短期记忆与上下文压缩:让 Agent 跑长而不崩

Agent; 上下文管理; 上下文压缩; 短期记忆; Context Engineering 3280 字 17 min read

前四篇搭好了 Agent 的骨架

、对话循环、工具契约、提示词与缓存。但凡你让一个 Agent 真的跑起来、跑久一点,第一个爆掉的地方几乎总是同一个——上下文窗口

这一篇讲短期记忆

、为什么会爆、以及生产级系统用哪一整套手段让它”跑长而不崩”。

本文是「专题·Agent」系列第 5 篇。原理部分译自开源课程 Agentic System Course ch05,真实项目对比来自《16 个开源 Agent 项目差异对比》。

一个每个人都会踩的坑

你的 Agent 已经跑了 40 轮。每一次文件读取、每一次 grep、每一次网页抓取、每一轮模型回复——全都堆在上下文里。每轮成本线性上涨。然后模型返回 prompt_too_long

你加了一步

。下一轮好了。再下一轮,摘要本身也太长了。你又加一步
。现在模型丢掉了原始任务,正在解决三轮之前的问题。

短期记忆做得差,平时看不出来,一爆就是灾难;做得好,你根本感觉不到它的存在,因为它从不爆。这一篇讲的就是这两者之间的差距。

三种视图,而不是一份

一个生产级 Agent 没有”一份”对话记录。它有三份

flowchart TD
    NEW["新一轮:模型回复 / 工具结果 / 用户消息"] --> AUDIT["追加式审计日志<br/>完整保真,永不修改"]
    NEW --> WORK["工作记忆<br/>可变草稿纸"]
    AUDIT --> COMPACT["精简运行记录<br/>近期原文 + 旧的摘要<br/>重复读去重 + 大输出裁剪"]
    WORK --> CONTEXT["提示词的易变尾部"]
    COMPACT --> CONTEXT
  • 追加式审计日志(append-only audit log) 是真相。每一轮模型回复、每一次工具调用、每一个工具结果,全量保存。你永远不编辑它。它是会话恢复(第 7 篇)的依据,也是日后审计要看的东西。
  • 精简运行记录(compact operating transcript) 才是模型下一轮真正看到的东西。它每次组装提示词时都从审计日志重新构建——裁剪、去重、摘要。它是一个视图,不是一个存储
  • 工作记忆(working memory) 是 Agent 为当前任务维护的一小块可变草稿纸——目标、当前计划、已读过的文件、待解决的问题。它可以每一步被覆写。

把这三者分开,是让本文其他所有技巧成立的前提。改了审计日志,你的恢复就废了;让运行记录无限增长,你的循环就死了;让工作记忆变成又一份对话记录,你就把同一个问题制造了两遍。

OpenCode 用 SessionTable + PartTable 存审计日志,用一个按需运行的压缩服务产出运行视图。Hermes Agent 用 SQLite messages 表 + FTS5 做审计,用 ContextCompressor 产出精简视图。形状是一样的。

手段一

单点收益最高的动作

进入运行记录之前就裁剪它。一个 50 KB 的 grep 结果,从现在到对话结束,每一轮都是 50 KB 的上下文。

// 带可见省略标记的裁剪。静默截断会教给模型一个错误的世界观。
function clip(text: string, maxChars = 2_000): string {
  if (text.length <= maxChars) return text;
  const half = Math.floor(maxChars / 2);
  return [
    text.slice(0, half),
    `\n[... 省略 ${text.length - maxChars} 字符;完整结果可通过 <ref> 获取 ...]\n`,
    text.slice(-half),
  ].join("");
}

生产中的两条规则:标记必须可见(模型需要知道它被裁剪了,否则会当自己拿到了全部内容来推理),完整结果必须存在某个模型能要回来的地方(临时文件、附件、单独的检索工具)。OpenCode 的截断服务把完整结果写到磁盘、返回摘要加指针;Hermes Agent 在工具注册项里强制一个 max_result_size_chars。都是同一个形状的变体。

手段二

