编程 code-review-graph 深度实战:Tree-sitter + SQLite 知识图谱,如何把 AI 代码审查的 Token 砍掉 82 倍

2026-07-27 05:43:56 +0800 CST views 6

code-review-graph 深度实战:Tree-sitter + SQLite 知识图谱,如何把 AI 代码审查的 Token 砍掉 82 倍

一、背景:AI 代码审查正在烧掉你的钱包

2026 年 7 月 21 日,一个叫 code-review-graph 的项目登顶 GitHub Trending,单日涨了 1600+ Star,总量突破 23000。它的 Slogan 非常直白:

Stop burning tokens. Start reviewing smarter.

翻译过来就是:别再把 Token 烧在无关代码上了。

这句话戳中的是一个所有重度使用 AI 编程助手的人都体会过的痛点。你让 Claude Code / Cursor / Copilot 审查一个 PR,它做的第一件事往往是——把半个仓库的文件读一遍。改了 30 行代码,AI 读了 30 万 Token 的上下文,其中 95% 和这次变更毫无关系。

这件事有三重代价:

  1. 。Token 是按量计费的。一个 fastapi 规模的仓库,全量上下文接近百万 Token,一次审查的成本可能顶得上几十次精准审查。
  2. 质量。LLM 的上下文窗口不是越满越好。无关代码塞得越多,注意力被稀释得越严重,真正该发现的问题反而被淹没——这就是所谓的 "lost in the middle" 现象。
  3. 时间。读取、传输、prefill 几十万 Token,延迟是以十秒计的。

code-review-graph(下文简称 CRG)给出的答案是:给代码库建一张本地知识图谱,让 AI 只读"爆炸半径"内的代码。按官方在 6 个真实开源仓库上的基准测试,Token 消耗中位数降低约 82 倍,最好的单案例(fastapi)达到 528 倍。

这篇文章我会做三件事:

  • 把 CRG 的三层架构(Tree-sitter → SQLite 图谱 → MCP)拆到原理层
  • 手写一个 mini 版代码知识图谱(约 200 行 Python),让你彻底理解它为什么 work
  • 给出安装配置、CI 集成、调优与选型的完整实操清单

二、核心概念:从「读全仓库」到「爆炸半径」

2.1 传统方案的困境

在 CRG 之前,让 AI 理解大代码库主要有三条路:

路线一:全量读取。 简单粗暴,小项目可行,大项目直接爆上下文窗口,而且贵。

路线二:RAG(向量检索)。 把代码切块、做 embedding、存向量库,审查时按语义相似度捞回来。这是过去两年的主流方案,但用过的人都知道它在代码场景有硬伤:

  • 切块破坏结构。一个函数被从中间切开,调用关系荡然无存。
  • 相似 ≠ 相关。语义相似度会把长得像的代码捞回来,但"谁调用了我、我改了会影响谁"是结构问题,不是语义问题。你改了 auth/session.py,真正该看的是所有 import 它的模块和覆盖它的测试,而不是另一个"看起来也在做认证"的文件。
  • 索引更新贵。代码天天变,重算 embedding 的成本不低。

路线三:静态分析工具(SonarQube / Semgrep)。 它们能找 bug、查规范,但输出的是"问题清单",不是"给 AI 的上下文",定位完全不同。

2.2 CRG 的答案:结构感知的知识图谱

CRG 的思路可以用一条流水线概括:

你的代码库
   ↓ Tree-sitter 解析成 AST
提取节点(函数、类、导入)和边(调用、继承、测试覆盖)
   ↓
存入本地 SQLite 知识图谱(.code-review-graph/ 目录)
   ↓ 通过 MCP 协议暴露查询接口
AI 审查时,只返回"爆炸半径"内的相关文件

三个核心机制:

① 爆炸半径分析(Blast-radius Analysis)。 一个文件变更时,沿着图谱的边反向追踪:谁调用了它、谁依赖它、哪些测试覆盖它。这个集合就是变更的爆炸半径,AI 只读这些。官方数据:在大型 Monorepo 里,27700+ 文件被排除在上下文之外,实际只读约 15 个文件。

② 增量更新(< 2 秒)。 文件保存或 git commit 触发 hook 后,通过 SHA-256 哈希比对找出真正变更的文件,只重新解析这些文件及其受影响的依赖。官方 benchmark:2900 文件的项目重新索引 < 2 秒。

③ MCP 协议暴露。 图谱不是给人看的报表,而是通过 Model Context Protocol 变成 AI 助手可调用的工具集。Claude Code、Cursor、Codex、Gemini CLI、Copilot 等 16 个平台可以直接查询它。

