凌晨一点,产品经理在群里甩了张截图:「用户聊了 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