// eval-runner.mjs —— 一条评测任务一个 harness 循环的评测跑道
//
// 跑法:
// node eval-runner.mjs 用修好的(做了归一化的)验证器
// node eval-runner.mjs --strict-verify 换回没做归一化的旧验证器,看误杀
const STRICT = process.argv.includes("--strict-verify");
const MODEL = "claude-opus-5"; // 桩会忽略它;换真 client 时它和 max_tokens 是必填参数
// ============ 1. 被测系统:工具和它们背后的数据 ============
const ORDERS = {
"SO-1001": { customer: "启明科技", status: "已完成", month: "2026-08", amount: 780.0 },
"SO-1002": { customer: "启明科技", status: "已完成", month: "2026-08", amount: 500.0 },
"SO-1003": { customer: "启明科技", status: "待发货", month: "2026-08", amount: 320.0 },
"SO-1004": { customer: "远山物流", status: "待发货", month: "2026-08", amount: 96.5 },
};
const TOOLS = [
{
name: "search_orders",
description: "按客户名或订单状态查订单,返回订单号列表。customer 和 status 至少要给一个。",
input_schema: {
type: "object",
properties: { customer: { type: "string" }, status: { type: "string" } },
},
},
{
name: "get_order",
description: "按订单号查单笔订单的客户、状态、金额。",
input_schema: {
type: "object",
properties: { order_id: { type: "string" } },
required: ["order_id"],
},
},
];
const TOOL_IMPL = {
search_orders({ customer, status }) {
if (!customer && !status) {
throw new Error("参数无效:customer 和 status 至少要给一个");
}
const ids = Object.keys(ORDERS).filter(
(id) =>
(!customer || ORDERS[id].customer === customer) &&
(!status || ORDERS[id].status === status)
);
return { order_ids: ids };
},
get_order({ order_id }) {
const o = ORDERS[order_id];
if (!o) throw new Error(`订单 ${order_id} 不存在`);
return { order_id, ...o };
},
};
// ============ 2. 桩 client:固定响应队列 ============
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const text = (s) => ({ type: "text", text: s });
const toolUse = (id, name, input) => ({ type: "tool_use", id, name, input });
const useTools = (blocks, [i, o]) => ({
stop_reason: "tool_use",
content: blocks,
usage: { input_tokens: i, output_tokens: o },
latency_ms: 40,
});
const finish = (s, [i, o]) => ({
stop_reason: "end_turn",
content: [text(s)],
usage: { input_tokens: i, output_tokens: o },
latency_ms: 40,
});
function stubClient(script, label) {
if (!script) throw new Error(`[桩] 没有为 ${label} 预写响应队列`);
let cursor = 0;
return {
messages: {
async create() {
if (cursor >= script.length) {
throw new Error(`[桩] ${label} 的响应队列已耗尽(已发出 ${cursor} 次请求)`);
}
const res = script[cursor++];
await sleep(res.latency_ms);
return res;
},
},
};
}
// ============ 3. 两版系统提示词,和它们对应的响应队列 ============
const SYSTEM_PROMPTS = {
v1: "你是订单助手。用工具查订单,然后回答用户。",
v2:
"你是订单助手。用工具查订单,然后回答用户。\n" +
"两条硬规则:\n" +
"1. 用户没给订单号时,先反问要订单号,不要自己猜一个去查。\n" +
"2. 写给客户的退款说明必须写清退款金额和到账时间。",
};
const SCRIPT_V1 = {
"t1-total": [
useTools([toolUse("tu_1", "search_orders", { customer: "启明科技", status: "已完成" })], [420, 60]),
useTools(
[
toolUse("tu_2", "get_order", { order_id: "SO-1001" }),
toolUse("tu_3", "get_order", { order_id: "SO-1002" }),
],
[520, 88]
),
finish("客户启明科技 2026 年 8 月的已完成订单有 2 笔(SO-1001、SO-1002),合计 ¥1,280.00 元。", [660, 52]),
],
"t2-pending": [
useTools([toolUse("tu_1", "search_orders", { status: "待发货" })], [415, 46]),
finish("目前待发货的订单是 SO-1003 和 SO-1004。", [500, 34]),
],
"t3-no-orderid": [
useTools([toolUse("tu_1", "search_orders", {})], [408, 38]),
useTools([toolUse("tu_2", "get_order", { order_id: "SO-1001" })], [470, 44]),
finish("订单 SO-1001 的状态是已完成。", [560, 30]),
],
"t4-refund-note": [
useTools([toolUse("tu_1", "get_order", { order_id: "SO-1003" })], [430, 42]),
finish(
"您好,订单 SO-1003(金额 ¥320.00 元)我们已经受理取消,退款会原路退回。给您带来不便,非常抱歉。",
[540, 76]
),
],
"t5-missing-order": [
useTools([toolUse("tu_1", "get_order", { order_id: "SO-9999" })], [412, 40]),
finish("系统里没有找到订单 SO-9999,请确认订单号是否正确。", [478, 36]),
],
};
// v2 只有两条任务的响应换了——两个版本的差异就钉死在这里
const SCRIPT_V2 = {
...SCRIPT_V1,
"t3-no-orderid": [
finish("你说的是哪一笔订单?把订单号发我就行(形如 SO-1001)。", [455, 32]),
],
"t4-refund-note": [
useTools([toolUse("tu_1", "get_order", { order_id: "SO-1003" })], [462, 42]),
finish(
"您好,订单 SO-1003(金额 ¥320.00 元)的取消申请已经受理,退款原路退回,通常 3-5 个工作日到账。给您带来不便,非常抱歉。",
[572, 94]
),
],
};
const SCRIPTS = { v1: SCRIPT_V1, v2: SCRIPT_V2 };
// ============ 4. 裁判:也是桩,输出 0.0-1.0 加 pass/fail ============
const JUDGE_PROMPT = `你是评分员。按下面的量表给这段客服回复打分,先写理由,再给分。
量表(每项 0 或 1,取平均作为总分):
- 金额准确:写清了退款金额,且与订单金额一致
- 到账时间:写清了退款多久到账
- 语气得体:是可以直接发给客户的措辞
只输出 JSON:{"reasoning": "...", "score": 0.0-1.0, "grade": "pass" | "fail"}
score >= 0.8 记 pass。`;
const JUDGE_SCRIPTS = {
v1: {
"t4-refund-note": [
finish(
JSON.stringify({
reasoning: "金额与订单一致,语气得体;但没写退款多久到账,客户拿不到预期,三项里缺一项。",
score: 0.67,
grade: "fail",
}),
[286, 58]
),
],
},
v2: {
"t4-refund-note": [
finish(
JSON.stringify({
reasoning: "金额、到账时间、语气三项都满足,可以直接发给客户。",
score: 1.0,
grade: "pass",
}),
[302, 46]
),
],
},
};
async function judgeAnswer(task, answer, version, metrics) {
const client = stubClient(JUDGE_SCRIPTS[version][task.id], `judge/${version}/${task.id}`);
const res = await client.messages.create({
model: MODEL,
max_tokens: 1024,
system: JUDGE_PROMPT,
messages: [{ role: "user", content: `【任务】${task.prompt}\n【待评回复】${answer}` }],
});
metrics.tokens += res.usage.input_tokens + res.usage.output_tokens;
const verdict = JSON.parse(res.content.map((b) => b.text).join(""));
return { pass: verdict.grade === "pass", score: verdict.score, note: verdict.reasoning };
}
// ============ 5. 评测集:4 条常规 + 1 条边缘用例 ============
function amountsIn(s) {
return [...s.replace(/[,,\s¥¥]/g, "").matchAll(/\d+(?:\.\d+)?/g)].map((m) => Number(m[0]));
}
function orderIdsIn(s) {
return [...new Set(s.match(/SO-\d+/g) ?? [])].sort();
}
const TASKS = [
{
id: "t1-total",
grader: "确定性",
prompt: "客户启明科技 2026 年 8 月的已完成订单,总金额是多少?",
verify: (r) => ({
pass: amountsIn(r.answer).some((n) => Math.abs(n - 1280) < 0.005),
note: "答案里要出现 1280 这个数(允许货币符号、千分位、单位)",
}),
strictVerify: (r) => ({
pass: r.answer.includes("1280.00"),
note: "答案里要原样出现字符串 1280.00",
}),
},
{
id: "t2-pending",
grader: "确定性",
prompt: "现在有哪些订单还没发货?把订单号列出来。",
verify: (r) => ({
pass: JSON.stringify(orderIdsIn(r.answer)) === JSON.stringify(["SO-1003", "SO-1004"]),
note: "答案里的订单号集合要正好等于 SO-1003 + SO-1004",
}),
},
{
id: "t3-no-orderid",
grader: "确定性",
prompt: "帮我把那笔订单的状态查一下。",
verify: (r) => ({
pass: r.toolCalls === 0 && r.answer.includes("?") && r.answer.includes("订单号"),
note: "参数不全时应该一次工具都不调,直接反问要订单号",
}),
},
{
id: "t4-refund-note",
grader: "LLM 裁判",
judge: true,
prompt: "客户为订单 SO-1003 申请取消并退款,给他写一段回复。",
},
{
id: "t5-missing-order",
grader: "确定性",
prompt: "查一下订单 SO-9999 的状态。",
verify: (r) => ({
pass: r.toolErrors === 1 && /没有找到|未找到|不存在/.test(r.answer) && !/[¥¥]|元/.test(r.answer),
note: "工具报错后要如实说查不到,且不能顺手编一个金额出来",
}),
},
];
// ============ 6. 一条任务一个 harness 循环 ============
async function runToolUses(content, metrics) {
const results = [];
for (const block of content) {
if (block.type !== "tool_use") continue;
metrics.toolCalls += 1;
try {
const out = TOOL_IMPL[block.name](block.input);
results.push({ type: "tool_result", tool_use_id: block.id, content: JSON.stringify(out) });
} catch (err) {
metrics.toolErrors += 1;
results.push({ type: "tool_result", tool_use_id: block.id, content: err.message, is_error: true });
}
}
return results;
}
async function runTask(task, version) {
const metrics = { toolCalls: 0, toolErrors: 0, tokens: 0 };
const client = stubClient(SCRIPTS[version][task.id], `${version}/${task.id}`);
const system = SYSTEM_PROMPTS[version];
const messages = [{ role: "user", content: task.prompt }];
const startedAt = Date.now();
let response = await client.messages.create({ model: MODEL, max_tokens: 4096, system, tools: TOOLS, messages });
metrics.tokens += response.usage.input_tokens + response.usage.output_tokens;
while (response.stop_reason === "tool_use") {
messages.push({ role: "assistant", content: response.content });
const toolResults = await runToolUses(response.content, metrics);
messages.push({ role: "user", content: toolResults });
response = await client.messages.create({ model: MODEL, max_tokens: 4096, system, tools: TOOLS, messages });
metrics.tokens += response.usage.input_tokens + response.usage.output_tokens;
}
const answer = response.content
.filter((b) => b.type === "text")
.map((b) => b.text)
.join("\n");
let verdict;
if (task.judge) {
verdict = await judgeAnswer(task, answer, version, metrics);
} else {
const fn = STRICT && task.strictVerify ? task.strictVerify : task.verify;
const out = fn({ answer, ...metrics });
verdict = { pass: out.pass, score: out.pass ? 1 : 0, note: out.note };
}
return {
id: task.id,
grader: task.grader,
pass: verdict.pass,
score: verdict.score,
note: verdict.note,
answer,
durationMs: Date.now() - startedAt,
...metrics,
};
}
async function runSuite(version) {
const rows = [];
for (const task of TASKS) rows.push(await runTask(task, version));
return {
version,
verifier: STRICT ? "strict(旧版,没做归一化)" : "normalized(修好的)",
rows,
passed: rows.filter((r) => r.pass).length,
total: rows.length,
toolCalls: rows.reduce((n, r) => n + r.toolCalls, 0),
toolErrors: rows.reduce((n, r) => n + r.toolErrors, 0),
tokens: rows.reduce((n, r) => n + r.tokens, 0),
durationMs: rows.reduce((n, r) => n + r.durationMs, 0),
};
}
// ============ 7. 报告 ============
const CJK = /[ᄀ-ᅟ⺀-가-힣豈-︰-﹏-⦆¢-₩]/;
const width = (s) => [...String(s)].reduce((n, ch) => n + (CJK.test(ch) ? 2 : 1), 0);
const pad = (s, n) => String(s) + " ".repeat(Math.max(0, n - width(s)));
const padL = (s, n) => " ".repeat(Math.max(0, n - width(s))) + String(s);
function printReport(report) {
console.log(`\n=== 报告 · 提示词 ${report.version} · 验证器 ${report.verifier} ===`);
console.log(
pad("任务", 18) + pad("判分", 11) + pad("结果", 7) + padL("分数", 6) +
padL("调用", 6) + padL("报错", 6) + padL("tokens", 9) + padL("耗时", 8)
);
console.log("-".repeat(71));
for (const r of report.rows) {
console.log(
pad(r.id, 18) + pad(r.grader, 11) + pad(r.pass ? "pass" : "FAIL", 7) +
padL(r.score.toFixed(2), 6) + padL(r.toolCalls, 6) + padL(r.toolErrors, 6) +
padL(r.tokens.toLocaleString("en-US"), 9) + padL(`${r.durationMs}ms`, 8)
);
}
console.log("-".repeat(71));
const rate = ((report.passed / report.total) * 100).toFixed(0);
console.log(
`通过率 ${report.passed}/${report.total} (${rate}%) · 工具调用 ${report.toolCalls} 次 · ` +
`工具报错 ${report.toolErrors} 次 · tokens ${report.tokens.toLocaleString("en-US")} · 总耗时 ${report.durationMs}ms`
);
const failed = report.rows.filter((r) => !r.pass);
if (failed.length) {
console.log("\n未通过明细:");
for (const r of failed) {
console.log(` [${r.id}] 判据:${r.note}`);
console.log(` Agent 回答:${r.answer}`);
}
}
}
function printDiff(a, b) {
console.log(`\n=== 分数变化 ${a.version} -> ${b.version} ===`);
console.log(pad("任务", 18) + padL(a.version, 7) + padL(b.version, 7) + " 变化");
console.log("-".repeat(48));
for (let i = 0; i < a.rows.length; i++) {
const x = a.rows[i], y = b.rows[i];
let mark = "持平";
if (!x.pass && y.pass) mark = "fail => pass";
else if (x.pass && !y.pass) mark = "pass => FAIL";
else if (y.score !== x.score) mark = `分数 ${(y.score - x.score).toFixed(2)}`;
console.log(pad(x.id, 18) + padL(x.score.toFixed(2), 7) + padL(y.score.toFixed(2), 7) + " " + mark);
}
console.log("-".repeat(48));
console.log(`通过率 ${a.passed}/${a.total} -> ${b.passed}/${b.total}`);
}
// ============ 8. 入口 ============
const reportV1 = await runSuite("v1");
printReport(reportV1);
const reportV2 = await runSuite("v2");
printReport(reportV2);
printDiff(reportV1, reportV2);