《互联网产品的 AI 架构·推理性能与成本工程》13.3 进入卷三

单能力优化收口之后,三个能力串成一条工作流会带来四类新成本:跨能力调用、上下文复用、结果传递与失败传播。本节用可复算的三能力场景跑出交接税、成功概率与回本复用率,并给卷三留下三个必须回答的编排问题,并附上可运行的 TypeScript 模块。

单能力都优化完了,然后呢

「回声」的三个能力各自完成了优化:检索重排换了经济档,摘要加了前缀缓存,回复加了输出上限。单看每个能力,成本都降了,延迟都稳了。于是产品提出下一步:把三个能力串起来,用户问一个问题,系统先检索、再摘要、最后生成回复。

上线前,团队按单能力的成本相加,预估每条请求 4,240 微元。实际跑起来,成本确实低了一点,但延迟涨了,失败时的表现也变得难以解释:检索失败后,摘要还要不要跑?摘要跑了一半失败,回复能不能用部分结果?这些问题在单能力视角下根本不存在。

多能力编排不是把三个单能力相加。它会引入四类新成本:跨能力调用、上下文复用、结果传递与失败传播。前两类影响钱,后两类影响钱和体验。本节用一个三能力场景把这四类成本量化,并给卷三留下三个问题。

文中成本、延迟与成功率都来自 ai-lab 的示例样本运行结果,不是生产实测;单价与 token 数是用于演示的假设参数。

四个新成本

新成本具体表现单能力视角为什么看不见
跨能力调用每次交接都要一次新请求、一次新计费单能力只算自己那一次
上下文复用共享上下文重复传还是按前缀复用单能力没有「共享」概念
结果传递上一跳结果变成下一跳输入,token 重新计费单能力没有上游产物
失败传播上游失败导致下游跳过或重试,成本与成功率一起变单能力的失败是局部失败

四类成本里,只有第一类容易被看见,因为它对应一笔明确的调用费。后三类都藏在 token 与概率里:上下文复用决定省多少,结果传递决定加多少,失败传播决定这些节省能不能兑现。

一个三能力场景

场景由三个顺序步骤组成。每个能力独立调用时都要携带完整的 3,200 token 共享上下文;编排时,首跳携带完整上下文,后续只传 600 token 增量上下文与 220 token 上一跳结果。

步骤输入 token输出 token延迟单次失败率
search.rerank70080160ms1%
doc.summarize2,600420620ms3%
chat.reply1,100240420ms2%

计价参数:输入 250 微元/百万 token,输出 1,000 微元/百万 token;前缀复用率 60%,缓存输入单价为全价的 10%;每步允许 1 次重试;每次交接额外 35ms。

前缀复用率不是拍脑袋的数字。它来自第 3 章的前缀缓存条件:稳定前缀越长、提示改版越少,复用率越高。60% 意味着共享上下文里仍有 40% 需要全价重算,这已经比「每次复制完整上下文」便宜。

实现

下面的模块把「独立调用」与「顺序编排」放在同一套单价下比较。它计算两件事:编排省下多少重复上下文,以及交接、重试与失败传播又加回多少成本。

// file: src/perf/handoff.ts
// tier: L1
// verify: node --test test/perf/handoff.test.ts

import { AiError } from "../core/errors.ts";

export interface OrchestrationStep {
  id: string;
  inputTokens: number;
  outputTokens: number;
  latencyMs: number;
  /** 单次尝试的失败率;重试会把有效失败率降到它的幂。 */
  failureRate: number;
}

/**
 * 多能力编排的最小成本模型:共享上下文在首跳全价,后续按前缀复用计价;
 * 每次交接额外传递上一跳结果与增量上下文。
 */
export interface OrchestrationScenario {
  steps: readonly OrchestrationStep[];
  sharedContextTokens: number;
  handoffContextTokens: number;
  resultTokensPerHop: number;
  prefixReuseRatio: number;
  cachedInputPriceRatio: number;
  retriesPerStep: number;
  handoffLatencyMs: number;
  inputMicrosPerKTok: number;
  outputMicrosPerKTok: number;
}

