没有不值得去解决的问题,也没有不值得去学习的技术!

Markdown 不是纯文本:Learn / Docs 为什么需要新的 parser 与结构保护

Markdown Page 经过 Parse / Protect、Generation 和 Reconstruct / Validate 的流程示意图,强调保护 inline code、链接、代码等机器语义,同时允许译文按照自然语言调整语序。

作者:

在

把 A Tour of Go 的翻译范围扩展到 Learn 和 Docs 以后,我很快遇到了一个不能简单沿用“把文本交给 AI”的问题。

Markdown 页面看起来是文本,但它实际上并不只是文本。

一篇技术文档里,可能同时存在普通段落、标题、列表、链接、inline code、代码块、教学代码注释、directive,以及各种不能被翻译模型随意修改的机器结构。

如果把整份 Markdown 原样交给 AI,模型当然有可能正确完成翻译。

但“有可能”对于一个长期运行的自动化翻译系统来说是不够的。

真正需要回答的是:

怎样让模型拥有足够的语言自由,同时又能确定代码、链接和 Markdown 结构不会被破坏?

这也是 Learn / Docs 纳入 v2 以后,parser 和 representation 必须进一步完善的原因。


Markdown 的困难,不是“它里面有一些符号”

如果只是几个 #、* 和反引号,其实并不难处理。

真正麻烦的是,同一段 Markdown 中经常混合了两类完全不同的内容:

需要翻译的自然语言

和

必须保持机器语义的结构

例如:

