《互联网产品的 AI 架构·多能力编排与任务闭环》7.1 端到端评测集

节点测试全绿,用户任务仍然失败,因为评测单位选错了。本节把一条用户任务作为样本单位,定义成功、部分成功、失败、超时、越权五类样本与验收口径,要求失败路径至少占四成,给出五类覆盖统计与发布门禁,并用 25 条示例样本跑出 48% 的任务完成率与 68% 的失败路径占比。

节点全绿,任务仍然失败

「回声」的编排链路有三条:内容发布安全链、私信客服链、站内下单咨询链,覆盖五项能力。上线前,团队把每个节点的单测都跑绿了:重排返回正常,摘要 JSON 合法,回复没有超时。上线后,客服仍然收到投诉:回答引用了已经下架的文档,订单没有创建,用户等了很久只看到「处理中」。

单看节点,每一步都成功;看任务,用户要的事情没有完成。节点测试回答「这次调用有没有报错」,端到端评测回答「这个任务有没有完成」。两者之间隔着组合语义:检索对了但引用错了、摘要对了但摘要的是旧版本、回复对了但没落到订单上。

本节只讨论一件事:怎么定义一套以任务为单位的评测集,让失败路径和成功路径一样可验收。文中数字来自 ai-lab 的示例样本运行结果,不是生产实测。

评测单位是任务,不是调用

一条样本就是一个用户任务:从输入进入编排,到最终交付或明确失败。样本记录四件事:

字段含义为什么不能省
expected期望的结果类别、任务是否完成、可验收产出下限没有期望就无法判定
observed实际结果类别、任务是否完成、首个失败节点失败样本必须能归因
costMicros该任务消耗的总成本,含重试与失败浪费只算成功调用会低估成本
latencyMs从进入到交付的端到端延迟节点延迟相加不等于用户等待

成本用整数微元,延迟用毫秒。主场景里三能力编排把成本从 4,240 降到 3,786 微元,延迟从 1,200 变成 1,269ms;这些是链路级结果,评测样本必须按同样的口径记录,否则节点账和任务账永远对不上。

五类样本

只测成功路径的评测集,会把「失败时会发生什么」留给线上。五类样本各回答一个问题:

类别定义任务完成验收要点
成功 success任务按契约完成,产出可被下游直接消费是交付比例必须为 1
部分成功 partial主任务完成,但有一项产出缺失或降级是交付比例不低于事前下限,例如 0.6
失败 failure节点返回错误或输出不可用,任务未完成否必须记录首个失败节点
超时 timeout在截止时间内没有交付否区分节点超时与整链超时
越权 unauthorized外部动作被权限拒绝,任务停在动作前否必须记录被拒绝的节点

部分成功最容易写错。它不是「模型说了一句抱歉」,而是「用户拿到了可用的主要结果,同时明确知道缺了什么」。如果交付比例低于下限,它应归入失败,而不是用「部分成功」美化成功率。

评测集的结构

评测集本身是一个有版本的工件:id、版本、单位固定为 task,样本列表按场景组织。单位写成 task 不是形式主义,它阻止有人把一次节点调用塞进样本,从而把任务级成功率算成调用级成功率。

样本的期望值由三部分构成:结果类别、任务是否完成、可验收产出下限。失败路径样本额外要求首个失败节点,因为没有节点的失败样本只能统计、不能归因,7.2 会用到这个字段。

实现:把验收口径写成代码

eval.ts 只做三件事:判定单条样本、统计五类覆盖、执行发布门禁。它不调用模型,也不生成样本。

// file: src/orch/eval.ts
// tier: L2
// verify: node --test test/orch/eval.test.ts

import { AiError } from "../core/errors.ts";

/** 端到端评测的五类样本,按用户可感知的任务结果划分,而不是按节点返回码划分。 */
export type Outcome = "success" | "partial" | "failure" | "timeout" | "unauthorized";

/** 顺序固定,统计表与门禁都按这个顺序输出,保证报告可比。 */
export const OUTCOMES: readonly Outcome[] = [
  "success",
  "partial",
  "failure",
  "timeout",
  "unauthorized",
];

export interface EvalExpectation {
  outcome: Outcome;
  /** 任务级判定:用户任务是否完成,而不是某个节点是否返回 200。 */
  completed: boolean;
  /** 可验收产出占比的下限;成功样本为 1,部分成功样本通常取 0.6。 */
  minDeliveredRatio: number;
  /** 失败路径样本必须携带首个失败节点,否则评测集无法支撑归因。 */
  requiresFailureNode: boolean;
}

export interface EvalObservation {
  outcome: Outcome;
  completed: boolean;
  deliveredRatio: number;
  firstFailureNode?: string;
}

export interface EvalSample {
  id: string;
  scenario: string;
  expected: EvalExpectation;
  observed: EvalObservation;
  costMicros: number;
  latencyMs: number;
}

/** 评测单位固定为 task:一条样本就是一个用户任务,不是一个节点调用。 */
export interface EvalSet {
  id: string;
  version: string;
  unit: "task";
  samples: readonly EvalSample[];
}

