PERSONAL LAB / ai/agent开发
AI Agent 开发

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 cachingKV 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 万个 token
  • enc.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 这种不常见的大写词被切成 ZLIB
  • 中文大多一个字一个 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

我项目里的 _truncateagent/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 轮出现过
摘要压缩早期轮次交给模型总结成要点保留关键信息多一次模型调用;摘要可能漏掉或写错细节
按重要性保留标记关键消息永远保留,其他的丢弃或压缩精确要定义”关键”的规则

摘要应该保留什么(按优先级):

  1. 任务目标和约束(最好根本不压缩,原样保留)
  2. 已经确认的事实(“项目依赖 zlib""zlib 已经装到 deps/zlib”)
  3. 做过的改动(改了哪些文件、改成什么)
  4. 失败过的方法和失败原因(防止模型再试一遍)
  5. 当前进度和待办

不需要保留的:成功命令的完整输出、已经被修复的报错全文、中间的试探性读取。

动手实验 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:

AnthropicOpenAI
服务端自动压缩Messages API 的 context_management.edits 里加 {"type": "compact_20260112"},默认输入超过 15 万 token 触发(最低可设 5 万);目前是 BetaResponses API 设 context_managementcompact_threshold,超过就在同一次请求里压缩
手动调用pause_after_compaction: true 可以压完先停下,自己补几条原文再继续单独的 /responses/compact 接口,发完整上下文,返回压缩后的上下文
摘要能不能看能,返回一个 compaction 块,里面是文字摘要;可以用 instructions 换掉默认的摘要提示词不能,压缩条目是加密的,只能原样传回去
下一次请求怎么发compaction 块原样带上,API 自动丢掉它之前的内容把返回的窗口原样作为下一次输入,文档明确说不要自己裁剪
和缓存的关系文档建议在系统提示词末尾单独打一个缓存断点,压缩后系统提示词的缓存还能用

更轻的一种:只清旧的工具结果。 Anthropic 另有 context editingclear_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 查阅官方文档):

DeepSeekOpenAIAnthropic
是否自动默认开启,不用改代码支持的模型自动开启在请求顶层加 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_tokensprompt_cache_miss_tokenscached_tokenscache_creation_input_tokenscache_read_input_tokens
保证尽力而为,不保证 100% 命中

来源:DeepSeek 上下文缓存DeepSeek 价格OpenAI Prompt CachingAnthropic 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 开始而不是 0
  • min(a, b) 取较小值,命中的量不能超过总量
  • 1_000_000:数字里的下划线只是为了好读,等于 1000000
  • lambda step: 0lambda 定义一个没有名字的简短函数,冒号左边是参数,右边是返回值。lambda step: SYSTEM if step > 1 else 0 等于”第 1 步返回 0,之后返回 3000”
  • append_only:历史只追加时,第 step 步能命中的就是上一步发过的全部内容
  • system_only:假设压缩每步都在改写历史,只有最前面的系统提示词能命中。这是个偏悲观的简化:实际上两次压缩之间前缀是稳定的,能命中更多
  • f"{sum(full):>9,}", 表示每三位加千位分隔符

结论:

  1. 压缩把累计输入从 129 万降到 57 万,token 数减少一半多
  2. 但完整历史配合缓存只要 $0.013,比无缓存便宜 15 倍
  3. 如果压缩破坏了缓存,反而比”不压缩但命中缓存”贵 5 倍

这不是说不要压缩。压缩解决的是窗口装不下长上下文效果变差,缓存解决的是。真实做法是:历史只追加,尽量吃满缓存;接近窗口或效果开始下降时,一次压缩到位,之后继续只追加。

另外要记住缓存是”尽力而为”的,DeepSeek 文档明确说不保证 100% 命中。评估成本时要看 API 返回的真实命中量。我项目的 trace.py 目前只记了 prompt_tokenscompletion_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 toolmemory_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 篇):

  1. runs.db 的 160 次运行里取失败命令,去掉路径和数字差异后只有 14 种报错
  2. 每种报错取报错关键行 + Agent 随后 8 个动作和结果,加上 libpng、re2 最近各 3 次完整轨迹
  3. 交给模型提炼,要求只写有证据的内容、写明来源运行编号
  4. 人工逐条对照运行记录审核: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.pysurvey 函数);文件内容、经验、日志都按需取。

反过来的教训是知识库: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.pyread_file从开头按行分页,结果首行写明范围和下一页 offset
按”字符”计长度MAX_BYTES能控制长度名字叫字节实际按字符;没有按 token 计
预加载build_agent.pysurvey根目录文件列表、CMake 选项放进第一条消息
按需检索read_filesearch_knowledge 等工具文件和经验由模型自己查知识库调用率低,没有在报错后自动检索
历史压缩没有做40 步以内窗口够用;困难项目单次几十万输入 token,加压缩或缓存统计有明显价值
缓存DeepSeek 默认自动缓存历史只追加,天然适合命中trace.py 没记 prompt_cache_hit_tokens,算不出真实花费和命中率
长期记忆knowledge/entries.jsonlknowledge/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 > 窗口大小 - 16384L132-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 全部失效。压得越晚、次数越少,缓存命中越多。代价是每一步都在用接近满的窗口,单步费用更高,长上下文干扰也更明显。阈值是成本、效果和缓存命中之间的取舍,要看任务长度和模型价格定。

延伸阅读

  1. Anthropic:Effective context engineering for AI agents(2025-09)— 上下文是有限资源、系统提示词的”合适高度”、即时加载、长任务的三种做法
  2. LangChain:Context Engineering for Agents — 写出去、选进来、压缩、隔离四类
  3. Lost in the Middle(Liu 等,TACL 2023)— 长上下文中间位置的信息利用变差
  4. Chroma:Context Rot(2025-07)— 输入长度增加对多个模型表现的影响
  5. DeepSeek:上下文硬盘缓存 / OpenAI:Prompt Caching / Anthropic:Prompt Caching
  6. CoALA(Sumers 等,2023)— 语言 Agent 的认知架构和记忆分类
  7. MemGPT(Packer 等,2023)— 借鉴操作系统虚拟内存管理上下文
  8. 12-Factor Agents — 第 3 条”自己掌控上下文窗口”、第 9 条”把错误压缩进上下文”
  9. pi:Compaction 文档(commit 7b4cfd6)— 一个真实编程 Agent 的压缩实现:触发条件、切分规则、摘要格式,配图清楚
  10. Anthropic:Compaction / OpenAI:Compaction — 两家服务端压缩的参数和返回格式
  11. Anthropic:Harness design for long-running application development(2026-03)— 压缩和上下文重置的对比,以及模型换代后哪些设计可以删
  12. Anthropic:Equipping agents for the real world with Agent Skills(2025-10)— Skills 的三层按需加载

下一篇:07 状态、持久化与恢复——任务跑到一半进程崩了、或者要等人确认几个小时,怎么接着跑。

Related · Agent 开发
⎇ main ai/agent开发 25 节 230 notes UTF-8