ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

AI编码代理上下文管理:基于MCP与SQLite的重构范式

AI编码代理上下文管理:基于MCP与SQLite的重构范式

1. 为什么"上下文管理"成了 AI 编码代理的生死线

过去一年我一直在折腾各种 AI 编码代理,从最早的补全插件到后来的自主 Agent,踩过的坑能写一本书。但真正让我意识到问题严重性的,是去年接手一个中型项目重构的时候——代码库大概有 12 万行,涉及 8 个微服务模块。我兴冲冲地把整个仓库丢给一个当时口碑不错的编码代理,结果它给出的修改建议里,有一半引用了根本不存在的函数名,还有几个文件路径完全是幻觉产物。

问题出在哪?不是模型不够聪明,而是上下文管理彻底崩了。

你可以把 AI 编码代理想象成一个刚入职的实习生,能力很强但记性有限。如果你一次性把公司所有文档、所有代码、所有会议记录全塞给他,他反而什么都记不住,还会把不同项目的信息混在一起。Context Mode 要解决的就是这个核心矛盾:如何在有限的上下文窗口里,让代理始终拿到"当前任务最需要的那部分信息"。

这个标题里的"重构上下文管理范式",说的不是小修小补,而是从底层重新思考:代理应该记住什么、忘记什么、什么时候去查、查完怎么用。结合热词里频繁出现的 MCP、SQLite 这些关键词,我基本可以判断,这套方案的核心思路是用结构化的外部存储替代无脑的上下文堆砌,让代理像人类工程师一样,需要什么就去查什么,而不是把所有东西都背在脑子里。

这篇文章我会从设计思路、核心机制、实操落地、问题排查四个维度,把这套范式拆开讲透。适合正在自建编码代理的开发者、被上下文窗口折磨过的技术负责人,以及想理解 MCP 生态到底怎么落地的人。读完你应该能自己搭一套可用的上下文管理系统,而不是停留在概念层面。

2. 上下文管理的核心矛盾与设计取舍

2.1 传统方案的三个致命伤

在讲 Context Mode 之前,得先说清楚老办法为什么不行。我总结下来,传统上下文管理有三个绕不过去的坑。

第一个坑是全量注入。很多代理的做法简单粗暴:把相关文件全部读进来,拼成一个巨大的 prompt。代码库小的时候没问题,一旦超过几万行,token 消耗直接爆炸。我实测过一个 5 万行的 Python 项目,全量注入单次请求就要 18 万 token,成本高不说,模型对中间部分的注意力还会明显衰减——这就是所谓的"lost in the middle"现象,开头和结尾记得清楚,中间的内容基本被忽略。

第二个坑是检索粒度太粗。有些方案用向量检索,把代码切成块存进向量库,需要时按相似度召回。听起来很美,但代码不是自然语言,一个函数的语义往往依赖它所在的类、导入的模块、调用的其他函数。你按 512 token 切块,召回来的片段经常缺胳膊少腿,代理拿到半截代码根本没法用。

第三个坑是状态丢失。代理执行多步任务时,前面步骤的结论、中间产生的临时变量、已经排除的错误方向,这些信息如果没有持久化,下一步就得重新推导。我见过最离谱的情况是代理在同一个任务里反复读同一个文件七八次,每次都从头解析,效率低得令人发指。

2.2 Context Mode 的核心思路:把记忆外包出去

Context Mode 的破局点很清晰:不要试图让模型记住一切,而是给它一套高效的外部记忆系统。

这个思路借鉴了人类工程师的工作方式。你写代码的时候,不会把整个项目背在脑子里,而是靠 IDE 的跳转、grep 搜索、文档查阅来按需获取信息。Context Mode 就是把这种"按需获取"机制工程化,用 MCP 作为代理和外部工具之间的标准接口,用 SQLite 作为轻量级的结构化存储。

