预算是计划,超支是常态
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 + 裁剪上下文 280 | 600 | 1.5% | 600 | 0 | trimmed |
| 900 | 缓存 320 + 裁剪 280 + 降档 300 | 900 | 5.79% | 600 | 300 | trimmed |
| 1,200 | 缓存 320 + 裁剪 280 + 降档 420 + 拒绝 180 | 1,200 | 100% | 600 | 600 | reject |
超支 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% 命中写进预算。命中率下降时,超支会比模型预期更严重。
- 裁剪上下文时不设白名单。安全策略、用户引用与刚发生的事件不应该被当成长尾。
- 用价格差估算降档损失。模型行为的变化必须用同一批样本评测,不能按比例猜。
- 把跳过可选节点当成「质量低一点」。它改变的是交付物,需要部分可用标准与用户提示。
- 把拒绝当成省钱手段。拒绝是止损,代价是本次没有交付,必须配排队或人工兜底。
- 运行时临时决定裁剪顺序。不同值班同学会做出不同选择,事后也无法归因。
- 把三种不可逆性混成一句「本次降级」。可逆、仅本请求、用户可见需要不同的补偿方式。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。