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

A Tour of Go 多语言翻译实战:第三批 10 页全部 Ready,继续校准 Protected Token 的结构角色

图 7:moretypes/1 最终中文页面预览。左侧课程正文、静态代码块、行内代码和教学注释均正常渲染,右侧官方 Go 示例继续保持原样。

作者:

,

A Tour of Go 多语言翻译项目

图3:访问过去的 Go Tour 简体中文站 tour.go-zh.org 时,当前已经无法正常建立连接。

(1) 从「A Tour of Go 中文版」这个搜索词开始:我决定做一个持续维护的 Go Tour 中文版

本地运行的 A Tour of Go「Methods continued」课程页面,左侧为课程说明,右侧为 Go 代码编辑器及运行结果。

(2) A Tour of Go 中文版项目设计冻结:从 101 页到 103 页,从 Gin 转向 Cobra CLI

A Tour of Go 中文版开发实战:英文基线、在线运行与 101 页上游同步体系

(3) A Tour of Go 中文版开发实战:英文基线、在线运行与 101 页上游同步体系

go-tour-i18n 项目完成首个简体中文课程页面 welcome/1 的整页翻译、结构校验和本地预览。

(4) A Tour of Go 多语言翻译项目实录:完成首个 zh-CN 页面翻译闭环

图 1:DeepSeek 官方更新日志显示,本次只更新 DeepSeek-V4-Flash,DeepSeek-V4-Pro API 与 APP、Web 模型均未更新

(5) A Tour of Go 中文翻译项目进展:完成前 8 页、修复 present 语法,并暂缓 DeepSeek 对比

图 1:generics/1 简体中文页面本地预览

(6) 一个页面重试五次:使用 GLM-5.2 翻译 A Tour of Go 时遇到的 Token 与 Present 结构问题

图 1:methods/16 简体中文页面浏览器预览

(7) A Tour of Go 中文翻译实录:如何只翻译教学代码注释,而不破坏 Go 代码

图 2:A Tour of Go methods/20「练习:错误」中文候选页面最终渲染效果

(8) A Tour of Go 多语言翻译:当正确的中文语序被受保护标记顺序校验误判

图 3:methods/24 中文页面,静态代码、Bounds 行内代码和链接内的 image.Rectangle 均正确渲染

(9) A Tour of Go 中文翻译完成 7 个代表页校准:最后 3 页又发现了哪些真实问题

图 2:首批 10 个普通页面经过试跑与流程校准后全部进入 ready

(10) A Tour of Go 多语言翻译项目:首批 10 个普通页面试跑,从 2 个 blocked 到全部 ready

图 2:flowcontrol/6 最终中文页面,return 与 v 为适应自然中文语序发生整体换位

(11) A Tour of Go 中文翻译第二批实跑:从 Inline Code 顺序误判到历史响应重新验证

图 1:flowcontrol/6 首次原始输入实验直接通过统一自动校验

(12) A Tour of Go 多语言翻译项目:从 swap 问题重新审视 Token 保护,原始输入与最小保护的一次真实实验

图 7:moretypes/1 最终中文页面预览。左侧课程正文、静态代码块、行内代码和教学注释均正常渲染,右侧官方 Go 示例继续保持原样。

(13) A Tour of Go 多语言翻译实战:第三批 10 页全部 Ready,继续校准 Protected Token 的结构角色

在前两批普通页面完成之后,我继续使用当前已经稳定下来的默认方案,推进 A Tour of Go 简体中文 zh-CN 翻译。

这一次处理的是第三批 10 个普通页面,范围从 flowcontrol/7 一直到 moretypes/3。最初的批量运行结果并不是全部成功:8 页进入 readyflowcontrol/10moretypes/1 两页进入 blocked

如果只是为了尽快增加翻译页数,这两页完全可以暂时跳过,等以后人工处理。但项目现阶段的重点并不只是“翻译多少页”,而是尽可能把自动翻译流程校准到:

GLM-5.2 第一次翻译就尽量生成能够通过完整自动校验的结果。

于是这两个 blocked 页面反而成了很好的真实测试样本。

经过这一轮连续排查和修复,第三批最终实现 10/10 ready,全局状态也更新为:

Plaintext
ready=45
pending=58
blocked=0
图 1:第三批页面最终全部进入 ready,当前全局状态为 ready=45、pending=58、blocked=0。
图 1:第三批页面最终全部进入 ready,当前全局状态为 ready=45pending=58blocked=0

