断层:榜单分数与业务表现不是同一件事

任何一个做上线决策的工程师都会遇到这种情况:同一个模型,在公开评测榜单上遥遥领先,接进业务系统之后却让人失望。不是差一点点,而是差得很远——客服助手在闲聊环节表现惊艳,一旦用户开始连续追问三个细节就答非所问;代码补全工具在单函数补全上正确率很高,面对跨文件的改动却频繁生成语法正确但逻辑错误的补丁;抽取任务在格式规整的工单上几乎不出错,扫到那些一年前的老工单就开始漏字段。

这类落差不是随机的,也不是模型"忽然变笨了"。它是系统性的,可以拆成若干条彼此独立的成因:评测数据的分布和你的业务分布不是一回事;评测任务的形式和你线上任务的形式不是一回事;单点能力在复合条件下会非线性退化,而公开榜单几乎只测单点。把这些因素和你的业务叠加起来,榜单分数就退化成了一个相关性很弱的参考量。

更深一层的问题是:榜单分数是一个标量,而你需要的是一张地图。你真正想知道的问题从来不是"这个模型强不强",而是"这个模型在我这类输入上,具体会在哪一步失效"。一个 70 分的模型,如果它的失分全部集中在长上下文中段的信息召回上,而你的业务恰好每条会话不超过三千字,那它对你是完全够用的;反过来,一个 88 分的模型,如果它的失分恰好落在多轮指令遵循上,而你每天要处理大量"改一下刚才那个方案"的请求,那它对你是危险的。

为什么平均值是个坏指标

任何单一数字汇总都有一个问题:它掩盖了分布。评测报告里的准确率、胜率、通过率,本质上都是把一个高维的能力向量投影到了标量轴上。投影过程是不可逆的,两个能力画像截然不同的模型,只要投影值相近,在榜单上就长得一样。

工程上更实用的做法是放弃追求一个综合分,转而追求一组带标签的分项通过率。你要的交付物不是"模型 A 比模型 B 高 3 个点",而是"在需要严格遵循输出 schema 的场景里,A 的通过率是 91%,B 是 99%,在这个场景上应该路由到 B;在需要跨十个文件做一致性重构的场景里,A 是 46%,B 是 12%,用 A"。这两种信息对架构决策的价值差着一个数量级。

这也解释了为什么本文要花大量篇幅讲评测集怎么造、判分怎么做、样本量怎么定。这些话题看起来琐碎,但它们是同一件事的不同侧面:你最终拿到的"能力边界画像"的质量,完全由生成这份画像的测量过程决定。测量过程有偏,画像就有偏,而且这种偏通常是无声的——你不会看到任何报错,只会在某次生产事故里才发现当初的评测结论是错的。

公开榜单与业务表现脱节的四个具体机制

下面这四条不是理论推测,而是每个上线过 LLM 功能的团队都会遇到的具体机制。理解它们,是决定"要不要自建评测"的前提。

机制一:数据分布错位

公开评测集的数据来自它们自己的采集渠道,通常有强烈的领域偏好。一部分偏通用知识,一部分偏学术推理,一部分偏标准化指令。这些数据和你的业务数据在多个维度上同时错位:

  • 领域错位:评测里的"推理题"多为数学与逻辑形式推理,而你的业务可能是要从一堆残缺的工单描述里推断用户真实意图。前者有干净的题面,后者一半信息缺失。
  • 语言与表达风格错位:用户的真实输入充满错别字、口语、省略、方言化表达、行业黑话和复制粘贴的格式噪声。评测集里的输入往往是规范书写的。
  • 长度分布错位:评测集里绝大多数样本是短输入。你线上遇到的输入长度分布可能长尾极重,而且长的那部分恰好是最难的。
  • 对抗性错位:评测集会刻意包含刁钻样本,但刁钻的方向是设计者能想到的。而真实用户会提出你根本想不到的方向。

分布错位还有一个隐蔽的放大效应:当输入偏离训练分布时,模型的方差会显著上升。同一个问题,在分布内多次采样可能全部正确,在分布外可能只有一半正确。这意味着你的评测如果只用"跑一次看对不对"的方式统计,在分布外的样本上会同时低估和误判——你既不知道失败率有多高,也不知道稳定性有多差。

机制二:任务形式不同

同一个能力目标,评测和线上可能用完全不同的任务形式来承载,而这个差别足以让结果差出几倍。最常见的几类:

  • 单轮 vs 多轮:榜单里的题目是一次性给全条件,然后回答。线上是条件逐步追加,模型必须记住并整合前面十几轮里出现过的约束("不要用第二段的语气"、"金额保留两位小数")。多轮场景会额外引入状态跟踪能力,而多数榜单不测这一项。
  • 直接回答 vs 工具调用:评测可能问"这个订单应该怎么处理",模型直接答。线上则是模型必须先查订单表、再查用户等级表、再调用退款接口,最后把三者的结果合成一个回复。工具调用把"生成能力"换成了"编排能力",后者对格式和顺序更敏感。
  • 自由文本 vs 结构化:榜单可能接受"模型答得对就行"。线上要求输出能被下游程序解析的 JSON,字段名、类型、嵌套结构、缺省值处理都有硬约束。哪怕语义完全正确,一个尾随的说明句就可能让整条链路解析失败。
  • 完整输入 vs 检索拼装:线上模型看到的"文档"是检索系统挑出来的片段,可能是相关的三段加上不相关的五段。评测里给的往往是干净且全部相关的材料。这两者对模型的干扰鲁棒性要求完全不同。

把这些差别叠加起来就会出现一个常见现象:模型在 A 榜单上表现优秀,但你的产品里"能正常用"的比例远低于那个分数暗示的水平。这不是榜单造假,而是它测的确实不是同一件事。

机制三:复合条件下能力非线性退化

单独测每项能力,得到的分数通常都不低。真正的问题出现在能力叠加的时候:长度、复杂度、干扰、轮次、结构约束,任何一个维度单独都在模型能力范围内,同时施加两到三个之后,正确率会断崖式下跌。这种退化往往不是线性的,甚至存在一个相变点——从相变点往后,输入再增加一点,结果就完全不可用了。

工程上这直接决定了评测集的设计:只用单项简单样本评测,会系统性地高估模型在你业务中的可用性。你的线上场景基本都处于复合区域,所以评测集也必须如此。你可以做一个粗略的分解:如果单项通过率是 0.95,两项复合通过率往往只有 0.75 到 0.85;三项复合可能掉到 0.5 以下。这个乘法近似只能当直觉,具体衰减取决于任务类型和实现方式,但它足以说明为什么必须做复合评测。

机制四:榜单分数掩盖了稳定性与成本结构

榜单几乎不报告方差、温度敏感性和输出长度。一个在贪心解码下表现不错的模型,可能在生产用的采样参数下明显抖动;一个准确率略高但输出长度长三倍的模型,实际成本可能反而更高。这些维度只有在你的具体配置下测才有意义,而任何脱离配置的分数都是不完整的。

还有一个容易被忽略的点:榜单分数对应的是一次性的、无状态的、无副作用的推理调用。线上的失败往往不是答错,而是答错了也没人知道——抽取少抽一个字段,没有异常、没有告警,错误直接流到下游。这类静默失败只能在你自己带真实副作用的评测流程里才能暴露出来。

从线上失败反推评测集:最值钱的一步

评测集有三种来源:公开数据集下载转换、自己凭经验手写、从线上真实失败案例反推。前两种是入门做法,第三种才是把测量变成工程资产的分水岭。原因很简单:前两种来源产生的样本,测的是你猜测模型可能不擅长的东西;第三种来源测的是你确知模型已经搞砸的东西。前者的价值是探索,后者是回归保障,而你的产品首先需要后者。

第一步:建一条能捞到失败样本的通路

反推数据集的前提是失败样本能被收集到。这件事听起来显然,实际上多数团队做不到,典型情况是线上只有一堆用户反馈工单,没有结构化的失败样本库。要建这条通路,最小可行方案包含四个部分:

  • 全量留痕:每条线上请求都持久化输入、输出、模型版本、提示词版本、采样参数、以及后续所有工具调用的轨迹。缺任何一项,事后都无法复现。
  • 明确的判负信号:必须有某个东西把"这次失败了"标记出来。可能是用户点了负反馈,可能是下游程序解析失败,可能是业务规则校验不通过,也可能是一个异步的人工审核队列在看。
  • 样本的稳定标识:同一个会话、同一个工单要能聚在一起;同一类错误的不同出现要能关联起来,否则你只会看到一千条"模型偶尔答错"的记录,而不是"它在处理这类输入时错得离谱"。
  • 定期的清洗与去重:失败样本里会有大量重复和近重复,直接拿来做评测集会让你误以为问题很多倍于实际。

