Context Rot,为什么长对话越聊越笨

outlier 15 分钟 5897 字 0 0
系列教程Agent从入门到放弃 第 17 / 19 章
  1. 01Agent 从入门到放弃:教程大纲
  2. 02①大模型、Chatbot、Agent,一次把名字理清楚
  3. 03②Agent 的最小定义,一个 while 循环
  4. 04③Agent 简史,从 ReAct 到 Claude Code
  5. 05④Workflow 还是 Agent?大部分场景你不需要 Agent
  6. 06Agent 能力地图,为什么编码 Agent 最先成熟
  7. 07Token,模型眼里的世界
  8. 08Next-token prediction,一个字一个字地猜
  9. 09采样,temperature、top-p 与"同题不同答"
  10. 10Transformer 与 Attention,不推公式的直觉版
  11. 11Context Window,模型的内存条
  12. 12训练三阶段,能力是怎么被"装"进去的
  13. 13推理模型,让模型先想再答
  14. 14用原理解释怪行为,原理篇结业考
  15. 15从 Prompt 工程到 Context 工程
  16. 16System Prompt 写作,角色、约束与示例
  17. 17Context Rot,为什么长对话越聊越笨
  18. 18项目记忆,CLAUDE.md 与 AGENTS.md
  19. 19检索,RAG 还是 Agentic Search
查看教程目录

开场:第三个小时的 Agent

有一次我让编码 Agent 帮我重构一个老模块。开头很顺利:它读了代码,提了一个方案,我们讨论了几轮,它开始动手。

一个小时后,它已经改了七八个文件,测试一直是绿的,我很满意。

两个小时后,事情开始变得奇怪。我一开始明确说过"不要动对外暴露的接口签名",它改了一个。我提醒它,它道歉,改回去了。十分钟后,它又改了另一个。

三个小时后,它开始在两种写法之间来回横跳:先把一段逻辑提取成函数,测试挂了,又内联回去;过了几轮,又提取出来。我翻了一下历史,同一个文件它已经读了五遍。

我没有换模型,也没有改 prompt,任务也没有变难。唯一变化的是:对话越来越长了。

最后我做了一件当时觉得很"认输"的事:让它把目前的进度写成一份文档,然后关掉这个会话,开了一个全新的,让新会话读那份文档接着干。

新会话的第一个小时,又回到了开头那种清醒、利落的状态。

这一篇讲为什么长对话会越聊越笨,以及怎么应对。读完本篇,你应该能回答这几个问题:

  • Context rot 是什么?它有研究依据吗?
  • 长对话为什么会让模型变笨?
  • 清空、压缩、开新会话,三种手段各适合什么场景?
  • 交接文档该怎么写?写好了长什么样?
  • 除了事后补救,怎么一开始就让上下文涨得慢一点?

一、上下文会"腐烂"

1.1 这不只是感觉

"聊久了模型会变笨",很多人都有这种感觉,但它是真的吗,还是只是我们累了、要求变高了?

2025 年,向量数据库公司 Chroma 发布了一份技术报告,标题就叫《Context Rot》(上下文腐烂),专门测了这件事。他们用了十几个主流模型,在一系列刻意设计得很简单的任务上,逐步增加输入的长度,观察表现的变化。

报告的几个主要发现(细节以原报告为准):

  • 输入越长,表现越差,即使任务本身很简单,比如从一段文本里找出一句话、或者原样复述一段内容;
  • 下降不是均匀的:有些情况下表现会在某个长度之后明显变差,而且不同模型的下降曲线差别很大;
  • 干扰信息会放大这种下降:上下文里如果有和关键信息"看起来很像、但其实不是"的内容,模型更容易出错;
  • 问题和答案越不像,越难:如果问题的措辞和文档里答案的措辞很接近,模型容易找到;如果需要一点"理解"才能对应上,随着长度增加,表现下降得更快。

第 10 篇讲过"大海捞针"测试,新模型在简单的单针测试上往往能拿满分。这份报告的意义在于:它说明满分只是在最简单的设定下,任务稍微贴近真实一点,长度就会成为敌人。

1.2 为什么会这样

把前面几篇的原理串起来,原因并不神秘:

flowchart TB L["对话越来越长"] --> A["注意力被稀释
第 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 → 开新会话你明确要求写下来的东西任务很长、要跨天、要换人
flowchart LR S{"上下文太长了"} -->|"换任务了"| A["清空
/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 篇)。
flowchart LR P["预防:别让它变长
截断、分段读、子 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,从"下一步"开始继续。
sequenceDiagram participant U as 你 participant A1 as 旧会话 participant F as progress.md participant A2 as 新会话 U->>A1: 按模板写交接文档 A1->>F: 写入 U->>F: 读一遍,补上漏掉的约束 U->>A1: 关掉 U->>A2: 读 progress.md,从下一步开始 A2->>F: 读取 Note over A2: 干净的上下文
只有确认过的信息 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 一般也有查看上下文用量的命令(截至写作时,具体名称以各产品的文档为准)。
flowchart LR S1["忘记约束"] --> D{"该换会话了"} S2["重复读同一个文件"] --> D S3["来回横跳"] --> D S4["上下文用量很高"] --> D D --> H["写交接文档
开新会话"] style D fill:#fff9c4 style H fill:#e8f5e9,stroke:#2e7d32

还有一个更主动的做法:在开始一个大任务之前,就把它拆成几个阶段,每个阶段一个会话,阶段之间用交接文档衔接。与其等它变笨了再补救,不如一开始就别让一个会话拖得太长。

还是拿开场那个重构举例,如果重来一次,我会这样拆:

  1. 第一个会话:只调研,不动手。 让它读完 billing/service.go,按职责列出函数清单,标出哪些是对外接口、哪些之间有依赖,写进 progress.md 的"目标"和"关键决定"。这一步上下文会涨得很快(要读一个一千八百行的文件),但它的产出是一份几十行的文档;
  2. 第二个会话:拆 invoice 和 refund。 读 progress.md 开工,做完更新"当前状态";
  3. 第三个会话:拆 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:用交接文档换一次会话

在同一个任务做到一半时:

  1. 让它按 examples/ch16-handoff/progress.template.md 写一份 progress.md;
  2. 自己读一遍,看看它漏了什么、写错了什么,补上;
  3. /exit 退出,重新启动 mini-agent(或者 /reset),第一句话说"读 progress.md,从下一步开始继续";
  4. 观察新会话接手得顺不顺利。

实验 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 顺手更新一下"当前状态"和"踩过的坑",再开始下一步。

一句话总结

上下文会腐烂:输入越长,注意力越分散,噪声和矛盾越多,模型越笨,而且它自己意识不到;应对的办法是一个任务一个会话、长了就压缩,而最可控的是交接文档:让它写,你来审,新会话从干净的状态接手。

flowchart LR A["长会话
越来越笨"] -->|"写 progress.md"| F["交接文档
你审过的"] F -->|"读 progress.md"| B["新会话
又清醒了"] style A fill:#ffcdd2,stroke:#b71c1c style F fill:#fff9c4 style B fill:#e8f5e9,stroke:#2e7d32

评论

还没有评论,来说点什么吧。

发表评论

Ctrl + Enter 快速提交
Enter 搜索Esc 关闭