ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

编码代理上下文工程:ChatMemory 滑动窗口与 Context-mode MCP 实践

编码代理上下文工程:ChatMemory 滑动窗口与 Context-mode MCP 实践 “上周我接手了一个多文件的 Java 服务拆分任务AI 编码代理改到第三个文件时突然把第一个文件里一个核心接口的方法签名改错了——它完全‘忘记’了之前对话里确认过的接口约定。这个场景你一定不陌生编码代理在短任务里确实惊艳但任务一拉长就开始频繁失忆、答非所问、改错地方。问题的根源不在模型智力而在上下文工程没做好。”这篇博文就围绕一个主题展开如何用 ChatMemory 滑动窗口管理代理的短期记忆再用 Context-mode MCP 做上下文层面的裁剪和按需注入把编码代理在长任务里的稳定性拉上来。文章会讲清楚原理、设计思路、可落地的实现代码以及我在实操中踩过的坑。适合正在做 AI 编程工具、智能体框架或者想深入理解编码代理上下文机制的读者。1. 为什么编码代理总在长任务里“忘记前面说了什么”1.1 上下文窗口AI 代理记忆的物理边界先说一个最基本的约束大语言模型有一个上下文窗口上限。不管你是用 Claude 的 200K、GPT 的 128K还是本地模型的 32K窗口一满旧内容就会被截断或压缩。对聊天机器人来说丢几句闲聊无所谓。但编码代理不一样——它的上下文窗口里同时塞着对话历史、文件内容、工具调用结果、系统指令、代码 diff每一类信息都在抢空间。我见过太多人把这个问题简单归为“模型不行”其实更准确的解释是窗口里的信息密度太低。比如你让代理重构一个项目它需要同时记住控制器、服务层、仓储层的代码结构还要记住你刚才说过的“不要动公共方法”这个约束。如果这些信息被几百条工具返回的 JSON 埋在下面模型的表现自然是断崖式下跌。打个比方上下文窗口就像一张固定尺寸的白纸。你最初在上面写了核心公式后来为了记录中间过程把角落里也填满了数字。等到真正解题要用公式时信息还在纸上但你已经找不到它了——不是因为纸不够大而是因为有效信息被稀释了。上下文工程要解决的就是这个“稀释”问题。1.2 编码代理的三种记忆冲突编码代理的记忆比普通聊天复杂得多至少有三类信息在互相争抢上下文预算。第一类是会话记忆也就是你和大模型之间一来一回的对话。这类信息最容易膨胀——你让代理读一个文件它会把文件整个打印出来你让它跑一次测试它会把十余条测试日志全部贴回来。这些内容单看都有用但累积起来极其占空间。第二类是任务状态。包括当前改的是哪个函数、用户指定过什么约束、哪些文件已经被修改过、接口签名当前是什么版本。这类信息才是代理在长任务里“不迷路”的关键可惜它在对话流里往往是零散分布的代理要靠模型自己去“记住”。第三类是工具协议。编码代理通常会挂载一堆工具读文件、写文件、运行测试、搜索代码、调 MCP 服务……每个工具的描述和参数 JSON Schema 都占用 token。工具一多光工具定义就能吃掉好几千 token。这三类信息混在一个窗口里就可能互相污染。最典型的情况是用户明确说“别动接口名”但代理在处理完一个长日志之后把这个指令给“挤”出了有效注意力范围。所以我后来做 ChatMemory 时第一件事就是把这三类信息物理分离开——各管各的缓冲而不是让它们混在同一个消息列表里由模型自己分辨。2. ChatMemory 滑动窗口是怎么把记忆省下来的2.1 滑动窗口原理一个经典到被低估的机制滑动窗口本身是个极其通用的工程概念网络协议里有滑动窗口做重传与流控信号处理里有滑动窗口滤波算法题里有单调队列求窗口最值。共同点是一样的——维护一个长度受限的缓冲区间新数据进入旧数据离开。在 AI 上下文工程里这个机制看起来更简单用一个deque(maxlenN)存对话历史超过 N 条就弹掉最旧的。但把滑动窗口用到编码代理身上设计目标就和聊天机器人完全不一样了。聊天机器人丢一条旧消息无所谓但编码代理丢一条旧消息可能丢的是“用户刚才说过的安全约束”或者“某个接口的新签名”。所以 ChatMemory 里的滑动窗口不是简单丢消息而是配合“摘要压缩”一起工作旧消息被挤出窗口之前先把里面还有价值的信息提炼成一小段摘要存到摘要区里。这样窗口大小没变但信息不会真正丢失——只是从“完整原文”变成了“压缩提炼稿”。我记得有个同事问过我既然摘要会丢细节为什么不干脆把窗口调大这个问题背后是对成本不敏感。窗口调大确实简单粗暴但代价有两层一是 token 成本线性上涨二是模型对长上下文的注意力会衰减。实测下来一个 128K 的窗口塞满日志时模型对 30 轮之前用户指令的遵循程度往往不如一个 32K 窗口配合良好摘要的效果。长上下文不是没有上限而是“有效注意力”本身就有上限。2.2 三种滑动窗口策略的取舍我在项目里对比过三种窗口策略各有适用场景这里直接放结论策略核心思路优点缺点适用场景固定截断FIFO队列满后直接丢弃最旧消息实现简单零额外成本关键历史可能被无辜挤掉短会话、Demo、QA 验证优先级保留给用户指令、文件路径、报错信息打高优先级低优先级先被淘汰保真度较高能扛住重要约束需要设计优先级打分逻辑代码审查、多轮修改时间衰减 摘要合并旧消息压缩成递归摘要保留重点再淘汰信息密度高长任务表现稳定摘要本身消耗 token实现复杂多文件重构、跨模块开发我最终的 ChatMemory 设计不是单纯二选一而是把第三种作为主干再叠加第一层的优先级标记。具体来说每一条消息进来时先被标记为“用户指令”“工具结果”“系统事件”“普通对话”中的一类。工具结果默认是低优先级的因为绝大多数工具输出都是临时中间产物用户指令和包含文件路径的消息打高优先级争取留在窗口内更久。窗口满的时候先淘汰低优先级的工具结果再对最旧的高优先级消息做摘要压缩。2.3 一个可落地的 ChatMemory 实现代码这一节给你一套可以直接抄的简化实现。我用的抽象是ChatMemory类内部维护三个缓冲区——recent滑动窗口、summary摘要区、task_state任务状态区。from collections import deque import json class ChatMemory: def __init__(self, window_size20, summary_max_tokens800): self.window_size window_size self.summary_max_tokens summary_max_tokens self.recent deque(maxlenwindow_size) self.summary [] # 已压缩的历史摘要 self.task_state {} # 关键任务状态独立于窗口之外 def add_message(self, role: str, content: str, priority: int 0): # priority: 2用户指令/文件路径, 1系统事件, 0工具结果/普通对话 msg { role: role, content: content, priority: priority, raw_len: len(content.split()) } if len(self.recent) self.window_size: self._compact_oldest() self.recent.append(msg) def _compact_oldest(self): old_msg self.recent.popleft() if old_msg[priority] 2 and old_msg[raw_len] 64: # 高优先级且很短的消息直接塞进任务状态不走摘要 self.task_state[old_msg[content][:80]] old_msg[content] return self.summary.append(old_msg) # 这里可以调用LLM把多条历史合并为一段摘要 # 实践中我会调用 llm.compress(self.summary) 返回压缩文本 def build_context(self, system_prompt: str) - list[dict]: messages [{role: system, content: system_prompt}] if self.summary: messages.append({ role: assistant, content: [History Summary] .join(m[content] for m in self.summary) }) for msg in self.recent: messages.append({role: msg[role], content: msg[content]}) return messages几个关键点在代码里已经标注了我再展开说说。task_state是整个设计的灵魂那些“保持接口名不变”“支付模块不要改”之类的用户关键指令一旦匹配到高优先级就直接复制到task_state里永远不会被窗口淘汰。这样代理即使在堆满工具日志的上下文里也能稳定读到这些核心约束。另外_compact_oldest里那条 64 词的限制是我调出来的经验值——过长的旧消息做全量保留不现实过短的消息做摘要又浪费调用64 词左右是性价比分界线。实际使用时你在每次调用模型前调用build_context()拼装消息。拼接顺序上摘要放在系统提示词之后、滑动窗口之前让模型先建立全局记忆再看最近的对话细节。这个顺序经过验证比先放最近消息再放摘要更稳。3. Context-mode MCP 如何做上下文优化3.1 MCP 解决了工具连接但没解决上下文膨胀MCPModel Context Protocol解决的是“AI 应用怎么标准化连接外部工具和数据”这个问题。它定义了 host比如编码代理、client协议客户端和 server工具提供方三者之间的关系。有了 MCP你不需要为每个工具单独写集成代码工具方只要实现 MCP 协议AI 应用就能自动发现和调用。但 MCP 有它自己的上下文问题默认情况下服务端会把工具列表全量暴露给客户端而客户端的 LLM 请求里会带上全部工具定义。如果 MCP 服务器注册了 100 个工具每个工具的 JSON Schema 平均 400 token那光工具定义就是 40K token——这还没算对话历史和代码内容。更糟糕的是工具越多模型做工具选择时的干扰越强经常出现“调用了一个看似相关实则无关的工具”这种低级错误。我看到这个问题的第一反应是“能不能在 MCP 协议层做上下文裁剪”。后来我在项目里试了多种方案最终采用的思路就是标题里写的 Context-mode服务端把自己暴露的上下文拆成可独立裁剪的单元客户端根据当前任务的实时需求动态决定加载哪些工具定义、过滤掉哪些参数并限制工具返回结果的大小。把“全量注入”变成“按需注入”上下文占用就能从 O(工具数) 降为 O(当前任务需要的工具数)。3.2 Context-mode 的三种裁剪手段我把 Context-mode 的实现拆成了三个层次每一层解决一个特定的浪费点。第一层是工具定义裁剪。比如代码搜索服务器提供了 100 个工具但当前这个子任务只需要search_code和read_file两个。Context-mode 在初始化阶段先做一次能力探测拿到全量工具列表随后根据任务意图动态圈定子集只把子集内的工具定义放进 LLM 请求。这一步省掉的 token 是最多的。第二层是参数裁剪。工具 JSON Schema 里那些一眼就知道不会用到的参数比如search_code里的ignore_case、max_depth、file_filter在不影响功能的前提下用简化模式发出去——描述保留非必要参数字段去掉。第三层是结果裁剪。MCP 工具返回的数据往往比需要的多得多比如搜代码返回了 20 条相似结果实际问题只需要前 3 条。Context-mode 会给返回结果加一层摘要器在最外层做截断和提炼。我在 MCP 服务器端实现的 ContextProvider 接口大概是这样的{ contextProviders: [ { name: codeSearchProvider, availableTools: [search_code, read_file, list_dir, get_symbol, get_callers], activeTools: [search_code, read_file], parameterFilter: { search_code: [query, scope], read_file: [path] }, resultLimit: compact } ] }客户端收到这个配置后把activeTools里没有的list_dir、get_symbol、get_callers全部过滤同时把search_code的入参从 6 个字段压缩到 2 个字段。这一步光看数字就很有说服力一个全量工具定义加 JSON Schema 约 400 词100 个工具全量注入是 40K 词裁剪后 2 个工具、每个约 100 词一共才 200 词。这个数量级的差距决定了代理在长对话中到底是“清醒干活”还是“迷失在 token 池里”。3.3 从 28K 到 9K一组实测的 token 对比纸上谈兵没用我直接给你看一组我在真实项目里记录的数据。场景是“Java 服务拆分”原始上下文包含系统提示词约 2K token、完整对话历史约 8K token、3 个 Java 文件全文约 12K token、一个未裁剪 MCP 服务器的 40 个工具定义约 6K token。总消耗 28K token。在 32K 窗口下这已经逼近极限了。接入 ChatMemory 和 Context-mode 之后同一场景的构成变成了系统提示词 2K、对话历史压缩摘要 1.5K 最近窗口 4K、3 个文件裁剪为关键类和接口签名 6K、MCP 工具动态裁剪为 4 个工具 1.2K、任务状态快照 0.8K。合计 15.5K 左右。后续我把文件读取改成“按方法级别切片而不是整个文件打印”又把上下文压到了 9K 上下。拿 9K 和 28K 对比token 少了近三分之二。但更重要的变化不是成本而是代理的正确率。同一轮测试里未裁剪方案的代理在第三轮任务后开始出现“忘记接口签名”的错误而裁剪方案把窗口压力释放出来后代理到第八轮依然稳定遵循初始约束。窗口不是越大越好有效内容才是。4. 踩坑记录与排查速查表4.1 截断后代理“装糊涂”怎么处理滑动窗口最常见的副作用是代理表现正常但就是“想不起来”某个早期指令。排查这类问题别先去怀疑模型能力先查三件事。第一确认这个早期指令有没有进入task_state快照区。我最早的实现里优先级打分太粗只有“用户明确说了‘不要’才算高优先级”结果有些约束是以半隐晦方式表达的比如“其实我不太喜欢那种链式调用风格”。这类消息被打成普通对话挤出了窗口代理自然照旧写链式调用。解法是调整优先级规则用户消息里凡包含“不要”“别”“保持”“注意”“风格”这类意图词都标记为高优先级。第二检查摘要质量。如果摘要里全是“用户说要改支付模块”这种大意丢掉了“支付模块的金额字段不要动”这个具体约束再多的压缩也是白折腾。我的经验是摘要生成需要两层第一层压缩对话大意第二层单独抽取“约束清单”。约束清单直接进task_state不参与摘要合并。第三别忽略系统提示词的锚定作用。我会把“你正在重构某个 Java 服务用户核心约束如下”这段动态系统提示词在每个任务节点都重新生成一次。这让代理在面对长上下文时始终有一个“稳定锚点”可以回归而不是在对话流里自己找重点。4.2 窗口参数怎么调才不玄学滑动窗口的参数设置确实容易变成玄学但实测下来有几个规律可以参考。窗口大小先按任务类型定单文件修改建议 812 轮多文件联调建议 2030 轮跨模块重构可以到 40 轮但 40 轮以上的边际收益会明显衰减。这里说的“轮”是一来一回的对话消息不是单条消息。摘要触发时机比窗口大小更重要不要等窗口满了再压缩而是在窗口使用率达到 70% 时提前触发一次异步摘要。满窗再压缩意味着有一部分消息已经被静默丢弃回不来了。另外一个容易被忽略的参数是“摘要的最大 token 数”。我之前把它设成 2K结果摘要比历史还占地方后来强制限制在 800 token 内并要求“只保留与代码结构、用户约束相关的内容”。摘要本身的长度本质上也在消耗上下文预算所以结论是摘要宁短勿长。4.3 MCP 模式下工具失联的排查思路用 Context-mode 做 MCP 裁剪时最常见的线上问题是“工具时灵时不灵”。排查顺序我记得特别清楚第一步看客户端有没有缓存工具列表。动态裁剪模式下如果客户端缓存了一份旧的activeTools列表而服务端已经升级了工具定义两边对不上就会报错。解决方法是给 ContextProvider 配置加一个版本号或etag客户端启动时先拉一次最新配置。第二步看参数裁剪有没有裁掉必填项。有些工具的参数表面看起来可省略但服务端实现里漏了默认值。比如search_code的scope字段我以为裁掉没事结果服务端直接返回 400。后来我在参数过滤规则里加了“必填字段白名单”凡是 JSON Schema 里required: true的参数一律不裁。第三步看返回结果被摘要器截断了没有。我之前把resultLimit设太狠返回结果截到只剩第一行导致代理拿着残缺数据硬推理报错报得莫名其妙。现在我对工具返回的截断策略是结构化数据按条数截断但保留每条的核心字段长度超过 200 词的返回先让摘要器提炼结论再交给代理。4.4 几条提升上下文质量的小习惯最后分享几个我在反复踩坑后养成的小习惯不一定写进代码里但很影响整体表现。一是“先定约束再开始干活”。如果你让代理做一个多文件重构开工前先花一次模型调用把要遵守的接口、不要动的模块、编码风格偏好全部整理成一个约束清单写进系统提示词或task_state。千万不要在对话中途零零散散地补充约束——滑动窗口可以保消息但保不住“中途插进来的约束”的组织度。二是“文件按需读取绝不整文件打印”。一个 800 行的 Java 文件代理真正需要的可能只有 3 个方法和 2 个字段。用代码索引工具先定位符号再按符号级切片读入能省下海量 token。折叠多余代码是编码代理上下文工程里最简单也最有效的省钱手段。三是“隔一段时间主动向代理要一份简短进度报告”。这不是为了汇报而是为了让代理把当前任务状态显式地归纳一遍喂回上下文。效果等于给代理做了一次“记忆整理体操”它会更清楚自己做到哪一步了、还有什么没做。这一步在很多长任务里比调大窗口管用得多。我在实际项目中最大的体会是上下文工程的核心不是“省 token”而是让模型在有限的窗口里始终能看见那些真正重要的信息。ChatMemory 滑动窗口解决的是“怎么让旧信息不丢失”Context-mode MCP 解决的是“怎么让新信息不膨胀”两个机制配合起来编码代理才能真正像一个有经验、记得住事的工程师——而不是一个每次对话都会犯迷糊的临时工。这套体系做到后面你会发现它不只适用于某个具体的编码代理任意智能体应用里记忆管理和上下文优化都是同一套底层逻辑。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进