凌晨两点被报警电话叫醒——线上用户投诉,说AI“明明昨天还叫我王总,今天就不认识我了”。我迷迷糊糊打开监控,发现向量数据库的检索延迟飙升,记忆召回直接丢了最近两轮对话。更让我冒冷汗的是:我们现有的手工测试用例,根本覆盖不到这种跨会话场景。长期记忆在偷偷“失忆”,而我们连验证手段都没有。
这篇文章记录我是怎么用 Playwright 把 LLM 记忆验证从“拍脑袋”做成自动化,以及中间踩过的坑。如果你也在做 RAG 或 Agent 的记忆模块,这篇可能省你几天时间。
问题拆解:为什么手工测试是“假装在测”
我们产品的长期记忆是典型 RAG 架构:对话结束后,摘要或关键事实存入向量库;下次用户提问时,检索最相关的记忆片段拼到 System Prompt 里。听上去简单,但记忆准确性由三个环节共同决定:
- 记忆写入:摘要模型是否提取了正确事实?embedding 是否对齐?
- 检索召回:向量相似度是否把旧记忆排在前面?有没有阈值截断漏掉?
- 融合推理:LLM 在看到 prompt 后,是真的“回忆”还是自由发挥?
常规方案是测 API:给一个 session,塞几句话,再查向量库,或者测模型输出。但真实用户跨设备、清缓存、换网络,前端和网关的行为完全没覆盖到。而且流式输出的解析逻辑、会话 ID 的绑定,这些在 API 测试里都是空白。我们必须做端到端验证——用真实浏览器走完整个流程。
那为什么不选 Cypress 或 Selenium? Playwright 对多个隔离的浏览器上下文 (Browser Context) 支持得最好,可以干净地模拟“两个独立的会话”,同时还能拦截网络请求观察记忆 API 的调用时机,这是 Selenium 很难做到的。所以选了 Playwright + pytest。
方案设计:用“双会话”模式验证记忆持久性
架构思路很直接:
- 用 Playwright 打开两个完全隔离的浏览器上下文(模拟两次独立访问,清除 cookies/storage)。
- 第一个上下文里进行“教学对话”,埋入一个唯一事实(比如用户名叫
张三-2027)。 - 等待后端异步处理(记忆写入 + 向量索引生效)。
- 第二个上下文里发起提问:“我们第一次见面时,我怎么称呼?”,断言回答中包含
张三-2027。
失败的情况就是记忆不准:要么丢了,要么回错了。这不是简单的关键词匹配——因为模型可能回答“你告诉我你叫张三-2027”,也可能“我记得你叫张三-2027”,需要一定的容错断言。
测试用 Page Object 封装聊天界面,核心待测点就三个:
- 发送消息后等待 AI 回复完整(非流式半截)。
- 跨会话隔离干净,别偷偷复用 Session ID。
- 记忆检索的时效性——写完立刻查可能没入库,必须有重试窗口。
下面直接看怎么实现的。
核心实现:能直接跑的 Playwright 测试代码
1. 封装聊天页面的 Page Object
这段代码解决两个麻烦:① 流式输出时怎么判断“AI 说完了”;② 提供高层 send_and_wait_reply 方法,让测试用例干净。
# chat_page.py
from playwright.sync_api import Page, Locator
import time
class ChatPage:
def __init__(self, page: Page):
self.page = page
self.input_box = page.locator('textarea[placeholder="输入消息"]')
self.send_btn = page.locator('button:has-text("发送")')
# 停止生成按钮仅流式输出时可见
self.stop_btn = page.locator('button:has-text("停止生成")')
# 最后一条 AI 回复
self.last_ai_msg = page.locator('[data-role="assistant-message"]').last
def goto(self, url="/chat"):
self.page.goto(url)
self.page.wait_for_selector('textarea', timeout=10000)
def send_message(self, text: str):
self.input_box.fill(text)
self.send_btn.click()
def wait_for_reply_complete(self, timeout=30000):
"""等待流式输出结束:停止按钮消失且最后一条消息内容稳定"""
start = time.time()
last_text = ""
stable_count = 0
while time.time() - start < timeout:
# 如果停止按钮还在,说明正在生成
if self.stop_btn.is_visible():
stable_count = 0
time.sleep(0.3)
continue
current = self.last_ai_msg.text_content() or ""
if current == last_text and len(current) > 0:
stable_count += 1
if stable_count >= 3: # 连续3次文本不变,认为结束
return current
else:
stable_count = 0
last_text = current
time.sleep(0.3)
raise TimeoutError("AI 回复未在超时时间内完成")
def send_and_wait_reply(self, text: str) -> str:
self.send_message(text)
return self.wait_for_reply_complete()
2. 编写双会话验证记忆的测试用例
这段代码直接对应我们的核心业务场景:跨会话记忆。pytest fixture 里我们用 context.new_page(),保证两个会话 Cookie/Storage 完全隔离,类似无痕窗口。
# test_memory.py
import pytest
from chat_page import ChatPage
from playwright.sync_api import BrowserContext
@pytest.fixture(scope="function")
def context1(browser: BrowserContext):
"""模拟用户第一次访问,建立记忆"""
context = browser.new_context(locale="zh-CN")
page = context.new_page()
chat = ChatPage(page)
chat.goto()
yield chat
context.close()
@pytest.fixture(scope="function")
def context2(browser: BrowserContext):
"""模拟用户第二次独立访问,验证记忆"""
context = browser.new_context(locale="zh-CN")
page = context.new_page()
chat = ChatPage(page)
chat.goto()
yield chat
context.close()
def test_long_term_memory_recall(context1, context2):
# 步骤1:在第一次会话中埋入唯一事实
unique_fact = "用户名叫张三-2027"
reply1 = context1.send_and_wait_reply(f"你好,请记住,{unique_fact},这是我未来找你的暗号")
# 确保模型确认记住(容错)
assert unique_fact in reply1 or "记住" in reply1
# 步骤2:等待后端记忆写入和索引生效(最关键——踩坑点)
import time
time.sleep(3) # 首次尝试等待,后续踩坑部分会优化为主动重试
# 步骤3:在新会话中提问,验证记忆
reply2 = context2.send_and_wait_reply("我们第一次见面时,我怎么称呼?")
# 断言回答中包含唯一事实
assert unique_fact in reply2, f"记忆召回失败,回复为: {reply2}"
3. 更稳健的“等待记忆就绪”辅助函数
上面 time.sleep(3) 太粗暴,而且生产环境记忆写入延迟不稳定。后来我们写了个小工具,主动轮询向量库状态(或者用内部接口),直到记忆可检索再继续。
import requests
def wait_until_memory_indexed(user_id: str, expected_fact: str, timeout=15):
"""调用内部搜索接口确认记忆已入库,避免苦等固定时间"""
start = time.time()
while time.time() - start < timeout:
resp = requests.get(
"http://localhost:8000/api/internal/search-memory",
params={"user_id": user_id, "query": expected_fact}
)
if resp.status_code == 200 and expected_fact in resp.text:
return
time.sleep(1)
raise TimeoutError("记忆未在规定时间内完成索引")
踩坑记录:官方文档不会告诉你的细节
坑 1:流式输出下“AI 说完了”的判断完全失灵
现象:测试脚本在 AI 回复刚出第一个字时就断言,last_ai_msg.text_content() 只拿到半句话,用例随机失败。
原因:前端用 SSE 逐字更新同一个 DOM 节点,Playwright 的 text_content() 是即时快照。如果刚巧在渲染间隙调用,就只能抓到局部内容。而 wait_for_selector 根本帮不上忙,因为元素一开始就存在。
解决:放弃所有“等待某个特定文本出现”的幻想,改成三态检测:
- 检查“停止生成”按钮是否消失(最可靠的“生成完毕”信号)。
- 然后轮询最后一条 AI 消息的文本内容,连续 3 次不变才视为稳定。
- 设置足够长的超时(30s),因为长篇生成可能很久。
上面的 wait_for_reply_complete 就是这套逻辑。这个坑折磨了我两天,因为本地模型快,流式几乎瞬间完成;一到测试环境部署了稍慢的模型,就大面积失败。
坑 2:记忆写入的异步延迟让你产生“能立刻读”的错觉
现象:context1 刚说完“我叫张三-2027”,context2 立刻问“我叫什么”,模型回答“你说你叫李四”或者直接说不知道。
原因:我们的记忆链路是:对话结束 → 异步队列 → 摘要生成 → 向量嵌入 → 索引生效。整个 pipeline 快的 1 秒,慢的时候 8 秒。而测试脚本在你本地开发机跑时,数据库几乎无延迟,所以从来不出错。一到 staging 环境,延迟就暴露出“硬等 3 秒不够”的问题。
解决:不是无脑加大 sleep,而是改为基于状态的等待。我们暴露了一个内部记忆搜索接口(仅测试环境),在测试里主动 poll 该接口,直到搜到目标事实才往下走。这就是上面 wait_until_memory_indexed 的由来。如果你没有现成接口,也可以监听 Playwright 的网络请求,当看到记忆写入的 API 返回 200 且再等待一个索引延迟系数,比盲目 sleep 准确得多。官方文档当然不会告诉你怎么测这种分布式异步场景,但这才是记忆测试成败的分水岭。
效果验证:从 40% 漏测到接近 0
用自动化之前,我们只靠手工测 5 个固定会话,常年漏掉“记忆覆盖更新”“多用户隔离”“长文本截断”等场景。接入 Playwright 的半个月里:
| 指标 | 手工测试 | Playwright 自动化 |
|---|---|---|
| 单次记忆测试耗时 | 12 分钟 | 40 秒 |
| 覆盖的场景数 | 5 个 happy path | 27 个带重试/异常 |
| 线上记忆类 Bug 遗漏 | 平均 3 个/月 | 0 个(同期) |
最关键的一次:自动化测试发现了“记忆写入时如果用户消息过长,摘要模型会截断后半段事实”,这个 Bug 手工永远测不出来,因为没人会手动输入 2000 字再切换浏览器去问。
直接拿去用的东西
一个等待 AI 流式回复结束的 Playwright helper,已经抽成独立函数,兼容大部分聊天 UI:
def wait_for_ai_reply(page, last_msg_locator, stop_btn_locator, timeout=30000):
# 用法:wait_for_ai_reply(page, page.locator('.msg.assistant').last, page.locator('.stop'))
# 返回完整回复文本
... # 逻辑同上文的 wait_for_reply_complete,可自行封装
把这段放进你的 conftest.py,以后所有 AI 对话测试都能复用。收藏率就靠它了。
关于作者
一个常年跟 RAG 记忆、向量库和自动化测试死磕的后端/架构开发者,用 Playwright 救过三次线上故障。
GitHub: github.com/baofugege — 会有 Playwright 测试模板和记忆验证用的工具库更新。
Sponsor: github.com/sponsors/ba… — 如果这篇文章让你少熬了一夜,请我喝杯咖啡。
提供服务:Python 后端性能优化 / RAG 架构咨询 / 自动化测试框架定制,联系 Telegram @baofugege
#LLM #Playwright #长期记忆 #自动化测试 #Python