export interface HandoffAnalysis {
  steps: number;
  standaloneCostMicros: number;
  orchestratedCostMicros: number;
  handoffTokensPerHop: number;
  /** 相对“每个能力各自携带完整上下文”的成本变化;负数代表更省。 */
  costDeltaPct: number;
  retryCostMicros: number;
  successProbability: number;
  costPerSuccessMicros: number;
  standaloneCostPerSuccessMicros: number;
  /** 编排后成功路径的累计延迟,包含每次交接开销。 */
  latencyMs: number;
  latencyTaxPct: number;
  /** 复用率低于此值时,共享上下文不如直接复制。 */
  breakEvenReuseRatio: number | null;
  /** 要让整体成功率达到目标,单步失败率的上限。 */
  maxStepFailureForTarget: number;
  expectedWastedCostMicros: number;
}

export const HANDOFF_QUESTIONS = [
  "跨能力调用:哪些上下文必须随调用传递,哪些可以只传引用、摘要或游标?",
  "上下文复用:共享前缀的复用率低于多少时,传递成本会超过复制成本?",
  "失败传播:上游失败后,下游应该跳过、重试,还是用部分结果继续?",
] as const;

function fail(message: string): never {
  throw new AiError("invalid-request", `handoff: ${message}`);
}

function finite(value: number, field: string, min = 0): number {
  if (!Number.isFinite(value) || value < min) fail(`${field} must be a finite number >= ${min}`);
  return value;
}

function integer(value: number, field: string, min = 0): number {
  if (!Number.isInteger(value) || value < min) fail(`${field} must be an integer >= ${min}`);
  return value;
}

function ratio(value: number, field: string): number {
  if (!Number.isFinite(value) || value < 0 || value > 1) fail(`${field} must be in [0, 1]`);
  return value;
}

function inputCost(tokens: number, pricePerKTok: number, factor = 1): number {
  return Math.ceil((tokens * pricePerKTok * factor) / 1_000);
}

function outputCost(tokens: number, pricePerKTok: number): number {
  return Math.ceil((tokens * pricePerKTok) / 1_000);
}

function validate(scenario: OrchestrationScenario): void {
  if (scenario.steps.length === 0) fail("至少需要一个能力步骤");
  const seen = new Set<string>();
  for (const step of scenario.steps) {
    if (step.id.trim() === "") fail("step.id 不能为空");
    if (seen.has(step.id)) fail(`重复的 step.id ${step.id}`);
    seen.add(step.id);
    integer(step.inputTokens, `${step.id}.inputTokens`);
    integer(step.outputTokens, `${step.id}.outputTokens`);
    finite(step.latencyMs, `${step.id}.latencyMs`);
    ratio(step.failureRate, `${step.id}.failureRate`);
  }
  integer(scenario.sharedContextTokens, "sharedContextTokens");
  integer(scenario.handoffContextTokens, "handoffContextTokens");
  integer(scenario.resultTokensPerHop, "resultTokensPerHop");
  ratio(scenario.prefixReuseRatio, "prefixReuseRatio");
  ratio(scenario.cachedInputPriceRatio, "cachedInputPriceRatio");
  integer(scenario.retriesPerStep, "retriesPerStep");
  finite(scenario.handoffLatencyMs, "handoffLatencyMs");
  integer(scenario.inputMicrosPerKTok, "inputMicrosPerKTok", 1);
  integer(scenario.outputMicrosPerKTok, "outputMicrosPerKTok", 1);
}

/**
 * 把“独立调用”与“顺序编排”放在同一套 token 单价下比较。
 * 编排省下的是重复上下文,付出的是交接 token、交接延迟与失败传播。
 */
