《互联网产品的 AI 架构·多能力编排与任务闭环》8.2 超支时的裁剪顺序

超支时最怕临时拍脑袋。本节给出缓存、裁剪上下文、降档、跳过可选节点、拒绝五步裁剪顺序,逐项标出节省、质量损失与不可逆性,并用 600、900、1,200 微元三个样本跑出确定性结果;质量下限决定少交付一块还是完全不交付;顺序应按可逆性与质量损失排,而不是按节省金额排。

预算是计划,超支是常态

8.1 把 4,000 微元分到三个节点,每个节点都拿到了保底,部分拿到了弹性。但预算是按典型请求做的,运行时总会出现计划外开销:摘要触发了一次重试,交接包比预期长了 120 token,某个节点因为上游结果不完整而重跑了一遍。主场景的重试成本是 82 微元,高失败变体是 293 微元;一次上下文膨胀就可能把整链推过上限。

超支发生时,系统只有两条路:在预算内把请求做完,或者明确拒绝。最危险的是第三条路——没有顺序地随手砍。先砍上下文、再换模型、最后发现还不够就丢一个节点,这种临时决策每次都能给出「这次省下来了」的解释,但没人能说清质量损失了多少、哪些动作不可逆、下次遇到同样情况是否还会做同样的选择。

裁剪顺序应该是一条预先写好的阶梯,不是运行时即兴发挥。本节把阶梯写成五步:缓存、裁剪上下文、降档、跳过可选节点、拒绝。每一步都标出三件事:最多省多少、质量损失多少、能不能撤回。

文中的节省、质量损失与判定都来自 ai-lab 示例样本上的运行结果,不是生产实测;基础链路沿用卷二的三节点样本,成本 3,786 微元。

五步裁剪阶梯

顺序动作最多节省质量损失可逆性成立条件额外代价
1复用缓存320 微元0%可逆上一跳结果仍在缓存窗口缓存命中率与陈旧风险
2裁剪上下文280 微元1.5%可逆长尾信息可以下一轮补回本轮可能丢长尾依据
3降档模型420 微元6%仅本请求经济档通过质量门槛同请求无法升回原档
4跳过可选节点671 微元18%用户可见缺席时仍有部分结果交付物缺一块
5拒绝请求剩余全部100%用户可见有排队或人工兜底本次没有交付

这张表最容易误读的是「节省」列。跳过可选节点省 671,比缓存加裁剪上下文还多,但它的质量损失是 18%,是前两步合计 1.5% 的十二倍。裁剪顺序不是按节省金额排序,而是按「每单位质量损失换回多少钱」以及「能不能撤回」排序。

缓存排第一,是因为它同时满足无损与可逆。命中缓存不改变本次交付的内容,下一次请求也不受影响;代价只是缓存本身的命中率与陈旧风险。缓存不命中时,这一步自然失效,不能把它当成必然收益写进预算。

裁剪上下文排第二,是因为它可逆但有损。少传一段长尾上下文,本轮回答可能少一个依据,但下一轮可以补回来。1.5% 的质量损失是示例值,真实数值要由评测集给出;如果裁剪会删掉安全策略或用户明确要求的信息,它就不该排在这一位。

降档排第三,是因为它有损且只对本次请求可逆。经济档模型在能力边界内可以完成大部分任务,但已经生成并交付的答复不能「升级」回原档;用户看到的就是降档结果。6% 的质量损失同样是示例值,必须由离线评测与灰度数据支撑,不能按价格比例猜。

跳过可选节点排第四,是因为它直接改变交付物。检索节点缺席,用户拿到的摘要就少了外部依据;这不是「质量低一点」,而是「少了一块」。它必须与「部分可用」的验收标准一起设计,不能由编排器单方面决定。

拒绝排最后,是因为它不是省钱手段,而是止损。拒绝意味着本次请求没有交付,质量损失按 100% 计;它唯一合理的场景是:继续跑下去的成本或风险超过不交付的代价,并且系统有排队、重试或人工兜底入口。

把顺序写成可执行策略

下面的实现把阶梯写成数据,把「按顺序执行、守住质量下限、部分执行按比例折算损失」写成函数。运行时只负责输入超支金额,不再临场决定砍什么。

// file: src/orch/budget-trim.ts
// tier: L2
// verify: node --test test/orch/budget.test.ts

import { AiError } from "../core/errors.ts";

/** 超支时的裁剪动作,按「先无损、后有损、最后止损」排序。 */
export type TrimKind = "cache" | "context-trim" | "downgrade" | "skip-optional" | "reject";
export type Reversibility = "reversible" | "request-scoped" | "user-visible";

export interface TrimStep {
  id: string;
  kind: TrimKind;
  /** 该动作在当前请求上最多能省下的微元。 */
  savingMicros: number;
  /** 完全执行时的质量损失,百分点;1.5 表示 1.5%。 */
  qualityLossPct: number;
  reversibility: Reversibility;
  precondition?: string;
}

