开场:第三个小时的 Agent
有一次我让编码 Agent 帮我重构一个老模块。开头很顺利:它读了代码,提了一个方案,我们讨论了几轮,它开始动手。
一个小时后,它已经改了七八个文件,测试一直是绿的,我很满意。
两个小时后,事情开始变得奇怪。我一开始明确说过"不要动对外暴露的接口签名",它改了一个。我提醒它,它道歉,改回去了。十分钟后,它又改了另一个。
三个小时后,它开始在两种写法之间来回横跳:先把一段逻辑提取成函数,测试挂了,又内联回去;过了几轮,又提取出来。我翻了一下历史,同一个文件它已经读了五遍。
我没有换模型,也没有改 prompt,任务也没有变难。唯一变化的是:对话越来越长了。
最后我做了一件当时觉得很"认输"的事:让它把目前的进度写成一份文档,然后关掉这个会话,开了一个全新的,让新会话读那份文档接着干。
新会话的第一个小时,又回到了开头那种清醒、利落的状态。
这一篇讲为什么长对话会越聊越笨,以及怎么应对。读完本篇,你应该能回答这几个问题:
- Context rot 是什么?它有研究依据吗?
- 长对话为什么会让模型变笨?
- 清空、压缩、开新会话,三种手段各适合什么场景?
- 交接文档该怎么写?写好了长什么样?
- 除了事后补救,怎么一开始就让上下文涨得慢一点?
一、上下文会"腐烂"
1.1 这不只是感觉
"聊久了模型会变笨",很多人都有这种感觉,但它是真的吗,还是只是我们累了、要求变高了?
2025 年,向量数据库公司 Chroma 发布了一份技术报告,标题就叫《Context Rot》(上下文腐烂),专门测了这件事。他们用了十几个主流模型,在一系列刻意设计得很简单的任务上,逐步增加输入的长度,观察表现的变化。
报告的几个主要发现(细节以原报告为准):
- 输入越长,表现越差,即使任务本身很简单,比如从一段文本里找出一句话、或者原样复述一段内容;
- 下降不是均匀的:有些情况下表现会在某个长度之后明显变差,而且不同模型的下降曲线差别很大;
- 干扰信息会放大这种下降:上下文里如果有和关键信息"看起来很像、但其实不是"的内容,模型更容易出错;
- 问题和答案越不像,越难:如果问题的措辞和文档里答案的措辞很接近,模型容易找到;如果需要一点"理解"才能对应上,随着长度增加,表现下降得更快。
第 10 篇讲过"大海捞针"测试,新模型在简单的单针测试上往往能拿满分。这份报告的意义在于:它说明满分只是在最简单的设定下,任务稍微贴近真实一点,长度就会成为敌人。
1.2 为什么会这样
把前面几篇的原理串起来,原因并不神秘:
第 09 篇:权重加起来等于 1"] L --> M["早期的要求被挤到中间
第 10 篇:lost in the middle"] L --> N["噪声越来越多
过时的方案、走过的弯路、旧版本的文件"] L --> C["自相矛盾的信息共存
早期的结论和后期的修改都在"] A & M & N & C --> R["越聊越笨"] style R fill:#ffcdd2,stroke:#b71c1c
前两条在原理篇讲过。后两条在 Agent 里尤其严重,值得多说两句。
噪声越来越多。 一个长任务里,消息历史会积累大量已经没用的东西:被否决的方案、失败的尝试、早期读过但已经被改过的文件内容、几千行的测试日志。它们都还在上下文里,模型每一圈都要"看"它们一遍。
矛盾的信息共存。 第 30 轮的时候,Agent 读了 order.go 的内容;第 45 轮它改了这个文件;第 60 轮,上下文里同时存在这个文件的旧版本(第 30 轮读的)和新版本的部分片段(第 45 轮的修改)。模型要搞清楚哪个是最新的,并不容易。开场那个"反复横跳",很可能就是它在两个版本之间迷失了。
1.3 Agent 里腐烂得更快
同样是"长对话",Agent 和聊天的腐烂速度完全不是一个量级。
聊天里,你说一段话、它回一段话,一轮下来新增几百个 token。聊五十轮,上下文也就几万 token,大部分还是你们之间有意义的讨论。
Agent 里,每一圈新增的是工具结果:一个文件几千 token,一次测试输出几千 token,一次 grep 几百到几千 token。再加上第 12 篇说的,推理模型每一圈的思考也会留在历史里。转三十圈,上下文就可能涨到十几万 token。而这十几万 token 里,真正还"有用"的部分,可能只有最近几圈的内容和最开始的那条任务描述。
更麻烦的是,Agent 的历史里,噪声和有用信息长得一模一样:都是文件内容、都是命令输出。第 30 轮读的 order.go 和第 60 轮读的 order.go,格式完全相同,只是内容差了几行。模型要从一大堆长得差不多的东西里,分辨出哪个是最新的、哪个是有用的,这正好是 Context Rot 报告里说的"干扰信息很像真信息"的情况。
所以开场那个故事里,三个小时并不算特别长,问题在于这三个小时里读进来的东西太多了。
1.4 为什么模型自己意识不到
还有一个让人头疼的问题:模型自己意识不到自己变笨了。 它不会说"上下文太长了,我有点看不清,建议你开个新会话"。它会用同样自信的语气,给出一个越来越差的回答。
所以,判断"该换会话了"这件事,目前主要得靠人,或者靠 harness 里的规则(比如第 28 篇的 token 预算)。harness 的规则很笨,只看 token 数,不看质量;人能看出质量的变化,但容易拖延,总想着"再让它试一次"。两者结合着用:预算兜底,人来提前判断。
二、三种应对手段
2.1 一张对比表
常见的编码 Agent 都提供类似的几种手段,各家的命令名略有不同。第五部分我们自己写的 mini-agent 里也有:
| 手段 | mini-agent 里的命令 | 保留了什么 | 适合 |
|---|---|---|---|
| 清空 | /reset | 什么都不留 | 换了一个不相关的任务 |
| 压缩 | /compact | 一份自动生成的摘要 | 同一个任务,还想接着聊 |
| 交接 | 写 progress.md → 开新会话 | 你明确要求写下来的东西 | 任务很长、要跨天、要换人 |
/reset"] S -->|"同一个任务,
想接着聊"| B["压缩
/compact"] S -->|"长任务、跨天、
想要可控"| C["交接
progress.md + 新会话"] style A fill:#e3f2fd,stroke:#1565c0 style B fill:#fff9c4 style C fill:#e8f5e9,stroke:#2e7d32
2.2 清空:最简单,也最常被忘记
一个任务做完了,开始下一个不相关的任务,直接清空。上一个任务的所有内容,对下一个任务来说都是噪声。
听起来理所当然,但很多人习惯在同一个会话里一直聊下去:"帮我修这个 bug",修完了,"再帮我写个文档",写完了,"再看看那个性能问题"……一个会话里塞了三四件不相关的事,后面的每件事都在被前面的噪声拖累。
一个任务,一个会话。 这是最便宜的上下文工程,一行代码都不用写,只需要改掉一个习惯。
2.3 压缩:让模型把历史写成摘要
如果是同一个任务,只是聊得太长了,可以让模型把到目前为止的对话总结成一份摘要,然后用这份摘要替换整段历史。
mini-agent 的压缩写在 agent/compact.go 里,第 28 篇会详细讲。它的核心是一段 prompt,要求模型按固定的结构写摘要:
## 任务目标
用户最初要求做什么,验收标准是什么。
## 已完成
已经做了哪些修改(文件路径 + 一句话),哪些已经验证过。
## 关键发现
读代码、跑命令时发现的重要事实:项目结构、相关文件和函数、报错原文、踩过的坑。
## 进行中 / 下一步
做到一半的事情,以及接下来第一步该做什么(具体到文件和命令)。
## 用户偏好
用户明确提出过的要求或拒绝过的做法。压缩完,历史里只剩下这一条摘要消息,上下文一下子清爽了。mini-agent 还会在上下文超过预算时自动压缩。
压缩的问题是:你不知道它丢了什么。 摘要是模型自己写的,它觉得不重要的东西就不写了。而它觉得不重要的,可能恰恰是你最在意的那条约束。开场那个"不要动接口签名",如果在压缩时被漏掉了,压缩之后它就彻底忘了。
2.4 交接:自己决定留下什么
第三种手段是本篇的重点:交接文档模式。
会话快结束、或者上下文快满的时候,让 Agent 把进度写成一份结构化的文档,存到项目里,比如根目录的 progress.md。然后关掉这个会话,开一个全新的,第一句话就是:"读 progress.md,从下一步开始继续。"
和压缩相比,它有几个好处:
- 你能看到它写了什么:文档是一个文件,你可以读、可以改、可以补充。它漏了"不要动接口签名"?你自己加上;
- 它是持久的:存在磁盘上,明天接着用,换一台电脑接着用,甚至交给同事接着用;
- 新会话是真正干净的:没有任何残留的噪声,只有你确认过的信息;
- 它逼着 Agent 整理思路:写交接文档的过程,本身就是一次复盘。
代价是多了一个手动的步骤,而且要写好这份文档,需要一个好的模板。
2.5 比治疗更好的,是预防
三种手段都是"上下文已经长了"之后的补救。更好的办法,是一开始就别让它涨得那么快。这正是后面几篇要做的事:
- 工具输出先截断:一次测试输出几千行,真正有用的只有失败的那几行。在进入上下文之前就截掉,它就不会在之后的每一圈里被反复带着(第 28 篇);
- 只读需要的部分:几千行的大文件,用
offset和limit只读相关的那一段,而不是整个读进来(第 25 篇); - 调研交给子 Agent:需要读二十个文件才能回答的问题,让子 Agent 去读,它的上下文用完就扔,主 Agent 只拿到一段结论(第 30 篇);
- 用搜索代替通读:先 grep 找到相关的位置,再精确地读那几行(第 18 篇)。
截断、分段读、子 Agent、先搜后读"] --> T["治疗:变长了再处理
清空、压缩、交接"] style P fill:#e8f5e9,stroke:#2e7d32 style T fill:#fff9c4
预防做得好,压缩和交接就用得少;但它们不能互相替代。再好的预防,一个真正的长任务也总有变长的一天。
三、交接文档怎么写
3.1 一份模板
配套仓库的 examples/ch16-handoff/progress.template.md 是一份交接文档的模板:
# 进度交接:<任务名>
> 写给下一个会话(也可能是明天的自己)。读完这一页就能接着干,不需要翻聊天记录。
## 目标
<!-- 一两句话:要做成什么样算完成?验收标准是什么? -->
## 当前状态
<!-- 做到哪一步了。哪些已经完成并验证过,哪些写了一半。 -->
- [x]
- [ ]
## 关键决定与原因
<!-- 只记"为什么这样做",代码里能看出来"做了什么"。被否掉的方案也记一笔,免得下次又走回去。 -->
-
## 踩过的坑
<!-- 报错原文、绕过的方法。 -->
-
## 下一步
<!-- 下一个会话第一件事做什么。越具体越好:哪个文件、哪个函数、跑什么命令。 -->
1.
## 验证方式
```bash
# 怎么确认没改坏,比如:
go test ./...
```3.2 每一节为什么这样设计
目标:一两句话,加上验收标准。新会话最容易犯的错,是对"做完"的理解和你不一样。
当前状态:用勾选框,区分"已完成并验证过"和"写了一半"。"写了一半"的部分特别重要,新会话要知道哪些代码是不完整的,不能当成可用的。
关键决定与原因:这一节最有价值,也最容易被写成流水账。要点是只记"为什么":代码里能看出来"做了什么",但看不出来"为什么这么做""考虑过哪些别的方案、为什么没选"。特别是被否掉的方案:不记下来,新会话很可能会兴冲冲地提出一个你们三个小时前就否掉的方案。
踩过的坑:报错的原文,以及怎么绕过去的。这些信息在代码里完全看不出来,丢了就要重新踩一遍。
下一步:越具体越好。"继续重构"是一句废话;"把 order.go 里的 calcDiscount 拆成两个函数,然后跑 go test ./order/..."才是能直接执行的下一步。
验证方式:新会话怎么确认自己没把东西改坏。一条命令胜过一段描述。
3.3 怎么用
会话快结束时,对 Agent 说:
把examples/ch16-handoff/progress.template.md复制成项目根目录的progress.md并填好。只写下一个会话接手需要的信息,不要复述聊天过程。
它写完之后,你一定要自己读一遍。这是交接模式和自动压缩最大的区别,也是它的价值所在。漏了什么就补上,写错了就改掉。
然后开新会话:
读 progress.md,从"下一步"开始继续。
只有确认过的信息 A2->>U: 好的,下一步是……
3.4 一份填好的样子
光看模板有点抽象。拿开场那个重构任务举例,如果在第二个小时、它第一次改错接口签名的时候就交接,一份合格的 progress.md 大概是这样的:
# 进度交接:拆分 billing 模块
## 目标
把 billing/service.go(1800 行)按职责拆成 invoice、refund、report 三个文件。
验收:go test ./billing/... 全部通过;对外暴露的函数签名一个都不变。
## 当前状态
- [x] invoice.go 拆出来了,测试通过
- [x] refund.go 拆出来了,测试通过
- [ ] report.go 只搬了一半:GenerateMonthly 已搬,ExportCSV 还在 service.go 里
## 关键决定与原因
- 公共的辅助函数放在 helpers.go,没有做成单独的包:做成包要改十几处 import,超出本次范围
- 否掉的方案:把 Refund 改成接收结构体参数。原因:它是对外接口,签名不能动
## 踩过的坑
- 直接搬 ExportCSV 会触发循环依赖(report → invoice → report),要先把 formatAmount 挪到 helpers.go
## 下一步
1. 把 formatAmount 从 invoice.go 挪到 helpers.go
2. 把 ExportCSV 搬进 report.go
3. 跑 go test ./billing/...
## 验证方式
go test ./billing/... && git diff --stat注意"关键决定与原因"里那条被否掉的方案:它就是"不要动接口签名"这条约束的具体化。写成"某个方案为什么被否掉",比单独写一句"不要改签名"更难被忽略,因为它同时告诉了新会话这条约束是在哪里差点被打破的。
progress.md 做完了任务就可以删掉,或者把其中值得长期保留的部分(比如"踩过的坑")挪到项目记忆文件里,这是下一篇的主题。
四、什么时候该换会话
一些实用的信号:
- 它开始忘记你明确说过的约束;
- 它开始重复读同一个文件,或者重复提出已经被否决的方案;
- 它在两种做法之间来回横跳;
- 回答开始变得敷衍,或者越来越长、越来越绕;
- 上下文用量已经很高了:mini-agent 里可以用
/usage看当前的上下文有多大,其他编码 Agent 一般也有查看上下文用量的命令(截至写作时,具体名称以各产品的文档为准)。
开新会话"] style D fill:#fff9c4 style H fill:#e8f5e9,stroke:#2e7d32
还有一个更主动的做法:在开始一个大任务之前,就把它拆成几个阶段,每个阶段一个会话,阶段之间用交接文档衔接。与其等它变笨了再补救,不如一开始就别让一个会话拖得太长。
还是拿开场那个重构举例,如果重来一次,我会这样拆:
- 第一个会话:只调研,不动手。 让它读完
billing/service.go,按职责列出函数清单,标出哪些是对外接口、哪些之间有依赖,写进progress.md的"目标"和"关键决定"。这一步上下文会涨得很快(要读一个一千八百行的文件),但它的产出是一份几十行的文档; - 第二个会话:拆 invoice 和 refund。 读
progress.md开工,做完更新"当前状态"; - 第三个会话:拆 report,处理循环依赖。 同样从
progress.md开始。
每个会话都只做一件明确的事,每个会话开始时上下文都是干净的。而且调研的那一大堆文件内容,从来没有进入过真正动手改代码的会话。第 32 篇讲的"计划模式",本质上就是把第一步做成了产品的一个功能。
五、动手:观察上下文的变化
实验 1:在 mini-agent 里看上下文怎么长大
mini-agent 要等第五部分写完才算完整,不过仓库里已经有现成的。要让它真正干活,需要接真实模型:先 export DEEPSEEK_API_KEY=sk-...。在线 Playground 演示不了。export DEEPSEEK_API_KEY=sk-...
go run ./cmd/mini-agent找一个稍微复杂的任务,比如"读一下 agent 目录下的代码,给我讲讲 Agent 的主循环是怎么工作的"。每隔几轮,输入 /usage,看看它报告的"当前上下文约多少 token"是怎么增长的。
然后试试 /compact,看看压缩前后上下文的大小,再问它一个关于你一开始提的要求的问题,看看它还记不记得。
实验 2:用交接文档换一次会话
在同一个任务做到一半时:
- 让它按
examples/ch16-handoff/progress.template.md写一份progress.md; - 自己读一遍,看看它漏了什么、写错了什么,补上;
/exit退出,重新启动 mini-agent(或者/reset),第一句话说"读 progress.md,从下一步开始继续";- 观察新会话接手得顺不顺利。
实验 3:对比压缩摘要和交接文档
同一个任务,分别试一次 /compact 和一次交接,然后对比:压缩后的摘要和 progress.md,各自记下了你一开始提的哪些约束?漏掉了哪些?哪一份更像"能直接接着干"的样子?
压缩用的那段 prompt 在 agent/compact.go 里,你会发现它和交接模板的结构很像。差别在于:摘要是写给模型自己的,没人审;交接文档是写给"下一个会话"的,你会审。这一点差别,往往就决定了那条关键约束能不能活下来。
实验 4:在你常用的 Agent 里做同样的事
大纲里的这个实验原本是"在 Claude Code 里用 /context 观察上下文的构成"。如果你手边有 Claude Code 或者其他编码 Agent,找到它查看上下文用量的命令,在一个长任务里每隔一段时间看一次:上下文由哪几部分组成?哪一部分涨得最快?对照第 14 篇的五个组成部分,你多半会发现,涨得最快的是工具输出。
六、踩坑记录:长对话的六个坑
坑 1:一个会话里塞好几个不相关的任务。
修完 bug 接着写文档,写完文档接着查性能问题。每个新任务都背着前面所有任务的包袱。一个任务一个会话,做完就清空。
坑 2:完全依赖自动压缩。
自动压缩很方便,但你不知道它丢了什么。关键的约束、被否掉的方案,最容易在压缩时丢失。重要的长任务,用交接文档,自己把关。
坑 3:交接文档写成了聊天记录的复述。
"首先我们讨论了 A 方案,然后用户说……然后我尝试了……"。新会话不需要知道过程,只需要知道结论、状态和下一步。模板里的每一节,都是为了逼着它写结论而不是写过程。
坑 4:不读 Agent 写的交接文档就直接用。
这一步省了,交接模式就退化成了"一个写到文件里的自动压缩"。你读的那几分钟,是整个模式里性价比最高的几分钟。
坑 5:等它彻底糊涂了才换会话。
它已经在反复横跳了,这时候让它写交接文档,写出来的东西也是糊涂的。在它还清醒的时候就交接,比如完成一个阶段性目标之后。
坑 6:交接文档写完就不更新。
新会话接手之后又干了一个小时,做完了两步,又踩了一个新坑,但 progress.md 还是一小时前的样子。下一次交接时,它要么被当成最新状态误导新会话,要么被整个重写、丢掉了上一次你亲手补上的约束。交接文档是活的:每完成一个阶段,让 Agent 顺手更新一下"当前状态"和"踩过的坑",再开始下一步。
一句话总结
上下文会腐烂:输入越长,注意力越分散,噪声和矛盾越多,模型越笨,而且它自己意识不到;应对的办法是一个任务一个会话、长了就压缩,而最可控的是交接文档:让它写,你来审,新会话从干净的状态接手。
越来越笨"] -->|"写 progress.md"| F["交接文档
你审过的"] F -->|"读 progress.md"| B["新会话
又清醒了"] style A fill:#ffcdd2,stroke:#b71c1c style F fill:#fff9c4 style B fill:#e8f5e9,stroke:#2e7d32
评论
还没有评论,来说点什么吧。