让 Agent 在沙箱里跑一遍再报 bug:AI 代码审查的最小闭环 让 Agent 在沙箱里跑一遍再报 bugAI 代码审查的最小闭环原文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 APIbeta上传变更后全文、上下文文件、diff每请求最多 20 个文件 IDopenrouter:shellbeta在托管 Linux 容器里执行命令默认无外网单命令默认 2 分钟超时openrouter:apply_patchbeta用 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 目录结构targetshlex.quote(frepo/{f})# filenames are data, never shell syntaxsetup.append(fmkdir -p ~/\$(dirname{target})\ fcp ~/{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 阈值默认 high2 表示八轮内没审完或者变更超过 19 个文件。比较阈值那几行是这样的RANK{low:0,medium:1,high:2}blocking[fforfinfindingsifRANK[f[severity]]RANK[a.block_at]]print(f{len(findings)}findings,{len(patches)}patches, ${cost:.4f},{time.time()-start:.0f}s)sys.exit(1ifblockingelse0)严重度定义别自己发明。原文给的是high 指安全漏洞、数据丢失或核心功能损坏medium 指范围有限的正确性 buglow 指小问题。四、确认与未确认把不确定性显式化这是整个设计里最值得抄的一点。每条发现都带一个 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.53/314/15121/3$0.0153125 sClaude Sonnet 5.53/315/15123/3$0.044760 sDeepSeek V4.1 Flash2/310/10102/2$0.0307206 sGPT-6 Luna3/312/15121/3$0.0167156 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.0159Sonnet 每次 $0.0047 到 $0.0068。沙箱在 Haiku 上的占比相当高因为它慢125 秒里有大量时间在容器里耗。对照一下只读 diff 的 Chat Completions 审查器Haiku 5.5 单次大约 $0.001。加上跑一遍验证这个动作成本贵了十到四十倍。这个倍数不是缺陷是你要不要为确认付钱的问题。如果 CI 里误报率高到需要人工逐条复核$0.015 换一条有命令和输出作证据的结论是值的如果只是想在提交前扫一眼大致问题纯 diff 版本更合适。还有个容易忽略的计费项Files API 本身不单独收费但有额度每文件 100 MiB、每 workspace 10 GiBworkspace 里的文件不过期。七、六条踩坑记录原文里讲设计取舍的那一节含金量最高都是踩出来的。必须用流式。长非流式请求会被丢弃所以每个请求都要开 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 条之外还有个函数很值得学专门防模型给绝对路径或跳目录跳出仓库definside(root,name):Resolve a model-supplied path and refuse anything outside root.path(root/name.lstrip(/)).resolve()ifnotpath.is_relative_to(root.resolve()):raiseValueError(fpath outside the repository:{name})returnpath先 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.execpython3 review.py--base${AI_REVIEW_BASE:-origin/main}--contexttests/test_orders.pychmod 加执行权限之后就能用。实测钩子跑一次是 5 findings, 1 patch, $0.0117, 96 s退出 1push 被拦下。三个注意点钩子是对工作树和基线分支做 diff推送前得把无关改动 commit 或 stash 掉git push --no-verify 能跳过钩子所以 CI 里也要跑一份Windows 上如果 Git Bash 的 PATH 里没有 python3写 Python 的完整路径。8.2 GitHub Actionsname:AI reviewon:pull_request:types:[opened,synchronize,reopened]permissions:contents:readjobs:review:# Trusted same-repository branches only: the job sends code to OpenRouter.if:github.event.pull_request.head.repo.full_name github.repositoryruns-on:ubuntu-latesttimeout-minutes:15steps:-uses:actions/checkoutv7with:fetch-depth:0-uses:actions/setup-pythonv7with:python-version:3.11-name:Review the PR with the reviewer from the base branchenv: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.4unittest 开箱可用、pytest 没装。三个服务端工具都是 beta。只在全球端点可用区域内端点返回 403行为也可能变。模型仍会漏 bug、夸大严重度、给坏补丁。把已确认当强信号把未确认当待人复核的提示别把退出码 0 理解成代码没问题。补丁默认存在 .git 的报告里只有传 --apply 才写工作树应用前先读补丁。十、如果要自己搭一套这套链路的价值不在那 233 行脚本本身而在它把模型说有问题变成了模型证明有问题。抽出来是三步换成任何模型服务都能复用给够上下文。授权范围内的文件全文比 diff 信息量大得多模型不必再猜 import 和 helper。要求可执行的证据。让模型对每个疑点写检查并跑出失败跑不出来的单独标注不要混进已确认里。把修复当数据而不是文本。补丁走结构化通道由应用层校验和应用别让模型在 markdown 里手抄代码。顺带一条容易被忽略的经验脚本只有 233 行、只用标准库这个约束逼着作者把所有复杂度都放在提示词和工具定义上而不是写一堆胶水。自己写第一版时建议也照这个规格来跑通了再谈接框架。它的成本比纯 diff 审查贵十到四十倍这是明码标价的取舍为确认付费还是接受大概可能也许。选哪个取决于你团队里人工复核一条误报要花多少时间。推荐标签OpenRouter AI代码审查 智能体工具调用 代码沙箱 AI安全