为了少花点 Token,我重新学习了 Agent 的上下文缓存
事情的起因是,在研究开发我自己的agent项目(后文称为xNagent)时,遇到了deepseek的好几次api价格调整。出于省成本的考虑,想要优化一下我的上下文实现方式。在此之前,其实已经听说过多次deepseek的缓存比例很高,尤其是使用deepseek-reasonix和dsh,因此也非常好奇这其中到底有什么门道。于是就有了这篇文章,大致讲述一下我自己的一些调查和实现。
从基础开始
上下文
一般而言,调用模型完成一次问答,只需要提交一次请求并取得回答。每次请求模型api都不会记住历史状态,因此需要连续对话时,就要把之前的交互一并提交,模型才能知道我们接着说的是什么。如果还需要模型调用工具,历史里就要继续增加工具调用和执行结果。
例如,让 Agent 修改代码,它可能先读取文件,再运行测试,根据报错继续修改。每次调用模型,都需要带上用户的要求和此前的相关交互。系统指令、工具定义,以及这些不断积累的内容,共同组成了模型这一次接收到的上下文。
最简单的agent上下文实现方式,就是维护一个消息数组。模型输出后把回复放进去,工具执行完再把结果放进去,下一次调用时一起提交(以下为简化伪代码):
history = [user_message(task)]
while True:
reply = call_model(
system=system_prompt,
tools=tool_definitions,
messages=history,
)
history.append(reply)
if not reply.tool_calls:
break
for call in reply.tool_calls:
result = execute_tool(call)
history.append(tool_result(call.id, result))
其中,call.id 用来关联一次工具调用和它的结果。假如模型同时要求读取两个文件,就需要靠这个关联区分两份返回内容。几次调用之后,输入大致会变成这样:
第 1 次:固定指令 + 工具定义 + 用户任务
第 2 次:固定指令 + 工具定义 + 用户任务 + 调用 A + 结果 A
第 3 次:固定指令 + 工具定义 + 用户任务 + 调用 A + 结果 A + 调用 B + 结果 B
更常见的场景是,用户提出了一个要求,但 Agent 自己运行十几次工具,积累了不少历史。模型会将输入转换为 token 进行处理,API 通常也按 token 计量费用。这些历史在后续调用中反复提交,输入量便逐渐增加,直到接近上下文窗口的上限。
模型还需要留出生成回答的空间。对于输入与输出共用窗口的模型,预算大致为:

这里的 W 是上下文窗口,Tinput 包括指令、工具定义、历史和当前输入;Toutput budget 是留给输出的预算,其中可能还包含推理 token(具体输入、输出上限由模型决定)。
上下文缓存
前面提到的请求有一个特点:每次新增的内容可能不多,但之前的历史会重复出现。如果这部分输入每次都重新处理,就会产生大量重复计算。上下文缓存,也就是 Prompt Caching,就是为了解决这个问题。它的大致原理正是复用历史消息中的一部分计算结果,以此来降低计算量,达到节约预算的目的。
以常见的自回归 Transformer 为例,模型处理输入时会产生后续计算需要的中间状态,例如注意力层的 Key、Value。服务端保存这些状态后,下一次输入若以相同的内容开头,就有机会复用已有前缀,减少重复的输入处理。
例如,前一次已经处理过“固定指令 + 工具定义 + 任务历史”,后一次只在末尾增加了工具结果,那么前面的部分就具备复用条件:

