代码审查自动化就三步:用 git diff 取出本次变更,过滤掉不该看的文件,把每个文件的变更片段丢给 AI API 拿结构化 JSON,再按严重级别贴回 PR 评论。一个 Python 文件加一个 GitHub Actions workflow 就够,不用自建服务。
每天 50 个 PR 的规模,用 deepseek-v3.2-thinking 一个月约 220 元,换成 claude-sonnet-4-6-thinking 同样的量是 900 元。选哪个看你对误报的容忍度,账在成本那一节。
openai 官方 SDK:pip install --upgrade openai,版本大于 1.40 即可。小鱼API 走 OpenAI 兼容协议,/v1/chat/completions 与 /v1/models 都是标准路径,代码里只改 base_url。git 的环境。Actions 的 runner 自带 git,本地调试用自己仓库。接入地址用主入口 https://xyuapi.top/v1,备用 https://xyuai.cc/v1(另支持 Gemini 原生 /v1beta)。模型名别凭记忆写,先拉列表确认,省掉一堆 model_not_found:
curl -s https://xyuapi.top/v1/models -H "Authorization: Bearer $OPENAI_API_KEY" | head -c 600
git diff(只取变更)
-> 按文件切分
-> 过滤 lock / node_modules / 图片 / min.js / 生成代码
-> 单文件太长就按 hunk 再切,分批送
-> 调 AI API,response_format=json_object
-> 汇总 issues,按 severity 排序
-> 输出 Markdown,贴成 PR 里的一条评论
-> 有 blocker/critical/major 就 exit 1,卡住合并
这个顺序不能乱。先过滤再切分,省掉大量无意义请求;先切分再调用,才避开上下文超限。
git diff --unified=3 HEAD~1 —— 取最近一次提交的改动,本地调试和 pre-commit 钩子够用。git diff --cached —— 取已 git add 未提交的改动,做提交前拦截用这条。git diff origin/main...HEAD —— CI 里审 PR 用这条,注意是三个点。三个点拿公共祖先当基准,只算本分支的改动;两个点会把主分支的新提交也算成你的,模型就只能对着别人的代码报错。CI 里更稳的是直接用 PR 的 sha 区间,避开浅克隆导致 origin/main 不存在:
DIFF_RANGE: ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}
--unified=3 是 git 默认值:每处改动前后各带 3 行没变的代码。这 3 行不是浪费——少了它,模型看到孤零零一句 return None,不知道上面判断过什么条件,只能靠猜。3 行够绝大多数语义判断;给到 10 行 token 翻两三倍,准确率提升很小。
所以别上调成 -U10,也别缩成 -U0——那等于抽掉判断依据,误报率明显上升。
diff 里文件名带中文时,git 默认输出八进制转义,src/订单模块.js 变成 "src/\350\256\242\345\215\225..."。模型把它当普通字符,返回的 file 对不上真实路径,评论里的定位链接全部点不开。两行命令解决:
git config core.quotepath false # 写进用户配置,永久生效
git -c core.quotepath=false diff --unified=3 HEAD~1 # 只对本次命令生效
CI 里用第二种,不污染 runner 配置。脚本里这个参数直接写在 subprocess 命令行上,不依赖环境。
一个 review.py 分四块,下面四段拼起来就是完整文件,pip install openai 后可直接跑。
lock 文件、依赖目录、图片、压缩产物、生成代码,送进去纯属烧钱,还会让模型对着机器生成的代码报一堆噪音。
# review.py 第一块
import fnmatch, json, os, re, subprocess, sys
from pathlib import Path
from openai import OpenAI
SKIP_PATTERNS = [
"*.lock", "package-lock.json", "yarn.lock", "pnpm-lock.yaml", "poetry.lock", "Cargo.lock",
"node_modules/*", "*/node_modules/*", "*/dist/*", "*/build/*", "*/vendor/*",
"*.min.js", "*.min.css", "*.map", "*.bundle.js",
"*.png", "*.jpg", "*.jpeg", "*.gif", "*.webp", "*.svg", "*.ico", "*.pdf",
"*.woff", "*.woff2", "*.ttf", "*.eot", "*.zip", "*.tar.gz",
"*_pb2.py", "*_pb2_grpc.py", "*.pb.go", "*generated*", "*/migrations/*", "*.snap",
]
MAX_LINES_PER_CHUNK = 400 # 单个请求最多带多少行 diff
MAX_CHARS_PER_CHUNK = 24000 # 保险丝,防止单行超长的压缩文件撑爆上下文
def should_skip(path):
p = path.replace("\\", "/")
if p.startswith("node_modules/") or "/node_modules/" in p or "/dist/" in p:
return True
for pat in SKIP_PATTERNS:
if fnmatch.fnmatch(p, pat) or fnmatch.fnmatch(Path(p).name, pat):
return True
return False
def load_ignore_list():
f = Path(".ai-review-ignore")
if not f.exists():
return []
out = []
for line in f.read_text(encoding="utf-8").splitlines():
line = line.split("#", 1)[0].strip()
if line:
out.append(line.replace("\\", "/"))
return out
def is_ignored(path, patterns):
p = path.replace("\\", "/")
return any(fnmatch.fnmatch(p, pat) or fnmatch.fnmatch(Path(p).name, pat) for pat in patterns)
diff 的结构就是 diff --git a/x b/x 一段加若干 @@ hunk。解析只抓这两级,增删语义交给模型。
# review.py 第二块
DIFF_HEAD = re.compile(r"^diff --git a/(.+?) b/(.+)$")
HUNK_HEAD = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@")
def parse_diff(text):
# 切成 {文件路径: [hunk, ...]},每个 hunk 记录新文件起始行号和原始文本
files, cur_file, cur_hunk = {}, None, None
for line in text.splitlines():
m = DIFF_HEAD.match(line)
if m:
cur_file = m.group(2)
files.setdefault(cur_file, [])
cur_hunk = None
continue
if cur_file is None:
continue
m = HUNK_HEAD.match(line)
if m:
cur_hunk = {"new_start": int(m.group(1)), "lines": [line]}
files[cur_file].append(cur_hunk)
continue
if cur_hunk is not None:
cur_hunk["lines"].append(line)
# 丢掉只有文件头、没有实际改动的条目:纯改名、纯权限变更、二进制文件
return {f: hs for f, hs in files.items() if hs}
def split_chunks(hunks, max_lines=MAX_LINES_PER_CHUNK, max_chars=MAX_CHARS_PER_CHUNK):
chunks, buf, n, chars = [], [], 0, 0
for h in hunks:
size = len(h["lines"]) + 1
csize = sum(len(l) for l in h["lines"])
if buf and (n + size > max_lines or chars + csize > max_chars):
chunks.append(buf)
buf, n, chars = [], 0, 0
buf.append(h)
n += size
chars += csize
if buf:
chunks.append(buf)
return chunks
def number_lines(hunk):
# 给 hunk 正文加新文件真实行号,模型照着抄就不会返回相对行号
out, new_no = [], hunk["new_start"]
for line in hunk["lines"]:
if line.startswith("@@"):
out.append(line)
continue
if line.startswith("-"):
out.append(" " + line) # 删除行不占新文件行号
continue
out.append("%5d %s" % (new_no, line))
if not line.startswith("\\"):
new_no += 1
return "\n".join(out)
system prompt 是整件事的关键:写松了全是风格建议,写紧了才只报真 bug。完整模板在下一节,先看调用和容错。
# review.py 第三块
SYSTEM_PROMPT = """你是资深代码审查员,只报告会导致运行结果错误的缺陷。
只报这五类:
1. 逻辑错误:条件写反、边界少算一次、返回值类型不一致
2. 空值与异常:可能为 null/None 就直接取属性、未捕获的异常路径
3. 并发与状态:共享可变状态被并发读写、锁范围过小、异步漏 await
4. 安全:字符串拼接 SQL、命令注入、密钥硬编码、越权读写
5. 资源:连接或文件句柄未释放、循环内建连接、明显的高阶复杂度
不要报:命名风格、缩进空格、注释多少、是否用某个语法糖、import 顺序、日志措辞。
不确定是不是 bug 时标 minor,并在 message 开头写「需要完整上下文:」。如果只是觉得写法不优雅,不要出现在结果里。宁可漏报,不要错报。
"""
USER_TEMPLATE = """文件:{path}
以下是从 git diff 中截取的变更(每行前缀是新文件真实行号):
{diff}
用 json_object 返回,结构如下:
{{"issues": [{{"file": "...", "line": 0, "severity": "...", "message": "...", "suggestion": "..."}}]}}
severity 只能取 blocker / critical / major / minor / info。
file 必须与上面的路径完全一致,line 用行号前缀里的数字,suggestion 给可直接替换的代码。
"""
client = OpenAI(
api_key=os.environ["OPENAI_API_KEY"],
base_url=os.environ.get("AI_BASE_URL", "https://xyuapi.top/v1"),
timeout=180.0, # 思考型模型首字延迟高,给足时间
max_retries=3, # 只对 429 和 5xx 生效,SDK 内置退避
)
def review_chunk(path, diff_text, model):
resp = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": USER_TEMPLATE.format(path=path, diff=diff_text)},
],
response_format={"type": "json_object"},
temperature=0.1,
)
if resp.choices[0].finish_reason == "length":
print("[warn] %s 输出被截断,把 chunk 调小" % path, file=sys.stderr)
raw = resp.choices[0].message.content or "{}"
try:
data = json.loads(raw)
except json.JSONDecodeError:
# 少数情况模型会把 JSON 套进三反引号围栏里
m = re.search(r"\{[\s\S]*\}", raw)
try:
data = json.loads(m.group(0)) if m else {}
except json.JSONDecodeError:
print("[warn] %s JSON 解析失败,跳过本批" % path, file=sys.stderr)
return []
issues = data.get("issues") or []
for it in issues:
it["file"] = path # 强制对齐,模型偶尔会改写路径
it.setdefault("severity", "minor")
if not isinstance(it.get("line"), int):
it["line"] = 0
return issues
# review.py 第四块
SEVERITY_ORDER = {"blocker": 0, "critical": 1, "major": 2, "minor": 3, "info": 4}
BLOCKING = ("blocker", "critical", "major")
def get_diff(mode):
cmd = ["git", "-c", "core.quotepath=false", "diff", "--unified=3"]
if mode == "staged":
cmd.append("--cached")
elif mode == "pr":
cmd.append(os.environ.get("DIFF_RANGE", "origin/main...HEAD"))
else:
cmd.append("HEAD~1")
r = subprocess.run(cmd, capture_output=True, text=True,
encoding="utf-8", errors="replace")
if r.returncode != 0:
print("[warn] git diff 失败:%s" % r.stderr.strip()[:200], file=sys.stderr)
return r.stdout
def markdown_report(issues):
if not issues:
return "AI 审查完成:没有发现会导致 bug 的问题。"
main = [i for i in issues if i["severity"] in BLOCKING]
minor = [i for i in issues if i["severity"] not in BLOCKING]
lines = ["AI 审查发现 %d 个问题。" % len(issues), ""]
for i in main:
lines.append("- **[%s]** `%s:%s` %s" % (i["severity"], i["file"], i["line"], i["message"]))
if i.get("suggestion"):
lines.append(" - 建议:%s" % i["suggestion"])
if minor:
lines += ["", "<details><summary>次要问题(%d 条)</summary>" % len(minor), ""]
for i in minor:
lines.append("- **[%s]** `%s:%s` %s" % (i["severity"], i["file"], i["line"], i["message"]))
lines += ["", "</details>"]
return "\n".join(lines)
def main():
mode = sys.argv[1] if len(sys.argv) > 1 else "head"
model = os.environ.get("REVIEW_MODEL", "deepseek-v3.2-thinking")
ignores = load_ignore_list()
files = parse_diff(get_diff(mode))
issues, calls = [], 0
for path, hunks in sorted(files.items()):
if should_skip(path) or is_ignored(path, ignores):
print("[skip] %s" % path, file=sys.stderr)
continue
for chunk in split_chunks(hunks):
diff_text = "\n".join(number_lines(h) for h in chunk)
issues += review_chunk(path, diff_text, model)
calls += 1
issues.sort(key=lambda i: (SEVERITY_ORDER.get(i["severity"], 9), i["file"], i["line"]))
Path("review-result.json").write_text(
json.dumps({"issues": issues}, ensure_ascii=False, indent=2), encoding="utf-8")
print(markdown_report(issues))
print("[stat] 文件 %d 个,请求 %d 次,模型 %s" % (len(files), calls, model), file=sys.stderr)
sys.exit(1 if any(i["severity"] in BLOCKING for i in issues) else 0)
if __name__ == "__main__":
main()
跑法:python review.py head 审最后一次提交,review.py staged 审暂存区,review.py pr 审 PR。结果同时落一份 review-result.json。
response_format={"type": "json_object"} 让模型只在 JSON 模式里输出,但有两个前提:prompt 里必须出现 json 这个词(模板写了 json_object,满足),返回的仍是字符串,得自己 json.loads。字段结构靠 prompt 约定而不是 schema 强约束,字段名要写死在模板里。
五个字段够用:
{
"issues": [
{
"file": "server/routes/auth.js",
"line": 142,
"severity": "critical",
"message": "令牌额度换算前未做空值判断,api_quota 为 null 时抛 TypeError",
"suggestion": "const quota = Number(rule.api_quota || 0) * tokenRate;"
}
]
}
五档的意义在于能不能卡合并,不在于措辞好听。
| 级别 | 含义 | 典型场景 | 是否卡合并 |
|---|---|---|---|
| blocker | 必然出错 | 语法错误、必崩路径、写死密钥 | 是 |
| critical | 高概率出错 | 空值取属性、未捕获异常、越权读写 | 是 |
| major | 有明确触发条件 | 边界少算一次、并发竞争、资源不释放 | 是 |
| minor | 要特定输入才触发 | 极端参数、罕见分支 | 否 |
| info | 只影响可维护性 | 重复代码、过长函数 | 否 |
主流程只让前三级 exit 1,后两级进折叠区。全都卡的话团队一周内就会关掉这个检查,那才是真浪费。
两种最常见。一是输出被长度截断,finish_reason 是 length,content 是半截 JSON,json.loads 报 Expecting value: line 42 column 1;二是模型给 JSON 套了一层三反引号围栏。两个都在 review_chunk 里兜住了:先直接解析,失败就正则抠最外层大括号,再失败当空结果,绝不因为一个文件解析失败中断整轮审查。上线前把 finish_reason 写进日志,一眼分清是截断还是格式问题。
很多人第一版 prompt 就一句:请审查以下代码,指出所有问题。
输出长这样:命名建议用驼峰、循环可以用 map 重写、建议补注释、函数太长建议拆分、魔法数字建议提成常量。二十条里可能一条真 bug 都没有,团队点开两天就再也没人看了。
根因是「问题」这个词太宽。风格偏好和运行错误在模型眼里是同一类,不划线它就全报。划线方式是给白名单和黑名单:只报哪五类,明确不报哪几类。
SYSTEM_PROMPT = """你是资深代码审查员,只报告会导致运行结果错误的缺陷。
只报这五类:
1. 逻辑错误:条件写反、边界少算一次、返回值类型不一致
2. 空值与异常:可能为 null/None 就直接取属性、未捕获的异常路径
3. 并发与状态:共享可变状态被并发读写、锁范围过小、异步漏 await
4. 安全:字符串拼接 SQL、命令注入、密钥硬编码、越权读写
5. 资源:连接或文件句柄未释放、循环内建连接、明显的高阶复杂度
不要报:命名风格、缩进空格、注释多少、是否用某个语法糖、import 顺序、日志措辞。
不确定是不是 bug 时标 minor,并在 message 开头写「需要完整上下文:」。如果只是觉得写法不优雅,不要出现在结果里。宁可漏报,不要错报。
硬性要求:
- file 必须与输入路径完全一致,不要改写,不要补前缀
- line 用新文件里的真实行号(每行前缀已标好)
- suggestion 给出可直接替换的代码,不要写「建议优化」
- 没有问题就返回空 issues 数组
"""
| 变更内容 | 差 prompt 的输出 | 好 prompt 的输出 |
|---|---|---|
if (list.length > 0) 改成 if (list) | 建议保留显式长度判断,可读性更好 | 不报,两者对空数组语义等价 |
const id = resp.data.items[0].id | 建议加注释说明结构 | critical:items 为空数组时下标越界,抛 TypeError |
循环里 await fetch() 没有 try | 建议增加错误处理 | major:任一次请求失败会中断整批,已写入数据无法回滚 |
变量名 d 改成 data | 建议使用有意义的命名 | 不报,不影响运行结果 |
差别不在模型,在约束。把「只报会导致 bug 的问题」写进 system prompt,误报能掉一大截。
别整文件送。整文件送有三个后果:token 超限、模型注意力被无关代码稀释、单次请求延迟高到 CI 超时。只送 hunk,每批不超过 400 行 diff。
单个 hunk 超过 500 行也常见(整文件重写、批量改缩进)。把 hunk 内部再按 200 行一段切开,切的时候必须把 hunk 头 @@ -12,7 +12,180 @@ 复制给每段,模型才知道行号基准。少了这步,返回的 line 全是相对值,定位全部错位。
Error code: 400 - {'error': {'message': "This model's maximum context length is 128000 tokens.
However, your messages resulted in 141027 tokens.", 'type': 'invalid_request_error',
'code': 'context_length_exceeded'}}
看到这个就是批次太大,把 MAX_CHARS_PER_CHUNK 调小重跑,不用改 prompt。
tiktoken 的 cl100k_base 是 OpenAI 的编码方案,拿它估别家模型的 token 数不是一回事。中文和代码混排时两边偏差常在 15% 以上,高的低的都有。脚本里故意用字符数当保险丝,MAX_CHARS_PER_CHUNK = 24000 对 128K 上下文用不到两成,余量足够。
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
print(len(enc.encode(diff_text))) # 参考值,别贴着上限用
输出也占长度预算:一个 hunk 里被要求逐行解释,模型输出能到三四千 token,撞上 max_tokens 就截断,JSON 直接坏掉。所以只让它输出问题条目。
第一周就要建这个文件,否则历史遗留代码的问题每天重报一遍,三天就没人看评论了。格式和 .gitignore 一样:
# 生成代码,不审
src/generated/*
**/*.min.js
**/*_pb2.py
# 历史模块,2026Q3 下线,期间不审
legacy/old_api.py
# 测试夹具,故意写的坏数据
tests/fixtures/*
load_ignore_list() 读它,命中的文件直接跳过,请求都不发出去。这个文件要进版本库,全组一起维护。
规则就是 system prompt 里那一句:不确定是不是 bug 时标 minor,或者干脆不提。这句单独成行,别混进长段落,模型对独立短句的遵守度更高。
配合 severity 分档:只有 blocker、critical、major 让 CI 失败,minor 和 info 只进折叠区。模型偶尔多报两条不影响合并节奏。
name: ai-code-review
on:
pull_request:
types: [opened, synchronize, reopened]
permissions:
contents: read
pull-requests: write # 发评论必须有这个
concurrency:
group: ai-review-${{ github.event.pull_request.number }}
cancel-in-progress: true # 同一 PR 连推多次只跑最后一次,也避开限流
jobs:
review:
if: github.event.pull_request.draft == false
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # 必须,浅克隆拿不到完整提交历史
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- run: pip install --upgrade openai
- name: run ai review
env:
OPENAI_API_KEY: ${{ secrets.AI_API_KEY }}
AI_BASE_URL: https://xyuapi.top/v1
REVIEW_MODEL: deepseek-v3.2-thinking
DIFF_RANGE: ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}
run: |
git config core.quotepath false
python review.py pr > review.md 2> review.log || true
cat review.log
- name: comment on pr
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const marker = '<!-- ai-code-review -->';
const body = marker + '\n' + fs.readFileSync('review.md', 'utf8').slice(0, 60000);
const { owner, repo } = context.repo;
const issue_number = context.issue.number;
const { data: comments } = await github.rest.issues.listComments({ owner, repo, issue_number });
const old = comments.find(c => c.body && c.body.includes(marker));
if (old) {
await github.rest.issues.updateComment({ owner, repo, comment_id: old.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number, body });
}
注意 run: 里挂了 || true,因为脚本发现问题时 exit 1,不加会让这步失败、评论步骤被跳过。审查结果靠评论体现,流程失败与否交给别的检查项。
permissions 里 pull-requests: write 不能省,不加评论步骤报 Resource not accessible by integration。
fork 过来的 PR 拿不到 secrets,AI_API_KEY 是空字符串,脚本直接 401。两种正当处理:加条件只跑同仓库分支(if: github.event.pull_request.head.repo.full_name == github.repository),或用自托管 runner 加人工审批。别为了拿 secrets 改成 pull_request_target 又 checkout PR 代码,那等于把仓库密钥交给任意 fork 的提交者。
触发条件 types: [opened, synchronize, reopened] 就够,别加 edited——改标题也触发审查,纯烧钱。草稿 PR 用 if 排除。
小鱼API 以按次计费为主:一次请求一个固定价,输入多长都不影响价格。这对代码审查尤其关键。同一个 PR 的 diff 可能 50 行也可能 3000 行,按 token 计价的话大 PR 成本是小 PR 的几十倍,月末账单不可预测;按次计价预算能提前算死。
平台另有按量计费与无限卡套餐,量特别大或要跑长上下文批处理时切过去。最低充值 7 元,先小额跑通再放量。
算例:每天 50 个 PR,每个 PR 平均 3 次请求,合计 150 次每天,按 30 天算:
| 模型 | 单价(元/次) | 日请求量 | 日成本 | 月成本 |
|---|---|---|---|---|
| gemini-2.5-pro | 0.031 | 150 | 4.65 元 | 139.5 元 |
| deepseek-v3.2-thinking | 0.049 | 150 | 7.35 元 | 220.5 元 |
| claude-sonnet-4-5-thinking | 0.09 | 150 | 13.5 元 | 405 元 |
| claude-sonnet-4-6-thinking | 0.2 | 150 | 30 元 | 900 元 |
| claude-opus-4-6-thinking | 0.25 | 150 | 37.5 元 | 1125 元 |
| gpt-5.3-pro | 0.3 | 150 | 45 元 | 1350 元 |
deepseek-v3.2-thinking 的账:150 乘 0.049 等于 7.35 元每天,乘 30 天是 220.5 元每月。claude-sonnet-4-6-thinking:150 乘 0.2 等于 30 元每天,乘 30 天是 900 元每月。同样 150 次请求差 4 倍。先跑两周便宜的统计误报率,再决定要不要换。换模型只改一个环境变量。
一是按规模分级路由,绝大多数 PR 都很小,全用贵模型是浪费:
| 单文件 diff 规模 | 建议模型 | 单价 |
|---|---|---|
| 200 行以内 | gemini-2.5-pro | 0.031 元/次 |
| 200 到 800 行 | deepseek-v3.2-thinking | 0.049 元/次 |
| 800 行以上或核心模块 | claude-sonnet-4-5-thinking | 0.09 元/次 |
| 支付、权限、认证模块 | claude-sonnet-4-6-thinking | 0.2 元/次 |
二是缓存。同一 commit sha 加同一段 diff 结果不会变,没必要重复请求:
import hashlib
CACHE_DIR = Path(".ai-review-cache")
def review_chunk_cached(path, diff_text, model, sha):
CACHE_DIR.mkdir(exist_ok=True)
key = hashlib.sha256(("%s|%s|%s|%s" % (path, model, sha, diff_text)).encode()).hexdigest()[:16]
cache_file = CACHE_DIR / ("%s.json" % key)
if cache_file.exists():
return json.loads(cache_file.read_text(encoding="utf-8"))
issues = review_chunk(path, diff_text, model)
cache_file.write_text(json.dumps(issues, ensure_ascii=False), encoding="utf-8")
return issues
调用处把 review_chunk 换成 review_chunk_cached,多传一个 os.environ.get("COMMIT_SHA", "local"),缓存目录加进 .gitignore。
三是只审变更行。整个文件全量送,一个 2000 行文件每次 PR 都送一遍,成本是只送 hunk 的十几倍,模型还容易被无关代码带偏,报出你这次没碰的问题。
超限的症状是 context_length_exceeded,或者 JSON 解析失败但 finish_reason 是 length。动作:把 MAX_LINES_PER_CHUNK 降到 200,MAX_CHARS_PER_CHUNK 降到 12000。改这两个数字,别改 prompt。
限流症状是 429 Too Many Requests,同时开五六个 PR 就容易撞上。openai SDK 的 max_retries=3 会退避重试,但默认退避太快,跨不过分钟级窗口。workflow 里的 concurrency 能挡掉重复触发;再稳一点就把文件级请求改串行,十个文件也就多几秒。
症状是评论里的定位链接跳到不相干的位置,九成是模型返回了 hunk 内的相对行号。两层保险:脚本用 number_lines() 给每行加真实行号前缀;事后校正,返回行号小于 hunk 起始行号就加偏移量。
另外审查用的是快照 diff,评论发出时代码可能又推了新提交,所以评论里要标明审的是哪个 commit sha。
一次贴 30 条评论,PR 页面没法看。全程只发一条评论,用 HTML 注释当 marker,重跑就更新同一条,minor 和 info 折叠起来:
<details><summary>次要问题(8 条)</summary>
- **[minor]** `src/utils.ts:88` 空数组时返回 undefined
- **[info]** `src/utils.ts:120` 重复的判断分支可以合并
</details>
日志别大意。调试时不要把 diff 原文和完整请求体打出来,日志里出现真实密钥是最常见的泄密方式。Actions 的 secrets 会自动打码,本地调试没有这层保护。日志只打文件名、批次数、耗时和请求次数就够,排查成本时也用得上。