最终答案只能告诉我们智能体有没有答对,却很难解释它为什么失败。本文用 Node.js 构建一个可运行的步骤级轨迹评测器,把规划、工具调用、参数、观察结果和答案验证统一为事件,再用规则断言与局部评分定位问题。
只评最终答案为什么不够
常见的智能体测试会给定输入,然后比较最终文本是否包含关键词,或者交给另一个模型打分。这类黑盒评测适合衡量整体效果,但不适合排查执行链路。
例如,一个知识库智能体没有引用正确文档,原因可能完全不同:
- 规划阶段没有意识到需要检索;
- 选择了不适合当前任务的工具;
- 工具选对了,但查询词为空或
topK越界; - 工具返回空结果,智能体仍继续生成答案;
- 已取得正确资料,却没有检查引用是否来自资料。
如果测试只保留最终答案,这些问题都会被压缩成同一个“回答错误”。修复时只能反复修改提示词,很难判断改动究竟影响了哪一步。
更实用的做法是记录可观察的执行事件。这里不要求保存模型的隐藏思维过程,而是记录系统本来就能获得的结构化信息,例如规划结果、工具名称、调用参数、工具响应和验证结论。这样既方便测试,也能避免把不可控的自然语言推理当成稳定接口。
建立步骤级事件模型
先把一次智能体运行抽象为按顺序排列的事件。每个事件都包含运行标识、序号、时间、类型和负载:
| 事件类型 | 表示的动作 | 典型负载 |
|---|---|---|
plan.created | 生成执行计划 | steps |
tool.selected | 选择工具 | name |
tool.called | 发起工具调用 | name、args |
tool.returned | 收到工具结果 | ok、items |
answer.validated | 执行答案检查 | ok |
answer.finalized | 生成最终答案 | text、citations |
事件应尽量描述事实,而不是写成日志句子。比如应记录 args: { query: '', topK: 50 },而不是只保存“正在搜索文档”。前者可以被程序断言,后者通常还要再次调用模型解析。
序号同样重要。评测器不仅要知道某个事件是否存在,还要检查调用是否发生在选择工具之后、验证是否位于最终输出附近。生产系统还可以增加 parentEventId、耗时、重试次数和工具版本,但第一版不必追求覆盖所有字段。
规则可以按阶段组织,并分配权重:
| 阶段 | 示例断言 | 失败通常意味着 |
|---|---|---|
| 规划 | 至少存在一个计划步骤 | 任务分解缺失 |
| 工具选择 | 工具在允许列表内 | 路由或提示配置错误 |
| 参数生成 | 查询非空、topK 合法 | 参数生成或约束失效 |
| 结果观察 | 调用成功且返回内容 | 工具异常或检索无结果 |
| 结果验证 | 引用来自工具结果 | 缺少事实一致性检查 |
用 Node.js 实现评测器
下面的示例只使用 Node.js 内置能力,不依赖第三方包。将代码保存为 evaluator.mjs,使用 Node.js 18 或更高版本运行。
const allowedTypes = new Set([
'plan.created',
'tool.selected',
'tool.called',
'tool.returned',
'answer.validated',
'answer.finalized'
]);
function validateTrace(events) {
if (!Array.isArray(events) || events.length === 0) {
throw new Error('轨迹不能为空');
}
const runId = events[0].runId;
events.forEach((event, index) => {
if (event.runId !== runId) {
throw new Error(`第 ${index + 1} 个事件的 runId 不一致`);
}
if (event.seq !== index + 1) {
throw new Error(`事件序号应为 ${index + 1}`);
}
if (!allowedTypes.has(event.type)) {
throw new Error(`未知事件类型: ${event.type}`);
}
if (Number.isNaN(Date.parse(event.ts))) {
throw new Error(`事件时间无效: ${event.ts}`);
}
});
}
const find = (events, type) => events.find(event => event.type === type);
const rules = [
{
id: 'plan.has_steps',
phase: '规划',
weight: 2,
check(events) {
const event = find(events, 'plan.created');
const pass = Array.isArray(event?.payload?.steps) &&
event.payload.steps.length > 0;
return { pass, message: pass ? '计划包含步骤' : '计划步骤缺失' };
}
},
{
id: 'tool.allowed',
phase: '工具选择',
weight: 2,
check(events) {
const event = find(events, 'tool.selected');
const allowed = ['docs.search'];
const pass = allowed.includes(event?.payload?.name);
return {
pass,
message: pass ? '工具选择合法' : `不允许的工具: ${event?.payload?.name}`
};
}
},
{
id: 'args.query',
phase: '参数生成',
weight: 1,
check(events) {
const query = find(events, 'tool.called')?.payload?.args?.query;
const pass = typeof query === 'string' && query.trim().length > 0;
return { pass, message: pass ? '查询词有效' : '查询词不能为空' };
}
},
{
id: 'args.top_k',
phase: '参数生成',
weight: 1,
check(events) {
const topK = find(events, 'tool.called')?.payload?.args?.topK;
const pass = Number.isInteger(topK) && topK >= 1 && topK <= 10;
return { pass, message: pass ? 'topK 合法' : `topK 越界: ${topK}` };
}
},
{
id: 'result.usable',
phase: '结果观察',
weight: 2,
check(events) {
const payload = find(events, 'tool.returned')?.payload;
const pass = payload?.ok === true &&
Array.isArray(payload.items) && payload.items.length > 0;
return { pass, message: pass ? '工具结果可用' : '工具失败或结果为空' };
}
},
{
id: 'validation.executed',
phase: '结果验证',
weight: 1,
check(events) {
const event = find(events, 'answer.validated');
const pass = event?.payload?.ok === true;
return { pass, message: pass ? '已执行验证' : '验证缺失或未通过' };
}
},
{
id: 'validation.citations',
phase: '结果验证',
weight: 2,
check(events) {
const items = find(events, 'tool.returned')?.payload?.items ?? [];
const citations = find(events, 'answer.finalized')?.payload?.citations ?? [];
const knownIds = new Set(items.map(item => item.id));
const pass = citations.length > 0 &&
citations.every(id => knownIds.has(id));
return {
pass,
message: pass ? '引用均来自工具结果' : '引用为空或包含未知文档'
};
}
}
];
function evaluate(events) {
validateTrace(events);
const results = rules.map(rule => {
const result = rule.check(events);
return { ...rule, ...result };
});
const phases = {};
for (const result of results) {
phases[result.phase] ??= { earned: 0, total: 0 };
phases[result.phase].total += result.weight;
if (result.pass) phases[result.phase].earned += result.weight;
}
const phaseScores = Object.fromEntries(
Object.entries(phases).map(([phase, value]) => [
phase,
{ ...value, score: Number((value.earned / value.total).toFixed(2)) }
])
);
const earned = results
.filter(result => result.pass)
.reduce((sum, result) => sum + result.weight, 0);
const total = results.reduce((sum, result) => sum + result.weight, 0);
return {
runId: events[0].runId,
score: Number((earned / total).toFixed(2)),
phaseScores,
failures: results.filter(result => !result.pass).map(result => ({
rule: result.id,
phase: result.phase,
message: result.message
}))
};
}
const ts = '2025-01-10T08:00:00.000Z';
const trace = [
{ runId: 'run-001', seq: 1, ts, type: 'plan.created',
payload: { steps: ['检索文档', '基于结果回答'] } },
{ runId: 'run-001', seq: 2, ts, type: 'tool.selected',
payload: { name: 'docs.search' } },
{ runId: 'run-001', seq: 3, ts, type: 'tool.called',
payload: { name: 'docs.search', args: { query: '', topK: 50 } } },
{ runId: 'run-001', seq: 4, ts, type: 'tool.returned',
payload: { ok: true, items: [] } },
{ runId: 'run-001', seq: 5, ts, type: 'answer.validated',
payload: { ok: true } },
{ runId: 'run-001', seq: 6, ts, type: 'answer.finalized',
payload: { text: '请参考相关文档。', citations: ['doc-9'] } }
];
const report = evaluate(trace);
console.log(JSON.stringify(report, null, 2));
console.table(report.failures);
运行命令:
node evaluator.mjs
示例会在参数生成、结果观察和结果验证阶段报错。虽然轨迹声称验证已经通过,但引用规则会重新比较最终引用与工具返回的文档 ID,因此不会盲目信任智能体自己的判断。
从失败报告走向可回放测试
局部评分的价值不是制造一个更复杂的总分,而是保留失败分布。总分可以用于持续集成中的门禁,阶段分则适合定位回归来源。例如升级工具路由后,“工具选择”分数下降,就应优先检查工具描述和路由策略,而不是直接调整答案生成提示词。
落地时建议把输入、事件轨迹、工具响应和评测报告一起保存。回归测试可以直接读取历史轨迹重新执行规则,不必再次调用模型或外部工具。这种回放成本更低,也能保证同一条规则在不同版本上得到可比较的结果。
规则还应区分确定性检查和语义检查。参数范围、事件顺序、引用归属适合使用代码断言;答案是否完整、计划是否合理等开放问题,才考虑引入模型评分。即便使用模型评分,也应保存评分提示、模型版本和原始输出,避免把不可复现的判断伪装成精确事实。
随着系统扩展,可以逐步加入超时、重试、工具错误码、敏感参数脱敏和跨事件因果关系。不要一开始就建立庞大的事件规范,优先覆盖真实发生过、且能够指导修复的失败模式。
总结
步骤级轨迹评测把智能体测试从“答案对不对”推进到“具体哪一步出了问题”。本文示例的核心做法包括:
- 用统一事件模型记录规划、工具、参数、结果与验证;
- 对可观察事实编写确定性的规则断言;
- 按阶段计算局部评分,同时保留具体失败原因;
- 重新验证引用等关键事实,不盲目信任智能体自报结果;
- 保存完整轨迹,用固定输入进行离线回放和持续回归。
最终答案评测仍然有价值,但它更适合作为整体指标。将它与步骤级轨迹结合,才能让智能体失败变得可复现、可定位,也让后续优化有明确的工程依据。
评论