export interface TrimPolicy {
  /** 一条请求允许累计的质量损失上限,百分点。 */
  maxQualityLossPct: number;
  allowSkipOptional: boolean;
  allowReject: boolean;
}

export interface TrimDecision {
  stepId: string; kind: TrimKind;
  status: "applied" | "skipped" | "not-needed";
  reason?: "quality-floor" | "disabled";
  requestedMicros: number; appliedMicros: number;
  qualityLossPct: number; reversibility: Reversibility;
}

export interface TrimPlan {
  overrunMicros: number; coveredMicros: number; residualMicros: number;
  appliedMicros: number;
  /** 可逆动作省下的钱:下一次请求可以恢复。 */
  reversibleSavingMicros: number;
  /** 用户可见动作省下的钱:本请求已经交付了更少的东西。 */
  userVisibleSavingMicros: number;
  totalQualityLossPct: number;
  decisions: TrimDecision[];
  appliedKinds: TrimKind[];
  decision: "within-budget" | "trimmed" | "reject" | "over-budget";
  fallback?: string;
}

function fail(message: string): never {
  throw new AiError("invalid-request", `budget-trim: ${message}`);
}

function nonNegative(value: number, field: string): number {
  if (!Number.isFinite(value) || value < 0) fail(`${field} must be a finite number >= 0`);
  return value;
}

function pct(value: number, field: string): number {
  if (!Number.isFinite(value) || value < 0 || value > 100) fail(`${field} must be in [0, 100]`);
  return value;
}

function validate(steps: readonly TrimStep[], policy: TrimPolicy): void {
  if (steps.length === 0) fail("至少需要一个裁剪动作");
  const ids = new Set<string>();
  for (const step of steps) {
    if (step.id.trim() === "" || ids.has(step.id)) fail(`step.id 非法或重复: ${step.id}`);
    ids.add(step.id);
    nonNegative(step.savingMicros, `${step.id}.savingMicros`);
    pct(step.qualityLossPct, `${step.id}.qualityLossPct`);
  }
  pct(policy.maxQualityLossPct, "maxQualityLossPct");
  const rejectIndex = steps.findIndex((step) => step.kind === "reject");
  if (rejectIndex >= 0 && rejectIndex !== steps.length - 1) fail("reject 必须是最后一个动作");
}

/**
 * 按顺序尝试裁剪,直到覆盖超支或用完动作。
 * 部分执行时质量损失按节省比例折算;下一步会突破质量下限就跳过,
 * 让后面的止损动作接管。
 */
export function planTrim(overrunMicros: number, steps: readonly TrimStep[], policy: TrimPolicy): TrimPlan {
  nonNegative(overrunMicros, "overrunMicros");
  validate(steps, policy);

  let remaining = overrunMicros;
  let qualityLoss = 0;
  let reversibleSaving = 0;
  let userVisibleSaving = 0;
  const decisions: TrimDecision[] = [];
  const record = (
    step: TrimStep, status: TrimDecision["status"], applied: number,
    reason?: TrimDecision["reason"],
  ): void => {
    decisions.push({
      stepId: step.id, kind: step.kind, status, reason,
      requestedMicros: status === "not-needed" ? 0 : step.kind === "reject" ? remaining : step.savingMicros,
      appliedMicros: applied,
      qualityLossPct: Number((step.savingMicros === 0 || step.kind === "reject"
        ? 0
        : (step.qualityLossPct * applied) / step.savingMicros).toFixed(4)),
      reversibility: step.reversibility,
    });
  };

  for (const step of steps) {
    if (remaining === 0) {
      record(step, "not-needed", 0);
      continue;
    }
    if (step.kind === "skip-optional" && !policy.allowSkipOptional) {
      record(step, "skipped", 0, "disabled");
      continue;
    }
    if (step.kind === "reject") {
      if (!policy.allowReject) {
        record(step, "skipped", 0, "disabled");
        continue;
      }
      record(step, "applied", remaining);
      decisions[decisions.length - 1].qualityLossPct = Number((100 - qualityLoss).toFixed(4));
      userVisibleSaving += remaining;
      qualityLoss = 100;
      remaining = 0;
      continue;
    }

    const applied = Math.min(step.savingMicros, remaining);
    const loss = step.savingMicros === 0 ? 0 : (step.qualityLossPct * applied) / step.savingMicros;
    if (qualityLoss + loss > policy.maxQualityLossPct) {
      record(step, "skipped", 0, "quality-floor");
      continue;
    }

    remaining -= applied;
    qualityLoss += loss;
    if (step.reversibility === "reversible") reversibleSaving += applied;
    else userVisibleSaving += applied;
    record(step, "applied", applied);
  }

  const covered = overrunMicros - remaining;
  const rejected = decisions.some((item) => item.kind === "reject" && item.status === "applied");
  const decision = overrunMicros === 0 ? "within-budget"
    : rejected ? "reject"
      : remaining > 0 ? "over-budget"
        : "trimmed";

  return {
    overrunMicros,
    coveredMicros: covered,
    residualMicros: remaining,
    appliedMicros: covered,
    reversibleSavingMicros: reversibleSaving,
    userVisibleSavingMicros: userVisibleSaving,
    totalQualityLossPct: Number(qualityLoss.toFixed(4)),
    decisions,
    appliedKinds: decisions.filter((item) => item.status === "applied").map((item) => item.kind),
    decision,
    fallback: decision === "reject"
      ? "返回排队提示与人工兜底入口;不要用降档结果冒充完整交付。"
      : decision === "over-budget"
        ? "本次不允许拒绝;必须回退到共享池、下调目标或在上游限流。"
        : undefined,
  };
}

