..
用大模型翻译 EPUB:从占位符到最小干预

在开发 orange-translator 的过程中,我为一个问题折腾了好几个版本:如何处理 EPUB 里的 HTML 内联标签

orange-translator 是一个将英文 EPUB 电子书翻译为双语版本的工具,译文紧跟原文段落之后,形成”原文 + 译文”交替排布的阅读体验。表面上看,翻译流程并不复杂:

EPUB 解包 → HTML 解析 → 文本提取 → LLM 批量翻译 → 双语重组 → EPUB 重新打包

真正让我踩坑的,是第三步和第四步之间的那道墙。

核心挑战:内联标签怎么办

EPUB 的 XHTML 里,一段文字往往不是纯文本,而是夹杂着各种内联标签:

<p>
  In <em>The Dhammapada</em>, verse 103 reads:
  <sup><a href="#fn1">1</a></sup>
  "Conquer yourself<br/>rather than the world."
</p>

里面有 <em>(斜体)、<sup>(脚注序号)、<a>(链接)、<br/>(换行)。

如果把整个 inner_html 原样送给 LLM,有几个问题:

  1. HTML 标签大量占用 token,增加成本和延迟
  2. LLM 不擅长精确复制任意 HTML 结构,容易错位或丢失
  3. 翻译后 <em> 里的词可能位置变了,硬保留反而不自然

真正的问题:哪些标签必须保留?哪些可以丢弃?哪些需要特殊处理?

第一个方案:占位符

直觉上,这是个”显然”的解法:把内联标签替换为 LLM 可以透传的占位符,翻译完后再还原。

数学括号 ⟦N⟧

第一版用 Unicode 数学白方括号 ⟦N⟧(U+27E6/U+27E7)作为占位符,透明标签用 ⟦0⟧content⟦/0⟧,不透明标签用 ⟦0⟧

问题:翻译模型(translategemma:4b)把 ⟦⟧ 翻译成了 《》

输出变成了这样:

《0》《/0》托《1》奥《/1》曼尼《2》

根本原因:LLM 的训练数据中 极少见,模型会把它当作”奇怪的外语括号”进行翻译,而 《》 是它见过的最相似的括号形式。

教训:占位符不能用模型训练数据中罕见的字符。

XML 风格标签 <gN>

改用 <g0>content</g0> 表示透明标签,<x0/> 表示不透明标签。<g><x> 在 HTML 中不存在,模型不会把它们当真实 HTML 处理——理论上。

问题:不透明的自闭合 <x0/> 被模型扩展为 <x0>内容</x0>

模型看到一个孤立的 <x0/> 没有内容,觉得不合理,于是自作主张给它配了内容。

Token 风格 [OT:N]

将不透明标签改为更像”标记/代码”而非”HTML 标签”的格式 [OT:N]

《》 问题消失了,但新问题出现了:[OT:N] 被模型概率性丢弃

有时候输出完整,有时候 [OT:0] 凭空消失。丢弃率和 batch 大小、上下文长度、模型状态都有关,不可预测。

占位符方案的根本矛盾

到这里,我意识到占位符方案存在根本性矛盾

LLM 的翻译本质是:给定源语言文本,生成目标语言的自然表达。它的整个训练目标是生成流畅自然的人类语言。

而占位符要求模型做相反的事:在自然语言输出中,精确地、不遗漏地复制一些非自然语言符号。这与模型的优化目标是冲突的。

小模型尤其无法可靠地完成这个”规则遵从”任务,因为它没有足够的上下文理解能力来始终遵守指令。

重新审视:哪些标签真的需要保留

关键洞察来自对双语 EPUB 使用场景的重新审视:

在双语 EPUB 中,原文就在译文正上方。读者可以直接看到原文的完整格式——斜体、粗体、超链接都在。译文的作用是”帮助理解原文”,而不是”替代原文”。

这意味着:

  • <em><strong><span> 等装饰性格式在译文中可以丢弃,原文已经有了
  • <a href> 链接在译文中意义不大
  • <br/>结构性换行,必须保留(诗歌、台词等场景)
  • <sup>/<sub> 通常是脚注序号,丢失了脚注引用就断了
  • <a id="N"/> 空锚点是页码标记,完全不可见,直接丢弃即可

