如何在 VPS 自架 AI PR 程式碼審查代理
在您控管的 VPS 執行 AI PR 審查代理,涵蓋自架 runner、diff 範圍提示、路徑與大小篩選、內嵌留言,以及每個 PR 的實際成本。
自架 PR 程式碼審查代理的用途
自架 PR 程式碼審查代理是在自有伺服器上執行的小型程式。它會讀取 pull request (PR) 的 diff,並只將變更的行傳送給模型。模型回傳的結果會以內嵌審查留言發布。它不會 checkout 您的分支,也不會讀取 pull request 未變更的檔案。它持有的憑證只有一組模型 API(application programming interface)金鑰,以及一組只能新增留言、無法執行其他操作的 token。
模型能夠讀取 diff。這部分已經解決。真正重要的是 diff 會傳送到哪裡,以及金鑰由誰持有。使用代管式審查機器人時,每個私有儲存庫的 diff 都會離開您的網路,進入第三方的日誌,並受其資料保留政策管理。使用您擁有的 VPS(virtual private server)時,diff 會從 GitHub 傳到您的伺服器,再傳送至模型 API;您也能檢視決定傳送哪些內容的 40 行程式碼。
開始前的準備
- 一台執行 Ubuntu 24.04 的 VPS,且已將 自架 GitHub Actions runner 註冊至該 repository。註冊時請額外指定標籤
pr-review,因為下方的 workflow 會依據該標籤選取 runner。 - 從 Claude Console 取得的 Anthropic API key。
- 一個可控管誰能建立 pull request 的 repository。private repository 是較簡單的情況。下方的 fork 章節會說明 public repository 的情況,但其答案較不理想。
在 VPS 上安裝 reviewer
runner 服務會以您執行 ./svc.sh install 時建立的非特權帳號執行。請以相同帳號安裝 reviewer,讓工作不必使用 sudo 即可執行 reviewer。將下方的 runner 替換為您的帳號名稱。
sudo apt update && sudo apt install -y gh python3-venv
sudo install -d -m 755 -o runner -g runner /opt/pr-review
sudo -u runner python3 -m venv /opt/pr-review/venv
sudo -u runner /opt/pr-review/venv/bin/pip install anthropic
gh --version截至 August 2026,gh --version 在 Ubuntu 24.04 上會輸出 gh version 2.45.0。2.20 以上的任何版本都具備下方使用的 --input 旗標。若出現 Command 'gh' not found 訊息,表示尚未啟用 universe 元件,因此請執行 sudo add-apt-repository universe 後重試。
金鑰與 token 的存放位置
兩個 secret,生命週期不同。兩者都不應放入 repository。
ANTHROPIC_API_KEY 是 repository secret,請在 Settings、Secrets and variables、Actions 中設定。GitHub 會加密該 secret,並在執行階段將其注入步驟的環境。它不會成為磁碟上的檔案,也不會出現在 git 歷史記錄中。
GITHUB_TOKEN 的運作方式不同。Actions 會為每個 job 產生新的 token,並在 job 結束時銷毀它。該 token 可執行的操作由 workflow 中的 permissions: 區塊設定,因此最小權限原則會在這裡實際生效:
permissions:
contents: read
pull-requests: write該 token 可以發布 review,但不能 push commit、merge branch、編輯 workflow 檔案,也不能操作其他 repository。能夠留言的 agent 是 reviewer。能夠 push 的 agent 是 committer,而沒有人同意授予這項權限。模型金鑰也應採取相同程度的保護,因為它會使用你帳戶中的資金。關於這類問題的更多內容,請參閱 讓 secret 不落入 AI agent 的存取範圍。
Actions 會在 job log 中將完全相同的 secret 字串替換為 ***。它只比對完全相同的字串,因此,若你將金鑰進行 base64 編碼、拆成兩行,或逐字元輸出,內容就會以明文顯示。不要加入會傾印環境變數的 debug 步驟。
為何來自 fork 的 pull request 永遠看不到你的 API key
GitHub 的規則很簡單:除了 GITHUB_TOKEN 之外,工作流程若由 fork 的 repository 觸發,secret 不會傳遞給 runner。因此,來自 fork 的 pull_request 執行時,腳本啟動後沒有 ANTHROPIC_API_KEY,第一次 API 呼叫就會因 invalid x-api-key 而失敗。
看似合理的修正方式,是將觸發條件改為 pull_request_target。這會在 base repository 的內容環境中執行,也能取得 secret。但這裡不要這樣做。GitHub 自己的安全性指南指出,這類工作流程「具有特殊權限,也就是會與其他具特殊權限的工作流程觸發方式共用主分支的相同快取,並且可能具備 repository 寫入權限及存取所參照 secret 的權限」,而其結果「可能被利用來接管 repository」。
同一份指南也明確說明 runner 的風險:「Self-hosted runner 幾乎不應用於 GitHub 上的公開 repository,因為任何使用者都能對該 repository 建立 pull request,進而入侵其環境。」
因此需要採取兩項設計。Job 會加入防護條件,只在推送至你自己的 repository 的分支上執行。工作流程也完全沒有 actions/checkout 步驟。Agent 不會將該分支放在磁碟上,因此惡意 pull request 只會以文字形式傳送給模型。它無法在你的 VPS 上執行 build script,因為你的 VPS 根本不會執行該 script。不過,文字不代表沒有風險:陌生人撰寫的 diff 是傳入模型的不受信任輸入,這與你 讓 agent 執行網路搜尋 時面對的是相同的信任邊界;這裡唯一的限制是,該 agent 只能發表 comment。
取得差異內容,而不是儲存庫
一次請求即可取得完整差異內容的純文字。
export GH_TOKEN=your_token # in the workflow this comes from secrets.GITHUB_TOKEN
gh api /repos/OWNER/REPO/pulls/42 -H "Accept: application/vnd.github.diff"Accept: application/vnd.github.diff 媒體類型會將回應從描述 pull request 的 JSON 物件轉為統一差異內容本身,而 gh api 會原樣輸出該回應本文。您看到的第一行應以 diff --git a/ 開頭。出現 gh: Not Found (HTTP 404) 表示該 token 無法查看儲存庫;對 fine-grained personal token 而言,幾乎總是因為未啟用 Pull requests 權限。
在花費 token 前先篩選
本節決定機器人是會被人閱讀,還是會被人靜音。以下每項篩選都會在模型看到任何位元組前執行。
- 路徑篩選。 排除鎖定檔案、vendor 目錄、minified bundle 和產生的程式碼。模型對
package-lock.json的評論完全是雜訊,而這些檔案通常佔 diff 中大部分的位元組。 - 大小上限。 超過上限時跳過審查並以成功狀態結束。對於 4,000 行的重構,應誠實說明內容過大,無法自動審查,而不是產生 60 則猜測。
- 嚴重性門檻與評論上限。 回報 high 和 medium 等級的問題,最多 10 則,並依嚴重性由高至低排列。沒有人會閱讀第 11 則評論。
指令碼
將此內容儲存為 /opt/pr-review/review.py。它會從環境變數讀取設定,因此工作流程可以變更模型,而不必修改程式碼。
#!/usr/bin/env python3
"""Review only the changed lines of one pull request."""
import json
import os
import subprocess
import sys
import anthropic
REPO = os.environ["GITHUB_REPOSITORY"]
PR = os.environ["PR_NUMBER"]
MODEL = os.environ.get("REVIEW_MODEL", "claude-haiku-4-5-20251001")
MAX_DIFF_BYTES = int(os.environ.get("MAX_DIFF_BYTES", "120000"))
MIN_SEVERITY = os.environ.get("MIN_SEVERITY", "medium")
MAX_COMMENTS = 10
RANK = {"low": 0, "medium": 1, "high": 2}
SKIP = ("package-lock.json", "poetry.lock", "/vendor/", "/node_modules/", ".min.js")
raw_diff = subprocess.run(
["gh", "api", f"/repos/{REPO}/pulls/{PR}",
"-H", "Accept: application/vnd.github.diff"],
check=True, capture_output=True, text=True,
).stdout依檔案拆分 diff,才能進行路徑篩選。為每一行編號,才能讓審查留言定位到正確位置。GitHub 只接受發布在 diff 中行列的內嵌留言,因此模型必須引用實際的行號。將行號交給模型,它就能直接複製,而不必自行捏造。
def per_file(diff_text):
"""Split a unified diff into one string per file."""
sections, current = [], []
for line in diff_text.splitlines():
if line.startswith("diff --git ") and current:
sections.append("\n".join(current))
current = []
current.append(line)
if current:
sections.append("\n".join(current))
return sections
def annotate(section):
"""Prefix every line that exists in the new file with its line number."""
out, n, in_hunk = [], 0, False
for line in section.splitlines():
if line.startswith("@@"):
n = int(line.split("+")[1].split(",")[0].split(" ")[0])
in_hunk = True
out.append(line)
elif not in_hunk or line.startswith(("-", "\\")):
out.append(line)
else:
out.append(f"{n}\t{line}")
n += 1
return "\n".join(out)
kept = [s for s in per_file(raw_diff)
if not any(p in s.split("\n", 1)[0] for p in SKIP)]
payload = "\n".join(annotate(s) for s in kept)
if not payload.strip():
print("every changed file was filtered out")
raise SystemExit(0)
if len(payload) > MAX_DIFF_BYTES:
print(f"diff is {len(payload)} bytes, over the {MAX_DIFF_BYTES} cap")
raise SystemExit(0)區塊標頭包含行號資訊。@@ -12,7 +12,9 @@ 表示新檔案的區塊從第 12 行開始,因此計數器從這裡開始,且只在新增行與未變更行上遞增。刪除行不標示行號,因為它們不存在於新檔案中。針對反斜線開頭行的防護條件,會略過 git 在檔案結尾寫入的無換行標記;否則該標記會讓後續所有行號偏移 1。
兩個結束狀態都使用 0,而不是 1。經過篩選或過大的 pull request 應顯示綠色檢查結果。人員無法處理的紅色檢查結果會被忽略,而一旦忽略其中一個檢查結果,所有檢查結果都會被忽略。
SYSTEM = (
"You review one pull request diff. Every line that exists in the new file is "
"prefixed with its line number and a tab character. "
"Report only defects you can see in the lines shown: a crash, a resource leak, "
"a security mistake, a wrong boundary condition, a broken contract with code "
"that is visible in this diff. Do not comment on style, naming or formatting. "
"Do not guess about code you cannot see. Leave out anything you are not "
"certain about. An empty findings list is a normal and common answer. "
'Reply with JSON only, in this shape: {"findings": [{"path": "src/app.py", '
'"line": 42, "severity": "high", "comment": "what is wrong, then why"}]} '
"Every line number must be one you can see in the left column of that file."
)
client = anthropic.Anthropic()
message = client.messages.create(
model=MODEL,
max_tokens=2000,
system=SYSTEM,
messages=[{"role": "user", "content": payload}],
)
print(f"stop={message.stop_reason} in={message.usage.input_tokens} "
f"out={message.usage.output_tokens}", file=sys.stderr)
text = message.content[0].text
findings = json.loads(text[text.find("{"):text.rfind("}") + 1])["findings"]
findings = [f for f in findings if RANK.get(f["severity"], 0) >= RANK[MIN_SEVERITY]]
findings.sort(key=lambda f: -RANK.get(f["severity"], 0))
del findings[MAX_COMMENTS:]
if not findings:
print("nothing above the severity threshold; posting no comment")
raise SystemExit(0)
review = {
"event": "COMMENT",
"body": f"Automated review of the changed lines. {len(findings)} finding(s).",
"comments": [
{"path": f["path"].removeprefix("b/"), "line": f["line"], "side": "RIGHT",
"body": f"**{f['severity']}** {f['comment']}"}
for f in findings
],
}
subprocess.run(
["gh", "api", "-X", "POST", f"/repos/{REPO}/pulls/{PR}/reviews", "--input", "-"],
input=json.dumps(review), text=True, check=True,
)該區塊中有三個關鍵細節。JSON 會截取第一個 { 與最後一個 } 之間的內容,因為模型有時會將答案包在程式碼區塊中,而 json.loads 無法處理這種包裝。path 開頭的 b/ 會被移除,因為該前綴來自 diff 標頭,而 GitHub 需要的是相對於 repository 的路徑。--input - 會將整份審查內容以一次 API 呼叫送出,因此 10 個發現會合併為一則通知,而不是 10 則通知。
沒有需要回報的內容時,指令碼不會發布任何內容。每個 pull request 都由 bot 留下「找不到問題」,會讓人們養成快速略過留言的習慣,最後連真正重要的留言也會略過。
將它接入工作流程
將以下內容儲存為 .github/workflows/pr-review.yml:
name: pr-review
on:
pull_request:
types: [opened, synchronize, reopened]
paths-ignore:
- '**.md'
- 'docs/**'
permissions:
contents: read
pull-requests: write
concurrency:
group: pr-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
review:
if: github.event.pull_request.head.repo.full_name == github.repository && !contains(github.event.pull_request.labels.*.name, 'no-ai-review')
runs-on: [self-hosted, linux, pr-review]
steps:
- name: Review the changed lines
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
PR_NUMBER: ${{ github.event.pull_request.number }}
REVIEW_MODEL: claude-haiku-4-5-20251001
MIN_SEVERITY: medium
run: /opt/pr-review/venv/bin/python /opt/pr-review/review.pyGITHUB_REPOSITORY 不在該 env: 區塊中,因為 Actions 已經為每個工作設定它。concurrency 群組會影響費用:沒有這項設定時,將三個快速修正推送到分支會執行三次完整審查,三次都會產生費用;加入後只會保留最後一次。
if: 這一行有兩個作用。前半段會略過來自 fork 的 pull request,因為沒有 key 時這類請求本來就會失敗。後半段提供團隊一個停用開關:將 no-ai-review 標籤加到 pull request,工作就不會執行。
開啟 pull request,觀察執行結果:
gh run list --workflow=pr-review.yml --limit 3
gh run view --log
gh pr view 42 --comments如果執行在幾秒內完成,且日誌中出現 nothing above the severity threshold; posting no comment,表示運作正常。對於小型且乾淨的 pull request,這就是預期結果。
自動化 pull request 審查的成本是多少?
diff 幾乎就是全部輸入,因此 diff 的大小會決定價格。以下是包含 system prompt 的實測 500 行 diff,使用 token 計數端點計算,而不是估算。
The data behind this chart
[
{
"label": "Haiku 4.5",
"input_tokens": "8,000",
"output_tokens": "1,200"
},
{
"label": "Sonnet 5",
"input_tokens": "10,400",
"output_tokens": "1,560"
},
{
"label": "Opus 5",
"input_tokens": "10,400",
"output_tokens": "1,560"
}
]在 Haiku 4.5 上,這份 diff 產生 8,000 個輸入 token;在 Sonnet 5 上則產生 10,400 個。文字完全相同,但計數不同。Claude 4.7 以上的模型使用較新的 tokenizer,同一份輸入產生的 token 大約多 30%;Anthropic 已在定價頁面說明這項差異。僅比較每百萬 token 的價格時,將較新模型與較舊模型比較,務必將此因素納入考量。
截至 August 2026 的牌價如下:Haiku 4.5 的輸入 token 每百萬個 $1,輸出 token 每百萬個 $5。Sonnet 5 在持續至 31 August 2026 的上市優惠期間,價格分別為 $2 與 $10;之後為 $3 與 $15。Opus 5 的價格分別為 $5 與 $25。
The data behind this chart
[
{
"label": "Haiku 4.5",
"cost_per_pr_cents": 1.4,
"cost_200_prs_usd": "2.80"
},
{
"label": "Sonnet 5",
"cost_per_pr_cents": 3.64,
"cost_200_prs_usd": "7.28"
},
{
"label": "Opus 5",
"cost_per_pr_cents": 9.1,
"cost_200_prs_usd": "18.20"
}
]在 Haiku 4.5 上,每個 pull request 的成本為 1.4 cents;在 Opus 5 上則為 9.1 cents。每月合併 200 個 pull request 的團隊,在 Haiku 4.5 上每月約支付 $2.80,在 Sonnet 5 上為 $7.28,在 Opus 5 上為 $18.20。自 1 September 2026 起,Sonnet 5 這一列的金額需乘以 1.5。
實際帳單可能高於上述估算,主要有兩個原因。synchronize trigger 會在每次 push 時執行審查,因此有 8 次 push 的活躍分支會產生 8 次審查;而 concurrency 規則只有在多次 push 的時間接近時才有幫助。上述數字也假設 path filters 正常運作;單一未套用篩選的 lock file 就可能自行使輸入量加倍。
Prompt caching 在此沒有幫助。每次呼叫之間,快取的 prefix 必須逐 byte 完全相同,而 diff 每次都不同。system prompt 是唯一穩定的部分,但其長度遠低於可快取的最低長度。一般規則請參閱 prompt caching 何時能回本;若要在上述 3 個模型之間選擇,請參閱 不同工作該使用哪個 Claude 模型。
在啟用前先測量自己的 diff
在建立 payload 後加入這一行,然後手動執行 script,針對上個月的幾個 pull request 進行測試:
print(client.messages.count_tokens(
model=MODEL, system=SYSTEM, messages=[{"role": "user", "content": payload}]
).input_tokens)計數端點不會執行模型,因此不會消耗輸入或輸出 token,並且會使用你指定之模型所屬的 tokenizer。針對自己 repository 中 10 個實際的 pull request 執行計數,並採用中位數而非平均值,避免單一大型 migration 造成估算偏差。
為什麼 review bot 會被停用,以及如何避免
有兩種行為會破壞人們對這類 bot 的信任,前述程式碼也都已針對這兩點處理。
一次審查所有內容。 一個 bot 留下 40 則評論,最後一則都不會有人閱讀。嚴重性門檻與 10 則評論上限不是出於禮貌,而是用來確保真正的問題仍然可見。先依嚴重性排序,再截斷結果,可讓上限淘汰重要性最低的問題,而不是隨機刪除 10 則。
對無法檢查的內容過度自信地發表評論。 這會讓工程師永久停用它。模型只看到 40,000 行程式碼中的 200 行,仍可能針對它從未看過的檔案寫出「這會破壞 redis_client.py 中的快取失效機制」。system prompt 以明確的語言加以限制:只回報所顯示程式碼中的可見缺陷,對不確定的內容不要提及。直接說明不可接受的錯誤,比籠統要求提高準確性更有效;告知模型空結果是正常情況,則能避免它為了兩行變更而捏造問題。
將審查結果發布為 COMMENT,不要發布為 REQUEST_CHANGES。模型的意見不應能阻擋 merge;一旦它具備這種能力,面臨期限壓力的人往往會直接移除整個 workflow,而不是與它爭論。
錯誤情況,以及你會看到的字串
發布審查時收到 HTTP 422。 gh 會輸出 gh: Unprocessable Entity (HTTP 422),而回應本文會指出欄位:Pull request review thread line must be part of the diff。GitHub 無法定位該留言。常見原因包括模型捏造行號、仍帶有 b/ 前綴的 path,或留言位於已刪除的行上;這種情況必須將 side 設為 LEFT,而不是 RIGHT。發布前先輸出審查 JSON,並手動以 diff 核對其中一則留言。
模型 API 回傳 invalid x-api-key。 此步驟會在第一次 messages.create 呼叫時失敗。可能是儲存庫未設定 ANTHROPIC_API_KEY secret,或 pull request 來自 fork,導致 Actions 完全未傳入 secret。if: 行中的 fork guard 原本應略過這種情況,因此先檢查該行。
gh: Resource not accessible by integration (HTTP 403)。 工作的 token 無法寫入 pull request。將 pull-requests: write 加入 permissions: 區塊。如果該設定已存在,請前往 Settings,再選 Actions,最後選 General;組織政策可能限制任何 workflow token 可要求的權限。
json.decoder.JSONDecodeError。 模型未回傳可解析的 JSON。常見原因是回應達到 token 上限,並在物件尚未完成時停止。日誌行會為此輸出 stop_reason:max_tokens 的值表示應提高 max_tokens 或降低 MAX_COMMENTS。
工作流程完全未執行。 gh run list 沒有顯示該 pull request 的任何資訊。先確認 paths-ignore 沒有篩除所有變更檔案,再檢查 fork guard 與 label guard,最後在 VPS 上使用 sudo systemctl status 'actions.runner.*' 確認 runner 是否正常運作。runner 離線時,工作會持續排隊,pull request 中也不會顯示任何錯誤訊息。
每次審查結果都是空的。 將 MIN_SEVERITY 設為 low 執行一次。如果出現結果,表示 threshold 正常運作。如果仍沒有結果,請輸出 payload,並確認篩選條件沒有移除整個 diff。
與其他 agent 一起執行
reviewer 規模不大,因此很容易將它放到已經執行其他服務的主機上。但如果 repository 重要,請將它分開部署。這個程序持有可對程式碼發表評論的 token,以及可支出費用的 key;而 self-hosted runner 的設計用途,就是執行 workflow 程式碼。使用沒有 sudo 權限的 專用 unprivileged account,並在未執行其他服務的主機上運作,是基本要求。若也執行會 checkout 程式碼的互動式 agent,則應為每個 agent 使用 一部可拋棄的 VM;在 VPS 上執行 coding agent 涵蓋一般設定。若你不熟悉 Anthropic API,在 VPS 上建立第一個 Claude API app 會是比本教學更適合的起點。
FAQ
AI PR review agent 需要對我的 repository 具備寫入權限嗎?
不需要。它只需要 pull-requests: write 來發布 review,以及 contents: read 來取得 diff。這就是全部所需的權限。你可以在 workflow 的 permissions: 區塊中設定這些權限,該區塊會限制每個 job 的 GITHUB_TOKEN 可執行的操作。設定這兩行後,agent 可以在 pull request 上留言,但無法 push commit 或合併 branch。請使用 event: COMMENT 發布 review,不要使用 REQUEST_CHANGES,這樣它也無法阻擋合併。
為什麼我的 review 留言會失敗並回傳 HTTP 422?
GitHub 只接受發布在 pull request diff 內部行上的 inline review comment。如果留言位置不在其中,GitHub 就會回傳 Pull request review thread line must be part of the diff。請確認 path 使用 repository 相對路徑,且沒有 diff 標頭中的 b/ 前綴,也確認行號位於該檔案的 hunk 內。對新增或未變更的行,side 必須是 RIGHT;對刪除的行,則必須是 LEFT。在將 diff 傳給 model 前,先為每一行加上其新檔案行號,才能避免 model 自行產生錯誤的行號。
我可以在包含 fork pull request 的 public repository 上執行這項功能嗎?
不能採用這種設計。GitHub 不會將 secrets 傳給由 fork 觸發的 workflow,因此 model key 不存在,執行就會失敗。GitHub 也表示,self-hosted runner「幾乎不應用於 public repository」,因為任何人都能開啟 pull request,讓程式碼在你的機器上執行。對於 public project,你可以將 reviewer 限制在直接 push 到該 repository 的 branch,這就是 if: guard 的作用;或者將 review 步驟移至 GitHub-hosted runner,但必須接受 diff 會離開你自己的基礎架構。
我應該使用哪個 model 進行 pull request review?
先使用 Haiku 4.5。針對固定缺陷類型清單,檢查受限範圍內的 diff 並不是困難的推理工作。使用成本最低的 model,也能讓每月帳單維持在無人會爭論的金額。若發現它漏掉語言或 framework 中的實際錯誤,再升級至 Sonnet 5,並以測量結果判斷,而不是預設它一定更好。Opus 5 在每個 pull request 上的成本,遠高於另外兩個 model。相較於每次 push 至每個 feature branch,將它用於 release branch 更容易合理化。