凌晨两点被用户工单砸醒:"我晚上跟 AI 聊了 40 分钟的需求,中途刷了下页面,它把前面说的全忘了,又让我重新自我介绍。" 我第一反应是 Redis 里肯定存了,开发环境明明好好的。可这类"间歇性失忆"的问题,手动复现全靠运气------直到我写了一套 Playwright 端到端测试,跑了 300 次,那个藏了三天的 bug 才浮出水面。
问题拆解
给大模型做记忆持久化,说白了就是把对话历史存起来,下次用户回来时带上历史消息,避免上下文断裂。我们用的是经典的 session_id 绑定一份对话列表,后端收到消息后推到 Redis 的 list 里,并设置 7 天过期。看上去毫无破绽。
但用户反馈的问题有两个反常点:
- 不是每次都丢:大多数时候刷新没事,但偶尔整个对话消失,像被一键清零。
- 集中在长时间对话后:短聊几句就刷新的测试从来不会出错,但聊了十几轮之后再刷新,就高概率失忆。
常规的单元测试只测了"存进去、读出来"的链路,完全覆盖不到用户真实的操作时序:发送消息 → 等待回复 → 页面渲染 → 用户刷新 → 重新加载。而且手动测试根本没法在"刚好异步没写完"或"Redis 刚好过期"的临界点踩点。
我缺的不是另一种缓存策略,而是能精确重放用户聊天时序、还能随心所欲控制时间和页面生命周期的自动化测试。
方案设计
市面上能驱动真实浏览器的工具不少:Selenium、Cypress、Playwright。为什么选 Playwright?
- 多浏览器 + 多 context :我需要模拟同一个用户多次打开、关闭标签页,Playwright 的
browser_context天然隔离 session,用完就丢,跟真实用户行为一致。 - 网络控制 :可以拦截请求、验证写入是否完成,这是 Cypress 也能做的,但 Playwright 的
page.wait_for_response写起来更符合直觉。 - 时间模拟 :虽然这次没直接上
clock,但 Playwright 有原生的时钟 API,能模拟空闲等待,避免测试里用硬sleep导致用例不稳定。
整体思路是这样:用 pytest-playwright 写一个参数化的用例,模拟"多轮对话 → 强制刷新 → 检查记忆是否还在",并对后端服务的异步写入、Redis 过期策略做全链路断言。测试中如果发现历史丢失,立刻把页面截图和网络日志 dump 下来排查。
核心实现
为了能复现问题,我先搭了一个最小化的聊天后端,暴露了两个接口:POST /chat 发送消息并返回 AI 回复,GET /history?session_id=xxx 获取该会话的完整对话历史。记忆存储在 Redis,session_id 通过 cookie 维系。以下是挖坑版后端代码(含 bug):
python
# app.py - 有问题的后端,只为复现 bug
import asyncio
import uuid
from flask import Flask, request, jsonify, make_response
import redis.asyncio as redis
app = Flask(__name__)
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
async def save_message(session_id, role, content):
# 模拟大模型生成回复的异步写入
await r.rpush(f"chat:{session_id}", f"{role}:{content}")
# 只要写过一次就设过期,但这里没有续期逻辑
await r.expire(f"chat:{session_id}", 7*24*3600)
@app.route('/chat', methods=['POST'])
async def chat():
data = request.json
session_id = request.cookies.get('session_id', str(uuid.uuid4()))
user_msg = data['message']
# 存用户消息 ------ 注意这里没有 await!
asyncio.create_task(save_message(session_id, 'user', user_msg))
# 模拟 AI 回复(简化为固定话术)
bot_reply = f"你说的是:{user_msg},我记得之前聊过。" if await r.llen(f"chat:{session_id}") > 1 else "请开始你的问题。"
# 同样不等待 bot 消息写入
asyncio.create_task(save_message(session_id, 'bot', bot_reply))
resp = make_response(jsonify({"reply": bot_reply, "session_id": session_id}))
resp.set_cookie('session_id', session_id)
return resp
@app.route('/history', methods=['GET'])
async def history():
session_id = request.cookies.get('session_id', '')
msgs = await r.lrange(f"chat:{session_id}", 0, -1)
return jsonify(msgs)
if __name__ == '__main__':
app.run(port=5000)
这段代码解决什么问题 :展示一个"看起来能跑"但埋了两个致命 bug 的持久化实现。asyncio.create_task 不等待任务完成,主线程就返回响应了;expire 只在首次写入时设置一次,后续追加消息不会续期。只要网络或 CPU 稍有延迟,用户刷新时 Redis 可能还来不及写完,或者 key 刚好过期。
接下来是 Playwright 测试脚本,用来稳定复现这个"刷新丢记忆"的场景:
python
# test_memory.py
import re
from playwright.sync_api import Page, expect
import pytest
@pytest.mark.parametrize("rounds", [5, 15]) # 短聊、长聊两种场景
def test_chat_memory_survives_refresh(page: Page, rounds: int):
page.goto("http://localhost:5000") # 打开聊天
# 多轮对话
for i in range(rounds):
msg = f"第{i}轮消息"
page.fill("input#message", msg)
page.click("button#send")
# 等待 AI 回复完整出现
page.wait_for_selector(f"text=你说的是:{msg}", timeout=5000)
# 关键操作:强制刷新,模拟用户重载页面
page.reload()
# 断言:历史记录中至少包含第一轮用户消息
first_msg = "第0轮消息"
expect(page.locator("#history")).to_contain_text(first_msg, timeout=3000)
这段代码解决什么问题 :用 Playwright 模拟真实用户交互:输入消息、发送、等待回复、最后刷新。测试参数化 rounds,短聊 5 轮、长聊 15 轮,正是长聊场景下 bug 高发。page.wait_for_selector 确保后端处理完成再执行下一步,比盲目 sleep 可靠得多。
跑一遍就会发现:rounds=5 时偶尔失败,rounds=15 几乎必败。打开 Redis 一看,key 要么不存在,要么长度远小于预期,验证了"写入未完成"和"过期未续"两个问题。
修复后的后端极致简单:把 create_task 换成 await,并在每次写入或读取时刷新过期时间:
python
@app.route('/chat', methods=['POST'])
async def chat():
# ... 获取 session_id, user_msg
await save_message(session_id, 'user', user_msg) # 等它写完
bot_reply = "..."
await save_message(session_id, 'bot', bot_reply)
resp = make_response(...)
resp.set_cookie('session_id', session_id)
return resp
@app.route('/history', methods=['GET'])
async def history():
session_id = request.cookies.get('session_id', '')
# 读取前续期,保证活跃会话不过期
await r.expire(f"chat:{session_id}", 7*24*3600)
msgs = await r.lrange(f"chat:{session_id}", 0, -1)
return jsonify(msgs)
这段代码解决什么问题 :通过 await 保证响应返回前消息已落盘;每次读历史前刷新 TTL,使得只要用户还在聊天,key 就不会被淘汰。后面的 Playwright 测试跑 300 次,全部通过。
踩坑记录
坑一:Playwright 的 page.reload() 之后 cookie 丢失导致会话断裂
- 现象:测试中刷新后历史接口返回空数组,但用 curl 手动请求却能拿到数据。
- 原因 :Playwright 默认的 browser context 是非持久化的,但 cookie 还在。真正的问题是我在后端设置 cookie 时没有指定
path=/,导致刷新后某些浏览器(Chromium 的某些版本)未回传 cookie。Playwright 完美还原了这个浏览器差异。 - 解决 :
resp.set_cookie('session_id', session_id, path='/')。官方文档只告诉你set_cookie怎么用,没告诉你缺省 path 在不同内核下的差异。
坑二:wait_for_selector 文本匹配被前端换行符打断
- 现象 :
text=你说的是:xxx总超时,但页面上肉眼可见。 - 原因 :前端在渲染历史记录时插入了
<br>或其他标签,导致文本节点被拆分,Playwright 的text=选择器无法跨节点匹配。 - 解决 :改用
page.locator("#history").inner_text()然后手动断言first_msg in text,牺牲了一点优雅,换来了稳定性。这也给我上了一课:端到端测试别过度依赖文本选择器。
效果验证
修复前,用 Playwright 跑 300 次长聊刷新测试,仅 47 次通过,失败率高达 84%。修复后,同样的 300 次全绿,通过率 100%。关键是从"偶尔丢"变成了 ZERO,再也没接到失忆的工单。
可直接用的代码/工具
如果你也用 Redis 存会话,直接把这段续期逻辑抄进你的 history 读取函数里:
python
async def get_history(session_id):
await redis.expire(f"chat:{session_id}", 7*24*3600)
return await redis.lrange(f"chat:{session_id}", 0, -1)
玩大模型应用的朋友可以直接拿走,这是一行省掉你至少一通凌晨电话的代码。
#Playwright #大模型 #自动化测试 #后端踩坑 #Redis
关于作者
一个把"能跑就行"当作耻辱的后端/架构实战派,专注用测试和工具把 bug 扼杀在代码层。
GitHub: github.com/baofugege
Sponsor: github.com/sponsors/ba... --- 如果这篇文章帮你省下排查的时间,请我喝杯咖啡。
提供服务:Python 后端性能优化 / 工具定制 / 技术咨询,联系 Telegram @baofugege