我给 PaiCLI 出了 28 道题,跑完两个模型,正式分数是 null
大家好,我是二哥呀。
从 8 月底开始,我给 PaiCLI 做了一套评测集,28 道题,满分 100。DeepSeek V4 Flash 和 GLM-5.3-Flash 两个模型接进去,前前后后跑了好几轮,真实调用了两百多次模型。
到今天为止,这套评测的正式分数是 null。
数字其实跑出来过。8 月 31 日那一轮,DeepSeek 拿了 100,GLM 拿了 87;9 月 4 日复测,两家都是 89。这些数字都在报告里,可我一个都不敢拿出来当成绩发。
原因说起来有点丢人。9 月 4 日那两个 89 分,扣掉的 11 分是同一道题,出错的是我写的验题脚本。它把只读快照复制到临时目录后,删旧文件时报了一个 Permission denied,旧的判分程序把这个退出码直接记成了模型做错。修好脚本、用原始产物重新验一遍,一次模型都没调,16 份产物全部通过。
如果当时直接拿汇总文件里的数字写文章,“两家模型都是 89 分”就会被当成成绩引用出去。
这件事之后,我对评测的看法变了。给 Agent 打分,出题反而容易,难在分清楚一次失败到底是谁的错。模型答错了,是有效的 0 分;评测框架自己出了问题,这一次就不该有分数。两者搅在一起,分数越精确越误导人。
所以这套评测里有不少看起来很麻烦的规矩。候选程序关在断网的容器里跑,密钥只留在宿主机;拿不出模型身份和 Token 用量证据的运行,直接判评测无效;跑挂了不许换道题重来,也不许跑三次挑最好的一次。
这篇我按一条问题链来讲这套评测,每条规矩背后都对应一次真实踩过的坑,报告和源码都在 PaiCLI 仓库的 benchmarks/paicli-native-agentbench-v0.1/ 目录下,大家可以对照着看。
如果大家也在做自己的 Agent,或者面试时被问到“你怎么评估 Agent 的效果”,希望这篇能帮上忙。分数不一定要漂亮,但得知道手里的分数能信几分。
01、为什么不直接刷公开榜单
第一个问题,SWE-bench、Terminal-Bench 这些现成的榜单为什么不够用。
PaiCLI 的设计文档开头就把测量对象写死了,测的是模型在 PaiCLI 这套执行框架里完成真实 Agent 任务的能力。一次任务从提示词组装、工具策略、上下文压缩、人工审批一路走到 MCP 路由,任何一环坏掉,分数都会掉。公开榜单测的是“某个模型加某个脚手架”,回答不了“我自己的产品哪一环坏了”。
外部榜单也被当成方法来源,设计文档要求只能写“受某某启发”,不能把自己的分数和公开榜单放在一起比。套件配置里有一个字段 externalBenchmarkClaim=false,就是在代码层面把这件事钉死。

28 道题按能力分成七个通道,权重直接照搬设计文档的表。
| 通道 | 题数 | 权重 |
|---|---|---|
| A 代码定位与理解 | 4 | 8 |
| B 软件工程修复 | 6 | 24 |
| C 终端任务 | 3 | 12 |
| D MCP / Web / Browser 编排 | 5 | 20 |
| E 长上下文与多 Agent 编排 | 4 | 16 |
| F 安全与控制 | 4 | 16 |
| G 原创推理控制 | 2 | 4 |
分通道的好处是失败能定位。总分掉了 8 分,看一眼是 D 通道掉的,就知道该去查 MCP 调用,而不是在整个产品里乱翻。
还有一个现实问题是污染。公开题面迟早会进训练数据,模型在上面拿高分,可能只是见过类似的题。PaiCLI 的做法是题面框架公开,出题用一个私有的 256 位随机种子。同一个种子每次生成逐字节一致的题目,换一个种子,28 道题的变体全部改变;标准答案和验题程序也放在 Git 之外,只有本机所有者能读。
先想清楚要测的是模型,还是模型加上你的产品,这决定了你能不能直接用公开榜单。
02、89 分是怎么来的
自建评测能测到产品本身,这是好处。代价是评测框架也是自己写的,它也会有 bug。
9 月 4 日那次复测,两组原始汇总都自动记录了 7/8、89 分。唯一的失败项都是同一道题,权重 11 分,考的是生成器会不会读取不该读的敏感文件。
报告里写了根因。
验证脚本把只读快照 cp -R 到临时目录后,该副本仍不可写,rm 删除旧输出时报 Permission denied,
尚未执行生成器的 allowlist 断言。
这不是已证实的 Candidate 失败,而是验证器准备阶段错误。
旧 Runner 把退出码 1 统一计作做题失败,在此处误分类。这段出自 DEV-PILOT-REPORT-2026-09-04.md。翻译成大白话,模型交上来的答案验题程序根本还没开始检查,脚本自己先在准备阶段摔了一跤,旧的判分程序只看退出码,把这一跤算到了模型头上。

