大模型多轮对话“失忆”踩坑实录:一次线上事故让我排查了48小时,最终靠 Playwright + Pytest 把记忆锁死

凌晨2点,我被运维电话闹醒:"智能客服疯了,用户问'我刚才说的订单号是多少',它居然回答'您还没告诉过我订单号'。"我瞬间清醒------这是我们花了两个月打磨的记忆存储模块,明明单元测试全部通过,怎么一到线上就"失忆"?接下来48小时,我经历了从怀疑人生到彻底根治的全过程。如果你也在用大模型做多轮对话,这篇复盘应该能帮你少走点弯路。

问题拆解:为什么单测全过,线上却翻车?

我们的架构并不复杂:前端聊天界面 → API网关 → 对话服务 → 大模型,对话服务里有一个 MemoryStore 负责把每轮对话存进 Redis,并在下一轮组装成上下文。单元测试用 mock 的 Redis 客户端把 MemoryStore 每一行都覆盖了,覆盖率95%以上,看起来稳如老狗。

但问题出在真实用户交互相对于单测有"时序放大效应"

  1. 用户可能快速连续发送消息,上一轮的存储操作还没写完,下一轮就已经读历史------你单测里不会出现这种竞态,因为 mock 全是同步的。
  2. Session 管理在前端、网关、服务三层都可能被缓存或错绑,单测只测了服务层,根本没摸到真实链路。
  3. 浏览器端 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

相关推荐
嘟嘟07171 小时前
前端异步编程完全指南:从json-server到DeepSeek大模型接口调用
前端
橘子星1 小时前
前端薅数据神器 Fetch:不用翻墙,在线拿捏后端与 AI 接口
前端·后端
步步为营DotNet1 小时前
探索.NET 11:Blazor 在跨平台客户端应用开发的进阶实践
前端·asp.net·.net
Hello馒头儿1 小时前
vue3+uniapp经典hook方式实现一个更多加载的列表组件
前端·javascript·vue.js
浩风祭月1 小时前
前端错误监控方案对比:Sentry SaaS vs 自部署 vs 纯开源组合
前端·openai·ai编程
ze_juejin1 小时前
promise和try catch的比较
前端
用户573240037231 小时前
AgentForge-WX v0.3.0:12项更新 + 框架重新定位,把微信小程序AI对话的坑全填了
前端
米丘1 小时前
HTTP 传输层 TCP 三次握手 / 四次挥手
前端·网络协议·http
小lan猫1 小时前
多域 RAG 知识库:从 Vue 前端到 NestJS + PGVector 的全栈实践
前端·人工智能·typescript