凌晨两点,企业微信闪出一条消息:"哥,ChatBot又把用户名字忘了,第五轮说自己叫'小明',第八轮就变成了'王先生'。"我一个激灵从床上弹起来,翻看对话日志------果然,记忆在第七轮某个工具调用后断掉了。这种Bug,手工回归一次要对着浏览器敲30分钟,边敲边在心里骂"上次不是修好了吗"。更恶心的是,人肉验证总会漏掉那么一两轮,测完心里还是虚。直到我用 Playwright + Pytest 搭了一套记忆一致性自动化测试,2分钟跑完核心遗忘场景,还顺手揪出3个藏在流式响应里的隐藏问题。今天就跟你聊聊这个"下班拯救者"是怎么炼成的。
问题拆解:为什么LLM记忆测试让测试同学生无可恋
大模型应用里的记忆,不是简单的存个变量,而是把历史对话塞进Prompt,让LLM基于上下文回答。常见的实现比如LangChain的ConversationBufferMemory、ConversationSummaryMemory,或者自研的滑动窗口。一致性测试的本质,就是在多轮交互后,验证LLM是否还记得之前明确过的信息。
典型痛点:
- 回归耗时到肉疼:一个完整的长对话场景(比如用户注册后聊20轮,中间穿插澄清、工具调用,再回头问名字),纯手工走一遍至少5-10分钟,10个场景就是一个小时。
- 断言靠人眼,漏了白漏:第18轮回复里是否还带着"你的生日是5月20日"?盯久了谁都会走神,更别说流式输出的文字是一点点蹦出来的。
- 常规接口测试不够:直接调LLM API的文字补全,没法覆盖前端渲染、网络抖动、流式中断等真实环境下才会暴露的毛病。
这就逼着我们不得不上端到端自动化,把浏览器操作、真实对话流、断言一把梭。
方案设计:为什么不选Selenium/ Cypress
我们技术栈是Python后端+React前端,自动化测试最好能让后端同学也能随手写、随手维护。对比下来:
- Selenium :老牌,但API不够现代,等待机制需要手动写
WebDriverWait,碰到流式渲染经常抽风,而且启动慢,不适合频繁回归。 - Cypress:很棒,但JavaScript生态,对我们全栈Python团队来说,要多维护一套Node环境,学习成本陡增,而且对多页面、跨域的处理没Playwright灵活。
- Playwright:原生支持async/await,auto-wait智能等待,自带trace viewer,还能直接拦截网络请求Mock API。配合Pytest的fixture和参数化,不同记忆场景可以写成独立用例,一键并发跑。
架构思路很直白:一个conftest.py负责初始化浏览器、封装"发送消息并等待回复完成"的工具函数;测试文件里每个函数就是一个记忆场景,模拟N轮带干扰的对话后,用expect().to_contain_text()断言LLM回复里保留着关键信息。另外,为了对抗前端频繁改版,我们强制要求前端同学给关键元素加上data-testid属性,让选择器不再脆弱。
核心实现:从打开浏览器到断言记忆
下面的代码分两层:公共fixture conftest.py,和测试用例 test_memory.py。
这段代码解决:共享的浏览器和页面工具,让每个测试用例不用重复写启动逻辑。
python
# conftest.py
import pytest
from playwright.sync_api import sync_playwright, Page, expect
@pytest.fixture(scope="session")
def browser():
"""
session级别,整个测试会话只启动一次浏览器。
"""
with sync_playwright() as p:
# headless=False 能看到界面,便于调试;CI上换成 True
browser = p.chromium.launch(headless=True)
yield browser
browser.close()
@pytest.fixture
def page(browser):
"""每个测试用例独立的页面上下文,互不干扰"""
context = browser.new_context(locale="zh-CN")
page = context.new_page()
yield page
context.close()
@pytest.fixture
def chat(page):
"""
封装对话操作:发送一条消息,并等待本次回复完整出现在页面上。
返回本次AI回复的完整文本内容。
"""
def _send_and_get_reply(message: str, timeout: int = 15000) -> str:
# 输入框
input_box = page.locator('[data-testid="chat-input"]')
input_box.fill(message)
# 发送按钮
page.locator('[data-testid="send-btn"]').click()
# 等待最后一条AI消息出现且不再变化------这里用独特的"消息气泡 + 完成图标"组合来保证流式输出结束
# 前端约定:流式输出完成时,消息气泡内会多出一个 [data-testid="typing-indicator"],消失即完成
last_msg = page.locator(
'[data-testid="message-bot"] >> nth=-1'
)
# auto-wait:先等到元素可见
expect(last_msg).to_be_visible(timeout=timeout)
# 等待打字指示器消失,表示流式结束
typing_indicator = last_msg.locator('[data-testid="typing-indicator"]')
expect(typing_indicator).to_be_hidden(timeout=timeout)
# 返回完整的文本内容
return last_msg.text_content()
return _send_and_get_reply
这段代码解决:具体的用户名字记忆场景,模拟多轮干扰后,断言记忆没有丢失。
python
# test_memory.py
import pytest
import re
def test_user_name_persists_after_long_turn(page, chat):
"""
场景:用户告知姓名,经过10轮无关对话和一次多工具调用后,
再次询问自己的名字,系统应准确回忆。
"""
page.goto("https://your-app.com/chat")
# 第1轮:设定名字
reply = chat("你好,我的名字叫王小明")
assert "王小明" in reply, f"首次回复未包含名字: {reply}"
# 第2-6轮:穿插各种无关对话,模拟自然聊天和可能的记忆挤压
chat("今天天气怎么样?")
chat("帮我订一张去北京的机票")
reply = chat("可以推荐几个北京的景点吗?")
# 这里复用reply断言景点包含长城,确保流程正常
assert "长城" in reply or "故宫" in reply, "景点推荐未正常返回"
chat("你能讲个笑话吗?")
chat("谢谢,再帮我查下北京明天下雨吗")
# 第7轮:直接询问名字,验证记忆
reply = chat("对了,你还记得我叫什么名字吗?")
# 核心断言:回复中必须同时出现"王小明"和与名字相关的提示词,避免LLM绕开问题说"您没告诉我"
assert "王小明" in reply, f"姓名记忆丢失,回复内容: {reply}"
# 增加宽松正则,防止模型说"您的名字是王小明"等变体,但不降低准确度
assert re.search(r"(名字|叫|姓名)", reply), f"回复未涉及名字确认: {reply}"
# 第8-10轮:进一步干扰后再次验证
chat("我有点饿了,附近有餐厅吗?")
chat("算了,直接帮我订一个酒店")
reply = chat("我的名字,你还没忘吧?")
assert "王小明" in reply, f"长时间记忆丢失,最终回复: {reply}"
你可以像这样继续增加偏好记忆、生日记忆等场景,利用Pytest的@pytest.mark.parametrize轻松覆盖更多数据。
踩坑记录:官方文档不会告诉你的三个坑
坑1:流式输出导致"假装断言成功,实则内容不全"
- 现象 :断言明明通过了,日志却显示AI回复只有半截。原来Playwright的
to_be_visible()对已经部分渲染的元素会立即返回True,但文本可能还在持续更新。 - 原因 :我们的前端在流式输出时,消息气泡会先出现,文字逐字填入,最终才隐藏
typing-indicator。如果只等气泡可见,可能捕获到流式进行中的不完整文本。 - 解决 :双重等待------先等消息气泡
visible,再等typing-indicator隐藏(如上面fixture实现)。这是对接前端规范后的自救,如果没有这么一个标记,就需要用expect(page.locator).to_contain_text(expected_partial, timeout=...),但注意text仍可能断在半截,最好抓取textContent()后在整个用例层面做延迟重试。
坑2:LLM回复的非确定性让==断言变成炸弹
- 现象:昨晚通过的测试,今早挂了,因为模型把"您的名字是王小明"改成了"您告诉过我您叫王小明"。
- 原因:大模型的输出本质是概率采样,相同Prompt下文字可能有轻微变化。直接字符串相等断言是测试中的"玻璃腿"。
- 解决 :永远不要
==,改用in+ 关键词组合(例如"王小明"和"名字"同时出现)。更稳定的方案是用一个验证函数,通过几个必需关键信息(姓名、身份代词等)的共现来判定,甚至可以用一个小型的文本相似度模型,但对CI流水线太重,不推荐。
坑3:选择器写成div:nth-child(3) > span,维护起来想砸电脑
- 现象:前端同学重构了消息列表的DOM结构,所有测试全红一片。
- 原因:用Playwright录制生成的自动选择器脆弱如纸。
- 解决 :强迫前端在关键交互元素上加
data-testid,比如输入框、发送按钮、每条AI消息容器。这是我们用血的教训换来的,没有这些稳定锚点,E2E测试就是自我安慰。
效果验证:数据说明一切
| 指标 | 手工回归(10个记忆场景) | Playwright自动化 |
|---|---|---|
| 单次回归耗时 | 约30分钟 | 约2分钟(并行跑) |
| 覆盖完整度 | 依赖注意力,常漏断 | 每个场景精确断言,不漏 |
| 可重复性 | 下班后不想跑 | 随时跑,凌晨自动跑 |
| 隐藏bug发现 | 0(手工难发现流式Bug) | 3个(流式中断记忆丢失、切换模型版本记忆错乱、前端渲染记忆信息缺失) |
自动化不仅把我从30分钟的重复劳动里解放出来,还真正抓到了3个靠人眼根本揪不出来的Bug------比如流式输出被网络波动截断时,记忆信息只渲染了一半,而后端仍认为已发送完毕。
可直接拿去用的工具
最小化起步,把你的URL替换进上面的conftest.py和test_memory.py,然后一行命令开跑:
bash
pytest test_memory.py --headed --base-url https://your-app.com
如果前端还没有data-testid,马上拉上前端把这份规范加进开发流程------比任何自动化脚本都值钱。
#Python #LLM #测试自动化 #Playwright #Pytest
关于作者 一个在LLM应用落地和自动化测试上反复折腾的后端开发,坚信"能自动的绝不手动"。
GitHub: github.com/baofugege (这里有更多LLM测试小工具)
Sponsor: github.com/sponsors/ba... --- 如果这篇文章帮你的团队省下了测试时间,欢迎请我喝杯咖啡。
提供服务:Python后端性能优化 / LLM应用E2E测试定制 / 技术咨询,联系 Telegram @baofugege