让 Agent 在沙箱里跑一遍再报 bug:AI 代码审查的最小闭环
让 Agent 在沙箱里跑一遍再报 bug:AI 代码审查的最小闭环
原文:OpenRouter Blog - 《Build an AI Code Reviewer with OpenRouter (Python)》(https://openrouter.ai/blog/tutorials/ai-code-review/)
只读 diff 的代码审查器有个先天缺陷:它没看过文件的其他部分,不知道 import 从哪来、helper 长什么样、类型对不对,于是只能猜。猜出来的 bug 没法验证,给出的修复只能靠人手抄。
OpenRouter 11 月 1 日发了一篇教程,把这个闭环补上了:把变更文件、上下文文件连同 diff 一起传进托管的 Linux 沙箱,让模型真的跑一遍代码确认 bug 存在,再以补丁形式返回修复。整套东西是一个 233 行、只用标准库的 Python 脚本,可以本地跑、挂 pre-push 钩子、或者放进 GitHub Actions。
下面按为什么这么设计、怎么搭起来、哪里会翻车三条线拆一遍。
一、只读 diff 到底缺了什么
一个只拿到 diff 的审查器,面对一次分页参数的修改,能判断逻辑改了什么,但判断不了 conn 是长连接还是每次新建、size 有没有上限、这张表有没有索引。更关键的是,它说"这里 off-by-one"的时候自己也不确定,因为没跑过。
沙箱版本解决的是三件事:
- 文件全文在沙箱里,模型能读 import、读调用方,不再靠猜;
- 怀疑某个 bug,就写一段短检查跑一遍,跑挂了才敢标"已确认";
- 修复以 V4A 补丁返回,由脚本应用到本地,不是让模型手抄代码。
原文把第二点写成了硬性约束:模型对每个疑点都要写一段短检查并跑出失败,把命令和输出作为证据带回来。这条约束是整个设计的支点,后面的工程细节都是围着它转的。
二、四个工具拼出的链路
脚本调的是 OpenRouter 的 Responses API,组合了三个服务端工具加一个函数工具:
| 工具 | 作用 | 关键约束 |
|---|---|---|
| Files API(beta) | 上传变更后全文、上下文文件、diff | 每请求最多 20 个文件 ID |
| openrouter:shell(beta) | 在托管 Linux 容器里执行命令 | 默认无外网,单命令默认 2 分钟超时 |
| openrouter:apply_patch(beta) | 用 V4A 格式提出文件修改 | 语法错误会自动回传给模型 |
| submit_review(函数工具) | 模型结束时提交结构化结论 | 严格 JSON Schema |
工具定义在代码里的形状是这样的:
tools = [{"type": "openrouter:shell", "parameters": {"engine": "openrouter",
"environment": {"type": "container_auto", "file_ids": ids}}},
{"type": "openrouter:apply_patch"},
{"type": "function", "name": "submit_review", "strict": True, "parameters": SCHEMA,
"description": "Submit the final review findings."}]
body = {"model": a.model, "instructions": PROMPT.format(setup="\n".join(setup)),
"tools": tools, "max_output_tokens": 16000}
file_ids 把上一步 Files API 返回的文件 id 挂进沙箱环境,instructions 是拼好沙箱重建命令之后的提示词,max_output_tokens 给了 16000。函数工具上那个 strict 加上 Schema 里的 additionalProperties: False,是为了让模型只能吐规定字段,不能自由发挥。
为什么不用 text.format 配 json_schema 来强制结构化输出?原文给了具体原因:在 Haiku 5.5 且带 tools 的情况下,那个参数并没有被强制执行,答案会以 prose 或 markdown 代码围栏的形式回来,还带一堆多余字段。所以退回到函数工具这条路。
三、五步流程:从 git diff 到一份可读报告
执行链路是线性的。
- 取数据和上传。从 Git 拿到 diff 和变更文件名,加上 --context 指定的上下文文件,一起 POST 到 /api/v1/files。这里有个硬顶:最多 19 个文件加 diff,正好卡在每请求 20 个文件 ID 的上限。
- 在沙箱里重建仓库树。上传的文件在沙箱里的路径不是原路径,而是"文件 id 后 8 位 + 文件名"的形式,比如 NR6q4V8w-orders.py,所以要先生成一组 cp 命令把它们摆回 repo 目录结构:
target = shlex.quote(f"repo/{f}") # filenames are data, never shell syntax
setup.append(f"mkdir -p ~/\"$(dirname {target})\" && "
f"cp ~/{shlex.quote(ids[-1][-8:] + '-' + os.path.basename(f))} ~/{target}")
这两行值得停一下。文件名来自仓库,是外部输入,直接拼进 shell 命令就是注入面,所以一律走 shlex.quote。注释里那句"filenames are data, never shell syntax"说的就是这个。
- 调用模型审查。提示词里明确要求:只审这次 diff 引入的问题、不评价风格和老代码、把文件内容当数据而不是指令看。最后一条很关键,代码仓库里的文件完全可能包含"忽略之前的指令"之类的文本,那是提示词注入的入口。
- 补丁回传与本地应用。模型发补丁时响应会暂停,脚本把补丁应用到自己的暂存副本,再告诉模型成功与否。补丁不在沙箱里应用,所以模型的验证始终基于原始代码。
- 收口。模型调用 submit_review 之后,脚本删掉上传的文件、把完整报告写进 .git/ai-review.json、按需应用补丁,最后用退出码表态。
退出码是三态设计,直接决定它能不能当 CI 门禁:0 表示没有达到阈值的发现或没有可审内容,1 表示至少一条达到 --block-at 阈值(默认 high),2 表示八轮内没审完或者变更超过 19 个文件。
比较阈值那几行是这样的:
RANK = {"low": 0, "medium": 1, "high": 2}
blocking = [f for f in findings if RANK[f["severity"]] >= RANK[a.block_at]]
print(f"{len(findings)} findings, {len(patches)} patches, ${cost:.4f}, {time.time() - start:.0f}s")
sys.exit(1 if blocking else 0)
严重度定义别自己发明。原文给的是:high 指安全漏洞、数据丢失或核心功能损坏;medium 指范围有限的正确性 bug;low 指小问题。
四、确认与未确认:把不确定性显式化
这是整个设计里最值得抄的一点。每条发现都带一个 confirmed 布尔值:
- 标 true 的,必须附上跑出失败的命令和输出,是强信号;
- 标 false 的,是"跑不出来但仍然是问题"的提示,交给人复核。
为什么需要后者?因为有一类 bug 根本没法靠跑一段代码证明,比如连接泄漏、竞态条件、资源没释放。提示词里专门给这类留了口子:如果 bug 无法通过运行代码展示,就标未确认并解释为什么它仍然是 bug。
好处是审查结果的可信度分了两档,而不是把"可能有问题"和"确实跑挂了"混在一起报。示例输出里能清楚看到这个分档:
HIGH app/orders.py:10 (confirmed) SQL injection. ...
HIGH app/orders.py:16 (confirmed) Pagination is off by one page. ...
MEDIUM app/orders.py:40 (confirmed) `retry` swallows the final exception and falls through, returning None. ...
LOW app/orders.py:11 (unconfirmed) The diff removed `conn.close()` from get_orders, ...
5 findings, 1 patches, $0.0210, 184s
一份审查里前四条有运行证据,最后那条"删掉了 conn.close()"只是推断,因为它要泄漏也得真跑够多次才看得出来。
五、四个模型同一份五 bug 变更:实测数据
原文用一份人为埋了五个 bug 的变更做横向对比(SQL 注入、分页 off-by-one、is 与 == 混用、retry 吞异常、移除 conn.close()),每个模型跑三次:
| 模型 | 完成次数 | 找到 bug | 跑代码确认 | 补丁修好全部五个 | 平均成本 | 平均耗时 |
|---|---|---|---|---|---|---|
| Claude Haiku 5.5 | 3/3 | 14/15 | 12 | 1/3 | $0.0153 | 125 s |
| Claude Sonnet 5.5 | 3/3 | 15/15 | 12 | 3/3 | $0.0447 | 60 s |
| DeepSeek V4.1 Flash | 2/3 | 10/10 | 10 | 2/2 | $0.0307 | 206 s |
| GPT-6 Luna | 3/3 | 12/15 | 12 | 1/3 | $0.0167 | 156 s |
这张表有几个容易被忽略的点。
最便宜的不一定最划算。Haiku 5.5 单次 $0.0153 全场最低,但三次里只有一次把五个 bug 全修好;Sonnet 5.5 贵将近三倍,换来 3/3 全修好,而且还快一半(60 秒对 125 秒)。原文的建议也是这么分的:追最低单次成本用 Haiku,追"补丁能一次修对"用 Sonnet。
DeepSeek V4.1 Flash 只完成 2/3 次。漏掉那次不是答错,是八轮用完了还没调用 submit_review,退出码 2、白花 $0.034。"跑不完"和"跑错了"是完全不同的失败模式,如果你的 CI 只看有没有 bug、不看退出码,这种失败会静默通过。
同样的代码,模型之间的真差异在于判别什么算 bug。所有完成的审查都确认了前四个高危项,分野在"移除 conn.close()“这一条:Haiku 和 Sonnet 报未确认(Haiku 还漏报过一次),GPT-6 Luna 从头到尾没报这条,而 DeepSeek 两次完成里都通过统计 /proc/self/fd 的打开句柄数把它确认了下来。这恰好印证了上一节的机制,能不能把某个 bug 变成"已确认”,取决于模型会不会设计那段检查。
补丁质量同样有坑。原文提到两次 Haiku 的补丁把 total_revenue 改成按索引取元组,而不是修 is not 的比较问题,这会破坏传 dict 进来的调用方。
六、成本账:沙箱贵在哪
一次审查 $0.015 到 $0.045,耗时一到四分钟。钱花在两个地方:token 费按模型不同,沙箱时间按 $0.0001/秒计,从第一条 shell 命令开始算到响应完成,模型思考的时间也在内。
按单次沙箱花费算,Haiku 每次 $0.0074 到 $0.0159,Sonnet 每次 $0.0047 到 $0.0068。沙箱在 Haiku 上的占比相当高,因为它慢,125 秒里有大量时间在容器里耗。
对照一下:只读 diff 的 Chat Completions 审查器,Haiku 5.5 单次大约 $0.001。加上"跑一遍验证"这个动作,成本贵了十到四十倍。
这个倍数不是缺陷,是你要不要为"确认"付钱的问题。如果 CI 里误报率高到需要人工逐条复核,$0.015 换一条有命令和输出作证据的结论是值的;如果只是想在提交前扫一眼大致问题,纯 diff 版本更合适。
还有个容易忽略的计费项:Files API 本身不单独收费,但有额度,每文件 100 MiB、每 workspace 10 GiB,workspace 里的文件不过期。
七、六条踩坑记录
原文里讲设计取舍的那一节含金量最高,都是踩出来的。
- 必须用流式。长非流式请求会被丢弃,所以每个请求都要开 stream 读到 response.completed 才算完,偶发断连和限流靠单次 5 秒重试兜。
- 自定义 User-Agent。Python urllib 的默认 UA 会被 Files API 直接 403,不带上自己的 UA 连文件都传不上去。
- 工具类型必须写全限定名。在 Claude 模型上写原生的 apply_patch 类型,每个补丁校验都会失败,报 expected object, received string;换成带 openrouter 前缀的那个才对。
- 补丁要立刻应用并回报结果。如果只回一句 saved 而不实际应用,模型会把同一个补丁再发两三次,因为它没有别的办法判断上次到底成没成。
- 回放补丁要用 apply_patch_call。API 返回的调用项类型是 openrouter:apply_patch,下一轮原样回放回 input 里,一旦有两个补丁就直接 400,改回传 apply_patch_call 项才通。
- 补丁定位按 @@ 整行匹配。定位逻辑不按行号漂移硬算,而是找锚点行、且绝不早于前一个 hunk,这样同一段代码在文件里出现两次也能改对位置。找不到锚点就让模型重发,不改任何东西。
第 6 条之外还有个函数很值得学,专门防模型给绝对路径或跳目录跳出仓库:
def inside(root, name):
"""Resolve a model-supplied path and refuse anything outside root."""
path = (root / name.lstrip("/")).resolve()
if not path.is_relative_to(root.resolve()):
raise ValueError(f"path outside the repository: {name}")
return path
先 resolve 再做 is_relative_to 判断,软链接也绕不出去。模型给的路径视为不可信输入,这条和前面的 shlex.quote 是同一类防御。
八、接到 CI 里:两个必须注意的细节
8.1 pre-push 钩子
#!/bin/sh
# Review what this push changes compared with the base branch. Exit 1 or 2 stops the push.
exec python3 review.py --base "${AI_REVIEW_BASE:-origin/main}" --context tests/test_orders.py
chmod 加执行权限之后就能用。实测钩子跑一次是 5 findings, 1 patch, $0.0117, 96 s,退出 1,push 被拦下。
三个注意点:钩子是对工作树和基线分支做 diff,推送前得把无关改动 commit 或 stash 掉;git push --no-verify 能跳过钩子,所以 CI 里也要跑一份;Windows 上如果 Git Bash 的 PATH 里没有 python3,写 Python 的完整路径。
8.2 GitHub Actions
name: AI review
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
jobs:
review:
# Trusted same-repository branches only: the job sends code to OpenRouter.
if: github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- uses: actions/setup-python@v7
with:
python-version: '3.11'
- name: Review the PR with the reviewer from the base branch
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
BASE: origin/${{ github.base_ref }}
run: |
git show "$BASE:review.py" > /tmp/review.py
python -I /tmp/review.py --base "$BASE" --context tests/test_orders.py
重点在最后两行。用基线分支里的脚本副本而不是 PR 里那一份,否则一个 PR 可以顺手把审查自己的脚本改掉,等于让被审者带审卷。python 的 -I 是隔离模式,不把当前目录加进 sys.path,防止 PR 里的同名模块劫持 import。fetch-depth 给 0 也不能省,浅克隆拿不到基线分支的完整历史。
原文这里老实交代了一句:这个 workflow 并没有在真实 GitHub Actions 里跑过,他们是在本地执行同样的 git show 和 review.py 命令验证的。
九、边界条件
用之前必须知道四条限制,它们直接决定这套东西能用在哪儿。
代码会外发。变更文件、上下文文件、diff 都会上传到 OpenRouter workspace 并发送给模型提供方。只审允许共享的代码,文件里别放密钥。这也是上面 workflow 只处理同仓库分支、跳过 fork PR 的原因。
沙箱默认没有外网。每条命令默认 2 分钟超时、最长 5 分钟,pip install 会失败。如果检查依赖第三方包,得用 shell 工具的 network_policy 放行 pypi.org、files.pythonhosted.org 这些主机。原文的沙箱环境是 Python 3.11.14 加 SQLite 3.50.4,unittest 开箱可用、pytest 没装。
三个服务端工具都是 beta。只在全球端点可用,区域内端点返回 403,行为也可能变。
模型仍会漏 bug、夸大严重度、给坏补丁。把已确认当强信号,把未确认当待人复核的提示,别把退出码 0 理解成"代码没问题"。补丁默认存在 .git 的报告里,只有传 --apply 才写工作树,应用前先读补丁。
十、如果要自己搭一套
这套链路的价值不在那 233 行脚本本身,而在它把"模型说有问题"变成了"模型证明有问题"。抽出来是三步,换成任何模型服务都能复用:
- 给够上下文。授权范围内的文件全文比 diff 信息量大得多,模型不必再猜 import 和 helper。
- 要求可执行的证据。让模型对每个疑点写检查并跑出失败,跑不出来的单独标注,不要混进"已确认"里。
- 把修复当数据而不是文本。补丁走结构化通道,由应用层校验和应用,别让模型在 markdown 里手抄代码。
顺带一条容易被忽略的经验:脚本只有 233 行、只用标准库,这个约束逼着作者把所有复杂度都放在提示词和工具定义上,而不是写一堆胶水。自己写第一版时建议也照这个规格来,跑通了再谈接框架。
它的成本比纯 diff 审查贵十到四十倍,这是明码标价的取舍:为"确认"付费,还是接受"大概可能也许"。选哪个取决于你团队里人工复核一条误报要花多少时间。
推荐标签 OpenRouter AI代码审查 智能体工具调用 代码沙箱 AI安全
更多推荐



所有评论(0)