一条链路只有一个钱包
卷二把三能力链路算到 3,786 微元,比独立调用的 4,240 微元省 10.71%。这个数字回答的是「这条链路平均花多少」,不回答「钱该在哪个节点花、哪个节点该省」。一旦链路要上线,第二个问题就更具体:产品给一条请求 4,000 微元的上限,三个节点怎么分?重试次数给谁?延迟预算怎么切?谁先用共享池?
如果只分成本,工程侧会得到一张看起来合理的账单,却守不住另外三条线:摘要节点拿走了大部分钱,却可能没有足够的输出 token;回复节点成本不高,却因为重试次数不够而在高峰期整链失败;交接需要的 35ms 没有被预留,延迟预算在第二跳就穿底。预算不是一列数字,而是一个四维向量。
本节把「整链预算」拆成四条资源线,按节点做保底、弹性与共享池三段分配。文中所有数值都来自 ai-lab 示例样本上的运行结果,不是生产实测;三节点成本沿用卷二样本:检索 1,055、摘要 1,643、回复 1,088 微元。
四条资源线
| 资源 | 整链上限 | 共享池 | 可分配给节点 | 谁在争 |
|---|---|---|---|---|
| 成本 | 4,000 微元 | 400 | 3,600 | 三个节点都想买更好的模型与更长的输出 |
| token | 9,600 | 820 | 8,780 | 上下文、上一跳结果与输出预留 |
| 延迟 | 1,400ms | 105 | 1,295 | 模型耗时、交接开销与重试等待 |
| 重试 | 6 次 | 3 次 | 3 次 | 每个节点都想要第二次机会 |
延迟上限 1,400ms 是在卷二样本的成功路径 1,269ms 之上留的余量;独立调用是 1,200ms,编排多出的 69ms 主要来自两次交接。
四条线的口径不同,不能互相换算。成本可以在节点之间转移,token 不能凭空变出来,延迟不能被重试「借」走,重试次数也不是花钱就能买。它们只能分别分配,最后取最紧的那条约束。
共享池的设计是这张表的关键。成本池 400 微元约等于回复节点的一次重试;token 池 820 正好是一次交接包(共享上下文 3,200,缓存输入按 10% 计价,即 90% 折扣);延迟池 105ms 是三次 35ms 交接;重试池 3 次是留给突发失败的机动额度。共享池不预先属于任何节点,谁在运行时触发,谁才动用。
保底、弹性与共享池
每个节点对每项资源提两个数:保底与目标。保底是「低于它就不要跑」的最低配置,例如检索节点至少要 820 微元才能完成一次有效重排;目标是「跑好需要多少」,例如 1,055 微元。两者之间是弹性区间,按权重分配。
三段分配的顺序不能颠倒。
第一段发保底。按优先级从高到低,先保证必需节点能跑。发不出保底的节点直接丢弃,不进入后续分配。可选节点(例如检索重排)可以被丢;必需节点(摘要、回复)被丢,说明整条链路在当前预算下不可行,应该由上游改目标,而不是让某个节点拿半截预算硬跑。
第二段发弹性。剩余预算按权重分给还有空间的节点。权重不是「谁重要」,而是「多给一块钱,谁的质量提升更大」。摘要的权重 3,检索与回复各 2,因为摘要的输出质量直接决定下游能不能用。
第三段退回共享池。如果所有节点都拿满目标还有剩余,剩余不留在节点账上,而是退回共享池。节点不应该因为「今年预算没用完」而拿到超额配置。
整数分配的实现
成本、token、延迟、重试都是整数,不能出现 0.3 次重试或 0.7 个 token。下面的实现用最大余数法分配弹性预算,并在某项触顶后把剩余单位重新分给还有空间的节点。
// file: src/orch/budget.ts
// tier: L2
// verify: node --test test/orch/budget.test.ts
import { AiError } from "../core/errors.ts";
/** 一条链路同时守四条资源线,全部按整数计。 */
export type BudgetResource = "costMicros" | "tokens" | "latencyMs" | "retries";
export const BUDGET_RESOURCES: readonly BudgetResource[] = [
"costMicros", "tokens", "latencyMs", "retries",
];
/** floor 是保底,target 是跑好所需,weight 只作用于两者之间的弹性区间。 */
export interface ResourceDemand { floor: number; target: number; weight: number }
export interface NodeBudgetDemand {
id: string;
/** 越大越先保底;预算不足时从最小的开始丢。 */
priority: number;
/** 必需节点被丢表示整链不可行;可选节点被丢只是少了增强。 */
required: boolean;
demand: Record<BudgetResource, ResourceDemand>;
}
export interface ChainBudgetSpec {
total: Record<BudgetResource, number>;
/** 预留给交接、重试与临时溢出,不预先分给任何节点。 */
sharedPool: Record<BudgetResource, number>;
nodes: readonly NodeBudgetDemand[];
}
export interface ResourceGrant {
floor: number; target: number; allocated: number; elastic: number; unmet: number;
}
export interface NodeGrant {
id: string; priority: number; required: boolean;
dropped: boolean; dropReason?: "budget-floor" | "required-floor";
resources: Record<BudgetResource, ResourceGrant>;
/** 最紧的一项资源,它决定节点实际能跑多大。 */
bindingResource: BudgetResource | null;
}
export interface ResourceSummary {
total: number; sharedPool: number; available: number;
floorSum: number; allocated: number; returnedToPool: number;
effectivePool: number; floorDeficit: number;
dropped: string[]; feasible: boolean;
}
export interface BudgetPlan {
nodes: NodeGrant[];
resources: Record<BudgetResource, ResourceSummary>;
}
function fail(message: string): never {
throw new AiError("invalid-request", `budget: ${message}`);
}
function int(value: number, field: string, min = 0): number {
if (!Number.isInteger(value) || value < min) fail(`${field} must be an integer >= ${min}`);
return value;
}
function each<T>(make: (resource: BudgetResource) => T): Record<BudgetResource, T> {
return {
costMicros: make("costMicros"), tokens: make("tokens"),
latencyMs: make("latencyMs"), retries: make("retries"),
};
}
function validate(spec: ChainBudgetSpec): void {
if (spec.nodes.length === 0) fail("至少需要一个节点");
const ids = new Set<string>();
for (const node of spec.nodes) {
if (node.id.trim() === "" || ids.has(node.id)) fail(`node.id 非法或重复: ${node.id}`);
ids.add(node.id);
int(node.priority, `${node.id}.priority`);
for (const resource of BUDGET_RESOURCES) {
const item = node.demand[resource];
int(item.floor, `${node.id}.${resource}.floor`);
int(item.target, `${node.id}.${resource}.target`);
if (item.target < item.floor) fail(`${node.id}.${resource}.target 低于 floor`);
if (!Number.isFinite(item.weight) || item.weight < 0) fail(`${node.id}.${resource}.weight 非法`);
}
}
for (const resource of BUDGET_RESOURCES) {
int(spec.total[resource], `total.${resource}`);
int(spec.sharedPool[resource], `sharedPool.${resource}`);
if (spec.sharedPool[resource] > spec.total[resource]) fail(`sharedPool.${resource} 超过 total`);
}
}
/**
* 按权重分整数单位:先取整,余数按小数部分补齐;某项触顶后退出,
* 剩余单位重新分给还有空间的节点。输入顺序固定,结果可复现。
*/
function distribute(amount: number, entries: readonly { id: string; weight: number; cap: number }[]): number[] {
const grants = entries.map(() => 0);
let remaining = amount;
while (remaining > 0) {
const active = entries
.map((entry, index) => ({ entry, index }))
.filter(({ entry, index }) => grants[index] < entry.cap && entry.weight > 0);
if (active.length === 0) break;
const totalWeight = active.reduce((sum, item) => sum + item.entry.weight, 0);
const shares = active.map((item) => ({ ...item, raw: (remaining * item.entry.weight) / totalWeight }));
let given = 0;
for (const share of shares) {
const take = Math.min(share.entry.cap - grants[share.index], Math.floor(share.raw));
grants[share.index] += take;
given += take;
}
let leftover = remaining - given;
if (leftover === 0) break;
const ordered = [...shares].sort((a, b) => {
const delta = (b.raw - Math.floor(b.raw)) - (a.raw - Math.floor(a.raw));
if (delta !== 0) return delta;
return a.entry.id < b.entry.id ? -1 : a.entry.id > b.entry.id ? 1 : 0;
});
let progressed = false;
for (const share of ordered) {
if (leftover === 0) break;
if (grants[share.index] >= share.entry.cap) continue;
grants[share.index] += 1;
leftover -= 1;
progressed = true;
}
if (!progressed) break;
remaining = leftover;
}
return grants;
}
function allocateResource(
total: number, sharedPool: number, nodes: readonly NodeBudgetDemand[],
pick: (node: NodeBudgetDemand) => ResourceDemand,
): { grants: Map<string, { grant: ResourceGrant; dropped: NodeGrant["dropReason"] | null }>; summary: ResourceSummary } {
const available = total - sharedPool;
const ordered = [...nodes].sort((a, b) =>
a.priority !== b.priority ? b.priority - a.priority : a.id < b.id ? -1 : 1);
let remaining = available;
let floorSum = 0;
let floorDeficit = 0;
let feasible = true;
const dropped: string[] = [];
const grants = new Map<string, { grant: ResourceGrant; dropped: NodeGrant["dropReason"] | null }>();
// 第一遍只发保底;发不出保底的节点不进入弹性分配,避免拿到半截预算。
for (const node of ordered) {
const demand = pick(node);
if (demand.floor > remaining) {
const reason = node.required ? "required-floor" : "budget-floor";
if (node.required) feasible = false;
floorDeficit += demand.floor;
dropped.push(node.id);
grants.set(node.id, {
grant: { ...demand, allocated: 0, elastic: 0, unmet: demand.target }, dropped: reason,
});
continue;
}
remaining -= demand.floor;
floorSum += demand.floor;
grants.set(node.id, {
grant: {
floor: demand.floor, target: demand.target, allocated: demand.floor,
elastic: 0, unmet: demand.target - demand.floor,
},
dropped: null,
});
}
const entries = ordered
.filter((node) => grants.get(node.id)?.dropped === null)
.map((node) => {
const demand = pick(node);
return { id: node.id, weight: demand.weight, cap: demand.target - demand.floor };
})
.filter((entry) => entry.cap > 0);
const elastic = distribute(remaining, entries);
let elasticSum = 0;
entries.forEach((entry, index) => {
elasticSum += elastic[index];
const current = grants.get(entry.id);
if (!current || current.dropped !== null) return;
current.grant.allocated += elastic[index];
current.grant.elastic = elastic[index];
current.grant.unmet = current.grant.target - current.grant.allocated;
});
const allocated = floorSum + elasticSum;
const returnedToPool = available - allocated;
return {
grants,
summary: {
total, sharedPool, available, floorSum, allocated, returnedToPool,
effectivePool: sharedPool + returnedToPool, floorDeficit, dropped, feasible,
},
};
}
/**
* 四资源分别做「保底 → 弹性 → 退回共享池」三段分配。
* 每项资源独立计算,节点最终受最紧的那项约束。
*/
export function allocateChainBudget(spec: ChainBudgetSpec): BudgetPlan {
validate(spec);
const summaries = each((resource) =>
allocateResource(spec.total[resource], spec.sharedPool[resource], spec.nodes, (node) => node.demand[resource]));
const nodes: NodeGrant[] = spec.nodes.map((node) => {
const resources = each((resource) => {
const entry = summaries[resource].grants.get(node.id);
if (!entry) fail(`缺少节点 ${node.id} 的 ${resource} 分配`);
return entry.grant;
});
let binding: BudgetResource | null = null;
let ratio = Number.POSITIVE_INFINITY;
for (const resource of BUDGET_RESOURCES) {
const grant = resources[resource];
if (grant.target === 0) continue;
const current = grant.allocated / grant.target;
if (current < ratio) {
ratio = current;
binding = resource;
}
}
const reason = BUDGET_RESOURCES
.map((resource) => summaries[resource].grants.get(node.id)?.dropped ?? null)
.find((value) => value !== null) ?? null;
return {
id: node.id, priority: node.priority, required: node.required,
dropped: reason !== null, dropReason: reason ?? undefined,
resources, bindingResource: reason === null ? binding : null,
};
});
return { nodes, resources: each((resource) => summaries[resource].summary) };
}
distribute 只做一件事:在容量受限时按权重分整数。先按权重取整,余数按小数部分从大到小补;如果某个节点触顶,退出后把剩余单位重新分配。整个过程只依赖输入顺序与 id,因此同一份预算永远得到同一组整数,适合放进回归测试。
allocateResource 的三段顺序是刻意写死的。保底必须最先发,否则一个节点可能先拿到弹性预算,导致另一个必需节点发不出保底;退回共享池必须最后做,否则节点会把没用完的额度当成自己的。
bindingResource 是分配结果里最容易被忽略的字段。它取「已分配 ÷ 目标」最小的一项,告诉你要让这个节点跑得更好,应该先补哪条线。补错资源,钱花了但节点还是被另一条线卡住。
示例样本上的运行结果
把三节点样本输入分配器,整链 4,000 微元、400 微元共享池,得到下面的节点级结果。
| 节点 | 成本(分配/目标) | token(分配/目标) | 延迟(分配/目标) | 重试(分配/目标) | 最紧资源 |
|---|---|---|---|---|---|
| search.rerank | 1,054 / 1,055 | 1,590 / 1,600 | 187 / 260 | 1 / 2 | 重试 |
| content.summarize | 1,532 / 1,643 | 5,000 / 5,000 | 661 / 820 | 1 / 2 | 重试 |
| chat.reply | 1,014 / 1,088 | 2,190 / 2,400 | 447 / 620 | 1 / 2 | 重试 |
四条资源线的汇总如下。
| 资源 | 保底合计 | 已分配 | 目标合计 | 未满足 | 退回池 |
|---|---|---|---|---|---|
| 成本 | 2,780 | 3,600 | 3,786 | 186 | 0 |
| token | 6,600 | 8,780 | 9,000 | 220 | 0 |
| 延迟 | 1,200 | 1,295 | 1,700 | 405 | 0 |
| 重试 | 0 | 3 | 6 | 3 | 0 |
这张表说明三件事。
第一,成本几乎分满:三个节点合计拿到 3,600 微元,离目标 3,786 只差 186。整链预算 4,000 比独立调用的 4,240 低 240,但通过编排仍然够用,因为共享上下文省下了重复计费。
第二,token 的缺口集中在回复节点:它拿到 2,190,目标是 2,400。摘要节点拿满 5,000,因为它承担主要生成任务;检索只差 10 个 token,可以忽略。
第三,所有节点的最紧资源都是重试。每个节点目标 2 次重试,但整链只发出 3 次,平均每个节点 1 次。这意味着即使成本和 token 都给足,节点仍然只敢重试一次;再多一次,整链的重试池就会穿底。
重试为什么成了共同瓶颈
这不是巧合。三节点链路里,重试池 6 次、共享池 3 次,只剩 3 次可分;三个节点各要 2 次,总需求 6 次,供给只有 3 次。无论成本怎么加,重试都是 1 对 2 的缺口。
这个结果对编排设计有两个直接含义。
第一,不能把重试当成节点自己的事。如果每个节点独立配置 2 次重试,整链最坏情况会尝试 9 次,远超预算;而预算分配器给出的 1 次,是「整链视角」下的可持续值。节点本地可以允许 2 次,但编排层只能用共享池再补 1 次。
第二,提升可靠性不该只靠重试。重试次数是最稀缺的资源,应该优先给失败后果最重的节点。如果摘要失败会导致整链没有结果,它的 1 次重试比检索的 1 次更值钱;分配器用权重表达这件事,但权重需要按失败后果校准,不能拍脑袋。
共享池不是剩余,是保险
400 微元、820 token、105ms、3 次重试,这四个共享池数字看起来零散,其实对应四类运行时事件。
成本池覆盖一次计划外重试。主场景的重试成本是 82 微元,高失败变体是 293 微元;400 微元足够覆盖一次较重的重试,但不够覆盖两次。这是有意的:共享池是保险,不是第二份预算。
token 池覆盖一次交接包。正常请求的交接已经在节点预算里,共享池的 820 是给「多一次重放」或「补一段上下文」用的。它不应该被日常请求消耗。
延迟池覆盖三次交接。三节点链路只有两次交接,105ms 留了一次余量;如果编排增加节点,这个池要同步扩大,否则交接开销会直接吃掉模型延迟预算。
重试池覆盖一次整链级的补偿。三个节点各 1 次本地重试之外,编排层还能再发 3 次;这 3 次应该由编排器统一调度,而不是被某个节点抢先借走。
共享池的第一个使用规则是「先池后裁」。超支在池内,直接动用共享池;池也覆盖不了,才进入下一节的裁剪顺序。把共享池当免费额度、平时就花掉,等于没有保险。
预算与链路结构一起变
分配结果不是一条永久配置。链路结构一变,四条资源线都要重算。
加一个节点,重试池的需求立刻上升,单步失败预算下降;减一个可选节点,成本与 token 会松,但交付完整性也变。把串行改成并行,延迟池可能下降,但并行分支的失败传播会改变成功率。预算分配器只负责在给定结构下分钱,结构本身要由第 1 章与第 3 章决定。
同样,预算目标也不是越低越好。把整链上限从 4,000 压到 3,500,保底仍然发得出,但弹性只剩 320,摘要的质量会被明显压缩;压到 2,500,检索节点直接被丢,链路从「检索增强」退化成「无检索摘要」。这些不是分配器能替你做的取舍,而是它必须如实报告的结果。
小结
整链预算要同时管成本、token、延迟与重试四条线。三节点样本把 4,000 微元拆成 2,780 保底、820 弹性与 400 共享池;token、延迟与重试也按同样三段分配。示例样本上的结果是:成本分满 3,600,摘要拿满 5,000 token,所有节点的最紧资源都是重试 1/2。
保底保护正确性,弹性购买质量,共享池吸收运行时波动。三者顺序不能颠倒,节点也不应该把未用完的额度留在自己账上。预算分配的输出不只是一张表,还包括每个节点的最紧资源与整链是否可行;这两项决定了下一步是加预算、换结构,还是进入裁剪。
阅读导航:上一节:7.3 回放与可复现调试 · 下一节:8.2 超支时的裁剪顺序 。
常见错误与边界
- 把整链预算平均分给节点。平均分会让摘要与检索拿到同样的钱,但它们的成本结构与失败后果完全不同。
- 只分成本,不分 token、延迟与重试。四条线中任何一条穿底,链路都会失败,省下的钱没有意义。
- 用 target 当 floor。target 是「跑好需要多少」,不是「最低能跑多少」;把两者混同会让预算永远不够。
- 先发弹性再发保底。这样会让先处理的节点吃掉预算,必需节点反而发不出保底。
- 把共享池当剩余额度。共享池是保险,平时花掉,超支时就没有缓冲。
- 每个节点独立配置重试次数。节点本地上限与整链重试预算是两个概念,前者不能直接相加成后者。
- 不看 bindingResource。补错资源会让节点继续被另一条线卡住,投入没有回报。
- 让可选节点被丢后仍按原链路验收。丢了检索节点,交付物已经不同,验收标准要同步调整。
- 必需节点发不出保底时仍强行运行。这时应该改目标或改结构,而不是让节点拿半截预算硬跑。
- 把示例样本的 4,000/2,780/400 当成通用配置。换一批请求或换一条链路,四个数字都要重算。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。