从零实现 Agent Harness 系列 · 第 05 篇
这一篇讲
Context Compact和Memory。它们要解决的不是"怎么让模型记住一切",而是: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 才能既不被旧日志拖慢,也不会在压缩之后丢掉关键状态。这也是它从"能跑几轮"走向"能持续工作"的关键一步。