在这次 Go Learning & Documentation v2 的设计中,有一个看起来并不算复杂,但实际上影响了整个翻译工作流的问题:
一篇 Markdown 页面,到底应该怎样划分 TranslationUnit?
这个问题之所以值得单独拿出来写,是因为从程序实现的角度来看,答案似乎非常简单。
Markdown 天然就是结构化内容。
一个标题是一块。
一个段落是一块。
一个列表项是一块。
一个引用是一块。
甚至一个代码块中的教学注释,也可以单独抽取出来。
既然程序本来就需要解析这些结构,那么最直接的办法似乎就是:
把每一小块内容都变成一个 TranslationUnit。
这样做有很多表面上的好处。
每个 TranslationUnit 都很短。
翻译失败时只需要重试一个小片段。
修改一个段落,也不用重新处理整篇文章。
机器校验、diff、缓存,看起来都会更加简单。
但在真正把 Learn 和 Docs 纳入 v2 以后,我最后没有采用这种设计。
现在的正式规则反而是:
一个 physical Page,就是一个正式 Page TranslationUnit。
Markdown 内部当然还是会被 parser 拆解。
但这些内部结构只是为了:
- 解析;
- 保护;
- 校验;
- 重建;
- 故障诊断。
它们不是正式的语言治理单位。
为什么会这样?
原因其实和翻译质量本身关系很大。
程序最喜欢的切分方式,不一定适合自然语言
站在程序的角度,小粒度几乎总是很诱人。
假设一篇文档有:
Title
Paragraph A
Paragraph B
- List item 1
- List item 2
Code block
Paragraph C完全可以把它变成:
TU-001 = Title
TU-002 = Paragraph A
TU-003 = Paragraph B
TU-004 = List item 1
TU-005 = List item 2
TU-006 = Code comment
TU-007 = Paragraph C每个单元独立 Generation、独立 validation、独立 QC。
从数据结构来看非常整齐。
但真正开始翻译以后,很快就会发现另外一面。
自然语言不是数据库中的独立字段。
一个段落经常依赖前面的内容。
“它”“这种方式”“前面的例子”“接下来”“因此”这些表达,本身就需要上下文。
标题和正文之间也有关系。
列表项之间可能共享一个省略掉的主语。
某一句话单独翻译完全正确,放回前后文以后却可能显得重复、突兀,甚至改变整段的重点。
所以这里出现了一个很重要的矛盾:
机器希望把内容拆得更细,语言质量却往往需要更完整的上下文。
v2 最后选择优先保护后者。
一个句子正确,不代表整页读起来正确
这个问题在 AI 翻译里尤其明显。
如果把每个段落分别提交给 Generation,模型当然可以把每一段都翻译得语法正确。
但最终重新拼在一起以后,可能出现很多小问题。
例如前一段已经明确说过:
“Go 中的接口是隐式实现的。”
下一段如果单独翻译,可能再次把完整背景解释一遍。
单独看没有错。
连续阅读时就会显得啰嗦。
又比如英文中两个连续段落分别使用:
this value
it如果单独处理第二段,Generation 可能不知道 it 到底对应哪个概念,只能根据当前片段猜。
再比如教程页面常见这样的结构:
First, create a new file.
Then add the following code.
This program...如果拆开处理,“这个程序”“上述代码”“然后”等连接关系都更容易出现不自然的问题。
这些问题机器 validation 通常发现不了。
因为:
- Markdown 结构是正确的;
- protected token 一个不少;
- inline code 全部存在;
- link 也没有丢;
- 占位符完全一致。
但语言本身已经失去了连续性。
所以 TranslationUnit 的边界不能只按照:
机器能够独立校验到哪里
来决定。
它还必须考虑:
Reviewer 到底需要看到多少上下文,才能判断翻译是否真的好。
Reviewer 也需要看到完整页面
这件事情不仅影响 Generation,同样影响 Independent Reviewer。
假设一个 Page 被切成 30 个 TranslationUnit。
Reviewer 可能会得到:
TU 1
source: ...
candidate: ...
TU 2
source: ...
candidate: ...
TU 3
source: ...
candidate: ...每一个都可以单独打 A、B、C、D。
问题是:
这些小单元全部得到 A,并不代表重新组合出来的 Page 一定是 A。
例如:
第一段使用了“并发”。
第三段换成了“并行”。
第五段又写成“同时执行”。
每个小片段独立看都可能没有明显错误。
但如果 source 实际上始终在讲同一个概念,那么整页已经出现了术语漂移。
又例如,同一个页面中前面采用“你可以……”,后面忽然变成“您可以……”。
单独审核每一段,这种问题非常容易漏掉。
还有更细微的情况:
两个相邻段落可能把同一个解释重复了一遍。
这种问题只有把它们放在一起,才真正看得出来。
因此如果 Reviewer 的正式治理单位本身就是 Page,那么 Reviewer 做出的 A/B/C/D 才真正对应用户最终会读到的内容。
这也是 v2 中我越来越重视的一条原则:
正式 Quality Check 应该尽量对应最终用户实际消费的内容边界。
用户打开的是一篇页面。
不是几十条内部字符串。
但机器内部仍然必须拆
这里很容易产生另外一个误解:
既然一个 Page 是一个 TranslationUnit,是不是意味着程序就把整篇 Markdown 当成一大段纯文本处理?
当然不是。
实际上,越是坚持 Page-level TranslationUnit,内部 parser 和保护机制反而越重要。
因为一个页面里面可能同时存在:
- Markdown heading;
- paragraph;
- ordered / unordered list;
- inline code;
- fenced code block;
- link text;
- link destination;
- directive;
- HTML;
- placeholder;
- teaching comment;
- 其他必须保持机器语义的内容。
这些内容不可能全部直接交给模型自由改写。
所以内部仍然需要把 Page 解析成更细的结构。
可以把它理解成类似:
Physical Page
│
├── translatable slot
├── protected structure
├── translatable slot
├── inline-code binding
├── protected code
├── translatable comment
└── link binding这些 slot 对程序非常重要。
它们决定什么可以翻译,什么不能动。
它们也帮助机器在 Generation 返回结果以后重新检查:
- token 是否完整;
- code 是否被破坏;
- link 是否还绑定在正确的位置;
- Markdown 是否能够重建;
- directive 是否仍然合法。
但这些 slot 的存在,并不意味着它们应该成为正式 TranslationUnit。
这是 v2 中一个很重要的分层。
Internal slot 是实现细节,不是治理 namespace
如果内部 parser 切出了 20 个 slot,就建立 20 个正式 TranslationUnit,看起来很自然。
但这样做会产生一个长期问题:
内部实现方式开始反过来控制语言工作流。
假设今天 parser 把一个复杂段落拆成 3 个 slot。
明天 parser 改进以后变成 5 个。
如果 slot 本身就是正式 TranslationUnit,那么仅仅一次 parser 改进,就可能导致:
- TranslationUnit identity 发生变化;
- 历史 QC 很难继续复用;
- revision evidence 需要重新映射;
- Snapshot 结构变化;
- Reviewer scope 变化;
- 甚至出现大量并没有真正发生语言变化的 stale 状态。
这其实是把 parser implementation 泄漏到了治理层。
而 physical Page 相对稳定得多。
只要那个真实页面仍然存在,它作为正式 TranslationUnit 的 identity 就可以继续存在。
至于内部今天拆成多少 slot、明天 parser 怎样改进,属于机器层自己的事情。
这也是为什么我后来越来越明确地把两者分开:
Page 是治理单位。
slot 是解析单位。
两者不能混为一谈。
这也让 parser 可以继续演进
这种分层还有一个很现实的好处。
Learn / Docs 加入以后,parser 并不是一次性就能设计完。
有些结构一开始没有完全覆盖。
有些完整代码中的教学注释,后来才发现需要更细致的识别方式。
有些 inline code 和自然语言之间的 binding,也需要随着真实内容不断调整。
如果正式 TranslationUnit 与 parser 内部结构强绑定,那么每次 parser 改进都可能影响大量已有历史。
而现在只要:
Page identity 不变内部 parser 可以继续升级。
旧 candidate 也可以重新经过新的 parser / validator 判断是否仍然合法。
真正受到影响的内容再处理。
没有受到影响的 Page,则没有必要因为 parser 内部重构就重新翻译一次。
从长期维护来看,这种隔离非常重要。
自然语序也是我不愿意把 TranslationUnit 切太碎的原因
还有一个问题,是自然语序。
英语和中文的表达顺序并不总是一致。
尤其当一句话中包含:
- inline code;
- link;
- protected token;
- technical term;
- placeholder;
如果把内容切得过细,就很容易出现一种错误倾向:
为了保护机器结构,强迫译文沿用英文的局部顺序。
这种结果结构上很安全。
但中文可能非常别扭。
后来项目已经逐渐明确:
只要完整的 pair、inline code、link binding 和机器语义没有被破坏,就应该允许 Generation 根据中文自然语序调整它们的位置。
例如,英文可能是:
Use `go test` to run the tests.中文自然表达可以是:
运行测试时,可以使用 `go test`。go test 并不需要机械地留在原来的词序位置。
如果 TranslationUnit 被拆得太细,甚至把 inline code 周围的自然语言分成不同片段,这种自由度就会明显下降。
Page-level TranslationUnit 则给 Generation 保留了足够的语言空间。
程序负责保护边界。
语言模型负责自然表达。
两者各自处理自己擅长的事情。
Exact revision 也因此更容易保持“局部完整”
Page 作为正式 TranslationUnit,并不意味着发现一个小问题以后就必须整页重译。
这同样是两个不同层次的问题。
Reviewer 可以指出某个 Page 中的具体 finding。
例如:
某一句中的术语不准确。
或者:
某一处把教学含义翻译反了。
Generation 收到的是这个完整 Page,以及对应 finding。
但 revision 的要求并不是:
“重新翻译整篇。”
而是:
finding-anchored + locally complete。
也就是说,只修改真正受到问题影响的局部,同时确保这个局部修改以后和周围上下文仍然连贯。
这样就同时保留了两件事情:
完整 Page context
和
精确 revision scope
这比把正式 TranslationUnit 拆成一句一句更合适。
因为小粒度 revision 并不要求小粒度治理单位。
小 TranslationUnit 并不一定真的更省成本
最开始看到 Page-level TranslationUnit 时,一个很自然的担心是:
一个页面这么长,如果只有一处错误,重新处理整个 Page,不是很浪费吗?
但实际工作流并不是简单地“整页重新生成”。
真正消耗成本的地方,还包括:
- export;
- identity 管理;
- Reviewer Bundle;
- Quality Check record;
- Snapshot;
- revision eligibility;
- re-QC;
- history;
- pending state;
- finalization。
如果把一篇页面拆成几十个正式 TranslationUnit,虽然每个单元更小,但治理对象的数量会急剧增加。
一篇 20 段的 Page 可能变成 20 个甚至更多 TU。
49 个 Learn / Docs Page,可能很快变成几百甚至上千个 TranslationUnit。
然后每一个都要单独维护:
- identity;
- QC status;
- revision state;
- evidence;
- history。
系统复杂度并不会凭空消失。
它只是从“单个 TranslationUnit 比较大”,转移成了“TranslationUnit 数量巨大”。
而对于文档翻译这种需要上下文的任务来说,这种交换未必划算。
一页一个 TranslationUnit,并不是绝对真理
当然,这里也需要说明:
“Page = TranslationUnit”并不是适用于所有翻译系统的普遍真理。
如果处理的是:
- UI 按钮;
- 菜单项;
- 错误提示;
- 独立消息;
- 数据库字段;
- 软件资源字符串;
小粒度 TranslationUnit 往往非常合理。
因为它们本来就是独立消费的内容。
但 Learn / Docs 是连续阅读的技术文档。
用户消费的天然单位就是页面。
所以在这个项目里,我最终更愿意让正式 TranslationUnit 接近最终内容的自然边界,而不是 parser 最方便的边界。
v2 中最终形成的原则
现在回头看,这个设计其实可以压缩成一句很简单的话:
机器可以按自己需要的方式解析内容,但不能让机器内部的切分方式决定人类语言的治理边界。
对于 Learn / Docs:
Physical Page
=
Formal Page TranslationUnit而:
Internal slots
=
parser / protection / reconstruction / validation
implementation details两者各自承担自己的职责。
这样既可以让机器做细粒度的结构保护,也可以让 Generation 和 Reviewer始终看到完整的语言上下文。
同时 parser 以后继续演进,也不需要因为内部 slot 发生变化,就把正式语言历史一起推翻。
这不是 v2 才开始允许自然语序
这里还需要特别说明一点。
允许译文根据目标语言调整语序,并不是 v2 才加入的能力。
在之前的 A Tour of Go 翻译中,就已经不能机械要求译文中的 protected token、inline code 或其他受保护结构严格保持英文中的原始位置。
只要必要的 token 没有丢失,成对结构和 binding 没有被破坏,代码与机器语义仍然正确,Generation 就应该优先生成自然的目标语言。
例如:
Use `go test` to run the tests.翻译成:
运行测试时,可以使用 `go test`。这样的顺序调整本来就是允许的。
因此,v2 真正发生的变化,并不是:
从“严格保持顺序”变成“允许自然语序”。
而是随着 Learn / Docs 被纳入系统以后,需要处理的 representation 复杂得多。
一个 Page 中可能同时出现:
- Markdown 结构;
- inline code;
- link text 和 link destination;
- fenced code;
- 教学代码注释;
- directive;
- HTML;
- placeholder;
- 其他需要保护的机器结构。
原来已经存在的原则:
保护机器语义,但不要因此牺牲自然语言。
到了这里,需要应用到更复杂的内容模型中。
这也带来了一个新的架构问题:
parser 为了保护这些结构,可以把 Page 拆得非常细;但这些内部拆分,是否也应该成为正式 TranslationUnit?
v2 最终给出的答案是否定的。
机器内部可以继续细分 representation,以便完成解析、保护、校验和 reconstruction。
但正式的语言治理仍然保持在完整 Page 层级。
换句话说,v2 并没有改变 Tour 阶段已经采用的自然语言原则,而是进一步把两层职责分开:
机器负责保护不能破坏的语义边界。
Generation 仍然可以在这些边界之内按照目标语言自然表达。
Reviewer 则按照完整 Page 判断最终语言质量。
这才是 Learn / Docs 加入以后真正需要进一步明确的地方。
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 官方无隶属、授权或背书关系。
