当规则引擎遇上大模型代码审查系统的双引擎协同实战引子前段时间写了 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 里看到连续insertupdate逻辑上应该包事务。但Transactional注解在文件头部的类声明上diff 变更片段不展示。不能把没看见当成没有。所以只圈 WARNING 标记嫌疑LLM 或人做最终确认。语言匹配不能让规则跨语言瞎扫。空 catch 等规则声明了applicableLanguage() javaRuleEngine 扫描时自动跳过.go、.ts文件。这个过滤不复杂但很关键——一条 Java 的正则扔到 Go 文件上去匹配结果全是噪声。规则引擎接口薄配置活在数据库里ReviewRule接口就四个方法。多了就是过度设计publicinterfaceReviewRule{StringruleId();// 和 rule 表对应Severityseverity();StringapplicableLanguage();// null 全语言ListReviewFindingapply(DiffFilefile,RuleContextctx);}RuleEngine 通过 Spring 的 List 注入自动收拢所有实现publicRuleEngine(ListReviewRulerules,RuleMapperruleMapper,...){this.ruleImplsrules.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 字段。MapString,StringparamsparseParams(config.getParamsJson());RuleContextctxnewRuleContext(params,files,mr);findings.addAll(rule.apply(file,ctx));rule表几十条记录每次现查开销可以忽略。量上去了加个本地 60 秒缓存改造成本很小。降级策略写得早但真触发过才有底。本地开发时 MySQL 没起来RuleEngine 的 DB 查询抛异常try{enabledRulesruleMapper.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_CONCATPattern.compile(\[^\]*(?i:select|insert\\sinto|update|delete\\sfrom)[^\]*\\\s*\\);// 模式二jdbcTemplate.query(... var) 这类执行方法实参含拼接PatternEXECUTE_CONCATPattern.compile(\\.(executeQuery|executeUpdate|execute|query|queryForObject|queryForList|createQuery|createNativeQuery|prepareStatement)\\s*\\([^)]*\\);// 排除 MyBatis #{} 和 JDBC ? 这两种参数化占位符PatternSAFE_PLACEHOLDERPattern.compile(#\\{|\\?);apply只扫新增行跳过注释和空行每文件最多报 3 条——一个文件几十处拼接刷评论区没有意义。SAFE_PLACEHOLDER不加的话会产生大量误报MyBatis 的#{userId}在正则视角跟字符串拼接毫无二致。这种排除逻辑没有语义理解纯粹靠经验 hardcode。但规则的确定性给了一个好处你加了排除它 100% 生效不像 LLM 那样这次记得下次忘。空 catch单行和跨行两种形态都得覆盖// 写法一catch (Exception e) {}PatternSINGLE_LINE_EMPTYPattern.compile(catch\\s*\\([^)]*\\)\\s*\\{\\s*});// 写法二catch 以 { 结尾向下找下一行非空非注释的行若是 } 则是空块if(content.trim().endsWith({)){for(intji1;jlines.size();j){Stringnextlines.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){booleanduplicateruleFindings.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 侧因为规则 confidence1.0 是确定性判断LLM 的置信度只会更低。规则输出的是规范值sql_injectionLLM 可能写成sql-injectionruleId 对不上去重失败也认了至少不会拿 LLM 版本替换规则版本。行号容差 ±2diff 只有增量行号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())){booleanruleHitSameruleFindings.stream().anyMatch(rf-Objects.equals(rf.filePath(),llmFinding.filePath())Objects.equals(rf.ruleId(),llmFinding.ruleId()));if(!ruleHitSame){// 规则扫过同文件同类型——说没有。LLM 说有 幻觉continue;// 丢弃}}否决范围严格限定——只针对 LLM 使用规则 ruleIdsql_injection、todo_comment等上报的问题。LLM 自造 ruleIdundefined_type、logic_error不受影响因为规则管不到语义领域。语义类幻觉走另一条路。调 prompt 期间观察到 LLM 把 case_004 里的thirdParty对象误判为未定义——其实它是类成员字段只是 diff 片段没展示。这种问题规则管不了靠 prompt 加可证明性约束编译错误类推断涉及 diff 片段之外的符号时只有在同一 diff 片段内能直接证明的编译错误才可报告。几轮 prompt 调整后收敛到了可用状态7 月 26 日的报告回到全绿。评测数据评测框架有 5 个手工构造的用例SQL 注入、吞异常、TODO、缺测试、干净 MR。EvalRunner 调的是全链路reviewWithDiff——规则引擎 LLM 去重 裁决全部走一遍不单独评某个组件。每次评测产出一份日期戳报告存在eval/reports/下三次关键快照日期RecallPrecisionVerdict 一致率说明07-211.001.001.00基线07-231.001.001.00复跑确认稳定07-241.000.831.00case_004 冒出 TODO 幻觉FP07-261.001.001.00幻觉否决 可证明性约束后回绿07-24 那次是值得记住的一次加了规则引擎之后指标反而变差了——不是规则的错是 LLM 的幻觉正好被评测逮住。这恰恰说明评测框架在干它该干的事。单用例协同细节以 07-24 报告为准用例规则LLM结果case_001 SQL 注入sql_injection 命中也报了同一问题去重保留 RULEcase_002 空 catchempty_catch 92遵守指令不重复LLM 注意力释放case_003 TODOtodo_comment 命中遵守指令不重复同上case_004 缺测试no_test 5幻觉报 TODOFP规则正确没报case_005 清洁 MR无无双引擎均无噪声踩过的坑三个坑每个坑都花了至少一轮评测才定位到。裸 hunk 导致规则集体静默首次全链路评测时case_001SQL 注入和 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 hitsN rules[...]这条日志就是因为这次排查花了太多时间。评测标注迁就了 LLMcase_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 可靠性设计
当规则引擎遇上大模型:代码审查系统的双引擎协同实战
当规则引擎遇上大模型代码审查系统的双引擎协同实战引子前段时间写了 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 里看到连续insertupdate逻辑上应该包事务。但Transactional注解在文件头部的类声明上diff 变更片段不展示。不能把没看见当成没有。所以只圈 WARNING 标记嫌疑LLM 或人做最终确认。语言匹配不能让规则跨语言瞎扫。空 catch 等规则声明了applicableLanguage() javaRuleEngine 扫描时自动跳过.go、.ts文件。这个过滤不复杂但很关键——一条 Java 的正则扔到 Go 文件上去匹配结果全是噪声。规则引擎接口薄配置活在数据库里ReviewRule接口就四个方法。多了就是过度设计publicinterfaceReviewRule{StringruleId();// 和 rule 表对应Severityseverity();StringapplicableLanguage();// null 全语言ListReviewFindingapply(DiffFilefile,RuleContextctx);}RuleEngine 通过 Spring 的 List 注入自动收拢所有实现publicRuleEngine(ListReviewRulerules,RuleMapperruleMapper,...){this.ruleImplsrules.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 字段。MapString,StringparamsparseParams(config.getParamsJson());RuleContextctxnewRuleContext(params,files,mr);findings.addAll(rule.apply(file,ctx));rule表几十条记录每次现查开销可以忽略。量上去了加个本地 60 秒缓存改造成本很小。降级策略写得早但真触发过才有底。本地开发时 MySQL 没起来RuleEngine 的 DB 查询抛异常try{enabledRulesruleMapper.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_CONCATPattern.compile(\[^\]*(?i:select|insert\\sinto|update|delete\\sfrom)[^\]*\\\s*\\);// 模式二jdbcTemplate.query(... var) 这类执行方法实参含拼接PatternEXECUTE_CONCATPattern.compile(\\.(executeQuery|executeUpdate|execute|query|queryForObject|queryForList|createQuery|createNativeQuery|prepareStatement)\\s*\\([^)]*\\);// 排除 MyBatis #{} 和 JDBC ? 这两种参数化占位符PatternSAFE_PLACEHOLDERPattern.compile(#\\{|\\?);apply只扫新增行跳过注释和空行每文件最多报 3 条——一个文件几十处拼接刷评论区没有意义。SAFE_PLACEHOLDER不加的话会产生大量误报MyBatis 的#{userId}在正则视角跟字符串拼接毫无二致。这种排除逻辑没有语义理解纯粹靠经验 hardcode。但规则的确定性给了一个好处你加了排除它 100% 生效不像 LLM 那样这次记得下次忘。空 catch单行和跨行两种形态都得覆盖// 写法一catch (Exception e) {}PatternSINGLE_LINE_EMPTYPattern.compile(catch\\s*\\([^)]*\\)\\s*\\{\\s*});// 写法二catch 以 { 结尾向下找下一行非空非注释的行若是 } 则是空块if(content.trim().endsWith({)){for(intji1;jlines.size();j){Stringnextlines.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){booleanduplicateruleFindings.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 侧因为规则 confidence1.0 是确定性判断LLM 的置信度只会更低。规则输出的是规范值sql_injectionLLM 可能写成sql-injectionruleId 对不上去重失败也认了至少不会拿 LLM 版本替换规则版本。行号容差 ±2diff 只有增量行号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())){booleanruleHitSameruleFindings.stream().anyMatch(rf-Objects.equals(rf.filePath(),llmFinding.filePath())Objects.equals(rf.ruleId(),llmFinding.ruleId()));if(!ruleHitSame){// 规则扫过同文件同类型——说没有。LLM 说有 幻觉continue;// 丢弃}}否决范围严格限定——只针对 LLM 使用规则 ruleIdsql_injection、todo_comment等上报的问题。LLM 自造 ruleIdundefined_type、logic_error不受影响因为规则管不到语义领域。语义类幻觉走另一条路。调 prompt 期间观察到 LLM 把 case_004 里的thirdParty对象误判为未定义——其实它是类成员字段只是 diff 片段没展示。这种问题规则管不了靠 prompt 加可证明性约束编译错误类推断涉及 diff 片段之外的符号时只有在同一 diff 片段内能直接证明的编译错误才可报告。几轮 prompt 调整后收敛到了可用状态7 月 26 日的报告回到全绿。评测数据评测框架有 5 个手工构造的用例SQL 注入、吞异常、TODO、缺测试、干净 MR。EvalRunner 调的是全链路reviewWithDiff——规则引擎 LLM 去重 裁决全部走一遍不单独评某个组件。每次评测产出一份日期戳报告存在eval/reports/下三次关键快照日期RecallPrecisionVerdict 一致率说明07-211.001.001.00基线07-231.001.001.00复跑确认稳定07-241.000.831.00case_004 冒出 TODO 幻觉FP07-261.001.001.00幻觉否决 可证明性约束后回绿07-24 那次是值得记住的一次加了规则引擎之后指标反而变差了——不是规则的错是 LLM 的幻觉正好被评测逮住。这恰恰说明评测框架在干它该干的事。单用例协同细节以 07-24 报告为准用例规则LLM结果case_001 SQL 注入sql_injection 命中也报了同一问题去重保留 RULEcase_002 空 catchempty_catch 92遵守指令不重复LLM 注意力释放case_003 TODOtodo_comment 命中遵守指令不重复同上case_004 缺测试no_test 5幻觉报 TODOFP规则正确没报case_005 清洁 MR无无双引擎均无噪声踩过的坑三个坑每个坑都花了至少一轮评测才定位到。裸 hunk 导致规则集体静默首次全链路评测时case_001SQL 注入和 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 hitsN rules[...]这条日志就是因为这次排查花了太多时间。评测标注迁就了 LLMcase_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 可靠性设计