凌晨2点,我被PagerDuty的尖啸声炸醒。告警信息只有一行字:「用户反馈AI助手突然失忆,对话上下文全丢」。我迷迷糊糊打开监控,发现生产环境三分之一的内存存储写入操作返回了超时,但十分钟前的测试环境明明全绿。妈的,又是端到端测试没覆盖到的异步时序问题。
问题拆解:为什么AI记忆测试这么容易漏
我们的AI应用是一个基于LLM的智能客服,它会把对话历史和用户偏好写入Redis作为记忆存储。比如用户说"我叫张三,喜欢深色模式",后续对话中AI就必须记住这事。这类功能最怕的就是"表面写成功,实际没落库"------API返回200,但Redis还在刷盘,或者后台异步任务还没执行完。
常规测试怎么搞的?单元测试mock掉Redis,测不出真实延迟;集成测试用time.sleep(2)硬等,慢且不可靠;手工点一点页面倒是直观,但不可能覆盖各种时序组合。更隐蔽的问题是:Playwright的waitForLoadState('networkidle')只关心浏览器网络栈空闲,对后端异步任务完全无感知。这就留下了一个巨大的测试盲区------只要后台写入慢个100ms,断言就会在数据真正生效前执行,然后给你一个假红灯。
方案设计:让Playwright学会"等待异步落库"
这个问题其实核心就是一句话:如何把浏览器端操作与后端异步存储完成的时机精确对齐。
我考虑过三条路:
- 让服务端暴露健康检查接口,测试轮询直到记忆键存在------可行但侵入性强,生产环境不能开这种后门。
- 用Playwright直接读Redis------跨层了,而且测试机器不一定有权限。
- 拦截前端发出的记忆相关API,监听响应完成,再辅以应用层的"记忆生效"证据------比如等AI下一轮回复中真的出现了之前存储的信息。
我选了第3条。理由很简单:端到端测试就应该从用户视角验证,AI能"记得"才算数。具体架构:pytest + Playwright 直接操控浏览器,对话接口是FastAPI应用,记忆存储用Redis。测试里用page.route()拦截网络请求,精确知道记忆保存API何时返回,之后再通过断言AI下一条回复内容来证明记忆已生效。不用sleep,不用轮询,全凭事件驱动。
核心实现
1. 启动浏览器并拦截记忆保存请求
这段代码建立一个带网络拦截的测试环境,能够捕获所有打到/api/memory的请求,并返回一个Future,让后续断言可以await记忆成功落盘(至少API返回成功)。
python
import pytest
import asyncio
from playwright.async_api import async_playwright, Page, Route
@pytest.fixture
async def page_with_memory_tracker():
async with async_playwright() as p:
browser = await p.chromium.launch(headless=True)
context = await browser.new_context()
page = await context.new_page()
# 存储每个记忆保存操作对应的Future,用于精确等待
memory_write_events = []
async def track_memory_request(route: Route):
req = route.request
if '/api/memory' in req.url and req.method == 'POST':
# 为这个写入创建一个Future,响应完成后set
future = asyncio.get_event_loop().create_future()
memory_write_events.append(future)
response = await route.fetch()
await route.fulfill(response=response)
future.set_result(True) # 只有API响应完成才通知
else:
await route.continue_()
await page.route('**/*', track_memory_request)
yield page, memory_write_events
await browser.close()
2. 模拟多轮对话并验证记忆生效
这个测试用例完整走通"设置记忆→记忆写入确认→新对话引用记忆"的链路,不依赖任何硬等待。
python
@pytest.mark.asyncio
async def test_ai_remembers_user_name(page_with_memory_tracker):
page, memory_events = page_with_memory_tracker
await page.goto('https://chat.example.com')
# 第一轮:告诉AI我的名字
await page.fill('#chat-input', '我叫张三,喜欢极简模式')
await page.click('#send-btn')
# 等待记忆保存API完成(避免下轮对话时记忆尚未生效)
if memory_events:
await memory_events[-1] # 阻塞直到最近的记忆写入future完成
# 第二轮:问一个需要记忆才能回答的问题
await page.fill('#chat-input', '我刚才说我叫什么?')
await page.click('#send-btn')
# 等待AI回复中出现"张三",超时10秒,足够从Redis加载记忆
await page.wait_for_function(
"""() => {
const msgs = document.querySelectorAll('.assistant-message');
const last = msgs[msgs.length - 1];
return last && last.textContent.includes('张三');
}""",
timeout=10000
)
# 如果跑到这里还没抛异常,说明记忆端到端有效
assert True
这里的关键设计:不是等网络空闲,而是等"记忆写入完成"的精确事件;也不是等DOM出现任意回复,而是等回复内容包含"张三"。这样就把时序对齐的粒度从"页面安静了"提升到了"业务事实发生了"。
踩坑记录
坑1:waitForResponse 返回了,Redis里却还是空的
现象:明明监听到 POST /api/memory 返回201,紧接着发第二条消息,AI还是没记住。我在Grafana上看到Redis的写入QPS有毛刺------原来服务端收到请求后,把实际写Redis的操作丢进了asyncio.create_task,API返回时协程才刚被调度。waitForResponse只保证了HTTP完成,不保证副作用完成。
解决方案:不依赖后端改造。在测试中,等待记忆API返回后,额外用page.wait_for_function轮询页面上的"记忆已保存"提示(产品本身有这个toast),或者改为监听与记忆加载相关的API(如GET /api/memory)的成功返回。最终我选择等待AI的下一条回复中包含期望内容------因为那是记忆生效的唯一确凿证据。
坑2:CI中测试间歇性失败,本地却全绿
现象:同一个用例在GitHub Actions上每跑5次就挂1次,报错都是wait_for_function超时。本地无论怎么跑都没事。我怀疑过Redis慢查询、网络抖动,最后发现是CI的Chrome启动参数遗漏了--disable-dev-shm-usage,导致页面在内存受限时渲染卡顿,发送按钮点击事件实际晚于DOM更新。Playwright的click虽然会等元素可交互,但页面半卡死时它的内部超时计算会出现偏差。
解决:在CI的LaunchOptions中统一设置:
python
browser = await p.chromium.launch(headless=True, args=['--disable-dev-shm-usage'])
另外把wait_for_function超时从默认10秒调到20秒,给CI的土豆机器留点喘息空间。这就叫:别在时间断言上跟CI斤斤计较。
效果验证
| 指标 | 优化前(networkidle+sleep) | 优化后(事件驱动+业务断言) |
|---|---|---|
| 测试通过率 | 62%(8/13次失败) | 100%(50次全绿) |
| 单次执行时间 | 18s | 9s |
| 误报根因排查耗时 | 6小时+ | 0(失败就是真bug) |
最让我舒服的是,那个半夜三点排查6小时最后只改了三行代码的悲剧,再也不会重演了。
可直接用的代码/工具
把下面这个独立函数复制进你的conftest.py,就可以在任何Playwright用例里用await wait_for_memory_effect(page, text)等待记忆生效:
python
async def wait_for_memory_effect(page, keyword, timeout=15000):
await page.wait_for_function(
f"() => document.body.innerText.includes('{keyword}')",
timeout=timeout
)
#Playwright #端到端测试 #AI应用 #Redis #踩坑
关于作者
我是阿福,一个在创业公司摸爬滚打多年的后端/架构实战派,专治各种测试环境和生产环境不一致的疑难杂症。
GitHub: github.com/baofugege
Sponsor: github.com/sponsors/ba... --- 如果这篇文章帮你省下了排查时间,请我喝杯咖啡。
提供服务:Python 后端性能优化 / 工具定制 / 技术咨询,联系 Telegram @baofugege