Clipping 微信公众号

使用 Bun 和 claude -p 从零构建你自己的AI评估框架

by 扶苏 原文 ↗
Created: 2026-06-29

公众号名称:奇点先锋

作者名称:扶苏

发布时间:2026-06-29 08:00

你是不是也被 AI Agent 的测试搞得头大?每次跑出来的结果都不一样,传统的单元测试根本用不上。很多人第一反应是去找 SaaS 平台,但其实——你根本不需要任何框架。本文教你用 Bun + Claude CLI,从零搭建一个轻量级 Eval Harness(评测框架),一个文件、150 行代码,就能跑沙箱、打分、投票、卡 CI。


太长不看(TL;DR):

  1. 🔧 Eval 的本质就三步:跑 Agent → 给结果打分 → 循环统计通过率,不需要任何复杂框架。

  2. 🎯 打分要分层:能用字符串匹配就别用 LLM Judge,省钱、快速、不抽风。

  3. 🗳️ 多次运行 + 投票机制是应对 Agent 不确定性的关键,还能揪出”摇摇欲坠”的边界行为。


我们要造什么?

Eval(评测),说白了就是给非确定性软件写的测试

传统单元测试问的是:“2 + 2 返回 4 吗?“答案固定,一个 assert 搞定。但 AI Agent 不一样——你每次问它同一个问题,它给你的回答都不一样,根本没有固定值可以断言。

所以 Eval 换个思路:盯住一个可观测的行为。比如”当还没有计划时,它应该建议先写计划”,然后检查 Agent 是否做了这件事,同时容忍它每次用不同的措辞。

很多人一上来就去找托管平台、花哨的 Dashboard。但撕掉那些花里胡哨的包装,所有 Eval Harness 底层都是同一套三板斧

  1. 跑 Agent:给它一个 Prompt,在受控环境里运行,抓住它说过的每一句话、做过的每一个动作。

  2. 给结果打分:能用字符串和文件断言的地方就便宜地检查,不能用硬规则的地方就请第二个 LLM 来当裁判。

  3. 循环统计:遍历所有 Case, tally pass/fail,有失败就非零退出,让 CI 卡住。

Bun 给了我们一个自带 spawnSync 和文件系统的 TypeScript 运行时;claude CLI 给了我们一个可以被命令行驱动的 Agent,外加一个能当 Judge 的 LLM。齐活了。

你最终会得到一个 evals.ts 文件,大概 150 行,bun evals.ts 一把梭。


环境准备:Bun 和 claude CLI

两个前置依赖,都是一行命令的事:

```bash
# 1. Bun —— 我们评测框架的运行时
curl -fsSL https://bun.sh/install | bash

# 2. Claude Code CLI —— 被测试的 Agent,同时也是我们的裁判
npm install -g @anthropic-ai/claude-code

# 验证安装:应该打印出模型的回复
claude -p "用三个字打招呼" --output-format json
```

🔥 重点记住 --output-format json 这个参数——它让 CLI 输出一个机器可读的 JSON 信封,而不是给人看的文字流。这是我们整个框架的数据基础。

创建一个文件夹,放一个空的 evals.ts,我们开始往里填肉。


第一步:用代码驱动 Agent

先写一个函数:接收 Prompt,返回 Agent 的回复。

原理很简单——我们 shell 出去调 claude -p(“print”模式,非交互),然后解析它输出的 JSON 信封。这个信封里装着:最终文本 result、花费 total_cost_usd、以及错误标记 is_error

```typescript
// evals.ts
import { spawnSync } from "bun";

// 在 cwd 目录下用 prompt 跑 Agent,返回它的最终回复
function runAgent(prompt: string, cwd: string) {
const res = spawnSync({
cmd: [
"claude", "-p", prompt,
"--output-format", "json",                // 在 stdout 输出一个 JSON 信封
"--permission-mode", "bypassPermissions", // 运行过程中别弹权限确认
"--max-budget-usd", "0.50",               // 单次运行费用硬上限
],
cwd,
stdout: "pipe",
stderr: "pipe",
timeout: 180_000, // 3 分钟超时
});

const envelope = JSON.parse(res.stdout.toString());

return {
text: envelope.result ?? "",
ok: res.exitCode === 0 && envelope.is_error !== true,
cost: Number(envelope.total_cost_usd ?? 0),
};
}
```

