整体 99% 达标,为什么摘要节点是红的
月度 SLO 评审上,「回声」的三能力链路报出 99.0023% 的可用率,刚好越过 99% 的整体目标。团队准备结案,直到有人把节点报表摊开:content.summarize 的可用率是 99.3%,低于它自己的 99.5% 目标;search.rerank 与 chat.reply 都过了线。
整体过了,节点没过。这不是报表口径不一致,而是合成规则必然产生的现象:串行链路的整体可用率是各节点相乘,乘积一定低于最弱的那个节点;反过来说,只要整体目标比节点目标松,乘积就可能在某个节点已经失守时仍然过线。把节点目标与整体目标混为一谈,就会得到「整体绿灯、局部失血」的报表。
本节回答三个问题:节点 SLO 怎样合成整体 SLO;人工节点为什么必须单列;以及为什么整体达标不能替代节点达标。文中数字都来自 ai-lab 示例样本上的运行结果,不是生产实测。
节点 SLO 先要能被合成
一个可合成的节点 SLO 至少要有五个数:可用率、P95 延迟、单次成本、可用率目标、延迟目标。可用率是比率,必须写清窗口与分母,例如「自然月内成功运行数 ÷ 总运行数」;延迟用 P95,不用平均值;成本用整数微元。
| 节点 | 可用率 | P95 | 成本 | 可用率目标 | 延迟目标 |
|---|---|---|---|---|---|
search.rerank | 99.9% | 160ms | 1,055 微元 | 99.5% | 220ms |
content.summarize | 99.3% | 620ms | 1,643 微元 | 99.5% | 700ms |
chat.reply | 99.8% | 419ms | 1,088 微元 | 99.5% | 500ms |
三个节点的成本相加正好是 3,786 微元,延迟相加再加两次 35ms 交接正好是 1,269ms。节点 SLO 不是另一套账,它必须能还原出整体样本,否则合成结果无法与 10.1 的 trace 对账。
串行:可用率相乘,延迟相加
串行链路的整体可用率是各节点可用率的乘积:
0.999 × 0.993 × 0.998 = 0.990022986
也就是 99.0023%。它比最弱的 99.3% 还低,因为每一次交接都是一次新的失败机会。延迟则是各节点 P95 相加,再加交接固定开销:160 + 620 + 419 + 35 × 2 = 1,269ms。
相乘有一个容易被忽略的后果:链路越长,整体可用率下降越快。三个 99.5% 的节点串起来只有 98.5%,四个就跌破 98%。这不是模型变差了,而是失败机会变多了。想让长链路的整体目标保持 99%,单节点目标必须显著高于 99%。
并行:取弱,不是取平均
并行分支的合成规则取决于汇合语义。
all 汇合要求所有分支都成功,整体可用率仍是乘积,因此不会高于最弱分支;P95 取最大值,因为最慢的分支决定汇合时间。A 链路里摘要 99.3%、审核分类 99.9%,并行整体是 0.993 × 0.999 = 0.992007,最弱分支 99.3%,P95 是 max(620, 180) = 620ms。
any 汇合只要一个分支成功,整体可用率是 1 - (1 - a₁)(1 - a₂),P95 取最小值。同样两个分支,any 的可用率是 99.9993%,P95 是 180ms。这不是「并行更快」,而是「允许只交付一个结果」;业务是否接受单分支结果,是合成之前就要回答的问题。
最危险的错误是把并行可用率取平均。(99.3% + 99.9%) / 2 = 99.6% 看起来比 99.2007% 好看,但它描述的是一个不存在的系统:既不是两个都成功,也不是至少一个成功。平均值只适合做容量规划,不适合做 SLO 合成。
人工节点必须单列
人工复核的「可用率」与机器节点不是同一个量。机器节点说的是「调用是否成功」,人工节点说的是「是否在承诺窗口内完成」。把 97% 的人工按时率乘进机器可用率,会让整条链路的 SLO 变成人工排班的函数,机器侧的退化反而看不见。
正确做法是单列:机器可用率仍按前两节合成,人工节点单独报按时率与等待 P95;墙钟时间另算,把人工等待加回去。示例中人工复核按时率 97%、等待 P95 为 3.2 小时(11,520,000ms),机器可用率保持 99.0023%,墙钟 P95 变成 11,521,304ms。机器 P95 与墙钟 P95 相差三个数量级,混在一张表里没有解释力。
人工节点的目标也要单列。示例里人工按时率目标是 98%,实际 97%,整体判定因此不通过,misses 里是 human,而不是 availability。这样值班的人一眼就知道该去找排班,而不是去查模型。
为什么整体达标不代表节点达标
三个机制会让整体指标掩盖节点问题。
第一,目标松紧不同。整体目标 99%、节点目标 99.5%,乘积 99.0023% 可以同时满足「整体过线」与「摘要节点失守」。整体只比目标高 0.0023 个百分点,摘要却低了 0.2 个百分点。
第二,乘积掩盖个体。串行乘积低于最弱节点,但整体目标只要低于乘积,最弱节点仍可能低于它自己的目标。乘积不会告诉你「谁在拖后腿」,只会告诉你「合起来是多少」。
第三,重试与降级在整体层修复。节点失败一次后重试成功,整体记为成功,节点自身仍记了一次失败。如果节点 SLO 只统计最终交付,它会把重试成本藏起来;如果统计每次尝试,它又会与整体口径不一致。两个口径都要有,且要明确哪个用于对外承诺、哪个用于内部排障。
因此合成结果必须同时返回三样东西:整体指标、每个节点的独立判定、以及「整体过线但节点失守」的 masked 列表。只看第一样,SLO 报表就是一张只能结案、不能行动的表。
实现
下面的合成器把计划写成递归结构:节点、串行、并行。它不执行节点,只做算术与判定;人工节点在递归中被单独收集,不进入机器可用率。
// file: src/orch/observability-slo.ts
// tier: L2
// verify: node --test test/orch/observability.test.ts
import { AiError } from "../core/errors.ts";
export type SloNodeKind = "capability" | "human" | "action";
export type JoinMode = "all" | "any";
/** 单个节点的月度 SLO。可用率与延迟都带自己的目标,整体目标另算。 */
export interface NodeSlo {
id: string;
kind: SloNodeKind;
/** 月度可用率,0..1;人工节点表示按时完成率。 */
availability: number;
/** 节点自身 P95 延迟,毫秒。 */
p95LatencyMs: number;
/** 单次执行成本,整数微元。 */
costMicros: number;
targetAvailability: number;
targetP95LatencyMs: number;
}
export type SloPlan =
| { kind: "node"; node: NodeSlo }
| { kind: "serial"; children: readonly SloPlan[]; handoffLatencyMs: number }
| { kind: "parallel"; join: JoinMode; children: readonly SloPlan[] };
export interface SloTarget {
availability: number;
p95LatencyMs: number;
/** 人工节点的按时完成率目标;不传则不参与整体判定。 */
humanOnTimeRate?: number;
}
export interface NodeVerdict {
id: string;
kind: SloNodeKind;
availability: number;
p95LatencyMs: number;
availabilityPass: boolean;
latencyPass: boolean;
pass: boolean;
}
export interface HumanRollup {
count: number;
onTimeRate: number;
p95LatencyMs: number;
nodes: string[];
}
export interface SloComposition {
machineAvailability: number;
machineP95LatencyMs: number;
/** 含人工等待的墙钟 P95;不能与机器 P95 混在同一个 SLO 里。 */
wallClockP95LatencyMs: number;
costMicros: number;
human: HumanRollup | null;
nodes: NodeVerdict[];
/** 机器节点中可用率最低者;并行结构下整体不会高于它。 */
weakestNodeId: string | null;
overallPass: boolean;
/** 未达标的整体维度:availability / p95 / human。 */
misses: string[];
/** 自身未达标、却被整体指标掩盖的节点。 */
masked: string[];
}
function fail(message: string): never {
throw new AiError("invalid-request", `slo: ${message}`);
}
function ratio(value: number, field: string): number {
if (!Number.isFinite(value) || value < 0 || value > 1) fail(`${field} 必须落在 [0, 1]`);
return value;
}
function finite(value: number, field: string, min = 0): number {
if (!Number.isFinite(value) || value < min) fail(`${field} 必须是 >= ${min} 的有限数`);
return value;
}
function validateNode(node: NodeSlo): void {
if (node.id.trim() === "") fail("node.id 不能为空");
ratio(node.availability, `${node.id}.availability`);
ratio(node.targetAvailability, `${node.id}.targetAvailability`);
finite(node.p95LatencyMs, `${node.id}.p95LatencyMs`);
finite(node.targetP95LatencyMs, `${node.id}.targetP95LatencyMs`);
if (!Number.isInteger(node.costMicros) || node.costMicros < 0) {
fail(`${node.id}.costMicros 必须是非负整数`);
}
}
interface Folded {
machineAvailability: number;
machineP95LatencyMs: number;
machineWallMs: number;
costMicros: number;
humans: NodeSlo[];
}
function combine(plan: SloPlan, verdicts: NodeVerdict[]): Folded {
if (plan.kind === "node") {
const node = plan.node;
validateNode(node);
verdicts.push({
id: node.id,
kind: node.kind,
availability: node.availability,
p95LatencyMs: node.p95LatencyMs,
availabilityPass: node.availability >= node.targetAvailability,
latencyPass: node.p95LatencyMs <= node.targetP95LatencyMs,
pass: node.availability >= node.targetAvailability && node.p95LatencyMs <= node.targetP95LatencyMs,
});
if (node.kind === "human") {
// 机器可用率不乘人工节点,但墙钟时间必须把等待加回去。
return {
machineAvailability: 1,
machineP95LatencyMs: 0,
machineWallMs: node.p95LatencyMs,
costMicros: node.costMicros,
humans: [node],
};
}
return {
machineAvailability: node.availability,
machineP95LatencyMs: node.p95LatencyMs,
machineWallMs: node.p95LatencyMs,
costMicros: node.costMicros,
humans: [],
};
}
if (plan.children.length === 0) fail(`${plan.kind} 至少需要一个子计划`);
if (plan.kind === "serial") {
const folded = plan.children.map((child) => combine(child, verdicts));
const handoff = finite(plan.handoffLatencyMs, "handoffLatencyMs");
const boundaries = plan.children.length - 1;
return {
machineAvailability: folded.reduce((value, item) => value * item.machineAvailability, 1),
machineP95LatencyMs: folded.reduce((value, item) => value + item.machineP95LatencyMs, 0) + handoff * boundaries,
machineWallMs: folded.reduce((value, item) => value + item.machineWallMs, 0) + handoff * boundaries,
costMicros: folded.reduce((value, item) => value + item.costMicros, 0),
humans: folded.flatMap((item) => item.humans),
};
}
const folded = plan.children.map((child) => combine(child, verdicts));
const all = plan.join === "all";
return {
machineAvailability: all
? folded.reduce((value, item) => value * item.machineAvailability, 1)
: 1 - folded.reduce((value, item) => value * (1 - item.machineAvailability), 1),
machineP95LatencyMs: all
? Math.max(...folded.map((item) => item.machineP95LatencyMs))
: Math.min(...folded.map((item) => item.machineP95LatencyMs)),
machineWallMs: all
? Math.max(...folded.map((item) => item.machineWallMs))
: Math.min(...folded.map((item) => item.machineWallMs)),
costMicros: folded.reduce((value, item) => value + item.costMicros, 0),
humans: folded.flatMap((item) => item.humans),
};
}
/**
* 串行可用率相乘、延迟相加;并行 all 汇合乘积、P95 取最大;
* 人工节点不进入机器可用率,单独汇总按时率与等待时间。
*/
export function composeSlo(plan: SloPlan, target: SloTarget): SloComposition {
ratio(target.availability, "target.availability");
finite(target.p95LatencyMs, "target.p95LatencyMs");
if (target.humanOnTimeRate !== undefined) ratio(target.humanOnTimeRate, "target.humanOnTimeRate");
const verdicts: NodeVerdict[] = [];
const folded = combine(plan, verdicts);
const machineAvailability = Number(folded.machineAvailability.toFixed(9));
const machineP95LatencyMs = Math.round(folded.machineP95LatencyMs);
const humans = folded.humans;
const human: HumanRollup | null = humans.length === 0
? null
: {
count: humans.length,
onTimeRate: Math.min(...humans.map((node) => node.availability)),
p95LatencyMs: Math.max(...humans.map((node) => node.p95LatencyMs)),
nodes: humans.map((node) => node.id),
};
const misses: string[] = [];
if (machineAvailability < target.availability) misses.push("availability");
if (machineP95LatencyMs > target.p95LatencyMs) misses.push("p95");
if (human && target.humanOnTimeRate !== undefined && human.onTimeRate < target.humanOnTimeRate) {
misses.push("human");
}
const machineNodes = verdicts.filter((node) => node.kind !== "human");
const weakest = machineNodes.reduce<NodeVerdict | null>(
(low, node) => (low === null || node.availability < low.availability ? node : low),
null,
);
const overallPass = misses.length === 0;
return {
machineAvailability,
machineP95LatencyMs,
wallClockP95LatencyMs: Math.round(folded.machineWallMs),
costMicros: folded.costMicros,
human,
nodes: verdicts,
weakestNodeId: weakest?.id ?? null,
overallPass,
misses,
masked: overallPass ? verdicts.filter((node) => !node.pass).map((node) => node.id) : [],
};
}
combine 对三种结构分别处理:节点返回自身指标;串行把子结果相乘、相加;并行按 join 选择乘积或补集。人工节点返回 machineAvailability = 1,但保留 machineWallMs,这样它不会污染机器可用率,却会进入墙钟时间。
composeSlo 最后做三件事:把整体指标与目标比较,把每个节点与自己的目标比较,把「整体通过但节点不通过」的节点放进 masked。weakestNodeId 只在机器节点中选,人工节点不参与「最弱机器节点」的评选。
wallClockP95LatencyMs 与 machineP95LatencyMs 分开返回,是这套合成器最重要的接口决定。只要这两个数混在一起,人工等待就会把机器 SLO 的讨论带偏。
示例样本上的运行结果
对同一组节点分别按串行、并行与含人工的结构合成,结果如下。所有数字来自 node --test test/orch/observability.test.ts 的确定性断言。
| 结构 | 机器可用率 | 机器 P95 | 成本 | 最弱节点 | 整体判定 | 被掩盖节点 |
|---|---|---|---|---|---|---|
| 三节点串行 | 99.0023% | 1,269ms | 3,786 微元 | content.summarize | 通过 | content.summarize |
| 摘要 ∥ 分类(all) | 99.2007% | 620ms | 1,898 微元 | content.summarize | 通过 | content.summarize |
| 摘要 ∥ 分类(any) | 99.9993% | 180ms | 1,898 微元 | content.summarize | 通过 | content.summarize |
| 串行 + 人工复核 | 99.0023% | 1,304ms | 3,786 微元 | content.summarize | 不通过(human) | 无 |
| 重排 →(摘要 ∥ 分类)→ 回复 | 98.9033% | 1,269ms | 4,041 微元 | content.summarize | 通过(目标 98%) | content.summarize |
any 这一行最能说明问题:整体可用率被另一个分支抬到 99.9993%,摘要节点的 99.3% 仍然低于自己的 99.5% 目标,因此它照样出现在 masked 里。并行结构不会修复节点,只会让节点的失败更不容易被整体看见。
含人工的结构比三节点串行多一个节点,也多一次 35ms 交接,所以机器 P95 从 1,269ms 变成 1,304ms。人工等待 11,520,000ms 只进入墙钟 P95,不进入机器 P95。
节点目标怎么定
节点目标不是把整体目标平均分。串行链路的目标分配要满足 ∏ aᵢ ≥ A;三个节点时,每个节点至少要达到 A^(1/3)。整体 99% 意味着每个节点约 99.67%,而不是每个 99%。并行 all 同理;并行 any 则相反,整体目标可以高于任一节点。
这条约束解释了一个常见矛盾:产品承诺 99%,工程给每个节点定 99%,链路一上线就只有 97%。不是实现变差了,是目标从一开始就没有按结构分配。
小结
节点 SLO 合成有三条硬规则:串行可用率相乘、延迟相加;并行 all 取乘积与最大延迟,any 取补集与最小延迟;人工节点单列按时率与等待时间,不进入机器可用率。三能力样本的整体可用率是 99.0023%,刚好越过 99% 的整体目标,却掩盖了 99.3% 的摘要节点。
整体达标不代表节点达标。目标松紧、乘积掩盖与重试修复都会让整体指标失去定位能力。合成器必须同时返回整体指标、节点判定与 masked 列表,SLO 报表才能从「能不能结案」变成「该找谁」。
阅读导航:上一节:10.1 编排链路的最低观测字段 · 下一节:10.3 编排事故的止血与复盘 。
常见错误与边界
- 把节点目标平均分。串行链路要把整体目标开 n 次方,不是除以 n。
- 并行可用率取平均。平均值不对应任何汇合语义,只适合容量规划。
- 把人工按时率乘进机器可用率。机器退化会被人工排班掩盖。
- 把人工等待加进机器 P95。两个数差三个数量级,混在一起无法解释。
- 只报整体 SLO。整体过线时,
masked里的节点不会被看见。 - 用最终交付成功率替代节点成功率。重试成功会掩盖节点的首次失败。
- 用平均值代替 P95。长尾被平均掉,用户感知不到「平均很快」。
- 把不同结构的样本混着比较。串行 1,269ms 与含人工的 1,304ms 差一次交接。
- 忽略
any汇合的业务含义。允许单分支结果,才可以用补集公式。 - 把示例的 99.0023% 当成通用阈值。节点数与目标一变,合成结果就要重算。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。