验证 AI 聊天记忆持久性踩坑实录:30% 的对话在刷新后凭空消失

3 阅读1分钟

凌晨一点,产品经理在群里甩了张截图:「用户聊了 20 分钟,刷新页面之后历史记录全没了,你们记忆功能到底做了没有?」我心头一紧——这已经是本周第三次反馈记忆丢失。更扎心的是,我们明明写了测试,但那些手工跑的多轮对话用例,从来不敢点浏览器的刷新按钮。我决定用 Playwright + LangChain 写一套能真实模拟用户行为的自动化测试,专门盯着记忆持久这个点打。最终不仅复现了 bug,还顺藤摸瓜揪出 3 个隐藏级问题,这篇就来复盘整个过程。

问题拆解:记忆持久性为什么那么难测

场景很典型:用户打开聊天页面,连续对话好几轮,中间可能刷新页面、关闭标签页重新打开、甚至在移动端切后台。AI 要能记住前面聊过的上下文,不能丢历史,也不能串会话。我们的聊天服务后端用 LangChain 的 ConversationBufferMemory 做记忆管理,前端是 SPA,通过 session_id 跟后端绑定。

常规测试只覆盖「同一页面内连续对话」,因为人工测试很难模拟复杂的刷新时序,更别说验证 localStorage、sessionStorage、Cookie 与后端记忆之间的一致性。自动化测试也不是没想过,但团队之前试过 Selenium,处理页面刷新后元素等待经常超时,且多标签场景写起来一坨回调,维护成本极高。

根因在于:记忆持久性测试本质上是一个有状态的、跨会话的、需要精确时序控制的 E2E 场景,它必须同时操作浏览器 UI 和后端状态,缺一不可。这就是为什么纯 API 测试(例如只调 /chat 接口)根本发现不了问题——当用户刷新页面,前端能否正确从后端拉回历史?session_id 是否会被清掉?后端记忆是否因序列化错误回退?这些都需要浏览器真实走一遍。

方案设计:Playwright + LangChain 的记忆测试沙盒

我需要的是一套能快速搭建、可插拔记忆后端、并且能模拟真实用户行为的测试方案。选型思路如下:

  • 为什么用 Playwright 而不是 Selenium 或 Cypress:Playwright 原生支持多页面(page)、多上下文(context),自动等待元素可见,而且可以直接注入脚本操作 Cookie/localStorage,这对模拟「刷新后重新加载历史」的场景简直是刚需。Selenium 的等待策略太原始,Cypress 多标签支持受限,果断 pass。
  • 为什么用 LangChain:不是为了蹭热度。LangChain 的记忆抽象做得很好,可以一行代码切换 ConversationBufferMemory 的内存实现或 Redis 实现,方便测试不同持久化策略下的行为差异。同时它自带的 message history 接口让我能直接在测试里断言记忆内容,不用再去解析前端 DOM 找历史记录。
  • 架构:用 FastAPI 起一个简单的聊天接口,内部挂 LangChain 的 ConversationChain,接收 session_id 和用户消息,返回 AI 回复。Playwright 测试脚本模拟用户操作,通过 page.evaluate() 读写前端 localStorage 中的 session_id,甚至模拟存储被篡改的边界情况。

核心实现:从零搭建可落地的测试框

1. 聊天服务:把记忆暴露成可断言的状态

这段代码解决的是「怎么让后端记忆既能在真实场景中使用,又能在测试中被精确断言」。我用 FastAPI 包装 ConversationChain,关键点在用一个字典存储各 session 的记忆实例,这样就能通过测试用的接口直接取出记忆内容,而不依赖前端 DOM。

# chat_server.py
from fastapi import FastAPI
from pydantic import BaseModel
from langchain.memory import ConversationBufferMemory
from langchain.chains import ConversationChain
from langchain.chat_models import ChatOpenAI
import uuid

app = FastAPI()

# 存储不同会话的 chain 实例,真实的生成环境会用 Redis,这里演示用内存字典
chains = {}

class Message(BaseModel):
    session_id: str
    content: str

