真正的技术挑战,往往藏在把多个环节串成一条链的路上。我最初以为翻译质量是瓶颈,等真正上线跑起来,发现 GitHub API 的并发触发、自己写回内容的死循环、PR 自动合并的失败重试,才是反复出事的源头。对比过把翻译逻辑做成离线批处理、靠人工触发任务等方案,最终还是选了”增量翻译 + Webhook 防循环 + PR 自动合并”这条实时链路:只有它能在用户 push 文档后几分钟内完成翻译并回写,体验才说得过去。如果只能选一个最具技术挑战性的功能,我会选它。它把 GitHub API、状态机、并发、错误恢复、用户体验都串在一起,任何一环考虑不周都会带来线上故障。
一、为什么这条链路最难
先说结论:它难在没有任何一环可以单独做好。翻译服务本身可以独立调优,Webhook 接收也可以独立实现,但把它们串成一条自动流水线,问题就开始叠加了。翻译慢一点,Webhook 队列就积压;队列积压,GitHub 那边会认为投递失败而重试;重试又产生重复翻译,既浪费模型成本又可能把错误内容写回仓库。每一环的异常都会顺着链路放大到下游。
完整链路是这样走的:
Webhook 到达 → 校验签名
↓
判断 commit 来源(防循环)
↓
计算变更文件
↓
排队翻译任务
↓
翻译 + 校对
↓
提交分支 + PR
↓
按规则自动合并
任意一步出错都会导致:
- 重复翻译(浪费模型成本);
- 提交到错误分支(污染历史);
- 自动合并失败(任务卡住);
- 循环触发(雪崩)。
我把这条链路画成流程图挂在自己工位旁边,原因是纸上推演和实际运行的差距比我预想的大。画出来之后才发现状态迁移有好几条隐藏分支,比如翻译失败后是重试还是标记人工、PR 合并被拒绝后是否回滚分支,这些不画出来根本想不全。
二、防循环翻译
防循环是整个项目里让我花时间最多的部分。它的难点在于:翻译工具提交的翻译 commit,从 GitHub 的角度看和用户手动提交的 commit 没有本质区别。如果工具把自己的 commit 也当作”待翻译内容”重新处理一遍,就会反复触发翻译、反复写回,形成一个停不下来的循环。第一次上线时我没考虑周全,一个仓库在十分钟内被工具自身触发了上百次翻译任务,模型账单当场告警。
难度在于”既要识别本工具的 commit,又要兼容用户手动操作”。我用四重判断:
- commit message 前缀匹配;
- commit author 校验(app bot);
- branch 命名规则(
docs/translate-*); - 任务维度去重(同一 commit 只能起一次任务)。
任何一重都不够稳,组合后才可靠。
这里说说为什么单靠一重不够。只看 commit message 前缀,用户可以手动提交一个带同样前缀的 commit,会被误判跳过;只看 author,工具用 bot 身份提交,但用户也可能配置了同样的 bot 身份;只看 branch 命名,手动创建 docs/translate-* 分支也会误伤。四重判断本质是不同维度的证据互相兜底,宁可少触发一个任务,也不能让它循环起来。
三、增量翻译的差异计算
增量翻译要解决的问题很直白:文档仓库动辄几千个文件,每次 push 只改了一两个文件,如果把整个仓库都重新翻译一遍,成本和时间都不可接受。我一开始想简单了,以为拿到 push 事件里带的 commit 列表就能直接开干,结果发现远不止这么简单。
难点是大仓库的 diff 拉取:
- REST API 一次最多 100 条,需翻页;
- 部分 Markdown 字符变化(仅空白)不应触发翻译;
- 二进制文件、锁文件必须跳过;
- 删除的文件要标记 stale 而不删库。
实现上把 diff 计算拆为”commit 维度 + 文件维度 + 内容维度”三层,层层过滤。
这三层过滤是我在实际跑了一个大型文档仓库后逐步补出来的。最初只有 commit 维度,结果一个改动触发了十几个文件的翻译,因为用户在同一 commit 里顺手改了 README、改了锁文件、还删了个图片。加上文件维度后,锁文件这种被工具跳过;加上内容维度后,只改了一个空格的 Markdown 不再浪费一次模型调用。删除文件的处理也踩过坑:直接删库会丢掉历史翻译记录,改成标记 stale 后,用户在原文里恢复删除的内容时,翻译结果还能复用。
四、并发与幂等
GitHub 的 Webhook 投递有重试机制,投递失败会指数退避重试,而且重试时带的 X-GitHub-Delivery 头不变。这就意味着同一个事件可能被投递多次,如果处理逻辑不幂等,翻译任务就会重复创建。另外用户连续 push 两次,两个 Webhook 几乎同时到达,也必须保证只处理有意义的那一次。
- Webhook 可能并发到达:用
X-GitHub-Delivery作为幂等键; - 同一仓库短时间多次 push:只处理最新一次;
- 任务排队用 Redis Stream,避免重复消费;
- 任务入队前查重。
幂等键是解决重复投递的关键。我把 X-GitHub-Delivery 作为幂等键存进数据库,入队前先查重,查到就直接返回 200。这里有一个我踩过的细节坑:查重必须和入队是原子的,否则两个并发请求可能同时通过查重再同时入队。Redis 里用 SETNX 或者数据库约束来保证,比先查后写可靠得多。Redis Stream 的 XREADGROUP 天然支持消费组,配合 ACK 机制,崩溃后重新投递也能保证不丢不重。
五、PR 自动合并
翻译完成之后,系统会创建 PR 等 CI 通过,再自动合并。这一步单独看不难,但和手动合并的逻辑完全不同:手动合并可以观察后再点按钮,自动合并则必须在代码层面把所有安全条件前置校验好,任何一个条件没满足就合进去,都可能把翻译内容直接推进主干分支。
翻译完成后自动合并 PR 有几道关:
- 必须满足仓库的 branch protection;
- 必须 CI 全部通过;
- 合并策略:squash / merge / rebase;
- 失败重试:3 次后转人工。
合并不是简单调一次 merge 接口,必须先查 PR 状态,确认可合并、检查项全过,才发起合并。下面的代码就是合并前的核心判断逻辑,这段逻辑解决的是”条件不满足时绝对不能合”的问题:
async function mergeIfPossible(prNumber: number) {
const pr = await octokit.pulls.get({ owner, repo, pull_number: prNumber });
if (pr.data.mergeable && checksPass(pr.data.head.sha)) {
await octokit.pulls.merge({
owner, repo, pull_number: prNumber,
merge_method: "squash",
});
}
}
这段代码写出来容易,真正让它可靠是另一回事。pr.data.mergeable 是异步计算的,PR 刚创建时它可能是 null,需要轮询几次才能拿到稳定结果。checksPass 需要主动去查 CI 的 check runs,而不是假设它一定通过。我最初没处理 mergeable 为 null 的情况,导致有 PR 在未就绪时就尝试合并,返回失败后又触发重试,把简单的重试逻辑拖成了状态机。合并策略统一用 squash,是因为翻译 PR 的历史价值不大,保留一条干净的提交记录更方便以后回溯。
六、错误恢复
自动流水线的主要风险是”静默失败”:任务卡在某一环,既不报错也不推进,用户根本不知道文档没翻译。所以从设计一开始,我就给每一阶段定了明确的失败语义,做到”任何一步出错都有明确去向”。
每一阶段都要有失败语义:
| 阶段 | 失败处理 |
|---|---|
| Webhook 校验失败 | 返回 401,GitHub 重试 |
| 循环识别失败 | 跳过任务 |
| 翻译失败 | 任务标记 FAILED,留人工 |
| PR 创建失败 | 重试,仍失败转人工 |
| 自动合并失败 | 留 PR 不动,发通知 |
这张表看起来简单,背后是多次线上故障换来的。最典型的教训是自动合并失败:我最初让它在失败后自动关闭 PR,结果 CI 偶发抖动导致 PR 被误关,翻译内容白白丢失。改成”留 PR 不动、发通知”之后,人工介入还能补救。另一个是翻译失败直接标记 FAILED 而不是无限重试,因为模型调用失败大概率是同一批任务都失败,重试只会浪费钱,留人工处理更稳妥。
七、可观测性
链路越长,越需要一个能回答”任务现在卡在哪”的观测手段。第一次出现”文档没翻译”的反馈时,我排查了很久,因为当时的日志分散在各处,无法把一次翻译任务的完整过程串起来。后来我把可观测性当作链路的一部分来建设,而不是事后补丁。
最复杂的链路最需要可观测:
- 每个阶段 trace_id 贯穿;
- 关键指标:循环触发率、自动合并率、平均 PR 时长;
- 告警:自动合并失败率 > 1% 持续 5 分钟。
trace_id 贯穿整个链路后,一次翻译任务的每个阶段耗时都能在追踪系统里看到。循环触发率这个指标尤其有用:它直接反映防循环逻辑是否失效,正常应该是 0,一旦大于 0 立刻告警。自动合并失败率的告警阈值我定得比较激进(1% 持续 5 分钟),因为自动合并失败通常意味着 branch protection 或 CI 配置出了问题,拖得越久积压的任务越多。
八、给同类项目的建议
复盘下来,这条链路的经验可以抽象成几条通用原则,不局限于翻译工具,任何”接收事件 → 处理 → 回写”的自动化系统都适用。
- 状态机要画出来,所有失败路径都写出来;
- 幂等是底线:每个入口都要去重;
- 防循环是基本功,必须多重判断;
- 自动合并是”锦上添花”,先确保不自动合并也安全;
- 链路变长时埋点要齐全。
把状态机画出来这个建议,是我从一次线上事故里学到的。当时翻译任务卡在”已排队”状态一天没推进,原因是一条异常路径没写状态迁移逻辑,任务永远停在原地。把状态机画出来、把所有失败路径标出来之后,这类”卡死”问题基本绝迹。
九、与其它功能相比
项目里还有几块功能,比如多语言 README 链接、模型分级、Docker 部署,但它们的挑战维度不同。用一个对比来看更清楚:
| 功能 | 难度 | 影响面 |
|---|---|---|
| 增量翻译防循环 | 高 | 性能 + 安全 |
| 多语言 README 链接 | 中 | 体验 |
| 模型分级 | 中 | 成本 |
| Docker 部署 | 低 | 运维 |
增量翻译链路对系统的稳定性、成本、安全都至关重要,因此”最具挑战性”当之无愧。多语言 README 链接虽然也要处理编码和路径问题,但失败影响面限于展示;模型分级影响的是调用成本,调优空间大且可回退。相比之下,增量翻译链路一旦出问题,轻则浪费模型费用,重则把错误内容写进客户仓库,风险等级完全不同。
十、踩坑与教训
这条链路能稳定运行,靠的不是一次设计到位,而是一轮轮踩坑后补出来的。挑几条对同类项目最有参考价值的列出来:
- Webhook 重试要靠 delivery_id 去重;
- 循环翻译的发生概率比想象高;
- 大仓库的 diff 拉取不能依赖单次 API;
- 自动合并不等于安全,仍要 branch protection。
循环翻译概率高这条,是我最初的认知盲区。我以为防循环判断足够可靠就不会触发,但实际上一次配置错误(比如 webhook 订阅了工具自身 push 的仓库事件)就能让循环重新出现。所以防循环不只是一段判断代码,还应该有配套的告警和熔断。branch protection 那条也提醒过:自动合并绕过了人工确认,如果仓库没有设置保护规则,翻译内容可能直接覆盖用户的手动修改,所以自动合并必须建立在 branch protection 之上。
到这里,这条链路的复盘就完整了。它不涉及什么高深算法,难的是把每一环的边界条件、失败路径、幂等语义都处理到位。如果给后来者一句话:先把状态机画完整,再谈自动化和效率。
常见问题(FAQ)
Q1:怎么快速判断循环?
优先 commit message 前缀,作者次之。
Q2:自动合并要不要默认开启?
不要默认开启,给企业级配置项。
Q3:链路这么长怎么测试?
按子模块单测 + 全链路 e2e,模拟 GitHub 端事件。