export type VerdictCode =
  | "pass"
  | "outcome-mismatch"
  | "task-completion"
  | "partial-delivery"
  | "missing-failure-node";

export interface SampleVerdict {
  id: string;
  passed: boolean;
  code: VerdictCode;
}

export interface EvalPolicy {
  /** 每一类样本至少要有多少条,缺一类就不算覆盖失败路径。 */
  minPerOutcome: number;
  /** 非成功样本占全集的最低比例。 */
  minFailurePathShare: number;
  /** 成功样本占全集的上限,防止评测集退化成 happy path 集合。 */
  maxSuccessShare: number;
}

export const DEFAULT_EVAL_POLICY: EvalPolicy = {
  minPerOutcome: 3,
  minFailurePathShare: 0.4,
  maxSuccessShare: 0.6,
};

export interface OutcomeStat {
  outcome: Outcome;
  samples: number;
  share: number;
  passed: number;
  passRate: number;
}

export interface EvalSetReport {
  total: number;
  byOutcome: OutcomeStat[];
  passed: number;
  passRate: number;
  /** 任务完成率的分母是全部样本,包含失败、超时与越权样本。 */
  taskCompletionRate: number;
  successShare: number;
  failurePathShare: number;
  gaps: string[];
}

function fail(message: string): never {
  throw new AiError("invalid-request", `eval: ${message}`);
}

function ratio(value: number, field: string): number {
  if (!Number.isFinite(value) || value < 0 || value > 1) fail(`${field} 必须落在 [0, 1]`);
  return value;
}

function metric(value: number, field: string): number {
  if (!Number.isInteger(value) || value < 0) fail(`${field} 必须是 >= 0 的整数`);
  return value;
}

/**
 * 单条样本的判定严格按“任务是否完成”执行:
 * 先比对结果类别,再比对任务完成标志,最后检查部分交付与失败节点。
 */
export function judgeSample(sample: EvalSample): SampleVerdict {
  ratio(sample.observed.deliveredRatio, `${sample.id}.deliveredRatio`);
  ratio(sample.expected.minDeliveredRatio, `${sample.id}.minDeliveredRatio`);
  metric(sample.costMicros, `${sample.id}.costMicros`);
  metric(sample.latencyMs, `${sample.id}.latencyMs`);
  if (sample.observed.outcome !== sample.expected.outcome) {
    return { id: sample.id, passed: false, code: "outcome-mismatch" };
  }
  if (sample.observed.completed !== sample.expected.completed) {
    return { id: sample.id, passed: false, code: "task-completion" };
  }
  if (sample.observed.deliveredRatio + 1e-9 < sample.expected.minDeliveredRatio) {
    return { id: sample.id, passed: false, code: "partial-delivery" };
  }
  if (sample.expected.requiresFailureNode && !sample.observed.firstFailureNode) {
    return { id: sample.id, passed: false, code: "missing-failure-node" };
  }
  return { id: sample.id, passed: true, code: "pass" };
}

/** 统计五类样本的数量、通过率与覆盖缺口;缺类、成功样本过多都会写进 gaps。 */
export function summarizeEvalSet(
  set: EvalSet,
  policy: EvalPolicy = DEFAULT_EVAL_POLICY,
): EvalSetReport {
  if (set.unit !== "task") fail(`评测单位必须是 task,当前 ${String(set.unit)}`);
  if (set.id.trim() === "") fail("评测集 id 不能为空");
  if (set.samples.length === 0) fail("评测集不能为空");
  if (!Number.isInteger(policy.minPerOutcome) || policy.minPerOutcome < 1) {
    fail("minPerOutcome 必须是 >= 1 的整数");
  }
  ratio(policy.minFailurePathShare, "minFailurePathShare");
  ratio(policy.maxSuccessShare, "maxSuccessShare");
  if (policy.minFailurePathShare + policy.maxSuccessShare > 1) {
    fail("失败路径下限与成功样本上限互相矛盾");
  }

  const seen = new Set<string>();
  const counts = new Map<Outcome, number>();
  const passes = new Map<Outcome, number>();
  let passed = 0;
  let completed = 0;

  for (const sample of set.samples) {
    if (sample.id.trim() === "") fail("样本 id 不能为空");
    if (seen.has(sample.id)) fail(`重复的样本 id ${sample.id}`);
    seen.add(sample.id);
    const verdict = judgeSample(sample);
    if (verdict.passed) passed += 1;
    if (sample.observed.completed) completed += 1;
    const outcome = sample.observed.outcome;
    counts.set(outcome, (counts.get(outcome) ?? 0) + 1);
    if (verdict.passed) passes.set(outcome, (passes.get(outcome) ?? 0) + 1);
  }

  const total = set.samples.length;
  const byOutcome = OUTCOMES.map((outcome): OutcomeStat => {
    const samples = counts.get(outcome) ?? 0;
    const ok = passes.get(outcome) ?? 0;
    return {
      outcome,
      samples,
      share: Number((samples / total).toFixed(4)),
      passed: ok,
      passRate: samples === 0 ? 0 : Number((ok / samples).toFixed(4)),
    };
  });

  const successShare = Number(((counts.get("success") ?? 0) / total).toFixed(4));
  const failurePathShare = Number((1 - successShare).toFixed(4));
  const gaps: string[] = [];
  for (const stat of byOutcome) {
    if (stat.samples < policy.minPerOutcome) {
      gaps.push(`${stat.outcome} 样本不足:${stat.samples}/${policy.minPerOutcome}`);
    }
  }
  if (failurePathShare < policy.minFailurePathShare) {
    gaps.push(`失败路径占比 ${failurePathShare},低于下限 ${policy.minFailurePathShare}`);
  }
  if (successShare > policy.maxSuccessShare) {
    gaps.push(`成功样本占比 ${successShare},高于上限 ${policy.maxSuccessShare}`);
  }

  return {
    total,
    byOutcome,
    passed,
    passRate: Number((passed / total).toFixed(4)),
    taskCompletionRate: Number((completed / total).toFixed(4)),
    successShare,
    failurePathShare,
    gaps,
  };
}

