1. 代码评审的隐形瓶颈:PR越积越多,问题却还是那几类
接手维护一个中等规模的团队仓库之后,我第一个直观感受是:PR不是在“排队”,是在“堆尸”。开发高峰期一个下午能有十几条PR涌进来,人工评审平均要等半天到一天,小的逻辑问题反复在不同PR里出现——键名写错、异常被吞、依赖没锁版本、死代码没删。不是评审人不认真,而是人脑在短时间内处理几十个跨模块的diff时,天然只能抓大放小。小问题漏掉,往往要到code review结束、合并之后,再由线上告警来“提醒”。
那时候我开始认真考虑一件事:能不能让一个自动化的审查Agent先进场,把所有机械的、重复的、规则明确的检查全部吃掉,把人力的注意力聚焦到真正的架构设计和业务逻辑上。于是就有了这篇博文的主角——Hermes,一个跑在GitHub工作流里的自动化代码评审智能体,专门做PR审查这一件事。
先说清楚它能干什么:每当有人打开或更新一个PR,Hermes自动拉取变更内容,结合仓库内的既有规则、依赖关系、静态扫描结果和上下文理解,输出一份评审意见。它不只是跑一下lint,也不只是看有没有密钥,它会把“这段代码改了之后,可能影响谁”“这个并发场景有没有竞态”“这个错误处理路径会不会吞异常”这类需要一定推理的问题一并点在PR里。
这篇文章不是官方文档的复读机,而是我在把Hermes接入真实项目、跑通完整流程、处理了上千条PR审查之后沉淀下来的实践记录。适合两类人看:一类是受困于PR积压、想引入自动化评审又不知道从哪下手的团队维护者;另一类是已经搭了个雏形、但发现规则写得不好、误报太多、大家开始不看的同学。我会把架构、接入步骤、规则设计、成本控制、以及我踩过的坑都摊开讲。
2. Hermes审查流水线的整体架构:一次PR事件到评论回写的完整链路
2.1 从Webhook到评论,中间究竟发生了什么
很多人以为自动化评审就是把LLM接进来,丢一段diff进去,然后让它输出评论。真要这么简单,项目早就满地跑了。实际落地时,最难的部分不在“让模型看懂代码”,而在“让整个链路稳定、可控、不会把仓库搞乱”。
我的落地架构大概分为五层:
- 触发层:监听GitHub Webhook事件,主要是
pull_request下的opened、synchronize、reopened、ready_for_review,以及pull_request_review_comment用于响应后续追问。 - 数据层:根据PR编号拉取元信息(标题、描述、提交列表、Changed Files)、每个文件的diff、基础分支与当前分支的对比快照。
- 分析层:跑静态检查脚本、读取仓库规则配置,再把这些结构化结果连同diff一起打包,拼接成交给大模型的分段上下文。
- 决策层:模型输出分文件、分严重级别的审查意见,每个意见附带行号和类别标签。
- 回写层:通过GitHub REST API创建Review,把评论挂到对应代码行上,顺带在Check Run里给一个“审查通过/需要修改”的状态标记。
这五层之间不是串行就算了。我实际做的时候,每一层都加了缓存、重试和熔断。后面讲踩坑的时候会细说,但记住一句话:自动化工具一旦挂在了CI或者合并流程上,它本身就是一套分布式系统,稳定性优先级高于一切炫技功能。
2.2 为什么数据层是整个系统的命门
说个容易让新人翻车的地方:diff数据不是拉下来一份就完事。GitHub的application/vnd.github.diff返回的是基础分支和PR分支的完整对比结果,大仓库一次几万行diff很常见。如果直接把这几万行塞给模型,不说token成本,单是上下文窗口就顶不满。
我的做法是分三档处理:
# 简化示例:按文件大小和改动行数分档 PRICING_BUCKET = { "small": {"max_changed_lines": 50, "max_file_size": 100_000}, "medium": {"max_changed_lines": 300, "max_file_size": 500_000}, "large": {"max_changed_lines": 2000, "max_file_size": 2_000_000}, } def classify_file(diff_entry): changed_lines = diff_entry["additions"] + diff_entry["deletions"] file_size = diff_entry["file_size"] if changed_lines <= 50 and file_size <= 100_000: return "small" if changed_lines <= 300 and file_size <= 500_000: return "medium" return "large"小文件直接完整送入分析;中等文件做基类上下文剪裁,只保留引用到的符号定义;大文件默认只审diff涉及的函数和类,不展开整段历史逻辑。
这样做还有一个好处:当某个文件被修改了2000行以上,你其实不太需要Agent逐行看——人也不会这么看。正确地退化为“只做变更影响评估,不做行级评论”,反而比硬塞结果更可用。
2.3 分析层为什么要“多管齐下”,而不是只信大模型
纯靠大模型做代码审查,短期看着惊艳,长期用会有两个问题:一是幻觉会编造不存在的风险点,二是对仓库里已经存在的、别人定死的规矩一无所知。所以我的分析层里,大模型只是其中一个组件,前面还挂了老实的确定性检查:
- gitleaks扫密钥,只输出新增泄露,不翻历史旧账;
- eslint/checkstyle直接读仓库现有配置,把报错项作为硬性输入;
- 自定义脚本来查“允许出现在PR中的调试标记”,比如
debugger、console.log、print(); - 依赖锁定文件的diff对比,确认有没有在非预期情况下改lock文件。
这批输出会以类似“工具结果引用”的方式拼进给模型的上下文里。实现时我在Prompt里写明:“以下工具输出是真实扫描结果,如果报告中标注了ERROR,你必须将其视为优先级最高的阻塞项。”这一步是为了绑住模型的手脚,避免它自由发挥把误报当成真问题,也避免它漏掉机器已经查出来的硬伤。
3. 审查规则的分层设计:哪些该拦死、哪些该提示、哪些该闭嘴
3.1 规则三层模型:硬阻塞、软反馈、静默建议
我见过最失败的自动化评审配置,是把所有规则全部设成error级别。结果就是PR里飘着五十条评论,真正有用的三条被淹没,开发直接把机器人拉黑。所以从我第一天接入Hermes,就确立了一个原则:自动化Agent的输出必须分层,而且要允许仓库owner通过配置调整每一层的强度。
我常用的三层模型长这样:
| 层级 | 用途 | 典型例子 | 对应操作 |
|---|---|---|---|
| L1 硬阻塞 | 合并前必须解决 | 密钥泄露、高危依赖、明显的空指针、死锁、破坏性API变更未做兼容 | review状态置为REQUEST_CHANGES |
| L2 软反馈 | 应该讨论 | 缺少错误处理、并发风险、资源未关闭、测试覆盖不足 | 行级comment,不阻塞合并 |
| L3 静默建议 | 可选优化 | 命名统一、函数拆分、注释补充、代码重复 | 汇总到一个总评里,不逐行刷屏 |
L1规则数量我刻意控制在20条以内,全部是确定性问题,规则越少越容易维护,误报率越低。L2是主力,大部分时候都是“你这里的错误处理吞掉了异常,建议至少log一下”这类。L3有一个关键约束:不在文件diff里散落评论,而是合并成一条“优化建议汇总”,挂在PR总览里。
3.2 仓库级配置:用YAML让每条规则都可被覆盖
Hermes的规则配置文件我放在仓库根目录下的.hermes/rules.yml里。设计上先给一套默认值,再允许仓库自行覆盖阈值、开关和汇报级别。
rules: # 硬阻塞L1 secret_detection: enabled: true level: block tools: [gitleaks] invalid_migration: enabled: true level: block pattern: "db/migrate/.*" hint: "数据库迁移文件不允许在普通功能PR中出现" # 软反馈L2 unhandled_error: enabled: true level: warn confidence_threshold: 0.7 # 置信度低于70%时降级为静默 test_coverage_for_new_code: enabled: true level: warn required_files: ["**/*_test.*", "**/*.spec.*"] ignore_paths: ["docs/**", "examples/**"] # 静默建议L3 repeated_code: enabled: true level: silent max_occurrences: 3 keywords: - "TODO" - "FIXME" - "HACK" exclude_paths: - "dist/**" - "build/**" - "vendor/**" - "*.lock" - "package-lock.json" - "yarn.lock"有几个细节值得注意。第一,confidence_threshold是对模型输出置信度的“降级开关”,我要求模型在输出每条评论时同时给0到1的置信度,低于阈值的自动降到L3,而不是直接丢弃。这样既保留信息,又不会污染主要评论流。第二,exclude_paths里的锁文件是必加的,否则每次依赖升级都会触发一堆没营养的文件diff审查。第三,规则的hint字段是对模型的额外引导,比如“数据库迁移文件不允许出现在普通PR中”这条,如果不给hint,模型很可能看不出来文件名和目录的含义。
3.3 默认规则会漏掉的那两类“人的直觉”
规则文件写得再细,也覆盖不了所有情况。我用了一段时间后发现,模型有几类判断是规则脚本永远给不了的,而恰恰是人工评审最希望它盯住的。
第一类是“变更影响范围”。比如你改了一个底层的SessionManager类,它被几十个Service引用,正常的规则检查只会看你这一处改动有没有语法错误,但Hermes会去读引用关系,然后提示“这个改动会影响以下模块的初始化流程,建议补一下冒烟测试”。这是我在Prompt里加了一个任务角色定义才做到的,我让它先读目录结构和引用关系,再对改动做影响面分析,最后才落到具体的行级评论。
第二类是“历史上下文关联”。很多bug不是这次PR引入的,而是这次PR把原本的一个假设打破了。比如说,某个函数一直没做空值校验,是因为顶层已经保证不为空,而你这次新增的调用路径绕过了顶层保护。这种问题本质上是“跨文件、跨历史”的推理,大模型相比纯规则工具有天然优势,也确实是它在整个体系里最值钱的部分。
4. 从零接入:注册GitHub App、配置Webhook与首次审查
4.1 选GitHub App而不是个人Token
如果你只是想在本地试一下,用个人访问Token当然快。但要在团队仓库里正式用,我强烈建议以GitHub App的方式接入。原因有三个:
- 权限可以收敛到只能读代码、写PR评论,不会碰到仓库设置、分支保护这些高危权限;
- App安装到仓库后,与个人账号解耦,谁维护这套系统的密钥都不会影响个人账号安全;
- GitHub App的Token是短期动态生成的,比一个长期有效的Token泄露了要好处理得多。
注册路径是在GitHub账号的Settings -> Developer settings -> GitHub Apps里新建一个App。我当时设置的权限大致是:
| 权限项 | 权限值 | 用途 |
|---|---|---|
| Pull requests | Read & write | 创建Review、发表评论 |
| Checks | Read & write | 写Check Run状态 |
| Contents | Read-only | 拉取代码和diff |
| Metadata | Read-only | 获取仓库基础信息 |
| Issues | Read-only | 读取PR关联的issue上下文 |
4.2 Webhook事件的取舍与配置
Webhook不是把所有事件都勾上就行。勾太多,你的服务会收到大量无关请求;勾太少,又会有一些PR场景漏触发。我跑了两个月后,最终固定在下面四个事件上:
pull_request.opened:新PR进来,首轮审查。pull_request.synchronize:PR更新了提交,做增量复审。pull_request.reopened:关闭的PR重新打开,重新审查。pull_request.ready_for_review:从Draft转成正式PR,启动审查。
注意不要勾pull_request.edited。只改标题和描述不会影响代码质量,反而会触发大量无效审查,浪费token,也容易让团队觉得这个机器人在乱刷存在感。
Webhook的Payload URL指向你部署的服务地址,比如https://hermes.example.com/github/webhook。GitHub会验签,你在App设置里生成一个Webhook Secret,服务端校验证书签名后才会继续处理。
# 用Flask实现的极简入口,重点是验签 from flask import Flask, request, abort import hmac, hashlib, os app = Flask(__name__) SECRET = os.environ["GITHUB_WEBHOOK_SECRET"] @app.post("/github/webhook") def handle_webhook(): signature = request.headers.get("X-Hub-Signature-256", "") body = request.get_data() expected = "sha256=" + hmac.new( SECRET.encode(), body, hashlib.sha256 ).hexdigest() if not hmac.compare_digest(signature, expected): abort(401) event = request.headers.get("X-GitHub-Event") payload = request.get_json() # 只处理我们需要的事件 if event == "pull_request": action = payload.get("action") if action in ("opened", "synchronize", "reopened", "ready_for_review"): enqueue_review_task(payload) return {"ok": True}其实这一层不需要做得太重,关键是不要阻塞Webhook请求。GitHub的Webhook有超时阈值,如果服务在10秒内不返回,它会认为投递失败并进入重试队列。所以我这里只做验签和入队,真正的审查逻辑丢给后台worker异步执行,这样体验最稳。
4.3 首次审查的完整流程
假设你已经把App装到了测试仓库,接下来第一次真实运行的流程是:
- 开发者在仓库里创建一个PR,事件触发,服务端收到Webhook。
- 服务端用App的私钥动态生成一个Installation Token,用这个Token去调GitHub API。
- 它先拉取PR的
files接口,拿到所有变更文件的列表和diff。 - 读取仓库根目录下的
.hermes/rules.yml,合并默认规则。 - 对文件做分档处理,小文件直接审,大文件做上下文剪裁。
- 拼接成Prompt后发给模型,模型返回结构化JSON。
- 服务把JSON里的每一条评论映射回具体的文件、行号,调用create review接口。
- 如果存在L1规则命中,Review状态置为
REQUEST_CHANGES;否则置为COMMENT。 - 最后写一个Check Run,名字叫
hermes/code-review,结论为success或neutral。
第一次跑通这个流程的时候,我盯着PR页面上冒出来的那几条评论看了半天,老实说有些震撼。不是因为它写得有多惊艳——实际上里面有一条误报,后来我调了规则——而是整个闭环完全自动,从开发push代码到评论出现在PR上,只需要一两分钟。
4.4 自托管服务的部署要点
服务本身我用Docker部署在一台2C4G的机器上。这个配置对单仓库、日审查量100次以内的场景绰绰有余,瓶颈从来不在算力,而在API调用频率。如果你有多个仓库要接,建议队列和worker分离,同时给GitHub API限流留好余量。
# docker-compose.yml关键片段 services: hermes: image: hermes-agent:latest env_file: .env ports: - "8080:8080" volumes: - ./data:/data # 存放审查历史记录 restart: unless-stopped.env里至少要配这几个变量:
GITHUB_APP_ID=123456 GITHUB_APP_PRIVATE_KEY_PATH=/run/secrets/hermes.private-key.pem GITHUB_WEBHOOK_SECRET=your_webhook_secret MODEL_API_BASE=https://api.example.com/v1 MODEL_API_KEY=sk-xxxx MODEL_NAME=hermes-1.5 REVIEW_MAX_FILES_ONCE=30 REVIEW_WORKER_CONCURRENCY=4这里REVIEW_MAX_FILES_ONCE是一个我自己加的保险。有的PR一次性改了七八十个文件,全部塞进一次审查会非常慢。我设置的30个文件上限,一旦超过,就拆成多批审查,或者只审其中改动最大的一半文件,剩余的在总评里提示“由于文件数量较多,本次仅完成前30个文件的逐行审查”。
5. 评论风格工程:把自动回复写得像人话,且不惹人烦
5.1 自动化评论的“刷屏灾难”是怎么发生的
接入Hermes两周后,我第一次收到团队成员的抱怨,不是“审查有误报”,而是“评论太多了,PR页面像弹幕”。点开每条评论看内容,单独拎出来都算合理,但几十条堆在一起,体验非常差。人看代码评审的时候,注意力是有限的,评论密度一旦超过某个阈值,反而会漏掉真正重要的问题。
那次之后我做了三处调整,可以说直接影响了这个工具在团队里能不能活下来:
- 同一类型的问题只报一次,不逐行刷屏。比如一个文件里有5处
console.log,只挂一条评论,列出行号清单。 - 行级评论只留给L1和L2,L3绝不逐行出现。
- 每次PR审查的总评论数设了上限,默认20条,超过之后其余的自动合并进总评。
5.2 评论模板的标准化
固定评论格式能显著提升阅读效率。早期让模型自由发挥,风格千奇百怪,有些评论长到像一篇小作文,有些又短到看不出问题在哪。后来我要求输出严格走模板,每条评论必须包含四个部分:问题摘要、影响分析、修改建议、严重级别。
**Issue**: 这里的 session 刷新失败后错误被吞掉了 **Impact**: 用户会在登录状态过期后看到空白页,而不是被引导到登录页 **Suggestion**: - 捕获异常后至少调用 `log_warn()` 记录一条日志 - 返回上游一个可识别的错误码,而不是静默继续 **Level**: L2 (warn)这个模板的妙处在于,它把“批评”变成了“问题描