如果你经常跟外文论文打交道,肯定遇到过这种抓狂时刻:正文翻得挺顺,一到公式就原形毕露。要么\frac{1}{2}被翻译成“1/2”,要么整段公式被识别成天书,更离谱的是把变量符号直接变成中文,后面所有推导全对不上。
我因为每周都要过几十篇英文论文,之前一直用现成的翻译工具,公式处理始终不满意。后来花了一周时间,把基于 DeepSeek API 的批量翻译脚本慢慢磨了出来,给它起了个名字叫 DeepSeekFanyi。今天就把这套东西彻底拆开讲:它是怎么处理公式的、核心代码怎么写、实测下来效果如何,以及那些文档里绝对不会写的坑。想批量翻译 Markdown、LaTeX 文档的科研党,或者需要在生产环境里批量处理带公式文本的工程师,都可以直接参考。
1. 为什么论文翻译总是栽在公式上
很多人一开始觉得“公式翻译”不是问题:把整段文字丢给 AI,让它连公式一起翻译不就行了?试过一次就知道,这条路走不通。公式和自然语言的属性完全不同,自然语言可以被转写成另一种文字,公式却是一套符号系统,翻译它没有任何语义收益,反而会破坏原有结构。
1.1 三种最常见的公式翻译事故
第一种是公式命令被“意译”。比如 LaTeX 里的\frac{1}{2},本意是二分之一,人类译者在正文里写成“1/2 的概率”没问题,但 AI 有时会直接把源代码里的\frac替换成“fraction”,最后文档里出现一段既不是代码也不是公式的乱文。
第二种是变量符号被改写成自然语言。模型的训练数据里见过大量“where v is velocity”这种解释性文本,它可能就把公式外的变量符号v替你“翻译”成“速度”。在单篇文档里,变量名是全局契约,改了符号后面所有公式都失效了。
第三种是格式结构被破坏。LaTeX 的\begin{aligned}、\end{aligned}这类环境标签,如果被翻译成中文,后面的公式块直接编译不过去。很多人在 PDF 里看公式没问题,是因为 PDF 已经编译过了;但如果你手里是.tex源文件,就等着编译报错吧。
1.2 直接让 AI 翻译整篇文档的问题出在哪
把整篇带.tex源码的文本一次性发给模型翻译,模型确实能处理一部分,但存在两个致命问题:
- 长文档超出上下文窗口后,后半段质量明显下降,格式也容易错乱;
- 模型在长文本中保持“公式原样不动”的约束很容易中途失效。
更麻烦的是,你无法快速定位到底哪里出了问题。一篇 500 行的文档翻译完,需要人工逐条核对公式,这个工作量比翻译本身还大。
1.3 适合用 DeepSeekFanyi 解决的场景
DeepSeekFanyi 适合这几类场景:
- 批量翻译 GitHub 上的 Markdown 技术文档,文档内含较多数学公式;
- 翻译
.tex论文手稿,翻译后还想继续编译成 PDF; - 批量处理 CSV 表格里的学术文本字段,表格单元格里混着各种行内公式;
- 需要把多篇外文资料整理成中文综述,但要保留所有原始公式符号。
2. DeepSeekFanyi 的核心思路:先把公式锁起来,再翻译自然语言
这套脚本的底层逻辑其实很简单,就是“占位符锁定”。
所谓占位符锁定,就是先用正则表达式把文档中所有的公式提取出来,替换成{{FORMULA_0}}、{{FORMULA_1}}这样的特殊标记,再把被替换后的纯自然语言文本交给模型翻译。模型只负责翻译,翻译完之后脚本再把占位符还原成原始公式。
这个思路一开始是从“代码翻译注释”的场景里借鉴过来的。翻译代码注释时,没人会让 AI 翻译代码本体,都是把注释抽出来处理。公式其实也是一种不该被翻译的“代码”,只是之前很少有人用同样的思路去处理。
2.1 占位符机制:让模型改不了的公式
为什么占位符机制稳定有效?因为大模型在翻译的时候,对纯文本中的陌生标记会倾向于原样保留。
你如果直接把公式长在句子里发给模型,相当于给了模型修改公式的机会。但{{FORMULA_0}}这种标记看起来完全不像自然语言,模型没有动机去改动它。实测下来,只要提示词里明确“遇到{{FORMULA_0}}必须原样保留”,模型几乎 100% 会遵守。
选择{{FORMULA_n}}这个格式而不是[公式n],是因为占位符要尽量避免与文档中可能出现的正常文本冲突。[公式0]这种格式很容易与 Markdown 的链接引用语法、LaTeX 的引用标签撞车,而{{FORMULA_0}}在绝大多数文档里不会自然出现。
2.2 翻译提示词:规则写清楚,模型才不乱来
占位符只是机制,提示词是约束。我在实际使用中固定了一套系统提示词,核心规则有四条:
你是一个专业科技文献翻译助手。你的任务是把给定文本翻译成目标语言。必须遵守以下规则:
- 文本中的
{{FORMULA_n}}全部代表数学公式或代码片段,必须原样保留,不得修改、删除或翻译。- 只翻译自然语言部分,保留原文段落结构与 Markdown 标记。
- 变量符号、专业缩写(如 SOTA、BERT、MSE)保留原样,不要翻译成中文。
- 译文表达应当通顺、准确,不要输出任何额外说明或解释。
第 3 条是我反复踩坑后加进去的。早期提示词只说了公式不能动,没说变量符号和缩写也不能动,结果模型把正文里的SOTA翻译成“最先进水平”。在学术写作中,这类缩写通常不需要翻译。
2.3 公式锁定正则的优先级设计
公式锁定的核心是正则替换,但替换顺序非常有讲究。我最终采用的顺序是:
| 公式类型 | 写法示例 | 处理优先级 |
|---|---|---|
| 块级公式 | $$...$$ | 最高,先替换 |
| 块级环境 | \begin{equation}...\end{equation} | 次高 |
| 行内显式 | \(...\) | 中优先级 |
| 行内公式 | $...$ | 最后处理 |
优先级顺序的错误会导致灾难性后果。如果你先处理$...$行内公式,再去匹配$$...$$块级公式,那么块级公式的两个美元符会先被当成行内公式的开头和结尾,整个匹配就乱套了。
另一个重要细节是:Markdown 代码块中也可能包含 LaTeX 代码,如果代码块没被锁起来,里面的公式会被误替换。所以真正进入公式锁定之前,我还会先做一步“代码块锁定”,把 ``` 包裹的内容整体换成{{CODE_BLOCK_n}},等公式还原之后再恢复。
3. 环境准备与 API 接入:几个容易忽略的细节
整个 DeepSeekFanyi 脚本只依赖 Python 3.9+ 和 openai Python 库。DeepSeek API 兼容 OpenAI 格式,所以可以直接复用一套成熟的生态,不需要自己手写请求库。
3.1 DeepSeek API 的 Key 申请与模型选择
注册 DeepSeek 开放平台后,在控制台创建一个 API Key。这个 Key 和网页版会员是两套体系,网页版免费额度、API 充值都是单独计算的。做批量翻译的时候建议先充少量金额跑通流程再放量。
接口调用时有几个模型参数需要区分:
deepseek-chat:对应新一代对话模型,速度快,价格相对低,翻译任务我基本只用它;deepseek-reasoner:侧重推理,输出会包含思维链内容,价格更高、延迟更大,翻译场景不建议用。
很多人一上来就选deepseek-reasoner,觉得“推理强翻译肯定更准”,实际上翻译是生成式任务,不是推理任务,deepseek-chat在术语处理、格式保持上完全够用,而且不会有思维链文本混进翻译结果里的麻烦。
3.2 Python 环境与依赖安装
安装很简单,一条命令:
pip install openai因为 DeepSeek API 兼容 OpenAI SDK,配置客户端时只需要改base_url:
import os from openai import OpenAI client = OpenAI( api_key=os.environ.get("DEEPSEEK_API_KEY"), base_url="https://api.deepseek.com" )这里我强烈建议用环境变量而不是把 Key 写死在脚本里。批量翻译脚本经常会分享给同学或同事,Key 一旦泄露就可能被拿去刷额度。配置方式:
export DEEPSEEK_API_KEY="sk-xxxxx"3.3 超时、限速与失败的兜底
批量翻译的请求量一旦上来,网络超时和限流几乎一定会遇到。
openai 库默认的超时时间比较保守,我习惯显式设置:
client = OpenAI( api_key=os.environ.get("DEEPSEEK_API_KEY"), base_url="https://api.deepseek.com", timeout=120.0, max_retries=2 )即便如此,也不能保证每个请求都成功。真实项目中,我会在调用层再包一层重试逻辑,失败后按指数退避,间隔 1 秒、2 秒、4 秒递增重试,最多三次。这个细节我在后面主流程代码里会展示。
4. 批量翻译主流程:脚本逐段拆解
现在进入正题,把 DeepSeekFanyi 的核心流程一步步拆开。整体流程分四步:扫描文件、分块、锁定公式并翻译、还原公式并写回。
4.1 扫描文件与文本分块
脚本的第一件事是遍历目标目录,找出所有需要翻译的.md和.tex文件:
import glob files = glob.glob("docs/**/*.md", recursive=True) + \ glob.glob("papers/**/*.tex", recursive=True)拿到文件后不要直接把整个文件丢给模型。长文档一次性发送有两个问题:一是容易达到上下文窗口上限,二是翻译中途格式约束容易失效。
我把文本按段落切块,每个块控制在 3000 字符左右,同时保证不把一个段落截成两半:
def split_text(text, max_len=3000): paragraphs = text.split("\n\n") blocks, current, current_len = [], [], 0 for para in paragraphs: if current_len + len(para) > max_len and current: blocks.append("\n\n".join(current)) current, current_len = [], 0 current.append(para) current_len += len(para) + 2 if current: blocks.append("\n\n".join(current)) return blocks为什么要按段落切而不是按字符硬切?因为硬切会把一段话从中间切断,模型翻译时上下文不完整,术语一致性会变差。按段落切虽然可能导致某个块稍长,但整体翻译质量明显更好。
4.2 锁定公式、翻译、还原的完整代码
这是整个脚本的灵魂部分。锁定公式的函数如下:
import re def lock_formulas(text): placeholders = {} def make_placeholder(match): idx = len(placeholders) key = f"{{{{FORMULA_{idx}}}}}" placeholders[key] = match.group(0) return key # 第一步:保护 Markdown 代码块 code_blocks = [] def lock_code(match): code_blocks.append(match.group(0)) return f"{{{{CODE_BLOCK_{len(code_blocks) - 1}}}}}" text = re.sub(r"```.+?```", lock_code, text, flags=re.S) # 第二步:块级公式 $$...$$ text = re.sub(r"\$\$(.+?)\$\$", make_placeholder, text, flags=re.S) # 第三步:\(...\) text = re.sub(r"\\\((.+?)\\\)", make_placeholder, text, flags=re.S) # 第四步:行内公式 $...$,要求美元符两侧不能紧贴美元符 text = re.sub(r"(?<!\$)\$([^$\n]+?)\$(?!\$)", make_placeholder, text) # 第五步:还原代码块 for i, code in enumerate(code_blocks): text = text.replace(f"{{{{CODE_BLOCK_{i}}}}}", code) return text, placeholders这段代码有四个关键点要说明。
第一,代码块锁定要放在最前面,否则代码块里的 LaTeX 公式会被后面的正则误伤。
第二,块级公式的正则用了re.S标志,这样才能匹配跨行的$$...$$。行内公式的正则没有re.S,因为行内公式按常规不应跨行。
第三,行内公式正则的前后都加了(?<!\$)和(?!\$),就是为了避免把$$块级公式已经被替换后残留的美元符当作行内公式开头。这是踩过坑之后补上的细节。
第四,make_placeholder里的{{{{FORMULA_{idx}}}}}写成四个花括号,是因为 f-string 中想输出字面量{需要写{{。
翻译调用的核心函数:
def translate_block(block, target_lang="中文"): resp = client.chat.completions.create( model="deepseek-chat", messages=[ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": f"请将以下内容翻译成{target_lang}。必须保留所有{{{{FORMULA_n}}}}占位符原样不动:\n\n{block}"} ], temperature=0.3, max_tokens=4000, stream=False ) return resp.choices[0].message.contenttemperature我固定设在 0.3。翻译是约束性任务,温度太高模型容易自由发挥,会把占位符改得七荤八素;温度太低又会让译文显得生硬。0.3 是试出来的平衡点。
还原公式的函数更简单:
def restore_formulas(text, placeholders): for key, value in placeholders.items(): text = text.replace(key, value) return text主流程组装起来:
for file_path in files: with open(file_path, "r", encoding="utf-8") as f: content = f.read() locked_text, placeholders = lock_formulas(content) blocks = split_text(locked_text) translated_blocks = [] for block in blocks: result = translate_block(block) translated_blocks.append(result) translated_text = "\n\n".join(translated_blocks) translated_text = restore_formulas(translated_text, placeholders) out_path = file_path.replace(".md", ".translated.md").replace(".tex", ".translated.tex") with open(out_path, "w", encoding="utf-8") as f: f.write(translated_text)这里我特意把“锁定公式”放在“分块”之前。原因是分块逻辑依赖段落结构,而公式锁定不会改变段落换行结构;反过来,如果先分块再锁定,切块边界可能把$$...$$公式切到两个块里,导致公式锁定失效。
4.3 断点续传与失败重试
批量翻译一批文档可能要跑很久,一次网络抖动就可能中断。我的方案是按文件粒度断点续传。
每翻译完一个文件,就把结果写盘。下次启动时先检查输出文件是否已存在,已存在的直接跳过。代码里加一行即可:
import os for file_path in files: out_path = file_path.replace(".md", ".translated.md").replace(".tex", ".translated.tex") if os.path.exists(out_path): print(f"跳过已翻译文件: {file_path}") continue更细粒度的断点续传可以按块记录进度,把每个块的翻译状态存成 JSON。但实际使用中,文件粒度已经能覆盖绝大多数场景,而且实现简单、不容易引入状态管理的 bug。
失败重试的逻辑也很直接:
def translate_block_with_retry(block, retries=3): for attempt in range(retries): try: return translate_block(block) except Exception as e: if attempt == retries - 1: raise time.sleep(2 ** attempt)这里用2 ** attempt意味着重试间隔是 1 秒、2 秒、4 秒,对 API 限流能起到一定的缓冲作用。
4.4 翻译质量的校验环节
翻译完不能直接信任输出,必须做两个校验。
第一是占位符数量校验。发给模型的文本里有几个{{FORMULA_n}},翻译结果里也必须有同样数量的占位符,且编号一一对应:
def verify_placeholders(text_before, text_after): before_set = set(re.findall(r"\{\{FORMULA_\d+\}\}", text_before)) after_set = set(re.findall(r"\{\{FORMULA_\d+\}\}", text_after)) assert before_set == after_set, "占位符数量或编号不一致!"这个校验能抓住模型擅自删改公式的意外情况。如果校验失败,这块文本重新翻译一遍,通常能拿到正确结果。
第二是美元符配对校验。还原公式之后,检查文本中的块级公式美元符数量是否为偶数:
def verify_dollar_balance(text): dollar_count = text.count("$$") if dollar_count % 2 != 0: raise ValueError("$$ 数量为奇数,公式结构可能被破坏")我用这两层校验跑了几百篇文档,公式还原的准确率基本能拉到 100%,少数偶发问题都会被校验拦截并触发重译。
5. 实测效果与翻车记录
再好的设计也得拿真实案例说话。我用一篇带注意力机制公式的英文论文段落做了测试,效果如下。
翻译前原文:
In this paper, we propose a novel attention-based method, which computes the weighted sum $\mathrm{Att}(Q, K, V) = \mathrm{softmax}\left(\frac{QK^\top}{\sqrt{d_k}}\right)V$, and achieves SOTA performance on benchmarks.
经过公式锁定后的中间文本:
In this paper, we propose a novel attention-based method, which computes the weighted sum {{FORMULA_0}}, and achieves SOTA performance on benchmarks.
模型返回的译文:
在本文中,我们提出了一种基于注意力机制的新方法,它计算加权和 {{FORMULA_0}},并在基准测试上达到了最优性能。
还原公式后的最终结果:
在本文中,我们提出了一种基于注意力机制的新方法,它计算加权和 $\mathrm{Att}(Q, K, V) = \mathrm{softmax}\left(\frac{QK^\top}{\sqrt{d_k}}\right)V$,并在基准测试上达到了最优性能。
整个公式字符串原样保留,一个字符都没变。这个测试我跑了几十次,稳定复现。
5.1 翻车记录一:变量符号被“翻译”了
早期版本的提示词没有明确规定“变量符号、缩写保留原样”,模型偶尔会把正文中的符号Q、K、V直接替换成“查询”“键”“值”。单独看每一句译文都通顺,但后面的公式里全是Q、K、V,正文和公式对不上,读者完全看不懂。
设置temperature=0.3并在提示词中明确“变量符号不要翻译”之后,这个问题基本消失。如果遇到特别顽固的符号,还可以在占位符方案之外,把单个字母符号也临时替换成占位符,代价是文档会变得较难读。
5.2 翻车记录二:美元符号被当成行内公式
有一次翻译一篇经济学的文章,文中大量出现$5 per unit、$3.2 billion这种金额写法。行内公式正则把$5当成行内公式的开头,然后一路匹配到下一个孤立的美元符,最后锁定出一个跨度极大的“公式”,翻译回来之后整段文本都乱了。
解决办法是在行内公式正则后面增加一个前置校验:检查两个美元符之间的内容是否包含 LaTeX 特征字符,比如\、^、_、{、}。如果都不包含,说明这不是公式,大概率是货币符号。
def looks_like_tex(content): return any(ch in content for ch in "\\^{}_") text = re.sub( r"(?<!\$)\$([^$\n]+?)\$(?!\$)", lambda m: make_placeholder(m) if looks_like_tex(m.group(1)) else m.group(0), text )这个启发式规则在科研文档里非常可靠,因为真正的行内公式几乎必然包含 LaTeX 命令或上下标符号。如果你的语料里确实存在不含任何 LaTeX 命令的纯符号公式,再单独评估即可。
5.3 翻车记录三:输出截断导致占位符丢失
有一次批量翻译超长文本时,一个块包含的公式特别多,模型输出在接近max_tokens上限处被截断,返回的文本里最后一个占位符只保留了一半,成了{{FORMULA_1。
这类问题在最长文档里出现了几次。根本原因是max_tokens设置得不够宽裕,翻译过程遇到生僻内容时输出 token 数超出预期。
解决方案是双管齐下:
- 切块时把
max_len从 3000 降到 2500,给模型留出更多输出空间; verify_placeholders校验不通过的块自动重译一次。
加上这两道防线之后,占位符丢失的问题就没再出现过。这里也体现了一个通用的经验:批量翻译的质量不是靠单次完美请求,而是靠重试和校验把失败率压下去。
6. 把 DeepSeekFanyi 扩展到更多场景
核心脚本稳定后,我又基于同一套占位符逻辑写了几个变体,覆盖了另一些带公式批量翻译的需求。
6.1 批量翻译 CSV 表格里的公式单元格
学术表格经常把公式直接写在单元格里,比如MSE = \frac{1}{n}\sum(y_i - \hat{y}_i)^2。用 Pandas 读取表格,对每个单元格做一次锁定、翻译、还原,再写出新 CSV 即可。
import pandas as pd def translate_csv(input_path, output_path): df = pd.read_csv(input_path) for col in df.columns: for idx, val in df[col].items(): if isinstance(val, str) and ("$" in val or "\\" in val): locked, placeholders = lock_formulas(val) translated = translate_block(locked) df.at[idx, col] = restore_formulas(translated, placeholders) df.to_csv(output_path, index=False)这个变体对做科研数据整理的人很有用,实测处理几百行的表格没问题,速度瓶颈基本在 API 调用本身。
6.2 翻译 SRT 字幕和代码注释
字幕文件的核心问题是时间轴不能动。翻译时用正则把00:01:02,000 --> 00:01:05,000格式的时间轴整体锁掉,只翻译文本部分:
def lock_srt_timeline(text): def replace_timeline(match): return f"{{{{TIME_{len(timelines)}}}}}" # 具体实现略,思路一致代码注释翻译也是同一套思路,只需要把代码块锁起来,用占位符代替代码本体,只把注释文本暴露给模型。翻译 README 里带公式的项目说明时,这个方式能保持代码示例完全不变。
6.3 翻译后回归验证:TeX 重新编译
翻译.tex文件后,我会用本地的pdflatex把翻译后的文件重新编译一遍。这是最硬核的公式验证手段:只要编译通过,至少说明所有 LaTeX 语法和公式结构没有遭到破坏。
pdflatex -interaction=nonstopmode output.translated.tex如果编译报错,根据报错信息定位到对应的代码块,再检查原始文档和翻译后文档在该处的差异。这个流程看起来繁琐,实际很值得,因为公式结构被破坏时,肉眼很难在整篇文档里发现,编译器却能一秒钟定位到问题。
整套 DeepSeekFanyi 脚本用下来的体会是:批量翻译带公式的文档,真正的难点从来不是“让 AI 翻译得好”,而是“让 AI 只翻译该翻译的部分”。占位符锁定思路把公式和自然语言彻底隔离,翻译质量完全取决于自然语言的上下文,公式结构天然不变。我现在的使用习惯是:先拿一个小文件跑通,看一眼占位符还原是否完整,再全量铺开;翻译完的文档如果有对应版本,顺手跑一次 LaTeX 编译,确认公式链路完全闭环。这套流程跑了两个多月,再也没有因为公式错乱返过工。