给 PR 接一个 LLM 自动 Review:GitHub Actions 落地踩坑全记录

给 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,要么认命花钱。

误报怎么压

三层:

  1. Prompt 层------"宁漏勿错"写死在指令里
  2. 置信度层------模型输出 confidence 字段,低于 0.7 直接丢
  3. 规则层 ------已知误报 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、做迭代。

相关推荐
一只不会编程的猫2 小时前
Echart 3D环形图
前端·javascript·3d
米开朗积德2 小时前
终于不用看到CSDN该死的弹窗限制了
前端·javascript
网络点点滴2 小时前
Vue组件通信-mitt
前端·javascript·vue.js
王家视频教程图书馆2 小时前
大前端(原生开发的尽头是html css js)
前端·javascript·css
低保和光头哪个先来2 小时前
TinyEditor 篇2:剪贴板粘贴图片并同步上传至服务器
服务器·前端·javascript·css·vue.js
无巧不成书02182 小时前
React Native 深度解析:跨平台移动开发框架(2026实战版)
javascript·react native·react.js
阳火锅2 小时前
AI时代的到来,我想打造这样一款产品。
前端·javascript·vue.js
llxxyy卢2 小时前
polar-web题目
开发语言·前端·javascript
OpenTiny社区2 小时前
不仅是修复 Bug:TinyVue 3.29.0 把“无障碍信息”写进了组件的 DNA 里
前端·javascript·vue.js