全量翻译的路子,文档一多就走不通:每次仓库有 push,就把整站文档翻一遍。文档库上百页,一天几十次 push,模型调用量很快把成本顶到扛不住,翻译耗时也长。于是我决定改成增量翻译,只处理变化的部分。真正动手才发现难点不在”翻译”,而在”怎么准确判断哪些文件变了”。我先后试过只信 commit 信息、全量比对文件内容两种思路,前者漏文件,后者太耗网络,最后落地成 GitHub Contents API + 本地哈希表 + 任务状态机三层组合的方案。下面把变更检测的设计和踩坑过程讲清楚。
一、变更检测的三层结构
先看整体架构。变更检测要回答的是”仓库里哪些文件需要重新翻译”,单靠任何一层都做不完整,所以拆成三层各管一段。
| 层 | 数据 | 作用 |
|---|---|---|
| GitHub | commit、tree、blob SHA | 仓库级变更 |
| 数据库 | TranslationSegment.sourceHash | 任务级缓存 |
| 内存 | 当前任务的 hash 集合 | 任务内去重 |
GitHub 层靠 commit 和 tree 的 SHA 回答”仓库里什么变了”;数据库层靠 TranslationSegment.sourceHash 回答”这份源文我翻过没有”;内存层靠 hash 集合回答”这次任务里有没有重复文件”。三层各司其职,缺一层就会出现重复翻译或漏翻。最初我只做了 GitHub 层,以为 commit 信息够用,结果同一个文件被多个 commit 命中,一次任务翻了两遍,成本直接翻倍,才补上内存层去重。
二、GitHub 层:从 commit 拿差异
WebHook 携带 push 事件的 commits[],每个 commit 含 added/modified/removed:
{
"commits": [
{
"id": "abc123",
"added": ["docs/zh/index.md"],
"modified": ["docs/en/index.md"],
"removed": []
}
]
}
拿到目标分支最新 SHA head_sha,再调 GET /repos/{owner}/{repo}/compare/{base_sha}...{head_sha} 拿完整差异。
这段解决”本次 push 涉及哪些文件”的问题。commit 数组里的字段拼出第一版变更集合,compare 接口再从 base 到 head 拿完整 diff,两边的信息合并,才能覆盖”多个 commit 合并推送”的场景。这里有个坑:compare 接口有分页,超过限制要按 next 指针翻页。我最初漏了翻页,大 push 事件总是少拿文件,翻译任务偶尔缺漏,排查了挺久。
三、数据库层:判断源文是否变化
每个 TranslationSegment 存 sourceHash = sha256(content)。新一次任务执行前:
const seg = await prisma.translationSegment.findFirst({
where: { path, jobId: previousJobId },
orderBy: { createdAt: "desc" },
});
const newHash = sha256(fetchedContent);
if (seg && seg.sourceHash === newHash) {
// 源文未变,跳过
} else {
// 创建新片段或更新源文
}
用哈希而不是直接比内容,是为了省传输和比较开销:sha256 结果只有 32 字节,源文可能是几十 KB。这段代码的核心是拿”数据库里最近一次任务、同 path 的片段”当基准,哈希一致就跳过,否则重建片段。容易出错的是基准选择:必须按 createdAt 降序取最新一条,否则 revert 之后再修改的场景会拿旧片段比较,该翻译的没翻译。
四、内存层:任务内去重
任务内可能因合并推送、并发拉取导致同一 path 多次进入。处理:
const seen = new Set<string>();
for (const file of files) {
if (seen.has(file.path)) continue;
seen.add(file.path);
enqueue(file);
}
这段解决”同一个文件在一次任务里只进队一次”。合并推送时多个 commit 可能都改了同一个文件,不去重的话,翻译、写库、推送全部重复执行,既浪费模型调用又污染数据库。Set 去重是开销很小的做法,内存放一个哈希集合,判断是 O(1)。
五、变更检测的判定流程
把三层串起来,完整流程如下。
- Webhook 解析出本批变更文件集合
changed; - 拉取每个文件的最新内容;
- 计算 hash,与数据库最近一次任务的同 path 片段比较;
- 新增 / hash 变化 → 加入待翻译队列;
- 删除的文件 → 把数据库片段标记
stale,不删除以备审计。
这个顺序不能乱:先拉内容再算 hash,保证比较的是最新源文;先查数据库再决定入队,避免对已翻译内容重复调用模型。删除文件不直接删记录而是标 stale,是为了审计和历史追溯,这一点在回滚场景里特别关键。
六、与 GitHub 树 API 的配合
对于大仓库,commit diff 包含的列表可能不全。补一步:
const tree = await octokit.git.getTree({
owner, repo, tree_sha: head_sha, recursive: "true",
});
把整棵树 hash 入库,只对差异文件做内容校验,效率与准确性兼顾。
tree 接口返回整棵文件树的 SHA,相当于仓库当前状态的快照指纹。用它对照差异列表,可以兜住 commit 信息里没体现的变更,比如某些合并场景下 diff 不完整的情况。但整棵树对超大仓库体积不小,所以只在 diff 列表不可信时才全量拉,平时仍以 commit 差异为主。
七、源文变化的边界
哈希比较解决了”变没变”,但”变了要不要翻”还需要定义边界,否则容易误翻。
- Frontmatter 变化:只翻译指定字段,不重写其它部分;
- 仅空白变化:hash 不变,跳过;
- 链接变化:保留链接,更新链接文本;
- 代码块变化:原样保留,不翻译。
这些边界是测试阶段逐渐总结出来的。文档库经常有”只改空行”的提交,按内容重翻的话,模型会把整段重写,产生一堆无谓 diff;代码块里的内容交给模型翻译几乎必错,所以一律原样保留。Frontmatter 只翻 title、description 这类指定字段,其余元数据不动。
八、回滚与恢复
当 push 是 revert 提交时,diff 列表会反向。处理:
- 不再翻译 revert 删除的文件;
- 翻译 revert 重新引入的文件;
- 数据库里对历史片段保留 “stale” 标记,便于审计。
回滚的坑在于”删除的文件其实恢复了旧版本”,如果只按 diff 的 removed 字段处理,会把重新引入的文件漏掉。这里以最终 head 状态为准:文件存在于 head 就翻译,不存在就标 stale。数据库里所有历史片段保留,配合 sourceHash 可以随时追溯某个版本翻过没有。
九、性能与缓存
变更检测跑得够快,翻译任务才能及时开始。几个优化点:
- 拉取文件用并发,但并发度按 Installation 限流调整;
- 内容缓存到 Redis,TTL 5 分钟;
- 批量拉取用 GraphQL 一次拿多文件元数据。
并发度这个参数我一开始拍脑袋设成 20,结果频繁触发 GitHub 限流,任务大量 403 失败。后来改成按 API 剩余配额动态调整并发,限流问题基本消失。Redis 缓存内容避免同一文件在同批任务里反复拉取,TTL 5 分钟足够覆盖一个任务周期。
十、可观测性
变更检测的正确性要靠观测兜底,没有指标就没法判断逻辑对不对。
- 指标:扫描文件数、跳过数、待翻译数、翻译成本;
- 日志:每个被翻译文件记录 path、hash、segment_id;
- 告警:跳过率突然下降说明可能拉取异常。
“跳过率”是我后来补的指标。正常情况下,大部分 push 应该被哈希命中跳过,跳过率突然下降,大概率是 hash 逻辑出了问题或者拉取异常。光看翻译成功不够,看”该跳过的有没有跳过”才能暴露隐藏的 bug。
十一、回归测试
逻辑改完之后,靠回归测试保证变更检测不出错。测试覆盖四类场景:
- 模拟无变化 push:任务 0 片段创建;
- 模拟修改 push:仅修改文件入队;
- 模拟大批量 push:分片处理;
- 模拟 revert push:按反向规则处理。
这四条分别验证去重、哈希比对、分页和回滚逻辑。其中”无变化 push”是容易漏的用例——不写这条,某次把 hash 比较改坏时,全量翻译的 bug 可能一周后才在线上暴露出来。
到这里,增量翻译的变更检测链路就完整了:三层结构、哈希比对、边界定义、回滚处理、性能与可观测性,再到回归测试,每一环都有明确的职责和易踩的坑。
常见问题(FAQ)
Q1:仅空白变化要不要重翻译?
不要,hash 不变即可跳过。
Q2:翻译过的文件被删除怎么办?
数据库标记 stale,不删除,便于追溯。
Q3:增量翻译如何保证完整性?
每次执行前用最新 head_sha 全量校验一次任务范围。