继续推进 A Tour of Go 多语言翻译项目时,我原本只是准备完成下一个代表性校准页面 methods/20。
这一页的标题是:
Exercise: Errors
也就是“练习:错误”。
从页面结构来看,它并不算特别复杂:普通正文、行内代码、一个跨章节链接、两个预格式化 Go 代码块、一个加粗的 Note 提示段,以及最后的 .play 示例。
此前项目已经通过多个代表页面逐步完善了行内代码、legacy present 语法、预格式化代码、教学代码注释、强调结构等保护与校验。我原本判断,这一页应该可以直接进入第一次开发翻译。
没想到,第一次翻译就再次失败了。
但这一次,真正出问题的并不是 GLM-5.2 的翻译,而是我之前设计的一条看起来很合理、实际上对多语言翻译过于严格的规则:
所有受保护标记必须严格保持原始全局顺序。
这次排查最终让我重新划清了一个非常重要的边界:
结构校验应该负责机器可以可靠判断的结构安全,而不应该把不同语言之间正常的语序变化误判为结构损坏。
一、methods/20 第一次翻译:API 正常,自动校验却失败
methods/20 对应的官方原文如下:
* Exercise: Errors
Copy your `Sqrt` function from the [[/tour/flowcontrol/8][earlier exercise]] and modify it to return an `error` value.
`Sqrt` should return a non-nil error value when given a negative number, as it doesn't support complex numbers.
Create a new type
type ErrNegativeSqrt float64
and make it an `error` by giving it a
func (e ErrNegativeSqrt) Error() string
method such that `ErrNegativeSqrt(-2).Error()` returns `"cannot`Sqrt`negative`number:`-2"`.
*Note:* A call to `fmt.Sprint(e)` inside the `Error` method will send the program into an infinite loop. You can avoid this by converting `e` first: `fmt.Sprint(float64(e))`. Why?
Change your `Sqrt` function to return an `ErrNegativeSqrt` value when given a negative number.
.play methods/exercise-errors.go
第一次开发翻译执行:
go run ./cmd/tour-i18n translate run --locale zh-CN --id methods/20 --dev
GLM-5.2 请求本身正常完成:
Attempt: 1
finish_reason: stop
但是自动校验失败:
{
"attempt": 1,
"api_success": true,
"token_valid": false,
"present_valid": false,
"failures": [
"protected token order mismatch at 1",
"protected token order mismatch at 2",
"protected token order mismatch at 6",
"protected token order mismatch at 7",
"protected token order mismatch at 10",
"protected token order mismatch at 11"
],
"passed": false
}
页面因此仍然处于:
pending
乍一看,这似乎又是一次模型没有遵守 Protected Token 规则。
但继续检查后发现,情况完全不是这样。
二、16 个受保护标记,一个都没有丢
这一页一共生成了 16 个受保护标记。
GLM-5.2:
- 没有删除任何标记;
- 没有重复任何标记;
- 没有伪造未知标记;
- 所有 16 个标记都完整保留。
真正发生变化的,只是其中三组标记交换了出现顺序:
Sqrt与链接 target;error与一个预格式化代码块;fmt.Sprint(e)与Error。
也就是说,这次失败并不是:
模型破坏了受保护内容。
而是:
模型按照中文自然语序重新排列了部分受保护内容。
这两个问题的性质完全不同。
三、第一组换序:链接先出现,Sqrt 后出现
原文:
Copy your
Sqrtfunction from the earlier exercise…
按照英语语序,大致是:
复制
→ 你的 Sqrt 函数
→ 从之前的练习中
而模型生成的中文是:
从之前的练习中复制你的
Sqrt函数……
更自然的中文语序显然是:
从之前的练习中
→ 复制
→ 你的 Sqrt 函数
因此,模型输出中链接 target 对应的受保护标记出现在 Sqrt 之前。
旧校验器看到的是:
源顺序:
Sqrt
→ link target
模型输出变成:
link target
→ Sqrt
于是判定:
protected token order mismatch
但从翻译质量来看:
“从之前的练习中复制你的
Sqrt函数”
不仅完全正确,而且明显比强行维持英文顺序自然。
四、第二组换序:error 被移动到代码块之后
原文这一段比较特殊:
and make it an `error` by giving it a
func (e ErrNegativeSqrt) Error() string
method such that ...
英语表达的是:
通过给它下面这个方法,使它成为一个
error
模型翻译以后则变成:
并为其实现
func (e ErrNegativeSqrt) Error() string
方法,使它成为一个 `error`
于是:
`error`
→ 预格式化代码块
变成:
预格式化代码块
→ `error`
如果只看受保护标记的全局顺序,这当然发生了变化。
但进一步分析 present 结构后发现:
- 第二个代码块仍然位于原来的代码块位置;
- 代码内容一个字节都没有变化;
- 两个预格式化代码块没有互换;
- 只是普通正文里的
error从代码块之前移动到了代码块之后。
换句话说:
代码块根本没有移动。
改变的只是代码块前后自然语言的组织方式。
最终人工润色后,我将这一段调整为:
并为其实现
func (e ErrNegativeSqrt) Error() string
方法,使其实现error接口……
不仅结构正确,而且技术表达也比“使它成为一个 error”更准确。
五、第三组换序:Error 与 fmt.Sprint(e)
原文:
A call to
fmt.Sprint(e)inside theErrormethod will send the program into an infinite loop.
英语顺序是:
fmt.Sprint(e)
→ Error
而模型翻译成:
在
Error方法中调用fmt.Sprint(e)会导致程序陷入无限循环。
中文自然变成:
Error
→ fmt.Sprint(e)
旧规则于是再次判断 token 顺序错误。
但这个中文句子没有任何技术问题。
相反,如果为了强行保持英文 token 顺序,写成类似:
调用
fmt.Sprint(e)在Error方法中……
中文反而会明显变差。
六、这不是第一次:generics/1 已经暴露过相同问题
继续回查此前的翻译记录后,我发现,这并不是第一次出现这种情况。
在 generics/1 的一次历史翻译尝试中,也曾发生:
T
→ comparable
变成:
comparable
→ T
原文大致表达:
type
Tthat fulfills the built-in constraintcomparable
自然中文则更适合写成:
满足内置约束
comparable的任意类型T
这同样是非常典型的英语和中文语序差异。
当时只把它作为一次 token 换序失败处理。
直到这一次 methods/20 一页连续出现三组类似情况,我才有足够证据确认:
问题不是某一次模型偶然不遵守规则,而是“所有 Protected Token 必须保持全局顺序”这条规则本身不适合作为多语言结构不变量。
七、真正需要保护的到底是什么?
这里出现了一个非常关键的设计问题。
假设两个行内代码在翻译后交换位置,校验器是否能够仅根据 present AST 或 token 顺序确定:
这是正常的中文语序变化?
还是:
技术语义真的发生了错误颠倒?
很多情况下,答案是不能。
例如:
T / comparable
以及:
fmt.Sprint(e) / Error
从 present 结构来看,它们都只是普通正文里的 program span。
仅靠静态结构信息,没有一种通用算法能够同时做到:
- 允许正确的跨语言语序变化;
- 又准确拒绝所有技术语义上的错误换位。
如果硬要实现,最后很可能变成越来越多针对英语、中文甚至某个具体句式的启发式规则。
这与项目未来支持更多语言的目标反而背道而驰。
因此,我重新定义了校验器的职责:
校验器负责机器能够可靠验证的结构安全。
跨语言技术语义是否正确,不伪装成静态结构校验能力。
八、恢复前:不再要求 Protected Token 全局顺序一致
修改后的 Protected Token 恢复规则仍然非常严格。
模型输出必须满足:
- token 总数正确;
- 不允许未知 token;
- 每个期望 token 恰好出现一次;
- 不允许缺失;
- 不允许重复;
- token payload 仍然按照确定性映射逐字恢复。
唯一删除的是:
所有 token 必须严格保持一条全局原始顺序。
与此同时,行内 token 的边界规范化也调整为按照:
模型实际输出中的 token 顺序
执行,并根据每个 token 自身的类型判断对应规则。
这样模型可以为了目标语言的自然表达调整位置,但仍然不能:
- 删除代码;
- 重复代码;
- 修改代码;
- 伪造 token;
- 偷换受保护内容。
九、恢复后:让 present 结构校验真正承担结构安全职责
取消全局顺序限制,并不意味着放弃严格校验。
恰恰相反,这次同时增强了恢复后的结构验证。
行内代码
以前要求:
内容、数量、顺序全部完全一致。
现在调整为:
内容和数量的多重集合一致,同时仍然必须正确解析为 program span。
因此:
T / comparable
可以因为目标语言语序发生变化。
但是以下情况仍然必须失败:
- 某个行内代码消失;
- 多出一个行内代码;
- 内容发生修改;
- legacy program span 被拆坏。
十、legacy program span 仍然严格保护
methods/20 中还有一个很典型的 legacy present 写法:
`"cannot`Sqrt`negative`number:`-2"`
它在 present 中实际是一个完整的 program span。
最终页面显示为:
"cannot Sqrt negative number: -2"
这类内容仍然作为一个完整 payload 保护。
模型不能拆分,也不能修改其中的 present 编码。
浏览器最终检查时,这一段正确显示为:
"cannot Sqrt negative number: -2"
没有出现内部反引号泄漏,也没有 program span 断裂。
十一、链接仍保持更严格的规则
链接 target 与普通行内代码不同。
目前仍然严格检查:
- target 内容;
- target 数量;
- target 自身顺序;
- target 是否仍然处于合法链接结构中。
也就是说,虽然某个:
link target
可以和旁边的 Sqrt 因为中文语序发生跨类别位置变化,但多个链接 target 之间暂时仍不能任意交换。
这是一个有意保持的保守策略。
目前没有真实案例证明多个链接必须为了目标语言语序互换,因此没有必要提前扩大放宽范围。
十二、预格式化代码仍逐块严格校验
对于预格式化代码,规则仍然严格。
继续检查:
- 代码块数量;
- 代码块顺序;
- 静态代码块字节;
- 可翻译教学注释中的代码结构;
- 代码块所属 Section。
因此,虽然这次允许:
`error`
→ 代码块
调整为:
代码块
→ `error`
但绝不允许:
第一个代码块
↔ 第二个代码块
互换。
更不能将代码块移动到另一个 Section。
这正是:
自然语言可以重组,但结构对象本身不能被破坏。
十三、directive 也增加了 Section 级保护
.play、.image 等 directive 同样继续严格检查:
- 内容;
- 数量;
- directive 自身顺序;
- 所属 Section。
此外,这次还增加了一条机器可以稳定判断的规则:
如果源 directive 是所属 Section 的最后一个元素,那么候选中也必须保持为最后一个元素。
例如:
.play methods/exercise-errors.go
它本身不仅决定代码文件,也决定教学页面中示例代码出现的位置。
如果模型把原本位于页面末尾的 .play 移到说明文字中间,即使 present 仍然能够解析,也应该被视为结构保护失败。
不过,我没有进一步要求:
directive 必须和所有普通文本段落保持完全相同的相对位置。
因为普通自然语言本来就可能因为翻译而拆分、合并或者调整表达顺序。
十四、固定翻译提示词也必须同步修改
只修改校验器还不够。
之前的 zh-CN 固定翻译提示词同样要求:
Protected Token 的数量、顺序和形式保持一致。
如果校验器已经允许自然语序调整,而提示词仍然要求全局顺序严格一致,两套规则就互相矛盾。
因此,这次同步修改提示词:
- 所有 Protected Token 必须完整保留;
- 每个 token 必须恰好出现一次;
- 不得修改;
- 不得删除;
- 不得复制;
- 不得伪造;
- 允许为了目标语言自然语序进行必要的位置调整;
- 但不得破坏链接、代码、directive、预格式化代码等结构关系。
这并不是一条只针对中文的特殊规则。
它更适合作为未来多语言扩展时共同遵守的原则:
保护结构,而不是保护英语语序。
十五、最关键的验证:没有重新调用 GLM-5.2
修改完成后,我没有立即进行第二次翻译。
因为如果重新调用一次 GLM-5.2,即使新结果通过,也无法直接证明:
旧结果真的只是被错误的校验规则拒绝。
因此,我保留了第一次翻译产生的原始:
request.jsonresponse.jsonvalidation.json
然后直接使用修改后的生产代码,对同一份 attempt-001 原始 response 进行确定性回放。
也就是说:
模型输出本身完全没有变化。
改变的只有校验规则。
旧结果:
protected token order mismatch at 1
protected token order mismatch at 2
protected token order mismatch at 6
protected token order mismatch at 7
protected token order mismatch at 10
protected token order mismatch at 11
新规则下,同一份 response:
- token 完整性:PASS
- token 恢复:PASS
- present 解析:PASS
- inline-code:PASS
- link target:PASS
- preformatted block:PASS
- directive:PASS
- emphasis / font span:PASS
- glossary:PASS
- Section 结构:PASS
- candidate validation:PASS
这次回放非常关键。
它直接证明:
失败的不是翻译,而是旧校验规则。
十六、因此没有创建 attempt-002
既然原始 attempt-001:
- API 请求正常;
finish_reason=stop;- 16 个 token 全部完整;
- 修复规则后完整通过;
- 人工审核也没有发现技术误译;
那么继续为了流程形式重新调用一次 GLM-5.2,就没有实际意义。
最终仍然保留:
methods/20
ready
Attempts=1
并没有虚构一个实际上不存在的第二次模型翻译。
第一次失败的历史记录也没有覆盖。
旧 validation.json 仍然保留当时六项顺序失败,作为这次规则演进的完整历史证据。
十七、人工润色后的最终译文
最终采用的译文如下:
* 练习:错误
从[[/tour/flowcontrol/8][之前的练习]]中复制你的 `Sqrt` 函数,并修改它,让它返回一个 `error` 值。
当传入负数时,`Sqrt` 应当返回一个非 nil 错误值,因为它不支持复数。
创建一个新类型
type ErrNegativeSqrt float64
并为其实现
func (e ErrNegativeSqrt) Error() string
方法,使其实现 `error` 接口,这样 `ErrNegativeSqrt(-2).Error()` 就会返回 `"cannot`Sqrt`negative`number:`-2"`。
*注意:* 在 `Error` 方法中调用 `fmt.Sprint(e)` 会导致程序陷入无限循环。可以先转换 `e` 来避免这个问题:`fmt.Sprint(float64(e))`。为什么?
修改 `Sqrt` 函数,使其在传入负数时返回一个 `ErrNegativeSqrt` 值。
.play methods/exercise-errors.go
主要人工调整包括:
- “前一个练习”改为“之前的练习”;
- “并修改它使其返回”改为“并修改它,让它返回”;
- “非 nil 的错误值”改为“非 nil 错误值”;
- “使它成为一个
error”改为更准确的“使其实现error接口”。
随后重新执行正式候选译文校验:
candidate OK: locale=zh-CN page_id=methods/20
十八、浏览器中英文页面实际对照检查
完成候选译文校验后,我又启动了本地 Tour,对 methods/20 的英文原始页面和中文候选页面进行实际浏览器对照。
英文原始页面