一句话总结 CRG 的哲学:AI 工具集成的核心不是"给更多上下文",而是"给更精准的上下文"。

三、架构分析:三层拆解

3.1 第一层:Tree-sitter——增量式、多语言的 AST 引擎

Tree-sitter 是 GitHub 出品的增量解析器生成器,Neovim、Zed、GitHub 代码导航背后都是它。选它做地基有三个原因:

  1. 真正理解语法。它产出的是具体语法树(CST/AST),函数定义、类、import 语句都是结构化节点,不是正则匹配出来的文本片段。
  2. 增量解析。文件改动一行,Tree-sitter 只重解析受影响的子树,这和 CRG 的增量更新哲学天然契合。
  3. 语言无关。每种语言一个 grammar,tree_sitter_language_pack 这个 Python 包一次打包了几十种。CRG 借此支持 40+ 语言——从 Python/TS/Go/Rust/Java/C++ 到 Solidity、Terraform、Vue SFC、Zig,甚至 Jupyter notebook。语言不在列表里还能通过 languages.toml 自定义节点映射,不用改源码。

CRG 对每种语言配置了四类节点映射:

  • function_node_types:函数定义节点
  • class_node_types:类定义节点
  • import_node_types:导入语句节点
  • call_node_types:函数调用节点

解析完成后,AST 被抽象成图的两要素:

  • 节点(Nodes):函数、类、模块、测试用例
  • 边(Edges):调用(calls)、导入(imports)、继承(inherits)、测试覆盖(tests)

3.2 第二层:SQLite——被低估的图数据库

很多人第一反应:图谱为什么不用 Neo4j?答案藏在 CRG 的定位里——本地优先(local-first)

  • 图谱存在项目根目录 .code-review-graph/ 下,单文件 SQLite
  • 不需要联网,源代码永远不离开你的机器(企业合规友好)
  • 零部署成本,pip install 完即用

而 SQLite 做图查询完全够用。代码图谱的典型查询是"从某个节点出发、沿边遍历 2~3 跳",用 SQLite 的**递归 CTE(WITH RECURSIVE)**就能优雅实现,后面实战部分我会写给你看。一个几千文件的仓库,节点和边通常在十万级,SQLite 加上索引后毫秒级返回,根本不需要一个 JVM 起步的图数据库。

这其实是个值得程序员记住的工程决策模式:当图的规模在百万边以内、查询模式固定时,SQLite + 递归 CTE 是比专用图数据库更香的选择——Turso、Datasette 等一票新工具都在验证这条路。

3.3 第三层:MCP——图谱的"对外 API"

MCP(Model Context Protocol)是 Anthropic 发起、现已被 OpenAI/Google 等广泛跟进的标准协议,作用类似"AI 工具界的 USB-C":工具方实现一次 MCP Server,所有支持 MCP 的 AI 客户端都能调用。

CRG 通过 MCP 暴露了约 30 个工具,大致分三类:

  • 构建类:建图、重建、增量刷新
  • 查询类:爆炸半径、语义搜索、架构概览、调用链、测试缺口
  • 管理类:安装、卸载、平台配置

于是 AI 助手的工作流变成:

用户:帮我审查这个 PR
AI:(调用 MCP 工具 blast_radius(changed_files=[...]))
MCP:返回 15 个相关文件 + 风险评分 + 受影响的测试
AI:(只读这 15 个文件)给出审查意见

对比传统流程里 AI 自己 grep + 猜 + 全量读 的窘态,这是质的区别。

值得一提的是,CRG 还在结构化关系之外补了语义搜索:对节点做 embedding(在 CPU 上确定性运行),支持"认证逻辑"、"支付流程"这类高层概念查询——结构图谱负责精准,语义搜索负责兜底,两条腿走路。

四、代码实战:200 行手写一个 mini 版代码知识图谱

理解一个系统最好的方式是造一个丐版。下面我们用 Python + tree-sitter + SQLite 复刻 CRG 的核心链路:解析 → 建图 → 爆炸半径查询。以 Python 代码库为分析目标。

4.1 准备环境

pip install tree-sitter tree-sitter-language-pack

4.2 用 Tree-sitter 提取节点和边

# graph_builder.py
import hashlib
import sqlite3
from pathlib import Path
from tree_sitter_language_pack import get_parser

parser = get_parser("python")

def sha256_file(path: Path) -> str:
    return hashlib.sha256(path.read_bytes()).hexdigest()

