把代码审查交给模型,容易踩两个坑:要么把整份 diff 一次性丢进去,结论发散、定位不准;要么只让它"看看有没有问题",出来的东西全是"建议增加注释"。这篇教程把审查拆成一套仓库里能跑、CI 里能接、人工能收口的流程,四个脚本加一组提示模板,配置全部可以直接照抄。
做完之后你会得到什么
一个可执行的审查流水线,目录结构如下:
```text
repo/
├── .claude/
│ ├── CLAUDE.md # 仓库约定,所有审查任务的共享上下文
│ └── commands/
│ ├── review-correctness.md # 五个维度各一个提示模板
│ ├── review-security.md
│ ├── review-performance.md
│ ├── review-tests.md
│ ├── review-maintainability.md
│ └── apply-fix.md # 低风险自动修复
├── docs/review-checklist.md # 人类可读的审查清单
├── scripts/
│ ├── shard-diff.sh # 把 diff 切片
│ ├── review-all.sh # 并行跑所有维度
│ ├── merge-findings.mjs # 汇总、去重、分级
│ ├── list-fixable.mjs # 挑出可自动修复项
│ └── autofix.sh # 带护栏的自动修复
└── .github/workflows/ai-review.yml # CI 接入
```
本地跑一遍就是四行命令:
```bash
bash scripts/shard-diff.sh origin/main
bash scripts/review-all.sh origin/main
node scripts/merge-findings.mjs > .review/report.md
bash scripts/autofix.sh
```
PR 打开后,CI 会自动生成一份按严重度排序的审查报告贴在评论区,同时把低风险问题修成一个独立分支,等人工确认。
前置条件清单
- 一个 git 仓库,有稳定的基线分支(下面统一用
origin/main指代,按实际情况替换) - Node.js 环境。示例脚本只用标准库,不需要装任何依赖
- bash。示例用到数组语法,macOS 自带的旧版 bash 需要注意,见"常见坑"
- Claude Code CLI 已安装,并且能在非交互模式下执行单次任务
- 可用的认证方式(API key 或订阅),具体方式以官方文档当前版本为准
- 代码仓库能跑测试,并且你知道那条命令是什么
- 如果用 GitHub Actions:仓库开启了 Actions,Workflow 有 PR 写权限
把 .review/ 加进 .gitignore,中间产物不进版本库。
第一步:把审查标准写成清单
模型并不知道你们团队在意什么。不告诉它,它就只能给通用建议。所以第一步不是写提示词,是写清单。
docs/review-checklist.md:
```markdown
代码审查清单
通用维度
正确性
- 边界条件:空集合、单元素、越界下标、除零
- 空值与异常:可能为 null 的返回值、未捕获的异常路径
- 并发:共享可变状态、竞态、缺少同步
- 错误处理:吞异常、过宽的 catch、失败无日志
- 契约变更:签名、返回值、副作用变化是否影响调用方
安全
- 外部输入是否经过校验才进入查询、命令、路径拼接
- 密钥、令牌、内网地址是否出现在代码或日志里
- 鉴权检查是否在新增的路由/接口上生效
- 反序列化、文件上传、重定向是否有限制
性能
- 循环内的 IO、查询、网络调用
- N+1 查询、缺失索引的过滤条件
- 无上限的集合增长、无分页的全量加载
- 缓存失效策略与内存占用
测试
- 新增分支是否有对应用例(含失败路径)
- 断言是否真的验证行为,而不是验证"没抛异常"
- 是否有被跳过、被注释掉的用例
- 测试之间是否互相依赖
可维护性
- 命名是否表达意图
- 重复逻辑是否值得抽取(不要为抽象而抽象)
- 死代码、调试输出、临时开关
- 注释是否与代码一致
本仓库特有项
- 数据迁移必须可回滚,且与代码发布解耦
- 新增配置项必须在示例配置文件中登记
- 涉及权限矩阵的改动必须同时改测试
```
然后让所有审查任务共享这段上下文。在 .claude/CLAUDE.md 里写:
```markdown
仓库约定
技术栈:<按实际填写>
测试命令:<按实际填写,例如 npm test>
格式化命令:<按实际填写>
审查规则
- 审查标准见 docs/review-checklist.md
- 只报告 diff 中改动引入的问题,不报告历史遗留问题
- 不确定的问题不要报,宁缺毋滥
- 不做风格挑刺,风格由格式化工具兜底
```
这里有个容易被忽略的点:CLAUDE.md 是共享上下文,提示模板是具体任务。约定放前者,任务放后者,改起来才不会互相污染。
第二步:把 diff 切片
分片解决三个问题:上下文不会溢出、多个分片可以并行、每条结论能精确定位到文件。
scripts/shard-diff.sh:
```bash
#!/usr/bin/env bash
把一次 PR 的 diff 切成若干小块,供并行审查使用
set -euo pipefail
BASE="${1:-origin/main}"
OUT="${2:-.review/shards}"
MAX_LINES="${MAX_LINES:-600}" # 单个分片的 diff 行数上限
mkdir -p "$OUT"
rm -f "$OUT"/*.diff
files=()
while IFS= read -r f; do
files+=("$f")
done < <(git diff --name-only --diff-filter=ACMR "$BASE"...HEAD)
if [ "${#files[@]}" -eq 0 ]; then
echo "没有需要审查的文件改动"
exit 0
fi
idx=0
buf=0
out="$OUT/shard-0.diff"
: > "$out"
for f in "${files[@]}"; do
n=$(git diff --unified=20 "$BASE"...HEAD -- "$f" | wc -l | tr -d ' ')
放不下就换一个新分片,单个大文件会自然独占一片
if [ "$buf" -gt 0 ] && [ $((buf + n)) -gt "$MAX_LINES" ]; then
idx=$((idx + 1))
out="$OUT/shard-$idx.diff"
: > "$out"
buf=0
fi
{
echo "### FILE: $f"
git diff --unified=20 "$BASE"...HEAD -- "$f"
} >> "$out"
buf=$((buf + n))
done
echo "分片完成:$(ls "$OUT"/*.diff | wc -l | tr -d ' ') 个分片,输出目录 $OUT"
```
几个参数的含义:--unified=20 给足上下文,模型才能判断改动是否符合原有意图;--diff-filter=ACMR 跳过删除的文件,避免审查一个已经不存在的东西;MAX_LINES 默认 600,按模型上下文和你们的代码风格调整。
每片开头写了 ### FILE: <路径>,这样模型引用位置时不会串台。
第三步:为每个维度写一个提示模板
关键设计是"一个模板只管一件事"。让一个模型同时看正确性和命名风格,它就会把注意力平均分配,两类结论都不深。
.claude/commands/review-correctness.md:
```markdown
---
description: 正确性维度审查
argument-hint: <diff 文件路径>
allowed-tools: Read, Grep, Glob
---
你是一名严格的代码审查员。只关注正确性,其他维度一律不报。
输入:读取 $1 指向的 diff 文件。
检查项:
1. 边界条件:空集合、单元素、越界、除零、负数下标
2. 空值与异常:可能为 null 的返回值、未捕获的异常路径
3. 并发:共享可变状态、竞态、缺少同步
4. 错误处理:吞异常、过宽的 catch、失败无日志
5. 契约变更:签名、返回值、副作用变化是否影响调用方
输出要求:
- 只输出一个 JSON 数组,不要任何解释文字,不要 markdown 代码块围栏
- 每项字段:file, line, severity(critical|major|minor), title, evidence, suggestion, confidence(0-1)
- evidence 必须是 diff 中出现的原始代码行,原样引用
- 无法确认的问题不要报
- 没有发现问题就输出 []
严重度判定:
- critical:会导致数据错误、安全漏洞或线上故障
- major:特定输入下会出错,或明显违反接口约定
- minor:可读性、防御性写法、错误信息不清晰
```
其他四个模板结构相同,只换检查项:review-security.md 关注输入校验、鉴权、密钥泄露、注入面;review-performance.md 关注循环内 IO、N+1、无上限增长;review-tests.md 关注分支覆盖、断言质量、被跳过的用例;review-maintainability.md 关注死代码、调试输出、重复逻辑。
注意 allowed-tools 只给读类工具。审查阶段不应该有写权限,这是后面自动修复能单独管控的前提。
第四步:并行执行
scripts/review-all.sh:
```bash
#!/usr/bin/env bash
set -euo pipefail
BASE="${1:-origin/main}"
SHARDS_DIR=".review/shards"
FINDINGS_DIR=".review/findings"
PARALLEL="${PARALLEL:-4}"
DIMENSIONS=(correctness security performance tests maintainability)
mkdir -p "$FINDINGS_DIR"
rm -f "$FINDINGS_DIR"/*.raw.json "$FINDINGS_DIR"/*.err
bash scripts/shard-diff.sh "$BASE" "$SHARDS_DIR"
JOBS="$FINDINGS_DIR/jobs.txt"
: > "$JOBS"
for shard in "$SHARDS_DIR"/*.diff; do
[ -s "$shard" ] || continue
for dim in "${DIMENSIONS[@]}"; do
printf '%s|%s\n' "$shard" "$dim" >> "$JOBS"
done
done
echo "待执行审查任务:$(wc -l < "$JOBS" | tr -d ' ') 个,并发度 $PARALLEL"
run_one() {
local shard="$1" dim="$2" name
name="$(basename "$shard" .diff)__${dim}"
if ! claude -p "/review-${dim} ${shard}" \
--output-format json \
--allowedTools "Read,Grep,Glob" \
> "$FINDINGS_DIR/${name}.raw.json" 2> "$FINDINGS_DIR/${name}.err"; then
echo "WARN: 任务失败 ${name}(详见 ${FINDINGS_DIR}/${name}.err)" >&2
fi
}
export -f run_one
export FINDINGS_DIR
xargs -P "$PARALLEL" -I{} bash -c '
line="{}"
run_one "${line%%|*}" "${line##*|}"
' < "$JOBS"
```
几个设计取舍:
- 任务失败不中断整批。单个分片超时或输出异常,其他分片的结果照样有;失败信息落到
.err文件,便于排查。 - 并发度默认 4。太高会撞限流,把重试逻辑做得比并发更值钱的往往是前者,先稳再快。
- 命令名和参数以官方文档当前版本为准。
-p、--output-format、--allowed-tools这类开关在不同版本间可能有差异,升级前先拿一个分片试跑。
分片数 × 维度数就是任务数。一个 8 分片的 PR 跑 5 个维度,是 40 个任务,并发 4 的话大概十轮。
第五步:汇总、去重、分级
同一条问题常常被两个维度同时命中,比如一个空指针既算正确性也算安全。汇总脚本要做三件事:解析出 JSON、按位置去重、按严重度排序。
scripts/merge-findings.mjs:
```javascript
import { readdir, readFile, writeFile } from "node:fs/promises";
import path from "node:path";
const DIR = ".review/findings";
const DIM_NAME = {
correctness: "正确性",
security: "安全",
performance: "性能",
tests: "测试",
maintainability: "可维护性",
};
const SEV_ORDER = { critical: 0, major: 1, minor: 2 };
const MIN_CONFIDENCE = Number(process.env.MIN_CONFIDENCE || 0.6);
// 兼容信封格式与纯文本两种输出
function unwrap(raw) {
try {
const env = JSON.parse(raw);
if (env && typeof env.result === "string") return env.result;
} catch {
/* 不是 JSON 信封,按纯文本处理 */
}
return raw;
}
// 容错解析:整体是 JSON、被代码块包裹、或前后有说明文字都能处理
function parseFindings(text) {
const fenced = text.match(/``(?:json)?\s*([\s\S]*?)``/);
const body = fenced ? fenced[1] : text;
const start = body.search(/[\[{]/);
const end = Math.max(body.lastIndexOf("]"), body.lastIndexOf("}"));
if (start === -1 || end === -1 || end < start) return [];
try {
const parsed = JSON.parse(body.slice(start, end + 1));
return Array.isArray(parsed) ? parsed : parsed.findings || [];
} catch {
return [];
}
}
function keyOf(f) {
const line = Math.round(Number(f.line || 0) / 5) * 5; // 行号±5 视为同一处
return [f.file, line, String(f.title || "").slice(0, 24)]
.join("|")
.toLowerCase();
}
const files = (await readdir(DIR)).filter((f) => f.endsWith(".raw.json"));
const byKey = new Map();
let parseErrors = 0;
for (const file of files) {
const dim = (file.split("__")[1] || "unknown").replace(".raw.json", "");
const raw = await readFile(path.join(DIR, file), "utf8");
const items = parseFindings(unwrap(raw));
if (items.length === 0 && raw.trim() && !/\[\s*\]/.test(raw)) parseErrors += 1;
for (const it of items) {
const finding = {
...it,
dimension: dim,
severity: SEV_ORDER[it.severity] === undefined ? "minor" : it.severity,
confidence: Number(it.confidence ?? 0.5),
};
if (finding.confidence < MIN_CONFIDENCE) continue;
const k = keyOf(finding);
const prev = byKey.get(k);
if (!prev) {
byKey.set(k, { ...finding, dimensions: [dim] });
} else {
prev.dimensions.push(dim);
prev.confidence = Math.min(1, prev.confidence + 0.1);
}
}
}
const findings = [...byKey.values()].sort(
(a, b) =>
SEV_ORDER[a.severity] - SEV_ORDER[b.severity] || b.confidence - a.confidence
);
// 供后续自动修复阶段消费
await writeFile(".review/findings.json", JSON.stringify(findings, null, 2));
const counts = findings.reduce((acc, f) => {
acc[f.severity] = (acc[f.severity] || 0) + 1;
return acc;
}, {});
const lines = [];
lines.push(## 代码审查报告(${findings.length} 条,已去重));
lines.push("");
lines.push(
严重:${counts.critical || 0} | 重要:${counts.major || 0} | 次要:${counts.minor || 0}
);
lines.push("");
if (parseErrors > 0) {
lines.push(> ${parseErrors} 个任务输出无法解析,见 .review/findings/*.err);
lines.push("");
}
for (const f of findings) {
lines.push(### [${f.severity}] ${f.title || "未命名问题"});
lines.push("");
lines.push(- 位置:\${f.file}:${f.line || "-"}\``);
lines.push(- 维度:${f.dimensions.map((d) => DIM_NAME[d] || d).join(" / ")});
lines.push(- 置信度:${f.confidence.toFixed(2)});
if (f.evidence) lines.push(- 依据:\${String(f.evidence).trim()}\``);
if (f.suggestion) lines.push(- 建议:${f.suggestion});
lines.push("");
}
process.stdout.write(lines.join("\n"));
```
两个细节值得留意。行号做了 ±5 的归一化,因为不同分片的上下文长度不同,模型报的行号会有偏移,不归一化就永远去不掉重。置信度阈值默认 0.6,低于这个数的直接丢弃但仍保留在原始 JSON 里,方便回溯调参。
报告按严重度排序,人从上往下读,读到不想读的地方停下即可。
第六步:带护栏的自动修复
自动修复的问题不在于能不能修,而在于修错了没人发现。护栏是三层:只修指定类别、只改指定文件、必须过测试。
scripts/list-fixable.mjs:
```javascript
import { readFile, writeFile } from "node:fs/promises";
// 只自动修复低风险、机械性的问题
const FIXABLE_DIMENSIONS = new Set(["maintainability", "tests"]);
const FIXABLE_SEVERITIES = new Set(["minor"]);
// 这些路径永不由脚本改动
const BLOCKED_PATH = /(^|\/)(auth|migrations?|infra|deploy|\.github)\//i;
const all = JSON.parse(await readFile(".review/findings.json", "utf8"));
const fixable = all.filter((f) => {
if (!FIXABLE_SEVERITIES.has(f.severity)) return false;
if (!FIXABLE_DIMENSIONS.has(f.dimension)) return false;
if (BLOCKED_PATH.test(f.file || "")) return false;
return true;
});
await writeFile(".review/fixable.json", JSON.stringify(fixable, null, 2));
process.stdout.write(String(fixable.length));
```
修复提示模板 .claude/commands/apply-fix.md:
```markdown
---
description: 按审查结论做低风险自动修复
argument-hint: <fixable.json 路径>
allowed-tools: Read, Edit, Grep, Glob
---
读取 $1 中的 findings 数组,逐条修复。
硬性约束:
1. 只改 findings 中点名的文件与对应位置附近,不做顺手重构
2. 不改变任何公开接口、返回值语义、数据库结构、CI 配置
3. 每条修复保持最小改动,能一行解决就不写三行
4. 不新增依赖
5. 修完对每条 finding 输出一行:<file>:<line> -> 已修复 | 跳过(原因)
6. 无法在不违反上述约束的前提下完成时,直接跳过,不要勉强
不要在回复里贴大段代码。
```
scripts/autofix.sh:
```bash
#!/usr/bin/env bash
set -euo pipefail
TEST_CMD="${TEST_CMD:-}"
BRANCH="${BRANCH:-ai-review/autofix}"
FIXABLE_JSON=".review/fixable.json"
if [ ! -f ".review/findings.json" ]; then
echo "缺少 .review/findings.json,先跑 merge-findings.mjs" >&2
exit 1
fi
count="$(node scripts/list-fixable.mjs)"
echo "可自动修复项:$count"
[ "$count" = "0" ] && exit 0
独立分支,保证主分支干净可回滚
git switch -c "$BRANCH" 2>/dev/null || git switch "$BRANCH"
claude -p "/apply-fix $FIXABLE_JSON" --permission-mode acceptEdits
if [ -z "$(git status --porcelain)" ]; then
echo "没有产生改动,结束"
exit 0
fi
if [ -n "$TEST_CMD" ]; then
if ! eval "$TEST_CMD"; then
echo "测试未通过,回滚全部自动修复" >&2
git checkout -- .
git clean -fd
exit 1
fi
else
echo "警告:未设置 TEST_CMD,自动修复未经测试验证" >&2
fi
git add -A
git commit -m "chore: 自动修复低风险审查问题"
echo "已提交到 $BRANCH,请人工复核后再合并"
```
用法是 TEST_CMD="npm test" bash scripts/autofix.sh。测试不通过就全量回滚,这条比任何提示词约束都可靠。
第七步:接进 CI,把人工复核放在收口位置
.github/workflows/ai-review.yml:
```yaml
name: AI Code Review
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
permissions:
contents: read
pull-requests: write
concurrency:
group: ai-review-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
review:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
下面各 action 的版本号请替换为官方仓库当前推荐的 tag
- name: 检出代码(需要完整历史以计算 diff)
uses: actions/checkout@官方推荐版本
with:
fetch-depth: 0
- name: 准备 Node 环境
uses: actions/setup-node@官方推荐版本
with:
node-version: lts/*
- name: 安装 Claude Code
run: npm install -g @anthropic-ai/claude-code
- name: 分片并并行审查
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
PARALLEL: "4"
MIN_CONFIDENCE: "0.6"
run: bash scripts/review-all.sh "origin/${{ github.base_ref }}"
- name: 生成报告
run: node scripts/merge-findings.mjs > .review/report.md
- name: 上传中间产物
if: always()
uses: actions/upload-artifact@官方推荐版本
with:
name: ai-review-report
path: .review/
- name: 在 PR 上留言
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh pr comment "${{ github.event.pull_request.number }}" --body-file .review/report.md
```
包名与参数以官方文档当前版本为准,升级前先在本地跑通再改 CI。
接入 CI 时有两条安全边界必须守住。第一,来自 fork 的 PR 拿不到仓库 secret,不要为了让它跑起来改用 pull_request_target 并把 secret 暴露给未审查代码,更稳妥的做法是给 PR 打标签后再触发,或者只对内部分支运行。第二,CI 里只做审查和留言,不要自动 push。自动修复在本地或内部分支上跑,人看过再推。
人工复核的规则建议写进 CONTRIBUTING:
critical阻断合并,必须修复或有明确豁免理由major需要作者在 PR 里逐条回应(修、或者说明为什么不是问题)minor记进 backlog,不阻断- 置信度低于阈值的结论不进报告,但原始 JSON 保留,用于评估误报率
- 每周抽样几十条结论,统计误报比例,把反复出现的误报模式写回
docs/review-checklist.md或提示模板
最后这条是整套流程能长期活下来的关键。清单不是写完就完,它是被误报喂出来的。
常见坑与排错
输出不是 JSON,汇总阶段全军覆没。 提示词里明确"不要代码块围栏"只是降低概率,解析端必须容错。上面 parseFindings 处理了三种情况:纯 JSON、被围栏包裹、前后有说明文字。再加上失败计数,报告顶部会提示有多少任务解析失败。
五个维度说同样的话。 说明提示词没有限定范围。每个模板开头那句"只关注 X,其他维度一律不报"要保留,并且检查项要具体到可判定,比如"循环内的网络调用"比"性能问题"好得多。
分片边界切断了跨文件改动。 一个接口改了签名、三个调用方没改,如果分在不同片里,任何一片都看不全。解决办法是让分片脚本把同目录文件尽量放在一起,或者在审查前先跑一次跨文件的调用方搜索。
并发跑满导致限流。 把 PARALLEL 调到 2 试试,如果仍然频繁失败,说明限流是硬约束,需要在 run_one 外面套一层退避重试。
macOS 上脚本报语法错误。 系统自带的 bash 版本较旧,数组和进程替换的支持有限。用 bash --version 确认,必要时装一个更新的 bash 并显式用它的路径执行。
自动修复改了一大片。 检查 --permission-mode 是否给得太宽,以及 apply-fix.md 里的"最小改动"约束是否被保留。真正可靠的兜底是测试门禁加独立分支,不是提示词。
报告太长没人看。 把 critical 和 major 放前面已经有了,还可以在 PR 里只贴这两个级别,minor 只放到 artifact 里。
自动修复分支反复冲突。 autofix.sh 会复用同名分支。本地清理掉旧分支再跑,或者改成每次带一个唯一后缀。
下一步建议
流程跑通之后,可以从三个方向加深。一是补维度:把"数据库迁移安全性""可观测性""依赖变更"拆成独立模板,每个新维度都配一份清单小节。二是把审查结果沉淀成规则:把高频问题写成 lint 规则或提交前检查,让能在本地拦住的问题不要流到审查环节。三是量化:跟踪自动修复的首次通过率、误报率、人工复核平均耗时,用数据决定该收紧还是放宽 MIN_CONFIDENCE。
接下来可以先在一个活跃分支上手动跑一周,对照人工审查结果校准提示模板,再考虑接入 CI。
