在开发 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,有几个问题:
- HTML 标签大量占用 token,增加成本和延迟
- LLM 不擅长精确复制任意 HTML 结构,容易错位或丢失
- 翻译后
<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 翻译,适用于所有工具的使用。