用 Index-Translate 这类流程批量翻文档,慢点通常不在翻译本身,而在两头:一是几十上百份文件没法只跑一次就完事,中途报错、超长段落、接口超时都会打断;二是同一术语在不同段落、不同文档里被译成不同说法,读者看到前后不一致,比漏译还难修。比较稳的做法是把批处理拆成四步:把待翻文件扫成任务清单、按段落分片并保留位置标记、用术语表在译前替换译后还原、最后跑一遍译后校验。每一步都能单独验证,出问题也容易定位到具体文件和段落。Index-Translate 只负责“翻译”这一段,术语一致性和断点续跑要自己在外面维持。
批量翻文档的核心不是把翻译接口调通,而是把任务状态留在磁盘上:任务清单管来源与进度,分片标记管回填,术语表管一致性,校验脚本管漏译。适用场景是几十到上百份结构化文档的离线批翻;操作上先跑任务清单生成、再分片、再替换翻译、再校验;验证方式是比对段落数与占位符、检查异常清单;边界是术语表只能约束你列出的词,遇到多义词、嵌套术语仍需要人工抽检,别指望脚本一次到位。
把待翻文件扫成一份任务清单
批量任务要能断点续跑,前提是每份文件的处理状态落盘,而不是放在内存里。用一个 JSONL 或 CSV 任务文件,字段至少包含源路径、目标路径、状态、错误信息和更新时间。状态字段是续跑的锚点:每次启动时只挑状态为 pending 或 failed 的记录。脚本骨架可以这样写:
# task_scan.py
import os, json, hashlib
SRC_DIR = "./docs_in"
DST_DIR = "./docs_out"
TASK_FILE = "./tasks.jsonl"
def scan(src_dir):
seen = set()
if os.path.exists(TASK_FILE):
with open(TASK_FILE, encoding="utf-8") as f:
for line in f:
seen.add(json.loads(line)["src"])
with open(TASK_FILE, "a", encoding="utf-8") as out:
for root, _, files in os.walk(src_dir):
for name in files:
if not name.lower().endswith((".md", ".txt", ".html")):
continue
src = os.path.join(root, name)
if src in seen:
continue
rel = os.path.relpath(src, src_dir)
dst = os.path.join(DST_DIR, rel)
rec = {
"src": src,
"dst": dst,
"status": "pending",
"error": "",
"updated": ""
}
out.write(json.dumps(rec, ensure_ascii=False) + "\n")
seen.add(src)
if __name__ == "__main__":
os.makedirs(DST_DIR, exist_ok=True)
scan(SRC_DIR)续跑时只需遍历 tasks.jsonl,跳过 status 为 done 的记录。注意不要在扫描时用文件哈希做去重键,改内容后路径不变会让旧记录被误判为已完成;如果确实需要识别内容变化,单独加 mtime 或 hash 字段,由你决定是否重跑。
按段落分片并保留原文位置标记
整篇丢给翻译接口容易超长截断,也让译文回填失去对齐依据。建议按空行或标题切段,每段生成唯一标记,例如 [[S0001]],标记单独成行,紧贴段落上方。分片时把 {marker, text} 写入一个 sidecar 文件,译后按 marker 顺序回填。标记本身不要放进要翻译的文本里,否则模型可能改写、丢弃或翻译标记。检查方式很简单:翻译前后各跑一次正则统计 \[\[S\d{4}\]\] 的出现次数与顺序,数量或顺序不一致就说明分片或回填环节出错了。
如果文档本身含有类似方括号的语法,换个不太可能出现在正文里的标记格式,例如 <<SEG001>>,并在校验脚本里同步改正则。段落过长的(比如超过接口单次限制)先按句号或换行做二级切分,标记用 S0001-01 这种带父级编号的形式,回填时再拼回原段。
用术语表在译前替换、译后还原
术语一致性靠翻译模型自己保持并不可靠,尤其是同一术语在不同段落里被拆成不同语境时。做法是维护一张术语表,字段至少包含 source、target、alias、case_sensitive、priority。翻译前把原文里的术语替换成不太可能被翻译的占位 token,例如 __T_HTTP2__,翻译后再把 token 还原成目标译法。
import json, re
TERMS = [
{"source": "Index-Translate", "target": "索引翻译",
"alias": [], "case_sensitive": True, "priority": 100},
{"source": "pipeline", "target": "流水线",
"alias": ["pipelines"], "case_sensitive": False, "priority": 50},
]
def build_terms(terms):
items = []
for t in terms:
forms = [t["source"]] + t.get("alias", [])
for f in forms:
items.append((f, t))
# 长词优先,避免子串误替换
items.sort(key=lambda x: (len(x[0]), x[1]["priority"]), reverse=True)
return items
def apply_terms(text, items):
for form, t in items:
token = "__T_%s__" % t["source"].upper().replace(" ", "_")
flags = 0 if t["case_sensitive"] else re.IGNORECASE
text = re.sub(re.escape(form), token, text, flags=flags)
return text
def restore_terms(text, items):
for form, t in items:
token = "__T_%s__" % t["source"].upper().replace(" ", "_")
text = text.replace(token, t["target"])
return text还原的匹配顺序要跟替换一致:长术语和 alias 先处理,短术语后处理,否则 Index-Translate 可能先被 Index 之类的子串规则命中。发生冲突时,比如同一个 target token 对应多条术语,按 priority 最高的一条生效,其余在日志里记一条 warning,别静默丢弃。注意 token 本身如果被翻译模型改写,还原会失败,所以校验脚本里要专门检查 token 是否原样保留。
写译后校验脚本比对段落数与占位符
校验不评估翻译质量,只确认结构完整:段落数量是否一致、占位符是否一一对应、是否存在空译文。建议输出一份异常清单,每行包含文件路径、缺失或多余的标记、异常类型。
import re, json, os
MARKER = re.compile(r"\[\[S\d{4}\]\]")
TOKEN = re.compile(r"__T_[A-Z0-9_]+__")
def check(src_text, dst_text, path):
issues = []
sm = MARKER.findall(src_text)
dm = MARKER.findall(dst_text)
if len(sm) != len(dm):
issues.append({"file": path, "type": "marker_count",
"src": len(sm), "dst": len(dm)})
st = sorted(set(TOKEN.findall(src_text)))
dt = sorted(set(TOKEN.findall(dst_text)))
if st != dt:
issues.append({"file": path, "type": "token_mismatch",
"missing": list(set(st) - set(dt))})
for i, seg in enumerate(dst_text.splitlines()):
if MARKER.match(seg.strip()) is None and seg.strip() == "":
issues.append({"file": path, "type": "empty_segment",
"line": i + 1})
return issues
if __name__ == "__main__":
report = []
for rec in map(json.loads, open("./tasks.jsonl", encoding="utf-8")):
if rec["status"] != "done":
continue
src = open(rec["src"], encoding="utf-8").read()
dst = open(rec["dst"], encoding="utf-8").read()
report.extend(check(src, dst, rec["dst"]))
with open("./issues.jsonl", "w", encoding="utf-8") as f:
for r in report:
f.write(json.dumps(r, ensure_ascii=False) + "\n")这份清单只用来决定哪些文件需要人工看或重跑,别拿它当翻译质量结论。空译文检查容易误报列表项和代码块,如果你的文档里有大量短行,先把判断条件调成“连续两行以上为空”再跑。
记录失败片段并单独重跑
超长片段和接口报错不可能靠调大重试次数解决,得先记下来。失败清单建议按 file、marker、reason、retry_count、last_error 五个字段写入 JSONL,每次重跑前先读这个文件,只重试这些 marker 对应的片段。重跑时不要整篇覆盖:把成功片段的译文读进内存,只替换失败 marker 对应的段落,再写回目标文件。
# retry_failed.py 要点
# 1. 读 failed.jsonl,按 file 分组
# 2. 对每个 file,先读已生成的 dst 文件
# 3. 只对 failed 里的 marker 重新翻译
# 4. 用 marker 定位并替换 dst 中的对应段落
# 5. 成功后从 failed 清单移除,追加到 done 清单要避免覆盖已成功结果,有两处要卡住:一是重跑前用 os.path.exists 和 marker 双重确认,不整篇覆盖;二是重跑脚本只在 marker 级别做替换,写回时保留其它段落不动。处理完后建议把 tasks.jsonl 里对应记录的状态改回 done,并更新时间字段,否则下次续跑又会把它扫进来。单条失败片段可以继续沿用同一个术语表,别在重跑时切换术语表版本,否则跨段落的一致性会被自己打乱。