DeepSeek 是使用上下文缓存机制的典型。它默认开启上下文缓存,服务端将前缀持久化为缓存单元,后续请求匹配这些单元时便可以命中。缓存需要时间构建,也会被清理,因此实际命中量要从接口返回的用量中读取。DeepSeek Context Caching
命中以后,新的回答仍然需要生成,命中缓存的段落也仍然占用上下文窗口。它只是被先行处理过,而不是直接复用旧的结果前缀。
xNagent的旧实现
xNagent 采用 Planner–Executor 架构,也就是 P-E。Planner 负责规划与协调,Executor 负责执行具体任务;项目里也把执行角色称为 Worker。
在这种分工下,两个角色需要的上下文有所不同。Planner 要判断任务进展、决定接下来做什么;Executor 更关心分配给自己的任务,以及刚刚读到的文件、运行过的命令和工具结果。因此,最开始我分别维护了它们的上下文:Planner 会收到这一轮规划过程中的工具交互以及根据Executor当前任务状态构造的一份快照;Executor 则在一次具体任务内,持续追加自己的执行历史。
它们收到的内容大致如下:
Planner 的输入
固定指令
本轮已有的交互历史
当前状态快照:目标、进展、近期变化、相关事实……
Executor 的输入
固定指令
分配的任务与必要背景
调用 1 → 结果 1
调用 2 → 结果 2
……
数据库保存当前状态,调用前再组织成文本,这样就可以让 Planner 读到最新进展。不过,重新生成快照时,原来放在输入末尾的内容也会被替换。
用 S 表示固定指令,H 表示本轮已有历史,A、B 表示两次快照,旧请求可以简化为:
请求 1:S + H + 快照 A
请求 2:S + H + 新工具交互 + 快照 B
↑
从这里开始,旧输入不再被完整保留
第一次请求里的快照 A 没有保留在原位置,第二次请求从这里开始就与旧输入不同了。即使 A、B 包含许多相同事实,它们也没有像 Executor 的执行历史那样持续追加在后面。
历史变长后,还要考虑裁剪。按消息条数或字符数删除旧内容容易实现,但同样长度的中文、代码和 JSON,token 数可能不同;即使按完整消息组删除,也可能删掉后续仍然需要的决定或约束。
于是,上下文的组织需要同时考虑两件事:怎样让已有输入便于复用,以及怎样在历史过长时保留有用的信息。
顺理推求
让外部记忆来帮助取舍
要保留信息,并不需要每轮都把全部记录发给模型。Agent 可以把完整交互保存在文件或数据库中,再选择本次调用需要的内容。假设工具产生了一份很长的测试日志,这一轮只需要定位失败原因,就可以提供失败用例、错误栈和日志位置。需要更多细节时,再读取原始文件。
完整测试日志:保存所有输出,供复查和定位
本轮模型输入:失败摘要 + 相关片段 + 原始日志位置
这样,缩短模型输入时,完整日志仍然保留着。任务目标、已经确认的结论和文件改动也可以单独保存,在需要时重新加入上下文。

