《互联网产品的 AI 架构·多能力编排与任务闭环》8.1 把预算分到节点

整链预算要同时管 token、成本、延迟与重试四条线。本节用三节点样本把 4,000 微元拆成保底、弹性与共享池,给出可复现的整数分配算法;四条线不能互相换算,分配顺序一旦颠倒,必需节点就会发不出保底;保底保护正确性,弹性购买质量,共享池吸收运行时波动。

一条链路只有一个钱包

卷二把三能力链路算到 3,786 微元,比独立调用的 4,240 微元省 10.71%。这个数字回答的是「这条链路平均花多少」,不回答「钱该在哪个节点花、哪个节点该省」。一旦链路要上线,第二个问题就更具体:产品给一条请求 4,000 微元的上限,三个节点怎么分?重试次数给谁?延迟预算怎么切?谁先用共享池?

如果只分成本,工程侧会得到一张看起来合理的账单,却守不住另外三条线:摘要节点拿走了大部分钱,却可能没有足够的输出 token;回复节点成本不高,却因为重试次数不够而在高峰期整链失败;交接需要的 35ms 没有被预留,延迟预算在第二跳就穿底。预算不是一列数字,而是一个四维向量。

本节把「整链预算」拆成四条资源线,按节点做保底、弹性与共享池三段分配。文中所有数值都来自 ai-lab 示例样本上的运行结果,不是生产实测;三节点成本沿用卷二样本:检索 1,055、摘要 1,643、回复 1,088 微元。

四条资源线

资源整链上限共享池可分配给节点谁在争
成本4,000 微元4003,600三个节点都想买更好的模型与更长的输出
token9,6008208,780上下文、上一跳结果与输出预留
延迟1,400ms1051,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.rerank1,054 / 1,0551,590 / 1,600187 / 2601 / 2重试
content.summarize1,532 / 1,6435,000 / 5,000661 / 8201 / 2重试
chat.reply1,014 / 1,0882,190 / 2,400447 / 6201 / 2重试

四条资源线的汇总如下。

资源保底合计已分配目标合计未满足退回池
成本2,7803,6003,7861860
token6,6008,7809,0002200
延迟1,2001,2951,7004050
重试03630

这张表说明三件事。

第一,成本几乎分满:三个节点合计拿到 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 当成通用配置。换一批请求或换一条链路,四个数字都要重算。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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