如果你的编码代理最近开始反复修改同一个函数,把刚测试通过的逻辑又改回去,甚至在你明确说过“不要动这段兼容代码”之后的三轮对话里又开始动手——先别急着怪模型变笨。我最近在几个真实项目里折腾 AI 编码代理的上下文管理,从最早的 ChatMemory 滑动窗口方案一路改到基于 Context-mode MCP(Model Context Protocol)的上下文优化方案,中间踩了不少隐蔽的坑,也攒了一些可以复用的实测数据。这篇就把整个从“被动裁剪窗口”到“主动管理上下文”的演进过程整理出来,给正在用编码代理做实际业务的人作参考。
先说清楚本文的适用范围:它不教你怎么训练模型,也不讨论某个具体 Agent 框架的 API 细节,而是聚焦上下文工程这条主线——编码代理在长任务、多文件、多工具调用的真实场景下,如何让上下文窗口里的每一条 token 都花在刀口上。读完之后你应该能自己判断:什么时候该用滑窗,什么时候该上 MCP,以及怎么把两者组合起来。
1. 上下文撞墙:编码代理“变傻”的真正原因
1.1 三种典型的“变傻”表现
我最早意识到上下文出问题,不是因为日志报错,而是因为代理的行为开始变得诡异。第一个表现是重复劳动:同一个 bug 修了三遍,每次修完都信誓旦旦说“已修复”,但每次都换一种改法,最后把代码改得面目全非。第二个表现是规则漂移:项目规范写在系统提示里,前两轮它记得清清楚楚,到了第十二轮就开始跟规范对着干。第三个表现最隐蔽——它开始编造不存在的依赖或 API,比如引用一个旧版本才有的函数签名,或者给一个根本不存在的配置项写默认值。
这三类问题表面上各不相同,根子其实只有一个:上下文窗口里的信息被新内容挤掉了,而模型对此毫无感知。它不会告诉你“我忘了”,只会基于它当前能看到的那一小段信息自信地生成答案。
1.2 200k token 为什么还不够用
很多人觉得模型上下文窗口现在动辄 200k、1M token,怎么还会不够?我拿一个中等规模的前后端项目做过统计:项目本身的源代码总量大约 80 万 token,模型系统提示和工具定义约 1.5 万 token,一次任务跑下来通常需要 30~60 轮对话,每轮还要附带前一轮的工具调用结果、文件读取结果、报错堆栈。算下来,一轮对话消耗 8k~20k token 非常正常,还没算上代理自己生成的代码。
也就是说,哪怕窗口号称 200k,实际跑不到四十轮,早期任务目标、关键约束、已经确认过的技术方案就可能被挤出窗口。这跟浏览器标签页一样——标签多到一定程度,最早打开的那个页面就被浏览器扔进后台缓存,等你切回去再加载,可能已经丢了一些状态。模型比浏览器更糟,它丢掉的上下文不会自动重新加载,除非你有显式的检索机制。
1.3 上下文工程到底要解决什么
所以上下文工程的核心,不是把窗口撑大,而是做三件事:保证关键信息不丢、尽量降低每轮的 token 消耗、让模型在需要的时候能重新取回被丢弃的信息。后两者靠检索和工具,第一点靠裁剪策略。大部分 Agent 框架默认给的方案就是滑动窗口,也就是 ChatMemory 那一套。它解决了最紧迫的“窗口溢出”问题,但离“上下文工程”还差得远。
2. ChatMemory 滑动窗口:原理、伪代码与三个坑
2.1 滑动窗口在 AI 语境下的真正含义
“滑动窗口”这个词在计算机网络、信号处理、硬件设计里都有,TCP 有滑动窗口协议,Verilog 里有滑动窗口滤波器,甚至统计里也有滑动平均。很多做工程的同事一听到这个词就把它往“流量控制”或者“滤波”上想,其实 AI 上下文里的滑动窗口语义完全不同。
这里它指的是一个时间轴上的裁剪策略:把对话历史按时间排序,窗口向右滑动,新消息从右侧进入,旧消息从左侧被丢弃。窗口大小一般用 token 数来限定,也可以限定为消息条数。被丢出去的消息直接消失,除非你额外给它做摘要存档。它本质上是一个“带遗忘的环形队列”,不是重传机制,也不是滤波机制——这个差异很重要,因为它决定了你不可能靠调整滑窗参数来“找回”已经被丢弃的约束。
2.2 一个 20 行的滑动窗口实现思路
理解滑窗最好的方式是看一段简化实现。很多框架的 ChatMemory 核心逻辑跟这段伪代码是相通的:
MAX_TOKENS = 120_000 # 滑动窗口大小 def build_chat_memory(messages, system_prompt): truncated = [] used_tokens = estimate_tokens(system_prompt) # 从最新消息往回填充,直到窗口装满 for msg in reversed(messages): msg_size = estimate_tokens(msg) if used_tokens + msg_size > MAX_TOKENS: # 剩余空间不够就跳过,或者只保留前半段 break truncated.insert(0, msg) used_tokens += msg_size return [system_prompt] + truncated逻辑很简单:不是从时间正序往里塞,而是从最新的消息开始倒着装填,装到窗口容量上限为止。这样能保证模型永远看到最近的内容,代价是任意早的“历史关键信息”都可能在一轮之后被新内容顶掉。实际框架还会加一些优化,比如对最早一批消息做摘要压缩、对工具调用结果做截断、对特别长的代码块做折叠等。
2.3 三个实际工程坑
我在真实项目里用 ChatMemory 滑窗跑了大概两周,遇到三个很典型的坑。
第一个坑是任务规范被滑出窗口。当时我给了代理一份“不要改动支付模块数据库表结构”的强约束,前五轮它都遵守得不错,到第七轮因为连续读取了大量文件,这条约束被挤出了窗口,它就开始“顺手”建议加字段了。滑窗不会区分“高优先级长期指令”和“一次性的临时中间结果”,它对所有历史一视同仁,这是结构性缺陷。
第二个坑是工具调用结果反客为主。编码代理喜欢用搜索类工具,一次全库搜索可能返回上百行匹配代码,轻松吃掉几万 token。滑窗策略下,这些搜索结果是“最新消息”,会优先占据窗口,反而把更早、更关键的项目规范挤出去。你为了找一段代码,付出的代价是忘了更重要的东西。
第三个坑是摘要压缩的失真累积。有些框架会给被滑出的消息做摘要,保留一份“旧闻摘要”,这确实有用,但摘要本身就是模型生成的,存在信息损耗。我遇到过摘要把“在 A 文件中增加接口,但不改 B 模块”压缩成“修改 A 文件”,这种失真经过几轮累积,会让代理做出完全偏离原意的改动。
提示:滑窗适合的其实是短交互场景,比如客服对话、单文件补全、短问答。一旦进入多文件协同的长任务,就必须有额外的上下文管理层兜底。
3. 从应用层记忆到协议层上下文:Context-mode MCP 的升级逻辑
3.1 MCP 为什么适合承载上下文
聊到 MCP 之前,需要先明确一个概念:ChatMemory 管的是聊天记忆,MCP 管的是工作区上下文,两者不是同一层的东西。ChatMemory 在 Agent 应用内部,按时间轴管理“我们之前聊过什么”;MCP 在 Agent 与外部工具之间,按语义管理“当前项目里有什么、你需要什么”。
MCP 协议的核心能力有三块:工具(tools)——让代理调用外部函数;资源(resources)——让代理读取项目文件、文档、配置;提示词模板(prompts)——让外部系统向代理注入标准化的任务指令。这三块组合起来,正好覆盖了上下文工程缺失的那一层:不是被动地丢弃历史,而是主动地按需提供信息。
我在项目里用 MCP 服务器接入了代码索引、需求文档、架构约束清单。代理需要某个模块的设计说明时,可以直接通过 resources 读取;需要搜索代码引用时,可以调工具实时查;需要按照《数据库规范》改表时,prompts 能保证规范始终存在聊天记录的显要位置。这比把几千行规范文档塞进系统提示词或者祈祷它别被滑窗挤掉,要可靠得多。
3.2 Context-mode 与普通工具调用的区别
我这边“Context-mode”不是一个现成协议,而是我基于 MCP 设计的一套上下文管理模式,核心思路是:同一套 MCP 工具,但根据当前代理所处的任务阶段,动态决定向模型暴露哪些上下文、暴露多少。
举个例子,在“需求理解”阶段,Context-mode 只向模型注入需求文档、验收标准、相关目录结构;到了“编码实现”阶段,它会把滑窗策略切换成文件级,优先注入最近修改的文件和依赖关系图;到了“测试验证”阶段,它注入的是测试框架配置和最近一次构建日志。它本质上是对 MCP 资源的动态选择器,让代理每轮只看到当前阶段最必要的信息。
这跟单纯把文件作为工具暴露给代理不同。普通 MCP 工具调用是“模型主动去搜”,模型得先知道该搜什么;Context-mode 是“根据阶段主动投喂”,模型不需要猜测上下文在哪,服务器已经按当前的 mode 把该给的给到位了。前者的效率取决于模型是否会追问,后者的效率取决于模式划分是否合理——后者明显更可控。
3.3 与 ChatMemory 共存还是取代
落到工程上,二者不是非此即彼。我最后落地的方案是混合架构:底层继续用 ChatMemory 的滑动窗口管理对话流水,防止窗口溢出;但在滑窗之上叠了两层东西,一层是面向全局约束的摘要斗篷,把“不可违反的长期规则”单独隔离,不参与滑窗淘汰;另一层就是 Context-mode MCP,负责按任务阶段动态加载工作区上下文。滑窗管时间轴,MCP 管语义空间,两者互补。
4. 落地实录:MCP 上下文服务器的设计与参数调优
4.1 先搞清楚要解决什么问题
在做任何配置之前,我把痛点列了一张表:第一,项目规范文档太长,不可能常驻上下文;第二,代理搜代码时容易把上下文挤爆;第三,跨文件修改时经常找不到相关的依赖关系;第四,代理切换工具链(比如从代码分析切到数据库调试)时,旧工具上下文残留造成混乱。
这些问题直接决定了 MCP 服务器暴露哪些资源:规范类和架构类资源走 resources;代码搜索和依赖分析走 tools;不同任务阶段的标准指令走 prompts。这样分类之后,接入和调试都有清晰的入口。
4.2 服务端骨架设计
我用 Node.js 写了一个轻量 MCP 服务器,核心就干三件事:读配置、按 mode 筛选上下文、把结果包装成标准 MCP 响应。里面最关键的一段逻辑是上下文筛选器:
// context-router.ts export class ContextRouter { private mode: string = 'coding'; switchMode(newMode: string) { this.mode = newMode; } async resolveResources(): Promise<Resource[]> { const registry = { analysis: ['docs/architecture.md', 'REQUIREMENTS.md'], coding: ['docs/db-schema.md', 'CONTRIBUTING.md'], testing: ['docs/testing-guide.md', 'jest.config.json'], }; const paths = registry[this.mode] || []; return Promise.all( paths.map((p) => this.workspace.loadResource(p)) ); } }这里有个很实际的心得:mode 划分不是越细越好。我最初分了 requirement / design / coding / review / testing / debug 六个 mode,结果代理经常在模式切换时丢掉前一个阶段的关键结论。后来收敛成三个——analysis(读代码/需求)、coding(改代码)、verify(测试/排错),每个 mode 里再绑定当前任务的增量上下文,稳定很多。
4.3 客户端接入配置
Agent 客户端接入 MCP 服务器的方式各家不同,但配置文件格式高度相似。我这里是一个典型的 JSON 接入配置:
{ "mcpServers": { "context-mode": { "command": "node", "args": ["dist/server.js"], "env": { "WORKSPACE": "/path/to/project", "CONTEXT_WINDOW_LIMIT": "180000", "DB_CONN_STRING": "..." } } } }很多同学第一次接入失败,问题往往出在环境变量和命令路径上。MCP 服务器是通过本机命令启动的,如果node不在代理运行用户的环境变量里,或者工作目录不对,连接就会失败。我建议接入之后先用官方的 MCP Inspector / 调试工具跑一遍,确认资源列表能正常拉取,再交给代理使用,别直接上生产任务。
4.4 参数调优与混合滑窗策略
调优阶段最重要的三个参数是:上下文窗口上限、滑窗摘要阈值、单次资源注入上限。
- 上下文窗口上限:我设为模型最大窗口的 90%,剩余 10% 留给模型生成本身,避免输出中途被截断。
- 滑窗摘要阈值:当对话历史 token 数达到上限的 80% 时,就会把最早 20% 的对话消息压缩成一段摘要,并强制保活“项目规范”分组的消息。
- 单次资源注入上限:MCP 服务器返回的每个资源条目最大 5k token,超过的部分只返回文件结构摘要和关键代码行号,代理需要时再用专门的工具按需读取。
落地后的混合策略是:聊天层继续用滑动窗口;Context-mode MCP 既不把项目规范塞进聊天历史,也不参与滑窗淘汰;窗口满了先折叠对话摘要,再清理临时工具输出,最后才动上下文资源。这个优先级顺序要写死在实现里,不然滑窗的“就近淘汰”原则会把优质上下文先干掉。
5. 实测数据与排错日志
5.1 三类方案的对比
我在同一个中型仓库上跑了三组对比,每组跑一个 40 轮左右的真实任务:给现有服务增加一个带权限校验的 REST 接口并补测试。
| 指标 | 裸上下文(无滑窗) | ChatMemory 滑窗 | ChatMemory + Context-mode MCP |
|---|---|---|---|
| 平均单轮 token 消耗 | 38k | 12.5k | 6.8k |
| 任务指令遗忘概率 | 高(约 40%) | 中(约 15%) | 低(约 3%) |
| 单任务 API 成本 | 高 | 中 | 低(约为裸方案的 1/3) |
| 人工纠错轮数 | 7 | 4 | 1 |
这个数据不严谨,毕竟样本量很小,但趋势非常明显:把上下文从“聊天历史”挪到“按需资源”之后,单轮 token 消耗下降主要不是因为信息少了,而是因为代理不再需要靠聊天记录去回溯项目结构,每个阶段的上下文都是显式提供的。成本下降是附带红利,更好的部分是代理的改动方向从一开始就对了,人工纠错成本直线下降。
5.2 排错记录 A:上下文重复注入
接入 MCP 之后第一个严重问题是 token 反而暴涨。排查下来发现是会话级上下文在每次工具调用时被重复注入。我原来的实现里,每次请求都会调用resolveResources(),再把返回结果拼进提示词,这在单轮对话里没什么问题,但代理一轮会发起多次 MCP 工具调用,资源就被塞了三四次。
解决办法是在服务器端给每个会话加一个上下文缓存和版本号。会话开始后首次加载的上下文进入缓存,后续工具调用只返回版本号;只有当mode发生切换,或者代理明确请求刷新,才重新加载。加了这个缓存之后,单轮 token 消耗立刻降到了正常水平。
5.3 排错记录 B:大文件把窗口顶爆
第二个坑出现在读取大文件上。某个历史遗留模块的原生代码有近三万行,代理在排查问题时直接通过 MCP 读取了整个文件,结果一次性注入十几万 token,整个上下文窗口直接饱和。之后所有对话都卡在“记忆一堆无关代码细节”的状态里。
这个问题的根治办法是给 MCP 资源读取加两层保护:第一层是文件大小过滤,超过 200KB 的文件禁止整体读取,只能通过摘要接口访问;第二层是按需切片,代理调用读取工具时需要指定行范围,比如“读取 src/legacy-parser.ts 第 100-300 行”。代理如果不知道行号,可以先读函数索引,再选择具体函数读取实现。上下文窗口再大,也不能让它被单文件灌满。
5.4 排错记录 C:MCP 初始化失败和模型工具能力瓶颈
还有一类问题不在上下文管理本身,而是 MCP 连接稳定性。我遇到过服务器启动超时、本地端口被占用、环境变量里包含特殊字符导致 JSON 解析失败等问题。这些问题的排查套路一致:先在终端手动启动 MCP 服务器,确认能不能独立正常工作;再用 Inspector 测试连接;最后才把 Agent 接上来。没有捷径。
另外要提醒一点,MCP 给了模型很强的工具能力,但前提是模型本身的工具调用能力过关。如果基础模型不擅长按结构化参数调用工具,就算 MCP 服务器做得再完善,代理也会用得很别扭。我实测下来,对工具调用能力弱的模型,与其硬上,不如把 Context-mode 简化为纯资源注入模式——只让服务器主动给上下文,不给代理过多可调工具,反而更稳。
6. 最后想说的一点经验
这些东西折腾下来,我个人最大的体会是:上下文管理不是“优化技巧”,而是编码代理系统设计的一等公民。滑窗解决的是底线问题,防止窗口爆掉;MCP 解决的是质量问题,确保关键上下文在正确的时间出现在正确的位置。两者都不可少,但不要把滑窗当成上下文工程的终点,它只是最基础的一道保险丝。
最后分享一个判断标准:如果你的代理经常“聊着聊着就忘了”,先别急着加上下文窗口大小或者换模型,先统计一下它每轮的 token 都花在哪了。如果大量 token 消耗在重复读取文件、反复加载相同规范、以及工具调用结果残留上,那就说明你需要的不是更大的窗口,而是更聪明的上下文筛选器。与其给代理一个能装下整个仓库的窗口,不如让它学会每次只取当下最需要的 5%。这才是 Context-mode 和 MCP 这类方案真正值钱的地方。