为什么用 spawnSync 而不是异步?

Eval 本身就很慢(每次都是真实的模型调用),但我们想要框架简单、执行有序,不想搞一套复杂的异步管道。同步调用让整个 Harness 从上到下一目了然。先正确,再优化。等你跑通了,再考虑并行。


第二步:给 Agent 一个沙箱

让 Agent 在你真实的代码仓库里横冲直撞?想都别想。不仅危险,还会让每次运行的结果不可复现。

正确做法是:每个 Case 都启动一个一次性的 Git 仓库,里面只放这个行为测试需要的文件(Fixture)。跑完之后,想检查就检查,想删就删。

```typescript
import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";

// 创建一个一次性 Git 仓库,用 files 参数填充文件;返回仓库路径
function makeSandbox(files: Record) {
const dir = mkdtempSync(join(tmpdir(), "eval-")); // 在临时目录创建 eval-xxxxxx 文件夹

spawnSync({ cmd: ["git", "init", "-q"], cwd: dir }); // 安静地初始化 Git

for (const [path, content] of Object.entries(files)) {
const target = join(dir, path);

mkdirSync(dirname(target), { recursive: true }); // 递归创建父目录

writeFileSync(target, content); // 写入文件内容
}

return dir;
}
```

Fixture 要尽量小

如果你要测的是”Agent 是否注意到已经存在一份计划”,那你只需要一个文件——比如假的 docs/plans/checkout.md,而不是把你整个代码仓库克隆进去。小 Fixture = 行为隔离 + 运行飞速


第三步:用廉价的确定性检查来打分

重头戏来了——打分。

先从最便宜的工具开始:字符串和文件检查。它们免费、瞬间完成、永远不会抽风。只有当这些规则表达不了的时候,才请 LLM Judge 上场。

```typescript
import { existsSync } from "node:fs";

// 大小写不敏感地检查 haystack 是否包含 needle
const has = (haystack: string, needle: string) =>
haystack.toLowerCase().includes(needle.toLowerCase());
type Checks = {
required_substrings?: string[];   // 回复中必须出现的字符串
forbidden_substrings?: string[];  // 回复中不能出现的字符串
required_files?: string[];        // 运行后沙箱中必须存在的文件
};

// 返回每个检查的 [标签, 是否通过]
function checkAssertions(checks: Checks, reply: string, dir: string) {
const out: [string, boolean][] = [];

// 检查必须包含的子串
for (const s of checks.required_substrings ?? [])
out.push([`包含 "${s}"`, has(reply, s)]);

// 检查不能包含的子串
for (const s of checks.forbidden_substrings ?? [])
out.push([`排除 "${s}"`, !has(reply, s)]);

// 检查必须存在的文件
for (const f of checks.required_files ?? [])
out.push([`创建了 ${f}`, existsSync(join(dir, f))]);

return out;
}
```

把复合要求拆成多个检查

如果一个行为有两层含义,比如”记录仓库路径它的 origin URL”,那就写两个检查,而不是一个。否则 Agent 给出一个半对不半对的答案也能通过。一个要求,一个断言,铁律。


第四步:用 LLM Judge 评测”模糊行为”

有些行为是没办法用关键词匹配的。比如:

  • “它是否在问第一个问题之前先读了仓库?”

  • “它是否解释了技术选型的权衡?”

遇到这种情况,你需要把回复交给一个更便宜的小模型,让它来逐条打分。这是武器库里最强大但也最贵的那把枪,所以要省着用。

Prompt 里有两个关键设计:

  1. 让 Judge 先推理,后打分(放在 标签里)

  2. reason 字段放在 met 字段前面——这样它会先给出理由再下结论,而不是先拍脑袋再找理由

