先说一个让我印象深刻的翻车现场。
当时我在给一个编码代理接入中等规模的Java后端项目,任务是从零迁移一个订单模块。刚开始非常顺利:代理能准确给出模块依赖图、识别出REST Controller和Service的调用链,前5轮修改基本靠谱。但到第8轮,它突然开始重复生成早就被淘汰的旧Controller,还坚持认为某个已经被删除的DTO仍然在用。最让我心态崩掉的是,我一开始就写进系统提示里的约束"Service层禁止直接访问仓储实现",从第10轮起完全失效,它生成的代码几乎次次踩雷。
我花了两天时间反复排查,最终把根因锁在上下文工程上:发送给模型的实际prompt里,早前那些关键约束、模块关系、具体文件路径早就被滑动窗口挤出上下文了。留在窗口里的全是一堆中间态噪声——工具调用的报错堆栈、临时调试输出、无关文件的搜索片段。AI编码代理不是能力不够,是"记忆被垃圾填满了"。
这篇文章我会结合自己在这类系统上的实战,把两条主线讲透:一条是ChatMemory滑动窗口这种经典的对话记忆机制,它的原理、实现方式、以及为什么单纯"记最近"远远不够;另一条是基于Context-mode MCP的上下文优化,如何把上下文管理从"应用层堆料"下沉到"协议层按需供给"。对正在做AI编码代理、智能体或多工具工作流的工程师来说,这两块都是绕不开的硬功夫。
1. 上下文工程为什么是编码代理的隐形天花板
1.1 "模型不行"还是"上下文不行":一个快速判断方法
先说一个快速判断方法。当时我怀疑是模型能力问题,换了更强的模型重跑同样的任务,结果前半程表现确实变好,但到第10轮左右又开始犯同样的"失忆"错误。这就很能说明问题:如果换不同模型后错误形态一致,那大概率是输入端出了问题,而不是模型输出能力出了问题。
我后来做了一个对照实验:同一任务,一组用完整上下文跑,另一组用被驱逐后的上下文跑,两者差异极其明显。完整上下文下代理能准确遵守约束,被驱逐后的代理则频繁猜测。这个实验非常简单,但在排查AI编码代理问题时极其好用——先区分是上下文坏了还是模型不行,再决定优化路径。
1.2 编码场景比聊天场景更难的三个矛盾
AI编码代理和普通聊天助手的最大区别,在于它的"记忆"不止包含对话,还包含文件、依赖关系、工具输出、用户反复确认过的接口契约。要把这么多信息塞进有限的上下文窗口,同时保证关键信息不被冲走,本质上是在解决三个矛盾:
有限窗口与无限项目信息:一个中大型项目有几百个文件、几十万行代码,但上下文窗口只有几万到十几万token。全量注入不现实,只能靠取舍。
时间近邻与逻辑相关:对话记忆天然按时间排列,但编码任务的关联性往往是空间/拓扑的。比如"30分钟前确认过的接口签名"和"当前正在修改的调用方"在时间上隔了很多轮,在逻辑上却是一体的。按时间窗口保留记忆,很容易丢掉真正相关的东西。
信息完整性与token成本:注入越多上下文,token消耗越大,注意力越分散;注入太少,代理又缺乏决策依据。这个平衡点非常微妙。
理解这三个矛盾后你会发现,上下文工程不是简单的"调窗口大小",而是一整套围绕"什么信息值得留下、什么信息可以丢弃、什么信息应该按需获取"的设计。这也是后面所有方案的原点。
2. 从"断尾求生"到"筛选淘汰":ChatMemory滑动窗口的演进
2.1 最朴素的滑动窗口实现与其失效模式
滑动窗口(Sliding Window)是ChatMemory体系中最基础的机制。它的朴素思路很简单:维护一个先进先出的消息队列,总量超过上限时,从最旧的消息开始丢。
import tiktoken from collections import deque class ChatMemoryWindow: def __init__(self, max_tokens: int): self.max_tokens = max_tokens self.records: deque = deque() self.total_tokens = 0 self.enc = tiktoken.encoding_for_model("gpt-4") def add(self, role: str, content: str, meta: dict | None = None): record = { "role": role, "content": content, "meta": meta or {}, "tokens": len(self.enc.encode(content)), } self.records.append(record) self.total_tokens += record["tokens"] # 粗暴策略:超过上限就从头丢 while self.total_tokens > self.max_tokens: dropped = self.records.popleft() self.total_tokens -= dropped["tokens"]这段代码逻辑没错,但用一段时间就会发现问题:被丢掉的往往恰恰是最不该丢的。比如用户在第2轮明确说过"缓存不要用Redis,用进程内Caffeine",这条信息在第3轮被某次大型工具输出挤出窗口后,代理从第4轮开始就频繁生成Redis相关代码。时间最久的信息被无条件优先丢弃,跟"信息最重要"根本不搭边。
2.2 双因子驱逐策略:时间衰减乘以重要性评分
朴素窗口只认时间,不认价值。我开始改造它的第一步,是引入"驱逐评分":不再机械地从头砍掉旧消息,而是对窗口内所有消息打分,然后砍掉综合得分最低的一批。
# 重要性因子:编码场景下的关键信息信号 KEY_TERMS = [ "必须", "禁止", "注意", "契约", "不要用", "接口签名", "依赖方向", "架构约束", "用户偏好", ] def time_decay(record, current_time) -> float: # 时间衰减:越旧的分越低,但衰减不是线性的 age_minutes = (current_time - record["timestamp"]).total_seconds() / 60 return max(0.2, 1.0 / (1.0 + age_minutes / 30.0)) def importance_score(record) -> float: text = record["content"].lower() score = 0.0 score += 2.0 if record["role"] in ("user", "system") else 1.0 score += 3.0 if any(k in text for k in KEY_TERMS) else 0.0 score += 4.0 if record["meta"].get("is_anchor") else 0.0 return score def eviction_candidates(records, budget_ratio=0.2): scored = [] for r in records: # 总分 = 时间衰减 * 重要性 total = time_decay(r, r["timestamp"]) * importance_score(r) scored.append((total, r)) scored.sort(key=lambda x: x[0]) keep = int(len(scored) * (1 - budget_ratio)) return [r for _, r in scored[keep:]]这个改动立竿见影:用户明确表达过的约束被降级驱逐的概率大幅下降。但很快又暴露出新的问题——评分只是个启发式,它无法理解语义。比如"不要在事务里调用远程接口"这类句子,如果措辞不包含强标记词,权重就会偏低。我后来补了一个硬规则:所有记录在写入时可以通过meta标记is_anchor=True来锚定,锚点记录无论多旧都不参与驱逐。
2.3 摘要编译层:窗口之外的第二级记忆
即使有锚点和双因子评分,窗口空间依然有限。我引入的第二层是"摘要编译":当一个高价值记录即将被驱逐、或者窗口占用即将超过阈值时,把一批旧记录交给模型压缩成结构化摘要,然后存到独立摘要存储中。
def compress_records(records: list) -> str: raw = "\n".join(f"{r['role']}: {r['content'][:500]}" for r in records) summary = llm.complete( "请压缩以下对话记录为结构化要点,保留接口签名、" "架构约束、用户明确偏好,忽略调试噪声:\n" + raw ) return summary注意摘要层的设计目标不是"全面",而是"信息密度高"。我一般把摘要分成三个层级:
- 任务级摘要:当前任务的目标、已完成步骤、剩余步骤。
- 会话级摘要:本次会话中用户反复强调的偏好和约束。
- 项目级摘要:跨会话通用的架构决策、目录规则、技术栈约定。
摘要层每次注入模型时也要算token成本,所以它不是用来全量塞进窗口的,而是作为"候选上下文"存在:当新任务需要时,从摘要层检索相关片段注入。简单说,窗口管的是"过程记忆",摘要层管的是"长期记忆"。
2.4 一个容易搞错的点:全局窗口与会话窗口的切分
在编码代理场景里,有一类问题特别容易踩:把"对话轮次"和"工具输出"放进同一个滑动窗口,导致一次大型搜索的输出直接挤掉好几轮关键对话。我的做法是把窗口按职责切分:
- 全局窗口:系统提示、用户核心目标、锚点约束。这部分用最大隔离度保护,驱逐优先级最低。
- 会话窗口:近几轮的对话与决策记录。走双因子驱逐。
- 工具输出区:文件读取、搜索结果、命令输出。这个区域通常单独限制在2k-4k token内,超出后立刻转摘要,不进入主窗口。
切分之后,至少"搜索输出挤掉用户指令"这类惨案不再发生。这算是我在ChatMemory设计上最收益最大的一个改动。
3. 滑动窗口解决不了的错位问题:最近的不等于关键的
3.1 长链路重构中的上下文断裂
滑动窗口本质上是"时间近邻优先"的机制,但编码任务中真正要命的信息往往不是最近的。拿那次订单模块迁移来说:第3轮确认了新模块的命名规范,第15轮代理开始写代码时,它已经完全不记得这个规范了。中间隔的十几轮里塞满了各种探索性的grep、报错、临时思路,真正重要的那个决策反而被淹没。
这就是上下文工程里最经典的错位:时间上的距离,不等于逻辑上的距离。滑动窗口用时间轴组织记忆,但编码任务需要的是按依赖关系组织记忆。当"一个30轮前的架构决策"和"当前正在改的文件"在逻辑上高度相关时,滑动窗口帮不上忙——它只会给你最近的东西。
3.2 摘要漂移:压缩带来的信息失真
摘要层也不是万能的。模型在压缩信息时会有损耗,尤其在经过多轮"摘要的摘要"之后,约束会逐渐失真。我见过一个案例:原始约束是"核心模块禁止依赖基础模块",经过三层压缩后变成"注意核心模块依赖问题",再后来直接变成"模块依赖需要注意"。约束的刚性完全消失了,代理在代码里该违反还是违反。
应对办法是:关键约束必须用锚点而不是摘要来保存。锚点可以是原始文本原样保留,不允许压缩,只允许新增或替换。在我在ChatMemory中的实现里,锚点记录有几个硬性规则——不驱逐、不压缩、不参与token预算淘汰,每次请求时按需加载。代价是如果锚点太多,token占用会激增,所以我一般把锚点上限控制在20-30个,超出后需要人工或模型合并。
3.3 编码场景中最容易被误驱逐的三类高价值上下文
结合我自己的经验,以下三类信息在滑动窗口里最容易"意外消失",必须特殊对待:
- 架构约束:分层规则、依赖方向、禁止反向调用等。这类信息通常只在任务早期出现,但影响贯穿整个任务。
- 接口契约:已经确认过的函数签名、参数顺序、返回结构、数据模型。模型一旦记错,生成的代码会引发连锁编译错误。
- 用户偏好:命名风格、测试框架选择、注释语言。这类偏好往往藏在某轮不起眼的对话里,别人不提它就想不起来。
对于这三类信息,光靠评分权重不够,应该直接走锚点机制。我甚至会为它们单独建一个"契约区",放在系统提示之后最显眼的位置。毕竟在编码代理里,"用户怎么说的"往往比"模型觉得怎么合理"更重要。
4. 下沉到协议层:Context-mode MCP如何改变上下文供给方式
4.1 MCP是来干什么的,为什么它对上下文工程有意义
先简单说下MCP(Model Context Protocol)。它是用来统一AI应用与外部工具、数据源之间通信的协议,核心结构是Host(AI应用,比如编码代理本身)、Client(协议客户端)、Server(工具/数据提供方)。Server把能力暴露为资源和工具,Host通过标准接口调用它们。
在MCP出现之前,编码代理获取项目信息最粗暴的方式是"全量注入":把相关源文件全部读出来,拼进prompt。这种方式对上下文窗口的压力极大,而且文件一大,很多内容模型根本没用到,白白浪费token。MCP不一样的地方在于:它把信息从"跟着prompt走"变成"摆在远端,按需拉取"。Host可以在具体任务需要时,通过资源URI请求某个特定的数据集,而不是把整个仓库都塞进来。
4.2 Context-mode:让服务器成为"上下文提供方"而不是"工具堆"
严格来说,"Context-mode"不是MCP协议里某个强制标准,而是我在实际项目中把MCP能力组织成上下文服务时采用的一种设计模式。它的核心思路是:MCP Server除了暴露工具,还要暴露了一批可以按需查询的"上下文域"(context domain)。
打个比方:传统方式是找个实习生把图书馆所有书都搬到你桌上;Context-mode则是图书馆配一个专业检索员,你问"我要查接口调用链",她给你一张A4纸的结构化摘要,而不是把整本书丢过来。
在我落地过的系统里,Context-mode Server会声明自己的上下文域,比如:
dependencies:模块依赖图、反向依赖、循环依赖检测。api-contract:指定符号的签名、调用方列表、相关测试。architecture-rules:项目级分层约束与模板。error-trace:最近一次失败的堆栈、相关日志摘要。
Host(编码代理)在任务运行中根据当前需求动态订阅或查询这些上下文域。返回的内容不再是文件源码,而是已经加工过的结构化信息,信息密度远高于原生文本。
4.3 一个最小Context-mode服务端的骨架
下面是一个示意性的Server骨架,用来说明Context-mode的代码形态。生产上你可以用官方MCP SDK来实现,我这里的重点是看它的资源路由方式:
# context_mode_server.py import json from mcp.server import Server app = Server("repo-context") @app.resource("context://dependencies/{module}") def dependency_context(module: str): """返回模块的依赖关系摘要,而不是完整源码""" graph = build_dependency_graph(module) return json.dumps({ "direct_deps": graph.direct_dependencies(module, depth=2), "reverse_deps": graph.reverse_dependencies(module), "risk_nodes": graph.find_cycle_nodes(), }) @app.resource("context://api-contract/{symbol}") def api_contract(symbol: str): """返回接口签名、调用方、相关测试,而不是整个文件""" decl = symbol_index.lookup(symbol) return json.dumps({ "signature": decl.signature, "callers": decl.callers[:20], "related_symbols": decl.related[:10], "tests": decl.test_refs[:10], }) @app.resource("context://recent-errors") def recent_errors(): """返回最近失败的编译/测试错误摘要""" return json.dumps(build_error_summary(limit=5))注意这些接口的返回值都是摘要级别的内容。例如查接口契约,返回的是签名、调用方、相关测试;如果模型真需要看函数体,它会再通过普通工具去读取源码。两步配合下来,既保证了信息足够,又不会让上下文窗口被大段源码塞满。
4.4 为什么要用协议层管理,而不是继续在应用层拼字符串
我把Context-mode MCP和传统滑动窗口放在一起对比,核心差异是上下文供给的时机和信息形态:
| 维度 | 应用层堆料(传统方式) | Context-mode MCP |
|---|---|---|
| 获取时机 | 任务开始时一次性注入大量内容 | 执行中按具体需求动态拉取 |
| 信息形态 | 原始文件、完整文本 | 结构化摘要、语义化数据 |
| 信息损耗 | 大量无用token占据窗口 | 信息密度高,按需取用 |
| 失效更新 | 注入后就固定,过期内容难感知 | 可以按订阅/失效机制动态刷新 |
| 与窗口的关系 | 挤压ChatMemory空间 | 主动向窗口"按需投喂" |
在实际项目中,Context-mode MCP对编码代理带来的最大改变,是把"猜测需要什么信息"变成了"查询需要什么信息"。传统方式下,Host只能一次性把所有可能要用到的文件塞进prompt,然后祈祷模型在这堆文件里抓取重点;而Context-mode下,Host在任务执行到特定阶段时可以主动发起查询,并且拿到的还是高度加工后的答案。窗口占用率降下来后,ChatMemory滑动窗口的驱逐压力大幅减小,锚点保护的力度也能更集中。
5. 三层上下文架构在生产项目的落地与参数调优
5.1 三层架构总览
把前文的讨论整合起来,我在生产项目里搭的是三层上下文架构,每一层管不同节奏的记忆:
| 层级 | 载体 | 更新频率 | 典型内容 |
|---|---|---|---|
| 过程层 | ChatMemory滑动窗口 | 每轮对话 | 近期对话、工具输出、临时决策 |
| 长期层 | 锚点+摘要库 | 事件触发 | 用户约束、架构决策、偏好、接口契约 |
| 项目层 | Context-mode MCP | 按需查询 | 依赖图、接口契约快照、错误摘要 |
这三层的配合逻辑很直接:过程层负责"最近发生了什么",长期层负责"用户到底要什么",项目层负责"这个项目长什么样"。任何一层缺位,代理都会出现各种奇怪的失忆症状。
5.2 上下文路由器的实现思路
三层架构不是简单地把信息全塞给模型,而是需要一个路由器来动态组装每一次请求的上下文。我这里的核心逻辑是"先查锚点,再按需拉项目上下文,最后补过程记忆":
class ContextRouter: def __init__(self, window, anchors, mcp_client): self.window = window self.anchors = anchors self.mcp = mcp_client def resolve(self, task: str): parts = [] # 1. 锚点:用户反复强调的约束,永远排在最前 for anchor in self.anchors.match(task_keywords(task)): parts.append(anchor.render()) # 2. 项目层:根据任务语义订阅需要的上下文域 for domain in self.mcp.domains_for(task): parts.append(self.mcp.query(domain)) # 3. 过程层:最近的对话决策与工具输出摘要 parts.append(self.window.render_recent(limit_ratio=0.6)) return "\n\n".join(parts)锚点匹配使用的是关键字加语义向量的双重检索,确保任务进入某个阶段时能激活对应的约束;MCP查询也不是每个任务都把全部上下文域拉一遍,而是根据任务分类(重构、修bug、新增功能)选择不同的订阅集。这套路由器跑下来,token消耗比最初的全量注入方案减少了大概40%,同时约束违反率明显下降。
5.3 几个值得记录的调优参数
以下参数来自我在不同项目里的实测经验,不一定普适,但可以作为起点:
| 参数 | 建议初始值 | 调整依据 |
|---|---|---|
| ChatMemory主窗口 | 6k-12k token | 超过12k后模型对中段信息的敏感度明显下降 |
| 工具输出独立窗口 | 2k-4k token | 只保留提取后的关键行,超限必须摘要化 |
| 摘要触发阈值 | 主窗口用满80%时 | 提前压缩,避免被动驱逐造成关键信息丢失 |
| 锚点数量上限 | 20-30个 | 超过后锚点本身会占用过多token,需合并或替换 |
| 上下文域订阅超时 | 按工具调用粒度 | 长时间任务中定期刷新依赖图等易变化数据 |
其中摘要触发阈值是我最推荐的"低成本高收益"调优点。很多实现是在窗口满了之后才被迫压缩,这时候驱逐已经开始发生,信息已经丢了。提前在80%时主动压缩,可以让摘要层从容地挑重点进行整理。
5.4 上下文交付给模型时的格式约定
同样的信息,以不同格式呈现给模型,效果差异很大。我在实践中总结了三条约定:
- 强约束用独立区块:系统提示中设置"契约区",所有锚点约束放在这个区块里,用分隔线隔开。模型对这里的内容遵循度高得多。
- 结构化优于叙述式:依赖图、接口清单等数据尽量用表格或JSON块输出,而不是写成长段文字。结构化信息更容易被模型"看见"。
- 排序策略要考虑首尾效应:模型通常对开头和结尾的内容记忆更强,因此最重要的锚点放在开头,最新的过程信息放在结尾附近。中间区域放"辅助性"上下文,即使被忽略也不致命。
6. 高频坑位复盘与可观测性设计
6.1 上下文重复注入会让模型"选择困难"
三层架构引入后,我一度很兴奋,觉得信息越全越好。结果发现同一个约束在锚点区、摘要区和MCP返回中出现了三遍,模型反而开始犹豫,甚至在代码里出现自相矛盾的处理。重复注入不等于加强约束,它只是在稀释注意力。我的修正方式是单源性原则:每条约束只在一处保存,其他位置用唯一的引用ID代替。比如锚点区出现约束后,摘要区只保留"该约束详见锚点#7",不再复制原文。
6.2 长工具输出是窗口的最大杀手
编码代理特别依赖grep、find这类命令,但一次搜索返回上千行结果是常事。如果不做任何加工直接丢进窗口,无论你的滑动窗口设计得多好,都会被瞬间打爆。我的处理方案是在工具调用和窗口之间插入一层"信息提取器":先让一个轻量模型把原始输出压缩成关键行列表,再进入窗口。比如搜索"谁调用了Utils.getId",输出直接提取为"调用方:OrderService.java:42, PaymentService.java:117"。这一层有个额外好处:原始输出留在日志里,需要查细节时仍然可以追溯。
6.3 驱逐日志是排查所有上下文问题的"黑匣子"
如果你在给代理添加日志时只记录最终prompt,那当代理表现异常时,你根本不知道哪个信息是被谁挤掉的。我强烈建议在ChatMemory里记录结构化的驱逐事件:
{ "ts": "2025-06-12T10:23:41Z", "event": "eviction", "trigger": "window_full", "dropped_record_id": "msg_8821", "dropped_type": "tool_output", "drop_reason": "importance_low + oldest", "summary_ref": "sum_452_created" }有了这些日志,回放"代理为什么忘掉某条约束"就变成了一条清晰的因果链:某时刻某条记录因为什么原因被驱逐,是否生成了摘要。我自己在搭建初期,靠这个黑匣子发现了至少三类隐藏问题:工具输出意外占用主窗口、锚点没有正确激活、摘要ID引用断裂。
6.4 一点个人体会
上下文管理做到最后,我认为核心逻辑并不复杂:编码代理最需要的不是"记住一切",而是在正确的时间把正确的信息以正确的形态送到模型面前。滑动窗口管的是过程的连续性,锚点管的是约束的刚性,MCP按需拉取管的是项目知识的可得性。这三层协同好了,哪怕模型不变,编码代理的整体表现都会上一个台阶。
最后分享一个经验:如果你正在改造自己的编码代理,第一优先级永远是加可观测性——先搞清楚上下文里到底有什么,再谈优化策略。我见过太多人一上来就调窗口、调摘要、调路由,结果因为缺少日志,连问题在哪一层都定位不到。先把驱逐日志和上下文构成统计做了,你会发现自己对"上下文工程"的理解会清晰非常多。