跳至主要内容

回归测试的触发源不是日历,是变更

上周三我把系统提示里的一句「在给出结论前先复述用户的问题」删了。理由挺充分:模型已经够强,这句话显得啰嗦,还多花 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,下次开工先比对:

PYTHON25 行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
import hashlib, json, os, pathlib

def fp(paths):
h = hashlib.sha256()
for p in sorted(map(str, paths)):
h.update(pathlib.Path(p).read_bytes())
return h.hexdigest()[:12]

current = {
# prompt 目录下所有 md,含工具描述
"prompt": fp(pathlib.Path("prompts").rglob("*.md")),
# 服务端返回的真实版本,不是别名
"model": os.environ["MODEL_VERSION"],
# 由建索引的脚本写出来
"index": pathlib.Path("index/manifest.sha").read_text().strip(),
}

last = json.loads(pathlib.Path(".eval/last_run.json").read_text())

if current["model"] != last["model"] or current["index"] != last["index"]:
print("full")
elif current["prompt"] != last["prompt"]:
print("core")
else:
print("smoke")

这里有个取舍值得说:全量集我刻意控制在几百条以内。原因是它一旦跑到半小时、花掉几百块,人就会开始找理由跳过它——「这次应该没事吧」。一个会被跳过的测试等于没有。宁可覆盖窄一点,但要便宜到没人舍不得跑。

至于「多久跑一次全量」,我的答案是:不定周期,只定触发。模型版本变了就跑,索引重建了就跑,一个季度都没变过,那也补跑一次,确认没被环境漂移影响。

判定不能是 pass/fail

这是 LLM 回归最难的地方。传统断言只有对和错,LLM 的输出没有唯一正确答案。

我现在的用例分两类断言。第一类是硬断言:JSON 能不能解析、必填字段齐不齐、该拒答的有没有拒、敏感词有没有漏。这类必须 100% 通过,挂一条直接阻断合并,没有商量的余地。第二类是软断言:关键词包含、正则、或者拿另一个模型按 rubric 打分。软断言设容差,比如五档打分里掉一档以内算过。

容差线定在哪里是有争议的,我知道有人反对这么松。但我的经验是,卡得太死会得到一堆假阳性,团队两周之后就开始无视红色。

还有抖动。同一条用例跑三次,结果可能不一样,尤其温度没调到零的时候。我的做法是跑三次取多数,同时记录三次里的一致性——方差大的用例挑出来单独放一列,不进红线,只观察。这条「抖动榜」其实就是我最该去看的东西,它比通过率更能告诉我系统哪里不稳。

索引重建是最容易漏的一次性大变更

我漏过一次。改切分规则之后重建索引,核心集跑完,端到端通过率从 92% 掉到 84%。我以为是新切分不好,花了半天调参数,最后发现是召回里有两条旧文档的 id 已经不存在了——重建脚本把它们删了,但映射表没更新。

从那以后我把检索层单独拆出来测。一个六十来条的集合,每条是「查询 → 期望命中的文档 id」,不经过模型,纯检索,跑完几十秒。它只在索引变更时进主流程,但每天也会跑一次增量版本,新文档进来之后把它挤出 top-k 的情况,我遇到过不止一次。

manifest 里我固定存四样:embedding 模型名、chunk 参数、文档总数、内容 hash。少一样,都会在某次重建之后对不上账。

现在我做的最小动作是:每天扫一遍调用日志里出现过的 model 字段,出现没见过的新值就给自己发封邮件。回归测试的排期,最后落到的就是这个字符串比对。

你那边 CI 里,最后一次记录真实模型版本是什么时候?

关于本文

由 Lu's Blog 撰写,采用 CC BY-NC 4.0 许可协议。

#Prompt #回归测试 #评测集

评论