《互联网产品的 AI 架构·多能力编排与任务闭环》4.2 节点间的上下文预算分配

同一条编排里,高优先级节点只拿到最小上下文,低优先级节点却用不完预算,这是平均分配的典型结果。本节把输入预算拆成固定系统提示、共享片段、节点私有与输出预留四段,给出保底再增长的两阶段分配器,并用三组预算复算裁剪、丢弃、窗口不足与可追踪的丢弃原因。

一条编排把上下文吃光了

「回声」的工单处理链路有四个节点:意图识别、知识检索、方案生成、合规检查。每个节点都需要系统提示、同一份工单背景、自己的私有工作集,以及一块输出空间。团队最初按「总预算除以节点数」分配,每个节点 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,000A、B、C5,5005000无1,200 / 900 / 700
4,200A、B、C4,20001,300无1,000 / 300 / 200
3,000A、B3,00001,200C(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 会降低输出质量,等于把成本转移到质量与失败率上。
  • 丢弃节点后静默继续。下游必须知道上游没有结果,否则会把「缺失」当成「没有」。
  • 只记录最终分配,不记录丢弃原因。没有原因的丢弃无法区分加预算、换模型还是减需求。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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