基线不是一张成功率截图
前面十二章分别讲了结构、契约、执行图、上下文、失败、人工、评测、预算、权限、观测、平台与演进。到了卷三收口,团队需要一个能反复运行、能贴进评审会、能和下一版比较的东西。它不是一张「成功率 99%」的截图,而是一组带样本、带口径、带判定规则的基线。
回声有三条在跑的业务链路:内容摘要与审核、会话回复、检索下单。它们的节点数差不多,成本与失败结构却完全不同。把三条链路塞进一个平均成功率,会掩盖真正的问题:内容链路卡在人工复核,会话链路卡在尾部延迟,下单链路卡在下单动作的失败率。
本节给出一条完整的三链路编排基线:节点、交接、预算、失败策略、观测字段,以及用 240 条确定性样本跑出的整链指标。所有数字都来自 ai-lab 的示例样本运行结果,不是生产实测;样本规模、随机种子与单价都写在代码里,可以逐条复核。
三条链路与节点
三条链路的节点定义如下。模型节点按 token 计费,人工与动作节点按固定成本计费;每条链路都有明确的成功率目标、单次交付预算、P95 延迟预算与交接成本上限。
| 链路 | 节点序列 | 节点类型 | 主要风险 |
|---|---|---|---|
| A 内容摘要与审核 | content.summarize → moderation.classify → 人工复核 → 发布 | 模型、模型、人工、动作 | 人工复核拒绝、发布失败 |
| B 会话回复 | chat.reply → support.assist → chat.reply | 模型、模型、模型 | 尾部重试、上下文复用不足 |
| C 检索下单 | search.rerank → chat.reply → 下单动作 | 模型、模型、动作 | 下单动作失败、重复下单 |
节点级参数决定成本与延迟的下限。A 的摘要是 1,800 输入 / 420 输出 token、620ms,分类是 900 / 60 token、180ms;人工复核固定 600 微元、2,400ms;发布固定 120 微元、90ms。B 的三跳沿用卷二的三能力口径:700 / 80、2,600 / 420、1,100 / 240 token,基础延迟分别为 160、620、420ms。C 的重排与回复是 700 / 80、1,100 / 240 token,下单动作固定 80 微元、120ms。
三条链路的成功率目标分别是 96%、97%、97%。A 的目标低一个点,因为人工复核本身有拒绝率;C 的目标与会话链路一致,因为下单是用户可感知的承诺。
交接与预算
交接不是免费的。每一跳携带 820 token 的增量上下文与上一跳结果,额外 35ms。首个模型节点携带完整的 3,200 token 共享上下文;后续模型节点按链路各自的复用率命中缓存,缓存输入按全价的 10% 计费,也就是折扣 90%。
| 口径 | 数值 | 用途 |
|---|---|---|
| 每次交接 token | 820 | 计入每次尝试的输入成本 |
| 共享上下文 | 3,200 | 首跳全价,后续按复用率折算 |
| 缓存折扣 | 90% | 缓存输入按全价 10% 计费 |
| 交接延迟 | 35ms | 每个非首节点的每次尝试 |
| 复用回本线 | 28.47% | 低于它,共享不如复制 |
| 单步失败预算 | 13.02% | 三步、各一次重试、目标 95% |
回本线与失败预算不是本节新拍的数,而是卷二 13.3 留下的两个判据。基线把它们的实际值算出来:A 的复用率是 93%,B 是 60%,C 是 72%,都高于 28.47% 的回本线。复用率最高不代表最好,A 的共享上下文只有一跳可复用,B 的两跳才是交接成本的主要来源。
预算按链路分别设定:A 每次成功交付不超过 6,200 微元、P95 不超过 6,500ms;B 不超过 4,200 微元、1,800ms;C 不超过 3,000 微元、1,400ms。预算不是拍脑袋的上限,而是产品能接受的价格与等待时间。
失败策略
失败策略必须写进基线,否则成功率只是一个无法解释的结果。三条链路共用一套规则,但每个节点的重试次数不同。
模型节点允许一次额外尝试:第一次失败后立刻重试,重试仍失败则节点失败,下游全部标记为 skipped。人工复核与动作节点不自动重试:人工拒绝需要重新走内容修改,动作失败可能是重复下单或库存冲突,自动重试会把局部失败放大成业务事故。
失败节点的成本仍然计入。重试的每次尝试都计费,失败后下游虽然跳过,但已经发生的输入、交接与人工工时不能从账上消失。这条规则让「每次成功交付成本」高于「平均单次成本」,也解释了为什么高失败率变体在卷二里从 3,872 涨到 4,139 微元。
观测字段
每条运行样本必须带以下字段,少一个就无法复核交接与失败:
runId:一次链路执行的唯一标识,与回放包共用。chain:A/B/C,禁止跨链聚合。nodeId与attempt:定位到具体节点的第几次尝试。status:ok / failed / skipped,下游跳过必须显式记录。costMicros与latencyMs:整数微元与毫秒,不用浮点金额。handoffTokens与reusedTokens:本次尝试实际传递与复用的 token。failureKind:失败类别,至少区分模型、人工与动作。deadlineRemainingMs:节点结束时剩余预算,用于判断是否还有重试空间。
这十一个字段就是基线的最小观测面。追踪系统可以更丰富,但基线表只依赖这些字段,因此任何团队都能复算。
实现
下面的模块定义三条链路、确定性样本生成、交接计费、失败传播与 P95。聚合与判定在 report.ts 中完成,评审记录在 review.ts 中生成;三者共用同一组类型。
// file: src/orch/baseline.ts
// tier: L2
// verify: node --test test/orch/baseline.test.ts
import { AiError } from "../core/errors.ts";
export type ChainId = "A" | "B" | "C";
export type NodeKind = "model" | "human" | "action";
export type NodeStatus = "ok" | "failed" | "skipped";
export type Verdict = "pass" | "conditional" | "fail";
/** 全书共享的口径;基线模块只引用,不重新定义。 */
export const ECHO_FACTS = {
standaloneCostMicros: 4_240,
orchestratedCostMicros: 3_786,
costDeltaPct: -10.71,
standaloneLatencyMs: 1_200,
orchestratedLatencyMs: 1_269,
latencyDeltaPct: 5.79,
highFailureSuccessRate: 0.9812,
highFailureCostPerSuccessMicros: 4_139,
handoffTokensPerHop: 820,
sharedContextTokens: 3_200,
cacheDiscount: 0.9,
breakEvenReuseRatio: 0.2847,
maxStepFailureForTarget: 0.1302,
handoffLatencyMs: 35,
inputMicrosPerKTok: 250,
outputMicrosPerKTok: 1_000,
} as const;
export interface ChainNode {
id: string;
capability: string;
kind: NodeKind;
inputTokens: number;
outputTokens: number;
baseLatencyMs: number;
failureRate: number;
/** 允许的额外尝试次数;人工与动作节点通常不再重试。 */
retries: number;
/** 人工工时或动作调用的固定成本。 */
fixedCostMicros?: number;
}
export interface ChainSpec {
id: ChainId;
name: string;
nodes: readonly ChainNode[];
successTarget: number;
costBudgetMicros: number;
p95LatencyBudgetMs: number;
handoffShareCap: number;
/** 第二个及之后的模型节点命中缓存的比例。 */
sharedReuseRatio: number;
}
/** 三条链路就是「回声」在卷三里的基线对象。 */
export const CHAINS: readonly ChainSpec[] = [
{
id: "A",
name: "内容摘要与审核",
nodes: [
{ id: "summarize", capability: "content.summarize", kind: "model", inputTokens: 1_800, outputTokens: 420, baseLatencyMs: 620, failureRate: 0.03, retries: 1 },
{ id: "classify", capability: "moderation.classify", kind: "model", inputTokens: 900, outputTokens: 60, baseLatencyMs: 180, failureRate: 0.01, retries: 1 },
{ id: "review", capability: "human.review", kind: "human", inputTokens: 0, outputTokens: 0, baseLatencyMs: 2_400, failureRate: 0.02, retries: 0, fixedCostMicros: 600 },
{ id: "publish", capability: "content.publish", kind: "action", inputTokens: 0, outputTokens: 0, baseLatencyMs: 90, failureRate: 0.01, retries: 0, fixedCostMicros: 120 },
],
successTarget: 0.96,
costBudgetMicros: 6_200,
p95LatencyBudgetMs: 6_500,
handoffShareCap: 0.35,
sharedReuseRatio: 0.93,
},
{
id: "B",
name: "会话回复",
nodes: [
{ id: "reply-draft", capability: "chat.reply", kind: "model", inputTokens: 700, outputTokens: 80, baseLatencyMs: 160, failureRate: 0.01, retries: 1 },
{ id: "support-assist", capability: "support.assist", kind: "model", inputTokens: 2_600, outputTokens: 420, baseLatencyMs: 620, failureRate: 0.03, retries: 1 },
{ id: "reply-final", capability: "chat.reply", kind: "model", inputTokens: 1_100, outputTokens: 240, baseLatencyMs: 420, failureRate: 0.02, retries: 1 },
],
successTarget: 0.97,
costBudgetMicros: 4_200,
p95LatencyBudgetMs: 1_800,
handoffShareCap: 0.35,
sharedReuseRatio: 0.6,
},
{
id: "C",
name: "检索下单",
nodes: [
{ id: "rerank", capability: "search.rerank", kind: "model", inputTokens: 700, outputTokens: 80, baseLatencyMs: 160, failureRate: 0.01, retries: 1 },
{ id: "reply", capability: "chat.reply", kind: "model", inputTokens: 1_100, outputTokens: 240, baseLatencyMs: 420, failureRate: 0.02, retries: 1 },
{ id: "create-order", capability: "order.create", kind: "action", inputTokens: 0, outputTokens: 0, baseLatencyMs: 120, failureRate: 0.04, retries: 0, fixedCostMicros: 80 },
],
successTarget: 0.97,
costBudgetMicros: 3_000,
p95LatencyBudgetMs: 1_400,
handoffShareCap: 0.3,
sharedReuseRatio: 0.72,
},
];
/** 每条样本必须带这些字段,少一个就无法复核交接与失败。 */
export const OBSERVATION_FIELDS = [
"runId",
"chain",
"nodeId",
"attempt",
"status",
"costMicros",
"latencyMs",
"handoffTokens",
"reusedTokens",
"failureKind",
"deadlineRemainingMs",
] as const;
export interface NodeSample {
nodeId: string;
status: NodeStatus;
attempts: number;
costMicros: number;
latencyMs: number;
handoffTokens: number;
reusedTokens: number;
failureKind?: string;
}
export interface RunSample {
runId: string;
chain: ChainId;
nodes: NodeSample[];
succeeded: boolean;
costMicros: number;
latencyMs: number;
}
/** 固定种子的线性同余生成器:同样的种子永远产出同样的样本。 */
function nextSeed(seed: number): number {
return (seed * 1_664_525 + 1_013_904_223) >>> 0;
}
function unit(seed: number): [number, number] {
const next = nextSeed(seed);
return [next, next / 0x1_0000_0000];
}
function inputCost(tokens: number): number {
return Math.ceil((tokens * ECHO_FACTS.inputMicrosPerKTok) / 1_000);
}
function outputCost(tokens: number): number {
return Math.ceil((tokens * ECHO_FACTS.outputMicrosPerKTok) / 1_000);
}
function assertChain(chain: ChainSpec): void {
if (chain.nodes.length === 0) throw new AiError("invalid-request", `baseline: 链路 ${chain.id} 没有节点`);
if (chain.successTarget <= 0 || chain.successTarget >= 1) throw new AiError("invalid-request", `baseline: ${chain.id}.successTarget 不合法`);
for (const node of chain.nodes) {
if (node.inputTokens < 0 || node.outputTokens < 0 || node.baseLatencyMs <= 0) {
throw new AiError("invalid-request", `baseline: ${node.id} 的 token 或延迟不合法`);
}
}
}
function tokenCost(node: ChainNode, sharedTokens: number): number {
if (node.kind !== "model") return node.fixedCostMicros ?? 0;
return inputCost(sharedTokens + node.inputTokens) + outputCost(node.outputTokens);
}
/**
* 逐节点执行一条链路。失败节点会耗尽重试,之后的下游节点标记为 skipped;
* 交接 token 与 35ms 只发生在第二个及之后的节点。
*/
export function runChain(chain: ChainSpec, runNumber: number, seed: number): RunSample {
assertChain(chain);
let currentSeed = seed;
const nodes: NodeSample[] = [];
let succeeded = true;
let firstModelSeen = false;
for (let index = 0; index < chain.nodes.length; index += 1) {
const node = chain.nodes[index];
if (!succeeded) {
nodes.push({ nodeId: node.id, status: "skipped", attempts: 0, costMicros: 0, latencyMs: 0, handoffTokens: 0, reusedTokens: 0 });
continue;
}
const handoffTokens = index === 0 ? 0 : ECHO_FACTS.handoffTokensPerHop;
let sharedTokens = 0;
let reusedTokens = 0;
if (node.kind === "model") {
if (!firstModelSeen) {
sharedTokens = ECHO_FACTS.sharedContextTokens;
firstModelSeen = true;
} else {
reusedTokens = Math.round(ECHO_FACTS.sharedContextTokens * chain.sharedReuseRatio);
sharedTokens = ECHO_FACTS.sharedContextTokens - reusedTokens;
}
}
const baseCost = tokenCost(node, sharedTokens) + inputCost(handoffTokens);
let attempts = 0;
let cost = 0;
let latency = 0;
let failed = true;
while (attempts <= node.retries) {
attempts += 1;
const [seedA, roll] = unit(currentSeed);
const [seedB, costJitter] = unit(seedA);
const [seedC, latencyJitter] = unit(seedB);
currentSeed = seedC;
cost += Math.round(baseCost * (1 + (costJitter - 0.5) * 0.06));
latency += Math.round(node.baseLatencyMs * (1 + (latencyJitter - 0.5) * 0.1));
if (index > 0) latency += ECHO_FACTS.handoffLatencyMs;
if (roll >= node.failureRate) {
failed = false;
break;
}
}
if (failed) {
succeeded = false;
nodes.push({ nodeId: node.id, status: "failed", attempts, costMicros: cost, latencyMs: latency, handoffTokens: handoffTokens * attempts, reusedTokens, failureKind: `${node.kind}-error` });
} else {
nodes.push({ nodeId: node.id, status: "ok", attempts, costMicros: cost, latencyMs: latency, handoffTokens: handoffTokens * attempts, reusedTokens });
}
}
return {
runId: `${chain.id}-${String(runNumber).padStart(3, "0")}`,
chain: chain.id,
nodes,
succeeded,
costMicros: nodes.reduce((sum, node) => sum + node.costMicros, 0),
latencyMs: nodes.reduce((sum, node) => sum + node.latencyMs, 0),
};
}
/** 生成固定规模的样本集合;种子与规模都是显式参数。 */
export function generateSamples(chain: ChainSpec, runs: number, seed: number): RunSample[] {
if (!Number.isInteger(runs) || runs <= 0) throw new AiError("invalid-request", "baseline: runs 必须是正整数");
const samples: RunSample[] = [];
let current = seed;
for (let index = 1; index <= runs; index += 1) {
const sample = runChain(chain, index, current);
samples.push(sample);
current = nextSeed(current + index);
}
return samples;
}
/** 最近秩 P95:n 个样本中第 ceil(0.95n) 个。 */
export function percentile95(values: readonly number[]): number {
if (values.length === 0) throw new AiError("invalid-request", "baseline: 无法计算空样本的 P95");
const sorted = [...values].sort((left, right) => left - right);
return sorted[Math.ceil(sorted.length * 0.95) - 1];
}
runChain 的关键是三个乘数:到达概率、尝试次数与交接次数。节点只有在前面都成功时才会执行,所以下游的样本天然带有「到达」语义;失败节点的重试会重复计费;每次非首节点尝试都会加 820 token 与 35ms。generateSamples 用固定种子的线性同余生成器,同样的链路、规模与种子永远得到同一组样本。
示例样本上的运行结果
三条链路各 240 条样本,种子分别为 1301、1308、1303。结果如下:
| 链路 | 样本 | 成功率 | 每次成功交付(微元) | P95(ms) | 交接成本占比 | 交接延迟占比 | 复用率 | 结论 |
|---|---|---|---|---|---|---|---|---|
| A 内容摘要与审核 | 240 | 96.25% | 3,496 | 3,523 | 18.00% | 3.05% | 93% | pass |
| B 会话回复 | 240 | 99.58% | 3,804 | 1,738 | 11.18% | 5.34% | 60% | pass |
| C 检索下单 | 240 | 95.00% | 2,429 | 793 | 17.91% | 9.00% | 72% | conditional |
三条链路合计 720 条样本,698 条成功,整链成功率 96.94%,每次成功交付 3,253 微元。总判定取最差的一条,因此是 conditional。
A 的 P95 是 3,523ms,均值 3,385ms,尾部几乎全部来自 2,400ms 的人工复核。A 的交接成本占比 18%,但因为人工节点不产生 token,交接在总成本里的绝对额并不高。A 的成功率 96.25% 刚好越过 96% 的目标,失败集中在人工拒绝与发布失败。
B 的 239/240 与 99.58% 接近卷二算出的 99.86% 理论值。平均每次运行 3,788 微元,与卷二的 3,786 微元只差抽样抖动;每次成功交付 3,804 微元,因为那一条失败样本的成本也被摊进了分母。B 的复用率 60%,正好对应卷二的复用参数;交接成本占比 11.18%,是三条链路里最低的。
C 的 95.00% 是唯一没有达标的一项。失败样本几乎都落在下单动作:4% 的动作失败率没有重试,直接把整链拉到 97% 以下。C 的 P95 只有 793ms,成本也最低,说明它不是「贵」或「慢」,而是「最后一跳不可靠」。这正是 13.2 要评审的对象。
结论判定
判定规则是五道门禁:成功率、每次成功交付成本、P95、交接成本占比、复用率。全部通过才是 pass;任意一项未过但未超过严重阈值是 conditional;成功率低于目标 5 个百分点、成本或 P95 超过预算 25%,或者同时有三项未过,才是 fail。
这套规则故意不让「平均分」决定结论。C 的成本和延迟都很好,但成功率不达标,结论就是 conditional;B 的成本不是最低,但五道门禁全过,结论就是 pass。基线的作用不是排名,而是把「哪条链路需要改、改哪里」变成可复核的事实。
基线还要能比较。样本版本、链路定义、种子、单价与判定阈值任一变化,旧基线就失效,必须重跑并记录差异。下一节评审方案时,所有「改动前」的数字都引用这张表,所有「改动后」的数字都必须用同一套样本重跑。
小结
编排基线由五部分组成:三条链路的节点定义、820 token / 35ms 的交接口径、分链路的成本与延迟预算、按节点类型区分的失败策略,以及十一个观测字段。240 条确定性样本跑出的结果是:A 96.25% / 3,496 微元 / P95 3,523ms,B 99.58% / 3,804 微元 / P95 1,738ms,C 95.00% / 2,429 微元 / P95 793ms;三条链路合计 96.94%,总判定 conditional。
基线不是一次性的报告,而是后续评审、灰度与回归的共同参照。C 链路的 95.00% 把问题缩小到下单动作,下一节就用一次真实格式的方案评审判定:这个改动值不值得做、做完预期是什么、什么条件下才能上线。
阅读导航:上一节:12.3 从固定编排到自主 Agent · 下一节:13.2 一次编排方案评审 。
常见错误与边界
- 把三条链路合成一个平均成功率。链路结构不同,平均值会掩盖 C 的下单失败。
- 只统计成功样本的成本。失败与重试的成本必须计入每次成功交付成本。
- 把交接当成零成本。820 token 与 35ms 在每个非首节点、每次尝试都会发生。
- 让动作节点自动重试。下单、支付、发券的重试必须有幂等键,否则会重复执行。
- 失败后不记录 skipped。下游跳过是结果的一部分,缺了它无法解释成功率。
- 用均值代替 P95。B 的均值 1,309ms,P95 1,738ms,尾部才是用户体验。
- 复用率越高越好。复用率要结合共享上下文与交接 token 一起看,不能单独排名。
- 改一个参数就沿用旧基线。种子、单价、节点定义或阈值变了,基线必须重跑。
- 把示例样本当成生产数据。240 条样本只用于演示口径,生产基线需要真实流量与更长窗口。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。