1. 为什么上下文管理成了 AI 编码代理的命门
1.1 从一个真实痛点说起
过去大半年我一直在折腾各种 AI 编码代理,从最早的简单补全工具,到后来能自主读写文件、跑命令、调工具的智能体,踩的坑可以说一箩筐。但真正让我头疼的,从来不是模型本身够不够聪明,而是上下文管理这件事。你肯定也遇到过:让代理改一个跨了七八个文件的 bug,它改到第三个文件的时候,已经把第一个文件里刚定义的接口名忘得一干二净;或者一个会话聊到几十轮,前面聊过的架构约定全被挤出了窗口,代理开始胡编乱造。
这就是Context Mode想要解决的核心问题。简单说,它是一套专门为 AI 编码代理设计的上下文管理范式,把原本塞在对话历史里的信息,重新组织成结构化、可检索、可持久化的状态。它要回答的问题是:当代理需要记住的东西远超模型上下文窗口时,我们到底该怎么存、怎么取、怎么用?
这套东西适合谁?如果你只是偶尔用 AI 写个函数,那可能感受不深。但只要你做过稍微复杂点的项目——多文件重构、长周期任务、需要跨会话延续的工程——你就会发现上下文管理才是决定代理能不能真正干活的分水岭。下面我结合自己实际搭过的一套方案,把 Context Mode 的思路、实现和坑一次性讲透。
1.2 传统上下文方案的三个死结
在讲 Context Mode 之前,得先说清楚老办法为什么不行。目前主流的上下文处理无非三种:全量塞进对话历史、滑动窗口截断、以及简单的摘要压缩。这三种我都用过,各有各的死结。
全量塞入最直接,把所有文件内容、历史对话一股脑丢给模型。问题是 token 消耗爆炸,一个中等项目随便就几十万 token,成本高不说,模型还会因为信息过载而抓不住重点,出现所谓的“lost in the middle”现象——中间部分的信息基本被忽略。
滑动窗口就是只保留最近 N 轮对话。这个方案便宜,但代价是代理彻底失忆。我实测过一个场景:让代理先读完项目结构,再改代码,结果窗口一滑,项目结构没了,它开始瞎猜文件路径。
摘要压缩看起来聪明,用一个小模型把历史总结成几句话。但摘要是有损的,尤其是代码里的精确信息——变量名、函数签名、类型定义——一旦被“总结”掉,代理就再也拿不回来了。我见过摘要把getUserById和getUserByEmail合并成“用户查询函数”,然后代理调用时直接报错。
Context Mode 的思路,本质上是跳出“对话历史”这个框,把上下文当成一个外部可管理的数据库来对待。
2. Context Mode 的核心设计思路拆解
2.1 把上下文从“对话”变成“状态”
Context Mode 最关键的一个转变,是不再把所有信息都当成对话消息来传递。它引入了一个概念:上下文是一组有结构的状态,而不是一串线性的文本。这个区别听起来抽象,但落地之后差别巨大。
打个比方,传统对话历史像是一本流水账日记,你只能从头往后翻;而 Context Mode 更像是一个带索引的档案柜,每个抽屉里放着特定类型的信息——项目结构、当前任务、已修改文件、待办事项、关键决策——需要哪个就拉开哪个。代理每次行动前,不是把整本日记读一遍,而是按需查询档案柜。
这个设计直接解决了前面说的三个死结:token 消耗可控(只取需要的)、不会失忆(信息持久化在外部)、不会丢精度(存的是原始结构化数据,不是摘要)。
2.2 为什么选 SQLite 作为存储底座
热词里反复出现 SQLite,这不是偶然。Context Mode 这类方案落地时,存储选型基本都会落到 SQLite 上,原因很实在。
第一,零运维。SQLite 是单文件数据库,不需要起服务、不需要配连接池,代理进程直接读写一个.db文件就行。对于本地运行的编码代理来说,这是最省事的选择。你在 Linux 下装它也就一条命令的事,sudo apt install sqlite3或者yum install sqlite,Windows 和 macOS 更是自带。
第二,查询能力够用。上下文检索经常需要按类型过滤、按时间排序、按关键词模糊匹配,这些 SQL 都能优雅解决。相比自己写个 JSON 文件然后全量加载,SQLite 的索引能让十万条数据的查询控制在毫秒级——我实测过,十万条记录带索引的LIKE查询大概 20 到 50 毫秒,完全够用。
第三,生态成熟。调试的时候用 DB Browser for SQLite 直接打开.db文件可视化查看,比翻日志舒服太多。而且 SQLite 的触发器、回调机制(callback)能让我们在数据变更时自动触发上下文更新逻辑,这点后面会细讲。
当然,如果你的场景是多人协作、需要远程访问,那 PostgreSQL 可能更合适。但对单机编码代理来说,SQLite 的性价比无可替代。
2.3 MCP 在其中的角色定位
热词里 MCP 出现频率极高,这里必须说清楚它和 Context Mode 的关系。MCP(Model Context Protocol)本质上是一套标准化的接口协议,让 AI 代理能以统一的方式调用外部工具和数据源。你可以把它理解成“代理和外部世界之间的 USB 接口”。
Context Mode 和 MCP 是互补的:Context Mode 定义了上下文该怎么组织和存储,MCP 则提供了代理访问这套上下文的标准化通道。代理不需要知道底层是 SQLite 还是别的什么,它只需要通过 MCP 暴露的工具——比如query_context、update_context、search_files——来读写上下文。
这个分层很关键。它意味着你的上下文存储可以随时替换,而代理侧的调用逻辑不用改。我一开始是自己写函数直接读 SQLite,后来改成 MCP 工具暴露,代理的提示词几乎没动,维护成本一下就降下来了。
3. 核心细节解析与实操要点
3.1 上下文的数据模型怎么设计
这是整个方案的地基,设计不好后面全是坑。我踩过几轮之后,总结出一套比较稳的表结构。核心是几张表:context_items存通用上下文条目,file_snapshots存文件快照,task_state存任务进度,decisions存关键决策记录。
CREATE TABLE context_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, session_id TEXT NOT NULL, item_type TEXT NOT NULL, -- 'fact' | 'constraint' | 'reference' content TEXT NOT NULL, embedding BLOB, -- 可选,向量检索用 created_at INTEGER NOT NULL, expires_at INTEGER -- 可选,过期自动清理 ); CREATE INDEX idx_session_type ON context_items(session_id, item_type); CREATE INDEX idx_created ON context_items(created_at);这里有几个设计要点值得说。item_type字段把上下文分了类,代理查询时可以只取某一类,避免无关信息干扰。expires_at是个很实用的字段,有些临时上下文——比如“当前正在看的这个文件”——过一段时间就该失效,自动清理能防止数据库无限膨胀。
file_snapshots表我建议存增量而不是全量。每次文件变更只存 diff,配合一个基线版本,能省下大量空间。我一开始傻乎乎地每次存整个文件,一个项目跑两天数据库就上百兆,改成增量后压到了几兆。
3.2 检索策略:什么时候查、查什么
上下文管理最难的不是存,是取。代理在每一步行动前,到底该给它喂哪些上下文?喂多了浪费 token,喂少了它犯错。
我的做法是分三层检索。第一层是强制上下文,比如当前任务描述、项目根目录结构、编码规范,这些每次都必须带上,量小但关键。第二层是相关性检索,根据当前操作的文件路径、正在编辑的符号名,去数据库里查相关的历史决策和文件快照。第三层是按需拉取,代理自己判断需要什么,主动通过 MCP 工具查询。
def build_context(session_id, current_file, current_symbol): ctx = [] # 第一层:强制上下文 ctx += query("SELECT content FROM context_items WHERE session_id=? AND item_type='constraint'", session_id) # 第二层:相关性检索 ctx += query(""" SELECT content FROM context_items WHERE session_id=? AND content LIKE ? ORDER BY created_at DESC LIMIT 10 """, session_id, f"%{current_symbol}%") return ctx注意:相关性检索的
LIKE匹配很容易误召回,比如搜user会把username、userService、superuser全带出来。如果数据量大,建议上 FTS5 全文索引或者向量检索,精度会好很多。
3.3 上下文写入的时机与去重
写入时机同样讲究。我的经验是在关键节点写入,而不是每步都写。关键节点包括:完成一个子任务、做出一项技术决策、修改了公共接口、遇到并解决了一个错误。这些信息才值得持久化,日常的琐碎操作写进去只会污染数据库。
去重是另一个必须处理的问题。代理很容易反复记录同一件事,比如每次读文件都记一条“读了 xxx 文件”。我的做法是给context_items加一个内容哈希字段,写入前先查哈希是否存在,存在就更新created_at而不是新增。
ALTER TABLE context_items ADD COLUMN content_hash TEXT; CREATE UNIQUE INDEX idx_hash ON context_items(session_id, content_hash);这样配合INSERT OR REPLACE,天然去重。实测下来数据库增长速度能降一半以上。
4. 实操过程与核心环节实现
4.1 环境搭建与数据库初始化
先把环境搭起来。Linux 下装 SQLite 就一行命令,Windows 和 macOS 一般自带,没有的话去官网下个预编译包解压即可。调试工具强烈推荐 DB Browser for SQLite,可视化查看和编辑.db文件,比命令行舒服太多。
初始化脚本我一般写成一个 Python 文件,跑一次就把表建好:
import sqlite3 def init_db(path="context.db"): conn = sqlite3.connect(path) conn.execute("PRAGMA journal_mode=WAL") # 并发读写更稳 conn.execute("PRAGMA synchronous=NORMAL") # 性能与安全的平衡 with open("schema.sql") as f: conn.executescript(f.read()) conn.commit() return connWAL模式这里必须开。默认的 journal 模式在代理频繁读写时容易锁库,开了 WAL 之后读写可以并发,实测卡顿明显减少。synchronous=NORMAL是在性能和掉电安全之间取平衡,本地开发够用了。
4.2 通过 MCP 暴露上下文工具
接下来把上下文操作包装成 MCP 工具。核心就三个:查询、写入、搜索。下面是一个简化的工具定义示例:
@mcp_tool("query_context") def query_context(session_id: str, item_type: str = None, limit: int = 20): sql = "SELECT content FROM context_items WHERE session_id=?" params = [session_id] if item_type: sql += " AND item_type=?" params.append(item_type) sql += " ORDER BY created_at DESC LIMIT ?" params.append(limit) return [row[0] for row in conn.execute(sql, params)] @mcp_tool("save_context") def save_context(session_id: str, item_type: str, content: str): h = hashlib.sha256(content.encode()).hexdigest() conn.execute(""" INSERT OR REPLACE INTO context_items (session_id, item_type, content, content_hash, created_at) VALUES (?, ?, ?, ?, ?) """, (session_id, item_type, content, h, int(time.time()))) conn.commit()工具描述要写得让代理一看就懂什么时候用。比如save_context的描述我会写“当你做出一项会影响后续工作的决策,或发现一个重要的项目约束时调用”。描述写得好,代理的调用准确率能提升一大截。
4.3 上下文注入到提示词的完整流程
代理每一轮行动前,都要走一遍“构建上下文 → 注入提示词 → 执行 → 回写上下文”的循环。这个循环的实现质量直接决定代理表现。
def agent_step(session_id, user_input, current_file): # 1. 构建上下文 ctx = build_context(session_id, current_file, extract_symbol(user_input)) # 2. 组装提示词 prompt = render_template("agent_prompt.j2", context=ctx, input=user_input) # 3. 调用模型 response = call_model(prompt) # 4. 解析动作并执行 action = parse_action(response) result = execute_action(action) # 5. 关键节点回写 if is_milestone(action, result): save_context(session_id, "fact", summarize(action, result)) return result这里is_milestone的判断逻辑是核心。我的实现是:动作类型属于“修改公共接口”“新增依赖”“解决报错”这几类时算里程碑,其余不算。这个规则可以根据项目特点调整,但原则不变——只存真正影响后续的信息。
4.4 参数选择与容量估算
很多人关心数据库会涨多大、查询会不会变慢。我拿一个真实项目测过:一个约 5 万行的代码库,代理连续工作 8 小时,产生约 3 万条上下文记录,数据库文件 12MB 左右。带索引的查询稳定在 10 到 30 毫秒。
如果你担心十万条数据的查询性能,可以这样估算:SQLite 单表在千万级数据量下,带索引的点查依然是毫秒级,全表扫描才会明显变慢。所以关键是别做全表扫描,所有查询都走索引。我一般会定期跑ANALYZE让查询优化器更新统计信息,偶尔VACUUM一下回收空间。
提示:如果上下文条目超过几十万,建议按
session_id或时间分区归档,把老会话的数据挪到单独的库文件,主库保持轻量。
5. 常见问题与排查技巧实录
5.1 代理“记不住”刚说过的话
这是最常见的问题。表现是代理在同一个会话里,前面刚确认的接口约定,后面就违反了。排查思路分三步:先确认信息有没有真的写进数据库,用 DB Browser 打开看看;再确认检索时有没有被查出来,手动跑一遍查询 SQL;最后确认查出来的内容有没有被正确注入提示词,打印最终 prompt 检查。
我遇到最多的情况是写进去了但没查出来,原因是检索用的关键词和存储时的表述对不上。解决办法是在写入时同时存一份关键词标签,检索时优先匹配标签。
5.2 数据库锁死导致代理卡住
多进程或高频写入时容易遇到database is locked。根因通常是没开 WAL 模式,或者有长事务没提交。解决方法是开 WAL、缩短事务、给写入加超时重试。
conn = sqlite3.connect("context.db", timeout=10) # 10秒超时如果还是频繁锁,检查是不是有代码忘了commit。我踩过一次,一个异常分支里没提交事务,导致后面所有写入全堵住,排查了半天。
5.3 上下文污染导致代理跑偏
有时候代理会突然开始做一些莫名其妙的事,追根溯源往往是上下文里混进了错误或过期的信息。比如某个早期决策后来被推翻了,但旧记录还在,检索时又被捞出来。
解决办法是给上下文条目加状态标记,被推翻的标记为superseded,检索时过滤掉。同时定期清理过期条目,别让数据库变成垃圾场。
| 问题现象 | 可能原因 | 排查方法 | 解决手段 |
|---|---|---|---|
| 代理失忆 | 信息未写入或未检索到 | 查库、手动跑 SQL | 补写入、优化检索关键词 |
| 数据库锁死 | 未开 WAL 或长事务 | 看错误日志 | 开 WAL、缩短事务、加超时 |
| 代理跑偏 | 上下文污染 | 检查检索结果 | 加状态标记、定期清理 |
| 查询变慢 | 全表扫描 | EXPLAIN 看执行计划 | 加索引、ANALYZE、VACUUM |
| 数据库膨胀 | 重复写入、存全量 | 看文件大小增长 | 哈希去重、存增量 |
5.4 几个独家避坑心得
第一,别迷信向量检索。很多人一上来就上 embedding,觉得语义检索高级。但代码场景里精确匹配往往更重要,getUserById和getUserByEmail语义上很近,实际用起来必须区分。我的做法是精确匹配为主,向量检索只作为补充召回。
第二,上下文要有优先级和预算。给代理的上下文不是越多越好,我会设一个 token 预算,比如 8000 token,超了就按优先级砍。强制上下文永远保留,相关性检索的按分数排序截断。
第三,定期做上下文“体检”。每周跑一次脚本,统计各类上下文的数量、命中率、过期比例,能提前发现很多问题。我靠这个发现过某类上下文从来没被检索到,纯属浪费空间。
第四,MCP 工具的描述比实现更重要。代理能不能正确调用工具,八成取决于描述写得好不好。描述里要明确“什么时候用”“什么时候不用”,最好给一两个例子。
6. 上下文管理的扩展方向
6.1 跨会话的上下文继承
单会话的上下文管理做扎实之后,自然会想跨会话继承。比如今天没做完的任务,明天接着做,代理应该记得昨天的进度和决策。实现上就是给session_id加一个父子关系,新会话启动时把父会话的关键上下文加载进来。
这里要注意继承的粒度。全量继承会让新会话一开始就背上一堆历史包袱,我一般只继承decisions和未完成的task_state,文件快照按需重新生成。
6.2 多代理共享上下文
如果你同时跑多个代理处理不同模块,它们之间的上下文共享就很有价值。一个代理改了公共接口,另一个代理应该立刻知道。这需要把上下文存储从本地 SQLite 升级到支持并发的方案,或者用 SQLite 的 WAL 模式配合文件锁做轻量共享。
我试过让两个代理共享一个 SQLite 库,WAL 模式下基本能用,但写入冲突还是偶发。如果并发要求高,PostgreSQL 会更稳,代价是运维复杂度上升。
6.3 上下文的可视化与调试
最后说个提效的点:给上下文做个可视化面板。我用一个简单的 Web 页面,实时展示当前会话的上下文条目、检索命中情况、token 占用。调试代理行为的时候,有这个面板能省下大量时间——你能一眼看出代理到底“看到”了什么,而不是靠猜。
这套东西搭起来不复杂,但价值极高。毕竟上下文管理做得好不好,最终还是要靠可观测性来验证。我在实际项目里,正是靠这个面板发现了好几个隐蔽的检索 bug,否则光看代理的输出根本定位不到问题根源。