Agent 长上下文处理机制:Context Compact 与 Memory 的协同

从零实现 Agent Harness 系列 · 第 05 篇

这一篇讲 Context CompactMemory

它们要解决的不是"怎么让模型记住一切",而是:Agent 跑久以后,哪些内容应该继续留在上下文里,哪些应该压缩,哪些必须外置保存。

前言

很多人刚开始做 Agent,会下意识追求一件事:

text 复制代码
尽量保留完整历史

这个想法很自然。毕竟用户需求、工具输出、文件内容、测试日志、错误堆栈,看起来都可能对后续有用。

但真实系统里,Agent 不是只回答一轮问题。它会反复读文件、跑命令、修错误、再测试。messages 会不断变长,里面既有关键状态,也有大量中间噪音。

如果不处理,会出现三个问题:

  • 成本变高:每一轮都要把完整上下文重新发给模型。
  • 噪音变多:旧日志、临时搜索结果、已经修复的错误会一直留在窗口里。
  • 注意力被干扰:模型可能被过时计划、旧失败、无关输出带偏。

所以长会话 Agent 真正需要的不是"无限记忆",而是两种能力:

text 复制代码
Context Compact:让当前上下文保持可用
Memory:让重要状态离开 messages,变成可恢复的外部状态

一、为什么必须有 Context Compact

messages 是模型下一轮真正能看到的工作上下文。

它应该尽量保留三类信息:

  • 用户当前目标
  • 最近决策和当前状态
  • 下一步必须依赖的代码、错误和工具结果

但它不适合长期背着所有原始历史。

比如一次 coding agent 任务里,模型可能运行过:

text 复制代码
ls
rg "retry"
cat tests/test_checkout.py
pytest tests/test_checkout.py
npm run build

其中有些结果很重要,比如当前失败用例和刚读过的源码;有些结果只在当时有用,比如旧的 ls 输出或已经过时的测试日志。

如果全部原样保留,模型每一轮都要重新"路过"这些内容。它不仅慢,还可能把旧信息当成当前事实。

所以 Context Compact 的必要性在于:

text 复制代码
当前上下文窗口有限,必须把低价值历史折叠掉,把高价值状态留下来。

它不是删除记忆,而是重写工作记忆。

二、Context Compact 的两层实现

这份教学实现把 compact 分成两层:

text 复制代码
micro_compact:每轮请求前做轻量清理
auto_compact:上下文超过阈值后生成连续性摘要

1. 轻量压缩:先清理旧工具输出

每次请求模型前,agent_loop() 会先调用:

python 复制代码
micro_compact(messages)

它做三件事:

  • 找出历史里的 tool 消息
  • 保留最近 KEEP_RECENT 条工具结果
  • 把更旧、很长、价值较低的工具输出替换成占位文本

比如一段很长的 shell 输出,可能会被替换成:

text 复制代码
[Previous: used bash]

这样做的好处是直接:模型不用继续背完整日志。

但这个占位文本信息量很低。它只能说明"之前用过 bash",不能说明执行了什么命令、成功还是失败、关键错误是什么。

所以这只是教学版的减负占位,不是理想的生产级做法。

更好的方式应该是:

text 复制代码
当前 messages 里放短摘要
完整 stdout / stderr 单独落盘
摘要里保留命令、退出码、关键错误、日志路径
需要细节时再按日志路径取回

比如旧日志可以压成:

text 复制代码
[Previous bash: pytest tests/test_checkout.py, exit=1, failed=tests/test_checkout.py::test_retry, full_log=.logs/tool_42.txt]

这样既减轻上下文,又保留恢复线索。

这里还有一个取舍:代码会尽量保留 read_file 的结果。

原因是文件内容通常比旧 shell 输出更有后续价值。模型刚读过的源码、配置、文档,可能直接影响下一步编辑;如果太早折叠,模型就可能丢掉函数签名、类名、参数细节。

所以教学版的策略可以概括为:

text 复制代码
旧 shell 输出优先压缩
旧 read_file 内容谨慎保留

2. 完整压缩:超过阈值后生成连续性摘要

轻量压缩只能处理局部噪音。如果整个 messages 还是太长,就要触发完整压缩。

