程云来 / 杭州
Back to Blog·AST
·About 21 min

AST 不只是前端工具:把一条设备规则变成可检查的程序

从一条工业设备告警规则出发,区分文本、CST、AST 与 IR,用 Python 标准库实现解析、语义收窄、内存执行和 SQL 编译,并说明这套思想何时值得采用。


适用环境:CPython 3.13,示例在 CPython 3.13.3 与 SQLite 3.49.2 验证;只使用 Python 标准库。资料与代码核对日期为 2026-09-10。

一条告警规则,为什么不能直接塞进 if

设想一套设备监测程序收到一条规则:

text
temperature > 80 and vibration >= 6

当前读数是 temperature=88.0vibration=6.3,程序应该触发告警。把它写成 Python 条件并不难:

python
if temperature > 80 and vibration >= 6:
    send_alarm()

麻烦从规则离开源码的那一刻开始。现场人员需要在管理系统里修改阈值;程序要在读数到达时立即判断,也要把同一条规则转换成 SQL 查询历史记录;发布前还要回答“规则用了哪些字段”“是否包含不允许的函数调用”。

如果系统一直传递字符串,每个使用方都要重新猜它的结构。直接调用 eval 虽然能得到布尔值,却把 Python 的函数调用、属性访问和其它语法一并开放了,而且不能直接生成参数化 SQL。继续堆字符串切割也会很快遇到括号和 and / or 优先级。

这里需要的不是某个 Web 构建工具,而是一个稳定的中间对象:先把文本还原成结构,检查结构,再决定怎样执行。

抽象语法树(Abstract Syntax Tree,AST)是源文本的结构化语义骨架:节点保存“这是什么操作”和“操作谁”,后续程序不再靠搜索字符串理解它。

“抽象”不是把文本画成一棵树

同一条告警规则经过词法与语法分析后,可以得到下面的领域 AST:

text
Logical(and)
├── Comparison(temperature, >, 80)
└── Comparison(vibration, >=, 6)

空格、原始字符位置和 and 的三个字母没有成为业务节点;真正留下的是逻辑连接、字段、比较运算和阈值。运行时不必再问字符串里有没有 and,只需处理 Logical;SQL 编译器也不必拆文本,只需把两个 Comparison 按顺序转换。

但“抽象到什么程度”没有唯一答案。Clang 的 AST 会保留括号表达式和未折叠的编译期常量,因为这对重构工具有用;LLVM 的语言教程则让 AST 捕获程序行为,再把它转换为更适合后端的 LLVM IR。1 2 3

因此,CST、AST 和 IR 应按使用目的区分,而不是按名字判断谁更高级:

表示主要保留什么更适合
具体语法树(Concrete Syntax Tree,CST)标点、括号等具体 token 与语法产生式格式化、语法高亮、编辑器增量更新
AST对后续分析有意义的语法结构检查、重构、解释、代码生成
中间表示(Intermediate Representation,IR)已解析的类型、符号或面向后端的操作优化、跨后端编译、执行计划

Tree-sitter 明确把自己的输出称为 CST:每个 token,包括逗号和括号,都可以出现在树中;它也指出,去掉次要细节的 AST 更适合某些代码分析。4 这条对照说明了 AST 的要点不是“树”,而是按后续任务舍弃表面细节

Web 只是 AST 最常见的橱窗

前端开发者容易从 Babel、ESLint 或 ESTree 认识 AST。ESTree 用带 type 字段的节点定义 JavaScript 工具之间的共同结构;ESLint 规则再按节点类型访问和报告问题。5 6

同一种处理方式并不属于浏览器:

领域结构化表示读什么后续程序写什么
编译器声明、表达式、类型和控制结构机器码、字节码或 LLVM IR
静态分析与重构节点类型、符号与源码位置诊断、索引或源码修改
数据库SQL 的语法结构、表、列和运算重写后的查询树与执行计划
规则与配置系统业务字段、运算符和组合关系决策结果、SQL、搜索条件或设备指令

PostgreSQL 的官方文档没有把所有内部结构都统称为 AST。它先从 SQL 文本建立 raw parse tree,再做语义转换,查明表、函数、运算符和数据类型,得到 query tree;随后重写、规划并递归执行 plan tree。7 8 名称不同,背后的工程动作相同:不要让执行器直接解释原始文本;先建立能被检查、改写和交给下一阶段的数据结构。

设备规则属于最后一行。它不是“把编译器术语搬进业务代码”,而是承认规则已经是一门很小的语言:它有允许的词、组合方式、语义和执行后端。只要这些约束会独立演进,就值得把它们显式建模。

先定语言边界,再定义节点

直接创建 BinaryExpressionIdentifier 等节点,很容易复制一套通用编程语言,却说不清业务究竟允许什么。这个实验先把规则语言限制写出来:

text
expression     ::= or_expression
or_expression  ::= and_expression ("or" and_expression)*
and_expression ::= primary ("and" primary)*
primary        ::= comparison | "(" expression ")"
comparison     ::= FIELD (">" | ">=" | "<" | "<=" | "==" | "!=") NUMBER

FIELD 目前只能是 temperaturevibration。没有函数调用、属性访问、算术、赋值和连续比较。括号可以改变组合关系,Python 自身的优先级规则负责把 andor 分组。

项目拥有的领域 AST 只需要两种节点:

python
@dataclass(frozen=True, slots=True)
class Comparison:
    field: str
    operator: Literal[">", ">=", "<", "<=", "==", "!="]
    threshold: int | float


@dataclass(frozen=True, slots=True)
class Logical:
    operator: Literal["and", "or"]
    children: tuple["Rule", ...]


Rule = Comparison | Logical

Comparison 读取一个设备字段和一个数值阈值,写出真假;Logical 读取若干子规则,按 andor 合并真假。frozen=True 让解析后的规则保持不可变,多个执行请求可以安全地共享它。

这里故意没有沿用 CPython 的全部 AST 节点。Python AST 是语法前端的输出;Comparison | Logical 才是当前项目愿意长期维护的业务契约。

Parser 只证明“读得懂”,不证明“允许执行”

从零编写 lexer 与 parser 可以完全控制语法;LLVM 的 Kaleidoscope 教程就依次实现词法分析、递归下降解析、运算符优先级和 AST。9 这个实验为了把重点放在 AST 边界上,复用 Python 3.13 的表达式 parser。

ast.parse(source, mode="eval") 接收一条表达式字符串,返回 CPython AST;它不会执行表达式。Python 文档也提醒,成功生成 AST 不代表代码最终一定可执行,作用域等检查发生在后续编译阶段。10

所以 parse_rule 不能在 ast.parse 返回后就宣布成功。它必须把通用 Python AST 翻译成领域 AST;翻译过程就是语义白名单:

python
def parse_rule(source: str) -> Rule:
    try:
        parsed = ast.parse(source, mode="eval")
    except SyntaxError as error:
        raise RuleSyntaxError(to_location(error)) from error

    return _translate(parsed.body)


def _translate(node: ast.expr) -> Rule:
    if isinstance(node, ast.BoolOp):
        operator = "and" if isinstance(node.op, ast.And) else "or"
        return Logical(operator, tuple(_translate(child) for child in node.values))

    if isinstance(node, ast.Compare):
        require_one_allowed_operator(node)
        field = require_allowed_field(node.left)
        threshold = require_number(node.comparators[0])
        return Comparison(field, comparison_name(node.ops[0]), threshold)

    raise RuleSemanticError(f"不允许的语法:{type(node).__name__}")

上面是正式实现的连续调用骨架,错误格式化和三个 require_* 检查在配套 example 中展开。输入 temperature > 80 时,Python 的 Compare 被收窄成领域 Comparison;输入 __import__('os') 时,parser 仍能产生合法的 Python Call,但 _translate 没有 Call 分支,因此在任何调用发生前拒绝它。

这一步是 AST 实施中最容易漏掉的边界:语法解析回答文本能否形成结构,语义验证回答这个结构在当前领域是否有意义、是否获准。 PostgreSQL 也把固定语法规则的解析与需要查系统目录的语义转换分开;设备规则的字段白名单扮演了更小规模的同一角色。7

同一棵树,交给两个后端

领域 AST 建立后,原始字符串退出执行链路。内存解释器读取节点和实时指标,SQLite 编译器读取同样的节点,写出 WHERE 子句与绑定参数:

正在绘制图表…
查看 Mermaid 源码
flowchart LR
    source[规则文本] -->|ast.parse| pythonAst[CPython AST]
    pythonAst -->|语义白名单 + 翻译| domainAst[领域 AST]
    domainAst -->|evaluate + 实时读数| decision[告警 / 正常]
    domainAst -->|compile_sql| sql[WHERE 子句 + 参数]
    sql -->|SQLite 执行| history[历史命中记录]

Mermaid 大图

可滚动查看图表,点击缩放比例可恢复 100%。按 Esc 关闭。

两个后端只依赖 Comparison | Logical。Python 语法前端以后即使换成表单、YAML 或自写 parser,只要仍能生成同一份领域 AST,执行端就不用跟着重写。

解释器递归读取树。Comparisonmetrics 取当前值,Logical(and) 使用 all 短路,Logical(or) 使用 any 短路:

python
def evaluate(rule: Rule, metrics: Mapping[str, int | float]) -> bool:
    if isinstance(rule, Comparison):
        actual = require_runtime_number(metrics, rule.field)
        return COMPARISON_FUNCTIONS[rule.operator](actual, rule.threshold)

    if rule.operator == "and":
        return all(evaluate(child, metrics) for child in rule.children)
    return any(evaluate(child, metrics) for child in rule.children)

SQL 编译器执行另一种递归。列名来自固定映射,不能来自未经验证的字符串;阈值只进入 parameters,不拼进 SQL:

python
def compile_sql(rule: Rule) -> CompiledSql:
    if isinstance(rule, Comparison):
        column = FIELD_TO_COLUMN[rule.field]
        sql_operator = SQL_OPERATORS[rule.operator]
        return CompiledSql(f'"{column}" {sql_operator} ?', (rule.threshold,))

    children = tuple(compile_sql(child) for child in rule.children)
    joiner = " AND " if rule.operator == "and" else " OR "
    return CompiledSql(
        "(" + joiner.join(child.clause for child in children) + ")",
        tuple(value for child in children for value in child.parameters),
    )

compile_sql 的输出不是查询结果,而是 CompiledSql(clause, parameters)。公开入口把它交给 SQLite:

python
rule = parse_rule("temperature > 80 and vibration >= 6")

alarm = evaluate(rule, {"temperature": 88.0, "vibration": 6.3})
compiled = compile_sql(rule)
rows = connection.execute(
    f"SELECT COUNT(*) FROM sensor_readings WHERE {compiled.clause}",
    compiled.parameters,
).fetchone()[0]

这条调用链中,parse_rule 写领域 AST;evaluate 读取 AST 和一次实时读数;compile_sql 读取 AST 并写 SQLite 的结构化输入。没有后端重新解析源文本。

跑一次,观察结构而不只看结果

完整代码位于 examples/ast-thinking/。从仓库根目录执行:

bash
python3 examples/ast-thinking/user_code/run_demo.py
python3 examples/ast-thinking/verify.py

第一次命令会先打印领域 AST JSON,再得到下面的关键输出:

text
输入: temperature > 80 and vibration >= 6
内存判断: 告警
SQL: ("temperature" > ? AND "vibration" >= ?)
参数: [80, 6]
SQL 查询: 1 条命中
拒绝: pressure > 10 -> 未知字段:pressure
拒绝: __import__('os').system('echo 不应执行') -> 不允许的语法:Call

这次执行证明的是:一个输入只解析一次;两个后端消费同一结构;未知字段和函数调用不会到达执行阶段。它没有证明 Python 与 SQL 对所有值都具有相同语义,也没有证明当前 AST schema 可以永久不变。

可以再把规则改成:

text
temperature < 0 or (temperature > 80 and vibration >= 6)

括号不会成为领域节点,但会改变 Logical 的嵌套方式。用 temperature=-2vibration=0 运行时,左侧比较为真,or 立即得到真;这正是“保留语义结构、舍弃表面括号”的可观察结果。

三条失败路径会决定生产设计

能 parse 的表达式绕过了业务边界

症状__import__('os').system(...) 能被 Python parser 解析,团队误以为 ast.parse 已经提供安全沙箱。

原因:parser 只负责 Python 语法;CallAttribute 等节点是否合法,是设备规则自己的语义与授权问题。

处理:从“默认拒绝”开始,只翻译明确允许的节点、字段、运算符和常量;不要先 compile / eval 再尝试拦截副作用。示例还限制源文本长度和节点数量,避免异常复杂的输入消耗过多资源。

验证:把函数调用、属性访问、列表、字符串和连续比较加入拒绝测试,断言没有执行端被调用。公开验证脚本已经覆盖 Call、未知字段与连续比较。

规则合法,运行时数据却不完整

症状:规则已经保存,某批设备消息缺少 vibration,解释器在比较时才发现没有值。

原因:AST 验证的是规则结构和字段名称;它不能保证每一次事件都满足输入 schema。

处理:在执行边界定义缺失值策略。当前实现选择显式抛出 RuleEvaluationError,而不是把“缺数据”悄悄解释成 False。生产系统可以把它路由到数据质量告警,但必须与业务方约定。

验证:使用 {"temperature": 88} 执行示例规则,断言错误是“缺少运行时字段:vibration”,并确认没有发送正常或告警结论。

两个后端都成功,结果却不等价

症状:内存判断为假,SQL 查询却没有返回真假而是 NULL;或小数、时间、字符串排序在两个后端得出不同结果。

原因:AST 统一了结构,不会自动统一每个后端的值语义。SQL 的 NULL 是三值逻辑,Python 的缺失字典键是异常;两者不能靠相同运算符名称自然对齐。

处理:为字段定义类型、单位、空值与时区策略,并让每个编译器声明支持的节点集合。当前实验只接受有限数值字段,缺失值直接失败,因此避开了 NULL 的歧义。