export function analyzeHandoff(
  scenario: OrchestrationScenario,
  successTarget = 0.95,
): HandoffAnalysis {
  validate(scenario);
  if (!(successTarget > 0 && successTarget < 1)) fail("successTarget 必须落在 (0, 1)");
  const reuseFactor = 1 - scenario.prefixReuseRatio * (1 - scenario.cachedInputPriceRatio);
  const handoffTokensPerHop = scenario.handoffContextTokens + scenario.resultTokensPerHop;

  let standaloneCost = 0;
  let standaloneFullCost = 0;
  let orchestratedCost = 0;
  let fullCost = 0;
  let expectedCost = 0;
  let expectedWithoutRetry = 0;
  let reach = 1;
  let success = 1;
  let latency = 0;
  let standaloneLatency = 0;

  scenario.steps.forEach((step, index) => {
    const standaloneStep =
      inputCost(scenario.sharedContextTokens + step.inputTokens, scenario.inputMicrosPerKTok) +
      outputCost(step.outputTokens, scenario.outputMicrosPerKTok);
    standaloneCost += standaloneStep;
    standaloneLatency += step.latencyMs;

    const sharedCost = index === 0
      ? inputCost(scenario.sharedContextTokens, scenario.inputMicrosPerKTok)
      : inputCost(scenario.sharedContextTokens, scenario.inputMicrosPerKTok, reuseFactor);
    const handoffCost = index === 0
      ? 0
      : inputCost(handoffTokensPerHop, scenario.inputMicrosPerKTok);
    const stepCost = sharedCost + handoffCost +
      inputCost(step.inputTokens, scenario.inputMicrosPerKTok) +
      outputCost(step.outputTokens, scenario.outputMicrosPerKTok);
    orchestratedCost += stepCost;

    const attemptFactor = 1 + scenario.retriesPerStep * step.failureRate;
    const effectiveFailure = Math.pow(step.failureRate, 1 + scenario.retriesPerStep);
    standaloneFullCost += standaloneStep * attemptFactor;
    fullCost += stepCost * attemptFactor;
    expectedCost += reach * stepCost * attemptFactor;
    expectedWithoutRetry += reach * stepCost;
    latency += reach * (step.latencyMs + (index === 0 ? 0 : scenario.handoffLatencyMs));
    reach *= 1 - effectiveFailure;
    success *= 1 - effectiveFailure;
  });

  const costPerSuccess = success === 0 ? Number.POSITIVE_INFINITY : expectedCost / success;
  const standaloneCostPerSuccess = success === 0 ? Number.POSITIVE_INFINITY : standaloneFullCost / success;
  const breakEven = handoffTokensPerHop === 0
    ? 0
    : scenario.sharedContextTokens === 0 || scenario.cachedInputPriceRatio === 1
      ? null
      : handoffTokensPerHop / (scenario.sharedContextTokens * (1 - scenario.cachedInputPriceRatio));
  const maxStepFailure = Math.pow(
    1 - Math.pow(successTarget, 1 / scenario.steps.length),
    1 / (1 + scenario.retriesPerStep),
  );

  return {
    steps: scenario.steps.length,
    standaloneCostMicros: standaloneCost,
    orchestratedCostMicros: orchestratedCost,
    handoffTokensPerHop,
    costDeltaPct: standaloneCost === 0 ? 0 : ((orchestratedCost - standaloneCost) / standaloneCost) * 100,
    retryCostMicros: Math.round(expectedCost - expectedWithoutRetry),
    successProbability: success,
    costPerSuccessMicros: Math.round(costPerSuccess),
    standaloneCostPerSuccessMicros: Math.round(standaloneCostPerSuccess),
    latencyMs: Math.round(latency),
    latencyTaxPct: standaloneLatency === 0 ? 0 : ((latency - standaloneLatency) / standaloneLatency) * 100,
    breakEvenReuseRatio: breakEven !== null && breakEven <= 1 ? Number(breakEven.toFixed(4)) : null,
    maxStepFailureForTarget: Number(maxStepFailure.toFixed(4)),
    expectedWastedCostMicros: Math.round(expectedCost - success * fullCost),
  };
}

analyzeHandoff 的关键不是把三项成本相加,而是把「到达概率」乘进每一步:第 i 步只有在前面都成功时才会发生,所以它的成本要乘以 reach。失败传播因此不是一句定性描述,而是一个会改变期望成本的乘数。

breakEvenReuseRatio 回答的是「复用率低到什么程度,共享上下文还不如直接复制」。它由交接 token 与可复用上下文的比例决定,不依赖具体单价;单价只影响绝对金额,不影响回本线。

示例样本上的运行结果

把三能力场景输入分析函数,主场景与一个高失败率变体的结果如下:

指标主场景高失败率变体
独立调用成本4,240 微元4,240 微元
编排后成本3,786 微元3,786 微元
成本变化-10.71%-10.71%
每次交接 token820820
重试成本82 微元293 微元
成功率99.86%98.12%
每次成功交付成本3,872 微元4,139 微元
独立调用每次成功成本4,339 微元4,663 微元
成功路径延迟1,269ms1,262ms
延迟变化+5.79%+5.20%
失败浪费成本4 微元58 微元

