用声明式 YAML Schema 驱动 LLM 做 `Code Review` 自动化

10 阅读1分钟

用声明式 YAML Schema 驱动 LLM 做 Code Review 自动化

搞过 GPT-4 或者 Claude 接 PR 自动评审的人应该都经历过这么一个阶段:刚上线的时候全组都觉得酷炫,Bot 在评论区刷刷刷写了一堆"建议",大家还会认真看两眼。三天后?没人理了。

为啥呢,因为它输出的全是正确的废话。

问题不在 LLM 笨不笨。

问题在你压根没告诉它"聪明"长什么样。真正要解的题是:怎么把一个团队脑子里那些零散的、模糊的、每个人标准还不一样的 Code Review 经验,变成一份机器可读的声明式规则集,然后用自动化流水线把评审过程钉死。这就是 OpenSpec 协议在尝试做的事——虽然还在早期阶段,但思路值得拆开来聊聊。

ESLint 到 LLM Prompt:确定性和概率之间的鸿沟

传统 Lint 的能力天花板

ESLint 能帮你检查 === 有没有用、import 顺序对不对、变量声明了没使用。靠谱。给定输入,输出唯一,AST 层面的确定性规则就是这么干脆:

// ESLint 能搞定的 
const x = 1  // no-unused-vars → 直接报错,没什么好商量的

// ESLint 搞不定的 
function processOrder(order: Order) {
  // 这个函数 200 行,支付、库存、通知三件事全揉在一起
  // 你一眼看出来该拆,ESLint 不知道
  // "职责是否单一"这种事不是语法树能算出来的
}

声明式规则集这层到底在干嘛

矛盾很清楚了:LLM 有语义理解力但输出飘忽不定,ESLint 输出稳如老狗但对语义一无所知。OpenSpec 的想法嘛,说白了就是在两者中间插一层用 YAML Schema 写的声明式规则集,把团队里那些口口相传的 CR 标准给形式化。

我个人理解这层 Schema 不是给人类阅读的文档。

最小化的一份规则定义长这样:

# openspec-rules.yaml
version: "1.0"
context:
  language: typescript
  framework: vue3
  project_type: saas

rules:
  - id: no-business-in-composable
    severity: error          # error / warning / info
    scope: "src/composables/**"
    description: |
      composable 只做状态管理和副作用封装,
      不允许包含业务判断逻辑(如价格计算、权限校验)
    detect_pattern: semantic  # 不是正则匹配,是让 LLM 语义判断
    examples:
      bad: |
        // useOrder.ts 里直接算折扣
        const discount = order.total > 100 ? 0.9 : 1
      good: |
        // 折扣逻辑放到 domain service
        const discount = calcDiscount(order)

  - id: async-error-boundary
    severity: warning
    scope: "src/**/*.ts"
    description: |
      所有 async 函数必须有显式错误处理,
      不允许裸 await 无 try-catch 也无 .catch()
    detect_pattern: hybrid    # AST 预筛 + LLM 语义确认

重点看 detect_pattern 这个字段。三种模式:ast 是纯语法树分析,确定性的,跟 ESLint 一个路子;semantic 是纯 LLM 语义判断,灵活但输出不稳定;hybrid 先用 AST 缩小范围再让 LLM 做精确判断。hybrid 是核心打法,后面详细拆。

Hybrid 模式拆解:AST 预筛 + LLM 精判

这是整个方案里最值得聊的部分。

纯靠 LLM 做 review 有两个死穴:一是贵,一个中等规模的 PR 改二十来个文件,光 token 费就是好几美元,一天几十个 PR 下来账单能看哭;二是对简单的语法层面问题,LLM 判断反而不如 AST 靠谱——你让 Claude 帮你数有几个未使用的 import,它真有可能数错。真就离谱。

纯靠 AST 呢,前面聊了,语义层面的判断彻底无能为力。

hybrid 模式的核心思路是做一个漏斗:先用成本低且确定性高的 AST 分析把嫌疑代码圈出来,再用 LLM 对这些嫌疑片段做精准的语义裁决。

全量代码变更(可能 2000 行)
        ↓  AST 预筛
疑似违规片段(可能 80 行)
        ↓  LLM 精判
确认违规(可能 20 行)+ 修复建议

2000 行缩到 80 行再缩到 20 行,token 消耗直接砍掉 90% 以上。来看个具体的——规则是"async 函数必须有错误处理",AST 阶段怎么做预筛:

import * as ts from 'typescript'

