最终答案只能告诉我们智能体有没有答对,却很难解释它为什么失败。本文用 Node.js 构建一个可运行的步骤级轨迹评测器,把规划、工具调用、参数、观察结果和答案验证统一为事件,再用规则断言与局部评分定位问题。

只评最终答案为什么不够

常见的智能体测试会给定输入,然后比较最终文本是否包含关键词,或者交给另一个模型打分。这类黑盒评测适合衡量整体效果,但不适合排查执行链路。

例如,一个知识库智能体没有引用正确文档,原因可能完全不同:

  • 规划阶段没有意识到需要检索;
  • 选择了不适合当前任务的工具;
  • 工具选对了,但查询词为空或 topK 越界;
  • 工具返回空结果,智能体仍继续生成答案;
  • 已取得正确资料,却没有检查引用是否来自资料。

如果测试只保留最终答案,这些问题都会被压缩成同一个“回答错误”。修复时只能反复修改提示词,很难判断改动究竟影响了哪一步。

更实用的做法是记录可观察的执行事件。这里不要求保存模型的隐藏思维过程,而是记录系统本来就能获得的结构化信息,例如规划结果、工具名称、调用参数、工具响应和验证结论。这样既方便测试,也能避免把不可控的自然语言推理当成稳定接口。

建立步骤级事件模型

先把一次智能体运行抽象为按顺序排列的事件。每个事件都包含运行标识、序号、时间、类型和负载:

事件类型表示的动作典型负载
plan.created生成执行计划steps
tool.selected选择工具name
tool.called发起工具调用nameargs
tool.returned收到工具结果okitems
answer.validated执行答案检查ok
answer.finalized生成最终答案textcitations

事件应尽量描述事实,而不是写成日志句子。比如应记录 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,因此不会盲目信任智能体自己的判断。

从失败报告走向可回放测试

局部评分的价值不是制造一个更复杂的总分,而是保留失败分布。总分可以用于持续集成中的门禁,阶段分则适合定位回归来源。例如升级工具路由后,“工具选择”分数下降,就应优先检查工具描述和路由策略,而不是直接调整答案生成提示词。

落地时建议把输入、事件轨迹、工具响应和评测报告一起保存。回归测试可以直接读取历史轨迹重新执行规则,不必再次调用模型或外部工具。这种回放成本更低,也能保证同一条规则在不同版本上得到可比较的结果。

规则还应区分确定性检查和语义检查。参数范围、事件顺序、引用归属适合使用代码断言;答案是否完整、计划是否合理等开放问题,才考虑引入模型评分。即便使用模型评分,也应保存评分提示、模型版本和原始输出,避免把不可复现的判断伪装成精确事实。

随着系统扩展,可以逐步加入超时、重试、工具错误码、敏感参数脱敏和跨事件因果关系。不要一开始就建立庞大的事件规范,优先覆盖真实发生过、且能够指导修复的失败模式。

总结

步骤级轨迹评测把智能体测试从“答案对不对”推进到“具体哪一步出了问题”。本文示例的核心做法包括:

  • 用统一事件模型记录规划、工具、参数、结果与验证;
  • 对可观察事实编写确定性的规则断言;
  • 按阶段计算局部评分,同时保留具体失败原因;
  • 重新验证引用等关键事实,不盲目信任智能体自报结果;
  • 保存完整轨迹,用固定输入进行离线回放和持续回归。

最终答案评测仍然有价值,但它更适合作为整体指标。将它与步骤级轨迹结合,才能让智能体失败变得可复现、可定位,也让后续优化有明确的工程依据。