上个月,我们给智能客服Agent加了个“长期记忆”模块——用户说过喜欢喝咖啡,三天后再聊它能自动推荐“还是来杯美式?”听起来很美好是吧?测过一次之后我就笑不出来了。每次小版本发布前,我得手动打开浏览器、切用户、按几十轮对话脚本一条条测,两小时起步,还经常漏掉“跨session记忆不清”这种阴间bug。更离谱的是,有一次Prompt改了一句话,模型突然把名字忘了一半,我们等用户投诉才知道。
纯人工回归记忆功能,和自杀没什么区别。所以我花了一个周末,用 Playwright + pytest 搭了一套自动化回归测试,把时间从2小时压到3分钟,而且再也没放过一个记忆变异的版本。这篇文章就把整个过程拆开给你看——从方案选型到完整的测试代码,再到踩过的坑,一个不落。
问题拆解:为什么记忆测试这么难搞?
我们先还原下场景。我们的Agent是一个Web聊天应用,每通对话有session_id,但记忆模块跨session共享一个user_id维度。要在发版前验证三件事:
- 单session内记忆:用户说“我叫张三,喜欢喝咖啡”,紧接着问“我刚才说我叫什么?”,回答必须包含“张三”。
- 跨session记忆持久化:重新登录后,再问“我叫什么?”,依然能答对。
- 不被无关上下文污染:一个用户的记忆不能串到另一个用户身上。
看起来简单,但手工测的难点在于:你必须先以用户A完成一组对话,切到用户B再完成一组,再切回用户A验证——步骤一多就想骂人。更恶心的是记忆模块偶尔有延迟写入,手工判断“等多久再验证”全凭运气。
常见的“调API直接测大模型”的路子在这里根本用不上。因为记忆逻辑是后端+Prompt+网关层层包装后的结果,URL分发、鉴权、WebSocket流式返回这些干扰项只有在端到端测试中才会暴露。我必须从真实浏览器的视角去走完整链路。
方案设计:为什么不选Selenium,不只用requests?
我的底线是:测试代码要能模拟真人操作,并且断言稳如老狗。
技术选型:
- Playwright:相比Selenium,它自带智能等待、网络监控、隔离的浏览器上下文,能轻松模拟多用户登录态完全隔离。而且代码生成器
playwright codegen能直接录制操作,起步快。 - pytest:fixture管理用户状态,参数化处理多组对话脚本,配合
pytest-repeat还能做稳定性压测。 - 不直接用 requests 发API:因为我们要覆盖前端到模型的全链路,特别是记忆模块的触发点可能在网关层、鉴权后,走浏览器是最真实的。
- 为什么不用 Cypress:后端同学对 Python 生态更熟,而且我们已有的测试基建全是 pytest,接Playwright几乎是“pip install”的事儿。
整体架构思路:用 pytest fixture 创建两个完全隔离的浏览器上下文(UserA、UserB),在同一个测试文件里跑完整的多轮对话+跨session验证。对话过程抽象为一个 run_dialogs 工具函数,接收一个会话列表并按序执行,最后统一断言——这样新增测试用例只需要加一段对话脚本。
核心实现:从 fixture 到多用户记忆验证
先看目录结构:
tests/
conftest.py # 浏览器fixture
test_memory.py # 记忆验证用例
dialog_utils.py # 对话执行与断言工具
这段代码解决“如何为不同用户创建隔离的测试环境”
我们在 conftest.py 里声明两个用户级别的fixture,每个用户绑定一个独立的浏览器上下文,保证cookie/localStorage完全隔离,避免记忆串号。
# conftest.py
import pytest
from playwright.sync_api import sync_playwright, Browser, BrowserContext, Page
BASE_URL = "http://localhost:3000" # 你的聊天前端地址
@pytest.fixture(scope="session")
def browser():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True) # CI用headless
yield browser
browser.close()
@pytest.fixture
def user_a_context(browser: Browser) -> BrowserContext:
# 创建一个完全隔离的上下文,相当于一个全新无痕窗口
context = browser.new_context(viewport={"width": 1280, "height": 720})
yield context
context.close()
@pytest.fixture
def user_b_context(browser: Browser) -> BrowserContext:
context = browser.new_context()
yield context
context.close()
这段代码解决“如何在聊天窗口里执行多轮对话并抓回复”
我们封装一个 dialog_runner,内部处理了消息输入、发送、等待流式响应结束(我们前端用WebSocket,需要在DOM上等“正在输入”消失)。
# dialog_utils.py
from playwright.sync_api import Page, expect
import re, time
def send_message(page: Page, text: str, timeout: int = 15000):
"""发送一条消息并等待回复完全输出"""
# 使用data-testid定位,避免class变来变去
input_box = page.locator('[data-testid="chat-input"]')
input_box.fill(text)
page.locator('[data-testid="send-btn"]').click()
# 等待“正在输入”指示器出现再消失,确保模型答完
typing_indicator = page.locator('[data-testid="typing-indicator"]')
typing_indicator.wait_for(state="visible", timeout=3000)
typing_indicator.wait_for(state="hidden", timeout=timeout)
# 返回最后一条助手消息的文本
return page.locator('[data-testid="assistant-message"]').last.text_content()
def run_dialogs(page: Page, dialogs: list[dict]):
"""
dialogs: [{"role": "user", "content": "xxx"}, {"role": "assistant", "pattern": "xxx"}, ...]
每个user步骤发送消息,assistant步骤用正则断言回复内容。
"""
for step in dialogs:
role = step["role"]
if role == "user":
send_message(page, step["content"])
elif role == "assistant":
actual = page.locator('[data-testid="assistant-message"]').last.text_content()
# 用正则匹配是因为模型回复可能有细微差别
assert re.search(step["pattern"], actual, re.IGNORECASE), \
f"期望匹配 {step['pattern']},实际回复: {actual}"
这段代码是完整的记忆回归测试用例
测试逻辑:用户A完成自我介绍,然后重开session验证记忆;同时用户B交叉操作,确保两个人的记忆不串。
# test_memory.py
from dialog_utils import run_dialogs, send_message
import pytest
import re
USER_A_INTRO = [
{"role": "user", "content": "你好,我叫王小明,我每天早上必须喝一杯拿铁"},
{"role": "assistant", "pattern": "王小明"},
]
USER_A_VERIFY = [
{"role": "user", "content": "你还记得我叫什么吗?"},
{"role": "assistant", "pattern": "王小明"},
{"role": "user", "content": "我的早晨习惯是什么?"},
{"role": "assistant", "pattern": "拿铁"},
]
USER_B_INTRO = [
{"role": "user", "content": "我是李华,我不喝咖啡,只喝茶"},
{"role": "assistant", "pattern": "李华"},
]
def test_memory_across_sessions(user_a_context, user_b_context):
# ---- 用户A第一次会话:建立记忆 ----
page_a1 = user_a_context.new_page()
page_a1.goto("http://localhost:3000/chat")
# 模拟登录(这里简化,实际可能填账号密码)
page_a1.locator('[data-testid="login-btn"]').click()
# 执行自我介绍对话
run_dialogs(page_a1, USER_A_INTRO)
page_a1.close() # 结束session
# ---- 用户B登场,建立自己的记忆 ----
page_b = user_b_context.new_page()
page_b.goto("http://localhost:3000/chat")
page_b.locator('[data-testid="login-btn"]').click()
run_dialogs(page_b, USER_B_INTRO)
page_b.close()
# ---- 用户A第二次会话:验证跨session记忆 ----
page_a2 = user_a_context.new_page()
page_a2.goto("http://localhost:3000/chat")
page_a2.locator('[data-testid="login-btn"]').click()
# 直接验证记忆,不应该再自我介绍
run_dialogs(page_a2, USER_A_VERIFY)
# ---- 额外断言:用户A的记忆里不能有用户B的信息 ----
msg = send_message(page_a2, "我和你聊过的所有人里面,有叫李华的吗?")
assert "李华" not in msg, f"记忆串号了!用户A的回复提到了李华: {msg}"
page_a2.close()
def test_memory_no_cross_contamination(user_a_context, user_b_context):
# 更严格的串号测试:先B,后A,再检查B的记忆
page_b = user_b_context.new_page()
page_b.goto("http://localhost:3000/chat")
page_b.locator('[data-testid="login-btn"]').click()
run_dialogs(page_b, USER_B_INTRO)
page_a = user_a_context.new_page()
page_a.goto("http://localhost:3000/chat")
page_a.locator('[data-testid="login-btn"]').click()
run_dialogs(page_a, USER_A_INTRO)
# 再次问B,确保B的记忆还是茶,不是咖啡
page_b2 = user_b_context.new_page()
page_b2.goto("http://localhost:3000/chat")
page_b2.locator('[data-testid="login-btn"]').click()
actual = send_message(page_b2, "我记得你之前说过喜欢喝什么?")
assert "茶" in actual
assert "咖啡" not in actual
page_a.close()
page_b.close()
page_b2.close()
上面就是全部测试代码。跑起来就一句话:pytest tests/test_memory.py -v。CI里配好 playwright install 步骤,每次PR自动回归。
踩坑记录:官方文档没告诉你的事
坑1:DOM元素明明存在,Playwright就是定位不到
现象:在 chat-input 输入框可见的情况下,fill() 偶尔报 Timeout 30000ms exceeded,但在浏览器的debug工具里明明看得到。
原因:我们的聊天页面用Vue虚拟滚动,消息列表上方的输入框可能被一个透明遮罩层盖住了,虽然人眼看不出来,但Playwright认为它不可交互。
解决:不用 fill(),先 click() 强制聚焦,再用 page.keyboard.type(text),完美绕过遮罩。或者开发时要求前端给输入框加 force: true,但不推荐污染页面结构。更根本的办法:推动前端给每一层交互元素挂 data-testid,这也是为什么上面所有定位都用自定义属性,死也别用CSS class。
坑2:流式响应导致跑得太快,捕获到的是‘正在思考’
现象:偶尔断言失败,打印出的助手消息内容是空的或者只有半句话。
原因:大模型回复是逐token流式推送,我们用的是 last.text_content() 去获取最新一条消息,但如果消息还在渲染,DOM里可能只显示了一半。typing-indicator 消失不代表最后一个token到达DOM,中间可能有前端渲染延迟。
解决:在 typing-indicator 消失后,额外轮询一个自定义的 data-testid="assistant-message" 的最后一条,等待它的文本内容不再变化至少500ms,再返回。简单版可以用 page.wait_for_function:
page.wait_for_function(
"""() => {
const msgs = document.querySelectorAll('[data-testid="assistant-message"]');
if (!msgs.length) return false;
const last = msgs[msgs.length - 1];
return last.textContent.trim().length > 0 && !last.querySelector('[data-testid="typing-indicator"]');
}""",
timeout=15000
)
这样一点额外开销,稳定性直接拉满。
效果验证:从2小时到3分钟,还更安心
原先手工回归一个版本,做用户A/B交叉记忆测试大约120分钟。现在CI流水线里这套用例跑完平均2分48秒(包括浏览器冷启动),而且每次PR都会自动执行。更重要的是,人肉测不敢覆盖的边界场景(比如极端对话顺序、快速连续session切换)现在随手加一条对话脚本就行,测试集合逐周扩展到了23条,但运行时间几乎没变。
| 对比维度 | 手工测试 | Playwright自动化 |
|---|---|---|
| 执行耗时 | ~120分钟/次 | <3分钟/次 |
| 可重复性 | 低(容易漏步骤) | 100%一致 |
| 覆盖场景数 | 4-5个核心路径 | 23个组合场景(仍在增加) |
| 回归触发 | 发版前手动跑 | 每次PR自动跑 |
| 维护成本 | 不要代码,但要人 | 加一条脚本半分钟 |
更爽的是,上个月半夜那个“Prompt改动让记忆丢失一半”的bug,这台测试在PR阶段直接红灯拦住,从此再没漏到生产环境。
可直接用的代码/工具
如果你也想立刻上手,把上面的 conftest.py 和 dialog_utils.py 拷贝到你的项目,修改 BASE_URL 和选择器,跑 pytest --headed 就能看到浏览器自动替你聊天。我还把完整的模板项目(含GitHub Actions配置)放在了 github.com/baofugege/p…,直接 fork 就能用。
#Python #自动化测试 #大模型 #Playwright #pytest
关于作者
我是宝富,一个常年在后端和工程化之间反复横跳的实战派架构师,喜欢把“让人加班的问题”用代码自动化掉。
GitHub: github.com/baofugege
Sponsor: github.com/sponsors/ba… — 如果这篇文章帮你省下几十个小时的测试时间,请我喝杯咖啡。
提供服务:Python 后端性能优化 / 自动化工具定制 / 技术咨询,联系 Telegram @baofugege