如果模型在第 3 轮读了某个文件,又在第 17 轮读了同一个,运行记录不需要两份。丢掉早的,留最新的:

// 最新胜出去重:每个 (工具, 入参) 对只保留最近一次结果。
// 跳过被标为 open_world 的工具(第 3 篇)——它们的结果跨调用不稳定。
function dedupeRepeatedReads(messages, registry) {
  const stable = (m) => m.role === "tool" && !registry[m.toolName]?.open_world;
  const latest = new Map<string, number>();
  messages.forEach((m, i) => {
    if (!stable(m)) return;
    latest.set(JSON.stringify({ tool: m.toolName, input: m.toolInput }), i);
  });
  return messages.filter((m, i) => {
    if (!stable(m)) return true;
    return latest.get(JSON.stringify({ tool: m.toolName, input: m.toolInput })) === i;
  });
}

关键是按 (工具, 入参) 去重,而不是按文本——文本启发式又脆又有损。去重只在工具的结果对给定入参稳定时才安全

URL 的 web_fetch 在第 3 轮和第 17 轮可能返回不同字节,被编辑过的文件的 read_file 也不再匹配早先的读取。第 3 篇的 open_world: true 元数据就是标记这些的,去重会跳过它们。

一个附带好处

doom-loop 信号。连续三次完全相同的工具调用,正是第 2 篇检测卡死循环用的同一个签名。

手段三
——两头保护,中间压缩

一个扁平策略(“保留最后 8 轮,其余摘要”)能顶一阵,但会漏掉重要信息。生产系统的模式是非对称

,最早的若干轮也全保真,只压缩中间那一段。

  • OpenCodeDEFAULT_TAIL_TURNS = 2——最后两轮免于任何压缩。
  • Hermes AgentContextCompressor 保护头 N 轮和尾 N 轮,只摘要中间。

为什么两头都保护:开头承载模型反复回看的任务框架(原始目标、关键约束、用户真正的问题),结尾承载模型此刻正在推理的最新状态。中间是模型已经推理完的部分——事实还在,原始轮次不必在。

手段四
,以及什么是好摘要

裁剪和去重都不够时,才摘要。标准做法是用一个便宜的辅助模型把中间压缩成一小段引用块。三个实践要点:

  • 摘要器用不同的模型。压缩是少数几个”跑更弱的模型才是对的”的场景
    ,质量标准只是”保住事实”,成本差异会复利。
  • 摘要目的要写明。“保住继续任务所需的事实”产出有用摘要;“总结一下对话”产出没用摘要。
  • 摘要是内容,不是元数据。模型下一轮会把它当提示词读。一个清晰标记([以下是前 N 轮的摘要])让模型能显式地推理”我没有原文,需要就重新去取”。

好摘要的几条属性:保留具体、丢掉泛化(要”从 next-auth@4 迁到 @5,已改 route.ts”,不要”用户想升级 auth 库”);标注哪些是重构的、哪些是观察到的(“测试结果从记录里看不清”胜过想当然的”测试通过了”);结构化而非流水账——生产摘要器收敛到三段:已确立的事实 / 已做的决策(带理由) / 未解决的问题

手段五
,再摘要

便宜的动作排在昂贵的前面。两阶段模式

,先跑激进的裁剪 + 去重(“折叠”);只有折叠后还是太大,才启动摘要器。

原因

、无每轮成本、常常直接解决压力;摘要多花一次模型调用。把摘要器留到真正需要时,让压缩平均成本很低——大多数”压缩事件”根本到不了第二阶段。

压缩方法一览

近看,“压缩”不是一个技巧,而是一族,各有成本/质量权衡:

方法做什么成本丢什么
裁剪截断超阈值的单个工具结果,插可见标记,完整版存盘近乎免费,确定性单个结果内的细节,可重取
最新胜出去重丢掉相同 (工具,入参) 的早先重复近乎免费,确定性几乎不丢——后者本就取代了前者
历史裁剪(snip)丢掉超过固定深度的整轮旧结果,保留推理轮免费旧工具结果的细节
非对称摘要辅助 LLM 把中间压成一块引用,头尾保原文每次一次辅助调用中间的细粒度结构
微压缩同上但窗口更小,每次只压最旧几轮,反复做多次小的辅助调用每次丢得少,但多次易漂移
会话轮换开新会话,带一个交接块,用 parent_session_id 串起来一份全新的缓存交接块没抓住的一切 + 缓存温度

