大模型对话记忆持久化踩坑实录:用 Playwright 自动化测了 300 次,终于揪出会话丢失的真凶

凌晨两点被用户工单砸醒:"我晚上跟 AI 聊了 40 分钟的需求,中途刷了下页面,它把前面说的全忘了,又让我重新自我介绍。" 我第一反应是 Redis 里肯定存了,开发环境明明好好的。可这类"间歇性失忆"的问题,手动复现全靠运气------直到我写了一套 Playwright 端到端测试,跑了 300 次,那个藏了三天的 bug 才浮出水面。

问题拆解

给大模型做记忆持久化,说白了就是把对话历史存起来,下次用户回来时带上历史消息,避免上下文断裂。我们用的是经典的 session_id 绑定一份对话列表,后端收到消息后推到 Redis 的 list 里,并设置 7 天过期。看上去毫无破绽。

但用户反馈的问题有两个反常点:

  1. 不是每次都丢:大多数时候刷新没事,但偶尔整个对话消失,像被一键清零。
  2. 集中在长时间对话后:短聊几句就刷新的测试从来不会出错,但聊了十几轮之后再刷新,就高概率失忆。

常规的单元测试只测了"存进去、读出来"的链路,完全覆盖不到用户真实的操作时序:发送消息 → 等待回复 → 页面渲染 → 用户刷新 → 重新加载。而且手动测试根本没法在"刚好异步没写完"或"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

相关推荐
丙氨酸長鏈1 小时前
Web前端入门第 问:JavaScript 一个简单的 IndexedDB 数据库入门示例
前端·javascript·数据库
kyriewen2 小时前
面试官让我手写虚拟列表——AI生成的版本,快速滚动几下就白屏了
前端·javascript·面试
IT_陈寒2 小时前
Vite热更新失效?你可能漏了这个配置
前端·人工智能·后端
Revolution613 小时前
React 组件重新渲染时,到底重新执行了什么
前端·react.js·面试
林焱_RPAAI3 小时前
影刀RPA操作数据库进阶:事务处理与批量写入优化
css
用户2181697049305 小时前
Flutter(四)Dart语法 空安全 运算符 流程控制
前端
许彰午5 小时前
政务督办的分合模式:主办协办的并发审批
前端·javascript·政务
用户69371750013845 小时前
AI时代,程序员该往哪走?
前端·后端
ttwuai5 小时前
GoFrame 后台日志清空失败:无 WHERE 删除为什么被拦住
前端·golang