
先聊一个现象不少团队的代码评审还是“人在盯”静态检查工具只跑了个摆设规则集是默认的类型标注是稀稀拉拉的AI模型要么没接要么接了也只是把diff丢给模型让它“帮忙看看”。直到我最近把一个内部数据平台的MR评审流程重新搭了一遍才真正摸清了怎么让ruff mypy 模型组成一套可靠的“双轨代码审查”流水线。这套玩法并不是把三个工具简单堆在一起而是要让它们各自咬住自己最擅长的那一环。双轨不是指ruff和mypy两条轨而是“静态工具轨”和“语义模型轨”两条轨工具轨负责把代码格式、导入、类型和明显逻辑错误死死拦住模型轨负责看变更意图、影响面和那些工具规则表达不出来的风险。这篇文章就讲讲我是怎么搭的、配置怎么调、结果怎么汇总以及跑了一个季度之后哪些经验值得保留。1. 为什么要拆成双轨静态规则与AI语义各自管不住的那半边先说结论代码审查这件事天然是两种能力混在一起的工作。一种是“机器能给你确定性答案的问题”比如这个变量没定义、这个函数返回了Optional但调用方没做空值判断、这里import了但没有用。另一种是“需要理解意图才能回答的问题”比如这个字段改成了默认值会不会影响下游依赖方这里把列表改成生成器表达式虽然类型都能过但调用方可能在多处消费这个序列语义变了。第一种能力模型也能做但你让模型去做既慢又贵还容易在简单问题上犯错第二种能力静态工具几乎做不了但模型反而可以给出有价值的判断。这就是双轨的起点不是堆工具而是把问题按“确定性”和“语义性”切开。1.1 单靠ruff和mypy会漏掉哪些问题我见过很多项目ruff和mypy都装了但实际保护效果很弱。原因很简单默认配置太宽松规则没选对严格模式没开。我接手那个数据平台时mypy跑出来的错误数量是零因为项目里大部分函数根本没写类型注解mypy默认情况下对没注解的函数直接跳过。ruff只开了E和F能拦住的只有语法错误和未使用import这类皮毛问题。但即便你把规则集调得很严格静态工具还是有明显的盲区。举个真实的例子某同学在重构时把一个函数的返回值从list[str]改成了Iterable[str]mypy不会报错因为后者是前者的父类型所有调用方依然能通过类型检查。但问题在于原来调用方会把结果直接len()现在拿到的是生成器运行时直接抛TypeError。这种问题写成规则会非常笨拙但人或者模型一眼就能看出来“这个改动破坏了调用约定”。还有更微妙的某个接口的timeout参数从int | None改成了默认值30类型上没问题但所有依赖默认超时行为的调用方行为都被悄悄改变了。这类问题静态工具永远沉默。1.2 单靠AI模型审查为什么会翻车那反过来用模型全覆盖审查行不行我早期试过直接把整个PR的diff丢给模型让它“检查有没有问题”。结果是它确实能发现一些逻辑漏洞但也会产生大量幻觉式建议。比如它会指出一个并不存在的“潜在空指针”会建议把一个已经被模块顶层import的符号再import一次会对格式缩进指手画脚还会在上下文较长的时候漏掉最关键的那几个文件。模型审查还有一个致命问题不一致。同一个改动今天让模型看它说“建议在入口处校验参数”明天同样的代码风格它又说“保持现状即可入口校验由调用方负责”。这种稳定性缺失让开发人员很难把模型意见当成“准绳”。而且每次调用都有成本大diff动辄几万token全部丢给模型费用先不说很多意见根本没法落到具体行号上评审人改起来也无从下手。所以我的结论是模型不该做“什么活都接”的通用审查员它只该做静态工具做不了的那一小块语义推理。1.3 双轨的边界划分工具回答What和Where模型回答Why和When最后给一个可执行的分工原则这也是我后来写进团队审查流程里的约定ruff回答这行代码“是什么状态”——有没有语法错误、样式问题、未使用变量、 import排序是否合规、是否有常见反模式。mypy回答这个值“到底是什么类型”——有没有类型不匹配、有没有不该出现的Any、有没有非空断言。模型回答这个改动“为什么危险、什么时候会出问题”——变更对下游的影响、语义变化、边界条件覆盖、并发场景下的隐患、与注释或文档描述不一致的地方。其中最关键的一步是模型必须能看到mypy的输出结果。为什么因为类型信息就是给模型喂的“事实检查器”。比如模型看到某个函数的入参是dict[str, Any]它大概率会猜测“这里有类型安全问题”但如果它同时看到mypy的严格报告中说“该参数在调用方均为具体类型”模型就会知道自己不该过度解读。反过来mypy报告里标注了大量# type: ignore的区域模型审查时就会优先注意这些“人工强行绕过检查”的位置。这条经验我后面会详细展开。2. 第一轨落地把ruff和mypy调到“能拦住真bug”的强度很多团队的问题不是没装工具而是装了以后配置太温和导致静态轨形同虚设。我建议直接上严格配置但严格不是“全选规则”而是“分层启用”。如果你把所有规则都打开现有存量的几千条告警会把每日新增的问题淹没团队很快会对报告麻木。正确做法是先让存量错误可见但可豁免新增代码必须达到高标准。2.1 ruff的规则集选择要选能抓逻辑错误的规则不要停留在格式ruff不止是格式化工具它的lint能力可以抓很多低级逻辑问题。我最终采用的pyproject.toml大概是这样的[tool.ruff] target-version py311 line-length 88 [tool.ruff.lint] select [ E, # pycodestyle errors W, # pycodestyle warnings F, # pyflakes未定义名、未使用 import、重复赋值 I, # isortimport 排序 UP, # pyupgrade语法现代化 B, # bugbear容易踩坑的反模式 A, # flake8-builtins覆盖内建名的参数 SIM, # 简化表达式的建议 C4, # 可迭代对象的复制方式有误 RUF, # ruff 专用规则 ] ignore [ E501, # 行长度交给 formatter不在这里报 ] [tool.ruff.lint.per-file-ignores] tests/**/*.py [B008, S101]F和B这两类是性价比最高的F抓未定义、未使用和重复导入B抓像try-except里不小心把except写太宽、dict.get后的复合赋值这类容易引发运行时问题的情况。UP和SIM更像是“代码现代化工具”会让代码风格保持一致减少reviewer在样式上的争论。真正值得强调的配置是per-file-ignores。比如测试文件里用pytest.fixture这种函数作为参数默认值会被B008拦下来但这是pytest的常见用法所以对tests豁免。这看起来是小事但它决定了团队对静态轨的信任度——如果工具老是在合理代码上报错大家就会默认忽略它。2.2 mypy的严格开关从“跑了”到“真的拦住类型事故”mypy这块我吃过亏。早期项目里我的配置是[tool.mypy] python_version 3.11 strict true warn_return_any true warn_unused_configs true disallow_untyped_defs true disallow_untyped_calls truestrict true是开启所有严格检查的总开关它会强制要求函数都有类型注解、不允许无法推导的Any随便传播。但直接在全仓库开启strict会有一大堆存量错误。我的做法是先用--non-interactive模式下跑一遍把存量问题记录到mypy_allowlist.txt里mypy src --show-error-codes --allowlist mypy_allowlist.txtallowlist的思路是旧错误先写成豁免名单新代码和新增错误必须保持在名单之外。这样团队不会在巨大的历史债务面前崩溃又能保证每天新增代码的类型安全。这种“存量豁免增量严格”的节奏比一次性修完所有问题更现实也更可持续。还有一个易被忽略的开关是warn_unused_ignores true。这个开关会在某个# type: ignore注释已经不必要时主动报错逼着开发者删掉失效的忽略标记。为什么重要因为失效的ignore就是“我说的谎”你告诉mypy这个错误存在但实际错误已经不存在了代码里就留了一个虚假的脚手架。我清理过一次项目中大概100多处多余的ignore一眼就能看出哪些地方曾经出过事这本身就是很有价值的审查线索。2.3 在CI里让两个工具并行跑但不要让它们互相拆台静态轨在实际流水线上我最开始踩过一个坑CI里直接让ruff check和mypy跑失败就退出结果模型审查根本没机会执行。因为静态轨经常因为存量小问题先把流水线标红等到你要接模型轨时MR已经被标记为失败模型的输出根本不产生意义。所以我现在在CI里分成两步。第一步是这样跑的set e ruff check src tests --output-format sarif ruff.sarif RUFF_EXIT$? mypy src --sqlite-cache --cache-dir .mypy_cache --show-error-codes --output-fmt json mypy.json MYPY_EXIT$? python scripts/aggregate_results.py ruff.sarif mypy.json model_review.json --report review_comment.md REVIEW_EXIT$? exit $((RUFF_EXIT | MYPY_EXIT | REVIEW_EXIT))关键在于每一步失败都记录下来但先不中断等聚合脚本收集完所有报告后再统一决定是否失败。为什么这么设计因为静态工具的告警和模型审查意见需要结合起来看一个change如果静态轨有20个问题模型轨可能只需要重点看其中3个模型的一些高优意见反过来也能帮助开发者优先修静态问题。如果静态轨一失败就退出模型轨永远接触不到这些diff这不符合双轨的“协同”含义。还要注意版本锁定。ruff和mypy的规则集更新得非常频繁隔一个季度再跑可能因为规则升级多出一堆和代码质量无关的告警团队会觉得“审查工具疯了”。我的方案是在CI的约束文件里固定这两个工具的版本每次升级规则、调整排除项都单独走一次MR让影响可见、可控。3. 第二轨落地给模型一套能读懂diff和类型的审查提示词模型轨要解决的核心问题是怎么让模型在有限上下文里做出有效的语义审查而不是泛泛而谈。我实践下来的结论是模型审查的效果70%取决于输入侧的设计只有30%取决于模型本身。很多人直接把整个diff字符串粘进提示词再问一句“看一下有什么问题”这样得到的意见基本没法用。3.1 模型审查的输入设计diff加类型摘要而不是整个仓库我最终采用的输入方案是“diff片段 受影响符号摘要 mypy相关条目”。具体来说一个审查单元由三部分组成该文件的git diff统一上下文行数控制在5行以内减少无关注释的干扰变更涉及的函数签名、所在类、以及这个函数在仓库中被调用的次数和位置摘要mypy对该文件输出的errors、notes以及相关# type: ignore的位置。先看diff片段这部分。大diff要切分我一般限制每个审查请求包含最多200行变更超出就按文件拆成多次请求这样上下文窗口里能容纳更多与变更相关的代码而不是被大段无关的上下文占满。然后是“调用方摘要”。这是关键因为模型如果只看到函数内部的改动它不知道这个函数在外面被怎么用。一个极端的例子函数原来接收list[int]并返回第一个元素新实现改成next(iter(...))如果输入为空列表会抛StopIterationmypy是完全不会报这种语义错误的因为它类型依然合法。但当模型看到“该函数被37处调用且有3处传入的可能为空列表”它就能意识到边界情况没有处理。这类信息要直接喂给模型不要指望模型自己从diff里推导出来。mypy的输出同样是输入的一部分。我自己的做法是让聚合脚本先解析mypy的JSON报告把与本次变更文件相关的错误、Note、ignore标记位置提取出来拼成一段文本Mypy report for src/data_loader.py: - line 128: error: Item None of Optional[Connection] has no attribute execute (union-attr) - line 131: note: revealed type is Any - line 145: ignore comment present, code exists as None check bypass模型看到这些之后就不会在“这里是不是有个Optional空值问题”上重复瞎猜。它会知道如果mypy已经帮你指出了那这条要重点讲的是“为什么会在业务逻辑上引入None的可能性”而不是“这里有类型错误”。这就是我前面提到的“用类型信息约束模型让模型在真实证据上推理”。3.2 审查提示词模板只关注语义风险不要做风格评审模型审查最怕的就是模型把注意力放在风格和格式上。我建议在系统提示词里加一条非常强硬的边界指令。下面是我目前使用的模板核心部分你是一名资深Python代码审查员。你接收的信息包括 1. 一个文件的git diff 2. 变更涉及的函数签名及调用方摘要 3. mypy对该文件输出的类型信息 约束 - 不要评论格式、命名、行长度、import排序这些由静态工具负责。 - 不要给出泛泛的最佳实践建议。 - 只审查语义风险变更导致的调用方行为变化、边界条件遗漏、并发问题、资源释放问题、与类型信息矛盾的逻辑假设。 - 每条审查意见必须定位到具体的文件和行号并说明触发场景什么时候会出问题。 - 区分严重级别critical确定会导致运行时错误或数据错误warn特定条件下可能出错info可维护性风险非阻塞。 输出格式为JSON数组 [ { file: src/data_loader.py, line: 88, severity: warn, category: edge_case, message: 当records为空列表时next(iter(...))会抛出StopIteration, suggestion: 改为 records[0] if records else None 并让调用方处理None } ]把输出格式强制成JSON数组不是为了花哨而是为了让下游聚合脚本能自动解析、去重、排序、映射到MR评论上。如果你让模型生成一段描述性文字聚合就会非常痛苦。我还把temperature调到了0.2避免模型在同样的输入下每次给出不一样的意见这对评审的可信度很重要。3.3 审查覆盖率全量审新增代码抽样审重构类改动模型审查最忌讳“什么都查”。在我这个数据平台项目里我按变更类型制定了不同的审查策略新增文件必须全量审查。因为新文件没有历史包袱语义风险最容易被发现而且行数少成本可控。已有文件的新增分支重点审查新增分支内的逻辑以及该分支返回值对调用方的影响。重构类改动函数提取、循环展开按函数块抽样审查优先选择调用方超过10个的“热点函数”。纯配置、文档、依赖升级不做模型审查静态轨和CI测试覆盖就够了。这个策略直接决定了模型调用的成本。我算过一笔账一个中型MR大概修改30个文件、净增减±300行模型轨的调用次数大约在6到8次每次输入约4000token总体响应时间在2到5分钟。这点耗时放在MR评审环节是可以接受的因为模型轨和静态轨并行跑不拖累开发流程的主链路。真正要控制的是别对老旧大文件做全量审查那种文件往往是几百年来没人动的“边角料”你让模型看一遍纯属浪费。4. 两轨结果汇总从纯流水线到一份可讨论的评审清单双轨流程跑通之后立刻会遇到一个新问题三份报告怎么合并ruff输出SARIFmypy输出JSON或纯文本模型输出我自定义的JSON。如果这三份报告不聚合开发者就必须在三个页面之间来回切换没人愿意看。所以我在CI里加了一个聚合脚本把三份报告整合成一份带优先级的评审清单。4.1 一条审查记录的完整拼装逻辑聚合脚本的核心逻辑是按“文件路径 行号”做载体。所有审查意见最终都要落到具体文件和行上因为代码审查的第一诉求是“能定位”。我对三个来源分别做了归一化ruff的SARIF取results[].locations[].physicalLocation与level再结合ruleId翻译成人类可读的说明。mypy的JSON取file, line, severity, message并明确标记这是“类型错误”。模型的JSON就是我上面格式里的file, line, severity, category, message, suggestion。归一化之后脚本会先把静态轨和模型轨的报告按文件行做笛卡尔积式的“邻近匹配”。比如模型在src/data_loader.py:88报了一个warn恰好mypy在src/data_loader.py:88报了一个类型错误那么聚合脚本会把这两条合并成一条综合意见- file: src/data_loader.py line 88 - 类型错误: Item None of Optional[SQLiteConnection] has no attribute execute - 模型提示: 当表不存在时sqlite_conn可能为None此时execute必然抛异常 当前空值检查在调用前并没有覆盖所有分支 - 建议: 将mysql报错转换为自定义TableMissingError并在调用方做降级处理这种“静态工具给出证据链 模型给出业务触发场景”的组合比任何单独一套报告都更能说服开发者。这就是双轨真正产生化学效应的地方工具轨负责证明“这里有问题”模型轨负责解释“什么时候会出问题为什么值得现在修”。4.2 在MR/PR上做差分和去重聚合脚本还需要解决一个噪音问题模型和静态工具经常在同一个位置报多个相似意见。比如ruff可能对同一行报E501行太长和F401未使用import模型可能对同一行报“建议拆分表达式提高可读性”——这显然是同一个问题。聚合脚本要去重并做优先级合并。我的规则是同一文件同一个逻辑块行号±5行范围内的多条意见只保留最高严重级别的那一条其他作为附件信息折叠进去。然后把合并后的意见按严重级别分组级别定义是否阻断合并critical确定会导致运行时异常、数据损坏或安全风险是阻断MR合并warn特定边界条件下可能出错或明显破坏调用方约定否但要求两个工作日内处理info可维护性建议不影响本次合并否不计入准入门槛这个分级表是跟团队讨论过之后定的核心标准是可执行critical一定是“必然出问题”而不是“万一出问题”。只有这种确定性别人才愿意把合并权限交给自动审查。4.3 给模型意见挂上证据链没有行号的建议直接丢弃聚合脚本在渲染成MR评论时我加了一条硬规则模型意见如果没有精确到具体行号或者经模糊匹配后找不到对应文件位置直接丢弃不显示在最终报告里。这条规则听起来简单但在实际中极大地提升了报告的可信度。模型有时会输出类似“建议为该函数增加输入验证”这种没有任何定位的抽象意见这类意见在评审会上谁都能提AI提出来也没有额外价值反而让开发者觉得“模型在说废话”。所以我在提示词里就写了“每条审查意见必须定位到具体文件和行号”在聚合侧再设一道防线把不符合格式的JSON记录过滤掉。还有一个细节是评论的本地化展示。我通常把最终报告渲染成Markdown表格贴到MR上按文件排序每个文件下只保留合并后的意见清单开头再放一个“本次审查摘要”审查摘要目前 ruff 发现 2 个可修复问题mypy 发现 1 个类型错误 模型发现 1 个 warn 级边界问题。整体评估静态质量达标语义风险可控。摘要的作用不是给人看而是给CI的“准入门槛”用的。我设的自动准入条件是critical为0warn不超过3条new issues相对上一个基线新增不再增加。超过任何一个条件CI就保持红色直到人工确认。5. 一个季度的实践复盘误报、耗时、以及值得长期保留的教训任何审查方案都要经历“老工具信任崩塌”和“新工具盲目信任”两个阶段。我现在把这套双轨流程跑了一个季度多多少少积攒了一些具体的数字和可以复用的经验。单独列出来给准备模仿的团队做一个参考基准也把踩过的坑说得更直白一点。5.1 静态轨的误报控制存量豁免和per-file-ignores静态轨最大的阻力来自存量代码。我一开始把ruff的规则集全开mypy也强行strict结果第一次跑就产生了上千条告警光是F401未使用导入就占了40%。开发者根本不知道从哪改起干脆把审查报告当空气。后来我改成“存量豁免增量严格”的策略才把这个局面扭转过来。具体做法我在前面提过mypy用allowlist豁免老错误ruff用per-file-ignores豁免测试文件和脚本类代码。但这里有一个重要心得豁免名单要放在代码库里并且定期review。我见过很多团队的mypy_allowlist.txt越来越大最后变成了“垃圾场”谁都不去清理。我的方法是每个月由一人专门花半小时看一遍新被加入豁免名单的错误如果发现某个模块连续新增5条以上豁免就说明这个模块的维护方式可能有系统性问题值得单独要一个技术债MR来处理。这种“审查审查者”的节奏能防止豁免名单悄悄变成新债的来源。5.2 模型轨的误报率与成本不稳定但可以通过反馈降低模型轨的误报率比我想象中高尤其是刚接入的第一周。我们手工抽查了50条模型意见真正可执行的只有30条左右准确率大约60%低于我的预期。大部分误报分布在两类一是模型对类型系统的理解不够精确比如它看到一个变量经过函数处理后返回list就猜测“可能有None混入”但实际上调用方在入口处已经做了filter二是模型对项目内部约定不了解比如我们这个平台里的“空列表表示全量拉取”就是一种隐含约定模型看不懂经常会建议改成显式参数。后来我做了两件事把准确率慢慢提了上来。第一件是把项目里的“隐含约定”写进系统提示词的追加上下文里比如“在这个项目中空列表表示全量None表示不传参请勿建议改变该语义”。第二件是建立了人工反馈闭环开发者在MR评论里对模型意见点“同意/不同意”聚合脚本每月汇总一次把“不同意”最多的那几类问题加入提示词的负面列表明确告诉模型“本项目不采用这些建议风格”。经过两个多月的修正模型意见的可用率大约提升到了75%左右虽然不能替代人但已经足够作为“第二道语义防线”来用了。成本方面模型轨的月度费用相当于一顿团队聚餐的规模完全是可控的。真正的成本其实是审查阅读时间——如果模型意见太冗长或者噪音偏高开发者光读就要花很多时间。所以我后来在提示词里又加了一条硬性要求每条意见的消息正文不超过3句话而且必须包含“触发条件”和“建议动作”。这个约束比“你可以多说一点”有效得多因为它逼着模型把话说短说清楚。5.3 三个真正值得长期保留的教训第一不要让模型轨去抢静态轨的活。模型每抓到一个line too long或“未使用的变量”都意味着我们花了模型调用的钱却只完成了本该由静态工具免费完成的事。双轨里工具轨是门槛门槛必须低延迟高覆盖模型轨是纵深纵深必须只在关键处探针式下钻。一个健康团队的审查体验应当是“工具轨快速扫过模型轨只在你最担心的语义处停下来说话”。第二模型审查要带上静态工具的证据而不是让两者各说各话。这一点我在4.1里已经详细展开过mypy报出的类型错误和模型发现的边界问题如果被拼成一条综合意见开发者接受的意愿会大幅提升如果两者是两份互相独立的报告开发者会习惯性跳过模型轨。证据链是模型轨获得信任的核心。第三渐进式落地远比“一步到位”可靠。我一开始确实想把整个仓库的100万行代码全部纳入双轨审查结果不到一周就发现行不通存量技术债太大团队工蜂被淹没。后来我缩小到“新增代码必须过双轨存量代码只过静态轨、且允许豁免”的版本团队立刻就有了正反馈因为新代码质量肉眼可见地提高了老问题也不再天天跳出来干扰视野。等这个节奏稳定之后我才逐步提高存量代码的审查力度。第三个教训说白了就是双轨审查是一件需要让团队感受到“它在帮我兜底”的事如果让它变成“它在报我的旧账”方案再完美也会在实施层死掉。这套流程现在成了我们数据平台MR合入前的标配。往后我还在继续往里加东西比如把历史上真正出过线上事故的修复commit做成极端样本定期让模型对照这类样本做回归增强。等这个反馈集再攒多一些我可以单独写一篇如何“用线上事故样本给模型审查做few-shot增强”的实操记录。在那之前先把这套ruffmypy模型的双轨底子打好已经能让大多数项目少流不少血。