在分子层面动手术:AST 与 Semantic Operations 闭环修改
(第 56 篇:Agent 动力学之 AST 引擎)
顺着上一章提到的痛点:
我们绝对不可能指望一个带有概率偏差的大语言模型,
能在一个含有 5000 行混编前端 tsx 文件中,
稳定产出“可应用、可回滚、可复盘”的正确补丁。
想让 Agent 从“偶尔写对”进化为“工程可用”, 必须抛弃对“字符串 (String)”的执念, 上升到对 抽象语法树 (Abstract Syntax Tree, AST) 的语义级编辑。 但更关键的是: 你不能只讲 AST 的好处, 你必须把 AST 编辑落成一套可执行的工程契约, 让每一次修改都进入闭环: 定位可验证、应用可事务、失败可降级、结果可审计。
1. 代码不是文字:为什么“字符串补丁”在工程里必炸
对于人类来说,代码是 index.js 里的段落。
对于大模型来说,代码是 BPE token 的概率序列。
但对于编译器来说,代码是一个严格的结构:
它有作用域,
有符号绑定,
有语法树与语义约束。
字符串补丁在工程里失败的原因不是“LLM 不够聪明”,
而是“字符串缺少边界”。
你说“把 calculate 改名为 calculateTotal”,
sed 不知道哪些是符号,
哪些是注释,
哪些是字符串字面量,
哪些是另一个作用域里的同名局部变量。
它只能瞎换。
AST 的价值是:
把“文本”投影到“结构”,
让你可以只改一个 Identifier 节点,
同时对注释和字符串字面量天然免疫。
但在 Agent 系统里, 更重要的是把“改哪里”变成可验证的事实。 因为可验证,才能闭环。
2. AST 不等于编译器:CST、增量解析、与 Tree-sitter 的位置
在 Agent 的多语言代码操作里, Tree-sitter 是一条现实可走的路: 它足够快, 支持增量更新, 语言覆盖广, 并且提供查询语言让你“按结构找节点”。
这里先把概念钉死:
- AST(抽象语法树)强调抽象语义。
- CST(具体语法树)强调“你写了什么字符”。
- Tree-sitter 更接近 CST, 它要在编辑器里“每次敲键都能更新树”, 因此它的核心能力是增量解析与容错恢复。 citeturn0search2
这对 Agent 很重要, 因为真实工程里, 代码经常处在“半改不改”的破碎状态: 少一个括号, 多一段未闭合字符串, 你依然要尽可能定位到可修改的结构片段, 然后在验证回路里逼近正确。
3. 语义操作的核心:选择器(Selector)而不是行号
“行号”是人类 UI 里的概念, 不是可靠的工程定位手段。 原因很简单: 格式化器会换行, 导入排序会插入, 合并冲突会挤压, 哪怕你只改了文件开头的一个空格, 后面所有行号都漂移。
要让 Agent 能稳定改代码, 你需要一个确定性的地址体系。 我把它叫做 Selector。
一个可靠的 Selector 至少要满足:
- 可重定位:前面插入/删除不影响目标定位。
- 可验证:能证明“找到了正确的那个节点”。
- 可降级:找不到时能退到更粗粒度的定位。
在 Tree-sitter 体系里, Selector 常见的落点有三种:
- Query selector:用 query 匹配结构(最推荐)。
- Path selector:按树路径定位(易漂移)。
- Anchor selector:内容锚点 + 哈希(适合降级)。
下面给一个可落地的“Query selector”示例, 注意我刻意不把它写成某个绑定的“唯一正确代码”, 而是强调契约与风险点。
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
class NodeSlice:
# 注意:这里用 byte offset,而不是行号
start_byte: int
end_byte: int
node_type: str
node_text_sha256: str
class SemanticLocator:
"""
语义定位器(只读层)。
目标:把“我要改哪个符号”的意图,变成一个可验证的 NodeSlice。
"""
def locate_function_by_name(self, source_bytes: bytes, function_name: str) -> Optional[NodeSlice]:
# 伪代码:解析、query、获取 node 的 start_byte/end_byte
# 关键点:
# 1) query 的匹配必须足够具体,避免同名歧义
# 2) 产出的 slice 需要带上 node_text 的 hash,用于防漂移校验
# 3) 对缺失节点(error/missing node)要显式标记,禁止直接进入写入路径
raise NotImplementedError
4. 为什么 byte offset 更稳,但也更危险
Tree-sitter 这类解析器暴露的定位往往是 byte offset, 这比行号稳定, 因为它直接对应底层字节序列。
但 byte offset 有一个很现实的坑: 你必须保证“你替换的是同一份字节内容”。 否则 offset 会对错对象下刀。
工程上必须做三道闸:
- 统一编码:读写都用 UTF-8(并在写回前验证无 BOM/无损 round-trip)。
- 内容校验:应用补丁前,对目标 slice 的 hash 做比对(不一致就拒绝写入)。
- 事务边界:把“定位 + 应用”绑定到同一个 source snapshot 上,禁止跨版本复用 offset。
5. Semantic Operations:让 LLM 输出“意图”,让运行时输出“补丁”
顶级 Agent 工具链的设计准则是: LLM 只做高阶决策, 本地引擎做低阶执行, 并且引擎必须产出可验证的证据。
把工具接口做对, 你会发现 LLM 的错误率会显著下降, 因为它不再需要“猜格式”和“猜位置”。
对比两代工具接口:
- 字符串时代:
replace_text(file, old, new) - 语义时代:
rename_symbol(selector, new_name, constraints)
语义时代的最小契约通常长这样:
selector:告诉系统“改哪个结构节点”。constraints:告诉系统“允许的上下文是什么”(例如必须在某个 class 内、必须匹配参数列表)。expected_effect:告诉系统“我期待诊断变化是什么”(例如编译错误数量下降、引用点数量保持一致)。
这样做的本质是把修改变成可测的, 否则你永远只能靠“看起来像对了”。
6. Apply 是一场手术:事务、幂等、并发冲突、回滚
在工程里, “找到节点”只是开始, 真正难的是“怎么改”。 你要假设所有步骤都会失败, 并为失败设计出可控的后果。
6.1 事务化 apply
一次语义修改至少分 4 步:
- parse:得到语法树与节点 slice。
- plan:生成变更意图(LLM 输出)。
- apply:在同一快照上把意图转成补丁并应用。
- verify:跑格式化、类型检查、单测或最小 lint。
这 4 步必须写入审计, 并且需要一个“事务 id”, 保证你能复盘: 是哪一次变更导致了哪个诊断变化。
6.2 幂等性(重复执行不应产生额外副作用)
Agent 系统最常见的工程事故不是“改错”, 而是“重试把改对的东西又改坏”。
因此写入型工具必须具备幂等判定:
- 如果目标节点已经满足期望,直接 no-op。
- 如果目标节点不再存在,直接失败并进入降级定位。
- 如果目标节点内容 hash 不一致,拒绝写入(防止漂移误伤)。
6.3 并发冲突(多点改写)
一个复杂任务常常需要多处 patch。 你有两种策略:
- 串行:每次 patch 后重新 parse(稳定但慢)。
- 批处理:在同一棵树上做多个 edit(快但容易冲突)。
批处理要额外做冲突检测: 两个 slice 的 byte range 不能重叠, 并且应用顺序要固定(例如按 start_byte 升序), 否则 offset 会被前一个 edit 改写后漂移。
6.4 回滚(可逆性)
最朴素也最可靠的回滚是: 写入前把旧内容完整保存, 写入失败就恢复。
更工程化一点: 把每一次 apply 的 patch 记录成可逆 diff, 并把回滚作为一等公民工具暴露出来, 不要把回滚逻辑藏在“异常处理”里。
7. Verify 回路:把“自我纠错”变成确定性流程
修改完成后不是结束, 验证才是“闭环”的闸门。
一个最小但有效的验证链:
- Formatter:先格式化(把无意义的空格差异挤掉)。
- Typecheck / compile:再跑类型检查或编译。
- Lint:最后跑 lint(捕获更细的约束)。
验证失败时, 你不应该把完整日志塞回模型, 而是把“最小可用事实”回注:
- 错误码与最关键的 20 行上下文(清洗 ANSI 后)。
- 相关符号的定义(可以从 AST/LSP 抽取)。
- 上一次 apply 的意图与 selector(用于模型对齐)。
这才是 Plan -> Act -> Verify -> Refine 的工程化版本。
8. AST 不是万能:必须承认的边界与降级策略
AST/Tree-sitter 在以下场景会明显变弱:
- 预处理器宏:真实语义不在源码文本里。
- 生成代码:你改的是产物,不是源。
- 混合语言:例如
.tsx里嵌模板字符串与 CSS-in-JS。 - 破碎语法:编辑中途的半成品。
因此你的运行时必须提供降级路径:
- AST 定位失败:退到“内容锚点 + 邻域片段”。
- AST 解析不可信:只读提取 + 人类确认,禁止写入。
- 语言服务器更可靠:在可用时用 LSP 的 definition/references 做交叉验证。
9. 用 AST 做上下文脱水:把 10000 行压到 200 行但不丢结构
当文件巨大时, 你不应该把全文喂给模型。 你应该用 AST 生成骨架视图:
- 保留:module/class/function 的声明、签名、docstring、导入与导出。
- 丢弃:实现体(body)用
...占位。 - 另外导出:符号表与引用图(可选)。
骨架视图的关键不是省 token, 而是让模型的注意力集中在结构决策, 而不是被实现细节淹没。
这一策略在“计划阶段”尤其有效: 模型先基于骨架产出修改意图, 再由本地引擎去精确执行。
本章精粹(把话说死)
- 不要给 LLM 行号,让它给你“意图 + selector + 约束 + 预期效果”。
- byte offset 很稳,但必须用 hash 与事务边界防漂移误伤。
- 语义修改不是“改一段文本”,而是一场手术:幂等、并发、回滚、审计都要有。
- AST 很强,但不是万能;必须设计降级路径与验证回路。
下一章我们会继续补齐“语义定位”的另一半: 在一个巨大工作区里, 如何用索引与搜索把候选范围缩到“足够小”, 再交给 AST/LSP 做最终确定性定位。
(本文完 - 深度解析系列 22)
参考与延伸(写作核验)
- Tree-sitter 官方仓库与增量解析定位说明:
tree-sitter/tree-sitter。 citeturn0search2 - Tree-sitter query 语言与结构化匹配:Tree-sitter CLI / Query 文档入口。 citeturn0search2