为什么选 SQLite 而不是别的?我分析下来有几个现实考量。第一,它是单文件数据库,零配置,代理启动时不需要额外起服务,这对本地开发场景太友好了。第二,SQL 查询能力足够强,代理可以用自然语言转成 SQL 来精确检索代码符号、调用关系、修改历史。第三,它足够轻,十万条记录的查询在毫秒级完成,完全不会成为性能瓶颈。热词里有人问"sqlite 查询十万条数据需要多久",我可以负责任地说,建好索引的情况下,单表十万行的条件查询通常在 5 到 20 毫秒之间,比网络请求快几个数量级。

MCP 在这里扮演的角色是协议层。它定义了代理怎么调用外部工具、怎么传递参数、怎么接收结果。有了 MCP,上下文管理系统就不用为每个代理框架单独适配,任何支持 MCP 的客户端都能接进来。这也是为什么热词里 MCP 相关的内容这么多——它正在成为 AI 工具集成的事实标准。

2.3 方案选型的关键权衡

设计这套系统时,有几个权衡点必须想清楚,我把自己当时的决策过程分享一下。

存储选型上,SQLite vs 向量库。我最终选了 SQLite 为主、向量检索为辅的混合方案。纯向量库的问题前面说了,粒度难控;纯 SQLite 又缺乏语义模糊匹配能力。实际做法是:代码符号、文件结构、调用关系这些结构化信息进 SQLite,用精确查询;注释、文档、自然语言描述这些进向量库,用相似度召回。两者通过文件路径和符号 ID 关联。

更新策略上,全量重建 vs 增量更新。全量重建简单可靠,但大项目一次要几分钟。增量更新快,但容易因为文件监听遗漏导致索引和实际代码不一致。我的选择是:首次全量建索引,之后用文件系统监听做增量,同时每天定时做一次全量校验。这个组合在实测中把索引不一致的概率压到了很低。

上下文组装上,固定预算 vs 动态预算。固定预算就是每次给代理塞固定大小的上下文,简单但浪费。动态预算根据任务复杂度调整,复杂任务多给,简单任务少给。我倾向动态方案,具体做法是先用一个轻量模型判断任务类型,再决定召回多少内容。

3. 核心机制拆解:MCP 与 SQLite 如何协同

3.1 MCP 工具层的设计要点

MCP 本质上是一套让 AI 代理调用外部能力的标准协议。在 Context Mode 里,我把它设计成几个核心工具,每个工具职责单一,参数明确。

第一个工具是search_symbol,输入符号名或模糊描述,返回匹配的函数、类、变量的定义位置和签名。这个工具背后是一条 SQL 查询,走的是符号表的索引。为什么要单独做这个工具而不是让代理直接写 SQL?因为代理写 SQL 容易出错,而且不同数据库方言不一样,封装成工具后代理只需要传自然语言参数,稳定性和可移植性都更好。

第二个工具是get_context,输入文件路径和行号范围,返回该区域的代码以及相关的导入、调用者、被调用者信息。这个工具解决的就是前面说的"检索粒度"问题——不是简单返回一段代码,而是把这段代码的依赖关系一起带出来。

第三个工具是record_finding,让代理把当前步骤的结论写回数据库。比如代理发现"函数 A 在模块 B 中被调用了 3 次",这条信息就存下来,后续步骤可以直接查,不用重新分析。

第四个工具是query_history,查询某个文件或符号的修改历史。这个在排查回归问题时特别有用,代理可以知道"这段代码上周被改过",从而优先怀疑最近的改动。

提示:工具设计的第一原则是"代理不需要知道底层实现"。如果代理需要理解 SQL 表结构才能用你的工具,那这个工具就是失败的。

3.2 SQLite 表结构设计

表结构直接决定了查询效率和信息完整性。我踩过几次坑之后,最终定下来的核心表有这么几张。

symbols表存所有代码符号,字段包括 id、name、kind(函数/类/变量)、file_path、start_line、end_line、signature、parent_id。parent_id 用来表达嵌套关系,比如类里面的方法。这张表在 name 和 file_path 上建索引,模糊查询走 name 的前缀索引。

