从一次上下文溢出说起
「回声」的私信智能回复在 6.1 之后已经用上了模板与变量,system 只保留角色和硬规则。但另一个功能没有跟上:长文摘要。
摘要的输入拼装逻辑是这样的:system 放角色与输出格式,中间放最近二十条历史,再放检索到的相关段落,最后放工具返回的订单与物流信息。团队为了让「指令离问题更近」,把 system 拼在了最末尾。
上线后遇到一篇特别长的帖子,检索结果把上下文撑到了模型窗口之外。提供方没有报错,而是从尾部截断。system 恰好就在尾部,于是角色、输出格式和「不得编造」的规则一起消失。模型仍然返回了一段摘要,只是开始补出不存在的物流节点。
复盘时发现三个问题:
- 拼装时没有总预算,每一类内容各自只看自己的长度。
- 没有区分内容类别,截断只看位置,system 和历史被同等对待。
- 估算用的是字符数,与提供方的 token 计数不同,却没有留安全余量。
这三点合起来,就是本节要解决的上下文预算问题。
上下文不是越多越好
「把相关材料都放进去」听起来稳妥,实际会同时付出三种代价。
第一是成本。输入 token 按量计费,塞满窗口意味着每次请求都按上限付费,而多出来的材料未必被模型使用。
第二是延迟。输入越长,首字延迟通常越高;对私信回复这类交互,首字慢半秒就会明显影响体验。
第三是注意力稀释。上下文里无关内容越多,模型越容易抓错重点。这不是玄学,而是长上下文评测里反复出现的现象:关键信息放在中段时,召回率会下降。
所以问题不是「能不能塞下」,而是「在有限预算里放哪些、放多少、先放弃谁」。这是一个分配问题,不是压缩问题。
四类上下文与四个预算
「回声」把所有上下文片段分成四类。分类的依据不是格式,而是「谁产生、多久过期、裁掉之后产品会怎样」。
| 类别 | 典型内容 | 变化频率 | 裁剪位置 | 裁掉后的后果 |
|---|---|---|---|---|
| system | 角色、语气、硬规则 | 低 | 永不裁剪 | 规则丢失,输出不可控 |
| history | 最近对话轮次 | 每轮 | 最后裁 | 指代与上下文断裂 |
| retrieval | 检索到的文章片段 | 每请求 | 中间裁 | 事实依据缺失 |
| tool | 订单、物流等工具结果 | 每请求 | 最先裁 | 细节缺失,可降级提示 |
「永不裁剪」不是指 system 可以无限长,而是指 system 超预算时整个请求失败,而不是悄悄切掉一半。一个缺了规则的请求,比一个明确失败的请求更危险。
每类有一个硬上限,四类加起来还有一个全局上限。硬上限防止某一类挤占全部空间,全局上限防止四类同时膨胀。
为什么先分类再裁剪
统一的「按长度截断」有两个致命缺陷。
第一,它按位置决定生死。system 如果恰好排在后面,就会和历史消息一起被切掉;而这两类内容的价值完全不同。
第二,它无法表达类内优先级。十条历史里,最近两条通常比最早两条重要;二十条检索结果里,相关性最高的三条通常比其余十七条重要。按长度截断会把高价值片段和低价值片段一视同仁。
分类之后,裁剪可以分两步:先在各目的类别上限内按优先级保留,再在全局超预算时按类别顺序释放。每一步都有明确的排序键,结果可以复现。
估算口径
预算需要数字,数字需要口径。本节不引入真实 tokenizer,而是用一个可复现的近似:
- CJK 与全角字符:1 字符按 1 token 计。
- 其余字符:每 4 字符按 1 token 计,向上取整。
- 逐段估算后相加,不做跨段合并。
这个口径比真实分词更保守,因为跨段拆开的拉丁字符串会被分别向上取整。保守的好处是留出余量,坏处是可能提前触发裁剪。对教学与回归测试来说,确定性比绝对精确更重要。
真实生产里,如果提供方返回了 usage,应该用实际 input tokens 校准;如果提供方提供 count API,应该在网关侧统一封装。不要在业务代码里各引一个 tokenizer,版本一升级,预算就对不上。
实现:两阶段裁剪
下面的实现把「类别上限」和「全局预算」拆成两个阶段。system 只检查、不裁剪;其余三类先按优先级保留,再按 trimOrder 释放。
// file: src/prompt/context-budget.ts
// tier: L1
// verify: node --test test/prompt/context-budget.test.ts
import { AiError } from "../core/errors.ts";
export type ContextKind = "system" | "history" | "retrieval" | "tool";
export type TrimmableKind = Exclude<ContextKind, "system">;
export const CONTEXT_KINDS: ContextKind[] = ["system", "history", "retrieval", "tool"];
export const TRIMMABLE_KINDS: TrimmableKind[] = ["history", "retrieval", "tool"];
export interface ContextSegment {
id: string;
kind: ContextKind;
text: string;
/** 同类内越大越先保留:历史用新旧序,检索用相关性,工具结果用新旧序。 */
priority: number;
}
export interface BudgetPlan {
maxTokens: number;
/** 每类硬上限;system 超限直接失败,其余按优先级裁到上限以内。 */
caps: Record<ContextKind, number>;
/** 全局仍超预算时的裁剪顺序,必须恰好包含三个可裁剪类别。 */
trimOrder: TrimmableKind[];
}
export interface DroppedSegment {
id: string;
kind: ContextKind;
tokens: number;
reason: "class-cap" | "global-budget";
}
export interface BudgetResult {
kept: ContextSegment[];
dropped: DroppedSegment[];
usedTokens: number;
byKind: Record<ContextKind, number>;
}
const CJK = /[ -〿㐀-䶿一-鿿豈--]/u;
/**
* 可复现的近似口径:CJK 与全角字符按 1 字符 1 token,其余每 4 字符 1 token,
* 逐段向上取整。它不是任何提供方的真实分词结果,只用于预算比较与回归测试。
*/
export function estimateTokens(text: string): number {
if (text === "") return 0;
let tokens = 0;
let latin = 0;
for (const char of text) {
if (CJK.test(char)) {
tokens += 1;
if (latin > 0) {
tokens += Math.ceil(latin / 4);
latin = 0;
}
} else {
latin += 1;
}
}
if (latin > 0) tokens += Math.ceil(latin / 4);
return tokens;
}
function fail(message: string): never {
throw new AiError("invalid-request", `context-budget: ${message}`);
}
function validatePlan(plan: BudgetPlan): void {
if (!Number.isInteger(plan.maxTokens) || plan.maxTokens <= 0) {
fail("maxTokens must be a positive integer");
}
for (const kind of CONTEXT_KINDS) {
const cap = plan.caps[kind];
if (cap !== Number.POSITIVE_INFINITY && (!Number.isInteger(cap) || cap < 0)) {
fail(`caps.${kind} must be a non-negative integer or Infinity`);
}
}
const order = plan.trimOrder;
if (
order.length !== TRIMMABLE_KINDS.length ||
new Set(order).size !== order.length ||
TRIMMABLE_KINDS.some((kind) => !order.includes(kind)) ||
order.some((kind) => !TRIMMABLE_KINDS.includes(kind))
) {
fail("trimOrder must contain history, retrieval and tool exactly once");
}
}
/**
* 两阶段裁剪:先按类别上限保留高优先级片段,再按 trimOrder 从低优先级开始
* 释放全局预算。system 只检查、不裁剪;它放不下时整个请求失败。
*/
export function planContext(plan: BudgetPlan, segments: ContextSegment[]): BudgetResult {
validatePlan(plan);
const seen = new Set<string>();
for (const segment of segments) {
if (segment.id.trim() === "") fail("segment id must not be empty");
if (seen.has(segment.id)) fail(`duplicate segment id "${segment.id}"`);
seen.add(segment.id);
if (!CONTEXT_KINDS.includes(segment.kind)) fail(`unknown kind "${segment.kind}"`);
if (!Number.isFinite(segment.priority)) fail(`priority for "${segment.id}" must be finite`);
}
const items = segments.map((segment, index) => ({
segment,
index,
tokens: estimateTokens(segment.text),
}));
const systemTokens = items
.filter((item) => item.segment.kind === "system")
.reduce((sum, item) => sum + item.tokens, 0);
if (systemTokens > plan.caps.system || systemTokens > plan.maxTokens) {
throw new AiError(
"budget-exceeded",
`context-budget: system prompt needs ${systemTokens} tokens and is never trimmed`,
);
}
const kept = new Set<string>();
for (const item of items) {
if (item.segment.kind === "system") kept.add(item.segment.id);
}
const dropped: DroppedSegment[] = [];
for (const kind of TRIMMABLE_KINDS) {
const candidates = items
.filter((item) => item.segment.kind === kind)
.sort((left, right) => right.segment.priority - left.segment.priority || left.index - right.index);
let used = 0;
for (const item of candidates) {
if (used + item.tokens <= plan.caps[kind]) {
kept.add(item.segment.id);
used += item.tokens;
} else {
dropped.push({ id: item.segment.id, kind, tokens: item.tokens, reason: "class-cap" });
}
}
}
let total = items
.filter((item) => kept.has(item.segment.id))
.reduce((sum, item) => sum + item.tokens, 0);
for (const kind of plan.trimOrder) {
if (total <= plan.maxTokens) break;
const candidates = items
.filter((item) => item.segment.kind === kind && kept.has(item.segment.id))
.sort((left, right) => left.segment.priority - right.segment.priority || right.index - left.index);
for (const item of candidates) {
if (total <= plan.maxTokens) break;
kept.delete(item.segment.id);
dropped.push({ id: item.segment.id, kind, tokens: item.tokens, reason: "global-budget" });
total -= item.tokens;
}
}
if (total > plan.maxTokens) {
throw new AiError("budget-exceeded", `context-budget: context needs ${total} tokens after trimming`);
}
const position = new Map<string, number>();
items.forEach((item) => position.set(item.segment.id, item.index));
dropped.sort((left, right) => position.get(left.id)! - position.get(right.id)!);
const byKind: Record<ContextKind, number> = { system: 0, history: 0, retrieval: 0, tool: 0 };
const keptSegments: ContextSegment[] = [];
for (const item of items) {
if (!kept.has(item.segment.id)) continue;
keptSegments.push(item.segment);
byKind[item.segment.kind] += item.tokens;
}
return { kept: keptSegments, dropped, usedTokens: total, byKind };
}
实现里有几个刻意的选择。
第一,trimOrder 必须恰好包含 history、retrieval、tool 各一次。少写一个类别,那一类就会在全局裁剪时被遗忘;重复写一个类别,排序就失去意义。校验在入口处完成,不等到运行到一半才报错。
第二,类内排序用 priority 降序,相同优先级时保留更早出现的片段。这样历史消息可以按新旧赋权,检索结果可以按相关性赋权,工具结果可以按新鲜度赋权,而裁剪逻辑本身不需要知道业务含义。
第三,全局裁剪用 priority 升序,相同优先级时先丢更晚出现的片段。这一步保证「先牺牲最不重要、最旧」的片段,而不是从头开始切。
第四,返回的 kept 按原始输入顺序排列。裁剪改变的是「保留哪些」,不改变「保留下来的顺序」;对话历史和检索证据都需要保持原有叙事顺序。
第五,dropped 记录每个被裁片段的原因:class-cap 表示类别上限触发,global-budget 表示全局预算触发。日志里能区分这两者,才能判断是该调类别上限,还是该扩窗口。
第六,system 超限直接抛 budget-exceeded。它属于配置或输入错误,重试不会变好;调用方应该收到明确失败,而不是一段缺规则的输出。
测试与真实输出
预算逻辑必须是确定性的:同样的片段、同样的计划,每次得到同样的保留与丢弃结果。完整测试放在 test/prompt/context-budget.test.ts,覆盖估算口径、类别裁剪、全局裁剪、顺序保持与错误边界。
在实验工程根目录执行:
node --test test/prompt/context-budget.test.ts
本机一次运行的真实输出如下,耗时随机器变化:
✔ token 估算口径可复现 (1.054167ms)
✔ system 永远保留,先牺牲工具结果 (0.708375ms)
✔ 类别上限先裁掉低优先级片段 (0.108667ms)
✔ 全局超预算按 trimOrder 依次释放 (0.124625ms)
✔ 保留片段维持原始输入顺序 (0.110834ms)
✔ system 超限直接失败,不裁 system (0.257084ms)
✔ trimOrder 必须覆盖三个可裁剪类别 (0.081917ms)
✔ 同一输入重复运行结果完全一致 (0.103834ms)
ℹ tests 8
ℹ suites 0
ℹ pass 8
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 141.792
测试里所有片段都用 "a".repeat(tokens * 4) 构造,让每个片段恰好等于若干 token。这样断言可以写成精确数字,不需要依赖浮点或真实分词。
一次「检索结果挤掉系统规则」的复盘
回到开头的摘要事故。团队最初的拼装顺序是:历史、检索、工具、system。他们认为「指令放最后,模型更愿意遵守」。这个经验来自短提示,在长上下文里不成立,因为提供方的截断策略通常从尾部开始。
修复没有靠换模型,而是改了四件事。
第一,system 永远排在最前,并由预算器单独保护。它不参与类内排序,也不进入全局裁剪。
第二,拼装前先算总预算。窗口 8000,最大输出 800,安全余量 800,输入预算就是 6400(案例假设值)。四类片段的总和超过 6400 时,先在预算器里裁,不把超长请求发给提供方。
第三,设置类别上限。system 600、history 1800、retrieval 3200、tool 800,总和 6400(案例假设值)。这些数字不是拍脑袋,而是从评测里看「哪类内容删掉之后通过率下降最多」倒推出来的。
第四,记录每次请求的 usedTokens 与 dropped 原因。如果某类长期因为 class-cap 被裁,说明上限偏低;如果长期因为 global-budget 被裁,说明总预算或输入规模需要重新设计。
改完之后,同样的长文不再触发尾部截断,system 规则稳定保留。摘要里仍然可能缺少细节,但缺的是明确被裁掉的工具结果,而不是整段规则。
这个案例的教训是:上下文事故往往不是「模型记不住」,而是「系统在模型看到之前就把规则弄丢了」。预算器的价值,是让丢失发生在可控的位置,并留下记录。
预算参数从哪来
预算参数不能靠感觉,建议按下面的顺序推导。
先算输入预算:contextWindow - maxOutputTokens - safetyMargin。安全余量用来吸收估算误差和提供方计数差异,案例里取窗口的 10%。
再定类别上限:从评测集出发,逐类做消融,观察通过率变化。system 的下限由规则数量决定,通常最稳定;history 和 retrieval 的上限取决于任务类型;tool 结果最容易过期,适合给较小的上限和较早的裁剪顺序。
最后定 trimOrder:先裁「删掉之后还能用别的方式补救」的类别。工具结果缺失可以提示用户重试,检索片段缺失可以降低置信度,历史缺失只能让模型少一点指代能力。所以案例里的顺序是 tool、retrieval、history。
这些数字都要随模型和数据变化复核,不能一次写死。
与 6.1、6.3 的衔接
6.1 把提示变成有变量声明的产物,本节把「往产物里放多少内容」变成可计算的预算。两者结合,提示不再是一段无限增长的文本,而是一组有名字、有上限、有优先级的片段。
6.3 再往前一步:预算计划本身也会变。类别上限调整、裁剪顺序调整,都应该作为提示配置的一部分被版本化,而不是在代码里悄悄改一个常量。
小结
上下文预算不是「窗口够不够大」,而是「有限空间怎么分配」。system、历史、检索、工具四类内容的价值衰减速度不同,必须分开设上限,再按明确的顺序释放全局预算。
system 保底不裁,是这条规则里最重要的一条。一个缺了硬规则的请求,输出看起来仍然流畅,却可能在关键事实上编造;明确失败至少能让产品走降级路径。
下一节处理发布:提示和预算配置如何版本化、如何灰度、如何回滚,以及为什么要和模型快照绑定。
阅读导航:上一节:6.1 提示是产物,不是字符串 · 下一节:6.3 提示版本化与发布 。
常见错误与边界
- 只设总长度,不分类别。system 和历史被同等对待,规则最容易先丢。
- 把 system 放在末尾以「增强遵守」。长上下文里,尾部正是很多截断策略的起点。
- 用字符数直接当 token 数,还不留余量。不同提供方的计数差异足以让预算失效。
- 每类都设上限,但没有全局上限。四类同时膨胀时,总请求仍然超窗。
- 裁剪顺序漏掉一类。没有校验的配置会让某一类永远不被释放。
- 裁剪后重排片段。历史与证据的顺序本身携带信息,保序比“更紧凑”重要。
- 用估算值代替真实 usage。估算用于事前规划,事后必须用实际计数校准。
- 把 system 超限当成可重试错误。配置错误重试多少次都还是超限。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。