在多语言翻译工作流里,我原本以为“候选快照”是一个相对简单的阶段。
前面的翻译生成已经完成,候选译文也通过了机器验证。接下来无非就是把当前候选状态冻结下来,再交给独立审核会话进行质量审核。
但在最近处理 site-v2-shell 首页时,我遇到了一个很典型的问题:
我明明只想给首页的 1 个页面创建候选快照,结果系统却生成了 122 个翻译单元。
真正麻烦的地方还不是“数量错了”。
而是这份候选快照已经作为正式工作流产物生成了。按照整个项目一直坚持的历史不可变原则,它不能因为生成错了就直接删除、覆盖,或者假装从来没有存在过。
最后,我只能先保留这份错误快照,再修复真正的问题,然后创建一份新的后继快照。
这次事故让我更加确认了一件事:
在正式工作流中,操作范围不能依赖上下文猜测。只要一个命令可能作用于多个内容包,就应该明确绑定它所操作的内容包。
背景:首页已经不再是原来的翻译单元
这次问题发生在简体中文 Site v2 首页。
首页的原始源文件是 _content/index.md。
它的原始字节并没有发生变化。
真正发生变化的是:
机器如何把这份源文件拆成“可翻译内容”和“受保护内容”。
此前首页里有一段用于代码编辑器教学的自然语言:
You can edit this code!它被旧的机器表示方式错误地放进了不可编辑区域。
也就是说,从语言质量角度看,我们知道它应该翻译。
但从机器规则看,翻译生成阶段根本没有权限修改它。
后来我专门为这类问题建立了新的首页表示继承机制。
这套机制不会修改原始源文件,也不会改写旧候选译文,更不会把旧的语言审核结论直接搬到新的翻译单元上。
新的表示方式正式生效以后,首页得到了一个新的页面翻译单元:
f4365d05fcff295676c0964f81058e6485bb0361f8440cbf243220a6acc1e97d这个新单元需要重新经历完整的语言流程:
翻译生成
→ 机器验证
→ 候选快照
→ 独立质量审核这本来应该是一条非常窄的处理链路。
当前范围只有:
- 内容包:
site-v2-shell - 路由:
/ - 页面:1
- 示例:0
- 翻译单元:1
但就在创建候选快照时,问题出现了。
一个页面,为什么会突然变成 122 个单元?
当时第一次生成的候选快照是 site-v2-shell-qc-003。
预期非常简单:
只冻结当前 site-v2-shell 首页这一个页面。
结果生成以后却发现:
快照里出现了 122 个翻译单元。
122 这个数字其实非常熟悉。
它正好对应当时 Tour 当前完整的翻译单元数量:
- 103 个页面
- 19 个示例
也就是说,这并不是随机的数据污染。
机器其实非常“认真”地执行了一套完整范围。
只是执行的是错误的范围。
真正的问题出在候选快照命令对“内容包”的选择上。
命令虽然是在 Site shell 的工作流上下文中执行的,但候选快照本身没有被一个足够明确的内容包参数约束。
于是底层翻译流程仍然使用了默认范围。
而这个默认范围恰好是 Tour。
从调用者的角度看,我正在处理的是 site-v2-shell。
但从底层工作流的角度看,它理解的却更接近:
给当前默认的翻译单元集合创建一份候选快照。
两边都没有明显报错。
结果却完全不是同一个东西。
最危险的问题,不一定会主动报错
这类问题其实比直接崩溃或者验证失败更危险。
因为它满足很多表面上的“正常条件”:
- JSON 结构合法;
- 候选快照可以成功写出;
- 里面的翻译单元也都是真实存在的;
- 数量不是 0;
- 文件哈希可以正常计算;
- 身份标识可以正常生成;
- 文件格式没有损坏;
- 命令甚至可能返回成功。
如果只看退出状态:
exit status = 0很容易认为整个步骤已经完成。
但真正的问题是:
机器只证明了这份产物内部自洽,却没有证明它属于我真正想操作的内容包。
这也是正式工作流和普通脚本之间一个非常重要的区别。
普通脚本经常只关注:
结果是否合法?
而正式工作流还必须继续追问:
这份结果到底属于哪个范围?
为什么不能直接把错误快照删掉?
发现 site-v2-shell-qc-003 不对以后,最直觉的处理方式当然是:
删除它,然后重新生成。
但我没有这么做。
因为一旦一个正式工作流产物已经产生,它本身也构成了历史事实。
即使它是一次错误操作的结果,也应该保留下来,让以后仍然可以回答:
- 当时用了什么输入;
- 为什么会包含 122 个单元;
- 哪一个命令契约没有约束好范围;
- 后来又是怎样修复的。
如果直接把它删除,后面再看到日志、哈希、引用或者诊断记录时,就会出现一个更麻烦的问题:
历史记录指向了一份“现在看起来从未存在过”的正式状态。
所以这次仍然遵循项目一直采用的原则:
历史可以失败,但不能被改写。
site-v2-shell-qc-003 被保留下来。
它不再继续作为正确的 Site shell 候选快照使用,但也没有被覆盖。
真正的修复不是“换一个默认值”
一开始其实很容易想到一种简单修法:
既然这次处理的是 site-v2-shell,那就让候选快照默认使用 site-v2-shell。
但这样只是把问题从一个内容包转移到了另一个内容包。
今天默认 Tour 会出错。
明天默认 Site shell,同样可能让 Learn / Docs 出错。
真正应该修复的是:
创建候选快照时,命令必须明确知道调用者选择的是哪个内容包。
因此后来把“内容包选择”提升成了正式命令契约的一部分。
调用方不能再依赖:
- 当前目录;
- 上一步执行了什么命令;
- 最近一次规划结果;
- 某个默认翻译目录;
- 历史上最常用的内容包;
- “大家都知道现在处理的是 Site shell”。
而是必须显式写明:
--content-package site-v2-shell这样一来,操作范围就从一种隐含假设,变成了机器可以验证的正式输入。
修复以后,候选快照终于回到了 1 个页面
命令契约修复以后,我没有修改原来的 site-v2-shell-qc-003。
而是重新创建了新的候选快照:
site-v2-shell-qc-004这一次结果终于符合真正的工作流范围:
- 页面:1
- 示例:0
- 翻译单元:1
- 当前单元:首页的新翻译单元
- 内容包:
site-v2-shell
也就是说,从错误的:
122 个翻译单元回到了真正需要审核的:
1 个翻译单元这里最重要的其实并不是“少了 121 个单元”。
真正重要的是:
候选快照的身份、成员列表和内容包范围终于重新对齐了。
从这一刻开始,后面的独立质量审核才重新拥有了可信的输入边界。
内容包不是一个方便分类的标签
这次之后,我对“内容包”的理解也更加清楚了。
它不是为了让目录看起来更整齐而额外增加的一层分类。
它实际上决定了很多关键问题:
- 哪些翻译单元属于当前生命周期;
- 哪些历史结果可以继续继承;
- 哪些候选译文可以进入当前快照;
- 哪些审核结论与当前对象匹配;
- 哪些最终确认结果可以进入内容包封存;
- 哪些站点审核结果属于当前内容;
- 哪些状态变化属于当前任务;
- 哪些内容仅仅只是旁边存在的上下文。
也就是说:
内容包本身就是一条权威边界。
只要一个命令可能跨越这条边界,内容包就应该成为显式输入,而不能只是隐含环境。
已经有翻译单元 ID,为什么还不够?
这里还会产生一个很自然的问题:
既然已经知道首页的新翻译单元 ID,例如:
f4365d05fcff295676c0964f81058e6485bb0361f8440cbf243220a6acc1e97d那直接传这个 ID 不就够了吗?
并不完全够。
翻译单元 ID 可以回答:
我要操作哪个翻译单元?
但内容包还需要回答另外一个问题:
这个翻译单元现在属于哪一条正式生命周期?
同一套底层翻译系统可以同时服务:
- Tour
- Learn / Docs
- Site shell
而后面的流程还包括:
候选快照
→ 独立质量审核
→ 最终确认
→ 内容包封存
→ 整合站点审核
→ 激活这些阶段并不是只看一个孤立的翻译单元 ID。
它们还要知道:
这个单元属于哪个内容包、哪一条处理链、哪一组正式历史。
因此,翻译单元 ID 和内容包并不是二选一。
它们分别解决两个不同层次的问题。
一个确定具体对象。
一个确定这个对象所处的正式范围。
这类问题为什么在大型工作流里特别常见?
当一个项目最开始只有单一翻译流程时,很多默认值其实没有问题。
例如系统只有 Tour。
那么:
没有指定内容包
和:
使用唯一存在的内容包
实际效果完全一样。
但后来系统逐渐扩展成:
Tour
+ Learn / Docs
+ Site shell
+ 结构化内容问题就出现了。
原来“只有一个可能答案”的默认值,慢慢变成了:
“多个可能答案中的其中一个”。
可是那个旧默认值仍然藏在代码里。
表面上的命令没有变化。
底层语义却已经发生了变化。
这种问题特别容易出现在:
- 候选快照;
- 翻译导出;
- 审核包导出;
- 最终确认;
- 内容包封存;
- 工作规划;
- 故障恢复。
因为这些命令很可能最早是在“系统里只有一种对象”的时期设计出来的。
后来支持范围扩大以后,如果没有重新检查命令契约,就很容易出现这种隐式范围泄漏。
我现在反而更愿意让正式命令“麻烦一点”
过去设计命令行工具时,我会觉得:
能自动推断出来的参数,就不要再让人手工输入。
现在我的看法有了一些变化。
对于普通工具来说,减少参数当然很好。
但对于正式状态变更工作流来说:
有些看起来重复的参数,其实就是安全设计的一部分。
例如一条正式命令可能需要同时明确:
localecontent package- 翻译单元 ID
- 前一个候选快照
- 前置状态 SHA
- 预期身份标识
这些参数分别在回答:
- 正在处理哪一种语言;
- 正在处理哪个内容包;
- 正在处理哪个具体翻译单元;
- 这次操作继承自哪个历史状态;
- 执行前仓库应该处于什么状态;
- 预期生成的正式结果是谁。
从使用体验上看,这当然比一个“自动帮你猜”的命令更加繁琐。
但真的出问题以后,这些参数可以组成一条非常清晰的来源链。
相反,如果命令大量依赖:
当前应该就是这个吧。
平时看起来省事。
一旦发生异常,诊断成本反而会更高。
这次事故最后留下的几条规则
经过这次候选快照范围事故以后,我给类似命令留下了几条更加明确的规则。
第一:
跨内容包的正式命令必须显式绑定内容包。
第二:
命令执行成功,不代表操作范围正确。
第三:
已经生成的错误正式产物不删除、不覆盖,通过后继状态修正。
第四:
一个默认值一旦从“唯一可能”变成“多个可能之一”,就应该重新审视它是否还适合作为默认值。
第五:
操作范围本身就是权威状态的一部分,不只是一个筛选条件。
最后一条也是我觉得最值得记录的:
一份工作流产物可以在格式、哈希、身份标识和内容结构上全部正确,但只要它属于错误的操作范围,它仍然是一份错误的正式结果。
这也是为什么现在我的多语言工作流越来越倾向于:
无法确认时按失败处理。
并不是因为我希望系统更容易失败。
而是因为当系统已经无法证明:
我现在操作的到底是不是正确对象?
这时候继续“成功”,往往比明确停止更加危险。
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 官方无隶属、授权或背书关系。

