《互联网产品的 AI 架构·多能力编排与任务闭环》4.1 共享上下文的三种形态

同一份共享上下文,全量复制、共享前缀与检索句柄在 token、成本与风险上完全不同。本节用三节点样本复算三种形态,说明 28.47% 的回本线从哪里来、句柄模式省下的钱花在哪,并给出可运行的成本比较模块、选择判据与可判定的上线条件,最后指出三种形态混用的边界。

13.3 留下的第二个问题

卷二最后一节算了三能力编排的账:独立调用 4,240 微元,编排后 3,786 微元,省下的 454 微元几乎全部来自「共享上下文不再每个节点完整复制一遍」。同时,每次交接还要多传 820 token、多花 35ms。于是问题被写成一句话:共享前缀的复用率低于多少时,传递成本会超过复制成本?

这个问题不能只用「共享」或「不共享」回答。真实系统里至少有三种形态:每个节点都拿完整上下文;节点之间复用同一段前缀,只传增量交接包;或者只传一个检索句柄,让节点自己去上下文库里取需要的片段。三者的 token 账单、失败方式和运维成本都不同。

本节先把三种形态放进同一套单价里比较,再回答 28.47% 的回本线怎么来的,最后给出选择判据。文中的成本、token 与百分比都来自 ai-lab 示例样本上的运行结果,不是生产实测。

三种形态各自在传什么

先统一场景:一条编排有三个节点,共享上下文 3,200 token,每次交接的增量上下文与上一跳结果合计 820 token,句柄模式每次传 96 token 的引用,节点按引用解析出 320 token 的片段。缓存输入按全价的 10% 计费,也就是 90% 折扣;输入单价 250 微元/千 token。

形态每个节点拿到什么首节点后续节点主要风险
全量复制完整 3,200 token3,2003,200重复计费,上下文越长越贵
共享前缀复用前缀 + 820 token 交接包3,200820 + 未命中部分复用率下降时比复制还贵
检索句柄96 token 句柄 + 320 token 片段416416存储、权限、版本与陈旧引用

全量复制最直接:每个节点都自带完整上下文,节点之间没有隐式依赖,任何一跳单独重跑都能复现。代价是 token 线性增长,三个节点就是 9,600 token。

共享前缀要求同一请求内复用同一段前缀。首节点全价,后续节点只对未命中的部分付全价,命中部分按缓存折扣计费。它省的是重复前缀,不是交接包;交接包仍然每次都要传。

检索句柄把上下文留在外部存储,节点只收一个引用。它把 token 成本压到最低,但把成本转移到了存储读取、租户鉴权、版本固定和失败处理上。句柄解析失败时,节点拿不到上下文,效果等同于一次上游失败。

把三种形态放进同一套单价

三种形态的每节点 token 可以写成:

copy(i)          = S
shared-prefix(0) = S
shared-prefix(i) = H + S × (1 − r × (1 − c))
handle(i)        = Q + R

其中 S 是共享上下文,H 是交接包,r 是前缀复用率,c 是缓存输入占全价的比例,Q 是句柄 token,R 是解析片段 token。共享前缀的后续节点只对未命中部分付全价;句柄模式没有共享前缀,但每次都要付句柄与解析片段的钱。

示例取 S=3,200、H=820、r=0.6、c=0.1、Q=96、R=320。共享前缀的后续节点是 820 + 3,200 × (1 − 0.6 × 0.9) = 2,292 token。三个节点的总数分别是 9,600、7,784 与 1,248。

按 250 微元/千 token 计费,对应 2,400、1,946 与 312 微元。共享前缀比全量复制省 454 微元,降幅 18.92%;句柄比全量复制省 2,088 微元,降幅 87%。这两个数字与卷二 13.3 的 4,240 → 3,786 对得上:那 454 微元正是共享前缀相对全量复制省下的部分。

28.47% 的回本线怎么来的

把「共享前缀」与「全量复制」逐节点相减。首节点两者相同,不需要比较;从第二个节点开始,复制要付 S,共享前缀要付 H + S × (1 − r × (1 − c))。共享前缀更便宜的条件是:

H + S × (1 − r × (1 − c)) < S
H < S × r × (1 − c)
r > H ÷ (S × (1 − c))

