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 token | 3,200 | 3,200 | 重复计费,上下文越长越贵 |
| 共享前缀 | 复用前缀 + 820 token 交接包 | 3,200 | 820 + 未命中部分 | 复用率下降时比复制还贵 |
| 检索句柄 | 96 token 句柄 + 320 token 片段 | 416 | 416 | 存储、权限、版本与陈旧引用 |
全量复制最直接:每个节点都自带完整上下文,节点之间没有隐式依赖,任何一跳单独重跑都能复现。代价是 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,200 | 9,600 | 2,400 | 基线 |
| 共享前缀 | 3,200 / 2,292 / 2,292 | 7,784 | 1,946 | −454(−18.92%) |
| 检索句柄 | 416 / 416 / 416 | 1,248 | 312 | −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% 上下时用单次采样做决策。这个区间内复制与共享前缀的差额很小,需要看稳定分布。
- 只比较成功路径。句柄过期、缓存未命中、权限拒绝都会改变三种形态的实际成本。
- 把「最便宜形态」直接当成「默认形态」。默认形态应该是最少依赖、最容易回放的形态。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。