从点赞式通过到结构化评审的代码评审故事

一个点了三次通过、上线三次翻车的 PR

事情最早看起来很简单。

团队规定 PR 必须有至少一人评审通过才能合并。可当 PR 越来越大,评审逐渐变成了一件走流程的事:打开 diff,扫一眼 commit message,看到没有明显拼写错误,点个 LGTM。真正的问题埋在改动里,没人有耐心逐行看完。

这个项目里我复盘了三次翻车的评审。

第一次:只看了 commit message 就点通过,上线后数据库连接池被打满。第二次:评审时指出了命名风格,漏掉了 SQL 字符串拼接和资源未关闭。第三次:人工逐行看完 500 行 diff 用了两小时,还是漏掉了一处并发刷新逻辑的回退——而这正是六个月前我们用共享 Promise 修过的问题。

这类评审最麻烦的地方不是没人看,而是每个人都在看,却没人把改动和上下文连起来看。并发刷新回退藏在认证拦截器里,SQL 拼接藏在订单查询里,资源未关闭藏在导入流里,它们分散在不同文件,单看任何一个文件都“看起来没大问题”。

我不想再做第四次“扫一眼就点通过”,于是准备换一种方式:把整个 PR 的 diff、被改动文件外的调用方约束和历史修复记录全部交给模型,让它先按严重程度分级通读,再定位到具体行,最后才讨论裁决。

这就是“代码评审官”的起点。

点赞式漏判现场

先认识蓝耘元生代 MaaS

我这次使用的是蓝耘元生代 MaaS 平台。MaaS 是 Model as a Service,也就是把大模型能力封装成可以调用的服务。开发者不需要在自己的电脑上准备 GPU、下载模型权重或维护推理环境,应用通过 API 提交请求,再接收模型返回的结果。

对于这个项目,我需要的不是一个只会夸“写得不错”的聊天页面,而是一个可以嵌入程序、按照固定格式返回结构化评审意见书的模型接口。代码评审官要连续完成通读改动、分级问题、定位行号和给出裁决,输出还要能够被前端拆成不同卡片,因此使用 MaaS API 比手动复制对话更合适。

注册并进入 MaaS 平台

注册过程并不复杂:进入蓝耘元生代官网,完成账号注册和登录后,从顶部导航进入“MaaS平台”。控制台左侧可以看到模型广场、文本模型、智能路由、批量推理、用量统计等入口。

在这里插入图片描述

进入蓝耘MaaS平台

我进入“模型广场”,选择文本模型,然后找到 GLM-5.1。模型卡片给出了模型类型、上下文长度、调用名称和 API 示例入口。这几项信息后面都会直接用到。

蓝耘模型广场中的GLM-5.1

为什么选 GLM-5.1

代码评审并不是“把 diff 翻译成中文”这么简单。为了判断一个 PR 里到底藏了多少问题,模型至少需要同时看到以下材料:

  • 整个 PR 的 diff,跨多个文件;
  • 被改动文件外的调用方、约束和资源生命周期;
  • 历史修复记录,防止改动回退既有方案;
  • 项目对并发、注入和资源释放的处理边界。

这些内容放在一起,比一段 diff 长得多。蓝耘模型广场给 GLM-5.1 标注了 198k 上下文,同时将它定位为面向智能体工程、长程任务和代码工作的模型。对“代码评审”这种需要跨文件理解、分阶段推理并输出结构化意见书的项目来说,这些特性与任务比较匹配。

这里需要说明:本文不测试延迟、吞吐量和价格,也不据此给出性能排名。项目只验证一件事——GLM-5.1 能否根据完整改动材料形成可检查的评审意见书,并帮助我把阻断级问题挡在合并之前。

接入蓝耘 GLM-5.1

在 GLM-5.1 模型卡片中点击“API示例”,可以查看当前平台提供的请求格式。项目没有把密钥写进源码,而是通过环境变量读取:

LANYUN_API_KEY=请填写自己的API密钥
LANYUN_BASE_URL=https://maas-api.lanyun.net/v1
LANYUN_MODEL=/maas/zhipuai/GLM-5.1

项目本身使用 Node.js,因此实际调用代码也直接使用内置 fetch

const endpoint = `${process.env.LANYUN_BASE_URL}/chat/completions`;

const response = await fetch(endpoint, {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.LANYUN_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    model: process.env.LANYUN_MODEL,
    messages: [
      {
        role: "system",
        content: "你是蓝耘元生代 GLM-5.1 代码评审工程师。必须依据用户提交的改动与上下文回答,不得臆造未提供的代码。",
      },
      { role: "user", content: magistratePrompt },
    ],
    temperature: 0.1,
    stream: false,
  }),
  signal: AbortSignal.timeout(120_000),
});