这些选择由 Agent 完成。缓存发生在之后的模型调用中,服务端根据收到的输入判断能复用哪些计算。如果日志里的结论已经过时,仍需要 Agent 更新材料,缓存本身不会判断事实的新旧。
前缀与压缩
选好了内容,还要考虑排列。前缀缓存从输入开头匹配,所以同一段文字放在不同位置,复用情况也可能不同:
旧输入:A B C D
新输入:A B C D E ← 保留旧输入,尾部追加
旧输入:A B C D
新输入:A X B C D ← B、C、D 仍在,但前面的上下文变了
第二种排列中,B、C、D 虽然还在,但它们前面多了 X。模型处理这几段内容时所处的上下文已经不同,原来的计算结果也就不能直接照搬。
这也是为什么工具定义的排序、系统提示词里的动态时间、每轮改写的任务摘要,都可能影响前缀复用。若这些变化出现在很靠前的位置,后面再多的相同内容也不构成原来的完整前缀。OpenAI Prompt Caching
一种处理方式是保持已有输入不动,把新的状态作为后续消息追加。例如,任务由“等待测试”变为“测试通过”,可以追加这次变化,而不必重新生成包含全部任务背景的大段文字。模型既能读到更新,前面的历史也得以保留(指令与工具的更新方式还需遵循接口规则)。
历史持续追加有利于保留前缀,但上下文窗口总有用满的时候。此时常见的处理方式,是把较早的交互总结成摘要,保留近期内容:
压缩前:固定部分 + 很长的旧历史 + 最近交互
压缩后:固定部分 + 摘要 + 最近交互
从替换位置开始,后面的前缀关系发生变化。最近交互即使原样保留,也已经跟在不同的内容后面。
用模型生成摘要,会增加一次调用,也会改变已有前缀。不过,后续请求变短了,便能腾出窗口空间,减少重复携带旧内容的费用。是否值得压缩,要把这两边一起考虑,还要看摘要能否保留继续执行所需的信息。因此,普通执行时可以尽量追加,接近窗口限制时再收缩历史。具体怎样选取旧内容、保留多少近期交互,以及压缩后如何继续,各个项目有不同的实现。
站在巨人的肩膀上
Pi:agent的基线
Pi 的会话管理保留了完整交互,模型调用流程再从中选取需要的内容。顺着这两部分代码,可以看到历史怎样累积,又怎样在压缩后继续使用。
Pi 的会话记录支持父子关系,当前工作历史对应会话树上的一条路径。session-manager.ts 会沿选定路径构造模型上下文。若路径上有压缩记录,就用压缩摘要、指定边界之后保留的消息,以及后续新消息构造工作上下文,而不是把整个会话文件全部交给模型。session-manager.ts
压缩记录用摘要和保留位置描述这次变化(字段简化):
type CompactionBoundary = {
summary: string; // 被压缩部分的摘要
firstKeptEntryId: string; // 从哪条记录开始保留原始内容
};
summary 保存摘要,firstKeptEntryId 标明近期原文从哪里开始。构造下一次输入时,就可以用摘要替代较早的内容,再接上保留的原文。原始会话记录不需要随之删除。
Pi 的自动压缩判断,可以概括为:当前估算用量超过窗口减去预留量时触发。对应版本使用 reserveTokens 留出空间,使用 keepRecentTokens 控制近期历史的保留预算。选择切点时还会考虑消息类型,避免直接从孤立的工具结果开始。compaction.ts
已有会话: [较早的交互........................][近期交互]
│
▼
模型上下文:[较早交互的摘要] [近期交互]
原始会话记录仍保留;摘要不是把会话文件截短。
这些消息在发送前还会经过转换。Pi 的 agent loop 先应用可选的 transformContext,再通过 convertToLlm 转成模型兼容的消息。因此,内部记录可以包含应用自己的信息,真正调用模型时再按需要筛选和转换。agent-loop.ts
从缓存角度看,最终转换出来的请求才是需要比较的对象。会话文件虽然一直追加,经过筛选和压缩后的模型输入,却可能已经改变了旧前缀。它实际上是一个符合直觉的上下文设计,但是对于缓存命中并不友好。
Codex:Chat 与 Responses
Codex 的循环同样是调用模型、执行工具、追加结果、继续调用。OpenAI 在介绍其 agent loop 时,专门讨论了如何让下一次请求尽量包含上一次请求的完整前缀,也提到工具顺序等细节对缓存的影响。配置变化能否作为后续消息表达,而不去改写之前的输入,是其中一个实现考虑。Codex agent loop
如果固定内容保持不变,只在后面增加新的交互,相邻输入就可以写成:

P_n 表示第 n 次请求的模型输入,Delta_n 表示随后新增的模型输出、工具结果或用户输入,|| 表示拼接。模型切换、工具更新或压缩发生时,则需要重新检查前缀是否仍然一致。
这是一种控制的思路,在自己的 Agent 实现中,也可以比较前后两次构造好的输入,检查某次改动有没有意外重写历史(以下为本地检查示例):
# 比较已经规范化的输入条目。
def preserves_prefix(previous_items, current_items):
return current_items[:len(previous_items)] == previous_items
assert old_system == new_system
assert old_tools == new_tools # 包括顺序与 schema
assert preserves_prefix(old_input, new_input)
这里单独比较了工具定义,因为它们通常不在 input 数组里,却同样参与模型输入。若只检查聊天消息,就会漏掉工具 schema 或排列顺序的变化(完整 HTTP 请求还可能包含不参与模型前缀的控制字段)。
Codex 更值得称道的是它使用的 Responses API,也会影响需要保留哪些上下文。传统的 Chat Completions 可以表达文字和自定义工具调用;Responses 则用不同类型的条目表达消息、工具调用和推理状态,并提供可选的服务端会话接续以及更多平台工具。对于支持的模型,保留 reasoning items 能延续聊天正文之外的状态。Responses 迁移指南、推理状态指南
应用关心的普通输出: 回答文字、工具调用
可能还要保留的条目: reasoning、compaction 等协议状态
这些状态不一定是可读文字。把响应全部摊平成 role + text,可能丢掉下一轮本来可以使用的内容。而使用items的方式来承载各种不同格式的状态,能够显著提高消息构造的灵活性。实际上,这也使很多api能够实现自己的内置工具,例如网络搜索。
如果模型支持使用 previous_response_id,还可以让服务端保存和找回消息历史,客户端只提交新增内容。不过,找回的历史仍然计入模型输入,费用要继续区分缓存命中和未命中部分。但是这种api天然实现了append-only的上下文,是缓存友好的。会话状态文档
遗憾的是,这些能力仍要取决于具体服务的实现。有的厂商只兼容 Responses 的消息格式,而不能使用历史找回(说的就是你,deepseek)。值得一提的是,Claude 没有使用 response api,而是在自己的 Messages API 中提供缓存和压缩。选择接口时,需要看实际支持的功能,不能只看接口名称。Claude 压缩机制
前面介绍 Pi 时,压缩结果是一段摘要,再加上保留原文的位置。OpenAI 的 /responses/compact 则直接接受一段会话,返回后续可继续使用的压缩后窗口,其中可以包含加密的 compaction item。应用要保留并使用返回的完整输出窗口,而不是只提取文字。OpenAI 也提供按阈值触发压缩的方式(支持范围随模型而异)。OpenAI Compaction
应用自己生成的摘要可以阅读、修改,而这类加密条目需要按协议原样使用。两种方式都能缩短后续上下文,但保留的数据和接续方式不同。

