我把 AI 代码审查接入 CI,感觉自己很聪明,然后在一个忙碌的 PR 周后看了看账单。每一次 push、每一次 commit,都把完整 diff 发送给大型前沿模型。费用增长得比我预想的要快,而且它审查的大多是格式改动和 README 编辑,这些并不需要天才级模型来阅读。于是我把它重构成了两层,成本大幅下降,同时也没漏掉真正需要关注的问题。

思路很简单:不要把所有内容都发给昂贵模型。使用廉价模型作为“守门员”,决定哪些内容值得升级,再把大型模型(开启缓存)留给真正可能造成伤害的文件。

第一层:廉价的 triage 阶段

第一层查看 diff 并回答一个问题:这里是否有任何内容风险高到值得进行真正审查?这是一个分类任务,分类不需要前沿模型。

我用小型本地模型运行这一层。在我自己的机器上使用 Ollama + qwen2.5-coder,但在 CI 中可以使用自托管的小模型,或是提供商提供的最便宜的云层。这个阶段的任务是分流,而不是判断。

triage prompt 故意写得很窄。我不让它做审查,而是让它做路由:

You are a triage filter for code review. For the diff below, output JSON only:
{ "risk": "low" | "high", "reason": "<one short phrase>" }

Mark "high" if the diff touches: auth, access control, money/token math,
cryptography, SQL or shell string building, deserialization, file paths,
network calls, or anything that changes who can do what.
Mark "low" for formatting, comments, docs, tests, renames, and config bumps.

DIFF:
<diff here>

Enter fullscreen mode Exit fullscreen mode

小型模型完全能胜任“是否触及 auth 或 money”的判断。当它输出 low 时,CI 仅贴一行“未检测到高风险变更”就停止。不再调用昂贵模型。实际中这能跳过大多数 PR,因为大多数 PR 确实只是文档、测试和基础改动。

第二层:仅对高风险文件使用大型模型,并开启缓存

当第一层判断为 high 时,高风险文件才会被升级到强模型。这里才是真正的审查发生的地方,而 prompt caching 在此发挥作用。

诀窍在于,你发送给大型模型的大部分内容在多次审查之间是不会变化的。系统提示(审查标准、严重程度定义、家规)每次都相同,而仓库上下文(周边文件、约定、变更代码所依赖的接口)在触及同一模块的多次小 PR 中也是稳定的。对这些稳定的前缀做缓存,意味着你只需为它们支付一次全价,之后每次复用时只需支付很小的增量费用。

因此我把请求结构化为:缓存的系统提示、缓存的仓库上下文,最后才是新鲜的 diff。Anthropic 风格的缓存以稳定前缀为键,所以顺序很重要:稳定的内容在前,易变的内容在后。

# pseudocode, provider-agnostic shape
response = client.messages.create(
    model="big-model",
    system=[
        {
            "type": "text",
            "text": REVIEW_RUBRIC,            # never changes
            "cache_control": {"type": "ephemeral"},
        },
        {
            "type": "text",
            "text": repo_context_for(files),  # changes rarely
            "cache_control": {"type": "ephemeral"},
        },
    ],
    messages=[
        {"role": "user", "content": build_review_prompt(diff)},  # changes every time
    ],
)

Enter fullscreen mode Exit fullscreen mode

审查标准和仓库上下文走缓存。每次只有 diff 是新鲜的。在大量小 PR 都触及相同模块的仓库中,缓存命中率很高,大模型每次审查的成本主要只剩新鲜 token。

GitHub Actions 示意

把这些串起来并不复杂。一个 job、两个 step、条件升级:

name: ai-review
on: pull_request

permissions:
  contents: read
  pull-requests: write

jobs:
  review:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683  # v4.2.2
        with:
          fetch-depth: 0
          persist-credentials: false

      - name: Triage (cheap model)
        id: triage
        run: |
          git diff origin/${{ github.base_ref }}...HEAD > diff.patch
          python scripts/triage.py diff.patch > triage.json
          echo "risk=$(jq -r .risk triage.json)" >> "$GITHUB_OUTPUT"

      - name: Deep review (big model, cached)
        if: steps.triage.outputs.risk == 'high'
        env:
          MODEL_API_KEY: ${{ secrets.MODEL_API_KEY }}
        run: python scripts/deep_review.py diff.patch

Enter fullscreen mode Exit fullscreen mode

第二个 step 上的 if: 就是整个成本控制的关键。低风险 PR 永远不会触达昂贵模型,高风险 PR 才获得完整处理。

diff-only 审查会遗漏的问题

这里是很多人容易出错的地方,也是天真的 AI 审查器会给人虚假自信的原因。如果你只发送 diff,模型就看不到跨文件的 bug。

比如某个 PR 修改了函数的返回类型或某个参数的含义。diff 本地看起来没问题。审查者只看到这些行,就批准了。但有三个其他文件调用该函数,现在它们传入的假设已经过时,而这些文件根本不在 diff 里。这个 bug 是真实且严重的,而 diff-only 审查在结构上对此是盲的。

廉价的修复方法是把被修改函数的调用者也包含到你发送的上下文中。你不需要发送整个仓库,只需要发送被修改的符号以及所有触及它们的文件:

# find who calls the functions changed in this PR
changed_funcs=$(git diff origin/main...HEAD \
  | grep -oP '^\+.*function \K\w+' | sort -u)

for fn in $changed_funcs; do
  grep -rl "$fn(" src/ >> caller_files.txt
done
sort -u caller_files.txt

Enter fullscreen mode Exit fullscreen mode

你把这些调用者文件喂给第二层的上下文(这部分会被缓存,因为调用者变化缓慢)。现在当一个变更改变了接口时,模型就能看到依赖旧行为的代码,并标记出不匹配。这一个改动就能捕获纯 diff 审查永远看不到的一类 bug,而且因为调用者是稳定的,第一次缓存之后几乎不产生额外成本。

这真正带来了什么

经济性来自路由,而不是让廉价模型去做困难的工作。小模型是过滤器,过滤既便宜又容易。大模型做真正的工作,但只针对值得做的那一小部分,并且只对变化的 diff 支付全价的 token,而审查标准和上下文则走缓存。

我在 spectr-ai 的成本控制中也使用了类似思路:先用快速本地 pass 决定什么值得仔细、昂贵的查看,然后只在真正重要的地方做昂贵的查看。使用 qwen2.5-coder 在本地做 triage 层,意味着这个阶段在我已经拥有的硬件上几乎是免费的。

需要避免的失败模式是把廉价层当作审查者。它不是。它是守门员。如果你的 triage 模型开始写审查评论,说明你把困难的工作交给了错误的工具。保持第一层窄、第二层用调用者和缓存上下文充分喂养,账单就能保持很小,同时真正的问题也能被抓住。

如果你在 CI 中运行 AI 审查,你是只发送裸 diff,还是会把变更函数的调用者也拉进来?