Plaintext
Use [`go test`](/cmd/go/#hdr-Test_packages) to run the tests.

这里至少包含:

  • 普通英文;
  • inline code;
  • link text;
  • link destination;
  • Markdown link binding。

真正需要翻译的是自然语言。

go test 不能被翻译。

URL 通常也不能被修改。

但最终中文又不能被强迫机械保持英文词序。

合理的译文可能是:

Plaintext
运行测试时,可以使用 [`go test`](/cmd/go/#hdr-Test_packages)。

也就是说:

结构必须稳定,但结构所在的位置不一定固定。

这件事情其实在之前的 Tour 翻译中就已经存在。

Tour 阶段并不是要求所有 protected token 都严格保持英文中的原始顺序。

只要必要的 pair、binding、token 完整性以及机器语义没有被破坏,就应该允许目标语言按照自己的自然语序表达。

所以 v2 并不是第一次允许“换序”。

真正的新问题是:

Learn / Docs 中需要参与这种保护与重建的结构,比 Tour 更复杂了。


如果保护得太少,AI 很容易“帮忙”修改不该修改的东西

假设直接把整份 Markdown 交给模型。

模型可能会非常自然地做一些人类读者看来似乎无害的修改。

例如调整一个 URL。

改变代码中的大小写。

把一个 identifier 翻译成中文。

把代码注释和周围说明混在一起。

改变 fenced code block 的边界。

或者把 Markdown directive 当成普通文本重新组织。

这些变化单独看可能很小。

但对于机器来说,可能意味着:

页面无法正常渲染。

或者:

示例代码不再正确。

甚至:

文档表面上可以显示,但链接、directive 或其他隐藏语义已经改变。

所以系统需要先明确:

哪些东西可以交给语言模型决定,哪些东西必须由机器负责保护。


但保护得太多,同样会破坏翻译质量

另一个极端也有问题。

最简单的保护方式是:

把所有特殊结构替换成 token,然后要求这些 token 原位置不动。

例如:

Plaintext
Use ⟪TOKEN_1⟫ to run the tests.

然后要求中文结果仍然严格是:

Plaintext
使用 ⟪TOKEN_1⟫ 来运行测试。

这样机器确实很好验证。

但如果目标语言的自然表达需要调整位置,规则就开始和语言质量冲突。

中文可能更自然的是:

Plaintext
运行测试时,可以使用 ⟪TOKEN_1⟫。

如果 validator 把这种变化判为错误,那么系统得到的是:

机器结构非常稳定,但中文越来越像英文语序的投影。

这不是我想要的结果。

因此 v2 中一直需要坚持的原则仍然是:

保护语义,而不是保护不必要的位置。

机器应该规定哪些关系不能破坏。

而不是规定译文必须怎样排列。


真正需要保护的是关系

做到后面以后,我越来越觉得,“protected token”这个名字其实很容易让人把注意力放错地方。

真正重要的并不是某一个 token 本身。

而是 token 和周围结构之间的关系。

例如一个链接:

Plaintext
[Go documentation](https://go.dev/doc/)

需要保护的并不只是:

Plaintext
https://go.dev/doc/

还包括:

哪一段文本仍然是这个 URL 的 link text。

再例如 inline code:

Plaintext
The `error` value...

error 本身必须保持代码语义。

但中文可以是:

Plaintext
`error` 值……

也可以根据上下文调整它在句子中的位置。

所以 parser 真正需要保留的是:

Plaintext
this is inline code

以及:

Plaintext
this code belongs to this surrounding language structure

而不是:

Plaintext
它原来在第 17 个字符,所以翻译以后还必须在第 17 个字符附近

这种区别后来对 validator 的设计影响很大。


Pair 也比单个 token 更重要

有些结构必须成对出现。

例如 Markdown link 的:

Plaintext
[text](destination)

或者某些 directive、placeholder、内部保护标记。

如果只检查:

所有 token 最后都还存在

其实还不够。

可能出现:

Plaintext
token A 存在
token B 也存在

但:

A 和 B 已经不再属于原来的那一组结构。

这种情况特别危险,因为简单 token count 仍然可以 PASS。

所以机器校验需要逐渐从:

Plaintext
token exists

升级到:

Plaintext
pair / binding remains valid

这也是为什么 Markdown translation 的结构保护不能简单理解成“字符串替换”。

它实际上是在保存 representation 中的一部分语义关系。


Link binding 是一个很典型的例子

链接尤其适合说明这个问题。

假设 source 中有两个链接:

Plaintext
Read the [language specification](...) and the [memory model](...).

中文为了自然表达,可以调整句子:

Plaintext
可以继续阅读[语言规范](...)以及[内存模型](...)。

这没有问题。

甚至在更复杂的句子里,两个链接在译文中的整体位置也可能发生变化。

但有一件事情不能变:

“语言规范”不能最后绑定到 memory model 的 URL。

只检查:

  • 两个 URL 都还在;
  • 两段 link text 都还在;

并不能证明结果正确。

必须进一步保证:

link text 和它原来的 destination binding 没有被交叉。

这就是 parser / protector 真正应该解决的问题。


完整代码又是另一类 representation

Learn / Docs 中还有比普通 Markdown 更复杂的内容:

完整代码。

普通段落中的:

Plaintext
`fmt.Println`

和 fenced Go code block 中真实存在的:

Plaintext
fmt.Println("Hello")

显然不能采用同一种处理方式。

完整代码首先必须保持 Go 语义正确。

绝大部分代码根本不应该翻译。

但教学文档中的代码又可能包含:

需要翻译的人类可读注释。

例如:

Plaintext
// Print the result.
fmt.Println(result)

这里:

Plaintext
fmt.Println(result)

必须保持代码语义。

而:

Plaintext
// Print the result.

又可能是需要本地化的教学内容。

所以一个完整 code block 不能简单判断成:

“代码,全部保护。”

也不能判断成:

“里面有英文,所以全部允许翻译。”

它需要继续理解:

哪些内容属于 Go syntax,哪些内容属于可以翻译的 teaching comment。

这类 representation 也是 Learn / Docs parser 后来需要不断完善的原因之一。


Teaching comment 很容易暴露 parser 的边界

代码注释看起来很好识别。

Go 中有:

Plaintext
// comment

和:

Plaintext
/* comment */

似乎找到 comment node,然后翻译里面的文字就可以了。

但真正进入完整教学代码以后,情况往往更复杂。

注释可能:

  • 单独占一行;
  • 位于语句末尾;
  • 包含代码 identifier;
  • 包含格式约定;
  • 与 surrounding code 的教学含义紧密相关。

这意味着 parser 不只是要知道:

这里有一段 comment。

还要知道:

这是不是应该交给语言模型处理的教学内容,以及处理以后如何安全放回原来的代码 representation。

如果这一层判断错误,就很容易出现两种失败。

一种是:

本来应该翻译的教学注释被完全保护,中文页面里留下英文。

另一种则更危险:

为了翻译注释,意外改变了完整代码。

因此这种结构不能依赖“模型应该知道哪里不要动”。

机器必须先把责任边界画清楚。


Parser 和 Generation 的职责不能混在一起

这里其实存在一个非常重要的职责划分。

Parser 应该负责:

确定结构。

Generation 应该负责:

确定语言表达。

例如 parser 可以告诉 Generation:

Plaintext
这里是一段可以翻译的自然语言。
这里是 inline code。
这里是一组 link binding。
这里是 protected code。
这里是一段可翻译的 teaching comment。

但 parser 不应该继续规定:

中文必须按照英文的词序排列。

同样,Generation 可以决定:

这句话中文怎样表达更加自然。

但不能决定:

这个 URL 好像不好看,我顺便帮你改一下。

两者各自越界,都会出问题。

所以比较理想的关系是:

Plaintext
Parser
↓
定义机器边界

Generation
↓
在合法边界内完成自然语言表达

然后再由 reconstruction 和 validator 检查:

Generation 有没有突破这个边界。


Reconstruction 和 Parse 同样重要

很多人想到 parser,关注的都是:

怎样把输入拆开。

但对于 AI 翻译系统来说,另外一半同样重要:

怎样把结果重新还原成合法页面。

也就是说,整个过程不是:

Plaintext
Markdown
↓
Parse
↓
AI
↓
结束

而更像:

Plaintext
Markdown
↓
Parse
↓
Protected / translatable representation
↓
Generation
↓
Reconstruction
↓
Machine validation
↓
Markdown

只有 reconstruction 成功,才能说明 Generation 返回的内容仍然能够重新组成合法 representation。

所以 parser 和 reconstruction 实际上是一对能力。

一边定义:

哪些结构被拆开。

另一边证明:

这些结构最终可以正确合回来。


Round-trip 是一个很重要的机器门禁

这也是为什么我后来非常重视 machine round-trip。

一个 TranslationUnit 在进入语言审核以前,机器至少应该先证明:

结构上能够还原。

例如:

  • protected 内容没有丢;
  • pair 没有断;
  • link binding 没有错;
  • Markdown 结构仍然合法;
  • code representation 没有损坏;
  • placeholder 没有缺失。

如果这些基础条件都失败了,就没有必要让 Independent Reviewer 去判断中文质量。

Reviewer 应该把时间花在:

“这是不是好的中文?”

而不是:

“这个 Markdown 还能不能解析?”

能由机器确定的问题,应该尽量在进入语言审核以前解决掉。


机器 PASS 仍然不代表翻译正确

当然,另外一个方向也必须保持清醒。

假设:

Plaintext
ReconstructWorkflow = PASS

只能证明:

representation 可以正确恢复。

它不能证明:

中文就是好的。

甚至也不能完全证明:

技术含义一定正确。

一个译文可以做到:

  • 所有 inline code 正确;
  • 所有 link binding 正确;
  • Markdown 完整;
  • code 完整;
  • placeholders 完整;

但把英文原意理解错了。

这时所有 machine validation 都可能 PASS。

所以 parser / protector 的目标从来不是取代 Reviewer。

它只是尽可能把问题分层:

Plaintext
机器可判断的问题
→ Machine Validation

语言和技术表达问题
→ Independent Reviewer

两者各自处理最适合自己的问题。


Internal slot 为什么不能变成正式 TranslationUnit

这又回到了前面那篇关于 Page TranslationUnit 的文章。

Parser 为了完成这些工作,内部可能需要拆出很多 slot。

例如:

Plaintext
slot 1 = paragraph text
slot 2 = inline code
slot 3 = link text
slot 4 = link destination
slot 5 = teaching comment
...

这些 slot 对机器非常有用。

但它们不应该因此变成正式语言治理单位。

因为 parser 是会演进的。

今天 parser 把一段内容拆成 4 个 slot。

以后发现 representation 更准确的方式,可能改成 6 个。

如果 slot 就是正式 TranslationUnit,那么一次纯 parser 改进就会导致语言 identity 大量变化。

这显然不合理。

所以 v2 最后逐渐明确:

physical Page 是正式 Page TranslationUnit。

internal slot 只是解析、保护、重建和诊断工具。

Parser 可以进化。

正式语言历史不应该因此被无意义地重写。


Parser 改进以后,也不代表所有 Page 都要重译

这其实和前面 glossary affected scope 的思路非常相似。

假设发现 parser 对某一种 teaching comment 的 representation 有问题。

修复 parser 以后,最简单的办法当然是:

所有 Learn / Docs Page 全部重新翻译。

但如果只有少数页面实际包含这种 representation,就没有必要把整个 package 全部推倒重来。

更合理的方式仍然是判断:

哪些 Page 真正 affected。

未受到影响、并且在新 parser / validator 下仍然能够可靠验证的已有 TranslationUnit,应该继续保留。

这背后仍然是整个 v2 越来越明确的一条原则:

基础设施发生变化,不自动等于所有语言成果失效。

真正需要处理的是受到变化影响的部分。


结构保护的目标不是让 validator 更容易写

这次 parser 工作里,我觉得最容易走偏的一件事情就是:

为了让 validator 简单,设计过强的翻译约束。

例如:

所有 token 必须保持原顺序。

这种规则当然非常容易验证。

直接比较 sequence 就行。

但这并不代表它是正确的语言规则。

如果中文自然表达需要改变顺序,那么 validator 就不应该因为实现方便而禁止这种变化。

换句话说:

validator 应该验证真正不能破坏的语义,而不是把实现方便伪装成语言约束。

这句话对我来说很重要。

因为很多机器规则一开始看起来都很合理。

直到真正出现一种合法的自然语言表达,却被机器拒绝以后,才会发现:

也许不是翻译错了,而是 validator 的模型太窄了。


但放宽 validator 也不能变成“什么都允许”

当然,另外一个极端也同样危险。

发现严格 sequence check 会误伤自然语序以后,不能直接改成:

token 都在就行。

如果这样,pair、binding、link destination 和结构关系都可能发生错位。

所以更合理的改进不是:

Plaintext
strict
→
loose

而是:

Plaintext
错误的约束
→
更准确的约束

例如从:

token 必须保持原位置。

改成:

token 可以移动,但合法 pair / binding / machine semantics 必须保持。

这不是降低 validation 强度。

实际上要求反而更准确。

只是机器验证的对象从:

表面位置

变成了:

真正需要保持的语义关系。


Example 会比普通 Page 更严格

这里还需要区分另外一种内容:

Example。

普通 Page 的自然语言往往需要较大的语序自由。

但 Example 特别是包含 Go code、预期输出或者机器相关 token 的内容,对 representation 的要求会更严格。

所以“允许 Page 根据自然语言调整合法 binding 的位置”,并不能简单推广成:

所有内容都可以自由移动。

最终仍然需要根据 TranslationUnit kind 判断:

哪些东西属于语言表达,哪些东西属于程序语义。

也就是说,结构保护不是一条全站统一的字符串规则。

它必须理解内容类型。


做到后来,parser 更像一份机器契约

最开始我把 parser 理解成:

把 Markdown 解析出来的工具。

但随着 v2 越做越深,我觉得更准确的理解可能是:

它在 source、Generation 和最终页面之间建立一份机器契约。

这份契约规定:

哪些东西可以改变。

哪些东西不能改变。

哪些内容可以移动。

哪些 binding 必须保持。

哪些结构必须成对存在。

哪些机器语义必须在 round-trip 后完全恢复。

Generation 在这个契约内部拥有语言自由。

Validator 则负责证明它没有越界。

这样一来,parser 就不再只是一个文本预处理器。

它其实是整个 AI 翻译流水线中:

机器安全边界的一部分。


这也是为什么 parser 会不断迭代

对于这种系统,不太可能第一次就定义出完美 representation。

真正的内容会不断暴露新的边界情况。

一开始可能觉得:

inline code 只要保护住就够了。

后来发现需要 link binding。

再后来发现完整 code 中还有 teaching comment。

继续做下去,又可能发现某种 directive 或 nested structure 需要新的 representation。

这种迭代本身并不可怕。

真正需要避免的是:

每一次 parser 改进,都迫使已经稳定的语言成果全部重做。

所以 Page identity、internal representation 和 affected scope 之间的分层非常重要。

Parser 可以继续完善。

但它应该尽量让真正 unaffected 的 TranslationUnit 保持稳定。


最终想要的其实是一个平衡

做到现在,我觉得 Learn / Docs parser 最核心的问题可以浓缩成一句话:

既不能把 Markdown 当成纯文本,也不能为了保护 Markdown 把自然语言锁死。

太自由:

Plaintext
AI may break structure

太严格:

Plaintext
machine-safe but unnatural translation

真正需要的是:

Plaintext
Protect machine semantics
        +
Allow natural language
        +
Verify round-trip

机器负责保证:

不该坏的东西没有坏。

Generation 负责保证:

中文像中文。

Independent Reviewer 再负责判断:

最终内容是不是真的准确、自然,而且适合技术文档。

这三层职责不能互相替代。


从 Tour 到 Learn / Docs,变化的是 representation 的复杂度

所以最后还有一点需要特别说明。

这次 v2 并不是从:

Tour 阶段严格保持原顺序

变成:

Learn / Docs 才允许自然语序。

Tour 阶段本来就已经允许在保持机器语义的前提下,根据目标语言调整表达顺序。

Learn / Docs 真正带来的新挑战,是:

需要保护和重建的 representation 类型更多、更复杂了。

因此原来已经存在的原则:

机器保护不能牺牲自然语言。

到了 v2,需要被扩展到:

  • Markdown;
  • inline code;
  • links;
  • pair / binding;
  • complete code;
  • teaching comment;
  • directive;
  • 以及更多结构化内容。

也正是在这个过程中,我越来越确定:

好的结构保护,不应该让读者感觉到结构保护曾经存在。

最终用户看到的,应该仍然是一篇自然、连贯的中文技术文档。

至于为了做到这一点,机器内部用了多少 slot、token、binding 和 reconstruction,那些都应该留在实现层。

关于作者 · 持续学习与技术实践

我是一名拥有 15 年以上开发经验的技术从业者,自 2013 年起持续运营个人技术博客,记录工作与个人项目中遇到的真实问题,以及解决问题过程中的探索、实践和思考。

除了技术写作,我也在持续开发和维护自己的独立项目,探索新技术和新工具在实际场景中的应用。我始终相信,没有不值得去解决的问题,也没有不值得去学习的技术。

如果这篇文章对你有所帮助,欢迎浏览博客中的其他文章,也欢迎交流技术经验与想法。

关于我  ·  GitHub  ·  邮件联系