向量库静默丢数据踩坑实录:Playwright 端到端测试让我排查了72小时

凌晨两点,用户群里炸出一条消息:"为什么AI助手昨天还记得我叫小李,今天又问一遍?"我打开监控,内存正常、CPU 平稳、日志没有 ERROR。重启服务后,记忆又回来了------然后第二天再次丢失。没有报错、没有异常堆栈,Chroma 就这么悄无声息地把文档丢了。那一刻我才意识到,常规的单元测试在向量数据库面前就是一张废纸。

问题拆解

这个 LLM 应用用的是典型的 RAG 记忆链路:用户对话被摘要后写入向量库,下次对话从库中检索相关记忆拼进 Prompt。记忆持久化依赖 Chroma 的 persist(),我们在集成测试里 mock 了它的返回,写几个文档、查一下,全绿。但生产环境是另一回事。

根因出在三个地方叠加。第一,Chroma 的默认 persist() 在写入后不会立刻刷盘,底层 SQLite 有自己的缓存策略,进程异常退出时最近几秒的写入直接消失。第二,我们用了异步的 add_texts,但没有等底层 flush 完成就返回了 HTTP 200。第三,容器化部署时 /tmp/chroma-data 被 k8s 的 ephemeral 存储回收,而日志里只有一句 "Persist succeeded",实际上目录已经没了。常规单元测试根本模拟不出这些边界,因为所有操作都在同一个进程里,Mock 把最危险的部分悄悄盖住了。

方案设计

要逮住这种"静默丢数据",必须让测试在真实的多进程、有网络延迟、有磁盘 I/O 的环境下跑,同时覆盖持久化的完整生命周期:写入→服务重启→读取。这就是端到端测试的用武之地。

工具选型上,我们排除了单纯的 API 直测------虽然快,但绕过了浏览器和真实请求链,没法验证前端触发后整个调用链路是否真正把数据落盘。也排除了 Selenium,太重且 async 支持差。最终定下 Playwright + pytest-asyncio,因为它可以:

  • 模拟用户在真实浏览器里的多轮对话
  • 通过 page.wait_for_* 控制时序,捕获那些先返回 200 再异步丢数据的窗口
  • 在测试里直接控制服务进程的重启,覆盖"数据是否在磁盘上"的验证

架构思路是"测试即用户":每个用例都形如"打开页面→发送消息→关闭浏览器→重启服务→再次打开页面→检查历史记忆是否存在"。只有这种野蛮的方式才会让向量库的持久化问题原形毕露。

核心实现

我们先给一个简化的被测试应用------一个 FastAPI 服务,前端聊天页,后端用 Chroma 存记忆。为了演示,启动命令是 uvicorn main:app。下面的代码解决一个问题:如何用 Playwright 起停服务,并封装成可复用的 fixture

python 复制代码
# conftest.py
import subprocess
import time
import httpx
import pytest_asyncio
from playwright.async_api import async_playwright

@pytest_asyncio.fixture(scope="function")
async def app_service(tmp_path):
    # 每个测试用例拥有独立的 Chroma 持久化目录,避免数据串扰
    chroma_dir = tmp_path / "chroma_data"
    chroma_dir.mkdir()

    # 启动被测试服务,设置环境变量指定持久化路径
    proc = subprocess.Popen(
        ["uvicorn", "main:app", "--host", "127.0.0.1", "--port", "8765"],
        env={"CHROMA_PERSIST_DIR": str(chroma_dir), **__import__("os").environ},
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE
    )
    # 等待服务就绪
    async with httpx.AsyncClient() as client:
        for _ in range(30):
            try:
                resp = await client.get("http://127.0.0.1:8765/health")
                if resp.status_code == 200:
                    break
            except Exception:
                pass
            await asyncio.sleep(0.5)
        else:
            proc.kill()
            raise RuntimeError("Service did not start")

    yield {"proc": proc, "base_url": "http://127.0.0.1:8765", "chroma_dir": chroma_dir}

    # 测试结束后强杀进程,模拟生产环境可能出现的非正常退出
    proc.kill()
    proc.wait()

这段 fixture 把"每次测试独立数据目录"和"进程生命周期"统一管理,确保每个用例的持久化环境干净,且能模拟意外终止。接下来是核心测试用例,验证记忆在服务重启后是否还在。

python 复制代码
# test_memory_persistence.py
import asyncio
import pytest
from playwright.async_api import async_playwright

pytestmark = pytest.mark.asyncio(loop_scope="module")

async def test_memory_survives_restart(app_service):
    """
    场景:用户说自己的名字,重启服务后,AI 仍然记得。
    这个测试直接揪出了 Chroma 的异步刷盘问题。
    """
    base = app_service["base_url"]
    
    async with async_playwright() as p:
        browser = await p.chromium.launch()
        page = await browser.new_page()

        # Step 1: 用户首次对话,留下记忆
        await page.goto(base)
        await page.fill("#user-input", "我叫王小明,记住这个")
        await page.click("#send-btn")
        # 等待 AI 回复出现,确认本轮结束(实际应用里可能用 networkidle 不够可靠,后面踩坑会说)
        await page.wait_for_selector(".ai-message", timeout=10000)

        await browser.close()   # 关闭浏览器,但服务仍在

    # Step 2: 杀掉服务并重启(模拟崩溃或发布重启)
    app_service["proc"].kill()
    app_service["proc"].wait()
    # 重启同一个服务,注意 chroma_dir 保持不变
    import subprocess
    proc = subprocess.Popen(
        ["uvicorn", "main:app", "--host", "127.0.0.1", "--port", "8765"],
        env={"CHROMA_PERSIST_DIR": str(app_service["chroma_dir"]), **__import__("os").environ},
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE
    )
    await asyncio.sleep(1.5)   # 简单等待,生产环境可换成探活轮询

    async with async_playwright() as p:
        browser = await p.chromium.launch()
        page = await browser.new_page()
        await page.goto(base)
        await page.fill("#user-input", "我叫什么名字?")
        await page.click("#send-btn")
        # 核心断言:AI 回复中是否包含"王小明"
        response_el = page.locator(".ai-message").last
        await response_el.wait_for(timeout=10000)
        text = await response_el.inner_text()
        assert "王小明" in text, f"记忆丢失!实际回复: {text}"

        await browser.close()

    # 清理重启的进程
    proc.kill()
    proc.wait()

