最近跟一个团队复盘AI编码代理的使用情况,发现大家最头秃的问题不是模型不够聪明,而是代理聊着聊着就“失忆”。明明十分钟前刚确认过技术选型,你问它“刚才那个方案为什么被否掉”,它开始一本正经地重新推导,甚至给出相反的结论。这类问题根子不在模型,而在上下文——准确说是上下文工程的姿势不对。这篇文章想把两个我已经落地验证过的方向掰开揉碎讲清楚:ChatMemory的滑动窗口机制,以及MCP的Context-mode上下文优化。内容主要面向已经在用Claude Code、Cursor这类AI编码代理,但总觉得“代理偶尔会犯傻”的开发者。
先说一个残酷的事实:AI编码代理的上下文窗口看着很大,200K token级别,但真正能留给“用户对话”的远比你想象得少。工具调用结果、读文件返回的代码、MCP server注册的工具描述,全在抢这一亩三分地。谁的优先级设置得不合理,谁就先被挤出去。而恰恰是那些你以为“无关紧要”的聊天记录,往往承载着项目决策、修改意图和坑点警告。等它们被挤出窗口,代理自然就变成了一个“新来的实习生”。
1. 为什么先爆炸的总是聊天记录——AI编码代理的上下文困境
1.1 上下文窗口不是给你聊天的,是给工具用的
我自己做一个粗测就能说明问题。一个中等规模的TypeScript项目,代码里能检索到的文件数量大概四五百个,单文件平均大小按900 token算,全读一遍就要40多万token,直接把200K窗口撑爆两次。所以代理只能借助检索和增量读取。问题是,每次“读文件”都是要花钱的——钱不是指API费用,而是上下文预算。
我习惯把上下文想象成一个环形缓冲区。新事件到达,旧事件被覆盖。但AI编码代理的覆盖策略并不是均匀淘汰,它遵循一套隐式优先级:工具结果 > 系统指令 > 用户最近消息 > 用户早期消息。最离谱的时候,代理会保留几KB的工具垃圾输出,却把你两轮前的技术决策挤出去。你问它“刚才为什么选A方案”,它只能从文件里的蛛丝马迹反推,推理出来往往跟你当时的真实考量对不上。
这就引出了上下文工程的核心问题:内容的价值不取决于进入上下文的时间顺序,而取决于对当前任务的边际贡献。聊天记录恰恰是价值密度波动最大的内容——里面既有“别用xxx库,它有坑”这种能救命的决策,也有“好的”“继续”这类纯噪声。如果不做干预,窗口淘汰算法会用它的方式替你做决定,而它的决定通常不关心你的项目。
1.2 压缩重写的代价:结构信息优先于内容信息
当上下文窗口逼近上限时,Claude Code会触发自动压缩(auto-compact)。它会把当前工作目录、对话摘要、关键决策压缩成一版新的摘要,替代原有对话。这个机制出发点是好的,但它在实际操作中有个明显偏向:压缩器倾向于保留“能继续干活”的信息,而不是“为什么不这么干”的信息。
我给你举个真实例子。我帮一个客户改造一个老旧的支付服务,中途发现当前代码里硬编码了测试环境的证书路径。这个信息很重要——不仅影响改造方案,还涉及安全合规。于是在对话里明确说“上线前必须把证书路径改成环境变量”。结果过了半小时,代理在写部署脚本时,又把硬编码路径原样抄进去了。我翻日志发现,auto-compact生成的摘要里只保留了“支付服务改造”和“部分文件结构”,那条具体的路径约束被概括成了“处理证书配置”,等于没记。
这类问题不是模型能力不够,而是压缩阶段的信息丢失。要解决它,不能指望压缩器变聪明,得把关键信息主动“钉”在某个不会被淘汰的位置。ChatMemory就是干这个的。它的核心思路和TCP滑动窗口有异曲同工之妙——不是把所有数据都记住,而是维护一个可推进的窗口,窗口内的消息可靠交付,窗口外的果断丢弃。
2. ChatMemory滑动窗口:本质是一种记忆淘汰算法
2.1 为什么拿“滑动窗口重传协议”来理解它最形象
有网络基础的朋友对TCP滑动窗口都不陌生。发送方和接收方维护一个窗口,窗口内的数据可以发送,窗口外的等待确认或丢弃;窗口随着确认不断前进。ChatMemory的思路类似:AI编码代理维护一个叫Chat.txt的核心记忆文件,里面保存有限条目的“高价值决策信息”。会话过程中,新产生的重要决策会被写入;当文件超出尺寸阈值,代理会重写整个文件,把次要信息裁掉,只保留它认为对后续工作最关键的部分。
这个过程本质上是一个可推进的窗口。窗口的后沿不是时间,而是“对任务仍具效用的最小上下文”。你之前说过什么不重要,重要的是哪些决策仍然影响后续步骤。代理每次重写Chat.txt,就是在做一次窗口推进。
我第一次看到这个机制时有点不以为然——这不就是个摘要缓存嘛。但用了一段时间后我改了看法。摘要缓存是“尽量保留”,ChatMemory是“主动淘汰”。别小看这个区别,主动淘汰意味着它在写每个条目时都要问一句“这个信息未来被引用的概率有多高”,这比无脑保留要智能得多。实际上,Claude Code对Chat.txt的管理有一个明确的策略:会话中发生两轮以上的压缩重写后,它会接管Chat.txt的维护,截断并重写其中的条目,保持文件在几千字符级别。这个阈值是经验值,背后逻辑是:文件太大会稀释注意力,太小则丢失决策上下文。
2.2 /memory实操:一条笔记如何穿过滑动窗口
Claude Code提供了/memory命令来主动管理聊天记忆。用法很简单,在会话里敲:
/memory这个命令有几个作用:查看当前维护的记忆条目,以及追加新的记忆。往里面写东西的时候,我习惯用“决策记录”的格式,而不是流水账。举个例子,有一次我需要代理在后续任务里始终避开某个公司内部SDK,我写的是:“确认不使用旧版sdk-utils包:原因(1)已被官方标记废弃;(2)存在线程安全问题;替代方案:使用platform-io包”。
写入后,这条记录会进入Chat.txt。之后即使主对话被压缩重写,这条信息也会保留。我实测过,在一段长达两个多小时的重构会话里,最初关于“不用旧SDK”的决策,在没有写/memory的情况下,auto-compact后基本只剩“不使用旧包”,原因被压缩掉了;而写入/memory的版本,即使在反复compact之后依然完整保留着原因和替代方案。这就是“钉住”和“留存”的区别。
这里有个实操细节:/memory写入的内容并不是越多越好。如果你写一整段技术分析进去,窗口淘汰时它仍然可能被裁掉,因为信息太杂、关键点不突出。更好的做法是把结论前置、原因精炼、替代方案可执行,每条不超过两三句话。就像一个高效团队的开会纪要,只记决策和行动项,不记讨论过程。
2.3 观察Chat.txt的变化——别把它当便签
很多教程让你去手动编辑Chat.txt。我的建议是:别急着这么干。Claude Code自带的重写机制有自己的格式约定,手动编辑很容易破坏它理解的结构。我吃过一次亏:手动往Chat.txt里塞了一堆项目背景资料,结果代理在后续对话里反复引用那些过时信息,因为它把它们当成了高优先级记忆,反而忽略了文件代码里的新变化。
正确用法是:把Chat.txt当作只能通过/memory和系统机制写入的“只读观察窗”。你在里面看到什么,就知道代理当前认为哪些决策仍然算数。如果发现它管着一堆过期信息,就用/memory补充一条新决策,覆盖旧的那条。
我还有一个习惯:在项目每个阶段结束时,用/memory写一条“阶段成果摘要”。比如“支付服务重构已完成:签名逻辑迁移到auth模块,旧接口保留兼容层”。这样进入下一个阶段时,代理的上下文里自带前情提要,不需要我再重复。
3. Context-mode MCP:把“全量预加载”改成“按需加载”
3.1 MCP工具描述是上下文里的沉默开销
MCP(Model Context Protocol)这两年已经成了AI编码代理接入外部能力的主流方式。GitHub、数据库、浏览器调试工具、设计稿解析器,全都有了MCP server。工具多了是好事,但它带来的上下文开销是大家普遍忽略的。
一台MCP server注册给代理时,通常带着一串工具定义,每个工具包含名称、描述、参数schema、返回值说明。一个功能健全的server,比如浏览器自动化类的工具集合,注册一次可能就要吃掉几千到一万多token。这还没算工具调用时返回的结果。假设你同时挂了五个MCP server,光是工具定义的静态开销就占掉好几千token,并且它是每个请求都会携带的,不随对话淘汰。
更头疼的是,代理在读工具定义时是“一视同仁”的。它不知道你当前任务根本不需要“修改Blender场景”“抓取PCAP文件”“操作PostGIS图层”这些能力,它只知道“我有这些工具,也许能用”。于是很多时候你会看到它在一堆无关工具之间犹豫,甚至生成了明显外行的工具调用参数。这不是模型能力问题,是上下文里塞了太多“有效噪声”。
3.2 tools / definitions / disabled三种模式怎么选
Context-mode的设计思路很直接:给MCP工具的加载方式加一个开关,按需加载而不是全量常驻。目前客户端实现里常见的三种模式是:
| 模式 | 作用 | 适用场景 |
|---|---|---|
disabled | 工具不注册,代理完全不知道存在 | 临时关掉高风险工具(如写数据库) |
tools | 工具按需动态加载,代理需要时会请求定义 | 大多数日常开发场景 |
definitions | 工具定义始终在上下文中(默认全量行为) | 高频使用的核心工具集,需避免频繁加载开销 |
我个人的结论是:大多数MCP server应该设为tools,只有极少数核心工具集保持definitions。拿我自己的配置来说,文件系统操作类工具我保持definitions,因为读写文件几乎是每轮都要做的事;而GitHub操作、浏览器调试、数据库查询这类“低频但必要”的工具全部切到tools模式,代理真正要调用时才加载对应定义。
这里解释一下tools模式的工作机制。它不是说“工具没有被定义”,而是客户端把它变成了一个可发现但未展开的条目,工具的完整schema在代理决定调用时才填充进请求。这有点像懒加载:链接是有的,页面内容等你点进去才加载。带来的好处是,日常对话的每轮请求里,那些无关工具的几百上千token描述不会再占据窗口。
我不会把所有工具都设成definitions还有一个原因:防止代理滥用。当某个工具的定义常驻时,代理会倾向于“想办法用它”。比如浏览器自动化工具常驻时,代理可能只是为了截图就启动一套浏览器会话;切成tools后,它会在真的需要时才想起来加载。上下文干净了,工具调用的精度反而提高了。
3.3 配置实操:Client端如何设置Context-mode
以Claude Code为例,MCP server的配置通常在.mcp.json或全局claude配置里注册。在Claude Code 2.x之后的版本里,注册时可以指定contextMode字段。一个典型的配置长这样:
{ "mcpServers": { "github": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"], "env": { "GITHUB_PERSONAL_TOKEN": "<your-token>" }, "contextMode": "tools" }, "fs-tools": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "./workspace"], "contextMode": "definitions" } } }这里github被设成tools,fs-tools保持definitions,是我个人认为比较合理的默认组合。不同客户端对contextMode字段的支持程度不太一样,有的走config文件,有的在GUI里勾选,但语义是一致的。
完成配置后怎么验证生效?两个方法:第一,直接在会话里让代理“列出你所有的工具和它们的能力”,看它是完全报出工具名还是省略描述;第二,打开客户端日志,观察请求体里附带了多少token的MCP工具描述。我自己用第二种方法最直观,配置前后同一轮普通对话的请求体大小,能差出好几千token。在长会话里,这几千token的释放意味着代理能多记住你几条关键指令,价值是实打实的。
4. 实战组合:两小时改造任务里的上下文预算分配
4.1 任务设置与分阶段策略
光讲理论容易飘,我拿最近做的一个真实项目当例子。任务是为一个在线教育平台补一个课程签到功能的后端接口,涉及用户模块、课程模块、订单模块三个旧服务,外加一个新增的签到记录表。整个过程我刻意用上了ChatMemory和Context-mode MCP,看看效果。
我把任务拆成了三个阶段:领域调研 → 表结构设计 → 接口实现与联调。
阶段一开始时,我先给MCP server重新排了一遍加载策略。数据库相关的两个MCP(负责执行SQL查询的)从definitions降到了tools,因为我明确知道前一小时主要是读代码、不需要频繁碰数据库。然后我打开/memory,先写了一条初始决策:“本次改造仅新增接口,不迁移旧表结构,所有改动须兼容现有线上数据”。
阶段一过程里,代理需要快速阅读三个旧服务的代码,确定现有字段含义。这个阶段我对上下文的观察是:由于MCP工具描述做了懒加载,代理在头三十分钟就能记住用户表、课程表的关键字段映射关系。如果按以前的配置,两个数据库工具的schema描述常驻,至少占掉三五千token的空间,而这些空间本可以存下好几张关键表的字段注释。
阶段二进入表结构设计时,我追加了一条/memory:“签到记录表使用event_time代替created_at作为唯一时间线,避免和订单表的created_at混淆”。这条记录在当前会话后半段被反复引用。最明显的一次,是代理在写索引时主动说“因为event_time是主时间线,所以索引应该以user_id+event_time组合为主”。它能说出这个结论,就是因为它还记得我半小时前写的决策,而不是靠现场重新推理。
4.2 调用对比与结果评估
阶段三接口实现时,我做了个对照。同一个需求,我先用旧配置(所有MCP全量加载、ChatMemory不启用)跑一遍,再用组合配置跑一遍。对比结果让我挺意外:
| 指标 | 旧配置 | 组合配置 |
|---|---|---|
| 上下文压缩触发次数 | 5次 | 2次 |
| 18次有效问答中的决策一致性 | 11次一致,7次被压缩影响 | 16次一致 |
| 请求体平均token | 明显偏大(工具描述常驻) | 减少约18% |
| 完成时间 | 1小时52分 | 1小时18分 |
压缩次数从5次降到2次是最关键的变化。旧配置下,代理每次一被压缩,我对它前半个小时的指令记忆就“归零”一次,后面得反复补充“我之前说过不要动订单表结构”;组合配置下,这类信息全部进了Chat.txt,压缩只会丢过程性内容,决策性内容稳稳还在。整个项目体验下来,我最大的感觉是——上下文工程不是给模型“喂更多”,而是让模型“少遗忘”。
5. 排查链路:当代理开始“反复问同一件事”
5.1 完整排查步骤
实战多了,你迟早会遇到“代理开始重复问你已经答过的问题”这种灵异现象。这不是模型抽风,而是上下文链路出了问题。我分享一套排查路径,按顺序走,基本能定位九成问题。
第一步,看压缩日志。Claude Code在claude --debug模式下会把上下文压缩的关键事件打出来,包括压缩时机、摘要内容、Chat.txt是否被改写。如果日志显示压缩后摘要里丢掉了某类信息,源头基本就找到了。我第一次排查时,就是在这里发现auto-compact把我关于“证书路径”的指令压成了“处理证书配置”。
第二步,检查Chat.txt的当前内容。用/memory命令查看,确认代理以为的“关键决策”和实际决策是否一致。不一致就直接用/memory补充一条,覆盖错误认知。很多“代理自作主张”的现场,都是它维护的记忆条目和真实需求发生了偏差。
第三步,检查MCP工具的注册状态。用/mcp命令列出所有server,看哪些工具定义还常驻在上下文里。如果挂载了五六个server全是definitions,那上下文里一半的“记忆空间”都被工具描述占了,聊天决策被挤掉就不奇怪。此时把不常用的server切到tools,往往立竿见影。
第四步,确认是否有旧版本Chat.txt的残留影响。项目换过目录、分支或换过机器时,旧的记忆文件可能被带到新会话里,代理会“以为”它知道一些这个分支上根本不存在的内容。处理方法是手动清理或绕过旧记忆文件,重置项目上下文。
5.2 三条容易踩的坑
排查了十几轮之后,我总结出三个最坑的操作。
第一条坑:在Chat.txt里写“解决方案”,而不是“决策和原因”。写过“用方案B解决”没写“为什么不用方案A”,结果代理后面看到方案B有问题,想回头试方案A,完全不知道方案A被否过。写记忆时务必带上原因链路。
第二条坑:给MCP工具设contextMode: definitions后又把工具数量堆到十几个。这样“上下文优化”就名存实亡了,常驻描述照样吃满窗口。核心工具控制在三四个以内,剩下的全走tools。如果发现自己在给第十个工具加definitions,多半是架构设计有问题,需要换个思路,比如合并server的工具集。
第三条坑:把外部拿到的过时规则写死进记忆。比如某个依赖库版本更新后,旧版本的坑已经不存在了,但Chat.txt里还留着“避开使用xxxAPI”的旧记录。代理会忠实执行这条早该淘汰的指令。定期审视/memory里的条目,给每条加个“有效期”心智模型,发现过期立刻覆盖。
6. 落地时的取舍和一些个人体会
到现在我依然不认为上下文工程有一个“普适最优解”,它本质上是针对项目节奏的记忆资产管理。我自己的体会是,先明确当前任务的关键决策点,再倒推需要哪些记忆条目和哪些MCP工具常驻。MCP工具能少挂就少挂,chat memory该写就写,两件事的优先级都高于“让模型更大、窗口更宽”。
顺手分享一个小习惯:每个工作单元结束时,我都会花三十秒用/memory补一条“当前状态+下一步计划”。比如“签到接口已完成,pending联调,注意线上环境数据库连接串用配置中心的值”。这个习惯在过去两周帮我省了大量“重新交代前情”的时间。代理不再是那个失忆的实习生,更像一个带着工作交接便签的项目成员。上下文工程这活儿,说白了就是给AI当好项目经理。