上周三我把系统提示里的一句「在给出结论前先复述用户的问题」删了。理由挺充分:模型已经够强,这句话显得啰嗦,还多花 token。CI 全绿,单测全过,12 条 smoke 用例也全过。三天后客服转来一个工单,用户问退款时效,回复里答的是发票抬头——问题被「顺着」回答了,但答错了对象。
那次之后我把回归测试的排期整个重排了一遍。结论只有一句:回归测试的频率不该由日历决定,该由变更源决定。
只盯代码 diff 是不够的
一个 LLM 应用的行为,大致由四样东西共同决定:代码、Prompt、模型、检索索引(工具 schema 算在 Prompt 那一侧,因为它是跟着上下文一起进去的)。
传统 CI 只看得见第一样。这是问题的根源——我们花了很多精力把代码变更管得很严,而另外三样的变动,很多时候连一条提交记录都没有。
模型这块最隐蔽。有些供应商的版本号带日期,比如 xxx-2024-08-06 这种,你能明确知道自己在用什么;但也有些别名会指向浮动的实现,某天背后悄悄换了。我的做法是在调用日志里落一列返回体里的真实 model 字段——不是我自己传进去的别名,是服务端回来的那个值。有一回我把七月的日志和八月的日志拉出来做 distinct,发现同一个别名下的值不一样了,而代码一行都没动。那天我们的输出格式合格率掉了一点,没人说得清为什么,直到翻出这一列。
索引这块影响面更大。重新切块、改 chunk size、换 embedding 模型,任何一次重建之后,top-k 召回的分布都会变。你如果只测端到端答案,看到的是「答得没以前准」,但分不清是检索拿错了材料,还是模型没用好材料。
Prompt 呢,一行字的影响面经常比一次重构还大。代码重构最多让某个分支走不通,Prompt 改动会让整个系统的语气、边界、拒答策略一起漂移。
我按变更源分了四档
| 档位 | 触发条件 | 用例数 | 单次成本(大致) | 频率 |
|---|---|---|---|---|
| smoke | 任何 PR | 20 | 几毛钱,两三分钟 | 每天好几次 |
| 核心 | Prompt 目录 hash 变、工具 schema 变、发版前 | 100 上下 | 十几块 | 每周几次 |
| 全量 | 模型版本字符串变、索引 manifest 变、季度 | 400+ | 一两百块 | 每月一两次 |
| 人工 | 月度抽检 | 抽 30 条 | 半个人天 | 每月 |
判断跑哪一档,不靠人记,交给一个几十行的脚本。每次跑完把四类指纹写进 .eval/last_run.json,下次开工先比对:
1 | import hashlib, json, os, pathlib |
这里有个取舍值得说:全量集我刻意控制在几百条以内。原因是它一旦跑到半小时、花掉几百块,人就会开始找理由跳过它——「这次应该没事吧」。一个会被跳过的测试等于没有。宁可覆盖窄一点,但要便宜到没人舍不得跑。
至于「多久跑一次全量」,我的答案是:不定周期,只定触发。模型版本变了就跑,索引重建了就跑,一个季度都没变过,那也补跑一次,确认没被环境漂移影响。
判定不能是 pass/fail
这是 LLM 回归最难的地方。传统断言只有对和错,LLM 的输出没有唯一正确答案。
我现在的用例分两类断言。第一类是硬断言:JSON 能不能解析、必填字段齐不齐、该拒答的有没有拒、敏感词有没有漏。这类必须 100% 通过,挂一条直接阻断合并,没有商量的余地。第二类是软断言:关键词包含、正则、或者拿另一个模型按 rubric 打分。软断言设容差,比如五档打分里掉一档以内算过。
容差线定在哪里是有争议的,我知道有人反对这么松。但我的经验是,卡得太死会得到一堆假阳性,团队两周之后就开始无视红色。
还有抖动。同一条用例跑三次,结果可能不一样,尤其温度没调到零的时候。我的做法是跑三次取多数,同时记录三次里的一致性——方差大的用例挑出来单独放一列,不进红线,只观察。这条「抖动榜」其实就是我最该去看的东西,它比通过率更能告诉我系统哪里不稳。
索引重建是最容易漏的一次性大变更
我漏过一次。改切分规则之后重建索引,核心集跑完,端到端通过率从 92% 掉到 84%。我以为是新切分不好,花了半天调参数,最后发现是召回里有两条旧文档的 id 已经不存在了——重建脚本把它们删了,但映射表没更新。
从那以后我把检索层单独拆出来测。一个六十来条的集合,每条是「查询 → 期望命中的文档 id」,不经过模型,纯检索,跑完几十秒。它只在索引变更时进主流程,但每天也会跑一次增量版本,新文档进来之后把它挤出 top-k 的情况,我遇到过不止一次。
manifest 里我固定存四样:embedding 模型名、chunk 参数、文档总数、内容 hash。少一样,都会在某次重建之后对不上账。
现在我做的最小动作是:每天扫一遍调用日志里出现过的 model 字段,出现没见过的新值就给自己发封邮件。回归测试的排期,最后落到的就是这个字符串比对。
你那边 CI 里,最后一次记录真实模型版本是什么时候?
评论