使用 Bun 和 claude -p 从零构建你自己的AI评估框架
公众号名称:奇点先锋
作者名称:扶苏
发布时间:2026-06-29 08:00
你是不是也被 AI Agent 的测试搞得头大?每次跑出来的结果都不一样,传统的单元测试根本用不上。很多人第一反应是去找 SaaS 平台,但其实——你根本不需要任何框架。本文教你用 Bun + Claude CLI,从零搭建一个轻量级 Eval Harness(评测框架),一个文件、150 行代码,就能跑沙箱、打分、投票、卡 CI。
太长不看(TL;DR):
-
🔧 Eval 的本质就三步:跑 Agent → 给结果打分 → 循环统计通过率,不需要任何复杂框架。
-
🎯 打分要分层:能用字符串匹配就别用 LLM Judge,省钱、快速、不抽风。
-
🗳️ 多次运行 + 投票机制是应对 Agent 不确定性的关键,还能揪出”摇摇欲坠”的边界行为。
我们要造什么?
Eval(评测),说白了就是给非确定性软件写的测试。
传统单元测试问的是:“2 + 2 返回 4 吗?“答案固定,一个 assert 搞定。但 AI Agent 不一样——你每次问它同一个问题,它给你的回答都不一样,根本没有固定值可以断言。
所以 Eval 换个思路:盯住一个可观测的行为。比如”当还没有计划时,它应该建议先写计划”,然后检查 Agent 是否做了这件事,同时容忍它每次用不同的措辞。
很多人一上来就去找托管平台、花哨的 Dashboard。但撕掉那些花里胡哨的包装,所有 Eval Harness 底层都是同一套三板斧:
-
跑 Agent:给它一个 Prompt,在受控环境里运行,抓住它说过的每一句话、做过的每一个动作。
-
给结果打分:能用字符串和文件断言的地方就便宜地检查,不能用硬规则的地方就请第二个 LLM 来当裁判。
-
循环统计:遍历所有 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 里有两个关键设计:
-
让 Judge 先推理,后打分(放在 标签里)
-
把
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 文件,包含 name、description 两个 frontmatter 字段,加上正文里的指令。Claude 读了 description 后自己决定是否调用这个 Skill。所以你要测两件事:
-
触发对不对? 给定应该触发的 Prompt,Claude 选了这个 Skill 吗?给定无关的 Prompt,它放过了吗?
-
行为对不对? Skill 被调用后,它做了正文要求的事吗——写文件、遵循格式、推荐了正确的下一步?
🔥 关键技巧:Fixture 负责安装 Skill。Claude Code 从工作目录的 .claude/skills//SKILL.md 发现项目级 Skill,而我们的框架已经把 claude -p 的 cwd 设成了一个全新的沙箱。所以只要把 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,是你能买到的最便宜的保险——防止你把一个坏版本推给所有信任你的人。
内容效果不满意?点此反馈