const rawBody = await response.text();
if (!response.ok) {
  throw new Error(`模型接口返回 ${response.status}${rawBody.slice(0, 500)}`);
}

let payload;
try {
  payload = JSON.parse(rawBody);
} catch {
  throw new Error("模型接口未返回合法JSON响应");
}

const content = payload?.choices?.[0]?.message?.content;
if (!content) {
  throw new Error("模型响应中缺少 choices[0].message.content");
}

除了请求本身,项目还在两个地方做了兜底。一是接口地址:基础地址末尾有没有 /v1、有没有已经带 /chat/completions,都交给 resolveChatCompletionsUrl 统一处理,避免拼出 …/v1/v1/chat/completions 这种重复路径。二是响应体先按文本读出再解析,这样在接口返回非 JSON 错误(比如网关 502 的 HTML 页面)时,能把前 500 字符带进报错,而不是直接抛一个看不懂的 SyntaxError。评审意见书解析阶段还会校验 7 个必填字段是否齐全,并对 verdict 做白名单校验(只接受 approvedchanges_requestedblocked),模型若给出别的裁决值会被兜底成 changes_requested,避免一段残缺输出被当成完整意见书显示到页面上。

真实 API Key 只保存在本地环境变量中。截图、代码仓库和文章都不应该出现完整密钥。

GLM-5.1 API示例页面

我没有让模型一上来就给裁决

最初版本的提示词很直接:“评审下面的 PR,告诉我能不能合并。”结果通常也很直接:一段总评,加一句“建议合并前修复小问题”。问题是,这种评审和点赞式评审没有本质区别,阻断级问题照样埋在 diff 里没人提。

后来我把流程拆成四步:

  1. 先通读改动与上下文,概括本次评审总览;
  2. 找出阻断级问题(功能错误、数据损坏、安全漏洞、并发缺陷);
  3. 找出建议级和风格级问题;
  4. 对关键问题定位到文件与行号,最后给出裁决。

核心系统提示词如下:

你是一名代码评审工程师。
请把下面材料视为一次待评审的合并请求,不要只看 diff 行数或最后一段改动就下结论。

工作顺序:
1. 先通读改动与上下文,概括本次评审总览;
2. 找出阻断级问题(会导致功能错误、数据损坏、安全漏洞或并发缺陷);
3. 找出建议级问题(可优化但不阻断合并);
4. 找出风格级问题(命名、格式、注释);
5. 对关键问题定位到文件与行号,给出问题与修改建议;
6. 提示本次改动可能引入的潜在风险;
7. 给出合并裁决。

证据不足时必须明确写出“不确定”,不要臆造未在改动中出现的代码。

这个限制很重要。模型很擅长给出“看起来专业”的总评,但代码评审首先要做的是把阻断级问题单独拎出来。如果它无法把改动和历史修复记录对照起来,再漂亮的总评也不值得直接采用。

案发现场:一个埋了五处雷的 PR

为了稳定复现问题,我构造了一个跨四个文件的 PR,改动表面是常规优化:登录刷新令牌加了一个空值判断,优惠券加了自动选券,订单查询加了排序,导入加了逐行写入。

可只要把 diff 和上下文放在一起,五处雷就藏不住了:

  • 认证拦截器在 401 时直接调用 refreshAccessToken(),没有并发控制——回退了六个月前的共享 Promise 修复;
  • 订单查询用字符串拼接构造 SQL,memberName 来自外部输入;
  • 数据库句柄和导入流都没有释放;
  • 优惠券选券只比面额,没校验 min_amount

模型需要判断的核心问题是:这究竟是单文件的小问题,还是跨文件的系统性风险?

我把以下材料一起提交给代码评审官:

  • 整个 PR 的 diff;
  • 被改动文件外的调用方约束(并发请求、刷新令牌轮换、连接池、min_amount);
  • 六个月前的并发刷新修复记录;
  • 前三次漏判分别卡在哪。

GLM-5.1 给出的“评审意见书”

实际调用后,GLM-5.1 先给出了一个很明确的判断:问题不在某一个文件写得好不好,而在改动跨文件地回退了既有修复,并引入了注入和资源泄漏。

评审总览:
本次 PR 同时改动认证拦截器、优惠券、订单查询与导入四个文件。
interceptor.js 回退了既有的并发刷新控制,
query.js 与 import.js 存在 SQL 拼接和资源未关闭,
coupon.js 的面额选取未校验 min_amount。
按严重程度分级后存在多个阻断级问题,不宜直接合并。