/**
 * 回声的三节点样本:基础成本 3,786 微元。
 * 超支通常来自重试、上下文增长与重复执行,五步按伤害从低到高排列。
 */
export const ECHO_TRIM_STEPS: readonly TrimStep[] = [
  {
    id: "reuse-cache", kind: "cache", savingMicros: 320, qualityLossPct: 0,
    reversibility: "reversible", precondition: "上一跳结果仍在缓存窗口内",
  },
  {
    id: "trim-context", kind: "context-trim", savingMicros: 280, qualityLossPct: 1.5,
    reversibility: "reversible", precondition: "长尾上下文可以下一轮补回",
  },
  {
    id: "downgrade-model", kind: "downgrade", savingMicros: 420, qualityLossPct: 6,
    reversibility: "request-scoped", precondition: "经济档在能力边界内且通过质量门槛",
  },
  {
    id: "skip-optional", kind: "skip-optional", savingMicros: 671, qualityLossPct: 18,
    reversibility: "user-visible", precondition: "可选节点缺席时仍有可交付的部分结果",
  },
  {
    id: "reject-request", kind: "reject", savingMicros: 0, qualityLossPct: 100,
    reversibility: "user-visible", precondition: "已有排队、重试或人工兜底入口",
  },
];

export const ECHO_TRIM_POLICY: TrimPolicy = {
  maxQualityLossPct: 12, allowSkipOptional: true, allowReject: true,
};

planTrim 的关键不是遍历五步,而是三个约束。

第一,部分执行按比例折算质量损失。超支 900 时,降档最多能省 420,但只需要再省 300;实际质量损失是 6% × 300 ÷ 420 = 4.29%,不是完整的 6%。如果按整步损失记账,系统会过早触发质量下限,做出比必要更激进的裁剪。

第二,质量下限是累计约束。每一步执行前检查「已损失 + 本步损失」是否超过上限;超过就跳过,让后面的动作接管。跳过不等于失败,它只是把决策权交给下一级。

第三,拒绝是最后一个动作,且必须是显式的。validate 强制 reject 只能出现在末尾,防止有人在拒绝之后还安排动作;allowReject 为 false 时,裁剪到底返回 over-budget,由上游限流或改目标,而不是静默丢弃请求。

示例样本上的运行结果

把三个超支金额输入策略,质量下限设为 12%,结果如下。

超支执行动作覆盖质量损失可逆节省用户可见节省判定
600缓存 320 + 裁剪上下文 2806001.5%6000trimmed
900缓存 320 + 裁剪 280 + 降档 3009005.79%600300trimmed
1,200缓存 320 + 裁剪 280 + 降档 420 + 拒绝 1801,200100%600600reject

超支 600 时,前两步刚好覆盖,系统没有动模型,也没有改变交付物。这是最理想的情况:质量损失 1.5%,全部可逆,下一次请求可以完全恢复。

超支 900 时,前两步只覆盖 600,还差 300;降档最多能省 420,但只执行 300,质量损失按比例记为 4.29%,累计 5.79%。用户拿到的是降档结果,本请求不能撤回,但下一次请求仍可回到原档。这是「有损但可恢复」的边界。

超支 1,200 时,前两步加工降档覆盖 1,020,还差 180。下一步跳过可选节点会新增 18% 损失,累计 25.5%,超过 12% 的质量下限,因此被跳过;最后由拒绝覆盖剩余 180。注意这里的 100% 不是「拒绝损失了 100%」,而是「前面已经损失 7.5%,拒绝让剩余部分也没有交付,整条请求的交付质量归零」。

把质量下限从 12% 放宽到 30%,同样的 1,200 超支会走另一条路:跳过可选节点执行 180,按比例损失 4.83%,累计 12.33%,在 30% 以内,因此不再拒绝。这个对照说明:质量下限不是装饰参数,它直接决定系统在超支时是「少交付一块」还是「完全不交付」。

