上下文工程
上下文工程
第 07 章我们写出了最小 Agent Loop,第 08 章给它装上了工具系统。循环很漂亮:模型每轮看到 messages,决定要不要调工具,tool_calls 执行完回填 tool_result,继续下一轮。但有个问题从第 07 章起就一直悬着:messages 只增不减。跑得越久,发给模型的上下文越大,而模型的上下文窗口是有限的。这一章解决「在有限的窗口里,让 Agent 跑得久、跑得稳、跑得便宜」。
本章目标
读完本章并做完配套练习后,你应该能够:
- 说出为什么 Agent 的上下文必须被管理:窗口有限、成本线性增长、超限直接报错;
- 手写
estimateTokens:零依赖的token 启发式估算,并理解它和 APIusage实测值的关系; - 手写
trimHistory:滑动窗口保留头尾裁中间,且绝不拆散工具调用与其结果的配对; - 手写
compactHistory:超过阈值时用辅助模型把旧历史换成结构化摘要,理解摘要压缩的信息衰减问题; - 手写
truncate与buildSystemPrompt:截断工具结果、分层构建 system prompt(含单层上限); - 组装一条「便宜的先跑贵的后跑」的上下文管理流水线,看懂真实系统(Claude Code、OpenAI server-side compaction)的对应机制。
开场反例:不管理上下文,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 的上下文压缩章,见延伸阅读):
- 上下文窗口(context window):模型这一轮真正能一起看到的输入容量,比如 8192 tokens。可以理解成办公桌面积:桌子就那么大,能同时摊开的文件有限;
- 活跃上下文(active context):桌面上「当前继续工作最值得看到的部分」。你和 Agent 说过一百句话,但不需要每一句都永远摊在桌上;
- 完整历史(transcript):文件柜(之前的所有对话、所有工具结果)。上下文管理不是删除:把不需要的收进文件柜、腾出桌面,需要时还能拿出来(第 10 章会讲把完整历史存进持久层)。
于是问题变成了一个预算问题:桌面上只能放 预算 这么多东西,怎么安排才不丢工作连续性? 三件事:
- 知道用了多少 → token 估算;
- 超了怎么办 → 两条路:滑动窗口(扔掉旧内容)与摘要压缩(把旧内容改写成短表示);
- 怎么少用 → 治理最大的消耗者(工具结果)与结构优化(system prompt 分层)。
token 估算:先知道桌面有多满
管理的第一步是测量。tiktoken 这类精确 tokenizer 很准,但它是个依赖、还要下载词表。大多数时候你只需要一个够做预算决策的近似值。启发式很简单:
- 英文大致 4 个字符 1 token;
- 中文大致 1 个字 1~2 token,取中间值 1.5;
- 结果向上取整。
// 零依赖 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:模型看到「我要调工具」,却找不到对应的结果。所以:
- 头部保护:头部最后一条是带
tool_calls的 assistant 时,把它后面的tool结果吞进头部; - 尾部保护:尾部第一条是
tool结果时,说明它的配对在切口另一边,向左扩展把 assistant 一起保留。
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> 标记加预览,模型需要时可以重新读。
对本章来说,记住三条:
- 截断在工具执行层做(第 08 章
executeTool的回填处),一行truncate的事; - 截断 ≠ 静默丢弃:带标记,让模型知道还有更完整的内容可读;
- 占位符替换旧结果是「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 <-- 爆窗
- 不管理:token 线性增长,第 20 轮 10773 超过 8192 窗口,API 拒绝,Agent 死亡;
- 管理后:第 20 轮稳定在 1104 tokens,全程低于预算,跑满 20 轮不爆窗,而且因为便宜的步骤(截断 + 滑动窗口)已经够用,一次摘要都没触发。
整个决策过程可以画成一张状态图,注意每条路径都收敛到一个可发送的 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 拒绝后抢救便宜得多,「便宜的先跑贵的后跑」的顺序里,最便宜的永远是不触发贵的那一步。
小结
- 上下文是有限预算:窗口 − 输出 − 保险丝 = 历史输入预算;估算(
estimateTokens)用于调用前决策,usage实测用于事后校准; - 两条压缩路线:滑动窗口(0 API、纯切片、信息直接消失)与摘要压缩(1 API 辅助模型、结构化模板、信息随轮次衰减),叠用而非二选一;
- 工具结果截断是性价比最高的治理:入口处拦一刀(
truncate+ 标记),旧结果用占位符替换,比事后收拾便宜得多; - system prompt 分层:独立来源、按序拼装、单层上限;稳定前缀触发 prompt cache,分层不只是整洁,是省钱;
- 流水线原则:便宜的先跑贵的后跑,截断 → 滑动窗口 → 阈值摘要 → reactive 应急,配合 preflight 与 CompressionStuck 检测;
- 上下文管理的本质是整理桌面,不是碎纸:完整历史始终在存储层等着(第 10 章)。
下一章(ch10)进入持久层:把完整历史、会话与事件存进 SQLite,那时你会发现,上下文管理裁掉的每一段,都能在存储层被找回;而本章「压缩不是删除」的承诺,也将在那里兑现。现在去浏览器里完成本章练习,亲手实现 estimateTokens、truncate、trimHistory、compactHistory 与 buildSystemPrompt。
延伸阅读
- Learn Hermes Agent — s05: Context Compression:三层递进压缩(旧工具输出占位 → 保护头尾找边界 → 辅助模型结构化摘要)+ TaskState 不可压缩区 + preflight 压缩 + CompressionStuckError。「桌面」比喻、摘要模板五要素、信息衰减客户案例均出自这里。
- Learn Hermes Agent — s04: System Prompt Assembly:system prompt 六层动态组装、优先级链、单层 20K 上限、组装一次缓存复用与 prompt cache 的关系。
- shareAI-lab/learn-claude-code · s08: Context Compact:四层压缩管线(snip / micro / tool_result_budget / compact_history)+ reactive 应急 + 执行顺序约束(budget 最先);「便宜的先跑贵的后跑」的直接出处。
- OpenAI — Compaction guide:Responses API 的 server-side compaction(
context_management+compact_threshold)与独立/responses/compact端点,业界「压缩作为平台能力」的参照。 - Anthropic — Prompt caching:system prompt 稳定前缀的经济学:缓存命中的机制与计费,理解「为什么组装一次」。