methods/20「Exercise: Errors」英文原始课程页面这里的英文页面可以作为最终视觉结构的参照,包括:
- 正文;
- 跨章节链接;
- 两个预格式化 Go 代码块;
- Note 提示;
- 右侧
exercise-errors.go示例。
中文候选页面

中文页面截图尤其能直观说明,这次修改为什么是必要的。
例如,旧校验器曾经拒绝:
fmt.Sprint(e)
→ Error
变成:
Error
→ fmt.Sprint(e)
但是从最终中文页面可以直接看到:
在
Error方法中调用fmt.Sprint(e)会导致程序陷入无限循环。
这正是正常而自然的中文表达,并没有造成任何 present 结构损坏。
浏览器实际对照检查确认:
- “练习:错误”标题正常;
- “之前的练习”链接正常;
- 两个 Go 代码块的位置和内容正确;
"cannot Sqrt negative number: -2"正常渲染;- legacy present 内部反引号没有暴露;
- “注意:”正确加粗;
Error与fmt.Sprint(e)调整顺序后的中文自然;.play对应的exercise-errors.go正常加载;- 页面没有 present 解析、渲染或运行时异常。
因此,浏览器最终结果进一步证明:
那三组被旧规则拒绝的 Protected Token 换序,并没有造成结构问题,反而是正常的跨语言语序调整。
十九、完整测试重新通过
修复完成,并同步 methods/20 的提交状态基线以后,完整测试重新通过:
ok github.com/shuijingwan/go-tour-i18n/internal/i18n 0.499s
git diff --check 也全部通过。
最终提交:
13d9129b3bb0223b5e39af7679868832352e3b42
提交说明:
feat: 完成 methods/20 并修复受保护标记顺序校验
随后已经推送到 origin/main。
项目仓库:
二十、这次最大的收获:结构安全不等于英语顺序不变
这次问题让我更加明确了一件事情。
开发多语言翻译系统时,很容易把:
“原文结构”
和:
“原文表面顺序”
混为一谈。
但它们并不是同一回事。
例如:
fmt.Sprint(e)
→ Error
在英语里是自然顺序。
中文却更自然地写成:
Error
→ fmt.Sprint(e)
真正需要保持的是:
Error仍然是正确的 program span;fmt.Sprint(e)没有被修改;- 二者都没有丢失;
- present 仍然能够正确解析;
- 技术含义仍然正确。
而不是:
两个字符串必须永远按照英语里的出现顺序排列。
同样:
“从之前的练习中复制你的
Sqrt函数”
也不应该因为链接 target 出现在 Sqrt 之前,就被判定为结构失败。
二十一、自动校验越严格,并不意味着质量越高
这个问题还有一个很值得记录的教训:
严格校验本身不是目标。
如果一条规则能够拒绝大量内容,却无法区分:
- 真正的结构破坏;
- 正常的跨语言语序调整;
那么规则越严格,产生的误报反而越多。
一个好的自动校验器应该尽可能严格地检查:
机器真正能够确定的事实。
例如:
- token 有没有丢失;
- 有没有重复;
- 有没有未知 token;
- 代码有没有改变;
- link target 有没有被修改;
- directive 是否仍然属于正确的 Section;
- 预格式化代码块有没有互换;
- present 是否仍然能够正确解析。
而对于:
“行内代码换序以后,跨语言技术语义是否仍然正确?”
如果静态结构无法可靠判断,就应该承认这个边界,而不是设计一个看似严格、实际上并不可靠的伪语义规则。
二十二、下一步:完成最后 3 个代表页
目前代表页校准阶段已经完成 4 页:
generics/1flowcontrol/8methods/16methods/20
接下来已经确定继续处理另外 3 个不同类型的代表页。
concurrency/7
官方课程中唯一包含 .image 的正式页面。
主要验证:
.imagedirective;- 图片与正文混排;
- 非尾部 directive;
- JavaScript target 链接。
concurrency/11
长篇、多段、高密度链接页面。
主要验证:
- 大量 link target;
- 跨段链接;
slides强制术语 label;- 没有代码和 directive 时的长文本稳定性。
methods/24
API 说明型页面。
主要验证:
- 较长静态 interface 代码块;
- 多个 API 链接;
- 强调段;
- 尾部
.play。
如果这 3 页也稳定通过,总代表页将达到 7 页。
届时就准备结束这一阶段的逐页校准,进入:
10 个普通 pending 页面自动试跑。
如果 10 页试跑继续稳定,再进入剩余页面的批量翻译阶段。
结语
methods/20 原本只是计划中的又一个代表性校准页面。
最终,它却帮助这个项目发现并修掉了一个更加底层的问题:
Protected Token 的全局顺序并不是可靠的多语言结构不变量。
这一次最有说服力的地方,是我没有通过:
“重新翻译一次,直到它通过”
来绕开问题。
而是保留原始失败 response,修复校验器以后,再让同一份模型输出重新经过完整生产校验。
最终证明:
GLM-5.2 第一次其实就已经翻译成功。
真正需要修正的,是自动校验系统对于“结构安全”的定义。
对于一个准备从简体中文继续扩展到更多语言的项目来说,这类问题比完成某一个页面本身更加重要。
因为真正需要构建的,并不是一套:
“尽量让译文保持英语原文表面顺序”
的规则。
而是一套:
允许不同语言自然表达,同时仍然严格保护代码、链接、指令和 present 结构的多语言翻译流程。
A Tour of Go 多语言翻译项目
本系列完整记录 A Tour of Go 多语言翻译项目从架构设计、整页翻译、结构保护、自动校验,到生产发布与后续维护的实际开发过程。
项目入口:
✅ 在线学习:A Tour of Go 简体中文版
✅ 项目源码:GitHub:shuijingwan/go-tour-i18n
当前第一阶段已完成简体中文版本。项目属于非官方社区多语言翻译项目,与 Go 官方无隶属或授权关系。

发表回复