在之前公司实习的时候,参与了小组内GitLab里面嵌入一个AI审查工具;当你每次MR时候,欸,你就会发现,ld不审查你代码了,随之而来的是AI更加严厉的批评,更加专业更加仔细,让你无处可逃;于是我这次也把他嵌入到了我的开源项目里面,狠狠的批阅一下我自己的代码。
一、问题:没人审和审不细
举个例子:仓库推了一个 PR,改了 22 个文件。等了半小时没人 review,自己看了一遍,有个 SQL 拼接没注意,合了。上线后被安全团队找上门。
这种场景不陌生。代码审查有两个老问题:
- 没人审:团队忙的时候 PR 堆半天没人看,阻塞合入
- 审不细:人连续审 5 个 PR,第 6 个基本就在走形式了,diff 翻到第三屏注意力就散了
AI 编码工具(Copilot/Cursor)普及后单 PR 代码量还在涨,但 Reviewer 的注意力带宽没变。于是我自己搭了一个 AI 代码审查机器人------PR 一创建就自动触发,45 秒内把行级评论贴到 Files changed 页面,零手动操作。
它不是资深架构师,更像一个不知疲倦的初级审查员,先把机械性检查(安全漏洞、复杂度、命名)过一遍,人只需要审它搞不定的业务逻辑部分。
先看效果:PR #2 提交后,GitHub Actions 自动触发审查,在 22 个文件上贴了 30 条行级评论:
当你有邮箱,那么你就会收到来自邮箱的提醒