这套通路的成本不低,但它是一次性的基础设施投入,且它同时服务于线上问题定位、提示词调优、数据飞轮等多个用途。如果你只打算做一件事来提升对模型的掌控力,应该是这件事,而不是先写评测脚本。

第二步:对失败做归因,而不是直接堆样本

捞到失败样本后,直接把它们全塞进评测集是最常见的错误。原因是这些样本里混杂着大量与模型能力无关的失败:检索根本没召回正确材料、上下文被截断在关键信息之前、工具接口本身返回了脏数据、提示词模板当时正好缺了一个变量。如果不区分,你会得到一个通过率极低的评测集,然后完全不知道该改什么。

正确的做法是给每个失败样本打一个归因标签,标签的取值应该是可行动的工程动作,而不是笼统的描述:

归因标签典型症状该动的地方
知识缺失编造了不存在的实体或规则补充上下文或换检索策略
推理断裂条件都识别对了但结论不成立拆任务或引入中间校验
指令违背忽略明确写出的约束或顺序改提示词结构,约束前置
上下文丢失用了前文给过、后文被截断的信息调整上下文预算或做摘要
多轮漂移第几轮之后开始违背早期约束引入外部状态而非依赖上下文
工具误用参数错、顺序错、无谓重试收紧 schema 或改编排逻辑
格式失守语义对但结构解析失败约束解码或后处理校验
该拒未拒超出能力或超出授权时仍然作答显式补充拒答策略与权限边界
非模型问题检索缺失、截断、上游脏数据先修管线,别动提示词

这张表的价值在于它把"模型不行"这个无用的结论,翻译成了具体的工程动作。做完归因,你会发现失败样本里"非模型问题"的比例往往超出预期——这个发现本身就值得把管线问题修一遍,之后评测集的基线会自然抬高。

第三步:只保留"模型本可以做对"的样本

经过归因之后,还要再筛一次。判断标准很简单:把模型输出换成一个正确输出,这条链路是否就正常了。如果不换也正常,那它是管线问题,不该进这个评测集。评测集里每一条样本都应该满足一个性质:它的失败能够归因到模型行为本身,而不是环境。

这个筛选纪律非常重要。一个混杂了管线问题的评测集,其分数无法指导任何决策——你改了提示词,分数动了,但你不知道是模型变好还是检索碰巧多召回了一条。分数从此失去了因果解释力,评测集也就废了。

控制难度分布:让分数有信息量

有了样本还不够。如果 90% 的样本都是简单题、10% 是极难题,那准确率 0.9 里绝大部分由简单题贡献,你无法区分"模型从 0.85 提升到 0.90"和"模型在难题上从 0.1 提升到 0.15"。这两种改进对你的业务价值可能差十倍,但总分几乎一样。

做法是主动设计难度分层,而不是被动接受样本的自然分布。实用的分层维度有几种:

  • 输入长度分层:按 token 数分成若干桶,比如千级、五千级、两万级。分桶报分,因为长桶的塌陷是最常见的。
  • 条件数量分层:一条任务里用户给了几个约束、要求同时满足几件事。约束数量与通过率往往呈现明显的非线性关系。
  • 干扰强度分层:上下文里放多少条语义相关但实际无关的材料。这是检索增强场景下最能拉开差距的维度。
  • 对抗分层:是否包含诱导、否定、歧义、多义指代、隐含假设需要被主动推翻的表述。
  • 轮次深度分层:多轮任务在第几轮开始崩塌。

按这些维度分桶之后报分,你会得到一张比任何综合分都有用的表。它直接告诉你:这个模型能不能接你这类活,断点在哪里,以及断点之后还有没有必要救。

防止测试集泄漏:三条纪律

评测集最大的死法不是不准,而是被悄悄污染之后继续给出一个虚高的分数。评测在工程团队里往往是同一个人在建、在调、在看,这个身份重叠使得泄漏几乎必然发生,除非制度上明确隔开。三条纪律:

纪律一,测试集与调优集物理分离。调优集是你反复跑、反复看结果、改提示词的地方,它会不可避免地被"过拟合",这个过程本身是合理的、甚至是必须的。测试集只能定期跑,用于回答"我们这次改动到底是进步还是退步"。如果每次改提示词都看测试集分数,测试集就变成了调优集。

纪律二,线上新样本先进调优集,不进测试集。反推出的失败样本天然来自当前系统的边界,如果直接进测试集,等于每次发布都在用测试集选型。更稳妥的做法是:新样本先进调优集,经过若干轮迭代确认它代表一个稳定的能力维度之后,再把它"提升"为测试集的一部分,并在测试集里给它配一个此前未被扰动过的孪生样本——输入相似但结果不同,以此防止模型或人记住了表层模式。

纪律三,测试集的标注答案不要给写提示词的人看。这条听起来有点过头,但它防的是一种非常隐蔽的泄漏:知道答案之后,人会不自觉地在提示词里加入针对特定答案的引导,而这些引导对新样本无效甚至有害。更实际的做法是让测试集的判分环节独立于提示词调优环节。

除了这三条,还有一个工程手段值得做:为测试集生成扰动版本。对每个测试样本自动生成若干变体(改写表述、调整顺序、替换实体、增删无关句子),发布前跑扰动版,看模型在扰动下是否还稳。一个模型在原始测试集上 95%、扰动版上 62%,说明它学到的是表层模式而不是能力,这类模型在面对新表述时会立刻崩塌,而原始测试集永远告诉你这件事。

能力分层:八个各自独立的天花板

"这个模型行不行"是个没有工程价值的问法。有价值的形式是把能力拆成若干个相对独立的维度,分别测出各自的通过率和塌陷点。因为这些维度的塌陷点位置不同,处置方式也完全不同,所以必须分开测。

维度一:知识

知识维度的天花板不是模型参数决定的,而是你能塞进上下文的信息量与检索系统能提供的准确度决定的。测这一维的正确方法是端到端测:给定一个真实业务问题,完整的检索加生成链路一起跑,看最终答案是否正确。

单独测"模型知不知道某条私有知识"意义不大,因为你永远不会直接把私有知识灌进提示词,你会走检索。这里真正要测的是两件事:检索到的材料是否足以支撑答案(这是检索的活),以及材料充分时模型是否会忠实使用(这是模型的活)。分开测的方法是构造一组"给了正确上下文"的样本,此时模型不该再错,错就是生成问题。

维度二:推理

推理维度要区分推理链长度和推理链是否存在这两个不同的问题。前者是步数增加时准确率如何衰减,后者是模型是否会在中间步骤就跳过、误读或者自相矛盾。

探测推理上限最有效的方法不是找更难的题,而是把一道已知的推理题做受控加长:把原题的条件拆得更细、把中间量显式写出来、插入一步无害的中间计算,看准确率随推理链增长的变化曲线。曲线形状比单点分数更有信息:缓慢线性下降说明推理链的衰减是可预测的、可以靠增加思考预算补偿的;陡峭崩塌则说明存在结构性缺陷,需要改变任务拆分方式而不是加预算。

维度三:指令遵循

这是最容易被高估也最容易被单独修复的一维。所谓指令遵循,指的是模型对显式写出的、格式化的、可验证的约束的遵守程度:输出必须是某种结构、必须包含哪些字段、必须按什么顺序、必须在某个长度以内、必须用某种语气、必须用哪种语言。

测它的关键是把约束写成可自动校验的形式。比如约束"输出必须是合法 JSON 且必须含字段 a、b",那判分就不该用人工或语言模型,直接用解析器和字段检查。约束遵循度是少数可以做到近乎客观判定的能力维度,评测成本极低,而且改提示词的效果通常立竿见影,值得优先做。

指令遵循里有一个特殊子类值得单列:否定式约束。要求模型不要做某件事,比要求它做某件事难得多,也更容易被忽略,尤其是当否定约束与正向指令冲突时(比如"不要解释,只给答案"与"遇到不确定时说明原因")。测否定约束时要专门设计"引诱"样本——把被禁止的行为摆在最容易被触发的位置。

维度四:长上下文

长上下文维度需要拆成三个子项分别测,因为它们的塌陷机制完全不同:

  • 大海捞针(needle retrieval):在长材料里定位一个特定字符串。这是最简单的一层,绝大多数宣称长上下文的模型都能通过。它其实只证明了位置编码能处理这个长度,不证明理解能力。
  • 信息聚合:需要把散落在长材料不同位置的多个事实组合起来才能回答。这才是真正对应业务的长度依赖能力。
  • 长材料干扰:材料中包含大量语义上高度相似的干扰项,目标是区分它们。这是最难的一层,也是最容易在生产中出问题的一层——检索给模型的十条材料往往互相高度相关,模型必须精确区分。

