在上一篇中,我整理了 Go Learning & Documentation v2 为什么最终选择:
一个 locale,只维护一份正式 glossary。
Tour、Learn、Docs,以及以后可能增加的其他 content package,都共享同一套正式术语规则。
这样可以避免同一个 Go 概念在不同 package 中被重复决定,也能够让整个 locale 的语言体验保持一致。
但当 Unified Glossary 真正建立以后,马上出现了另外一个问题:
术语表只要发生变化,之前已经翻译和审核完成的内容应该怎么办?
最简单的解决方法非常直接:
old glossary SHA
≠
new glossary SHA
↓
所有 TranslationUnit stale
↓
重新翻译
↓
重新审核从工程实现的角度来看,这个方案甚至很有吸引力。
规则简单。
很好验证。
也很安全。
只要 glossary 文件发生变化,就不相信以前的任何结果。
但真正放到一个已经有大量高质量翻译成果的长期项目里以后,我越来越觉得:
这虽然简单,却不是一个合理的长期方案。
文件变了,不代表所有翻译都变错了
假设简体中文 glossary 中增加了一个新术语。
这个术语只出现在两个页面中。
另外一百多个 TranslationUnit 中根本没有出现过这个概念。
如果仅仅因为:
glossary.yaml SHA changed就要求所有页面重新 Generation、重新 QC,那么绝大部分工作其实没有任何语言意义。
那些页面的英文 source 没变。
中文 candidate 没变。
新增的术语也没有出现在里面。
之前的 Independent QC 结果仍然成立。
唯一发生变化的只是:
它们共同引用的那个 glossary 文件的 SHA 不一样了。
如果整个系统把“文件 identity 变化”直接等同于“语言语义失效”,结果就会产生大量没有必要的返工。
这也是 v2 后来需要解决的核心问题之一:
我们真正关心的不是 glossary 文件有没有变化,而是这次变化有没有影响某个具体 TranslationUnit。
SHA 很适合证明 identity,但不适合直接证明语义影响
这里并不是说 SHA 没有价值。
恰恰相反,在整个项目中 SHA 非常重要。
它可以准确告诉我们:
这两份输入是不是完全相同。
例如:
old glossary SHA = A
new glossary SHA = B只要 A != B,就可以明确证明 glossary 的字节内容发生了变化。
但 SHA 无法回答另外一个问题:
这次变化具体意味着什么?
可能只是:
- 调整了条目的顺序;
- 修改了说明文字;
- 增加了一个当前 corpus 中根本没有出现的词;
- 修改了一个只影响某两个 Page 的术语;
- 修改了一个几乎遍布整个 Go 文档的核心术语。
从 SHA 的角度来看,这些情况完全一样:
changed但从翻译系统的角度来看,它们的影响范围完全不同。
所以在 v2 中,glossary identity 和 glossary semantic impact 不能再被当成同一件事。
第一步不是重翻,而是先找出“到底改了什么”
更合理的做法首先是比较:
old glossary 和 new glossary 之间真正的 semantic delta 是什么?
这里关心的不是 YAML 哪一行变化了,而是正式语言规则发生了什么变化。
例如,一个术语可能从:
preferred: A变成:
preferred: B也可能新增了一条:
forbidden: C或者某个此前没有正式约束的 Go 概念,现在第一次进入 glossary。
真正需要传播到 TranslationUnit 的,是这些语言规则变化。
而不是文件层面的 diff 本身。
于是整个判断开始从:
glossary changed?变成:
which glossary semantics changed?这是两个完全不同的问题。
第二步:这个变化到底出现在哪些内容里
知道哪些术语发生变化以后,下一个问题就变成:
这些变化与哪些 TranslationUnit 有关系?
假设这次只修改了一个术语:
foo那么最直接需要检查的,就是哪些 source context 实际涉及 foo 对应的概念。
如果一个 Page 从头到尾都与这个术语无关,它就没有理由因为这次 glossary mutation 被重新卷入流程。
因此可以把逻辑粗略理解成:
Glossary semantic delta
↓
Relevant source contexts
↓
Relevant TranslationUnits这一步已经能够排除大量完全无关的内容。
但还没有结束。
因为:
source 中出现了受到影响的术语,也不一定意味着当前译文有问题。
第三步:旧译文可能已经符合新的术语规则
这是整个机制里我觉得比较重要的一点。
假设 glossary 以前没有正式规定某个术语的译法。
但 Generation 在翻译某个 Page 时,已经自然采用了后来 glossary 才正式确定的译法。
例如:
旧 glossary:
没有正式规则
已有 candidate:
已经使用 A
新 glossary:
preferred / required decision = A从依赖关系来看,这个 Page 确实与新的 glossary rule 有关。
但从最终语言结果来看:
它已经满足新规则。
如果这时候仍然强制重新 Generation,就没有太大意义。
甚至还可能产生反效果。
一个原本已经通过 Independent QC 的稳定译文,被重新送进模型以后,可能出现新的表达变化。
然后 Reviewer 又必须重新审核。
也就是说:
为了修一个实际上并不存在的问题,反而制造了一次新的语言变化。
因此,判断 affected scope 不能只问:
source 中有没有这个词?
还要继续问:
当前 candidate 是否已经与新的 glossary 兼容?
Relevant 和 affected 不是同一个概念
这也是后来我越来越重视的一个区别。
一个 TranslationUnit 可以与某个 glossary change 相关,但最终仍然是 compatible。
可以粗略理解成:
Glossary change
↓
TU relevant?
│
├── No
│ ↓
│ unaffected
│
└── Yes
↓
candidate already compatible?
│
├── Yes
│ ↓
│ compatible / carry
│
└── No
↓
affected只有最后真正 affected 的内容,才应该进入后续处理。
这件事情看起来只是少跑了一些翻译任务,但实际上意义更大。
因为它开始把:
“依赖发生变化”
和:
“已有结果已经失效”
分成两个不同的状态。
这是很多长期运行系统都需要面对的问题。
Compatible 的价值,不只是节省一次 Generation
如果只是省几次 AI 调用,这套机制其实不值得做得这么复杂。
真正重要的是,它保护了已经完成的高质量成果。
假设某个 TranslationUnit 已经经历:
Generation
↓
Machine validation
↓
Independent QC
↓
A甚至还经历过:
B / C
↓
exact revision
↓
re-QC
↓
A这时它已经不是一段“随时可以重新生成的文本”。
它实际上是一份已经经过验证的语言成果。
如果没有实际语义原因,就不应该因为一个外部文件 SHA 发生变化,把这些成果全部丢掉。
因此 semantic compatibility 背后的原则其实和整个项目后来越来越强调的一件事情是一致的:
已经高质量完成、且没有真正受到影响的工作,应该尽可能继续复用。
历史不是为了保存而保存。
它首先应该帮助我们避免重复做已经做对的事情。
但“已经兼容”也不能靠猜
另一方面,也不能为了减少返工,就随便认为旧 candidate 没问题。
如果系统无法可靠证明一个 TranslationUnit 与新 glossary 兼容,那么默认继续 carry 同样有风险。
因此这里的原则不能是:
“看起来应该没影响。”
而应该尽可能建立机器可以复现的 evidence。
至少需要能够回答:
- old glossary 是什么;
- new glossary 是什么;
- 哪些正式规则发生变化;
- 哪些 source context 与变化相关;
- 当前 candidate 中对应内容是什么;
- 为什么这个 context 被判定为 compatible 或 affected。
也就是说:
减少返工不能以降低可验证性为代价。
如果某种情况无法安全证明 compatible,更合理的处理方式仍然应该是 fail closed,让它进入进一步检查。
这比“为了提高 reuse 数字而尽量判成兼容”安全得多。
Preferred、mandatory、forbidden 等规则也不能简单等价
另外一个很容易把 affected scope 做得过粗的地方,是把所有 glossary rule 都当成同一种约束。
但正式 glossary 中,不同类型的规则本身可能代表不同的语言含义。
例如:
- 某个词必须使用固定表达;
- 某个词倾向使用某种表达;
- 某种旧译法应该禁止;
- 某个技术词应该保留英文。
这些变化的风险并不完全一样。
如果一个明确禁止的译法仍然存在于 candidate 中,问题通常比较明确。
但一个 preferred term 发生变化,不一定意味着所有不同表达都必须立刻被判为错误。
具体如何处理,仍然需要服从正式 glossary 对不同类别的定义。
所以 semantic compatibility 也不能退化成:
字符串 A 是否出现它需要理解:
这条 glossary rule 本身要求什么。
这也是为什么这项机制后来比简单搜索字符串复杂得多。
Page 才是最终需要决定是否 affected 的治理单位
上一篇我刚刚写过,Learn / Docs 中虽然 parser 内部可以拆出很多 slot,但正式语言治理仍然以 physical Page 为 TranslationUnit。
这个设计在这里同样很重要。
一个 Page 内部可能有多个 glossary context。
其中:
context 1 → compatible
context 2 → compatible
context 3 → affected
context 4 → compatible最终这个 Page 仍然应该被视为:
affected因为只要其中有一个正式语言问题需要修改,这个 Page 就需要进入对应流程。
反过来,如果所有相关 context 都能够证明兼容,那么整个 Page 才可以继续复用原来的结果。
所以内部可以做细粒度分析。
但最终治理仍然回到 Page。
这与前一篇的设计原则正好一致:
机器可以把问题分析得很细,但正式语言状态仍然应该对应用户最终阅读的内容单位。
affected 也不意味着“整页重新翻译”
这里还有一个特别容易误解的地方。
一个 Page 被判定为 affected,并不意味着:
整页重新 Generation。
假设一个 2000 字的 Page 中,只有一个术语因为 glossary 变化需要调整。
最合理的处理方式当然不是把另外 1990 多个已经审核通过的字全部重新生成。
这时仍然应该采用 exact revision:
affected Page
↓
exact finding / affected context
↓
Generation revision
↓
machine validation
↓
Independent re-QC也就是说:
affected scope 决定“哪些正式 TranslationUnit 需要重新进入流程”;
exact revision 决定“TranslationUnit 内部到底修改多少”。
这两个层级不能混在一起。
这样才能同时做到:
- 不遗漏真正受到影响的 Page;
- 又不把一个局部术语变化放大成整页重译。
为什么“全部重做”反而可能降低稳定性
表面上看,全量重新翻译是一种非常保守的做法。
因为所有内容都重新按照最新 glossary 生成。
好像最安全。
但如果从长期项目角度来看,它实际上可能降低稳定性。
原因很简单:
AI Generation 不是纯函数。
即使输入非常接近,同一段内容再次生成,也可能出现不同的:
- 句式;
- 用词;
- 语序;
- 段落表达。
其中很多变化并不是修复。
只是另外一种同样成立的表达。
如果每次 glossary 发生一个很小的变化,就重新生成整个 locale,那么系统会产生大量不必要的语言 churn。
Reviewer 也不得不重新检查这些实际上与 glossary mutation 无关的变化。
最终结果是:
真正需要修复的内容只占很小一部分,
但绝大部分时间却花在验证无关变化上。
因此“只处理 affected 内容”不仅是在节省成本。
它同时也是在减少没有意义的语言漂移。
这和缓存失效其实有点像
做到这里以后,我发现这个问题其实和软件工程中一个很经典的问题很相似:
Cache invalidation。
如果某个依赖发生变化,最简单的策略当然是:
clear everything这样肯定不会留下 stale cache。
但效率很差。
更成熟的系统会继续判断:
what actually depends on this change?术语表也是类似的。
Glossary mutation 本身只是一个 dependency change。
真正需要失效的是:
那些语义上确实依赖这次变化,而且当前结果已经不再满足要求的内容。
而不是所有曾经使用过 glossary 的内容。
因此我后来越来越愿意把这套机制理解成一种:
semantic invalidation
而不仅仅是“术语检查”。
但语言系统比普通 cache 更麻烦
当然,它又不能完全等同于普通 cache。
编译系统中的依赖往往更加确定。
文件 A import 文件 B,B 变化以后,可以建立比较明确的 dependency graph。
自然语言没有这么简单。
一个 glossary rule 是否影响一句译文,可能涉及:
- source context;
- candidate expression;
- technical meaning;
- rule category;
- 同义表达;
- 上下文中的自然语言选择。
所以这不是简单建立:
term → page的反向索引就结束了。
真正困难的是后半段:
相关,不代表失效。
这也是 semantic compatibility 存在的意义。
这次机制也暴露出了一个新的性能问题
不过,把简单的 SHA gate 改成 semantic compatibility,也带来了新的代价。
以前判断:
SHA equal?几乎瞬间就能完成。
现在则需要:
- 读取 glossary semantic delta;
- 找到相关 context;
- 分析 source;
- 分析 candidate;
- 判断兼容性;
- 汇总到 TranslationUnit;
- 生成对应 evidence。
当这些逻辑开始被大量命令重复调用以后,CLI 性能很快出现了明显问题。
有些原本应该很快完成的 workflow,开始需要几十秒,甚至几分钟。
后来排查发现,真正的问题并不在:
semantic compatibility 这个方向错了。
而在于:
同一份昂贵的 compatibility state 被重复构建了太多次。
这也是为什么我在 10 月 8 日那篇文章里,已经先记录过一次相关的性能问题。
而后面我还准备专门写一篇:
从分钟级到秒级:这套 CLI 后来到底是怎样优化的。
因为它也是这次 v2 中比较典型的一次:
语义设计是对的,但执行架构还需要继续调整。
最后形成的规则,其实并不复杂
虽然实现过程逐渐变得复杂,但最终想达到的规则反而很简单。
当 glossary 发生变化以后,不再立即假设:
所有历史结果失效。
而是依次判断:
1. Glossary 真正发生了哪些 semantic changes?
2. 哪些 TranslationUnit 与这些变化有关?
3. 对于相关 TranslationUnit:
当前 candidate 是否已经满足新规则?
4. 如果已经兼容:
保留可信结果。
5. 如果真正 affected:
只让 affected TranslationUnit 进入 revision / validation / re-QC。
6. 无法可靠判断时:
fail closed,不猜测兼容。它最终想保护的是两件事情:
新的 glossary rule 必须真正生效。
同时:
以前已经正确完成的工作不能因为无关变化被全部推翻。
从“文件是否改变”走向“语义是否受到影响”
现在回头看,这可能也是 v2 中一个很典型的变化。
早期很多状态判断都很容易依赖:
SHA changed因为它简单、确定,而且 fail closed。
但当项目规模越来越大以后,单纯的 identity change 已经不足以决定工作量。
系统开始需要进一步理解:
到底是什么发生了变化?
这个变化真正影响谁?
哪些已经完成的成果仍然成立?
这并不意味着以后不再使用 SHA。
相反,SHA 仍然负责证明输入 identity。
只是它不再单独承担:
判断语言语义是否失效
这个本来就不属于它的职责。
而这套 affected scope 机制,最终也成为整个 v2 中一个越来越重要的设计方向:
只让真正受到变化影响的内容重新进入工作流。
Go 学习与文档多语言本地化项目
本系列完整记录 Go 学习与文档多语言本地化项目从 A Tour of Go 起步,逐步扩展到站点首页、项目说明页、学习内容、文档以及未来内容包的实际开发过程,包括架构设计、统一术语治理、整页翻译、结构保护、自动校验、独立质量审核、生产发布与长期维护。
项目源码:
✅ GitHub:shuijingwan/go-tour-i18n
语言版本:
当前跨项目目标语言池共包含 66 个语言版本。Go 项目本身目前提供 65 个社区 Production 语言版本,下面每一门 Go 语言版本都直接链接到其当前公开站点。语言列表沿用项目首页现有的展示顺序。
英语(美国)仅作为跨项目目标语言纳入列表,并不是单独的 Go Production 语言版本。当前 Go 的英文源内容仍为官方 generic English,因此英语(美国)链接到 Go 官方站点,而不是不存在的本地社区部署。
- 阿姆哈拉语
- 阿拉伯语
- 孟加拉语
- 葡萄牙语(巴西)
- 保加利亚语
- 加泰罗尼亚语
- 克罗地亚语
- 捷克语
- 丹麦语
- 荷兰语
- 英语(美国)(Go 官方英文站点)
- 英语(澳大利亚)
- 英语(加拿大)
- 英语(印度)
- 英语(新加坡)
- 英语(南非)
- 英语(英国)
- 爱沙尼亚语
- 菲律宾语
- 芬兰语
- 法语(法国)
- 法语(加拿大)
- 德语(德国)
- 德语(奥地利)
- 德语(瑞士)
- 希腊语
- 古吉拉特语
- 希伯来语
- 印地语
- 匈牙利语
- 印度尼西亚语
- 意大利语
- 日语
- 卡纳达语
- 哈萨克语
- 韩语
- 西班牙语(拉丁美洲)
- 拉脱维亚语
- 立陶宛语
- 马来语
- 马拉雅拉姆语
- 马拉地语
- 书面挪威语(Bokmål)
- 波斯语
- 波兰语
- 葡萄牙语(葡萄牙)
- 旁遮普语(果鲁穆奇文,印度)
- 罗马尼亚语
- 俄语
- 塞尔维亚语(西里尔文)
- 简体中文(中国大陆)
- 简体中文(新加坡)
- 斯洛伐克语
- 斯洛文尼亚语
- 西班牙语(西班牙)
- 斯瓦希里语(坦桑尼亚)
- 瑞典语
- 泰米尔语
- 泰卢固语
- 泰语
- 繁体中文(台湾)
- 繁体中文(香港)
- 土耳其语
- 乌克兰语
- 乌尔都语
- 越南语
项目正在持续扩展新的 Go 学习与文档内容,并长期维护各语言版本的术语一致性、翻译质量、独立审核、生产发布与后续更新。
本项目属于非官方社区多语言本地化项目,与 Go 官方无隶属、授权或背书关系。

