第一次接触 DeepSeek Harness 的人,八成会把它当成一个"接口封装库"——觉得无非是把 HTTP 请求包一层,省得自己写重试和解析。我最初也是这么想的,于是先手写了一版两百行的脚本,跑得挺顺,直到要对比两个提示模板的效果时,才发现自己陷进去了:结果没有归档、失败样本混在成功样本里、换个参数就得改代码、跑完一次之后再也复现不出当时的数字。真正把我拉回正轨的,是重新理解 Harness 的定位——它不是调用封装,而是一套围绕模型能力的评测执行骨架,负责把任务集、提示模板、采样参数、结果解析、打分规则、报告归档这几件事串成一条可重复的流水线。
这篇内容面向三类人:刚拿到 Harness 想跑通第一条评测的;已经能跑但结果不敢信的;以及准备把它接进日常迭代流程的。我会按"它是什么—怎么搭起来—任务和模板怎么设计—批跑怎么控—分数怎么打—报告怎么读—坑怎么排—怎么保证可复现"这条线讲,重点放在那些文档里通常不会写、但你一定会在第二个星期撞上的细节上。文中涉及的目录结构、配置字段、并发算法和评分器写法,一部分来自 Harness 本身的设计约定,一部分是我在实际项目里补的工程实践,凡是后者我都会说明。
1. 把 Harness 看成"考场",而不是"电话线"
1.1 手写脚本调接口的人,最后都会推倒重来
我见过太多团队的第一版评测脚本长这样:一个 for 循环遍历问题列表,调一次模型,把返回塞进列表,最后 print 出来人工看。这版脚本在任务数小于五十的时候是可以用的,超过一百就开始出问题——不是代码崩了,而是结论不可信了。原因很朴素:当你同时改了提示模板、改了温度、改了模型版本,而你手上只有一份"看起来还行"的输出列表,你根本说不清是哪一项改动带来了分数变化。
Harness 存在的意义,本质上是强迫你把"变量"显式化。它要求你把任务集单独存成文件、把提示模板单独存成模板、把采样参数写在配置里、把评分逻辑抽成独立的评分器。这套拆分刚开始会让人觉得繁琐——明明三行代码能搞定的事,为什么要建五个文件夹?但正是这种繁琐,让"第二周的你"和"第三周的同事"能看懂第一周到底跑了什么。
我一般会给团队这样解释:手写脚本是打电话,你拨号、对方接、你说一句他答一句,过程转瞬即逝;Harness 是考场,有考卷(任务集)、有考生(模型)、有监考规则(并发与重试)、有阅卷标准(评分器)、有成绩单(报告)。电话打完就没了,考场的成绩单可以存档、可以横向对比、可以复查。
1.2 Harness 的四层职责划分
把 Harness 拆开看,它其实只做四件事,每一件都对应一个可以独立替换的模块:
| 层 | 职责 | 出问题时最先怀疑的地方 |
|---|---|---|
| 任务层 | 定义"考什么",包括输入、期望输出、元数据 | 任务文件字段拼写、编码、JSON 结构 |
| 执行层 | 调用模型、控制并发、处理重试与超时 | 并发配额、超时阈值、重试次数 |
| 评分层 | 把原始输出转成可比较的分数 | 解析规则、字符串匹配、模型评分的提示 |
| 归档层 | 落盘原始输出、中间结果、汇总报告 | 目录覆盖、文件名冲突、缓存命中 |
这四层的关键在于解耦。任务层不应该知道模型是谁,执行层不应该知道怎么打分,评分层不应该重新调一次模型(除非它就是模型评分器)。一旦你发现自己在任务文件里写死了模型名,或者在评分器里又发了一次请求,基本就说明分层被破坏了。
我踩过最典型的一次分层破坏,是在评分器里为了"更准一点",又调了一次模型做二次判断。结果跑批的时候请求量直接翻倍,原本预估二十分钟的批次跑了五十分钟,而且因为评分器里的请求没有走执行层的并发控制,触发了限流,一批任务全部返回空。这个坑我后面第 7 章还会细讲。
1.3 什么时候该用 Harness,什么时候别用
不是所有场景都值得上 Harness。我的判断标准很粗暴:如果你打算对同一批问题跑第二次,就该用。只跑一次、看一眼输出就完事的探索性实验,直接写脚本更快;但只要涉及"改一版提示再跑一遍对比"、"换个参数看看差异"、"给同事复现我的结论",Harness 的投入产出比就立刻转正。
反过来说,如果你的任务集只有三五条、评分完全靠人眼看、也不打算做版本对比,那上 Harness 就是纯负担。我见过有同学为了让"三个问题"跑得规范,花两天时间搭目录结构,这就属于把工具当目的了。
2. 从零跑通第一条评测记录
2.1 先定目录骨架,再装依赖
很多人上手第一件事是装包,我建议反过来——先把目录骨架定下来。因为 Harness 的行为高度依赖路径约定,你在错误的目录下执行命令,最常见的结果是"任务文件找不到"或者"报告写到了意想不到的位置"。
我常用的骨架是这样的,你可以照抄,也可以按团队习惯改名,但层级关系建议保留:
eval-workspace/ ├── configs/ │ ├── base.yaml │ └── exp-2024-a.yaml ├── datasets/ │ └── qa-sample.jsonl ├── prompts/ │ └── qa-answer.txt ├── scorers/ │ └── exact_match.py ├── runs/ └── reports/configs放配置,datasets放任务集,prompts放模板,scorers放评分器,runs放原始输出,reports放汇总。这个结构的核心思想是:输入和输出物理隔离。输入目录只读,输出目录可以随便删。我见过把输出直接写回datasets的工程,第二次跑的时候把原始任务文件覆盖了,数据没了。
依赖安装这一步没什么可讲的,按 Harness 的说明装即可。但有一个细节值得提醒:如果它依赖某个特定版本范围的运行时,建议在项目里锁死版本并写进requirements.txt或等价文件。评测工具最怕的就是"上个月跑出来的分数,这个月跑不出来了",而版本漂移是这类问题的头号元凶。
2.2 最小可运行配置:一条任务、一个模板、一个评分器
跑通链路的最短路径不是跑一个大任务集,而是跑一条任务。我强烈建议第一次只放一条任务,确认整条流水线通了,再往上加量。因为一条任务出错时,日志干净、定位精准;一百条任务出错时,你面对的是满屏重复的报错。
先写任务文件,用 JSONL 格式,一行一条:
{"id": "q001", "input": "把下面这段话压缩成一句话:本季度销售额同比增长百分之十二,主要来自华东区域的新客户拓展。", "expected": "本季度销售额同比增长百分之十二,主要来自华东新客户拓展。", "tags": ["压缩", "中文"]}id必须唯一且稳定,这是后面做断点续跑和结果对齐的锚点。expected是参考答案,如果评分器是模型评分,这个字段可以写成评分要点而不是逐字答案。tags看起来可有可无,但它是后面做分维度分析的基础,建议一开始就加上。
模板文件就是一个纯文本,用占位符表示变量:
你是一名文字编辑。请将用户提供的段落压缩为一句话,保留全部关键数字与主语,不要添加解释。 原文:{{input}} 压缩结果:占位符的语法各家不同,{{input}}、{input}、${input}都见过,务必以 Harness 的实际约定为准。这里最容易踩的坑是占位符名字和任务字段名不一致——模板里写{{text}},任务里字段叫input,渲染出来就是原样的{{text}}字符串发给模型,模型多半会礼貌地问你"请问 text 是什么"。后面第 7 章会详细讲这个排查过程。
评分器先用最简单的规则版,逐字比对或者规范化后比对:
import re def normalize(s: str) -> str: s = s.strip() s = re.sub(r"\s+", "", s) s = s.replace(",", ",").replace("。", ".") return s def score(prediction: str, expected: str, **kwargs) -> float: if prediction is None: return 0.0 return 1.0 if normalize(prediction) == normalize(expected) else 0.0这段代码没什么技术含量,但它体现了一个重要原则:评分器必须先做归一化再做比较。中文标点、全半角、多余空格、结尾的句号,这些差异会让逐字比对产生大量假阴性。我第一次跑压缩任务时,准确率只有三成,一度以为模型不行,后来发现有一半的失败样本是因为模型多写了一个句号。
2.3 凭据和配置分离,别写进任务文件
这一点必须单独拿出来说。凭据只能放在环境变量或者独立的、被忽略的配置文件中,绝对不能出现在任务文件、模板文件、评分器代码里。原因不只是安全,还有协作:任务文件是要提交到版本库、要被同事 review 的,凭据混进去之后清理起来非常麻烦。
我习惯的做法是配置里只写引用,不写值:
model: name: "your-model-id" api_key_env: "EVAL_API_KEY" timeout_seconds: 60 max_retries: 3然后在启动前设置环境变量。这样配置文件本身是可以自由分享的,不同人用自己的凭据跑同一份配置,结果才有可比性。
3. 任务集和模板设计,决定了评测的天花板
3.1 任务定义的字段约定与常见错位
任务集的字段设计看起来是体力活,实际上决定了后面所有分析的便利度。我给团队定的最小字段集是这几个:
| 字段 | 必填 | 用途 | 常见错误 |
|---|---|---|---|
| id | 是 | 唯一标识、断点续跑锚点 | 用行号代替,增删行后全乱 |
| input | 是 | 送进模板的原始输入 | 与模板占位符名字不一致 |
| expected | 视评分方式 | 参考答案或评分要点 | 写成多行字符串破坏 JSONL |
| tags | 建议 | 分维度统计 | 标签体系不统一,同一含义多个写法 |
| meta | 可选 | 来源、难度、语言等 | 塞太多无用信息拖慢加载 |
id用行号是新手最常见的错误。你今天有 100 条任务,id 是 1 到 100;明天在第 30 行插入一条新任务,后面七十条的 id 全部偏移,之前的运行记录全部对不上。用内容哈希或者有语义的编号(如compress-zh-001)会省掉后面无数麻烦。
另一个高频问题是 JSONL 里出现跨行字符串。JSONL 的约定是一行一个完整 JSON 对象,如果你想在expected里写多行文本,必须用\n转义,不能直接换行。这类文件的报错往往只说"第 N 行解析失败",但 N 指的是整个文件的物理行号,而不是任务序号,定位起来很容易绕晕。
标签体系也值得提前统一。我见过一个项目的标签里同时存在"中文"、"zh"、"Chinese"三种写法,最后做分维度统计时得手写映射表。建议在项目开始时定一份标签字典,写进 README,所有人照着用。
3.2 模板渲染的时机,比模板内容更容易出错
模板的渲染时机有两个可能的位置:一是在任务加载后统一渲染,二是每次调用前渲染。这两种在功能上都能跑通,但对调试的影响完全不同。统一渲染的好处是你可以在跑批之前先把所有渲染结果 dump 出来检查一遍,坏处是内存占用随任务数增长。调用前渲染更省内存,但出错时你看到的只是"模型返回了奇怪的东西",不知道发出去的到底是什么。
我的建议是:调试阶段用统一渲染并落盘,确认无误后切到调用前渲染。落盘的文件就是排查问题的第一手证据。我一般会额外加一条脚本,把渲染结果的前若干条打印出来人工扫一眼,重点看三个地方——占位符有没有残留、换行结构对不对、有没有意外的空白字符堆积。
渲染环节还有一类隐蔽问题:如果输入内容本身含有类似占位符的字符(比如用户输入里就有{{...}}这样的文本),二次渲染可能会把它当成变量替换掉。解决办法是在渲染前对输入做转义,或者在渲染后检查是否还有未被替换的占位符并报警。我倾向后者,因为转义会改变原始数据,而报警只是提示。
3.3 温度和并发,是两个必须一起想的参数
温度决定输出的随机性,并发决定跑批的速度。这两个参数单独看都很简单,但放在一起会产生一个反直觉的结论:高温 + 单次采样 = 不可复现的分数。
道理不难理解。温度大于零时,同样的输入每次输出都可能不同,那么你今天跑出 0.72,明天跑出 0.69,你无法判断是模型变了、提示变了,还是单纯的随机波动。解决办法有两个:要么把温度设为零(多数服务在温度为零时仍非完全确定,但波动会小很多),要么在高温下做多次采样取平均,也就是常说的 pass@k 思路。
我给的经验值是:做回归对比时,温度设零、单次采样,追求可复现;做能力评估时,温度设一个适中值、采样三到五次,看平均值和方差。方差本身也是信息——如果同一批任务五次采样的分数波动很大,说明这个任务对模型来说处于能力边界附近,值得单独拎出来看。
采样次数直接决定请求量。任务数乘以采样数就是总请求数,再乘以单次平均 token 消耗,就是成本估算的基础。这个账建议在跑批之前先算一遍,而不是跑完看账单。
4. 批跑阶段的并发、重试与续跑
4.1 并发度的算法,别凭感觉设
并发设多少,是新手最爱凭感觉的地方。设小了跑得慢,设大了触发限流、大量失败。其实这个数是可以算的。
设任务数为 N,单次请求平均耗时为 T 秒,你希望整批在 W 秒内跑完,那么理论并发度约为:
并发度 ≈ N × T / W举一个具体例子:300 条任务,单次平均耗时 4 秒,希望 10 分钟(600 秒)跑完。300 × 4 ÷ 600 = 2。也就是说理论并发 2 就够了。但实际要考虑重试、网络抖动、个别长尾请求,我会留 1.5 到 2 倍余量,最终设 3 到 4。
这个公式的方向感很重要:它告诉你并发度不是越大越好。很多人默认设 20,结果 300 条任务跑得并不比并发 4 快多少,因为大部分请求都在排队等限流恢复。真正的瓶颈往往不是并发数,而是服务端的配额和你自己的重试策略。
还有一点:并发度要和你账户的实际配额对齐。如果你不清楚配额上限,宁可从小往大试,先设 2 跑 20 条,观察失败率和耗时,再逐步上调。一次跑 300 条然后一半失败,重跑的成本远高于慢慢试。
4.2 重试与退避,指数增长加抖动
重试不是"失败了再来一次"这么简单。如果不加间隔地重试,遇到限流时只会让情况更糟——你发的请求越多,恢复得越慢。标准的做法是指数退避:
第 n 次重试等待时间 = min(基准值 × 2^n, 上限) + 随机抖动基准值取 1 秒、上限取 30 秒、最多重试 3 次,是我用得比较顺的一组参数。加上随机抖动是为了避免多个并发任务在同一时刻集体重试,形成新的尖峰。
重试还要区分错误类型。超时、连接中断这类通常是可重试的;参数错误、内容被拒这类重试多少次都没用,应该直接标记失败并记录原因。我见过把参数错误也纳入重试的配置,结果一条错误任务重试三次,白白浪费三倍配额,日志里还多出两条重复记录。
失败任务的落盘也不能省。建议单独存一个失败清单,包含 id、错误类型、原始响应片段、重试次数。这份清单是后面排查的主要线索来源。
4.3 断点续跑:让中断不意味着从头再来
跑批最怕的是跑到 80% 中断。没有续跑能力的话,你只能从头再来,时间和配额双重浪费。续跑的实现依赖两个东西:幂等的结果文件和已完成集合的判断。
具体做法是:每条任务跑完后立即追加写入结果文件(而不是全部跑完再统一写),文件里带上 id。下次启动时先扫一遍结果文件,把已成功的 id 收集成集合,跳过这些任务。这样即使中断,最多只损失正在执行的那几条。
这里有个细节要注意:如果一条任务中途失败但写入了失败记录,续跑时应该是"跳过成功的、重跑失败的",而不是"只要 id 存在就跳过"。判断条件要写成"存在且状态为成功"。我见过只判断 id 是否存在的实现,结果失败的样本永远修不好。
还有一种情况是结果文件被追加写了两次同一条任务(比如并发写入时没加锁)。这会导致后续统计出现重复计分。防护办法是在汇总阶段按 id 去重,保留最后一条。这个小动作能避免很多莫名其妙的分数偏差。
4.4 成本和耗时的预估,跑之前先算账
我把成本估算放在这里,是因为它直接影响你能跑多大规模的任务集。计算方法很直接:
总消耗 ≈ 总请求数 × (平均输入 token × 输入单价 + 平均输出 token × 输出单价)总请求数等于任务数乘以采样次数,再加上重试产生的额外请求。平均 token 数可以在小批量试跑时统计出来,不用猜。
耗时估算用的是 4.1 节那个公式的反向形式:总时长 ≈ N × T / 并发度。这个数通常比实际偏乐观,因为长尾请求和重试会拉长时间。我的经验是实际耗时大约是理论值的 1.3 到 1.6 倍。
这两笔账算完,你就能判断"这个任务集跑五遍做对比"是不是可承受。如果不可承受,就该考虑缩小任务集或者减少采样次数,而不是硬跑然后心疼。
5. 评分器:把"感觉还行"变成能对比的数字
5.1 规则、模型、人工,三层各管一段
评分方式是整个评测里最容易被低估的部分。很多人觉得分数就是"对了几条除以总数",这个直觉在客观题上成立,在开放生成任务上完全不够用。
我通常用三层配合:
| 层级 | 适用场景 | 优点 | 局限 |
|---|---|---|---|
| 规则评分 | 格式校验、关键词命中、逐字或规范化匹配 | 快、确定、零成本 | 无法判断语义等价 |
| 模型评分 | 开放问答、摘要、写作类 | 能处理语义等价 | 有偏好、成本高、需校准 |
| 人工抽检 | 校准前两层、抽查边界样本 | 最可信 | 慢、贵、不可大规模 |
三层的配合方式是:规则评分做全量打底,模型评分做语义补充,人工抽检做抽样校准。关键是人工抽检不能省。我见过完全依赖模型评分的评测,两版模型分数差了 5 个百分点,人工一看发现是评分模型对某种句式有偏好,两版输出的实际质量几乎没差别。
5.2 自定义评分器的接口约定和常见写错的地方
自定义评分器的接口通常很朴素——接收预测、参考答案、以及一些上下文,返回一个数值。写的时候有几个地方特别容易错。
第一个是返回值类型。有的框架要求返回 0 到 1 的浮点数,有的接受布尔值,有的接受带解释的字典。类型不对的表现往往是"分数全是 0"或者"汇总时报类型错误",而不是一个明确的提示。写完之后先用几条手写的样本单独调一次,看返回值是什么,这是最省时间的验证方式。
第二个是空值和异常处理。预测为空、预测为None、预测是超长文本、参考答案缺失,这四种情况必须显式处理并给出明确分数,而不是让它抛异常。抛异常在有并发批跑时非常致命——一条任务崩了可能连带整个批次中止。
第三个是归一化的边界。前面说的中文标点半角化、去空格是常规操作,但要注意别过度归一化。我曾经写过一个把所有数字都替换掉的归一化规则,本意是避免数字格式差异,结果把答案里唯一有区分度的信息也抹掉了,所有含数字的题全部判对。
5.3 评分器校准:用已知答案先验一遍
评分器写完不能直接上,得先校准。校准的方法很土但有效:手工构造一小批样本,每条你都知道"应该打几分",跑一遍评分器,看结果对不对。
这批样本要覆盖几种情况:完全正确的、语义正确但表述不同的、格式正确但内容错误的、完全跑偏的、空输出的。前两种用来验证评分器不会误杀,后三种用来验证它不会误放。
校准过程中最常见的发现是评分器"过松"——因为规则写得太宽,错误答案也能拿分。这种情况在关键词命中式评分里特别普遍:答案里出现了期望的关键词就给分,但模型可能是在否定这个关键词。比如期望答案是"应该开启",模型回答"不应该开启",关键词命中了,分数给了,语义完全反了。
校准完还要定期回头复查。任务集扩充、模型版本变化、评分提示调整,都可能让原本校准好的评分器失准。我一般会在每次大版本对比之前,用同一批校准样本再跑一次,确认评分器本身没漂移。
6. 报告解读与版本回归对比
6.1 报告里最容易误读的几个字段
Harness 生成的报告通常包含总分、分项分、样本级明细、失败列表。总分最醒目,也最容易误导人。
第一个误读是只看总分不看分布。两个配置总分都是 0.75,但一个是"每条都差不多七成对",另一个是"一半全对一半全错",这两种情况的工程含义完全不同。前者说明整体能力尚可,后者说明任务集里混着两类难度差异极大的题目,可能该拆开评。所以要养成看分布的习惯,至少看一下分位数或者直方图。
第二个误读是把失败率当模型问题。报告里的失败通常包含三类:模型答错、请求失败、解析失败。后两类是工程问题,不是能力问题。如果不做区分就笼统看"通过率",你会把工程 bug 算到模型头上。
第三个误读是忽略耗时字段。耗时直接影响可用性。有些配置质量分略高但平均延迟翻倍,在生产场景里未必划算。我习惯在报告里同时记录平均耗时和分位数耗时,P95 往往比平均值更有参考价值。
6.2 回归对比的正确姿势:一次只动一个变量
版本对比的价值在于归因。而要实现归因,前提是一次只动一个变量。这句话说起来简单,做起来极难,因为人总想"顺手把温度也调一下"。
我的做法是给每次实验一个配置快照,只允许在一个快照和另一个快照之间有一个字段不同。如果确实要同时改两个,就拆成三次实验:A 基线、B 只改提示、C 只改参数。这样从 A 到 B 的差异归因于提示,从 A 到 C 的差异归因于参数。虽然多跑一次,但结论可信。
对比时还要注意任务集必须完全一致。我见过一次对比结论完全相反的案例:两版模型分别在不同版本的任务集上跑,新版任务集恰好删掉了几个旧版模型的强项题目,导致新版分数虚高。后来把任务集固定回同一版本重跑,差异消失了。
对比报告的呈现方式,我倾向做一张差异表,而不是两个总分并排:
| 维度 | 基线版本 | 实验版本 | 差值 | 样本量 |
|---|---|---|---|---|
| 总体 | 0.712 | 0.734 | +0.022 | 300 |
| 短文本压缩 | 0.780 | 0.775 | -0.005 | 120 |
| 长文本摘要 | 0.650 | 0.700 | +0.050 | 180 |
这张表的价值在于它把"总分涨了"拆成了"一类任务涨、另一类略降"。总分上涨可能掩盖了局部退化,而局部退化在有明确业务场景时才是最该关注的。
6.3 从失败样本里挖真正的问题
分数只是结果,失败样本才是信息。我每次跑完都会花二十分钟扫一遍失败清单,按失败模式归类。常见的模式有几类:
- 格式不符:该输出 JSON 却输出了带解释的文本,该简短却写了长篇。
- 指令遗漏:多任务指令里只完成了其中一项。
- 过度发挥:加了原文没有的推断或信息。
- 答非所问:理解了字面但没理解意图。
- 截断:输出长度触顶被切断。
归类之后你会发现,很多问题不是靠调模型能解决的,而是提示模板需要加约束。比如格式不符可以加一条明确的结构要求,指令遗漏可以把多任务拆成单任务。这种从失败样本反推模板改动的循环,才是评测真正的价值所在。
7. 三次跑不通的完整排查链路
7.1 全部任务返回空:从日志一路查到模板
那次的症状是:跑批正常结束,报告正常生成,但所有样本的预测都是空字符串,分数全零。
第一步看执行日志,发现有大量请求其实是成功的(状态码正常),说明不是网络问题。第二步看原始响应落盘文件,发现响应体里是一个礼貌的追问——大意是"您没有提供需要处理的文本"。这说明发出去的输入是空的或者无效的。
第三步看渲染后的请求内容,问题就很清楚了:渲染结果里赫然留着未经替换的占位符。根源在于模板里写的是{{text}},而任务文件的字段名是input。渲染器找不到text这个变量,就把原样字符串留在了提示里,模型看到的是一个它无法理解的模板片段。
修复本身很简单,改一处字段名。但这个坑的教训值得记:渲染之后必须做残留占位符检查。我在流程里加了一步,凡是渲染结果里还能匹配到占位符模式的,直接标记为错误并跳过,不让它发出去浪费配额。这一步几乎零成本,却挡住了一整类问题。
7.2 分数诡异偏高:评分器的字符串匹配陷阱
第二次是反向的问题:分数高得离谱,接近满分,但抽查输出发现质量平平。
从高到低排查。先看总体分布,发现几乎全是 1.0,没有中间值,这本身就反常——真实评测很少出现这么干净的分。然后抽了几条明细,发现预测和参考答案确实"很像",但仔细看,是因为评分器的归一化规则把句子里的数字和标点全删了,剩下的差异也被包含关系判断吃掉了。
具体来说,我写的评分器用了"期望答案是否被预测答案包含"的判断,逻辑是预测里包含了参考答案就算对。这个逻辑在短答案上还行,但在这批任务里,参考答案本身很短,模型只要在长篇输出里顺带提了一句,就算命中。极端情况下,模型把题目复述一遍都能拿分。
修复分两步:一是把包含判断改成更严格的双向匹配或者编辑距离阈值;二是加一条长度惩罚,预测长度超过参考答案太多时降分。改完之后分数从 0.96 掉到 0.68,这个数字才是可信的。
这件事之后我给自己定了个规矩:任何评分器上线前,必须用"明显错的答案"测一遍,看它会不会给分。如果答非所问也能拿高分,评分器就是废的。
7.3 跑到一半卡死:并发与连接池的双重积压
第三次是跑到大约六成的时候,速度断崖式下降,最后完全不动,进程还在但不产生任何新结果。
第一反应是限流,于是看了错误日志,确实有一批失败记录显示请求被拒。但奇怪的是,按照退避策略,重试应该会恢复,为什么一直不动?
把并发配置和实际请求数对了一下,发现问题出在评分器里的二次请求。前面提过,我在评分阶段又调了一次模型做判断,而这些请求完全绕开了执行层的并发控制。于是实际并发是配置值的两倍多,长时间运行之后限流越来越频繁,重试队列越堆越长,最终所有连接都被重试请求占住,新的正常请求反而排不上队。
解决办法是把评分器里的模型调用纳入统一的并发池,或者干脆改成批量提交、单独一轮跑,避免和主流程抢配额。同时把重试队列加了长度上限,超过上限就标记失败而不是无限等待,防止雪崩。
这次排查给我的最大启示是:并发控制必须是全局的,不能按模块各管一段。任何一个绕开统一调度的外部请求,都可能成为压垮整条流水线的那根稻草。
8. 可复现性:让三个月后的你还能重跑出同样的数
8.1 版本快照要记哪些东西
可复现性这事,最省事的做法不是"记住当时怎么跑的",而是"让配置自己记住"。我要求每次运行自动生成一个快照文件,至少记录以下几项:
| 项 | 为什么必须记 |
|---|---|
| 任务集文件的内容哈希 | 光记文件名没用,文件可能被改过 |
| 模板文件的内容哈希 | 同上,模板改动最容易被遗忘 |
| 评分器代码版本 | 评分逻辑一变,历史分数就不可比 |
| 采样参数全量 | 温度、采样次数、最大长度等 |
| 模型标识与相关参数 | 用于判断是否换了模型版本 |
| Harness 自身版本 | 工具行为变化也会影响结果 |
| 运行时间与耗时 | 用于区分"慢"和"卡死" |
记哈希比记文件名靠谱得多。我遇到过一次"同一个任务集跑出两套结果"的争论,最后发现任务集文件被人改了三行但文件名没变。有了哈希,这类争论三秒钟结束。
8.2 缓存要谨慎,脏结果比慢结果更可怕
为了省配额,很多人会开结果缓存——同样的输入和参数直接复用上次的输出。这在开发调试阶段非常有用,但在正式评测阶段是个陷阱。
陷阱在于缓存键的设计。如果缓存键只包含输入文本,那么你改了提示模板,缓存依然命中,跑出来的是旧模板的结果,而报告上写的是新模板。这种"脏结果"比慢结果危险得多,因为它会给出一个看起来合理但完全错误的结论。
我的做法是:缓存键必须包含所有影响输出的因素——输入、渲染后的完整提示、模型标识、采样参数。任何一项变了,键就变了,缓存自然失效。更保守一点的做法是分环境:调试环境开缓存,正式对比关闭缓存。多花的那点钱,换来的是结论可信。
还有一类隐性缓存是"断点续跑"用的结果文件。它的作用域应该限定在同一次实验内,跨实验复用就会出现上面说的脏结果问题。我一般把结果文件名带上实验标识,从物理上隔离开。
8.3 命名混乱是复现失败的头号原因
最后说一个特别不起眼但杀伤力极大的问题:文件和实验的命名。
exp1、exp2、test、test2、new、final、final_v2——这套命名我见过太多次,结果就是两周之后没人知道哪个是最终版。我的命名习惯是带上时间戳和一句话说明:
runs/2024-06-11T14-30_prompt-tighten-temp0/ configs/exp-2024-06-11-prompt-tighten.yaml时间戳保证唯一,后半段说明保证"看一眼就知道改了什么"。这比任何文档都有效,因为它是跟着文件走的,不会忘记更新。
顺带一提,runs目录要定期清理,但清理之前先把快照文件和报告归档。原始输出往往几十上百兆,留着占地方;而快照和报告是几 KB,留着能救命。我一般按月归档,把当月的快照和报告集中存一份,原始输出只保留最近两三个实验的。
我个人在实际操作中的体会是,Harness 这类工具真正的门槛不在"跑通",而在"跑出来的数敢不敢用"。跑通大概一个下午,敢用可能要经历两三次结论被推翻的过程。每次推翻之后回头看,原因几乎都不是工具用错了,而是某个变量没控住——模板悄悄改了、任务集悄悄换了、评分器悄悄松了。把这些变量显式化、哈希化、快照化,比研究任何高级参数都更值得先做。