/** 发布门禁:有缺口就拒绝把这次评测结果当作“可上线”的证据。 */
export function assertEvalSet(
  set: EvalSet,
  policy: EvalPolicy = DEFAULT_EVAL_POLICY,
): EvalSetReport {
  const report = summarizeEvalSet(set, policy);
  if (report.gaps.length > 0) {
    fail(`评测集未通过门禁:${report.gaps.join(";")}`);
  }
  return report;
}

judgeSample 的顺序是刻意的:先看结果类别,再看任务完成标志,最后看交付比例与失败节点。顺序反过来会让「类别错了但比例刚好达标」的样本蒙混过关。

summarizeEvalSet 的分母始终是全部样本。任务完成率因此不会被成功样本的数量稀释:25 条样本里只有 12 条完成,完成率就是 48%,而不是「成功样本内部 100%」。

示例样本上的运行结果

示例评测集有 25 条样本:成功 8 条、部分成功 4 条、失败 5 条、超时 4 条、越权 4 条。运行结果如下:

类别样本数占比通过率
成功832%100%
部分成功416%100%
失败520%100%
超时416%100%
越权416%100%

汇总指标:任务完成率 48%,成功率 32%,失败路径占比 68%,门禁缺口为空。这里的「通过率 100%」不是模型表现好,而是这 25 条样本的观测结果都符合各自类别的验收口径;它衡量的是评测集与判定器是否一致,不是线上成功率。

把成功样本加到 20 条、其余四类各 1 条,成功率会升到 80%,失败路径只剩 20%。summarizeEvalSet 会同时报出「成功样本占比过高」和四类样本不足;assertEvalSet 直接拒绝把这份报告当作上线证据。

门禁:缺一类就不算覆盖

默认门禁有三条:每类至少 3 条样本、失败路径至少占 40%、成功样本不超过 60%。三条都不是统计定理,而是防止评测集退化的工程约束。

第一条防止「只补了一条超时样本就宣称覆盖超时」。第二条防止评测集变成 happy path 集合。第三条与第二条互为镜像:成功样本过多时,失败路径必然被压缩。门禁失败不是「评测结果不好」,而是「这次评测不足以支持发布决定」,两者要分开汇报。

评测集与节点测试的分工

节点测试保证「每个零件按契约工作」,端到端评测保证「装起来能完成用户任务」。节点测试可以 mock 下游,端到端评测不能:它必须记录真实的任务结果、真实的重试成本和真实的交付比例。

两者也不能互相替代。节点全绿而任务失败,说明组合语义有问题;任务成功而节点测试缺失,说明成功不可复现。发布前两份报告都要在,且使用同一套成本与延迟口径。

小结

端到端评测的单位是任务,不是调用。五类样本分别覆盖成功、部分成功、失败、超时与越权,失败路径至少要占四成;每条失败样本必须带首个失败节点。示例集 25 条样本跑出 48% 的任务完成率、68% 的失败路径占比,门禁无缺口。

评测集只回答「任务有没有完成」。至于失败由哪个节点造成,需要 7.2 的归因;为什么同一个任务重跑会得到不同结果,需要 7.3 的回放。

阅读导航:上一节:6.3 长任务与异步交付 · 下一节:7.2 失败归因:哪个节点造成的 。

常见错误与边界

  • 用节点调用成功率代替任务完成率。节点全绿,任务仍可能因为组合语义失败。
  • 只放成功样本。失败、超时、越权路径没有样本,线上第一次出现时就没有基线。
  • 把部分成功算作成功。交付比例低于下限时应归入失败,否则成功率会虚高。
  • 失败样本不带首个失败节点。这样的样本只能计数,无法支撑 7.2 的归因。
  • 用节点延迟之和代表用户等待。并行、重试和交接会改变端到端延迟。
  • 成本只算成功调用。失败重试与浪费同样计费;高失败变体每次成功交付是 4,139 微元。
  • 事后调整验收口径。看到结果再改交付下限,等于把评测变成对结论的拟合。
  • 把评测集通过率当成线上成功率。示例里的 100% 只说明判定器与样本一致。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查