每一步的隐藏代价

节省列只写了微元,真实代价分布在四个地方。

缓存依赖命中率。320 微元是「命中时」的节省,不命中就是 0。如果预算模型把 320 当成必然收益,实际超支会比预期更严重。缓存还会引入陈旧风险:上一跳结果在窗口内可能已经过期,复用它会省一次调用,但可能把过期信息带进下游。

裁剪上下文会丢长尾依据。1.5% 是平均值,长尾请求的损失可能远高于它。安全策略、用户明确引用的段落、刚发生的事件,都不应该被当成「可裁剪的长尾」。裁剪策略必须带白名单,不能只按 token 长度排序。

降档改变的是模型行为,不只是价格。经济档在简单任务上接近原档,在复杂推理、长文一致性、工具调用成功率上可能明显下降。6% 的质量损失必须来自同一批评测样本,而不是按价格差比例估算。

跳过可选节点改变交付物。检索缺席时,摘要仍然能生成,但它不再是「有依据的摘要」。用户看到的是一个更短、更自信、但缺少出处的答案,这种损失比 18% 的数字更难被发现。必须同时定义「部分可用」的验收标准与用户提示。

三种不可逆性

「能不能撤回」不能只用一个布尔值。示例里区分三种:

可逆动作在本次请求结束后自动恢复。缓存与上下文裁剪属于这一类;它们只影响本轮,下一轮可以回到原配置。

仅本请求不可逆的动作,影响已经交付的那一份结果。降档属于这一类;用户看到的是降档答复,事后不能把同一条答复升级。系统可以记录「本次降档」,用于质量归因,但不能假装它没有发生。

用户可见的不可逆动作,改变的是用户拿到的内容或服务。跳过可选节点与拒绝属于这一类;前者少一块,后者没有交付。它们不能只记在成本账上,必须进入产品指标与客服口径。

三种不可逆性对应三种补偿方式:可逆动作只需要监控恢复;仅本请求不可逆的动作需要质量抽检与降档标记;用户可见的动作需要明确提示、排队入口或人工兜底。把三者混成一句「本次降级」,事后无法解释用户为什么看到了不同的东西。

顺序要在上线前定好

裁剪顺序的价值在于「事前约定」。运行时才决定砍什么,会有三个问题:不同值班同学做出不同选择;质量损失无法归因;产品侧不知道用户会看到什么。

上线前应该完成四件事。第一,按链路定义质量下限,并写清它来自哪批评测。第二,给每一步标注可逆性与用户可见性,确定补偿方式。第三,把顺序固化成配置或代码,不允许运行时插入新动作。第四,用超支样本回归,确认每个金额都落在预期档位。

顺序也不是永远不变。如果缓存命中率长期很低,第一步的实际收益接近 0,就应该把它从「必选」降为「条件动作」;如果降档在评测中损失远高于 6%,就应该把它排到跳过可选节点之后,或者直接取消。顺序可以调整,但调整必须走评审与回归,不能在事故现场改。

小结

超支裁剪是一条五步阶梯:缓存 320、裁剪上下文 280、降档 420、跳过可选节点 671、拒绝剩余。示例样本上,超支 600 只需前两步,质量损失 1.5%;超支 900 增加按比例执行的降档,累计损失 5.79%;超支 1,200 因跳过可选节点会突破 12% 的质量下限而转为拒绝。

每一步的代价不只是节省金额,还包括质量损失、可逆性与额外系统成本。裁剪顺序应按「可逆性 → 质量损失 → 节省」排序,并在上线前固化为策略;运行时只执行,不即兴发挥。

阅读导航:上一节:8.1 把预算分到节点 · 下一节:8.3 编排经济性复核 。

常见错误与边界

  • 按节省金额排序。跳过可选节点省得最多,但质量损失也最大,不能排在第一。
  • 把质量损失按整步记账。部分执行时应按节省比例折算,否则会过早触发质量下限。
  • 质量下限拍一个数字。它应该来自评测集与业务容忍度,不同链路不能共用。
  • 缓存节省按 100% 命中写进预算。命中率下降时,超支会比模型预期更严重。
  • 裁剪上下文时不设白名单。安全策略、用户引用与刚发生的事件不应该被当成长尾。
  • 用价格差估算降档损失。模型行为的变化必须用同一批样本评测,不能按比例猜。
  • 把跳过可选节点当成「质量低一点」。它改变的是交付物,需要部分可用标准与用户提示。
  • 把拒绝当成省钱手段。拒绝是止损,代价是本次没有交付,必须配排队或人工兜底。
  • 运行时临时决定裁剪顺序。不同值班同学会做出不同选择,事后也无法归因。
  • 把三种不可逆性混成一句「本次降级」。可逆、仅本请求、用户可见需要不同的补偿方式。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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