relations表存符号之间的关系,字段有 from_id、to_id、relation_type(calls/imports/inherits)。这张表是调用关系分析的核心,代理问"谁调用了这个函数"时,一条 SQL 就能答出来。

files表存文件元信息,包括路径、语言、最后修改时间、内容哈希。内容哈希用来判断文件是否变化,避免重复解析。

findings表存代理的中间结论,字段有 task_id、content、created_at、confidence。这个表让代理的推理过程可追溯,也方便多轮任务之间共享状态。

embeddings表存向量,字段有 symbol_id、vector、model_name。向量用 BLOB 存储,查询时先按 symbol_id 关联回 symbols 表拿到元信息。

建索引的时候有个细节要注意:relations表的 from_id 和 to_id 都要单独建索引,因为查询方向可能是双向的。我一开始只建了 from_id 的索引,结果反向查询慢得离谱,加上 to_id 索引后性能提升了十几倍。

3.3 上下文组装算法

有了存储和工具,接下来最关键的是怎么把召回的信息组装成给模型的上下文。这一步做不好,前面所有工作都白费。

我的组装算法分四步。第一步是任务解析,用一个轻量模型把用户请求拆成"目标符号"和"操作类型"。比如"给 UserService 的 login 方法加个日志"会解析出目标符号是UserService.login,操作类型是修改。

第二步是种子召回,根据目标符号从 symbols 表拿到定义,从 relations 表拿到直接调用者和被调用者,从 files 表拿到所在文件。这一步拿到的是"核心上下文"。

第三步是扩展召回,根据核心上下文里的符号,再往外扩一层。比如 login 方法调用了validatePassword,那就把validatePassword的定义也拉进来。扩展层数可以配置,一般两层足够,再多就会引入噪音。

第四步是预算裁剪,把所有召回内容按重要性排序,从高到低填充到 token 预算里。重要性排序的规则是:目标符号 > 直接调用关系 > 同文件符号 > 扩展关系。超出预算的部分直接丢弃,并在上下文里注明"还有 N 个相关符号未展示"。

这个算法实测下来,在 5 万行项目上,单次任务的上下文大小能控制在 8000 token 以内,比全量注入省了 95% 以上,而代理的准确率反而更高,因为它拿到的都是真正相关的信息。

4. 从零搭建:完整实操流程

4.1 环境准备与依赖安装

先把基础环境搭起来。我用的技术栈是 Python 3.11 + SQLite 3.40 + 一个 MCP 服务框架。SQLite 在 Linux 下的安装很简单,Ubuntu 系直接sudo apt install sqlite3 libsqlite3-dev,CentOS 系用sudo yum install sqlite sqlite-devel。装完用sqlite3 --version确认版本,建议 3.35 以上,因为要用到一些新的 JSON 函数。

Python 侧需要装几个库:mcp用于实现 MCP 服务,tree-sitter用于代码解析,sqlite-utils简化数据库操作,watchdog做文件监听。用 pip 一把装完:

pip install mcp tree-sitter tree-sitter-languages sqlite-utils watchdog

tree-sitter 是这套方案的关键依赖,它能把代码解析成语法树,我们才能准确提取符号和关系。它支持的语言很多,Python、JavaScript、Go、Rust 都有现成的语法定义。如果你的项目语言比较小众,可能需要自己写语法定义,这个成本要提前评估。

数据库文件我建议放在项目根目录的.context/下,命名为index.db。这个目录加到.gitignore里,避免索引文件被提交。初始化的时候用 sqlite-utils 建表:

import sqlite_utils db = sqlite_utils.Database(".context/index.db") db["symbols"].create({ "id": int, "name": str, "kind": str, "file_path": str, "start_line": int, "end_line": int, "signature": str, "parent_id": int }, pk="id") db["symbols"].create_index(["name"]) db["symbols"].create_index(["file_path"])

其他表类似建法,注意索引一定要在建表后立刻加上,等数据灌进去再加索引会慢很多。

4.2 代码解析与索引构建