业务上最有价值的报告形式是按 token 长度分桶的通过率曲线,而不是"在 12.8 万 token 处通过"。因为真实业务的输入长度是连续分布的,你需要知道的是"我的输入通常有多长"对应的那一段曲线的位置。

维度五:多轮一致性

多轮一致性问的是:模型的输出是否会随着不必要的信息被添加而改变。这是一个关于稳定性的性质,不是关于正确性的性质。一个完全正确的模型,如果用户换个说法问同一件事就给出不同答案,在产品里体验同样糟糕。

构造这一维样本的方法是"等价改写":把第 k 轮的提问改写成一个语义等价但表述不同的形式,观察后续输出是否发生实质变化。同时要设计"约束漂移"测试:在早期轮次给出若干约束,然后在几十轮之后要求模型产出继续满足这些约束,看它什么时候开始忘记。遗忘的轮次深度因模型而异,且与早期约束在上下文中的位置强相关。

维度六:工具调用

工具调用是当前最容易在生产中被高估的维度。它至少包含四个独立的能力,混在一起报分毫无意义:

  • 选择正确性:在可用工具集很大、且存在功能相近工具时,是否选了正确的那个。
  • 参数正确性:包括必填项、类型、枚举值、以及从上下文里正确抽取参数值。这一项的错误率通常最高,因为它是"从自然语言到结构化字段"的映射,而这类映射正是一致性最难保证的地方。
  • 调用序列正确性:多个工具之间的先后依赖是否满足,是否出现循环调用或无谓重试。
  • 结果利用正确性:拿到工具返回之后,是否正确使用了返回结果,而不是忽略它或者过度依赖它。

工具维度有一个特殊约束:副作用不可回滚。评测时必须区分只读工具和有副作用的工具,只读工具可以在评测集里随意调用,有副作用的工具需要沙箱环境或者录制回放。忽略这一点,你的评测集一跑就会污染真实数据——这是一个很多团队踩过的很贵的坑。

维度七:结构化输出

这一维表面上等同于指令遵循的一个子集,但值得单独列出,因为它有一个独立的失效模式:语义正确而语法失守。模型输出的内容完全正确,但外面多包了一层解释文字、或者用了一个全角引号、或者多了一个尾随逗号,整个解析就失败了。

处理这类失效的工程手段是约束解码(constrained decoding),也就是在采样阶段就用语法约束住输出,而不是事后解析修复。约束解码把"格式正确"从一个概率问题变成了一个结构保证问题,是这一维最有效的解法。代价是它会限制模型的自由表达空间,并且在复杂 schema 上的实现依赖具体引擎能力,是否可用取决于具体实现。

维度八:拒答能力

这是最容易被忽略、但对上线风险影响最大的一维。评测它有两个方向,缺一不可:

  • 该拒时是否拒:遇到超出知识范围、超出权限范围、或者输入本身不足以支撑答案的情况,模型是否会明确拒答而不是编造。这一方向的错误代价极高。
  • 不该拒时是否误拒:遇到有足够信息可答、或者只需常识推理的问题,模型是否过度保守。这一方向的错误代价是可用性下降,且往往更隐蔽,因为它表现为"模型不太行"而不是"模型出事了"。

这两个方向的错误需要分别测量且分别优化,因为它们的驱动因素完全不同。该拒能力靠明确的边界声明和权限校验前置来提升;误拒率靠清晰的问题改写和"先尝试再降级"的策略来压低。把它们合并成一个"拒答准确率"会掩盖所有有用信息。

探针设计:用受控扰动定位断点

常规评测回答"在这个分布上通过率多少"。它不能回答"在什么条件下开始失效",而后一个问题才是能力边界真正关心的东西。定位断点的基本手法是受控扰动(controlled perturbation):一次只改一个变量,观察指标怎么变,然后沿着这个变量扫出一条曲线。

这条曲线的价值在于它区分了三种截然不同的失效模式。缓慢线性衰减意味着能力在衰减但可预测,可以通过增加资源(思考预算、更多采样、拆任务)补偿。存在相变点意味着存在一个明确的阈值,越过后急剧崩塌,此时必须改变任务形式而不是加资源。完全平坦的悬崖(一个微小扰动就导致零通过)则通常指向格式或解析层面的脆弱,而不是能力不足——这类问题往往一个好提示词就能修掉。三种模式对应三种完全不同的处置方式,而只报一个总分的话,你永远分不出自己面对的是哪一种。

扰动维度清单

实用的扰动维度有这么几类,值得逐个扫一遍:

  • 规模扰动:输入长度、约束数量、工具数量、轮次深度、上下文里干扰材料的条数。
  • 结构扰动:要求的输出格式层级深度、字段数量、嵌套关系的复杂度。
  • 表述扰动:同义改写、语序调整、语气变化、把主动句改成被动句、加入口语化表达。
  • 信息扰动:把关键信息从开头移到中间或结尾、在中间插入高相似度的干扰项、把关键信息拆散到多处。
  • 对抗扰动:在材料里加入与正确答案矛盾的陈述、在多轮里撤回先前给出的约束、在提问里植入错误前提。
  • 参数扰动:改变采样温度、改变思考预算、改变最大输出长度。

每个维度取三到五个扰动强度就足够看出趋势,不必做密集扫描。密集扫描的成本很高,而在大多数情况下三五个点已经足以区分"缓慢衰减"和"悬崖"。

边界样本(boundary case)怎么造

边界样本不是随机挖出来的难样本,而是专门构造在能力断点附近的样本。造它们的通用手法有四种:

二分收缩法。先找到一对极端样本,一个稳定通过、一个稳定失败,判定为"失败"的究竟是长度、约束数还是信息位置。如果一时看不出来,就在多个维度上同时插值,找到那条分界线在哪。二分几次之后,边界通常就浮出来了。

最小反例化。挑一条失败样本,逐个删除它的成分(删掉一个约束、删掉一段干扰材料、缩短一半长度),直到它变成通过。剩下的那个最小结构就是这条能力边界的形状。这个方法的副产品非常有价值:它直接告诉你在生产里哪些输入成分是必须被拦截的——只要你的产品里还可能出现那个最小反例,就说明风险仍在。

对照孪生法。造两条输入,除了一个关键成分之外完全相同:一条有那个成分,一条没有。通过率的差值就是这个成分的边际贡献。这个方法能排除掉"这条样本本来就难"带来的混淆。

对抗生成加人工收敛。用模型批量生成一批可能触发某类失败的输入,再由人工筛掉那些实际上并不困难的。这一步能快速扩大样本池,但必须保留人工筛选,因为自动生成的"困难样本"里有很大比例只是措辞古怪,并不真的触及能力上限。

一个可复用的探针执行器

下面这段代码给出探针执行器的骨架。它的核心职责是把"扰动 → 执行 → 判分 → 聚合成分桶结果"这个循环标准化,避免每个维度各写一套脚本导致结果不可比。

#encoding:utf-8
from dataclasses import dataclass, field
from typing import Callable, Dict, List

@dataclass
class ProbePoint:
    "扰动坐标系里的一个点"
    dim: str            #扰动维度名,例如 "input_len"
    level: int          #该维度的强度档位
    payload: Dict       #构造好的实际请求参数
    bucket: str = "default"

@dataclass
class ProbeResult:
    dim: str
    level: int
    passed: bool
    judge_scores: Dict = field(default_factory=dict)  #保留原始判分,不只留对错

def run_axis(probe_points: List[ProbePoint],
             invoke: Callable[[ProbePoint], dict],
             judge: Callable[[ProbePoint, dict], tuple],
             repeats: int = 3) -> Dict:
    "沿单个维度扫一遍,按强度聚合"
    agg = {}
    for p in probe_points:
        outcomes = []
        raw = []
        for _ in range(repeats):
            out = invoke(p)
            ok, detail = judge(p, out)
            outcomes.append(ok)
            raw.append(detail)
        n = len(outcomes)
        agg.setdefault(p.level, []).append({
            "n": n,
            "pass_rate": sum(outcomes) / n,
            "samples": raw,
        })
    return agg

def find_breakpoint(agg: Dict, hi: float = 0.90) -> int:
    "返回通过率首次跌破 hi 的档位;找不到返回 -1"
    for level in sorted(agg):
        rows = agg[level]
        cnt = sum(a["n"] for a in rows)
        rate = sum(a["pass_rate"] * a["n"] for a in rows) / cnt
        if rate < hi:
            return level
    return -1

这段代码里有三个刻意的设计。第一,判分保留原始明细而不只是布尔值,因为语言模型判分给出的连续分数往往比二值化之后更有信息——它能区分"完全错"和"结论对但理由错",而这两者对应的处置方式完全不同。