```typescript
// 用一个便宜的小模型判断 reply 是否满足每条期望
function judge(reply: string, expectations: string[]): boolean[] {
if (expectations.length === 0) return [];

const numbered = expectations.map((e, i) => `${i + 1}. ${e}`).join("\n");
const prompt = `你正在对 AI Agent 的回复进行评分,评判标准是一组期望条件。
先在 … 标签内进行推理。然后在闭合标签之后,仅输出严格的 JSON:
{"results":[{"reason":"...","met":true}]}
按顺序每条期望一个条目。
=== 待评分的回复 ===
${reply}
=== 回复结束 ===
期望条件:
${numbered}`;

const res = spawnSync({
cmd: [
"claude", "-p", prompt,
"--model", "claude-haiku-4-5",   // 小而便宜的模型足以胜任评分工作
"--output-format", "json",
"--permission-mode", "bypassPermissions",
"--max-budget-usd", "0.10",      // 评分任务费用上限
],
stdout: "pipe", stderr: "pipe", timeout: 180_000,
});
// 剥掉  推理块,然后提取 JSON 对象
const text = (JSON.parse(res.stdout.toString()).result ?? "")
.replace(/[\s\S]*?<\/thinking>/gi, "");
const json = JSON.parse(text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1));

return expectations.map((_, i) => json.results?.[i]?.met === true);
}
```

一定要检查 Judge 的”作业”

一个 Judge 评测的可信度,取决于 Judge 本身的质量。前几次跑的时候,务必打印 Judge 的原始输出并阅读它的推理过程。一个误读回复的 Judge 会把你的门禁彻底反转——该红的绿了,该绿的红了。读两个样本,成本极低,保险极大。


第五步:多跑几次,投票决定

Agent 跑一次——通过了可能是运气好,失败了可能是手气差。

解法很朴素:每个 Case 多跑几次,少数服从多数。顺便还能发现哪些 Case 是”抽风型”的——几次运行结果不一致,说明这个行为已经游走在回归的悬崖边上了。

```typescript
// 跑 fn N 次,返回通过次数和是否多数通过
function vote(trials: number, fn: () => boolean) {
let correct = 0;

for (let i = 0; i < trials; i++) if (fn()) correct++;

return {
correct,
passed: correct * 2 > trials,           // 严格多数通过
flaky: correct > 0 && correct < trials,  // 结果不一致 = 抽风
};
}
```

⚠️ 这里才是真正烧钱的地方。3 次运行 = 3 倍花费,所以这是发布前的检查,不是每次改代码都跑的全量测试。默认对核心行为用 3 次 trial;迭代调试时降到 1 次。


组装起来!

骨架来了。Case 就是纯数据——一个 Prompt、一些可选的 Fixture 文件、一些可选的廉价检查、一些可选的 Judge 期望。主循环遍历每个 Case,全方位打分,统计通过/失败,有任何失败就非零退出,让 CI 卡住。