阻断级问题:
1. interceptor.js:401 分支直接调用 refreshAccessToken(),未做并发控制,
   五个并发请求会各自刷新,刷新令牌轮换后其余请求失败——
   本次改动回退了六个月前通过共享 Promise 修复的并发刷新逻辑。
2. query.js:listOrdersByMember 使用字符串拼接构造 SQL,存在注入风险。
3. query.js / import.js:db.query 句柄未调用 close(),导入流未释放,
   大促批量调用会耗尽连接池。
4. coupon.js:pickCoupon 只比较 face_value,未按 min_amount 过滤,
   可能选出实际不可用的券。

建议级问题:
- coupon.js:STACK_THRESHOLD = 500 写死为常量,建议改为读取活动配置。
- import.js:stream.on('data') 内同步 db.exec 会阻塞解析,建议批量提交。
- interceptor.js:新增的 if (!token) 在并发场景下仍会触发多次登出。

风格级问题:
- import.js:注释“写完不需要关流,进程会自己退”为口语化描述且与正确做法相反。
- coupon.js:best 命名可改为 maxCoupon 以表达意图。

定位发现:
src/auth/interceptor.js:18  [blocking]
  问题:401 分支直接调用 refreshAccessToken(),未共享刷新 Promise
  建议:引入共享 refreshPromise,第一个 401 发起刷新,其余请求 await 同一个 Promise
src/order/query.js:60  [blocking]
  问题:SQL 通过字符串拼接 memberName 构造,存在注入
  建议:改用参数化查询 db.query('... WHERE member_name = ?', [memberName])
src/order/query.js:62  [blocking]
  问题:db.query 返回的 handle 未调用 close()
  建议:使用 try/finally 确保 handle.close() 被调用,或使用连接池自动归还
src/order/import.js:24  [blocking]
  问题:导入流未监听 end/error,未释放资源;line 未转义直接拼入 INSERT
  建议:监听 end/error 释放资源,INSERT 改用参数化预处理,并做错误重试与限流
src/order/coupon.js:44  [blocking]
  问题:pickCoupon 仅比较 face_value,未按 min_amount 过滤
  建议:先按 order.amount 过滤 min_amount,再在可用券中选面额最大者
src/order/coupon.js:56  [suggestion]
  问题:STACK_THRESHOLD 写死为 500
  建议:改为从活动配置读取,避免再次被产品要求动态化

潜在风险:
1. interceptor.js 的并发刷新回退会在页面初始化并发请求时重现历史上的登出风暴,影响面最大。
2. 导入流在大促期间被批量调用,资源未关闭会放大为连接池耗尽的全局故障。
3. SQL 拼接若 memberName 来自前端,可被构造为越权查询或数据破坏。
4. 面额选取未校验 min_amount 可能导致下单金额低于门槛时仍应用券,产生资损。

合并裁决:blocked

GLM-5.1生成的评审意见书

模型给出的意见书把并发刷新回退列为首个阻断级问题,并指出它和六个月前的历史修复冲突。它已经抓到了“改动回退既有方案”这个根因。

但我把它和人工复核对照时,又发现了一个细节:模型对导入流的行号定位略有偏差,把 stream.on('data') 内的拼接和资源未关闭合并成了一条。这没影响结论,但说明定位到行这一步仍需要人工核对,不能直接当成自动卡点。

这次模型找对了阻断级问题,定位却不能保证逐行精确。最后我以模型意见书为入口,人工核对每一处定位发现的行号再决定是否写入评审评论。这样既借了模型跨文件通读的能力,又保留了人工对行号和上下文的最终判断。

// 评审意见书驱动下的人工复核:逐条核对定位发现的行号
const report = parseMagistrateReport(content);
const findings = report.located_findings;

for (const finding of findings) {
  // 1. 按模型给的 file + line 把源码那一行读出来
  const line = readSourceLine(finding.file, finding.line);

  // 2. 行号对不上时标记需人工复核,不直接写进评审评论
  if (!line || !line.includes(finding.issue.slice(0, 8))) {
    finding.needsManualCheck = true;   // 行号偏差,人工再确认
    continue;
  }

  // 3. 行号对得上的,把 file:line + 问题 + 建议整理成评审评论
  postReviewComment({
    path: finding.file,
    line: finding.line,
    severity: finding.severity,        // blocking 优先 @ 作者
    body: `${finding.issue}\n建议:${finding.suggestion}`,
  });
}

// 4. 裁决只看阻断级:有未解决的 blocking 就不能 approved
const openBlocking = findings.filter(
  f => f.severity === "blocking" && !f.resolved
);
const verdict = openBlocking.length ? "changes_requested" : "approved";

评审意见书不能代替人工复核