修复只加了一行 chmod -R u+rwX,让临时副本可写,题目、权重、预期输出和敏感文件断言一个字没动。然后用同一个镜像,对两家的 16 份原始产物重新跑验题,16 份全部通过,期间没有再调用一次模型。
这里有两个细节我觉得比修复本身更重要。原始的汇总 JSON 没有改写,只是在报告里把那次 89 分标成“验证器故障,失效”;修复还配了一个回归测试,证明修复前能稳定复现这个错误,修复后正确答案通过、偷读敏感文件的错误答案仍然失败。
评测结果出来之后,先确认失败发生在哪一步,再决定这个分数算不算数。
03、评测无效和有效 0 分怎么分
89 分事故引出了这套评测里最重要的一条规矩,失败要分两种。
模型真的做错了,是有效失败,记 0 分或低分,这个分数要保留,不能因为难看就删掉。评测框架自己出了问题,或者拿不出足够的证据证明这次运行是真实的,就是评测无效,这一次没有分数。
判断证据够不够的是证据门禁。
String invalidEvidence = invalidEvaluationFailureType(requestedModel, limits, metrics);
if (invalidEvidence != null) {
return invalidEvidence;
}
if (metrics.calls() <= 0) {
return NO_PROVIDER_CALL;
}
if (metrics.successfulCalls() != metrics.calls()) {
return PROVIDER_CALL_FAILED;
}
return null;代码在 BenchmarkProviderEvidenceGate.java。先检查五类证据,服务端返回的模型身份、Token 用量、输出策略、上下文上限、请求指纹,任何一类证明不了就是评测无效。证据齐全之后,才看模型有没有被调用、调用有没有成功。

有一条看起来反直觉。候选程序一次模型都没调就退出了,算有效失败,不算评测无效。源码注释写得很清楚。
/**
* A zero-call Candidate is deliberately distinct from missing provider evidence: it is a
* scored Candidate failure, while the UNPROVEN types invalidate the evaluation episode.
*/道理其实很简单。一次都没调模型就交卷,是 PaiCLI 这个产品的问题,这个 0 分应该记给产品。证据缺失则是测量工具的问题,这时候给任何分数都是在猜。
D3 那道题是另一个例子。首轮测试程序往候选程序的工作区里多复制了一个生成器元数据文件,触发了“工作区被改动”的门禁。报告把这两次首轮运行标成评测无效,修正输入后两家对称重跑,都是严格通过。报告里专门写了一句:“此次表面 0→100 不能宣传为产品能力提升;首轮是无效输入,第二轮才是有效观测。”
规矩的另一半是不许挑结果。正式批次的执行代码里有这样一段注释。
// Valid low scores continue. Invalid attempts stop spending and require a new
// all-model run; there is deliberately no best-of, subset, or automatic retry loop.
if (!(outcome instanceof FormalEpisodeOutcome.Scored)) break;有效的低分继续跑,遇到评测无效就停下来,整批重跑所有模型。不许跑三次挑最好的一次,不许只挑一部分题,也不许自动重试。
给评测写规则时,先把“什么情况这一次不算”写清楚,比设计打分公式更要紧。
04、为什么要关进断网容器
上一节的证据门禁有一个前提,证据本身不能被候选程序篡改。
正式评测用的是宿主中转模式。候选程序跑在 Docker 容器里,容器的启动参数是这样的。
arguments.add("--network");
arguments.add("none");
arguments.add("--read-only");
arguments.add("--cap-drop");
arguments.add("ALL");
arguments.add("--security-opt");
arguments.add("no-new-privileges:true");
arguments.add("--pids-limit");
arguments.add("128");
arguments.add("--memory");
arguments.add("1g");断网、只读根文件系统、去掉所有 Linux 能力、限制进程数和内存。候选程序要调模型,只能把请求交给宿主机,宿主机持有真实的密钥去调服务商,再把结果传回来。密钥从头到尾不进容器,模型身份、Token 用量和请求指纹也都由宿主机记录。