```typescript
type EvalCase = {
id: string;          // 用例标识
prompt: string;      // 给 Agent 的提示词
files?: Record;       // 沙箱中预置的文件
checks?: Checks;                      // 确定性检查
expectations?: string[];              // 需要 LLM Judge 评判的期望
};

const cases: EvalCase[] = [
{
id: "recommends-planning-first",
prompt: "我想加一个团队计费功能,应该先做什么?",
checks: {
required_substrings: ["计划"],          // 回复中必须包含"计划"
forbidden_substrings: ["直接写代码"],    // 不能出现"直接写代码"
},
expectations: [
"在动手实现之前,先建议澄清需求或写一份计划",
"没有立即开始写代码",
],
},
];

let pass = 0, fail = 0, spent = 0;

for (const c of cases) {
console.log(`\n▶ ${c.id}`);

// 每次 trial 都在全新的沙箱中运行
const result = vote(3, () => {
const dir = makeSandbox(c.files ?? {});          // 创建新的沙箱
const run = runAgent(c.prompt, dir);             // 跑 Agent
spent += run.cost;                               // 累计费用

if (!run.ok) return false;

// 确定性检查
const checks = checkAssertions(c.checks ?? {}, run.text, dir);

// LLM Judge 评判
const judged = judge(run.text, c.expectations ?? [])
.map((met, i) => [`期望 ${i + 1}`, met] as [string, boolean]);
const all = [...checks, ...judged];

for (const [label, ok] of all) console.log(`   ${ok ? "✓" : "✗"} ${label}`);

return all.every(([, ok]) => ok);  // 全部通过才算这次 trial 成功
});

console.log(`  ${result.passed ? "通过" : "失败"} ${result.correct}/3${result.flaky ? "  (抽风)" : ""}`);
result.passed ? pass++ : fail++;
}

console.log(`\n${pass} 通过, ${fail} 失败 — 花费 $${spent.toFixed(4)}`);

process.exit(fail > 0 ? 1 : 0);  // 有失败就以非零状态码退出,卡住 CI
```

package.json 里挂个脚本,一条命令搞定:

```json
{
"scripts": {
"test:evals": "bun evals.ts"    // 注册为 npm script
}
}
```

然后跑一下:

```bash
bun run test:evals

# ▶ recommends-planning-first
#    ✓ 包含 "计划"
#    ✓ 排除 "直接写代码"
#    ✓ 期望 1
#    ✓ 期望 2
#    ✓ 包含 "计划"
#    ✓ 排除 "直接写代码"
#    ✓ 期望 1
#    ✓ 期望 2
#    ✗ 包含 "计划"
#    ✓ 排除 "直接写代码"
#    ✓ 期望 1
#    ✓ 期望 2
#   通过 2/3  (抽风)
#
# 1 通过, 0 失败 — 花费 $1.0247
```

👆 这是用这个文件跑真实 CLI 的真实输出,不是精修过的截图。注意看 2/3 (抽风)——第三次运行时 Agent 给出了一个很好的回答,但恰好没用”计划”这个字面量,于是 required_substrings: ["计划"] 挂了。但 Judge 的语义期望三次都过了。投票机制保住了通过,同时 (抽风) 标签暴露了隐藏的脆弱性:如果只跑一次,结果就是掷硬币;而字符串匹配比真正关心的行为要窄得多。

那一次的运行费用:3 次 trial × (Agent + Judge) = 1.02 美元。


📢 自证清白:先让它红一次

在信任一个 Case 之前,先看着它失败一次。指向一个 Agent 还不具备的行为,确认它确实红了,而且红的原因是对的。一个在行为已经存在之后才写的 Eval,可能什么都没断言到——但它从出生就是绿的,你永远发现不了这个问题。


测试你自己的 Claude Code Skill

到目前为止,被测试的对象是一个裸 Agent——给它 Prompt,看它回答。但大多数人真正想测的,是自己写的 Claude Code Skill。好消息是:我们已经搭好的框架完全够用。

一个 Skill 就是一个 SKILL.md 文件,包含 namedescription 两个 frontmatter 字段,加上正文里的指令。Claude 读了 description 后自己决定是否调用这个 Skill。所以你要测两件事:

  1. 触发对不对? 给定应该触发的 Prompt,Claude 选了这个 Skill 吗?给定无关的 Prompt,它放过了吗?

  2. 行为对不对? Skill 被调用后,它做了正文要求的事吗——写文件、遵循格式、推荐了正确的下一步?

🔥 关键技巧:Fixture 负责安装 Skill。Claude Code 从工作目录的 .claude/skills//SKILL.md 发现项目级 Skill,而我们的框架已经把 claude -pcwd 设成了一个全新的沙箱。所以只要把 Skill 文件种进 fixture.files,它在沙箱里就自动生效了。不用全局安装,不用打包插件,完全可复现。

来看一个迷你 Skill——用押韵回答问题,并输出一个标记 Token 让测试能确认它跑过:

```typescript
// 被测试的 Skill,作为单文件 Fixture
const RHYME_SKILL = `---
name: rhyme-reply
description: 当用户提问并希望答案押韵时,或提到"押韵"、"以诗句形式"时使用。
---
# Rhyme Reply
被调用时,用一段简短的押韵对句回答问题,并在回复开头输出标记 Token RHYME_SKILL_ACTIVE,以便测试能确认 Skill 已运行。
`;

const skillCases: EvalCase[] = [
{
id: "rhyme-skill-triggers",             // Skill 应该触发
prompt: "什么导致了降雨?请用押韵的方式回答。",
files: { ".claude/skills/rhyme-reply/SKILL.md": RHYME_SKILL }, // 在沙箱中安装 Skill
checks: { required_substrings: ["RHYME_SKILL_ACTIVE"] },       // 标记出现 = Skill 触发了
expectations: ["对问题的回答是押韵的"],                          // Judge 评判行为
},
{
id: "rhyme-skill-stays-quiet",          // 反面双胞胎:Skill 不应该触发
prompt: "什么导致了降雨?用一句话平实地解释就好。",
files: { ".claude/skills/rhyme-reply/SKILL.md": RHYME_SKILL },
checks: { forbidden_substrings: ["RHYME_SKILL_ACTIVE"] },      // 标记不能出现 = Skill 没触发
},
];
```

第二个 Case 是第一个的双胞胎(Twin):装了同样的 Skill,但 Prompt 不应该唤醒它。没有这个双胞胎的话,一个对所有东西都触发的 Skill 也能通过第一个 Case——这和下文路由双胞胎防范的”过度阻塞”盲区如出一辙。


生产环境里怎么升级?

上面的 Harness 是诚实的核心。生产版本只是加了些润色,没有什么花活。

作者在开源项目 AFK(一个 Claude Code 插件)里跑着同一套骨架。它加了三个有价值的东西:

1. Case 是数据,不是代码

