代码智能:AST + LLM 的组合
LLM 单独理解代码有局限——它看的是文本,不懂精确结构。把 AST(抽象语法树)和 LLM 结合,能做更精准的代码搜索、重构、审查、补全。本文讲清为什么需要 AST、AST + LLM 的组合模式、tree-sitter/LSP 工具链,以及它如何让 AI 编程工具更可靠。
本文要点
- 主题聚焦:AI、TypeScript
- 适合人群:独立开发者、全栈工程师、AI 应用开发者
- 核心价值:快速理解背景、实践路径、常见误区和工程建议
一、LLM 单独理解代码,到底差在哪
把一段 TypeScript 丢给 LLM,让它"找出 getUser 函数的所有调用点",它会信心满满地列出一串结果。问题是,这些结果经常是错的。
它会犯这几类错:
- 把注释、字符串、变量名里的
getUser当成调用。文档里写了一句 "call getUser() to fetch",它就当真了。 - 分不清同名但不同作用域的符号。
a.ts里有个getUser,b.ts里也有个getUser,它默认当成同一个。 - 看不出类型不匹配。你问它"这里能不能传
User进去",它靠猜——它不知道参数实际类型是User | null。 - 重构改漏、改错。让它改名,它改了定义却漏了动态调用
obj[name]();让它改签名,它改了调用点却没意识到有个 mock 文件也实现了这个接口。
根本原因只有一个:LLM 看到的是文本,不是结构。Token 序列对它来说就是一串字符,它没有"这个标识符绑定到哪个声明""这个表达式是什么类型""这个变量在哪些行被读"这种确定性的信息。它擅长语义理解(这段代码大概在干嘛),但不擅长精确的结构推理(这个符号到底指谁)。
而结构信息恰恰是代码的骨架。骨架错了,生成的答案就是"看似对实则错"——最危险的一种错,因为它自信。
二、AST 补足的是什么:精确到字节的结构
AST(Abstract Syntax Tree,抽象语法树)是源码解析后的树形表示。一段 const x = f(1) 会被拆成 VariableDeclaration → Identifier(x) → CallExpression → Identifier(f) → NumericLiteral(1)。每个节点知道自己的类型、位置(行列偏移)、父子关系。
更重要的是,成熟的 AST 工具链还提供两类派生信息:
- 引用解析(reference resolution):某个
Identifier节点绑定到哪个声明节点,以及某个声明被哪些地方引用。 - 类型与作用域:某个表达式的静态类型,某个名字在当前作用域是否可见、绑定到谁。
这些信息是确定性的、可复现的、零幻觉的。AST 不会"觉得"某行代码可能是调用——它要么是 CallExpression,要么不是;字节偏移写在那儿,不会变。
LLM 擅长的语义理解和 AST 擅长的结构推理,正好互补。这就是 AST + LLM 组合的价值起点。
三、AST + LLM 的四种组合模式
实战中我反复用到的组合,基本就是下面四种模式。它们的核心思想一致:让 AST 干"确定"的活,让 LLM 干"理解与生成"的活,中间用结构化数据对接。
模式一:结构过滤 + 语义搜索(代码搜索)。 先用 AST 把候选集收敛到"真正的标识符引用",剔除注释/字符串/同名干扰,再把这部分精简上下文丢给 LLM 做语义判断("这些调用里哪些是真正在调用用户服务")。结果既精确又懂意图。
模式二:精确上下文 + LLM 重构(改名/改签名)。 用 AST 找全所有真实引用点,把它们连同定义一起喂给 LLM,让 LLM 生成新名字/新签名并保证一致性。AST 保证"不漏",LLM 保证"起得好"。
模式三:结构异常 + LLM 审查(代码 review)。 AST 检测结构层面的坏味道(嵌套过深、重复结构、未使用的导出、循环依赖),LLM 拿着这些结构事实去判断"为什么坏、怎么改"。结构信号让 LLM 不再凭感觉。
模式四:类型感知 + LLM 补全。 用 TS Compiler API 或 LSP 拿到当前光标位置的预期类型、可见符号、作用域变量,作为约束喂给 LLM。LLM 生成的补全候选再用类型检查过滤一遍,几乎可以杜绝"看起来合理但类型不对"的幻觉。
下面分别看工具链和实战。
四、工具链:tree-sitter、LSP、TS Compiler API
三者各管一段,组合起来几乎覆盖所有语言场景。
tree-sitter:多语言增量解析。 它是 GitHub 出品的解析器生成器,有 100+ 语言的 grammar,解析速度快、支持增量解析(源码改一个字符不用全量重解析),输出统一的 S-expression 风格 AST。它的强项是跨语言、稳、快,弱项是不做引用解析和类型推断——它只给你语法树,不给"这个 x 绑定到谁"。所以 tree-sitter 适合做"结构过滤""坏味道检测"这类只需要语法结构的事。
// tree-sitter:找出文件里所有真正的 getUser 调用
const Parser = require('web-tree-sitter');
const { TypeScript } = require('tree-sitter-typescript');
const parser = new Parser();
parser.setLanguage(TypeScript);
const tree = parser.parse(sourceCode);
const calls = [];
function walk(node) {
// 只认函数调用节点,字符串/注释里的 getUser 根本不进这个分支
if (node.type === 'call_expression') {
const fn = node.childForFieldName('function');
if (fn?.text === 'getUser') {
calls.push({ line: node.startPosition.row, text: node.text });
}
}
for (const child of node.namedChildren) walk(child);
}
walk(tree.rootNode);
// calls 里就是铁证如山的调用点,LLM 幻觉不了
LSP(Language Server Protocol):引用、定义、hover、类型。 LSP 是 VS Code 起的语言服务协议,几乎所有主流语言都有 language server。它的杀手锏是 textDocument/references(找引用)、textDocument/definition(跳定义)、textDocument/hover(类型/文档)——这些都是基于语义模型(类型系统、模块解析)算出来的,精度远超纯语法树。代价是需要起一个 language server 进程、做 LSP 通信、等它索引完成。适合需要"引用/类型"这种语义信息时用。
// 用 LSP 拿某个符号的真实引用(伪代码,基于 vscode-languageclient)
const refs = await languageClient.sendRequest(
'textDocument/references',
{
textDocument: { uri: fileUri },
position: { line: 10, character: 8 }, // getUser 的定义处
context: { includeDeclaration: false },
},
);
// refs 是排过重的、跨文件的、按语义绑定的真实引用
// 把这些位置的内容抽出来,就是给 LLM 的精确上下文
TS Compiler API:TypeScript 专属的核武器。 它是 typescript 包自带的编译器 API,能拿到类型检查器(checker)、符号表、类型、控制流分析结果。精度是三者里最高的,但只服务 TS/JS。做类型感知补全、复杂重构,首选它。
import ts from 'typescript';
const program = ts.createProgram(['user.ts'], { strict: true });
const checker = program.getTypeChecker();
const sf = program.getSourceFile('user.ts')!;
// 遍历每个节点,拿到符号的真实类型——这是 LLM 永远猜不准的东西
ts.forEachChild(sf, function visit(node) {
if (ts.isIdentifier(node)) {
const sym = checker.getSymbolAtLocation(node);
if (sym) {
const type = checker.getTypeOfSymbolAtLocation(sym, node);
// checker.typeToString(type) => "User | null"
// 把类型信息塞进 LLM 的 prompt,补全/审查的幻觉大幅下降
}
}
ts.forEachChild(node, visit);
});
选型一句话:只要语法结构 → tree-sitter;要引用/跳转/hover → LSP;TS 项目要类型/符号 → TS Compiler API。 三者不是互斥,而是分层:tree-sitter 做粗筛,LSP/TS 做精确定位,LLM 做最后的语义决策。
五、实战:用 AST 找引用喂 LLM 改名
需求很典型:把 getUser 改成 fetchUser,要求改全、不改错(不能动到 userCache.getUser 这种无关同名方法)。
纯 LLM 做这件事的典型翻车现场:漏改动态调用、误改注释里的示例代码、把别的模块的 getUser 也一起改了。
正确的姿势是 AST 找引用 + LLM 起新名字 + AST 验证:
// 第 1 步:用 TS Compiler API 找到 getUser 的所有"语义级"引用
import ts from 'typescript';
const program = ts.createProgram(['svc.ts', 'api.ts', 'cache.ts'], { strict: true });
const checker = program.getTypeChecker();
const edits: { file: string; span: ts.TextSpan; }[] = [];
for (const sf of program.getSourceFiles()) {
if (sf.isDeclarationFile) continue;
ts.forEachChild(sf, function visit(node) {
if (ts.isIdentifier(node) && node.text === 'getUser') {
const sym = checker.getSymbolAtLocation(node);
// 关键:只认绑定到"那个" getUser 声明的引用
// 同名的 cache.getUser() 方法会绑定到不同的 symbol,自动被排除
if (sym?.valueDeclaration?.name?.getText() === 'getUser'
&& isTargetDeclaration(sym)) {
edits.push({
file: sf.fileName,
span: { start: node.getStart(), length: node.getWidth() },
});
}
}
ts.forEachChild(node, visit);
});
}
// 第 2 步:把定义 + 调用上下文喂给 LLM,让它给一个语义更准的新名字
const prompt = `
你是资深工程师。我要把函数 getUser 改名。
定义:${getDefText(edits[0])}
被这些地方调用:${edits.map(e => snippet(e)).join('\n')}
基于职责,给我一个更准确的新名字,只回名字。
`;
const newName = await llm.complete(prompt); // => "fetchUser"
// 第 3 步:用 AST 的精确 span 做替换,而不是让 LLM 吐整份文件
const changes = edits.map(e =>
ts.factory.createTextChange(e.span, newName));
// 写回文件,零幻觉、零遗漏
这个流程的精髓在于分工:"改哪里"是结构问题,AST 一锤定音;"叫什么"是语义问题,LLM 来起;"怎么落地"又是结构问题,用 AST 的 span 精确替换。LLM 全程不碰文件内容,只输出一个名字——它最擅长的活。结果是改对地方、改全、不改错,而且 LLM 的幻觉面被压到了最小。
对比纯 LLM 的做法:让模型读三个文件、输出改完的三份文件。它大概率会"看起来对"——大部分地方改对了,但会漏掉一处动态调用、误改一行注释、顺手把无关的 userCache.getUser 也改了。在真实代码库里,这种错误难发现、难回滚。AST + LLM 的组合让结果变得可验证、可信任。
补全场景同理:用 TS Compiler API 或 LSP 拿到光标处的预期类型 User,再把可见的、类型匹配的符号列表喂给 LLM 排序——LLM 给候选,类型系统兜底过滤。这样生成的补全既懂意图又不会类型错。
小结
- LLM 是文本视角,AST 是结构视角。前者懂语义但会幻觉结构,后者懂结构但不懂意图,组合才稳。
- 分工原则:确定性的事交给 AST,理解与生成交给 LLM。"改哪里"用 AST,"叫什么/怎么写"用 LLM,中间用结构化数据对接。
- 工具链分层:tree-sitter 做语法粗筛(快、跨语言),LSP 做引用/跳转/hover(语义、跨语言),TS Compiler API 做类型/符号(TS 专属、最精)。
- 纯 LLM 的最大风险是"看似对实则错":自信地漏改、误改、类型错。AST + LLM 的价值是把幻觉面从"整份文件"压缩到"一个 token",让结果可验证、可信任。
- 一句话:做 AI 编程工具,先问"哪些是结构能确定的事",把这些锁死,再把剩下的语义决策交给 LLM。代码智能的可靠性,从来不是靠更大的模型,而是靠更准的结构。
LLM 负责懂代码在说什么,AST 负责证明代码到底在哪、是什么。一个负责聪明,一个负责较真——二者合一,才是真正可上生产的代码智能。
Related