给 PR 接一个 LLM 自动 Review:GitHub Actions 落地踩坑全记录
你提了个 PR。
团队仨 reviewer,一个开会,一个休假,一个已读不回。干等两天,review 回来了------"这个变量名改一下"。
逻辑漏洞?没人看。SQL 注入?想多了。
不是 reviewer 水平不行,是逐行扫 diff 这事本身就反人类。人脑带宽就那么大。
那让大模型干?
要解决的问题
把 LLM 塞进 CI 跑 Code Review,想法挺美。坑也挺多:
- PR diff 几千行,token 塞不下
- 模型幻觉一顿误报,reviewer 被"狼来了"搞麻了
- 怎么只审增量,不是整个仓库
- 安全检测和重构建议的 prompt 能不能共用一套
这不是调个 API 的事。是信息压缩 + prompt 工程 + CI 编排一套组合拳。
架构长这样
markdown
PR 创建/更新
↓
GitHub Actions 触发
↓
┌─────────────────────────┐
│ 1. 拉取增量 diff │
│ 2. 按文件分片 │
│ 3. 构造 prompt │
│ 4. 并发调 LLM │
│ 5. 聚合 + 去重 + 过滤 │
│ 6. 写回 PR Review 评论 │
└─────────────────────────┘
六步,每一步都有坑。
拿增量 Diff
GitHub API 能直接拿 PR 的变更文件列表。别用 REST API 的 diff 接口------返回纯文本 unified diff,解析起来一言难尽。
ts
// 拿 PR 变更文件列表
const { data: files } = await octokit.pulls.listFiles({
owner: 'your-org',
repo: 'your-repo',
pull_number: prNumber,
per_page: 100, // 超过 100 个文件要分页
})
// file 对象结构:
// {
// filename: 'src/auth/login.ts',
// status: 'modified', // added | modified | removed | renamed
// patch: '@@ -10,6 +10,8 @@\n ...', // unified diff 片段
// additions: 12,
// deletions: 3,
// }
// 只管有实际代码变更的文件,lock 文件和构建产物跳过
const reviewable = files.filter(f =>
f.status !== 'removed' &&
!f.filename.match(/\.(lock|min\.js|map|snap)$/) &&
!f.filename.startsWith('dist/')
)
patch 字段就是增量 diff。不用 clone 仓库,不用跑 git diff。
不过 patch 有大小限制。超大文件 GitHub 会截断,得 fallback 到 git diff。
分片------Token 是硬约束
一个 PR 改了 30 个文件,全拼一起扔给模型?
爆了。
就算没爆,上下文太长模型也会走神------long context 中间段落注意力衰减,"lost in the middle",这个问题挺多论文聊过。
ts
interface DiffChunk {
filename: string
patch: string
language: string
tokenEstimate: number
}
function splitIntoChunks(files: DiffChunk[], maxTokens = 3000): DiffChunk[][] {
const chunks: DiffChunk[][] = []
let current: DiffChunk[] = []
let currentTokens = 0
for (const file of files) {
// 单文件就超限 → 独占一个 chunk
if (file.tokenEstimate > maxTokens) {
chunks.push([file])
continue
}
if (currentTokens + file.tokenEstimate > maxTokens) {
chunks.push(current) // 满了,切一刀
current = [file]
currentTokens = file.tokenEstimate
} else {
current.push(file)
currentTokens += file.tokenEstimate
}
}
if (current.length) chunks.push(current)
return chunks
}
3000 token 一个 chunk。留 1000 给 system prompt,留 1000 给输出,加起来在 Claude/GPT-4 甜区里。不是拍脑袋定的------调过几轮才稳定在这个数。
太大的文件?按函数级别再切。用 AST?太重。正则按 function/class 关键字切就够了,Code Review 不需要编译级精度。
Prompt 工程------整条链路最值得花时间的地方
垃圾 prompt 进去,垃圾 review 出来。
别写"请帮我 review 这段代码"。太泛。模型会吐一堆"建议添加注释""变量命名可以更清晰"------正确的废话。
ts
const SYSTEM_PROMPT = `你是一个资深代码审查员。只关注以下三类问题:
1. **Bug 风险**:空指针、竞态、边界溢出、类型不安全
2. **安全漏洞**:注入(SQL/XSS/命令)、敏感信息泄露、权限校验缺失
3. **可维护性硬伤**:重复代码超过 10 行、圈复杂度过高、接口设计不合理
不要提出以下建议(这些是 linter 的活):
- 命名风格
- 缺少注释
- import 顺序
- 格式问题
输出格式:
\`\`\`json
[{
"file": "文件路径",
"line": 行号,
"severity": "error" | "warning" | "info",
"category": "bug" | "security" | "maintainability",
"message": "一句话说明问题",
"suggestion": "修复代码(可选)"
}]
\`\`\`
如果没有发现问题,返回空数组 []。
不要编造问题。宁可漏报,不要误报。`
三个设计决策:
一,明确告诉模型"别管什么"。 比告诉它"要管什么"管用。不写这条,一半输出都是 lint 噪音。
二,强制 JSON。 下游要解析、要写 GitHub 评论、要按行定位。自由文本没法自动化。
三,"宁可漏报,不要误报"。 这句是整个 prompt 里最值钱的。大模型天然倾向于多说,你不压它,每个 any 类型都给你标 error。reviewer 三天就关了这 bot。
并发调用 + 去重
片分好了,prompt 也有了,开始调 API。
ts
async function reviewChunks(chunks: DiffChunk[][]): Promise<ReviewComment[]> {
// 并发但限流,别把 rate limit 打爆
const limiter = new Bottleneck({ maxConcurrent: 3, minTime: 500 })
const results = await Promise.all(
chunks.map(chunk =>
limiter.schedule(() => callLLM(chunk))
)
)
return dedup(results.flat())
}
function dedup(comments: ReviewComment[]): ReviewComment[] {
const seen = new Set<string>()
return comments.filter(c => {
const key = `${c.file}:${c.line}:${c.category}`
if (seen.has(key)) return false
seen.add(key)
return true
})
}
3 个并发,500ms 间隔。这个数是踩了几次 Claude API rate limit 之后调出来的。
去重拿 file + line + category 当 key,同行同类问题只留一条。
写回 PR
GitHub PR Review API 有两种评论模式:单条(createReviewComment)和整体(createReview)。
用整体。一次提交不会疯狂刷通知。
ts
async function postReview(prNumber: number, comments: ReviewComment[]) {
if (comments.length === 0) {
await octokit.pulls.createReview({
owner, repo, pull_number: prNumber,
event: 'APPROVE',
body: '🤖 LLM Review: 未发现明显问题。',
})
return
}
await octokit.pulls.createReview({
owner, repo, pull_number: prNumber,
event: 'COMMENT', // 别用 REQUEST_CHANGES
body: `🤖 发现 ${comments.length} 个潜在问题`,
comments: comments.map(c => ({
path: c.file,
line: c.line,
body: formatComment(c),
})),
})
}
用 COMMENT 不用 REQUEST_CHANGES。
这点很关键。LLM 判断不是 100% 准,REQUEST_CHANGES 会卡 merge 流程。bot 的定位是辅助,不是守门人。搞反了团队会恨死你。
Actions Workflow
yaml
name: LLM Code Review
on:
pull_request:
types: [opened, synchronize] # 新 PR 和新 push 都触发
jobs:
review:
runs-on: ubuntu-latest
permissions:
pull-requests: write
contents: read
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # 完整历史,不然 diff 算不了
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Run LLM Review
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: node dist/review.js ${{ github.event.pull_request.number }}
fetch-depth: 0 容易漏。不写就是 shallow clone,diff 跑不了。
安全审计单独跑一轮
通用 review 和安全检测别混。
ts
const SECURITY_PROMPT = `你是安全审计专家。只检查以下问题:
1. SQL 注入:字符串拼接 SQL?用了参数化查询没?
2. XSS:用户输入直接插 DOM?转义了没?
3. 命令注入:exec/spawn 拼了用户输入?
4. 敏感信息泄露:hardcoded secret/密码/API key?
5. 路径遍历:文件操作校验路径了没?
6. SSRF:用户可控的 URL 请求?
只报 80% 以上把握的问题。
JSON 格式输出,category 固定 "security"。`
为什么分开?
安全检测需要不一样的"人格"。通用 review 要克制,安全审计要敏感,两种倾向塞一个 prompt 会互相打架。另外安全问题 severity 要统一拉高,后面过滤逻辑也不同。
几个绕不开的设计取舍
Fine-tune?不搞。
Fine-tune 要大量标注好的 Code Review 数据。哪来?开源项目的 review 评论质量参差不齐,跟你的业务风格也对不上。Prompt engineering 加通用大模型,性价比高得多。模型升级你直接受益,fine-tune 了旧模型,新版出来得重新训。
AST 做精确分片?不搞。
AST 依赖语言。仓库里 TypeScript、Python、Go、YAML 都有------每种配一个 parser?维护成本扛不住。文本级按 token 分片粗糙是粗糙,但够用。
成本算一下。
中等 PR,改 15 个文件,diff 约 500 行。5 个 chunk,每个约 3000 token 输入 + 500 输出。再加一轮安全审计。拿 Claude Sonnet 算:
bash
通用 review:5 × (3000 × $0.003 + 500 × $0.015) = $0.082
安全审计: 5 × (3000 × $0.003 + 500 × $0.015) = $0.082
总计:约 $0.16 / PR
一天 20 个 PR,一个月 $96。
PR 经常几千行的话......要么拆 PR,要么认命花钱。
误报怎么压
三层:
- Prompt 层------"宁漏勿错"写死在指令里
- 置信度层------模型输出 confidence 字段,低于 0.7 直接丢
- 规则层 ------已知误报 pattern 加白名单,比如 test 文件里的
eval
调好之后误报率能压到 15% 以下。不完美,但比裸奔强。
做成可插拔的
别把逻辑全糊在一个脚本里。
ts
// 审查管道,每一步可替换
interface ReviewPlugin {
name: string
prompt: string
filter?: (files: DiffChunk[]) => DiffChunk[]
postProcess?: (comments: ReviewComment[]) => ReviewComment[]
}
const plugins: ReviewPlugin[] = [
{
name: 'general',
prompt: GENERAL_PROMPT,
},
{
name: 'security',
prompt: SECURITY_PROMPT,
filter: files => files.filter(f => !f.filename.includes('__test__')),
},
{
name: 'sql-review',
prompt: SQL_PROMPT,
filter: files => files.filter(f => f.language === 'sql' || f.patch.includes('query')),
},
]
async function runPipeline(files: DiffChunk[]): Promise<ReviewComment[]> {
const allComments = await Promise.all(
plugins.map(async plugin => {
const target = plugin.filter ? plugin.filter(files) : files
if (target.length === 0) return []
const chunks = splitIntoChunks(target)
const raw = await reviewChunks(chunks, plugin.prompt)
return plugin.postProcess ? plugin.postProcess(raw) : raw
})
)
return dedup(allComments.flat())
}
要加个"国际化检测"?写个 plugin 就行,不碰主流程。中间件式的管道架构,各管各的。
踩坑
行号对不上。
diff 里的行号是 hunk 偏移量,不是文件绝对行号。得自己算映射。
ts
// @@ -10,6 +10,8 @@ 意思:
// 旧文件第 10 行起 6 行 → 新文件第 10 行起 8 行
// 评论要用新文件行号(+侧)
function parsePatchLineMap(patch: string): Map<number, number> {
const map = new Map<number, number>()
let newLine = 0
for (const line of patch.split('\n')) {
const hunkMatch = line.match(/^@@ .+\+(\d+)/)
if (hunkMatch) {
newLine = parseInt(hunkMatch[1])
continue
}
if (line.startsWith('+') || line.startsWith(' ')) {
map.set(newLine, newLine)
newLine++
}
// '-' 开头是删除行,不占新文件行号
}
return map
}
GitHub API 的 line 只接受 diff 里存在的行。 模型报了个不在 diff 范围的行号?422。写回之前得校验。
模型偶尔不返回 JSON。 prompt 写得再严格,总有 1% 概率它给你一段纯文本。加 try-catch,解析失败跳过这个 chunk,别让整条 pipeline 挂了。
不适合的场景
代码不能外发的。 金融、医疗那种合规要求,不能把代码丢第三方 API,得自建模型。成本翻 10 倍起。
超大 monorepo。 一个 PR 200 个文件 5000 行 diff,分片数爆炸,调用费飙升,评论多到没人看。这种先解决"PR 太大"的问题。
团队不买账。 reviewer 对 bot 每条建议都反复验证,那它不是省时间,是加活。信任得慢慢攒------先跑一个月 COMMENT 模式让大家看看准确率,再考虑要不要接进 CI 必过检查。
反馈闭环
跑起来不算完,得知道它干得咋样。
ts
// 每条评论带 👍/👎 reaction
// 定期统计
async function collectFeedback(prNumber: number) {
const reviews = await octokit.pulls.listReviews({ owner, repo, pull_number: prNumber })
const botReview = reviews.data.find(r => r.user?.login === 'github-actions[bot]')
if (!botReview) return
const comments = await octokit.pulls.listReviewComments({ owner, repo, pull_number: prNumber })
const botComments = comments.data.filter(c => c.pull_request_review_id === botReview.id)
for (const comment of botComments) {
const reactions = await octokit.reactions.listForPullRequestReviewComment({
owner, repo, comment_id: comment.id,
})
const thumbsUp = reactions.data.filter(r => r.content === '+1').length
const thumbsDown = reactions.data.filter(r => r.content === '-1').length
await db.insert({ commentId: comment.id, thumbsUp, thumbsDown, category: '...' })
}
}
thumbs down 多的 category,说明那类 prompt 得调了。
跑几个月回头看数据会发现:安全类检测准确率最高,pattern 明确嘛;重构建议争议最大,"好代码"这事本来就主观。准确率低的 plugin 降 severity 或者直接关掉。这套东西最后值不值钱,不取决于模型多强,取决于你愿不愿意持续看数据、调 prompt、做迭代。