节点全绿,任务仍然失败
「回声」的编排链路有三条:内容发布安全链、私信客服链、站内下单咨询链,覆盖五项能力。上线前,团队把每个节点的单测都跑绿了:重排返回正常,摘要 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 条。运行结果如下:
| 类别 | 样本数 | 占比 | 通过率 |
|---|---|---|---|
| 成功 | 8 | 32% | 100% |
| 部分成功 | 4 | 16% | 100% |
| 失败 | 5 | 20% | 100% |
| 超时 | 4 | 16% | 100% |
| 越权 | 4 | 16% | 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% 只说明判定器与样本一致。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。