从三次翻车到零漏判:我把GLM-5.1训练成了“死板”的代码评审官

一个点了三次通过、上线三次翻车的 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平台”。控制台左侧可以看到模型广场、文本模型、智能路由、批量推理、用量统计等入口。


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

为什么选 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 做白名单校验(只接受 approved、changes_requested、blocked),模型若给出别的裁决值会被兜底成 changes_requested,避免一段残缺输出被当成完整意见书显示到页面上。
真实 API Key 只保存在本地环境变量中。截图、代码仓库和文章都不应该出现完整密钥。

我没有让模型一上来就给裁决
最初版本的提示词很直接:“评审下面的 PR,告诉我能不能合并。”结果通常也很直接:一段总评,加一句“建议合并前修复小问题”。问题是,这种评审和点赞式评审没有本质区别,阻断级问题照样埋在 diff 里没人提。
后来我把流程拆成四步:
- 先通读改动与上下文,概括本次评审总览;
- 找出阻断级问题(功能错误、数据损坏、安全漏洞、并发缺陷);
- 找出建议级和风格级问题;
- 对关键问题定位到文件与行号,最后给出裁决。
核心系统提示词如下:
你是一名代码评审工程师。
请把下面材料视为一次待评审的合并请求,不要只看 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

模型给出的意见书把并发刷新回退列为首个阻断级问题,并指出它和六个月前的历史修复冲突。它已经抓到了“改动回退既有方案”这个根因。
但我把它和人工复核对照时,又发现了一个细节:模型对导入流的行号定位略有偏差,把 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,裁决需修改 | 阻断全部定位到行 |
| 意见书驱动:行号人工复核 | 定位偏差处标记需复核 | 行号不盲信模型 |


本地完整运行流程:配好环境变量后在 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 把排查范围缩小、把候选意见摆出来,能不能写进评审评论则由我复核说了算。
更多推荐



所有评论(0)