如何在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 上安装审核器
runner 服务以您运行 ./svc.sh install 时创建的非特权账户运行。请在同一账户下安装审核器,使任务无需使用 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 编码、将其拆分到两行,或逐字符输出,密钥就会以明文出现。不要添加用于转储环境变量的调试步骤。
为什么来自 fork 的拉取请求永远无法获取您的 API 密钥
GitHub 的规则很简单:除 GITHUB_TOKEN 外,工作流由 fork 的仓库触发时,不会将机密传递给运行器。因此,来自 fork 的 pull_request 运行时没有 ANTHROPIC_API_KEY,第一次 API 调用会因 invalid x-api-key 失败。
一个看似合理的修复方法是将触发器改为 pull_request_target。它会在基础仓库的上下文中运行,并且确实可以获取机密。但这里不要这样做。GitHub 的安全指南明确指出,这类工作流“具有特权。这意味着它们与其他特权工作流触发器共享主分支的同一缓存,并且可能拥有仓库写入权限以及对所引用机密的访问权限”,其结果“可能被利用来接管仓库”。
同一份指南也明确说明了运行器的风险:“对于 GitHub 上的公共仓库,几乎不应使用自托管运行器,因为任何用户都可以向该仓库发起拉取请求并入侵该环境。”
因此需要做出两项设计选择。作业包含一个保护条件,只在推送到您自己的仓库的分支上运行。工作流完全没有 actions/checkout 步骤。代理不会在磁盘上获得该分支,因此恶意拉取请求只会作为文本发送给模型。它无法在您的 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 行的重构,直接给出一行诚实说明,指出内容过大,无法自动审查,而不是生成 60 条猜测。
- 严重性阈值和评论上限。 报告高严重性和中严重性问题,最多报告 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。被过滤或过大的拉取请求应显示绿色检查通过状态。人无法处理的红色检查会被忽略;一旦某个检查被忽略,所有检查都会被忽略。
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: 行有两个作用。前半部分会跳过来自 fork 的拉取请求,因为没有密钥时这些请求本来也会失败。后半部分为团队提供了关闭开关:向拉取请求添加 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。文本相同,但计数不同。4.7 及更高版本的 Claude 模型使用更新的 tokenizer。对于相同输入,它产生的 token 大约多 30%。Anthropic 在其定价页面中说明了这一点。仅按每百万 token 的价格比较新旧模型时,必须考虑这一差异。
截至 2026 年 8 月的标价如下:Haiku 4.5 的输入价格为每百万 token $1,输出价格为每百万 token $5。Sonnet 5 的试行价格为 $2 和 $10,有效期至 2026 年 8 月 31 日;之后为 $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 次 push,就会产生 8 次审查;并发规则只有在多次 push 的时间接近时才有帮助。上述数据还假设路径过滤器正常工作:一个未过滤的 lock 文件就可能使输入量翻倍。
提示词缓存对此没有帮助。缓存前缀必须在不同调用之间逐字节完全相同,而差异内容每次都会变化。系统提示词是唯一稳定的部分,但其长度远低于可缓存的最小长度。一般规则请参阅 提示词缓存何时能够收回成本;有关如何在上述 3 个模型之间选择,请参阅 不同任务应使用哪个 Claude 模型。
在启用前测量自己的差异
在构建 payload 后添加以下行,然后手动对上个月的几个拉取请求运行脚本:
print(client.messages.count_tokens(
model=MODEL, system=SYSTEM, messages=[{"role": "user", "content": payload}]
).input_tokens)计数端点不会运行模型,因此不会消耗输入或输出 token;它使用您指定模型对应的 tokenizer。对您自己的代码库中 10 个真实拉取请求运行该脚本,并取中位数而不是平均值,这样一个巨大的迁移提交就不会扭曲估算结果。
为何审查机器人会被禁用,以及如何避免这种情况
这类机器人有两种行为会破坏信任,上面的代码已经分别提供了解决方案。
一次审查所有内容。 机器人一次留下 40 条评论,最终一条也不会有人阅读。严重性阈值和 10 条评论上限不是出于礼貌,而是为了让真正的问题保持可见。截断前先按严重性排序,可以让上限优先舍弃不重要的问题,而不是随机舍弃 10 条。
对无法检查的内容自信地下结论。 这会让工程师彻底关闭机器人。模型只看到 40,000 行代码库中的 200 行,却仍会针对从未见过的文件写出“这会破坏 redis_client.py 中的缓存失效机制”。系统提示词会用明确的语言限制它:只报告所显示代码行中可见的缺陷,对不确定的内容不要报告。直接说明这种失败模式,比笼统地要求模型提高准确性更有效;明确告知模型空结果是正常的,也能阻止它为了对两行变更发表意见而凭空编造问题。只有整个代码库范围内才会暴露的缺陷属于另一项工作,自行托管 open-kritt 进行安全扫描 是覆盖这部分范围的一种方式,同时不会扩大当前审查器允许查看的内容。
将审查结果发布为 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 密钥,也可能是拉取请求来自复刻仓库,因此 Actions 根本没有传递任何密钥。if: 行中的复刻保护本应跳过该步骤,因此先检查这一行。
gh: Resource not accessible by integration (HTTP 403)。 作业令牌无权写入拉取请求。在 permissions: 块中添加 pull-requests: write。如果该项已存在,请依次查看 Settings、Actions 和 General,因为组织策略可能限制所有工作流令牌可请求的权限上限。
json.decoder.JSONDecodeError。 模型未返回可解析的 JSON。常见原因是响应达到令牌上限,在对象尚未结束时就停止了。日志行会专门输出 stop_reason:如果其值为 max_tokens,则应增大 max_tokens 或减小 MAX_COMMENTS。
工作流从未运行。 gh run list 中没有显示该拉取请求的任何信息。检查 paths-ignore 是否过滤掉了所有已更改文件,然后检查复刻保护和标签保护,最后在 VPS 上使用 sudo systemctl status 'actions.runner.*' 确认运行器是否在线。运行器离线时,作业会一直处于排队状态,拉取请求中不会显示任何错误信息。
每次审查结果都是空的。 将 MIN_SEVERITY 设置为 low,运行一次。如果出现发现项,说明阈值正在生效。如果仍无内容,请输出 payload,并确认过滤器没有移除整个差异。
与其他代理并行运行
评审器规模较小,因此很容易将它部署到已经运行其他所有服务的主机上。如果代码仓库很重要,请将它单独部署。此进程持有一个可用于评论代码的令牌,以及一个可用于支付费用的密钥;而 self-hosted runner 的设计用途就是执行工作流代码。专用的非特权账户不得拥有 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。在将差异发送给模型之前,为每一行添加其新文件行号,可以从根本上避免模型自行编造行号。
我可以在包含来自分支仓库的拉取请求的公共代码仓库中运行此流程吗?
不能使用此设计。GitHub 不会向由分支仓库触发的工作流传递密钥,因此模型密钥缺失,运行会失败。GitHub 还明确表示,self-hosted runner“几乎绝不应”用于公共代码仓库,因为任何人都可以创建一个拉取请求,使代码在您的机器上运行。对于公共项目,您可以将审查范围限制为直接推送到该代码仓库的分支,这正是 if: 守卫所实现的功能;或者将审查步骤移至 GitHub-hosted runner,并接受差异会离开您自己的基础设施。
应该使用哪个模型审查拉取请求?
从 Haiku 4.5 开始。针对固定缺陷类型列表审查范围受限的差异,并不需要复杂推理能力,而成本最低的模型可以让月度账单保持在易于接受的水平。如果您发现它漏掉了所用语言或框架中的实际缺陷,再升级到 Sonnet 5,并通过测量结果确认,而不要凭假设决定。Opus 5 在每个拉取请求上的成本远高于另外两个模型,因此在发布分支上使用更容易合理化,而不适合用于每次推送到每个功能分支的场景。