二、整体架构:一条流水线
整个系统就是一条五步流水线:
bash
GitHub PR 事件 (opened/synchronize)
↓
拉 PR Diff (GitHub API: GET /pulls/:number/files)
↓
解析 Diff → 还原「新文件行号 → 代码行」映射
↓
逐文件跑 Skills 审查(规则预检 + LLM 语义分析)
↓
汇总评论 → POST 行级评论到 PR
技术栈极简:Node.js 原生 ESM,零第三方依赖 ,只用了 fetch(Node 18+ 内置)。大模型用 DeepSeek(OpenAI 兼容接口),成本约 0.03 元/PR。
分层设计
| 层 | 职责 | 关键文件 |
|---|---|---|
| Adapter 层 | 和代码托管平台交互(拉 diff、回贴评论) | src/adapters/github.js |
| Parser 层 | 解析 unified diff,还原行号映射 | src/parser.js |
| Skill 层 | 审查规则封装(规则 + LLM prompt) | src/skills/*.js |
| Reviewer 层 | 编排主流程:load → precheck → LLM → filter → post | src/reviewer.js |
为什么要 Adapter 层?因为这套逻辑同样跑在 GitLab 上(我实习时的版本),GitHub 和 GitLab 只是 API 路径和评论参数不同。换平台只需写一个新 Adapter,Skill 层零改动。
三、核心实现
3.1 GitHub Actions 自动触发
触发逻辑写在 .github/workflows/ai-cr.yml,监听 PR 的三个事件:新建(opened) 、推新 commit(synchronize) 、重新打开(reopened)。
yaml
name: AI Code Review
on:
pull_request:
branches: [main, develop]
types: [opened, synchronize, reopened]
permissions:
contents: read
pull-requests: write
jobs:
ai-review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 22 }
- name: Run AI Code Review
working-directory: ai-code-review
env:
ARK_API_KEY: ${{ secrets.ARK_API_KEY }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
node bin/ai-cr.mjs \
--platform github \
--repo ${{ github.repository }} \
--pr ${{ github.event.pull_request.number }} \
--post \
--min-severity warning \
--skills security,complexity
两个关键 token:
GITHUB_TOKEN:GitHub Actions 每次运行自动注入 的临时 token,有pull-requests: write权限就能回贴评论,不需要手动配ARK_API_KEY:DeepSeek 的 API key,需要在仓库 Settings → Secrets 里手动添加一次

3.2 Diff 解析:还原行号是最核心的一步
GitHub API 返回的 patch 是 unified diff 格式,长这样:
diff
@@ -10,7 +10,9 @@ function getUser(id) {
if (!id) return null
const user = db.query('SELECT * FROM users WHERE id = ' + id)
+ const profile = await fetchProfile(user)
+ if (!profile) throw new Error('no profile')
return user
-}
+}
要把评论精准锚定到行,必须搞清楚新文件里每一行对应的行号 。@@ -10,7 +10,9 @@ 的意思是:旧文件从第 10 行开始共 7 行,新文件从第 10 行开始共 9 行。
解析逻辑的核心(src/parser.js):
javascript
for (const raw of diffText.split('\n')) {
if (line.startsWith('@@')) {
const m = line.match(/@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/)
newLine = Number(m[3]) // 新文件起始行号
continue
}
if (line.startsWith('+') && !line.startsWith('+++')) {
// 新增行:记录行号,行号+1
hunk.lines.push({ type: 'add', text: line.slice(1), newLine })
newLine++
} else if (line.startsWith('-') && !line.startsWith('---')) {
// 删除行:在新文件里不存在,newLine 不变
hunk.lines.push({ type: 'del', text: line.slice(1), newLine: null })
} else if (line.startsWith(' ')) {
// 上下文行:行号+1
hunk.lines.push({ type: 'ctx', text: line.slice(1), newLine })
newLine++
}
}
坑点 :GitHub 要求回贴评论时 line 必须落在该文件 diff 的 hunk 范围内,否则返回 422。我们的 changedLines 天然取自 patch,不会越界。
3.3 Skills:规则 + LLM 混合审查
我把审查规则封装成可插拔的 Skill,每个 Skill = { systemRule, precheck }:
precheck:确定性规则校验,用正则或算法硬查,0 成本 0 幻觉systemRule:喂给 LLM 的 prompt 模板,做语义层面的判断
目前实现了三个 Skill:
| Skill | 规则预检做什么 | LLM 做什么 |
|---|---|---|
| 安全审查 | 正则扫 SQL 拼接、硬编码密钥、eval/exec | 语义分析 XSS、命令注入风险 |
| 命名规范 | 正则扫单字母变量、驼峰下划线混用 | 语义判断命名是否清晰、给出具体改名建议 |
| 圈复杂度 | 算法算函数圈复杂度,超 10 报 warning | 补充"怎么拆"的具体建议 |
以安全审查的 precheck 为例(src/skills/security.js):
javascript
const DANGEROUS = [
{
re: /(['"`])\s*(SELECT|INSERT|UPDATE|DELETE|DROP)\b[\s\S]*?\+\s*[A-Za-z_$]\w*\s*/i,
msg: '疑似 SQL 字符串拼接,存在注入风险,建议使用参数化查询',
},
{ re: /api[_-]?key\s*[:=]\s*['"`][^'"`]{4,}['"`]/i, msg: '代码中疑似硬编码密钥,应改用环境变量' },
{ re: /\b(eval|exec)\s*\(/g, msg: '禁止直接使用 eval/exec', skipRegex: true },
]
为什么不用纯 LLM? 三个原因:
- 成本:规则预检免费,90% 的确定性问题直接命中,LLM 只处理剩下 10% 的语义问题,单次 PR 成本从几毛钱降到几分钱
- 稳定性 :LLM 会幻觉------比如把正则定义里的
/eval\(/误判为 eval 调用(后面会讲这个坑),规则是确定性的,能兜底 - 可解释性:开发者看到"正则命中 SQL 拼接"比"LLM 觉得不安全"更有说服力
加新审查点?只需在 src/skills/ 下新建一个文件,导出到 src/skills/index.js,零改动其他代码。这就是 Monorepo 管理 Skills 的思路。
3.4 给 LLM 的 Prompt:行号标记 + JSON 约束
Reviewer 在组装 prompt 时,会给每一行加行号和变更标记,让 LLM 只对变更行给意见:
javascript
const matrix = lines.map((text, i) => {
const no = i + 1
const mark = file.addLines.has(no) ? '+' : file.changedLines.has(no) ? '~' : ' '
return `${String(no).padStart(4)}|${mark}| ${text}`
})
给 LLM 看到的代码长这样:
javascript
40| | function getUser(id) {
41| | if (!id) return null
42|+| const sql = 'SELECT * FROM users WHERE id = ' + id
43|~| const user = db.query(sql)
44|+| const profile = await fetchProfile(user)
+ 是新增行(重点审查),~ 是变更附近上下文,空格是未变更行。Prompt 明确要求"只对新增行给出意见,输出 JSON 数组"。
LLM 返回后做宽松解析(src/llm.js)------先剥离 Markdown 代码块,再找 [ 到 ] 的区间,兼容 LLM 输出 Markdown 包裹或加废话前缀的情况:
javascript
export function parseJsonArray(text) {
if (!text) return []
const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/)
const candidate = fenced ? fenced[1] : text
const start = candidate.indexOf('[')
const end = candidate.lastIndexOf(']')
if (start === -1 || end === -1) return []
try {
return JSON.parse(candidate.slice(start, end + 1))
} catch {
return []
}
}
3.5 回贴行级评论:GitHub API + 限流处理
回贴接口是 POST /repos/{owner}/{repo}/pulls/{pr}/comments,body 长这样:
json
{
"body": "🔴 [严重] 疑似 SQL 字符串拼接,存在注入风险",
"commit_id": "abc123def...",
"path": "src/db.js",
"line": 42,
"side": "RIGHT"
}
commit_id:PR 的 head SHA(从GET /pulls/:number的head.sha获取)side: "RIGHT":锚定在新文件(PR 合并后的版本),不是旧文件
四、三个真实踩过的坑
坑 1:GitHub 422 "was submitted too quickly"
第一次跑回贴时,10 条评论全部 422 失败。查文档没找到限流说明,反复试验发现:GitHub 对同一 PR 的行级评论有隐式速率限制,同一文件连续快速发评论会被拒。
解决方案(src/adapters/github.js):
javascript
// 按文件分组
const byFile = new Map()
for (const c of comments) {
if (!byFile.has(c.file.path)) byFile.set(c.file.path, [])
byFile.get(c.file.path).push(c)
}
for (const [, fileComments] of byFile) {
for (let i = 0; i < fileComments.length; i++) {
// 每条评论间 100ms 延时
await postOne(fileComments[i])
if (i < fileComments.length - 1) await sleep(100)
}
// 文件间 800ms 延时
await sleep(800)
}
再加指数退避重试(遇 422 等 1s→2s→3s),从 0/10 成功变成 10/10 全成功。
坑 2:Security 规则误报------正则定义里的 eval
安全规则用 \beval\s*\( 扫危险函数,但代码里 /eval\(/ 这种正则字面量也会命中:
javascript
// 这行是正则定义,不是 eval 调用,却被误报
const evalRegex = /eval\(.*\)/
解决方案:检查 eval 前后的 / 数量,两侧都是奇数说明在正则里,跳过:
javascript
function isInRegex(line, keyword) {
const idx = line.indexOf(keyword)
const slashBefore = (line.slice(0, idx).match(/\//g) || []).length
const slashAfter = (line.slice(idx).match(/\//g) || []).length
return slashBefore % 2 === 1 && slashAfter % 2 === 1
}
这就是规则+LLM 混合的价值:规则可以精确兜底 LLM 会犯的错。
坑 3:GITHUB_TOKEN fine-grained 权限不足
一开始用 fine-grained token,只勾了 Contents: Read and write,结果创建 PR 和回贴评论都 403。后来发现:
- 开 PR 和回贴评论 需要
Pull requests: Read and write权限 - 推代码 需要
Contents: Read and write权限 - 两者是独立的 Repository permissions
嫌 fine-grained 界面麻烦的同学,直接用 Classic token 勾 repo 作用域一步到位。
五、5 分钟接入你自己的仓库
步骤 1:Copy 代码
把项目里的 ai-code-review/ 目录和 .github/workflows/ai-cr.yml 复制到你的仓库。
步骤 2:配 LLM Key
仓库 Settings → Secrets and variables → Actions → New repository secret:
- Name:
ARK_API_KEY - Secret:你的 DeepSeek API Key(也可以用任何 OpenAI 兼容接口,改
ARK_BASE_URL即可)
步骤 3:开个 PR 试试
随便改几行代码提个 PR,30-60 秒后就能在 Files changed 页面看到 AI 评论。
也可以本地先跑 mock 看效果(不需要任何账号):
bash
cd ai-code-review
npm run review:sample
六、它能做什么,不能做什么
能做的:
- 检测安全漏洞(SQL 注入、硬编码密钥、eval/exec)
- 发现圈复杂度过高的函数,建议拆分
- 检查命名规范、魔法数字、代码风格问题
- 逐文件、逐行精准锚定评论到变更行
不能做的:
- 理解跨文件的复杂业务逻辑(判断不了"已发货"和"已签收"是否写反)
- 判断功能是否符合产品需求
- 替代资深工程师的架构审查
它是第一道过滤,把机械性检查接过去,人只需要集中审业务逻辑和架构设计。
七、后续迭代方向
- 增量审查:目前是对整个文件审查,后续可以只审查 diff 范围内的代码片段,减少 LLM 调用量
- 审查门禁:设置 severity=error 的评论 block 合入,需要开发者回复"已知,暂不修改"才能合并
- 去重评论:同一 PR 多次 push 后,避免重复发相同评论
- Skill 市场:把审查规则做成可共享的包,不同团队可以定制自己的规则集
仓库地址
完整代码已开源,clone 下来 cp .env.example .env 填个 API Key 就能跑:
关键文件索引:
- CLI 入口:
ai-code-review/bin/ai-cr.mjs - GitHub Actions 配置:
.github/workflows/ai-cr.yml - Diff 解析器:
src/parser.js - Reviewer 编排:
src/reviewer.js - GitHub Adapter(含限流处理):
src/adapters/github.js - Skills 示例:
src/skills/security.js/naming.js/complexity.js - LLM 调用 + JSON 宽松解析:
src/llm.js
如果对你有帮助,给个 Star ⭐ 支持一下。有问题欢迎在评论区交流。