一句话:只有影响内容可读性的结构才需要保留,纯装饰性格式可以丢弃。

最小干预方案

基于这个认识,放弃占位符,改为”最小干预预处理”:

def preprocess_for_translation(inner_html: str) -> tuple[str, str]:
    soup = BeautifulSoup(f"<div>{inner_html}</div>", "html.parser")
    div = soup.find("div")

    # 1. 规范化文本节点中的 \n 为空格,避免后续与 <br/> 转换的 \n 混淆
    for text_node in list(div.find_all(string=True)):
        s = str(text_node)
        if "\n" in s:
            text_node.replace_with(NavigableString(s.replace("\n", " ")))

    # 2. <br/> → \n(LLM 能自然保留换行)
    for br in list(div.find_all("br")):
        br.replace_with(NavigableString("\n"))

    # 3. 空锚点直接剥离
    for a in list(div.find_all("a")):
        if not a.get_text(strip=True):
            a.decompose()

    # 4. 装饰性内联标签:保留文字内容,丢弃标签本身
    for tag_name in _STRIP_INLINE:  # em, strong, b, i, span, a, ...
        for tag in list(div.find_all(tag_name)):
            tag.unwrap()

    # 5. sup/sub/img/wbr 保留原始 HTML 不动

    return div.decode_contents(), br_html

还原时,把翻译结果中的 \n 替换回原始的 <br/> 字符串(保留 calibre 生成的 class 属性)。

几个值得注意的细节

为什么先规范化文本节点中的 \n

XHTML 源文件里有时会有文本节点包含换行符(排版用途)。如果不先规范化,这些 \n 会和 <br/> 转换来的 \n 混淆,还原时会多出多余的 <br/>

<sup>/<sub> 为什么不用占位符?

实测发现,<sup>1</sup> 这类短标签 LLM 能正确透传——它足够短,不像乱码,模型见过足够多的 HTML 上下文,知道要原样保留。长段落中的复杂嵌套才是问题。

效果对比

方案 速度 缺陷
⟦N⟧ 占位符 基准 ⟦⟧《》 转译
<gN>/<xN/> -5% <xN/> 被扩展为含内容标签
<gN>/[OT:N] -10% [OT:N] 概率性丢弃
最小干预(最终) +22% 无已知缺陷

速度提升的原因:送给 LLM 的文本更短,prompt token 减少,批次解析失败率降为零。

其他踩坑记录

批量翻译的分段解析

批量翻译时用编号标记让 LLM 分段返回:[1][2]……解析时用正则 \[(\d+)\] 切分。

模型偶尔会把多段合并,或者跳过某个编号。解决方案是递归对半重试:10 段失败,拆成两个 5 段重试,直到单段为止。单段永远可以直接返回,无需解析。

ReadTimeout 与流式 API

httpx 调 Ollama 时,非流式 API 需要等待完整响应。对于长段落,生成时间可能超过 300 秒,触发 ReadTimeout

改用流式 API(stream: true),用 aiter_lines() 逐行消费。流式模式下,timeout 针对相邻两个 chunk 之间的等待时间(设为 60 秒),而不是整个响应时间。

续翻支持

翻译 300 章的大部头时中途崩溃,已翻译的部分不能白费。

实现:每章完成后写入 .ot-cache/<md5>.xhtml,同时更新 progress.json只有全部章节无错误完成时,才清理缓存。有失败章节时,保留缓存,下次运行自动重翻失败的章节。

总结

回头看这次折腾,走弯路的根本原因是:我在用错误的方式提问

一开始我问的是”如何让 LLM 精确透传 HTML 标签”,这是个错误的问题。LLM 的优化目标是生成流畅的自然语言,不是规则遵从。我想让它做的事,恰好和它的本质相违背。

换一个问题:“哪些格式信息对读者真正重要?” 一旦把问题问对了,答案就清晰了——在双语阅读场景下,原文就在旁边,大量格式信息根本不需要在译文中重复。

让模型做它擅长的事,自己处理规则性的事。这条原则不只适用于 LLM 翻译,适用于所有工具的使用。

引用