针对ASTVisitorAPI迁移,构建了涵盖源码扫描、运行时观测、独立比较、失败定位的验证闭环。接入真实SQL语料并引入覆盖率反馈,持续暴露未覆盖路径。该机制通过可执行等价性验证确保AI生成的迁移代码不改变SQL语义,使大规模基础设施迁移变得可控。
AST Visitor API迁移:如何证明AI未破坏SQL语义?
时间:2026-07-18
浏览:235
文章目录
先说说这次要解决的核心问题。AST 的旧 visitor API 用起来确实有些不便,团队决定换用一套全新的 API。从工程实现的角度看,这件事本身并没有多么高不可攀。AST 节点虽然数量众多,但大部分迁移代码的重复模式都很明显,非常适合批量生成,交给 AI 来处理大量的样板工作也完全可行。
但这里有一个真正的难点,不是代码能不能写出来,而是写完以后,你要怎么确认它没有静悄悄地改变语义?
在数据库内核里,AST Visitor 并不是一个孤立的基础设施。它可能服务于 parser、binder、optimizer、formatter、权限检查、表达式重写等多个下游环节。迁移一旦出现偏差,未必会立即变成编译错误,更可能是某个节点不再被访问,某个字段被多访问了一次,或者原本稳定的遍历顺序发生了微妙的变化。代码表面上看起来一切正常,真正的影响却可能在更靠后的逻辑中才暴露出来。
最初我们最担心的主要就是两类问题:
- 漏项或重复访问:某个节点、字段或分支没有被遍历到,或者被意外地遍历了多次。
- 顺序变化:某些需要保持先后关系的 hook,调用顺序发生了改变。
这类问题光靠 code review 很难全面覆盖。节点数量太多,人脑很难在脑海中完整展开整棵 AST;而且大量生成的代码又高度相似,review 很容易变成对样板代码的走读,流于形式。
因此,我们开始尝试建立一套可执行的验证机制,让正确性更多由程序化的证据来约束,而不是依赖人逐行去确认。最终形成的系统,大致是这样的一个闭环:

这个闭环当然不是一次就搭建完成的。为了讲清楚各个部分之间的边界,下面先完整说明由源码扫描、运行时观测、独立比较和失败定位组成的核心内核,再说明真实输入和覆盖率反馈是如何接入这个系统的。
从源码采集 AST 结构
要让后面的验证站得住脚,关键不在于写一套更复杂的逻辑来描述 AST,而在于尽量直接、机械地从源码中提取结构。类型、variant 和字段都已经写在源码里了,一个采集工具只需要机械地把它们展开,作为后续代码生成的输入即可。
这里最重要的原则就是少写逻辑。试想,如果为了确认 visitor 有没有漏项,又手工实现一套节点分类、递归关系和特殊规则,那不过是把原来的审计问题搬进了验证工具里:你要相信验证结果,就得先完整审计这套同样复杂的逻辑。
因此,源码分析应该保持短而直接:沿用语法分析得到的类型定义,枚举 struct、enum、variant 和字段,让产出能够对应回源码位置。一个声明对应一条结构记录,必要的排除项显式列出,不在采集阶段去推断遍历语义,也不要去复制 visitor 的特殊顺序。
这种做法的可信度,来源于映射关系足够简单。需要审计的,是少量通用的源码提取规则,而不是另一套与 AST 同规模的手写模型。它提供的是一份可追溯、可审计的结构输入;至于 visitor 实际怎样遍历这些结构,那就交给运行时观测和后续的比较来回答。
把运行时观测和等价性判断分开
扫描完源码之后,下一步就是把 visitor 实际的运行行为记录下来。这里有一个重要的设计原则:把观测和比较分开,不要让生成代码同时承担正确性判断的责任。
- 生成代码只负责产生 trace,它只忠实地记录进入了哪个 hook、看到了哪个节点,以及事件发生的次数和顺序。它不判断某个事件是否应该出现,也不在记录时排序、去重或过滤结果。
- 等价性判断由另一套独立的比较器完成。比较器读取新旧 visitor 的 trace,再根据遍历契约来判断访问项目、次数和关键顺序是否一致。
两个部分的关系是这样的:

这样一来,大量生成的代码可以保持机械性,而正确性规则则集中在少量的手写代码中。同一份原始的 trace 也能继续用于覆盖率统计和失败定位。
这部分的原则可以概括为:
这个分层很关键。AI 可以生成大量迁移代码,也可以生成一部分观测代码,但判断正确性的规则,必须足够集中、明确、可审计。
明确 Trace 应该怎样比较
观测层能够稳定地产生数据之后,下一个问题才真正变得具体起来:什么样的差异,才算 visitor 的语义变化?
最初最容易想到的,是比较“访问到了哪些项目”。这可以发现某个节点或 hook 在一边出现了,另一边却没有。但单纯比较集合是不够的,因为集合会丢掉访问次数这个关键信息:一个节点访问一次和访问两次,在集合里看起来完全相同。所以,要检查漏项和重复访问,至少需要比较多重集,或者直接比较保留了次数的事件记录。
顺序则是另一类约束。有些 visitor 逻辑只关心最终是否见过某些节点,顺序并不重要;但另一些逻辑却依赖隐含的遍历顺序,比如:
- 先见到定义,再处理引用;
- 按进入顺序进行归一化;
- 遇到第一个匹配项便提前退出;
- 依靠 enter 和 exit 的嵌套关系来维护状态。
所以,比较器不能只有一种全局规则。它需要根据 hook 的语义,分别检查:
- 访问项目是否一致;
- 访问次数是否一致;
- 对顺序敏感的事件序列是否一致;
- enter、exit、skip 和提前退出等控制行为是否一致。
这样一来,“等价”的定义就逐渐清晰了:不是要求新旧代码实现相同,而是要求它们在约定的可观察遍历行为上,没有未解释的差异。
旧 API 在这里是历史行为基线,但不必被假定为天然正确。如果新实现是有意修复旧行为,或者新 API 明确改变了某项契约,那么这类差异应该被显式记录,而不是为了让比较通过而被悄悄地过滤掉。
让失败能够直接定位
比较器能报出不一致之后,新的问题又来了:只知道两份 trace 不相等,对修复迁移的帮助其实很有限。如果失败信息只是一个布尔值,开发者仍然需要重新翻阅大量 visitor 代码,去寻找究竟是哪一个节点或 hook 产生了偏差。这样虽然有了自动检查,但定位成本依然很高。
因此,trace 和比较器还需要增加面向诊断的信息:
- 触发问题的输入或测试名称;
- 缺失或重复的是哪个事件;
- 哪个 hook 的顺序发生了变化;
- 事件对应的 AST 节点路径;
- 新旧 trace 第一次产生分歧的位置;
- 差异前后的少量上下文。
这项工作看起来发生在比较器完成之后,但它实际上会反过来影响观测层的设计。要报告 AST 路径,记录事件时就必须携带路径;要定位第一个顺序差异,就不能提前把 trace 排序;要发现重复访问,就不能在观测阶段去重。
这也是为什么观测层应该尽量保留事实,而把规范化和判断的工作留给比较器。失败定位不是最终输出上的一个装饰,它是 trace 协议从最开始就需要支持的能力。
到这里,最初的可用内核才算完整了:源码扫描提供 AST 结构,观测层把实际遍历行为记录成 trace,比较层根据遍历契约判断新旧行为是否一致,差异报告则让失败能够直接进入修复环节。只要给定一棵 AST,这个内核就已经能够跑起来了。