代码里的判断是:

python 复制代码
estimate_tokens(messages) > THRESHOLD

意思是:

text 复制代码
当前会话历史估算出来的 token 数,超过了预设安全线

这里:

  • messages 是当前会话历史,包括用户输入、模型回复、工具结果。
  • THRESHOLD 是压缩阈值,表示上下文长到什么程度以后必须整理。
  • estimate_tokens(messages) 是粗略估算,不追求精确计费,只判断"是不是大概太长了"。

超过阈值后,系统调用:

python 复制代码
auto_compact(messages)

它的流程是:

text 复制代码
保存完整 transcript
    ↓
让模型生成摘要
    ↓
用摘要替换旧 messages

注意,完整压缩不是直接丢掉历史,而是先把原始记录写到 .transcripts/,再用摘要替换当前上下文。

看一个简化例子。

压缩前,messages 可能是:

json 复制代码
[
  {
    "role": "user",
    "content": "帮我修复 checkout 重试失败的问题"
  },
  {
    "role": "assistant",
    "content": "我先看一下相关测试和实现。"
  },
  {
    "role": "tool",
    "name": "read_file",
    "content": "tests/test_checkout.py 的完整内容..."
  },
  {
    "role": "tool",
    "name": "bash",
    "content": "pytest tests/test_checkout.py\n...\nFAILED test_retry ..."
  },
  {
    "role": "assistant",
    "content": "失败点在 retry_count 没有递增,我会修改 checkout.py。"
  },
  {
    "role": "tool",
    "name": "edit_file",
    "content": "已修改 src/checkout.py"
  }
]

触发 auto_compact(messages) 后,系统先保存完整历史:

text 复制代码
.transcripts/transcript_1720000000.jsonl

然后把当前 messages 替换成一条连续性摘要:

json 复制代码
[
  {
    "role": "user",
    "content": "[Conversation compressed. Transcript: .transcripts/transcript_1720000000.jsonl]\n\n已完成:读取了 tests/test_checkout.py,运行了 pytest tests/test_checkout.py,并确认失败用例是 test_retry。\n\n当前状态:失败原因初步判断为 src/checkout.py 中 retry_count 没有在重试路径正确递增。已经修改了 src/checkout.py,但还没有重新跑完整测试。\n\n关键文件:tests/test_checkout.py、src/checkout.py。\n\n关键决策:保留现有 checkout 接口,不改测试语义,只修复重试计数逻辑。\n\n下一步:重新运行 pytest tests/test_checkout.py;如果通过,再运行更大范围测试。"
  }
]

这段摘要保留的是"继续工作所需的信息":

  • 用户目标
  • 已读文件
  • 失败测试
  • 已修改文件
  • 当前决策
  • 下一步动作
  • 原始 transcript 路径

这就是连续性摘要的意义:它不是普通总结,而是让 Agent 在压缩后还能接着干活。

另外,这份代码还暴露了一个 compact 工具。模型如果觉得上下文已经很乱,可以主动调用它,并传入 focus,告诉系统摘要应该重点保留什么。

比如修 bug 时,focus 可以强调:

text 复制代码
保留失败命令、错误栈、已修改文件和下一步验证命令

这说明 compact 的目标不是单纯变短,而是围绕当前任务保留连续性。

三、为什么必须有 Memory

Compact 解决的是"当前上下文太长"。

但它不能解决另一个问题:

text 复制代码
有些状态不应该只存在于 messages 里

原因很简单:

  • messages 会被压缩
  • messages 会被上下文窗口挤掉
  • 会话结束后,内存里的历史可能消失
  • 摘要不适合维护结构化状态,比如任务依赖和定时规则

所以需要 Memory

这里的 Memory 不要理解成"模型脑子里记住了什么"。在这份教学代码里,它更具体:

text 复制代码
把需要长期保留的状态,从 messages 移到文件系统

也就是说,Memory 的必要性在于:

text 复制代码
不是所有重要信息都应该留在上下文里;有些信息应该变成可恢复、可查询、可更新的外部状态。

四、Memory 的实现:过去、现在与未来状态

这套 harness 里没有一个单独的 memory.py。Memory 是分散在几个模块里的外部状态机制。