验题程序也单独放进一个容器,资源更紧,内存 256MB、半个 CPU。候选程序退出之后,隐藏的验题文件才被放进去,验题前后还会核对一遍文件摘要。
这一节我得如实交代两件事。8 月 31 日那次 100 分和 87 分的横评,候选程序跑在宿主机上,还没有进容器;到现在为止,候选程序用的镜像也还是开发阶段临时派生的,正式的冻结镜像还没做。
证据要由候选程序碰不到的一方来记录,否则门禁查得再严也没有意义。
05、测出来的到底是谁的问题
测量可信之后,失败才看得清楚。回头翻这几轮报告,评测抓到的问题有一半在 PaiCLI 身上。
最典型的是 DeepSeek 的 33 分。早期一个版本里,DeepSeek 只拿了 33 分,8 道题过了 3 道。报告追下去发现,另外 5 道需要本地工具的题,模型根本没有拿到工具,问题出在 PaiCLI 的任务入口没有把本地工具暴露出来。补上一个可信的任务入口之后,同一个模型 8/8,100 分。报告写明了这次提升靠的是修入口,不是调提示词。
GLM 的 87 分是另一种情况,它是这几轮里唯一一个真实的有效失败。失败的题要求输出一份事件统计报告,GLM 给出的 totalActive 是 4,按类型分别统计的数字加起来却是 5,前后对不上,验题程序判失败。这个分数就留着,不用旧版本的 100 分去替换它。

D2 那道多服务 MCP 题更有意思。GLM 两轮都严格通过;DeepSeek 两轮业务数据都对,仍然判失败。一个原因是它把有依赖关系的两个工具调用放进了同一批,后一个调用用的关联值还没从前一个的结果里拿到;另一个原因是最终回答带了解释文字和代码围栏,不是题目要求的纯 JSON。
第二轮之前我补强了提示词,改动只在两个提示词文件里,其余九千多个文件逐个比对完全一致。结果照样失败。报告里的原话是:“提示词存在不等于行为被强制执行,本次证据明确显示补强仍不足。”
评测还反过来抓到过 Planner 的缺陷。为 Plan 模式写的反例测试首跑 17 项失败了 14 项,原因是 Planner 会接受重复 id、未知依赖,还会把错误的依赖边悄悄删掉。这次修复就是第 2 期文章里讲的计划严格校验,报告特意注明这是产品输入校验修复,不是删除低分题或放宽评分。
评测分数低的时候,先查产品有没有把能力交给模型,再怪模型。
06、不用等大评测的小测试集
28 道题的正式评测很重,一次完整批次要跑 168 次。平时改代码,更常用的是几个小测试集,它们都在普通的单元测试里,不调用真实模型也能跑。
代码搜索有一个黄金集,只有 5 条。每条给定搜索模式,测的是“搜索定位、再按行号读取”这条确定性流程,强制关掉 ripgrep 走 Java 扫描,输出不能超过字符预算,必须命中“文件路径:行号”,必须给出建议读取的行段,读取后要包含预期文本。5 条很少,但它守住的是 Agent 理解代码库的第一步。