索引构建是整个系统里最耗时的环节,也是最容易出问题的环节。我的做法是分两阶段:先扫描文件系统收集文件列表,再逐个解析。

文件扫描阶段要注意排除目录。node_modules、.git、venv、__pycache__这些必须跳过,否则索引会爆炸。我用一个白名单加黑名单的组合:白名单是.py、.js、.ts、.go、.rs这些源码后缀,黑名单是上面那些目录名。

解析阶段用 tree-sitter 遍历语法树,遇到函数定义、类定义、变量声明就提取出来存进 symbols 表。提取的时候要记录 start_line 和 end_line,这个后面做上下文召回时要用。关系提取稍微复杂一点,需要在语法树里找调用表达式、导入语句、继承声明,然后解析出符号名,再通过符号表反查 ID。

这里有个坑要提醒:跨文件的关系解析需要两遍扫描。第一遍只建符号表,第二遍才解析关系,因为解析关系时需要查其他文件的符号 ID,如果符号表还没建完就查不到。我第一版没注意这个,导致跨文件调用关系全丢了,排查了半天才发现是顺序问题。

索引构建的代码大概长这样:

from tree_sitter import Language, Parser import tree_sitter_languages parser = Parser() parser.set_language(tree_sitter_languages.get_language("python")) def index_file(path): with open(path, "rb") as f: tree = parser.parse(f.read()) # 遍历 tree.root_node,提取符号和关系 # 具体遍历逻辑根据语言语法调整 ...

十万行代码的项目,全量索引大概需要 30 到 60 秒,取决于机器性能。这个时间可以接受,因为只在首次构建和每日校验时执行。

4.3 MCP 服务实现与接入

MCP 服务的实现要遵循协议规范,核心是定义工具列表和处理函数。我用官方 Python SDK 来写,结构很清晰:

from mcp.server import Server from mcp.types import Tool, TextContent app = Server("context-mode") @app.list_tools() async def list_tools(): return [ Tool(name="search_symbol", description="按名称搜索代码符号", inputSchema={"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}), Tool(name="get_context", description="获取指定位置的上下文", inputSchema={"type": "object", "properties": {"file_path": {"type": "string"}, "line": {"type": "integer"}}, "required": ["file_path", "line"]}), # 其他工具... ] @app.call_tool() async def call_tool(name, arguments): if name == "search_symbol": results = search_symbols(arguments["query"]) return [TextContent(type="text", text=format_results(results))] # 其他工具分发...

接入客户端的时候,不同工具配置方式不一样。以常见的桌面客户端为例,需要在配置文件里加上 MCP 服务器地址和启动命令。配置项一般包括 command、args、env 三部分。command 是启动命令,args 是参数,env 是环境变量。我建议把数据库路径通过 env 传进去,这样不同项目可以用不同的索引文件。

注意:MCP 服务启动后要确认客户端能正确握手。如果工具列表拉不出来,先检查服务是否正常监听,再看协议版本是否匹配。我遇到过客户端和服务端协议版本差一个小版本导致握手失败的情况,升级到同版本就好了。

4.4 增量更新与一致性保障

索引建好之后,代码还在不断变化,必须有一套增量更新机制。我用 watchdog 监听文件变化,文件保存后触发重新解析,更新对应的符号和关系记录。

增量更新的难点在于删除和重命名的处理。文件删了,对应的符号和关系要清理;符号改名了,引用它的地方要更新。我的做法是:文件变化时,先按 file_path 删掉该文件的所有符号和关系,再重新解析插入。这样虽然有点暴力,但保证了正确性,而且单文件重解析很快,几百毫秒的事。

一致性校验我放在每天凌晨跑一次,全量重新解析,和数据库里的记录对比,发现不一致就修正。这个校验能兜住文件监听遗漏的情况,比如某些编辑器保存时触发的文件系统事件不规范。

5. 常见问题与排查技巧实录

5.1 索引相关的高频问题

问题一:索引构建卡住不动。最常见的原因是遇到了超大文件或者解析器死循环。我遇到过一次,某个自动生成的代码文件有 8 万行,tree-sitter 解析时内存直接爆了。解决办法是设置文件大小上限,超过 1MB 的文件跳过解析,只记录文件元信息。另外解析要加超时,单个文件超过 10 秒就放弃。

问题二:符号查不到。先确认文件是否在索引范围内,检查白名单后缀和黑名单目录。如果文件在范围内还查不到,可能是解析器不支持该语言的某些语法特性。我遇到过 Python 的 match 语句早期版本 tree-sitter 不支持,升级语法定义后解决。

问题三:关系错乱。同名符号在不同文件里很常见,如果关系解析时只按名字匹配,就会张冠李戴。解决办法是关系解析要带上作用域信息,优先匹配同文件同作用域的符号,跨文件匹配时用导入路径限定。

5.2 上下文召回的质量问题

召回质量差通常表现为代理拿到一堆无关代码,或者关键代码没拿到。前者是扩展层数太多,后者是种子召回不准。

扩展层数我建议从 1 层开始调,观察代理的表现,不够再加。种子召回不准往往是任务解析出了问题,比如用户说"改一下登录逻辑",解析器不知道"登录逻辑"对应哪个符号。这种情况可以在任务解析后加一步确认,让代理先列出候选符号,再让用户或上层逻辑选择。

还有一个隐蔽的问题是上下文顺序。模型对上下文开头和结尾的内容注意力更强,所以重要的内容要放在开头或结尾,次要的放中间。我的做法是把目标符号放最前面,直接依赖放最后面,扩展内容放中间。

5.3 性能与资源问题

十万条记录的数据库,查询慢通常是索引没建好。用EXPLAIN QUERY PLAN看执行计划,如果出现SCAN TABLE就是全表扫描,需要加索引。我整理了一个常见查询和对应索引的对照表:

查询场景涉及表需要的索引
按名称搜符号symbolsname
查文件内符号symbolsfile_path
查调用者relationsto_id
查被调用者relationsfrom_id
查任务结论findingstask_id

内存占用方面,主要消耗在解析阶段。如果项目特别大,可以分批解析,每批处理完释放解析器。数据库本身很省内存,SQLite 默认的缓存配置对十万级数据完全够用。

5.4 与代理框架的集成问题

不同代理框架对 MCP 的支持程度不一样。有的框架工具调用很顺畅,有的框架在工具返回大段文本时会截断。遇到截断问题,可以在工具层做分页,返回时带上has_more标记,让代理决定是否继续拉取。

还有一个常见问题是工具调用频率过高。代理可能反复调用同一个工具查同样的信息。解决办法是在工具层加缓存,相同参数的调用在短时间内直接返回缓存结果。缓存有效期设短一点,比如 30 秒,避免拿到过期数据。

6. 我在实际项目中的几点体会

这套 Context Mode 我在三个项目里落地过,最大的一个代码库接近 20 万行。跑下来最深的体会是:上下文管理的收益不是线性的,而是有阈值的。代码库小于 1 万行的时候,全量注入和 Context Mode 的差距不明显,甚至全量注入因为实现简单反而更省事。但一旦超过 3 万行,Context Mode 的优势就指数级放大,代理的准确率和响应速度都会有质的提升。

另一个体会是索引质量决定一切。我见过太多人把精力花在上下文组装算法上,却忽略了索引本身的准确性。索引里符号位置错了、关系漏了,后面算法再精妙也是空中楼阁。所以我的建议是,索引构建和校验的代码要写得比组装算法更仔细,测试覆盖要更全。

最后分享一个实用技巧:给代理的上下文里加一条"元信息",告诉它当前上下文的来源和覆盖范围。比如"以下内容来自 UserService.py 第 45-120 行,包含 login 方法及其直接依赖"。这条元信息能显著减少代理的幻觉,因为它知道自己看到的是什么、没看到的是什么。这个技巧成本极低,但效果立竿见影,我在所有项目里都加上了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进