第二,每点默认重复三次。这不是为了刷精度,而是为了让方差本身可见。如果某个强度档位的通过率在不同重复之间跳得很厉害,那说明你正处在能力边界附近,这个点的分数几乎不该被用来做决策。

第三,断点查找单独抽成函数,因为"断点在哪"是一个需要被反复讨论和分享的结论,它不应该散落在各个脚本的打印输出里。每次模型或提示词变更之后重新跑一遍轴、重新定位断点,这条曲线本身就是一份可以版本化的资产。

把探针结果做成决策表

探针跑完之后,不要只记住一个断点数字,要把它翻译成一张决策表。这张表是你后面所有架构决策的输入,形式大致如下:

维度当前断点业务输入分布风险处置
输入长度约 1.2 万 token 后聚合类问题开始塌陷95% 低于 3 千低无需动作,但加长度监控
约束数量超过 4 条时格式遵循明显下降常见 6 到 8 条高前置合并约束或改用校验拦截
轮次深度约 12 轮后开始遗忘早期约束长会话可达 40 轮高把关键约束外置为状态
工具参数从自由文本抽取数字时错误率约一成高频中加类型校验与二次确认
拒答超出权限时拒答尚可,缺信息时倾向猜测高频高缺信息改为追问而非生成

表中数字是示意,具体值取决于实际测量。但表的结构才是关键:它把"模型强不强"翻译成了"在哪个维度、以什么形式、有多大风险、需要什么处置"。第 1 行说明长度不是瓶颈,因此不必花钱去扩上下文;第 2、3 行说明真正的问题在状态管理,处置方向是架构调整而不是换模型。有了这张表,选型讨论就从口味之争变成了工程判断。

判分方法本身就是一个被测系统

评测里最容易被忽略的事实是:测量工具本身有误差,而这个误差经常大于你要测量的差异。当你发现模型 A 比模型 B 高出两个百分点时,你首先要确认的是判分器能不能稳定地区分这两个点,而不是庆祝这次进步。一个判分噪声标准差是五个百分点的判分器,永远测不出两个百分点的差异。

判分模型(LLM-as-judge)的四类偏差

用语言模型给生成结果打分是当前最实用的做法,成本低、覆盖面广、和人工判断的相关性尚可。但它有四种系统性偏差,必须逐个识别和校正。

位置偏差(position bias)。当同时呈现两个待比较的输出时,判分模型倾向于偏好放在前面的那个,与内容无关。这在成对比较(pairwise)判分里表现为一个恒定的偏移:只要 A 在前,A 就多赢几个百分点。校正方法是对每个样本做双向评估并交换顺序,只有两次判断一致的样本才计入。代价是判分成本翻倍,但这是成对比较的必要开销。

长度偏差(length bias)。判分模型倾向于给更长的回答更高分,因为长度与表面完整性的相关性很强。这会造成一个非常有害的优化方向:你的提示词调优会不自觉地朝"写得更长"漂移,而线上用户要的是更短的答案。校验办法很简单——在一组已知优劣但长度相反的样本上测判分器,如果判分器偏向了长的那些,偏差就存在。

自我偏好(self-preference)。用某个模型当判分器去评它自己家族的输出,会系统性地偏高。跨家族对比时这个问题消失,但用同一家族的模型做日常评测时,你看到的一部分"提升"其实只是自我偏好带来的伪影。

表面一致性偏差。判分模型容易被表面特征带偏:引用了来源就加分、用了列表结构就加分、语气自信就加分。这些特征与实际正确性没有必然联系,但判分器很难抵抗。缓解手段是在判分提示里把这些维度显式拆开分别打分,而不是让它做一个整体的"好/不好"判断。

校准:把判分器当成一个要被标定的仪器

正确的做法是先标定再使用。标定集规模不需要很大,二三十条人工精标的样本通常就够用,因为它的目的是发现偏差方向,不是估计精确精度。下面是校准与双向比较的实现骨架。

#encoding:utf-8
import random
from typing import List, Tuple

def bidirectional_compare(sample, judge) -> Tuple:
    "交换顺序各判一次,只有两次一致才算有效"
    vd = {}
    for mode in ("A_first", "B_first"):
        x, y = (sample["x"], sample["y"]) if mode == "A_first" else (sample["y"], sample["x"])
        vd[mode] = judge(sample["prompt"], x, y)
    first, second = vd["A_first"][0], vd["B_first"][0]
    if first == "A" and second == "A":
        return ("A", 1.0, vd)
    if first == "B" and second == "B":
        return ("B", 1.0, vd)
    #两次不一致:该样本判为平局,不计入胜负统计
    return ("tie", 0.0, vd)

def calibrate(judge, gold: List[dict]) -> dict:
    "gold 每条含 gold_winner 与可选的 long_sample_name"
    n = agree = pos_collapse = len_resist = len_n = 0
    for g in gold:
        win, _, vd = bidirectional_compare(g, judge)
        n += 1
        agree += (win == g["gold_winner"] or win == "tie")
        #位置偏差探测:两次都判给先出现的那个
        pos_collapse += (vd["A_first"][0] == "A" and vd["B_first"][0] == "A")
        if g.get("long_sample_name"):
            len_n += 1
            len_resist += ((win == g["gold_winner"] or win == "tie")
                           == (g["gold_winner"] == g["long_sample_name"]))
    return {"agreement": agree / n, "positional_collapse_rate": pos_collapse / n,
            "length_bias_resistant": len_resist / max(1, len_n)}

def compare_with_ci(wins, n_boot=2000, seed=7):
    "成对比较的胜率与自助法置信区间"
    rng, n, boots = random.Random(seed), len(wins), []
    p = wins / n
    for _ in range(n_boot):
        boots.append(sum(1 for _ in range(n) if rng.random() < p) / n)
    boots.sort()
    return p, boots[int(0.025 * n_boot)], boots[int(0.975 * n_boot)]

几个设计要点。不一致即平局是这里最重要的规则:位置偏差的直接表现就是两次判断翻转,把它判成平局比随便采信一次要诚实得多。代价是有效样本量下降,所以判分器的预算应该优先给双向比较。

校准结果要当作门禁用,而不是参考信息。如果位置崩塌率超过某个阈值(比如两成),说明判分器在成对比较上不可用,你必须换判分方式或者换判分模型,而不是继续用它收集数据。

胜率必须带置信区间。看下面这个函数为什么必要:两个模型各跑两百个样本,胜率 0.52 和 0.55 看上去有差距,但自助法区间很可能大幅重叠,此时宣布"新模型更好"就是纯粹的自欺。判断一个新版本是否真的更好,应该看区间是否不重叠,或者做一个配对检验——因为对同一批样本的成对比较,配对检验的统计效能远高于把两组独立样本做比较。

什么时候根本不该用语言模型判分

判分器最弱的地方是需要外部知识或需要精确比对的场景。具体的分界线是这样:

  • 能用确定性检查的,绝不用判分器:输出是否为合法 JSON、字段是否齐全、数值是否与参照答案精确相等、集合是否相同。这类判定应当占整个评测的多数,否则你是在为可以自动化的东西持续付钱并引入噪声。
  • 需要外部事实核对的,用检索加引用比对:生成内容里的事实性主张能否在给定材料中找到支撑。这类可以做成半自动——先程序化提取主张,再人工或用判分器核对,最后抽样评估自动流程本身的准确率。
  • 多步执行结果的,用执行结果判分:代码类、SQL 类、工具编排类的任务,正确答案应当是"跑通了且结果符合预期"。让程序执行比让模型判断可靠一个量级。
  • 真正主观的(语气、有用性、多好),才交给判分器,并且必须配合双向比较和定期人工抽检。

这条分界线背后是一个更重要的原则:评测集的质量上限由判分方式决定,而不是由样本质量决定。一千条精心设计但用弱判分器打分的样本,测量精度可能不如一百条用确定性检查打分的样本。所以在做任何样本工作之前,先把判分能确定化的部分确定化。

方差与统计视角:为什么单个分数没有意义

评测报告里最常见的呈现形式是"通过率 87.3%"。这个数字几乎没有信息量,因为它既不告诉你测量精度,也不告诉你下次跑会不会还是这个数。要让分数变得有意义,必须同时报告方差和置信区间。

方差的三个来源

观测到的方差来自三个互相独立的部分,混在一起就无法定位问题:

  • 采样随机性:温度参数导致同一输入多次输出不同。这是模型本身的性质,可以被降低(降到接近零的确定性采样),但降为零有时会让模型陷入重复而降低质量。
  • 判分随机性:判分器自身的抖动。上文已经讨论过它的量级和校正方式。
  • 样本抽样误差:你抽的那一批样本不能代表真实分布。这一项才是通常意义上"样本量不足"所指的东西,也是唯一能靠增大评测集来缓解的。

