1. 为什么我要把 PR 评审交给一个智能体
把 PR 评审这件事从"人肉盯 diff"变成"智能体自动过一遍",是我今年做得最值的一个技术决策。Hermes 最初只是我写的一个脚本:监听 GitHub 的 PR 事件,拉取 diff,丢给大模型,再把评审意见写回评论区。后来它长成了一个带工具调用、有评审规范、能区分严重级别的自动化代码评审智能体,团队里每天二十几个 PR 先过它这一关,人工评审者只需要看它筛出来的结果。
1.1 人工评审的三个真实痛点
先交代背景。我在一个 20 人左右的研发团队,三个后端小组共用一个仓库,平均每天开 20 到 40 个 PR。过去半年我统计过,一个 PR 从提交到拿到第一条有效评审意见,中位数在 6 小时以上。不是没人看,是评审的成本确实太高了:
- 上下文切换成本高。评审人自己手里有开发任务,切过去看 diff,至少需要二十分钟才能完全进入状态,看完还得再切回来,这一来一回,一天的有效工作时间被吃掉一大块。
- 大 PR 的注意力衰减非常明显。超过 500 行的 diff,后半部分的问题检出率会显著下降。这不是个人能力问题,是注意力资源天然有限,前一半看仔细了,后一半自然就松懈。
- 低级问题反复出现。忘记判空、密钥打进了代码、异常被静默吞掉、错误信息不含上下文,这些东西在每个 PR 里都会反复出现,每次都要人肉再抓一遍,极其消耗耐心。
我一开始想的是堆规则,也就是写一堆自定义 lint 规则。但很快发现这条路走不通:规则能抓住模式和格式问题,却抓不住"这个函数在并发场景下会死锁""这里少了一个事务补偿"这种需要理解业务上下文和调用链路的语义问题。
1.2 Hermes 想解决什么问题
后来我把思路从"写规则"换成了"养一个智能体"。Hermes 这个名字是按"信使"的意思取的——它不写代码,它把代码里的信息高效地传达给人类评审者。
Hermes 的实际定位是:一个监听 GitHub PR 事件的自动化代码评审智能体。它拿到 PR 的完整上下文(标题、描述、diff、涉及文件、历史提交),交给具备代码理解能力的大模型做初步评审,再把意见按严重级别写回 PR 的行级评论里。
它能做到的事情有以下几件:
- 在 PR 打开或更新后自动跑一遍评审,分钟级出结果
- 按 blocker / major / minor / nit 四个级别对问题分级,只把真正值得看的顶到前面
- 行级评论,直接定位到具体代码行
- 对已经评论过的问题做指纹去重,不会每次 push 都刷屏
它不是用来替代人工评审的。它更像是把人工评审里最机械、最耗精力的那部分工作删掉了,评审人只需要专注看 Hermes 标出来的高风险区和设计层面的问题。
1.3 这套方案适合谁
如果你满足下面任意两条,就可以考虑做一套类似的方案:
- 团队有固定的 GitHub PR 流程,但评审经常拖延,PR 堆积严重
- PR 里反复出现低级错误,code review 的精力被大量消耗在基础问题上
- 你们已经在用大模型写代码,但对"让模型帮你看代码"这件事还没动手
- 你手里有一台能跑服务的机器,或者仓库能挂 GitHub Actions
对个人开发者来说,这个项目也能简化成"自带一个 Action 的评审机器人",不用自己写服务。后面我会给出两种运行模式的对比。
2. Hermes 的整体设计:不是一条 prompt,是一个有工具的智能体
2.1 事件接入的两种模式
先定架构。Hermes 有两种跑法,分别适合不同场景。
Webhook 常驻服务模式:用 GitHub App 注册一个 webhook,监听pull_request事件。常驻服务收到事件后拉取上下文、调模型、写回评论。优点是实时性好,可以做"评审意见回复"这类交互;缺点是必须部署,webhook 地址要公网可达,还要维护 token 生命周期。
GitHub Actions 模式:把评审逻辑打包成一个 Action,通过 workflow 的on: pull_request触发。计算环境由 GitHub 托管,不用自己部署服务。缺点是跑在 Actions 环境里,没法响应评论交互(除非再去回调 API),而且会消耗 Actions 分钟数。
我最后是两套共用的:核心评审引擎抽成同一个 Python 包,webhook 服务和 Action 都调它。个人项目或小团队用 Action 就够,能省一台机器;需要做交互式评审意见回复的,再上 webhook 服务。
| 维度 | Webhook 服务 | GitHub Actions |
|---|---|---|
| 部署成本 | 需要容器或云函数 | 零,仓库里放 workflow 即可 |
| 触发延迟 | 秒级 | 秒到分钟级 |
| 评论回复交互 | 支持 | 不好做 |
| 网络要求 | 需要公网可达地址 | 无,由 GitHub 托管 |
| 运行成本 | 服务器费用 | Actions 分钟配额 |
2.2 核心模块拆解
整个 Hermes 拆成五个模块,每个模块只干一件事:
event-adapter:接收 webhook 或 Action 的 event payload,统一转成内部的 PRContext 对象context-builder:从 GitHub API 拉取 PR 标题、描述、提交列表、文件列表、完整 diff,做 token 预算控制review-engine:组装评审 prompt,调用大模型,拿回结构化的评审意见result-writer:把意见转成 GitHub Review API 的 payload,批量写回state-store:记录已评审的 commit SHA、已发布的评论指纹,防止重复
这个拆分方式是我重构三次之后才定下的。最早的版本把"拉 diff"和"写评论"全写在主流程里,看起来代码很少,但项目一旦同时接入 Action 和 webhook 两套触发源就非常痛苦——事件来源不同、token 来源不同,唯一的共同点是中间的评审逻辑。把context-builder/review-engine/result-writer拆开之后,换触发源只需要换掉event-adapter。
2.3 为什么是 Python + FastAPI
语言选型上我对比过 Node 和 Python。Node 的优势是 GitHub 官方 SDK 生态成熟,但异步事件循环的写法对团队大多数人来说并不顺手。Python 的好处在于模型生态天然亲和:评审引擎要对接各种大模型的 OpenAI 兼容接口,Python 这边几乎是主场;同时 FastAPI 处理 webhook 的并发请求非常简单,asyncio 下同时拉起几十个 PR 的异步上下文没什么压力。
如果你问我的推荐技术栈,我的答案是:核心评审引擎用 Python,触发层用什么语言不重要,反正最后都是调同一套 HTTP API。
2.4 为什么必须是"智能体"而不是一次性的 prompt
这个点值得多说两句。Hermes 第一版就是一段 prompt:把整个 diff 全量塞给模型,让它输出 JSON 评论。用了两周就暴露了三个问题:
- token 撑不住:一个大 PR 的 diff 有几万行,模型上下文直接爆掉,只能硬截断,评审质量大幅下降
- 缺少全局信息:只看 diff 不看文件全貌,模型经常对修改后的函数产生误判。比如只看到一个函数从"总是返回列表"改成"可能返回 None",就报了一堆 NPE 风险,但调用方其实已经做了处理
- 无法按需验证:模型说"这里会死锁",但你没法让它去确认锁的获取顺序
改成智能体之后,模型不再只依赖一次输入,而是拥有了工具:获取某个文件的完整内容、在仓库里搜索某个函数定义、读取这个 PR 的历史评论。它可以在 diff 的基础上按需补查上下文,这才是"评审"而不是"看补丁"。
3. 从 GitHub API 拉取 PR 上下文的完整链路
3.1 认证方式:PAT 与 GitHub App
先解决认证问题,两条路。
PAT(Personal Access Token):适合个人项目,也适合 GitHub Action 内部使用。只需要repo权限,生成一个 token 放到环境变量里。限速按你的账号算,5000 次/小时,个人实验完全够用。
GitHub App:适合团队和组织场景,权限可以按仓库精细授予。用私钥签发 installation token,token 有效期 1 小时,到期自动轮换。安装级限速,比 PAT 稳定,出了安全问题还可以单独吊销某个安装的权限。
我的建议是:第一步先用 PAT 把链路跑通,确认评审逻辑可靠之后,再换成 GitHub App。不要一上来就搞 App,签名、轮换、权限配置这些会消耗大量注意力,让你没法专注于核心逻辑。
3.2 拉取 PR 元信息与 diff
核心就是三个接口:
GET /repos/{owner}/{repo}/pulls/{pull_number}:PR 标题、描述、base/head、状态GET /repos/{owner}/{repo}/pulls/{pull_number}/files:文件列表及每个文件的 patchGET /repos/{owner}/{repo}/pulls/{pull_number}/commits:提交列表,用于拿到最新的 head SHA
有一个容易踩的坑是 Accept 头。默认返回的是 JSON 格式,patch字段里已经有 diff hunk,但部分场景下你需要的是原始 diff——比如传给模型时,用纯文本+/-格式会比 JSON 转义后的字符串更清晰。这时对 pulls 接口加Accept: application/vnd.github.v3.diff,返回体就是纯文本 diff。
另一个接口细节:files 接口里,超大文件的patch字段可能被截断甚至为 null。GitHub 对超大 diff 有读取保护,拿到的不一定是完整内容。这个坑在第 6 节我会专门讲处理方式。
用 httpx 拉取文件列表的示意代码:
async def fetch_pr_files(owner, repo, pr_number, token, session): url = f"https://api.github.com/repos/{owner}/{repo}/pulls/{pr_number}/files?per_page=100" headers = { "Authorization": f"Bearer {token}", "Accept": "application/vnd.github.v3+json", } files = [] while url: resp = await session.get(url, headers=headers) resp.raise_for_status() page = resp.json() files.extend(page) url = resp.links.get("next", {}).get("url") return files用 requests 也能实现,但我建议直接上 httpx,因为后面 webhook 服务本身是异步的,复用同一个客户端省事很多。
3.3 组装模型输入时的 token 预算控制
拿到 diff 之后不能直接塞给模型。我整理了一套分层策略:
- 过滤不需要评审的文件:lock 文件、vendor 目录、生成的 protobuf、纯 rename 文件(文件名变内容不变)
- 按 token 估算排序:每个文件用 tiktoken 估算 diff 大小,大的排后面
- 设置上下文预算:以 32k 窗口的模型为例,给 diff 留 20k,剩余留给 prompt 指令和后续的文件内容补查
- 超出预算的文件做截断:只保留新增和修改的函数体,删掉未改动的大段 import
token 估算有个经验公式:文本长度 / 3是英文文本的平均 token 数,代码更密集,我一般用len(text) / 2做保守估计;更精确的做法是直接用 tiktoken 按目标模型对应的 encoding 算。
3.4 同一份上下文,喂给模型的三种形态
模型对纯文本 diff 的理解效果其实一般。实践下来,把上下文组织成三个部分效果最好:
- 先给 PR 概况:标题、描述、涉及的模块和文件清单
- 再给文件级变更摘要:每个文件删了多少行、加了多少行、改了哪个函数
- 最后才给 diff 细节:按文件分块,每块前面加一句说明(例如"这是 auth.py 的变更,涉及 login 流程")
顺序很重要。先让模型建立全局认知,再看局部补丁,很多误判会自然消失。这也是我坚持把context-builder单独抽出来的原因——喂料方式本身就是决定评审质量的关键变量。
4. 评审引擎:让大模型真的"会"评审
4.1 评审规范先行
模型能力再强,不给规范它也会乱来。我给 Hermes 写的系统 prompt 核心内容只有四部分:
你是 Hermes,一名资深代码评审工程师。 你的评审边界: 1. 正确性:并发、边界条件、异常处理、状态流转 2. 安全性:注入、密钥泄露、权限绕过 3. 性能:无谓的重复计算、N+1 查询、大对象复制 4. 可维护性:重复代码、过度设计、命名误导 规则: - 只报告你确定存在的问题,不确定的一律不报 - 每一条必须给出可落地的修改建议,不写空话 - 不要评论代码风格、缩进、命名偏好,那是 linter 的事 - 评论必须引用 diff 中真实存在的行 - 输出 JSON 数组,不要输出任何解释性文本这里最难落地的一条是"只报告确定的问题"。大模型的默认倾向是讨好用户,你不警告它,它就会把"我觉得这里可以优化一下"这种废话写满整个屏幕。我把"不确定就不报"放在规则第二位,并且在结果后处理时还会再做一次过滤。
模型后端我们默认接的是 DeepSeek Hermes,任何 OpenAI 兼容接口的模型都可以替换。切换的成本很低,无非是改base_url和模型名。
4.2 用结构化输出约束评审结果
模型输出用 JSON mode / function calling。我们定义的回传结构如下:
[ { "path": "src/auth/login.py", "line": 87, "severity": "blocker", "title": "用户输入直接拼入 SQL 查询,存在注入风险", "body": "query 参数未做参数化处理,建议改为 ...", "confidence": 0.96 } ]把path和line单独拎出来,是为了让result-writer能直接定位;severity用来排序;confidence是让模型自己表态,低于阈值的意见在写回前直接丢弃。
confidence这个字段非常有用。让模型先判断"我有多确定",比事后用规则过滤可靠得多。我们当时把阈值设为 0.7,低于这条线的意见不写回 PR,只在日志里留档。
4.3 严重级别的硬标准
分级不能靠模型拍脑袋,我给每一级都定了硬标准:
| 级别 | 判定标准 | 示例 |
|---|---|---|
| blocker | 会引发线上事故、数据丢失、安全问题 | SQL 注入、密钥硬编码、资源未释放 |
| major | 明显逻辑错误,特定场景下会出错 | 边界条件漏判、并发下状态不一致 |
| minor | 代码可运行但存在隐患 | 重复代码、错误处理不完整 |
| nit | 可改可不改的优化 | 变量命名、微小重构 |
模型在判断级别时经常混淆 minor 和 nit,这没关系,因为最终展示给人类评审者时,我们只默认展开 blocker 和 major 两级,后面两级折叠到"更多建议"里。关键原则是:别让低价值意见刷屏,把真正的问题顶到最前面。
4.4 规则引擎与模型结合,对抗幻觉
模型评审最大的风险是幻觉:它可能评论一个 diff 里不存在的行、引用一个不存在的函数、提出一个与代码事实矛盾的修改方案。我在review-engine前面加了一个规则预检层,把能被确定性规则捕获的问题先抓掉,包括:
- 高熵字符串、疑似密钥/Token
eval、exec、pickle.loads等危险函数调用- SQL 字符串拼接
- TODO/FIXME 残留
- 明显的越权接口缺少鉴权装饰器
规则层确定性 100%,模型层负责语义理解。两者结合之后,规则问题不用浪费模型 token,模型则专注规则抓不住的逻辑问题,整体准确率明显上升。
5. 把评审意见写回 PR:Review API 的接入细节
5.1 行级评论的定位方式
GitHub 的 Review API 有两种提交方式:一种是逐条 POST 创建评论,另一种是整体提交一个 review 附带多条 comments。Hermes 用的是后者:POST /repos/{owner}/{repo}/pulls/{pull_number}/reviews,一次性把整个 PR 的评审意见作为一个 review 提交。这样既减少了 API 调用次数,也方便人类评审者一眼看清"这是一轮机器人评审"。
payload 长这样:
{ "commit_id": "6dcb09b5b57875f334f61aebed695e2e4193db5e", "event": "COMMENT", "comments": [ { "path": "src/auth/login.py", "side": "RIGHT", "line": 87, "body": "query 参数未做参数化处理,存在 SQL 注入风险。建议改为参数化查询。" }, { "path": "src/config.py", "side": "RIGHT", "start_line": 42, "line": 45, "body": "这段配置加载逻辑在并发场景下可能重复初始化。" } ] }这里要特别注意 GitHub 的评论定位语义。新版 API 用line指定文件新版本的行号(如果是针对删除的代码,用side: LEFT指定旧版本行号)。很多人第一次写都会被网上老教程带偏,去用position(diff hunk 内的位置)字段——这个字段已经废弃了,新代码一律用line+side。
还有一个隐性问题:GitHub 只允许在 diff hunk 内(含少量上下文行)评论。如果模型返回的line不在 hunk 范围内,API 会直接报 422 错误。所以result-writer必须做一次行号合法性校验,把模型返回的line跟实际 diff 的 hunk 行号集合比对,不在范围内的意见降级为在文件末尾追加一条汇总评论。
5.2 多行评论与 review 的 event 选择
多行评论用start_line指定起始行,line指定结束行,并且两侧必须同side。如果模型给出的问题跨多个连续行,用这种形式比逐行评论体验好很多。
另外event字段有三个取值:COMMENT/APPROVE/REQUEST_CHANGES。机器人推荐用COMMENT,不要用REQUEST_CHANGES。因为机器人的判断不可能 100% 准确,如果它REQUEST_CHANGES,会直接阻塞合并流程,产生不必要的摩擦。如果真想接合并门禁,应该用 GitHub Checks API 做结论状态,而不是滥用 review 的 approve/reject 语义。
5.3 防止重复评论的指纹机制
PR 每次 push 都会触发synchronize事件,不做去重的话,Hermes 会把同一个问题在每一轮都刷一遍。我的方案是在state-store里存两个东西:
- 已评审的 commit SHA:同一 SHA 不重复跑
- 评论指纹:对
模型名 + 文件路径 + 行号 + 问题标题做 SHA256,写回前查一下有没有发过
指纹机制还有一个附加好处:当问题在新提交里被修复了,Hermes 不会基于旧 diff 再发一遍过期评论,因为指纹校验时会发现旧行号对应的代码已经变化,模型输出的 context 对不上,就会判定为失效意见并丢弃。
6. 上线后踩过的坑:完整排查链路
6.1 事件收到了,评论却永远发不出去
上线的第一个晚上,日志显示 webhook 正常收到pull_request.opened,context-builder也把 diff 拉回来了,模型也输出了意见,但result-writer一直在抛 422。
排查链路从外到内走了一遍,最终定位在行号上。错误信息是 "line must be part of the diff"。原因:模型输出的line是从 patch 文本里读到的"相对位置",而不是文件新版本的行号。diff 里 hunk 头@@ -12,6 +15,8 @@表示新文件从第 15 行开始,但模型可能把 hunk 内的第 3 行当成了文件第 3 行。
修法是在context-builder里提前把每个 hunk 的新旧文件行号映射表算出来,喂给模型时直接告诉它"这些是合法行号,你只能在这里面选",从源头消灭越界。
这个坑事后看很基础,但当时花了我一整个晚上。原因是 GitHub 文档里 diff 行号、hunk 行号、文件行号是三个概念,一开始没有仔细区分。经验是:写result-writer之前,先用几个小 PR 做回归测试,把行号计算方式验证一遍。
6.2 大 diff 被截断,评审出现"评价缺失"
上线第七天,一个重构 PR 有 3000 多行变更,Hermes 只评论了两个文件。查日志发现context-builder里 files 接口返回的patch字段是 null,导致整个文件被跳过。
GitHub 对超大文件和超大 diff 有读取保护,patch字段可能被省略。修法是分层拉取:
- 先通过 files 接口拿文件清单和变更统计
- 对
patch为 null 的文件,改用 raw contents 接口分别拉取 base 和 head 两个版本的文件内容,自己算 diff - 文件实在太大(比如超过 5000 行),就直接放弃展示完整 diff,只把变更的函数签名和文件摘要给模型,让模型决定是否需要申请读取文件内容
另外,一次 review 请求里的comments数组是有上限的,我记得当时踩到的是 100 条左右,具体数值要以 API 文档为准。超出上限就分批提交成多轮 review。上线的第一个月我没做分批,结果有一个 PR 因为评论量过大直接 422,整轮评审失败。
6.3 每次 push 都在刷屏,团队直接静音
拆指纹机制之前,一个 PR 只要 push 三次,Hermes 就会发三轮几乎一样的评论。团队成员很快把它的通知静音了,真正的风险问题也被一起忽略——这是比刷屏本身更严重的问题:信任一旦耗尽,工具就失去了价值。
去重逻辑在 5.3 已经讲了,这里补充一个细节:指纹要存到数据库或对象存储,不能只存内存。webhook 服务重启之后内存指纹就丢了,第二天一上线又开始刷屏,我当时就被这个低级失误坑过。
6.4 API 限流是怎么被触发的
跑了一个月之后,某天半夜有一批历史 PR 需要重跑评审,把 PAT 的 5000 次/小时限额打满了,之后一整天所有请求都 403。排查发现是context-builder为了拿每个文件的完整内容,对同一个 PR 发了几十次请求,重跑任务又堆叠在一起,瞬时请求量远超预期。
三个整改措施:
- 全链路加内存缓存,同一个 PR 的上下文在 10 分钟内不重复拉取
- 评论提交改成批量 review,一次 API 调用解决所有评论
- 加指数退避重试,并监控
X-RateLimit-Remaining响应头;剩余额度低于 500 时自动降级,只评审 blocker 级别风险,其他问题临时跳过
6.5 模型幻觉的高发场景与打压手段
我把前 100 个 PR 的错误评论全部拉出来做标注,幻觉高发场景主要有三类:
- 建议修改一个不存在的函数——模型从其他文件或训练记忆里串过来了
- 对加密/签名代码提出"安全问题"——因为它不认识这个代码模式
- 引用的代码行内容和实际不符——多行 diff 合并时行号错位
针对这三类,最直接有效的手段是:在 prompt 里强制要求每条评论的 body 必须以 diff 原文引用开头,比如代码:query += user_input,然后result-writer校验这个引用是否真的存在于 diff 中,校验不通过的直接丢弃。这个方法简单粗暴,但确实把幻觉评论率从 22% 降到了 6% 左右。
7. 效果评估与下一步扩展
7.1 我用三个指标衡量 Hermes 的价值
跑了三个月、累计 240 个 PR 之后,我统计了三个数字:
- 有效问题率(precision):Hermes 累计报了 1180 条意见,人工评审标注认同的 437 条,整体 precision 约 37%。但如果只看 blocker 和 major 两级,precision 能到 58%。也就是说,严重级别越高,模型判断越可靠,这正好符合它的使用定位。
- 评审耗时:PR 从提交到获得第一条有效评审意见的中位数,从 6.2 小时降到 1.8 小时。省掉的主要是"等人工评审者切换上下文"的时间。
- 人工评审行为变化:在 Hermes 覆盖的 PR 里,评审人在行级评论上花的时间少了大约 30%,这部分时间被转移到了讨论设计和架构方向上。
37% 的整体 precision 听起来不高,但注意这是"所有意见"的统计,里面包含大量 minor 和 nit。对机器人来说这个数字已经足够有价值——它单次运行成本只有几分钱到几毛钱,而每一批意见里都有值得人类花时间看的内容。
7.2 后续迭代的几个方向
按目前的使用反馈,后面几个迭代方向基本确定了:
- 支持评审对话:人工评审者在 PR 里回复"这个建议不成立,因为 XXX",Hermes 通过 webhook 接收评论事件,重新分析并更新自己的意见
- 接入 GitHub Checks API 做门禁:当存在未解决的 blocker 时,在 PR 状态区显示失败。是否启用合并保护,由团队按项目情况决定
- 与静态扫描工具联动:让 Hermes 专注语义级问题,重复代码、坏味道交给 SonarQube 这类工具,避免重复劳动
- 多模型路由:小 PR 用便宜的小模型,大 PR 用最强的模型,按 diff 行数和复杂度动态路由,控制成本
跑了这段时间,我个人的体会是:自动化代码评审真正的难点从来不是接入 API 或写 prompt,而是设定一个合理的期望边界。你不能指望它替代人类评审,它是把人从"验收代码能不能跑"的机械劳动里解放出来,让人把精力放回"这个设计方向对不对"。
最后分享一个小技巧:在 Hermes 的每条评论末尾加了一行_Hermes bot · 置信度 0.96_。人工评审者会先看置信度和级别,再决定要不要展开。让机器人自己承认"我不确定",反而比假装权威更容易建立信任。