当规则引擎遇上大模型:代码审查系统的双引擎协同实战
引子
前段时间写了 Code Review Agent,一个接 GitLab/GitHub webhook 自动审代码的个人项目。架构很简单:收到 MR 事件 → 拉 diff → 拼 prompt → 调 DeepSeek → 解析结果 → 回写评论到 MR 页面。MVP 跑起来只用了一个周末。
但用了一阵子,三个问题越来越刺眼。
一致性问题。同一段 SQL 拼接"SELECT * FROM user WHERE id = " + userId,这次报 ERROR,下次改动一点点重新提,模型就觉得"还行"。到后面我自己都开始怀疑:这工具到底准不准?LLM 是概率系统,prompt 写得再好也改不了它是采样生成这个事实。审代码不比聊天——同样的输入必须给同样的结论,否则没人信,包括我自己。
成本问题。一个 MR 三五千 token 灌进去,里面有大量"正则就能判死刑"的模式:SQL 字符串拼接、catch (Exception e) {}、// TODO注释、硬编码的 API Key。这些不用理解语义,正则扫一下 0 毫秒就能确认。让模型逐 token 读完、再组织 JSON 输出,相当于花钱请一个资深工程师帮你检查缩进——可以但没必要。
控制粒度问题。很快我自己也想关掉某些检查——比如 TODO 注释,有的仓库我想留着审,有的仓库纯属噪音。纯 LLM 方案只能改 system prompt 把这行删掉,但 prompt 是全局生效的,做不到 A 仓库跳过 B 仓库保留。而且 prompt 越长越贵,不可能把配置逻辑全塞进去。
结论很直白:不是所有代码审查都需要大模型,能把正则判死的和需要语义理解的分开处理。
分工:确定性 vs 概率性
定这条分界线的时候比想象中纠结——边界是糊的。最后定了"能否正则判死"一刀切:
| 问题类型 | 例子 | 谁处理 | 依据 |
|---|---|---|---|
| 模式确定 | SQL 拼接、硬编码密钥、空 catch、TODO、魔法数字 | 规则引擎 | 正则命中即命中,confidence 天然 1.0,零 token 零幻觉 |
| 语义判断 | 空指针解引用、业务逻辑 bug、并发竞态 | LLM | 需要理解数据流和业务意图,正则做不了 |
| 交叉地带 | 事务缺失(diff 看不到类注解) | 规则定 WARNING + LLM 确认 | 规则能发现连续写操作,但不确定类上有没有 @Transactional |
三处拿捏花了些时间。
空指针没上规则引擎。判断"这个对象此时是否为 null"需要往前追溯赋值链路、检查中间有没有 null check、有没有 Optional 包装。正则只能看到解引用的文本层面,不知道它是不是安全的。硬上结果就是满屏误报——看起来引擎很努力,其实全是噪声。
事务缺失只定 WARNING。规则在 diff 里看到连续insert+update,逻辑上应该包事务。但@Transactional注解在文件头部的类声明上,diff 变更片段不展示。不能把"没看见"当成"没有"。所以只圈 WARNING 标记嫌疑,LLM 或人做最终确认。
语言匹配不能让规则跨语言瞎扫。空 catch 等规则声明了applicableLanguage() = "java",RuleEngine 扫描时自动跳过.go、.ts文件。这个过滤不复杂但很关键——一条 Java 的正则扔到 Go 文件上去匹配,结果全是噪声。
规则引擎:接口薄,配置活在数据库里
ReviewRule接口就四个方法。多了就是过度设计:
publicinterfaceReviewRule{StringruleId();// 和 rule 表对应Severityseverity();StringapplicableLanguage();// null = 全语言List<ReviewFinding>apply(DiffFilefile,RuleContextctx);}RuleEngine 通过 Spring 的 List 注入自动收拢所有实现:
publicRuleEngine(List<ReviewRule>rules,RuleMapperruleMapper,...){this.ruleImpls=rules.stream().collect(Collectors.toMap(ReviewRule::ruleId,Function.identity()));}新增一条规则的全部工作:写@Component实现类 + 往rule表插一行。RuleEngine 一行业务代码不改。
代码给能力,数据库给开关。实现类决定"会扫什么",rule表的enabled和params_json决定"开不开、参数多少"。管理台禁用一条规则,下一个 MR 的审查流程就直接跳过它——没有重启,没有改 yml,没有发版。重新启用时,prompt 里"规则已覆盖"的清单自动把这类问题从 LLM 审查范围移除,token 减负跟着开关走。
参数化也走 DB。例如超大函数检测的params_json里存{"maxLines": 80},每次审查 RuleEngine 现查现解析,构造RuleContext传给规则方法。要给某仓库阈值调成 100?改一行 DB 字段。
Map<String,String>params=parseParams(config.getParamsJson());RuleContextctx=newRuleContext(params,files,mr);findings.addAll(rule.apply(file,ctx));rule表几十条记录,每次现查开销可以忽略。量上去了加个本地 60 秒缓存,改造成本很小。
降级策略写得早,但真触发过才有底。本地开发时 MySQL 没起来,RuleEngine 的 DB 查询抛异常:
try{enabledRules=ruleMapper.selectList(...);}catch(Exceptione){log.error("load enabled rules failed, skip rule scan");returnnewScanResult(List.of(),List.of(),List.of());// LLM 兜底}规则引擎整体跳过,LLM 兜底照常完成审查——功能没丢。单条规则 apply 抛异常也只 warn,不中断其他规则。从设计第一天规则就是增强而非依赖——挂了不影响主链路,LLM 天生就是兜底。
Diff 解析:规则引擎的地基
LLM 不需要结构化——diff 文本原样糊进 prompt 就行。但规则不行,它是逐行扫描的,得知道每行是新增还是删除、新旧行号分别是什么、当前文件后缀是什么。
DiffParser处理 unified diff 文本,输出DiffFile → DiffHunk → HunkLine三层结构。行号追踪的核心逻辑:
if(line.startsWith("+")){lines.add(newHunkLine(LineType.ADD,-1,newLineNo++,content));}elseif(line.startsWith("-")){lines.add(newHunkLine(LineType.DEL,oldLineNo++,-1,content));}else{lines.add(newHunkLine(LineType.CONTEXT,oldLineNo++,newLineNo++,content));}删掉的行newLineNumber = -1,新增的行oldLineNumber = -1。审查发现永远报newLineNumber,因为评论需要挂在 MR 新代码行上。语言检测走的是后缀映射表——.java→java,.kt→kotlin,以此类推,规则通过applicableLanguage()做匹配过滤。
两条规则的实际代码
把抽象设计翻译成具体实现,规则的"确定性"怎么体现的就清楚了。
SQL 注入:两条正则加一个排除规则
// 模式一:SQL 关键字字符串后跟 + 拼接PatternSQL_CONCAT=Pattern.compile("\"[^\"]*(?i:select|insert\\s+into|update|delete\\s+from)[^\"]*\"\\s*\\+");// 模式二:jdbcTemplate.query("..." + var) 这类执行方法实参含拼接PatternEXECUTE_CONCAT=Pattern.compile("\\.(executeQuery|executeUpdate|execute|query|queryForObject|"+"queryForList|createQuery|createNativeQuery|prepareStatement)\\s*\\([^)]*\\+");// 排除 MyBatis #{} 和 JDBC ? 这两种参数化占位符PatternSAFE_PLACEHOLDER=Pattern.compile("#\\{|\\?");apply只扫新增行,跳过注释和空行,每文件最多报 3 条——一个文件几十处拼接刷评论区没有意义。
SAFE_PLACEHOLDER不加的话会产生大量误报:MyBatis 的#{userId}在正则视角跟字符串拼接毫无二致。这种排除逻辑没有语义理解,纯粹靠经验 hardcode。但规则的确定性给了一个好处:你加了排除,它 100% 生效,不像 LLM 那样这次记得下次忘。
空 catch:单行和跨行两种形态都得覆盖
// 写法一:catch (Exception e) {}PatternSINGLE_LINE_EMPTY=Pattern.compile("catch\\s*\\([^)]*\\)\\s*\\{\\s*}");// 写法二:catch 以 { 结尾,向下找下一行非空非注释的行,若是 } 则是空块if(content.trim().endsWith("{")){for(intj=i+1;j<lines.size();j++){Stringnext=lines.get(j).content().trim();if(next.isEmpty()||next.startsWith("//"))continue;if(CLOSE_BRACE.matcher(next).matches()){hits.add(...);}break;}}startsWith("//")这里有个注意点——catch 里如果只有注释没有真实处理逻辑,本质上还是在吞异常。所以注释行 continue 了继续往下扫,最终如果匹配到}照报不误。
双引擎协同:三道防线
规则跑通了、LLM 也在跑,把两个引擎的结果合并才是真正费功夫的地方。目标就一个:输出的是一个统一的审查结论,不能出现"规则说 A 行有问题、LLM 也说 A 行有问题"这种重复,更不能出现"规则说没问题、LLM 凭空编了一个出来"。
防线一:prompt 减负——让 LLM 知道哪些不用管
规则扫描完成后,本次启用的能力清单和命中结果动态注入 system prompt。能力清单是动态生成的——管理台禁用某规则后,prompt 自动把该类问题还给 LLM:
// 规则已覆盖(动态拼到 system prompt 末尾)for(Stringsummary:capableSummaries){sb.append("- ").append(summary).append('\n');// 如"SQL注入风险(sql_injection)"}// 本次已命中——明确要求 LLM 不要重复输出for(ReviewFindingf:ruleFindings){sb.append(String.format("- [%s] %s:%s (%s) %s\n",f.severity(),f.filePath(),f.lineNumber(),f.ruleId(),f.message()));}这层本质是 token 减负——规则覆盖的模式问题从 LLM 的注意力范围中拿掉,让它专心处理语义问题。
但 prompt 只是建议,LLM 经常不死心。
防线二:结果去重——同文件同问题只留一份
合并时做了一个结构性去重:同 filePath、同 ruleId、行号差 ≤ 2,保留规则侧的版本:
for(ReviewFindingllmFinding:llmFindings){booleanduplicate=ruleFindings.stream().anyMatch(ruleFinding->Objects.equals(ruleFinding.filePath(),llmFinding.filePath())&&Objects.equals(ruleFinding.ruleId(),llmFinding.ruleId())&&Math.abs(ruleFinding.lineNumber()-llmFinding.lineNumber())<=2);if(duplicate)continue;merged.add(llmFinding);}保留规则侧不是 LLM 侧,因为规则 confidence=1.0 是确定性判断,LLM 的置信度只会更低。规则输出的是规范值sql_injection,LLM 可能写成sql-injection(ruleId 对不上去重失败也认了,至少不会拿 LLM 版本替换规则版本)。
行号容差 ±2:diff 只有增量行号,LLM 靠"数行"推理,差一两行是常态。
防线三:幻觉否决——用确定性系统按住概率系统
但去重解决不了 LLM 凭空编造问题。case_004 打了个措手不及。
这个用例的 diff 是"新增一个业务类但没写测试"。文件里有个完全无害的注释// 调用第三方支付。LLM 的输出里多了一条:
{"ruleId":"todo_comment","severity":"INFO","message":"代码中包含 TODO 注释"}diff 里根本没有 TODO。同一场景下todo_comment规则正确——正则老老实实没匹配到这三个字母,没报。这条误报完整记录在 7 月 24 日的评测报告里(eval/reports/2026-07-24_deepseek.md),当天的 Precision 被它拖到了 0.83。
这条数据是整个规则引擎存在价值的直接证据:概率系统真的会有幻觉,确定性系统不会。
于是加了否决逻辑:
if(capableRuleIds.contains(llmFinding.ruleId())){booleanruleHitSame=ruleFindings.stream().anyMatch(rf->Objects.equals(rf.filePath(),llmFinding.filePath())&&Objects.equals(rf.ruleId(),llmFinding.ruleId()));if(!ruleHitSame){// 规则扫过同文件同类型——说没有。LLM 说有 = 幻觉continue;// 丢弃}}否决范围严格限定——只针对 LLM 使用规则 ruleId(sql_injection、todo_comment等)上报的问题。LLM 自造 ruleId(undefined_type、logic_error)不受影响,因为规则管不到语义领域。
语义类幻觉走另一条路。调 prompt 期间观察到 LLM 把 case_004 里的thirdParty对象误判为"未定义"——其实它是类成员字段,只是 diff 片段没展示。这种问题规则管不了,靠 prompt 加可证明性约束:
编译错误类推断涉及 diff 片段之外的符号时,只有在同一 diff 片段内能直接证明的编译错误才可报告。
几轮 prompt 调整后收敛到了可用状态,7 月 26 日的报告回到全绿。
评测数据
评测框架有 5 个手工构造的用例:SQL 注入、吞异常、TODO、缺测试、干净 MR。EvalRunner 调的是全链路reviewWithDiff——规则引擎 + LLM + 去重 + 裁决全部走一遍,不单独评某个组件。每次评测产出一份日期戳报告(存在eval/reports/下),三次关键快照:
| 日期 | Recall | Precision | Verdict 一致率 | 说明 |
|---|---|---|---|---|
| 07-21 | 1.00 | 1.00 | 1.00 | 基线 |
| 07-23 | 1.00 | 1.00 | 1.00 | 复跑确认稳定 |
| 07-24 | 1.00 | 0.83 | 1.00 | case_004 冒出 TODO 幻觉(FP) |
| 07-26 | 1.00 | 1.00 | 1.00 | 幻觉否决 + 可证明性约束后回绿 |
07-24 那次是值得记住的一次:加了规则引擎之后指标反而变差了——不是规则的错,是 LLM 的幻觉正好被评测逮住。这恰恰说明评测框架在干它该干的事。
单用例协同细节(以 07-24 报告为准):
| 用例 | 规则 | LLM | 结果 |
|---|---|---|---|
| case_001 SQL 注入 | sql_injection 命中 | 也报了同一问题 | 去重保留 RULE |
| case_002 空 catch | empty_catch @ 92 | 遵守指令不重复 | LLM 注意力释放 |
| case_003 TODO | todo_comment 命中 | 遵守指令不重复 | 同上 |
| case_004 缺测试 | no_test @ 5 | 幻觉报 TODO(FP) | 规则正确没报 |
| case_005 清洁 MR | 无 | 无 | 双引擎均无噪声 |
踩过的坑
三个坑,每个坑都花了至少一轮评测才定位到。
裸 hunk 导致规则集体静默
首次全链路评测时,case_001(SQL 注入)和 case_002(空 catch)的规则命中数都是 0。全靠 LLM 兜底才没在指标上翻车。
根因链路:评测用例是裸 hunk(直接@@开头,没有diff --git a/b.java文件行)→ DiffParser 解析完文件路径是 null →detectLanguage(null)返回"unknown"→ 声明了applicableLanguage() = "java"的规则全部被语言过滤跳过。
修起来就是给parse()加了个defaultPath参数。但这个 bug 真正的问题在于静默传导——路径 null → 语言 unknown → 规则跳过,整个调用链没有任何异常,最终表现是"功能没生效"。后来在 RuleEngine 里加了rule scan done hits=N rules=[...]这条日志,就是因为这次排查花了太多时间。
评测标注迁就了 LLM
case_002 规则报了空 catch 在 92 行——这是 diff 结构推算出来的客观行号。但 expected 标注当时写的是 88 行(方法声明行),|92-88| = 4 超出 ±2 容差,Recall 直接掉到 0.75。
原因:纯 LLM 时代,标注是"看 LLM 输出什么标什么",迁就了 LLM 的行号估算习惯。规则引擎严格按 diff 行号推算,不偏移。评测对象从"模型输出"变成"系统行为"之后,标注标准也必须从"模型习惯"切换到"diff 结构事实"。现在 expected 里标的就是 92 行。
降级必须真实触发过
写"规则挂了 LLM 兜底"这种设计很容易。但本地 MySQL 没起来那次,RuleEngine 输出skip rule scan、LLM 兜底照常完成审查——真实触发过一次,才确认设计成立。单条规则异常只 warn 不中断这件事也是同样逻辑。纸上写的降级和真实触发过的降级,对后续设计的信心影响不一样。
几点总结
做完规则引擎之后回头想,几个判断可以复用。
确定性和概率性分开。正则能判死的别烧 token。这条分界线划清楚,后续的协同机制才有基础。别人问你"为什么有了大模型还要规则引擎",把 7 月 24 日报告里那条 TODO 幻觉给他看就够了。
能力归代码、开关归数据库。新增规则不改引擎,禁用规则不重启。enabled字段 +params_json这两列扛了全部配置灵活性。
协同不能只靠 prompt。减负、去重、否决三道防线互不替代。prompt 只是建议,去重和否决是代码层面强制执行。
评测当开发工具,不当验收工具。裸 hunk 路径 bug、标注偏移、TODO 幻觉,全是在评测跑出来的,不是手动 review 发现的。每次改代码前跑一次评测,改完再跑一次对比——这个习惯比事后复盘有用。
降级验证不能只靠文档。MySQL 没起来那次比任何设计文档都有说服力。
系列目录:
- 第一篇:架构演进总览
- 第二篇:本文(双引擎协同)
- 第三篇:多 LLM Provider 可插拔架构
- 第四篇:Webhook 可靠性设计