凌晨2点,我被运维电话闹醒:"智能客服疯了,用户问'我刚才说的订单号是多少',它居然回答'您还没告诉过我订单号'。"我瞬间清醒------这是我们花了两个月打磨的记忆存储模块,明明单元测试全部通过,怎么一到线上就"失忆"?接下来48小时,我经历了从怀疑人生到彻底根治的全过程。如果你也在用大模型做多轮对话,这篇复盘应该能帮你少走点弯路。
问题拆解:为什么单测全过,线上却翻车?
我们的架构并不复杂:前端聊天界面 → API网关 → 对话服务 → 大模型,对话服务里有一个 MemoryStore 负责把每轮对话存进 Redis,并在下一轮组装成上下文。单元测试用 mock 的 Redis 客户端把 MemoryStore 每一行都覆盖了,覆盖率95%以上,看起来稳如老狗。
但问题出在真实用户交互相对于单测有"时序放大效应":
- 用户可能快速连续发送消息,上一轮的存储操作还没写完,下一轮就已经读历史------你单测里不会出现这种竞态,因为 mock 全是同步的。
- Session 管理在前端、网关、服务三层都可能被缓存或错绑,单测只测了服务层,根本没摸到真实链路。
- 浏览器端 WebSocket 重连后,某些前端框架会丢失上下文标识,导致后端以为是新会话,直接抹掉记忆。
这些鬼问题,任何一个单独拿出来都不难修,但组合在一起,让"多轮对话一致性"变成玄学。靠手工点一点浏览器测?根本测不过来,而且容易漏。
方案设计:用 E2E 测试把记忆"锁死"
我需要一种测试方式,能完全模拟用户从浏览器发送多轮消息的完整路径,并且断言确实记住了上下文。选型时考虑过三个方向:
- 后端集成测试:可以用 requests 直接调 API,但跳过了 WebSocket 握手、前端会话管理等关键环节,不够真实。
- Selenium:老牌工具,但异步等待和现代前端框架的适配总有不爽,对 iframe、shadow DOM 的处理也偏重。
- Playwright:天生支持自动等待、网络拦截、多浏览器,API 设计像2024年的东西,而且和 Pytest 集成极顺滑。
最后选了 Playwright + Pytest,原因很简单:它可以直接操控 Chromium,模拟连续输入、等待 LLM 流式输出、检查页面上的历史气泡,还能在测试中调用后端 API 捞记忆存储的数据做双重验证。整个测试链路变成:
Pytest 用例 → Playwright 控制浏览器 → 前端 Chat UI → API → MemoryStore → Redis
等于用一套脚本同时验证了前端展示 和后端存储是否正确,不给"失忆"留死角。
核心实现:一步步搭起记忆验证测试
1. 测试夹具:一把能自动清理记忆的浏览器
这段代码解决"每次测试必须从一个干净会话开始"的问题,避免前后用例之间记忆串扰。
python
# conftest.py
import pytest
from playwright.sync_api import sync_playwright, Page
import requests
BASE_URL = "http://localhost:3000" # 前端地址
API_BASE = "http://localhost:8000/api" # 后端地址
@pytest.fixture(scope="function")
def browser_page():
with sync_playwright() as p:
browser = p.chromium.launch(headless=True) # 生产环境请用 headless
context = browser.new_context()
page = context.new_page()
# 先通过 API 确保会话清理干净,官方文档没告诉你这一步能避免一半的 flaky
requests.post(f"{API_BASE}/session/reset", json={"user_id": "test_user"})
yield page
context.close()
browser.close()
为什么不在用例结束后再清理?因为用例执行中可能异常退出,yield 后的清理代码不一定执行,倒不如先在 setup 阶段暴力重置,保证每次都在同一起跑线。
2. 核心用例:验证两轮对话的记忆一致性
这个用例模拟用户先告诉机器人自己的名字,再问"我叫什么",断言回复里必须包含之前告诉它的名字。同时我们通过后端 API 直接拉取记忆存储,做双重保险。
python
# test_memory.py
import time, requests
from playwright.sync_api import Page
def test_multi_turn_memory_consistency(browser_page: Page):
page = browser_page
page.goto(f"{BASE_URL}/chat")
# 第一轮:告诉它名字
send_message(page, "我叫李白,记住这个。")
# 等待 LLM 流式输出完成,这里自动等元素出现避免假失败
page.wait_for_selector("div.message-bot:last-child", timeout=10000)
bot_reply_1 = get_last_bot_message(page)
# 第二轮:询问名字,必须在回复中找到"李白"
send_message(page, "我刚才说我叫什么名字?")
page.wait_for_selector("div.message-bot:last-child", timeout=10000)
bot_reply_2 = get_last_bot_message(page)
assert "李白" in bot_reply_2, f"预期回复包含名字,实际:{bot_reply_2}"
# 后端双重验证:确认记忆存储确实有相应历史
session_history = requests.get(
f"{API_BASE}/memory/test_user"
).json()
messages = [m["content"] for m in session_history["messages"]]
assert any("我叫李白" in m for m in messages), "记忆存储中没有第一轮用户消息"
assert any("李白" in m for m in messages), "记忆存储中没有出现过名字"
其中辅助函数如下:
python
def send_message(page: Page, text: str):
"""在聊天框输入并按回车发送,保证输入框清空后才进入下一步"""
input_box = page.locator("textarea.chat-input, input[type='text']") # 根据实际选择器修改
input_box.fill("")
input_box.type(text, delay=50) # 模拟人类打字速度,减少反爬或限流干扰
page.keyboard.press("Enter")
def get_last_bot_message(page: Page) -> str:
"""拿到页面上最后一条机器人消息的完整文本"""
# 等待至少有一条机器人消息,避免拿到空
page.wait_for_selector("div.message-bot")
messages = page.locator("div.message-bot")
return messages.last.inner_text()
3. 异常恢复用例:模拟断网重连后记忆是否还在
这是线上出过的事故:用户 Wi-Fi 切换导致 WebSocket 断开,重连后前端发起的请求带错了 session token,后端直接分配了新会话,记忆全丢。我们用 Playwright 模拟网络离线再上线,验证记忆不会因重连而丢失。
python
def test_memory_survives_reconnect(browser_page: Page):
page = browser_page
page.goto(f"{BASE_URL}/chat")
send_message(page, "记住:我的订单号是 ORDER12345")
page.wait_for_selector("div.message-bot:last-child")
# 模拟断网
page.context.set_offline(True)
time.sleep(2)
page.context.set_offline(False)
# 等待页面自动重连后稳定
page.wait_for_function("() => navigator.onLine === true")
page.wait_for_timeout(3000) # 给 Websocket 重建留时间
send_message(page, "我的订单号是什么?")
page.wait_for_selector("div.message-bot:last-child")
reply = get_last_bot_message(page)
assert "ORDER12345" in reply, f"重连后丢失订单号记忆:{reply}"
踩坑记录:官方文档不会告诉你的三个暗坑
坑1:wait_for_selector 超时但元素明明存在
现象:用例偶发失败,错误是 timeout 30000ms,但截图显示消息气泡确实已经渲染。原因:我们用了 div.message-bot:last-child 这个伪类选择器,而 Playwright 的自动等待会等待元素"附加到 DOM 并且可见"。在 LLM 流式输出过程中,last-child 对应的元素可能被瞬间插入又替换,导致选择器找不到稳定元素。解决:改用 .message-bot >> nth=-1 或者等待元素数量大于某个值再过半秒后取值。
坑2:后端记忆 API 验证总是慢半拍
用例断言记忆存储里有内容,但经常报错说列表空。根因是记忆存储采用异步写,消息到达后端后,会先返回 SSEClient 的流,再在后台任务里写 Redis。测试里 API 请求瞬间就到了,Redis 还没写完。一开始加 time.sleep(1),不优雅而且不稳定。最终用了 轮询等待 :封装一个 wait_for_memory_contains 函数,每 200ms 请求一次,直到包含关键字或超时。这招官方文档没有提,但实际场景必用。
坑3:多用例并行时 session 冲突
为了加速,我们在 CI 里用 pytest-xdist 并行跑测试,结果记忆验证用例频繁互相踩踏------因为它们共用同一个 test_user。即使每个 case 前重置会话,仍然存在时间窗口。最后方案是让每个用例在创建会话时带唯一标识,比如用 uuid4 作为 user_id,并在 fixture 里传递给 browser_page 动态修改前端登录参数,彻底做到用例隔离。
效果验证:以前靠人肉,现在靠"机器人"
| 指标 | 手工回归测试 | Playwright + Pytest |
|---|---|---|
| 单次回归耗时 | 30分钟以上 | 5分钟(10+用例并行) |
| 多轮对话漏测率 | 约15%(人总会走神) | 0(失败即报警) |
| 可重复性 | 低,依赖操作顺序 | 100%,CI 每次触发 |
| 上线信心 | "应该没问题吧" | "红灯不过就不发版" |
自从把记忆验证接入 CI,再也没在凌晨接到过"失忆"报警------至少没因为回归不充分而接到。
可直接用的代码
把下面这个 fixture 放进你的 conftest.py,改一下 URL,跑一个最简单的两轮对话用例,就能立刻拦截大部分记忆丢失的 bug:
python
@pytest.fixture
def chat_page(browser_page):
browser_page.goto("http://你的前端地址/chat")
return browser_page
第一版测试就跑这个:先发一条包含特定 token(比如"菠萝蜜")的消息,再发一条"我刚才说过的水果是什么?",断言回复包含"菠萝蜜"。就这一条用例,已经能抓住至少 80% 的记忆存储时序问题。
#大模型 #自动化测试 #Playwright #Pytest #多轮对话
关于作者
一个在大模型工程化方向摸爬滚打的后端/架构开发者,坚信"能自动化的绝不人肉"。
GitHub: github.com/baofugege --- 本文相关测试工具和模板脚本不定期更新。
Sponsor: github.com/sponsors/ba... --- 如果这篇文章帮你省下 48 小时排查时间,请我喝杯咖啡。
提供服务:Python 后端性能优化 / 工具定制 / 技术咨询,联系 Telegram @baofugege