为纪念 35 周年:《Terminator 2: Judgment Day》将在全球影院重映
2026-07-13
2026-07-17 0
很多人刚开始做 Agent,会下意识追求一件事:

尽量保留完整历史
这个想法很自然。毕竟用户需求、工具输出、文件内容、测试日志、错误堆栈,看起来都可能对后续有用。
但真实系统里,Agent 不是只回答一轮问题。它会反复读文件、跑命令、修错误、再测试。messages 会不断变长,里面既有关键状态,也有大量中间噪音。
如果不处理,会出现三个问题:
所以长会话 Agent 真正需要的不是“无限记忆”,而是两种能力:
Context Compact:让当前上下文保持可用Memory:让重要状态离开 messages,变成可恢复的外部状态
messages 是模型下一轮真正能看到的工作上下文。
它应该尽量保留三类信息:
但它不适合长期背着所有原始历史。
比如一次 coding agent 任务里,模型可能运行过:
lsrg "retry"cat tests/test_checkout.pypytest tests/test_checkout.pynpm run build
其中有些结果很重要,比如当前失败用例和刚读过的源码;有些结果只在当时有用,比如旧的 ls 输出或已经过时的测试日志。
如果全部原样保留,模型每一轮都要重新“路过”这些内容。它不仅慢,还可能把旧信息当成当前事实。
所以 Context Compact 的必要性在于:
当前上下文窗口有限,必须把低价值历史折叠掉,把高价值状态留下来。
它不是删除记忆,而是重写工作记忆。
这份教学实现把 compact 分成两层:
micro_compact:每轮请求前做轻量清理auto_compact:上下文超过阈值后生成连续性摘要
每次请求模型前,agent_loop() 会先调用:
micro_compact(messages)
它做三件事:
tool 消息KEEP_RECENT 条工具结果比如一段很长的 shell 输出,可能会被替换成:
[Previous: used bash]
这样做的好处是直接:模型不用继续背完整日志。
但这个占位文本信息量很低。它只能说明“之前用过 bash”,不能说明执行了什么命令、成功还是失败、关键错误是什么。
所以这只是教学版的减负占位,不是理想的生产级做法。
更好的方式应该是:
当前 messages 里放短摘要完整 stdout / stderr 单独落盘摘要里保留命令、退出码、关键错误、日志路径需要细节时再按日志路径取回
比如旧日志可以压成:
[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 输出更有后续价值。模型刚读过的源码、配置、文档,可能直接影响下一步编辑;如果太早折叠,模型就可能丢掉函数签名、类名、参数细节。
所以教学版的策略可以概括为:
旧 shell 输出优先压缩旧 read_file 内容谨慎保留
轻量压缩只能处理局部噪音。如果整个 messages 还是太长,就要触发完整压缩。
代码里的判断是:
estimate_tokens(messages) > THRESHOLD
意思是:
当前会话历史估算出来的 token 数,超过了预设安全线
这里:
messages 是当前会话历史,包括用户输入、模型回复、工具结果。THRESHOLD 是压缩阈值,表示上下文长到什么程度以后必须整理。estimate_tokens(messages) 是粗略估算,不追求精确计费,只判断“是不是大概太长了”。超过阈值后,系统调用:
auto_compact(messages)
它的流程是:
保存完整 transcript↓让模型生成摘要↓用摘要替换旧 messages
注意,完整压缩不是直接丢掉历史,而是先把原始记录写到 .transcripts/,再用摘要替换当前上下文。
看一个简化例子。
压缩前,messages 可能是:
[{"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.pyn...nFAILED test_retry ..."},{"role": "assistant","content": "失败点在 retry_count 没有递增,我会修改 checkout.py。"},{"role": "tool","name": "edit_file","content": "已修改 src/checkout.py"}]
触发 auto_compact(messages) 后,系统先保存完整历史:
.transcripts/transcript_1720000000.jsonl
然后把当前 messages 替换成一条连续性摘要:
[{"role": "user","content": "[Conversation compressed. Transcript: .transcripts/transcript_1720000000.jsonl]nn已完成:读取了 tests/test_checkout.py,运行了 pytest tests/test_checkout.py,并确认失败用例是 test_retry。nn当前状态:失败原因初步判断为 src/checkout.py 中 retry_count 没有在重试路径正确递增。已经修改了 src/checkout.py,但还没有重新跑完整测试。nn关键文件:tests/test_checkout.py、src/checkout.py。nn关键决策:保留现有 checkout 接口,不改测试语义,只修复重试计数逻辑。nn下一步:重新运行 pytest tests/test_checkout.py;如果通过,再运行更大范围测试。"}]
这段摘要保留的是“继续工作所需的信息”:
这就是连续性摘要的意义:它不是普通总结,而是让 Agent 在压缩后还能接着干活。
另外,这份代码还暴露了一个 compact 工具。模型如果觉得上下文已经很乱,可以主动调用它,并传入 focus,告诉系统摘要应该重点保留什么。
比如修 bug 时,focus 可以强调:
保留失败命令、错误栈、已修改文件和下一步验证命令
这说明 compact 的目标不是单纯变短,而是围绕当前任务保留连续性。
Compact 解决的是“当前上下文太长”。
但它不能解决另一个问题:
有些状态不应该只存在于 messages 里
原因很简单:
messages 会被压缩messages 会被上下文窗口挤掉所以需要 Memory。
这里的 Memory 不要理解成“模型脑子里记住了什么”。在这份教学代码里,它更具体:
把需要长期保留的状态,从 messages 移到文件系统
也就是说,Memory 的必要性在于:
不是所有重要信息都应该留在上下文里;有些信息应该变成可恢复、可查询、可更新的外部状态。
这套 harness 里没有一个单独的 memory.py。Memory 是分散在几个模块里的外部状态机制。
可以按时间维度理解:
过去发生过什么:.transcripts/现在还有什么任务:.tasks/未来什么时候再做:.scheduled_tasks.json
.transcripts/:保存过去的完整记录auto_compact(messages) 生成摘要前,会先把完整历史写到:
.transcripts/transcript_1720000000.jsonl
这解决的是:
摘要可以变短,但原始过程不能完全消失
压缩后的摘要可能只写:
pytest tests/test_checkout.py 失败,失败用例是 test_retry
但如果后面需要追溯完整 stdout、stderr、工具调用顺序,就可以回到 transcript。
所以 .transcripts/ 不是模型每轮都看的上下文,而是系统保留的可追溯记录。
.tasks/:保存当前任务状态第 06 节里的 TaskManager 会把任务写成 JSON:
.tasks/task_1.json.tasks/task_2.json
一个任务大致是:
{"id": 2,"subject": "补 checkout retry 测试","description": "覆盖失败重试和成功重试两种路径","status": "pending","blockedBy": [1],"owner": ""}
这类信息不适合只放在对话里。因为任务状态需要跨会话存在,还要能维护依赖关系。
写进 .tasks/ 后,它就不再依赖当前 messages:
.scheduled_tasks.json:保存未来唤醒规则第 08 节里的 CronScheduler 会把 durable cron job 写进:
.scheduled_tasks.json
比如:
[{"id": "cron_102314","cron": "0 9 * * 1","prompt": "检查项目状态并汇总未完成任务","recurring": true,"durable": true}]
这类状态描述的是:
未来什么时候,把什么任务重新交给 Agent
如果只放在对话历史里,程序一重启就可能丢掉。持久化以后,Agent 才能在未来重新醒来。
所以,这份代码里的 Memory 雏形可以总结为:
.transcripts/保存过去.tasks/保存现在.scheduled_tasks.json保存未来
它还不是完整产品级 Memory。
它还缺少:
但它已经说明了最核心的一点:
Memory 的第一步不是让模型多背一点,而是让关键状态离开 messages。
现在可以把两者关系说清楚了。
Context Compact 解决的是当前窗口问题:
当前 messages 太长、太乱,必须压缩
Memory 解决的是状态持久化问题:
有些信息不能只靠 messages 活着,必须外置保存
所以它们不是替代关系,而是协同关系:
短期噪音:用 compact 折叠当前状态:用摘要保留长期状态:用 memory 外置完整记录:用 transcript 追溯
如果没有 compact,当前上下文会越来越慢、越来越吵。
如果没有 memory,压缩之后就容易丢掉长期状态。
一个更接近真实系统的链路应该是:
工具输出很长↓提取关键信息放回 messages↓完整日志落盘↓任务状态写入 .tasks/↓上下文过长时生成连续性摘要↓未来需要细节时,再通过日志、任务文件或 transcript 找回
这才是长会话 Agent 能持续工作的基础。
这份教学实现跑通了主线:
旧工具输出可以折叠长历史可以压成摘要重要状态可以写到文件系统
但如果对齐 Claude Code、Codex 这类产品级 coding agent,还要继续补几层。
下面不是说它们内部一定按这个结构实现,而是从公开资料和产品能力里,可以看到产品级系统通常会把问题拆得更细。
教学版里,状态大致分成:
messages当前工作上下文.transcripts/ 历史记录.tasks/ 任务状态.scheduled_tasks.json 定时唤醒规则
产品级系统会继续细分。
以 Codex 为例,公开文档里能看到多种控制面:
AGENTS.md 保存仓库级长期规则和项目约定Memories 保存跨线程可复用的偏好、工作流、技术栈和已知坑点Claude Code 也有类似分层,例如 CLAUDE.md、auto memory、hooks、subagents 等。
这说明产品级系统不会把长期规则、用户偏好、项目状态、工具结果都塞进 messages,而是按用途拆成不同层:
规则层:项目约定、团队规范记忆层:跨线程稳定信息任务层:当前目标、依赖、进度工具层:命令、文件、API 调用结果审计层:完整 transcript / trace
这样做的好处是,每层都有自己的生命周期。规则可能长期存在,工具结果可能很快过期,trace 主要用于追溯,任务状态需要可更新。
教学版的 micro_compact 会把旧工具输出替换成:
[Previous: used bash]
这个实现足够说明原理,但信息量太低。产品级系统通常会把“工具结果清理”做得更像一个可配置策略。
比如策略可以是:
超过 50,000 tokens 时开始清理保留最近 5 组工具调用结果清理旧的 bash / search / API response保留 read_file / memory / task 这类关键工具每次至少清出 5,000 tokens
Claude 的 Context Editing 就是一个参照:它不只是摘要,还会清理旧工具结果和 thinking block,并支持配置触发阈值和保留最近多少组工具调用。
清理前,模型可能看到的是:
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 的源码...
清理后,旧的工具结果会被移出当前上下文,但工具调用轨迹和关键结果仍然保留:
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 的源码...
这里的关键不是“删掉旧结果”,而是:
删掉大块原文保留恢复线索必要时能重新取回
这和完整 compaction 不一样。完整 compaction 是“把一大段历史总结成摘要”;Context Editing 更像“按类型清理上下文里的低价值块”。它的目标不是生成总结,而是在不打断会话结构的前提下,把最占空间、又可重新获取的旧工具结果清掉。
教学版的 auto_compact 会让模型生成一段自然语言摘要。
产品级系统通常会要求摘要更结构化。
比如不是只写:
修复了 checkout retry 的问题,接下来要跑测试。
而是拆成:
goal:修复 checkout 重试失败current_state:已修改 src/checkout.py,尚未重新跑完整测试files:- tests/test_checkout.py- src/checkout.pydecisions:- 不修改 checkout 对外接口- 不改测试语义,只修重试计数逻辑next_steps:- pytest tests/test_checkout.py- 通过后运行更大范围测试trace:.transcripts/transcript_1720000000.jsonl
这样做有两个好处:
Claude Code 的 compaction 公开说明里也强调,压缩要保留架构决策、未解决 bug 和实现细节,同时丢弃冗余工具输出。也就是说,产品级 compaction 的目标不是“短”,而是“短到还能继续干活”。
教学版的 Memory 雏形是:
.transcripts/.tasks/.scheduled_tasks.json
它们能保存状态,但还没有解决一个关键问题:
下一轮模型请求时,应该把哪些 memory 放回上下文?
产品级 Memory 通常会多一层选择机制。
比如一次新任务来了,系统可能会先判断:
当前仓库是什么?用户在做什么任务?哪些历史偏好相关?哪些项目规则必须注入?哪些旧任务状态需要恢复?
然后只注入相关内容,而不是把所有 memory 都塞进去。
Codex memories 就体现了这个方向:把有长期价值的信息整理成本地 memory 文件,未来按需带入上下文。AGENTS.md 则承担更稳定的项目规则角色。两者定位不同,不能混用。
所以产品级 Memory 至少要回答三个问题:
写什么:哪些信息值得长期保存存哪里:用户级、项目级、任务级分别放哪取什么:当前请求应该注入哪些 memory
教学版主要解决了“存哪里”的雏形,后续还可以继续补“写什么”和“取什么”。
教学版把完整 transcript 存到 .transcripts/,已经有了可追溯的基础。
产品级系统通常还会继续接:
举个例子,如果一次 compact 后 Agent 忘了“不能修改 checkout 接口”这个决策,产品级系统应该能追溯:
compact 前原始 transcript 是什么summary 里有没有保留这个决策如果没保留,是 prompt 问题还是策略问题下一版 compact prompt 应该怎么改
这就把 transcript 从“备份文件”升级成了“可观测、可评估、可调试的 trace”。
把这些做法抽象成一句话:
messages:只放当前工作所需tool log:保存可重新取回的工具细节memory:保存跨会话稳定信息rules:保存团队和项目约定trace:保留原始过程和压缩前后差异hooks / compact:在关键时机触发整理
长会话 Agent 的核心,不是把所有历史都塞进上下文,而是给信息找到合适的位置。
当前要用的,留在 messages只需要连续性的,压成摘要需要跨会话恢复的,写入 memory需要追溯细节的,保存到 transcript / trace
Context Compact 解决当前窗口的负担,Memory 解决长期状态的归宿。
两者配合起来,Agent 才能既不被旧日志拖慢,也不会在压缩之后丢掉关键状态。这也是它从“能跑几轮”走向“能持续工作”的关键一步。