生产中这些不是”选一个”,而是一条流水线

→ 每次调用前去重 → 裁掉超深旧轮 → 到阈值摘要中间 → 摘要跑过两三次后轮换会话。每个方法都为下一个方法争取时间。设计决策不是”选哪个”,而是”什么顺序、什么阈值”。

6 个项目的真实阈值

《16 个开源项目对比》里,上下文压缩这一维度的实测差异特别能说明”没有最优,只有适配约束”:

维度GooseHermesOpen CodeOh My Open Agent
触发阈值80%50%上限减 20000 token buffer90%
触发方式Tool Call Cut Off(保证干净时机)每条消息后检查is_overflow三级警戒
摘要执行者主模型(成本高)辅助小模型(便宜 50~100 倍)专用无工具 AgentCompact 命令
状态保留最近 N 条最近 10 条摘要替换摘要 + Task List 注入
历史可回溯✅ SQLite 留原始消息❌ 内存替换

三个关键洞察:

  • Goose 的 Tool Call Cut Off 保证压缩时机干净——在高频工具调用场景是必须项(否则会在一次工具调用中途压缩,把配对的 tool_use/tool_result 切散)。
  • Hermes 的辅助小模型让 50% 这种激进阈值在成本上可行。
  • Oh My Open Agent 的 Task List 状态注入是整个领域最创新的贡献之一——压缩后 Agent 仍然知道”做到哪了”。这解决了开头那个坑
    ,Agent 就会重做。

不是所有工具结果都平等

不同工具在运行记录里该有不同的处置策略:

  • Skill 结果、结构化输出
    ,保留原文
  • Shell 日志、原始文件转储、网页抓取
    ,插入时激进裁剪,几轮后模型移开注意力就整个丢掉。
  • 补丁和 diff
    ,需在应用/拒绝前保持可见数轮
  • 图片附件
    ,之后压成文字描述。

有用的练习

keep_verbatim(保留原文)、clip_on_insert(插入即裁)、drop_after_consumed(消费后丢),把策略和第 3 篇的元数据一起烤进工具注册表。压缩器读策略,你就不用逐轮纠结”这条要不要丢”。

子 Agent 的记忆是独立的

当父循环委派给子 Agent(第 9 篇),子 Agent 有自己的短期记忆。父 Agent 看不到子 Agent 的中间轮次;子 Agent 只看到父给它的提示词加上自己工具产出的东西。这是刻意设计

Agent,会让压缩变难、让噪声污染父的推理。

推论(常有人栽):如果你想让父知道子 Agent 的中间工作,子 Agent 必须把它写进最终答案里。 子 Agent 私藏的一切,对父永远不可见。

冻结快照,重申一次

活在系统提示词里的记忆文件(MEMORY.mdUSER.md、Agent 笔记、Skill 索引)在会话启动时被捕获,会话中途不变。这条规则第 4 篇讲过,这里再次适用

(本篇)是中途变更的地方,稳定前缀(第 4 篇)是会话启动冻结的地方。分界线就是缓存断点。

想让一段记忆是”活的”,放尾部;想让它缓存常温,放前缀。想两者兼得——又活又缓存——每轮都会撞上昂贵的缓存 miss。


搭好了不会爆的运行记录,但这一切都活不过下一个会话。第 6 篇讲能跨会话存活的记忆

个项目怎么做长期记忆,以及向量检索、全文搜索、混合检索怎么比。

主要参考:Agentic System Course ch05(短期记忆);agent开发系列《上下文管理与压缩》;《16 个开源 Agent 项目差异对比》维度 3;Datawhale《Hello Generic Agent》上下文截断与压缩。