代入 H=820、S=3,200、c=0.1,得到 820 ÷ (3,200 × 0.9) = 28.47%。复用率低于 28.47% 时,交接包比省下的前缀还贵,不如每个节点复制一份;高于 28.47% 时,共享前缀才开始回本。

这个公式有一个容易被忽略的性质:回本线与单价无关。单价只改变绝对金额,不改变「哪种形态更便宜」的分界。它也与节点数无关:只要后续节点满足这个条件,每多一个节点就多省一次;如果条件不满足,每多一个节点就多亏一次。

回本线依赖三个参数:交接包 H、共享上下文 S、缓存折扣 1−c。交接包越大,回本线越高;共享上下文越长,回本线越低;缓存折扣越小,回本线越高。如果提供方根本不支持前缀缓存,把 c 设为 1,公式分母为 0,此时共享前缀永远不如复制,函数返回 null。

实现

context-shape.ts 只做三件事:按三种形态展开每节点 token、用统一单价算成本、给出回本线与最便宜形态。它不访问模型,也不假设任何提供方的缓存行为;cachedInputPriceRatio 由调用方按实际账单填写。

// file: src/orch/context-shape.ts
// tier: L1
// verify: node --test test/orch/context-budget.test.ts

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

/** 共享上下文的三种传递形态。 */
export type ContextMode = "copy" | "shared-prefix" | "handle";

/**
 * 三种共享形态的成本模型。token 都在输入侧,价格按千 token 的整数微元计。
 * - copy:每个节点都携带完整共享上下文。
 * - shared-prefix:首节点全价,后续节点复用前缀并只传增量交接包。
 * - handle:每个节点只收句柄,再按句柄解析出自己需要的片段。
 */
export interface ContextShapeSpec {
  nodes: number;
  sharedContextTokens: number;
  /** 共享前缀模式下,每次交接额外传的增量上下文与上一跳结果。 */
  handoffTokens: number;
  /** 句柄模式每次传递的引用 token。 */
  handleTokens: number;
  /** 句柄解析后节点真正读入的片段 token,用来把解析成本计入比较。 */
  resolvedSliceTokens: number;
  prefixReuseRatio: number;
  /** 缓存输入占全价的比例;0.1 表示 90% 折扣。 */
  cachedInputPriceRatio: number;
  inputMicrosPerKTok: number;
}

export interface ModeCost {
  mode: ContextMode;
  tokensPerNode: readonly number[];
  totalTokens: number;
  costMicros: number;
  deltaTokensPct: number;
  deltaCostMicros: number;
  deltaCostPct: number;
}

export interface ShapeComparison {
  nodes: number;
  handoffTokens: number;
  modes: readonly ModeCost[];
  copyCostMicros: number;
  cheapestMode: ContextMode;
  /** 共享前缀相对全量复制的回本复用率;null 表示无论复用多少都不划算。 */
  prefixBreakEvenReuseRatio: number | null;
  prefixProfitable: boolean;
}

const MODE_ORDER: readonly ContextMode[] = ["copy", "shared-prefix", "handle"];

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

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

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

function validate(spec: ContextShapeSpec): void {
  integer(spec.nodes, "nodes", 1);
  integer(spec.sharedContextTokens, "sharedContextTokens");
  integer(spec.handoffTokens, "handoffTokens");
  integer(spec.handleTokens, "handleTokens");
  integer(spec.resolvedSliceTokens, "resolvedSliceTokens");
  ratio(spec.prefixReuseRatio, "prefixReuseRatio");
  ratio(spec.cachedInputPriceRatio, "cachedInputPriceRatio");
  integer(spec.inputMicrosPerKTok, "inputMicrosPerKTok", 1);
}

/**
 * 共享前缀相对全量复制的回本线:复用省下的钱要覆盖每次交接包的成本。
 * 公式与具体单价无关,只取决于 token 结构与缓存折扣。
 */
export function prefixBreakEvenReuseRatio(
  sharedContextTokens: number,
  handoffTokens: number,
  cachedInputPriceRatio: number,
): number | null {
  integer(sharedContextTokens, "sharedContextTokens");
  integer(handoffTokens, "handoffTokens");
  ratio(cachedInputPriceRatio, "cachedInputPriceRatio");
  const savedPerFullReuse = sharedContextTokens * (1 - cachedInputPriceRatio);
  if (savedPerFullReuse <= 0) return null;
  const breakEven = handoffTokens / savedPerFullReuse;
  return breakEven <= 1 ? Number(breakEven.toFixed(4)) : null;
}