记忆自动提取有一组 18 条的合成样例,10 条正例、8 条负例。门槛是 F1 不低于 0.85、18 条里至少 15 条完全正确、负例误写入为 0。
这组数据我要多说一句。GLM-5.1 实跑,按第一版标注算 F1 是 0.870,17/18;第二版标注是看过 GLM 的输出之后修订的,复算变成 1.000。看过模型输出再改标注,分数会往模型那边偏,所以两个数都写在文档里。文档自己也注明:“本分数只代表这组小规模合成样例,不代表开放输入的准确率。”
LLM-as-a-Judge 用来评那些没法用程序判对错的开放题。打分分两步,模型只给每个维度打分,加权总分由 Java 代码来算。
double weighted = 0;
long totalWeight = 0;
for (Rubric rubric : rubrics) {
weighted += scoreByName.get(rubric.name()).score() * rubric.weight();
totalWeight += rubric.weight();
}
return weighted * 20.0 / totalWeight;模型漏了维度、重复打分或者打出界的分,程序直接报错,不会默默当成 0 分。
两个答案比较优劣时,模型当评委有位置偏见,容易偏向某个固定位置的答案。Zheng 等人 2023 年的 MT-Bench 论文(Judging LLM-as-a-Judge with MT-Bench and Chatbot Arena)专门测过这个现象。PaiCLI 的做法是交换 A、B 的位置各评一次。
RawJudgment first = judge(userInput, referenceAnswer, rubric,
baselineAnswer, candidateAnswer);
RawJudgment second = judge(userInput, referenceAnswer, rubric,
candidateAnswer, baselineAnswer);
boolean positionConsistent = firstLogicalWinner == secondLogicalWinner;
Winner finalWinner = positionConsistent ? firstLogicalWinner : Winner.TIE;两次结论一致才算数,不一致就判平局。这套 Judge 目前还没有和人工标注对比过一致率,所以还没有接进正式评测,正式批次里 Judge 固定标为不可用,依赖 Judge 的两道 A 通道题也暂时不计分。
小测试集跑得快、能天天跑,但它的分数只说明那几条样例,引用时要把局限一起带上。
07、离正式分数还差什么
回到开头的 null。截至 9 月 9 日,出题程序已经接入 25 道题,原始权重合计 88 分,还差 D5、E3、E4 三道。
已接入的 25 道里也不是都能计分。A3、A4 依赖还没校准的 Judge,正式请求会被拒绝;B5 因为并发门禁固定只能拿 20 分。候选程序的正式镜像没有冻结,正式批次的 168 次运行一次都还没开始,目前跑过的只是合成数据的循环测试和几道题的开发诊断。

设计文档还给公开发布定了门槛,L1 层级不低于 80 分,L2 不低于 65 分,总分不低于 70 分,Judge 和人工的一致率要达到 80%,交换位置后的一致率要达到 90%。
写这篇文章时,我还顺手查出了评测框架自己的两处问题。9 月 9 日接入 E2 之后,有两个测试还断言“24 道题、84 分”,一跑就失败,说明那次宣称的“定向回归全绿”没有覆盖到它们,测试我已经改过来了。另一处更实在,E2 的验题脚本输出的报告格式和其他 24 道题不一样,计分程序解析不了,这个我记进了仓库的已知问题,等下一阶段授权后再修。
评测框架写得这么严,自己照样会出错。所以这套评测给自己也定了规矩,已经公开的旧结果不改写,发现错误就另存勘误,在报告里写清楚哪次作废、为什么作废。
ending
一个 Agent 评测,题目、容器、门禁、Judge 都可以慢慢补,最先该定下来的是那条分界线,哪些失败记给产品,哪些失败说明这次测量本身不成立。
如果大家现在就想给自己的 Agent 做评测,我建议先做一件事。挑三五道自己产品最常见的任务,跑之前把“什么情况这一次不算”写下来,跑完先看失败发生在哪一步,再看分数。
PaiCLI 的正式分数还是 null。等 168 次运行跑完,再来和大家对一对账。
项目代码:github.com/itwanger/paicli
PaiCLI 如何写到简历上?
PaiCLI 项目(评测体系)| 2026.08 - 2026.09 | 独立开发者
项目描述:为 Java Agent CLI 设计自建评测集,按七个能力通道共 28 题 100 分,评估模型在产品执行框架中完成真实 Agent 任务的能力,区分评测无效与有效失败,保证分数可复现、可归因。
核心职责:
- 设计 28 题 100 分的分通道评测集,基于私有随机种子生成逐字节可复现的题目变体,标准答案与验题程序与仓库隔离,降低训练数据污染风险
- 实现宿主中转的隔离执行,候选程序运行在断网、只读、去除全部 Linux 能力的容器中,密钥只留宿主,由宿主记录模型身份、用量和请求指纹
- 设计证据门禁,五类证据任一缺失即判评测无效、零模型调用记为有效失败,正式批次禁止挑选最优结果和自动重试,遇到无效运行整批重跑
- 定位并修复验题脚本准备阶段的权限错误导致的误判,保留原始结果并用 16 份原始产物零调用重新验证,同时通过评测发现并修复 Planner 静默删除依赖边的缺陷
- 实现 LLM-as-a-Judge,维度分由模型给出、加权总分由程序计算,两两比较时交换位置复评,两次结论不一致判平局以消除位置偏见