可以按时间维度理解:

text 复制代码
过去发生过什么:.transcripts/
现在还有什么任务:.tasks/
未来什么时候再做:.scheduled_tasks.json

1. .transcripts/:保存过去的完整记录

auto_compact(messages) 生成摘要前,会先把完整历史写到:

text 复制代码
.transcripts/transcript_1720000000.jsonl

这解决的是:

text 复制代码
摘要可以变短,但原始过程不能完全消失

压缩后的摘要可能只写:

text 复制代码
pytest tests/test_checkout.py 失败,失败用例是 test_retry

但如果后面需要追溯完整 stdout、stderr、工具调用顺序,就可以回到 transcript。

所以 .transcripts/ 不是模型每轮都看的上下文,而是系统保留的可追溯记录。

2. .tasks/:保存当前任务状态

第 06 节里的 TaskManager 会把任务写成 JSON:

text 复制代码
.tasks/task_1.json
.tasks/task_2.json

一个任务大致是:

json 复制代码
{
  "id": 2,
  "subject": "补 checkout retry 测试",
  "description": "覆盖失败重试和成功重试两种路径",
  "status": "pending",
  "blockedBy": [1],
  "owner": ""
}

这类信息不适合只放在对话里。因为任务状态需要跨会话存在,还要能维护依赖关系。

写进 .tasks/ 后,它就不再依赖当前 messages

  • 会话结束后还在
  • compact 之后还在
  • 程序重启后还能读回来
  • 任务依赖可以结构化维护

3. .scheduled_tasks.json:保存未来唤醒规则

第 08 节里的 CronScheduler 会把 durable cron job 写进:

text 复制代码
.scheduled_tasks.json

比如:

json 复制代码
[
  {
    "id": "cron_102314",
    "cron": "0 9 * * 1",
    "prompt": "检查项目状态并汇总未完成任务",
    "recurring": true,
    "durable": true
  }
]

这类状态描述的是:

text 复制代码
未来什么时候,把什么任务重新交给 Agent

如果只放在对话历史里,程序一重启就可能丢掉。持久化以后,Agent 才能在未来重新醒来。

所以,这份代码里的 Memory 雏形可以总结为:

text 复制代码
.transcripts/              保存过去
.tasks/                    保存现在
.scheduled_tasks.json      保存未来

它还不是完整产品级 Memory。

它还缺少:

  • 自动抽取用户偏好
  • 用户级、项目级、任务级记忆分层
  • 检索索引
  • 权限、过期、删除管理
  • 每轮请求前自动选择相关 memory 注入上下文

但它已经说明了最核心的一点:

text 复制代码
Memory 的第一步不是让模型多背一点,而是让关键状态离开 messages。

五、Compact 和 Memory 如何协同

现在可以把两者关系说清楚了。

Context Compact 解决的是当前窗口问题:

text 复制代码
当前 messages 太长、太乱,必须压缩

Memory 解决的是状态持久化问题:

text 复制代码
有些信息不能只靠 messages 活着,必须外置保存

所以它们不是替代关系,而是协同关系:

text 复制代码
短期噪音:用 compact 折叠
当前状态:用摘要保留
长期状态:用 memory 外置
完整记录:用 transcript 追溯

如果没有 compact,当前上下文会越来越慢、越来越吵。

如果没有 memory,压缩之后就容易丢掉长期状态。

一个更接近真实系统的链路应该是:

text 复制代码
工具输出很长
    ↓
提取关键信息放回 messages
    ↓
完整日志落盘
    ↓
任务状态写入 .tasks/
    ↓
上下文过长时生成连续性摘要
    ↓
未来需要细节时,再通过日志、任务文件或 transcript 找回

这才是长会话 Agent 能持续工作的基础。

六、从教学实现到产品级 Agent

这份教学实现跑通了主线:

text 复制代码
旧工具输出可以折叠
长历史可以压成摘要
重要状态可以写到文件系统

但如果对齐 Claude Code、Codex 这类产品级 coding agent,还要继续补几层。

下面不是说它们内部一定按这个结构实现,而是从公开资料和产品能力里,可以看到产品级系统通常会把问题拆得更细。

1. 状态分层,而不是全靠 messages

