一个请求穿过了几个能力
「回声」是内容社区、IM 与轻交易的组合产品。产品经理提需求时通常只说一句「给这个入口加个 AI」,工程侧收到的却是一个请求可能穿过多个能力:作者点发布,先摘要再审核,灰度内容还要人工复核;用户发私信,先判意图再检索知识库,最后才生成答复;用户在商品页咨询,先检索再说明,最后落到下单动作。
如果按入口数估算容量,会得到「发布、私信、咨询三个入口」的结论;如果按能力调用数估算,同一批流量会变成 2 到 4 次模型调用,外加人工与外部动作。容量、账单、延迟预算和失败率都按后者发生,所以第一步不是选模型,而是把请求还原成链路。
本节把三条链路写成同一套可计算结构:每个节点的 token、延迟、失败率与重试次数进入模型,输出整链成功率、期望成本、每次成功交付成本与延迟。文中所有数字都是 ai-lab 示例样本上的运行结果,不是生产实测;价目表按千 token 计,输入 250 微元、输出 1,000 微元。
三条链路,不是三个接口
三条链路的触发点、节点构成与失败后果都不同。把它们放进同一张表,才能看出哪些节点花 token、哪些节点只花时间。
| 链路 | 触发 | 节点顺序 | 失败后果 |
|---|---|---|---|
| A 内容发布安全链 | 作者点发布 | content.summarize → moderation.classify → 人工复核(18%)→ 发布 | 内容不发布,或风险内容被放行 |
| B 私信客服链 | 用户发私信 | chat.reply 判意图 → support.assist 检索 → chat.reply 答复 | 答非所问,或该转人工没转 |
| C 站内下单咨询链 | 商品页咨询 | search.rerank 检索 → chat.reply 说明 → 创建订单 | 下错单、重复下单,或用户放弃 |
三条链路都只有两个不同的 AI 能力,但结构完全不同。A 有两个不可计 token 的节点:人工复核和发布动作;C 的最后一个节点是不可回滚的外部动作;B 没有外部副作用,失败可以重答。这意味着三条链路不能用同一个成功率目标,也不能用同一套重试策略。
一个容易犯的错是把「能力数」当成「调用数」。B 里的 chat.reply 出现两次:第一次输出 40 token 的意图标签,第二次输出 240 token 的答复。它们是同一个能力的两次独立调用、两次独立计费、两次独立失败。链路模型必须按节点算,不能按能力名去重。
三个成本视角
单次执行成本回答「这条链路跑一遍大概多少钱」;每次成功交付成本回答「一条可用结果到底多少钱」;失败浪费回答「多少钱花在了没有交付的请求上」。三个数一起看,才能判断链路是否值得优化。
计算口径可以写成三行:
期望成本 = Σ 到达率 × 执行率 × 单次成本 × 期望尝试次数
成功率 = Π (执行率 × 单步成功率 + (1 − 执行率))
每次成功成本 = 期望成本 ÷ 成功率
到达率是「前面的阶段都成功」的概率,所以下游阶段的成本要乘以前序成功率。执行率处理条件分支:A 的人工复核只有 18% 的请求会触发,它的成本与延迟都按 18% 加权,但成功率按「18% 执行时的人工失败率」折算。
重试改变的是单步失败率与期望尝试次数。一个节点重试 r 次后,整步失败当且仅当 r+1 次都失败,有效失败率是 f^(r+1);在单次成本固定的前提下,期望尝试次数是几何级数 1 + f + … + f^r。r=1 时它等于 1+f,与卷二的三能力模型一致。
把链路写成可计算的结构
workflow-shape.ts 用三层结构描述链路:FlowNode 是可计量动作,FlowStage 是最小调度单位,ChainSpec 把阶段串起来。阶段内可以是串行或并行;阶段之间按固定 35ms 交接。首跳携带 3,200 token 共享上下文,后续每跳只携带 820 token 增量上下文与上一跳结果;前缀复用率 60%,命中部分按 10% 计价。
// file: src/orch/workflow-shape.ts
// tier: L1
// verify: node --test test/orch/workflow-shape.test.ts
import { AiError } from "../core/errors.ts";
export type NodeKind = "ai" | "human" | "external";
export type StageMode = "serial" | "parallel";
export type ChainId = "A" | "B" | "C";
/** 一次可计量动作:模型调用、人工处理或外部副作用。 */
export interface FlowNode {
id: string; capability: string; kind: NodeKind;
inputTokens: number; outputTokens: number; latencyMs: number;
failureRate: number; retries: number;
}
/** 阶段内串行或并行;executionRate 让条件分支直接进入模型。 */
export interface FlowStage {
id: string; mode: StageMode; executionRate: number; nodes: readonly FlowNode[];
}
export interface ChainSpec {
id: ChainId; name: string;
sharedContextTokens: number; handoffTokens: number; handoffLatencyMs: number;
stages: readonly FlowStage[];
}
export interface PriceSheet {
inputMicrosPerKTok: number; outputMicrosPerKTok: number;
prefixReuseRatio: number; cachedInputPriceRatio: number;
}
export interface NodeBreakdown {
id: string; capability: string; kind: NodeKind; contextTokens: number;
attemptCostMicros: number; expectedCostMicros: number;
effectiveFailureRate: number; latencyMs: number;
}
export interface StageBreakdown {
id: string; mode: StageMode; executionRate: number;
costMicros: number; retryCostMicros: number;
successGivenRun: number; expectedSuccess: number; latencyMs: number;
nodes: NodeBreakdown[];
}
export interface ChainRun {
id: ChainId; name: string; nodeCount: number; stageCount: number;
successProbability: number; expectedCostMicros: number; costPerSuccessMicros: number;
/** 所有阶段都执行且不重试时的延迟,即最坏路径。 */
fullPathLatencyMs: number;
/** 按到达概率与条件执行率加权的延迟,即典型请求。 */
expectedLatencyMs: number;
retryCostMicros: number; wastedCostMicros: number; handoffCount: number;
stages: StageBreakdown[];
}
export const ECHO_PRICE: PriceSheet = {
inputMicrosPerKTok: 250, outputMicrosPerKTok: 1_000,
prefixReuseRatio: 0.6, cachedInputPriceRatio: 0.1,
};
function fail(message: string): never {
throw new AiError("invalid-request", `workflow-shape: ${message}`);
}
function integer(value: number, field: string, min = 0): number {
if (!Number.isInteger(value) || value < min) fail(`${field} 必须是 >= ${min} 的整数`);
return value;
}
function finite(value: number, field: string, min = 0): number {
if (!Number.isFinite(value) || value < min) fail(`${field} 必须是 >= ${min} 的有限数`);
return value;
}
function ratio(value: number, field: string): number {
if (!Number.isFinite(value) || value <= 0 || value > 1) fail(`${field} 必须落在 (0, 1]`);
return value;
}
function tokenCost(tokens: number, microsPerKTok: number): number {
return Math.ceil((tokens * microsPerKTok) / 1_000);
}
/** 首跳全价;后续跳按前缀复用率与缓存折扣计价。 */
export function contextCost(tokenCount: number, price: PriceSheet, reused: boolean): number {
const factor = reused ? 1 - price.prefixReuseRatio * (1 - price.cachedInputPriceRatio) : 1;
return Math.ceil((tokenCount * price.inputMicrosPerKTok * factor) / 1_000);
}
/** 单次尝试 = 上下文 + 输入 + 输出;人工与外部动作不计 token。 */
export function attemptCost(
node: FlowNode, price: PriceSheet, contextTokens: number, reused: boolean,
): number {
const context = node.kind === "ai" ? contextCost(contextTokens, price, reused) : 0;
return context +
tokenCost(node.inputTokens, price.inputMicrosPerKTok) +
tokenCost(node.outputTokens, price.outputMicrosPerKTok);
}
/** 重试 r 次后,整步失败当且仅当每次都失败。 */
export function effectiveFailureRate(node: FlowNode): number {
return Math.pow(node.failureRate, node.retries + 1);
}
/** 固定单次成本下的期望尝试次数:1 + f + … + f^r,r=1 时等于 1+f。 */
export function expectedAttempts(node: FlowNode): number {
if (node.failureRate === 0) return 1;
return (1 - Math.pow(node.failureRate, node.retries + 1)) / (1 - node.failureRate);
}
export function validateChain(chain: ChainSpec): void {
if (chain.stages.length === 0) fail("链路至少需要一个阶段");
integer(chain.sharedContextTokens, "sharedContextTokens");
integer(chain.handoffTokens, "handoffTokens");
finite(chain.handoffLatencyMs, "handoffLatencyMs");
const stageIds = new Set<string>();
const nodeIds = new Set<string>();
for (const stage of chain.stages) {
if (stage.id.trim() === "" || stageIds.has(stage.id)) fail(`stage.id 非法或重复: ${stage.id}`);
stageIds.add(stage.id);
ratio(stage.executionRate, `${stage.id}.executionRate`);
if (stage.nodes.length === 0) fail(`${stage.id} 没有节点`);
for (const node of stage.nodes) {
if (node.id.trim() === "" || nodeIds.has(node.id)) fail(`node.id 非法或重复: ${node.id}`);
nodeIds.add(node.id);
integer(node.inputTokens, `${node.id}.inputTokens`);
integer(node.outputTokens, `${node.id}.outputTokens`);
finite(node.latencyMs, `${node.id}.latencyMs`);
if (!(node.failureRate >= 0 && node.failureRate < 1)) fail(`${node.id}.failureRate 必须落在 [0, 1)`);
integer(node.retries, `${node.id}.retries`);
}
}
}
/** 把一条链路展开成节点级成本、阶段级成功率与整链期望值。 */
export function evaluateChain(chain: ChainSpec, price: PriceSheet = ECHO_PRICE): ChainRun {
validateChain(chain);
integer(price.inputMicrosPerKTok, "inputMicrosPerKTok", 1);
integer(price.outputMicrosPerKTok, "outputMicrosPerKTok", 1);
ratio(price.prefixReuseRatio, "prefixReuseRatio");
ratio(price.cachedInputPriceRatio, "cachedInputPriceRatio");
let reach = 1, success = 1, expectedCost = 0, retryCost = 0;
let fullPathLatency = 0, expectedLatency = 0, nodeCount = 0, handoffCount = 0;
const stages: StageBreakdown[] = [];
chain.stages.forEach((stage, index) => {
const contextTokens = index === 0 ? chain.sharedContextTokens : chain.handoffTokens;
const handoffMs = index === 0 ? 0 : chain.handoffLatencyMs;
if (index > 0) handoffCount += 1;
let stageCost = 0, stageRetryCost = 0, stageSuccess = 1;
let serialLatency = 0, parallelLatency = 0;
const nodes: NodeBreakdown[] = [];
for (const node of stage.nodes) {
nodeCount += 1;
const single = attemptCost(node, price, contextTokens, index > 0);
const attempts = expectedAttempts(node);
stageCost += single * attempts;
stageRetryCost += single * (attempts - 1);
stageSuccess *= 1 - effectiveFailureRate(node);
serialLatency += node.latencyMs;
parallelLatency = Math.max(parallelLatency, node.latencyMs);
nodes.push({
id: node.id, capability: node.capability, kind: node.kind, contextTokens,
attemptCostMicros: single,
expectedCostMicros: Math.round(stage.executionRate * single * attempts),
effectiveFailureRate: Number(effectiveFailureRate(node).toFixed(6)),
latencyMs: node.latencyMs,
});
}
const stageLatency = stage.mode === "parallel" ? parallelLatency : serialLatency;
const expectedSuccess = stage.executionRate * stageSuccess + (1 - stage.executionRate);
expectedCost += reach * stage.executionRate * stageCost;
retryCost += reach * stage.executionRate * stageRetryCost;
expectedLatency += reach * stage.executionRate * (stageLatency + handoffMs);
fullPathLatency += stageLatency + handoffMs;
stages.push({
id: stage.id, mode: stage.mode, executionRate: stage.executionRate,
costMicros: Math.round(stageCost), retryCostMicros: Math.round(stageRetryCost),
successGivenRun: Number(stageSuccess.toFixed(6)),
expectedSuccess: Number(expectedSuccess.toFixed(6)),
latencyMs: stageLatency, nodes,
});
success *= expectedSuccess;
reach *= expectedSuccess;
});
return {
id: chain.id, name: chain.name, nodeCount, stageCount: chain.stages.length,
successProbability: Number(success.toFixed(6)),
expectedCostMicros: Math.round(expectedCost),
costPerSuccessMicros: success === 0 ? Number.POSITIVE_INFINITY : Math.round(expectedCost / success),
fullPathLatencyMs: Math.round(fullPathLatency),
expectedLatencyMs: Math.round(expectedLatency),
retryCostMicros: Math.round(retryCost),
wastedCostMicros: Math.round(expectedCost * (1 - success)),
handoffCount, stages,
};
}
const node = (
id: string, capability: string, kind: NodeKind, inputTokens: number,
outputTokens: number, latencyMs: number, failureRate: number, retries: number,
): FlowNode => ({ id, capability, kind, inputTokens, outputTokens, latencyMs, failureRate, retries });
const stage = (
id: string, nodes: FlowNode[], executionRate = 1, mode: StageMode = "serial",
): FlowStage => ({ id, mode, executionRate, nodes });
const chain = (id: ChainId, name: string, stages: FlowStage[]): ChainSpec =>
({ id, name, sharedContextTokens: 3_200, handoffTokens: 820, handoffLatencyMs: 35, stages });
/** 链路 A:人工复核只在 18% 的灰度区间触发。 */
export const CHAIN_A = chain("A", "内容发布安全链", [
stage("summarize", [node("summarize", "content.summarize", "ai", 2_600, 420, 620, 0.03, 1)]),
stage("moderate", [node("moderate", "moderation.classify", "ai", 900, 60, 180, 0.02, 1)]),
stage("human-review", [node("human-review", "human.review", "human", 0, 0, 90_000, 0.005, 0)], 0.18),
stage("publish", [node("publish", "action.publish", "external", 0, 0, 40, 0.002, 1)]),
]);
/** 链路 B:判意图、检索、答复三步串行。 */
export const CHAIN_B = chain("B", "私信客服链", [
stage("intent", [node("intent", "chat.reply", "ai", 700, 40, 180, 0.02, 1)]),
stage("retrieve", [node("retrieve", "support.assist", "ai", 1_200, 260, 300, 0.025, 1)]),
stage("answer", [node("answer", "chat.reply", "ai", 1_100, 240, 420, 0.02, 1)]),
]);
/** 链路 C:顺序固定,最后一步是不可回滚的外部动作。 */
export const CHAIN_C = chain("C", "站内下单咨询链", [
stage("rerank", [node("rerank", "search.rerank", "ai", 700, 80, 160, 0.01, 1)]),
stage("explain", [node("explain", "chat.reply", "ai", 1_100, 240, 420, 0.02, 1)]),
stage("create-order", [node("create-order", "action.createOrder", "external", 0, 0, 250, 0.005, 0)]),
]);
export const ECHO_CHAINS: readonly ChainSpec[] = [CHAIN_A, CHAIN_B, CHAIN_C];
evaluateChain 的关键是把「到达率」乘进每一层。第 i 个阶段的成本不是它自己的成本,而是「前面都成功」乘以「它被执行」再乘以「含重试的单次成本」。失败传播因此不是一句定性描述,而是一个会改变期望成本的乘数。
attemptCost 只对 kind === "ai" 的节点计上下文费用。人工与外部动作不产生 token 账单,但它们的延迟与失败率照常进入模型。这个区分很重要:如果只统计模型账单,A 链的瓶颈会被完全漏掉。
示例样本上的运行结果
把三条链路输入 evaluateChain,结果如下。成本单位是微元,延迟单位是毫秒。
| 指标 | A 发布安全链 | B 私信客服链 | C 下单咨询链 |
|---|---|---|---|
| 节点数 / 阶段数 | 4 / 4 | 3 / 3 | 3 / 3 |
| 期望成本 | 2,313 | 2,328 | 1,688 |
| 每次成功交付成本 | 2,318 | 2,331 | 1,697 |
| 整链成功率 | 99.7798% | 99.8576% | 99.4503% |
| 全路径延迟 | 90,945 | 970 | 900 |
| 期望延迟 | 17,095 | 969 | 900 |
| 重试成本 | 64 | 49 | 23 |
| 失败浪费 | 5 | 3 | 9 |
节点级分布更能说明钱花在哪。
| 节点 | 链路 | 节点成本 | 节点延迟 | 有效失败率 |
|---|---|---|---|---|
| content.summarize | A | 1,926 | 620 | 0.09% |
| moderation.classify | A | 388 | 180 | 0.04% |
| human.review | A | 0 | 90,000 | 0.50% |
| action.publish | A | 0 | 40 | 0.0004% |
| chat.reply(判意图) | B | 1,035 | 180 | 0.04% |
| support.assist | B | 671 | 300 | 0.0625% |
| chat.reply(答复) | B | 622 | 420 | 0.04% |
| search.rerank | C | 1,066 | 160 | 0.01% |
| chat.reply(说明) | C | 622 | 420 | 0.04% |
| action.createOrder | C | 0 | 250 | 0.50% |
A 的成本有 83.3% 落在摘要(1,926 ÷ 2,313),延迟却几乎全部落在人工:人工节点 18% 触发、单次 90 秒,期望贡献 16,200ms,占 17,095ms 的 94.8%。这就是「成本视角」和「延迟视角」必须分开看的原因。只优化摘要,A 的延迟几乎不会动;想压 A 的延迟,只能改人工的触发条件或把人工改成异步。
B 的成本沿链路递减:1,035、671、622。首跳携带完整 3,200 token 上下文,后续只带 820 token 并享受前缀折扣,所以后两跳更便宜。但检索节点的单次失败率是 2.5%,高于前后两跳的 2%,它是 B 成功预算的主要消耗者。
C 最便宜,成功率却最低:99.4503%。原因是最后一步 action.createOrder 单次失败率 0.5% 且不重试,而它又是不可回滚的外部动作。模型里的 0.5% 不是「重试一下就好」,而是「订单可能已经创建但响应丢失」;这类失败要靠幂等键与补偿处理,不能靠重试次数解决。
请求数不等于调用数
假设一天的请求结构是 A 20%、B 50%、C 30%,加权后的每次请求成本是:
0.2 × 2,313 + 0.5 × 2,328 + 0.3 × 1,688 = 2,133 微元
而按「三个入口、每个入口一次调用」估算,会得到完全不同的容量与账单。链路模型给出的 2,133 微元才是容量规划应该用的数字;它同时告诉你,B 占了成本的一半,优化 B 的收益最大。
还有一个常被忽略的读数:A 的期望延迟是 17,095ms,但全路径延迟是 90,945ms。两个数都对,回答的问题不同。前者用于估算用户平均等待,后者用于判断最坏情况下是否会撞上上游超时。只报一个数,评审时一定有人问另一个。
小结
一个请求在「回声」会穿过 3 到 4 个节点:两个 AI 能力、可能的人工复核、可能的外部动作。按示例样本,A/B/C 三条链路的期望成本分别是 2,313、2,328、1,688 微元,每次成功交付成本是 2,318、2,331、1,697 微元,成功率是 99.7798%、99.8576%、99.4503%。
三个读数各有用途:成本告诉你钱花在哪个节点,期望延迟告诉你用户等多久,全路径延迟告诉你最坏情况有多坏。链路结构一旦写成 FlowNode/FlowStage,这三个数就可以被复算,而不是靠拍脑袋。
阅读导航:上一节:目录与学习路径 · 下一节:1.2 串行、并行与条件分支 。
常见错误与边界
- 按入口数估算能力调用数。一个入口可能触发 2 到 4 次调用。
- 按能力名去重。
chat.reply在 B 里调用两次,是两次独立计费与独立失败。 - 只统计 AI 节点。人工与外部动作不花 token,但会主导延迟与失败后果。
- 用单次执行成本代替每次成功交付成本。失败请求同样产生费用。
- 把重试当免费。重试同时放大成本、延迟与下游压力,必须有全局预算。
- 首跳之后仍复制完整上下文。后续跳应只带增量与上一跳结果,并计算复用率。
- 把 18% 的人工触发率当成 0 成本。它的期望延迟仍可能占整链的九成。
- 给不可回滚的外部动作配重试。订单可能已创建,重试会制造重复副作用。
- 三条链路共用同一个成功率目标。发布安全、客服答复与下单的失败后果不同。
- 把示例样本的 2,313/2,328/1,688 当成生产数据。换一批请求结构,数字要重算。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。