这篇文章记录的重点,并不是这 10 页本身翻译了什么,而是在它们背后连续暴露出的几个更值得解决的问题:

  • Protected Token 不仅有内容,还承担结构角色;
  • 静态预格式化代码块的 token 化曾经隐藏了原始块边界;
  • 教学注释里的 Go 标识符虽然没有丢失,也可能因为词法边界变化而失效;
  • 第一次失败后的自动重试反馈,竟然还存在一次字符串误分类。

这些问题解决之后,项目的默认方案没有变成更复杂的结构化翻译系统,反而逐渐变得更符合最初目标:保持整页翻译,同时只保护真正危险的结构。


一、第三批第一次运行:8 页成功,2 页 blocked

第三批固定处理:

flowcontrol/7flowcontrol/9flowcontrol/10flowcontrol/11flowcontrol/14moretypes/1moretypes/3

其中大多数页面表现很好。除了 flowcontrol/7 前三次因为沙箱 DNS/socket 网络失败没有真正拿到模型响应以外,其余多数页面的第一份真实 GLM-5.2 输出就通过了校验。

真正值得分析的是两个 blocked 页面。

flowcontrol/10 连续出现:

Plaintext
preformatted code block mismatch at index 1:
static preformatted block changed

moretypes/1 则连续出现:

Plaintext
font span count mismatch:
expected 5, actual 3

一开始看起来,两者似乎是完全不同的问题。

但随着继续往下分析,我发现它们其实都指向了同一个更大的问题:

模型看到的是 Protected Token,但它并不知道每一种 Token 在原页面中究竟承担什么结构角色。


二、flowcontrol/10:Token 内容保住了,但独立代码块消失了

flowcontrol/10 的英文源中有一个静态预格式化代码块:

Go
switch i {
case 0:
case f():
}

这个代码块本身不应该翻译,因此现有机制会把整个块替换成一个 protectedPreformattedStatic token。

问题恰恰出现在这里。

实际发送给模型的受保护页面中,关键部分变成了类似:

Plaintext
⟪..._000001⟫does not call ...

也就是说,模型看到的并不是:

Plaintext
[一个独立代码块]

[后续普通段落]

而更像是:

Plaintext
[一个普通 Token][后续英文]
图 2:flowcontrol/10 修复前的真实 protected input。000001 代表完整静态代码块,但 token 后面直接续接 does not call,从输入形态上看并不像一个独立 block。
图 2:flowcontrol/10 修复前的真实 protected input。000001 代表完整静态代码块,但 token 后面直接续接 does not call,从输入形态上看并不像一个独立 block。

当时的 Prompt 虽然反复告诉模型“Token 不得删除、修改、复制”,但 000001 只属于普通的“单 token 必须保留”规则。

模型并不知道:

000001 不是可以自由塞进一句中文里的占位符,而是一个完整的块级结构。

因此它曾经生成:

Plaintext
当 `i==0` 时,⟪..._000001⟫ 不会调用 `f`。

Token 本身没有丢,恢复 payload 也完全正确,可一旦把真正的 Go 代码塞回这里,原本独立的预格式化块就被降级成了普通句子中间的一块内容。

validator 正确拒绝了这种结果。


三、第一步不是加强校验,而是让 Prompt 解释“结构角色”

这时我没有选择继续给 validator 增加更强的限制,也没有恢复之前已经放弃的“所有 Token 必须保持全局原始顺序”。

因为这里真正缺少的,不是新的校验能力。

validator 已经正确发现问题了。

真正缺少的是:

模型从第一次请求开始,就应该知道各种 Protected Token 分别代表什么。

于是首次翻译 Prompt 被调整为“payload + 结构角色”的思路。

对于行内代码 pair,允许它们随着中文自然语序整体移动,但要求继续保持为行内结构,不能跨越预格式化块或 directive 等块级边界。

对于 protectedPreformattedStatic,明确告诉模型:

它代表完整、独立的预格式化代码块,必须继续保持为独立 block,不得嵌入普通段落、标题、列表或 directive 行,也不得与相邻自然语言合并。

对于 protectedDirective,则明确说明它代表完整的 present directive 行。

这个修改非常重要的一点是:

结构角色必须保持,不等于全局顺序必须保持。

中文仍然可以调整正常语序,行内代码仍然可以随着语义单元移动;只是不能把一个本来是 block 的东西,重新解释成 inline 内容。

修改 Prompt 后,我重新对 flowcontrol/10 发起了一次完全干净的首次请求,不带任何历史 retry feedback。

结果第一份真实 GLM-5.2 响应就通过了全部校验。