三者的处理方式完全不同:降低采样随机性是调参问题,判分随机性靠双向比较和门禁控制,抽样误差靠样本量和分层。要区分它们,最简单的实验是固定评测集、固定判分器、只重复采样若干次,观察通过率的抖动幅度。这个幅度就是纯粹由模型采样引起的方差,它给出了任何单点分数的固有噪声下限——低于这个幅度的差异完全不可信。

还有一个容易被忽略的方差来源:上下文与外部服务的非确定性。如果评测集依赖真实检索,而检索索引在两次跑之间有变化,那两次分数的差异可能完全来自索引。控制手段是评测时使用快照索引,或者把检索结果预先固化到评测样本里。固化检索结果是更严格的做法,它把你的评测变成了"纯生成能力"评测,检索质量则需要单独一套评测。

样本量到底该定多少

这个问题没有万能答案,但有一个能落地的判据:算出置信区间,然后看它是否窄到你需要的决策精度。也就是说,不要先定样本量再算误差,而是先问"我能容忍多大的误差",反推样本量。

粗略的直觉是这样的:想让通过率的估计误差低于约 2 个百分点,在通过率处于中间区间时,量级上需要数百个样本;想区分两个版本之间 2 到 3 个百分点的差异,通常需要更多,量级上到千级。这个估计会随通过率变化——通过率越高或越接近极端,所需的样本数越多。而在通过率接近 0 或 1 的区域,通常更实用的做法是报告失败数而不是通过率,因为分母小的时候通过率的变化很不稳定。

比"凑够样本量"更有效的做法是成对比较(paired comparison)。如果两个版本跑的是同一批样本,就对每条样本记录"旧版对、新版错"和"新版对、旧版错"这两个计数,只看它们的不一致部分。绝大多数一致的样本对区分能力没有任何贡献,剔除掉它们之后,等效样本量大幅提升。这就是为什么在固定评测集上比较新旧版本永远比在两个不同评测集上比较更灵敏。

计算与呈现

下面这段代码给出方差的三个来源的分离方法,以及 Wilson 区间(在小比例和极端比例下比正态近似更稳)的实现。

#encoding:utf-8
import math, statistics
from typing import List, Dict

def wilson_interval(k: int, n: int, z: float = 1.96) -> tuple:
    "Wilson 区间,通过率接近 0 或 1 时比正态近似稳健"
    if n == 0: return (0.0, 1.0)
    p = k / n
    denom = 1 + z * z / n
    center = (p + z * z / (2 * n)) / denom
    half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / denom
    return (max(0.0, center - half), min(1.0, center + half))

def decompose_variance(runs: List[Dict]) -> Dict:
    "每条 run 含固定评测集的分数与仅重复采样的分数,用来分离两类方差"
    var_total = statistics.pvariance([r["score"] for r in runs])
    var_sampling = statistics.pvariance([r["score_resampled"] for r in runs])
    var_subsample = max(0.0, var_total - var_sampling)
    return {"var_sampling": var_sampling, "var_subsample": var_subsample,
            #单个分数的固有噪声下限,用标准差表达
            "sd_single_run": math.sqrt(var_sampling + var_subsample)}

def mcnemar_counts(pairs: List[tuple]) -> Dict:
    "成对比较:只保留不一致的样本对做符号检验"
    b = sum(1 for old, new in pairs if old and not new)   #旧对新的错
    c = sum(1 for old, new in pairs if new and not old)   #新对旧的错
    n = b + c
    if n == 0:
        return {"significant": False, "reason": "no_discordant_pairs"}
    #符号检验的精确二项 p 值(无校正的快速版本)
    from math import comb
    k = min(b, c)
    p = 2 * sum(comb(n, i) for i in range(0, k + 1)) / (2 ** n)
    return {"old_only_right": b, "new_only_right": c,
            "discordant": n, "p_value": min(1.0, p),
            "significant": p < 0.05}

注意 decompose_variance 的设计:它要求你既有一个固定的评测集(只重复采样,用来隔离采样方差),又有在子集上重跑的记录(用来估计抽样方差)。这个双轨设计看起来麻烦,但它是你唯一能区分"模型随机"和"样本代表性"两种失败的方式,而这两者的处置方式完全不同——前者靠调采样或多次取优,后者靠扩充和分层评测集。

什么时候必须重跑

有几类变更会让旧分数立即失效,必须重跑:

  • 模型版本或供应商变更:包括同一供应商的静默版本更新。后者尤其危险,因为它可能在没有任何通知的情况下改变行为。
  • 提示词模板任何改动:包括看起来无害的措辞调整,甚至包括示例的增删。
  • 采样参数变更:温度、思考预算、最大输出长度的任何变化。
  • 检索配置或索引内容变更:这会同时改变输入分布和上下文长度分布。
  • 工具集或工具描述变更:工具描述的措辞直接影响工具选择,这类变更的影响幅度常常超出直觉。
  • 评测判分器变更:换判分器之后新旧分数不可比,必须重跑基线。
  • 业务分布漂移:即使系统没变,输入分布变了,旧的断点位置也不再成立。这类漂移应当被定期监测,否则你会误以为是自己最近的改动导致了退化。

与之对称的一条纪律是:不要为了好看而重跑。如果某次分数不好看就调整配置再跑,直到通过为止,你就是在用采样噪声挑选结论。规则很简单——要么按预定协议重跑(协议里说好重复几次、取中位数还是取均值),要么就接受第一次的结果。

从评测到工程决策:能力边界如何变成架构约束

评测的产出不应该是一份报告,而应该是一组明确的架构约束。每个维度测出的断点都要对应到一个具体的处置决定,否则测量就没有兑现成价值。这一节把前面所有测量映射到四类处置动作上。

处置一:明确哪些环节必须换模型

换模型是最贵的手段,所以它应该被留给那些模型侧确实是瓶颈、且业务无法用其他方式兜住的维度。判断一个维度是否值得换模型,要同时满足两个条件:

一是该维度在你的业务输入分布里高频出现。一个在极端输入下才塌陷的能力,如果你的流量里几乎不出现这种输入,那它不该影响你的选型——你在为不存在的问题付钱。反过来,一个只在长输入下会错的能力,如果你的产品全是大输入,那它就是首要瓶颈。

二是该维度的失败无法被下游缓解。如果失败之后能被一次廉价的确定性校验拦住,那在模型侧花钱就不划算;反过来,如果失败会静默流到下游(比如抽取少一个字段、生成一句错误的事实),那它必须在模型侧或者校验侧被强处理。

只有同时满足这两条的维度才构成换模型的依据。在实践中,满足这两条的维度通常不多——多数评测做完之后会发现,真正的瓶颈往往不是"模型不够强",而是"上下文管理方式不对"或者"缺少校验层"。这个结论令人意外但非常常见,值得把它当作评测的默认假设去质疑。

处置二:明确哪些环节必须加校验

结构化输出、指令遵循、工具参数抽取这三类维度,几乎总是应该用确定性校验来兜底,而不是靠更强的模型。理由有三:

  • 校验的成本远低于提升模型能力的成本。一个 JSON schema 检查是一次函数调用,一次模型能力提升可能是数倍的推理成本和延迟。
  • 校验的可靠性是结构性的。校验通过就是 100% 确定,不存在概率性失败;而模型层面的改进永远是概率性的。
  • 校验失败可以转化成好的产品行为。schema 不合法时重试一次、或者降级到规则解析、或者明确告诉用户"我需要更多信息",这些都比一个自信但格式错误的回答要好得多。

校验层的正确位置在模型输出之后、下游消费之前,而不是模型内部。常见的三层结构是:第一层用约束解码保证语法合法;第二层做 schema 与类型校验,失败则重试并把错误信息回灌给模型(注意这里本身就是一次多轮的指令遵循测试,可以顺便监控);第三层做业务规则校验,比如金额合计是否对得上、引用的字段是否真的存在于源数据里。

第三层经常被省略,但它是捕获静默失败的唯一手段——语义正确、格式正确、但内容与事实不符的情况,前两层都拦不住。

处置三:明确哪些环节必须人工兜底

