维护开源仓库的人应该都有同感:每天打开 Issues 列表,看到的不是“需求”,而是一堆等着做第一轮筛选的原始信息——标题写了半句话、模板没按格式填、bug 报告里没有环境信息、两边明明是同一个问题却各发了一帖。我做过一个小工具,用规则引擎加少量 AI 判断,把这轮“脏活”接过来,让机器先给每个新 Issue 做一次分诊:查信息完整性、找重复、推标签、估算该谁来管。这篇文章就把这个分诊助手的设计思路、核心代码和踩坑过程完整还原一遍,适合被 Issue 日常淹没的开源维护者,也适合想给仓库加自动化的开发者参考。
我选择的路子和很多人想象的不太一样:没有一上来就接大模型,而是先把所有能用确定性规则做的事全部规则化,AI 只兜底处理规则覆盖不到的部分。原因很直接:Issue 分诊这件事,90% 的判断其实不需要“智能”,只需要把维护者脑内的检查清单翻译成机器能执行的逻辑。剩下的 10%,再用轻量模型去理解语义和意图。这样做的好处是每个判断都可解释、可调参、出了问题能追责——你总不希望机器人因为一次抽风,把别人的高优 bug 标成“已解决”。
1. 分诊助手到底解决什么问题
1.1 开源维护者的“第一轮脏活”是什么
先定义清楚什么是“第一轮脏活”。它不是修 bug,也不是写功能,而是新 Issue 进来之后那一连串重复劳动:看看标题写得清不清楚、正文有没有按模板填写、复现步骤有没有给全、环境信息缺不缺、这个问题是不是已经有人报过了、大概属于哪个模块、应该分配给谁处理。这些事情技术难度不高,但极其消耗注意力。
我自己带过一个模拟项目 X,巅峰时期一天涌入十几个 Issue,其中相当一部分是重复提问和残缺报告。单独看每一条都能在两分钟内处理完,可一旦你要在写代码、review、开会之间穿插做这件事,会明显感到节奏被打断。更麻烦的是,不同维护者的分诊标准不一致:有人觉得没贴日志就该打回去,有人觉得差不多能看懂就放进来。时间一长,仓库里就会堆积一批“半残”的 Issue,后人翻历史记录时根本不知道当初为什么开这个任务。
分诊助手要做的,就是把维护者的个人经验沉淀成仓库级的统一规则。新 Issue 进来的瞬间,机器人按照同一套标准做检查,把缺失项、风险项、归属项一次性标注出来。维护者不再是面对一个裸标题,而是面对一份已经整理好的“病例单”,只需要做最终裁定。
1.2 为什么值得自动化这一轮
有人说,分诊一次不过两分钟,值得写个工具吗?我的回答是,单次两分钟确实不多,但 Issue 的咨询特性决定了这项工作永远不会结束。只要项目还有人用,就不断有新用户、新问题、新报告。一次十万行的 PR 你都愿意花三小时去 review,为什么不愿意花一个下午写一个能永久分担重复劳动的脚本?
更关键的是速度优势。自动分诊能在 Issue 提交后十几秒内完成,用户刚提交完就会在页面上看到机器人给出的反馈——“你缺了复现步骤,请补充”“这个问题和 #1234 重复,请先去看那边的进度”。这种即时反馈对提问者体验的改善是巨大的。人的响应速度再快也要等几个小时,而一个用户如果在提交后十分钟内得到回应,他大概率愿意花时间继续完善报告;如果等了两天才有人理他,他可能已经放弃这个项目了。
自动化分诊的另一个隐性价值是数据沉淀。每一条分诊记录实际上都在帮助建立仓库的 Issue 知识库:哪些类型的报告最容易信息缺失,哪个模块的问题最集中,哪些关键词组合经常出现。维护者从这些数据里能看到项目健康度的变化,而这些信息之前都散落在个人脑子和不成系统的标签里。
1.3 助手的边界:只做第一轮,不做最终裁决
在设计这个助手时,我给自己定了一条原则:机器只做第一轮,不做最终裁决。分诊助手可以指出“这个 Issue 缺少版本号”,但它不会直接关掉这个 Issue;它可以判断“这大概率是性能问题”,但它不会直接分配给某个核心维护者。
原因是,自动化的价值在于解放人力,而不是取代人力。强行让机器做最终决策,就会产出一堆让社区反感的“AI 官僚”:明明是一个紧急的生产事故报告,却被机器人以“缺少模板字段”为由关掉了;明明是一个设计很好的新功能建议,却被机器人打上“重复 issue”的标签给合并了。这种误判带来的社区信任损失,远大于省下来的那几分钟人工时间。
所以我的架构是:机器负责检查和标注,维护者负责决策和收官。所有自动化动作都必须可追溯——哪个规则触发的判断,基于什么关键词,匹配到什么模板字段,全部记录在案。维护者如果发现某条规则过于激进,可以直接在配置里调低优先级或者干脆停用,整个过程不需要改代码。
2. 整体设计:规则引擎优先,AI 兜底
2.1 为什么不用纯提示词一次性生成结论
刚开始构思时,我一度想直接用 LLM 把整个 Issue 丢进去,让它输出“是否完整、是否重复、标签建议、负责人建议”一整套结论。实际试了之后发现这条路有个致命问题:不可控。同一个 Issue 换个措辞,模型的结论就可能变;更麻烦的是,模型会一本正经地给出错误理由,比如把“我的环境是 Windows 10”误判成“缺少环境信息”,因为提示词里没有明确告诉它环境字段的关键词。
另一个问题是成本。每个 Issue 都走一次模型调用,日均上百条就是不小的开销,而且大部分 Issue 根本不需要大模型的理解能力。一个“如何在 Mac 上安装”的提问,关键词规则表里写一句“macOS、brew、darwin”就能识别,没必要让模型来语义理解。
所以我最终的方案是分层处理。第一层是纯规则引擎:字段完整性、标签关键词、路径归属、标题相似度,全部走本地代码。第二层才是模型判断:规则层判定不出来、或者置信度不足的情况,再交给 LLM 去分析主题类别和意图。这样既保证了 90% 场景的确定性与零成本,又保留了对复杂语义的处理能力。
2.2 整体架构与事件链路
整个系统的链路不复杂:Issue 事件产生 → 工作流被触发 → 拉取 Issue 完整信息 → 执行规则引擎 → 落库/打标签/写评论 → 结束。我把核心逻辑放在一个独立的 Node.js 脚本里,通过 GitHub Actions 作为事件触发器。这样做的好处是可以白嫖平台的免运维能力,不用自己部署常驻服务。
选 GitHub Actions 而不是自建 Webhook 服务,理由有四个。第一,零托管成本,仓库自带的工作流基础设施不需要额外服务器;第二,令牌安全性,环境变量里用平台自动注入的 GITHUB_TOKEN,不需要自己管理密钥;第三,日志可观测,每次分诊的完整输出都在 Actions 日志里,出问题可以直接翻记录;第四,事件完整性,Actions 对 Issue 的各类动作有原生的支持,接入成本几乎为零。
脚本本身是无状态的,只接收一个 Issue 编号作为入参,通过 REST API 去拉取标题、正文、标签、评论等所有信息。无状态设计让我可以在本地跑同样的脚本去批量处理存量 Issue,只需要传入不同的编号,逻辑完全复用。
2.3 仓库接入成本与权限模型
接入成本其实低到很多人想不到。你只需要在工作流目录里放一个 YAML 文件和一个脚本,首次运行的初始化阶段会自动补齐缺失的标签(我在脚本里做了标签不存在则自动创建的逻辑)。整个过程不需要维护者在仓库设置里做任何手工配置。
权限模型方面,工作流里的 GITHUB_TOKEN 默认是只读的,如果想让它能打标签和发评论,必须在 workflow 文件里显式声明permissions: issues: write。这个坑我一开始就踩了,明明脚本逻辑没问题,但评论发不出去,排查半天才发现是默认权限不足。
对多维护者的项目,我建议把分诊标签统一加上triage/前缀,方便按前缀筛选和清理。我给模拟项目 X 设计的标签体系是:triage/incomplete、triage/duplicate、triage/feature、triage/bug、triage/docs、triage/performance。维护者一眼就能从标签颜色和前缀分辨出哪些是流程标记,哪些是内容分类。
3. 分诊规则拆解:四个核心能力
3.1 信息完整性检查:把模板缺口找出来
完整性检查的原理不复杂:把项目 Issue 模板里的关键段落作为结构化规则,检查新提交的正文是否包含这些段落的特征。我用了一个字段映射表,每个字段定义语义关键词,正则命中即认为该字段存在。比如“复现步骤”对应的关键词是复现|重现|Steps to reproduce|reproduce,“版本信息”对应版本|Version|v1\.\d,只要正文里有一个匹配,就判定该字段已填。
这里有个细节值得展开:为什么用关键词而不是检查模板的 Markdown 标题?因为很多用户提交时会删掉模板的小标题,只留一段自由文本。如果只检查## 复现步骤这种标题,误判率会非常高。用关键词做模糊匹配虽然不完美,但能覆盖大部分真实用户的填写习惯。
遇到缺失项时,我不建议直接关闭 Issue,而是打上triage/incomplete标签,同时发一条评论把缺失字段列出清单。评论模板我用的是:
感谢提交这个 Issue。为了让维护者更快定位问题,请补充以下缺少的信息: - 期望行为 / 实际行为 - 复现步骤 - 运行环境(操作系统、版本号) 补充完成后我会重新分诊。这条评论的措辞是经过几次迭代的。最早版本写得比较生硬,类似“请按模板重新编辑”,效果很差,不少用户直接弃坑不回来了。后来改成上面对话式的措辞,补充率明显提升。分诊工具不只是机器逻辑,也是对外沟通的界面。
3.2 重复 Issue 识别:基于标题与内容相似度
重复检测是分诊里最有技术含量的一环。策略上我用了三层递进:精确标题匹配、关键词搜索候选集、文本相似度计算。前两层是为了缩小范围,让相似度计算只在少量候选里执行,避免对全仓库所有 Issue 做一遍计算。
搜索候选集用 REST API 的搜索接口,按标题关键词检索当前仓库下所有处于开启状态的 Issue,取前五条结果。然后对每一条候选,计算它与新 Issue 的文本相似度。相似度算法用的是 Jaccard + bigram:先把标题和正文清洗成小写、去停用词,再拆成相邻字符的双字母组集合,两个集合的交集大小除以并集大小就是相似度。
举个例子,新 Issue 标题是“登录页在手机端样式乱”,候选标题是“移动端登录页面样式错乱”。清洗后分别是“登录页手机端样式乱”和“移动端登录页面样式错乱”,bigram 集合会有相当一部分重合,相似度大概在 0.6 左右。我实测下来,阈值定在 0.65 比较合适:低于 0.5 的基本是不同问题,高于 0.7 的几乎可以肯定重复,0.5 到 0.7 之间的需要人工复核。
对判定为重复的 Issue,我不会直接关闭,而是打上triage/duplicate标签并评论一句“这个 Issue 看起来和 #1234 相关,那边已经有进展,请先去那边补充信息,如果情况不同请回复说明”。关闭这个动作始终留给维护者,因为有时表面重复的问题实际有不同的触发条件,机器很难完全区分。
3.3 自动标签推荐:关键词规则表
标签推荐是最适合用规则表解决的任务。我把标签规则集中放在一个 JSON 文件里,维护者可以直接编辑而不需要碰代码。每一组规则包含标签名、关键词列表、优先级三部分。命中逻辑是:正文和标题中任意一个关键词出现即触发,取优先级最高的一组作为推荐标签。
我用的规则表示例:
{ "rules": [ { "label": "triage/bug", "keywords": ["报错", "异常", "崩溃", "bug", "crash", "fail", "错乱"], "priority": 10 }, { "label": "triage/feature", "keywords": ["建议", "希望增加", "需求", "feature", "roadmap"], "priority": 5 }, { "label": "triage/docs", "keywords": ["文档", "文档缺失", "typo", "拼写", "README"], "priority": 5 }, { "label": "triage/performance", "keywords": ["卡顿", "慢", "性能", "performance", "延迟", "内存溢出"], "priority": 8 } ] }为什么要把优先级单独拎出来?因为“性能”和“bug”的边界很模糊。一个“内存溢出导致崩溃”的 Issue,同时命中性能关键词和 bug 关键词,如果不设置优先级,机器人会纠结于该打哪个标签。我设置了 bug 优先级高于性能,这样这类问题统一归为 bug,性能细节留给维护者在处理时补充。
这个规则表需要持续维护。我每周末会扫一遍本周的分诊日志,看有哪些 Issue 没有被任何规则命中,或者命中的标签明显不合理,然后补关键词进去。规则表的生命力在于持续迭代,不是一次写完就完事。
3.4 负责人推荐:路径与模块映射
负责人推荐我实现了两种模式,按仓库规模灵活切换。小仓库用一个简单的模块映射表:
{ "认证": ["jwt", "oauth", "login", "session", "token"], "数据库": ["sql", "mysql", "postgres", "query", "索引"], "前端": ["react", "vue", "css", "界面", "样式"] }命中哪一组关键词,就在评论里提示“这个 Issue 可能和前端模块相关,建议由前端方向维护者跟进”。这个模式够用且完全透明,维护者可以随时调整关键词,不会出现莫名其妙的分配结果。
大仓库则优先走 CODEOWNERS 机制。如果仓库已经维护了 CODEOWNERS 文件,我会尝试解析文件路径规则,映射到 Issue 正文里提到的文件目录。比如正文提到src/auth/login.ts,而 CODEOWNERS 里写了src/auth/ @auth-team,我就能推断这是认证模块的活。难点在于很多用户不会主动贴文件路径,这时候还要结合关键词映射表做兜底。
我不建议机器人直接把 Issue 分配给具体的人,在多人协作的仓库里擅自分配容易引发被分配者的反感。最好的方式是“指向不指人”,说清楚问题可能属于哪个模块,让维护者自己决定要不要接手。这又一次体现了“只做第一轮、不做最终裁决”的原则。
4. 实操落地:从零到一
4.1 项目骨架与依赖
整个项目就两个核心文件:工作流定义和分诊脚本。依赖只有@octokit/rest(GitHub REST API 的官方 JavaScript SDK)和 Node.js 运行环境。目录结构:
issue-triage/ ├── .github/ │ └── workflows/ │ └── issue-triage.yml ├── rules/ │ ├── labels.json │ └── owners.json └── triage.jstriage.js是唯一需要关心的代码文件,大概 200 行左右。剩下的都是 JSON 配置,维护者改规则时连代码库都不需要懂。
4.2 工作流配置:一个重要的坑
工作流是分诊助手的事件入口,我先给出完整配置:
name: Issue Triage on: issues: types: [opened] permissions: issues: write jobs: triage: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 - name: Setup Node uses: actions/setup-node@v4 with: node-version: 20 - name: Install dependencies run: npm install @octokit/rest - name: Run triage env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} REPO: ${{ github.repository }} ISSUE_NUMBER: ${{ github.event.issue.number }} run: node triage.js这段配置里最容易出问题的就是第三段permissions。没有它,脚本能执行但打不了标签、发不了评论,我一开始调试时就是死在这个坑里,日志里 API 调用报 403,换了 token 也一样,最后才发现是 workflow 默认权限不够。
另外注意触发事件只用了issues: [opened]。这是有意为之,我不会监听评论事件和标签变更事件,避免机器人的评论再次触发自身逻辑形成死循环。这个设计后面在避坑章节会展开讲。
4.3 核心代码实现:分诊主流程
主脚本的骨架是这样的。先从环境变量和配置里初始化对象,然后拉取 Issue 详情:
const { Octokit } = require('@octokit/rest'); const fs = require('fs'); const token = process.env.GITHUB_TOKEN; const [owner, repo] = process.env.REPO.split('/'); const issueNumber = Number(process.env.ISSUE_NUMBER); const octokit = new Octokit({ auth: token }); async function main() { const { data: issue } = await octokit.rest.issues.get({ owner, repo, issue_number: issueNumber }); const report = { number: issue.number, title: issue.title, hasCodeBlock: /```[\s\S]*```/.test(issue.body || ''), bodyLength: (issue.body || '').length, labels: issue.labels.map(item => item.name) }; console.log('分诊报告:', JSON.stringify(report, null, 2)); } main().catch(error => { console.error('分诊出错:', error.message); process.exit(1); });这段代码本身不复杂,但把报告打出来是很有必要的。每次分诊执行,Actions 日志里都会留下一份结构化的 JSON 报告,方便事后复盘。有段时间我靠这个日志发现标签规则的关键词命中率偏低,于是针对性的补了一批常见的口语化表达。
信息完整性检查和重复检测是核心函数。完整性检查我做了模板字段的映射表,重复检测用搜索接口加相似度计算。这两个函数的设计在前面章节已经详细拆解过,代码上注意的一点是:搜索接口要用encodeURIComponent对查询内容编码,否则标题里带引号、空格等特殊字符时 API 会报错。
4.4 冷启动:给存量 Issue 补一轮分诊
很多人忽略了存量 Issue 的处理。新工具接上之后,如果只对新 Issue 生效,仓库里的历史遗留问题依然是一团乱麻,维护者还是无法从整体上获得清晰的视图。我在接入模拟项目 X 时做了一次冷启动,原理很简单:写一个批次脚本,从 Issue 列表接口依次拉取所有开启状态的 Issue 编号,然后逐个调用分诊函数。
冷启动和在线分诊的唯一区别是入参不同:在线分诊从 Actions 环境变量里拿 Issue 编号,冷启动从本地循环读取编号。核心逻辑完全复用,不需要写两套代码。我当时跑了一轮,把仓库里积压的 200 多个旧 Issue 全部补上了标签和分诊记录,列表瞬间从“一片空白”变成“分类清晰”。
冷启动有一个执行节奏的讲究。我建议不要一次性全量并发跑,因为搜索 API 有速率限制,全仓库扫描很容易触发限流。当时的做法是每处理一个 Issue 间隔 500 毫秒,一晚上跑完全部存量,第二天维护者打开列表时整个视图焕然一新。也别在仓库流量高峰期跑,线上新 Issue 的分诊优先级肯定要高于存量清洗。
5. 常见问题与避坑清单
5.1 五个高频问题排查表
我前后跑了几个月的分诊助手,把实际遇到的高频问题整理成了下面这张表。如果你也打算做类似工具,这些坑大概率会依次踩到。
| 现象 | 原因 | 处理方法 |
|---|---|---|
| 机器人不评论不打标签,日志 403 | workflow 缺少 issues 写权限 | 在 workflow 里添加permissions: issues: write |
| 搜索接口报 422 参数错误 | 查询串未做 URL 编码 | 对标题和查询词使用encodeURIComponent |
| 打标签时报 404 | 标签不存在,API 默认不允许直接附加未知标签 | 在 code 里调用createLabel先建标签,或忽略 404 |
| 机器人评论触发了新的工作流 | 监听事件过宽,机器人行为回流触发自身 | 只在opened事件触发,不监听评论事件 |
| 关键词命中过多导致标签泛滥 | 规则表太粗,如“错误”这个关键词命中率过高 | 引入括号表达式和排除词,比如“错误”但要排除“不是错误”这类语境 |
排查时最有效的手段不是看脚本日志,而是直接查看 Actions 工作流的完整运行日志,里面会打印 API 请求的状态码和错误消息。大部分问题都是权限、接口参数、事件回流这三类,按这张表对号入座基本能解决。
5.2 防止事件死循环:一个必须处理的设计问题
事件死循环是自动化工具最常见的隐形杀手。你的机器人给 Issue 发了条评论,这条评论本身会触发issue_comment事件,如果你的工作流监听这个事件,就会再跑一次分诊,再发一条评论,再触发一轮……往返几次之后仓库就被机器人的评论刷屏了。
解决这个问题有三种常见路径。第一种是只在issues.opened事件触发,完全不监听评论事件,这是最省心的方案,但缺点是用户补完信息后没办法自动重新分诊,需要维护者手动跑一次。第二种是在脚本里检查触发事件的 actor 是不是github-actions[bot],是就跳过本次处理。第三种是在脚本开头检查评论者身份,只有非机器人用户的新评论才触发二次分诊。
我最终选了第一种方案配合一个手动触发机制:维护者想对某个 Issue 重新分诊时,通过仓库内的一个自定义命令(比如在评论里输入/triage)触发工作流。这个方案既能避免死循环,又保留了二次分诊的能力,实现起来也不复杂。
5.3 评论噪音与社区体验
自动化工具容易变成“噪音制造机”,这是我在迭代过程中体会很深的一点。早期的版本每次分诊都会在 Issue 下发一条长评论,把标签、负责人建议、完整性报告全部列出来。结果是用户看到的不是帮助,而是一堆机器话术,评论区被机器人和用户来回刷屏淹没。
后来我调整了评论策略:正常分类不评论,异常才发声。如果 Issue 信息完整、没有重复、标签识别清晰,机器人就只静默打标签,不留下任何评论;只有两种情况会发声——信息缺失需要用户补充,或者疑似重复需要用户确认。这样的交互模式更接近真实维护者的习惯,评论区的可读性得到了很大改善。
另一个降噪技巧是把长报告放到 Action 日志里,评论里只保留摘要。用户需要看完整细节时,点开工作流日志就能看到结构化的分诊报告;不需要时,评论区的体验和原来完全一致。这个设计对新手用户特别友好,不会因为一堆机器术语劝退他们继续反馈问题。
给这个系统调优的这段时间,我最深的感受是:开源维护者面对的最大成本不是工作量本身,而是上下文切换带来的注意力损耗。分诊助手不能替你写代码,但它能把“每天刷几十个 Issue 做第一轮判断”这种琐碎且重复的事情稳稳接住。我个人体会比较深的一点是关键词规则表要持续“喂养”,它决定了这个工具的智商边界;另一个小技巧是别急着接大模型,先把规则层做到极致,你会发现很多问题根本不需要“智能”就能解决。如果你的仓库也积压了一堆没人处理的 Issue,花一个下午做一个这样的分诊助手,可能比想象中回报更快。