def parse_file(path: Path):
    """解析单个 Python 文件,返回 (nodes, edges)"""
    source = path.read_bytes()
    tree = parser.parse(source)
    nodes, edges = [], []

    def text(node):
        return source[node.start_byte:node.end_byte].decode("utf8")

    def walk(node, current_func=None):
        if node.type == "function_definition":
            name_node = node.child_by_field_name("name")
            func_name = text(name_node)
            nodes.append(("function", func_name, str(path),
                          node.start_point[0] + 1))
            current_func = func_name          # 进入新的函数作用域
        elif node.type == "class_definition":
            name_node = node.child_by_field_name("name")
            nodes.append(("class", text(name_node), str(path),
                          node.start_point[0] + 1))
            # 继承边:class Foo(Bar) -> Foo inherits Bar
            supers = node.child_by_field_name("superclasses")
            if supers:
                for ch in supers.named_children:
                    if ch.type == "identifier":
                        edges.append((text(name_node), "inherits",
                                      text(ch), str(path)))
        elif node.type in ("import_statement", "import_from_statement"):
            edges.append((str(path), "imports", text(node), str(path)))
        elif node.type == "call" and current_func:
            fn = node.child_by_field_name("function")
            if fn is not None and fn.type in ("identifier", "attribute"):
                callee = text(fn).split(".")[-1]   # 简化:取末段名
                edges.append((current_func, "calls", callee, str(path)))

        for child in node.children:
            walk(child, current_func)

    walk(tree.root_node)
    return nodes, edges

不到 60 行,我们已经拿到了四类信息:函数定义、类定义(含继承边)、import 边、调用边。真实的 CRG 当然精细得多——它要做跨文件符号解析、区分同名函数、给边打置信度——但骨架就是这样。

4.3 SQLite 图谱 schema + 增量更新

SCHEMA = """
CREATE TABLE IF NOT EXISTS files (
    path TEXT PRIMARY KEY,
    sha256 TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS nodes (
    id INTEGER PRIMARY KEY,
    kind TEXT, name TEXT, file TEXT, line INTEGER
);
CREATE TABLE IF NOT EXISTS edges (
    id INTEGER PRIMARY KEY,
    src TEXT, rel TEXT, dst TEXT, file TEXT
);
CREATE INDEX IF NOT EXISTS idx_edges_src ON edges(src);
CREATE INDEX IF NOT EXISTS idx_edges_dst ON edges(dst);
CREATE INDEX IF NOT EXISTS idx_nodes_name ON nodes(name);
"""

def build_graph(repo: Path, db_path: str = "graph.db"):
    con = sqlite3.connect(db_path)
    con.executescript(SCHEMA)
    for py in repo.rglob("*.py"):
        digest = sha256_file(py)
        row = con.execute(
            "SELECT sha256 FROM files WHERE path=?", (str(py),)
        ).fetchone()
        if row and row[0] == digest:
            continue                      # 哈希没变,跳过 —— 增量更新的关键
        # 清掉旧数据,只重建这个文件的子图
        con.execute("DELETE FROM nodes WHERE file=?", (str(py),))
        con.execute("DELETE FROM edges WHERE file=?", (str(py),))
        nodes, edges = parse_file(py)
        con.executemany(
            "INSERT INTO nodes(kind,name,file,line) VALUES(?,?,?,?)", nodes)
        con.executemany(
            "INSERT INTO edges(src,rel,dst,file) VALUES(?,?,?,?)", edges)
        con.execute(
            "INSERT OR REPLACE INTO files(path,sha256) VALUES(?,?)",
            (str(py), digest))
    con.commit()
    return con

注意 sha256 比对那几行——这就是 CRG "2900 文件 < 2 秒增量更新"的原理浓缩版:哈希不变的文件直接跳过,变了的文件只重建自己的子图。配合 pre-commit hook 或文件监听,图谱就能始终保持新鲜。CRG 在此之上还做了事务性更新(失败回滚)和依赖回溯(变更文件的上游边也刷新),思路一致。

4.4 爆炸半径:一条递归 CTE 搞定

这是全文最精华的一段代码。"谁会被这个函数的改动波及"本质是图的反向可达性问题,SQLite 的递归 CTE 天生擅长:

BLAST_RADIUS_SQL = """
WITH RECURSIVE impacted(name, depth) AS (
    SELECT :target, 0
    UNION
    SELECT e.src, i.depth + 1
    FROM edges e
    JOIN impacted i ON e.dst = i.name
    WHERE e.rel IN ('calls', 'inherits')
      AND i.depth < :max_depth
)
SELECT DISTINCT n.file, n.name, n.kind, i.depth
FROM impacted i
JOIN nodes n ON n.name = i.name
ORDER BY i.depth;
"""

