一个 coding agent 要回答“谁调用了 ProcessOrder”,最直觉的路径是:
列目录 -> grep 关键字 -> 读文件 -> 再 grep -> 再读文件 -> 拼上下文 -> 回答
这条路径的问题不只是慢,而是它把“找结构关系”退化成了“找文本片段”。函数调用、导入、继承、HTTP route、消息 channel、配置绑定这些关系,在源码里本来就是结构;如果每次都靠 agent 临时读文件重建这些结构,成本会随仓库规模反复付出。
codebase-memory-mcp 的答案是:先把代码库编译成一张持久化知识图,再让 agent 通过 MCP 工具查图,而不是把仓库从头翻一遍。项目 README 把它描述成面向 AI coding agents 的 code intelligence engine,核心能力包括 Tree-sitter AST 分析、Hybrid LSP 类型解析、SQLite 持久化图、BM25/语义/结构搜索、调用链追踪和跨服务链接。它背后的论文 Codebase-Memory: Tree-Sitter-Based Knowledge Graphs for LLM Code Exploration via MCP 也把问题定义得很清楚:LLM 代码探索的瓶颈常常不是模型不会推理,而是每次都要用大量 token 重建项目结构。
本文基于 DeusData/codebase-memory-mcp 的 commit d90986b2185badc3f8ce4fa881fff3991b851a92 做源码解读。重点不是安装教程,而是回答六个问题:
index_repository从 MCP 请求进入后,如何变成索引流水线?- 单个文件如何从源码变成 defs/calls/imports/usages 等结构化中间态?
- Tree-sitter 与 Hybrid LSP 分别解决什么问题?
- 图为什么先写内存 graph buffer,再一次性落到 SQLite?
- 增量索引为什么比“重新解析改过的文件”复杂得多?
search_graph、trace_path、get_code_snippet为什么能显著减少 agent 的读文件 token?
先给结论:
codebase-memory-mcp 不是“给代码做向量 RAG”。
它更像一个本地 codebase compiler:
用 Tree-sitter 把源码变成语法事实,
用轻量 LSP/type-resolution 把一部分文本调用提升为类型可信的关系,
用 graph buffer 归并、去重和补边,
最后把结果写成 SQLite 里的 nodes / edges / indexes / coverage metadata。
Agent 调 MCP 工具时,拿到的是已经编译好的结构子图,而不是一堆待读源码。
1. 整体架构:MCP 只是入口,核心是索引编译器
从源码看,项目可以拆成五层:
| 层 | 主要文件 | 职责 |
|---|---|---|
| MCP / CLI 入口 | src/mcp/mcp.c, src/cli/cli.c | 解析工具参数、校验项目、协调索引任务、组织响应 |
| Pipeline | src/pipeline/pipeline.c, src/pipeline/pass_*.c | 文件发现、全量/增量索引、并行抽取、后处理补边、落盘 |
| Extraction | internal/cbm/cbm.c, internal/cbm/extract_*.c | Tree-sitter 解析、定义/调用/导入/用法/字符串/通道/配置抽取 |
| Graph buffer | src/graph_buffer/graph_buffer.c | 内存图、节点/边去重、二级索引、dump 前归并 |
| Store / Query | src/store/store.c, src/cypher/cypher.c | SQLite schema、FTS5、结构搜索、图查询、BFS trace |
主路径可以画成:
flowchart LR
A[MCP tool: index_repository] --> B[handle_index_repository]
B --> C[cbm_pipeline_new]
C --> D[cbm_pipeline_run]
D --> E[staging SQLite DB]
E --> F[cbm_pipeline_run_staged]
F --> G[discover files]
G --> H{incremental possible?}
H -->|yes| I[load old graph + reparse changed files]
H -->|no| J[full graph buffer]
J --> K[Tree-sitter extraction]
K --> L[Hybrid LSP + calls/usages/semantic passes]
I --> L
L --> M[graph buffer dump]
M --> N[nodes / edges / FTS / file_hashes / coverage]
N --> O[atomic publish final DB]
O --> P[search_graph / trace_path / query_graph / get_code_snippet]
一个容易忽略的点:MCP 层不是薄薄转发一下。handle_index_repository 先做路径解析、allowed root 检查、cross-repo 模式分流、daemon executor 分发、supervised worker 包装、同项目 mutation lock、取消检查、模式解析、pipeline 创建、store cache 失效和响应构造。
这说明项目把“索引”当成有副作用的危险操作处理,而不是普通查询:
- repo 路径必须先 canonicalize,再进入 pipeline。
- daemon session 可以把写操作交给共享 job registry。
- supervised worker 用来隔离 crash / hang。
- 同一个 project 的 mutation 需要互斥。
- pipeline 写库前后会关闭 cached store,避免查询读到旧 DB 句柄。
这个入口的复杂度很高,但它保护的是同一个事实:图索引是持久化共享状态,不能让多个 agent session 随便并发改。
2. Pipeline:先写 staging DB,再原子发布
真正执行索引的是 cbm_pipeline_run。它不是直接写最终数据库,而是:
- 解析最终 DB 路径并确保父目录存在。
- 创建 staging DB 路径。
- 如果最终 DB 已存在,尝试备份到 staging。
- 临时把 pipeline 的
db_path指向 staging。 - 跑
cbm_pipeline_run_staged。 - seal staging DB。
- 用 rename/replace 发布到最终路径。
- 发布后按需导出 artifact。
这是一种典型的“构建产物先旁路生成,再原子替换”设计。对 agent 工具尤其重要:查询端要么看到旧的完整索引,要么看到新的完整索引,不应该看到写到一半的半成品图。
cbm_pipeline_run_staged 里再拆阶段:
0_userconfig_load
1_discover
try_incremental_or_delete_db
full path:
create graph buffer + registry
load path aliases
run_extraction_phase
run_post_extraction
dump_and_persist_hashes
源码里有一个小但很实际的优化:C/C++ macro extraction 只在 full mode 打开。注释解释说,在 Linux kernel 这类 macro-dense repo 上,宏节点会占很大比例,所以 fast/moderate 模式直接跳过它。这类判断体现了 codebase-memory-mcp 的设计目标:不是生成“理论上最全”的图,而是在质量、内存、速度和 agent 可用性之间取平衡。
3. 文件发现:语言识别不是只看扩展名
文件发现阶段会把候选文件变成 cbm_file_info_t 列表。语言识别的核心之一是 detect_file_language:
- 先按文件名/扩展名查语言。
.m、.cls、.inc这类多语言共享扩展名再做内容判别。- XML 里如果检测到 ObjectScript Studio Export 特征,则改成 ObjectScript export。
- 某些 JSON 文件会被显式过滤。
扩展名映射在 cbm_language_for_extension 里还有一层用户配置覆盖:先查项目配置,再查内置 EXT_TABLE。这解释了为什么它能支持大量语言,同时仍允许项目用自定义规则纠正边界情况。
语言识别不是小事。后续每一步都依赖它:
language -> CBMLangSpec -> Tree-sitter grammar -> extractor strategy -> LSP/type-resolution strategy
识别错了,后面的 AST、调用、导入和 LSP 都会错位。
4. 单文件抽取:CBMFileResult 是索引编译的中间表示
源码里最关键的中间结构是 CBMFileResult。它不是一个简单的“文件摘要”,而是一组按语义分类的数组:
| 字段 | 含义 |
|---|---|
defs | 函数、方法、类、变量、模块等定义 |
calls | 原始调用表达式与调用点上下文 |
imports | 导入、本地别名、模块路径 |
usages | 标识符使用 |
throws, rw, type_refs, env_accesses | 异常、读写、类型引用、环境变量访问 |
resolved_calls | LSP/type-aware 后得到的高置信调用边 |
string_refs | URL、配置 key、异步 target 字符串 |
infra_bindings | IaC 里 topic/queue/scheduler 到 endpoint 的绑定 |
channels | Socket.IO / EventEmitter 等 pub/sub 参与记录 |
cached_tree, source | 给后续跨文件 LSP 复用的 parse tree 和源码 bytes |
可以把它理解成 codebase-memory-mcp 的 IR(intermediate representation,中间表示)。每个文件先被编译成 IR;pipeline 后面再把所有文件 IR 合并成全局图。
单个定义结构 CBMDefinition 也不只是 name 和 line:
qualified_name,label,file_path,start_line,end_linesignature,return_type,receiver,docstringdecorators,base_classes,param_names,route_path,route_methodcomplexity,cognitive,loop_count,loop_depthis_recursive,linear_scan_in_loop,alloc_in_loop,unguarded_recursionis_exported,is_test,is_entry_point,body_tokens
这解释了为什么 search_graph 能返回复杂度、调用入度、是否测试文件、函数签名等属性:这些属性不是查询时读源码算出来的,而是在索引阶段已经作为节点属性写进图。
5. cbm_extract_file_ex:Tree-sitter 负责语法事实,LSP 补类型事实
单文件抽取的核心函数是 cbm_extract_file_ex。它的流程可以压成下面几步:
allocate CBMFileResult + arena
crash-quarantine guard
load language spec
load Tree-sitter grammar
get thread-local parser
parse source with timeout option
create module_qn and extraction context
run definitions extractor
run imports extractor
run unified extractor
run channel / K8s specialized extractors
run per-file Hybrid LSP
for C/C++/CUDA: preprocess and parse expanded source again
compute recursion / loop / scan / allocation / param metrics
record parse coverage
retain TSTree for later reuse
Tree-sitter 的接入点很薄。cbm_ts_language 从语言 spec 里取 grammar factory;get_thread_parser 复用线程本地 TSParser,语言变化时才 ts_parser_set_language。这个设计避免每个文件都重新创建 parser。
抽取器分三类:
cbm_extract_definitions:提取 Module/Function/Method/Class/Variable 等定义。cbm_extract_imports:按语言处理 import/use/require/include。cbm_extract_unified:用统一 cursor walk 处理 calls、usages、throws、read/write、type refs、env accesses、string refs、infra bindings 等。
为什么还要 Hybrid LSP?因为 Tree-sitter 看得懂语法形状,却不真正知道类型。比如:
user.save()
repo.save()
service.save()
语法层面都是 member call,只有类型解析才能尽量判断 save 落到哪个类、接口或模块。cbm_extract_file_ex 里对 Go、C/C++、PHP、Perl、Python、JS/TS/TSX、C#、Java、Kotlin、Rust 分别运行轻量 LSP/type-resolution。项目 README 把这称为 Hybrid LSP:不是启动官方 language server,而是在 C 里实现一批常见语言的类型解析算法。
这也是 codebase-memory-mcp 和纯 Tree-sitter 索引器的差别:Tree-sitter 给“语法事实”,Hybrid LSP 尝试给“类型事实”。前者更稳定,后者更难但更有价值。
6. 全量索引:并行抽取后,再做跨文件解析
全量索引在文件数足够多、worker 数大于 1 时走 run_parallel_pipeline:
parallel_extract -> registry_build -> lsp_cross_prepare -> parallel_resolve -> infra/k8s passes
这里的关键不是“开线程更快”这么简单,而是阶段顺序:
cbm_parallel_extract为每个文件产出CBMFileResultcache。cbm_build_registry_from_cache把所有定义放进全局 registry。cbm_pxc_collect_all_defs收集跨文件 LSP 需要的全局 definitions。cbm_pxc_build_module_def_index建module_qn -> defs的倒排索引。- 预构建 Go、Python、C、C#、TS 等语言的 cross-LSP registry。
cbm_parallel_resolve用全局 defs、导入、模块索引和语言 registry 解析 calls/usages。
这背后的原则是:
单文件抽取可以并行;
跨文件解析必须等全项目定义集可见;
否则第一个文件导入第三个文件时,目标节点可能还不存在。
串行路径 run_sequential_pipeline 也体现了同一原则。它的 pass 顺序是:
definitions -> k8s -> lsp_cross -> calls -> usages -> semantic
definitions 必须先跑,因为 calls/imports/usages 都要依赖已经存在的定义节点和 registry。源码里 cbm_pipeline_pass_definitions 明确采用两阶段策略:先抽取所有文件并创建 def-derived nodes,再回头创建 imports/channel/env edges。注释里给出的原因很直白:否则第一个文件的 workspace import 可能找不到后面文件的 Module 节点。
调用边的普通解析在 cbm_pipeline_pass_calls:每个文件取 extraction result,建 import map,算 same-module QN,再逐个 resolve_single_call。最后还会跑 FastAPI Depends 这种框架模式补边。
所以 codebase-memory-mcp 的图不是一次 AST walk 的产物,而是多阶段编译:
syntax facts -> symbol registry -> import map -> type-aware resolution -> framework/pattern passes -> graph edges
7. Graph buffer:先在内存里归并,再批量写 SQLite
Pipeline 不会在抽取每个节点时立刻写 SQLite。它先写内存图 graph_buffer。
节点入口是 cbm_gbuf_upsert_node。它按 qualified_name 查重,已存在时不是简单覆盖,而是处理几类棘手情况:
- Module def 不应该覆盖 Project/Folder 结构节点。
- 同 QN 但不同实体时,用确定性的内容规则选 canonical winner。
- label/name 变化时同步维护
nodes_by_label、nodes_by_name二级索引。
边入口是 cbm_gbuf_insert_edge。它用 source_id + target_id + type + properties 生成 key 去重。对 IMPORTS 这类边,properties 里的 local name 后面还会影响 SQLite 的唯一约束。
为什么要有 graph buffer?
抽取阶段需要频繁查 QN、label、name、source/target edges;
并行路径需要合并 worker 产物;
增量路径需要加载旧图、删文件相关节点、重新补边;
落盘前需要统一重排 id、去掉删除节点、分块写入。
直接在 SQLite 上做这些操作不是不可能,但会把大量细粒度写入、去重、级联删除和临时索引维护推给数据库。当前源码选择了 RAM-first:图先在内存里稳定成型,再一次性 dump。
cbm_gbuf_dump_to_sqlite 做了几件很“工程化”的事:
- 统计 live nodes。
- 建临时 id 到最终 id 的映射。
- 构造 dump nodes。
- 提前释放 graph buffer lookup indexes,降低 dump 峰值内存。
- 节点按 partition 流式 append。
- 构造 dump edges。
- remap vectors。
- finalize 写 metadata、indexes、SQLite master。
源码注释里多次提到 Linux kernel 规模下的内存峰值,这说明它不是只服务小 repo 的玩具实现。
8. SQLite schema:图表、全文索引和覆盖率元数据分开
持久化 schema 在 init_schema。
核心表是:
| 表 | 作用 |
|---|---|
projects | 项目名、索引时间、root path |
file_hashes | 增量索引用的文件状态 |
nodes | 图节点,含 label/name/QN/file/line/properties |
edges | 图边,含 source/target/type/properties |
project_summaries | ADR / architecture summary |
index_coverage | parse_partial、oversized、extract failed、not indexed 等覆盖率信号 |
index_coverage_meta | 覆盖率记录的 generation/mode/status |
nodes_fts | FTS5 虚拟表,用于 BM25 搜索 |
两个细节值得注意。
第一,nodes 的唯一约束是 (project, qualified_name)。这让 QN 成为图节点身份。graph buffer 里那些同 QN 冲突处理,最终都是为了符合这个身份模型。
第二,edges 的唯一约束是 (source_id, target_id, type, local_name_gen)。local_name_gen 是从 properties 里生成的列,主要服务 IMPORTS:同一个源文件从同一个模块导入多个不同名字时,不能被误判成同一条边。
FTS5 表 nodes_fts 是 contentless virtual table。落盘后 dump_and_persist_hashes 会把 nodes 回填到 nodes_fts,并对 name 做 camelCase splitting。这样 updateCloudClient 既能按原词搜,也更容易被 update cloud client 命中。
index_coverage 是一个很好的设计信号。源码没有假装 AST 图永远完整,而是把“不完整”作为可查询元数据保存下来。某个文件 parse tree 有 ERROR/MISSING 区域、过大被跳过、读取失败、被 ignore 规则排除,都会以 coverage row 的方式暴露给上层。对 agent 来说,这意味着它可以知道什么时候应该回退到读文件或 grep,而不是盲信图。
9. 增量索引:难点在“不要弄丢没变文件的跨文件边”
全量索引已经不简单,增量索引更能体现源码的工程含量。
入口是 try_incremental_or_delete_db:如果已有 DB 能打开、完整性检查通过、历史 file_hashes 数量合理,就走 cbm_pipeline_run_incremental;否则删除旧 DB,回到全量重建。
增量主流程大致是:
open existing DB
load stored file_hashes
classify changed / unchanged
classify deleted / mode-skipped
capture old coverage rows
load existing DB into graph buffer
snapshot inbound edges into changed files
purge changed/deleted files' nodes
seed registry from remaining graph
build path aliases and pipeline context
recreate File nodes for changed files
extract + resolve changed files
run postpasses
merge coverage rows
restore inbound edges
dump graph back to staging DB
文件变更判断在 classify_files。从当前源码看,它主要比较 mtime_ns 和 size。file_hashes 表有 sha256 字段,但 dump_and_persist_hashes 的全量路径写入时 sha256 为空字符串,实际增量分类依赖 mtime+size。这是一个典型工程取舍:快,但不能把它误解成强内容哈希验证。
更难的是跨文件边。
假设 A.ts 调用 B.ts 里的函数。现在只改了 B.ts。增量索引如果简单删除 B.ts 的节点再重建,就会把从没改过的 A.ts -> B.ts inbound edge 一并级联删掉;但 A.ts 不会被重新解析,自然也不会重新发出那条边。源码因此在 purge 前做 incr_capture_inbound_edge snapshot,重建后再 incr_restore_inbound_edges。
这就是增量图索引的核心难点:变更发生在文件级,但关系是跨文件的。 只重算变更文件不够,还要保护未变文件指向变更文件的旧关系,并在目标节点重建后重新挂回去。
10. 查询层:Agent 查的是结构子图,不是源码全文
索引完成后,MCP 工具基本都走 resolve_store -> verify_project_indexed -> query store。
handle_search_graph 有几条路径:
- 如果传
query,先走 BM25 / FTS5。 - 否则按
label、name_pattern、qn_pattern、file_pattern、relationship、degree 等结构条件查。 - 如果有
semantic_query,再跑向量语义结果并合并响应。 - 默认输出 grouped tree,减少 agent 解析成本。
handle_trace_call_path 的思路是:
- 用 name 或 QN 找目标节点。
- 对同名节点做定义优先和歧义处理。
- 根据
mode/edge_types决定遍历哪些边。 - 对 inbound/outbound 做 BFS。
- 用 depth、limit、cursor 控制结果窗口。
- 可选输出 risk labels 或 data flow 参数。
handle_get_code_snippet 则体现了“图索引不替代源码,而是让源码读取变得精准”:它先按 exact QN 找节点,找不到再 suffix match,最终根据节点的 file path 和 line range 读取对应片段。也就是说,agent 不需要先打开全文件猜上下文,而是先问图:“这个符号在哪里、范围多大、邻居是谁?”
query_graph 更直接:handle_query_graph 把 Cypher-like 查询交给 cbm_cypher_execute,返回 columns/rows 或 TOON 表。它适合做 agent 临时需要的结构聚合,例如:
找所有入度最高的 Function
找所有 HTTP_CALLS 指向的 Route
找 complexity 高且 callers 多的热路径函数
找某个目录边界外的 IMPORTS
这就是 token 省下来的地方。普通文件探索把“索引、过滤、排序、遍历”都交给上下文窗口;codebase-memory-mcp 把这些工作提前编译进 SQLite 图和查询函数,LLM 只消费结果。
11. 它和 RAG、grep、language server 的关系
我更愿意把 codebase-memory-mcp 放在这张表里理解:
| 工具形态 | 检索单位 | 优点 | 弱点 |
|---|---|---|---|
| grep / ripgrep | 文本行 | 快、准确找字面量 | 不知道调用关系和类型关系 |
| 向量 RAG | 文本 chunk | 能找语义相似段落 | 容易丢符号边界和方向 |
| language server | 当前语言的符号/类型服务 | 类型与跳转准确 | 多语言、多仓、多工具接入成本高 |
| codebase-memory-mcp | SQLite 知识图节点/边 | 多语言、持久、MCP 友好、结构查询便宜 | 解析覆盖率和类型解析仍是 best-effort |
它不是要消灭 grep。源码里的 index_coverage 已经承认:遇到 parse partial、未索引文件、语法不支持区域,agent 应该回到源码文本。但在“谁调用谁”“改这里影响哪些路径”“这个 route 接到哪里”“这个函数的上下游是什么”这些结构问题上,grep 是错误抽象。
它也不是完整 language server。Hybrid LSP 的实现目标不是替代 pyright、tsserver、gopls 或 rust-analyzer,而是在索引器内部拿到足够多的类型线索,把一批高价值关系补出来。项目 README 说它 structurally inspired by major language servers,这个定位比较准确:它取的是“语言服务器的部分解析思想”,服务的是“图索引”。
12. 这套实现最值得学的三个原则
第一,把 agent 的高频探索变成预编译产物。
每次问调用链都重新读文件,是把编译器工作丢给 LLM。codebase-memory-mcp 的核心价值在于把符号、边、索引和覆盖率提前算好,agent 请求时只取小结果。
第二,结构事实优先,语义搜索补充。
源码主干先抽 AST、imports、calls、types、routes,再做 BM25、semantic edges、vector search。这个顺序很关键:结构边是骨架,语义相似只是辅助召回。
第三,不完整性必须显式暴露。
parse_incomplete、index_coverage、skipped files、coverage meta 这些机制提醒使用者:图是高价值索引,不是源码真理本身。一个成熟的 agent 应该知道什么时候信图,什么时候回源文件。
最后用一句话概括:
codebase-memory-mcp 的原理不是“让模型记住代码库”,
而是“把代码库编译成模型能低成本查询的结构化外部记忆”。
这个方向会越来越重要。模型上下文再大,也不应该把确定性的结构分析全塞进 prompt 里临时重做。真正高杠杆的 agent 工程,是把确定性工作放进工具,把不确定的综合判断留给模型。
自检问题
读完这篇,如果能回答下面几个问题,就基本抓住了 codebase-memory-mcp 的核心:
- 为什么
index_repository入口要处理 daemon、supervisor、mutation lock,而不是直接跑 pipeline? - 为什么单文件抽取要先产出
CBMFileResult,而不是边抽取边写 SQLite? - Tree-sitter 和 Hybrid LSP 分别给图贡献什么类型的事实?
- 为什么全量索引要先收集所有 definitions,再解析 imports/calls?
- 增量索引为什么要 snapshot inbound edges?
search_graph和get_code_snippet为什么应该配合使用,而不是直接读文件?