人工兜底不是失败,而是对某些维度最经济的选择。需要人工的典型情况有四类:

  • 低频高危:发生率不到千分之一但一旦出错后果不可逆的操作。评测告诉你模型在这里的成功率是 0.985,那么即使加了三层校验,剩下的残余风险仍需要人来兜。
  • 判分本身就主观:某些质量维度(法律条款的合理解释、复杂纠纷的公平性判断)连人之间都没有稳定一致的答案。这类场景里"模型答案 + 人工复核"是唯一可靠的结构,而且应该把模型定位成初筛器而非决策器。
  • 用户信任敏感:医疗、法律、财务建议类场景,即使模型准确率很高,缺少人工复核在合规上也未必可行。
  • 模型自身不可信的信号:当输入本身信息量不足时(用户描述模糊、附件残缺),最好的行为是追问而不是生成。这类"该说不"的场景改成人工或强制追问,比换模型有用得多。

人工兜底的设计要点是不要让人成为流水线的隐形瓶颈。正确做法是把人放在模型确定失败的地方(校验不通过、重试耗尽、置信度低于阈值),让人只处理异常路径,同时保证异常率足够低。如果 10% 的流量都落到人手里,那不是兜底,那是把系统退化成了半自动。

处置四:明确哪些问题该改架构而不是改模型

有一类问题的正确解法完全不在模型侧。这类问题的共同特征是:它们要求模型维持超出上下文可靠范围的状态。具体形式包括:

  • 让模型"记住"几十轮之前设定的一个约束。对策是把约束外置成一个显式状态对象,每轮重新注入,而不是指望模型自己记住。
  • 要求模型在一个任务里同时维持十几个互相约束的子目标。对策是把任务拆成有明确边界的子任务,每个子任务有独立的一次生成。
  • 要求模型在生成时反复参考它自己前面写的一大段内容。对策是先写结构再填内容,把"参考自己"变成"参考数据"。
  • 要求模型在长流程里保持一致的风格或术语。对策是把风格约束变成转换层的规则,而不是生成层的期望。

评测在这一步的价值是给出断点的具体位置,从而让你知道架构改造的优先级该放在哪。上面那张决策表里"轮次深度"那一行如果显示模型在十几轮后开始遗忘早期约束,那答案就很明确:不要换模型,去做状态外置。反过来,如果断点显示模型在只有两三轮、上下文很短的情况下就已经开始违背明确的格式约束,那才说明模型能力本身不够,此时换模型或者改用约束解码才有意义。

把决策写成可执行的规则

上面四类处置最后都要落到系统里的某条规则上。可执行的形式大致是这样:

#encoding:utf-8
#每条规则由"触发条件 -> 动作"构成,来自能力边界评测的结论
POLICY = {
    #来源:约束数量维度断点约 4 条,格式遵循在超过后明显下降
    "constraints_over_4": "merge_constraints_before_call",
    #来源:轮次深度断点约 12 轮,早期约束开始被遗忘
    "turn_depth_over_12": "externalize_state_and_reinject",
    #来源:长输入聚合类问题在较长上下文后塌陷
    "input_len_over_12000": "route_to_long_context_path",
    #来源:工具参数抽取在自由文本数字上有残余错误
    "tool_args_from_text": "type_check_then_confirm",
    #来源:信息不足时模型倾向猜测而非追问
    "insufficient_info_detected": "ask_user_not_generate",
}

def apply_policy(features: dict, base_call):
    "features 是运行时提取的输入特征,决定走哪条路径"
    actions = []
    if features.get("constraint_count", 0) > 4:
        actions.append(POLICY["constraints_over_4"])
    if features.get("turn_depth", 0) > 12:
        actions.append(POLICY["turn_depth_over_12"])
    if features.get("input_len", 0) > 12000:
        actions.append(POLICY["input_len_over_12000"])
    if actions:
        #把动作拼进提示结构,而不是靠模型自觉遵守
        actions.append("inject_structured_preamble")
    return base_call(features, actions)

注意这里的设计取向:所有这些规则都不依赖模型"自觉"。约束数量超了就由编排层合并约束,而不是在提示里写"请注意约束太多时优先保证格式";轮次太深了就由编排层注入状态,而不是指望模型记得。这条原则值得反复强调:凡是评测发现模型做不到的事,就不要把它交给模型去做,交给确定性的代码层。

持续评测:把回归发现变成流水线的一部分

一次性的评测是一次性资产。真正有价值的是能在每次变更后自动告诉你"有没有退步"的评测流水线。而这里恰好是能力边界画像最有回报的用法:因为你已经知道每个维度的断点位置,所以回归检测可以是有方向的——不需要每次都跑完整评测集来发现任何维度的任何变化,只需要在断点附近保持足够的采样密度。

分层:快慢两套流水线

持续评测必须分层,否则成本会高到无法持续运行。实用的分法是三层:

层级样本量触发时机用途
冒烟层数十条每次提交拦截明显崩溃,格式与结构化输出为主
回归层数百到上千每日或每次合并各维度的断点探测,固定子集
全量层完整评测集每周或发版前重新定位断点,生成能力画像

冒烟层要小而专,只放那些判分确定、通过就说明管线没坏的样本,不要试图用它发现能力问题。回归层是日常主力,用固定的、不参与调优的样本集,跑各维度的扰动轴,重点看断点附近的档位有没有移动。全量层是重活,它的产出是那张能力边界画像本身,按周期刷新。

回归检测的正确判据

回归检测最容易犯的错误是把噪声当成信号。某个桶的通过率比上次低了两个点,于是宣布回归并回滚——而这两个点完全在采样波动范围内。正确的判据必须同时考虑三件事:这个差异相对于噪声下限是否显著、在有差异的样本对里方向是否一致、以及这个变化在业务上是否重要。三者都满足才叫回归。

下面这段代码给出一个分层流水线的骨架,重点在回归判定而非调度细节。

#encoding:utf-8
from dataclasses import dataclass
from typing import List, Dict

@dataclass
class BucketResult:
    "某个维度某个档位的聚合结果"
    dim: str
    level: int
    pass_rate: float
    n: int
    ci: tuple

def detect_regression(current: BucketResult,
                     baseline: BucketResult,
                     sd_single_run: float,
                     min_delta: float = 0.03) -> Dict:
    "回归判据:超过噪声下限 + 超过业务重要度 + 区间显著不重叠"
    delta = baseline.pass_rate - current.pass_rate  #正数表示变差
    exceeds_noise = delta > 2 * sd_single_run
    matters = delta > min_delta
    lo_cur, hi_cur = current.ci
    lo_base, hi_base = baseline.ci
    separated = (lo_base > hi_cur) or (lo_cur > hi_base)
    is_regression = exceeds_noise and matters and separated
    return {"dim": current.dim, "level": current.level,
            "delta": delta, "exceeds_noise": exceeds_noise,
            "matters": matters, "ci_separated": separated,
            "is_regression": is_regression}

def sweep_all(current: List[BucketResult],
              baseline_by_key: Dict, sd_single_run: float) -> List[Dict]:
    "扫全部档位,只返回需要关注的项,按恶化幅度排序"
    flagged = []
    for c in current:
        b = baseline_by_key.get((c.dim, c.level))
        if b is None:
            continue   #新增档位没有基线可比,先积累
        r = detect_regression(c, b, sd_single_run)
        if r["is_regression"]:
            flagged.append(r)
    flagged.sort(key=lambda r: r["delta"], reverse=True)
    return flagged

def gate_release(flagged: List[Dict], blocking_dims: set) -> Dict:
    "只有属于高风险维度的回归才拦截发版,其余记录但不阻塞"
    blocking = [r for r in flagged if r["dim"] in blocking_dims]
    return {"pass": len(blocking) == 0,
            "blocking": blocking,
            "watchlist": [r for r in flagged if r["dim"] not in blocking_dims]}

这里有三个刻意的设计。第一,把"超过噪声下限"写成显式判据,并要求传入 sd_single_run——这个值应该来自前面说的方差分解,而不是拍脑袋定。第二,用置信区间不重叠作为显著性判据,这比硬编码一个百分比阈值更诚实,因为它会自动适应样本量。第三,区分阻塞发版与仅观察,因为把所有维度的回归都设为阻塞会让整个门禁迅速被无视——被绕过的门禁等于没有门禁。

还有一个实践要点:基线要能被追溯。每个基线分数都应该记录它是在什么模型版本、什么提示词版本、什么评测集版本下产生的。否则三个月后你看到一条曲线在某个时点突然下跌,完全无法知道那个时点发生了什么。

生产反馈回流

持续评测的最后一环是把线上新出现的失败重新纳入评测集。但这一步必须异步且带审核,不能自动提升:线上样本先进入候选池,经过归因(是模型问题还是管线问题)和难度评估之后,判定为"代表了一个新的稳定能力维度"的,才被提升为正式回归样本。

这条纪律的代价是线上问题不能立刻变成测试用例,收益是测试集不会在几周内被业务噪声淹没。实际运行中通常需要在两者之间取平衡——比如对已经出现过多次、有明确归因标签的失败样本走快速通道,其余走完整审核。

