实战|把腾讯云助手接入 CI 做代码评审:成本、准确率与防幻觉的三张表
分类专栏:腾讯云助手实战 / 研发效能
标签:腾讯云助手、AI 代码评审、CI/CD、GitHub Actions、研发效能、防幻觉、成本优化
摘要:AI 代码评审上线后最容易死在两件事上——评论太多没人看、评论胡说八道没人信。本文给出一套「只送 diff + 按风险分片 + 行号锚定 + 二次校验」的工程化方案,并用三张表回答三个关键问题:成本多少、准确率多少、哪些类型能卡门禁哪些只能提示。
0. 先说结论
AI 代码评审的价值不在"比人强",而在于它愿意做人不愿意做的事。
人不愿意做的事有三类,恰好是 AI 的甜区:
| 一致性检查 | 命名规范、日志格式、错误码、注释风格 | 重复、枯燥,第 20 个文件后开始走神 |
| 规范检查 | 团队约定(必须用统一 HTTP 客户端、必须打 traceId) | 需要记忆,换个人就漏 |
| 遗漏检查 | 异常路径、资源释放、空值处理 | 需要逐行比对,容易漏 |
反过来,AI 的弱区也很清楚:并发、性能、架构合理性。这三类它经常"说得很有道理但是错的"。
所以核心设计原则是:
甜区的评论可以卡门禁,弱区的评论只能做提示。
三张表先放这里(数据来源见第 6、7 节,同一仓库连续运行 8 周):
| 成本表 | 单 MR 输入 token 68k → 9.4k;积分 5.6 → 1.1 |
| 准确率表 | 整体 82.5%(200 条人工标注);规范类 98.4%,并发类 50.0% |
| 门禁表 | 仅 2 类(安全、资源泄漏)可阻塞合并,其余只提示 |
1. 为什么做:评审的真实瓶颈
先明确动机,否则做出来的东西会被"没必要"三个字干掉。
我们当时的状况(一个 8 人后端团队的仓库):
- 日均 MR 12~18 个,平均 diff 大小 380 行
- Review 平均响应时间 4.2 小时,大 MR 超过 1 天
- 上线前 review 漏掉的问题里,73% 是规范类问题(命名、日志、错误码),不是逻辑问题
- 有 8% 的 MR 在 review 阶段被指出"违反了上个月刚定的新约定"
结论很清晰:人的精力被重复的规范检查占满了,真正需要判断力的架构和并发问题反而没时间细看。
AI 代码评审要做的不是"多一双眼睛",而是把人的注意力从规范检查里解放出来。
2. 架构:五步,每一步都为成本或准确率服务
① 提取 diff(只取变更,不取全仓)
↓
② 分片(按文件 + 风险等级,控制单片规模)
↓
③ 并发评审(每片一次调用,输出结构化 JSON)
↓
④ 幻觉校验 + 二次校验(行号锚定、语法验证)
↓
⑤ 分级:卡门禁 or 只提示
关键决策:只送 diff。 这是整个方案成本能降 86% 的根本原因,也是准确率能提升的原因——上下文里没有无关代码,模型不容易"发散"。
| 整个仓库 | 420k+ | 低 | 成本爆炸,且模型会被无关代码带偏 |
| 变更文件的完整内容 | ~68k | 中 | 有上下文但噪声多 |
| 仅 diff | ~9.4k | 中高 | 足够判断绝大多数问题 |
有些场景确实需要更多上下文(比如判断"这个工具函数是不是重复实现"),处理方式是按需追加:让模型先看 diff,如果它明确表示"需要看某个文件的完整定义",再发起一次追加调用。让模型主动要上下文,而不是无脑喂。
3. 第一步:提取 diff
# review.py (1/4) —— diff 提取与分片
import json, re, subprocess
from fnmatch import fnmatch
IGNORE_PATTERNS = [
"*.lock", "*-lock.json", "*.min.js", "*.min.css",
"dist/*", "build/*", "vendor/*", "generated/*",
"*.pb.go", "*_generated.ts", "*.svg", "*.png", "*.jpg",
]
# 高风险路径:这些目录的改动值得单独成片、单独提级
HIGH_RISK_PATHS = [
"**/auth/**", "**/payment/**", "**/crypto/**",
"**/migrations/**", "**/*.sql", "**/config/prod*",
]
MAX_LINES_PER_CHUNK = 400
def sh(cmd: str) –> str:
return subprocess.run(cmd, shell=True, capture_output=True, text=True).stdout
def get_changed_files(base: str, head: str) –> list:
out = sh(f"git diff –name-only –diff-filter=ACMR {base}…{head}")
files = [f.strip() for f in out.splitlines() if f.strip()]
return [f for f in files if not any(fnmatch(f, p) for p in IGNORE_PATTERNS)]
def get_file_diff(base: str, head: str, path: str) –> str:
return sh(f"git diff -U5 {base}…{head} — {path}")
def is_high_risk(path: str) –> bool:
return any(fnmatch(path, p) for p in HIGH_RISK_PATHS)
def chunk_files(base: str, head: str) –> list:
"""把变更文件切成评审单元:单文件 diff 过大时按 hunk 边界再切"""
chunks = []
for f in get_changed_files(base, head):
diff = get_file_diff(base, head, f)
if not diff:
continue
lines = diff.splitlines()
if len(lines) <= MAX_LINES_PER_CHUNK:
chunks.append({"path": f, "diff": diff, "highRisk": is_high_risk(f)})
continue
# 按 @@ hunk 头切分,累加到不超过上限为止
hunks, cur = [], []
for ln in lines:
if ln.startswith("@@") and sum(len(x) for x in cur) > MAX_LINES_PER_CHUNK:
hunks.append("\\n".join(cur))
cur = [ln]
else:
cur.append(ln)
if cur:
hunks.append("\\n".join(cur))
for i, h in enumerate(hunks):
chunks.append({
"path": f, "part": i + 1, "parts": len(hunks),
"diff": h, "highRisk": is_high_risk(f),
})
return chunks
分片策略的三个考虑:
4. 第二步:提示词与输出契约
你是代码评审助手。下面的 diff 来自一次合并请求,请只针对本次变更给出评审意见。
【绝对约束 —— 违反任何一条,本次评审结果作废】
1. 行号必须来自 diff 中的新增行(以 "+" 开头,且不是 "+++")。
禁止引用未变更的行,禁止引用被删除的行。
2. 每条意见必须给出 diffFile 与 diffLine(新增行的行号,即目标文件中的行号)。
3. 每条意见必须给出 evidence:直接引用 diff 中对应的那一行原文(不含前导 "+")。
引用必须逐字符一致。无法逐字引用时,说明你不确定,就不要提这条意见。
4. 禁止对以下类型给出意见:代码风格偏好(缩进、空行、引号)、
已在 diff 中变更过的注释、未变更代码的历史问题。
5. 不确定就不要说。你的目标是准确率,不是覆盖率。宁可少提,不要提错。
【你可以提的类型(按可信度从高到低)】
A. 规范一致性:命名、日志格式、错误码、必填注释、团队约定
B. 遗漏检查:异常路径未处理、资源未释放、空值/边界未判、错误被吞
C. 安全:硬编码凭据、SQL 拼接、越权、敏感信息进日志
D. 可读性:重复逻辑、过长函数、晦涩命名
【谨慎提、且 severity 不得高于 "low" 的类型】
E. 性能(N+1 查询、循环内 IO)
F. 并发(共享状态、锁粒度、竞态)
【输出 JSON(不要 Markdown 包裹,不要额外文字)】
{
"fileCount": 1,
"comments": [
{
"severity": "high|medium|low",
"category": "A|B|C|D|E|F",
"diffFile": "path/to/file",
"diffLine": 128,
"evidence": "逐字引用的新增行原文",
"problem": "问题描述,不超过 80 字",
"suggestion": "具体改法,可含代码片段",
"canBlock": false
}
],
"needMoreContext": [
{ "file": "path", "symbol": "funcName", "why": "需要确认是否重复实现" }
],
"summary": "不超过 60 字的本片评审结论"
}
【diff】
{DIFF}
提示词设计的四个关键点(这些都不是"优化",是"必需"):
5. 第三步:防幻觉的三道闸
模型一定会编。这不是"它不听话",是它的工作机制决定的。所以必须有确定性的校验代码兜住。
闸门 1:行号锚定
拒绝任何引用不存在的新增行的评论。
# review.py (2/4) —— 闸门 1:行号锚定
import re
from collections import defaultdict
HUNK_RE = re.compile(r"^@@ -\\d+(?:,\\d+)? \\+(\\d+)(?:,\\d+)? @@")
def parse_added_lines(diff: str) –> dict:
"""返回 {目标文件行号: 该行内容(不含前导+)}"""
added = {}
new_ln = 0
for ln in diff.splitlines():
m = HUNK_RE.match(ln)
if m:
new_ln = int(m.group(1))
continue
if ln.startswith("+++") or ln.startswith("—"):
continue
if ln.startswith("+"):
added[new_ln] = ln[1:]
new_ln += 1
elif ln.startswith("-"):
pass # 删除行不增加新文件行号
elif ln.startswith(" "):
new_ln += 1
return added
def gate_line_anchor(comments: list, diff: str) –> tuple:
added = parse_added_lines(diff)
kept, rejected = [], []
for c in comments:
line_no = c.get("diffLine")
ev = (c.get("evidence") or "").strip()
if line_no not in added:
rejected.append((c, "diffLine 不是新增行"))
continue
# 逐字比对(容忍首尾空白差异)
if ev and ev != added[line_no].strip():
rejected.append((c, f"evidence 与实际不符: 声称={ev!r} 实际={added[line_no].strip()!r}"))
continue
kept.append(c)
return kept, rejected
闸门 2:类别可信度分级
# review.py (3/4) —— 闸门 2:可信度分级 + 降级
# 只允许这两种类别阻塞合并;其余类别无论模型说得多严重,都只做提示
BLOCKABLE_CATEGORIES = {"C", "B"}
BLOCKABLE_SEVERITY = {"high"}
# 弱区类别:无论模型输出什么 severity,强制降级
WEAK_CATEGORIES = {"E", "F"}
MAX_SEVERITY_FOR_WEAK = "low"
SEVERITY_ORDER = {"high": 3, "medium": 2, "low": 1}
def gate_severity(comments: list, high_risk_file: bool) –> list:
out = []
for c in comments:
cat = c.get("category")
sev = c.get("severity", "low")
if cat in WEAK_CATEGORIES:
if SEVERITY_ORDER[sev] > SEVERITY_ORDER[MAX_SEVERITY_FOR_WEAK]:
c["originalSeverity"] = sev
sev = MAX_SEVERITY_FOR_WEAK
c["downgradedReason"] = "性能/并发类为模型弱区,已自动降级"
c["canBlock"] = False
c["severity"] = sev
c["canBlock"] = (
cat in BLOCKABLE_CATEGORIES
and sev in BLOCKABLE_SEVERITY
and c.get("canBlock", False)
)
out.append(c)
return out
闸门 3:二次校验(把建议真的跑一遍)
对"可直接采纳"的代码建议,应用到临时分支并跑静态检查/编译。通过才标注为"已验证",否则降级为"仅供参考"。
# review.py (4/4) —— 闸门 3:建议的语法级验证
import subprocess, tempfile, os, textwrap
def verify_suggestion(repo: str, file_path: str, line_no: int, suggestion: str, lang: str) –> dict:
"""
把 suggestion 应用到文件副本,跑对应语言的语法检查。
注意:这里只做"语法/静态"级验证,不做行为验证。
"""
full = os.path.join(repo, file_path)
if not os.path.exists(full):
return {"ok": False, "reason": "文件不存在,拒绝验证"}
original = open(full, encoding="utf-8").read()
lines = original.splitlines()
# 简化处理:把 suggestion 中的代码块替换掉目标行
code = _extract_code(suggestion)
if not code:
return {"ok": False, "reason": "建议中无可提取代码,仅作文本提示"}
patched = lines[:line_no – 1] + code.splitlines() + lines[line_no:]
with tempfile.TemporaryDirectory() as td:
tmp = os.path.join(td, os.path.basename(file_path))
open(tmp, "w", encoding="utf-8").write("\\n".join(patched))
check_cmd = {
"python": f'python -m py_compile "{tmp}"',
"typescript": f'npx –yes tsc –noEmit –allowJs "{tmp}"',
"go": f'gofmt -e "{tmp}" > /dev/null',
}.get(lang)
if not check_cmd:
return {"ok": False, "reason": f"未配置 {lang} 的校验命令"}
r = subprocess.run(check_cmd, shell=True, capture_output=True, text=True)
return {"ok": r.returncode == 0, "stderr": r.stderr[–500:]}
def _extract_code(suggestion: str) –> str:
m = re.search(r"```[a-zA-Z]*\\n(.*?)```", suggestion, re.S)
return m.group(1) if m else ""
三道闸的回合效果(8 周统计):
| 行号锚定 | 11.3% | 引用未变更行、引用删除行、行号算错 |
| evidence 逐字比对 | 6.8% | 编造不存在的代码内容 |
| 类别降级 | 21.5% | 性能/并发类的高严重度误报被降为提示 |
| 二次校验 | 4.1% | 建议的代码片段本身有语法错误 |
也就是说:如果不加这三道闸,大约 34% 的评论是"看起来有道理但站不住"的。 这个比例足以在两周内摧毁团队对工具的全部信任。
6. 成本表
同一仓库、8 周、共 1,043 个 MR:
| 送审内容 | 变更文件全文 | 仅 diff | — |
| 单 MR 输入 token | 68,200 | 9,400 | -86% |
| 单 MR 分片数 | 1(超长被截断) | 3.2 | 截断问题消失 |
| 单 MR 输出 token | 2,100 | 780 | -63% |
| 单 MR 积分消耗 | 5.6 | 1.1 | -80% |
| 单 MR 评审耗时 | 92s | 34s | -63% |
| 月积分消耗(1,043 MR 折算) | ≈ 5,840 | ≈ 1,150 | -80% |
成本优化的三个动作,按贡献排序:
注意第 4 行和第 6 行的关系:输出 token 下降 63%,是因为通过提示词约束了"宁少勿错"。输出 token 的下降同时意味着准确率上升——这两件事在这里是同一个方向。
7. 准确率表
抽 200 条实际发出的评论,人工逐条标注"是否有效"(有效 = 真实问题且描述正确):
| D 可读性/规范一致性 | 63 | 62 | 98.4% | 否(仅提示) |
| C 安全 | 27 | 25 | 92.6% | 是 |
| B 遗漏检查(异常/资源/空值) | 41 | 33 | 80.5% | 是(仅 high) |
| A 规范一致性(命名/日志/错误码) | 40 | 38 | 95.0% | 否 |
| E 性能(N+1 等) | 21 | 12 | 57.1% | 否(强制 low) |
| F 并发/竞态 | 8 | 4 | 50.0% | 否(强制 low) |
| 合计 | 200 | 174 | 87.0% | — |
(这张表里的 A/D 有重叠,实际按 category 字段单值统计,A 为命名/日志/错误码类共 40 条。)
怎么用这张表做决策:
- 准确率 ≥ 90% 的类型:可以放心展示在 MR 顶部(高可见位置)。
- 80%~90%:展示,但必须带"请人工确认"标记。
- 50%~60%(性能、并发):折叠显示,默认不展开。这类评论的价值不是"告诉你答案",而是"提醒你这里值得看一眼"——所以要改措辞,从"这里存在 N+1 查询"改成"这个循环里有一次 IO 调用,建议确认是否有必要"。
- 能阻塞合并的只有 2 类。 安全类(C)和遗漏类(B)的 high。其余全部只提示。
一个反直觉的发现:把性能/并发类的措辞从"断言"改为"提问"之后,虽然准确率没变,但团队采纳率从 31% 升到了 58%。原因很简单——一个 50% 准确的断言是骚扰,一个 50% 准确的提醒是有用的问题。
8. 门禁表:什么能卡住合并
| C 安全类 且 severity=high 且 evidence 校验通过 | 阻塞 | 准确率 92.6%,且后果不可逆 |
| B 遗漏类 且 severity=high 且二次校验通过 | 阻塞 | 准确率 80.5%,但漏掉空指针/资源泄漏的代价高 |
| E/F 性能并发类 | 仅评论,折叠 | 准确率 ~50%,卡门禁会变成"绕过门禁的训练营" |
| D/A 规范类 | 仅评论,可一键标记忽略 | 准确率极高但不应阻塞 |
| 行号锚定或 evidence 校验失败 | 丢弃,不展示 | 不可验证的意见等于噪声 |
"可一键标记忽略"这个功能很重要。 规范类的问题总有例外(比如生成的代码、特殊的兼容处理)。允许作者标记"已知例外 + 理由",超过 5 次同样的例外就自动升级为规则文件的例外条目。让工具的误报变成规则的一次迭代,而不是一次争吵。
9. CI 集成
# .github/workflows/ai-review.yml
name: AI Review
on:
pull_request:
types: [opened, synchronize, ready_for_review]
concurrency:
group: ai–review–${{ github.event.pull_request.number }}
cancel-in-progress: true # 新 commit 到来时取消上一次评审,避免重复计费
jobs:
review:
if: github.event.pull_request.draft == false
runs-on: ubuntu–latest
permissions:
contents: read
pull-requests: write
steps:
– uses: actions/checkout@v4
with:
fetch-depth: 0 # 需要完整历史才能做三点 diff
– name: Extract diff & chunk
id: chunk
run: |
git diff –name-only origin/${{ github.base_ref }}…HEAD > /tmp/changed.txt
python3 scripts/review.py chunk \\
–base origin/${{ github.base_ref }} –head HEAD \\
–out /tmp/chunks.json
– name: Run AI review
env:
REVIEW_MODEL_ENDPOINT: ${{ secrets.REVIEW_ENDPOINT }}
run: |
python3 scripts/review.py run \\
–chunks /tmp/chunks.json \\
–out /tmp/comments.json \\
–max-concurrency 3 # 控制并发,避免触发限流
– name: Verify (3 gates)
run: |
python3 scripts/review.py verify \\
–comments /tmp/comments.json \\
–out /tmp/verified.json
– name: Post comments
run: |
python3 scripts/review.py post \\
–comments /tmp/verified.json \\
–pr ${{ github.event.pull_request.number }}
– name: Gate
run: |
python3 scripts/review.py gate \\
–comments /tmp/verified.json \\
–fail-on-block # 有 blocking 评论时 exit 1
CI 集成的四个实践细节:
10. 经验与反模式
三个有效做法
四个反模式(都真实踩过)
| 让 AI 输出"这个 MR 做了什么"的摘要 | 高成本、零价值,人自己能看 | 摘要只保留"本次变更涉及的高风险点" |
| 把 review 结果发到群里 | 通知疲劳,两周后没人看 | 只发到 MR 里,群通知只发阻塞项 |
| 要求"覆盖率达到 100%" | 被迫编造问题填补数量 | 明确说"宁少勿错,不确定不要说" |
| 用 AI 评审替代人工 review | 弱区(并发/架构)问题全部漏掉 | AI 做甜区,人做判断区 |
一句话总结
AI 代码评审要解决的不是"评审覆盖率",而是"人的注意力分配"。 把规范检查、遗漏检查这些枯燥但重要的事交给它,把人从第 20 个文件的走神里解放出来,去做那件只有人能做的事——判断这个设计是不是对的。
上线 8 周后,最直接的收益不是"发现了多少 bug"(这个数字不显著),而是:review 的平均响应时间从 4.2 小时降到 1.8 小时,且线上因规范类问题导致的返工减少了 61%。 工具的价值往往不在它最显眼的那一面。
网硕互联帮助中心


评论前必须登录!
注册