def get_or_create_chain(session_id: str):
    if session_id not in chains:
        memory = ConversationBufferMemory(memory_key="history", return_messages=True)
        llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0)
        chains[session_id] = ConversationChain(llm=llm, memory=memory, verbose=False)
    return chains[session_id]

@app.post("/chat")
def chat(msg: Message):
    chain = get_or_create_chain(msg.session_id)
    response = chain.run(msg.content)
    return {"reply": response}

# 测试辅助:直接暴露记忆内容,避免依赖前端解析
@app.get("/memory/{session_id}")
def get_memory(session_id: str):
    chain = chains.get(session_id)
    if not chain:
        return {"messages": []}
    # ConversationBufferMemory 的 buffer 就是消息列表
    messages = chain.memory.buffer
    return {"messages": [{"role": m.type, "content": m.content} for m in messages]}

if __name__ == "__main__":
    import uvicorn
    uvicorn.run(app, host="0.0.0.0", port=8000)

启动这个服务后,任何 Playwright 脚本驱动的 UI 操作,最终都可以通过 /memory/{session_id} 接口直接断言后端记忆,测试就变得干净利落。

2. Playwright 测试脚本:模拟刷新与重开的完整流程

下面的脚本解决的是「用 Playwright 模拟真实用户从聊天到刷新再到重开的全过程,并且验证记忆持久性不丢」。注意我用了一个 browser context 保证隔离,每次测试都从同一个 context 打开 page,这样能精准控制 session 生命周期。

# test_memory_persistence.py
from playwright.sync_api import sync_playwright
import requests
import uuid

BASE_URL = "http://localhost:3000"   # 前端 SPA 地址
API_URL = "http://localhost:8000"

def test_memory_survives_refresh():
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=True)
        context = browser.new_context()  # 独立 context,不与其他测试共享 cookie

        page = context.new_page()
        page.goto(BASE_URL)

        # 生成唯一 session_id,前端逻辑会在首次访问时存入 localStorage
        session_id = str(uuid.uuid4())
        page.evaluate(f"localStorage.setItem('chat_session_id', '{session_id}')")
        page.reload()  # 确保 session_id 已生效

        # 第一轮对话
        page.fill("input[data-testid='chat-input']", "我叫张三,喜欢打篮球")
        page.click("button[data-testid='send-btn']")
        page.wait_for_selector("text=张三")  # 等待 AI 回复中包含名字

        # 第二轮对话
        page.fill("input[data-testid='chat-input']", "我刚才说我喜欢什么?")
        page.click("button[data-testid='send-btn']")
        page.wait_for_selector("text=篮球")

        # 🔥 关键动作:刷新页面,模拟记忆持久性考验
        page.reload(wait_until="domcontentloaded")
        # 刷新后需要确保前端重新拉取历史,通常会有 loading 状态
        page.wait_for_selector("text=篮球", timeout=5000)  # 历史记录应重新渲染出来

        # 直接调后端接口断言记忆未丢失
        resp = requests.get(f"{API_URL}/memory/{session_id}")
        messages = resp.json()["messages"]
        assert any("张三" in m["content"] for m in messages)
        assert any("篮球" in m["content"] for m in messages)

        # 🔥 进阶:关闭页面再重新打开(模拟关闭标签页)
        page.close()
        new_page = context.new_page()
        new_page.goto(BASE_URL)
        # 恢复 session_id,模拟持久化登录或会话恢复
        new_page.evaluate(f"localStorage.setItem('chat_session_id', '{session_id}')")
        new_page.reload()
        new_page.wait_for_selector("text=篮球", timeout=5000)  # 重新打开后历史仍在

        browser.close()
        print("✅ 记忆持久性测试通过")

if __name__ == "__main__":
    test_memory_survives_refresh()

上面这段测试一旦跑起来,就能彻底粉碎「手工测试不敢刷新」的魔咒。但真正开始实施时,我才发现事情远没有这么简单。

踩坑记录:官方文档不会告诉你的三个大坑

坑 1:page.reload() 后 wait_for_selector 永远超时

现象:刷新页面后,明明 UI 上已有历史消息,但 wait_for_selector("text=篮球") 一直等到超时报错。

