代码知识图谱:设计哲学与架构深度解析

让 AI Agent 真正理解你的代码库 —— 从原理到原型到扩展

确定性关系图谱 × 索引管线工程 × MCP 工具设计 × Agent 三层集成

知识图谱 属性图 Tree-sitter Leiden 社区 MCP 协议 Agent Hooks TypeScript

读完本文,你能:① 理解代码知识图谱的设计原理 ② 搭出最小可用原型 ③ 知道怎么扩展到企业级。

1. 设计哲学

1.1 为什么知识图谱,而不是 RAG?

传统 Agent 工具(Cursor、Claude Code、Codex 等)擅长语义匹配,但不真正知道你的代码库结构。典型故障:AI 修改了 UserService.validate(),却不知道有 47 个函数依赖它的返回类型,于是破坏性变更被悄然合并

检索增强生成(RAG,含 Graph RAG)的根本问题是概率性:它把原始图边喂给 LLM,指望它探索够多次。结果是 LLM 要连续做 4+ 次查询(先找调用者、再找文件、再过滤测试、再判高风险),每一跳都可能漏。知识图谱的做法相反——在索引时就预计算结构(聚类、追踪、打分、置信度),让工具在一次调用内返回完整上下文。

确定性 vs 概率性

"谁调用了 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 不是二选一,而是分层共存

正确的架构是分层共存:确定性的关系(谁调用谁、谁继承谁、谁实现哪个接口、字段被谁读/写)走图;语义相似度("哪个函数在做用户鉴权")走向量检索。这类工具同时维护:

这种分层让"硬"问题(影响分析、变更检测、协调重命名)走图的强保证路径,让"软"问题(自然语言定位)走向量路径,互不替代。

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)和抽象语义节点(索引时计算出来的高阶概念)。

类别节点类型来源
代码元素FileFolder文件系统扫描
FunctionClassInterfaceMethodTree-sitter AST
多语言:StructEnumTraitImplMacroModuleConstructorPropertyAnnotationTemplate各语言的 native AST 节点
抽象语义Community(社区/功能域)Leiden 算法聚类
Process(执行流)入口点 BFS 追踪
业务扩展Route(API 路由)、Tool(MCP 工具)、Section(Markdown 章节)框架/协议感知提取

为什么要分表?而不是一个大 Node 表用 label 区分?

这是属性图的核心取舍。每种节点类型一张表,每张表有专属字段。比如 Method 表有 parameterCountreturnTypeClass 表有 tableNametableSource(标记数据库实体映射);Route 表有 responseKeysmiddleware。这让 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 一张表)?

AI 友好是第一原则

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:可审计的关系

每条边带两个审计字段,是工程上区别于玩具图的关键:

下游工具就能做过滤: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

语言ImportsNamed BindingsHeritageType AnnotationsConstructor 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

  1. 字节预算分块CHUNK_BYTE_BUDGET = 20MB。20MB 源码 ≈ 200-400MB 峰值工作内存(AST 膨胀 + 提取记录 + 序列化开销同时驻留)。
  2. Worker 池size = Math.min(8, Math.max(1, os.cpus().length - 1)),默认最多 8 个线程。少于 15 个文件或 512KB 时不 spawn(开销 > 收益)。
  3. 子批(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.tsimport { getConfig } from './config'getConfig() 返回类型 Configb.tscfg = 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(),有两个实现类 SqlUserRepoMongoUserRepo。当代码 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 modelsmodels.User(),需要建 moduleAliasMapmodelsmodels.py),避免歧义符号展开。

5. 社区发现与执行流

5.1 Leiden 算法:基于 CALLS 边聚类

社区(Community)= 自动发现的功能域。这类工具用 Leiden 算法(比 Louvain 更稳健,保证连通性)在 graphology 图上聚类。community-processor.ts L104-205

聚类输入与策略

社区标签生成(启发式)

社区需要人类可读的名字。启发式优先级:community-processor.ts L332-392

  1. 取成员文件路径的最常见父目录名(跳过 src/lib/core/utils/common/shared/helpers 等通用名);
  2. 退而求其次:取成员函数名的公共前缀
  3. 最后兜底:Cluster_<编号>

社区还带 cohesion(内聚度,0-1)= 内部边数 / 总边数(大社区采样 50 个成员避免 O(N²))。community-processor.ts L420-445

5.2 执行流追踪:入口点 → BFS → Process

执行流(Process)= 从入口点到终点的调用链。它是 Agent 理解"这个功能怎么跑通"的核心抽象。process-processor.ts L81-215

四步流程

  1. 找入口点:调用多、被调少、导出、命名模式匹配(handle*on**Controller)。排除测试文件。打分后取 Top 200。process-processor.ts L270-336
  2. BFS 追踪:从每个入口点沿 CALLS 边(confidence >= 0.5)广度优先,记录所有路径。maxTraceDepth=10maxBranching=4minSteps=3(2 步只是"A 调 B",不算真流程)。process-processor.ts L346-394
  3. 去重:先子集去重(A→B→C→D 包含 A→B→C,删短的),再按入口-终点对去重(同一对保留最长路径)。
  4. 建 Process 节点:标签 "入口名 → 终点名",记录 stepCountcommunities(途经社区)、processTypecross_community 跨社区 / intra_community 社区内)。
