LLM长期记忆测试踩坑实录:这个 Bug 让我排查了 3 天

55 阅读8分钟

凌晨两点被报警电话叫醒——线上用户投诉,说AI“明明昨天还叫我王总,今天就不认识我了”。我迷迷糊糊打开监控,发现向量数据库的检索延迟飙升,记忆召回直接丢了最近两轮对话。更让我冒冷汗的是:我们现有的手工测试用例,根本覆盖不到这种跨会话场景。长期记忆在偷偷“失忆”,而我们连验证手段都没有。

这篇文章记录我是怎么用 Playwright 把 LLM 记忆验证从“拍脑袋”做成自动化,以及中间踩过的坑。如果你也在做 RAG 或 Agent 的记忆模块,这篇可能省你几天时间。

问题拆解:为什么手工测试是“假装在测”

我们产品的长期记忆是典型 RAG 架构:对话结束后,摘要或关键事实存入向量库;下次用户提问时,检索最相关的记忆片段拼到 System Prompt 里。听上去简单,但记忆准确性由三个环节共同决定

  1. 记忆写入:摘要模型是否提取了正确事实?embedding 是否对齐?
  2. 检索召回:向量相似度是否把旧记忆排在前面?有没有阈值截断漏掉?
  3. 融合推理: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 path27 个带重试/异常
线上记忆类 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