def blast_radius(con, target_func: str, max_depth: int = 3):
    rows = con.execute(BLAST_RADIUS_SQL,
                       {"target": target_func,
                        "max_depth": max_depth}).fetchall()
    files = sorted({r[0] for r in rows})
    return rows, files

if __name__ == "__main__":
    con = build_graph(Path("./my-project"))
    rows, files = blast_radius(con, "verify_token")
    print(f"改动 verify_token 会波及 {len(files)} 个文件:")
    for f in files:
        print("  ", f)

跑起来的效果:你改了 verify_token,它顺着 calls 边反向爬三跳,把所有直接/间接调用方连同所在文件吐出来。假设仓库有 3000 个文件,这个查询可能只返回 8 个——这 8 个文件就是 AI 真正需要读的上下文。Token 从"读 3000 个文件"变成"读 8 个文件",量级差异一目了然,82x 这个数字瞬间不神秘了。

4.5 再加 30 行:把图谱变成 MCP Server

用官方 mcp SDK(FastMCP)把查询暴露给 AI 助手:

# mcp_server.py
from mcp.server.fastmcp import FastMCP
import sqlite3

mcp = FastMCP("mini-code-graph")
con = sqlite3.connect("graph.db", check_same_thread=False)

@mcp.tool()
def get_blast_radius(function_name: str, max_depth: int = 3) -> dict:
    """返回改动某函数后受影响的函数与文件列表(爆炸半径)"""
    rows = con.execute(BLAST_RADIUS_SQL,
                       {"target": function_name,
                        "max_depth": max_depth}).fetchall()
    return {
        "impacted": [{"file": r[0], "symbol": r[1],
                      "kind": r[2], "distance": r[3]} for r in rows],
        "files_to_read": sorted({r[0] for r in rows}),
    }

@mcp.tool()
def find_symbol(name: str) -> list:
    """按名称查找函数/类的定义位置"""
    rows = con.execute(
        "SELECT kind,name,file,line FROM nodes WHERE name LIKE ?",
        (f"%{name}%",)).fetchall()
    return [{"kind": k, "name": n, "file": f, "line": l}
            for k, n, f, l in rows]

if __name__ == "__main__":
    mcp.run()   # stdio 模式,供 Claude Code / Cursor 挂载

在 Claude Code 里注册(.mcp.json):

{
  "mcpServers": {
    "mini-code-graph": {
      "command": "python",
      "args": ["mcp_server.py"]
    }
  }
}

至此,一个丐版 CRG 全链路就通了:Tree-sitter 解析 → SQLite 存图 → 递归 CTE 查爆炸半径 → MCP 暴露给 AI。真实项目请直接用 CRG(它多了跨文件符号解析、置信度模型、语义搜索、40+ 语言、风险评分等一大堆脏活累活),但亲手写过这 200 行,你对它的能力边界会有本质的判断力。

五、CRG 上手实操

5.1 安装(Python 3.10+)

# 推荐 pipx / uv,隔离环境
pipx install code-review-graph
# 或
uv tool install code-review-graph

5.2 一键接入 AI 平台

# 自动检测已安装的 AI 编程工具并写入 MCP 配置
code-review-graph install

# 或指定平台
code-review-graph install --platform claude-code
code-review-graph install --platform cursor
code-review-graph install --platform codex

install 命令会自动检测本机的 AI 工具、写入各家的 MCP 配置、安装平台原生 hooks/skills,并把"图谱感知"的指令注入平台规则——省去了逐个平台手改 JSON 的麻烦。支持 Claude Code、Cursor、Codex、Gemini CLI、Kiro、Copilot(含 CLI)等 16 个平台。

5.3 建图与日常使用

cd your-project
code-review-graph build     # 500 文件约 10 秒,之后增量 < 2 秒

然后在 AI 助手里直接说 "Build the code review graph for this project",之后的审查、架构分析、影响评估都会自动走图谱查询。

5.4 CI 集成:给每个 PR 自动打风险分

# .github/workflows/code-review-graph.yml
name: Code Review Graph
on:
  pull_request:
permissions:
  contents: read
  pull-requests: write
jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: tirth8205/code-review-graph@v2.3.6
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}

CI 模式的几个亮点:

  • 本地优先:图谱在 CI Runner 内构建,源码不出境
  • Sticky comment:每个 PR 自动发布一条评论,含风险评分、受影响执行流、测试缺口;后续 push 原地更新,不刷屏
  • 合并门控fail-on-risk 参数可以让高风险 PR 自动阻止合并