为什么 cross_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_mapAPI 路由 → 处理器 → 消费者映射
tool_mapMCP/RPC 工具定义 → 处理器映射
topic_mapMQ topic 的生产者/消费者(跨库)
变更类
(写图/写文件)
impact变更爆炸半径(upstream/downstream + 风险等级)
detect_changesgit diff → 受影响的符号和执行流
rename多文件协调重命名(graph + 文本搜索,带置信度标签)
api_impactAPI 路由变更预检(route_map + shape_check + impact 合一)
查询类
(图查询/导航)
cypher原始 Cypher 查询(万能逃生口)
list_repos列出所有索引仓库
resolve_repoFQN → 仓库(多库时定位)
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

超过 120 行,依从度显著下降

这不是直觉,是实测数据。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%"

教训:不要把关键工作流放进 skill

所以这类工具把所有关键工作流内联进 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 准备调用 GrepGlobBash(rg/grep) 时,hook 在工具执行前拦截,提取搜索词,调用增强引擎,把图上下文作为 additionalContext 注入。pre-tool-use.sh 全文

关键约束

#!/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

9.2 增量更新:commit 级 vs 文件级的缺口与方案

现状:增量索引是已知缺口

这类工具的 Roadmap 明确标注 "Incremental Indexing — Only re-index changed files" 还在 Actively Building。README.md L473 目前是全量重建。这是大规模仓库的主要痛点。

增量更新的难点不在"哪些文件变了"(git diff 就知道),而在关系传播

方案:把图谱的变更也建模成图问题——先 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 反模式警示

跨库关系的最大陷阱是 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 节点带 soaPrefixcrossRepoProvider 属性。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

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-leidenigraph、networkx(Python)
Embeddingtransformers.js + snowflake-arctic-embed-xs(384维)OpenAI text-embedding-3-small、Cohere
向量索引HNSW(LadybugDB 内建)Faiss、hnswlib、pgvector
MCP@modelcontextprotocol/sdk(目前唯一标准选项)
Agent HooksClaude Code 的 SessionStart / PreToolUse / PostToolUse
并发Node.js worker_threadsPiscina(worker 池库)
BM25winston-bm25 或自实现Lucene、Tantivy

10.4 踩坑清单(前人填过的坑)

症状解法出处
接口分派未延迟接口的 CALLS 漏掉部分实现类所有 chunk 的 heritage 缓冲,建完整 implementor map 后再解析 CALLSpipeline.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 OOMminified 大文件撑爆 worker 内存子批 1500 + 30s 超时 fail fastworker-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-lensgitlab.21tb.com/eln/code-lensMCP 形态的代码记忆 / 代码图谱TBC 内部 10 工具代码图谱,本文原理的工程实现
deepwiki-openDeepWiki 开源版,LLM 生成仓库文档理解"用 LLM 读图生成文档"的范式
sourcebot代码搜索引擎BM25 + 混合搜索的工程实现
graphologyJS 图算法库社区发现、图遍历的基础设施
tree-sitter增量解析器多语言 AST 提取的事实标准
modelcontextprotocolMCP 官方 SDK 与规范工具/资源/prompt 三件套协议
一句话总结

代码知识图谱的本质是把代码的确定性结构在索引时预计算好,通过决策树式的 MCP 工具一次返回完整上下文,让 AI Agent 从"概率性地摸索"升级为"确定性地查询"。图谱 + RAG 分层共存,确定性走图、语义走向量;工具描述即决策树、Next 提示自引导、instructions 极简内联——这三条是指令工程的核心。从 MVP 到企业级,按"语言 → 关系 → 社区 → 执行流 → 跨库"的路径扩展,每一步都有明确的踩坑清单可避。


核心源码出处索引

主题文件行号
图 schema(节点表 + CodeRelation 表 + Embedding 表)schema.tsL22-503
6 阶段管线编排pipeline.tsL1345-1926
Kahn 拓扑排序pipeline.tsL96-144
跨文件类型传播pipeline.tsL326-492
接口分派延迟解析pipeline.tsL878-990
Wildcard import 合成pipeline.tsL212-324
Worker 池(字节预算/子批/超时)worker-pool.tsL31-172
Leiden 社区发现community-processor.tsL104-268
执行流追踪(入口点/BFS/去重)process-processor.tsL81-450
14 MCP 工具定义tools.tsL27-527
MCP Server + instructions + Next 提示server.tsL41-333
动态 repo 注入server.tsL166-195
SessionStart hooksession-start.sh全文
PreToolUse hook(增强引擎)pre-tool-use.sh全文
设计哲学(vs RAG)README.mdL269-308
多库 MCP 架构README.mdL200-244