不再用 TypeScript 数组,而是每个 Suite 一个 JSON 文件(specs//evals.json),Runner 加载它。Case 的结构和上面一样,但变成了声明式的——非程序员也能加测试覆盖,Runner 本身永远不用改:

```json
{
"id": "grill-plan-records-reference-repo",
"prompt": "之前我们把 https://github.com/acme/awesome-streamer 克隆到了 reference/awesome-streamer 来复用它的 SSE 模式。最后请为 /chat SSE 端点写一份 docs/plans/streaming.md,遵循那个仓库的做法。",
"fixture": {
"files": {
"reference/awesome-streamer/README.md": "来源:https://github.com/acme/awesome-streamer\n"
}
},
"expectations": [
"在计划中记录了参考仓库被克隆用于复用模式",
"实现指向真实的克隆源码,而非凭记忆"
],
"assertions": {
"required_files": ["docs/plans/streaming.md"],
"required_file_substrings": {
"docs/plans/streaming.md": [
"reference/awesome-streamer",
"github.com/acme/awesome-streamer"
]
}
}
}
```

注意看:两层要求(记录克隆 + 指向真实源码)被拆成了两个独立的断言,半对半错的答案蒙混不过去。

2. 专门的路由 Case 类型

大多数 Agent 行为本质上是”它选了哪条路”——用字符串检查就能打分,不需要 Judge。AFK 把这些标记为 kind: "routing",用 expect / forbid 列表来评判:

```json
{
"id": "help-after-plan",
"prompt": "接下来怎么办?假设 docs/plans/checkout.md 已存在,且还没有实现 diff。",
"expected_output": "推荐 afk:implement 作为下一步。",
"kind": "routing",
"fixture": {
"files": {
"docs/plans/checkout.md": "# Checkout 计划\n\n## 任务\n1. 实现 checkout。\n"
}
},
"routing": {
"expect": ["afk:implement"],             // 必须推荐这个命令
"forbid": ["Next step: [Q]", "run afk:qa now"]  // 不能跳到 QA
}
}
```

每个安全门禁还配了一个 overblock_guard: true 的”应该放行”双胞胎——防止一个过度谨慎、什么都拦截的 Agent 蒙混过关:双胞胎失败被计为”过度阻塞”,而不是”漏判”。

3. 更丰富的 Transcript + 产物存档

生产版本用 --output-format stream-json --verbose 跑 Agent,从事件流中重建完整 Transcript,Judge 能看到 Agent 调用的每一个工具,而不仅仅是最终回复。当”它是否先读了仓库”就是你要测的行为时,这个能力至关重要。而且每次运行都会把沙箱、Transcript、Judge 的原始输出复制到一个带时间戳的文件夹里——失败不再是猜测,而是可以直接阅读的证据


这个框架还能测什么?

Skill 只是你能测试的众多对象之一。这个 Harness 不关心 Agent 是什么——它只管在沙箱里跑一个 Prompt,然后给返回的结果打分。任何能改变这个输出的东西,都是候选对象

以下是几个实战中已经证明有价值的场景:

🔥 你的 CLAUDE.md 和团队规范

你在里面写了”永远用 pnpm 不用 npm”、“新组件放在 src/features/ 下”——然后祈祷 Agent 会听。现在你可以把规范文件种进 Fixture,给一个任务 Prompt,断言规范被遵守了:命令里写的是 pnpm,文件落在了正确的目录。你的项目指令现在也有测试了。

🔥 你正在打磨的 Prompt

当你在改 System Prompt 的措辞时,两个版本”看起来都行”,你凭感觉选。现在把它们变成两个 Case,跑几轮 trial,让通过率说话。投票机制把”我觉得这个措辞更好”变成了一个数字

🔥 模型升级

新模型发布了,你想换,但盲换意味着在生产环境里发现回归。把现有 Suite 指向新模型(一个 --model 参数),对比新旧通过率,你就能看到哪些行为变好了、哪些悄悄挂了——在任何用户发现之前

🔥 MCP Server 或自定义工具

给 Agent 一个应该触发你的工具的 Prompt,用 stream-json 跑让 Transcript 显示工具调用,然后断言它调了正确的工具、参数合理,对错误的工具则置之不理。和 Skill 路由一样的双胞胎套路:一个该触发的 Case + 一个不该触发的 Case。

🔥 拒绝和护栏

如果 Agent 应该拒绝某些请求(危险操作、超出范围、缺少权限),写一个”必须拒绝”的 Case,再加上一个”不能过度拒绝”的双胞胎。这就是 overblock_guard 模式——这是唯一能让护栏不会慢慢勒死合法工作的方式

🔥 Subagent、Hook 和 Slash 命令

Claude Code 中任何从工作目录发现的东西——Subagent 定义、Hook、自定义命令——安装方式和 Skill 完全一样:扔进 Fixture,它在沙箱里就活了。被测试系统永远只是一个你种进去的文件。


写在最后

所有这些场景背后的模式是一样的:

盯住一个可观测行为 → 把触发条件种进一次性沙箱 → 能便宜检查就便宜检查、不能就请 Judge → 多跑几次直到你能信任结果。

一旦你有了这个循环,问题就不再是”我能测这个吗?“,而是”我关心的行为到底是什么?“——这才是值得问的好问题

唯一的真实缺点是成本。每次 trial 都是一次真实的模型调用,Judge 又在上面叠加一次调用。一个有规模的 Suite 跑一次是几美元,不像单元测试那样免费。所以这是发布前的门禁,不是每次敲键盘都跑的 Check。

但是,几个精心挑选的 Eval 带来的安全感远超它们的成本——尤其是当你在构建自己的 Skill、插件或库,并且要发布给别人使用的时候。那恰恰是你无法靠肉眼检查每个改动的场景:你感受不到别人仓库里的回归,“我试的时候好使”不是发布标准。几个能在行为偏移时立刻变红的 Eval,是你能买到的最便宜的保险——防止你把一个坏版本推给所有信任你的人。


内容效果不满意?点此反馈

输入关键词开始搜索