教学版里,状态大致分成:

text 复制代码
messages                  当前工作上下文
.transcripts/             历史记录
.tasks/                   任务状态
.scheduled_tasks.json     定时唤醒规则

产品级系统会继续细分。

以 Codex 为例,公开文档里能看到多种控制面:

  • AGENTS.md 保存仓库级长期规则和项目约定
  • Memories 保存跨线程可复用的偏好、工作流、技术栈和已知坑点
  • skills 和 MCP 按需加载专业能力或外部数据
  • hooks 在关键生命周期事件中执行外部逻辑
  • automations 保存后台任务、运行记录和 triage 结果

Claude Code 也有类似分层,例如 CLAUDE.md、auto memory、hooks、subagents 等。

这说明产品级系统不会把长期规则、用户偏好、项目状态、工具结果都塞进 messages,而是按用途拆成不同层:

text 复制代码
规则层:项目约定、团队规范
记忆层:跨线程稳定信息
任务层:当前目标、依赖、进度
工具层:命令、文件、API 调用结果
审计层:完整 transcript / trace

这样做的好处是,每层都有自己的生命周期。规则可能长期存在,工具结果可能很快过期,trace 主要用于追溯,任务状态需要可更新。

2. 工具结果清理,不只是简单占位

教学版的 micro_compact 会把旧工具输出替换成:

text 复制代码
[Previous: used bash]

这个实现足够说明原理,但信息量太低。产品级系统通常会把"工具结果清理"做得更像一个可配置策略。

比如策略可以是:

text 复制代码
超过 50,000 tokens 时开始清理
保留最近 5 组工具调用结果
清理旧的 bash / search / API response
保留 read_file / memory / task 这类关键工具
每次至少清出 5,000 tokens

Claude 的 Context Editing 就是一个参照:它不只是摘要,还会清理旧工具结果和 thinking block,并支持配置触发阈值和保留最近多少组工具调用。

清理前,模型可能看到的是:

text 复制代码
tool: bash("pytest tests/test_checkout.py")
result: 3000 行测试日志...

tool: bash("npm run build")
result: 2000 行构建输出...

tool: read_file("src/checkout.py")
result: checkout.py 的源码...

清理后,旧的工具结果会被移出当前上下文,但工具调用轨迹和关键结果仍然保留:

text 复制代码
tool: bash("pytest tests/test_checkout.py")
result: [cleared old result: exit=1, failed=test_retry, full_log=.logs/tool_42.txt]

tool: bash("npm run build")
result: [cleared old result: exit=0, full_log=.logs/tool_43.txt]

tool: read_file("src/checkout.py")
result: checkout.py 的源码...

这里的关键不是"删掉旧结果",而是:

text 复制代码
删掉大块原文
保留恢复线索
必要时能重新取回

这和完整 compaction 不一样。完整 compaction 是"把一大段历史总结成摘要";Context Editing 更像"按类型清理上下文里的低价值块"。它的目标不是生成总结,而是在不打断会话结构的前提下,把最占空间、又可重新获取的旧工具结果清掉。

3. 完整 compaction 要有结构化摘要

教学版的 auto_compact 会让模型生成一段自然语言摘要。

产品级系统通常会要求摘要更结构化。

比如不是只写:

text 复制代码
修复了 checkout retry 的问题,接下来要跑测试。

而是拆成:

text 复制代码
goal:
  修复 checkout 重试失败

current_state:
  已修改 src/checkout.py,尚未重新跑完整测试

files:
  - tests/test_checkout.py
  - src/checkout.py

decisions:
  - 不修改 checkout 对外接口
  - 不改测试语义,只修重试计数逻辑

next_steps:
  - pytest tests/test_checkout.py
  - 通过后运行更大范围测试

trace:
  .transcripts/transcript_1720000000.jsonl

这样做有两个好处:

  • 模型下一轮更容易接着执行
  • 系统更容易检查摘要是否漏掉关键状态

Claude Code 的 compaction 公开说明里也强调,压缩要保留架构决策、未解决 bug 和实现细节,同时丢弃冗余工具输出。也就是说,产品级 compaction 的目标不是"短",而是"短到还能继续干活"。

