《互联网产品的 AI 架构·多能力编排与任务闭环》1.1 一个请求要几个能力:回声的三条真实链路

同一个用户请求在回声会穿过不同能力:内容发布、私信客服、站内下单三条链路的节点、成本、延迟与成功率都不一样。本节用可运行模型把链路展开成节点级账单与失败预算,算出每次成功交付成本,说明为什么请求数不等于能力调用数,以及容量规划应该用哪个数来估算。

一个请求穿过了几个能力

「回声」是内容社区、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 / 43 / 33 / 3
期望成本2,3132,3281,688
每次成功交付成本2,3182,3311,697
整链成功率99.7798%99.8576%99.4503%
全路径延迟90,945970900
期望延迟17,095969900
重试成本644923
失败浪费539

节点级分布更能说明钱花在哪。

节点链路节点成本节点延迟有效失败率
content.summarizeA1,9266200.09%
moderation.classifyA3881800.04%
human.reviewA090,0000.50%
action.publishA0400.0004%
chat.reply(判意图)B1,0351800.04%
support.assistB6713000.0625%
chat.reply(答复)B6224200.04%
search.rerankC1,0661600.01%
chat.reply(说明)C6224200.04%
action.createOrderC02500.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 当成生产数据。换一批请求结构,数字要重算。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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