如果你做过 AI Agent、流式助手或长对话产品,大概率遇到过这种"灵异事件":
对话越聊越长,模型开始答非所问、忘了半小时前自己定的方案;或者账单暴涨------明明只是"继续",每次请求却把 20 万 token 的上下文全量重算一遍;再或者,长任务跑到一半直接报错
context_length_exceeded,前面全白干。
这些都不是模型"变笨"了,而是上下文管理(Context Management)没做好 。本文系统梳理 AI 应用里"上下文"到底是什么、窗口怎么算、缓存怎么用、预算怎么管,以及最关键的------如何用分层压缩把长对话"瘦身"又不丢关键信息。
一、什么是上下文
从 API 视角看,上下文(Context)就是每次调用大模型时,模型实际"看到"的全部信息。它分成两大部分:
1.1 组成:静态前缀 + 轨迹
- 静态前缀(Stable Prefix) :由
System Prompt(系统提示词)和Tool Definitions(工具定义)组成,整个对话期间保持不变。对应 API 请求里的system消息和顶层tools字段。 - 轨迹(Trajectory):即对话历史,随交互不断增长。由用户消息、模型回复、工具执行结果构成。
五个组成部分可以一句话记牢:System + User + Assistant + Tool(结果)+ Tools(定义) ------ 也就是"四种消息角色 + 一个 tools 字段"。
typescript
// context.ts ------ 一次模型请求的上下文骨架
interface ModelRequest {
system: SystemMessage; // 静态前缀:身份/规则/约束
tools: ToolDefinition[]; // 静态前缀:工具 schema
trajectory: Message[]; // 轨迹:动态历史
status: StatusMessage; // 状态栏(Agent 常把当前状态注入末尾)
}
function buildRequest(session: Session): ModelRequest {
const stablePrefix = session.system; // 永远不动 → 持续命中缓存
const stableTools = coreToolSchemas; // 永远不动
const trajectory = loadMessageHistory(session);
const status = makeStatusMessage(deriveState(trajectory));
return { system: stablePrefix, tools: stableTools, trajectory, status };
}
1.2 消息列表的四种角色
大模型 API 的核心是消息列表(messages) ,每条消息用一个 role 标识身份:
| 角色 | 含义 | 关键点 |
|---|---|---|
system |
系统提示词 | 开发者写,定义身份/规则,通常一条放最前,属静态前缀 |
user |
用户消息 | 终端用户输入;Agent 也常借这个槽位注入框架生成的元信息(状态栏) |
assistant |
助手消息 | 模型之前的回复,含文本与工具调用请求(tool_calls) |
tool |
工具结果 | 框架执行工具后送回,通过 tool_call_id 关联对应调用 |
typescript
type Role = 'system' | 'user' | 'assistant' | 'tool';
interface Message {
role: Role;
content: string;
tool_calls?: ToolCall[]; // assistant 发起工具调用
tool_call_id?: string; // tool 结果回关联对应调用
}
Agent 框架的核心工作,本质上就是管好这个
messages列表 :在合适时机追加消息(尤其是role: 'tool'的工具结果),再整段送给模型。
二、上下文窗口:有效窗口 = 模型窗口 − 预留输出令牌
模型单次能处理的 token 上限叫上下文窗口 (如 200K)。但别把"上限"当成"可用上限"------你还得给模型的输出留空间。
scss
有效窗口 = 模型窗口 − 预留输出令牌
预留输出令牌 = min(最大输出令牌, 20_000) // 压缩悖论:压缩本身也要消耗输出
场景示例 :你用 200K 窗口的模型,单次最多输出 8K。那么一轮对话能喂给模型的输入其实只有 200K − 8K = 192K。更关键的是,AutoCompact 这种"用 LLM 生成摘要"的压缩方式,自己也要消耗输出令牌,参考 Claude Code 会预留最多 20K 给压缩摘要,防止"想压缩却因空间不足而失败"的压缩悖论。
typescript
const MODEL_WINDOW = 200_000;
const MAX_OUTPUT = 8_000;
const RESERVED_OUTPUT = Math.min(MAX_OUTPUT, 20_000); // 取较小值,但上限 20K
const EFFECTIVE_WINDOW = MODEL_WINDOW - RESERVED_OUTPUT;
// 当前窗口水位计,作为所有阈值判断的统一输入
function usedRatio(ctx: ModelRequest): number {
return estimateTokens(ctx) / EFFECTIVE_WINDOW;
}
三个必须区分的概念(很多团队把它们混为一谈):
- 上下文溢出(Overflow):窗口装不下,直接报错。
- 上下文腐化(Context Rot):装得下但找不到重点,注意力分散导致决策质量下降。
- 上下文焦虑(Context Anxiety):模型"以为"窗口快满了,提前草草收尾。
三、上下文缓存:KV Cache 与 Prompt Cache 原理
理解压缩为什么"既要压又不能乱压",必须先懂缓存。两者都利用前缀不变性。
3.1 KV Cache(模型内部机制)
模型生成新 token 时要回头看前文所有 token 的中间计算结果(Key/Value 向量)。KV Cache 把这些 K、V 缓存下来,新 token 只算"新增部分"。
关键约束 :要复用的上下文 token 前缀必须保持不变 。一旦序列从某个位置开始不同,第一个不同 token 及其之后的 KV 状态都得重算------而且 Transformer 多层串联,变动点越靠前,重算越多。
3.2 Prompt Cache(推理引擎优化)
在多次 API 请求之间缓存相同前缀的计算结果。服务商匹配请求前缀,复用之前算好的 KV Cache,读取成本约为首次计算的十分之一(Anthropic、DeepSeek、OpenAI 等均支持)。
typescript
// 伪代码:前缀缓存命中取决于"前缀是否一致"
function cacheKeyOf(request: ModelRequest): string {
// 只有 system + tools + 历史前缀完全一致,才能复用缓存
return hash(stablePrefix(request)) + ':' + hash(commonHistoryPrefix(request));
}
// 若你在中途改了 system 或插入历史,前缀断裂 → 缓存失效 → 整段重算
一句话:KV Cache 加速单次请求内的生成,Prompt Cache 减少跨请求重复计算。 它们共享同一个前提------静态前缀(system + tools)要稳如泰山。
四、Token 预算管理
预算管理的本质是:在构造上下文时就做检查,超了就压缩,而不是等报错再救火。
typescript
const BUDGET = EFFECTIVE_WINDOW; // 可用上限
function buildRequestWithBudget(session: Session): ModelRequest {
const req = buildRequest(session);
if (estimateTokens(req) > BUDGET) {
// 压缩旧证据,但保留决策/约束/失败/引用这四类高价值信息
req.trajectory = compressOldEvidence(req.trajectory, {
preserve: ['decisions', 'constraints', 'failures', 'citations'],
});
}
return req;
}
// 阈值安全网(沿用 Claude Code 的分区,基于有效窗口)
function guard(ctx: ModelRequest) {
const r = usedRatio(ctx);
if (r > 0.95) throw new Error('BLOCK: 95%-100%,拒绝新请求');
if (r > 0.90) triggerAutoCompact(ctx); // 危险区:AutoCompact
else if (r > 0.85) warnUser('上下文接近上限'); // 警告区
}
预算控制的实战技巧:
- 工具结果预算:大体积输出(如整个文件、长日志)存磁盘,上下文里只放摘要预览。
- 自适应窗口化:监控使用率,超 80% 才激活压缩,避免频繁压缩抖动。
五、上下文压缩
5.1 为什么要压?
压缩不是"没钱才做"的妥协,它有三个独立动机:
- 解决长度 / 成本约束:防溢出、降成本与延迟。这是最直觉的原因。
- 提升思考质量 :上下文学习本质更接近"检索"而非"推理"。把一长篇原始对话压缩成结论性摘要 ,模型反而更容易"检索到"关键知识------总结后的知识比原始形式更利于模型使用。
- 缓解上下文焦虑(Context Anxiety):在窗口还宽裕时提前压缩,模型不会因"快满了"而提前收尾,决策质量更稳定。
typescript
// 压缩的三个目的,映射到不同策略
const MOTIVATION = {
lengthCost: 'compress to fit window & save tokens',
quality: 'summarize raw → conclusion for better retrieval',
anxiety: 'proactive compress before 85% to keep model calm',
};
5.2 压缩策略的设计原则
- 信息价值非均匀分布:决策、约束、失败、引用远比"我正在思考"有价值,压缩要保前者。
- 语义完整性:别把一条 tool_call 和它的 tool 结果拆散,否则模型无法关联。
- 任务相关性:注入当前查询意图与上下文做"上下文感知压缩",比无脑摘要更优。
- 压缩即理解:用模型调模型做递归压缩(让一个模型把另一段对话压成摘要),压缩过程本身也是一次"理解"。
5.3 分层压缩机制
生产级 Agent(以 Claude Code 为参照)通常不是"一把梭"全量摘要,而是五级渐进:
- 工具结果预算控制:大输出存盘、看摘要,替换冻结以保证缓存一致。
- 噪声直接删除:低价值内容直接移除,不做摘要(删比摘要便宜)。
- API 层微压缩:服务端移除指定工具结果,本地消息不变(移除点之后缓存失效)。
- 归档式摘要 :逐轮结构化摘要(如
git log保留独立记录,不混进主对话)。 - 全量压缩:LLM 驱动完整压缩,先试会话记忆再全量,配熔断器防循环失败。
核心思想:能用便宜的办法(删、替换、字符串操作)就别调 LLM;只有便宜办法都释放不出空间,才上 LLM 全量压缩。
5.4 Claude Code 压缩策略:不同阈值触发不同策略
Claude Code 把压缩做成四级渐进式 ,由阈值驱动,且越往后成本越高(越要调 LLM)。
| 级别 | 策略 | 触发条件 | 是否调 LLM | 保留 / 清除 | 适用场景 |
|---|---|---|---|---|---|
| L1 | Snip 裁剪 | 用户进入新阶段(手动/内部触发) | ❌ 零调用 | 清除较早消息序列;保留界面回看历史 | 前一阶段产生大量消息,当前进新阶段 |
| L2 | MicroCompact 微压缩 | 距上次助手消息超时(缓存过期) | ❌ 零调用(仅字符串替换) | 保留最近 N 个可压缩工具结果,其余替换为 [Old tool result content cleared] |
长对话"自然断点"(用户暂停后返回) |
| L3 | Collapse 折叠 | 使用率 90% 起提交,95% 阻新 spawn | ⚠️ 部分 LLM | 选择性重构消息组,保留更多原始细节 | 从"被动压缩"转"主动重构" |
| L4 | AutoCompact 自动压缩 | 以上三级释放不出空间,超危险阈值 | ✅ 完整 LLM | 清全对话原始内容;重建为边界+摘要+附件 | 最终兜底 |
下文伪代码为基于公开机制描述的示意实现,非 Claude Code 源码
5.4.1 Snip:用户手动触发,零 LLM 调用,如何精准清除?
Snip 在消息序列层面 裁剪历史。难点是"精准清除"------不能把 tool_call 和对应 tool 结果拆散。做法是找到阶段边界(phase boundary),整段切掉,并妥善处理关联。
typescript
// L1: Snip ------ 用户进入新阶段时裁剪较早消息
function findPhaseBoundary(messages: Message[]): number {
// 找到最近一个"用户开启新任务"的消息索引作为切分点
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].role === 'user' && isNewPhase(messages[i])) return i;
}
return 0;
}
function snipCompactIfNeeded(messages: Message[]): Message[] {
const cutoff = findPhaseBoundary(messages);
// 切掉 cutoff 之前的旧消息(界面回看历史不删,只是不进模型上下文)
return messages.slice(cutoff);
}
5.4.2 MicroCompact:缓存过期自动触发,零 LLM 调用,如何无损压缩?
MicroCompact 是性价比之王 :内容清除路径 (Content-Clear),当距上次助手消息超时(缓存已过期、全量重写不可避免),它直接用字符串替换把旧工具结果清掉。
typescript
// L2: MicroCompact ------ 时间触发,仅替换内容,不动结构
const COMPRESSIBLE = ['Read','Bash','Grep','Glob','WebSearch','WebFetch','Edit','Write'];
async function microCompact(messages: Message[], keepRecent: number): Promise<Message[]> {
const toolResults = messages.filter(m => m.role === 'tool');
// 保留最近 keepRecent 个,其余清空内容(keepRecent 最小为 1)
const toClear = toolResults.slice(0, Math.max(0, toolResults.length - keepRecent));
const clearSet = new Set(toClear.map(m => m.id));
return messages.map(m => {
if (m.role === 'tool' && clearSet.has(m.id) && COMPRESSIBLE.includes(m.toolName!)) {
return { ...m, content: '[Old tool result content cleared]' }; // 占位符,结构不变
}
return m;
});
}
场景示例(阅读 10+ 文件后) :Agent 读了一堆文件,工具结果把上下文塞满。用户去泡了杯咖啡(自然断点,缓存过期)。回来时 MicroCompact 自动把早期 Read 结果替换成占位符,消息结构不变、缓存前缀不破,下一次请求只需重写很小一段。
此外,还有一个 MicroCompact 缓存编辑路径(Cache-Edits ),当缓存仍然有效 时,通过 API 层面的 cache_edits 机制删除服务端在缓存副本中的工具结果而不破坏客户端本地的消息,消息结构不变 → 缓存前缀不破 → 无损优化。
5.4.3 Collapse 折叠
MicroCompact是"挖空"旧工具结果(丢内容、保结构),AutoCompact是"整段 LLM 摘要"(保密度、丢原始细节)。Collapse 站在两者中间的空白带 :它不调用 LLM、不把内容挖空,而是在上下文还没到危险水位时,提前把啰嗦的消息组"重构"成更紧凑的形态。
核心原理(为什么叫"主动重构"):
-
类比内存预取 :操作系统不会等内存满了才整理,而是在压力到来前就做页回收。Collapse 同理------当
usedRatio越过一个中档主动水位(如 70%~80%,远早于 AutoCompact 的 95%),它就提前整理消息组,而不是等到溢出才救火。 -
Selective Reconstruction(选择性重构) :它识别"动作组"(一次 grep 循环产生的多个 tool 结果、一串连续的 status 消息、一段 assistant 自我修正的碎碎念),把这些组折叠成一行紧凑日志,而不是逐条挖空或整体摘要。原始"做过什么"被保留,只是存储形态更密。
-
不影响 spawn :Collapse 是确定性的字符串/结构操作,足够轻量,不会冻结 Agent 去新开工具调用(只有到 95% 的 AutoCompact 才需要阻止新 spawn)。这也是它"像内存预取一样在后台悄悄整理"的原因。
-
比全量摘要保真度更高:因为不做 LLM 抽象,只是"组 → 行"的形态变换,它留下的信息比 AutoCompact 摘要更贴近原始细节。
场景示例(长任务中的 grep 调试循环) :Agent 排查一个 bug,跑了 6 轮 Bash/Grep 循环,每轮产生 4~5 条 tool 结果,上下文悄悄涨到 75%。Collapse 在此时主动把这 6 轮折成一行 [collapsed] Bash/Grep x6: 逐步定位到 auth.ts 第 42 行 token 校验缺失,既腾出空间、又保留了"排查路径"这一关键信息,Agent 继续 spawn 新工具调用不受影响;若等到 95% 才 AutoCompact,这一路调试细节会被 LLM 摘要稀释,反而更难回溯。
5.4.4 AutoCompact:涉及 API 调用的全量压缩
当上述三级都释放不出空间,AutoCompact 才用 LLM 把整个对话压成摘要。流程(compactConversation):
- 执行 PreCompact 钩子(用户可注入"特别保留某类讨论"的指令)
- 构建压缩提示(BASE / PARTIAL / PARTIAL_UP_TO 三选一)
- 流式生成摘要(
prompt-too-long时截断最老轮次组重试,最多 3 次) - 重建上下文(边界标记 + 摘要 + 附件 + 钩子结果)
- 执行 PostCompact 钩子
typescript
// L4: AutoCompact ------ 全量 LLM 压缩,带断路器保护
async function autoCompact(ctx: ContextState, breaker: CompactCircuitBreaker) {
return breaker.run(async () => {
const prompt = ctx.tokens > THRESHOLD
? BASE_COMPACT_PROMPT // 全量摘要
: PARTIAL_COMPACT_PROMPT; // 仅摘要最近
let summary = await llm.summarize(prompt);
let retries = 0;
while (isTooLong(summary) && retries < 3) { // prompt-too-long 截断重试
summary = await llm.summarize(truncateOldest(prompt));
retries++;
}
return rebuildContext(ctx, summary); // 边界 + 摘要 + 附件 + 钩子
});
}
断路器设计:避免雪崩效应
自动压缩并非总成功(网络波动、API 错误、结构问题)。没有断路器会陷入"压缩失败 → 重试 → 再失败"死循环。Claude Code 的真实数据:引入前曾观察到单个会话 50+ 次连续失败(最高 3,272 次),每天浪费约 25 万次 API 调用;引入断路器后级联失败彻底消除。
typescript
// 断路器:连续失败 3 次直接熔断,跳过后续压缩
class CompactCircuitBreaker {
private failures = 0;
private static MAX = 3; // MAX_CONSECUTIVE_AUTOCOMPACT_FAILURES
async run(task: () => Promise<CompactionResult>): Promise<CompactionResult | null> {
if (this.failures >= CompactCircuitBreaker.MAX) return null; // OPEN:熔断跳过
try {
const r = await task();
this.failures = 0; // 成功 → 回到 CLOSED,计数器清零
return r;
} catch {
this.failures++; // CLOSED → HALF_OPEN → OPEN
return null;
}
}
reset() { this.failures = 0; } // 新会话 / 手动压缩成功时重置
}
状态机:CLOSED(正常)→ HALF_OPEN(失败递增)→ OPEN(连续失败≥3 熔断,不再尝试)→ 新会话/手动压缩成功重置回 CLOSED。
压缩提示工程
三种模板:
BASE_COMPACT_PROMPT:全量对话摘要。PARTIAL_COMPACT_PROMPT(from):仅摘要最近消息。PARTIAL_COMPACT_UP_TO_PROMPT(up_to):摘要指定消息之前的上下文。
双阶段输出结构(哲学:"思考是过程,摘要是结果;过程不计费,结果计入上下文"):
<analysis>块:思维草稿本,组织思路,最终丢弃以节省令牌。<summary>块:正式摘要,formatCompactSummary提取它。
关键提示工程约束:要求模型仅文本回复、禁调工具(因为 forked agent 最多 1 轮,被拒的工具调用会导致空输出)。
压缩前需将关键信息保存为记忆文件
压缩会丢失对话细节,但若关键信息已存为记忆文件,压缩后 Agent 仍可通过读取记忆恢复关键上下文。
typescript
// 压缩前:把高价值决策写入记忆文件,与对话解耦
async function saveMemoryBeforeCompact(ctx: ContextState) {
const keyFacts = extractKeyFacts(ctx.trajectory, {
preserve: ['decisions', 'constraints', 'failures', 'citations'],
});
await writeMemoryFile('project/decisions.md', keyFacts); // 压缩后仍能读回
}
实用建议:养成在重要决策时让 Agent 保存记忆的习惯。即使对话被压成摘要,关键信息不丢。PreCompact 钩子也可注入自定义指令(如"特别保留与数据库相关的讨论")。
六、压缩与 KV Cache 的关系
这俩常被误以为矛盾------"压缩改了上下文,缓存不就失效了?"其实互补:
- 压缩发生在两次 API 调用之间(Agent 框架预处理),而非单次调用中。
- System Prompt 和 Tool Definitions 永远不动 → 静态前缀持续缓存。
- 压缩对象是对话历史里的 tool results,替换位置之后 缓存失效,之前仍有效。
- 权衡 :不压缩则溢出失败;压缩损部分缓存但密度更高。接近阈值时批量压缩最优(一次压缩覆盖多轮,避免每轮小改导致缓存反复失效)。
- 若模型把 thinking 绑定到前缀,摘要旧轮次会使旧 thinking 失效;推荐整段压成一条摘要并不再回传旧 thinking,或交服务端压缩(如
cache_edits路径,删除工具结果而不破坏缓存前缀)。
typescript
// 最佳实践:接近阈值时"批量压缩",而非每轮小改
function shouldBatchCompact(ratio: number): boolean {
return ratio > 0.85; // 进入警告区就一次性压,保护缓存命中率
}
总结:上下文工程的心法
把全文浓缩成可循的三条实践:
- 前缀要稳 :system + tools 永不动,才能持续吃 Prompt Cache 的红利;动态信息(时间戳、状态)一律追加到末尾,绝不改开头。
- 预算前置:在构造上下文时就做 token 检查与阈值守护(85% 警告 / 90% 自动压 / 95% 拒绝),别等报错。
- 压缩分层、阈值驱动:能删就删(Snip)、能替就替(MicroCompact)、能用便宜办法就别调 LLM;只有兜底才上 AutoCompact,并配断路器防雪崩。压缩前先存记忆文件,压缩后不丢关键信息。
如果说传统 Web 开发的心法是"无状态、可水平扩展",那么 AI Native 应用的心法就是"有状态、且状态必须被高效地组织、压缩与恢复"。上下文管理,正是这个心法的第一现场。