本文是“AI之旅”合集 转岗FDE的Evals课 系列的第4篇。评测要成为日常开发流程的一环,得有基线、阈值和 PR 门禁。这篇讲结果抖动时如何重复运行、pass@k到底是什么意思、用快速集与完整集和缓存控制成本,以及换模型或改 prompt 之前为什么先看对比报告。评测只跑一次,等于没跑
前三篇我们有了题库,也有了判分的办法。现在设想这样一个场景:某天下午,同事顺手改了一句 system prompt,想让语气更亲切一点。他自己试了三五个例子,看着不错,就提交了。没有人再把题库跑一遍。两周后,客服那边反馈:退货相关的回答好像变啰嗦了,有些还漏了关键的期限。这个故事移动端工程师应该很眼熟。没有 CI 的时候,单元测试写得再全,也要靠大家自觉在本地跑;一忙起来,总有人忘。所以我们才会把测试接进流水线:代码一提交就自动跑,红了就不让合并。evals 是 AI 功能的测试,道理完全一样。题库放在仓库里不跑,和没有题库差不多。改 prompt、换模型、调检索参数、加一个工具,这些改动都可能让某一类问题悄悄变差,而且和改代码一样,改动者自己往往看不出来。最稳妥的办法,就是让评测成为提交流程里一道自动的关卡,也就是“门禁”。基线与阈值:什么叫“没变差”
门禁要拦住什么?答案是:拦住“变差”。可是“变差”总得有个参照物,这个参照物叫基线。做法很朴素:在主分支上把整套评测跑一遍,记下总分和各类题目的分数,这就是基线。之后每个 PR 改了 prompt,就在 PR 的分支上再跑一遍,和基线比。这和性能基线的思路是一回事。移动端团队常给启动耗时、列表滑动帧率记一个基线,新版本比基线慢了某个比例,比如 10% 以上,CI 就标红。评测分数也一样,需要定一个阈值:比如总通过率不能比基线低超过 2 个百分点,或者“退货政策”这一类题的通过率一道都不许掉。看总分,也要看分类。总分持平,可能是一类题涨了、另一类题跌了,互相抵消。分类别盯住,才不会被平均数蒙过去。有些题是红线。比如涉及退款金额、隐私信息的题,一条都不能错,可以单独设成“必须全过”,不参与平均。基线要更新。改动合并之后,新的结果成为新的基线,否则对比的参照物会越来越旧。这就像每次发版之后,性能基线也要跟着刷新。阈值是团队约定出来的,并没有一个放之四海皆准的数字。先定一个宽松的,跑一阵子,看看正常情况下分数会晃多大,再慢慢收紧。结果会抖:怎么看这个波动
于是门禁会遇到一个尴尬:没改任何东西,今天通过率 91%,明天 89%。这算变差了吗?多半不算,只是抖动。如果门禁把每一次小幅下降都拦下来,大家很快就会学会“重跑一次,直到变绿”,门禁就形同虚设了。这和 UI 测试里的 flaky test(偶尔失败的不稳定用例)是同一种病。重复运行。每道题跑 k 次,用多次的结果综合判断,而不是赌一次。先量一量自然波动。在同一个版本上,把整套评测重复跑五六遍,看分数的上下范围。门禁的阈值要比这个范围大,才有意义。题目太少时,波动会更大。只有 20 道题的时候,一道题的对错就是 5 个百分点,分数晃一下很正常。小题库上的分数变化,别急着下结论。说到“每道题跑 k 次”,这里有一个常被提到的指标:pass@k。它出自 Chen 等人 2021 年的论文《Evaluating Large Language Models Trained on Code》(arXiv 2107.03374),原本是评测代码生成模型用的:让模型对同一道题生成 k 个答案,只要其中至少有一个通过测试,这道题就算通过。直观地说,就是“给 k 次机会,至少对一次”的概率。这个指标要看场景用。写代码时,我们可以生成好几个候选,再用测试挑出能跑的那个,所以“至少一次对”有意义。但客服助手每次只给用户一个回答,用户碰到的就是随机抽到的那一次。这时更该关心的是“k 次全对”的比例,也就是稳定性。拿pass@k当门禁,会让分数看起来比用户实际体验好。用哪种口径,取决于线上到底是“多次尝试挑一个”,还是“一锤定音”。控制成本:快速集与完整集
重复运行带来另一个问题:钱和时间。题库 200 道,每道跑 5 遍,就是 1000 次调用;如果还要请 LLM 评委打分,调用量再翻一倍。每个 PR 都这么跑,账单和等待时间都吃不消。移动端的 CI 里早就有解法:测试分层。每次提交只跑快的单元测试,夜里或者发版前才跑完整的 UI 测试和真机测试。评测也可以这样分层:快速集:从题库里抽几十道有代表性的题,每个类别都有,每个 PR 都跑。目的是尽早抓到明显的退步,几分钟出结果。完整集:全部题目,加上重复运行,在合并前、每晚或者发版前跑。目的是拿到更可靠的数字,也用来刷新基线。抽样也有讲究。快速集不能随便抽,要保证红线题都在、每个类别都有覆盖,而且题目要固定下来,不然每次抽到的不一样,对比就没有意义。缓存。判分用的评委提示词里,评分标准这一大段每次都一样,正好适合 prompt caching,这在 020 第 6 篇讲过。被缓存命中的那部分输入,计费比正常输入便宜不少。同一道题、同一个版本的模型输出也可以存下来,评委换了标准时只重新判分,不用重新生成。批量接口。评测对实时性没有要求,夜里慢慢跑完就行。Anthropic 的 Message Batches API 和 OpenAI 的 Batch API 都提供异步批量处理,官方文档里写的价格是标准价的 50%,代价是结果不会立刻返回(具体的时限和限制,以各家文档现行说明为准)。很适合放进每晚的完整集任务里。换模型之前先看对比报告
每隔一段时间,模型厂商会发布新版本,价格更低,或者更快。换不换?这就像升级系统 SDK:新版本说明里写得再漂亮,我们也不会不跑回归就上线,因为总有老行为被悄悄改掉。换模型也一样,同一份 prompt 放到新模型上,表现可能更好,也可能在某几类题上变差,因为每个模型对指令的“理解习惯”并不相同。有了评测,这件事就变得可以量化:用同一份题库、同一套判分,把旧模型和新模型各跑一遍,出一份对比报告。改 prompt 的 PR 也是同样的逻辑,对比的是“改之前”和“改之后”。对比报告不用复杂,下面这几行就够了,假设两个版本的结果已经存成了“题目编号到是否通过”的字典:def compare(base: dict, new: dict, red_line: set, tolerance: float = 0.02) -> bool: shared = [q for q in base if q in new] # 只比两边都有的题 base_rate = sum(base[q] for q in shared) / len(shared) new_rate = sum(new[q] for q in shared) / len(shared) regressed = [q for q in shared if base[q] and not new[q]] fixed = [q for q in shared if not base[q] and new[q]] print(f"通过率{base_rate:.1%} ->{new_rate:.1%}") print(f"变差{len(regressed)} 题,变好{len(fixed)} 题") return new_rate >= base_rate - tolerance and not (set(regressed) & red_line)
这个函数返回 False,CI 这一步就失败,PR 不能合并。red_line 是前面说的红线题集合。真正有价值的是那份“变差了哪些题”的清单:总分只告诉我们好不好,清单才告诉我们坏在哪里。读完清单,我们可能发现新模型整体更好,只是在“多件商品部分退货”这一类上栽了跟头,于是针对性地补一句 prompt,再跑一遍,问题就解决了,而不是糊里糊涂地全量切换,等用户来报错。在 CI 里,对应的只是一个普通步骤:PR 触发时跑快速集,调用对比脚本,失败就标红;每晚跑完整集,更新基线。题库之外,还有真实用户
到这里,评测已经成了开发流程的一部分:改动有门禁,波动有量化,成本有分层,换模型有报告。但有一件事门禁也管不了:题库是我们自己攒的,再用心,也只覆盖我们想到的问题。上线之后,真实用户会用各种我们没想过的方式来提问。这些问题只能到线上去看,下一篇我们来讲线上监控,以及怎么把线上发现的坏例子送回题库。