1. 设计哲学
1.1 为什么知识图谱,而不是 RAG?
传统 Agent 工具(Cursor、Claude Code、Codex 等)擅长语义匹配,但不真正知道你的代码库结构。典型故障:AI 修改了 UserService.validate(),却不知道有 47 个函数依赖它的返回类型,于是破坏性变更被悄然合并。
检索增强生成(RAG,含 Graph RAG)的根本问题是概率性:它把原始图边喂给 LLM,指望它探索够多次。结果是 LLM 要连续做 4+ 次查询(先找调用者、再找文件、再过滤测试、再判高风险),每一跳都可能漏。知识图谱的做法相反——在索引时就预计算结构(聚类、追踪、打分、置信度),让工具在一次调用内返回完整上下文。
"谁调用了 validateUser"——这是一道确定性的图查询,答案是闭集。把它交给概率检索等于把简单的集合运算降级成模糊猜测。图谱用 MATCH (a)-[:CALLS]->(b:Function {name:"validateUser"}) RETURN a 在毫秒内返回精确答案,零幻觉。
1.2 三大价值
| 价值 | 含义 | 对比 RAG |
|---|---|---|
| 可靠(Reliability) | LLM 不会漏上下文——关系已经在工具返回里 | RAG 的 Top-K 永远在漏边 |
| 省 Token(Token efficiency) | 不需要 10 次查询链去理解一个函数 | RAG 每跳都消耗上下文窗口 |
| 解放小模型(Model democratization) | 工具承担重活,小模型也能拿到完整架构视图,可与巨兽模型竞争 | RAG 依赖模型自己做图推理 |
README.md L304-308
1.3 图谱与 RAG 不是二选一,而是分层共存
正确的架构是分层共存:确定性的关系(谁调用谁、谁继承谁、谁实现哪个接口、字段被谁读/写)走图;语义相似度("哪个函数在做用户鉴权")走向量检索。这类工具同时维护:
- 属性图(关系数据库)——结构化、可审计、可 Cypher 查询;
- 向量索引(HNSW + cosine)——语义召回;
- BM25 + RRF 融合——关键词与语义混合排序。README.md L458 Tech Stack
这种分层让"硬"问题(影响分析、变更检测、协调重命名)走图的强保证路径,让"软"问题(自然语言定位)走向量路径,互不替代。
2. 架构总览
2.1 四层架构
┌─────────────────────────────────────────────────────────────────┐
│ L4 Agent 集成层 │
│ SessionStart hook(注入工具清单) │
│ PreToolUse hook(拦截 grep → 增强引擎,<500ms) │
│ MCP instructions(连接时注入 system 级指令) │
│ Skills(探索/调试/影响分析/重构 4 类) │
├─────────────────────────────────────────────────────────────────┤
│ L3 MCP 暴露层 │
│ 14 工具(理解类 / 变更类 / 查询类) │
│ Resources(codegraph://repo/{name}/context、/processes…) │
│ Prompts(detect_impact、generate_map) │
│ Next 提示(工具返回末尾自引导下一步) │
├─────────────────────────────────────────────────────────────────┤
│ L2 图存储层 │
│ 属性图:节点表(File/Function/Class/.../Community/Process) │
│ 单 CodeRelation 表(type/confidence/reason/step 四属性) │
│ Embedding 表(独立建表,HNSW 向量索引) │
│ 嵌入式 LadybugDB(本地) / 服务端(治理)双轨 │
├─────────────────────────────────────────────────────────────────┤
│ L1 索引层(6 阶段管线) │
│ 扫描 → Tree-sitter 解析 → 跨文件传播 │
│ → Leiden 社区 → 执行流追踪 → 业务概念提取 │
│ Worker 池并行(20MB 字节预算分块 / 8 线程 / 子批 1500) │
└─────────────────────────────────────────────────────────────────┘
2.2 端到端数据流
代码进入,智能出来,整条链路是纯本地、零网络的(CLI 模式):
源代码文件
│ walkRepositoryPaths 扫描文件树(只看路径,不读内容)
▼
File/Folder 节点 + CONTAINS 边
│ Tree-sitter 解析(多语言 14+)→ AST
▼
Symbol 节点(Function/Class/Method/Interface/...)
│ 关系提取:IMPORTS / CALLS / EXTENDS / IMPLEMENTS / ACCESSES
│ Kahn 拓扑排序跨文件传播类型绑定(解决链式调用)
▼
完整的关系图(带 confidence + reason 审计字段)
│ Leiden 聚类(基于 CALLS 边)→ Community 节点
│ BFS 执行流追踪(入口点 → Process 节点,跨社区标记)
▼
可查询的语义图(Community = 功能域,Process = 执行流)
│ 落库 LadybugDB + 可选 Embedding(384 维 HNSW)
▼
MCP 工具集(context / impact / detect_changes / rename / ...)
│ Agent 调用 → 一次返回完整上下文
▼
Agent 输出可靠答案 / 安全变更
2.3 关键技术选型理由
为什么是属性图(而非 RDF 三元组)?为什么是嵌入式(而非 Neo4j 服务端)?为什么是 MCP(而非自定义协议)?
- 属性图 vs RDF 三元组:属性图允许节点带任意属性、关系带属性(confidence/reason),查询语义更贴近代码心智模型。RDF 一切皆三元组,写"带置信度的调用关系"要绕成 reification,对 LLM 写 Cypher 不友好。schema.ts L7-10 注释
- 嵌入式图库(LadybugDB,前身 KuzuDB):CLI 模式下,索引落在仓库的
.codegraph/目录里(gitignored),零网络、零部署、隐私不外泄。LadybugDB 用列式存储 + Cypher 查询,对属性图 + 向量混合查询原生友好。README.md L456-457 - MCP(Model Context Protocol):它是 Anthropic 推出的开放标准,Cursor / Claude Code / Codex / Windsurf / OpenCode 都支持。一次集成,多 Editor 受益。自定义协议意味着每接一个 Agent 写一遍适配层。
- Tree-sitter(native bindings for CLI,WASM for Web):增量解析、容错(语法错误也能产出部分 AST)、统一 14+ 语言的查询接口。是代码索引的事实标准。
3. 图谱建模深度
3.1 节点设计:代码元素 + 抽象语义
这类工具把节点分成两大类:代码元素节点(直接来自 AST)和抽象语义节点(索引时计算出来的高阶概念)。
| 类别 | 节点类型 | 来源 |
|---|---|---|
| 代码元素 | File、Folder | 文件系统扫描 |
Function、Class、Interface、Method | Tree-sitter AST | |
多语言:Struct、Enum、Trait、Impl、Macro、Module、Constructor、Property、Annotation、Template… | 各语言的 native AST 节点 | |
| 抽象语义 | Community(社区/功能域) | Leiden 算法聚类 |
Process(执行流) | 入口点 BFS 追踪 | |
| 业务扩展 | Route(API 路由)、Tool(MCP 工具)、Section(Markdown 章节) | 框架/协议感知提取 |
为什么要分表?而不是一个大 Node 表用 label 区分?
这是属性图的核心取舍。每种节点类型一张表,每张表有专属字段。比如 Method 表有 parameterCount、returnType;Class 表有 tableName、tableSource(标记数据库实体映射);Route 表有 responseKeys、middleware。这让 Cypher 查询能精准带上类型与字段约束,且利于存储引擎按类型裁剪。schema.ts L22-200
3.2 单 CodeRelation 表设计(最重要的建模决策)
所有关系——不管是什么类型——都进同一张 CodeRelation 表,用 type 属性区分。schema.ts L221-428
CREATE REL TABLE CodeRelation (
FROM File TO File,
FROM File TO Function,
FROM Function TO Function,
FROM Class TO Method,
-- ... 几百种 FROM-TO 组合 ...
type STRING, -- 'CALLS' | 'IMPORTS' | 'EXTENDS' | 'IMPLEMENTS' | ...
confidence DOUBLE, -- 0.0-1.0,模糊匹配的置信度
reason STRING, -- 'ast-call' | 'fuzzy-global' | 'leiden-algorithm' | ...
step INT32 -- 仅 STEP_IN_PROCESS 用:1-indexed 步骤号
)
为什么不分表(CALLS 一张表、IMPORTS 一张表)?
LLM 写 Cypher 时,记住"所有边都在一张表,用 {type:'CALLS'} 过滤"远比记住"CALLS 在 Calls 表、IMPORTS 在 Imports 表"轻松。源码注释写得很直白:"This allows LLMs to write natural Cypher queries like: MATCH (f:Function)-[r:CodeRelation {type: 'CALLS'}]->(g:Function) RETURN f, g"。schema.ts L7-10
3.3 confidence + reason:可审计的关系
每条边带两个审计字段,是工程上区别于玩具图的关键:
confidence: 1.0= AST 静态确认(如显式import);<0.8= 模糊匹配(如全局函数名猜测);0.3= 最弱的 fuzzy-global。reason: 'ast-call'、'fuzzy-global'、'leiden-algorithm'、'trace-detection'、'route-handler-entry-point'——告诉你这条边是怎么来的。
下游工具就能做过滤:WHERE r.confidence >= 0.5 只信强关系;影响分析默认 minConfidence: 0.7;执行流追踪用 MIN_TRACE_CONFIDENCE = 0.5 滤掉会让路径乱跳的模糊匹配。process-processor.ts L228
3.4 Embedding 独立建表(性能取舍)
向量没有和符号节点存在一起,而是独立 Embedding 表,靠 nodeId 外键关联。schema.ts L444-457
为什么不存进符号表?
源码注释解释:"Separate table for vector storage to avoid copy-on-write overhead"(独立表避免写时复制开销)。向量是 384 维浮点数组(默认 snowflake-arctic-embed-xs),每个符号约 1.5KB。若存在符号表里,每次更新符号的任一属性都会触发整行的写时复制。独立表后,符号属性更新不影响向量,反之亦然。向量索引用 HNSW(Hierarchical Navigable Small World)算法 + cosine 相似度。CODEGRAPH_EMBEDDING_DIMS 环境变量可调维度。schema.ts L435-457
3.5 核心:图 schema 完整定义(可复现)
下面是从源码精炼的 TypeScript schema 定义。读者可以照着在任何属性图数据库(LadybugDB / Neo4j / Memgraph)上复刻:
// ============================================================================
// 节点表:每种代码元素一张表,带专属字段
// ============================================================================
const NODE_SCHEMAS = {
// 代码元素
File: 'CREATE NODE TABLE File (id STRING, name STRING, filePath STRING, content STRING, PRIMARY KEY (id))',
Function: 'CREATE NODE TABLE Function (id STRING, name STRING, filePath STRING, startLine INT64, endLine INT64, isExported BOOLEAN, content STRING, description STRING, PRIMARY KEY (id))',
Class: 'CREATE NODE TABLE Class (id STRING, name STRING, filePath STRING, startLine INT64, endLine INT64, isExported BOOLEAN, content STRING, tableName STRING, tableSource STRING, PRIMARY KEY (id))',
Method: 'CREATE NODE TABLE Method (id STRING, name STRING, filePath STRING, parameterCount INT32, returnType STRING, PRIMARY KEY (id))',
Interface: 'CREATE NODE TABLE Interface (id STRING, name STRING, filePath STRING, soaPrefix STRING, crossRepoProvider STRING, PRIMARY KEY (id))',
// 抽象语义(索引时计算)
Community: 'CREATE NODE TABLE Community (id STRING, heuristicLabel STRING, keywords STRING[], cohesion DOUBLE, symbolCount INT32, PRIMARY KEY (id))',
Process: 'CREATE NODE TABLE Process (id STRING, heuristicLabel STRING, processType STRING, stepCount INT32, communities STRING[], entryPointId STRING, terminalId STRING, PRIMARY KEY (id))',
// 业务扩展
Route: 'CREATE NODE TABLE Route (id STRING, name STRING, filePath STRING, responseKeys STRING[], middleware STRING[], PRIMARY KEY (id))',
Tool: 'CREATE NODE TABLE Tool (id STRING, name STRING, filePath STRING, description STRING, PRIMARY KEY (id))',
};
// ============================================================================
// 关系表:所有关系共享一张表,用 type/confidence/reason/step 四属性
// 这是 AI 友好的核心 —— LLM 只需记一个表名 + {type: 'XXX'} 过滤
// ============================================================================
const RELATION_SCHEMA = `
CREATE REL TABLE CodeRelation (
FROM File TO Function, FROM Function TO Function,
FROM Class TO Method, FROM Function TO Class,
FROM Class TO Interface, FROM Interface TO Method,
FROM Function TO Community, FROM Function TO Process,
-- ... 所有 FROM-TO 组合 ...
type STRING, -- CALLS | IMPORTS | EXTENDS | IMPLEMENTS | ACCESSES | OVERRIDES | MEMBER_OF | STEP_IN_PROCESS | HANDLES_ROUTE | FETCHES | HANDLES_TOOL | ENTRY_POINT_OF | QUERIES
confidence DOUBLE, -- 1.0=AST确认, <0.8=模糊匹配
reason STRING, -- 这条边是怎么来的(可审计)
step INT32 -- 仅 STEP_IN_PROCESS 用
)`;
// ============================================================================
// Embedding 表:独立建表,避免符号属性更新时的写时复制开销
// ============================================================================
const EMBEDDING_SCHEMA = `
CREATE NODE TABLE Embedding (nodeId STRING, embedding FLOAT[384], PRIMARY KEY (nodeId))
CALL CREATE_VECTOR_INDEX('Embedding', 'code_embedding_idx', 'embedding', metric := 'cosine')`;
// 关系类型枚举(17 种)
type RelType =
| 'CONTAINS' | 'DEFINES' | 'CALLS' | 'IMPORTS' | 'EXTENDS' | 'IMPLEMENTS'
| 'HAS_METHOD' | 'HAS_PROPERTY' | 'ACCESSES' | 'OVERRIDES' | 'MEMBER_OF'
| 'STEP_IN_PROCESS' | 'HANDLES_ROUTE' | 'FETCHES' | 'HANDLES_TOOL'
| 'ENTRY_POINT_OF' | 'QUERIES';
schema.ts 全文精炼
4. 索引管线工程化
4.1 六阶段管线
Phase 1 扫描文件树 walkRepositoryPaths(只看路径,0-15%)
Phase 2 结构 + Markdown processStructure + processMarkdown(15-20%)
Phase 3 Tree-sitter 解析 14+ 语言 AST 提取,20MB 字节预算分块(20-82%)
Phase 4 跨文件类型传播 Kahn 拓扑排序,跨 chunk 缓冲 CALLS(82%)
Phase 5 Leiden 社区发现 基于 CALLS/EXTENDS/IMPLEMENTS 聚类(82-92%)
Phase 6 执行流追踪 入口点打分 → BFS → Process 节点(94-99%)
─────────────────────────────────────────────────────────
并行子阶段:路由注册、MCP 工具检测、ORM 数据流、Entity→Table 映射
4.2 Tree-sitter 多语言解析(14+ 语言)
CLI 模式用 native bindings(快),Web 模式用 WASM(跨平台)。支持的语义维度因语言而异:README.md L325-342
| 语言 | Imports | Named Bindings | Heritage | Type Annotations | Constructor Inference |
|---|---|---|---|---|---|
| TypeScript / JavaScript | ✓ | ✓ | ✓ | ✓ / — | ✓ |
| Python | ✓ | ✓ | ✓ | ✓ | ✓ |
| Java / Kotlin / C# | ✓ | ✓ | ✓ | ✓ | ✓ |
| Go | ✓ | — | ✓ | ✓ | ✓ |
| Rust | ✓ | ✓ | ✓ | ✓ | ✓ |
| C / C++ / Swift / Dart / Ruby / PHP | 部分 | 部分 | 部分 | 部分 | ✓ |
4.3 Worker 池并行:字节预算分块
大仓库不能一次性塞进内存。这类工具用三层并行策略:worker-pool.ts + pipeline.ts L150-158
- 字节预算分块:
CHUNK_BYTE_BUDGET = 20MB。20MB 源码 ≈ 200-400MB 峰值工作内存(AST 膨胀 + 提取记录 + 序列化开销同时驻留)。 - Worker 池:
size = Math.min(8, Math.max(1, os.cpus().length - 1)),默认最多 8 个线程。少于 15 个文件或 512KB 时不 spawn(开销 > 收益)。 - 子批(sub-batch):每个 worker 内部再把任务切成
SUB_BATCH_SIZE = 1500一批,限制单次postMessage的结构化克隆内存。每子批 30 秒超时,遇到 minified 50MB JS 这类病态文件 fail fast。
// Worker 池核心调度逻辑(精简版)
const createWorkerPool = (workerUrl: URL, poolSize?: number) => {
const size = poolSize ?? Math.min(8, Math.max(1, os.cpus().length - 1));
const workers = Array.from({ length: size }, () => new Worker(workerUrl));
const SUB_BATCH_SIZE = 1500;
const SUB_BATCH_TIMEOUT_MS = 30_000;
const dispatch = async <TInput, TResult>(items: TInput[]) => {
if (items.length === 0) return [];
// 把 items 均分给 size 个 worker
const chunkSize = Math.ceil(items.length / size);
const chunks = [];
for (let i = 0; i < items.length; i += chunkSize) {
chunks.push(items.slice(i, i + chunkSize));
}
// 每个 worker 内部按 1500 一批 postMessage,避免单次结构化克隆爆内存
return Promise.all(chunks.map((chunk, i) => runWorker(workers[i], chunk)));
};
return { dispatch, terminate: () => Promise.all(workers.map(w => w.terminate())), size };
};
worker-pool.ts L44-172
4.4 Kahn 拓扑排序:跨文件类型传播
这是索引中最容易出错的一环。问题:在 a.ts 里 import { getConfig } from './config',getConfig() 返回类型 Config;b.ts 里 cfg = getConfig(); cfg.save()——要解析这个 .save() 调用,必须先知道 Config 类有 save() 方法。这就是跨文件类型传播。
解法:用 Kahn 算法按 import 依赖做拓扑排序,同一拓扑层内无相互依赖,可并行处理。处理顺序保证:上游文件的导出类型先就绪,下游文件再解析调用。pipeline.ts L96-144 topologicalLevelSort
/** Kahn 算法:按拓扑层级分组返回文件。
* 同一层内的文件互不依赖 —— 可安全并行处理。
* 环里的文件作为最后一组返回(不做跨环传播)。 */
function topologicalLevelSort(importMap: Map<string, Set<string>>) {
const inDegree = new Map<string, number>();
const reverseDeps = new Map<string, string[]>();
// file imports dep → dep 是 file 的前置(dep → file)
for (const [file, deps] of importMap) {
if (!inDegree.has(file)) inDegree.set(file, 0);
for (const dep of deps) {
if (!inDegree.has(dep)) inDegree.set(dep, 0);
inDegree.set(file, (inDegree.get(file) ?? 0) + 1);
(reverseDeps.get(dep) ?? reverseDeps.set(dep, []).get(dep)!).push(file);
}
}
// BFS 从入度为 0 的节点开始,按层分组
const levels: string[][] = [];
let currentLevel = [...inDegree.entries()].filter(([, d]) => d === 0).map(([f]) => f);
while (currentLevel.length > 0) {
levels.push(currentLevel);
const next: string[] = [];
for (const f of currentLevel) {
for (const dep of reverseDeps.get(f) ?? []) {
const newDeg = (inDegree.get(dep) ?? 1) - 1;
inDegree.set(dep, newDeg);
if (newDeg === 0) next.push(dep);
}
}
currentLevel = next;
}
// 入度仍 > 0 的文件在环里 —— 作为最后一组
const cycleFiles = [...inDegree.entries()].filter(([, d]) => d > 0).map(([f]) => f);
if (cycleFiles.length > 0) levels.push(cycleFiles);
return { levels, cycleCount: cycleFiles.length };
}
pipeline.ts L96-144
4.5 接口分派延迟解析(关键工程技巧)
接口 IUserRepo 有方法 find(),有两个实现类 SqlUserRepo、MongoUserRepo。当代码 repo.find() 调用 IUserRepo 时,要正确建立 CALLS 边,必须知道所有实现类的 find() 方法。如果在第一个 chunk 处理完就解析 CALLS,可能只看到 SqlUserRepo,漏掉 MongoUserRepo。
解法:在 worker 路径下,所有 chunk 的 heritage(继承/实现关系)都先缓冲,等全部 chunk 贡献完,构建完整的 implementor map,再一次性解析所有 CALLS(接口分派用完整 map)。pipeline.ts L878-990,L963-989
// 关键片段:延迟解析 CALLS,等所有 heritage 到齐
const deferredWorkerCalls: ExtractedCall[] = [];
const deferredWorkerHeritage: ExtractedHeritage[] = [];
for (let chunkIdx = 0; chunkIdx < numChunks; chunkIdx++) {
// ... 解析当前 chunk ...
deferredWorkerCalls.push(...chunkWorkerData.calls); // 缓冲
deferredWorkerHeritage.push(...chunkWorkerData.heritage); // 缓冲
// 注意:CALLS 不在这里解析,只解析 heritage 和 routes
await processHeritageFromExtracted(graph, chunkWorkerData.heritage, ctx);
}
// 所有 chunk 处理完 —— 构建完整的 implementor map
const fullWorkerImplementorMap = deferredWorkerHeritage.length > 0
? buildImplementorMap(deferredWorkerHeritage, ctx)
: new Map();
// 现在才解析 CALLS,接口分派有完整 map
await processCallsFromExtracted(graph, deferredWorkerCalls, ctx, undefined, fullWorkerImplementorMap);
pipeline.ts L878-990
4.6 核心:Tree-sitter 解析 + AST 关系提取
下面是一个最小可复现的 TypeScript 解析器骨架,演示如何从一个文件的 AST 提取节点和关系:
import Parser from 'tree-sitter';
import TS from 'tree-sitter-typescript';
const parser = new Parser();
parser.setLanguage(TS.typescript);
interface ExtractedNode {
id: string; kind: string; name: string;
filePath: string; startLine: number; endLine: number; isExported: boolean;
}
interface ExtractedEdge {
type: string; sourceId: string; targetId: string;
confidence: number; reason: string;
}
/** 解析单个文件,提取节点和关系 */
function parseFile(filePath: string, content: string): { nodes: ExtractedNode[]; edges: ExtractedEdge[] } {
const tree = parser.parse(content);
const nodes: ExtractedNode[] = [];
const edges: ExtractedEdge[] = [];
const cursor = tree.walk();
// 用 Tree-sitter 的 query 语法批量提取(比手写遍历快且容错)
const functionQuery = new Parser.Query(TS.typescript, `
(function_declaration name: (identifier) @name) @func
(method_definition name: (property_identifier) @name) @method
(class_declaration name: (type_identifier) @name) @class
(interface_declaration name: (type_identifier) @name) @iface
(import_statement) @import
(call_expression function: (_) @callee) @call
`);
const matches = functionQuery.matches(tree.rootNode);
for (const { captures } of matches) {
const byName = Object.fromEntries(captures.map(c => [c.name, c.node]));
if (byName.func) {
const name = byName.name.text;
nodes.push({
id: `Function:${filePath}:${name}`,
kind: 'Function', name, filePath,
startLine: byName.func.startPosition.row,
endLine: byName.func.endPosition.row,
isExported: isExportedNode(byName.func),
});
}
if (byName.class) {
const name = byName.name.text;
nodes.push({ id: `Class:${filePath}:${name}`, kind: 'Class', name, filePath,
startLine: byName.class.startPosition.row, endLine: byName.class.endPosition.row,
isExported: isExportedNode(byName.class) });
}
if (byName.call && byName.callee) {
// 调用关系:需要后续跨文件解析 callee 到底指向谁
const calleeName = byName.callee.text;
edges.push({
type: 'CALLS',
sourceId: `${getCurrentScope(filePath, byName.call)}`,
targetId: calleeName, // 占位,跨文件传播时解析
confidence: 0.9,
reason: 'ast-call',
});
}
// ... import / extends / implements / accesses 同理 ...
}
return { nodes, edges };
}
function isExportedNode(node: Parser.SyntaxNode): boolean {
// 检查是否在 export 语句下
let parent = node.parent;
while (parent) {
if (parent.type === 'export_statement') return true;
parent = parent.parent;
}
return false;
}
Wildcard Import(Go/Ruby/C++/Python)的特殊处理
有些语言是"整模块导入":import models(Python)、require 'user'(Ruby)、#include "user.h"(C/C++)、import "user"(Go)。它们不像 TS 的 import { X } 有具名绑定,而是把整个文件的导出符号都拉进来。
解法是 synthesize(合成)具名绑定:解析时记录每个文件导出了哪些符号(通过 isExported),然后在调用解析前,对每个 wildcard-import 文件,把所有被导入文件的导出符号合成为 namedImportMap 条目。pipeline.ts L163-177, L212-324 synthesizeWildcardImportBindings
Python 额外有 namespace 导入:import models 后 models.User(),需要建 moduleAliasMap(models → models.py),避免歧义符号展开。
5. 社区发现与执行流
5.1 Leiden 算法:基于 CALLS 边聚类
社区(Community)= 自动发现的功能域。这类工具用 Leiden 算法(比 Louvain 更稳健,保证连通性)在 graphology 图上聚类。community-processor.ts L104-205
聚类输入与策略
- 只取符号节点:
Function / Class / Method / Interface(排除 File/Folder 等结构节点)。 - 只取聚类边:
CALLS / EXTENDS / IMPLEMENTS(排除 IMPORTS/ACCESSES 等弱关系)。 - 大图过滤:符号数 > 10000 时,过滤掉 confidence < 0.5 的模糊边和度数为 1 的悬挂节点(它们要么变单例、要么被吸收进邻居社区,但严重拖慢迭代)。community-processor.ts L216-268
- 大图参数:
resolution: 2.0(更细粒度)、maxIterations: 3(前 2 轮捕获 ~95% 模块度,递减收益)。60 秒超时,超时回退到"全归一个社区"。 - 跳过单例:成员数 < 2 的社区直接丢弃。
社区标签生成(启发式)
社区需要人类可读的名字。启发式优先级:community-processor.ts L332-392
- 取成员文件路径的最常见父目录名(跳过
src/lib/core/utils/common/shared/helpers等通用名); - 退而求其次:取成员函数名的公共前缀;
- 最后兜底:
Cluster_<编号>。
社区还带 cohesion(内聚度,0-1)= 内部边数 / 总边数(大社区采样 50 个成员避免 O(N²))。community-processor.ts L420-445
5.2 执行流追踪:入口点 → BFS → Process
执行流(Process)= 从入口点到终点的调用链。它是 Agent 理解"这个功能怎么跑通"的核心抽象。process-processor.ts L81-215
四步流程
- 找入口点:调用多、被调少、导出、命名模式匹配(
handle*、on*、*Controller)。排除测试文件。打分后取 Top 200。process-processor.ts L270-336 - BFS 追踪:从每个入口点沿 CALLS 边(
confidence >= 0.5)广度优先,记录所有路径。maxTraceDepth=10、maxBranching=4、minSteps=3(2 步只是"A 调 B",不算真流程)。process-processor.ts L346-394 - 去重:先子集去重(A→B→C→D 包含 A→B→C,删短的),再按入口-终点对去重(同一对保留最长路径)。
- 建 Process 节点:标签
"入口名 → 终点名",记录stepCount、communities(途经社区)、processType(cross_community跨社区 /intra_community社区内)。
跨社区的执行流是架构上的关键链路(比如"登录"流程横跨 Auth、User、Session、Notification 四个社区)。Agent 一眼看到 processType: 'cross_community',就知道这是高价值、高耦合的链路,改动要特别小心。
5.3 核心:简化版社区发现(graphology + Leiden)
import Graph from 'graphology';
// Leiden 算法:graphology-communities-leiden(npm 上有发布版,这类工具因历史原因 vendor 了一份)
import leiden from 'graphology-communities-leiden';
interface CommunityNode {
id: string; label: string; heuristicLabel: string;
cohesion: number; symbolCount: number;
}
async function detectCommunities(knowledgeGraph: KnowledgeGraph): Promise<{
communities: CommunityNode[];
memberships: { nodeId: string; communityId: string }[];
}> {
// Step 1: 统计符号数,判断是否大图模式
let symbolCount = 0;
knowledgeGraph.forEachNode((node) => {
if (['Function', 'Class', 'Method', 'Interface'].includes(node.label)) symbolCount++;
});
const isLarge = symbolCount > 10_000;
// Step 2: 构建 graphology 无向图(只取符号节点 + 聚类边)
const graph = new Graph({ type: 'undirected' });
const symbolTypes = new Set(['Function', 'Class', 'Method', 'Interface']);
const clusteringRels = new Set(['CALLS', 'EXTENDS', 'IMPLEMENTS']);
knowledgeGraph.forEachRelationship((rel) => {
if (!clusteringRels.has(rel.type) || rel.sourceId === rel.targetId) return;
// 大图:过滤低置信度边
if (isLarge && rel.confidence < 0.5) return;
if (!graph.hasNode(rel.sourceId)) graph.addNode(rel.sourceId);
if (!graph.hasNode(rel.targetId)) graph.addNode(rel.targetId);
if (!graph.hasEdge(rel.sourceId, rel.targetId)) graph.addEdge(rel.sourceId, rel.targetId);
});
// Step 3: 跑 Leiden
const details = leiden.detailed(graph, {
resolution: isLarge ? 2.0 : 1.0,
maxIterations: isLarge ? 3 : 0, // 0 = 跑到收敛
});
// Step 4: 按社区分组,生成标签,跳过单例
const members = new Map<number, string[]>();
for (const [nodeId, commNum] of Object.entries(details.communities)) {
(members.get(commNum) ?? members.set(commNum, []).get(commNum)!).push(nodeId);
}
const communities: CommunityNode[] = [];
for (const [commNum, ids] of members) {
if (ids.length < 2) continue; // 跳过单例
communities.push({
id: `comm_${commNum}`,
label: generateHeuristicLabel(ids, knowledgeGraph),
heuristicLabel: generateHeuristicLabel(ids, knowledgeGraph),
cohesion: calculateCohesion(ids, graph),
symbolCount: ids.length,
});
}
communities.sort((a, b) => b.symbolCount - a.symbolCount);
return { communities, memberships: /* ... */ };
}
community-processor.ts L104-268 精炼
6. MCP 工具设计
6.1 14 个工具的三大分类
| 分类 | 工具 | 本质 |
|---|---|---|
| 理解类 (读图) | context | 符号的 360 度视图(入边/出边/参与的执行流) |
route_map | API 路由 → 处理器 → 消费者映射 | |
tool_map | MCP/RPC 工具定义 → 处理器映射 | |
topic_map | MQ topic 的生产者/消费者(跨库) | |
| 变更类 (写图/写文件) | impact | 变更爆炸半径(upstream/downstream + 风险等级) |
detect_changes | git diff → 受影响的符号和执行流 | |
rename | 多文件协调重命名(graph + 文本搜索,带置信度标签) | |
api_impact | API 路由变更预检(route_map + shape_check + impact 合一) | |
| 查询类 (图查询/导航) | cypher | 原始 Cypher 查询(万能逃生口) |
list_repos | 列出所有索引仓库 | |
resolve_repo | FQN → 仓库(多库时定位) | |
shape_check | 响应结构 vs 消费者字段访问的漂移检测 | |
table_context | 数据库表 FK 关系 | |
cross_repo_search | 跨库文本正则搜索(广度,补图的深度) |
6.2 工具描述即决策树(最关键的设计)
每个工具的 description 不是简单的一句话,而是一个完整的决策树,包含四个固定段落:tools.ts 全文
TRIGGER when: ... ← 什么场景触发这个工具
WHEN TO USE: ... ← 何时用(与相邻工具的边界)
AFTER THIS: ... ← 用完之后下一步该调什么
NEVER: ... ← 什么时候绝对不要用(防误用)
举例,impact 工具的描述开头:tools.ts L239-265
TRIGGER when: user wants to change/modify/rename code, asks "is it safe",
"what breaks", "who uses this".
MUST run BEFORE editing any function, class, or method.
REPO REQUIRED in multi-repo setup. ...
WHEN TO USE: Before making code changes — especially refactoring, renaming,
or modifying shared code. Shows what would break.
AFTER THIS: Review d=1 items (WILL BREAK). Use context() on high-risk symbols.
TIP: Default traversal uses CALLS/IMPORTS/EXTENDS/IMPLEMENTS. For class members,
include HAS_METHOD and HAS_PROPERTY in relationTypes.
因为 MCP 工具的描述是 LLM 唯一能看到的"使用说明书"。LLM 在决定调哪个工具时,只读 description。描述里没有的边界条件,LLM 一定踩。把决策树写进描述,等于把"工具选择 prompt"内联进协议,省去了额外的 system prompt。
6.3 Next 提示:对抗 LLM 的"单次调用即停"
LLM 有个通病:调一次工具拿到结果,就以为任务完成了。这类工具在每个工具返回的末尾追加一段 Next: 提示,引导它走下一步。server.ts L102-160 getNextStepHint
function getNextStepHint(toolName: string, args: any): string {
const repo = args?.repo;
const repoParam = repo ? `, repo: "${repo}"` : '';
switch (toolName) {
case 'context':
return `\n\n---\n**Next:** If planning changes, use impact({target: "${args?.name}", direction: "upstream"${repoParam}}) to check blast radius. To see execution flows, READ codegraph://repo/{name}/processes.`;
case 'impact':
return `\n\n---\n**Next:** Review d=1 items first (WILL BREAK). Use context({name: ""${repoParam}}) on high-risk items.`;
case 'detect_changes':
return `\n\n---\n**Next:** Review affected processes. Use context() on high-risk changed symbols. READ codegraph://repo/{name}/process/{name} for full traces.`;
case 'rename':
return `\n\n---\n**Next:** Run detect_changes() to verify no unexpected side effects.`;
// ... 每个工具一个 hint ...
}
}
这套机制创造了自引导工作流:LLM 不需要外部 hook 驱动,工具返回本身就在引导它走完整个流程(context → impact → detect_changes → 提交检查)。
6.4 动态 repo 注入(多库自适应)
当索引了多个仓库时,工具的 repo 参数描述会动态注入仓库清单,省去 LLM 去 list_repos 查一遍:server.ts L166-195 buildToolsWithRepoList
function buildToolsWithRepoList(repos, baseTools) {
if (repos.length <= 1) return baseTools; // 单库:repo 可选
const repoList = repos.map(r => `${r.name} (${r.description})`).join(', ');
const dynamicDesc = `REQUIRED when multiple repos are indexed. Available repos: ${repoList}`;
return baseTools.map(tool => {
if (tool.name === 'list_repos') return tool;
return { ...tool, inputSchema: { ...tool.inputSchema,
properties: { ...tool.inputSchema.properties,
repo: { ...tool.inputSchema.properties.repo, description: dynamicDesc } } } };
});
}
6.5 核心:MCP Server 骨架 + impact 工具实现
下面是用官方 @modelcontextprotocol/sdk 写的最小 MCP Server,外加 impact 工具的多跳 BFS + 风险等级核心实现:
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema, ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
// ── 工具定义(决策树式描述) ──
const TOOLS = [{
name: 'impact',
description: `TRIGGER when: user wants to change code, asks "is it safe", "what breaks".
MUST run BEFORE editing any function, class, or method.
Analyze the blast radius of changing a code symbol.
Returns affected symbols grouped by depth, plus risk assessment.
Depth groups: d=1 WILL BREAK, d=2 LIKELY AFFECTED, d=3 MAY NEED TESTING.`,
inputSchema: {
type: 'object',
properties: {
target: { type: 'string', description: 'Symbol name to analyze' },
direction: { type: 'string', enum: ['upstream', 'downstream'] },
maxDepth: { type: 'number', default: 3 },
minConfidence: { type: 'number', default: 0.7 },
},
required: ['target', 'direction'],
},
}];
// ── 创建 Server,注入 instructions ──
const server = new Server(
{ name: 'code-graph', version: '1.0.0' },
{
capabilities: { tools: {} },
instructions: `# Code Graph Intelligence
ALWAYS use these tools before reading source files.
## MUST Do
- Before modifying any function: Run impact({target, direction:"upstream"}). Report risk.
## NEVER Do
- NEVER edit code without first running impact.
- NEVER grep for API handlers when you have a route path.`,
},
);
// ── ListTools:动态注入 repo 清单(多库时) ──
server.setRequestHandler(ListToolsRequestSchema, async () => ({
tools: TOOLS.map(t => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })),
}));
// ── CallTool:执行 + 追加 Next 提示 ──
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
const result = await executeTool(name, args);
const hint = `\n\n---\n**Next:** Review d=1 items first (WILL BREAK). Use context() on high-risk symbols.`;
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) + hint }] };
} catch (e) {
return { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true };
}
});
// ── impact 核心实现:多跳 BFS + 风险等级 ──
interface ImpactResult {
risk: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL';
byDepth: Record<number, { id: string; name: string; edge: string; confidence: number }[]>;
affectedProcesses: string[];
}
function impact(graph: KnowledgeGraph, target: string, opts: {
direction: 'upstream' | 'downstream';
maxDepth?: number; minConfidence?: number;
relationTypes?: string[];
}): ImpactResult {
const maxDepth = opts.maxDepth ?? 3;
const minConfidence = opts.minConfidence ?? 0.7;
const relTypes = new Set(opts.relationTypes ?? ['CALLS', 'IMPORTS', 'EXTENDS', 'IMPLEMENTS']);
// 找起始节点(可能多个同名)
const startIds = findNodesByName(graph, target);
const byDepth: Record<number, any[]> = { 1: [], 2: [], 3: [] };
const visited = new Set(startIds);
// BFS:每跳收集一层的邻居
let frontier = startIds;
for (let depth = 1; depth <= maxDepth; depth++) {
const next: string[] = [];
for (const nodeId of frontier) {
// upstream = 找入边;downstream = 找出边
const edges = opts.direction === 'upstream'
? graph.getIncomingEdges(nodeId)
: graph.getOutgoingEdges(nodeId);
for (const edge of edges) {
if (!relTypes.has(edge.type)) continue;
if (edge.confidence < minConfidence) continue;
const neighborId = opts.direction === 'upstream' ? edge.sourceId : edge.targetId;
if (visited.has(neighborId)) continue;
visited.add(neighborId);
byDepth[depth].push({
id: neighborId,
name: graph.getNode(neighborId).name,
edge: edge.type,
confidence: edge.confidence,
});
next.push(neighborId);
}
}
frontier = next;
}
// 风险等级:基于直接调用者数量 + 受影响执行流数
const d1Count = byDepth[1].length;
const affectedProcesses = findProcessesTouching(graph, visited);
let risk: ImpactResult['risk'];
if (d1Count >= 10 || affectedProcesses.length >= 5) risk = 'CRITICAL';
else if (d1Count >= 5) risk = 'HIGH';
else if (d1Count >= 2) risk = 'MEDIUM';
else risk = 'LOW';
return { risk, byDepth, affectedProcesses };
}
// 启动 stdio transport(Agent 通过 stdin/stdout 通信)
const transport = new StdioServerTransport();
await server.connect(transport);
server.ts L201-333 + tools.ts impact 定义 + README L355-362 impact 示例
7. 指令工程
MCP 工具再好,LLM 不用也白搭。指令工程(Instruction Engineering)是让 LLM 真正按预期使用工具的学问。
7.1 80 行阈值
instructions(MCP 连接时注入的 system 级指令)有实测的长度依从度衰减。这类工具把它压在 ~80 行(硬上限 120 行)。server.ts L31-40 注释 + L41-91 实际 instructions
这不是直觉,是实测数据。LLM 的注意力在长指令上会稀释,后半段的规定容易被忽略。所以 instructions 必须极简:只放最关键的"MUST Do / NEVER Do / 工具选择决策树",细节留给工具描述。
7.2 RFC 2119 措辞
用 MUST / NEVER / ALWAYS / SHOULD 这些 RFC 2119 关键词,而不是"建议/最好/请"。LLM 对这些大写动词的依从度显著高于自然语言建议。
## MUST Do (Before Actions)
- Before modifying any function/class/method: Run impact({target, direction:"upstream"}).
- Before committing: Run detect_changes({scope:"staged"}).
## NEVER Do
- NEVER edit code without first running impact.
- NEVER grep/search for API handlers when you have a route path — use route_map.
- NEVER rename symbols with find-and-replace — use rename.
- NEVER commit without running detect_changes.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
server.ts L45-60
7.3 skill 忽略率 56%(关键工作流必须内联)
实测:Claude Code 的 skill(独立 .md 文件)有 56% 的概率被忽略。server.ts L36 注释 "skills skipped 56%"
所以这类工具把所有关键工作流内联进 MCP instructions 和工具描述,而不是依赖 skill 文件。Skill 只放"锦上添花"的内容(探索、调试、影响分析、重构的详细指南)。这是指令工程的硬教训。
7.4 GUARDRAILS Signs 机制:失败模式沉淀
项目维护一份 GUARDRAILS.md,用 "Signs"(征兆)机制沉淀失败模式。每条 Sign 描述一种"如果你看到 X,说明 Y 要出错"的预警。README.md L39 GUARDRAILS 引用
这本质上是把生产事故和踩坑结构化成可执行规则,喂给人类贡献者和 AI Agent 双方遵守。 Signs 会反向影响 instructions 和工具描述的迭代。
7.5 核心:instructions 模板示例
# CodeGraph — Code Intelligence
This MCP server provides a code knowledge graph. ALWAYS use these tools
before reading source files or making changes.
## MUST Do (Before Actions)
- **Before modifying any function/class/method**: Run `impact({target: "X", direction: "upstream"})`. Report blast radius and risk to user.
- **Before committing**: Run `detect_changes({scope: "staged"})`. Verify only expected symbols/flows affected.
- **When given an API route path**: Run `route_map({route: "/api/..."})` FIRST to find the handler.
- **When exploring unfamiliar code**: Use `context({name: "X"})` for 360-degree view before reading source.
## NEVER Do
- NEVER edit code without first running `impact` on the target symbol.
- NEVER grep/search for API handlers when you have a route path — use `route_map`.
- NEVER rename symbols with find-and-replace — use `rename`.
- NEVER commit without running `detect_changes`.
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
## Tool Selection (Decision Tree)
| User says | Tool to use FIRST |
|---|---|
| "How does X work?" | context({name: "X"}) |
| "Change X" / "Is it safe?" | impact({target: "X", direction: "upstream"}) |
| "/api/something" | route_map({route: "/api/..."}) |
| "Rename X" | rename({symbol_name: "X", new_name: "Y"}) |
| Before commit | detect_changes({scope: "staged"}) |
## Impact Risk Levels
| Depth | Meaning | Action |
|-------|---------|--------|
| d=1 | WILL BREAK — direct callers | MUST update |
| d=2 | LIKELY AFFECTED — indirect | Should test |
| d=3 | MAY NEED TESTING — transitive | Test if critical |
server.ts L41-91
8. Agent 集成三层
8.1 SessionStart Hook:自动注入工具清单
当 Agent 会话启动时,SessionStart hook 检测当前目录是否有索引(向上找 5 层 .codegraph),如果有,就把工具清单和资源 URI 注入到 Agent 上下文。session-start.sh 全文
#!/bin/bash
# SessionStart hook:会话启动时触发,stdout 直接注入 Agent 上下文
# 检测当前目录(向上找 5 层)是否有索引
dir="$PWD"
found=false
for i in 1 2 3 4 5; do
if [ -d "$dir/.codegraph" ]; then
found=true
break
fi
parent="$(dirname "$dir")"
[ "$parent" = "$dir" ] && break
dir="$parent"
done
if [ "$found" = false ]; then
exit 0 # 没索引,不注入
fi
# 有索引 → 注入工具清单(这段 stdout 进 Agent 上下文)
cat << 'EOF'
## CodeGraph Code Intelligence
This codebase is indexed by CodeGraph, providing a knowledge graph with
execution flows, relationships, and semantic search.
**Available MCP Tools:**
- `context` — 360-degree symbol view
- `impact` — Blast radius analysis
- `detect_changes` — Git-diff impact analysis
- `rename` — Multi-file coordinated rename
- `cypher` — Raw graph queries
**Quick Start:** READ `codegraph://repo/{name}/context` for overview,
then use `context` to explore symbols.
EOF
exit 0
session-start.sh
8.2 PreToolUse Hook:拦截 grep → 增强引擎(<500ms)
这是最精巧的一层。当 Agent 准备调用 Grep、Glob 或 Bash(rg/grep) 时,hook 在工具执行前拦截,提取搜索词,调用增强引擎,把图上下文作为 additionalContext 注入。pre-tool-use.sh 全文
关键约束
- 必须快:目标 <500ms,否则拖慢 Agent 的每一次搜索。所以只用 BM25(不用语义检索/聚类),避免 >1s 的延迟。
- 聚类不外泄:增强引擎只返回匹配符号和直接关系,不返回 Community/Process 的内部结构(那些是深度查询时才暴露的)。
- 静默失败:任何错误都
exit 0,绝不阻断原始工具调用。 - 模式提取:从
Grep取pattern;从Glob去掉通配符取有意义的名字;从Bash只在命令含rg/grep时提取;短于 3 字符的词跳过。
#!/bin/bash
# PreToolUse hook:拦截搜索工具,注入图上下文
# 输入:stdin 收到 { tool_name, tool_input, cwd, ... }
# 输出:JSON { hookSpecificOutput: { additionalContext: ... } }
INPUT=$(cat)
TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty')
CWD=$(echo "$INPUT" | jq -r '.cwd // empty')
# 按工具类型提取搜索词
PATTERN=""
case "$TOOL_NAME" in
Grep)
PATTERN=$(echo "$INPUT" | jq -r '.tool_input.pattern // empty')
;;
Glob)
# Glob 是路径模式,去掉通配符取有意义的名字
RAW=$(echo "$INPUT" | jq -r '.tool_input.pattern // empty')
PATTERN=$(echo "$RAW" | sed -n 's/.*[*\/]\([a-zA-Z][a-zA-Z0-9_-]*\).*/\1/p')
;;
Bash)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
if echo "$CMD" | grep -qE '\brg\b|\bgrep\b'; then
# 从 rg/grep 命令提取 pattern
PATTERN=$(echo "$CMD" | sed -n "s/.*\brg\s\+\(--[^ ]*\s\+\)*['\"]\\?\([^'\";\| >]*\\).*/\2/p")
fi
;;
*)
exit 0 # 非搜索工具,跳过
;;
esac
# 太短或空 → 跳过
if [ -z "$PATTERN" ] || [ ${#PATTERN} -lt 3 ]; then
exit 0
fi
# 检测是否在索引仓库内(向上找 5 层)
dir="${CWD:-$PWD}"
found=false
for i in 1 2 3 4 5; do
if [ -d "$dir/.codegraph" ]; then found=true; break; fi
parent="$(dirname "$dir")"
[ "$parent" = "$dir" ] && break
dir="$parent"
done
[ "$found" = false ] && exit 0
# 调用增强引擎 —— 必须 <500ms(只用 BM25,不做语义/聚类)
RESULT=$(cd "$CWD" && npx -y code-graph augment "$PATTERN" 2>&1 1>/dev/null)
if [ -n "$RESULT" ]; then
ESCAPED=$(echo "$RESULT" | jq -Rs .)
jq -n --argjson ctx "$ESCAPED" '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
additionalContext: $ctx
}
}'
else
exit 0
fi
pre-tool-use.sh
8.3 MCP Instructions:连接时注入 system 级指令
第三层是MCP 协议自带的 instructions 字段。当 Agent 连接 MCP server 时,instructions 作为 system 级指令注入,全程生效。这一层与 SessionStart hook 互补:hook 在会话启动时一次性注入"这个仓库有索引",instructions 在每次工具调用时持续约束"怎么用工具"。server.ts L41-91, L215
会话启动
│
├─→ SessionStart hook 注入:"本仓库已索引,工具清单如下"
│
└─→ MCP 连接
│
├─→ instructions 注入 system 级:"MUST/NEVER/决策树"
│
└─→ 每次工具调用
│
├─→ PreToolUse hook(若是搜索)→ 增强上下文注入
│
└─→ 工具执行 → 返回末尾带 Next 提示 → 引导下一步
9. 企业级扩展
9.1 多库 MCP 架构(registry + 懒加载连接池)
企业里一个 Agent 要同时面对几十上百个仓库(前端、后端、微服务、SDK)。这类工具用全局 registry + 懒加载连接池解决:README.md L200-244
- 全局 registry:
~/.codegraph/registry.json记录所有索引过的仓库路径与元数据。每次analyze注册一条,clean注销一条。 - 一个 MCP server 服务所有仓库:不需要每个项目单独配 MCP。配置一次,到处可用。
- 懒加载连接池:LadybugDB 连接首次查询时才打开,5 分钟不活动后驱逐,最多 5 个并发。避免一次性打开几十个连接爆内存。
- repo 参数自适应:单库时
repo可选;多库时 REQUIRED,且工具描述自动注入仓库清单(见 6.4)。
9.2 增量更新:commit 级 vs 文件级的缺口与方案
这类工具的 Roadmap 明确标注 "Incremental Indexing — Only re-index changed files" 还在 Actively Building。README.md L473 目前是全量重建。这是大规模仓库的主要痛点。
增量更新的难点不在"哪些文件变了"(git diff 就知道),而在关系传播:
- 文件 A 改了一个函数签名 → 所有调用 A 的文件(跨文件传播)的 CALLS 边都要重算;
- 类 B 加了一个方法 → 所有继承 B 的类的 MRO(方法解析顺序)要重算;
- 接口 I 加了一个方法 → 所有实现 I 的类的 implementor map 要更新,进而影响 CALLS 解析。
方案:把图谱的变更也建模成图问题——先 diff 出受影响的符号子图,再用 Kahn 拓扑排序在这个子图上重跑跨文件传播。但这需要图数据库支持高效的"删除某节点关联的所有边 + 局部重算",工程复杂度高。
9.3 跨库 sidecar 索引:消费者 / MQ topic / SOA
微服务架构里,一个仓库的代码会调用另一个仓库的 API(HTTP fetch)、消费另一个仓库的 MQ 消息(RocketMQ topic)、调用另一个仓库的 SOA 服务。这些跨库关系是分布式系统的命脉,也是图索引最难捕捉的部分。
这类工具的方案是sidecar 索引:每个仓库索引时,把它的 fetch 调用、MQ 生产/消费、SOA 提供者写入一个全局跨库索引。pipeline.ts L1700-1708 updateCrossRepoIndex
跨库关系的最大陷阱是 N+1 查询:要回答"A 仓库的方法调用了哪些其他仓库",如果对每个方法单独去查全局索引,就是 N+1。正确做法是批量预加载:一次把 A 仓库所有跨库关系读进来,在内存里 join。这类工具的 topic_map 工具就是一次性返回所有 topic 的生产者/消费者,避免 Agent 反复查询。
跨库关系的三种形态与建模
- HTTP fetch(FETCHES 边):前端
fetch('/api/users')→ 后端路由处理器。索引时扫描所有fetch()调用,匹配同仓库路由,未匹配的写入跨库索引(带配置变量解析,如API_BASE_URL)。pipeline.ts L1671-1708 - MQ topic(publishes/subscribes):RocketMQ 的生产者/消费者。索引时识别
@RocketMQMessageListener等注解,建Interface:mq:<topic>节点,所有生产/消费方法连到它。topic_map工具跨库查询。 - SOA 调用(crossRepoProvider):Interface 节点带
soaPrefix和crossRepoProvider属性。context工具返回时,outgoing.calls里带calledMethod + providerRepo字段,提示这是跨库调用。
9.4 存储双轨:嵌入式日常 / 服务端治理
| 模式 | 数据库 | 适用场景 | 部署 |
|---|---|---|---|
| 嵌入式(CLI) | LadybugDB native,落在 .codegraph/ | 个人开发、本地隐私、CI | 零部署,随仓库走 |
| 服务端(治理) | LadybugDB server / 中央化 | 团队共享、权限治理、跨库查询中心 | 需要运维,但支持多用户并发 |
| Web UI(WASM) | LadybugDB WASM,浏览器内存 | 演示、一次性分析、小仓库 | 零安装,但受浏览器内存限制(~5k 文件) |
| Bridge 模式 | CLI 索引 + Web UI 展示 | 本地索引 + 浏览器可视化 | code-graph serve 连接两者 |
README.md L19-31, L248-265
9.5 Branch Index Overlay(分支覆盖索引)
团队协作时,每个开发者都在不同分支上。如果每次切分支都全量重建索引,成本不可接受。Branch Index Overlay 的思路是:主分支建一份完整索引(base layer),每个特性分支只索引与主分支的 diff,作为 overlay 覆盖层叠加。查询时合并两层结果。
这本质上是把 git 的分支模型镜像到图索引。挑战在于关系的合并语义(overlay 删了一个节点,base 层指向它的边怎么办)。这是这类工具尚未实现但企业场景必需的能力。
9.6 权限分层:只读为主,rename 可写 + dry_run
安全第一。14 个工具里 13 个是只读的(context、impact、cypher 等都不改文件)。唯一会改文件的是 rename,且默认 dry_run: true(预览不写入)。tools.ts L201-235
- 每次 rename 的编辑都带置信度标签:
"graph"(图关系找到,高置信,可放心接受)vs"text_search"(正则文本搜索,低置信,需人工审查)。 - rename 后必须再跑
detect_changes验证无意外副作用(Next 提示强制引导)。 - 没有"批量自动应用"的工具——所有写操作都设计成需要人类确认。
10. 从零实现路线
10.1 最小可用原型(MVP:<500 行)
不要一上来就做 14 语言 + 14 工具。先做单语言 + 基础节点 + 3 工具的最小闭环,跑通了再扩展。
| 组件 | MVP 选择 | 理由 |
|---|---|---|
| 语言 | TypeScript 单语言 | Tree-sitter 支持成熟,类型信息丰富 |
| 节点 | File / Function / Class(3 种) | 覆盖 80% 场景 |
| 关系 | CALLS / IMPORTS(2 种) | 调用图是最核心的 |
| 图存储 | 内存 Map(不落库) | 验证概念,零依赖 |
| 工具 | context / impact / cypher(3 个) | 理解 + 变更 + 查询各一个 |
| MCP | @modelcontextprotocol/sdk | 官方 SDK,stdio transport |
// MVP:单文件知识图谱 + 3 工具(<500 行)
import Parser from 'tree-sitter';
import TS from 'tree-sitter-typescript';
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
// 1. 极简图存储(内存)
class SimpleGraph {
nodes = new Map<string, { id: string; kind: string; name: string; filePath: string }>();
edges: { type: string; source: string; target: string; confidence: number }[] = [];
addNode(n: any) { this.nodes.set(n.id, n); }
addEdge(e: any) { this.edges.push(e); }
findByName(name: string) { return [...this.nodes.values()].filter(n => n.name === name); }
callers(id: string) { return this.edges.filter(e => e.type === 'CALLS' && e.target === id).map(e => e.source); }
}
// 2. 解析单个 TS 文件 → 提取 Function/Class + CALLS/IMPORTS
function parseFile(graph: SimpleGraph, filePath: string, content: string) {
const parser = new Parser(); parser.setLanguage(TS.typescript);
const tree = parser.parse(content);
// 用 query 提取(详见第 4 章 4.6)
// ... 提取 function_declaration, class_declaration, call_expression ...
// graph.addNode(...); graph.addEdge(...);
}
// 3. 三工具实现
const tools = {
context: (args: { name: string }) => {
const nodes = graph.findByName(args.name);
return nodes.map(n => ({ ...n, callers: graph.callers(n.id) }));
},
impact: (args: { target: string }) => {
// 多跳 BFS(详见第 6 章 6.5)
},
cypher: (args: { query: string }) => { /* 简化:只支持 MATCH (a)-[:CALLS]->(b) RETURN */ },
};
// 4. MCP Server
const server = new Server({ name: 'mini-graph', version: '0.1.0' }, { capabilities: { tools: {} } });
// ... 注册 handlers ...
await server.connect(new StdioServerTransport());
10.2 扩展路径(循序渐进)
MVP(单语言 + 3 节点 + 3 工具,<500 行)
│
├─→ 加语言(Python / Java / Go ...)
│ 每语言一个 LanguageProvider(importSemantics: named/wildcard/namespace)
│
├─→ 加节点类型(Method / Interface / 多语言节点)
│
├─→ 加关系(EXTENDS / IMPLEMENTS / ACCESSES / OVERRIDES)
│ 触发 MRO(方法解析顺序)计算
│
├─→ 加社区发现(Leiden)→ Community 节点
│ graphology-communities-leiden
│
├─→ 加执行流追踪 → Process 节点
│ 入口点打分 + BFS + 去重
│
├─→ 加跨文件传播(Kahn 拓扑 + 接口分派延迟)
│ ← 这是质量分水岭,不做就是玩具
│
├─→ 加 Worker 池并行(大仓库必需)
│
├─→ 加 Embedding(HNSW 向量索引)
│ BM25 + 语义 + RRF 混合搜索
│
├─→ 加业务概念(Route / Tool / MQ topic / Entity-Table)
│
└─→ 加跨库(sidecar 索引 + cross_repo_search)
10.3 技术选型清单
| 层 | 推荐 | 备选 |
|---|---|---|
| 解析器 | Tree-sitter(native for CLI, WASM for Web) | Babel(仅 JS)、libclang(仅 C/C++) |
| 图存储 | LadybugDB(嵌入式,属性图+向量) | Neo4j(服务端)、Memgraph、Apache AGE(Postgres 扩展) |
| 图算法 | graphology + graphology-communities-leiden | igraph、networkx(Python) |
| Embedding | transformers.js + snowflake-arctic-embed-xs(384维) | OpenAI text-embedding-3-small、Cohere |
| 向量索引 | HNSW(LadybugDB 内建) | Faiss、hnswlib、pgvector |
| MCP | @modelcontextprotocol/sdk | (目前唯一标准选项) |
| Agent Hooks | Claude Code 的 SessionStart / PreToolUse / PostToolUse | — |
| 并发 | Node.js worker_threads | Piscina(worker 池库) |
| BM25 | winston-bm25 或自实现 | Lucene、Tantivy |
10.4 踩坑清单(前人填过的坑)
| 坑 | 症状 | 解法 | 出处 |
|---|---|---|---|
| 接口分派未延迟 | 接口的 CALLS 漏掉部分实现类 | 所有 chunk 的 heritage 缓冲,建完整 implementor map 后再解析 CALLS | pipeline.ts L963-989 |
| Wildcard import 未合成 | Go/Ruby/C++/Python 的跨文件调用解析不出 | synthesizeWildcardImportBindings:把导入文件的导出符号合成为具名绑定 | pipeline.ts L212-324 |
| N+1 跨库查询 | 跨库关系查询慢到超时 | 批量预加载,topic_map 一次返回所有 | tools.ts topic_map 设计 |
| 增量索引缺口 | 大仓库全量重建太慢 | Roadmap 项;方案:子图 diff + 局部重算 | README.md L473 |
| instructions 太长 | LLM 依从度衰减,后半段规定被忽略 | 压到 ~80 行(<120 硬上限),用 RFC 2119 措辞 | server.ts L36-40 |
| skill 被忽略 | 56% 概率 skill 不被读取 | 关键工作流内联进 instructions + 工具描述 | server.ts L36 |
| Worker OOM | minified 大文件撑爆 worker 内存 | 子批 1500 + 30s 超时 fail fast | worker-pool.ts L37-39 |
| 跨环类型传播 | 循环依赖导致死锁 | Kahn 把环内文件作为最后一组,不做跨环传播 | pipeline.ts L138-141 |
| 向量写时复制 | 符号属性更新触发向量行复制 | Embedding 独立建表 | schema.ts L432-433 注释 |
| PreToolUse 拖慢搜索 | 每次 grep 都等图查询 | 只用 BM25,目标 <500ms,聚类不外泄 | pre-tool-use.sh |
10.5 推荐参考开源项目
| 项目 | 定位 | 参考价值 |
|---|---|---|
| code-lens(gitlab.21tb.com/eln/code-lens) | MCP 形态的代码记忆 / 代码图谱 | TBC 内部 10 工具代码图谱,本文原理的工程实现 |
| deepwiki-open | DeepWiki 开源版,LLM 生成仓库文档 | 理解"用 LLM 读图生成文档"的范式 |
| sourcebot | 代码搜索引擎 | BM25 + 混合搜索的工程实现 |
| graphology | JS 图算法库 | 社区发现、图遍历的基础设施 |
| tree-sitter | 增量解析器 | 多语言 AST 提取的事实标准 |
| modelcontextprotocol | MCP 官方 SDK 与规范 | 工具/资源/prompt 三件套协议 |
代码知识图谱的本质是把代码的确定性结构在索引时预计算好,通过决策树式的 MCP 工具一次返回完整上下文,让 AI Agent 从"概率性地摸索"升级为"确定性地查询"。图谱 + RAG 分层共存,确定性走图、语义走向量;工具描述即决策树、Next 提示自引导、instructions 极简内联——这三条是指令工程的核心。从 MVP 到企业级,按"语言 → 关系 → 社区 → 执行流 → 跨库"的路径扩展,每一步都有明确的踩坑清单可避。
核心源码出处索引
| 主题 | 文件 | 行号 |
|---|---|---|
| 图 schema(节点表 + CodeRelation 表 + Embedding 表) | schema.ts | L22-503 |
| 6 阶段管线编排 | pipeline.ts | L1345-1926 |
| Kahn 拓扑排序 | pipeline.ts | L96-144 |
| 跨文件类型传播 | pipeline.ts | L326-492 |
| 接口分派延迟解析 | pipeline.ts | L878-990 |
| Wildcard import 合成 | pipeline.ts | L212-324 |
| Worker 池(字节预算/子批/超时) | worker-pool.ts | L31-172 |
| Leiden 社区发现 | community-processor.ts | L104-268 |
| 执行流追踪(入口点/BFS/去重) | process-processor.ts | L81-450 |
| 14 MCP 工具定义 | tools.ts | L27-527 |
| MCP Server + instructions + Next 提示 | server.ts | L41-333 |
| 动态 repo 注入 | server.ts | L166-195 |
| SessionStart hook | session-start.sh | 全文 |
| PreToolUse hook(增强引擎) | pre-tool-use.sh | 全文 |
| 设计哲学(vs RAG) | README.md | L269-308 |
| 多库 MCP 架构 | README.md | L200-244 |