Codebase Memory MCP 源码深读:把代码库编译成 Agent 的结构化记忆

从 MCP 入口、Tree-sitter 抽取、Hybrid LSP、graph buffer、SQLite/FTS5 和增量索引读懂 codebase-memory-mcp 的实现原理。

一个 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_graphtrace_pathget_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解析工具参数、校验项目、协调索引任务、组织响应
Pipelinesrc/pipeline/pipeline.c, src/pipeline/pass_*.c文件发现、全量/增量索引、并行抽取、后处理补边、落盘
Extractioninternal/cbm/cbm.c, internal/cbm/extract_*.cTree-sitter 解析、定义/调用/导入/用法/字符串/通道/配置抽取
Graph buffersrc/graph_buffer/graph_buffer.c内存图、节点/边去重、二级索引、dump 前归并
Store / Querysrc/store/store.c, src/cypher/cypher.cSQLite 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。它不是直接写最终数据库,而是:

  1. 解析最终 DB 路径并确保父目录存在。
  2. 创建 staging DB 路径。
  3. 如果最终 DB 已存在,尝试备份到 staging。
  4. 临时把 pipeline 的 db_path 指向 staging。
  5. cbm_pipeline_run_staged
  6. seal staging DB。
  7. 用 rename/replace 发布到最终路径。
  8. 发布后按需导出 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_callsLSP/type-aware 后得到的高置信调用边
string_refsURL、配置 key、异步 target 字符串
infra_bindingsIaC 里 topic/queue/scheduler 到 endpoint 的绑定
channelsSocket.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_line
  • signature, return_type, receiver, docstring
  • decorators, base_classes, param_names, route_path, route_method
  • complexity, cognitive, loop_count, loop_depth
  • is_recursive, linear_scan_in_loop, alloc_in_loop, unguarded_recursion
  • is_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。

抽取器分三类:

  1. cbm_extract_definitions:提取 Module/Function/Method/Class/Variable 等定义。
  2. cbm_extract_imports:按语言处理 import/use/require/include。
  3. 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

这里的关键不是“开线程更快”这么简单,而是阶段顺序:

  1. cbm_parallel_extract 为每个文件产出 CBMFileResult cache。
  2. cbm_build_registry_from_cache 把所有定义放进全局 registry。
  3. cbm_pxc_collect_all_defs 收集跨文件 LSP 需要的全局 definitions。
  4. cbm_pxc_build_module_def_indexmodule_qn -> defs 的倒排索引。
  5. 预构建 Go、Python、C、C#、TS 等语言的 cross-LSP registry。
  6. 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_labelnodes_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_summariesADR / architecture summary
index_coverageparse_partial、oversized、extract failed、not indexed 等覆盖率信号
index_coverage_meta覆盖率记录的 generation/mode/status
nodes_ftsFTS5 虚拟表,用于 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_nssizefile_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。
  • 否则按 labelname_patternqn_patternfile_patternrelationship、degree 等结构条件查。
  • 如果有 semantic_query,再跑向量语义结果并合并响应。
  • 默认输出 grouped tree,减少 agent 解析成本。

handle_trace_call_path 的思路是:

  1. 用 name 或 QN 找目标节点。
  2. 对同名节点做定义优先和歧义处理。
  3. 根据 mode / edge_types 决定遍历哪些边。
  4. 对 inbound/outbound 做 BFS。
  5. 用 depth、limit、cursor 控制结果窗口。
  6. 可选输出 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-mcpSQLite 知识图节点/边多语言、持久、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_incompleteindex_coverage、skipped files、coverage meta 这些机制提醒使用者:图是高价值索引,不是源码真理本身。一个成熟的 agent 应该知道什么时候信图,什么时候回源文件。

最后用一句话概括:

codebase-memory-mcp 的原理不是“让模型记住代码库”,
而是“把代码库编译成模型能低成本查询的结构化外部记忆”。

这个方向会越来越重要。模型上下文再大,也不应该把确定性的结构分析全塞进 prompt 里临时重做。真正高杠杆的 agent 工程,是把确定性工作放进工具,把不确定的综合判断留给模型。

自检问题

读完这篇,如果能回答下面几个问题,就基本抓住了 codebase-memory-mcp 的核心:

  1. 为什么 index_repository 入口要处理 daemon、supervisor、mutation lock,而不是直接跑 pipeline?
  2. 为什么单文件抽取要先产出 CBMFileResult,而不是边抽取边写 SQLite?
  3. Tree-sitter 和 Hybrid LSP 分别给图贡献什么类型的事实?
  4. 为什么全量索引要先收集所有 definitions,再解析 imports/calls?
  5. 增量索引为什么要 snapshot inbound edges?
  6. search_graphget_code_snippet 为什么应该配合使用,而不是直接读文件?