这证明 Prompt 的角色说明确实有效。

不过很快,moretypes/1 又让我发现:只有 Prompt 还不够。


四、更底层的问题:protected input 自己把 block boundary 吞掉了

重新检查 flowcontrol/10moretypes/1 的精确输入边界后,我发现一个此前忽略的问题。

flowcontrol/10 为例,英文源本来是:

Plaintext
(For example,

    switch i {
    case 0:
    case f():
    }

does not call ...

preformattedBlocks 的解析逻辑会把代码块末尾的空行视为完整 Present preformatted block 的一部分。

这在 Present 语义上没有问题。

但翻译保护阶段直接把整个 block替换成 token 后,尾部用于分隔下一段 prose 的换行也一起被 token payload 吞掉了。

于是:

Plaintext
\tcode\n\n

整体变成了:

Plaintext
⟪TOKEN⟫

原来 token 后面的两个换行也随之消失。

这就是为什么模型最终看到:

Plaintext
⟪TOKEN⟫does not call

而不是:

Plaintext
⟪TOKEN⟫

does not call
图 3:static preformatted token 的输入表示修复前后。修复前 token 与后续 prose 直接相连;修复后保留了源页面本来就存在的两个换行。
图 3:static preformatted token 的输入表示修复前后。修复前 token 与后续 prose 直接相连;修复后保留了源页面本来就存在的两个换行。

这个问题让我重新确认了一个很重要的原则:

Prompt 和实际输入必须向模型传达同一个事实。

如果 Prompt 一边说“这是独立 block”,输入另一边却长成 TOKENprose,模型仍然需要自己猜测并重建被隐藏的结构。

因此这里没有继续强化 Prompt,而是做了一个更底层、但范围很小的修复:

仅调整 protectedPreformattedStatic 的翻译输入保护右边界,把 source 中原本存在的 block separator 留在 token 外。

也就是说:

Plaintext
source:
\tcode\n\nThe prose

保护后变成:

Plaintext
⟪TOKEN⟫\n\nThe prose

restore 之后仍然能够逐字节恢复原始 source。

这个修改没有改变 preformattedBlocks 的 Present 语义,没有改变 validator,也没有新增 Token metadata,更没有引入 slot 或全局顺序限制。

它只是让模型能够真正“看到”这个 block 本来就存在的边界。


五、moretypes/1:修完 block,下一层问题又出现了

static block 输入边界修复之后,我再次对 moretypes/1 进行干净首次回归。

这一次,之前最明显的 font span 问题不再首先出现。

模型已经能够正确生成:

Plaintext
[static block token]

[普通 prose + inline pair]

也就是说,static token 与后面的 &* 行内代码不再紧贴。

但是 validator 随即暴露了下一层问题:

Plaintext
preformatted code block mismatch at index 3:
line comment mismatch at index 1:
referenced Go identifier count mismatch:
expected 1, actual 0

问题出现在教学注释。

原文中有:

Go
fmt.Println(*p) // read i through the pointer p
*p = 21         // set i through the pointer p

这里最后的 p 是同一代码块里真正出现过的 Go 标识符,因此项目已经会将它保护为 protectedPreformattedIdentifier

也就是说,保护机制其实已经存在。

但 Prompt 并没有告诉模型:

这个 Token 恢复后必须仍然是一个词法上独立的 Go 标识符。

于是模型生成了:

Plaintext
通过指针读取 i⟪...000015⟫

Token 没有丢,数量也没有错。

可恢复 000015 → p 后,结果变成:

Plaintext
通过指针读取 ip
图 4:moretypes/1 中 Protected Token 本身完整保留,但 p 与前面的自然语言字符 i 拼接成 ip,不再是独立的 Go 标识符,因此 validator 正确拒绝。
图 4:moretypes/1 中 Protected Token 本身完整保留,但 p 与前面的自然语言字符 i 拼接成 ip,不再是独立的 Go 标识符,因此 validator 正确拒绝。

这个案例特别有代表性。

它说明:

Protected Token 校验通过,不代表结构校验一定能通过。

Token 机制证明的只是:

  • Token 没有消失;
  • 没有重复;
  • 没有伪造;
  • payload 可以正确恢复。

但恢复以后,它到底处于怎样的词法边界、容器和结构关系中,仍然需要更高一层的规则判断。


六、继续补充的不是新保护器,而是已有 kind 的真实含义

p 已经被正确 token 化,因此这里没有必要再改保护器。

我只给首次 Prompt 增加了 protectedPreformattedIdentifier 的角色说明。

现在模型会明确知道:

这些 Token 代表教学注释中引用的 Go 源码标识符;必须在所属注释中原样保留,并且恢复以后仍要能作为词法上独立的 Go 标识符识别,不能与相邻中文、英文字母、数字或下划线拼接。

与此同时,整条教学注释仍然允许正常翻译和调整中文语序。

再次进行干净首次回归后,模型输出中出现了:

Plaintext
通过指针读取 i ⟪...000015⟫
通过指针设置 i ⟪...000017⟫

这一次 token 前有了明确的边界,恢复后 p 仍然是独立 identifier。

完整自动校验全部通过,moretypes/1 恢复为 ready

图 5:补充教学注释 Go 标识符的结构角色后,模型为 p 保留了词法边界。最终 candidate 又进一步将中文语序润色为“通过指针 p 读取 i / 设置 i”,并通过全部校验。
图 5:补充教学注释 Go 标识符的结构角色后,模型为 p 保留了词法边界。最终 candidate 又进一步将中文语序润色为“通过指针 p 读取 i / 设置 i”,并通过全部校验。

这里还有一个我觉得很值得保留的细节。

模型自动输出通过校验后,教学注释是:

Go
fmt.Println(*p) // 通过指针读取 i p
*p = 21         // 通过指针设置 i p

结构上完全正确,但中文稍显生硬。

最终我只对 candidate 做了非常小的人工润色:

Go
fmt.Println(*p) // 通过指针 p 读取 i
*p = 21         // 通过指针 p 设置 i

然后重新经过同一套统一校验。

这也符合项目一直坚持的原则:

人工可以改善译文,但不能绕过自动校验。


七、还有一个意外问题:自动重试反馈竟然分错类了

处理这两页的过程中,还发现了另一个比较隐蔽的问题。

项目本来已经实现:

Plaintext
第一次翻译
→ validation 失败
→ 根据失败原因生成 retry feedback
→ 注入下一次完整页面翻译

所以最初 flowcontrol/10moretypes/1 连续失败时,我一度怀疑是不是 retry feedback 根本没有执行。

检查真实 request 后发现:

feedback 确实注入了,但分类错了。

例如真正的错误明明是:

Plaintext
preformatted code block mismatch at index 1:
static preformatted block changed

但下一次模型收到的却是:

Plaintext
上一次输出出现了未受保护的额外 present directive,
或改变了 directive 结构。

原因非常具体。

validator 为结构错误统一追加了一段诊断:

Plaintext
check the named directive or protected content near the first difference

而当前 TranslationValidation 只保存:

Plaintext
Failures []string

没有结构化的 failure kind/code。

retryFeedbackForMode 只能对完整错误文本做字符串匹配。

旧分类逻辑又先检查 directive,于是:

Plaintext
真正的 preformatted failure
+
统一 suffix 中的 directive
=
误命中 directive feedback

moretypes/1 的 font span failure 也受到了完全相同的影响。

图 6:真实 failure 是 preformatted,但 diagnostic suffix 中出现了 directive,旧字符串匹配因此给出了完全错误的重试反馈。
图 6:真实 failure 是 preformatted,但 diagnostic suffix 中出现了 directive,旧字符串匹配因此给出了完全错误的重试反馈。

当前没有为了这个问题引入一套新的结构化错误体系。

这一次仍然采取最小修复:

先剥离固定 diagnostic suffix,再只对真正的 failure core 分类,同时增加 preformatted 专用分支,并确保 font span / emphasis 在 generic directive 之前识别。

修复以后:

Plaintext
preformatted
→ preformatted feedback

font span
→ font / emphasis feedback

真正的 directive
→ directive feedback

这一步虽然不是“首次成功率”的优化,但非常重要。

因为第一份输出再怎么优化,都不可能保证以后所有页面永远一次成功。

只要还存在有限重试,那么:

失败以后告诉模型的原因必须是真的。

否则所谓“智能重试”,实际上只是拿着错误方向再翻译一次。


八、第三批最终 10/10 ready

经过这一轮校准,两个最初进入 blocked 的页面最终都恢复为 ready

第三批最终完成 10/10 ready,全局状态为:

Plaintext
ready=45
pending=58
blocked=0

status check 同时确认:

Plaintext
status OK: 103 pages for zh-CN

所有 candidate 都重新通过统一 candidate validate,全量测试:

Bash
go test -mod=readonly -count=1 ./...

通过,git diff --check 也没有问题。

最终改动以提交:

Plaintext
be9413846f54e6679c79c0fc19957ac4e2a3f815
feat(i18n): 完成第三批并校准受保护翻译结构

推送到 origin/main


九、最终页面不只是校验通过,也能正常渲染

完成所有内部结构校准后,我又重新打开 moretypes/1 的浏览器预览页面。

最终效果如下。

图 7:moretypes/1 最终中文页面预览。左侧课程正文、静态代码块、行内代码和教学注释均正常渲染,右侧官方 Go 示例继续保持原样。
图 7:moretypes/1 最终中文页面预览。左侧课程正文、静态代码块、行内代码和教学注释均正常渲染,右侧官方 Go 示例继续保持原样。

这个页面非常适合作为本轮校准的最终结果展示。

因为它恰好包含了这次出现问题的多种结构:

  • 普通中文正文;
  • *TTnil&* 等行内代码;
  • 多个静态预格式化代码块;
  • 可翻译教学注释;
  • 教学注释中的 Go 标识符;
  • 右侧 .play 引用的官方代码示例。

最终页面看起来很普通。

但也正是这种“普通”,才是自动翻译基础设施应该追求的结果:读者不需要知道背后经历过多少 Token、restore、Present 解析和 validator,只需要看到一页正常、自然、结构完整的中文课程。


十、这一轮最重要的结论

这次第三批最值得记录的,并不是又完成了 10 页。

更重要的是,我对“Protected Token 应该保护什么”有了更清晰的认识。

早期更容易把它理解成:

Token 的主要任务,是让模型不要改动危险内容。

现在这个定义已经不够了。

更准确的说法应该是:

Protected Token 既有 payload,也有结构角色。

对于行内 pair,角色是“仍然属于行内结构”;对于 static preformatted token,角色是“仍然是一个独立 block”;对于 directive,角色是“仍然是一条独立 directive”;对于教学注释里的 Go identifier,角色则是“恢复以后仍然必须是词法上独立的标识符”。

另一方面,结构角色也不能被无限扩大。

我仍然不打算恢复:

Plaintext
所有 Token 必须保持英文原始全局顺序

因为这会重新妨碍正常中文语序调整。

当前更合适的边界是:

允许语言层面的重新组织,但不允许结构类型发生退化。

除此之外,这一轮还有另一个很重要的经验:

不能只要求模型“理解结构”,输入本身也必须把结构展示正确。

TOKENproseTOKEN\n\nprose,在人看来只差两个换行,但对一个本来应该表示独立代码块的 Token 来说,这两个换行就是非常真实的结构信息。

最后,retry feedback 的误分类也再次提醒我:

自动翻译流程里真正需要校准的,不只有模型 Prompt。

从:

Plaintext
输入
→ Prompt
→ 模型输出
→ Restore
→ Present 解析
→ 结构校验
→ Retry feedback

任何一个环节传达了错误的信息,最终都可能表现成“模型翻译失败”。

这也是为什么我现在越来越倾向于,在继续扩大页面数量之前,优先把真实批量运行中暴露出的通用问题解决掉。


十一、下一步

第三批完成后,项目已经有 45 页进入 ready,剩余 58 页仍为 pending,当前没有 blocked 页面。

下一步原本可以立即进入第四批普通页面。

不过在连续完成三批真实运行,并且这一批又完成了 Prompt、protected input、教学注释 identifier 以及 retry feedback 的连续校准之后,我打算先暂时停一下,把这次过程完整记录下来。

接下来再继续第四批时,我更关心的已经不是:

“10 页最后能不能都翻译成功?”

而是:

经过这一轮调整以后,新的普通页面首次通过率究竟能提高到什么程度?

如果后续大批量页面能够稳定维持较高的一次通过率,那么这个项目才算真正从“逐页开发校准”,开始进入可以持续推进的自动翻译阶段。

A Tour of Go 中文翻译完成 7 个代表页校准:最后 3 页又发现了哪些真实问题

A Tour of Go 多语言翻译项目

本系列完整记录 A Tour of Go 多语言翻译项目从架构设计、整页翻译、结构保护、自动校验,到生产发布与后续维护的实际开发过程。

项目入口:
✅ 在线学习:A Tour of Go 简体中文版
✅ 项目源码:GitHub:shuijingwan/go-tour-i18n

当前第一阶段已完成简体中文版本。项目属于非官方社区多语言翻译项目,与 Go 官方无隶属或授权关系。

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

这个站点使用 Akismet 来减少垃圾评论。了解你的评论数据如何被处理