本地 LLM 翻译实战
一、为什么用本地 LLM 做翻译
先说清楚场景。不是 Google 翻译那种"输入一个词吐一个词"的轻量需求,而是:
- 隐私敏感文本:公司内部文档、医疗病历、法律合同——不能发到第三方 API。
- 批量长文本:几万字的技术文档、小说章节,需要一次性处理且可控成本。
- 领域定制:通用翻译把"梯度下降"翻成"gradient descent"(没翻),但你想要的是带术语表的、风格统一的结果。
- 离线环境:内网部署,压根没有外网出口。
本地 LLM 的优势就在这里:数据不出机器、调用不限次、提示词可以极度定制。但代价是——你得自己把提示词和调用链路调好,没有 OpenAI 那边帮你做好的各种"对齐"兜底。
二、提示词设计:翻译的核心不是"翻译",是"约束"
很多人写翻译提示词就一句话:
请把以下内容翻译成英文:{text}
能用吗?能。好用吗?不好用。常见问题:
- 术语不一致:同一个词上下文翻成两种说法。
- 风格漂移:技术文档翻着翻着变成口语化。
- 漏译 / 加译:模型自作主张删掉"不重要的"部分,或者加自己的解释。
- 格式丢失:Markdown / 代码块 / 表格被破坏。
根因是:翻译任务对"忠实度"的要求远高于对话任务,但默认的提示词没有任何约束机制。
2.1 一个经过实战检验的提示词模板
你是一位专业的{source_lang}→{target_lang}翻译专家,擅长{domain}领域。
## 翻译规则
1. 严格逐句翻译,不遗漏、不新增、不合并句子。
2. 保持原文的段落结构和换行。
3. 保持原文中的 Markdown 格式标记(标题、列表、代码块、加粗等)不变。
4. 代码块内容不翻译,只翻译代码注释。
5. 专有名词和术语按以下术语表翻译:
{glossary}
6. 语气保持{tone}风格。
## 术语表
{glossary}
## 原文
{text}
## 译文(直接输出翻译结果,不要加任何解释或前后缀)
几个关键设计点:
- “逐句翻译,不遗漏不新增”:这是最重要的约束。没有这句话,模型有概率"意译"甚至跳过长段落。
- 格式保持指令:明确点名 Markdown 标记,否则模型会把
## 标题翻成**标题**之类。 - 代码块不翻译:技术文档翻译的命门。不加这句,模型会把你代码里的字符串也给翻了。
- 术语表:这是领域定制的核心,后面代码里会讲怎么动态注入。
- “直接输出”:防止模型输出 “好的,以下是翻译结果:\n\n…” 这种废话前缀。
2.2 Few-shot:给两个例子比说一百句规则管用
当规则约束不住时,上例子。在提示词里塞 1-2 个翻译样本:
## 翻译示例
示例1:
原文:该模型在ImageNet上达到了76.5%的top-1准确率。
译文:The model achieved 76.5% top-1 accuracy on ImageNet.
示例2:
原文:```python
# 初始化模型
model = ResNet50()
译文:```python
Initialize the model
model = ResNet50()
## 原文
{text}
Few-shot 的本质是用输入输出对来隐式定义格式和风格,比纯靠自然语言描述规则更可靠——模型从例子里"看"出来的模式,比从规则里"理解"出来的约束更稳。
2.3 长文本:分段 + 上下文窗口
本地模型上下文长度有限(4K / 8K / 32K 不等)。几万字的文档一次性塞进去必然截断。策略是分段翻译,每段带前一段的末尾做上下文:
你是一位专业翻译。以下是文档的第{n}段。
请根据前文语境,将本段翻译成{target_lang}。
## 前文末尾(用于保持语境连贯,不要翻译这部分)
{prev_tail}
## 本段原文
{chunk}
## 译文
prev_tail 取上一段的最后 2-3 句话即可,不用整段。这样既保持代词指代、术语一致性,又不会浪费上下文窗口。
三、代码调用:三种方式,从简单到生产级
以下代码假设你本地跑的是 Ollama(最常见)或任何 OpenAI 兼容 API(vLLM、llama.cpp server 都是)。Python 示例。
3.1 方式一:直接 HTTP 调用(最轻量,无依赖)
import requests
def translate(text: str, source_lang: str = "中文", target_lang: str = "英文") -> str:
"""最简翻译:直接调 Ollama 的 /api/chat 接口"""
system_prompt = f"""你是一位专业的{source_lang}→{target_lang}翻译专家。
翻译规则:
1. 严格逐句翻译,不遗漏、不新增、不合并句子。
2. 保持原文段落结构和 Markdown 格式。
3. 代码块内容不翻译,只翻译注释。
4. 直接输出译文,不加任何解释或前后缀。"""
response = requests.post(
"http://localhost:11434/api/chat",
json={
"model": "qwen2.5:14b",
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": text},
],
"stream": False,
},
)
response.raise_for_status()
return response.json()["message"]["content"]
# 使用
result = translate("大语言模型通过预测下一个token来生成文本。")
print(result)
# 输出: Large language models generate text by predicting the next token.
适用场景:快速验证、一次性脚本、不想装额外依赖。
缺点:没有重试、没有流式、没有 token 计数。生产环境不够用。
3.2 方式二:OpenAI SDK(推荐,兼容性最好)
vLLM、Ollama(开启 OpenAI 兼容模式)、llama.cpp server 都暴露 /v1/chat/completions 端点,可以直接用 openai 库:
from openai import OpenAI
client = OpenAI(
base_url="http://localhost:11434/v1", # Ollama 的 OpenAI 兼容端点
api_key="ollama", # 本地模型不需要真实 key,随便填
)
GLOSSARY = """
| 中文术语 | 英文翻译 |
|---------|---------|
| 大语言模型 | Large Language Model (LLM) |
| 提示词 | prompt |
| 微调 | fine-tuning |
| 推理 | inference |
"""
def translate(
text: str,
source_lang: str = "中文",
target_lang: str = "英文",
domain: str = "人工智能",
glossary: str = "",
) -> str:
"""带术语表的翻译"""
system_prompt = f"""你是一位专业的{source_lang}→{target_lang}翻译专家,擅长{domain}领域。
## 翻译规则
1. 严格逐句翻译,不遗漏、不新增、不合并句子。
2. 保持原文段落结构和 Markdown 格式标记。
3. 代码块内容不翻译,只翻译代码注释。
4. 专有名词按术语表翻译。
5. 直接输出译文,不加任何解释。"""
if glossary:
system_prompt += f"\n\n## 术语表\n{glossary}"
response = client.chat.completions.create(
model="qwen2.5:14b",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": f"## 原文\n{text}\n\n## 译文"},
],
temperature=0.3, # 翻译要确定性,温度调低
max_tokens=4096,
)
return response.choices[0].message.content
# 使用
text = """
## 大语言模型简介
大语言模型通过**预测下一个token**来生成文本。常见的微调方法包括:
- LoRA:低秩适配,只训练少量参数
- QLoRA:量化 + LoRA,显存占用更低
```python
# 加载预训练模型
model = AutoModel.from_pretrained("qwen2.5-14b")
“”"
result = translate(text, glossary=GLOSSARY)
print(result)
输出大致是:
Introduction to Large Language Models
Large Language Models generate text by predicting the next token. Common fine-tuning methods include:
- LoRA: Low-Rank Adaptation, only training a small number of parameters
- QLoRA: Quantization + LoRA, lower GPU memory usage
# Load the pretrained model
model = AutoModel.from_pretrained("qwen2.5-14b")
注意几个参数选择:
- **`temperature=0.3`**:翻译是高确定性任务,温度越低输出越稳定。但不要设成 0(某些模型在 `temperature=0` 时行为异常),0.1~0.3 是甜点区。
- **`max_tokens=4096`**:必须显式设置。默认值在某些实现里很小(256 / 1024),长文本会被截断而你浑然不觉——**这是最常见的"翻译结果莫名其妙短了一截"的原因**。
- **术语表用 Markdown 表格**:模型对表格格式的理解优于纯文本列表,对齐更准确。
### 3.3 方式三:流式输出 + 分段处理(生产级)
实际场景里你要翻译的可能是整本书。需要:流式输出(用户能看到进度)、分段处理(绕过上下文限制)、结果拼接。
```python
from openai import OpenAI
from typing import Iterator
client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
def chunk_text(text: str, max_chars: int = 2000) -> list[str]:
"""按段落切分文本,每段不超过 max_chars 字符。
尽量在段落边界切,不要从句子中间断开。"""
paragraphs = text.split("\n\n")
chunks, current = [], ""
for para in paragraphs:
if len(current) + len(para) > max_chars:
if current:
chunks.append(current)
current = para
else:
current = current + "\n\n" + para if current else para
if current:
chunks.append(current)
return chunks
def translate_stream(
text: str,
source_lang: str = "中文",
target_lang: str = "英文",
max_chars: int = 2000,
) -> Iterator[str]:
"""流式分段翻译,逐块 yield 译文文本。"""
chunks = chunk_text(text, max_chars)
prev_tail = ""
for i, chunk in enumerate(chunks):
system_prompt = f"""你是一位专业的{source_lang}→{target_lang}翻译专家。
翻译规则:
1. 严格逐句翻译,不遗漏、不新增。
2. 保持段落结构和 Markdown 格式。
3. 代码块内容不翻译,只翻译注释。
4. 直接输出译文。"""
# 第一段不需要前文上下文
if prev_tail:
system_prompt += f"\n\n## 前文末尾(保持语境连贯,不要翻译此部分)\n{prev_tail}"
user_content = f"## 本段原文(第{i+1}/{len(chunks)}段)\n{chunk}\n\n## 译文"
stream = client.chat.completions.create(
model="qwen2.5:14b",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_content},
],
temperature=0.3,
max_tokens=4096,
stream=True,
)
chunk_result = ""
for event in stream:
delta = event.choices[0].delta.content
if delta:
chunk_result += delta
yield delta # 实时吐给调用方
# 保存末尾用于下一段的上下文
sentences = chunk_result.rstrip().split("。")
prev_tail = "。".join(sentences[-3:])
# 使用:实时打印翻译进度
long_text = open("article.md", encoding="utf-8").read()
for piece in translate_stream(long_text):
print(piece, end="", flush=True)
几个设计决策的解释:
chunk_text按段落切而不是按字数硬切:从句子中间断开会导致模型翻译出不完整的句子,拼接时出现断裂。按\n\n(段落边界)切,最多在超长单段时才退化。prev_tail取最后 3 句:解决代词指代问题。比如第一段提到"该模型",第二段说"它的性能"——如果第二段没有上下文,"它"可能被翻成 “it” 而没有明确指代。带上前文末尾,模型能保持指代一致。- 流式 yield delta:调用方可以实时写文件 / 推前端 / 显示进度条,不用等全部翻完。
四、提示词调优的几个实战经验
4.1 System Prompt vs User Prompt:放哪都行,但有细微差别
翻译规则放 System Prompt 是惯例,好处是:
- 多轮调用时不用重复传(部分框架会缓存 system prompt)。
- 语义上更"权威"——模型对 system 的遵循度略高于 user。
但实测下来,对于翻译这种单轮任务,放 system 还是放 user 影响不大。真正影响结果的是规则的内容,不是位置。别在这上面纠结太久。
4.2 “直接输出译文” 这句话不能省
不加这句,模型有概率输出:
好的,我来帮您翻译这段内容:
[译文]
希望这个翻译对您有帮助!
这些前后缀在拼接长文本时是灾难——每段都带一句"好的",最终文档里全是"好的"。加一句"直接输出译文,不加任何解释或前后缀"能消除 95% 的情况。剩下 5% 靠代码后处理 strip 掉。
4.3 术语表的格式:表格 > 列表 > 纯文本
同样一份术语表,三种写法效果有差异:
纯文本(最差):
大语言模型翻译为Large Language Model,提示词翻译为prompt
列表(中等):
- 大语言模型 → Large Language Model
- 提示词 → prompt
表格(最好):
| 中文 | 英文 |
|------|------|
| 大语言模型 | Large Language Model |
| 提示词 | prompt |
原因猜测:训练数据里表格天然就是"映射关系"的结构,模型对这种格式的"查表"行为对齐得更好。这不是严格证明,是多次对比后的经验。
4.4 温度的选择
| 任务类型 | temperature | 原因 |
|---|---|---|
| 技术文档翻译 | 0.1 - 0.3 | 要确定性,术语统一 |
| 文学翻译 | 0.5 - 0.7 | 需要一定创造性,但不能跑偏 |
| 口语 / 对话翻译 | 0.3 - 0.5 | 自然但不过度发挥 |
| 术语提取 / 摘要 | 0.0 - 0.1 | 纯提取,零创造 |
翻译不是越低越好。文学翻译用 0.1 的温度,出来的句子干巴巴的,"她眼中闪过一丝不易察觉的哀伤"变成 “A trace of sadness flashed in her eyes”——准确但没味道。适当提高温度让模型有选词空间。
五、一个完整的可复用脚本
把上面的东西整合成一个开箱即用的命令行工具:
#!/usr/bin/env python3
"""本地 LLM 翻译工具
用法: python translate.py input.md --target en --output output.md
"""
import argparse
from pathlib import Path
from openai import OpenAI
def build_prompt(source_lang: str, target_lang: str, domain: str, glossary: str) -> str:
prompt = f"""你是一位专业的{source_lang}→{target_lang}翻译专家,擅长{domain}领域。
## 翻译规则
1. 严格逐句翻译,不遗漏、不新增、不合并句子。
2. 保持原文段落结构和 Markdown 格式标记。
3. 代码块内容不翻译,只翻译代码注释。
4. 专有名词按术语表翻译。
5. 直接输出译文,不加任何解释或前后缀。"""
if glossary:
prompt += f"\n\n## 术语表\n{glossary}"
return prompt
def chunk_text(text: str, max_chars: int = 2000) -> list[str]:
paragraphs = text.split("\n\n")
chunks, current = [], ""
for para in paragraphs:
if len(current) + len(para) > max_chars:
if current:
chunks.append(current)
current = para
else:
current = current + "\n\n" + para if current else para
if current:
chunks.append(current)
return chunks
def translate_file(input_path, output_path, source_lang, target_lang,
domain, glossary_path, model, base_url):
client = OpenAI(base_url=base_url, api_key="local")
glossary = ""
if glossary_path and Path(glossary_path).exists():
glossary = Path(glossary_path).read_text(encoding="utf-8")
system_prompt = build_prompt(source_lang, target_lang, domain, glossary)
text = Path(input_path).read_text(encoding="utf-8")
chunks = chunk_text(text)
results = []
prev_tail = ""
for i, chunk in enumerate(chunks):
sp = system_prompt
if prev_tail:
sp += f"\n\n## 前文末尾(不要翻译此部分)\n{prev_tail}"
resp = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": sp},
{"role": "user", "content": f"## 原文\n{chunk}\n\n## 译文"},
],
temperature=0.3,
max_tokens=4096,
)
translated = resp.choices[0].message.content
results.append(translated)
sentences = translated.rstrip().split("。")
prev_tail = "。".join(sentences[-3:])
print(f" [{i+1}/{len(chunks)}] 完成", file=sys.stderr)
Path(output_path).write_text("\n\n".join(results), encoding="utf-8")
print(f"翻译完成 → {output_path}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="本地 LLM 翻译工具")
parser.add_argument("input", help="输入文件路径")
parser.add_argument("--target", default="英文", help="目标语言")
parser.add_argument("--source", default="中文", help="源语言")
parser.add_argument("--output", help="输出文件路径")
parser.add_argument("--domain", default="通用", help="领域")
parser.add_argument("--glossary", help="术语表文件路径")
parser.add_argument("--model", default="qwen2.5:14b", help="模型名称")
parser.add_argument("--base-url", default="http://localhost:11434/v1")
args = parser.parse_args()
output = args.output or f"{args.input}.translated.md"
translate_file(
args.input, output, args.source, args.target,
args.domain, args.glossary, args.model, args.base_url,
)
用法:
# 基本用法
python translate.py article.md --target 英文
# 带术语表
python translate.py paper.md --target 英文 --domain 人工智能 --glossary terms.md
# 指定模型
python translate.py doc.md --target 英文 --model llama3.1:8b
术语表文件 terms.md 的格式:
| 中文 | 英文 |
|------|------|
| 大语言模型 | Large Language Model (LLM) |
| 提示词 | prompt |
| 微调 | fine-tuning |
| 零样本 | zero-shot |
| 思维链 | Chain-of-Thought (CoT) |
六、常见坑与排查
| 现象 | 原因 | 解决 |
|---|---|---|
| 译文莫名其妙短了一截 | max_tokens 默认值太小 |
显式设 max_tokens=4096 或更大 |
| 每段前面带"好的,以下是翻译" | 没加"直接输出"约束 | 提示词末尾加"直接输出译文,不加解释" |
| 代码块里的字符串被翻译了 | 没加"代码块不翻译"规则 | 加规则 + few-shot 示例 |
| 术语前后不一致 | 没用术语表,或术语表太长被忽略 | 用表格格式术语表,控制在 20 条以内 |
| 长文档代词指代丢失 | 分段时没带上下文 | 加 prev_tail 前文末尾 |
| 翻译速度极慢 | 模型太大 / 没开流式 / 逐段串行 | 用 7B-14B 模型,开 stream,或并发翻译多段 |
| 格式标记被破坏 | 没明确说"保持 Markdown 格式" | 加"保持原文 Markdown 格式标记不变" |
最后一个值得展开:并发翻译多段。如果你不在乎段间上下文连贯性(比如每段都是独立的技术说明),可以把分段后的 chunk 用 concurrent.futures 并发扔给模型,速度提升 N 倍(N = 并发数)。但如果段间有"它""该模型"这种指代,并发会导致指代丢失——这时候老老实实串行。
from concurrent.futures import ThreadPoolExecutor
def translate_concurrent(chunks: list[str], system_prompt: str) -> list[str]:
"""并发翻译(仅适用于段间无指代依赖的场景)"""
def translate_one(chunk: str) -> str:
resp = client.chat.completions.create(
model="qwen2.5:14b",
messages=[
{"role": "system", "content": system_prompt},
{"role": "user", "content": chunk},
],
temperature=0.3,
max_tokens=4096,
)
return resp.choices[0].message.content
with ThreadPoolExecutor(max_workers=4) as pool:
return list(pool.map(translate_one, chunks))
- 点赞
- 收藏
- 关注作者
评论(0)