一条编排把上下文吃光了
「回声」的工单处理链路有四个节点:意图识别、知识检索、方案生成、合规检查。每个节点都需要系统提示、同一份工单背景、自己的私有工作集,以及一块输出空间。团队最初按「总预算除以节点数」分配,每个节点 2,000 token。
上线后发现两个问题。方案生成节点经常只拿到最小上下文,生成的方案漏掉工单里的关键约束;合规检查节点却用不完 2,000 token,剩下的预算又无法转给前面的节点。更糟的是,有一次模型窗口只剩几十 token 给输出,回复被截断,用户看到半句话。
这不是模型能力问题,而是预算分配问题。平均分假设每个节点的需求相同、优先级相同、输出需求相同,这三个假设在编排里都不成立。本节讨论如何把输入预算按节点分配,并给出一个确定性的分配器:先保底,再增长,装不下就整节点丢弃,不给半截上下文。
文中的分配结果都来自 ai-lab 示例样本上的运行结果,不是生产实测;token 数是用于演示的假设参数。
四段结构
一个节点的上下文需求可以拆成四段。前三段计入输入预算,第四段从模型窗口里单独扣。
| 段 | 字段 | 是否可裁 | 说明 |
|---|---|---|---|
| 固定系统提示 | fixedSystemTokens | 不可裁 | 节点身份、输出契约、安全规则 |
| 共享片段 | sharedSliceTokens | 当前策略不裁 | 工单背景、检索摘要等跨节点共享内容 |
| 节点私有 | privateTokens | 可裁到最小值 | 节点自己的工作集与上游结果 |
| 输出预留 | outputReserveTokens | 不可裁 | 从模型窗口扣除,不占输入预算 |
两个派生量决定了节点能不能被服务:
inputCap = maxContextTokens − outputReserveTokens
floor = fixedSystemTokens + sharedSliceTokens + minPrivateTokens
inputCap 是模型窗口留给输入的上限,floor 是节点能工作的最小输入。floor > inputCap 时,这个节点无论预算多少都装不下,应该在分配预算之前就被拒绝;floor 能装下但总预算不够时,节点会拿到最小上下文,或者被整节点丢弃。
把输出预留单独扣,是因为模型窗口同时装输入和输出。输入预算再充足,只要输出预留被挤掉,模型就会截断回复或用 finish_reason=length 结束。反过来,预留过大也会饿死输入。预留的合理起点是节点输出的 p95 token 加一点缓冲,再受 maxOutputTokens 约束。
预算不是平均分
用三节点示例说明平均分为什么失效。总输入预算 6,000,三个节点的最小需求分别是 1,600、1,200、800,私有工作集的期望值分别是 1,200、900、700。
如果平均分,每个节点 2,000 token。A 的最小需求是 1,600,只剩 400 给私有工作集,离期望的 1,200 差 800;B 能拿到 800,差 100;C 的最小需求只有 800,私有工作集可以拿满 700,还剩 500 无法转给 A 或 B。总预算没有少花,但高优先级节点被饿着,低优先级节点有闲置。
按优先级分配的结果是:A 拿满 1,200,B 拿满 900,C 拿满 700,总使用 5,500,还剩 500。同样的预算,高优先级节点从「最小可用」变成「完整工作集」,代价只是把 C 用不完的部分收回。
这说明预算分配的目标不是「每个节点一样多」,而是「在总预算内让优先级高的节点先达到可用状态,再让低优先级节点尽量完整」。平均分只适合一种情况:所有节点同优先级、同需求、同输出预留,而这在真实编排里很少见。
两阶段分配
分配器分两个阶段,顺序都是优先级从高到低,同优先级按节点 id 字典序,保证同一输入每次得到同一结果。
第一阶段保底。对每个节点先算 inputCap 与 floor:窗口装不下就丢弃,原因记为 context-window;总预算不够就丢弃,原因记为 budget-floor;两者都通过才扣掉 floor,进入候选集。保底阶段不允许「给一半」:节点要么拿到最小可用上下文,要么不参与本次执行。
第二阶段增长。按同一顺序,把剩余预算分给候选节点,把私有工作集从 minPrivateTokens 长到 desiredPrivateTokens,同时受 inputCap 限制。预算在某个节点用完时,后面的节点保持最小私有工作集,不会回退到「半个节点」。
这个顺序有两个重要性质。第一,高优先级节点先增长,低优先级节点先被裁到最小值;第二,保底失败的低优先级节点会被丢弃,但预算可以继续服务后面的节点,不会因为一个大节点放不下而让整条编排空转。
代价是「优先级」不等于「一定保留」。如果一个高优先级节点的 floor 超过剩余预算,它会被丢弃,而后面 floor 更小的节点可能被保留。这是显式的准入策略:先到先得的是预算顺序,不是节点身份。需要「高优先级必须保留」时,应该给高优先级节点预留专用预算,而不是依赖排序。
实现
context-budget.ts 把上面的两阶段写成纯函数。它不调用模型、不读配置,输入是总输入预算与节点需求,输出是每个节点的分配、裁剪量与丢弃原因。校验集中在入口,任何重复 id、负数或 min > desired 都会在分配前失败。
// file: src/orch/context-budget.ts
// tier: L2
// verify: node --test test/orch/context-budget.test.ts
import { AiError } from "../core/errors.ts";
/**
* 一个节点对上下文的四段需求:
* 固定系统提示、共享片段、节点私有工作集,以及必须留给输出的预留。
* 前三段计入输入预算,第四段从模型窗口里扣。
*/
export interface NodeContextDemand {
id: string;
/** 数值越大越先保底、越先增长。 */
priority: number;
fixedSystemTokens: number;
sharedSliceTokens: number;
desiredPrivateTokens: number;
minPrivateTokens: number;
outputReserveTokens: number;
maxContextTokens: number;
}
export interface ContextBudgetSpec {
/** 所有节点输入 token 的总额;不包含输出预留。 */
totalInputTokens: number;
nodes: readonly NodeContextDemand[];
}
export type DropReason = "budget-floor" | "context-window";
export interface NodeAllocation {
id: string;
priority: number;
allocated: boolean;
systemTokens: number;
sharedTokens: number;
privateTokens: number;
outputReserveTokens: number;
inputTokens: number;
/** 输入 + 输出预留,必须不超过节点的模型窗口。 */
contextTokens: number;
desiredPrivateTokens: number;
/** 相对 desiredPrivateTokens 少拿到的私有 token。 */
clippedPrivateTokens: number;
dropReason?: DropReason;
}
export interface BudgetAllocation {
totalInputTokens: number;
usedInputTokens: number;
remainingInputTokens: number;
allocations: readonly NodeAllocation[];
dropped: readonly { id: string; reason: DropReason }[];
/** 被保留节点少拿到的私有 token 总数;被丢弃节点不计入。 */
clippedPrivateTokens: number;
feasibleCount: number;
fullySatisfied: boolean;
}
function fail(message: string): never {
throw new AiError("invalid-request", `context-budget: ${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 text(value: string, field: string): string {
if (typeof value !== "string" || value.trim() === "") fail(`${field} must be a non-empty string`);
return value;
}
function validate(spec: ContextBudgetSpec): void {
integer(spec.totalInputTokens, "totalInputTokens");
if (spec.nodes.length === 0) fail("至少需要一个节点");
const seen = new Set<string>();
for (const node of spec.nodes) {
text(node.id, "node.id");
if (seen.has(node.id)) fail(`重复的节点 id ${node.id}`);
seen.add(node.id);
integer(node.priority, `${node.id}.priority`);
integer(node.fixedSystemTokens, `${node.id}.fixedSystemTokens`);
integer(node.sharedSliceTokens, `${node.id}.sharedSliceTokens`);
integer(node.desiredPrivateTokens, `${node.id}.desiredPrivateTokens`);
integer(node.minPrivateTokens, `${node.id}.minPrivateTokens`);
integer(node.outputReserveTokens, `${node.id}.outputReserveTokens`);
integer(node.maxContextTokens, `${node.id}.maxContextTokens`, 1);
if (node.minPrivateTokens > node.desiredPrivateTokens) {
fail(`${node.id}.minPrivateTokens 不能大于 desiredPrivateTokens`);
}
if (node.outputReserveTokens > node.maxContextTokens) {
fail(`${node.id}.outputReserveTokens 不能超过 maxContextTokens`);
}
}
}
function drop(node: NodeContextDemand, reason: DropReason): NodeAllocation {
return {
id: node.id,
priority: node.priority,
allocated: false,
systemTokens: 0,
sharedTokens: 0,
privateTokens: 0,
outputReserveTokens: 0,
inputTokens: 0,
contextTokens: 0,
desiredPrivateTokens: node.desiredPrivateTokens,
clippedPrivateTokens: 0,
dropReason: reason,
};
}
/**
* 两阶段分配:先按优先级保底,再按同一顺序把剩余预算长到 desired。
* 保底失败或超出模型窗口的节点被丢弃,不会得到半截上下文;
* 保底成功后私有部分可以被裁到 min 与 desired 之间。
*/
export function allocateContextBudget(spec: ContextBudgetSpec): BudgetAllocation {
validate(spec);
const ordered = [...spec.nodes].sort((a, b) => {
if (a.priority !== b.priority) return b.priority - a.priority;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
});
let remaining = spec.totalInputTokens;
const dropped: { id: string; reason: DropReason }[] = [];
const kept: { node: NodeContextDemand; inputCap: number; floor: number }[] = [];
for (const node of ordered) {
const inputCap = node.maxContextTokens - node.outputReserveTokens;
const floor = node.fixedSystemTokens + node.sharedSliceTokens + node.minPrivateTokens;
if (floor > inputCap) {
dropped.push({ id: node.id, reason: "context-window" });
continue;
}
if (floor > remaining) {
dropped.push({ id: node.id, reason: "budget-floor" });
continue;
}
remaining -= floor;
kept.push({ node, inputCap, floor });
}
const allocations: NodeAllocation[] = [];
let clipped = 0;
let satisfied = true;
for (const item of kept) {
const { node, inputCap } = item;
const maxPrivate = Math.min(
node.desiredPrivateTokens,
inputCap - node.fixedSystemTokens - node.sharedSliceTokens,
);
const wanted = maxPrivate - node.minPrivateTokens;
const take = Math.max(0, Math.min(wanted, remaining));
remaining -= take;
const privateTokens = node.minPrivateTokens + take;
const inputTokens = node.fixedSystemTokens + node.sharedSliceTokens + privateTokens;
const clippedPrivate = node.desiredPrivateTokens - privateTokens;
if (clippedPrivate > 0) satisfied = false;
clipped += clippedPrivate;
allocations.push({
id: node.id,
priority: node.priority,
allocated: true,
systemTokens: node.fixedSystemTokens,
sharedTokens: node.sharedSliceTokens,
privateTokens,
outputReserveTokens: node.outputReserveTokens,
inputTokens,
contextTokens: inputTokens + node.outputReserveTokens,
desiredPrivateTokens: node.desiredPrivateTokens,
clippedPrivateTokens: clippedPrivate,
});
}
for (const item of dropped) {
const node = ordered.find((candidate) => candidate.id === item.id);
if (node) allocations.push(drop(node, item.reason));
}
return {
totalInputTokens: spec.totalInputTokens,
usedInputTokens: spec.totalInputTokens - remaining,
remainingInputTokens: remaining,
allocations,
dropped,
clippedPrivateTokens: clipped,
feasibleCount: kept.length,
fullySatisfied: satisfied,
};
}
分配器有一个显式取舍:共享片段当前不参与裁剪。它被当作节点的「最小事实集」:如果共享片段本身要裁,说明节点看到的信息已经不完整,继续执行会把残缺上下文写进结果。更保守的做法是整节点丢弃,让上游决定是否降级;更激进的做法是先把共享片段压缩成摘要,再重新分配。前者简单可解释,后者需要压缩质量门禁,放在第 8 章的预算裁剪里讨论更合适。
示例样本上的运行结果
沿用三节点示例:A 优先级 90,窗口 4,000,输出预留 500;B 优先级 70,窗口 3,200,输出预留 400;C 优先级 50,窗口 2,400,输出预留 300。三者的最小需求是 1,600、1,200、800,私有期望是 1,200、900、700。
| 总输入预算 | 保留节点 | 已用 | 剩余 | 裁剪 | 丢弃 | A/B/C 私有 |
|---|---|---|---|---|---|---|
| 6,000 | A、B、C | 5,500 | 500 | 0 | 无 | 1,200 / 900 / 700 |
| 4,200 | A、B、C | 4,200 | 0 | 1,300 | 无 | 1,000 / 300 / 200 |
| 3,000 | A、B | 3,000 | 0 | 1,200 | C(budget-floor) | 600 / 300 / — |
6,000 的预算足够让三个节点都拿满私有工作集,最后剩 500 token 没有节点需要。这不是浪费,而是预算上限高于需求总和时的正常余量;如果这 500 必须花掉,应该增加节点或提高某个节点的 desiredPrivateTokens,而不是强行塞给低优先级节点。
4,200 时三个节点都能保底,但私有工作集被裁掉 1,300。A 先拿,拿到 1,000(差 200);B 拿到最小值 300(差 600);C 拿到最小值 200(差 500)。裁剪顺序与优先级一致:高优先级少裁,低优先级先裁到最小值。
3,000 时 C 的最小需求 800 超过剩余预算,被整节点丢弃,原因记为 budget-floor。A 与 B 保底后,A 用剩余预算把私有工作集从 400 长到 600,B 保持最小值 300。注意 A 虽然优先级最高,也没有拿满 1,200:总预算只够保底加一点增长,分配器不会为了满足 A 而让 B 无法保底。
另一个边界是模型窗口。把 A 的窗口改成 2,000、输出预留保持 500,inputCap 变成 1,500,小于 A 的 floor 1,600,A 会在预算检查之前被丢弃,原因记为 context-window。这不是「预算不够」,而是「窗口装不下」,两者的处理方式不同:前者可以加预算,后者只能换模型或减需求。
分配结果怎么进执行图
分配器输出的是每个节点的 inputTokens 与 contextTokens,执行图在发起调用前应该做三件事。
第一,把 inputTokens 写进节点的请求预算,超过就拒绝,而不是让提供方截断。截断是静默的,拒绝是可观测的。
第二,把 contextTokens 与模型的 contextWindow 再比一次。分配器用的是调用方给的 maxContextTokens,如果路由层换了模型,窗口可能变小,必须在调用前重新校验。
第三,把 dropped 与 clippedPrivateTokens 写进追踪。一个节点被丢弃,下游看到的是「上游没有结果」,这属于第 5 章的失败传播;一个节点被裁剪,下游看到的可能是「结果缺少某个字段」,这属于质量门禁。两者都不应该在编排层被静默吞掉。
分配器本身不决定失败后怎么办。它只保证一件事:每个被保留的节点都拿到了不小于最小需求的上下文,每个被丢弃的节点都有明确原因。这个保证让失败处理有据可依,而不是在上游看到一段莫名其妙的长输出。
小结
节点间的上下文预算要拆成固定系统提示、共享片段、节点私有与输出预留四段。前三段计入输入预算,第四段从模型窗口单独扣。平均分会让高优先级节点只拿到最小上下文、低优先级节点用不完预算;两阶段分配先按优先级保底,再按同一顺序增长,能把手里的预算先变成可用状态。
三节点示例中,6,000 预算让三者全部拿满,使用 5,500;4,200 预算裁掉 1,300 私有 token;3,000 预算丢弃最小需求 800 的 C,并保留 A、B。窗口装不下与预算不够是两种不同的丢弃原因,必须分开记录。
阅读导航:上一节:4.1 共享上下文的三种形态 · 下一节:4.3 长任务的会话状态与记忆边界 。
常见错误与边界
- 把总预算平均分给所有节点。平均分不区分优先级、需求与输出预留,通常同时造成饥饿与闲置。
- 只算输入 token,不算输出预留。模型窗口是输入加输出,输出预留被挤掉就会截断。
- 给节点分配「半个上下文」。保底失败时应整节点丢弃,半截上下文会把残缺信息写进结果。
- 把
floor当成固定值。共享片段或系统提示一变,最小需求就要重算。 - 用浮点数表示 token。分配器只接受整数,避免
Math.ceil在边界上产生不可复现的差异。 - 认为高优先级一定保留。当前策略只保证高优先级先保底、先增长;
floor超过剩余预算时仍会被丢弃。 - 分配完成后不复核模型窗口。路由换模型后窗口可能变小,必须在调用前再校验一次。
- 把裁剪当成免费。少给私有 token 会降低输出质量,等于把成本转移到质量与失败率上。
- 丢弃节点后静默继续。下游必须知道上游没有结果,否则会把「缺失」当成「没有」。
- 只记录最终分配,不记录丢弃原因。没有原因的丢弃无法区分加预算、换模型还是减需求。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。