原因:SPA 渲染历史消息时,文本节点是通过虚拟 DOM 批量更新的,Playwright 的文本选择器 text=篮球 在某些框架(如 React)中可能因为 textContent 被拆分而导致匹配失败。更坑的是,Vue 的 v-text 和 React 的文本插值在 Playwright 的自动捕捉里行为不一致。

解决:改为使用更稳定的 locator 策略,比如 page.locator('[data-testid="message"]', hasText="篮球"),或者干脆用 page.wait_for_function 检测 DOM 中是否含有目标文本。

# 替换 wait_for_selector 的稳健写法
page.wait_for_function(
    "() => document.body.innerText.includes('篮球')",
    timeout=5000
)

坑 2:LangChain Memory 在服务重启后全部丢失,导致测试误报

现象:测试脚本连续跑,第一条过,第二条失败,断言发现记忆为空。

原因:我们最初的 FastAPI 服务把 chains 字典放在内存里,Playwright 模拟刷新时虽然没重启服务,但有时候为了模拟「服务器重启」场景,我会手动重启,一重启内存就没了。更隐蔽的是,在 CI 里并行跑测试时,多个测试 worker 起的是独立服务实例,session_id 对应的记忆当然不存在。

解决:引入 Redis 作为记忆持久化后端。LangChain 早就提供了 RedisChatMessageHistory,配合 ConversationBufferMemory 使用非常简单:

from langchain.memory.chat_message_histories import RedisChatMessageHistory
from langchain.memory import ConversationBufferMemory

def get_redis_memory(session_id: str):
    message_history = RedisChatMessageHistory(
        session_id=session_id, url="redis://localhost:6379/0"
    )
    return ConversationBufferMemory(
        memory_key="history", chat_memory=message_history, return_messages=True
    )

这样哪怕服务重启,只要 Redis 没丢,记忆就在。同时,测试中的 /memory 接口也能随时验证 Redis 里的内容。

坑 3:localStorage 里的 session_id 被前端框架的 router 覆盖

现象:刷新后页面白屏,然后所有历史消失,后端记忆明明还在。排查发现 localStorage 中的 chat_session_id 被清成了 null

原因:SPA 框架(Next.js / Nuxt)在路由保护或初始化时,会检查某些 localStorage 字段的合法性,如果字段不符合预期格式就直接覆盖。我们前端不知道哪一版本加了个逻辑:如果 chat_session_id 不是合法的 UUID v4 就重置它,而测试里我为了方便生成的是 uuid.uuid4()(带横杠),前端验了个去掉横杠的正则,结果就被清掉了。

解决:跟前端对齐 session_id 的生成规则和验证逻辑,并且在测试里严格按照真实环境生成 ID。同时 Playwright 脚本里增加一个断言:刷新后立即检查 localStorage 里的 session_id 是否仍然存在,防止这种静默丢失。

# 在 page.reload() 之后立即断言
assert page.evaluate("localStorage.getItem('chat_session_id')") == session_id

效果验证

之前手动测试时,记忆持久性相关的 bug 平均两周才暴露一次,而且复现不稳定。引入这套 Playwright + LangChain 自动化方案后:

指标优化前优化后
记忆持久性用例覆盖率~30% (无刷新场景)100% (含刷新/重开/重启)
测试执行耗时人工 20 min / 次自动化 35 s / 次
上线前漏测导致的记忆丢失事故本季度 3 起0 起

可直接复制使用的测试命令

把上面两个脚本保存好,一行命令启动自动化记忆测试:

# 默认开启无头模式,适合 CI
pytest test_memory_persistence.py --browser chromium --headed=false

#Playwright #LangChain #自动化测试 #AI工程化 #踩坑复盘

关于作者
一个喜欢把“记忆丢失”这种玄学 bug 变成自动化断言的实战派后端/架构工程师。
GitHub: github.com/baofugege
Sponsor: github.com/sponsors/ba… — 如果这篇文章帮你省下凌晨排查的时间,可以请我喝杯咖啡
提供服务:Python 后端性能优化 / 自动化测试框架定制 / AI 工程落地咨询,联系 Telegram @baofugege