function findNakedAwaits(sourceFile: ts.SourceFile): Range[] {
  const suspects: Range[] = []

  function visit(node: ts.Node) {
    if (ts.isAwaitExpression(node)) {
      // 沿着 AST 往上爬,看有没有 try-catch 包着
      let parent = node.parent
      let hasTryCatch = false
      while (parent) {
        if (ts.isTryStatement(parent)) {
          hasTryCatch = true
          break
        }
        parent = parent.parent
      }

      // 也要查是不是 .catch() 链式调用的一部分
      // ...省略细节,思路相同

      if (!hasTryCatch) {
        suspects.push(getRange(node))
      }
    }
    ts.forEachChild(node, visit)
  }

  visit(sourceFile)
  return suspects
}

这一步能把所有"裸 await"精确地从 2000 行 diff 里捞出来。差不多。但裸 await 不代表一定有问题——有的场景就是故意不捕获错误的,因为上层调用方会统一兜底。这时候就需要 LLM 出场了:结合调用方上下文判断这个 await 的错误处理到底是真缺失还是有意为之。

Prompt 模板不是随便糊的

喂给 LLM 的 prompt 得严格结构化,不然输出质量方差大到没法用:

# prompt-template.yaml
system: |
  你是一个前端代码审查专家。
  你只需要判断给定的代码片段是否违反了指定规则。
  输出必须是 JSON 格式。

user_template: |
  ## 规则
  {rule.description}

  ## 违规示例
  {rule.examples.bad}

  ## 合规示例
  {rule.examples.good}

  ## 待审查代码
  ```{language}
  {code_snippet}

上下文(调用方)

{caller_context}

请判断待审查代码是否违反上述规则。 输出格式: { "violated": true/false, "confidence": 0.0-1.0, "reason": "一句话说明", "suggestion": "修复建议(如有)" }


这个模板里藏了几个关键决策。

`confidence` 字段——这个是拿来防抖的。LLM 自己都拿不准的判断(`confidence < 0.7`),自动降级成 `info` 级别的温和提示,不当 `error` 去阻断合并流程。0.7 这个阈值是我们实际跑了两个多月调出来的,太低噪音刹不住,太高有用的发现会被吞掉。(虽然这个数字我觉得不同团队差异会挺大的)

`caller_context` 更关键。不给调用方的代码上下文,LLM 一看到裸 await 就会报问题,根本不知道外面可能已经有个 `try-catch` 兜着了。但是要拿到调用方上下文就得做 call graph 分析,跨文件的调用链追踪——这已经是静态分析的深水区了,实现成本不低,这里不展开了。

### 结果缓存不做会后悔

同一个文件没改过就不该重新跑 LLM。缓存 key 用文件内容的 hash 加上规则版本的 hash 来组合:

```ts
function getCacheKey(file: FileChange, rule: Rule): string {
  return hash(`${file.contentHash}:${rule.id}:${rule.version}`)
}

简单吧?有个坑。hybrid 模式下 LLM 的判断是依赖 caller_context 的,如果调用方代码变了而被调用方本身没变,上面这个缓存 key 不会失效,你就会拿到一个基于过期上下文的判断。解决办法是把 caller_context 的 hash 也塞进 key 里,但这样缓存命中率会断崖式跌落,调用方改一个字缓存就全废了。没有完美方案——工程就是在不完美的选项里选个最不难受的。

踩过的坑和边界场景

Token 预算控制

大 PR 一下子改了 50 个文件,每个文件带 30 行上下文,全量喂给 LLM 的话 token 直接爆表、请求超时、账单起飞——三连暴击。必须做分块和预算控制:

const TOKEN_BUDGET = 8000  // 单次 LLM 调用的 token 上限

function splitIntoChunks(
  suspects: CodeSnippet[],
  budget: number
): CodeSnippet[][] {
  const chunks: CodeSnippet[][] = []
  let current: CodeSnippet[] = []
  let currentTokens = 0

  for (const s of suspects) {
    const tokens = estimateTokens(s.content) // 粗估 1 token ≈ 4 字符
    if (currentTokens + tokens > budget) {
      chunks.push(current)
      current = [s]
      currentTokens = tokens
    } else {
      current.push(s)
      currentTokens += tokens
    }
  }
  if (current.length) chunks.push(current)

  return chunks
}

LLM 幻觉这事是真的

我真见过这种场景:一段完全没问题的代码,LLM 说"此处存在内存泄漏风险",分析得有理有据、头头是道。

防御策略是加一道"代码引用校验"——LLM 输出的 line_range 必须对应真实存在的代码行号,message 里提到的标识符必须在原始 diff 中出现过:

function verifyReviewResult(
  result: ReviewResult,
  originalCode: string
): boolean {
  const lines = originalCode.split('\n')
  if (result.line_range.end > lines.length) return false

  const identifiers = extractIdentifiers(result.message)
  return identifiers.every(id => originalCode.includes(id))
}

百分百防住幻觉?不可能。但能把最离谱的那些过滤掉,比如引用了压根不存在的函数名、报了个超出文件行数的行号——这类一眼假的东西先自动干掉,剩下的让人来判断。

规则之间的隐含依赖

这个坑踩上去才知道疼。规则 A 说"所有 API 调用必须走统一的 request() 封装",规则 B 说"request() 调用必须配置 timeout 参数"。B 天然依赖 A——如果代码压根没用 request() 封装,那 B 的检查就毫无意义。

rules:
  - id: use-request-wrapper
    severity: error

  - id: request-timeout-required
    severity: warning
    depends_on: use-request-wrapper   # A 不通过就跳过 B

depends_on 看着简洁,跑起来可不省心。确实。A 和 B 如果被分发到了不同的 LLM chunk 里并行执行,B 有可能比 A 先拿到结果。所以规则分发层得做拓扑排序——先跑没有依赖的规则,等它们出完结果了再跑有依赖的。跟 webpack 模块依赖解析的思路一回事,规模虽然小很多但坑点是一样的。

接进 CI/CD 和多团队复用

GitHub Actions 里的配置

整套流水线跑在 CI 里,一个最基础的 GitHub Actions 配置长这样:

# .github/workflows/code-review.yaml
name: LLM Code Review
on:
  pull_request:
    types: [opened, synchronize]

jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0    # 需要完整 git 历史来生成 diff

      - name: Run OpenSpec Review
        env:
          LLM_API_KEY: ${{ secrets.LLM_API_KEY }}
          OPENSPEC_CONFIG: ./openspec-rules.yaml
        run: npx openspec-review --diff origin/main...HEAD

      - name: Check Results
        run: |
          if grep -q '"severity":"error"' review-output.json; then
            echo "Code review found errors, blocking merge"
            exit 1
          fi

fetch-depth: 0 这一行极容易忘掉。不加的话 checkout 下来的是 shallow clone,git diff 拿不到基准分支的完整历史,生成的 diff 要么是空的要么残缺不全——然后你就会看到 review bot 在评论区输出"未发现任何问题",团队还以为代码写得好,其实是 diff 压根没读到。

多团队的规则集怎么管

公司有好几个前端团队,规范不完全一样但有公共基础。用"基础规则集 + 团队级覆盖"的分层模式来处理:

# base-rules.yaml(公司级别)
rules:
  - id: no-any-type
    severity: error
    scope: "**/*.ts"
    description: "禁止使用 any 类型,用 unknown 替代后做类型收窄"

  - id: async-error-boundary
    severity: warning
    scope: "**/*.ts"
    description: "async 函数需要错误处理"

---
# team-a-rules.yaml
extends: base-rules.yaml
overrides:
  - id: async-error-boundary
    severity: error            # 团队 A 把这条从 warning 提到 error

rules:
  - id: vue-composable-naming
    severity: warning
    scope: "src/composables/**"
    description: "composable 文件和导出函数必须以 use 开头"

extends + overrides,跟 tsconfig.json 的继承机制一个逻辑。公司级规则更新了团队自动继承,省事。代价是公司级别新加了一条规则如果恰好跟某个团队的自有规则冲突,排查起来会比较头疼——你得在两层甚至三层配置之间来回翻找,确认到底是哪一层把 severity 改成了什么。

一个意外的收获

这套方案跑起来之后有个我没预料到的副作用:团队开始认认真真地讨论"什么样的代码算好代码"了。以前大家 review 的时候标准全在自己脑子里,张三觉得 composable 里写个简单 if/else 没问题,李四觉得绝对不行,各说各话谁也说服不了谁,最后含糊过去拉倒。现在要把标准写成 YAML 规则的 description,模糊地带一下就暴露出来了。"composable 里能不能写 if (loading) return?""单层的条件判断到底算不算业务逻辑?"这些问题必须给出一个 LLM 能理解的明确定义,没法再含糊了。

某种意义上讲,定义规则的过程本身比自动化评审的结果更有价值。

跑了大半年,error 级规则的误判率稳定在 8% 上下,warning 级的大概 15%。数字不算好看,但想想之前的状态——有些 PR 根本没人 review 就直接合了进去——这已经是质变了。能跑就行,慢慢调。