风险评分的逻辑值得借鉴:修改核心模块(入度高的节点)→ 高风险;修改有测试覆盖的业务逻辑 → 中风险;修改边缘功能(叶子节点)→ 低风险。本质上是把图的中心性指标翻译成了工程语言。

六、性能数据与调优思考

6.1 官方 benchmark:82x 的成色

官方在 6 个真实开源仓库、13 个 commit 上做了自动化基准测试:

仓库原始 Token图查询 Token降低倍数
fastapi951,0712,169528.4x
code-review-graph208,8212,49593.0x
gin166,8681,99091.8x
flask125,0221,98671.4x
express135,9553,46540.6x
httpx89,4922,43838.0x

两个观察:

  1. 官方自己强调中位数 82x 才是真实参考值,528x 是最优个案。这种"主动把宣传数字往低了说"的做法在开源项目里不多见,值得点赞。
  2. 仓库越大、耦合越松,倍数越高。fastapi 文件多但模块边界清晰,图谱过滤效果爆表;httpx 本身小,全量上下文本来就不大,倍数自然低。推论:你的 Monorepo 越庞大,CRG 的收益越夸张

另外 benchmark 是完全可复现的:上游 commit SHA 锁定、Leiden 社区检测固定种子、embedding 在 CPU 上确定性运行,两台机器跑出完全相同的数字。做过 benchmark 的人都知道"可复现"三个字有多难得。

6.2 实际落地的调优清单

结合原理,给几条实操建议:

  1. 先测基线再开图谱。用同一个 PR,分别在开启/关闭 CRG 的情况下让 AI 审查,对比 Token 账单。用数据说服自己(和老板)。
  2. 爆炸半径深度不是越大越好。深度 2~3 通常够用;调太深会把半个仓库又拉回来,重新退化成全量读取。
  3. 警惕动态语言的反射盲区getattr、依赖注入、事件总线这类动态调用,静态 AST 是看不见的。这些区域的爆炸半径会偏小,审查关键路径(支付、权限)时建议手动补充上下文。
  4. 把 build 挂到 pre-commit hook,而不是靠 CI 冷启动全量建图,让增量更新的优势发挥出来。
  5. 小项目别用。几百个文件的项目,全量上下文本身不大,CRG 的配置和维护 overhead 可能大于收益。

6.3 该泼的冷水

客观说几个缺点:

  • 核心闭源。MIT 协议开源的是客户端、文档和 benchmark 脚本,核心 Python 包是闭源分发的。算法不可审计,对部分企业是硬伤——虽然"本地优先、代码不出境"缓解了大部分担忧。
  • 图谱质量受限于 Tree-sitter grammar 质量。小众语言的 grammar 不完善,边就会缺。
  • 82x 依赖场景。审查类任务收益最大;如果你的任务本来就需要 AI 通读全库(比如全局重构、安全审计),图谱过滤反而会遮蔽信息。

七、总结与展望

code-review-graph 的爆火不是偶然,它踩中了 2026 年 AI 工程化的一条主线:上下文工程(Context Engineering)正在取代提示词工程,成为 AI 应用的核心竞争力

回顾它的三个关键词:

  • 精准:结构感知的知识图谱 + 爆炸半径,替代"语义相似度撒网"
  • 本地:Tree-sitter + SQLite,零部署、不联网、合规友好
  • 标准:MCP 协议一次接入、16 平台通用

更值得程序员记下的是它背后的三个通用工程模式:

  1. 结构问题用结构工具解。代码的调用/依赖关系是图问题,向量检索硬套只会得到"看起来相关"的噪声。RAG 不是万能钥匙。
  2. SQLite + 递归 CTE 是被严重低估的图查询方案。百万边以内的图,别急着上图数据库。
  3. 增量哈希 + 局部重建是所有索引类工具做到"秒级更新"的通用套路,从 Bazel 到 Turbopack 到 CRG,殊途同归。

可以预见,"给 AI 的精准上下文"这个赛道会越来越挤:Aider 的 Repo Map 是轻量内置版,CodeGraph 面向编码 Agent,CRG 深耕审查场景,各家 IDE 厂商也一定会跟进内置类似能力。但无论工具怎么换,方向已经清晰——

下一代 AI 编程基础设施的胜负手,不在于让模型读得更多,而在于让它读得更准。

如果你的团队每天都在为 AI 助手的 Token 账单肉疼,或者受够了 AI 审查"睁着眼睛说瞎话"(因为压根没读到关键代码),花 10 分钟装个 code-review-graph 跑一遍 benchmark,大概率不会后悔。

推荐文章

Vue3中的v-bind指令有什么新特性?
2024-11-18 14:58:47 +0800 CST
程序员茄子在线接单