
如何给 AI 工作流出题、录题、改卷:一套自动评测系统的设计实录
说实话,给 AI Agent 做评测这件事,比我想象的要复杂得多。
我们有一组 AI 工作流——由多个 Skill、Rule、Hook 和 Subagent 组成。改了某个 Skill 之后怎么知道它真的变好了?以前靠人肉感觉:跑一遍看看,"嗯,这次输出好像比上次好一点"。这太不靠谱了。
于是我花了几周搭了一套自动评测系统。它不追求通用,只聚焦一件事:可重复地告诉你工作流改完之后是变好还是变坏。这篇文章就是它的设计实录。
问题定义:我们要解决什么
① 人肉评测的三种死法
一个 AI 工作流改了之后好不好用,你只有三种办法知道:凭感觉、靠回忆、或者让同事跑一遍给你反馈。这三种都有硬伤。
凭感觉的问题在于你改代码时已经种下了"这里应该会变好"的锚定,你会不自觉地寻找正面证据。靠回忆的问题更直接:你没有对比基线,你记忆中"上次的表现"是一个模糊的印象,而不是数据。让同事跑需要他放下手头的事,搭环境、看日志、给意见——成本太高,不可持续。
更大的问题是维度。一次评测有太多维度要同时关注:流程遵循度、执行准确率、工具调用失败率、是否产生了不该产生的产物、是否跳过了不该跳过的 gate。人在同一个时间窗口只能关注其中一两个。
② 我们要测什么:不是速度,是合规
很多人以为 AI 评测就是比谁跑得快。但这不是我们关心的。我们关心的是:
- 这个 Skill 改完之后,考生还会不会走完所有规定的步骤?
- 某个 Rule 边界条件改了之后,原来能通过的场景现在会不会翻车?
- 换了一个更强的模型,在"遵循流程"这个维度上有没有真正提升?
这个问题域决定了我们不做通用 Benchmark。我们做的是回归测试——确认改动之后的输出和改动之前一样正确(或更好)。
③ 三个核心原则
在动手写代码之前,我定了三条铁律:
第一条:题目和供给侧解耦。 一道题只写"测什么",不写"用什么模型测"、"在什么 IDE 下跑"。题目是长期资产,供给侧是运行时变量。同一道题,你可以今天用 GPT-5 跑,明天用 Claude 跑;可以在 Cursor 里跑,也可以在 CodeBuddy 里跑。
第二条:每次 run 的工作流快照不可变。 你要回答的核心问题之一是"改了工作流之后变好还是变坏"。如果不记录这次 run 用的是哪个版本的工作流,你就没法做这个对比。所以每个 run 必须保存一份当时的工作流副本,一旦创建不可修改。
第三条:所有评估结论必须可追溯到原始证据。 你不能说"我觉得 pass 了"——你必须能从 transcript.jsonl 里找到支持你结论的具体行。这个约束看起来很重,但它防止了评估滑入"感觉流"。
第一层:出题 —— eval_set 的设计
整个系统的核心资产只有 16 个字:一道题四个文件,长期迭代,版本化管理。
① 一道题的四个零件
s 每道评估题就是一个目录,里面固定四个文件:
| 文件 | 回答什么 | 给谁看 |
|---|---|---|
meta.yaml |
这道题是谁,测什么能力 | 系统 |
task.md |
题面是什么 | 考官(转述给考生) |
rubric.md |
怎么判分 | 裁判 |
env.yaml |
需要什么前提 | 系统(precondition 检查) |
这四个文件各自独立,但有一个共同的约束:需求侧和供给侧严格隔离。meta.yaml 里不会有 model: gpt-5 这样的字段,env.yaml 里不会写具体的工作流版本。供给侧信息全部由运行时参数注入。
meta.yaml 的设计很克制:
id: P-hotfix-create-server-yaml
version: v1
target_workflow: pace
category: execution
purpose: 考察考生在服务目录下正确创建 server.yaml 的能力
max_turns: 10
每个字段都有明确的语义边界。version 不是自动递增的——它由人来维护。只有当 task.md、rubric.md、env.yaml 中的任一文件发生"会影响评分结论"的改动时,才需要递增 version。纯 typo 修正不改 version。这样做是因为题目的版本变化本身就是你需要分析的一个维度:同一道题 v1 到 v2,通过率从 0.4 变成 0.8——你得知道这是因为题目变简单了,还是工作流变好了。
② task.md:考题与答题卡的分离艺术
task.md 是整个系统里最容易被写坏的零件。原因很简单:你既想告诉考官怎么演,又怕考官不小心"泄题"给考生。
我的解法是把 task.md 拆成三个段落:
- 面向考生的题面(Turn 1 原话)——用
>引用,考官原样转发,一字不改。 - 场景背景——只给考官看的上下文,告诉考官"你现在是什么身份,有什么目标"。
- 考官后续对话的原则性指引——告诉考官在 Turn 2+ 该怎么推进,什么时候揭晓关键信息,什么时候该结束。
这种"考题与答题卡分离"的设计有一个好处:命题人可以在不改变题面的情况下,微调考官的互动策略。比如你发现考生经常在 Turn 3 卡住,你可以在指引里加一句"如果考生在第 3 轮不主动确认参数,你就再问一遍"。
③ rubric.md 与 env.yaml:判分标准与环境前提
rubric.md 是写给裁判看的。它分三个小节:
- 硬性通过项——全部满足才能 pass。比如"考生必须成功执行
go build"。 - 质量项——影响分数但不阻塞 pass。比如"考生是否主动确认了关键参数"。
- 典型失分项——常见失败模式的描述,帮你快速定位"这题最容易在哪个点上翻车"。
一个经验教训:硬性通过项不要超过 5 条,超过之后裁判会 halluncinate。另外,硬性通过项必须是可被工具调用记录验证的——不要写"考生理解正确"这种主观表述,裁判只能看 transcript.jsonl 里的事件。
而 env.yaml 定义了这道题需要什么前置环境:
preconditions:
- description: "服务目录下不存在 server.yaml"
check: "test ! -f ${SANDBOX_DIR}/server/service_test/server.yaml"
severity: hard
注意:这是一个 checker,不是一个 builder。env.yaml 只说"我需要什么",不说"怎么搭建"。环境搭建由 setup_commands 和 post_run_cleanup 承担。这层分离让同一道题可以被不同团队在完全不同的项目上跑。
第二层:录题 —— 双 Agent 对话引擎
有了题库,下一步是让人来答题。但"人"不是真人,而是一个 AI Agent——考生。谁来监考?谁来收卷?这里我设计了一个三角色架构。
① 三角色的职责边界
整个执行阶段只有三个角色:
| 角色 | 真实身份 | 看到什么 | 产出什么 |
|---|---|---|---|
| 考官 | 用户模拟器(一个 LLM) | 只读 task.md,和考生对话 |
对话内容 + <<END>> 信号 |
| 考生 | 被测 AI Agent | sandbox 里的项目文件,通过 IDE 工具自由操作 | 工具调用、命令执行、写盘操作 |
| 裁判 | 唯一判分者(一个 LLM) | rubric.md + 完整对话精简版 |
score.yaml + review.md |
这三个角色的职责边界是我花了最长时间思考的部分。
考官的定位最容易出错。直觉上你会觉得"监考官嘛,当然也该判分"。但这个直觉是错的。
② 考官为什么不能判分:假阴性陷阱
考官只能看到对话文本。考生在 sandbox 里的文件写入、命令执行、代码修改——这些都不在对话文本里。如果让考官判分,会发生什么?
一个真实的案例:考生在 Turn 2 静默写入了 server.yaml,然后在 Turn 4 的回复里简单提了一句"文件已创建"。考官看到的是四轮对话文本,没发现"写入成功"这个关键事件,于是判 fail。但实际上考生做得完全正确。
这就是假阴性——考官的视野局限造成了错误的否定。它把"没在话里完整复述动作"当成了"没做"。
解法很简单但意味深远:把判分职责完全交给一个独立的裁判。裁判读的不是考试题对话——它读的是 transcript.jsonl,这份文件里包含了考生的全部工具调用事件、命令执行结果、写盘确认。裁判能看到考生实际做了什么,而不只是考生说了什么。
这个"单判架构"是我认为整个系统最重要的一处设计决策。
③ transcript.jsonl:从流式事件到结构化记录
transcript.jsonl 由评测系统自己写出来——它不是外部采集的数据,是内置组件 transcriptWriter 逐轮 append 的。每一行是一条 JSON,包含 turn、role、content 和 usage 信息。
role 字段是一个分层命名空间:
examiner/candidate— 每轮的最终文本回复candidate.thinking— 思考过程,截断到 500 字符保留candidate.tool_call.started/completed— 工具调用起止事件,含参数和结果摘要candidate.assistant_partial— 中间文本片段
每个 tool_call 事件的 content 都是自包含摘要,不内嵌原始载荷。早期版本会把一条 shell 命令的 100KB+ 完整输出塞进去,现在只保留 shell: go build → exit=0 这样的摘要,让裁判在合理上下文窗口内看到全部关键事件。
transcript 的写入还涉及一个协议适配层。考生和考官通过 cursor-agent 或 codebuddy CLI 调用,它们输出的是不同的 stream-json 事件格式。streamEventSink 组件自动归一化两种协议——type=thinking、type=tool_call、type=assistant 内嵌的不同子结构——到同一套 role 命名空间。这让裁判 prompt 不需要区分数据来源。
第三层:改卷 —— 单判架构的裁判设计
有了对话记录,下一步是请裁判出场。这是整个系统最需要 LLM 的部分——同时也是最需要工程化约束的部分。
① 精简 transcript:裁判到底看到了什么
原始 transcript.jsonl 里有很多信息裁判不需要,也有很多信息裁判需要但不能原样给。
我的策略是先精简,再按需截断。compactTranscriptForJudge 函数做了三件事:
- 跳过
assistant_partial——这部分内容在candidatefinal 文本里已经完整出现,重复给裁判只会浪费上下文。 - 截断
thinking到 500 字符——思考过程对裁判理解考生的推理路径有用,但不值得全量保留。 - 输出人类可读格式——
[Turn 1][candidate] 好的,我先来检查一下项目的当前结构...
更重要的是截断策略。如果是简单的 text[:maxLen] 截断,裁判大概率看不到结尾的事件——但硬性通过项(go build 成功、qqep hotfix 执行、<<END>> 信号)都在 transcript 末尾。尾部截断等于让裁判瞎判。
所以我做了一个"首尾保留"策略:如果精简后仍超过 160KB,保留开头 60% 和结尾 40%,中间插入省略标记。这样裁判至少能看到开场如何、收尾如何。
② 裁判 Prompt 的核心约束
裁判的 system prompt 可能是整个系统里最经过迭代的部分。它需要同时满足:
- 严格 YAML 输出——不允许裁判自由发挥,必须按固定 schema 返回
- 引号规则——
summary/reason/evidence的值必须用英文单引号包裹,避免中英文混合导致的 YAML 解析崩溃 - 三段式评分——
compliance(流程遵循度)、execution_quality(执行质量)、overall(综合分),各 0~5 分 - 改进建议必须维度标签化——每条建议带前缀
[workflow]、[eval]或[capability]
最关键的约束是这句:
你拥有完整 transcript 视角(含考生的 tool call / 写盘 / 命令执行),不要仅凭考生对话里是否复述某些动作来判 fail;以 transcript 里的实际事件为准。
这一句话解决了前文提到的"假阴性陷阱"。
另外,裁判有可能因为 YAML 里出现未转义的冒号而解析失败。我不会让整个评测因此中断——parseJudgeFallback 会用正则逐行抢救回 result、compliance 等关键字段。只要能抓到 result 或任意一项评分,就认为解析成功。
③ score.yaml:从分数到改进建议
裁判的输出最终落成两个文件:
score.yaml:
result: pass
compliance: 4
execution_quality: 4
overall: 4
summary: '考生按 pace-hotfix 完整跑完新建→Rainbow→写盘→build→qqep 五个节点'
review.md 则是人类可读的完整结论,包含四个段落:
- 是否按要求执行
- 关键观察(来自裁判的 evidence)
- 改进建议(按
[workflow]/[eval]/[capability]分类) - 运行异常检测(对话超时、轮数上限、边际 pass 等基础设施健康检查)
"改进建议"和"运行异常检测"分开写,因为它们的消费者不同:改进建议给工作流开发者看,运行异常检测给评测系统维护者看。
第四层:看板 —— 从分数到洞察
单次评测的价值有限。真正有价值的是多轮对比。
① 成绩单不是终点,是迭代的起点
每跑完一批题目,系统自动更新 summaries/ 目录:
latest.md——最近一批的汇总表格latest-stats.yaml——按题分组的统计:通过率、平均分数、平均 token 消耗score-history.yaml——所有历史 run 的得分追加记录,包含workflow_rev(工作流 git commit)batch-insights.md——本批所有 run 的改进建议聚合,按[workflow]/[eval]/[capability]分类
其中 score-history.yaml 是最核心的数据资产。它让"工作流改了之后变好还是变坏"这个问题有了可量化的答案:
- eval_set_id: P-hotfix-create-server-yaml
result: pass
overall: 4
workflow_rev: abc123def456...
run_id: run-P-hotfix-create-server-yaml-cursor@20260424T150405
你可以按 workflow_rev 分组聚合,看不同版本的工作流通过率曲线。
② 零依赖 HTML 看板:让数据流动起来
光有 YAML 不行——你需要一眼能看出趋势。我写了一个 dashboard 子命令,它把 summaries/ 下所有的 YAML 数据渲染成一份单文件、完全离线的 HTML 看板。
设计取舍很明确:
- 只读——看板不写回
summaries/,权威数据始终由系统维护 - 零外部依赖——HTML 内嵌 JSON 数据 + 手写 SVG 折线图,不依赖任何 CDN
- 四个视图区块:顶部 KPI 卡片、轮次趋势折线图、单题得分时间线、工作流版本对比表
workflow_rev 对比是使用频率最高的功能。它会列出每个工作流版本的通过率和平均分数,新旧版本的差异一目了然。
③ batch-insights:从 bug 到改进方向
batch-insights.md 是一份很特殊的产物。它把本批所有裁判给的改进建议按三个维度聚合:
[workflow]:工作流规则盲区、gate 缺失、skill 执行力不足。这些是接下来要改的工作流 bug。[eval]:评分标准遗漏、题面描述歧义、环境前提不合理。这些是接下来要优化的题目。[capability]:考生暴露的通用能力短板。这些是模型选型或 prompt 优化的参考。
它本质上是一个"从测试结果到产品改进"的交接物。你不需要去翻每个 review.md 找改什么——它已经帮你归类好了。
并发与隔离:当你要同时跑 10 道题
如果你一次只跑一道题,隔离不是问题。但如果你想同时跑 10 道题对比——而且每道题的考生都会在 sandbox 里创建文件、修改代码——隔离就是必须要解决的事了。
① git worktree:不冲突、不等待、不串行
考生的 cwd 是一个本地 git 仓库。如果 10 个考生同时在这个目录下操作,他们会在同一份文件上竞争写入——结果完全不可控。
我的方案是利用 git worktree add --detach 为每次 run 创建一个独立的沙箱副本:
sandbox 目录/
├── worktree-eval/
│ └── sandbox/
│ ├── run-P001-cursor@20260424T150405/ ← Run 1 的隔离沙箱
│ └── run-P002-cursor@20260424T150410/ ← Run 2 的隔离沙箱
每个 worktree 是 detached HEAD 模式,不占用分支名——这意味着任意数量的 run 可以共享同一份 git 对象库,磁盘开销仅为工作树文件的增量。
更重要的是 .cursor 目录的隔离。每个 run 都有自己的工作流快照,通过 symlink 挂载到各自 worktree 的 .cursor/ 目录下。这保证了一个 run 读到的是自己的工作流版本,不会和其他 run 冲突。
run 结束后自动清理 worktree——先走 git worktree remove --force,失败则手动 rm -rf 加 git worktree prune。
② 工作流快照绑定:reproducibility 的前提
每次 run 启动时,系统会把 ai_ability 目录下的四个白名单子目录逐文件复制到 run 目录:
agents/ commands/ rules/ skills/
不做增量,不做全量——只复制这四个目录。代价是每次 run 多几 MB 的磁盘占用,换来的好处是:
- run 目录自包含——单独打包即可复现
- 不依赖
ai_ability仓库的历史状态——哪怕你后来改了 skill、删了分支,旧的 run 仍然完整 env_checklist.yaml里记录了workflow_rev(git commit hash),保证可追溯
这个全量复制的决策看起来笨重,但这个体量下(四个目录总共几 MB)带来的可复现性远远超过了代价。
系统的完整闭环
把上述所有部分串在一起,完整的一次评测流程如下。
① 七步自动流水线
- 加载题库——从
eval_sets/读取匹配的题目 - 创建 run 目录——写
config.yaml、env_checklist.yaml,复制工作流快照,建立 symlink - 环境准备——执行
setup_commands,跑 precondition 检查(hard 失败则直接 abort) - 执行对话——考官和考生两个 agent 进程交替调用,
transcriptWriter逐轮写transcript.jsonl - 裁判打分——精简 transcript,调用裁判 LLM,产出
score.yaml和review.md - 汇总更新——更新
summaries/,聚合改进建议到batch-insights.md - 看板刷新——渲染 HTML 看板,对比历史趋势
② 一条命令跑完全程
整个过程对用户来说只有一行:
go run scripts/*.go \
-project ../sandbox \
-only P-hotfix-create-server-yaml \
-candidate-model claude-sonnet-4.6 \
-runs-per-case 5
所有步骤自动串联,结束后输出摘要并更新看板。
写在最后
这套系统从第一行代码到现在,核心设计一直没有偏离最初的三个原则:题目和供给侧解耦、工作流快照不可变、评估结论可追溯。这三条很简单,但每一条在实现过程中都逼出了不少工程决策——从单判架构的选择,到 transcript 的压缩策略,到 worktree 并发隔离。
如果你正在给 AI Agent 做评测,我个人的经验是:先想清楚你要验证的是什么变量。如果你每次跑测试都同时换了模型、改了 skill、换了环境——你会得到一堆数据但得不到任何结论。锁死所有变量,只让你想验证的那一个变化,然后跑 5 次取平均。这比任何系统设计都更重要。
另外,不要低估"题目质量"的意义。一个好题目的价值远超一段代码。好的 task.md 能让假阳性降低一个数量级,好的 rubric.md 能让裁判的通过率判断和你人工判断的吻合度超过 90%。题目是长期资产,值得持续打磨。