4. Memory 不是一份文件,而是一套注入机制

教学版的 Memory 雏形是:

text 复制代码
.transcripts/
.tasks/
.scheduled_tasks.json

它们能保存状态,但还没有解决一个关键问题:

text 复制代码
下一轮模型请求时,应该把哪些 memory 放回上下文?

产品级 Memory 通常会多一层选择机制。

比如一次新任务来了,系统可能会先判断:

text 复制代码
当前仓库是什么?
用户在做什么任务?
哪些历史偏好相关?
哪些项目规则必须注入?
哪些旧任务状态需要恢复?

然后只注入相关内容,而不是把所有 memory 都塞进去。

Codex memories 就体现了这个方向:把有长期价值的信息整理成本地 memory 文件,未来按需带入上下文。AGENTS.md 则承担更稳定的项目规则角色。两者定位不同,不能混用。

所以产品级 Memory 至少要回答三个问题:

text 复制代码
写什么:哪些信息值得长期保存
存哪里:用户级、项目级、任务级分别放哪
取什么:当前请求应该注入哪些 memory

教学版主要解决了"存哪里"的雏形,后续还可以继续补"写什么"和"取什么"。

5. transcript / trace 不只是备份

教学版把完整 transcript 存到 .transcripts/,已经有了可追溯的基础。

产品级系统通常还会继续接:

  • 检索:需要时找回旧记录
  • 审计:知道 compact 前后丢了什么
  • 评估:检查摘要有没有漏掉关键状态
  • 回放:复盘 Agent 当时为什么这样决策
  • 调试:定位某次上下文清理是否过于激进

举个例子,如果一次 compact 后 Agent 忘了"不能修改 checkout 接口"这个决策,产品级系统应该能追溯:

text 复制代码
compact 前原始 transcript 是什么
summary 里有没有保留这个决策
如果没保留,是 prompt 问题还是策略问题
下一版 compact prompt 应该怎么改

这就把 transcript 从"备份文件"升级成了"可观测、可评估、可调试的 trace"。

把这些做法抽象成一句话:

text 复制代码
messages:只放当前工作所需
tool log:保存可重新取回的工具细节
memory:保存跨会话稳定信息
rules:保存团队和项目约定
trace:保留原始过程和压缩前后差异
hooks / compact:在关键时机触发整理

小结

长会话 Agent 的核心,不是把所有历史都塞进上下文,而是给信息找到合适的位置。

text 复制代码
当前要用的,留在 messages
只需要连续性的,压成摘要
需要跨会话恢复的,写入 memory
需要追溯细节的,保存到 transcript / trace

Context Compact 解决当前窗口的负担,Memory 解决长期状态的归宿。

两者配合起来,Agent 才能既不被旧日志拖慢,也不会在压缩之后丢掉关键状态。这也是它从"能跑几轮"走向"能持续工作"的关键一步。

相关推荐
h_a_o777oah1 小时前
【动态规划】区间 DP :三层循环逻辑与模板实现细节(洛谷 P1880)
c++·算法·动态规划·acm·区间dp·化环为链·石子合并
从此以后自律1 小时前
迪杰斯特拉
算法
QiLinkOS1 小时前
第三视觉理解徐玉生与他的商业活动(32)
大数据·c++·人工智能·算法·开源协议
KaMeidebaby2 小时前
卡梅德生物技术快报|实操手册:CXCL4 蛋白原核表达全套工艺,两步层析去除蛋白多聚体附完整电泳数据
人工智能·算法·机器学习·架构·spark
AIGCmagic社区2 小时前
Unlimited OCR 论文精读:R-SWA 如何实现一次性长文档解析
人工智能·算法·aigc
鹏易灵2 小时前
C++——7.类与对象,掌握封装、继承、多态.详解
开发语言·c++·算法
Mortalbreeze2 小时前
深入 Linux 线程机制(三):线程互斥——竞争条件与互斥锁的本质
linux·运维·服务器·c++·算法
Mr_Controll2 小时前
【运动控制——电子凸轮曲线设计(多项式)】
算法·自动化
Sam09272 小时前
【AI 算法精讲 15】余弦相似度:向量检索与归一化内积
人工智能·python·算法·ai