上下文工程

上下文工程

第 07 章我们写出了最小 Agent Loop,第 08 章给它装上了工具系统。循环很漂亮:模型每轮看到 messages,决定要不要调工具,tool_calls 执行完回填 tool_result,继续下一轮。但有个问题从第 07 章起就一直悬着:messages 只增不减。跑得越久,发给模型的上下文越大,而模型的上下文窗口是有限的。这一章解决「在有限的窗口里,让 Agent 跑得久、跑得稳、跑得便宜」。

本章目标

读完本章并做完配套练习后,你应该能够:

开场反例:不管理上下文,Agent 跑 20 轮就死

设想一个「文件整理助手」。用户让它「把 /tmp/project 下所有文件读一遍并总结」。它很勤快:

// 第 1 轮:读 file_1.md(200 字符)
messages.push({ role: 'assistant', content: '', tool_calls: [{ id: 'call_1', name: 'read_file' }] });
messages.push({ role: 'tool', content: file1Content, tool_call_id: 'call_1' });
// 第 2 轮:读 file_2.md(400 字符)
messages.push({ role: 'assistant', content: '', tool_calls: [{ id: 'call_2', name: 'read_file' }] });
messages.push({ role: 'tool', content: file2Content, tool_call_id: 'call_2' });
// …… 第 20 轮:读 file_20.md(4000 字符)

每轮读一个更大的文件,messages 越来越胖。第 20 轮时,整段历史已经堆了 42 条消息、超过 1 万 token,一个常见的 8192 上下文窗口早就被撑爆了。API 拒绝请求:

Error: 400 - prompt is too long (10773 tokens > max 8192 tokens)

Agent 死了,死在距离完成任务还差最后一步的地方。更糟的是,它是每轮都在浪费:即使不爆窗,第 20 轮的请求也把前 19 轮读过的所有文件内容原样重发了一遍:这些 token 每一轮都在付费。这就是「context 爆炸」:不管理的 Agent,要么死,要么贵

flowchart LR
  A["第 1 轮<br/>messages=4 条"] --> B["第 5 轮<br/>messages=12 条"]
  B --> C["第 10 轮<br/>messages=22 条"]
  C --> D["第 15 轮<br/>messages=32 条<br/>6221 tokens"]
  D --> E["第 20 轮<br/>messages=42 条<br/>10773 tokens"]
  E --> F[prompt_too_long<br/>Agent 死亡]
  D -. "预算线 8192" .- G[(爆窗点)]
  E -. 越过预算线 .-> G

概念与动机:上下文是一笔有预算的账

管理上下文之前,先把三个概念分清(这个「桌面」比喻来自 Learn Hermes Agent 的上下文压缩章,见延伸阅读):

于是问题变成了一个预算问题:桌面上只能放 预算 这么多东西,怎么安排才不丢工作连续性? 三件事:

  1. 知道用了多少 → token 估算;
  2. 超了怎么办 → 两条路:滑动窗口(扔掉旧内容)与摘要压缩(把旧内容改写成短表示);
  3. 怎么少用 → 治理最大的消耗者(工具结果)与结构优化(system prompt 分层)。

token 估算:先知道桌面有多满

管理的第一步是测量。tiktoken 这类精确 tokenizer 很准,但它是个依赖、还要下载词表。大多数时候你只需要一个够做预算决策的近似值。启发式很简单:

// 零依赖 token 估算:CJK 每字 ~1.5,其他字符每 4 个 1,向上取整
function estimateTokens(text: string): number {
  if (text === '') return 0;
  const cjk = (text.match(/[\u3400-\u4dbf\u4e00-\u9fff]/g) ?? []).length;
  const other = text.length - cjk;
  return Math.ceil(cjk * 1.5 + other / 4);
}

estimateTokens('你好,请帮我查一下北京的天气。'); // -> 20
estimateTokens('Hello, could you check the weather?'); // -> 9
estimateTokens('你好 hello 混合文本 12345'); // -> 13

有了估算,就有了预算模型。把上下文窗口拆成三块,只有一块是「历史输入」能用的:

const WINDOW = 8192;        // 模型上下文窗口
const MAX_OUTPUT = 1024;    // 预留模型回复输出
const BUFFER = 512;         // 保险丝:JSON 包装、system prompt、未来工具 schema
const INPUT_BUDGET = WINDOW - MAX_OUTPUT - BUFFER; // -> 6656,历史输入预算

