
在团队里待久了你会发现PR审查这件事往往不是“代码质量问题”而是“人的精力问题”。一个稍微像样点的项目每天涌进来的PR少说五六个多的时候十几个。逐行看过去逻辑、命名、安全、测试、边界条件真看下来一个PR至少二十分钟。而且最磨人的不是难是重复——每次都要检查有没有写死密码、有没有绕过鉴权、有没有忘加空指针判断。就是因为被这种重复劳动逼到烦了我才动手搞了 Hermes 这个项目专门做 GitHub PR 的自动化代码评审。Hermes 本质是一个常驻服务的智能体GitHub 上只要有 PR 被打开或更新它就自动拉取变更内容交给大模型理解再把评审意见以行级评论的形式贴回 PR 对应位置。对个人项目来说它相当于多了一个不知疲倦的“结对搭子”对团队项目来说它能把 70% 的机械性问题拦在人工评审之前。这篇文章不打算讲太多空泛的理念会直接把我从需求拆解到部署落地的完整过程包括踩过的坑和调优经验都摊开来讲。1. 先想清楚为什么我决定做一个 PR 审查机器人1.1 人工评审的三大痛时间、一致性、注意力先说时间。评审一个 PR不只是“看代码改了什么”而是要先理解这次改动想解决什么问题再看改动是否真正解决了问题最后还要判断有没有引入新问题。这套流程走下来轻量改动十分钟大改动半小时起步。一个三五个人的小组光 PR 审查每天就能吃掉一位工程师半天的时间。再说一致性。同一个团队里不同人对同一类问题的敏感度差很多。有人习惯查 SQL 注入有人对资源泄漏很敏感有人特别喜欢抠命名。这种差异导致评审结果很不稳定——同一个坏味道在这个 PR 里被揪出来在下个 PR 里就溜过去了。尤其是新加入团队的成员没经历过前几个月的“教训沉淀”很容易把历史踩过的坑再踩一遍。还有一个痛点是注意力。人对重复的、低信息量的任务会产生习惯化反应看多了千篇一律的代码很容易漏掉真正致命的问题。我自己就发生过一次很离谱的事一个 PR 里把密码校验逻辑写反了我两轮评审都没看出来上到测试环境才被发现。这种错误不是能力问题是注意力疲劳。人工评审的极限就在那里硬扛是扛不住的。1.2 自动化评审能做到什么程度先说清楚一个底线自动化评审不可能替代人类评审也不应该替代。你不太可能指望一个机器人理解业务背景、判断架构取舍、体察技术债的偿还节奏。但自动化评审在“规则明确、重复出现、容易疲劳”的问题类型上非常有优势。比如密钥硬编码、危险函数调用、不平衡的错误处理、明显的越权风险、测试缺失等等。Hermes 的目标不是“找出所有 Bug”而是把评审者从重复工作里解放出来。它负责快速产出第一遍粗糙但覆盖面广的检查结果把明显的低级问题先标记出来然后把真正需要人类判断的问题留给工程师。这个定位非常重要它决定了后续所有的设计取舍——我们不需要一个 100% 准确的系统我们需要一个低成本、低噪音、能在几分钟内给出反馈的系统。1.3 这个方案适合谁适合搞一套 Hermes 的人大概是这几类个人开发者维护着多个开源或私有项目没有固定评审人三五个人的小团队PR 数量不少但人不够以及想固化团队规范、把检查规则沉淀成“机器默认行为”的成长型团队。反过来如果你们团队有专职的评审人、每个 PR 都会被仔细看或者项目还在需求频繁变动的早期阶段那自动化评审的优先级可以往后放——你更需要的是业务讨论而不是机器挑刺。2. Hermes 的整体设计与技术选型2.1 本质是一个基于事件驱动的智能体闭环我一开始想过很多种实现方式是不是搞成一个 Git 钩子是不是做成 CI 的一个步骤最后还是选了“GitHub App Webhook 常驻服务”的形态。原因很简单Git 钩子只对本地的操作生效别人往远端推代码的时候根本不会触发CI 的方式启动时间不可控少则几十秒多则几分钟而且每次跑都要重新初始化环境。而 GitHub App 的 Webhook 可以在 PR opened、synchronize、reopened 这些关键时刻即时推送事件给我服务端收到消息立刻处理整个闭环延迟可以控制在 10 秒级别。对比下来几种方案的性质其实很清楚方案触发时机推荐度理由Git 钩子本地操作不推荐只对自己生效无法覆盖团队CI 步骤代码推送后一般有环境初始化成本反馈慢GitHub App Webhook事件发生时推荐即时触发统一管理权限定时轮询 GitHub API固定间隔一般实现简单但有延迟和限流问题确认了形态之后我把 Hermes 设计成四个模块感知层负责接收 Webhook 事件理解层负责拉取 PR 的 diff、文件列表、提交信息并按文件类型做切分推理层把整理好的上下文交给 LLM让模型产出一份结构化评审结果行动层负责把评审结果转换成 GitHub 的行级评论或总结评论并管理评论的去重和状态。2.2 技术栈选型为什么用 Python 而不是更“重”的框架技术栈其实没有太多悬念我选了 Python FastAPI httpx SQLite。Python 在这类脚本型服务里优势太明显了解析 diff 的字符串处理、调用 REST API、处理 JSON、拼 Prompt全都顺手。FastAPI 用来接收 Webhook 请求非常轻异步支持也好。我用 SQLite 做问题指纹的去重缓存不用额外部署 Redis单人使用完全够用。LLM 接入这一层我选择的是 OpenAI 兼容接口协议。这样可以灵活切换不同的模型服务商——我自己主要接的是 DeepSeekHermes 只认接口格式不绑定任何一家厂商。切换模型只需要改一行配置。有一些热词里提到的“Hermes 智能体安装”“DeepSeek Hermes”其实说的就是这个思路把一个聪明的模型装进一个能感知 GitHub 事件的外壳里让它以“评审专家”的角色工作。2.3 权限模型GitHub App 比 Personal Token 安全得多权限设计这里我一定要多说两句。很多初学者做自动评论图省事直接在配置里写一个 Personal Access Token。这在小规模测试阶段没什么问题但对一个要长期跑的服务来说很危险。个人 Token 的权限范围绑定的是你的账号一旦泄露攻击者等于拿到了你账号的所有仓库访问权。而且 GitHub 对个人 Token 的限流是跟着账号走的哪天你在本地刷多了 API整个服务跟着一起限流。我用的 GitHub App 模式是另一套逻辑App 是独立的“身份”可以安装到指定仓库或组织权限可以精确控制比如只给 Pull requests 的读和写不给代码内容的写权限每个安装点会生成一个短期 installation token有效期只有一小时过期自动失效。泄密的后果比个人 Token 小得多。创建 App 之后你会拿到一个 App ID、一个私钥文件以及一个 Webhook Secret这套东西配合起来才是一个合格的服务端身份。3. 核心模块拆解Hermes 是怎么看懂 PR 的3.1 感知层校验 Webhook 签名过滤无关事件Webhook 接收这步看似简单实际上最容易出安全问题。GitHub 会把每个事件用 HMAC-SHA256 签名签名放在请求头的X-Hub-Signature-256里。服务端必须用 Webhook Secret 做同样的 HMAC 计算再跟请求头里的签名比对否则任何人都能伪造请求骗你的服务去分析任意仓库。校验通过之后还要过滤事件和 action。Hermes 只关心pull_request事件下的三种 actionopened新 PR 创建、synchronizePR 更新了代码、reopenedPR 重新打开。其他像assigned、labeled之类的事件直接忽略避免白跑一轮计算。这个过滤逻辑听着简单但实测下来能砍掉大概 60% 的无效 Webhook 流量。from fastapi import FastAPI, Request, HTTPException import hmac, hashlib, json app FastAPI() TRIGGER_ACTIONS {opened, synchronize, reopened} app.post(/webhook) async def handle_webhook(request: Request): body await request.body() signature request.headers.get(X-Hub-Signature-256, ) expected sha256 hmac.new( WEBHOOK_SECRET.encode(), body, hashlib.sha256 ).hexdigest() if not hmac.compare_digest(signature, expected): raise HTTPException(status_code403, detailinvalid signature) event request.headers.get(X-GitHub-Event, ) payload json.loads(body) if event pull_request and payload.get(action) in TRIGGER_ACTIONS: await analyze_pr(payload[pull_request]) return {ok: True}3.2 理解层拿到 diff 之后先别急着丢给模型这是整个项目里最有价值的一个环节也是我从最初版本到成熟版本改动最大的地方。第一版我很天真拿到 Webhook 之后直接调用 GitHub API 拉完整 diff然后整段丢给 LLM。结果模型经常回我“这段代码里我看到了上下文之外的函数无法判断……”或者说一些车轱辘话。问题出在大模型面对一个几百行、涉及多个文件的陌生 diff上下文太杂反而抓不住重点。后来我加了一个“预处理”步骤。拿到 PR 信息后Hermes 会做四件事第一按文件类型过滤掉 lock 文件、自动生成的代码、二进制资源第二按改动行数排序优先分析改动大的核心文件第三对每个文件单独评测它的类型和风险等级比如auth.py、payment.py、auth_service.go这些天然高风险文件要加权第四如果 diff 总行数超过 500 行就按“每个文件只保留变更块”的策略截断避免上下文溢出。这里记住一个关键点GitHub 提供的 PR diff 格式是标准 unified diff里面 -48,7 48,7 这段信息非常重要前面的数字对应当前仓库原始文件的行号后面的数字对应新文件的行号。我们在做行级评论时需要的是新文件的行号所以要把 diff 解析成“文件路径 - 新行号 - 变更内容”的映射表。这一步如果没有做好后面所有评论都会定位到错误的行体验极其糟糕。3.3 推理层Prompt 设计的几个关键原则推理层是 Hermes 的“大脑”它的核心是一段设计精良的 System Prompt。经过多轮迭代我总结下来有几个关键原则。第一明确角色的边界。Prompt 里要告诉模型你是资深代码评审专家但只能基于给定的 diff 和文件内容做判断不能臆测上下文。这一条能显著减少模型的“脑补”行为。第二强制结构化输出。要求模型返回 JSON格式固定为{summary, issues, suggestions}。其中issues数组里的每条问题包含file、line、severity、type、message五个字段。severity分三级P0 是会导致线上事故的阻塞问题P1 是明显的逻辑风险P2 是优化建议。结构化输出的好处是后续生成评论、去重、打标签都变得非常简单。第三评审维度的引导。我会在 System Prompt 中显式列出评审维度逻辑正确性、安全性、性能、资源释放、错误处理、可测试性。同时也会强调一些团队特别在意的红线比如“不可以在代码中硬编码密钥”“不可以使用eval处理不可信输入”。第四控制温度。我实际测试下来temperature 设在 0.2 左右最合适。等于 0 时输出太死板经常答非所问大于 0.6 时开始出现幻觉会报告一些 diff 里根本不存在的行。降低温度并不影响它发现问题的能力但这个参数一高噪音就明显变大。下面是我用的一段示例 Prompt仅供参考你是资深代码评审专家。你将收到一个 GitHub PR 的 diff 内容以及相关文件信息。 请仅基于以下 diff 和文件内容进行评审不要假设你没有看到的上下文。 评审维度 1. 逻辑正确性是否存在明显逻辑错误、边界条件遗漏 2. 安全性是否存在注入、越权、硬编码密钥、危险反序列化等风险 3. 性能是否存在不必要的 N1 查询、循环内重复计算等 4. 资源管理是否有连接、文件句柄未释放 5. 可测试性关键逻辑是否缺少测试覆盖 严重级别定义 P0会导致线上故障或严重安全漏洞的问题 P1明确的功能风险或逻辑错误建议修复后合并 P2代码质量和可维护性优化建议。 输出格式严格 JSON {summary: 整体评价, issues: [{file: 路径, line: 行号, severity: P0/P1/P2, type: bug/security/performance/style, message: 问题说明及修复建议}], suggestions: []}3.4 行动层行级评论、去重与状态管理推理完成之后Hermes 要做的第一件事是去重。实现方式很简单对每条 issue 生成一个指纹比如md5(file line type message)存进 SQLite。如果这个指纹在数据库里已经存在就说明这个问题之前已经评论过了直接跳过。如果没有才执行评论。为什么去重这么重要因为 PR 更新会触发synchronize事件每次 push 新代码 Hermes 就会重跑一遍。如果不去重同一个问题会被反复评论好几遍开发者会被烦到直接把机器人关掉。首次实现之后我发现了一个更微妙的问题开发者修好了问题但同一行上又冒出了相似问题指纹完全一样就会漏报。后来我改成“按文件 行号 状态”联合判断如果之前在该行标记过问题而 diff 显示该行已有修改就清掉旧标记允许新问题进入。评论发布我同时用了两种格式。对于 P0 和 P1 级别的问题使用 GitHub 的 pull request review comments 接口把评论贴到具体的代码行上这样开发者打开 Files changed 页面就能看到。对于整体总结和 P2 优化建议集中发到 PR 的评论区作为一条置顶评论方便快速总览。评论内容统一以[Hermes]开头既是品牌标注也方便后续批量清理。4. 从零部署一套可复用的 Hermes 配置4.1 环境准备与目录结构Hermes 对运行环境要求不高一台 2 核 4G 的云主机或者普通的开发机能跑就行。我用 conda 创建独立环境避免污染系统 Python。创建完环境之后项目的目录结构大概是这样hermes/ ├── main.py # FastAPI 入口负责 Webhook 接收和路由 ├── github_client.py # 封装 GitHub API 调用 ├── diff_parser.py # 解析 unified diff 为结构化数据 ├── llm_reviewer.py # 调用 LLM 进行评审输出结构化结果 ├── dedup.py # SQLite 去重逻辑 ├── config.yaml # 全局配置 └── requirements.txt # 项目依赖依赖安装很简单核心就是fastapi、uvicorn、httpx、pyyaml、openai或任何兼容 SDK。conda 环境下需要注意 Python 版本我建议使用 3.10 或 3.11新版本对类型特性和异步支持都更好。conda create -n hermes python3.10 conda activate hermes pip install fastapi uvicorn httpx pyyaml openai4.2 关键配置文件说明配置文件我习惯用 YAML 维护里面分成几个区块。GitHub 区块配置 App ID、私钥路径、Webhook Secret模型区块配置 API 基础地址、模型名称、API Key策略区块配置仓库白名单、每次最多分析文件数、diff 行数阈值等。github: app_id: 123456 private_key_path: ./hermes-app.private-key.pem webhook_secret: replace-with-a-long-random-secret target_repos: [my-org/my-service, my-name/my-side-project] model: api_base: https://api.deepseek.com/v1 api_key_env: LLM_API_KEY name: deepseek-chat temperature: 0.2 max_tokens: 4096 review: max_files: 8 max_diff_lines_per_file: 500 skip_files: [*.lock, package-lock.json, go.sum, *.min.js] high_risk_keywords: [auth, password, token, payment]这里有一个容易踩的坑api_key_env的意思是从环境变量读 Key而不是直接写在配置文件里。因为配置文件经常会被提交到仓库直接把 Key 写在里面等于把密钥公开在网上。我从一开始就规定所有敏感信息一律走环境变量配置里只存一个变量名。4.3 在 GitHub 上创建 App 并安装部署流程里最绕的是创建 GitHub App。先到 GitHub 的Settings - Developer settings - GitHub Apps点击New GitHub App。GitHub App 有几个字段一定要填对。Webhook URL 填https://你的域名/webhookWebhook Secret 填一个随机字符串并同步到配置文件。Permissions 里至少要把 Pull requests 设为 Read and writeContents 设为 ReadMetadata 自动为 Read。Subscribe to events 里勾上 Pull requests。这些配置完成后GitHub 会让你下载一个私钥文件妥善保存服务启动时会用到。App 创建完成之后还要在Install App页面把它安装到你自己的账号或组织下。安装时可以选择绑定到全部仓库或指定仓库我这里只选了白名单里的那几个。安装完成后GitHub 会生成一个 installation ID调用 API 时需要用 App 的私钥生成 JWT再通过这个 JWT 换取 installation token。这个交换流程官方文档写得比较绕但实现不复杂本质就是“用私钥证明自己的身份然后换取一个临时访问令牌”。4.4 启动服务并验证端到端流程所有配置就绪后启动服务只需要一行命令uvicorn main:app --host 0.0.0.0 --port 8080启动没问题的话先在 GitHub App 管理页面的 Webhook 面板发一个 Test Ping 事件确认服务能正常收到并返回 200。然后建立一个测试仓库提交一个包含明显问题的 PR比如在 Python 代码里写一个eval(input())或者在 JavaScript 里直接给innerHTML赋值。PR 开出来之后观察服务日志里是否出现了 Webhook 接收记录、LLM 调用记录、评论发布成功的记录。我实测过一次端到端验证提交了一个故意写坏的登录接口password字段没有做长度校验还把用户输入直接拼进了 SQL 查询。PR 刚打开大约 15 秒Hermes 就在 diff 对应的行上评论了“检测到 SQL 拼接风险建议使用参数化查询严重级别 P0”。这个响应速度已经足够在日常工作流中做为第一道评审防线了。5. 常见问题与调优实录5.1 部署与运行时的高频故障速查Hermes 运行半年下来我自己遇到过的坑和群里朋友反馈的问题大概能整理成一张表按出现频率排序现象可能原因排查与解决Webhook 收到但服务无反应事件 action 过滤错误查看日志确认pull_request的 action 是否在触发列表里GitHub 返回 403App 权限不足或 token 过期检查 Pull requests 权限确认 JWT 交换 installation token 的流程评论发布到错误行号diff 解析时新旧行号混淆确认使用新文件行号定位而非旧文件行号同一个问题反复评论缺少去重机制启用指纹去重按 filelinetype 判断模型经常返回空结果上下文被截断或 Prompt 不清晰调大 max_tokens简化 Prompt降低 temperature响应太慢PR 等了 2 分钟大 diff 未做截断按文件拆分、多线程并发调用模型API 被限流拉取 diff 时没有条件请求头请求时带上If-None-Match等缓存相关头限流这个问题容易被忽略。GitHub 的 REST API 有明确的速率限制installation token 的限额比个人 Token 大不少但依然不能浪费。我的习惯是拉取 PR 元数据和 diff 时尽量用条件请求并且只在 diff 有变化时才触发 LLM 召唤。另外一个实用做法是把files changed数量为 0 或负数的空 PR 直接跳过这种事件通常是标题编辑或者分支误触发没有任何评审价值。5.2 评审质量和噪音控制把规则写进配置里自动化评审最容易翻车的地方是“噪音太多”。模型会把一些无关紧要的代码风格建议当成问题提出来或者过度反应于某些常见但无害的写法。为了压制噪音我做了三件事。第一件缩小评审范围。配置里的max_files和max_diff_lines_per_file是用来避免一次性塞入太多上下文的如果文件太多模型容易乱宁可少看两个文件也要保证每个文件看得仔细。第二件用规则词表给“高风险信号”加权。配置里的high_risk_keywords数组会在 Prompt 中额外强调这些路径需要重点关注。第三件设置一个“评论阈值”只有当问题属于 P0 或 P1 时才发行级评论P2 建议只在 summary 里提一句避免刷屏。试过几个不同模型之后我的感受是一个中端模型如果 Prompt 写得清晰效果比一个高端模型随便丢一个大 diff 要好。LLM 评审的上限由模型能力决定但下限完全由上下文组织决定。只要把 diff 拆得足够细、把目标定义得足够明确即使模型能力一般也能稳定发现大多数低级问题。5.3 落地心得不要把机器人变成第二个评审人而是把它变成“第一道过滤器”最后说说我个人的落地体会。刚开始用 Hermes 时我很想把它做得“全能”什么都让它看什么都让它管。结果就是它评论了 40 多条问题工程师根本看不过来最后被投诉太吵。后来我把策略改成了“第一道过滤器”Hermes 只负责标记最确信的问题比如明显的安全漏洞、语法级的 bug、硬编码密钥这类问题一旦发现准确率几乎百分之百。至于代码风格、架构取舍、命名建议一律不主动提除非问我。调整之后效果立竿见影开发者每天打开 PR 看到 Hermes 的评论时不会再有“又是机器人瞎说了”的抵触情绪。因为每条评论都是能直接修的问题久而久之他们甚至会主动等 Hermes 先跑完再开始人工评审。我觉得这才是自动化评审正确的打开方式它不是替代人的思考而是把人从最低级的重复劳动里拉出来让人去做真正只有人才能做的事情。最后再分享一个小技巧PR 如果被打回重开Hermes 之前打的hermes/reviewed标签可能还挂在旧版本上记得在评论逻辑里加一个版本比对否则同一轮评审结果会被错误复用。这个小坑我修了好几轮才处理好。