1. 从“记不住”到“用不完”:我为什么开始折腾上下文工程
先聊个真实的场景。我最近在用 AI 编码代理(Coding Agent)做一个小型微服务项目,代码量不大,但横跨了前端、后端、部署脚本和一堆配置文件。结果不到半天就发现一个让人抓狂的问题:这个 AI 助手在同一个会话里,前面还在分析 API 路由,后面就开始“失忆”——要么忘了之前确认过的表结构,要么把已经废弃的旧函数名当成新代码引用进来。
一开始我以为是模型本身不行,后来看着上下文面板里的 token 上限琢磨了一会儿,才意识到一个更本质的问题:我喂给它的上下文,根本没经过管理。
上下文工程(Context Engineering)这个概念,这两年其实已经被反复提,但真正上手做的人不多。所谓上下文工程,通俗点讲,就是把“喂给模型的上下文”当作一种需要设计、维护、优化的资源,而不是随手往里塞东西。它跟提示词工程不一样:提示词工程关心的是“怎么把一句话写清楚”,上下文工程关心的是“整个会话里模型能看到的全部信息——包括历史消息、工具返回、外部文档、代码片段——如何编排才能让模型始终保持最佳状态”。
我这次的实战目标是给一个基于大模型 API 构建的编码代理加上一套可维护的上下文管理系统,核心手段是两条线:一是ChatMemory 的滑动窗口,二是Context-mode MCP(Model Context Protocol)。这两样东西配合在一起,解决了三个长期困扰我的问题:上下文无限膨胀导致费用飙升、关键信息被远端历史冲淡、工具调用返回的数据格式把上下文塞满噪声。
这整套折腾下来,水比较深,踩的坑也不少。这篇文章不打算写成文档翻译,而是把我从设计思路到落地实现,再到排查问题的完整过程梳理出来。适合正被“AI 编码代理记性差、上下文贵、工具结果乱”这三件事反复折磨的人参考。看完你至少能回答这三个问题:上下文到底是怎么被消耗掉的?滑动窗口到底怎么滑才合理?MCP 的 context mode 到底能优化什么东西?
2. 为什么编码代理是“上下文吞噬怪”:先理解钱和注意力是怎么没的
2.1 一个会话到底会产生多少上下文
要理解上下文工程,先得知道 AI 编码代理和普通聊天机器人的区别。普通聊天机器人对话短、语义边界清晰,而编码代理在背后做了大量循环操作:读文件、跑测试、看报错、改代码、再验证。每一次循环,都会把新的内容压入上下文。
我举个自己实测的例子。用某个主流编码代理框架跑一个小任务,任务是“给现有 Python 服务加一个 GET /health 接口”。整个过程中,模型大概经历了这些动作:
1. 用户输入任务描述(一条消息,约 1000 token) 2. 读取项目结构、相关文件内容(约 30000 token) 3. 生成代码修改(第一次输出,约 2000 token) 4. 工具返回 lint 结果(约 3000 token) 5. 根据报错修改代码(第二次输出,约 3000 token) 6. 再次读取被修改的文件(约 8000 token) 7. 最终输出验证结论(约 2000 token)这些累积下来,单次小任务就消耗了近 5 万 token,而且这里的每一步都是必需的,没有哪个环节是浪费的。更可怕的是,如果代理在同一个会话中连续做多个任务,之前的文件内容、中间输出、工具返回会一直堆在上下文里。到后面你想让它专注处理“当前这个 bug”,它眼里全是前面 10 个 bug 的相关信息——不是它不想专注,是它“眼里”看见的就是所有东西。
这就像一个员工被塞进一个塞满杂物的房间,你要他找桌上的图纸,他得先翻过三摞无关文件。不是他笨,是房间没有整理。
2.2 长上下文不等于好上下文
很多模型厂商现在都在宣传“200K 上下文窗口”,听起来很强。但实际经验告诉我,长窗口解决的是“放得下”的问题,不是“看得清”的问题。模型对上下文中部位置的注意力天然不如开头和结尾,这在 Transformer 架构里叫“lost in the middle”。你把一个关键配置项埋在 150K token 的中间位置,它“看过”和它“记住并引用”完全是两码事。
另外,200K 窗口的推理成本也不是线性的,很多 API 是按输入 token 计费的,长上下文每次调用都肉疼。更要命的是延迟,输入越长,首 token 返回越慢。你在编码代理场景中,一轮循环往往要发起多次模型调用,如果每次都要吃下 100K+ 的上下文,整个任务的执行时间会成倍拉长。
所以结论很直接:上下文 optimization 的目标不是“塞满窗口”,而是“让窗口里始终保持高频价值密度的信息”。这就像一个讲究的冰箱:不是塞满就叫好,把不常用的冷冻起来、常用的放在手边,才是真正的经营之道。
2.3 我定义的三个核心优化方向
基于上面的问题,我把编码代理的上下文管理拆成了三个方向,后面的方案选择也都绕不开这三个方向:
- 容量控制:把上下文总量控制在预设预算内,避免无限膨胀。核心手段是滑动窗口和历史消息截断。
- 新鲜度保护:让模型永远亲近最近的事实(当前代码状态、最近报错),而不被几天前的旧结论干扰。核心手段是消息过期机制。
- 结构降噪:工具返回的结构化数据经过压缩/筛选后再注入上下文,避免一堆 JSON 模板噪声浪费 token。核心手段是 MCP 的 context mode。
这三个方向不是互相独立的,往往要组合使用。这也决定了后面的架构:滑动窗口管容量,过期机制管新鲜度,MCP 管结构。
3. 核心工具拆解:ChatMemory 滑动窗口与 Context-mode MCP 各解决什么问题
3.1 ChatMemory 滑动窗口:一种“有遗忘机制”的上下文管理器
滑动窗口本身不是新鲜概念,网络协议里的滑动窗口、信号处理里的滑动窗口滤波,核心思想都是维护一个固定长度的动态区间,新数据进入,旧数据淘汰。落到 AI 编码代理的上下文管理上,ChatMemory 做的就是同一件事:把消息队列维护成一个固定大小的窗口,超过容量后自动移出最旧消息。
但我必须说,真正实现好后,有几个容易被忽视的细节:
第一,窗口大小不能只看消息条数,要看 token 数。有些消息很短,比如“好的”;有些消息很长,比如某个文件的完整内容。如果按条数切,一条长消息可能占掉半壁江山;如果按 token 切,就需要在切割时小心处理,别把一条消息拦腰截断,否则模型读到的是一堆不完整的内容,比没有更糟。
我实践中的做法是:给每条消息设定一个 token 估算值,然后维护一个累积和,新消息插入后将尾部消息出队,直到总 token 落在目标窗口的 90% 左右,留出一些余量给模型输出。这个估算可以简单用len(text) / 4(中文场景大致换算),也可以用 tiktoken 精确计算。在编码代理里,我建议做精确计算,因为代码内容里特殊符号、空格多,粗略估算偏差太大。
第二,滑动窗口不能无差别滑。有些“旧消息”是不能被滑走的,比如用户在任务开始前给出的硬性约束:“不要修改数据库迁移文件”“只允许使用已有依赖”“这个项目必须兼容 Python 3.9”。这些约束一旦被滑出上下文,模型后续就可能犯错。ChatMemory 在这一点上的设计我是认可的:它支持对消息做“固定”标记,带有该标记的消息在窗口滑动时不会被淘汰,而是被挤入一个永久保留区。这个功能简直是编码代理的救星。
第三,滑动窗口淘汰旧消息时如何保持“语义连贯”。模型突然发现上下文里少了前面一大段内容,有可能会出现困惑。缓解方式是在窗口边界注入一段摘要或者“之前讨论过的结论汇总”,让模型知道身边发生了什么。这种“摘要 + 窗口”的混合模式,比纯硬切割要稳健得多。
3.2 Context-mode MCP:给外部数据加“闸门”
MCP(Model Context Protocol)是一个让 AI 应用与外部工具、数据源进行标准化通信的开放协议。可以把它理解为“AI 世界的 USB 接口”——你不需要为每个数据源单独写适配器,只要它们实现了 MCP server,AI 就能以统一的方式调用它们。
MCP 里不同资源请求有不同用途,Context-mode 是其中最贴合上下文工程的一种设计。它和普通 mode 的区别,核心在于回答方式不同:
- 普通模式:工具完整返回请求的所有数据,原样塞入上下文。比如让你读一个 3000 行的配置文件,它就真的把 3000 行全部给你。
- Context-mode MCP:由 MCP server 侧对请求意图做出来判断,返回“当前模型智能体任务最需要的那部分上下文”,而不是“整个资源内容”。同时会给模型提供有关资源结构与用途的元信息,方便模型按需再请求。
我一开始用 MCP 的时候完全没意识到这个模式的价值,默认就是普通模式。结果写了一个读取数据库 schema 的 MCP server,项目里有 120 张表,每张表的 DDL 都往上下文里塞,两次调用下来上下文就爆了。
换成 context mode 之后,同样是“读取数据库 schema”的请求,MCP server 返回的是:
数据库共有 120 张表,与当前任务相关的核心表:users、orders、order_items。 users: 主键 id,关键字段 email、status。 orders: 主键 id,外键 user_id,关键字段 amount、status、created_at。 如需查看某张表的完整 DDL,可使用 schema.detail(table=xxx) 方法。这下模型既能知道全局面貌,又不会陷入所有表的细节里。需要某张表细节时,它再发起一次精准请求。这就是 context-mode 的本质:上下文按需供给,而非全量供给。
3.3 两者结合:一个“窗口控制 + 内容筛选”的双层流水线
在真实编码代理里,ChatMemory 滑动窗口和 Context-mode MCP 不是替代关系,而是叠加关系。我最后搭的架构可以概括成这样一个双层流水线:
外部工具/数据源 -> MCP context mode(第一层筛选:压缩噪声) | v 模型消息历史 -> ChatMemory 滑动窗口(第二层控制:总量裁剪) | v 组装 Final Prompt -> 送入 LLM第一层负责“什么内容值得进上下文”,第二层负责“上下文装得下多少内容”。两层各管一件事,叠加之后的效果比我之前任何单层优化都要明显。我会在第 5 节详细展示配置和代码,这里先不做展开。
4. 设计一个编码代理的上下文管理系统:参数、策略与代价权衡
4.1 定窗口大小之前,先算清 Token 预算
很多人的第一反应是“窗口越大越好”,但经验告诉我,滑动窗口大小不是拍脑袋定的,而是跟着预算和任务模型走的。我给自己定了一个流程:
第一步,明确模型上下文上限。比如用的是 128K 上下文的模型。
第二步,预留输出空间。一般保留 20% 给当前轮次的模型输出(生成代码、生成解释等)。这样可用输入空间就是大概 100K。
第三步,根据任务类型确定窗口目标。编码代理场景里,我认为 30K 到 60K 是一个比较合理的“高质量工作集”。太少了装不下代码和工具结果,太多了会引发前述 attention 衰减问题。
我做过的测试里,将窗口设为 40K token 的效果比较均衡,既能容纳一轮完整迭代所需的文件内容、工具输出,又不至于让陈旧信息长期堆积。这只是我的经验值,不同框架和模型可能不同,建议你从 30K 起步,逐步观察模型行为变化。
4.2 滑窗淘汰策略:不只是 FIFO
简单 FIFO(先进先出)策略虽然能用,但在编码场景里很容易把关键信息误杀。我最终采用的策略包含两层:
一层是按优先级区分消息。我给消息定义三档优先级:
| 优先级 | 适用范围 | 窗口滑动时的处理 |
|---|---|---|
| 高 | 用户硬性约束、当前任务的最终目标 | 永不剔除,即使超出窗口预算 |
| 中 | 当前文件内容、最近的工具返回 | 正常参与滑窗淘汰,但保底保留最近 N 条 |
| 低 | 早期阶段的分析、过时尝试 | 优先被淘汰,必要时直接忽略 |
另一层是保底保留条数。即使窗口已经超出预算,我也会保留中优先级里最近若干条消息,因为编码代理的循环迭代非常依赖最近的报错信息,一旦丢掉最新报错,模型就可能在盲改。
实际测试下来,高优先级消息占比一般控制在 5% 以内,如果超过这个数,说明用户任务里塞了过多“不可丢弃”的内容,这时候我会提示用户将硬性约束精简,而不是放任它挤占窗口。
4.3 ChatMemory 核心参数配置参考
由于 ChatMemory 的具体实现可能因框架而异,我这里给出一份基于常见实践的配置参考,带有完整的“为什么”解释,方便你迁移到自己的框架中:
chat_memory_config = { "max_tokens": 40_000, # 窗口总 token 预算 "reserve_ratio": 0.15, # 保留给模型输出的比例,约 15% "hard_fixed_ids": ["goal"], # 永不淘汰的消息分组 "summary_every": 10_000, # 每累积 10K token 就生成一次小结 "summarizer_model": "fast", # 使用轻量模型生成摘要,省成本 "recent_keep": 6, # 无论窗口如何,保留最近 6 条消息 }这里的summary_every是我自己加的机制:当一个低优先级消息即将被淘汰前,把它的核心结论抽取成一句话,合并到全局摘要中。这样虽然具体的文件内容被滑走了,但“这个文件里有什么结论”的大意还在。模型后面需要细节时可以再通过 MCP 去请求原数据。
4.4 Context-mode MCP server 设计:如何决定返回什么内容
设计一个 context-mode MCP server,核心是要回答清楚一个问题:“模型当前真正需要什么粒度的信息?”这个判断做不好,context-mode 就会变成一个语义模糊的裁剪器。
我的经验是,把 MCP server 的每个资源请求都拆成三个层级,server 根据请求参数自动选择返回粒度:
- 概要层:资源存在哪些模块、关键文件清单、表结构总览。适合模型做规划。
- 结构层:目标文件的关键类/函数定义、类型签名、核心逻辑的注释。适合模型做局部修改。
- 全量层:完整内容。只有模型明确请求时才返回,并压缩成高密度形式。
以文件读取为例,普通的 MCP server 收到read_file请求后返回整个文件内容。context-mode server 则可以根据参数自动返回:
{ "mode": "structure", "target": "src/services/order_service.py", "summary": "订单服务的核心模块,负责订单创建与状态流转", "structure": [ "class OrderService:", " create_order(user_id, items) -> Order", " cancel_order(order_id) -> bool", " get_order_detail(order_id) -> dict" ], "size": "共 640 行,如需读取完整实现,请调用 read_file_full" }看到没有,模型拿到的是一个“地图”,而不是一篇长文。它能知道文件里有什么、去哪里找什么,但不会被 640 行代码淹没。需要细节时再按图索骥。
4.5 成本与收益:优化前后我实测的对比数据
为了验证这套方案的价值,我在一个约有 2 万行代码的中型项目中跑了一组对比测速。任务内容是“修改订单模块,增加优惠券字段并覆盖测试”。每组任务各跑 5 次取平均,结果如下:
| 指标 | 未使用上下文工程(原配置) | 使用 ChatMemory + Context-mode MCP |
|---|---|---|
| 单任务总 token 消耗 | 约 420K | 约 180K |
| 有效工作耗时 | 约 8 分钟 | 约 5 分钟 |
| 首次修改正确率 | 60% | 85% |
| 最大上下文尖峰 | 约 110K | 约 46K |
| 上下文超限中断次数 | 2 次 | 0 次 |
最直观的感受是 token 消耗降了接近 60%,而且模型的修改正确率提升非常明显。原因也简单:语境中“噪音”少了,模型注意力更集中。上下文工程不是省了钱就亏了质量,恰恰相反,它是省钱、提速、涨质量的三赢。
5. 实操:从 0 到 1 搭建一个带上下文优化的编码代理
5.1 整体架构与代码落地
我采用的方案是基于 Python 实现的,依赖一个假设的coding_agent_core库,配合 MCP SDK。整体流程如下:
用户输入 -> CodingAgentSession -> ChatMemory(滑动窗口管理历史消息) -> MCPClient(连接 Context-mode MCP Server) -> PromptAssembler(组装最终请求) -> LLM API下面展示一个简化版但可以跑通的核心代码,方便理解整个框架的关系。
5.2 ChatMemory 滑动窗口:骨架代码与配置
class ChatMemory: def __init__(self, max_tokens=40_000, reserve_ratio=0.15): self.max_tokens = max_tokens self.reserve_tokens = int(max_tokens * reserve_ratio) self.hard_fixed_ids = set() self.messages = [] # 每条消息: {"id", "priority", "content", "tokens"} self.summary = "" # 全局摘要 self.recent_keep = 6 def add_message(self, msg): token_count = estimate_tokens(msg["content"]) msg["tokens"] = token_count self.messages.append(msg) self._slide_window() def _slide_window(self): # 一直压缩到“总 token - 保留区”以内 while self._total_tokens() > self.max_tokens - self.reserve_tokens: # 找到第一个可以被滑走的低/中优先级消息 evict_idx = None for i, m in enumerate(self.messages): if m["id"] in self.hard_fixed_ids: continue # 高优先级永不淘汰 if m["priority"] == "low": evict_idx = i break if evict_idx is None: # 全部都是不可淘汰?那就先压缩摘要,保留最近几条 self.summary = self._update_summary() break evicted = self.messages.pop(evict_idx) self._absorb_into_summary(evicted) # 将大意并入摘要这段代码里最关键的是_absorb_into_summary,需要在踢出消息之前把它的“有价值结论”捕捉进摘要里。我在实际实现中是把原始消息发给一个轻量模型,让它用 50 字总结出“关键事实”,再追加到全局摘要后。成本很低,效果却非常好。
5.3 Context-mode MCP Server:从一个“读数据库 schema”的实例说起
我用 FastMCP 框架实现了一个 schema 查询 server,核心是资源注册和 context mode 判断。代码逻辑大致如下:
@ctx_server.resource("schema://overview") def get_schema_overview(params): # context mode 核心:根据 params 决定返回哪个层级的粒度 request_mode = params.get("context_mode", "overview") if request_mode == "overview": tables = db.list_tables() return { "mode": "overview", "table_count": len(tables), "tables": tables[:80], # 只给清单,不全量 DDL } elif request_mode == "structure": table = params["table"] cols = db.get_columns(table) return { "mode": "structure", "table": table, "columns": cols, # 只给字段名与类型 } elif request_mode == "full_ddl": return db.get_ddl(params["table"]) # 只有明确请求才完整返回这个 server 的元信息设计很重要。每个返回值都带mode字段,模型看到这个字段就知道当前拿到的信息层级,需要更多细节时,它可以按资源地址发第二次请求。实际测试中,模型会自己学会这套交互模式,很少出 bug。
5.4 组装最终 Prompt:把滑动窗口和 MCP 输出拼进一个请求
最后,把前面两个模块的输出合并到最终 prompt 中。我的组装逻辑是:
def assemble_prompt(user_query, memory: ChatMemory, mcp_client: MCPClient): # 1. 从 MCP 获取与当前任务最相关的外部上下文 external_ctx = mcp_client.fetch_context( query=user_query, context_mode="auto", # auto = 由 server 自动判断层级 ) # 2. 从 ChatMemory 取出当前窗口内的消息列表 history_messages = memory.get_visible_messages() # 3. 拼接:系统提示 -> 外部摘要 -> 历史 -> 用户新指令 system_prompt = ( "你是一个编码代理助手。请使用以下外部上下文与历史消息," "完成用户的编码任务。若需要更多详细信息,可以主动调用工具获取。" f"\n\n[全局摘要] {memory.get_summary()}" f"\n\n[外部上下文] {external_ctx}" ) return [{"role": "system", "content": system_prompt}] + history_messages + [ {"role": "user", "content": user_query} ]注意,外部上下文被放在历史消息之前、系统提示之后,这样模型在阅读后续消息时已经有了地图感,不会迷失。
5.5 执行一个真实任务:从“读代码”到“改代码”的全程记录
为了展示这套系统如何生效,我跑了一个具体的任务:“在这个仓库中定位订单金额计算的位置,并修复负数金额被接受的问题。”
实际操作流程如下:
第一步,模型先向 MCP server 请求仓库结构概览。MCP 返回概要层:仓库拥有 12 个模块,订单相关的核心文件是src/order.py、src/payment.py、src/price.py。此时上下文消耗极少。
第二步,模型读取src/price.py的结构层,快速获得了类层次与关键函数签名。它发现金额计算入口是PriceCalculator.calculate(price, quantity)。
第三步,模型请求完整读取calculate方法的具体实现。此时 MCP 返回全量层,模型看到了负数校验缺失的问题所在。
整轮下来,上下文消耗大约 8K token,而如果直接让模型每步都读全量文件,很容易突破 30K。模型在输出修改意见时,引用的代码行都是真实存在的,说明它“看到的”信息足够精确。
6. 实战中我踩过的坑:ChatMemory 与 MCP 的黄金避坑手册
6.1 滑窗乱滑导致“人格分裂”
最开始我的滑动窗口只是单纯的 FIFO 按条数切。结果有一次任务里,用户在前面给出“不要使用 ORM,请直接编写 SQL”的硬性约束,跑了几轮后,这条约束被滑出窗口,模型后面竟然给出一段 ORM 代码,还浑然不觉。从那以后,我把“硬性约束”全部标记为高优先级,永不淘汰。这个踩坑经历也验证了我在 4.2 中说过的优先级分层。
6.2 滑窗的“消息污染”链
还有一个问题是滚动窗口会把工具返回的中间消息当成历史消息,导致上下文里累积了一堆“文件读取结果”。我发现 ChatMemory 有一个隐藏问题:每次模型调用工具读取文件后,工具返回内容都会作为用户消息加入历史,滑动窗口需要区分这些“工具消息”和正常用户消息。如果它只看消息角色就会乱套,必须为消息打上类型标签,比如user_query、tool_call、tool_result、assistant_code。不同类型在窗口滑动时的保留策略也不一样,tool_result是最需要被快速淘汰的,因为它体积通常巨大,而且往往只在下一轮有用。
6.3 MCP 返回 JSON 被当成代码误报
Context-mode MCP 返回的 JSON 结构本身有时会被模型误认为“JSON 配置文件”并尝试修复。解决方案是在返回的元信息里加上显式的类型声明,比如"data_type": "context_summary",让模型不对其做语法修复,也不用它去做代码补全。
6.4 摘要模型不可靠时的兜底方案
实际使用中我发现,summary_every机制依赖轻量模型的摘要能力,但如果摘要模型质量不够,摘要可能丢关键信息。兜底做法是:对于被淘汰的低优先级消息,不直接依赖摘要,而是保留一个“消息指纹”——比如文件名、行号、任务的最近状态。这些结构化信息比自然语言摘要更可靠,丢失概率更低。
6.5 回调循环问题
最后一个坑是代理本身带来的:模型在调用 MCP 工具时,上下文管理同样会触发新的消息传递。如果 ChatMemory 的滑窗是在模型请求结束时触发,而不是在每次工具返回时触发,就可能在下一次工具调用时发现上下文已经超限,进而报错。建议把滑窗压缩放在“任何消息进入之前”,这样可以保证每一次模型调用前,上下文都是正常状态。
7. 我的一些额外心得:上下文工程是一门“经营”手艺
做完这套系统之后,我最大的感悟是:上下文工程不是某个具体算法,而是一套价值观——你希望模型把注意力花在哪里,你就应该在管理上下文中体现这种偏好。
ChatMemory 的滑动窗口本质上是在回答一个问题:“历史记忆中,哪些记忆值得留着”。Context-mode MCP 则是回答另一个问题:“外部信息中,哪些信息该被拿出来”。两者凑在一起,才是完整的上下文生命周期管理:信息的进入、驻留、淘汰,全部有章法。
根据我个人的使用经验,我通常在以下几个场景中最感受到这套系统的价值:
第一个是大型仓库下的跨模块重构。没有上下文优化时,模型经常在修改 A 模块时引用已过时的 B 模块信息。有了滑窗 + MCP 概要层之后,模型的视野被精确控制在一个“因果范围”内,不会东看西看。
第二个是长时间后台自动执行。编码代理经常要跑很长时间,如果任务执行到第 20 步,上下文已经变成一团乱麻,模型基本就开始原地打转了。而滑窗机制保证每步的上下文状态都是干净的,每一步都能扎实往前走。
第三个是成本敏感型应用。个人开发者做起 AI 编码代理实验来,token 费用是实打实的开销。66% 的 token 下降对我来说意味着每个月能省下不少预算,可以用在更值得的地方。
最后再分享一个细节上的小技巧:我习惯在窗口里放一个“可导航的文件索引”消息,里面存着当前仓库的文件树和每个文件的摘要,每条摘要不超过 30 个中文字符。这个索引消息体积小、信息密度高,却能让模型在不需要频繁调用工具的情况下快速决定下一步操作。这个小设计让整个代理的行为变得更“聪明”,推荐你试试。