评测跑完,模型分数很难看,群里讨论一轮,结论是“这个小模型不行,换更大的”。这个流程在 SLM(Small Language Model,小语言模型)选型和落地方案里几乎每天都在发生。但如果你把那些被判失败的样本翻出来,逐条看完整输出,会发现一个很容易被忽略的事实:相当一部分失败样本里,正确答案就藏在模型输出中,只是评测脚本没有认出来。
标题这句话——“Half Our SLM Benchmark 'Failures' Contained the Right Answer”——说的正是这个问题。意思是,在小模型基准评测里,接近一半被记为失败的样本,其实在输出中已经包含了正确答案。这不是模型完全不会,而是“会了,但没被检测到”。这个问题如果被忽略,后果非常直接:SLM 的能力被系统性低估,好模型被误淘汰,最后被迫换更大的模型、上更多显卡、付出更高的推理成本。
遇到这种情况,先别急着换模型。先拆评测管线,看是生成问题、解析问题还是打分问题。这篇文章会按“根因分析 -> 评测管线改造 -> 采样与胜率口径 -> 本地资源控制 -> Agent 场景差异 -> 排查清单 -> 最佳实践”的顺序展开。适合三类读者:正在做小模型本地选型的工程师、自己搭评测脚本的算法同学、以及负责把 SLM 接入业务系统的后端开发。
1. 核心结论速览
先给结论,再讲细节。这个主题不带 GUI、不需要视频演示,本质是一套评测方法论的纠偏,所以下面这张表直接列出现象、原因和可落地的动作。
| 项目 | 说明 |
|---|---|
| 核心现象 | SLM benchmark 中部分失败样本的原始输出里存在正确答案,但被评测脚本判错 |
| 直接影响 | SLM 能力被低估,选型结果失真,可能误换更大模型抬高成本 |
| 主要原因 | 输出截断、格式跟随失败、答案抽取过严、字符串匹配过于死板、单次采样波动 |
| 解决思路 | 在打分前增加“宽松抽取 + 归一化匹配”,并引入多采样统计胜率 |
| 适用场景 | 小模型本地评测、模型选型对比、榜单胜率核对、Agent 任务轨迹审计 |
| 不适用场景 | 完全开放式问答、需要人工主观评分的创意生成、没有参考答案的评测任务 |
| 验证方式 | 抽取一定比例失败样本人工复核,再用宽松口径重跑一次对比分数 |
核心一句话:在说“模型不会”之前,先确认是“模型真的不会”,还是“评测脚本没长眼睛”。
2. 为什么会出现“答对了却判错”
SLM 的评测链路通常分四段:模型生成、答案抽取、归一化、匹配打分。四个环节里任何一处出问题,结果都会变成“失败”。从实际评测日志里看,问题主要集中在下面四类。
2.1 生成阶段:输出被截断,答案差一口气
SLM 的推理链通常比大模型更长,因为它能力弱,喜欢先把思路铺开再收尾。评测时如果max_tokens给得不够,模型可能把过程写完了,最后一步“所以答案是 42”还没来得及输出就被截断。这种情况在 GSM8K 这类需要多步推理的数据集上特别常见。日志里能看到模型已经把 42 算出来了,就差最后的回车。
解决办法不是无脑调大max_tokens,而是先统计失败样本的平均输出长度,再决定上限。同时可以在 prompt 里要求模型“先给出最终答案,再解释过程”,把关键信息前置。
2.2 格式阶段:小模型对格式指令的跟随能力弱于大模型
评测数据集经常要求模型输出指定格式,比如 JSON、选择题字母、数学数值。大模型通常能守住格式,小模型则更容易“跑偏”:要求输出 JSON,它给你一个 markdown 代码块;要求输出 A/B/C/D,它在前面加了“我认为答案是”;要求输出纯数字,它写了“约等于 3.14”。这些输出在内容上没毛病,但在严格格式解析器面前就是失败样本。
这个问题的本质是格式跟随能力本身也是模型能力的一部分,但要看评测目的是什么。如果目标是选一个能稳定输出 JSON 的模型,那么格式错误确实该扣分;如果目标是验证模型的数学能力或知识水平,格式错误就不应该把内容的正确性一起抹掉。评测之前,先明确你评的是“能力”还是“工程输出稳定性”。
2.3 解析阶段:正则写得太死,只认标准答案的“标准长相”
很多自建评测脚本在解析答案时用非常严格的正则,比如只匹配\d+,或者只匹配某个固定 key。一旦模型输出的答案带着单位、带着“约等于”、带着小数点后多一位,解析器就提不出来,直接判错。
更隐蔽的情况是大小写和全角半角问题。模型输出中文冒号“答案是:”,脚本匹配英文冒号answer:;模型输出全角数字“3”,脚本只认半角数字“3”。这些问题在人工看日志时一眼就能发现,但脚本看不到。
2.4 采样阶段:temperature 过高,单次采样全凭运气
评测时如果 temperature 设为 0.7 甚至更高,模型每次生成的格式和措辞都会变。同一个 prompt 采样 5 次,可能 3 次对、2 次格式乱。如果评测脚本只采一次,那么“对不对”就取决于那一次抽签的运气,而不是模型真实能力。越小的模型,采样波动越大。
这里要说清一个概念:模型输出格式乱,不等于模型不会这道题。需要把“采样稳定性”和“能力上限”分开统计。
3. 从“判错”到“判对”:一套可落地的评测管线
解决这个问题的核心,是在打分之前加两层处理:宽松抽取和归一化匹配。下面给出一个可以直接改到评测脚本里的最小实现。
3.1 宽松答案抽取
import re def extract_answer(text: str) -> str: """从模型输出中宽松提取答案,兼容代码块、标签和自然语言。""" if not text: return "" # 去掉 markdown 代码块围栏 text = re.sub(r"```(?:json|python|text)?", "", text) # 优先提取显式答案标记 patterns = [ r"(?:答案是|答案[::]|Answer\s*[::]|answer\s*[::])\s*([^\n]+)", r"\[/?final[^\]]*\]\s*([^\n]+)", ] for pattern in patterns: m = re.search(pattern, text, re.IGNORECASE) if m: return m.group(1).strip() # 没有显式标记时,返回最后一段非空内容 lines = [line.strip() for line in text.splitlines() if line.strip()] return lines[-1] if lines else text.strip()这一步解决的是“答案藏在长输出里”的问题。先找显式标记,找不到就取最后一段,覆盖大多数数据集“答案放在末尾”的习惯。
3.2 归一化匹配
import unicodedata import re def normalize_answer(text: str) -> str: """归一化答案:全角转半角、小写化、去标点和空格、去前导零。""" text = unicodedata.normalize("NFKC", text).lower() text = re.sub(r"[^a-z0-9\u4e00-\u9fff]", "", text) text = re.sub(r"^0+(\d)", r"\1", text) return text def is_correct(prediction: str, reference: str) -> bool: return normalize_answer(extract_answer(prediction)) == normalize_answer(reference)这版匹配已经能覆盖大多数常见坑:中英文冒号、全角半角、大小写、单位、前后缀、多余标点。对于选择题、判断题、数学数值类数据集,建议直接用这种宽松匹配作为第一遍打分。
3.3 评测脚本整合示例
import json def evaluate(predictions, references): correct = 0 details = [] for pred, ref in zip(predictions, references): ok = is_correct(pred, ref) correct += int(ok) details.append({"prediction": pred, "reference": ref, "correct": ok}) return correct / len(predictions), details跑完第一遍宽松匹配后,把仍然失败的样本单独导出,做一次人工抽样复核。建议每类失败抽 10% 到 20%,看剩余失败里有多少是模型真错,有多少是评测口径还没覆盖。这一步是整篇文章最推荐的工程动作。
4. 采样次数与“胜率”口径:别让单次抽签决定模型生死
热词里反复出现 “benchmark 胜率”,也就是模型对比榜单里的胜负百分比。这个指标看着直观,其实是最容易被评测口径影响的数据之一。原因很简单:胜率是“两个模型各跑一轮,谁分高谁赢”的统计值,只要单轮分数有波动,胜率就会被放大或缩小。
4.1 Pass@1、Pass@k 与多数投票
模型选型时,至少要区分三个口径:
| 口径 | 含义 | 适合场景 |
|---|---|---|
| Pass@1 | 单次采样即正确 | 模拟生产环境单次调用 |
| Pass@k | 采样 k 次,任意一次正确即算对 | 衡量模型能力上限 |
| Maj@k | 采样 k 次,多数结果一致才作为最终答案 | 通过投票提升稳定性 |
如果评测脚本只统计 Pass@1,而模型推理时又用了偏高的 temperature,那么能力上限会被严重低估。建议对每个样本采样 3 到 5 次,同时记录 Pass@1 和 Pass@k,两个指标分开汇报。
4.2 Pass@k 的无偏估计代码
from math import comb def pass_at_k(n: int, c: int, k: int) -> float: """从 n 次采样中正确 c 次,计算 pass@k 的无偏估计。""" if n == 0 or c == 0: return 0.0 if n - c < k: return 1.0 return 1.0 - comb(n - c, k) / comb(n, k)举个例子说明使用方式:某个模型对一道题采样 5 次,其中 3 次输出能抽取出正确答案。那么n=5, c=3,分别计算pass_at_k(5, 3, 1)和pass_at_k(5, 3, 3),就能看到单次采样和多次采样的能力差异。这个差异在小模型上通常比大模型更明显。
4.3 胜率对比要标注评测参数
任何形式的“模型 A 胜模型 B”结论,至少要附带以下信息:采样次数、temperature、max_tokens、打分方式是严格匹配还是宽松匹配、失败样本人工复核比例。缺少这些参数的胜率榜单,参考价值有限。
5. 榜单与指标解读:先看评测口径,再看分数差距
公开榜单上的模型对比,比如最近讨论较多的 Nemotron 3.5 系列 benchmark 对比,同样存在这个问题。榜单上模型 A 比模型 B 高两个点,很多人直接解读成“A 更强”,但实际上两个点可能完全来自打分口径差异:B 的输出格式更自由,被严格匹配规则扣掉的分比 A 多。
给团队内部选型时,建议按下面的顺序解读榜单:
- 先确认榜单使用的评测集是否和你的业务场景一致。数学、代码、知识问答、Agent 工具调用,考察的能力完全不同。
- 再确认打分方式。公开榜单大多用严格匹配或 LLM 判分,严格匹配对 SLM 不友好。
- 看失败样本的原始输出。如果榜单没提供样本日志,就自己在本地用小样本复测一遍。
- 最后看分数差距是否大于噪声区间。分数差距小于 1% 到 2% 时,基本可以视为统计噪声,不要据此换模型。
另外要提醒一点:不要用测试集反向调 prompt。反复用同一批测试样本调评测 prompt,最终得到的分数是测试集上的“过拟合分数”,不是模型能力。正确的做法是固定评测 prompt 后不再改动,只调整答案抽取和匹配逻辑。
6. 本地 SLM 评测的资源占用与成本控制
虽然这个主题不是图形界面项目,但实际评测 SLM 仍然涉及硬件资源和部署方式。这里给出一套通用的本地评测环境检查清单。
6.1 硬件与运行方式
SLM 的定义比较宽泛,常见的 1B 到 8B 参数模型在消费级显卡上就能推理。更稳妥的判断是:评测前先看模型参数量、量化方式和上下文长度,再决定用 GPU 还是 CPU。如果模型是 4-bit 量化的小模型,CPU 也能跑,但批量评测时速度会明显变慢;GPU 推理适合批量任务,显存占用需要按实际模型版本确认。
资源观察建议:
# 评测过程中实时观察显存占用 nvidia-smi -l 5量化对 SLM 评测结果的影响不可忽视。同样的模型,FP16、INT8、INT4 三档量化跑出来的 benchmark 分数会有差异,尤其对推理和数学类任务更敏感。选型对比时,所有候选模型尽量保持同一量化精度,否则对比不公平。
6.2 批量评测接口化
本地评测建议把模型部署成兼容 OpenAI 格式的本地服务,然后用脚本批量请求。这样做的好处是:评测脚本和模型服务解耦,模型只负责生成,评测脚本只负责打分,可以分别替换。下面是通用调用模板:
curl -s http://127.0.0.1:8000/v1/chat/completions \ -H "Content-Type: application/json" \ -d '{ "model": "local-slm", "messages": [{"role": "user", "content": "1+1=?"}], "temperature": 0.2, "max_tokens": 256 }'批量评测的 Python 侧可以这样组织:
import requests URL = "http://127.0.0.1:8000/v1/chat/completions" def generate(prompt: str, temperature: float = 0.2, max_tokens: int = 512) -> str: payload = { "model": "local-slm", "messages": [{"role": "user", "content": prompt}], "temperature": temperature, "max_tokens": max_tokens, } resp = requests.post(URL, json=payload, timeout=120) resp.raise_for_status() return resp.json()["choices"][0]["message"]["content"] if __name__ == "__main__": prompts = ["问题1", "问题2", "问题3"] results = [generate(p) for p in prompts] for p, r in zip(prompts, results): print(p, "->", r)max_tokens建议先按数据集最长答案的 2 倍设置,避免截断;temperature在能力评测时建议设为 0 到 0.2,在稳定性评测时可以单独调高对比。
6.3 输出目录与日志管理
评测产物建议分目录保存:
eval_results/ ├── raw_outputs/ # 每次生成的原始输出,JSON Lines 格式 ├── parsed/ # 抽取后的答案 ├── scores/ # 打分结果 └── failed_samples/ # 失败样本,用于人工复核原始输出是排查“答对但判错”的最关键资产。没有原始输出,后面所有分析都无从谈起。
7. Agent 基准评测:失败模式更隐蔽
热词里出现的 Harvey AI agent benchmark 这类 Agent 评测,比传统知识问答更复杂,也更需要关注“答案在输出里但被判失败”的问题。
Agent 评测通常考察多步任务,例如调用工具、读取返回结果、修正计划、最终输出。失败模式比单轮问答多出三类:
第一类是工具调用格式不匹配。模型算出了正确结果,但工具调用的参数名、JSON 结构和评测器预期不一致,整个步骤被标记失败。这属于格式跟随问题,和模型真实规划能力无关。
第二类是“早期正确、终局失败”。Agent 在对话中途已经提到正确方案,但最后一步因为上下文过长或输出截断,没有把答案写进最终回复。如果评测只检查最终回复,就会漏掉中途的正确信息。
第三类是“过程正确、判据不对”。评测脚本按任务是否完成的二值结果打分,但 Agent 走了一条合理但不同的路径,导致最终状态和脚本预期不同。这种情况需要人工审计轨迹,不能只看结尾。
处理 Agent 评测失败样本时,建议把审计对象从“最终输出”扩展到“完整轨迹”,至少看工具调用序列、关键中间结论、最终回复三段。抽样比例可以比单轮问答更高,特别是新场景刚接入时,先做一轮全量人工复核再建立自动规则。
8. 常见问题与排查方法
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 输出有正确答案但脚本判错 | 答案抽取正则过严 | 看原始输出和解析结果 | 改用宽松抽取加归一化匹配 |
| 输出在答案处被截断 | max_tokens 设置偏小 | 统计失败样本输出长度分布 | 调大 max_tokens,或要求答案前置 |
| 要求 JSON 却输出 markdown 代码块 | 模型格式跟随能力弱 | 查看输出是否带 ``` 围栏 | 解析前去掉代码块围栏 |
| 同一道题多次采样结果不一致 | temperature 偏高 | 对比多次输出的格式差异 | 能力评测降 temperature,或统计 maj@k |
| 分数忽高忽低,不稳定 | 测试集太小或单次采样 | 重复跑三轮看波动 | 增加样本量,固定 seed,多次采样 |
| 榜单胜率和自测结果对不上 | 打分口径不同 | 对比双方评测参数 | 统一采样次数和匹配规则后重测 |
| 模型在 Agent 任务中过程正确但判失败 | 只检查最终回复 | 审计完整轨迹 | 增加轨迹级打分和人工复核 |
| 评测启动后显存不足 | 模型规模或上下文过长 | 观察 nvidia-smi 占用 | 降低上下文长度、改用量化版本、减小 batch size |
| API 批量请求超时 | 并发过高或单请求生成过长 | 看服务端日志 | 降低并发、调大超时时间、增加重试 |
其中“评测启动后显存不足”和“API 批量请求超时”在实际评测里很常见。批量评测不要一次性把所有样本打满并发,先跑 10 条样本确认显存和耗时,再按资源余量逐步加大并发,能省掉不少排查时间。
9. 最佳实践与评测规范
结合前面所有内容,这里整理一套可以直接落到团队内部使用的 SLM 评测规范。
第一,评测分两遍打分。第一遍用严格匹配,第二遍用宽松匹配加归一化,两份分数都保留。严格分用于工程选型,宽松分用于能力判断。两个分数的差如果很大,说明模型能力不差,问题在格式和输出稳定性。
第二,失败样本强制抽样人工复核。每次评测至少抽取 10% 到 20% 的失败样本,由人判断到底是模型不会,还是评测没认。复核结果写回评测配置,持续迭代抽取规则。
第三,采样参数固定并记录。所有对比评测统一 temperature、max_tokens、采样次数、seed,并在测试报告里写明。没有参数的胜率没有意义。
第四,保留最小可运行评测集。每次评测都用一个小规模的固定子集快速冒烟,确认部署、调用、解析、打分全链路正常,再跑完整数据集。这样可以避免辛苦跑几小时,最后发现是脚本问题。
第五,数据集和模型输出分开管理。原始输出、解析结果、打分结果按版本存档,prompt 和数据集的改动要记录,方便追溯分数变化的原因。
第六,合规与授权提醒。使用公开 benchmark 数据集时,确认数据集的许可协议;涉及私有业务数据时,注意脱敏和权限控制;评测中如果使用到第三方模型 API,也要确认数据边界和隐私政策。评测数据尽量不要直接上传到不受控的外部服务。
第七,不要为刷分而改测试集。评测的目的是选型和迭代,不是拿到一个漂亮分数。一旦发现为了提升测试集分数而反复调整评测 prompt,赶紧停下来,这已经偏离了评测的意义。
10. 总结与下一步
这个主题最值得记住的一点是:SLM benchmark 分数低,不等于模型能力差。在做任何选型结论之前,先花半小时检查失败样本的原始输出。如果发现大量失败样本里确实有正确答案,那么问题不在模型,在评测管线。给脚本加上“宽松抽取 + 归一化匹配”,再把采样次数从 1 次提升到 3 到 5 次,分数往往会明显回升,而且比直接换模型便宜得多。
建议拿到这篇文章后先做三件事:第一,把现有评测脚本的输出日志打开,统计失败样本里有多少能抽取到答案;第二,按第 3 节的代码接入宽松匹配,重新跑一遍;第三,对仍然失败的样本抽样人工复核。做完这三步,大概率能发现自己手里的 SLM 比 benchmark 分数显示的更能打。后续想继续深入,可以再研究 Agent 场景的轨迹级评测、多模型投票推理,以及不同量化精度对评测分数的影响。