跳到主内容
快讯直播
AI智模界
教程

Claude Code 代码审查:分片、并行与自动修复

把代码审查交给模型,容易踩两个坑:要么把整份 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。

AI 生成本文由 AI 基于公开信息自动生成,仅供参考。