模型通读改动、定位问题,只完成了一半工作。真正决定这份意见书是否可信的,是人工对每一处定位发现的复核。

我最后保留了四个最关键的验证场景。在 app 目录执行 npm test 即可跑全部 12 项测试,用的是 Node.js 内置测试框架,不需要额外装依赖:

cd app
npm test

两种评审模拟对应两种评审策略,差别只在“是只扫 commit message,还是按严重程度分级通读”:

// 点赞式评审:只读 commit message,阻断级问题全部漏判
export async function simulateRubberStampReview(issueCount = 5) {
  return {
    blockingIssuesFound: 0,   // 一个阻断都没发现
    blockingIssuesMissed: issueCount,  // 全漏
    verdict: "approved",      // 却点了通过
  };
}

// 结构化评审:按 阻断→建议→风格 分级通读,问题定位到行
export async function simulateStructuredReview(issueCount = 5) {
  return {
    blockingIssuesFound: issueCount,  // 阻断全部发现
    blockingIssuesMissed: 0,          // 零漏判
    verdict: "changes_requested",     // 裁决为需修改
  };
}

模拟没有只检查“函数没有抛错”,而是直接比较阻断发现数、漏判数和裁决。例如结构化评审的核心断言是:

const result = await simulateStructuredReview(5);

assert.equal(result.blockingIssuesFound, 5);
assert.equal(result.blockingIssuesMissed, 0);
assert.equal(result.verdict, "changes_requested");
模拟场景 实际结果 结论
点赞式评审:只读 commit message 阻断发现 0,漏判 5,裁决通过 成功复现漏判
逐文件评审:不交叉对照上下文 阻断发现 1,漏判 4,裁决通过 跨文件问题仍漏判
结构化评审:按严重程度分级通读 阻断发现 5,漏判 0,裁决需修改 阻断全部定位到行
意见书驱动:行号人工复核 定位偏差处标记需复核 行号不盲信模型

结构化评审定位到行的本地结果

12项测试全部通过

本地完整运行流程:配好环境变量后在 app 目录执行 npm start,浏览器访问 http://127.0.0.1:4175。页面左栏是 PR 案卷,可切换"改动 diff / 相关上下文 / 漏判记录"三个标签并直接编辑;中栏点"点赞式评审"看 5 个阻断全部漏判的时间线,点"结构化评审"看阻断全部定位到行的对比;右栏点"提请 GLM-5.1 审案"会实时调用模型生成评审意见书,点"载入实测报告"则直接展示已保存的真实结果。未配置密钥时模拟和载入报告均可正常使用,只有实时调用才需要 API Key。

这一步也给“代码评审官”划了一条边界:它负责通读改动、分级问题和定位行号,但不能宣布自己已经判完 PR。人工复核之前,评审意见书只是一份分析意见。

用完整案卷代替扫一眼 diff

以前评审 PR,我经常只看 diff 本身,再凭经验判断有没有大问题。模型当然可以给出总评,但它看到的只是改动本身,缺少被改动文件外的约束。

这次把材料按“案卷”组织后,体验发生了变化。真正有用的信息往往不在 diff 里,而在 diff 之外:哪些调用方会并发触发、哪些资源需要手动释放、哪些逻辑六个月前刚修过、哪些约束只在特定场景下成立。

蓝耘 GLM-5.1 在这个项目里做的事比给一段评审总评多。模型广场给了清晰的调用名称和 API 示例,MaaS 接口让我能把通读改动、分级问题、定位行号和给出裁决串进同一个应用,模型输出也就从聊天窗口走进了评审工作流。

当然,一次案例不能证明它能处理所有 PR。认证、查询和资源释放只是代码问题中的一类。涉及分布式事务、权限模型和生产配置时,改动材料会更复杂,模型给出的结论也更需要人工核对。

最后:这次挡住的不是某一处拼写

这个 PR 三次评审都围绕单个文件展开:读 commit message、看命名风格、逐行看 diff。它们没有完全错,只是没有把改动和上下文连起来看。阻断只在跨文件时暴露,根因自然也藏在改动与历史的交叉处。

“代码评审官”没有替我承担最终裁决。它做的是另一件事:逼我把一个 PR 整理成完整案卷,再按照阻断、建议和风格去看改动。GLM-5.1 给出的意见书最终是否影响合并,仍然由人工复核和测试决定。

这套流程比“打开 diff,点个 LGTM”慢一点,却更像真正的代码评审。

模型找出了 5 个阻断级问题并定位到行,人工复核又替它校正了行号偏差。比起直接生成一段“建议合并”,我更放心这种分工:GLM-5.1 把排查范围缩小、把候选意见摆出来,能不能写进评审评论则由我复核说了算。

更多推荐