这段代码解决问题的关键:在真实多进程、真实磁盘读写的条件下,验证向量库里写入的文档是否能在重启后完整召回 。我们故意用 proc.kill() 而不是 graceful shutdown,就是为了模拟最坏情况------Chroma 的 flush 策略一旦有漏洞,这里就会报错。

踩坑记录

坑1:Playwright 的 wait_for_selector 根本不等异步写入

现象:测试偶发失败,text 里不包含"王小明",但手动测试总能复现。起初以为 Chroma 丢数据,后来加日志发现前端回显了 AI 响应,但后端向量库的 persist 还在写。原因是 Chroma 的 add 是异步的,HTTP 返回 200 只是任务提交成功,真正的 sqlite3_exec 发生在几秒后。Playwright 看到 .ai-message 出现就继续跑,此时磁盘上可能还是空的。

解决:在断言前加一个显式等待,调用后端一个专门的 /debug/vector-count 接口,循环检查文档数量直到达到预期值或超时。这是官方文档不会告诉你的------端到端测试里最可靠的"等待"往往要绕过后端的异步队列。

python 复制代码
# 在断言前轮询确保向量库已持久化
async with httpx.AsyncClient() as client:
    for _ in range(20):
        resp = await client.get(f"{base}/debug/vector-count")
        if resp.json()["count"] >= 1:
            break
        await asyncio.sleep(0.3)

坑2:容器内 Chroma 的 Segment 文件被静默清理

现象:CI 流水线里测试全绿,但预发环境第二天记忆全丢。Chroma 默认把 Segment 文件放在 /tmp 下,而 k8s 的 emptyDir 在某些策略下会清理临时文件。Chroma 的日志只打一句 "loaded 0 embeddings",不报错。如果测试的数据目录也指向 /tmp,而且恰好没有触发清理策略,测试永远发现不了。

解决:在 fixture 里强制指定 chroma-data 为临时目录,配合 tmp_path 确保路径可控。线上则挂载持久卷并开启 Chroma 的 settings.anonymized_telemetry = False 减少后台任务干扰。

效果验证

把这套 Playwright 端到端测试接入 CI 后,我们发现了三类以前漏掉的问题:

场景 单元测试(mock) 端到端测试(Playwright)
正常持久化
进程 kill -9 后恢复 ✅(mock 认为已写) ❌ 首次发现丢失 2s 内写入
容器 /tmp 清理 ❌ 首次发现目录消失
并发写入时冲突 ❌ 发现 collection 版本不一致

测试平均耗时 8 秒,覆盖了记忆写入→崩溃重启→读取的完整链路,再也没出现用户抱怨"AI 失忆"的线上事故。

可直接用的代码/工具

如果你想立刻为你的 LLM 应用加上同样的测试,这里是一个开箱即用的 fixture 工厂函数,记得在 conftest.py 里照抄前面的 app_service,然后把业务测试用例放进 test_memory.py。所有依赖:pip install playwright pytest-asyncio httpx,然后 playwright install 即可跑起来。


#Playwright #向量数据库 #端到端测试 #LLM #Python

关于作者

一个在生产环境踩过无数坑的后端/架构实战派,专注 LLM 应用的可观测性和工程质量。

GitHub: github.com/baofugege

Sponsor: github.com/sponsors/ba... --- 如果这篇文章帮你省下了半夜排查的时间,请我喝杯咖啡。

提供服务:Python 后端性能优化 / 工具定制 / 技术咨询,联系 Telegram @baofugege

相关推荐
Asize1 小时前
CSS 3D:从布局到立方体
前端
梨子同志1 小时前
React
前端
万少2 小时前
22 点后,我靠这个 AI 工具成了"夜间天才程序员"
前端·后端
狂师2 小时前
比 Playwright 更给力,推荐一个AI Agent的浏览器自动化开源项目!
前端·开源·测试
IT_陈寒2 小时前
React hooks 闭包陷阱把我的状态吃掉了,原来问题出在这里
前端·人工智能·后端
壹方秘境2 小时前
使用ApiCatcher在 iOS 上像修改 hosts 一样自定义域名解析
前端·后端·客户端
柳杉2 小时前
可视化大屏设计器脚手架:从设计到交付的一站式方案
前端·three.js·数据可视化
kyriewen16 小时前
我手写了一个 EventEmitter,面试官追问了 6 个问题——第 4 个我没答上来
前端·javascript·面试
IT_陈寒16 小时前
Java的Date类又坑了我一次,改用时间戳真香
前端·人工智能·后端