这两年处理文档翻译任务,真正让我头疼的从来不是普通段落,而是那些嵌在文中的公式。技术文档、论文初稿、产品手册里,公式一旦被机器翻译碰过,轻则 LaTeX 命令被强行改写,重则整段公式直接变成乱码。DeepSeekFanyi 这个项目,就是专门为解决这类问题做的:基于 DeepSeek 模型接口,做带公式文档的批量翻译。它适合需要反复处理技术文档、学术论文、教学课件和软件本地化内容的同学。整套方案的核心思路其实就一句话——公式原样保护,编号逐条保留,其余文本交给模型翻译。
1. 需求拆解:公式批量翻译到底难在哪
1.1 公式不是普通文本,不能按单词翻译
刚开始做这个项目时,我犯过一个很典型的错误:直接把包含公式的段落丢给翻译模型,然后发现公式里的\frac被翻译成了“分数”,\alpha变成了“alpha”,数学环境里的变量名甚至被改成了自然语言单词。这在公式领域是完全不可接受的。
原因其实很简单:公式是结构化符号系统,它的语义由数学符号和 LaTeX 命令承载,而不是由自然语言承载。\sum_{i=1}^{n} x_i这一段,如果翻译成英文单词,后续无论是编译还是阅读,都会变得莫名其妙。所以处理公式翻译的第一原则就是:公式内部必须原样保留,一个字都不能动。
这里说的“公式”不只是块级的 equation 环境,还包括行内公式、对齐环境、编号引用等。只要带数学结构,都应该纳入保护范围。把“公式当普通文本”是绝大多数翻译工具做不好的根源,也是 DeepSeekFanyi 第一个要解决的问题。
1.2 批量任务的核心矛盾是“流水线”而不是“翻译”
单条公式翻译其实不难,难的是批量。一篇论文动辄几十个公式,一个软件手册可能有几百段带公式的说明文字。人工逐条复制粘贴到模型对话里,效率太低,而且容易遗漏或弄错上下文。
批量处理要解决的核心矛盾不是“让模型更会翻译”,而是“把一批杂乱的文档片段整理成可重复执行的流水线”。拆块、保护、翻译、还原、校验,每一步都不能跳。只要流水线稳定,100 段和 10000 段的区别只是跑多久的问题,而不是质量不可控的问题。
所以我在设计 DeepSeekFanyi 时,第一版的目标不是做一个交互页面,而是做一个命令行工具:输入是一段长文档,输出是翻译后的文档,中间过程全部自动化。这样既方便自己调试,也方便后续挂到文档生成流程里。
1.3 方案骨架:拆块、保护、翻译、还原
整套流程可以拆成四个固定步骤。
第一步是拆块。把长文档按段落、标题或空行切成多个文本块。为什么不能整篇直接丢进去?因为模型上下文长度有限,而且整篇翻译容易产生前后不一致,切块后每个块独立翻译,最后拼接,反而更稳。
第二步是保护。把文本块里的公式、编号、特殊占位符全部提取出来,用占位符替换,再送去翻译。这样模型看到的内容里不存在任何数学命令,自然就不会乱翻译。
第三步是翻译。按块调用模型接口,统一使用一套 prompt 和参数,保证输出风格稳定。
第四步是还原。把占位符替换回原始公式和编号,再拼接成完整文档,最后做校验。
这套骨架看起来简单,但每个步骤都有不少细节。下面分别展开讲。
2. 关键模块是怎么实现的
2.1 接入模型接口:参数别乱调
DeepSeekFanyi 的翻译引擎直接对接模型接口,没有做二次封装。配置上只需要几个关键项:
| 配置项 | 推荐值 | 说明 |
|---|---|---|
| model | deepseek-chat | 通用对话模型,翻译场景够用 |
| temperature | 0.3 | 降低随机性,避免输出发散 |
| max_tokens | 2048 | 按单块长度调整,别设太小 |
| timeout | 60 秒 | 长文本块需要更久,建议取高 |
| base_url | 从环境变量读取 | 不要硬编码在代码里 |
temperature 是这里最容易翻车的参数。文档翻译需要的是准确和稳定,不是“有创造性”。我实测下来 0.3 左右比较合适,超过 0.6 就会出现公式说明文字被改写、术语不统一的情况。
max_tokens 也不要随手设成 512。我遇到过几次译文被截断的情况,原因是单块文本太长,输出超过限额。经验做法是先按 500 字分块,对应 max_tokens 给 2048,基本不会截断。
接入方式用标准接口调用即可,鉴权信息通过环境变量注入,不要写死在配置文件里。下文的脚本骨架里会有完整示例。
2.2 公式保护机制:先把公式锁进保险柜
公式保护是整个项目最核心的部分。我用一个非常朴素的办法:正则匹配公式,替换成占位符,翻译完成后还原。
占位符格式我统一用[[FORMULA_0]]、[[FORMULA_1]]这种形式。为什么不用普通文字?因为占位符本身要足够“不像人话”,模型才不容易改它。
匹配范围需要覆盖常见的公式形态:
- 块级环境:
\begin{equation}...\end{equation} - 对齐环境:
\begin{align}...\end{align} - 行内公式:
\(...\) - 展示公式:
$$...$$或\[...\]
实际用到的正则大致是这样:
import re FORMULA_PATTERN = re.compile( r"(\\begin\{equation\}.*?\\end\{equation\}" r"|\\begin\{align\}.*?\\end\{align\}" r"|\\\[.*?\\\]" r"|\\\(.*?\\\)" r"|\$\$.*?\$\$" r"|\$.*?\$)", re.S, )注意最后的\$.*?\$这一条,只处理单美元符号包裹的行内公式。如果文档里同时存在大量货币符号,要小心误匹配。我的做法是在预处理阶段先剔除货币符号场景,或者干脆在配置里关掉单美元匹配。
保护函数提取公式并替换占位符:
def protect_formulas(text: str): placeholders = [] def keep(match): placeholders.append(match.group(0)) return f"[[FORMULA_{len(placeholders) - 1}]]" protected = FORMULA_PATTERN.sub(keep, text) return protected, placeholders还原函数更简单,把占位符逐个替换回原公式:
def restore_formulas(translated: str, placeholders: list[str]) -> str: for idx, formula in enumerate(placeholders): translated = translated.replace(f"[[FORMULA_{idx}]]", formula) return translated这里有个细节:还原函数的 replace 必须从 0 开始顺序执行,不能逆序,否则编号会错位。如果担心占位符被模型改动,可以在还原前检查一遍[[FORMULA_数字]]是否全部存在,发现缺失直接标记为翻译异常。
提示:把公式提取出来单独保存还有一个额外好处,就是翻译失败时可以重新只翻译公式附近的文本块,不用整篇重跑。
2.3 批量并发与速率控制
批量翻译最怕两件事:一是并发太高被限流,二是并发太低浪费时间。
我的做法是先单线程顺序跑一遍,把平均单块耗时记录下来。实测下来,一个 300 字左右的文本块,包含公式的情况下,响应时间通常在 5 到 15 秒之间。如果文档有 200 块,顺序跑大概需要 20 到 40 分钟,这个时间对大多数场景可以接受。
如果确实需要提速,可以用 ThreadPoolExecutor 开到 3 到 4 个并发。为什么不建议更高?因为模型接口对单账号的并发限制比较严格,超过阈值就会返回状态码 429。与其频繁重试,不如把并发控制在稳定区间。
重试逻辑也要设计好。我的经验是:遇到网络超时或 5xx 错误,指数退避重试,初始等待 2 秒,最多重试 3 次;遇到 429 则等待 10 秒再试,因为这是限流信号,等太久反而更稳。重试多次仍失败就把这条文本块写入错误日志,最后统一处理。
2.4 术语表与上下文一致性
批量翻译的另一个痛点是术语不统一。第一段把“参数”翻译成 parameter,第三段又翻译成 argument,读者一看就知道是拼出来的。
我用的方案是:在 prompt 里注入一个术语表,让模型严格按表翻译。术语表用简单的键值对表示,放在翻译指令之后、正文之前。
示例术语表:
{ "参数": "parameter", "权重": "weight", "损失函数": "loss function", "梯度下降": "gradient descent" }prompt 里的组织方式大致如下:
术语对照表: 参数 -> parameter 权重 -> weight 损失函数 -> loss function 梯度下降 -> gradient descent 请严格使用上述术语,不得使用同义词替换。实践证明,注入术语表能显著提升一致性。而且术语表可以沉淀成文件,同一个项目反复使用,不同批次之间的翻译风格也能保持一致。
3. 实操:从一段中文文档到双语版本
3.1 准备待翻译的文本块
DeepSeekFanyi 处理的是文本文件,不是直接解析 Word 或 PDF。所以第一步是把源文档导出为纯文本或 Markdown。如果源文档是扫描版 PDF,需要先过 OCR 引擎做文本抽取,配置里保留的paddle_zh2en方向就是干这个用的:中文文档识别后转成英文任务预设。
OCR 这一步不是必需的,但做的话要在拆分前完成。识别出的文本里如果混入公式,同样会被保护机制捕获。
文本块的划分我推荐两种模式:
- 按空行切块:适合段落边界清晰的文档,实现最简单。
- 按标题切块:适合有章节结构的文档,能保证每个块的主题单一。
我平时用的是按空行切块,再结合一个长度控制:超过 500 字的块继续往下拆,避免单块过大导致翻译不稳定。
3.2 完整脚本骨架
下面是一个可直接跑通的简化版脚本。我把它写成一个函数库的形式,方便集成到自己的文档流水线里。
import os import re import time import requests API_KEY = os.getenv("DEEPSEEK_API_KEY") BASE_URL = os.getenv("DEEPSEEK_BASE_URL", "") MODEL = "deepseek-chat" FORMULA_PATTERN = re.compile( r"(\\begin\{equation\}.*?\\end\{equation\}" r"|\\begin\{align\}.*?\\end\{align\}" r"|\\\[.*?\\\]" r"|\\\(.*?\\\)" r"|\$\$.*?\$\$" r"|\$.*?\$)", re.S, ) def split_blocks(text, max_len=500): raw_blocks = [b.strip() for b in text.splitlines() if b.strip()] blocks = [] current = "" for block in raw_blocks: if len(current) + len(block) > max_len: blocks.append(current) current = block else: current = current + "\n" + block if current: blocks.append(current) return blocks def protect_formulas(text): placeholders = [] def keep(match): placeholders.append(match.group(0)) return f"[[FORMULA_{len(placeholders) - 1}]]" protected = FORMULA_PATTERN.sub(keep, text) return protected, placeholders def restore_formulas(translated, placeholders): for idx, formula in enumerate(placeholders): translated = translated.replace(f"[[FORMULA_{idx}]]", formula) return translated def translate_block(text): prompt = ( "你是一名科技文档翻译专家。请把下面的中文文档片段翻译成英文。\n" "要求:\n" "1. 所有以[[FORMULA_数字]]形式出现的占位符,翻译时必须原样保留,不能拆开、不能改写。\n" "2. 数学环境(equation、align等)中的内容保持LaTeX原样输出,不翻译任何数学命令。\n" "3. 公式编号如(1)、(2)一律保留,不翻译、不删除。\n" "4. 术语使用专业表达,保持上下文连贯。\n" "5. 只输出译文正文,不要输出解释。\n\n" f"待翻译内容:\n{text}" ) resp = requests.post( BASE_URL, headers={"Authorization": f"Bearer {API_KEY}"}, json={ "model": MODEL, "messages": [{"role": "user", "content": prompt}], "temperature": 0.3, "max_tokens": 2048, "timeout": 60, }, ) resp.raise_for_status() return resp.json()["choices"][0]["message"]["content"] def translate_document(doc_text): blocks = split_blocks(doc_text) translated_blocks = [] for block in blocks: protected, placeholders = protect_formulas(block) translated = translate_block(protected) restored = restore_formulas(translated, placeholders) translated_blocks.append(restored) return "\n\n".join(translated_blocks)这个脚本没有做并发和重试,但已经能处理大多数中小型文档。把translate_block里的单次请求换成带重试的版本,就是生产可用的状态。
3.3 一次真实批次的效果
我拿一段模拟的中文技术文档做过测试,内容是介绍某个优化算法的实现。原段落里包含一个编号公式:
在每次迭代中,我们更新权重向量 w,使损失函数逼近最小值。更新规则定义如下: \begin{equation} w_{t+1} = w_t - \eta \nabla L(w_t) \end{equation} 其中 \eta 是学习率。翻译结果:
In each iteration, we update the weight vector w to make the loss function approach its minimum. The update rule is defined as follows: \begin{equation} w_{t+1} = w_t - \eta \nabla L(w_t) \end{equation} where \eta is the learning rate.公式、编号、\eta、\nabla全部原样保留,正文翻译准确。这是我测试过最理想的情况,但大多数文本块的表现都在这个水平附近。个别块会出现术语不一致,比如“权重向量”被翻译成 weight vector 和 weight array 混用,这时候术语表就派上用场了。
3.4 质量检查四步走
批量翻译跑完,不能直接交付,至少要做四步检查。
第一步,公式数量校验。统计原文里\begin{equation}的数量,对比译文里是否一致。这个用脚本很容易完成,不一致说明有公式被模型吞掉或改写了。
第二步,编号连续性校验。检查(1)、(2)这类编号在译文里是否按顺序出现,有没有跳号。
第三步,术语一致性抽查。按提前准备的术语表,在译文里搜索对应英文,确认没有混用。
第四步,人工抽查。按 10% 的比例随机抽几个文本块,重点看公式附近的连接词翻译是否自然。这步虽然费时间,但能发现 prompt 和参数都解决不了的语义问题。
4. 常见问题与排查技巧
4.1 问题速查表
| 现象 | 原因 | 对策 |
|---|---|---|
| 公式里的命令被翻译 | 公式未被占位符保护 | 检查正则匹配范围,确认 equation、align 都覆盖 |
| 公式编号丢失 | 编号随正文被模型忽略 | prompt 里明确要求保留编号,并用占位符保护 |
| 译文被截断 | max_tokens 太小 | 调大 max_tokens,或减小单块文本长度 |
| 同一术语翻译不一致 | 缺少术语表 | 在 prompt 中注入术语表并强调严格使用 |
| 接口返回 429 | 并发过高或超频 | 降低并发,增加超时等待时间 |
| 公式出现在不该出现的位置 | 占位符被模型移动 | 检查 prompt,明确要求占位符顺序不变 |
| OCR 阶段漏掉公式 | 扫描件清晰度不够 | 提高图片分辨率,或手工补录关键公式 |
4.2 排障思路:先隔离变量,再猜问题
批量翻译出问题,第一件事不是改 prompt,而是先定位是哪一步出的问题。
我的排查顺序是:先看原文里的公式是否都被正确提取。方法是直接跑保护函数,输出替换后的文本,检查占位符数量对不对。这一步没问题,再看译文里的占位符是否都还原了。如果还原成功但公式位置不对,那就是模型动了顺序,要在 prompt 里强调。
如果连占位符都没有出现在译文里,问题多半出在模型本身,比如上下文过长被截断,或者术语表干扰了模型对占位符的识别。这时候先用一个最小样本复现,把文本块缩短到一两句话,看看能不能稳定触发,再逐步加长,很快就能找到触发条件。
4.3 我在实际项目里踩过的几个坑
第一个坑是 temperature 调太高。最初我把参数设成 0.8,觉得这样译文更自然,结果数学说明文字被大量改写,术语不统一,修补成本比翻译成本还高。后来改回 0.3,问题立刻消失。
第二个坑是占位符用了太像普通词的符号。第一版我用的是FORMULA_0,没有加双括号。结果模型经常把FORMULA_1当成普通文本,翻译成“公式一”。改成[[FORMULA_1]]之后,模型识别准确率高了很多。这类占位符越不像自然语言,越不容易被改动。
第三个坑是 max_tokens 设了 512。表面上看单块文本不长,但公式多的时候输出会膨胀,导致译文在公式后面被硬生生截断。后来统一用 2048,再没出过截断问题。
第四个坑是 OCR 阶段漏掉行内公式。扫描版 PDF 里的\(x\)有时会被识别成普通括号和字母,导致后面无法保护。这个没法完全靠算法解决,只能结合人工抽查,把高频出现的公式形态补进 OCR 矫正规则里。
根据我的个人经验,这套方案跑顺之后,最大的收益不只是省时间,而是翻译结果稳定可复现。任何一次输出都可以回到同一个流水线重新跑,不会出现同一条公式这次翻译保留、下次翻译乱掉的情况。如果后续要继续扩展,可以考虑把术语表改成按学科分类的多套配置,或者把翻译结果接到双语渲染流程里,直接生成对照版文档。公式保护这个思路本身是通用的,换任何翻译引擎都适用,核心还是把“不该翻的东西”先牢牢锁住。