
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_budget8192, reserve_system1024): 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落在3060条消息之间token预算落在1024016384之间。调参时记住一个原则宁可少留几条消息也要保证系统提示和最近一次报错完整在场。滑动步长和重叠是防止“记忆断裂”的关键。如果每次滑出都是整段的那么被切在中间的一次工具调用其前提和结果会被拆到窗口内外模型就无法把因果链接起来。我的做法是采用重叠采样每次挤出的消息块与当前滚动区末尾保留最近12条消息让上下文保持连续性。这个细节不处理最直接的后果就是模型“前言不搭后语”刚问完的问题又重复问一遍。压缩触发阈值建议提前到占用率70%左右。等到100%满了才压缩一次要挤掉的内容太多摘要生成的信息损失会被放大提前到70%触发每次只滑出很小一段摘要粒度更细损失更小。这有点像缓存淘汰里的提前换页策略看似浪费了一点空间但换来了更平稳的上下文质量。2.3 编码场景和闲聊场景的滑动窗口并不一样聊天机器人做滑动窗口记忆单元就是“你来我往的消息”。编码代理则不同它的记忆单元应该是“动作块”——一次函数检索、一次文件修改、一次测试执行、一次报错返回这些加在一起才构成一个完整决策。如果一个窗口只按消息滑动常常会把“修改文件”和“看到测试结果”硬生生拆开丢失最重要的因果信息。我遇到过一个非常典型的案例一个代理在重构时反复使用旧版API一开始以为模型能力不够检查上下文才发现滑动窗口里保留了旧的代码片段和对应解释而最近一次修改记录被滑出了窗口模型只能参考陈旧信息。把窗口调小成“仅保留最近8个动作块”旧代码块的引用位置很快被挤掉模型立刻改用新API问题迎刃而解。这个案例给我留下一个印象窗口本身没有立场它只决定模型“此刻更容易看到什么”而我们要做的就是让“此刻该看到的东西”恰好停在窗口里。但滑动窗口也有一个天然天花板它只负责窗口内的调度窗口外是什么状态它管不着。真正要解决“旧设计决策在三个月后还要被想起”这种问题就得引入跨会话、跨存储的上下文管理也就是下一节要讲的Context-mode MCP。3. Context-mode MCP把上下文优化变成外部能力MCPModel 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转而尝试换一种查询词去召回。最后再说一个小技巧也是我最近才沉淀下来的不要等到项目做完才整理上下文策略而是每跑完一个任务就顺手把“这次窗口里哪些信息是真正有用的、哪些是浪费掉的”记下来定期回看。上下文工程的调优本质上是反直觉的——它要求你不断做减法把“可能有用”的信息挡在门外把“此刻必需”的信息放在眼前。做久了你会形成一种手感判断一条信息该不该进窗口唯一的标尺就是“模型接下来这一步缺了它会不会瞎猜”。