1. 上下文为什么先爆掉,而不是模型能力先不够
前阵子我把一个自用的AI编码代理丢进一个中型仓库里去改一个跨模块bug,刚开局一切正常,它还能准确定位文件;但跑了二十多分钟之后,画风开始失控——它反复调一个已经被删除的函数,把早期对话里否决过的方案又捡回来,甚至在报错日志里翻来翻去找同一个错误。那一刻我意识到,问题根本不在模型推理能力,而在AI编码代理的上下文工程没做好:窗口里堆了太多过期信息和无用噪声,模型的注意力被稀释了,早期指令和最近指令在我没注意的时候打了起来。
编码代理和普通聊天机器人完全不同,它是上下文窗口的绝对消耗大户。一次看似简单的“帮我查一下为什么登录接口超时”任务,代理实际要往窗口里放多少东西,我粗算过一笔账:系统提示约500token、工具定义约800token、检索回来的代码片段和文件快照约2000token、历史操作记录和中间输出约3000token、模型自己的回复约2000token。一轮完整决策下来就是万级token的基线消耗,来回折腾几轮,几十万token的窗口说满就满。这还是没算上大文件、长git日志和冗长的测试输出,真算进去,窗口就是一条早晚堵死的高速公路。
很多人把上下文问题简单归咎于模型窗口不够大,但我的观察恰恰相反:上下文优化的核心不是扩大容器,而是决定哪些信息应当进入窗口、哪些应当留在外部、哪些以压缩形态进入、哪些干脆丢掉。这就是上下文工程和提示词工程的分野。提示词工程研究的是“怎么把指令写清楚”,上下文工程研究的是“模型在这一步到底应该看到什么”。同样一句“你是资深工程师”,前者关心措辞,后者关心这句话会不会被淹没在500条工具输出里。
最有迷惑性的是,如今各家模型动辄支持几十万token上下文窗口,让人误以为只要窗口够大,模型就什么都记得住。实测下来完全不是这么回事。一个关键概念叫注意力的稀释:信息塞得越多,模型对每条信息的敏感度反而越低;而且长上下文里存在很强的首因效应和近因效应,落在中段的细节经常变成“视觉盲区”。我做过一个很简单的实验,把一条关键修复建议放在一段长对话的正中间,模型常常漏看,几乎前功尽弃;同一条建议挪到最近几条消息的位置,模型马上照做。这直接说明了长上下文不等于长记忆,窗口是用来调度注意力的,不主动治理,填进去的都是“看起来有用、实际帮倒忙”的内容。
所以我把上下文工程拆成两条主线来处理:一是会话内部的记忆管理与窗口滑动,二是窗口之外的存储、压缩和按需召回。前者落在ChatMemory滑动窗口上,后者落在一个叫Context-mode MCP的外部上下文服务上。接下来说说这两条线分别怎么落地,以及组合使用时的调度细节。
2. ChatMemory 滑动窗口:会话记忆的存活策略与参数落地
ChatMemory听起来像某个高大上的记忆模块,拆开来看本质就是给会话历史加一个有状态的缓冲区,负责三件事:记住什么、丢掉什么、以及用什么样的形态来丢。滑动窗口是这个缓冲区里最基础也最容易见效的策略。
2.1 滑动窗口在编码会话里是怎么工作的
ChatMemory滑动窗口并没有多玄妙。它把会话历史按照“消息条目/工具调用记录/文件变更记录”切成若干记忆单元,窗口只保留最近N个单元,更早的内容要么被丢弃,要么被压缩成摘要后继续占用一点点位置。典型布局是三段式的:
- 固定驻留区:系统提示、任务声明、不可变规则,始终留在窗口顶部。
- 滚动区:最近若干条消息、最近几次工具调用结果、最近的文件变更动作,窗口滑动主要发生在这里。
- 摘要区:更早内容的压缩表示,可以放在滚动区之前,也可以单独维护一个外部摘要,视实现而定。
滑动时机也值得理解。不是等窗口触顶才动,而是每来一条新消息就计算当前token占用,超过预算就触发“挤出”操作:把滚动区最早的那批消息送进压缩器,生成一段摘要写回摘要区,然后腾出空间给新消息。整个过程对模型透明,模型看到的始终是一个“系统提示+历史摘要+最近动作”的组合。
动手实现的时候可以直接做一个很朴素的按token预算控制的类。下面这段伪代码代表了我早期版本的核心逻辑,它简单但很能说明问题:
class SlidingWindow: def __init__(self, token_budget=8192, reserve_system=1024): self.budget = token_budget self.reserve = reserve_system # 给系统提示和任务声明留出的固定空间 self.items = [] # (kind, tokens, payload) def add(self, item): self.items.append(item) while self.estimate_tokens() > self.budget - self.reserve: oldest = self.items.pop(0) # 挤出最早的记忆单元 summary = self.summarize(oldest) self.items.insert(0, summary) # 放回压缩后的摘要,位置保持在前有人会问,为什么压缩后的摘要还要插回窗口,而不是直接扔到外部?因为摘要区承担了一个重要的“指路”功能:模型至少知道“之前讨论过某个话题”,真需要细节时再触发外部检索。如果连摘要都没了,模型会表现得完全失忆。
2.2 三个让我踩坑最多的关键设置
窗口大小,别按条数设,按token预算设。固定“保留最近30条消息”看起来很省事,可消息长短差异极大:有时一条工具输出就顶得上二十条普通对话。我后来改成给窗口分配一个token预算,再按平均消息长度反推条数,实测稳定很多。编码场景我常用的区间是window_size落在30~60条消息之间,token预算落在10240~16384之间。调参时记住一个原则:宁可少留几条消息,也要保证系统提示和最近一次报错完整在场。
滑动步长和重叠,是防止“记忆断裂”的关键。如果每次滑出都是整段的,那么被切在中间的一次工具调用,其前提和结果会被拆到窗口内外,模型就无法把因果链接起来。我的做法是采用重叠采样:每次挤出的消息块与当前滚动区末尾保留最近1~2条消息,让上下文保持连续性。这个细节不处理,最直接的后果就是模型“前言不搭后语”,刚问完的问题又重复问一遍。
压缩触发阈值,建议提前到占用率70%左右。等到100%满了才压缩,一次要挤掉的内容太多,摘要生成的信息损失会被放大;提前到70%触发,每次只滑出很小一段,摘要粒度更细,损失更小。这有点像缓存淘汰里的提前换页策略,看似浪费了一点空间,但换来了更平稳的上下文质量。
2.3 编码场景和闲聊场景的滑动窗口并不一样
聊天机器人做滑动窗口,记忆单元就是“你来我往的消息”。编码代理则不同,它的记忆单元应该是“动作块”——一次函数检索、一次文件修改、一次测试执行、一次报错返回,这些加在一起才构成一个完整决策。如果一个窗口只按消息滑动,常常会把“修改文件”和“看到测试结果”硬生生拆开,丢失最重要的因果信息。
我遇到过一个非常典型的案例:一个代理在重构时反复使用旧版API,一开始以为模型能力不够,检查上下文才发现,滑动窗口里保留了旧的代码片段和对应解释,而最近一次修改记录被滑出了窗口,模型只能参考陈旧信息。把窗口调小成“仅保留最近8个动作块”,旧代码块的引用位置很快被挤掉,模型立刻改用新API,问题迎刃而解。这个案例给我留下一个印象:窗口本身没有立场,它只决定模型“此刻更容易看到什么”,而我们要做的,就是让“此刻该看到的东西”恰好停在窗口里。
但滑动窗口也有一个天然天花板:它只负责窗口内的调度,窗口外是什么状态,它管不着。真正要解决“旧设计决策在三个月后还要被想起”这种问题,就得引入跨会话、跨存储的上下文管理,也就是下一节要讲的Context-mode MCP。
3. Context-mode MCP:把上下文优化变成外部能力
MCP,Model Context Protocol,是一套把模型与工具、资源、上下文能力解耦的开放协议。常规做法是让模型通过MCP调用外部工具去拿实时数据,而Context-mode MCP是一种变体:它不提供业务工具,而是提供一整套“上下文加工管线”,外部服务来做摘要、筛选、重构、注入。编码代理本身不需要在核心代码里维护复杂的内存和检索逻辑,只需要在合适的节点去调用这些能力。
3.1 为什么上下文管理值得从代理内部拆出去
内部逻辑写多了,最大的问题不是功能复杂,而是“不好迭代”。今天改一点窗口策略,就要重新发一版代理,而且不同项目、不同团队的代理之间很难复用同一套治理方案。把上下文管理抽成独立的MCP服务之后,代理把“该把哪些信息放进窗口”“如何处理历史”“按什么策略召回”这些职责委托出去,自己只负责决策和操作。
这和数据库连接池的思路很像。业务代码不需要关心连接什么时候该复用、什么时候该释放、连接池满了怎么办,连接池替业务代码扛掉这些细节。Context-mode MCP也是这个角色:编码代理不需要关心记忆的存取调度,一个外部服务帮它完成窗口的压缩与重建、上下文的按需召回、还有长周期设计决策的索引与检索。整个上下文策略变成可插拔、可复用、还可以单独做测试和审计的组件。
3.2 Context-mode MCP的几个核心工作模式
不同阶段需要不同的上下文加工方式。我按自己的使用经验把Context-mode的能力切分为四种模式,日常用得最多的是这四种。
| 模式 | 典型用途 | 输入 | 输出 |
|---|---|---|---|
| inject | 注入固定基线 | 代码规范、仓库结构说明、任务声明 | 压进系统提示之前的固定上下文块 |
| compact | 结构化压缩 | 长对话、长日志、大量工具输出 | 保留关键实体的摘要,如文件路径、错误码、结论 |
| select | 按需召回 | 查询条件、语义描述 | 从索引中挑出最相关的代码片段与文档片段 |
| rebuild | 任务上下文重建 | 任务目标、仓库索引 | 组装出本次任务专用的上下文包 |
compact模块和ChatMemory滑动窗口里的压缩器是同一个思路,但更结构化的区别在于它不只输出一段“文字总结”,而是输出带字段的JSON结构。简单说,它知道要保留file、error、api这类关键字段,而不是把整段文本概括成一团“用户之前遇到了一些问题”。
select模块解决的是跨时间的召回问题。滑动窗口覆盖的是本次会话内部的“短时记忆”,但一个项目改到第三天,需要回忆起第二天讨论的某个设计取舍,这时候只能靠外部索引和检索。select接收一个查询描述,返回一组“锚点+摘要+路径”的条目,代理可以自行决定要不要展开完整内容。
接入时,客户端配置长这样:
{ "mcpServers": { "context-mode": { "command": "npx", "args": ["@your-org/mcp-context-mode", "--index", ".ctx-index"], "env": { "CONTEXT_MODE_PROFILE": "coding-agent" } } } }工具接口描述也有点讲究,比如compact的输入输出是这样定义的:
{ "name": "context.mode.compact", "inputSchema": { "type": "object", "properties": { "source": { "type": "string" }, "targetBudget": { "type": "integer" }, "preserveKeys": { "type": "array", "items": { "type": "string" } } } } }从这你可以看出,Context-mode MCP并不直接插手对话,它只是暴露了一批“上下文工具”,代理在需要时按约定去调用。最大的好处是,不同的代理产品可以共用一套上下文治理管线;同一个代理在不同项目里,也可以通过切换不同的index目录拿到不同的上下文策略。
3.3 它和ChatMemory滑动窗口的分工
可以把这两条线理解成“CPU缓存”和“磁盘+索引”的关系:ChatMemory滑动窗口负责缓存区内的快读写,保证当前决策需要的信息尽量在场;Context-mode MCP负责缓存区外的大容量存储与精确召回,保证需要的时候能把“很久以前的重要信息”捞回来。
实际协作的节奏是这样:代理在处理一个跨文件修复任务时,滑动窗口不停地在滚动,把最近几步操作和报错留在窗口尾部;一旦发现某个设计决策在窗口里找不到,或者需要查阅三天前的某段历史提交,代理就调用context.mode.select从本地索引中召回相关片段,再按需注入当前窗口。这个过程对模型是透明的,它只感觉“记忆变好了”,其实背后是窗口与外置存储之间的协同调度。
4. 组合实战:滑动窗口 + 摘要压缩 + 按需召回的分层调度
前面已经铺垫了概念,这节用一场真实的任务推演来把这些东西串起来,看看在“修一个跨模块老bug”这种典型编码任务里,到底该怎么调度。
4.1 从任务启动到修复完成的六个阶段
阶段A:任务启动。代理拿到“登录接口偶发超时”的新需求,先调用context.mode.rebuild做一次任务上下文重建。它会从仓库索引里把和登录相关的模块、最近改动过的文件、相关测试用例抽出来,组装成一个任务专用的上下文包。与任务无关的几十个模块,在这个阶段就被挡在窗口之外。
阶段B:会话初始化。ChatMemory窗口初始化,把任务包内容放进固定驻留区,再把系统提示和编码规范注进去。此时窗口里是“规范+任务范围+目标文件”,信息干净,没有历史包袱。
阶段C:迭代调试。代理开始读代码、改代码、跑测试;每轮产生的工具输出和报错都追加到滚动区。滑动窗口按token预算不断滚动,旧工具输出被挤出,最新一次报错始终被保留。这一步能保证模型不会在排查过程中忘了自己刚才改了什么。
阶段D:窗口压力预警。连续十几轮调试后,token占用逼近阈值,触发context.mode.compact。它把这个阶段的完整错误日志、调试过程、尝试过的路径压缩成一个结构化摘要,保留字段包括file、error、attempts、commit、conclusion。窗口一下子松快下来,但关键信息没有丢。
阶段E:历史决策召回。修复过程中模型想知道为什么当初某处设计用了同步方式而不是异步方式,窗口里没有答案。代理调用context.mode.select,用一句查询描述“auth timeout and sync design tradeoff”找到三个月前的一段设计文档,再把摘要注入窗口,让模型恢复这段“记忆”。
阶段F:方案输出。模型基于压缩摘要和召回材料给出修复方案,代理修改文件并跑测试。测试通过后,窗口里的内容可以直接回写成一个新的摘要,存回Context-mode索引,为下一次相似任务留下经验。
4.2 分层记忆的调度策略
这套跑起来之后,我习惯把记忆分成短期、中期、长期三层来管理,每层的载体和召回方式都不同。
| 层级 | 载体 | 召回策略 | 预算占比建议 |
|---|---|---|---|
| 短期 | ChatMemory滑动窗口内的滚动区与摘要区 | 最近优先,窗口自然滚动 | 30%~40% |
| 中期 | 每个任务结束后生成的结构化摘要文件 | 周期合并,按任务粒度归档 | 20%~30% |
| 长期 | 本地索引(代码、文档、历史摘要) | 语义检索,按需召回 | 30%~40% |
分层设计最大的收益是预算可控。滑动窗口只管短期,不会因为一次会话拖太长就把整个上下文质量拖垮;长期信息平时不占窗口,只在明确需要时通过select召回。这样整个系统的token消耗就变得可以预测,而不是“每次任务都像是从零开始”。
4.3 可观测性:上下文工程的手感从哪来
做上下文工程最怕两眼一抹黑,不知道每一轮到底把token花在了哪里。我的做法是在调度链路上加一个计数点,每轮打印一张token分布小账本。主要关注四个值:系统提示和任务声明占多少、工具输出和文件快照占多少、滚动区历史占多少、当前真正有用的“有效任务上下文”占多少。
用这个口径去观察,很多问题会一下暴露出来。比如有一次我发现某轮对话里工具输出占了将近70%的token,但模型真正需要的信息只有其中两行,其余全是无用日志。把工具输出接入compact之后,同样的任务只用原来三分之一的token就完成了,而且模型表现更稳定。我这边一般会定义一个“有效上下文占比”指标:有效任务上下文除以轮次总token。如果这个值长期在40%以下,要么是窗口调度有问题,要么是检索召回的质量不行,需要回头调策略。
5. 边界与取舍:我踩过的坑和最终留下的经验
讲了这么多方法和架构,最后说点反面的东西。实践过程中最不缺的就是坑,有些坑能让人一夜回到解放前。
5.1 无脑调大滑动窗口,是我犯过的第一个错误
刚开始总想着“窗口越大,模型记得越多”。结果把滑动窗口拉到很大之后,模型反而开始“犹豫”——因为窗口里堆满了旧的讨论过程、被否决的备选方案和大量中间调试输出,它分不清哪些是最终指令,哪些是过程性思考。最典型的表现是,模型会在两个方案之间反复摇摆,今天给出的结论被三天前的某个讨论干扰掉。
后来我强制把滑动窗口压缩到“决策需要的最小集合”,只保留任务声明、最近一次操作、最近一次结果。噪声没了,模型反而坚决了。窗口大小从来不应该由“能塞多少”决定,而应该由“最少需要什么”决定。
5.2 摘要压缩必须先保实体,再谈通顺
早期版本的compact模块直接用大模型生成一段“自然语言总结”,看起来不错,但模型后续经常找不到具体文件路径、报错码和commit号。这些信息被概括进去了,却无法被精确定位。后来改成结构化输出,摘要长这样:
{ "file": "src/auth/token_validator.py", "error": "TimeoutError: tcp connect timed out", "commit": "a3f2c9d", "attempts": ["retry once", "switch to async socks proxy"], "conclusion": "sync socket blocks worker, need non-blocking approach" }字段保留的可检索性让后续推理明显变强,模型不再反问“你刚才说的那段报错在哪里”。压缩的优先级应当是:实体 > 逻辑关系 > 文采。
5.3 外部召回不能喧宾夺主
context.mode.select太方便了,就忍不住多召回一些。结果有一次,召回了十几个文件片段,窗口瞬间被塞满,模型的注意力又被摊薄了。这里我学到的技巧是:默认情况下select只返回“锚点+摘要+路径”,不展开全量代码;只有代理或用户明确要细看某个文件时,才把完整内容注入窗口。让外部检索的结果保持“折叠状态”,是防止上下文回流的必要手段。
5.4 上下文回环是压缩系统最容易出现的故障
所谓上下文回环,就是压缩器把关键信息丢掉了,模型在后续对话里因为缺少这个信息,不得不反复问同一个问题;每一轮询问又会产生新的上下文,触发新一轮压缩,于是进入死循环。问题根源通常是压缩时没有保留“待办问题列表”。修复方式是在compact输出里始终带一个open_questions字段,记录这一步还没解决的问题;模型看到这个字段会明确知道“我需要继续调查什么”,而不是盲目重复之前的行为。另外在代理侧加一道重试护栏:如果模型在很近的历史里已经问过同一个问题且没有得到更完整的信息,就禁止它再重复调用select,转而尝试换一种查询词去召回。
最后再说一个小技巧,也是我最近才沉淀下来的:不要等到项目做完才整理上下文策略,而是每跑完一个任务就顺手把“这次窗口里哪些信息是真正有用的、哪些是浪费掉的”记下来,定期回看。上下文工程的调优本质上是反直觉的——它要求你不断做减法,把“可能有用”的信息挡在门外,把“此刻必需”的信息放在眼前。做久了你会形成一种手感:判断一条信息该不该进窗口,唯一的标尺就是“模型接下来这一步,缺了它会不会瞎猜”。