验证:建立一组相同 AST 与相同数据的 backend conformance tests,同时跑内存解释器和真实数据库;新增节点或字段类型时先补对照案例,再开放生产规则。

AST schema 是一份需要演进的 API

Python 文档明确说明,Python 的抽象语法可能随版本变化。10 因此,不建议把 ast.BoolOp 等 CPython 对象直接序列化后当作长期存储格式,也不建议让其它服务依赖它。

在这个项目里,更稳妥的持久化记录至少包含:

json
{
  "source": "temperature > 80 and vibration >= 6",
  "grammarVersion": 1,
  "astSchemaVersion": 1,
  "policyVersion": 3
}

source 用于编辑和重新诊断;grammarVersion 决定怎样解析;astSchemaVersion 决定 Comparison / Logical 怎样序列化;policyVersion 决定当时允许哪些字段和运算符。若另外保存 AST 快照,它应带版本并可由 source 重建,而不是成为无法迁移的唯一事实。

一次规则发布也不应只检查“JSON 能否反序列化”。建议按下面的顺序推进:

  1. 解析 source:生成语法前端的树,保留错误位置。
  2. 建立领域 AST:解析字段、类型和权限,拒绝未知节点。
  3. 验证每个目标后端:确认实时解释器与 SQL 编译器都支持全部节点。
  4. 冻结并发布版本:计算 source、schema 与 policy 的版本键,缓存不可变 AST。
  5. 记录执行证据:关联 rule ID、AST schema、backend、输入版本和结果;敏感读数不直接进入普通日志。

如果规则会产生发信、停机等副作用,AST 只决定“应该做什么”,不负责一次性执行、重试与幂等。副作用仍需要独立的任务 ID、状态存储和审计记录。把程序表示成树,不会自动获得事务与恢复能力。

什么时候值得引入 AST

回到最初的设备告警需求,选择 AST 的依据不是“表达式看起来像代码”,而是结构是否需要成为多个能力的共同边界。

我会在以下条件同时出现时采用它:规则由源码之外的人或系统提供;括号、优先级或嵌套已经让字符串处理不可靠;系统需要验证、解释、转换、检索或多个执行后端中的至少两项;语法和节点 schema 有明确负责人。

如果只有三条固定条件,并且只在一个函数中执行,普通 if 更清楚。如果输入本来就是若干独立配置项,没有组合语法,经过 schema 验证的 JSON 已经足够。如果任务要求保留每个空格、注释并无损回写源码,CST 往往比 AST 更合适。如果后端已经需要类型解析、优化和执行计划,领域 AST 之后还应继续降低为 IR,而不是让一个节点模型承担所有阶段。

最后也要守住一个容易泛化过头的边界:DOM、组件树、组织树虽然也是树,但只有在它们表示某种语言的抽象语法时,才应称为 AST。AST 思想真正可迁移的部分,是把外部表达先变成有类型、可遍历、可验证、可转换的结构;树形只是嵌套语法自然得到的形状。

现在再看开篇规则,系统不再只有一段待执行字符串。它已经拥有一份可检查的领域程序:parser 负责读懂,语义翻译负责守门,解释器与 SQL 编译器分别执行,而 schema 版本负责让这份结构能够演进。这才是 AST 从“前端工具名词”变成工程方法的时刻。

Footnotes

  1. Clang:Introduction to the Clang AST。Clang AST 服务于编译与重构,因此会保留部分接近源码和 C++ 标准的结构。

  2. LLVM Kaleidoscope:Implementing a Parser and AST。教程从 lexer、parser 建立捕获程序行为的 AST。

  3. LLVM Kaleidoscope:Code generation to LLVM IR。该阶段把已经建立的 AST 转换为 LLVM IR。

  4. Tree-sitter:Basic Parsing。Tree-sitter 输出保留具体 token 的 CST,并说明 AST 会移除较次要的细节。

  5. ESTree Spec。ESTree 为 JavaScript 源码工具维护可扩展的 AST 节点约定。

  6. ESLint:Custom Rule Tutorial。ESLint 自定义规则按 ESTree 节点类型注册访问逻辑并报告问题。

  7. PostgreSQL 18:The Parser Stage。PostgreSQL 区分固定语法解析与依赖系统目录的语义转换。 2

  8. PostgreSQL 18:The Path of a Query。查询依次经过 parser、rewrite、planner / optimizer 与 executor。

  9. LLVM:My First Language Frontend。官方教程用一门小语言串起 lexer、parser、AST、IR、JIT 与目标文件生成。

  10. Python 3.13:ast — Abstract Syntax Treesast.parse 生成 CPython AST;抽象语法可能随 Python 版本变化。 2