function compareToCopy(mode: ContextMode, cost: number, tokens: number, copyCost: number, copyTokens: number): ModeCost {
  const deltaCostMicros = cost - copyCost;
  const deltaTokens = tokens - copyTokens;
  return {
    mode,
    tokensPerNode: [],
    totalTokens: tokens,
    costMicros: cost,
    deltaTokensPct: copyTokens === 0 ? 0 : Number(((deltaTokens / copyTokens) * 100).toFixed(4)),
    deltaCostMicros,
    deltaCostPct: copyCost === 0 ? 0 : Number(((deltaCostMicros / copyCost) * 100).toFixed(4)),
  };
}

/**
 * 在同一个场景、同一套单价下比较三种形态。
 * 共享前缀的后续节点按“交接包 + 未命中部分的全价上下文”计费;
 * 句柄模式按“句柄 + 解析片段”计费,不含上下文存储本身的运维成本。
 */
export function compareContextShapes(spec: ContextShapeSpec): ShapeComparison {
  validate(spec);
  const reuseFactor = 1 - spec.prefixReuseRatio * (1 - spec.cachedInputPriceRatio);
  const raw: Record<ContextMode, number[]> = {
    copy: Array.from({ length: spec.nodes }, () => spec.sharedContextTokens),
    "shared-prefix": [spec.sharedContextTokens],
    handle: Array.from({ length: spec.nodes }, () => spec.handleTokens + spec.resolvedSliceTokens),
  };
  for (let index = 1; index < spec.nodes; index += 1) {
    raw["shared-prefix"].push(spec.handoffTokens + Math.round(spec.sharedContextTokens * reuseFactor));
  }

  const copyTokens = raw.copy.reduce((sum, value) => sum + value, 0);
  const copyCost = raw.copy.reduce((sum, value) => sum + tokenCost(value, spec.inputMicrosPerKTok), 0);

  let cheapestMode: ContextMode = MODE_ORDER[0];
  let cheapestCost = Number.POSITIVE_INFINITY;
  const modes: ModeCost[] = MODE_ORDER.map((mode) => {
    const tokens = raw[mode].reduce((sum, value) => sum + value, 0);
    const cost = raw[mode].reduce((sum, value) => sum + tokenCost(value, spec.inputMicrosPerKTok), 0);
    if (cost < cheapestCost) {
      cheapestCost = cost;
      cheapestMode = mode;
    }
    return { ...compareToCopy(mode, cost, tokens, copyCost, copyTokens), tokensPerNode: raw[mode].slice() };
  });

  const breakEven = prefixBreakEvenReuseRatio(
    spec.sharedContextTokens,
    spec.handoffTokens,
    spec.cachedInputPriceRatio,
  );
  const rawBreakEven = spec.sharedContextTokens * (1 - spec.cachedInputPriceRatio) <= 0
    ? null
    : spec.handoffTokens / (spec.sharedContextTokens * (1 - spec.cachedInputPriceRatio));

  return {
    nodes: spec.nodes,
    handoffTokens: spec.handoffTokens,
    modes,
    copyCostMicros: copyCost,
    cheapestMode,
    prefixBreakEvenReuseRatio: breakEven,
    prefixProfitable: rawBreakEven !== null && spec.prefixReuseRatio >= rawBreakEven,
  };
}

finite 在当前实现里没有被调用,但保留它是有意的:后续给 handoffTokens 或节点数加入非整数来源时,校验函数可以复用它。测试会检查 nodes=0、复用率越界与单价为 0 都会被拒绝。

示例样本上的运行结果

把示例参数输入 compareContextShapes,结果如下:

形态每节点 token总 token成本(微元)相对复制
全量复制3,200 / 3,200 / 3,2009,6002,400基线
共享前缀3,200 / 2,292 / 2,2927,7841,946−454(−18.92%)
检索句柄416 / 416 / 4161,248312−2,088(−87%)

回本线是 28.47%,示例复用率 60% 高于它,所以共享前缀确实比复制便宜。把复用率降到 28%,共享前缀的总 token 变成 9,628,成本 2,408 微元,反而比复制的 2,400 贵 8 微元;把复用率提到 30%,总 token 降到 9,512,成本 2,378 微元,重新比复制便宜。