第 02 章我们看过 API 响应里的 usage 字段,那是真实 token 数。估算值和服务端实测通常只有几个百分点的差异(我们的 6221 vs 实测 6162,差不到 1%),这正好是它的定位:估算用于在调用前做决策(要不要压缩),实测用于调用后记账校准。生产系统两种都用:决策时估算,事后拿 usage 对账、把估算系数调到更准。

手段一:滑动窗口——扔旧保新

最朴素也最便宜的管理:历史超过预算时,把中间最旧的部分裁掉,只留头尾。头部是 system 与最初的用户指令(身份与任务定义,丢不得),尾部是最近的工作(模型正在看的内容)。

// 滑动窗口:保留前 head 条 + 最近若干条,裁掉中间,返回 { messages, dropped }
function trimHistory(messages, { maxMessages = 10, head = 2 } = {}) {
  if (messages.length <= maxMessages) return { messages, dropped: 0 };

  // 头部:前 head 条;若最后一条是带 tool_calls 的 assistant,吞入它的 tool 结果
  let headEnd = head;
  if (hasToolCalls(messages[headEnd - 1])) {
    while (headEnd < messages.length && messages[headEnd].role === 'tool') headEnd++;
  }

  // 尾部:最近 (maxMessages - head) 条;若第一条是孤立的 tool 结果,向左并入其 assistant
  let tailStart = Math.max(headEnd, messages.length - (maxMessages - head));
  while (
    tailStart > headEnd &&
    messages[tailStart].role === 'tool' &&
    hasToolCalls(messages[tailStart - 1])
  ) {
    tailStart--;
  }

  return {
    messages: [...messages.slice(0, headEnd), ...messages.slice(tailStart)],
    dropped: tailStart - headEnd,
  };
}

两个 while 是这段代码的灵魂,对应一个 API 硬约束:tool_calls 消息和它的 tool_result 必须成对出现。如果你把 assistant(tool_calls) 留在头部、把它的结果裁进中间,下一次调用 API 直接 400:模型看到「我要调工具」,却找不到对应的结果。所以:

Learn Hermes Agent 和 learn-claude-code 的实现里都有同样的边界处理(详见延伸阅读),这是「会写滑动窗口」和「写对滑动窗口」的区别。配对保护会让结果偶尔比预算多一两条,配对的完整性优先于精确的条数预算

滑动窗口的代价也很直白:被裁掉的信息永久消失。第 5 轮读过的文件内容、第 8 轮用户说的偏好,模型再也看不见了。它是「用遗忘换长度」——便宜(纯数组切片,0 次 API 调用),但有损。

手段二:摘要压缩——旧内容换一种更短的表示

如果旧信息还有价值、只是太长,怎么办?用摘要:把中间段发给一个便宜的辅助模型,让它产出结构化摘要,用一条摘要消息替换整段旧历史。裁掉的不该是「删除」,而是「换一种更短的表示」:原文进文件柜(持久层),桌面上只留便签。

// 摘要压缩:估算 token 超阈值时,把中间段换成一条 [Compacted] 摘要消息
function compactHistory(messages, { threshold, summarize, keepTail = 8 }) {
  const systemCount = countLeadingSystem(messages);      // system 永不参与摘要
  const rest = messages.slice(systemCount);

  let tailStart = Math.max(0, rest.length - keepTail);   // 尾部保留(同样配对保护)
  while (tailStart > 0 && rest[tailStart].role === 'tool' && hasToolCalls(rest[tailStart - 1])) {
    tailStart--;
  }
  const middle = rest.slice(0, tailStart);
  const tail = rest.slice(tailStart);
  if (middle.length === 0) return { messages, dropped: 0, compacted: false };
  if (estimateTokens(serialize(messages)) <= threshold) {
    return { messages, dropped: 0, compacted: false };
  }

  const summary = summarize(middle);                      // 辅助模型(注入式)
  const summaryMessage = { role: 'user', content: `[Compacted]\n\n${summary}` };
  return {
    messages: [...messages.slice(0, systemCount), summaryMessage, ...tail],
    dropped: middle.length,
    compacted: true,
  };
}

三个工程决策,每个都有讲究:

一、用辅助模型,不用主模型。 压缩是系统操作,不是用户请求。不该花贵模型的预算做「整理桌面」的脏活:用一个便宜的、快的模型就够了(Hermes 的做法,见延伸阅读)。这也是为什么 summarize注入式回调:测试和 demo 里注入确定性脚本,真实系统里注入辅助模型调用。

二、给摘要一个固定模板。 别让模型「随便总结」,给它一张表格。Learn Hermes Agent 的摘要模板固定五个区块:

Goal:           当前目标
Progress:       已完成的步骤
Key Decisions:  关键决定
Files Modified: 改过的文件
Next Steps:     下一步

这样写出来的摘要,换谁读都能接上活。模型读到它就知道「之前在干什么、干到哪了、接下来该干什么」。

三、摘要消息是「系统生成的」,不是「用户的新指令」。 消息角色用 user 但 content 以 [Compacted] 前缀开头,模型读到这个前缀会把它当作系统生成的上下文,而不是用户又下了一条新指令。否则它可能以为用户重新规划任务,把已经做完的事重做一遍。

摘要压缩的代价比滑动窗口隐蔽:信息衰减。摘要是「有损压缩」:第一轮压缩把「已读 s01、s02、s03」写进摘要,下一轮压缩时这份摘要又被进一步浓缩成「已读若干文件」,再下一轮变成「已读部分文件」。多轮下来,模型连「还剩哪些文件没读」都不知道,只能重新规划、甚至重复读已读过的文件(Hermes 记录的客户案例,见延伸阅读)。所以业界的共识是:把「目标与进度」这类连续性关键信息从消息流里搬出来,放进一个永远不会被压缩的区域:比如 Hermes 的 TaskState(目标 + 待办清单,每轮注入 system prompt,不进消息流,压缩算法碰不到它)。「摘要只该当中间段的备忘,不该当连续性关键信息的唯一载体」。

两种策略的取舍:

策略成本信息损失适合场景代表实现
滑动窗口0 次 API,纯数组切片高:旧内容直接消失旧消息与当前任务无关、丢了也无妨learn-claude-code 的 snip_compact
摘要压缩1 次 API(辅助模型)低但会随轮次累积衰减旧历史还有上下文价值,值得保留要点Hermes 的辅助模型摘要、learn-claude-code 的 compact_history

生产系统两者叠起来用,不是二选一:先滑动窗口扔掉彻底没用的,再对「还有价值但太长」的部分做摘要,最后才轮到别的。这个顺序就是下一节「便宜的先跑贵的后跑」的雏形。

手段三:工具结果截断——先拦最大的消耗者

滑动窗口和摘要都在「事后收拾」:历史已经堆起来了才处理。但上下文爆炸的源头是工具结果:read_file 一次返回 50KB、search 一次返回几十条结果,直接回填就把大半窗口烧掉了(第 08 章就预告过「结果要截断,留给上下文工程」)。在入口处拦一刀,比事后收拾便宜得多。

// 截断工具结果:保留头部 + 省略号标记,让模型知道输出被截了
function truncate(text: string, maxLength: number, marker = '…'): string {
  if (maxLength <= 0) return '';
  if (text.length <= maxLength) return text;
  if (marker.length >= maxLength) return marker.slice(0, maxLength);
  return text.slice(0, maxLength - marker.length) + marker;
}

truncate('x'.repeat(5000), 800); // -> 800 字符,token 从 1250 降到 200

marker 不是装饰:模型需要知道「这段结果被截断了」,才会在需要时重新读完整内容。learn-claude-code 的 micro_compact 把这条思路推到了消息层:只保留最近 3 条工具结果的完整内容,更旧的替换成一行占位符[Earlier tool result compacted. Re-run if needed.]),旧结果对当前工作基本无用,占着几千 token 纯属浪费。它的 L3(tool_result_budget)更激进:单轮所有结果超过 200KB 时,把最大的几个落盘到 .task_outputs/,上下文里只留 <persisted-output> 标记加预览,模型需要时可以重新读。

对本章来说,记住三条:

  1. 截断在工具执行层做(第 08 章 executeTool 的回填处),一行 truncate 的事;
  2. 截断 ≠ 静默丢弃:带标记,让模型知道还有更完整的内容可读;
  3. 占位符替换旧结果是「0 成本的滑动窗口」:不做摘要、不丢结构,纯粹把旧的大文本换成一行字。

system prompt 分层:结构上省,顺手省钱

前面都在管理 messages。但上下文里还有一大块固定开销:system prompt。很多 Agent 把 system prompt 写成一大坨硬编码字符串:「你是一个助手,要遵守这些规则,项目规范是这样的,用户偏好是那样……」,改一句就要改代码,而且一坨到底,谁也说不清哪部分占了多少钱。

正确的做法是分层构建:每一层一个独立来源、按需组装、单层设上限。Learn Hermes Agent 的 system prompt 是六层动态拼装(详见延伸阅读):