评测集自身也需要版本化与污染监控

持续运行一段时间之后,评测集会面临两种形式的腐蚀,而且这两种都很隐蔽。第一种是样本退化:随着业务变化,早期加入的样本逐渐变得不再代表真实输入,它们依然能跑、依然有明确的判分结果,于是安静地留在回归集里,把总分拉向一个过时的方向。第二种是结论过时:评测集的构成没变,但它所依据的能力假设变了,比如你当初认为某类输入不重要、只放了两三条,后来发现这类输入成了主要流量。

对策是给评测集本身建立版本和元数据。每条样本都要记录它进入评测集的时间、来源、归因标签、当前标记的难度档位,以及最近一次被重新确认的时间。有了这些字段,你才能回答一些关键问题:哪一批样本在什么时点之后一次都没有在真实失败里出现过(这类样本很可能是已经解决的,也可能是已经不再发生的,值得复核);哪些归因标签的样本数量在持续下降但从未被移除(这类标签对应的能力可能已经被你的架构改造覆盖了,可以降权);以及新增样本中有没有某个来源的比例过高(说明评测集被单一渠道主导了)。

除了元数据,还要有一个定期重访机制:每隔一段时间,随机抽一批评测样本,让熟悉业务的人重新做一次判断——这条样本今天还重要吗,判分标准还成立吗,难度档位还准确吗。这个过程不需要覆盖全部样本,抽一到两成就足以发现系统性的偏移。它的价值在于把"评测集在悄悄腐烂"这个不可见的问题,变成一个定期的、有人负责的检查项。

分布漂移:让评测权重跟随真实流量

评测集失效最常见的原因其实不是被污染,而是被时代抛下。半年过去了,上游数据源的结构变了,用户群体的构成变了,产品的入口场景变了,于是评测集所代表的分布和线上真实分布之间出现了系统性偏移。这时候你看到的所有分数都还"准确",只是准确测量了一个不存在的问题。

检测这种偏移的办法是把线上输入做一个粗粒度的聚类或者模板抽象,然后定期统计各簇的占比变化。实操上不需要复杂的聚类算法,按输入的长度分桶、按是否包含特定类型实体分桶、按任务意图分桶,就足以发现大部分漂移。关键指标有两个:各簇占比的绝对变化(某个曾经只占百分之一的输入类型现在占了三成)和评测集覆盖率的变化(线上出现了评测集里完全没有的输入类型)。第二个指标尤其重要,它直接告诉你评测正在盲区里运行。

发现漂移之后不要立刻重建整个评测集,那既昂贵又没有必要。务实的做法是调整权重而非替换样本:保留原有样本作为基础层保证可比性,在其上增加覆盖新分布的样本层,并且报告分数时同时给出"全量分数"和"按线上分布重新加权后的分数"两个数字。前者用于版本之间的回归对比,后者用于判断当前系统对真实流量的实际表现。

这样做的额外好处是,当某个输入簇彻底消失时,你会在报告里看到它的加权权重趋近于零,此时再决定是否把它从基础层里移除就有了明确的依据,而不是凭感觉清理。评测集的维护节奏应该由数据变化的速度决定,而不是由日历决定。

离线分数与线上指标之间的闭环

评测体系建好之后,几乎一定会遇到这样的失望:离线各维度的分数都在涨,线上业务指标却纹丝不动;或者线上用户投诉明显增加,离线分数却显示一切正常。这两种背离都需要专门解释,否则整个评测体系会在几次之后被质疑可信度。

离线涨、线上不动的三个常见原因

第一,改动落在了业务不关心的维度上。评测集覆盖了八个维度,但真实流量在某些维度上几乎没有暴露,所以那个维度的提升对用户完全不可感知。典型情况是你花了两周把多轮一致性从七成提到九成,而用户根本不会在一轮里问两次同样的问题。修正办法是给每个维度标注一个"业务权重",并且在汇报时只强调高权重维度的变化。

第二,改动把错误从一种挪到了另一种。综合分持平甚至略升,但错误类型变了——原来错在内容上,现在错在格式上,或者原来触发自动兜底,现在变成了静默通过。分维度的分数也未必能发现这类变化,因为它需要对照的是"错误的后果分布"而不是"通过率分布"。所以除了通过率,评测报告里还应该有错误类型分布这一项,并且要跨版本对比它的迁移。

第三,线上还有评测覆盖不到的影响因素。最常见的是延迟和成本。一次改动让准确率提升两个百分点,但首字延迟涨到用户能感知的程度,净效果是负的;或者成本涨了三倍,导致同预算下你能支撑的流量下降,从而间接降低了覆盖。评测集里必须包含延迟与成本这两列,否则离线结论天然是残缺的。

线上动、离线不动的三个常见原因

第一,分数分布被平均掩盖了。整体通过率不变,但某一小簇的通过率崩了,而这簇在流量里占比不大、却恰好是高价值用户。这正是前面强调分桶报分的原因:只看总分的话,这种退化完全不可见。

第二,失败从模型侧转移到了下游。模型输出变好了,但下游的解析、状态管理或者业务规则出现了新问题。表现是用户侧的失败,而模型的输出在评测里是对的。判断方式是看线上失败样本的完整轨迹,而不是只看模型那一段输出。

第三,分布已经漂移,而评测集还停在原地。这一条与上一节的漂移监测直接对应。它的特征是分数从某一天开始缓慢偏离,而且很难关联到任何一次系统变更,因为真正的触发因素在流量侧。

用分层验证确认改动真的有效

无论哪种背离,正确的处置都是回到分层验证:先确认线上指标的变化是否真实(排除流量结构变化、排除统计噪声),再把它归因到某个具体环节(模型、提示、检索、编排、下游),最后才回到离线评测去构造对应的回归样本。

这个闭环的最后一环最容易被跳过:把线上发现的新失败补回评测集。跳过它的结果是同一个问题反复发生,而每次排查都从零开始。前面讨论过的"生产反馈回流"机制,本质上就是为了保证这个最后一环不会被跳过。评测体系的成熟度,衡量的标准不是它当前报告了多少分数,而是它发现的新问题有多少被沉淀成了不再复发的回归样本。

2026 年的进展:评测方法本身在被重构

前面几节描述的方法论是相对稳定的。但最近一两年评测领域本身正在发生几个方向的演进,它们的共同点是试图解决传统评测"测量不准"这个根本问题。以下讨论的是机制和取舍,不涉及任何具体的榜单数字或论文结论。

方向一:动态评测与对抗性测试集

传统评测集的一个根本弱点是它一旦发布就会被针对。样本固定意味着模型可以(无论是训练阶段的意外还是评测阶段的刻意)逐渐适配这批具体样本,使得分数上升而能力没有同步上升。动态评测的思路是让测试集本身成为一个随时间变化的靶子:每次评测从更大的样本池里现场抽取,或者由一个生成器按预设的分布约束现场产出新样本。

对抗性测试集则是把"找模型的弱点"本身作为一个持续的优化目标——由一个专门训练的模型(或者一个搜索过程)持续寻找能让被测模型失败的输入,找到的失败样本自动进入评测集。这与传统评测"人工构造一批困难样本然后固定"的做法有本质区别,它把评测集变成了一个持续演化的对抗过程。

这两个方向解决了"过拟合评测集"的问题,但引入了新的工程挑战:评测结果的可复现性变差了。如果每次跑的样本都不一样,那么两次之间的分数差异里就混入了"样本难度波动"这个新的方差来源,而它往往比采样方差大得多。应对方式是固定生成器的随机种子、并且用足够多的样本把样本难度波动摊薄——代价是评测成本显著上升。

对工程团队的实际建议是:动态评测用于探索和发现新弱点,固定评测用于回归检测和版本决策。两者角色不同,不应该相互替代。固定评测的价值恰恰在于它的样本是恒定的,所以任何变化都能归因到系统上。

方向二:智能体(agentic)场景下的长程评测

传统评测的隐含假设是"一次输入、一次输出",整个交互在几千 token 内结束。当评测对象变成一个能自主执行几十步的智能体时,这个假设全面失效。新的困难至少有五层:

  • 成功判定变复杂:任务可能"达成了目标但绕了远路",或者"没达成但做了一件更有价值的事"。二值判分丢失了大量信息,需要按达成度的连续谱打分。
  • 单步小错可能被自我修复:一个 95% 单步准确率的模型,在二十步任务上的整体成功率远低于 0.95 的二十次方,因为很多单步错误会被后续步骤修正。这意味着单步准确率对长程表现几乎没有预测力,必须整段测。
  • 变异性来源更多:工具返回的时序、外部状态的变化、甚至时间相关的输入都会让同一任务的重跑结果不同。
  • 失败需要归因到步:整段任务失败时,定位到"第几步、因为什么"比判断成败更重要,但这需要保存完整的执行轨迹。
  • 成本成为一等问题:长程评测会放大 token 成本和时延成本,一个不设上限的评测跑一轮可能比线上服务还贵。

