如果你也用 AI 编码代理干活,大概见过这种场面:上午刚跟它确认的接口约定,下午它写代码时就忘了;你说“按我们项目里那个工具类来封装”,它反手给你新建了一个风格完全不同的类。很多人第一反应是“模型太笨”,但折腾过几轮之后我越来越确信,问题往往不在模型,而在你喂给它的上下文。这也是我想认真聊“上下文工程”的原因——尤其是 ChatMemory 滑动窗口和 Context-mode MCP 这两套思路,基本覆盖了从“先让代理别失忆”到“让代理精准要上下文”的完整进化路径。这篇文章会用我实际踩坑的经验,把原理、参数、落地代码和选型判断一次性讲清楚。
1. 编码代理的"记忆危机":为什么大窗口解决不了真问题
1.1 窗口从 4K 涨到 200K,失忆问题反而变隐蔽了
先说一个反直觉的现象。早期模型上下文窗口小,4K、8K,聊不了几句就溢出,大家被迫想办法——截断、重述、把关键信息往前塞。后来窗口一路涨到 200K,甚至更大,很多人觉得“记忆问题自动消失了”,但实际用下来并没有。
原因很简单:上下文窗口变大,只是让你“能装下”更多内容,不等于模型“会用”这些内容。你塞进去的代码、对话记录、工具输出越多,每条信息的相对权重就越低。心理学里有个“注意力通胀”的说法,放在这里也很贴切:当窗口里堆了 300 个文件的内容,模型对“三小时前你强调过的那个设计约束”的注意力,往往会被旁边一大段无关日志稀释掉。
我做过一个很无聊但很有说服力的实验:在同一个 200K 上下文的代理里,分别让它处理“只有对话历史”和“对话历史+50 个无关工具输出”两种场景,让它回忆最早提出的三条硬性要求。结果后者稳定漏掉两条以上。不是模型没这个能力,是它在海量上下文里做了错误的注意力分配。
1.2 编码场景里的上下文消耗大户是哪些
做上下文工程之前,先得知道 token 都花在哪了。我自己统计过一轮典型的长任务会话,大致是这样的分布:
| 消耗项 | 占比 | 说明 |
|---|---|---|
| 系统提示词与角色设定 | 5% - 10% | 固定开销,但容易被忽略 |
| 工具定义(MCP/Function calling schema) | 10% - 20% | 工具越多,schema 越冗长 |
| 工具返回结果(diff、文件内容、报错堆栈) | 25% - 40% | 最容易被撑爆的部分 |
| 对话历史(用户指令 + 模型回复) | 30% - 50% | 滑动窗口主要管的就是这里 |
| 检索注入的代码片段/文档 | 10% - 25% | 不加筛选会严重膨胀 |
这里最容易被低估的是工具返回结果。你让代理读一个文件,它可能把整个文件读进来;你让它改一个函数,它把整个文件 diff 完整输出一遍,再往后模型还要对着这份 diff 继续思考。一个 500 行的源文件,经过“读取→分析→生成 diff→应用 diff→验证”几轮循环,可能吃掉你几万 token,而这些 token 里有大量重复内容。
所以我做上下文管理的第一刀,永远先砍工具返回,而不是对话历史。
1.3 上下文工程的本质:不是塞更多,而是给对的
提示词工程解决的是“怎么把话说清楚”,上下文工程解决的是“把哪些话放进来,哪些话留在外面”。两者是上下游关系。
我记得曾看过一个比喻:提示词工程是教一个人怎么读说明书,上下文工程是决定只让他读哪几页说明书。放在编码代理身上尤其准确——代理要做的事太依赖局部信息了,一个 10 万行代码仓库里,真正跟当前任务相关的可能只有 500 行。上下文工程的目标,就是让这 500 行以最可靠的方式出现在窗口里,同时让其余 9.95 万行安静地待在外面。
从 ChatMemory 滑动窗口到 Context-mode MCP,本质上是同一个目标的两层解法:前者管“内部记忆怎么组织”,后者管“外部上下文怎么按需索取”。下面我就分别展开。
2. ChatMemory 滑动窗口:第一个能上线的上下文管理方案
2.1 滑动窗口到底在滑什么
滑动窗口的思路很朴素:只保留最近 N 条消息,超出窗口的一律丢弃。N 可以是消息条数,也可以是 token 数量,工程上更常以后者为准。
为什么“最近 N 条”这个策略在编码场景里意外地好用?因为大多数编码任务具有强局部性——你刚让代理改完一个函数,紧接着的指令通常和这个函数相关;你刚贴了一个报错,下一步大概率是让代理分析这个报错。近因信息在编程对话里的价值密度,远高于那些陈旧的早期讨论。
但滑动窗口也隐藏着一个结构性缺陷:它做的是“匀速遗忘”。不管早期消息里是闲聊、试错记录,还是你拍板决定的架构约束,只要滑出窗口,待遇完全一样。我在实践中见过最典型的翻车场景是:项目一开始,用户明确说“不要用第三方 JSON 库,用标准库解析”,聊到第 80 条消息时这条约定被滑出窗口,代理开始兴致勃勃地给项目引入 jsonpath-ng,而且它自己完全不觉得有问题——因为“历史依据”已经被遗忘了。
2.2 窗口参数怎么定:先算预算,再谈算法
很多滑动窗口实现失败,不是算法不对,而是参数拍脑袋。“窗口定多少条消息合适”这个问题本身就是错的,正确的是先算 token 预算。
我用的预算公式很简单:
可用上下文 = 模型上限 - 系统提示词 - 输出保留 - 工具返回抖动预留举例,假设模型上限是 128K,系统提示词 + 工具 schema 固定占 12K,你希望模型每次至少能输出 8K 的代码或分析,工具返回可能瞬时占用 30K(diff 和文件读取经常会这样),那么可分配给对话历史的预算就是:
128K - 12K - 8K - 30K ≈ 78K这 78K 再拆给两条链路:一条是“当前正在处理的、需要完整保留的近端上下文”,一条是“远端上下文”。我的习惯是 7:3 分配——近端 55K,远端 23K。近端用原始消息填充,远端用摘要和历史结论填充。这里的 7:3 不是公式,是我在多种任务上调出来的折中点,远端摘要占比太高时,细节丢失严重;太低时,摘要又起不到兜底作用。
2.3 一个极简可跑的 ChatMemory 实现
我早期做一个内部工具时,写过一版非常朴素的 ChatMemory,结构很简单,核心就三件事:记录消息、按 token 淘汰、保留锚点。伪代码大致长这样:
from collections import deque class ChatMemory: def __init__(self, max_tokens: int, token_counter, anchor_keywords=()): self.buffer = deque() self.max_tokens = max_tokens self.count_tokens = token_counter # 传入与模型一致的 tokenizer self.anchor_keywords = anchor_keywords def add(self, message: dict): # 锚点消息(比如用户钉住的约束)不允许被滑动淘汰 is_anchor = any(k in message.get("content", "") for k in self.anchor_keywords) self.buffer.append({"message": message, "anchor": is_anchor}) def evict(self): # 从最旧的消息开始淘汰,锚点消息跳过 total = self._total_tokens() while total > self.max_tokens: oldest = self.buffer[0] if oldest["anchor"]: # 锚点的处理不是直接删,而是触发远端摘要 self._promote_to_summary(oldest) self.buffer.popleft() total = self._total_tokens() def _total_tokens(self): return sum(self.count_tokens(m["message"]) for m in self.buffer) def view(self) -> list: return [m["message"] for m in self.buffer]实际工程里有几个细节值得注意。第一,token 计数必须和模型推理时用的 tokenizer 一致,用 tiktoken 或模型的官方 tokenizer,不要自己数字符,否则偏差会累积,窗口实际大小和预期差很远。第二,淘汰策略我建议“按条淘汰”,不要在一轮对话中途做“按 token 截断”,否则会出现半截消息,模型读起来非常困惑。第三,锚点是滑动窗口最重要的补丁,后面单独讲。
这版实现在我自己的工具里跑了挺久,稳定,但也就是“能用”。它最大的问题在于:窗口内的内容密度太低了。大量早期消息被完整保留,而那些消息里真正有用的核心决策可能就一句话。所以后来我从“匀速遗忘”转向了“重点遗忘”。
3. 从"匀速遗忘"到"重点遗忘":滑动窗口的三种进化形态
3.1 金字塔分层:热层、温层、冷层
给 ChatMemory 升级,第一步是引入分层记忆。我不再只有一个 deque,而是把记忆分成三层:
- 热层:最近 10-20 条完整消息,保持全量原文,因为正在进行的任务高度依赖这里。
- 温层:热层之外的近期消息,按 token 预算保留,但每次有消息滑出热层时,先把它的核心信息提取出来写进温层摘要。
- 冷层:拉长到整个会话周期的“事实索引”,比如用户拍板的规则、接口约定、项目结构约定,存成结构化条目,不进滑动窗口,只在接上下文时按需注入。
这个结构的价值在于:它承认了“遗忘是必须的”,但把遗忘从“被动丢弃”改成“主动沉淀”。消息被遗忘前,先把它对人类有价值的部分压进温层和冷层。
3.2 摘要压缩:用 10% 的 token 保住 80% 的语义
摘要压缩是温层的核心机制。我的做法是:每滑出 K 条消息,就触发一次摘要任务,把这 K 条消息的关键信息提炼成 200-400 token 的摘要,作为一条特殊消息放回缓冲区。
摘要的提示词我会刻意强调“保留与代码决策相关的信息”,比如:
请把以下对话压缩为摘要,必须保留: 1. 所有用户明确提出的约束和偏好 2. 涉及的文件路径、函数名、类名、接口签名 3. 已经做过的技术选型及其理由 4. 尚未解决的待办事项 忽略寒暄、重复尝试、不再有用的中间过程。这里最关键的是第二点:一定要保留代码标识符(类名、函数名、路径)。摘要和原始消息不同,原始消息可以靠“看上下文”理解标识符指代什么,摘要一旦把标识符泛化成“某个工具函数”,下游就彻底丢失了可操作信息。
摘要压缩有个容易踩的坑,我称之为“摘要漂移”:对同一段会话反复做多级摘要,细节逐级丢失,几轮之后摘要里的信息已经和真实代码对不上了。解决办法是冷层里存“事实条目”,不是存“摘要文本”,每条事实就是一个可验证的断言,比如“payment_service.py 的 refund() 使用订单号作为唯一入参”。断言比叙述更容易保持准确。
3.3 按需召回:滑动窗口 + 检索的混合
分层记忆解决的是“已知重要的信息不丢”,但还有一种场景它管不了:某个信息在早期出现过,当时没觉得重要,现在突然需要了。比如你半小时前提过一个边缘 case,现在写代码正好撞上。
我的做法是给 ChatMemory 加一个按需召回层。当代理在当前窗口内找不到足够信息时,不直接瞎编,而是触发一次检索——用当前问题的关键词去搜冷层和温层的历史记录。
召回实现上我走过一条弯路:一开始直接上向量检索,后来发现对编码场景没那么好用,因为代码对话里的关键词(比如ORDER_STATUS这种常量名)更吃精确匹配。现在的实现是关键词 BM25 为主、向量检索为辅。召回结果不会直接塞进滑动窗口,而是作为一个“临时上下文块”附加在这次请求里,用完即弃,避免污染窗口。
3.4 锚定机制:把不可遗忘的东西钉在窗口里
最后是锚点。有些消息无论滑动窗口怎么滑,都不能丢——用户明确说“以后所有命令都用 Python 的 argparse”,或者“数据库访问必须经过 storage 层”。这类消息我会在写入时打 anchor 标记,淘汰时跳过。
但锚点不能无限多。锚点太多的后果是窗口里全是“不可丢弃的旧消息”,滑动窗口形同虚设。我给锚点的预算上限是窗口总预算的 15%,超过就要人工评估哪些锚点可以降级成温层摘要。这个矛盾本质上是“长期承诺”和“短期灵活性”的对抗,没有完美解,只有预算约束。
4. Context-mode MCP:把上下文优化从客户端搬到协议层
4.1 MCP 到底解决什么问题
说完了内存侧的优化,再聊外部上下文。很多人听到 MCP(Model Context Protocol)第一反应是“又一个插件协议”,但它在上下文工程里的位置被严重低估了。
MCP 标准化的东西其实是“模型与外部数据源之间的上下文交换方式”。一个 MCP server 可以暴露三样东西:工具(让模型执行动作)、资源(向模型提供数据)、提示词模板(组合复用上下文)。对上下文工程来说,资源才是最关键的——它定义了“模型需要信息时,怎么向外部系统要”。
传统做法的缺点是:上下文获取逻辑散落在 agent 的各处代码里,读取文件用一套逻辑,查文档用另一套,拉 issue 又是第三套。而且它们有一个共同默认行为——一次给全量。MCP 的抽象价值在于:把“上下文获取”变成了一个统一的、可观测的、能带参数调用的接口层。
4.2 Context-mode 的核心思路:声明式上下文契约
“Context-mode”是我在实践里逐步沉淀出来的模式,核心思想一句话:让每一次上下文请求带上 mode 参数,明确这次任务需要什么粒度的信息,而不是无脑全量返回。
我把 mode 定义成几个等级:
| mode | 含义 | 典型场景 |
|---|---|---|
| schema | 只返回接口签名、函数列表、数据结构 | 探索阶段,先搞清楚有什么 |
| compact | 返回压缩后的关键实现(去掉注释、空行) | 理解模块大致逻辑 |
| detail | 返回指定函数/类的完整实现 | 需要精确修改某个函数 |
| diff | 只返回变更内容和影响范围 | 审查改动、合并前检查 |
| full | 返回全量内容 | 只用于小文件或明确需要全量时 |
为什么要多此一举?因为编码任务里,绝大多数上下文请求的“最佳信息量”远小于全量。让代理改一个函数时,它需要的是这个函数的完整实现 + 调用方的签名 + 相关常量定义,而不是整个项目 100 个文件。Context-mode 相当于把“要什么粒度”这个决策显式化了。
4.3 实现一个带 Context-mode 的 MCP 服务骨架
我实现过一个面向代码仓库的 MCP server,核心资源接口大概长这样(以 Python 为例,基于官方 mcp SDK 的思路):
from mcp.server import Server from mcp.types import Resource, ReadResourceResult # 假设已有 code_index 负责从仓库里按路径定位符号 @server.list_resources() async def list_resources(): return [ Resource( uri="code://current-task/symbol?mode=schema", name="当前任务相关符号 (schema)", mime_type="text/plain", ), Resource( uri="code://current-task/detail?mode=detail", name="当前任务相关实现 (detail)", mime_type="text/plain", ), ] @server.read_resource() async def read_resource(uri: str) -> ReadResourceResult: # 解析 uri 里的 mode 参数 mode = parse_mode(uri) task_context = get_current_task_context() if mode == "schema": content = code_index.get_symbol_signatures(task_context.files) elif mode == "compact": content = code_index.get_compact_impl(task_context.files) elif mode == "detail": content = code_index.get_full_impl(task_context.target_symbols) else: content = "" return ReadResourceResult(contents=[{"uri": uri, "text": content}])实现本身不难,难在两点:一是code_index要能把“当前任务”映射到“相关文件和相关符号”,这是上下文工程里最重的活;二是要在这里做 token 计量和缓存。MCP 层是绝佳的计量点,因为所有外部上下文都从这个口子进出,你可以在 read_resource 里打日志、算 token、做缓存,客户端完全不用改。
4.4 为什么我把 MCP 放在上下文工程的最外层
我现在的架构里,MCP 是上下文的最外层,ChatMemory 是内层。两者的分工是:ChatMemory 决定“已经聊过的内容怎么组织、怎么遗忘”,MCP 决定“还没聊过、但任务需要的内容怎么取回”。
MCP 层带来的最大好处是可替换性。今天代码库在本地,MCP server 直接读文件系统;明天代码库变成远程的,换个 server 实现就能复用同一套上下文策略。客户端侧逻辑完全不用感知上下文来源的具体形态。
这里有一个我后来才体会到的点:Context-mode 不只是在省 token,更是在减少决策噪音。当代理拿到的 500 行里只有 30 行相关,它可能还能靠能力找到重点;当它拿到的 2000 行里有 1600 行相关但分布在 6 个文件里,它平均分配注意力后,出错的概率会显著上升。Context-mode 的粒度声明,本质上是帮代理提前把注意力收敛到一个可信的小范围内。
5. 实战选型:滑动窗口 vs Context-mode MCP,怎么搭
5.1 决策框架
很多人问我是“用滑动窗口方案”还是“用 Context-mode MCP 方案”,我的回答通常是:这俩不是二选一,而是不同层的工具。
| 场景 | 核心诉求 | 首选方案 |
|---|---|---|
| 长会话工具代理,聊天记录长 | 别忘早期决策 | ChatMemory + 分层记忆 + 锚点 |
| 大型代码仓库,改一个跨模块功能 | 别把无关代码塞进来 | Context-mode MCP |
| 多文件重构 | 既要相关上下文,又要保存重构决策 | 两者一起上 |
| 极短任务,一次问答就完 | 不需要任何花活 | 默认配置即可 |
判断依据很简单:如果代理的“上下文来源”主要是对话历史,先做 ChatMemory;如果来源主要是文件、issue、文档这些外部系统,先上 MCP 的 mode 设计。两者不冲突,甚至天然互补。
5.2 我的一组实测数据
我在一个内部代码辅助工具上做过对比实验,任务定义为“在 3000 行的服务模块里新增一个错误重试功能并接入测试”,跑三组配置,每组跑 5 次:
| 配置 | 平均每次任务总 token 消耗 | 需要人工修正的次数 | 首轮正确率(按行 diff 命中) |
|---|---|---|---|
| 默认全量上下文 | 186K | 2.4 | 41% |
| 仅 ChatMemory 滑动窗口 | 149K | 1.8 | 53% |
| ChatMemory + Context-mode MCP | 112K | 0.8 | 67% |
这个数据不是严谨论文,样本量也不大,但趋势和我在其他任务上的观察一致:上下文管理对 token 的节省是次要收益,真正的收益是减少人工修正。代理拿到的上下文对了,它对任务的理解就对了,下游就不用反复返工。
5.3 我推荐的分层架构
如果让我给一个可直接照抄的参考架构,那长这样:
- 内层:ChatMemory 负责对话历史,热层保留最近完整消息,温层放摘要,锚点钉住不可遗忘的约束。
- 外层:MCP 负责外部信息取回,所有文件、文档、issue 的读取都走带 mode 的资源接口。
- 连接层:一个“上下文组装器”统一负责拼接——从 ChatMemory 拿记忆上下文,从 MCP 拿任务上下文,按预算组装成最终发给模型的 prompt。
最后这块“组装器”极其重要,因为我见过很多人把窗口管理和外部上下文合在一起处理,结果一团乱。分开之后,出问题能快速定位是“记忆丢了”还是“外部信息没取对”,排查成本完全不同。
6. 踩坑记录:五个让我返工的细节
6.1 Token 计数不一致,窗口形同虚设
我最早一版 ChatMemory,token 计数用的是字符数除以 4 的近似换算。代码和中文混排时误差极大,实际窗口比预想小很多,导致模型经常看不到该看的内容。后来我改成加载模型官方 tokenizer,每次命中计数的耗时从毫秒级变成了微秒级(用缓存),准确性才真正解决。统一 tokenizer 是上下文工程的第一条底线。
6.2 工具返回结果总能找到办法撑爆窗口
即使窗口管理做得再好,一个返回 8000 行的git diff也能瞬间击穿预算。我现在的做法是在 MCP 的 detail mode 里做结构截断:优先保留“变更行附近 ±20 行”的上下文,而不是整个文件的 diff。这个策略牺牲了一点全局性,但保住的是模型对改动影响的判断力。
6.3 摘要漂移让早期的“事实”慢慢失真
分层记忆用得越久,越能感觉到摘要漂移的可怕。第一层摘要还算准,第二层基于第一层继续压缩,函数名和具体参数一旦被省略,后面再想恢复就难了。我最后的解法是冷层存结构化事实而不是叙述性摘要,并且定期用实际代码重新校验这些事实,算是给记忆做个“体检”。
6.4 检索噪音比不检索更伤人
按需召回不是每次都加分。有一次我加了向量检索,召回了和当前任务“表面相似但实际无关”的代码片段,代理为了迎合这些片段,写出了风格突兀、还引用不存在工具函数的代码。后来我加了相关性阈值,低于阈值宁可不召回,让模型明确知道“这个信息我不掌握”,它反而会更诚实。
6.5 上下文策略也需要回归测试
上下文工程最隐蔽的问题是“不稳定的隐性收益”:改了逻辑,你可能感觉代理变好了,也可能变差了,但说不清是哪个改动引起的。后来我养成了一个习惯:把典型任务录制成回归用例,跑完对比 diff 和成功率。没有这层保障,所有上下文优化都是在赌手感。
7. 最后分享一点个人体会
做了这么久上下文工程,我最大的感受是:“上下文窗口”这个词误导了很多人——它听起来像一个容器,实际上更像一条传输通道。通道的价值不在于能装多少,而在于知道该送什么过去。ChatMemory 滑动窗口解决的是通道内部的秩序问题,Context-mode MCP 解决的是通道入口的取货问题,两者合在一起,才是编码代理真正可靠的记忆系统。
如果你正在做类似的工具,我的建议是先别追求复杂的检索和向量化,从两层做起:第一层把对话历史的滑动窗口管好,锚点钉住;第二层把外部上下文获取统一到一个带 mode 的接口里。这两步做完,你的代理稳定性大概率会有肉眼可见的提升。后面再谈多智能体、多模态上下文,那是另一个故事了。