后面的工作,就不再是补齐这个内核的基本组成,而是扩大它能处理的输入,并让验证的边界持续可见。
接入真实 SQL 语料:让验证面对真实输入形状
有了可用的内核,并不意味着验证就已经充分了。它只能比较你实际送进去的那些 AST。如果依靠手工构造 AST,很快会遇到两个现实问题:构造 AST 的代码本身就很重;而且人工样例容易只覆盖到编写者刚好想到的那些结构。
所以,我们开始复用 parser 已有的 SQL 语料:
- 用现有的 SQL 解析出 AST;
- 对同一棵 AST 运行旧 visitor;
- 再运行新 visitor;
- 收集双方的 trace;
- 由独立比较器检查差异。
parser golden 是一个很自然的输入来源。一条 SQL 往往能同时覆盖多个节点和嵌套关系,这些语料也是项目已经认可的真实输入形状。相比专门维护一批庞大的手写 AST,它们更接近 visitor 实际会遇到的数据。
但这里需要把结论说准确:这种方法证明的是“在当前 SQL 语料实际覆盖到的 AST 上,新旧遍历行为一致”,而不是整个 AST 状态空间都已经被穷举了。
用覆盖率暴露验证盲区:测试通过不等于空间已覆盖
运行时等价比较始终依赖一个前提:输入必须真正触达我们关心的 AST 分支。最初复用 parser SQL 语料时,我们只能知道测试运行了,却不知道它究竟覆盖了多少手写 walk 分支。如果某个 enum variant、可选字段或特殊路径从未出现,那么新旧 visitor 即使在所有测试上完全一致,也不能说明那部分迁移得到了验证。
这时,覆盖率就成为验证输入是否充分的反馈信号:
- 用现有 SQL 语料运行新旧 visitor 的等价比较;
- 统计手写 walk 和相关分支的覆盖情况;
- 找出一直没有执行到的 AST 路径;
- 反推缺少什么样的 SQL;
- 补充相应语料;
- 再运行等价比较和覆盖率。
覆盖率在这里不负责证明代码正确,它回答的是另一个问题:我们用来比较新旧行为的输入,实际触达了多少待验证空间。
这样一来,就形成了两个相互配合、但不能互相替代的证据:
- trace 比较说明已触达输入上的行为是否一致;
- 覆盖率说明还有哪些行为尚未被输入触达。
发现缺口之后,能由真实 SQL 表达的,就优先补到 parser 上游,让语料同时服务 parser、AST 构造和 visitor;难以通过 SQL 稳定构造的边界状态,再用局部定向测试来覆盖。
这不是五个步骤,而是一个持续反馈系统
回头看,这套系统包含源码扫描、运行时 trace、独立比较、失败定位、真实 SQL 输入和覆盖率反馈,但它们承担的角色并不相同。前四项构成基本可用的验证内核:给定一棵 AST,它们能运行比较,并把差异直接交给修复。真实 SQL 输入扩大了内核的适用范围,覆盖率则建立了继续扩展验证范围的反馈机制。
它们分别解决不同的问题:
- 源码扫描约束 AST 结构是否被纳入考虑;
- 观测层记录 visitor 实际做了什么;
- 比较层定义什么叫行为等价;
- SQL 语料提供真实 AST 输入;
- 差异报告降低修复和解释成本;
- 覆盖率暴露输入空间中的空白;
- 新增语料继续扩大可验证范围。
这些部分在后续迁移中会同时工作,并彼此反馈:
迁移代码变化 ─→ trace 差异 ─→ 修正迁移或记录有意变化
AST 定义变化 ─→ 生成结果变化 ─→ 暴露未处理结构
覆盖率缺口 ─→ 新增 SQL 语料 ─→ 扩大等价比较范围
定位信息不足 ─→ 扩展 trace 协议 ─→ 改善后续差异报告
因此,更准确的说法不是“按照五个步骤完成 visitor 迁移”,而是“围绕迁移不断补齐不同种类的证据,直到这些证据形成一个可以持续运行的闭环”。
这套验证能证明什么?
最终,我们希望得到的结论包括:
- 已知 AST 类型、variant 和字段都已被纳入分类;
- 在当前语料触达的 AST 上,新旧 visitor 的访问项目和次数一致;
- 对顺序敏感的 hook 没有出现未解释的顺序变化;
- skip、递归和提前退出行为没有出现未解释差异;
- 没有覆盖到的结构仍然可见,而不是被一次测试通过所掩盖;
- 有意变化和旧实现已有问题都有明确记录。
它仍然不是对整个 AST 状态空间的数学证明。 有限 SQL 语料上的 trace 比较,只能约束实际触达的输入;覆盖率可以暴露没有运行的路径,却不能单独证明已经运行的代码是正确的;生成器和比较器本身也仍然需要审计。
所以,把它称为“有明确覆盖边界的可执行等价性验证”更加准确。它不承诺一次性证明一切,而是把原本依赖人工信心的迁移,变成一个能够持续发现缺口、补充证据并逐渐收敛的工程过程。
AI 负责生成,人和验证系统负责正确性边界
这套机制最初是为了降低一次 visitor 迁移的风险,但它也形成了一种更清晰的人、AI 和程序之间的分工。
- AI 擅长大规模生成重复而结构化的代码,也能快速修正局部 diff。但它不擅长的是,在一个规模很大、结构稀疏并且包含隐含契约的 AST 世界里,自发地保证全局完备性。
因此,更合适的分工是:
- AI 负责生成和迁移大量实现代码;
- 观测系统负责忠实记录运行行为;
- 比较器负责执行明确的等价规则;
- 人负责定义遍历契约、解释差异以及决定哪些变化是有意的。
这里的重点不是为 AI 单独设计一套较低的正确性标准。无论代码由 AI 还是人完成,大规模基础设施迁移都应该接受同一套独立的验证。 AI 只是提高了代码生产的速度,也让“不能把正确性寄托在逐行 review 上”这件事变得更加明显了。
对 Databend 数据库工程的启发
Databend 是一个面向现代数据工作负载的云原生数仓。SQL parser、AST、binder、optimizer、表达式系统和执行计划生成,都是支撑复杂查询能力的基础设施。这类基础设施的长期演进,需要同时满足两个要求:
- 快速迭代,让新语法、新函数、新查询模式和 AI 辅助开发能够进入工程流程;
- 保持 correctness,让迁移、重构和 API 演进不会悄悄改变 SQL 语义。
AST Visitor 迁移验证体现的是 Databend 工程体系中更底层的一类能力:不是只追求“代码生成得快”,而是把复杂变更放进一个可观测、可比较、可诊断、可持续扩展的验证闭环里。
这和 Databend 的用户心智是一致的。作为开放、Rust-native、面向云原生分析场景的数据仓库,Databend 不只是提供 SQL 功能本身,也需要在内核层面保证这些能力可以长期演进、可维护、可验证。
对于正在把 AI 引入工程流程的团队来说,这类实践也提供了一个更实际的判断标准:AI 是否真正提升了工程效率,不取决于它能生成多少代码,而取决于系统是否能验证这些代码进入基础设施之后仍然保持正确。
总结
旧 visitor API 换成新 API,真正困难的从来不是批量写出代码,而是建立足够可信的迁移证据。
这套方法也不是一开始就以最终形态出现的。围绕“怎样验证一棵给定 AST 上的新旧行为”,最终形成了四个相互配合的内核能力:源码扫描、运行时观测、独立比较和失败定位。在此之上,真实 SQL 语料扩大了输入范围,覆盖率反馈又让尚未验证的范围保持可见,最终形成一个完整的闭环。
AI 可以加速迁移代码的生产,但它不负责为自己的输出提供可信度。真正让这种大规模基础设施迁移变得可控的,是独立观测、集中判断、真实输入、可诊断差异以及持续可见的覆盖边界。
实用工具推荐
绿化、压缩、噪点和虚拟水印,自由调节做旧程度。
选择画质和输出格式,预览压缩前后的大小。