主场景的结论是:编排省下 454 微元(-10.71%),代价是 69ms 交接延迟(+5.79%)。这个交换在延迟预算内是划算的。但「每次成功交付成本」比「每次调用成本」更值得看:独立调用是 4,339,编排是 3,872;前者比 4,240 高,是因为失败重试也被计入了成本。

高失败率变体把摘要步骤的失败率从 3% 提到 12%,回复步骤从 2% 提到 6%。成本几乎没变,成功率从 99.86% 掉到 98.12%,每次成功交付成本从 3,872 涨到 4,139,失败浪费从 4 涨到 58。失败传播不会改变单次调用的价目表,但它会改变「一条可用结果到底多少钱」。

复用回本线与失败预算

两个判据可以直接写进编排设计。

第一,复用回本线。交接 token 为 820,共享上下文为 3,200,缓存折扣为 90%,回本复用率是 820 ÷ (3,200 × 0.9) = 28.47%。复用率低于 28.47% 时,共享上下文不如每次复制;高于它,编排才开始省 token。这个数字与单价无关,只与 token 结构有关。

第二,失败预算。三步、每步 1 次重试、目标成功率 95% 时,单步失败率上限是 13.02%。超过这个值,整条链路的成功率就守不住 95%。高失败率变体的摘要是 12%,已经逼近上限;如果再加一个能力,上限会继续下降。

两个判据合起来解释了一个常见现象:编排在实验室里总是更省,因为实验室只测成功路径;上线后可能更贵,因为失败路径的重试与浪费没有进模型。

留给卷三的三个问题

卷三讨论模型组合与编排。单能力优化回答「怎么让一个能力更便宜」,卷三要回答「多个能力怎么协同完成复杂任务」。三个问题必须先回答,否则编排只会把单能力的成本优势吃掉。

第一,跨能力调用:哪些上下文必须随调用传递,哪些可以只传引用、摘要或游标?传得越多,越不容易丢信息,也越贵。

第二,上下文复用:共享前缀的复用率低于多少时,传递成本会超过复制成本?本节给出的 28.47% 是一个可计算的起点,不是一个通用常数。

第三,失败传播:上游失败后,下游应该跳过、重试,还是用部分结果继续?跳过省钱但可能没有结果,重试提高成功率但放大成本,部分结果则需要定义「部分可用」的验收标准。

这三个问题没有统一答案,但它们有共同的判断方式:把每一次交接的 token、延迟与失败概率写进模型,再决定编排结构。卷三会沿着这个方向展开。

小结

单能力优化之后,多能力编排带来四类新成本:跨能力调用、上下文复用、结果传递与失败传播。三能力示例中,编排把成本从 4,240 降到 3,786 微元(-10.71%),但把延迟从 1,200 拉到 1,269ms(+5.79%);高失败率变体让成功率从 99.86% 降到 98.12%,每次成功交付成本从 3,872 涨到 4,139 微元。

编排是否值得,不取决于单次调用便宜多少,而取决于复用率是否越过 28.47% 的回本线、单步失败率是否守得住 13.02% 的预算。这三个数字,连同三个待回答的问题,就是卷二交给卷三的输入。

阅读导航:上一节:13.2 优化方案与 ADR · 下一节:目录与学习路径 。

常见错误与边界

  • 把编排成本当成各能力成本之和。交接、结果传递与重试都会新增计费。
  • 只测成功路径。失败路径的重试与浪费才是编排最容易失控的部分。
  • 共享上下文每次完整复制。复用率低于回本线时,复制更便宜;高于时,编排才划算。
  • 忽略交接延迟。每次 35ms 看似很小,三次交接就是 105ms。
  • 用单次调用成本评价编排。应看每次成功交付成本,把失败传播算进去。
  • 失败后一律重试。重试会同时放大成本与延迟,必须有全局预算。
  • 复用率拍一个 100%。提示改版、租户隔离与缓存容量都会让它下降。
  • 把示例样本的 28.47% 当成通用常数。token 结构一变,回本线就要重算。
  • 编排没有验收「部分可用」。上游失败后下游拿到半截结果,用户看到什么必须提前定义。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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