内容来源更新频率
1 身份人设(SOUL.md)全局文件很少改
2 行为规范工具使用规则、模型指导代码内置随版本更新
3 记忆MEMORY.md + USER.md 快照全局文件Agent 运行时可能更新
4 技能已安装技能索引技能目录安装/卸载时变
5 项目规则HERMES.md 等(优先级链)项目目录项目规则变化时改
6 运行时当前时间、模型信息运行时生成每次启动
// 分层构建:按序拼装 ## name 小节,空层跳过,单层截到 maxChars
function buildSystemPrompt(layers) {
  const sections = [];
  for (const layer of layers) {
    if (!layer.content.trim()) continue;
    const body = truncate(layer.content.trim(), layer.maxChars ?? 20000);
    sections.push(`## ${layer.name}\n${body}`);
  }
  return sections.join('\n\n');
}
flowchart LR
  A[identity<br/>身份] --> B[behavior<br/>行为规范]
  B --> C[memory<br/>记忆快照]
  C --> D[skills<br/>技能索引]
  D --> E[project<br/>项目规则<br/>上限 20000 字符]
  E --> F[runtime<br/>时间/模型]
  F --> G[拼接为一条<br/>system prompt]
  G --> H[组装一次<br/>缓存复用]

为什么分层,三个理由:

一、每层更新频率不同,维护者不同。 人设很少改,项目规则经常变,记忆运行时在变。分层让每个来源独立变化:改项目规则只动 project 层,不碰其他层。一坨硬编码则改一处动全身。

二、单层上限防「一文件吃满窗口」。 一个 50KB 的 AGENTS.md 全部塞进去,留给实际对话的空间就没了。每层 20K 字符上限,超了截断。再重要的部门也不能把整本手册撑到 500 页。层内截断用 truncate 的同一套逻辑,保证模型知道「这层被截了」。

三、稳定前缀 = 便宜的 API。 这是最容易忽略的一条:OpenAI / Anthropic 都有 prompt caching。如果 system prompt 在多轮之间保持不变,服务端可以缓存它,大幅降低费用与延迟。条件是内容不能变:哪怕一个字节不同,缓存就失效。所以正确姿势是组装一次、复用整段会话,只在上下文压缩后重建。反过来说,如果你把时间戳这种每轮都在变的东西放进 system prompt,就等于亲手废掉了缓存,所以「运行时」层也要稳定(比如只到分钟粒度),临时指令则拼在缓存后面、不进缓存(Hermes 的做法)。

另外两个实践点:项目规则文件用优先级链而非全部加载HERMES.md 优先于 AGENTS.md 优先于 CLAUDE.md 优先于 .cursorrules,只取优先级最高的一个,避免互相冲突的规则同时上场;system prompt 不进 messages 数组:否则它会被上下文压缩误伤、被重复持久化,应该作为独立的请求字段每次调用时临时拼在最前面。

context 爆炸实战:一条流水线跑满 20 轮

最后把所有手段装成一条流水线。设计原则一句话:便宜的先跑,贵的后跑,0 次 API 的操作(截断、占位、滑动窗口)永远排在 1 次 API 的操作(摘要)前面,摘要只在前面都不够时才触发。这个原则来自 learn-claude-code 的四层压缩管线(详见延伸阅读),它把同一句话写成了顺序约束:budget(落盘)→ snip(裁中间)→ micro(旧结果占位)→ auto(摘要)。

// 每轮 API 调用前跑的管理流水线
function manageContext(messages) {
  // —— 便宜的先跑:0 次 API ——
  messages = truncateToolResults(messages, 800);                    // 1. 截断过大的工具结果
  messages = trimHistory(messages, { maxMessages: 12, head: 2 }).messages; // 2. 滑动窗口
  // —— 贵的后跑:1 次 API,只有仍超预算才触发 ——
  if (estimateTokens(serialize(messages)) > 6000) {
    messages = compactHistory(messages, { threshold: 6000, summarize, keepTail: 8 }).messages;
  }
  return messages;
}

把这个流水线接到第 07 章的循环里:每轮调用模型之前先过一遍 manageContext。用一个确定性的对照实验看效果:同样 20 轮「读文件」工具调用,工具结果每轮增大 200 字符:

round    raw(不管理)     managed(管理)
   1        130 tokens       130 tokens
   5        870 tokens       813 tokens
  10       2920 tokens      1104 tokens
  15       6221 tokens      1104 tokens
  20      10773 tokens      1104 tokens  <-- 爆窗

整个决策过程可以画成一张状态图,注意每条路径都收敛到一个可发送的 messages,没有任何一条把异常漏出去:

flowchart TD
  A[每轮 API 调用前] --> B[preflight 估算 token]
  B --> C{超预算?}
  C -- 否 --> D[直接发送]
  C -- 是 --> E[truncateToolResults 截断<br/>0 API]
  E --> F[trimHistory 滑动窗口<br/>0 API]
  F --> G{仍超阈值?}
  G -- 否 --> D
  G -- 是 --> H[compactHistory 摘要<br/>1 API 辅助模型]
  H --> I{压缩有效?}
  I -- 是 --> D
  I -- 否 --> J[应急:保留最近 5 条<br/>只摘要更早的历史]
  J --> D

流水线之外,还有三个「真实系统里会撞上」的细节:

Preflight 压缩。 进入主循环前先检查一次:如果用户从大窗口模型切到小窗口模型,历史可能一开始就超了。主动防御比被动恢复好:不等 API 报错再处理(Hermes 的做法,见延伸阅读)。

Reactive 应急。 上下文增长速度可能快于压缩触发速度:API 还是返回了 prompt_too_long。这时走应急路径:保留最近约 5 条原始消息,只对更早的历史做摘要(learn-claude-code 的 reactive_compact)。应急路径要有重试上限(默认 1 次),再失败就抛出异常,绝不无限循环。

CompressionStuck 检测。 极端情况下头部本身就吃掉了大部分预算(比如用户第一条消息就塞了一整篇文档),压缩找不到可压的中间段,反复压缩只是空转。做法是比较压缩前后的 token 估算:没降到原值的 90% 以下,就抛 CompressionStuckError 退出当轮,告诉用户「会话已无法压缩,请新开 session」(Hermes 的实现)。

业界主流产品把这条流水线做成了平台能力:OpenAI Responses API 的 server-side compaction(请求里带 context_management: [{ type: 'compaction', compact_threshold: 200000 }],服务端在 token 数越过阈值时自动压缩,返回一个不透明的 compaction item 携带关键状态继续下一轮);Claude Code 有 /compact 命令与四层自动压缩管线(autoCompact.ts 里阈值 = 窗口 − 最大输出 − 13000 token 保险丝,压缩 prompt 首尾双重「禁止调工具」防呆,连续失败 3 次熔断)。我们这一章手写的,正是这些黑盒背后的机制——框架魔法再一次被拆成了你能控制的手艺

常见坑

坑一:把「滑动窗口裁掉」当成「删除」。 上下文管理只应该压缩发给模型的那一份,完整历史必须保留在存储层,否则摘要信息衰减后,你连找回原文的退路都没有。压缩是整理桌面,不是碎纸。完整历史的落盘是第 10 章的事,但从设计第一天就要留这个口子

坑二:切割点拆散 tool_calls / tool_result 配对。 滑动窗口与摘要的尾部边界都会撞上这种「孤儿结果」:模型看到工具调用指令却找不到结果,API 直接 400。头部保护(吞入后续 tool 结果)与尾部保护(向左并入 assistant)缺一不可,这也是练习的判题重点。

坑三:用主模型做摘要。 压缩是系统操作,不该花贵模型的预算。辅助模型、固定模板、[Compacted] 前缀,三件套缺一不可。模板写空话(「用户让 agent 做了些操作」)等于没写:必须保住目标、进度、决定、文件、下一步。

坑四:把「目标与进度」交给摘要托管。 摘要是有损压缩,多轮压缩信息熵指数衰减(「已读 s01、s02、s03」→「已读若干文件」→「已读部分文件」)。目标和待办必须放进不可压缩区(TaskState,每轮注入、不进消息流),摘要只当备忘。

坑五:每轮重新组装 system prompt。 内容哪怕一个字节不同,服务端 prompt cache 就失效,费用和延迟全部回退。组装一次、缓存复用,只在压缩后重建;时间戳这类运行时信息要控制变化粒度,临时指令拼在缓存后面。

坑六:等 413 报错才处理。 Reactive 路径是兜底不是常态。每轮调用前 preflight 估算、主动压缩,比被 API 拒绝后抢救便宜得多,「便宜的先跑贵的后跑」的顺序里,最便宜的永远是不触发贵的那一步

小结

下一章(ch10)进入持久层:把完整历史、会话与事件存进 SQLite,那时你会发现,上下文管理裁掉的每一段,都能在存储层被找回;而本章「压缩不是删除」的承诺,也将在那里兑现。现在去浏览器里完成本章练习,亲手实现 estimateTokenstruncatetrimHistorycompactHistorybuildSystemPrompt

延伸阅读

完成阅读,去做练习 →