06 上下文工程与记忆
Agent 跑几步的时候一切正常,跑到二三十步就开始出问题:费用暴涨、响应变慢、模型忘了一开始的约束、把几十步前的旧报错当成当前问题。这些都是上下文管理的问题。
这篇要讲清楚:模型每次到底”看到”了什么;为什么上下文不是越长越好;工具输出和历史怎么截断、压缩才不出错;prompt caching 为什么能省几十倍的钱、什么操作会让它失效;记忆(memory)怎么设计。
怎么读:
前置:05 Agent 循环。
第一部分 速记页
| 问题 | 一句话答案 |
|---|---|
| 上下文是什么 | 一次模型调用时模型能看到的全部输入:系统提示词、工具定义、历史消息、工具结果、检索到的资料 |
| 模型有记忆吗 | 模型本身不记得上一次请求;“记得”是因为程序把历史又发了一遍,或者从外部存储里取出来放进上下文 |
| token 和字数的关系 | 不固定;英文常见单词约 1 个 token,中文一个字常常是 1 个左右,路径、代码会被切得很碎;不同模型分词器不同 |
| 上下文窗口 | 一次请求输入加输出的 token 上限,比如 DeepSeek 当前模型是 100 万 |
| 窗口够大就全塞进去吗 | 不行:越长越贵、越慢;模型对中间位置的信息利用变差;无关内容会干扰判断 |
| 上下文工程 | 在每一步只把完成任务真正需要的信息放进窗口;常见四种手段:写出去、选进来、压缩、隔离 |
| 工具输出太长 | 截断但保留关键部分(报错看末尾),写明省略了多少、完整内容在哪、怎么读 |
| 历史太长 | 保留系统提示词和任务,把早期轮次压成摘要,最近几轮原样保留 |
| 压缩最容易犯的错 | 把工具调用和它的结果拆开,只留一半,下次请求被 API 拒绝 |
| 摘要要保留什么 | 目标、约束、已确认的事实、做过的改动、失败过的方法和原因、待办 |
| 压缩一定要自己写吗 | 不一定。2026 年起 Anthropic(compact_20260112)和 OpenAI(Responses 的 context_management、/responses/compact)都提供服务端压缩;换多家模型时还是得自己写 |
| 只清旧工具结果行不行 | 行,这是最便宜的一种压缩:Anthropic 的 context editing 超过阈值后把旧工具结果换成占位符,保留”调过这个工具”的记录 |
| prompt caching | 服务端缓存前缀的计算结果,下次请求前缀完全相同时直接复用,便宜且快 |
| 缓存为什么会失效 | 前缀变了:系统提示词里放了时间戳、工具定义顺序变了、修改了早期消息、压缩改写了历史 |
| 省多少 | DeepSeek flash 当前价格下,命中缓存的输入比未命中便宜 50 倍 |
| 长期记忆怎么做 | 程序把值得保留的信息写到外部存储,下次按需检索放回上下文;要处理过期、冲突和隐私 |
| 预加载 vs 按需检索 | 预加载一开始就塞进去;按需检索只给模型工具,让它需要时自己查。任务越长越倾向按需 |
| Agent Skills 和上下文有什么关系 | 一种分层按需加载:开头只放每个 skill 的名字和一句描述,用到时才读完整的 SKILL.md 和附带文件 |
第二部分 易混对照
| 容易混的两个 | 区别 | 一句话记法 |
|---|---|---|
| 上下文 vs 记忆 | 上下文是这一次调用能看到的;记忆是存在外部、以后可以取回的 | 桌面 vs 柜子 |
| 会话状态 vs 长期记忆 | 会话状态是这次任务的进度;长期记忆跨会话保存偏好和经验 | 这次干到哪 vs 以后都要记得 |
| 上下文窗口 vs 最大输出长度 | 窗口是输入加输出的总上限;最大输出只限制生成的部分 | 总容量 vs 出口大小 |
| 截断 vs 压缩 | 截断直接丢掉一部分;压缩把一部分改写成更短的摘要 | 剪掉 vs 缩写 |
| prompt caching vs 应用层缓存 | 前者是服务商复用前缀的计算,答案照样重新生成;后者是程序把整个回答存起来,同样问题直接返回旧答案 | 省计算 vs 省调用 |
| KV cache vs prompt caching | KV cache 是模型推理时一次请求内部保存中间结果的机制;prompt caching 是跨请求复用它 | 请求内 vs 请求间 |
| 滑动窗口 vs 摘要压缩 | 滑动窗口只保留最近 N 轮,旧的全丢;摘要把旧的压成要点保留 | 忘掉 vs 记个大概 |
| RAG vs 长期记忆 | RAG 通常检索外部知识库;记忆检索的是 Agent 或用户自己过去产生的信息 | 查资料 vs 翻日记 |
第三部分 面试口述稿
3.1 “模型怎么记住之前的对话?”
模型本身是无状态的,每次请求都是独立计算,它不记得上一次说过什么。多轮对话能延续,是因为应用程序把之前的消息又放进了这次请求。所以”记忆”其实分几层:一是这次请求的上下文,就是发过去的全部消息;二是会话状态,应用保存的这次任务的历史和进度;三是长期记忆,跨会话保存到数据库里的用户偏好、历史经验,需要时检索出来放回上下文。
有些服务商提供服务端保存会话的功能,应用不用每次重发,但本质一样,只是保存的位置不同。
3.2 “上下文窗口越来越大,还需要上下文管理吗?”
需要,原因有三个。一是成本和延迟,Agent 每一步都要重发历史,窗口用得越满每一步越贵越慢,我项目 288 次运行输入 token 是输出的 41 倍多。二是效果,有研究发现模型对放在长上下文中间的信息利用得最差,Chroma 在 2025 年的测试也显示输入越长、表现越不稳定。三是干扰,几十步之前已经解决的报错还留在上下文里,模型可能又去处理它。
所以我的原则是每一步只放完成任务需要的信息:工具输出截断并告诉模型完整内容在哪;早期历史压缩成摘要;资料不一开始全塞,给工具让模型按需查。
3.3 “Agent 的历史消息太长,怎么压缩?”
我会分三块处理。系统提示词和原始任务永远保留,因为约束都在里面。最近几轮原样保留,模型需要看到刚发生的细节。中间更早的轮次压成一段摘要,摘要里要保留目标、约束、已确认的事实、改过哪些文件、试过哪些方法失败了以及原因、还剩什么没做。
实现上,现在不一定要自己写:Anthropic 和 OpenAI 在 2026 年都有服务端压缩,超过设定的 token 阈值自动总结。我项目调的是 DeepSeek,没有这个功能,得自己做;而且服务端压缩的摘要内容自己控制不了,OpenAI 的压缩结果还是加密的,调试时看不到里面写了什么。
有两个坑。一是切分边界不能把工具调用和它的结果拆开,assistant 发起的调用和对应的 tool 结果必须同时保留或同时压缩,否则下次请求 API 会报错。二是压缩会改写历史前缀,会让 prompt caching 失效,所以不能每步都压,一般是接近阈值时才压一次。
3.4 “讲一下 prompt caching”
模型处理输入时要为每个 token 计算中间结果,就是 KV cache。如果两次请求的开头部分完全一样,服务商可以把这部分计算结果存下来复用,这就是 prompt caching。命中缓存的输入 token 便宜很多,首个 token 出来也更快。DeepSeek 当前价格下命中比未命中便宜 50 倍,而且默认自动开启;OpenAI 也是自动的;Anthropic 要显式标记缓存位置,写缓存要额外付费,读缓存是一折。
关键是前缀必须完全相同。所以提示词要把不变的放前面、变化的放后面:工具定义和系统提示词固定,不要在系统提示词里放当前时间;历史只追加不修改。Agent 场景下这点特别重要,因为每一步前面的历史都是上一步的原样,理论上大部分输入都能命中。
3.5 “工具返回的内容太长怎么办?”
看内容类型决定保留哪部分。编译日志报错一般在最后,保留末尾;文件内容保留开头并支持翻页;搜索结果只返回前几条和关键字段。截断时要在结果里写清楚:保留了哪部分、一共多长、完整内容存在哪、用什么工具怎么读。
我项目里吃过亏:读文件工具早期只返回末尾 100 行又不能翻页,模型看不到开头的构建选项,自己写 CMake 脚本分段读,第一版评测里
read_file被截断了 89 次。改成分页后这类绕路就没了。另外命令输出截断时要把退出码放在不会被截掉的第一行。
3.6 “长期记忆怎么设计?”
先分清要记什么:用户偏好和事实,比如”这个用户的项目用 CMake”;过去的经历,比如”上次编 libpng 是先编 zlib 成功的”;做事方法,比如一套处理某类报错的步骤。
流程是写入、存储、检索、使用。写入时要判断值不值得记,不能什么都存;存储要带来源和时间;检索时按相关性和时效取少量放进上下文;使用时要提示模型这是过去的信息,可能已经过期。
风险主要是三个:过期信息误导、错误信息被反复强化、隐私。我项目里知识库条目是从运行记录里让模型提炼、再人工逐条审核的,7 条候选里合并 1 条、丢弃 1 条,就是为了防止错误经验进库。
3.7 “资料是一开始全给模型,还是让它自己查?”
这是预加载和按需检索的取舍。预加载简单、少一轮调用,适合资料少且一定会用到的情况。按需检索是只给模型工具和资料的索引,比如文件列表,让它需要时再读,上下文更干净,适合资料多、任务长的情况,Anthropic 的上下文工程文章也提到能力强的模型越来越适合这种即时检索。
实际常常组合。我项目里任务开始前,程序会把项目根目录的文件列表和 CMakeLists.txt 里的构建选项预先放进第一条消息,因为每个任务都需要;具体文件内容则让模型用
read_file按需读。
第四部分 逐个详解
4.1 模型每次到底看到了什么
官方定义:上下文(context)是一次模型调用时提供给模型的全部输入 token 序列。上下文窗口(context window)是模型一次能处理的 token 数量上限,输入和输出共用这个上限。
打个比方:模型像一个记性为零、但读得极快的临时工。每次你找他,他都不记得上次的事,你得把一个文件夹递给他,他只根据这个文件夹里的东西干活。文件夹有厚度上限(窗口),文件夹越厚他收费越高、读得越慢,而且太厚的时候中间几页他容易看漏。上下文工程就是每次决定文件夹里放什么。
一次 Agent 调用的上下文通常由这几块组成:
flowchart TB
subgraph W["一次请求的上下文窗口"]
direction TB
T["工具定义<br/>每次都发,数量多时占几千 token"]
S["系统提示词<br/>角色、规则、流程"]
U["原始任务<br/>用户要做什么、约束"]
K["预加载的资料<br/>项目结构、检索结果、记忆"]
H["历史轮次<br/>assistant 的调用 + tool 的结果<br/>随步数增长,是最大头"]
O["留给输出的空间"]
end
T --- S --- U --- K --- H --- O
从上到下大致也是”越来越容易变化”的顺序,这对 4.7 节的缓存很重要。
模型本身无状态。第 2 步请求时,程序把第 1 步的全部消息加上新消息一起发过去,模型才”知道”之前发生了什么。所以要分清三个概念:
| 概念 | 存在哪 | 生命周期 | 例子 |
|---|---|---|---|
| 上下文 | 这一次请求的输入里 | 一次调用 | 这次发过去的 messages |
| 会话状态 | 应用的内存、数据库或检查点 | 一个任务或一次会话 | 已经跑了 12 步、改过哪些文件 |
| 长期记忆 | 外部数据库、文件 | 跨会话 | 用户偏好、过去任务的经验 |
4.2 token:计量单位
官方定义:token 是模型处理文本的基本单位,由分词器(tokenizer)把文本切分得到。计费、窗口上限、速度都按 token 算。
分词器会把常见的字符组合合并成一个 token,不常见的切碎。不同模型的分词器不同,同一段文字在不同模型上的 token 数不一样。
动手实验 1:用 OpenAI 开源的分词库 tiktoken 看看文字被切成什么样。需要 pip install tiktoken。保存为 count_tokens.py。
import tiktoken
enc = tiktoken.get_encoding("o200k_base")
samples = [
"Could NOT find ZLIB (missing: ZLIB_LIBRARY ZLIB_INCLUDE_DIR)",
"交叉编译时找不到目标平台的 zlib 库",
"/opt/homebrew/Cellar/abseil/20240722.0/include/absl/base/config.h",
'{"path": "CMakeLists.txt", "offset": 101}',
]
for text in samples:
ids = enc.encode(text)
pieces = [enc.decode([i]) for i in ids[:8]]
print(f"{len(text):>3} 字符 -> {len(ids):>3} token 前几个: {pieces}")
实际输出:
60 字符 -> 16 token 前几个: ['Could', ' NOT', ' find', ' Z', 'LIB', ' (', 'missing', ':']
20 字符 -> 14 token 前几个: ['交', '叉', '编', '译', '时', '找', '不到', '目标']
65 字符 -> 24 token 前几个: ['/', 'opt', '/home', 'brew', '/', 'Cell', 'ar', '/']
41 字符 -> 15 token 前几个: ['{"', 'path', '":', ' "', 'C', 'Make', 'Lists', '.txt']
逐行讲:
tiktoken.get_encoding("o200k_base"):取一个编码方案(encoding)。o200k_base是 OpenAI 较新模型用的分词表,词表大约 20 万个 tokenenc.encode(text):把文字编码成一串整数,每个整数是一个 token 在词表里的编号(id)enc.decode([i]):把单个编号还原成文字,用来看每个 token 是什么ids[:8]:切片取前 8 个f"{len(text):>3}"::>3是格式说明,表示右对齐、占 3 格宽,让输出列对齐- 列表推导式
[enc.decode([i]) for i in ids[:8]]对前 8 个编号逐个解码
能看出来:
- 英文常见词基本一个 token,
ZLIB这种不常见的大写词被切成Z和LIB - 中文大多一个字一个 token,常用词(“不到""目标”)合成一个
- 路径和 JSON 被切得很碎,工具参数和文件路径比看起来更占 token
注意:DeepSeek、通义千问等模型用的是自己的分词器,这里的数字只能当估算。精确的数字以 API 返回的 usage 字段为准(02 模型 API 工程篇讲怎么读)。
4.3 为什么窗口够大也不能全塞
现在很多模型窗口有几十万到上百万 token(DeepSeek 当前两个模型都是 100 万)。那为什么还要管理?
原因一:成本和延迟。 05 篇讲过,Agent 每步重发历史,累计输入近似随步数平方增长。我项目 runs.db 里 288 次运行的合计:输入 4870 万 token,输出 117 万 token,输入是输出的 41 倍多。窗口塞得越满,每一步越贵,首个 token 出来得也越慢。
原因二:模型对长上下文的利用并不均匀。
- Lost in the Middle(Liu 等,TACL 2023)发现:相关信息放在长上下文开头或结尾时模型表现最好,放在中间时明显变差
- Chroma 在 2025 年 7 月发布的 Context Rot 测试了多个主流模型,结论是输入越长,表现越不可靠;问题和答案在字面上越不相似,随长度下降得越厉害
这些研究测的是特定任务,具体下降多少因模型而异,但方向一致:不能假设模型会同样认真地读窗口里的每个字。
原因三:无关信息干扰。 20 步之前已经解决的 Could NOT find ZLIB,如果还原样留在上下文里,模型可能在后面又去处理它;已经被推翻的假设,也可能被当成事实引用。
flowchart LR
subgraph 放进上下文的每一段内容
A[有用信息]
B[过期信息]
C[无关噪声]
end
A --> G1[帮助决策]
B --> G2[误导:处理已解决的问题<br/>引用被推翻的结论]
C --> G3[稀释注意力<br/>增加成本和延迟]
所以目标不是”塞满”,而是信息密度高。
4.4 上下文工程:四种手段
先说为什么会有”上下文工程”这个说法。 2023 到 2024 年大家说”提示词工程”,关心的主要是一段提示词怎么写:角色怎么定、示例怎么给、格式怎么要求。可到了 Agent 场景,一次请求里自己写的提示词可能只占一小部分,剩下的是几十轮历史、工具返回的日志、检索到的资料、记忆条目。模型做错事,往往不是因为提示词写得不好,而是这一步塞进去的东西不对:该有的资料没有,过期的报错还在,工具输出把窗口挤满了。“提示词工程”这个词已经装不下这些问题。2025 年 6 月 19 日,Shopify 的 CEO Tobi Lütke 在 X 上说他更喜欢”context engineering”这个词,意思是把任务需要的上下文都准备好,让模型有可能解出来;6 月 25 日 Andrej Karpathy 转发附和,说在正经的 LLM 应用里,这是一门”往上下文窗口里为下一步装进恰好合适的信息”的学问。之后 LangChain、Anthropic 等都写了专门的文章,这个词很快就流行开了。
官方定义:Anthropic 在 Effective context engineering for AI agents(2025-09)里把上下文工程描述为:把上下文当作有限资源,找到最少但信息量最高的 token 集合,让模型最可能产生期望的结果。它比”提示词工程”范围更大,关心的是每一步整个上下文的状态,而不只是提示词怎么写。
LangChain 的 Context Engineering for Agents 把常见做法归成四类,好记也好讲:
flowchart TB
CE[上下文工程] --> W["写出去 Write<br/>把信息存到窗口外面"]
CE --> S["选进来 Select<br/>需要时把信息取回窗口"]
CE --> C["压缩 Compress<br/>只保留必要的 token"]
CE --> I["隔离 Isolate<br/>拆给不同上下文处理"]
W --> W1[草稿本 / 笔记文件<br/>长期记忆]
S --> S1[检索记忆、知识库<br/>按需读文件<br/>挑选相关工具]
C --> C1[截断工具输出<br/>历史摘要]
I --> I1[子 Agent 各自上下文<br/>沙箱里执行只回传结果]
| 手段 | 做法 | 本篇位置 |
|---|---|---|
| 写出去 | 让 Agent 把计划、发现记在窗口外的文件或数据库里 | 4.8 记忆 |
| 选进来 | 按需检索记忆、资料、工具;Anthropic 称为”即时”(just-in-time)加载 | 4.9、08 RAG 篇 |
| 压缩 | 截断工具输出,把历史压成摘要 | 4.5、4.6 |
| 隔离 | 子任务交给有独立上下文的子 Agent,只把结论交回来 | 13 多 Agent 篇 |
Anthropic 的文章里对长任务给了三种做法:压缩(compaction)、结构化笔记(structured note-taking)、子 Agent 架构,和上表基本对应。
4.5 截断工具输出
工具输出是上下文膨胀最快的来源:一次 cmake --build 可能输出几千行。原则在 04 篇讲过,这里补上具体做法。
按内容类型决定保留哪部分:
| 内容 | 保留 | 原因 |
|---|---|---|
| 编译、测试日志 | 开头几行 + 末尾几十行 | 开头有命令和环境信息,报错和总结通常在最后 |
| 文件内容 | 从开头分页 | 结构定义、选项、导入通常在前面 |
| 搜索、检索结果 | 前 N 条,每条只留关键字段 | 排序靠后的相关性低 |
| 表格、JSON 数据 | 结构 + 少量样例行 + 总行数 | 模型需要知道长什么样、有多少 |
| 网页 | 正文提取后截断 | 导航栏、广告全是噪声 |
截断时一定要告诉模型三件事:省略了多少、完整内容在哪、怎么读剩下的。否则模型不知道信息不完整,会基于残缺信息下结论。
看一个”头 + 尾”截断的最小实现(完整代码在 4.6 节的 compact.py 里):
def shorten(text, head=5, tail=15):
lines = text.splitlines()
if len(lines) <= head + tail:
return text
hidden = len(lines) - head - tail
return "\n".join(lines[:head] + [f"...[省略中间 {hidden} 行]..."] + lines[-tail:])
text.splitlines():按换行拆成行的列表lines[:head]取前 5 行,lines[-tail:]取最后 15 行(负数下标从末尾数)- 三个列表用
+拼成一个列表,中间插入一行说明省略了多少 "\n".join(...)再用换行连回字符串
对一段 301 行的日志,输出是前 5 行、...[省略中间 281 行]...、最后 15 行,最后一行正好是 CMake Error: Could NOT find ZLIB。
我项目里的 _truncate(agent/tools.py)比这个多做了两件事:把完整输出写到工作目录里的 .xbuild/logs/ 下,并在说明里写”完整输出: .xbuild/logs/cmd-xxx.log,可用 read_file 的 offset 分段读”;退出码 [exit=N] 放在截断内容之外的第一行。
截断的一个陷阱:只给路径不给读取手段。如果完整日志存到了 /tmp,而读文件工具只允许读工作目录,模型拿到路径也读不了。
4.6 压缩历史,不要拆散调用和结果
截断解决单条消息太长,压缩(compaction)解决消息太多。常见三种策略:
flowchart TB
subgraph 原始["原始历史(30 轮)"]
direction LR
O1[系统 + 任务] --- O2[第 1-22 轮] --- O3[第 23-30 轮]
end
subgraph 滑动窗口
direction LR
A1[系统 + 任务] --- A3[第 23-30 轮]
end
subgraph 摘要压缩
direction LR
B1[系统 + 任务] --- B2["摘要<br/>第 1-22 轮的要点"] --- B3[第 23-30 轮]
end
subgraph 按重要性保留
direction LR
C1[系统 + 任务] --- C2["关键轮次<br/>改过的文件、确认的事实"] --- C3[第 23-30 轮]
end
原始 --> 滑动窗口
原始 --> 摘要压缩
原始 --> 按重要性保留
| 策略 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 滑动窗口 | 只保留最近 N 轮 | 最简单,不用额外调模型 | 早期的重要信息直接丢了,比如”不能用主机库”如果只在第 3 轮出现过 |
| 摘要压缩 | 早期轮次交给模型总结成要点 | 保留关键信息 | 多一次模型调用;摘要可能漏掉或写错细节 |
| 按重要性保留 | 标记关键消息永远保留,其他的丢弃或压缩 | 精确 | 要定义”关键”的规则 |
摘要应该保留什么(按优先级):
- 任务目标和约束(最好根本不压缩,原样保留)
- 已经确认的事实(“项目依赖 zlib""zlib 已经装到 deps/zlib”)
- 做过的改动(改了哪些文件、改成什么)
- 失败过的方法和失败原因(防止模型再试一遍)
- 当前进度和待办
不需要保留的:成功命令的完整输出、已经被修复的报错全文、中间的试探性读取。
动手实验 2:压缩历史并检查调用和结果的配对。只用标准库,保存为 compact.py。为了简洁,这里的工具调用格式省略了 04 篇里 function 那一层嵌套。
import json
def shorten(text, head=5, tail=15):
lines = text.splitlines()
if len(lines) <= head + tail:
return text
hidden = len(lines) - head - tail
return "\n".join(lines[:head] + [f"...[省略中间 {hidden} 行]..."] + lines[-tail:])
def split_turns(messages):
turns = []
for msg in messages:
if msg["role"] == "tool":
turns[-1].append(msg)
else:
turns.append([msg])
return turns
def fake_summarize(turns):
facts = []
for turn in turns:
for msg in turn:
if msg["role"] == "tool" and "error" in msg["content"]:
facts.append(json.loads(msg["content"])["error"])
if msg["role"] == "assistant" and msg.get("tool_calls"):
for tc in msg["tool_calls"]:
facts.append(f"做过 {tc['name']} {tc['arguments']}")
return "之前的进展摘要:\n- " + "\n- ".join(facts)
def compact(messages, keep_turns=2):
head = messages[:2]
turns = split_turns(messages[2:])
if len(turns) <= keep_turns:
return messages
old, recent = turns[:-keep_turns], turns[-keep_turns:]
summary = {"role": "user", "content": fake_summarize(old)}
return head + [summary] + [m for t in recent for m in t]
def check_pairing(messages):
open_ids = set()
for i, msg in enumerate(messages):
if msg["role"] == "tool":
if msg["tool_call_id"] not in open_ids:
return f"第 {i} 条 tool 结果找不到对应的调用"
open_ids.discard(msg["tool_call_id"])
else:
if open_ids:
return f"第 {i} 条之前还有调用没有结果: {sorted(open_ids)}"
if msg.get("tool_calls"):
open_ids = {tc["id"] for tc in msg["tool_calls"]}
return "配对完整"
def step(n, error=None):
call = {"id": f"call_{n}", "name": "run_command", "arguments": json.dumps({"command": f"cmake try{n}"})}
result = {"ok": error is None}
if error:
result["error"] = error
return [
{"role": "assistant", "content": None, "tool_calls": [call]},
{"role": "tool", "tool_call_id": call["id"], "content": json.dumps(result, ensure_ascii=False)},
]
long_log = "\n".join(f"-- line {i}" for i in range(1, 301)) + "\nCMake Error: Could NOT find ZLIB"
print(shorten(long_log))
print()
messages = [
{"role": "system", "content": "你负责交叉编译。"},
{"role": "user", "content": "把 libpng 编到 aarch64,不能用主机上的库。"},
]
messages += step(1, "Could NOT find ZLIB")
messages += step(2, "zlib.h not found")
messages += step(3, "undefined reference to inflate")
messages += step(4)
print("压缩前", len(messages), "条,", check_pairing(messages))
small = compact(messages)
print("压缩后", len(small), "条,", check_pairing(small))
for m in small:
body = m["content"] if m["content"] else json.dumps(m["tool_calls"], ensure_ascii=False)
print(f" [{m['role']}] {body}")
bad = messages[:2] + messages[3:]
print("错误截断:", check_pairing(bad))
实际输出(截断日志的部分省略,见 4.5 节):
压缩前 10 条, 配对完整
压缩后 7 条, 配对完整
[system] 你负责交叉编译。
[user] 把 libpng 编到 aarch64,不能用主机上的库。
[user] 之前的进展摘要:
- 做过 run_command {"command": "cmake try1"}
- Could NOT find ZLIB
- 做过 run_command {"command": "cmake try2"}
- zlib.h not found
[assistant] [{"id": "call_3", "name": "run_command", "arguments": "{\"command\": \"cmake try3\"}"}]
[tool] {"ok": false, "error": "undefined reference to inflate"}
[assistant] [{"id": "call_4", "name": "run_command", "arguments": "{\"command\": \"cmake try4\"}"}]
[tool] {"ok": true}
错误截断: 第 2 条 tool 结果找不到对应的调用
逐段讲。
① 按”轮”切分 split_turns。 这是整个实验最关键的函数。遍历消息:
- 遇到
tool消息,追加到上一组的末尾:turns[-1].append(msg),turns[-1]是列表里最后一个元素(它本身也是个列表) - 遇到其他消息,新开一组:
turns.append([msg])
结果是每组以一条 assistant(或 user)消息开头,后面跟着它引发的所有 tool 结果。以”组”为单位保留或丢弃,调用和结果就永远不会被拆开。
② 假摘要 fake_summarize。 真实系统里这里是一次模型调用,提示词要求按 4.6 节开头的清单总结。这里用规则代替:把每个工具调用记成”做过什么”,把每个报错原文记下来。"\n- ".join(facts) 用”换行加短横线”把要点连成列表形式。
③ 压缩 compact。
head = messages[:2]:系统提示词和原始任务,原样保留turns[:-keep_turns]是除最后 2 组以外的所有组(旧的),turns[-keep_turns:]是最后 2 组(新的)- 旧的变成一条摘要消息。这里用
user角色放摘要,也有做法放进系统提示词末尾,各家 API 对角色顺序有要求,要按接口规定来 [m for t in recent for m in t]:两层循环的列表推导式,把”列表的列表”摊平成一个列表。读法是”对 recent 里的每一组 t,对 t 里的每条消息 m,取出 m”
④ 配对检查 check_pairing。 比 04 篇的版本更严格,按顺序扫描:
open_ids是”已发起、还没收到结果”的调用 id 集合- 遇到 tool 消息,它的 id 必须在
open_ids里,否则说明调用丢了;找到后用discard从集合里移除 - 遇到非 tool 消息时,如果
open_ids还没清空,说明有调用没拿到结果 - 遇到带
tool_calls的 assistant 消息,把它的所有调用 id 放进open_ids
⑤ 构造测试数据 step(n, error=None)。 生成一轮”调用 + 结果”。{"ok": error is None}:error is None 本身是一个 True/False 表达式,没传 error 时为 True。
⑥ 结果解读:
- 10 条消息压成 7 条:前两轮(4 条消息)变成 1 条摘要,最后两轮原样保留
- 压缩后配对依然完整
- 最后演示错误做法:
messages[:2] + messages[3:]跳过了第 3 条(第 1 轮的 assistant 调用),但保留了它的 tool 结果,检查立刻报错。真实 API 会直接返回 400
这个实验的摘要有什么问题? 仔细看摘要内容,它只记了”做过什么、报了什么错”,没有记”已经确认缺的是目标平台的 zlib""try2 改了什么”。规则摘要丢失了推理结论,这就是真实系统要用模型做摘要、并且要写清楚摘要要求的原因。
什么时候触发压缩? 常见做法是 token 数接近某个阈值(比如窗口的 70%,或者按成本设的上限)时压一次,而不是每一步都压。原因在下一节:每次压缩都会改写前缀,让缓存失效。
服务商自带的压缩(2026-09 查阅官方文档)。上面是自己动手压,2026 年两家大厂都把这件事做进了 API:
| Anthropic | OpenAI | |
|---|---|---|
| 服务端自动压缩 | Messages API 的 context_management.edits 里加 {"type": "compact_20260112"},默认输入超过 15 万 token 触发(最低可设 5 万);目前是 Beta | Responses API 设 context_management 的 compact_threshold,超过就在同一次请求里压缩 |
| 手动调用 | pause_after_compaction: true 可以压完先停下,自己补几条原文再继续 | 单独的 /responses/compact 接口,发完整上下文,返回压缩后的上下文 |
| 摘要能不能看 | 能,返回一个 compaction 块,里面是文字摘要;可以用 instructions 换掉默认的摘要提示词 | 不能,压缩条目是加密的,只能原样传回去 |
| 下一次请求怎么发 | 把 compaction 块原样带上,API 自动丢掉它之前的内容 | 把返回的窗口原样作为下一次输入,文档明确说不要自己裁剪 |
| 和缓存的关系 | 文档建议在系统提示词末尾单独打一个缓存断点,压缩后系统提示词的缓存还能用 | — |
更轻的一种:只清旧的工具结果。 Anthropic 另有 context editing(clear_tool_uses_20250919),输入超过阈值(默认 10 万 token)时把较早的工具结果换成占位符,模型仍知道”调过这个工具”,但看不到当时的输出。这比写摘要便宜,也不会写错,适合工具输出占大头的 Agent,缺点是需要时得重新调工具。
压缩还是干脆重开? Anthropic 在 Harness design for long-running application development(2026-03)里比较过两种做法:压缩保留连续性;“上下文重置”是清空窗口,只靠一份结构化的交接文件接着干,窗口更干净,但编排更复杂、token 更多。他们发现 Claude Sonnet 4.5 上下文快满时会急着收尾(文中叫 context anxiety),必须重置;到 Opus 4.5、4.6 这个现象基本消失,一个会话跑到底也行。这说明压缩策略要跟着模型换代重新测,不是定一次就完。
怎么选:只用一家、且是 Anthropic 或 OpenAI,优先用服务端压缩,少写代码;要换多家模型(比如我项目用 DeepSeek),或者需要控制摘要写什么、要能审查摘要,就自己写。
4.7 prompt caching:同样的前缀别重复算钱
先理解原理。 模型处理输入时,要为每个 token 计算一组中间结果(叫 KV cache,键值缓存,Key-Value cache),后面生成每个新 token 都要用到前面所有 token 的这些结果(01 大模型基础篇讲为什么)。在一次请求内部,KV cache 避免了重复计算。
先说为什么会有 prompt caching。 在它出现之前,商用模型 API 基本是”算完就扔”:每个请求的 KV cache 只在这次请求内部用,请求结束就释放。于是 Agent 第 20 步的请求,前面 19 步的内容明明和第 19 步请求一字不差,服务端也要从头预填充一遍,按全价收钱;带着一本几万 token 的手册反复问问题的应用也一样。DeepSeek 在 2024 年 8 月 2 日宣布上线上下文硬盘缓存,理由就是用户输入里有很大一部分是重复的:把可能复用的前缀结果存到分布式硬盘阵列上,下次请求开头一样就直接取,命中部分价格降一个数量级,不用改代码。Anthropic 在 8 月 14 日推出了需要显式标记的 prompt caching(公测),OpenAI 在 10 月 1 日的开发者大会上推出了自动缓存,超过 1024 token 的提示词自动生效。
prompt caching(提示词缓存,DeepSeek 叫上下文硬盘缓存)把这个复用扩展到多次请求之间:如果新请求的开头和之前某次请求完全一样,服务商直接取出之前算好的结果,只计算不同的部分。
flowchart LR
subgraph 第 5 步请求
A1[工具定义] --> A2[系统提示词] --> A3[任务] --> A4[第 1-4 轮历史] --> A5[第 5 轮新内容]
end
subgraph 第 6 步请求
B1[工具定义] --> B2[系统提示词] --> B3[任务] --> B4[第 1-4 轮历史] --> B5[第 5 轮] --> B6[第 6 轮新内容]
end
A4 -. "完全相同的前缀<br/>命中缓存,按缓存价计费" .-> B4
B6:::miss
B5:::miss
classDef miss fill:#fde2e2,stroke:#d33
Agent 天然适合缓存:第 N 步的请求 = 第 N-1 步的请求原样 + 新增的一轮。只要历史只追加、不修改,前面的部分全部能命中。
三家的做法对比(2026-09 查阅官方文档):
| DeepSeek | OpenAI | Anthropic | |
|---|---|---|---|
| 是否自动 | 默认开启,不用改代码 | 支持的模型自动开启 | 在请求顶层加 cache_control 自动放缓存点,或在具体内容块上显式标记,最多 4 个 |
| 匹配规则 | 按”缓存前缀单元”完整匹配,单元边界在每次用户输入结束、模型输出结束处,长内容按固定间隔切 | 前缀必须完全一致 | 按 工具 → 系统 → 消息 的顺序,某一层变了,它和后面的缓存都失效 |
| 最短长度 | 文档未写明 | GPT-5.6 及以后 1024 token | 按模型 512 到 4096 token 不等 |
| 保留多久 | 通常几小时到几天 | GPT-5.6 及以后至少 30 分钟 | 默认 5 分钟,可选 1 小时 |
| 价格 | flash 模型命中 $0.003、未命中 $0.15(每百万 token,非高峰) | 见官方价格页 | 写缓存 1.25 倍(1 小时版 2 倍),读缓存 0.1 倍 |
| 用量字段 | prompt_cache_hit_tokens、prompt_cache_miss_tokens | cached_tokens | cache_creation_input_tokens、cache_read_input_tokens |
| 保证 | 尽力而为,不保证 100% 命中 | — | — |
来源:DeepSeek 上下文缓存、DeepSeek 价格、OpenAI Prompt Caching、Anthropic Prompt Caching。价格和规则经常调整,用之前查最新文档。
什么操作会让缓存失效:
| 操作 | 为什么失效 | 怎么避免 |
|---|---|---|
| 系统提示词里放当前时间、请求编号 | 每次开头都不一样,后面全部命中不了 | 时间这类变化的信息放到最后一条消息里 |
| 每次请求动态增减、重排工具 | 工具定义在最前面,一变全变 | 工具列表固定顺序;确实要变时尽量少变 |
| 修改早期消息 | 修改点之后的全部失效 | 历史只追加 |
| 压缩历史 | 摘要替换了旧消息,前缀变了 | 不要每步压缩,接近阈值时一次压够 |
| 字典键顺序不稳定、空格不同 | 字节不同就不算相同 | 序列化方式固定,比如 json.dumps(..., sort_keys=True) |
动手实验 3:算一笔账。假设系统提示词和工具定义 3000 token,每步新增 1500 token,跑 40 步;价格用 DeepSeek flash 非高峰价。对比三种情况。保存为 context_cost.py。
SYSTEM = 3000
PER_STEP = 1500
STEPS = 40
KEEP_RECENT = 8
SUMMARY = 800
PRICE_MISS = 0.15
PRICE_HIT = 0.003
def full_history(step):
return SYSTEM + PER_STEP * (step - 1)
def compacted(step):
older = step - 1 - KEEP_RECENT
if older <= 0:
return full_history(step)
return SYSTEM + SUMMARY + PER_STEP * KEEP_RECENT
def dollars(tokens_by_step, cached_prefix):
total = 0.0
for step, tokens in enumerate(tokens_by_step, start=1):
hit = min(cached_prefix(step), tokens)
total += (hit * PRICE_HIT + (tokens - hit) * PRICE_MISS) / 1_000_000
return total
full = [full_history(s) for s in range(1, STEPS + 1)]
comp = [compacted(s) for s in range(1, STEPS + 1)]
no_cache = lambda step: 0
append_only = lambda step: full_history(step - 1) if step > 1 else 0
system_only = lambda step: SYSTEM if step > 1 else 0
print(f"完整历史 累计输入 {sum(full):>9,} token 最后一步 {full[-1]:>6,}")
print(f"压缩历史 累计输入 {sum(comp):>9,} token 最后一步 {comp[-1]:>6,}")
print()
print(f"完整历史 + 无缓存 ${dollars(full, no_cache):.4f}")
print(f"完整历史 + 只追加命中缓存 ${dollars(full, append_only):.4f}")
print(f"压缩历史 + 只有系统提示命中 ${dollars(comp, system_only):.4f}")
实际输出:
完整历史 累计输入 1,290,000 token 最后一步 61,500
压缩历史 累计输入 570,800 token 最后一步 15,800
完整历史 + 无缓存 $0.1935
完整历史 + 只追加命中缓存 $0.0129
压缩历史 + 只有系统提示命中 $0.0684
逐段讲。
- 开头全大写的变量是常量的约定写法(Python 不强制,只是告诉读代码的人”别改它”)
full_history(step):第 step 步要发的 token 数 = 固定部分 + 之前每步新增的累积compacted(step):历史超过 8 轮后,只发固定部分 + 800 token 摘要 + 最近 8 轮dollars(tokens_by_step, cached_prefix):第二个参数是一个函数,告诉它”第 step 步有多少 token 命中缓存”。把函数当参数传,可以用同一段计费代码算不同的缓存情况enumerate(..., start=1):编号从 1 开始而不是 0min(a, b)取较小值,命中的量不能超过总量1_000_000:数字里的下划线只是为了好读,等于 1000000lambda step: 0:lambda定义一个没有名字的简短函数,冒号左边是参数,右边是返回值。lambda step: SYSTEM if step > 1 else 0等于”第 1 步返回 0,之后返回 3000”append_only:历史只追加时,第 step 步能命中的就是上一步发过的全部内容system_only:假设压缩每步都在改写历史,只有最前面的系统提示词能命中。这是个偏悲观的简化:实际上两次压缩之间前缀是稳定的,能命中更多f"{sum(full):>9,}":,表示每三位加千位分隔符
结论:
- 压缩把累计输入从 129 万降到 57 万,token 数减少一半多
- 但完整历史配合缓存只要 $0.013,比无缓存便宜 15 倍
- 如果压缩破坏了缓存,反而比”不压缩但命中缓存”贵 5 倍
这不是说不要压缩。压缩解决的是窗口装不下和长上下文效果变差,缓存解决的是钱。真实做法是:历史只追加,尽量吃满缓存;接近窗口或效果开始下降时,一次压缩到位,之后继续只追加。
另外要记住缓存是”尽力而为”的,DeepSeek 文档明确说不保证 100% 命中。评估成本时要看 API 返回的真实命中量。我项目的 trace.py 目前只记了 prompt_tokens 和 completion_tokens,没有记缓存命中量,所以算不出真实花费,这是可以补的。
4.8 记忆:写出去、取回来
先说为什么 Agent 要单独做记忆。 模型本身不记得任何一次请求,所谓”记住对话”,其实是每次把历史重新发一遍。这样做有上限:窗口满了只能截断或压缩,旧内容就丢了;换一个会话,之前积累的东西全没了。2023 年 10 月,UC Berkeley 的 Charles Packer 等人发表 MemGPT,借用操作系统管理内存的思路:内存不够时,操作系统把暂时不用的数据换到硬盘上,要用时再换回来。MemGPT 把上下文窗口当成”内存”、外部存储当成”硬盘”,让模型自己通过函数调用决定把什么写出去、什么时候取回来,用来处理超出窗口的长文档和跨多次会话的聊天。现在各家产品和框架里的记忆功能,基本都是”写到窗口外、需要时取回”这个思路。
官方定义:Agent 的记忆(memory)指 Agent 能在当前上下文之外保存信息,并在之后需要时取回使用的机制。
CoALA(Sumers 等,2023)借用认知科学的分类,把语言 Agent 的记忆分成四种,面试时能说出来会显得有体系:
| 类型 | 含义 | Agent 里的例子 |
|---|---|---|
| 工作记忆(working) | 当前正在处理的信息 | 这次请求的上下文 |
| 情景记忆(episodic) | 过去发生过的具体经历 | ”上次编 libpng,先交叉编译 zlib 装到 deps 后成功了” |
| 语义记忆(semantic) | 关于世界和用户的事实知识 | ”这个用户的项目都用 CMake""主机上的库不能用于交叉编译” |
| 程序性记忆(procedural) | 怎么做事的方法 | 系统提示词里的标准流程;可复用的操作步骤 |
记忆的完整流程:
flowchart LR
subgraph 写入
E[任务中产生的信息] --> J{值得记吗}
J -- 否 --> X[丢弃]
J -- 是 --> N[整理成条目<br/>内容 + 来源 + 时间 + 适用范围]
end
N --> DB[(记忆存储<br/>文件 / 数据库 / 向量库)]
subgraph 使用
Q[新任务或新的一步] --> R[检索<br/>相关性 + 时效]
DB --> R
R --> F{还有效吗<br/>和当前信息冲突吗}
F -- 有效 --> C[放进上下文<br/>注明来源和时间]
F -- 过期或冲突 --> U[忽略或更新条目]
end
几种常见实现:
| 实现 | 做法 | 适合 |
|---|---|---|
| 草稿本 / 笔记文件 | 给 Agent 一个”写笔记”的工具,把计划、发现写进文件,需要时读 | 单个长任务内的进度和发现,Anthropic 称为结构化笔记 |
| 用户画像 | 从对话里抽取用户偏好和事实,存成键值对,每次对话开头放进上下文 | 个人助手、客服 |
| 经验库 | 从过去任务轨迹里提炼可复用经验,检索相关条目 | 同类任务反复出现,比如我项目的知识库 |
| 分层记忆 | 模仿操作系统的内存和硬盘,窗口是”内存”,外部是”硬盘”,由模型通过函数调用自己换入换出 | MemGPT(Packer 等,2023)的思路 |
| 记忆目录工具 | 服务商定义一个读写文件的工具,文件存在你自己的存储里,模型开工前先看目录里有什么 | Anthropic 的 memory tool(memory_20250818,2025-09);文档建议和服务端压缩配合:压缩管窗口大小,记忆文件保存压缩后不能丢的信息 |
| Agent Skills | 把”怎么做某类事”打包成文件夹:一个 SKILL.md(名字、描述、步骤)加上脚本、模板、参考资料 | 程序性记忆的具体做法;12 篇第五部分对比了 Skills 和 MCP |
Skills 是按需加载的典型。 Anthropic 2025 年 10 月推出 Agent Skills,12 月作为开放标准发布在 agentskills.io,OpenAI Codex、GitHub Copilot、Cursor、Gemini CLI 等都支持了。它对上下文的设计分三层:启动时只把每个 skill 的名字和一句描述放进系统提示词;模型判断用得上时才读完整的 SKILL.md;里面引用的脚本、参考文件再按需读。装几十个 skill,平时也只占几十行描述。文章里把这叫渐进式披露(progressive disclosure),和 04 篇的 tool search 是同一个思路。
记忆的风险,面试时要主动讲:
| 风险 | 例子 | 应对 |
|---|---|---|
| 过期 | 记着”项目用 CMake 3.20”,项目已经升级 | 条目带时间;使用时提示可能过期;和当前观察冲突时以当前为准 |
| 错误被固化 | 一次偶然成功的错误做法被记下来,以后反复用 | 写入前验证;人工审核;记录来源方便追溯 |
| 越积越多 | 记忆太多,检索出一堆无关条目 | 去重、合并、定期清理;检索时限制数量 |
| 隐私 | 记住了用户不想被保存的信息 | 让用户能查看、删除;敏感字段不存 |
| 被注入 | 攻击者让 Agent 把恶意指令写进记忆,以后每次都会被取出来 | 记忆内容当数据而不是指令;写入要有权限控制(11 安全篇) |
我项目知识库的”写入”是怎么做的(详见 08 RAG 篇):
- 从
runs.db的 160 次运行里取失败命令,去掉路径和数字差异后只有 14 种报错 - 每种报错取报错关键行 + Agent 随后 8 个动作和结果,加上 libpng、re2 最近各 3 次完整轨迹
- 交给模型提炼,要求只写有证据的内容、写明来源运行编号
- 人工逐条对照运行记录审核:7 条候选里收了 5 条、合并进旧条目 1 条(旧条目的说法被新证据推翻了)、丢弃 1 条(是 git 用法,不属于交叉编译经验,放进去只会干扰检索)
这就是”写入前验证”的一个具体例子。
4.9 预加载还是按需检索
| 预加载 | 按需检索(即时加载) | |
|---|---|---|
| 做法 | 任务开始时把可能需要的资料全放进上下文 | 只给模型工具和资料索引(文件名、目录),需要时自己查 |
| 优点 | 少几轮调用;模型第一步就有全貌 | 上下文干净;资料再多也放得下;拿到的总是最新的 |
| 缺点 | 占窗口;可能大部分用不到;资料多了放不下 | 多几轮调用;模型可能不知道该查什么、查得不对 |
| 适合 | 资料少、每次必用 | 资料多、任务长、模型能力强 |
Anthropic 的上下文工程文章观察到,随着模型能力提升,越来越多的 Agent 采用即时加载:只保留轻量的引用(文件路径、查询语句、链接),运行时再通过工具加载数据,这更像人的做法,我们也不会把所有资料背下来,而是知道去哪儿找。
我项目的组合方式:
flowchart TB
subgraph SG1["预加载:程序在第一条消息里放好"]
P1["项目根目录文件列表(最多 25 个)"]
P2["CMakeLists.txt 行数"]
P3["option() 和语言标准设置(最多 20 条)"]
end
subgraph SG2["按需检索:模型自己用工具"]
Q1["read_file 分页读具体文件"]
Q2["list_files 看子目录"]
Q3["search_knowledge 查经验库"]
Q4["run_command 看构建输出"]
end
P1 --> M[模型]
P2 --> M
P3 --> M
M --> Q1
M --> Q2
M --> Q3
M --> Q4
预加载的是每个任务都需要、而且很短的信息(build_agent.py 的 survey 函数);文件内容、经验、日志都按需取。
反过来的教训是知识库:30 次运行只调了 8 次。说明”按需”依赖模型知道自己需要。对于确定有用的信息(构建失败后的相关经验),更好的做法可能是程序在报错后自动检索、把结果附在工具输出后面,这其实是一种”在正确时机预加载”。
4.10 隔离:让子任务有自己的上下文
当一个任务的某部分需要大量探索(比如读几十个文件找一个函数定义),探索过程产生的内容对主任务没用,只有结论有用。这时可以交给子 Agent:它有独立的上下文,探索完只把几百字的结论交回主 Agent。
sequenceDiagram
participant Main as 主 Agent(上下文 2 万 token)
participant Sub as 子 Agent(独立上下文)
Main->>Sub: 找出 zlib 在这个项目里是怎么被引用的
Note over Sub: 读 30 个文件<br/>上下文涨到 15 万 token
Sub-->>Main: 结论:只在 src/png.c 和 CMakeLists.txt 第 88 行引用,300 字
Note over Main: 主上下文只增加 300 字
代价是多了一次完整的 Agent 运行、结论可能遗漏细节、协调变复杂。什么时候值得用,在 13 多 Agent 篇展开。
第五部分 对照项目
| 本篇知识点 | 项目里的位置 | 做到了什么 | 没做到或可以改进的 |
|---|---|---|---|
| 工具输出截断 | tools.py 的 _truncate | 命令输出保留末尾 100 行 / 4000 字符;退出码放首行;完整输出落盘并提示读取方法 | 只保留末尾,没有保留开头的命令和环境信息 |
| 文件分页 | tools.py 的 read_file | 从开头按行分页,结果首行写明范围和下一页 offset | — |
| 按”字符”计长度 | MAX_BYTES | 能控制长度 | 名字叫字节实际按字符;没有按 token 计 |
| 预加载 | build_agent.py 的 survey | 根目录文件列表、CMake 选项放进第一条消息 | — |
| 按需检索 | read_file、search_knowledge 等工具 | 文件和经验由模型自己查 | 知识库调用率低,没有在报错后自动检索 |
| 历史压缩 | — | 没有做 | 40 步以内窗口够用;困难项目单次几十万输入 token,加压缩或缓存统计有明显价值 |
| 缓存 | DeepSeek 默认自动缓存 | 历史只追加,天然适合命中 | trace.py 没记 prompt_cache_hit_tokens,算不出真实花费和命中率 |
| 长期记忆 | knowledge/entries.jsonl、knowledge/mine_runs.py | 从运行记录提炼经验,人工审核后入库,条目带 source | 提炼是离线手动触发的,不是运行中自动写入 |
| 会话状态 | graph_agent.py 的 SQLite 检查点 | 可以中断续跑 | 手写版没有 |
真实数据:runs.db 共 288 次运行,输入 48,704,845 token、输出 1,169,783 token,输入约为输出的 41.6 倍。
对照开源实现:pi
pi 是开源的编程 Agent(05 篇第五部分介绍过),它的压缩和截断都做得比较完整,可以当一份现成的参考实现。源码以 commit 7b4cfd6 为准,压缩逻辑主要在 coding-agent/src/core/compaction/compaction.ts。
| 本篇讲的 | pi 的做法 |
|---|---|
| 什么时候压缩 | 上下文 token > 窗口大小 - 16384(L132-L136、L235)。16384 是给摘要请求和回复留的余量。对 20 万窗口的模型,大约用到 92% 才压 |
| 上下文 token 怎么算 | 优先用上一次模型回复里服务商返回的用量(输入、输出、缓存读写加起来);拿不到时按”字符数 ÷ 4”估算 |
| 什么时候检查 | 每批工具执行完、下一次调模型之前查一次(agent-session.ts L538);用户发新消息前、一次运行结束后也查 |
| 保留多少原文 | 从最新的消息往前累加,攒够 2 万 token 为止,更早的交给模型总结 |
| 切在哪 | 只能切在用户消息、模型回复这类位置,永远不切在工具结果上,保证调用和结果不分开。单独一轮就超过 2 万 token 时,只能切在这一轮中间,这一轮的前半段单独再做一份摘要,和历史摘要合并 |
| 摘要写什么 | 固定六段:目标、约束和偏好、进度(已完成 / 进行中 / 卡住)、关键决定及理由、下一步、继续工作需要的关键信息;要求原样保留文件路径、函数名、报错原文(L467) |
| 第二次压缩 | 把上一份摘要一起交给模型,要求”在旧摘要上更新”:保留原有内容、把进行中的挪到已完成、更新下一步,而不是从头重写 |
| 改过哪些文件 | 程序从被压缩的工具调用里提取读过、改过的文件,以 <read-files>、<modified-files> 附在摘要后面,多次压缩时累加 |
| 摘要放回哪 | 包在 <summary> 标签里,作为一条 user 消息放回上下文(messages.ts L176) |
| 请求报”上下文超长” | 删掉失败的那条回复,压缩,重试一次;还失败就报错,建议换更大窗口的模型(agent-session.ts L2154) |
| 工具输出截断 | 默认 2000 行或 50KB,先到哪个算哪个。读文件保留开头,末尾写”用 offset=N 继续读”;命令输出保留末尾,完整输出写进系统临时目录,结尾写明路径 |
| 缓存 | 用 Anthropic 模型时,在系统提示词、最后一个工具定义、最后一条消息上打缓存标记;压缩用的摘要请求不写缓存,因为这种一次性请求不会被复用 |
和本篇、和我的项目比,有四点值得说:
1. 压缩时机比本篇举的例子晚得多。 本篇 4.6 节说”比如窗口的 70%“,pi 基本等窗口快满了才压。原因和 4.7 节的缓存有关:每压一次前缀就变了,之前的缓存全部失效,压得越少缓存命中越多。代价是每一步都在用接近满的窗口,单步更贵,也更容易受 4.3 节说的长上下文干扰。这个阈值是成本、效果、缓存命中之间的取舍,没有标准答案,面试时讲清楚取舍比报一个数字更重要。
2. 摘要格式可以直接借鉴。 4.6 节实验指出规则摘要”丢失了推理结论”。pi 的六段格式里”关键决定及理由”和”下一步”正好补上这一块,而且”原样保留报错原文”这条对编译场景特别有用。我的项目如果要加压缩,这份提示词可以直接改成交叉编译版本。
3. 退出码放哪,思路不同但目的一样。 我的项目把退出码放在第一行,防止被截掉。pi 的命令输出本来就保留末尾,退出码写在最后,同样不会被截掉;而且退出码非零时,整条工具结果会被标成错误。
4. 4.5 节说的”给了路径却读不到”,两边的解法不同。 pi 把完整输出存在系统临时目录,看起来正好踩中这个陷阱。但它的读文件工具没有目录限制(pi 不做权限控制,见 11 安全篇),所以读得到。我的项目限制只能读工作目录,所以完整日志必须存在工作目录的 .xbuild/logs/ 下。结论一样:给了路径,就要保证用现有工具一定读得到。
第六部分 追问清单
| 你刚讲完 | 下一个追问 | 回答方向 |
|---|---|---|
| 模型无状态 | 那 ChatGPT 为什么记得我 | 应用保存了历史和记忆,每次放进上下文;服务端会话只是换了保存位置 |
| 窗口够大也要管理 | 有数据支持吗 | Lost in the Middle;Context Rot;我项目输入是输出 41 倍 |
| token | 中文和英文谁更费 token | 看分词器;中文常一字一 token,路径和代码切得碎;以 usage 为准 |
| 截断 | 截断会不会丢掉关键信息 | 按内容选保留位置;写明省略量;完整内容可读 |
| 压缩 | 摘要写错了怎么办 | 约束和任务不压缩;摘要要求保留事实和失败原因;关键信息可重新获取 |
| 压缩边界 | 为什么不能随便切 | 调用和结果必须成对;按”轮”切分 |
| prompt caching | 怎么设计提示词才能命中 | 不变的在前;不放时间戳;工具固定;历史只追加 |
| 缓存和压缩 | 两者冲突吗 | 压缩改写前缀使缓存失效;只在接近阈值时一次压够 |
| 长期记忆 | 过期信息怎么处理 | 带时间和来源;与当前观察冲突以当前为准;定期清理 |
| 记忆写入 | 什么信息该记 | 可复用、经过验证、有来源;我项目 7 条候选收 5 合 1 丢 1 |
| 预加载 vs 按需 | 你的项目怎么选的 | 短且必用的预加载(survey);其余按需;知识库调用率低的教训 |
| 子 Agent | 什么时候用子 Agent 隔离上下文 | 探索过程长但只需要结论时;代价是多一次运行和信息损失 |
| 服务端压缩 | 为什么不直接用厂商的压缩 | 只用一家时可以;换多家模型、要控制或审查摘要时自己写;OpenAI 的压缩结果是加密的,看不到 |
| Skills | 和把说明全写进系统提示词有什么区别 | 开头只放名字和描述,用到才读全文;几十个 skill 也不撑爆上下文 |
| 压缩阈值 | 窗口用到多少压缩合适 | 没有标准数字;越早压越省单步费用,但缓存失效更频繁;pi 等到”窗口 - 16K”才压 |
| 摘要格式 | 摘要提示词怎么写 | 固定分段:目标、约束、进度、关键决定及理由、下一步、关键信息;原样保留路径和报错;多次压缩时在旧摘要上更新 |
第七部分 闭卷自测
1. 多轮对话里,第 2 轮模型能引用第 1 轮的内容,是因为模型记住了吗?
答案
不是。模型无状态,是程序把第 1 轮的消息又放进了第 2 轮的请求里。
2. 窗口有 100 万 token,Agent 每步都把完整历史发过去,会有哪些问题?
答案
一是成本和延迟随步数快速增长(累计输入近似平方增长);二是模型对长上下文中间位置的信息利用变差,输入越长表现越不稳定;三是过期的报错、被推翻的结论会干扰判断。
3. 截断编译日志时应该保留哪部分?截断说明里要写什么?
答案
保留末尾(报错和总结通常在最后),最好也保留开头几行命令和环境信息。说明里要写省略了多少、完整内容存在哪、用什么工具怎么读,而且读取工具必须真的能访问那个位置。
4. 压缩历史时,为什么要按”轮”切分而不是按消息条数切?
答案
assistant 的工具调用和对应的 tool 结果必须同时保留或同时移除。按条数切可能只留下结果、丢了调用(或反过来),下一次请求会被 API 拒绝。实验 2 的”错误截断”就是这种情况。
5. 摘要里至少要保留哪几类信息?
答案
任务目标和约束、已确认的事实、做过的改动、失败过的方法和原因、当前进度和待办。约束最好根本不压缩。
6. prompt caching 缓存的是什么?命中后模型还会重新生成回答吗?
答案
缓存的是前缀部分的中间计算结果(KV cache),不是回答。命中后前缀部分不用重新计算,输出照样重新生成,所以回答仍然有随机性。和”把整个回答存起来直接返回”的应用层缓存不是一回事。
7. 下面哪些做法会让缓存命中率下降?(a)系统提示词第一行写当前时间(b)历史只追加(c)每一步都把历史压缩一次(d)每次请求随机打乱工具顺序
答案
a、c、d。它们都改变了前缀。b 是提高命中率的做法。
8. 实验 3 里,为什么”压缩历史”的 token 数更少,费用却比”完整历史 + 缓存”高?这说明压缩没用吗?
答案
因为假设里压缩每步都改写了历史,缓存只能命中系统提示词,而缓存价比未命中便宜 50 倍,完整历史大部分按缓存价计费。不说明压缩没用:压缩解决的是窗口装不下和长上下文效果下降,缓存解决的是钱。正确做法是平时只追加吃缓存,接近阈值时一次压够。另外这个假设偏悲观,真实情况两次压缩之间前缀是稳定的。
9. 说出 CoALA 里的四种记忆,并各举一个 Agent 的例子。
答案
- 工作记忆:当前请求的上下文
- 情景记忆:过去具体的经历,比如”上次编 libpng 先编 zlib 成功”
- 语义记忆:事实知识,比如”主机的库不能用于交叉编译”、用户偏好
- 程序性记忆:做事的方法,比如系统提示词里的标准流程
10. 长期记忆有哪些风险?各怎么应对?
答案
- 过期:带时间,冲突时以当前观察为准
- 错误被固化:写入前验证、人工审核、记录来源
- 越积越多:去重合并、限制检索数量、定期清理
- 隐私:用户可查看删除、不存敏感字段
- 被注入:记忆当数据不当指令,写入要有权限控制
11. tiktoken 算出来的 token 数能直接用来估算 DeepSeek 的费用吗?
答案
只能粗略估算。不同模型分词器不同,同一段文字 token 数不一样。精确数字以 API 返回的 usage 字段为准。
12. 你的项目知识库调用率很低(30 次运行调 8 次),从上下文工程的角度可以怎么改?
答案
按需检索依赖模型意识到自己需要。对于构建失败后的相关经验这种”确定有用”的信息,可以改成程序在命令失败后自动用报错关键行检索,把结果附在工具输出后面,在正确的时机主动放进上下文。同时要注意知识库覆盖不足的问题,检索不到相关条目时附上无关内容反而是干扰,需要设相关度门槛。
13. pi 默认在”上下文 token 超过窗口大小减 16384”时才压缩,为什么不早一点压,比如 70%?
答案
每次压缩都会改写历史前缀,让之前的 prompt caching 全部失效。压得越晚、次数越少,缓存命中越多。代价是每一步都在用接近满的窗口,单步费用更高,长上下文干扰也更明显。阈值是成本、效果和缓存命中之间的取舍,要看任务长度和模型价格定。
延伸阅读
- Anthropic:Effective context engineering for AI agents(2025-09)— 上下文是有限资源、系统提示词的”合适高度”、即时加载、长任务的三种做法
- LangChain:Context Engineering for Agents — 写出去、选进来、压缩、隔离四类
- Lost in the Middle(Liu 等,TACL 2023)— 长上下文中间位置的信息利用变差
- Chroma:Context Rot(2025-07)— 输入长度增加对多个模型表现的影响
- DeepSeek:上下文硬盘缓存 / OpenAI:Prompt Caching / Anthropic:Prompt Caching
- CoALA(Sumers 等,2023)— 语言 Agent 的认知架构和记忆分类
- MemGPT(Packer 等,2023)— 借鉴操作系统虚拟内存管理上下文
- 12-Factor Agents — 第 3 条”自己掌控上下文窗口”、第 9 条”把错误压缩进上下文”
- pi:Compaction 文档(commit
7b4cfd6)— 一个真实编程 Agent 的压缩实现:触发条件、切分规则、摘要格式,配图清楚 - Anthropic:Compaction / OpenAI:Compaction — 两家服务端压缩的参数和返回格式
- Anthropic:Harness design for long-running application development(2026-03)— 压缩和上下文重置的对比,以及模型换代后哪些设计可以删
- Anthropic:Equipping agents for the real world with Agent Skills(2025-10)— Skills 的三层按需加载
下一篇:07 状态、持久化与恢复——任务跑到一半进程崩了、或者要等人确认几个小时,怎么接着跑。