我把 AI 程式碼審查器接進 CI,當時覺得自己很聰明,結果忙碌的一週過後,看到了帳單。每一次 push、每一次 commit,都把完整的 diff 送到大型前沿模型。費用累積的速度超乎預期,而且大多數審查的內容其實只是格式調整與 README 編輯,根本不需要天才等級的模型來閱讀。因此我將審查流程改成兩層,成本大幅下降,同時也沒有漏掉真正重要的問題。
核心概念很簡單:不要把所有東西都送到昂貴的模型。改用廉價模型作為守門員,判斷哪些內容值得升級,再把真正可能造成傷害的檔案保留給大型模型(並開啟快取)。
第一層:廉價分類過濾
第一層只看 diff,並回答一個問題:「這裡是否有任何內容風險高到需要真正審查?」這屬於分類任務,分類不需要使用前沿模型。
我把這層放在小型本地模型上執行。在我自己的機器上使用 Ollama 搭配 qwen2.5-coder,但在 CI 中可以使用自架的小型模型,或是供應商提供的最便宜雲端方案。這個步驟的任務是分類,而不是判斷。
分類提示刻意設計得很窄。我不會要求模型進行審查,而是要求它進行路由:
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
小型模型非常適合判斷「這是否觸及驗證或金流」之類的問題。當它回報 low 時,CI 會只貼出一行「未偵測到高風險變更」並停止,不會呼叫昂貴的模型。實際上,這能跳過大多數 PR,因為多數 PR 確實只是文件、測試與管線調整。
第二層:大型模型,但只針對高風險檔案,並搭配快取
當第一層判定為 high 時,高風險檔案會被升級給強大的模型。這才是真正進行審查的地方,而這也是提示快取發揮作用的地方。
關鍵在於,你傳給大型模型的大多數內容在每次審查之間並不會改變。系統提示(你的審查標準、嚴重性定義、內部規則)每次都相同。而你的倉庫上下文(周遭檔案、慣例、被修改程式碼所依賴的介面)在許多觸及相同區域的小型 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 經常觸及相同模組的倉庫中,快取命中率很高,大型模型每次審查的成本主要只落在新的 diff token 上。
GitHub Actions 草稿
把這些串接起來並不複雜。一個 job、兩個步驟、條件式升級:
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
第二個步驟的 if: 就是整個成本控制的關鍵。低風險 PR 永遠不會觸及昂貴的模型。高風險的 PR 才會獲得完整處理。
純 diff 審查會遺漏的問題
以下是很多人會犯錯的地方,也是為什麼單純的 AI 審查器會讓你產生錯誤的安全感。如果你只傳送 diff,模型無法看到跨檔案的 bug。
假設某個 PR 改變了函式的回傳型別或其中一個參數的意義。diff 本身看起來沒問題。審查器只看到這些行,就通過了。但另外三個檔案呼叫了這個函式,現在卻沿用舊的假設,而這些檔案根本不在 diff 裡。這個 bug 是真實存在的,而且很嚴重,純 diff 審查在結構上就看不到它。
低成本的解法是把被修改函式的呼叫者也納入你傳送的上下文。你不需要傳送整個倉庫,只需要傳送被修改的符號,以及所有使用它們的程式碼:
# 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 的成本控制中也使用了類似的做法:先用快速的本地步驟決定哪些值得仔細且昂貴地查看,然後只在真正重要的地方進行昂貴的查看。在本地執行 qwen2.5-coder 作為分類層,代表這個階段在我的硬體上基本上是免費的。
要避免的失敗模式是把廉價層當成審查器。它不是。它只是守門員。如果你的分類模型開始撰寫審查評論,代表你把困難的工作交給了錯誤的工具。保持第一層範圍狹窄、第二層提供呼叫者與快取上下文,帳單就能維持低水位,同時也能捕捉到真正重要的問題。
如果你在 CI 中執行 AI 審查,你是只傳送裸 diff,還是會把被修改內容的呼叫者也拉進來?
0 Comments
Log in to join the conversation.No comments yet. Be the first to share your thoughts.