上下文预算是对固定 token 数的主动分配,而非「尽量短」的软约束。公式:available = window - reserved_output - safety_margin。安全边距 2-3% 必要,因为客户端和服务端 tokenize 结果存在差异,填满窗口再留输出空间是最常见算术错误。
上下文预算不是关于保持提示简短的指导原则。它是在任何内容被渲染之前,由代码对固定数量的 token 在命名索赔方之间进行的分配。以下是代码实现。
窗口同时容纳输入和输出。答案所需的空间对提示是不可用的,这是组装器中最常见的算术错误:填充到窗口大小后发现模型没有地方写入。其中的机制——以及产生的 400 错误——属于上下文窗口与最大输出 token 的问题。对于分配器而言,只有后果才重要:
available = window - reserved_output - safety_margin
安全边际不是迷信。你的 token 计数是一个估计值,由客户端 tokenizer 针对提供商将重新序列化的请求生成,而聊天模板、工具 schema 和角色标记都会添加你未曾编写的 token。窗口的百分之二到三的边际可以吸收这些;如果方向错了,则会在昂贵组装结束时导致请求失败。
预留输出是任务的属性,而不是模型的属性。返回一个词的分类器可以预留 64 个 token。可能输出一整个文件的代码生成器不能预留少于几千个 token,否则偶尔会在函数中间截断。推理模型使这个问题更加突出,因为隐藏的思考 token 来自相同的配额——为你看不见的思考预留,而不仅仅为你能看到的答案预留。
诱人的设计是用百分比:20% 系统、30% 历史、50% 检索。它立即失败,因为索赔方的弹性不同。系统块和工具 schema 有固定大小;它们不能被给予任何东西的 20%。历史和检索到的文档确实是弹性的。因此分配器每个块需要三个量,而不是一个:
floor — 低于此值块变得毫无价值,应该完全丢弃而不是缩减。将检索块缩减到 200 token 不是一个小的检索块;它是一份被截断的文档,会产生误导。
want — 如果没有竞争,块会使用的尺寸。
priority — 当仅靠地板值不能容纳时的牺牲顺序。优先级较低先被牺牲。
缩减和丢弃之间的分离是携带大部分价值的部分。统一比例缩减所有内容是简单的实现,但会同时降级每个块;地板值将其转化为丢失一个块而保持其余完整的决策,这几乎总是更好的权衡。
固定块优先支付,地板值按优先级顺序支付,剩余的任何东西按比例在弹性块之间分配。没什么奇特的——但写下来、可测试、每次请求都一样:
type Block = {
id: string;
fixed?: boolean; // must be included whole or the request is invalid
floor: number; // drop below this rather than shrink
want: number; // size with no competition
priority: number; // higher survives longer
};
function allocate(blocks: Block[], available: number) {
const out = new Map<string, number>();
let left = available;
// 1. Fixed blocks are not negotiable. If they do not fit, fail loudly.
for (const b of blocks.filter(b => b.fixed)) {
out.set(b.id, b.want);
left -= b.want;
}
if (left < 0) throw new Error("fixed context exceeds window");
// 2. Pay floors in priority order; anything unfunded is dropped.
const elastic = blocks
.filter(b => !b.fixed)
.sort((a, b) => b.priority - a.priority);
const funded: Block[] = [];
for (const b of elastic) {
if (left >= b.floor) { out.set(b.id, b.floor); left -= b.floor; funded.push(b); }
else out.set(b.id, 0); // dropped, not starved
}
// 3. Share the remainder in proportion to unmet demand.
const demand = funded.reduce((s, b) => s + (b.want - b.floor), 0);
if (demand > 0 && left > 0) {
for (const b of funded) {
const extra = Math.floor(left * (b.want - b.floor) / demand);
out.set(b.id, Math.min(b.want, out.get(b.id)! + extra));
}
}
return out;
}
有两个特性值得说明,因为它们正是使其有价值的原因。它是完整的——每个块都得到一个数字,包括零——所以下游代码永远不需要猜测一个块是被故意省略的。而且它是纯函数,所以你的整个分配策略都被覆盖在毫秒内运行的测试中,不需要任何模型。
假设——这里的每个数字都是一个假设,换成你自己的——一个 128,000 token 的窗口,预留 4,000 给输出,以及 3,000 token 的安全边际。剩下 121,000 可用。四个索赔方:
固定块占用 8,200,剩下 112,800。地板值占用 6,500,剩下 106,300 可分享。未满足的需求是历史 56,000 和检索 77,500,总计 133,500——所以历史额外获得 106,300 × 56,000 / 133,500 ≈ 44,600,检索获得约 61,700。最终分配:历史 48,600,检索 64,200。两者都低于其想要的,所以两者都会压缩,而且两者都知道压缩多少——在任何一個运行之前。
现在改变一个输入,看看策略如何发挥作用。切换到 32,000 token 的窗口,保持相同的预留:可用是 25,000,固定块占用 8,200,地板值占用 6,500,只剩 10,300 可分享。没有块被丢弃,但检索接近 8,500——大约一篇半文档。这正是注意到你的检索步骤应该返回三篇文档而不是十二篇的时刻——这是一个过滤决策,分配器刚刚使其变得可见。
计数错误的字符串。预算必须基于实际发送的内容计算——在聊天模板之后,工具 schema 必须按照 SDK 序列化的方式精确序列化。计数原始文本会少报,有时很严重。
一个无人声明的无界块。工具结果是常见的罪魁祸首:它们在请求之间到达,由框架附加,根本没有通过 allocate 传递。给它们一个块,否则它们会悄悄占据整个窗口。
一个每次请求都改变的预算。如果分配每轮移动几个 token,提示前缀每轮也会改变,每个缓存命中都会丢失。将分配四舍五入到粗粒度以保持前缀稳定——缓存交互本身就是一个独立的主题。
静默溢出。缺少预算的失败模式不是异常;而是提供者或框架从一端截断而不告诉你,这会产生一个根本没有读取部分指令的模型。
算术的这一半取决于两个 per-model 数字,这些数字在你更改模型时会改变,而猜测这些数字正是分配器最终为你不再调用的模型进行调优的方式。目录列出每个模型的上下文长度和最大输出,这样 window 和 reserved_output 的上限可以读取而不是假设。
Conversation Compaction: Summarising Without Losing State
Selective Attention: Filtering Before You Fill