长程评测的几个实用做法:给任务设步数与预算双上限并在触顶时记为失败(而不是让它无限跑);把轨迹作为一等公民存储和分析,而不是只留最终结果;按失败步骤的位置分布报错而不是只报整体成功率,因为前者能直接指向要改的是哪一环;以及设置一个最小可行成功率作为门禁——长程任务的成功率通常很低,用接近零的绝对成功率做比较毫无意义,比较的重点应该是同一任务上不同版本之间的相对差异。

方向三:可验证任务与自动打分

这是对前面"判分器本身不可靠"问题最彻底的回应。它的核心洞察是:如果任务的正确性可以被一个程序确定性地判定,那么评测就不需要任何模型参与判分。

可验证任务的典型形态包括:给定一个测试用例集写出满足全部断言的代码;给定一个目标状态生成一串操作序列,用状态机校验终态;给定一组约束求一个满足全部约束的解,用求解器校验;给定一组源数据产出符合 schema 的结果集,用校验器比对。它们的共同特征是验证是可能的,而且验证成本远低于生成成本。

可验证评测的价值在三个层面。第一,它把测量精度提升到了基本无噪声——这让"提升了两个点"这种判断第一次变得可信。第二,它让难度可以自动调节:把断言数量加一条、约束收紧一点、测试用例补一个,难度就上升了,而这个调节过程不需要人工判断"这条难不难"。第三,它天然支持自动搜索——可以自动生成大量难度不同的变体,找出模型的能力断点。

限制也很清楚:可验证任务天然只覆盖那些"正确性可判定"的能力。写作风格、语气appropriateness、有帮助程度、部分推理的合理性都无法这样验证。所以可验证评测不能取代传统评测,它取代的是传统评测中那些本来就可以被自动判定、却用了弱判分器去判的部分。而这个部分在很多团队的评测集里占比相当高。

还有一层需要注意:可验证任务容易被"刷"。如果断言本身写得不严,模型可能生成一个通过断言但明显错误的解(比如所有断言都检查了但漏了一个明显该检查的分支)。缓解办法是让断言本身也接受对抗审查——用一个模型专门尝试生成让当前测试通过的错误解,看它能不能成功。这个"反测试(metatesting)"的过程应该在断言集设计阶段就做。

方向四:评测与真实分布的对齐

一个更工程化但同样重要的方向,是把评测的输入分布直接锚定到真实流量上,做分布漂移的自动检测。做法通常是把线上输入按聚类或者按模板抽象分成若干簇,定期统计各簇的占比变化,一旦某个簇的占比显著上升或下降,就说明业务分布发生了变化,原有评测的权重需要重新调整。

这个方向的价值在于它回答了一个前面没解决的问题:你的评测集还代表现在的业务吗。评测集的失效往往不是被污染,而是被时代抛下了——业务形态变了、用户群体变了、上游数据源变了,而你的评测集还停留在半年前的分布上。定期重新抽样并对齐分布,是保持评测有效性的必要维护动作。

把整套东西落地的顺序

读完前面所有内容,最容易犯的错误是试图一次性做完所有事:建全量评测集、跑六个维度的扰动轴、搭判分校准、上持续流水线。这个顺序几乎必然失败,因为它需要的初始投入远超大多数团队愿意为一次"摸底"付出的预算,而且在中途看不到可见回报,容易夭折。

如果只能按一个顺序做,我建议是下面这个,它按"最快能产生决策价值"排序:

第一,先把评测信号通路建起来。全量留痕、判负信号、稳定标识。这件事不产出任何分数,但它是后面所有工作的数据基础,而且它同时解决线上问题定位的痛点,属于无论如何都要做的事。

第二,先做结构化输出和指令遵循这两个能确定判分的维度。判分是函数调用,成本极低,结果无噪声,改进效果通常立刻可见。这一步能让团队第一次对"模型在业务里到底行不行"有具体的、可复现的数字,而不是印象。

第三,用线上失败样本建一个两三百条的调优集,按归因标签分类。这一步开始产生真实的决策价值:你能说出"我们最常见的失败是参数抽取和长轮次遗忘",而不是"感觉有点不稳"。

第四,砍掉所有能确定判分的判分器,换成确定性检查。把判分噪音从系统里挤出去。这一步会让已有分数的可信度发生一次明显跳升,虽然看起来什么都没变。

第五,测一个真正有风险的维度,长上下文、多轮一致性或工具调用中选一个。用扰动扫出断点,把它翻译成一条架构规则。这一步通常是第一次产出"因为评测结果,我们改了架构"的时刻,也是让评测这件事获得组织支持的关键。

第六,引入置信区间和方差分解。从此之后所有分数都带区间,不再用单个数字做决策。这一步认知成本高但极其必要,可以安排在团队已经建立起对评测的信任之后。

第七,把能自动判定的部分转成可验证任务,并搭起三层持续流水线。这一步是规模化,前六步不做到位就来搭流水线,只会搭出一个没人信任因而没人看的门禁。

几个容易走错的岔路

最后总结几个在实践中反复出现的错误模式,它们比技术细节更容易让整套评测失效。

把评测当成一次性报告。评测的价值在于持续的对照,不在于某一次的结论。一份只跑一次的报告,无论数字多漂亮,用完就过期了。

追求一个综合分。综合分掩盖了分布,且没有对应的处置动作。分维度的通过率表比任何单一数字有用。

用弱判分器判分大量可判定的样本。这会让测量精度成为整个体系的瓶颈,而且是一种不易察觉的瓶颈——它不产生报错,只产生"说不清哪次改动有效"的无力感。

把管线问题算到模型头上。检索没召回、上下文被截断、上游数据脏,这些都会表现为"模型答错了"。归因不做这一步,你会不断调优一个根本没坏的东西。

让测试集参与调优。一旦测试集分数开始指导提示词迭代,它就变成了调优集,此后所有关于"新改动是否真的更好"的判断都失去意义。

把模型做不到的事继续交给模型。评测告诉你某个维度做不到,正确反应是加确定性校验、外置状态或换模型,而不是在提示词里把要求写得更详细一点然后期待奇迹。

能力边界探测本质上是把"模型很强"这个模糊判断,转换成一组有位置、有噪声、有对应处置动作的具体结论。它不提供确定性——没有任何评测能做到——但它能把不确定的争论变成可讨论的工程判断,而这本身就是巨大的进展。

怎么判断这套体系真的在起作用

评测体系很容易变成一种自娱自乐的形式:报告按时出,分数曲线很好看,但没有任何产品决策因为它而改变过。判断它是否有效,不需要复杂的指标,看几个朴素的现象就够了。

第一个信号是选型讨论变了性质。如果讨论还停留在"哪个模型更强",说明评测没有提供足够信息;如果讨论变成了"约束数量维度上我们该路由到哪个模型",说明它已经在支撑决策。这是最直接也最诚实的检验方法:随便找一次上个月的模型选型会,看有没有人引用过评测结论。

第二个信号是架构改动能不能追溯到断点。好的体系会让每一次为模型能力做的架构妥协都留下记录:当时测出在哪个维度、在什么条件下不行,所以采取了哪种补偿手段。如果有人想取消某个状态外置层或者某层校验,应该能立刻拿出当初的评测数据来说明取消之后会退回到什么水平。没有这种追溯能力的补偿层,通常会在某次"代码清理"里被误删。

第三个信号是线上问题的重复率。这是最硬的一个指标。同类失败被反复发现,说明回流机制没跑通——问题找到了却没有沉淀成回归样本,下个版本还会再犯一次。如果这个比例在持续下降,说明闭环是通的。反过来,如果新问题发现得越来越快而旧问题一个都没解决,那多半是评测集在膨胀而不是在收敛。

第四个信号是评测本身的信噪比。一个健康的体系里,"这个改动让分数涨了"这句话应该经常是可验证的,而不是经常是噪声。如果团队已经开始习惯性地忽略评测结果里的变化,那说明噪声水平已经超过了信号水平,此时最该做的不是继续加样本,而是回去改进判分方式或者增加重复次数。

最后提醒一个关于投入产出的现实判断:能力边界探测的收益不会在第一个月出现。它的价值来自长期积累的对照数据——每一次模型升级、每一次提示词变更、每一次架构调整,都在同一把尺子下留下了一个刻度。尺子本身只值很少钱,刻度的数量才是价值所在。这也是为什么前面反复强调不要做一次性评测:一次性评测只留下了零点,零点无法定义任何方向。