
AI 代码生成正在从“生成一段能跑的代码”转向“生成符合团队约束、可安全合入的代码”。这就引出一个问题如何低成本地阻止 LLM 生成包含不安全模式、违反团队规范的代码Argot 是这样一个 Rust AI guardrail 项目它的核心思路很直接不要用正则去猜而是直接分析代码库的 AST抽象语法树通过 AST 模式规则对 AI 生成的代码做检查。这篇内容不打算只做工具宣传而是围绕 Argot 的设计理念解释 AST 护栏为什么有效并带你用 Rust tree-sitter 实现一个最小可运行的 guardrail 引擎最后讨论如何接入 LLM 生成链路、CI 门禁以及上线后容易踩的坑。1. 为什么基于 AST 的 guardrail 能控制 AI 生成代码1.1 LLM 输出中的“语法正确”不等于“模式正确”LLM 生成的代码往往能通过编译器因为模型学习了大量语法结构。但团队关心的约束通常不是语法层面的而是模式层面的。例如禁止直接调用 unwrap、禁止在 SQL 语句里拼接外部输入、禁止吞掉异常后不记录日志。这些约束不是 parse 错误而是项目规范。如果只依赖编译器这类问题会直接进入 code review甚至进入生产环境。所以 guardrail 的职责是在代码进入 review、测试或生产之前自动发现这些模式。AST 模式匹配可以准确识别“某个节点是否是 unsafe 块”“某个函数调用是否是 unwrap”“某个字符串是否参与了表达式拼接”这是正则表达式很难稳定做到的因为正则不理解嵌套结构。例如表达式x.unwrap()和字符串unwrap在文本上都能被unwrap命中但它们在 AST 里的位置完全不同AST 模式可以精确区分。另一个关键点是可解释性。AST 模式一旦命中可以直接定位到具体行号和列号也可以输出命中的 AST 节点类型。团队看到报告时不需要猜测“这行代码为什么被拦”而是可以直接理解规则含义。这种确定性对于代码质量门禁非常重要。1.2 AST 模式匹配、正则和语义模型评审的对比选型时最容易想到的方案有四种正则、AST 模式、LLM 评审、完整语义分析。它们各有适用边界。方案示例优点缺点正则匹配\.unwrap\(\)简单、快、零依赖容易误报不理解嵌套结构注释里也会命中AST 模式tree-sitter query语法结构准确能定位节点和上下文需要维护语法规则不同语言需要不同 grammarLLM 评审让模型判断代码是否安全能理解语义和上下文延迟高、不稳定、成本高输出有概率性完整语义分析类型检查、数据流分析分析深度大实现成本高生态依赖重难以覆盖全语言AST 模式是“精确度与成本”的平衡点。它比正则准比完整语义分析轻。对于团队最关心的红线项比如禁用 unwrap、禁用 unsafe、禁止 TODOAST 模式能做到 100% 的确定性命中不会因为模型输出变化而产生概率性漏检。这是 guardrail 最重要的特性确定性和低延迟。实际项目中这些方案不是非此即彼。AST 护栏适合做快速、确定性的“红线检查”LLM 评审可以用于更高层的代码风格和坏味道判断完整语义分析则适合做跨文件的数据流检测。先有确定性防线再叠加其他分析是更现实的工程路径。1.3 Argot 在技术上的选型价值从项目标题来看Argot 是用 Rust 实现、基于 codebase AST 模式做 AI guardrail。这类工具选择 Rust 通常有几点考虑解析源码的性能、单文件分发的便利性、内存安全以及生态中可用的 tree-sitter 绑定。Rust 也方便把规则引擎做成 CLI或者嵌入到其他后端服务。但要强调的是Rust 只是实现语言guardrail 的价值核心在规则模型和 AST 查询能力。即使不用 Rust也可以用 Python、Go、TypeScript 实现同样的思路。Rust 的优势在于当规则库达到数百条、代码库达到数百万行时解析和匹配的开销仍然可控。对于需要频繁调用、甚至嵌入编辑器的场景执行效率不能忽视。2. 准备 Rust 工具链与最小项目2.1 安装 Rust 工具链并配置国内镜像学习环境只需要一台能运行 Rust 的机器。安装 Rust 最标准的方式是使用 rustup。如果你在国内网络环境建议同时配置 crates.io 镜像避免依赖下载超时。在~/.cargo/config.toml中添加[source.crates-io] replace-with sjtu [source.sjtu] registry sparsehttps://mirrors.sjtug.sjtu.edu.cn/crates.io-index/也可以使用中科大、清华等镜像源。镜像地址可能变化落地时以对应镜像官方文档为准。安装完成后验证rustc --version cargo --version预期输出参考rustc 1.85.0 (4d91de4e4 2025-02-05) cargo 1.85.0 (4d91de4e4 2025-02-05)如果 rustup 安装脚本下载慢可以设置RUSTUP_DIST_SERVER指向国内镜像。这一步只是加快下载速度不影响后续项目代码。2.2 初始化项目并添加依赖创建一个新项目cargo new --bin argot-guardrail-demo cd argot-guardrail-demo在Cargo.toml中添加依赖。建议直接使用cargo add避免手写版本号时出现不兼容cargo add tree-sitter tree-sitter-rust anyhow serde serde_json clap --features clap/derive生成的Cargo.toml类似[dependencies] anyhow 1 clap { version 4, features [derive] } serde { version 1, features [derive] } serde_json 1 tree-sitter 0.24 tree-sitter-rust 0.23这里要注意tree-sitter-rust的版本必须和tree-sitter主版本兼容。如果版本不匹配代码在设置 language 时可能报类型错误这是新手最常遇到的环境问题。遇到编译错误时优先检查Cargo.lock中解析出来的实际版本再对照官方 API。2.3 验证 tree-sitter 语言加载是否正常在src/main.rs先写一个最小解析代码确认 tree-sitter 能正确加载 Rust 语言use tree_sitter::{Parser, Language}; fn main() - anyhow::Result() { let mut parser Parser::new(); let language: Language tree_sitter_rust::LANGUAGE.into(); parser.set_language(language)?; let source bfn main() { println!(\hello\); }; let tree parser.parse(source, None).ok_or_else(|| anyhow::anyhow!(parse failed))?; println!(root: {}, tree.root_node().kind()); Ok(()) }预期输出root: source_file如果打印的不是source_file说明语法加载有问题。tree_sitter_rust::LANGUAGE.into()这种写法在不同版本里有变化有的版本是tree_sitter_rust::language()。遇到编译错误时优先看依赖版本的 API 文档不要盲目照搬网上代码。3. 用 AST 规则表达团队代码约束3.1 规则结构从一个 ID 开始设计一个 guardrail 规则至少要包含规则 ID、目标语言、AST pattern、提示信息、严重级别。用 JSON 定义规则便于后续扩展和管理[ { id: no-unsafe, language: rust, pattern: (unsafe_block) unsafe, message: 不允许在 AI 生成代码中新增 unsafe 块, severity: error }, { id: no-todo, language: rust, pattern: (macro_invocation macro: (identifier) mac (#match? mac \todo|unimplemented\)) call, message: AI 生成代码不允许包含 todo!() 或 unimplemented!(), severity: warning } ]这里有两个特点一是 pattern 只描述语法节点形状不依赖具体文本所以能避免“字符串里包含 unwrap 也报警”的误报二是 severity 可以让团队把“必须修复”和“需要人工确认”的规则分开处理。3.2 tree-sitter 查询语法与 AST 节点名tree-sitter query 的入门并不复杂。它的基本语法是节点类型加捕获名。例如(unsafe_block) unsafe表示匹配任意一个unsafe_block节点并命名为unsafe。还可以匹配嵌套结构( (call_expression function: (identifier) fn) (#eq? fn unwrap) ) call这表示匹配函数名为unwrap的call_expression。理解这些结构需要先查看目标语言的 AST 节点名。推荐用 tree-sitter 的在线 playground 查看代码对应的 AST 树再编写 query。例如unsafe { ... }在 tree-sitter-rust 中就是unsafe_blockprintln!(hello)是macro_invocation。容易误解的地方是AST 节点名不等于语言关键字。比如 Rust 中的fn main()不是简单的fn节点而是function_item。如果不知道节点名query 很难写对。所以写规则前先花 10 分钟把目标语言的常见语法节点看一遍比反复试错更高效。3.3 三个从实际场景提炼的规则示例第一个是禁止在生成代码中调用todo!()防止把未完成逻辑带入主线。上面已经给出 pattern。第二个是禁止直接使用 unsafe。可以写成(unsafe_block) unsafe但实际项目中有些团队允许在 FFI 边界使用 unsafe不允许在业务逻辑里使用。这时候需要配合 AST 路径判断 unsafe 是否处于某个特定函数内或者通过一个白名单函数列表来规避。规则层可以做粗略拦截精细判断需要结合调用链。第三个是禁止库代码打印调试信息。用macro_invocation匹配宏名( (macro_invocation macro: (identifier) mac) (#eq? mac println) ) call这个规则适合加在 SDK、核心库的检查中避免 AI 生成代码里残留调试输出。类似的还有dbg!、eprintln!。从这些示例可以看出AST 模式规则的成长路径是先处理“某个节点出现就报警”再进阶到“某个节点出现在特定上下文才报警”。不需要一开始就写复杂查询。4. 实现最小 guardrail 引擎4.1 模块拆分建议把代码拆成四个模块parser.rs负责把源码解析成 tree-sitter 语法树。rules.rs负责加载和校验规则配置。engine.rs负责对语法树执行 query收集命中结果。report.rs负责输出文本或 JSON 报告。这样后续增加语言、增加规则、接入 CI 时改动点相互隔离。最小 Demo 可以全部写在main.rs但一旦规则超过 10 条模块化会明显更易维护。4.2 核心代码解析源码并执行多条规则下面给出一个极简版本假设查询只针对 Rust 代码use tree_sitter::{Language, Parser, Query, QueryCursor}; #[derive(Debug, Clone)] struct Rule { id: String, pattern: String, message: String, severity: String, } #[derive(Debug)] struct Finding { rule_id: String, severity: String, message: String, row: usize, column: usize, } fn check_source(source: str, rules: [Rule]) - anyhow::ResultVecFinding { let language: Language tree_sitter_rust::LANGUAGE.into(); let mut parser Parser::new(); parser.set_language(language)?; let tree parser.parse(source.as_bytes(), None) .ok_or_else(|| anyhow::anyhow!(parse failed))?; let mut findings Vec::new(); for rule in rules { let query Query::new(language, rule.pattern)?; let mut cursor QueryCursor::new(); let matches cursor.matches(query, tree.root_node(), source.as_bytes()); for m in matches { for cap in m.captures { let node cap.node; let pos node.start_position(); findings.push(Finding { rule_id: rule.id.clone(), severity: rule.severity.clone(), message: rule.message.clone(), row: pos.row 1, column: pos.column 1, }); } } } Ok(findings) }这段代码有一个性能问题每个规则都执行一次Query::new如果一个文件有 100 条规则就会构建 100 次查询。学习 Demo 没问题但生产环境建议改为先把所有 query 编译好再复用同一个语法树执行。改造思路如下let root tree.root_node(); let mut findings Vec::new(); for rule in rules { let query compiled_queries.get(rule.id).unwrap(); let mut cursor QueryCursor::new(); for m in cursor.matches(query, root, source.as_bytes()) { // 收集命中 } }也就是把Query::new移到初始化阶段避免重复编译。4.3 输出结构化报告为了让调用方容易集成报告最好同时支持人类可读文本和 JSON。#[derive(serde::Serialize)] struct Report { files_checked: usize, findings: VecFinding, }CLI 主流程可以这样设计fn main() - anyhow::Result() { let args: VecString std::env::args().collect(); let source std::fs::read_to_string(args[1])?; let rules load_rules(rules.json)?; let findings check_source(source, rules)?; if findings.is_empty() { println!(No findings.); } else { for f in findings { let line format!({}:{}:{}: [{}] {}, f.severity.to_uppercase(), f.row, f.column, f.rule_id, f.message); println!({}, line); } } Ok(()) }这样运行cargo run -- example.rs即可检查一个文件。输出格式简洁方便在终端阅读也方便 CI 日志展示。5. 构造样例并验证规则命中5.1 准备正反样例正样例是“包含违规模式”的代码反样例是“合规但容易误触发”的代码。这里准备两组。positive.rsfn main() { let data vec![1, 2, 3]; let first data.first().unwrap(); unsafe { println!({}, first); } todo!(finish later); }negative.rsfn main() { let data vec![1, 2, 3]; if let Some(first) data.first() { println!({}, first); } println!(no unsafe or unwrap); }5.2 运行 demo 并观察输出用规则配置同时启用no-unsafe、no-todo两类规则对positive.rs运行预期至少找到两个 finding对negative.rs运行预期没有 finding。如果negative.rs也出现 unwrap 命中说明规则匹配过宽需要检查 AST 查询是否把if let解构误判成调用。预期输出参考ERROR:6:5: [no-unsafe] 不允许在 AI 生成代码中新增 unsafe 块 WARNING:9:5: [no-todo] AI 生成代码不允许包含 todo!() 或 unimplemented!()5.3 用误报率指导规则收敛在开发 guardrail 时建议同时记录两个指标精确率报警中真正违规的比例。召回率违规代码中被发现的比例。AST 模式规则通常召回率较高因为语法结构是确定的但精确率受查询编写影响很大。比如只写(identifier) fn (#eq? fn unwrap)会连函数定义名fn unwrap()都命中。必须用call_expression限定调用节点。下面是一个规则迭代示例规则版本精确率召回率说明初版63%90%误报到函数签名中的 unwrap限定 predicate 后95%85%漏了.unwrap()方法调用补充 method call 模式92%98%需要同时匹配 identifier 和 field_expression表中的数字用于说明评估方式。实际项目里建议把每条规则的测试样例纳入 CI每次修改规则都运行正反样例避免“修了误报漏了真违规”。6. 把 guardrail 接入 LLM 生成链路和 CI6.1 在 LLM 生成后增加进程外闸门最稳妥的接入方式是把 guardrail 做成独立 CLILLM 应用生成代码后将代码片段写入临时文件调用 CLI 检查读取退出码和 JSON 报告。这样做可以避免在应用进程内引入解析库的复杂依赖也让规则升级不需要重新发布主应用。示意调用argot-guardrail-demo check --lang rust --config rules.json generated.rs在集成代码里可以用类似下面的逻辑import subprocess result subprocess.run( [argot-guardrail-demo, check, --config, rules.json, file_path], capture_outputTrue, textTrue, ) if result.returncode ! 0: # 重试生成或交给人工处理 print(result.stdout)需要强调的是argot-guardrail-demo是这里最小实现的命名。真实项目 Argot 的 CLI 接口要以它的 README 为准。这里的重点是“进程外 CLI”的接入模式它比把规则引擎直接编译进应用更灵活。6.2 在 CI 中作为代码质量门禁除了在生成侧拦截guardrail 还可以作为 CI 的一步。比如在 GitHub Actions 中- name: Run guardrail run: argot-guardrail-demo check --config rules.json src/如果规则中有 error 级别就让命令以非零退出码结束CI 就会失败。这个模式适合团队把 guardrail 当作“红线检查”而不是“建议工具”。warning 级别可以只输出不阻断error 级别必须阻断。CI 场景下还要注意规则文件路径和版本。建议把rules.json放到独立仓库或同一仓库的guardrail/目录并用 git 记录变更历史。这样一旦规则升级导致误报激增可以快速回滚。6.3 生产环境需要的额外保障生产环境运行 guardrail 不能只写核心代码。至少还需要考虑日志记录每次检查的文件、规则、耗时、结果便于回溯。监控统计规则命中率、误报率、解析失败率。权限rules.json 必须有权限控制避免 AI 应用自行修改规则。回滚规则升级后如果误报暴增需要能快速回退到上一版规则。缓存在 CI 全量扫描时只重新解析变更文件或使用增量分析。另外AST 模式护栏只能发现“形态符合规则的代码”它无法判断代码的语义是否正确。例如即使没有 unwrap也不代表错误处理逻辑正确。生产链路要把 AST 护栏、代码 review、测试覆盖结合起来不要试图用一条防线解决所有问题。7. 常见坑与排查路径7.1 规则未命中先看 AST 树再改查询