压缩之后,历史前缀发生变化,固定指令等未变化的部分仍可能复用。后续交互再接到压缩结果后面,逐渐形成新的可复用前缀。
Reasonix:极致缓存
DeepSeek-Reasonix 在普通执行时尽量追加历史,达到压力阈值后再处理工具内容、生成摘要。它的默认压缩比例为窗口的 0.80。比较有意思的是,它连“生成摘要的那次请求”也考虑了缓存。
如果换一套系统提示词,再把旧对话重新包装成待总结文本,摘要请求本身就改变了前缀。Reasonix 的 cache-aligned 路径会尽量沿用原来的模型输入,只在末尾追加压缩指令,让摘要调用也有机会复用缓存。若这种请求超出窗口,就改用收紧材料后单独生成摘要的备用路径。压缩指令与入口、备用摘要路径
正常请求:原有指令 + 工具定义 + 历史
摘要请求:原有指令 + 工具定义 + 历史 + 压缩要求
后续请求:原有指令 + 工具定义 + 压缩结果 + 保留的近期内容
这样,生成摘要时仍尽量使用旧前缀,等摘要生成后,再用它替换旧历史。
这种请求的组织方式可以在测试中看到效果。它的 cachehit_e2e_test.go 使用模拟服务,比较相邻请求中相同的消息前缀,再由字符数量构造模拟 token 用量。测试覆盖了追加过程、推理内容回传、会话切换及发布门槛等场景。
Reasonix 的极致体现在它把缓存甚至覆盖到了连续执行和生成摘要这两个环节,平时保留历史前缀,摘要调用也尽量接着原来的输入,直到真正应用摘要时才重建上下文。相比 Pi,它更侧重保留和取舍会话内容,这里的处理更直接地围绕相邻请求能复用多少内容展开。而压缩产生的前缀变化被集中到了需要收缩历史的时候,减少了一些额外支出。
DeepSeek Harness:新生代
DeepSeek Harness(简称 DSH)是近期推出的 deepseek 的官方agent。它提供了组织模型调用、工具执行和会话记录的框架。与前面反复出现的消息数组相比,它对记录的管理更加细致。
它把 session 建模为追加式事件日志,再从日志派生模型历史。消息、工具结果等事件可以进入模型可见面,生命周期和诊断记录则不必都变成提示词。这里的“可见面”对应代码中的 surface,可以理解为当前要交给模型的有序内容。DSH Session、surface 源码
Session 事件日志
用户消息
模型输出
工具结果
请求与生命周期记录
压缩记录
│
│ 选择、投影、替换
▼
当前模型历史
当前有效的指令
摘要或保留的旧消息
近期交互
例如,原来的可见历史是 A B C D E。压缩时,可以在日志末尾新增一项“用 S 替换 B 到 D”的记录。再从日志生成模型输入时,得到的就是 A S E。旧的 B、C、D 仍在日志里,只是不再按原样交给模型。因此,日志继续追加的同时,模型看到的历史已经缩短了(以下为伪代码):
events.append(message_event("B"))
events.append(message_event("C"))
events.append(message_event("D"))
# 保留旧事件,新增一次改变模型可见范围的决定。
events.append(replace_surface(start="B", end="D", summary="S"))
visible_history = derive_history(events)
恢复时,除了知道摘要内容,还需要知道它替换了哪些内容。否则反复压缩后,模型输入里可能同时出现摘要与被摘要覆盖的旧消息,或者遗漏本来应保留的近期结果。
有了完整记录,压缩时就需要选定替换范围。DSH 将这部分做成独立能力:计算上下文压力、选择旧内容、生成摘要,再将摘要应用到模型历史中,并记录开始和结束。生成摘要需要等待模型返回,在这段时间里,待替换的历史可能发生变化。因此,应用摘要之前还会检查所选范围是否仍然有效,避免覆盖与摘要内容不一致的历史。压缩范围与事务源码
工具调用也是切分范围时必须考虑的结构。例如:
assistant:调用 read_file,call_id = c1
tool:返回 c1 的文件内容
如果只保留下面的结果、却把上面的调用裁掉,后续请求可能违反协议,模型也缺少解释结果的上下文。DSH 在当前 surface 上计算工具调用与结果的配对边界,用它判断哪些切点适合压缩,而不是简单按第 N 条消息下刀。特别长的工具结果则可以单独处理。例如,一次命令产生大量日志时,可以先裁剪结果,再判断是否还需要总结更大范围的历史。DSH 为这两类处理保留了不同环节。DSH 压缩子系统
执行时,DSH 的 agent loop 从 session 派生历史,组合工具定义和调用配置,执行模型与工具,再把新内容追加回记录。没有替换时,这种过程有利于延续前缀;发生 surface replacement 后,输入形状就会发生变化。
DSH 延续了 Pi 中完整记录与模型上下文分开管理的思路,但是又把压缩配置纳入了同一套流程。对于缓存而言,普通执行时尽量沿用已有输入,需要压缩时再替换旧历史,仍然是基本做法。它更值得参考的地方是把这些处理实现到了会话管理的地步,压缩之后如何组织下一次请求、需要恢复时如何获取到所需要的历史,这也许是一种方式避免上下文压缩的信息丢失。
新的秩序
看过了这么多设计,我想是时候开始了。
Append ,而非 Replace
原来的 Planner 通过快照获取 Executor 的最新进展,但每轮替换快照,会破坏上一轮输入的完整前缀。因此,需要尝试保留独立的任务状态存储,将输入改为“已有背景 + 后续变化”;历史过长时,再利用保存的状态重建上下文。一次任务可能经过多轮规划。如果任务历史只保留到本轮结束,下一轮仍然要重新组织输入。因此,模型历史应该改为按会话保存,跨轮次延续,代码中我称为 spine。会话尚无历史时,先提供任务目标、进展和相关事实作为完整背景,以此为基线;后续的请求再比较前后状态,只追加变化。
executeStep 中的处理如下(节选,省略无关参数):
const spine = this.conversationSpine(input.epoch.conversationId);
const baseline = spine.lastContext === null;
const contextMarkdown = baseline
? conversationPlannerContextToMarkdown(input.context)
: conversationPlannerContextDeltaToMarkdown(input.context, spine.lastContext!);
// contextMarkdown 随本轮操作记录等内容组成 dynamicPrompt。
spine.messages.push({ role: "user", content: dynamicPrompt });
spine.contextMessageIndex = spine.messages.length - 1;
spine.lastContext = structuredClone(input.context);
const messages: ModelMessage[] = [
{ role: "system", content: this.systemPrompt },
...spine.messages
];
lastContext 保存上一次提交的状态,供下一轮比较;contextMessageIndex 记录这次状态消息的位置,供后续压缩使用。模型回复和工具结果继续追加到同一段历史中。
旧:
第 1 次:固定部分 + 历史交互 + 快照 A
第 2 次:固定部分 + 历史交互 + 交互 1 + 快照 B
第 3 次:固定部分 + 历史交互 + 交互 1 + 交互 2 + 快照 C
新:追加完整快照
第 1 次:固定部分 + 快照 A
第 2 次:固定部分 + 快照 A + 交互 1 + 快照 B
第 3 次:固定部分 + 快照 A + 交互 1 + 快照 B + 交互 2 + 快照 C
只把完整的快照挪到末尾,虽然能保留前缀,却仍会反复携带相同背景。所以,增量函数按字段筛选本轮需要补充的内容:
| 内容 | 更新方式 |
|---|---|
| 会话消息 | 比较消息 ID,加入新增消息 |
| 当前任务 | 状态、目标发生变化时补充;切换任务时提供新任务背景 |
| 已确认事实 | 比较事实 ID,加入新增事实 |
| 工作项与执行进展 | 比较对应摘要,发生变化时提供当前内容 |
即
追加增量
第 1 次:固定部分 + 快照 A
第 2 次:固定部分 + 快照 A + 交互 1 + 增量 A→B
第 3 次:固定部分 + 快照 A + 交互 1 + 增量 A→B + 交互 2 + 增量 B→C
辅助信息也采用条件加入:Skills 列表首次提供,之后通过内容指纹判断是否更新;Planner 操作记录通过游标选取尚未纳入的部分;本轮调度的触发约束只在初始背景或新调度轮次开始时加入。保留旧前缀减少重复计算,这些去重处理则减少每轮新增量,延缓窗口被占满。
从状态重建
历史持续追加,最终仍然需要压缩。在 xNagent 的 P-E 实现中,Planner 需要根据 Executor 的执行进展持续调度,因此任务目标、已确认的事实和执行状态会单独保存,供后续规划时读取。历史过长时,也可以从这些记录重新组织一份任务背景,作为 checkpoint 替换较早的交互,而不必再调用模型从整段对话中提取这些信息。
buildPlannerContextCheckpoint 组装的内容包括:
checkpoint
会话与任务状态快照
当前可用 Skills
近期持久化 Planner 操作
详情读取入口:run_inspect / worker_status / read
不过,最后一次状态消息之后,可能还有即将响应的模型回复和工具结果。它们需要保留,否则重建会丢掉尚未反映在这份状态中的交互。代码用前面记录的 contextMessageIndex 划定边界:
const tailStart = spine.contextMessageIndex === null
? spine.messages.length
: spine.contextMessageIndex + 1;
const retainedTail = spine.messages
.slice(tailStart)
.map(compactHistoricalMessageForRebase);
spine.messages = [{ role: "user", content: checkpoint }, ...retainedTail];
spine.contextMessageIndex = 0;
spine.rebaseCount += 1;
即保留范围与 checkpoint 对应的状态位置有关,这与根据工具调用动态调整切分的思路不谋而合。
重建会在已有输入的估算用量超过软阈值时触发。计算包含系统指令、工具定义和消息历史,并且可以按需调整阈值。具体的检查行为发生在本轮动态消息追加之前,用于提前收缩历史。
已有历史
↓
估算已有历史的 token 用量
↓
超过阈值就压缩,否则保持原样
↓
追加这一轮的状态变化等新内容
↓
发送给模型
保留的交互尾部还要处理大块内容:工具结果和模型正文按字符预算保留首尾,超过字符预算的历史工具参数改为压缩标记。调用与结果的关联保留,精确内容按需重新读取。这些操作修改的是模型历史副本,任务记录和工作区文件不随之删除。
这套方式依赖已保存的状态;未进入状态、又被移出上下文的旧交互细节,不会自动进入 checkpoint。它省去了生成摘要的调用,同时也要求后续通过工具补取细节。实际上也就是使用外部记忆来持久化记录,让被压缩的内容有机会被召回。这种召回是一种“渐进式披露”,也节约了上下文成本。
实际效果
这么大费周章,有效果吗?
也许有,至少从图上来看,命中缓存的用量的确增加了
颜色由浅至深分别为:输入(命中缓存)、输入(未命中缓存)、输出

但是它为agent实际带来了多少的效益,我不知道,也很难计算。至少,保留一些学习收获吧
结语
文章的调研内容依赖ai,也许存在偏颇,读者多多担待。
有关AI Agent,我也是个半吊子,共同进步。

浙公网安备 33010602011771号