☰
DeepSeek API批量翻译带公式论文:占位符锁定法实战
2026/9/25 2:42:21 网站建设 项目流程

如果你经常跟外文论文打交道,肯定遇到过这种抓狂时刻:正文翻得挺顺,一到公式就原形毕露。要么\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 翻译提示词:规则写清楚,模型才不乱来

占位符只是机制,提示词是约束。我在实际使用中固定了一套系统提示词,核心规则有四条:

你是一个专业科技文献翻译助手。你的任务是把给定文本翻译成目标语言。必须遵守以下规则:

  1. 文本中的{{FORMULA_n}}全部代表数学公式或代码片段,必须原样保留,不得修改、删除或翻译。
  2. 只翻译自然语言部分,保留原文段落结构与 Markdown 标记。
  3. 变量符号、专业缩写(如 SOTA、BERT、MSE)保留原样,不要翻译成中文。
  4. 译文表达应当通顺、准确,不要输出任何额外说明或解释。

第 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.content

temperature我固定设在 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 编译,确认公式链路完全闭环。这套流程跑了两个多月,再也没有因为公式错乱返过工。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询