LLM 回归测试:Record/Replay 录放机制 + 本地语义裁判的完整拆解

📅 2026/8/3 17:01:19
LLM 回归测试:Record/Replay 录放机制 + 本地语义裁判的完整拆解
LLM 应用进 CI 最大的障碍不是不会写测试而是写了也没法稳定跑。assert reply ...这种断言在 LLM 场景下必然失败同一个 prompt 两次调用结果不一样测试费用随调用量上涨CI 里还得塞 API key。最近开源的parthmax2/ghostrunpip install ghostrunMITPython 3.9给出了一个工程化解法把 pytest 直接变成 LLM 应用的回归测试框架——第一次运行真实调用 LLM 并把 HTTP 响应录制成磁带cassette落盘之后每次运行都从磁盘回放零成本、零延迟、零抖动对语义的断言意图、语气、groundedness交给本地 Ollama 小模型当裁判裁判结论同样缓存。没有云面板、没有专有 CLI就是一个 pytest 插件测的就是用户走的真实代码路径。技术选型上最值得学的一点它没有 monkey-patch OpenAI/Anthropic SDK而是直接替换 httpx 的transport——因为两家 SDK 底层都走 httpx这是所有 LLM 调用的最低公共层SDK 升级不需要改测试代码。一、拦截层在 httpx transport 上做手脚核心是一个包装 transport代码逻辑极简class _RecordingTransport(httpx.BaseTransport): def handle_request(self, request: httpx.Request) - httpx.Response: if not _is_provider(request.url): return self._inner.handle_request(request) # 非 LLM 流量直通 body request.read() key request_key(request.method, str(request.url), body) if self._mode in (auto, replay) and self._cache.has(key): return _to_response(self._cache.get(key)) # 命中缓存回放 if self._mode replay: raise CacheMiss(...) # 严格回放miss 即失败 response self._inner.handle_request(request) # 录制真实请求 self._cache.put(key, request.method, str(request.url), body, ...) return response三个关键设计挂载点patchhttpx.Client._transport_for_url及其 async 版本任何 SDK 或用户代码 new 出来的 client 都会透明地路由进包装 transport。这是私有 API但 0.18 一直稳定项目启动时会显式检查该属性是否存在不存在就抛UnsupportedHttpx并提示pin httpx0.29 或提 issue——而不是让测试悄悄打到真实网络。白名单拦截PROVIDER_HOSTS里是 openai、anthropic、azure、gemini、vertex、bedrock、mistral、deepseek、x.ai 等 16 个 LLM 提供方主机子串命中才拦截其余 HTTP 流量完全直通自建网关可用ghostrun.interceptor.PROVIDER_HOSTS (llm.internal.corp,)运行时扩展。三模式record强制真实调用并覆盖缓存、replay禁网miss 即 CacheMiss 失败、auto有缓存回放无缓存录制。pytest 参数--ghostrun-record/--ghostrun-replay一键切换整个套件。二、缓存键与磁带格式可提交、可 diff 的测试资产缓存 key 是sha256(method \n url \n normalized_body)的前 32 位 hex。body 先做 JSON 规范化sort_keysTrue 紧凑分隔符字段顺序、空白不同的语义相同请求命中同一个 key而 body 里含 model 名不同模型天然不冲突。只拿 URL 当 key 是不够的——两个不同 prompt 会共享一个响应。磁带是自文档化的 JSON 文件可以直接提交进 git{ request: { method: POST, url: https://api.openai.com/v1/chat/completions, body: { model: gpt-4o-mini, messages: [{role: system, content: ...}, {role: user, content: Where is my refund?}], temperature: 0.7 } }, response: { status_code: 200, headers: {content-type: application/json}, body: {\id\: \chatcmpl-example\, \choices\: [...], \usage\: {...}} } }几个值得抄的细节响应头里content-length/content-encoding/transfer-encoding被丢弃——回放 body 与原响应不一致留着会误导下游。请求体、响应头写入磁盘前脱敏响应 body 原样保存它要原封不动回放给被测代码脱敏不影响回放行为因为 key 用的是原始字节。写入用临时文件 os.replace原子替换pytest -n并行 worker 同时写同一个 key 时不会出现半截 JSON 导致的JSONDecodeError那种报错看起来像你业务代码的 bug排查成本极高。磁带进 git 的额外收益PR 里能看到 prompt 变更引发的磁带 diff——相当于给 prompt 也上了 code review。三、语义断言LLM 当裁判但裁判也缓存确定性断言contains/is_valid_json/ tool-call 断言不碰模型语义断言走 judgeghostrun.record(modelgpt-4o-mini) def test_reply_generation(): reply generate_reply(Where is my refund?) ghostrun.expect(reply).contains_intent(apology) ghostrun.expect(reply).contains_intent(refund policy) ghostrun.expect(reply).does_not_contain_intent(arguing) ghostrun.expect(reply).tone_is(empathetic)裁判的 system prompt 只有一句你是严格的测试评分器第一行必须输出 PASS 或 FAIL不许含糊Do not equivocate。用户侧 prompt 是CRITERION: ...TEXT: ...两段Ollama 调用temperature0尽量稳定。Grade.parse只取首 token 判断 PASS/FAIL其余文字当 reason——所以模型啰嗦也不影响判定。裁判结论也走磁盘缓存key 是sha256(backend model text criterion votes)。judge 模型身份参与 key换裁判模型会自动失效旧结论不会悄悄复用另一个模型的意见votes 也参与 key把投票数从 1 提到 5 不会复用旧的 N1 结论。项目还实测过一个反直觉结论用 90 次真实打分做投票基准在真正模糊的判定上 3 票多数表决比 5 票还好投票甚至不单调。所以默认 N1disagreement_rate即 LLM-as-judge 文献里的 flip rate作为观测指标——别迷信多投几次更准。四、踩坑记录这些 edge case 才是工程的真相项目自述文档里有一条为什么不直接让 LLM 写这个工具全是实打实踩过的坑线程安全是静默失败的。第一版把拦截器状态做成进程全局pytest -n并行时后安装的拦截器静默捕获了所有线程的流量——测试互相录进对方的缓存目录不报错、不告警只在某些 run 出错。最终用 thread-local 栈 全局兜底栈 引用计数 patch 解决最后一个 Interceptor 退出才还原。脱敏别用关键词黑名单。对*token*的正则也会匹配max_tokens把请求体录坏。必须用显式安全名单safe-list。缓存 key 只放 URL 不够。两个不同 prompt 会共享一个响应必须把规范化后的请求体算进 key。私有 API 要 fail-loud。_transport_for_url一旦改名最坏情况是测试悄悄走真实网络慢、烧钱、还可能因缺 key 而红。启动时检查属性存在性直接报错。Windows 控制台编码。diff 报告里的→、·、—在 Linux CI 上毫无问题在默认编码的 Windows 终端上直接 crashprint()。五、CI 落地与适用边界标准工作流本地pytest --ghostrun-record跑一次生成.ghostrun_cache/提交进 git注意先确认脱敏生效CI 里pytest --ghostrun-replay禁网、无 key、秒级完成示例测试回放 0.04sprompt 或模型变更时重新 record肉眼 review 磁带 diffghostrun diff _last name可对比两次运行的输出与判定每次 pytest 运行都会自动存_last快照模式网络速度用途record出网慢真实调用本地开发、更新磁带replay禁网0.04s 级CI 回归、无 key 环境auto按需混合日常开发适用边界要说清楚适合回归测试、prompt 重构验证、工具调用断言——ToolCallExpectation把 OpenAI 的{function: {name, arguments: json string}}和 Anthropic 的{name, input: {...}}归一化成{name, arguments}支持called_once、called_with子集匹配忽略模型多给的参数。大多数 agent bug 是调错工具/传错参数而不是文案不佳这类断言全走确定性检查不花一分钱。不适合验证新模型效果是否更好回放会挡住真实调用必须显式 recordprompt 大改后忘 record 导致的回放旧响应假绿——所以 CI 里可以加一步检查磁带文件时间戳或 hash 与 git 中不一致时报警。RAG 场景expect(answer).is_grounded_in(context)直接把检索上下文塞给裁判验证答案里的每个论断是否都能在 context 中找到依据防止幻觉混进回归测试。总结与进阶方向一句话把录放从单测的 mock 思路移植到 LLM HTTP 层再用本地小模型做可缓存的语义判定LLM 应用就能获得和普通后端一样的确定性回归测试体验。进阶方向有三条一是把录放从单次 LLM 调用推广到 agent 多步轨迹每一步 tool call 都录整条轨迹可回放二是 judge 换成更强模型或自建 rubric 时的取舍——key 里带 judge 身份让这种切换是安全的三是和 promptfoo / DeepEval 这类数据集 在线评测路线互补它们测 prompt 本身ghostrun 测代码路径生产上可以同时用。