这组数字说明「共享前缀」不是一个开关,而是一条连续曲线。复用率在回本线附近时,几块钱的差异没有工程意义;真正决定选择的,是复用率能不能稳定地高于回本线,而不是某一次采样刚好高一点。

句柄省下的 2,088 微元花在哪

句柄模式的 token 账单最低,但它的成本没有消失,只是换了科目。至少有四笔账要补:

第一,上下文存储。共享上下文不再随请求传,就必须有一个按 tenantId + sessionId + 版本 可检索的存储。读取延迟、可用性与容量都要单独做。

第二,权限。句柄是一个可以被伪造或越权使用的引用。节点拿到句柄后,必须由存储侧校验「这个节点、这个租户、这个任务」是否有权读取该片段,不能只靠句柄里的字符串。

第三,版本。共享上下文在长任务里会变化。句柄必须指向一个不可变快照,否则同一个句柄在不同时刻解析出不同内容,重跑与回放就失去意义。

第四,陈旧引用与失败。句柄过期、目标被删除、权限被回收,都会让节点拿不到上下文。这类失败要计入第 5 章的失败预算,而不是当成「没找到」静默跳过。

所以句柄模式的正确说法不是「最便宜」,而是「token 最便宜、基础设施最贵」。它适合上下文远大于节点实际所需、且平台已经有强一致快照存储的场景;如果存储只是临时拼出来的,句柄会把省下的 token 钱以事故形式还回去。

选择判据

把上面的分析收成三条可执行判据。

第一,复用率低于 28.47% 时,选全量复制。此时共享前缀的交接包比省下的前缀更贵,复制至少没有额外的缓存依赖。回本线要按自己的 H、S、c 重算,不能把 28.47% 当常数。

第二,复用率稳定高于回本线时,选共享前缀。它保留了「上下文随请求走」的简单性,只多一个缓存命中假设;这个假设可以用账单里的缓存输入占比持续验证。

第三,只有同时满足四个条件才选句柄:共享上下文显著大于节点所需片段;有按租户隔离的不可变快照;节点侧有明确的句柄解析失败处理;句柄解析的 p95 延迟在交接延迟预算内。前三条是正确性条件,第四条是体验条件,缺一条都不应上线。

三种形态可以混用:首节点用复制保证完整,中间节点用共享前缀,只读的检索节点用句柄。混合的代价是复杂度,收益要按节点逐个算,而不是给整条链路贴一个标签。

小结

共享上下文有三种形态:全量复制、共享前缀与检索句柄。三节点示例中,全量复制是 9,600 token / 2,400 微元,共享前缀是 7,784 token / 1,946 微元,检索句柄是 1,248 token / 312 微元。共享前缀相对复制的回本线是 28.47%,由交接包 820、共享上下文 3,200 与 90% 缓存折扣共同决定。

回本线只回答「哪种形态更便宜」,不回答「哪种形态更可靠」。全量复制最自包含,共享前缀依赖缓存命中,句柄依赖存储、权限与版本。选择时先算回本线,再检查形态背后的依赖是否真的存在。

阅读导航:上一节:3.3 检查点、恢复与幂等 · 下一节:4.2 节点间的上下文预算分配 。

常见错误与边界

  • 把「共享上下文」当成「只传一次」。共享前缀省的是重复前缀,交接包仍然每次都要传。
  • 把 28.47% 当成通用常数。交接包、共享上下文或缓存折扣一变,回本线就要重算。
  • 在提供方不支持前缀缓存时仍然按缓存价做预算,把 cachedInputPriceRatio 设成 0.1。
  • 只看 token 账单选句柄,忽略存储读取、租户鉴权、版本固定与陈旧引用的成本。
  • 用可变对象做句柄目标。长任务里共享上下文会更新,句柄必须指向不可变快照。
  • 把句柄解析失败当成「没有上下文」继续跑。它应该进入失败分类与重试预算。
  • 在复用率 30% 上下时用单次采样做决策。这个区间内复制与共享前缀的差额很小,需要看稳定分布。
  • 只比较成功路径。句柄过期、缓存未命中、权限拒绝都会改变三种形态的实际成本。
  • 把「最便宜形态」直接当成「默认形态」。默认形态应该是最少依赖、最容易回放的形态。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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