如何在VPS上自托管AI PR审查代理
在您控制的VPS上运行AI PR审查器:使用自托管Runner、仅发送差异的提示、路径和大小过滤、行内评论,并计算每个PR的实际成本。
自托管 PR 审查代理的作用
自托管 PR 审查代理是运行在您拥有的服务器上的小型程序。它读取拉取请求(PR)的差异,并且只将发生变更的行发送给模型。模型返回的结果会以行内审查评论的形式发布。它不会检出您的分支,也不会读取拉取请求未修改的文件。它持有的凭据只有一个模型 API(应用程序编程接口)密钥,以及一个只能发表评论、不能执行其他操作的令牌。
模型可以读取差异。这部分已经解决。真正重要的是差异会发送到哪里,以及密钥由谁持有。使用托管式审查机器人时,每个私有仓库的差异都会离开您的网络,进入第三方的日志,并受其数据保留策略约束。在您拥有的 VPS(虚拟专用服务器)上,差异会从 GitHub 发送到您的服务器,再发送到模型 API;您还可以查看那 40 行决定发送哪些内容的代码。
开始前的准备
- 一台运行 Ubuntu 24.04 的 VPS,并且已在仓库中注册 自托管 GitHub Actions runner。注册时为它添加额外标签
pr-review,因为下面的工作流会根据该标签选择 runner。 - 从 Claude Console 获取的 Anthropic API key。
- 一个可以控制谁能够发起 pull request 的仓库。私有仓库最简单。下面的 fork 部分介绍公开仓库的情况,但其中的答案并不理想。
在 VPS 上安装 reviewer
runner 服务以您运行 ./svc.sh install 时创建的非特权账户运行。请在同一账户下安装 reviewer,这样作业无需使用 sudo 即可执行它。将下面的 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截至 2026 年 8 月,gh --version 在 Ubuntu 24.04 上输出 gh version 2.45.0。2.20 及更高版本均支持下面使用的 --input 标志。出现 Command 'gh' not found 消息表示未启用 universe 组件,因此请运行 sudo add-apt-repository universe,然后重试。
密钥和令牌的存放位置
两个密钥材料,生命周期不同。两者都不能放入代码仓库。
ANTHROPIC_API_KEY 是仓库密钥,可在 Settings、Secrets and variables、Actions 下设置。GitHub 会将其加密,并在运行时注入步骤的环境变量中。它不会作为文件写入磁盘,也不会出现在 git 历史中。
GITHUB_TOKEN 的工作方式不同。Actions 会为每个作业生成一个新令牌,并在作业结束时销毁它。该令牌可以执行哪些操作,由工作流中的 permissions: 块定义,因此最小权限控制实际就在这里实现:
permissions:
contents: read
pull-requests: write该令牌可以发布审查意见,但不能推送提交、合并分支、编辑工作流文件,也不能访问其他仓库。能够发表评论的代理是审查者。能够推送提交的代理就是提交者,而没有人同意授予它这种权限。模型密钥也必须同样谨慎地管理,因为它会消耗您账户中的资金。有关这类问题的更多内容,请参阅避免让 AI 代理接触密钥。
Actions 会在作业日志中将完全匹配的密钥字符串替换为 ***。它只匹配完整字符串,因此,如果您对密钥进行 base64 编码、将其拆分到两行,或逐个字符输出,密钥就会以明文出现。不要添加用于转储环境变量的调试步骤。
为什么来自复刻仓库的 pull request 永远无法获取您的 API 密钥
GitHub 的规则很简单:除 GITHUB_TOKEN 外,当工作流由复刻仓库触发时,不会将机密传递给 runner。因此,来自复刻仓库的 pull_request 运行时没有 ANTHROPIC_API_KEY,第一次 API 调用会因 invalid x-api-key 而失败。
一个看似可行的修复方法是将触发器改为 pull_request_target。它会在基础仓库的上下文中运行,并且可以获取机密。但这里不要这样做。GitHub 自己的安全指南指出,这类工作流“具有特权,这意味着它们与其他具有特权的工作流触发器共享主分支的同一个缓存,并且可能拥有仓库写入权限以及对所引用机密的访问权限”,其结果“可能被利用来接管仓库”。
同一份指南也明确说明了 runner 的风险:“对于 GitHub 上的公共仓库,几乎不应使用自托管 runner,因为任何用户都可以向该仓库提交 pull request,并危害运行环境。”
因此,需要做出两项设计选择。作业包含一个保护条件,因此只会在推送到您自己仓库的分支上运行。工作流完全没有 actions/checkout 步骤。代理永远不会将该分支保存到磁盘,因此恶意 pull request 只会作为文本发送给模型。它无法在您的 VPS 上运行构建脚本,因为您的 VPS 根本不会运行该脚本。
获取差异内容,而不是仓库
一次请求即可将完整差异内容作为纯文本获取。
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 媒体类型会将响应从描述拉取请求的 JSON 对象转换为统一差异内容本身,gh api 则原样输出响应正文。您看到的第一行应以 diff --git a/ 开头。出现 gh: Not Found (HTTP 404) 表示该令牌无权访问仓库;对于细粒度个人令牌,这几乎总是因为未启用 Pull requests 权限。
在消耗 token 前先过滤
本节决定机器人是会被人阅读,还是会被人静音。下面的每个过滤器都会在模型看到任何字节前运行。
- 路径过滤器。 排除锁文件、供应商目录、压缩后的资源包和生成的代码。模型对
package-lock.json的评论毫无价值,而这些文件通常占据差异内容中的大部分字节。 - 大小上限。 超过上限时跳过审查,并以成功状态退出。对于包含 4,000 行的重构,给出一行说明其规模过大、无法自动审查,比给出六十条猜测更可靠。
- 严重性阈值和评论上限。 报告高严重性和中严重性的问题,最多报告十条,并按严重性从高到低排列。没人会阅读第十一条评论。
脚本
将其保存为 /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)hunk 标头包含行号信息。@@ -12,7 +12,9 @@ 表示新文件的 hunk 从第 12 行开始,因此计数器从这里开始,并且只对新增行和未更改行递增。删除的行不会获得编号,因为它们不存在于新文件中。针对反斜杠开头行的保护逻辑会跳过 git 在文件末尾写入的无换行标记,否则该标记会使后续所有行号偏移 1。
两处退出都使用状态码 0,而不是 1。经过过滤或超出大小限制的拉取请求应显示绿色检查结果。人无法处理的红色检查结果会被忽略,而一旦某个检查结果被忽略,所有检查结果都会被忽略。
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,
)该代码块中有 3 个关键细节。JSON 会截取第一个 { 和最后一个 } 之间的内容,因为模型有时会将答案包在代码围栏中,而 json.loads 无法处理代码围栏。path 会去除开头的 b/,因为此前缀来自 diff 标头,而 GitHub 需要相对于仓库的路径。--input - 会将整个审查作为一次 API 请求发送,因此 10 个发现会作为一条通知到达,而不是 10 条通知。
没有需要报告的问题时,脚本不会发布任何内容。如果机器人在每个拉取请求上都写入“未发现问题”,人们就会习惯于略过它,之后也会略过真正重要的那条通知。
将其接入工作流
将以下内容保存为 .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 组会影响费用:没有它,向分支快速推送 3 个修复会运行 3 次完整审查,3 次都需要付费;启用后只保留最后一次。
if: 行有两个作用。前半部分跳过来自复刻仓库的拉取请求,因为没有密钥时这些请求本来就会失败。后半部分为团队提供关闭开关:向拉取请求添加 no-ai-review 标签后,该作业不会运行。
打开一个拉取请求,然后观察结果:
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,则说明运行正常。对于规模较小且内容干净的拉取请求,这是预期结果。
自动化拉取请求审查的成本是多少?
差异内容几乎构成了全部输入,因此差异大小决定价格。下面是一个经过实际测量的 500 行差异示例,另加系统提示词;数字来自 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 上占用 8,000 个输入 token,在 Sonnet 5 上占用 10,400 个输入 token。文本相同,但计数不同。Claude 4.7 及更高版本使用更新的 tokenizer,相同输入产生的 token 数大约多 30%;Anthropic 在其定价页面对此有说明。仅按每百万 token 的价格比较新旧模型时,应将这一差异计入成本。
截至 2026 年 8 月的标价如下:Haiku 4.5 的输入价格为每百万 token $1,输出价格为每百万 token $5。Sonnet 5 在截至 2026 年 8 月 31 日的介绍性定价期间,输入和输出价格分别为 $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 上,每个拉取请求的成本为 1.4 美分,在 Opus 5 上为 9.1 美分。每月合并 200 个拉取请求的团队,在 Haiku 4.5 上每月约支付 $2.80,在 Sonnet 5 上支付 $7.28,在 Opus 5 上支付 $18.20。从 2026 年 9 月 1 日起,将 Sonnet 5 这一行的金额乘以 1.5。
实际账单高于该估算值,主要有两个原因。synchronize 触发器会审查每次 push,因此一个活跃分支推送 8 次就会产生 8 次审查;并发规则只有在多次 push 时间接近时才有帮助。上述数字还假设路径过滤器正常工作:一个未过滤的 lock 文件就可能使输入量翻倍。
提示词缓存对此没有帮助。缓存前缀必须在多次调用之间逐字节相同,但每次差异内容都不同。系统提示词是唯一稳定的部分,但其长度远低于可缓存长度下限。一般规则请参阅提示词缓存何时能够收回成本;在上面 3 个模型之间进行选择时,请参阅不同任务应使用哪个 Claude 模型。
:::details启用前先测量自己的差异
在构建 payload 后添加下面这一行,然后手动对上个月的几个拉取请求运行脚本:
print(client.messages.count_tokens(
model=MODEL, system=SYSTEM, messages=[{"role": "user", "content": payload}]
).input_tokens)计数端点不会运行模型,因此不会消耗输入或输出 token;它使用的是您指定模型对应的 tokenizer。针对您自己的代码仓库中的 10 个真实拉取请求运行该端点,并取中位数而不是平均值,以免一个大型迁移任务使估算结果失真。
:::
为什么审查机器人会被静音,以及如何避免
有两种行为会破坏人们对这类机器人的信任,上面的代码已经针对这两种行为提供了修复方案。
一次审查所有内容。 机器人留下四十条评论时,通常一条都不会有人阅读。严重性阈值和十条评论上限不是出于礼貌,而是为了让真正的问题保持可见。在截断前按严重性排序,可以让上限优先删除不重要的问题,而不是随机删除十条。
对无法检查的内容自信地下结论。 这是最容易让工程师彻底关闭机器人的问题。即使只向模型展示一个 40,000 行代码库中的 200 行代码,它仍可能针对从未看过的文件写出“这会破坏 redis_client.py 中的缓存失效逻辑”。系统提示会用明确的语言限制它:只报告所展示代码行中可见的缺陷,不确定的内容不要写出。直接说明这种失败模式,比笼统地要求模型提高准确性更有效;明确告诉模型空结果是正常的,也能阻止它为了对两行变更发表评论而凭空编造问题。
将审查结果作为 COMMENT 发布,不要作为 REQUEST_CHANGES 发布。模型的意见不应阻止合并;一旦它可以阻止合并,赶时间的人就会直接删除整个工作流,而不是与它争论。
故障模式及其对应输出字符串
发布评审时返回 HTTP 422。 gh 输出 gh: Unprocessable Entity (HTTP 422),响应正文会指出具体字段:Pull request review thread line must be part of the diff。GitHub 无法为该评论定位代码行。常见原因包括模型虚构了行号,path 仍带有 b/ 前缀,或评论针对已删除的代码行;这种情况下需要将 side 设为 LEFT,而不是 RIGHT。发布前输出评审 JSON,并手动将其中一条评论与差异内容进行核对。
模型 API 返回 invalid x-api-key。 步骤在第一次 messages.create 调用时失败。原因可能是仓库未设置 ANTHROPIC_API_KEY secret,也可能是拉取请求来自 fork,因此 Actions 根本没有传递任何 secret。if: 行中的 fork 防护本应跳过该步骤,因此先检查这一行。
gh: Resource not accessible by integration (HTTP 403)。 作业令牌无权写入拉取请求。将 pull-requests: write 添加到 permissions: 块中。如果该配置已经存在,请依次检查 Settings、Actions 和 General;组织策略可能限制所有工作流令牌可请求的权限范围。
json.decoder.JSONDecodeError。 模型未返回可解析的 JSON。常见原因是响应达到令牌上限,在对象中途停止。日志行会专门输出 stop_reason;如果其值为 max_tokens,则应提高 max_tokens 或降低 MAX_COMMENTS。
工作流始终不运行。 gh run list 中没有显示该拉取请求的任何信息。检查 paths-ignore 是否过滤了所有变更文件,然后检查 fork 防护和标签防护,最后在 VPS 上使用 sudo systemctl status 'actions.runner.*' 确认 runner 是否在线。runner 离线时,作业会一直处于排队状态,拉取请求中不会显示任何错误消息。
每次评审返回的结果都是空的。 将 MIN_SEVERITY 临时设为 low,运行一次。如果出现发现项,说明阈值正在生效。如果仍无结果,请输出 payload,确认过滤器没有移除整个差异内容。
与其他代理并行运行
审查器规模较小,因此很容易想把它部署到已经运行其他所有服务的主机上。如果代码仓库很重要,请将其单独部署。此进程持有一个可以评论代码的令牌,以及一个可以消费资金的密钥;而自托管运行器的设计用途就是执行工作流代码。使用专用的非特权帐户,不授予 sudo 权限,并部署在不运行其他服务的主机上,这是最低基线。如果还运行会检出代码的交互式代理,为每个代理使用一次性 VM是更可靠的做法;在 VPS 上运行编码代理介绍了通用配置。如果您还不熟悉 Anthropic API,在 VPS 上创建第一个 Claude API 应用比直接从这里开始更适合入门。
FAQ
AI PR 审查代理需要对我的代码仓库拥有写入权限吗?
不需要。它只需要 pull-requests: write 来发布审查结果,并需要 contents: read 来获取差异内容。这就是全部权限。您可以在工作流的 permissions: 块中设置这些权限,该块会限制每个作业的 GITHUB_TOKEN 所能执行的操作。配置这两行后,代理可以在拉取请求中发表评论,但不能推送提交或合并分支。使用 event: COMMENT 发布审查结果,而不要使用 REQUEST_CHANGES,这样它也无法阻止合并。
为什么我的审查评论会失败并返回 HTTP 422?
GitHub 只接受针对拉取请求差异中某一行的内联审查评论。如果目标行不在差异中,GitHub 会返回 Pull request review thread line must be part of the diff。请确认 path 使用相对于代码仓库的路径,并且不包含差异标头中的 b/ 前缀。同时确认行号位于该文件的某个差异块中。对于新增行或未修改行,side 必须是 RIGHT;对于删除行,必须是 LEFT。在将差异发送给模型之前,为每一行添加其新文件中的行号,可以从根本上避免模型编造行号。
我可以在包含来自 fork 的拉取请求的公共代码仓库上运行此方案吗?
不能使用此设计。GitHub 不会向由 fork 触发的工作流传递机密信息,因此模型密钥缺失,运行会失败。GitHub 还明确表示,"should almost never be used for public repositories" 自托管 runner,因为任何人都可以打开一个拉取请求,使代码在您的机器上运行。对于公共项目,您可以将审查限制为直接推送到该代码仓库的分支,这正是 if: guard 的作用;或者将审查步骤迁移到 GitHub 托管的 runner,并接受差异内容会离开您自己的基础设施。
我应该使用哪个模型来审查拉取请求?
从 Haiku 4.5 开始。针对固定缺陷类型列表审查范围明确的差异,并不需要复杂的推理能力。使用成本最低的模型,可以将月度费用控制在通常不会引发争议的水平。如果发现它漏掉了您所用语言或框架中的真实缺陷,再升级到 Sonnet 5,并通过实际测量验证,不要直接假设。Opus 5 在每个拉取请求上的成本远高于另外两个模型。在发布分支上使用它更容易合理化,但不适合每次推送到每个功能分支时都使用。