用 AI API 做代码审查自动化:从 git diff 到 PR 评论的完整脚本

先给结论:一条流水线,一个脚本文件

代码审查自动化就三步:用 git diff 取出本次变更,过滤掉不该看的文件,把每个文件的变更片段丢给 AI API 拿结构化 JSON,再按严重级别贴回 PR 评论。一个 Python 文件加一个 GitHub Actions workflow 就够,不用自建服务。

每天 50 个 PR 的规模,用 deepseek-v3.2-thinking 一个月约 220 元,换成 claude-sonnet-4-6-thinking 同样的量是 900 元。选哪个看你对误报的容忍度,账在成本那一节。

开工前准备三样东西

接入地址用主入口 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 取出该审的变更

三条命令分别用在哪

CI 里更稳的是直接用 PR 的 sha 区间,避开浅克隆导致 origin/main 不存在:

DIFF_RANGE: ${{ github.event.pull_request.base.sha }}...${{ github.event.pull_request.head.sha }}

上下文为什么固定 3 行

--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 命令行上,不依赖环境。

完整可运行的 Python 脚本

一个 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 的结构就是 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

让模型必须吐 JSON

response_format 怎么用

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;"
    }
  ]
}

severity 五档怎么定

五档的意义在于能不能卡合并,不在于措辞好听。

级别含义典型场景是否卡合并
blocker必然出错语法错误、必崩路径、写死密钥
critical高概率出错空值取属性、未捕获异常、越权读写
major有明确触发条件边界少算一次、并发竞争、资源不释放
minor要特定输入才触发极端参数、罕见分支
info只影响可维护性重复代码、过长函数

主流程只让前三级 exit 1,后两级进折叠区。全都卡的话团队一周内就会关掉这个检查,那才是真浪费。

JSONDecodeError 从哪来

两种最常见。一是输出被长度截断,finish_reasonlengthcontent 是半截 JSON,json.loadsExpecting value: line 42 column 1;二是模型给 JSON 套了一层三反引号围栏。两个都在 review_chunk 里兜住了:先直接解析,失败就正则抠最外层大括号,再失败当空结果,绝不因为一个文件解析失败中断整轮审查。上线前把 finish_reason 写进日志,一眼分清是截断还是格式问题。

Prompt 怎么设计才不报废话

差 prompt 会输出什么

很多人第一版 prompt 就一句:请审查以下代码,指出所有问题。

输出长这样:命名建议用驼峰、循环可以用 map 重写、建议补注释、函数太长建议拆分、魔法数字建议提成常量。二十条里可能一条真 bug 都没有,团队点开两天就再也没人看了。

根因是「问题」这个词太宽。风格偏好和运行错误在模型眼里是同一类,不划线它就全报。划线方式是给白名单和黑名单:只报哪五类,明确不报哪几类。

好 prompt 的完整模板

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,误报能掉一大截。

大 diff 和超长文件怎么处理

单文件超过 2000 行

别整文件送。整文件送有三个后果:token 超限、模型注意力被无关代码稀释、单次请求延迟高到 CI 超时。只送 hunk,每批不超过 400 行 diff。

单个 hunk 超过 500 行也常见(整文件重写、批量改缩进)。把 hunk 内部再按 200 行一段切开,切的时候必须把 hunk 头 @@ -12,7 +12,180 @@ 复制给每段,模型才知道行号基准。少了这步,返回的 line 全是相对值,定位全部错位。

context_length_exceeded 的原文长什么样

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 算出来的数

tiktokencl100k_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 直接坏掉。所以只让它输出问题条目。

误报治理:白名单和降级规则

用 .ai-review-ignore 屏蔽已知噪音

第一周就要建这个文件,否则历史遗留代码的问题每天重报一遍,三天就没人看评论了。格式和 .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 只进折叠区。模型偶尔多报两条不影响合并节奏。

接进 GitHub Actions

完整 workflow

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,不加会让这步失败、评论步骤被跳过。审查结果靠评论体现,流程失败与否交给别的检查项。

GITHUB_TOKEN 权限和 fork PR 的坑

permissionspull-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 要花多少钱

算例:每天 50 个 PR,每个 PR 平均 3 次请求,合计 150 次每天,按 30 天算:

模型单价(元/次)日请求量日成本月成本
gemini-2.5-pro0.0311504.65 元139.5 元
deepseek-v3.2-thinking0.0491507.35 元220.5 元
claude-sonnet-4-5-thinking0.0915013.5 元405 元
claude-sonnet-4-6-thinking0.215030 元900 元
claude-opus-4-6-thinking0.2515037.5 元1125 元
gpt-5.3-pro0.315045 元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-pro0.031 元/次
200 到 800 行deepseek-v3.2-thinking0.049 元/次
800 行以上或核心模块claude-sonnet-4-5-thinking0.09 元/次
支付、权限、认证模块claude-sonnet-4-6-thinking0.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 的十几倍,模型还容易被无关代码带偏,报出你这次没碰的问题。

常见坑速查

token 超限与 429 限流

超限的症状是 context_length_exceeded,或者 JSON 解析失败但 finish_reasonlength。动作:把 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 会自动打码,本地调试没有这层保护。日志只打文件名、批次数、耗时和请求次数就够,排查成本时也用得上。

相关阅读

🚀 想要立即使用?来小鱼API体验全系列AI模型

查看全部产品

支持Gemini / Claude / GPT / Grok / DeepSeek · 国内直连 · 支付宝/微信