长段落翻译出现丢句、错位或整段截断时,先别急着换模型。按顺序量三件事:这段输入在分词器里实际占多少 token、从多长开始输出变坏、分片边界切在句子的什么位置。量完之后,通常短句整段提交、长文档分片提交,切分点尽量落在句末或空行。把分片编号写进输入、把编号还原写进拼接流程,边界问题才可复现、可回退。
用与线上同一个分词器统计输入 token 数,再用阶梯长度的文本找出译文开始异常的长度区间;短句整段翻,长段落按句或按空行切,token 上限切分作为兜底。分片时写入索引标记,拼接时按索引排序,再还原术语占位符与标点。切分方式是否合适,用译文完整度、错位、重复三项自查;窗口上限与模板符号占用需要结合所用模型和服务端配置确认。
在分词器里数出句子实际占用的 token 数
字符数和 token 数不是一回事,中英文的差距在同一个分词器下对比才有意义。下面骨架把 tokenizer 换成你实际使用的那一个,先只统计正文,不带模板特殊符号。
from your_tokenizer import load_tokenizer # 替换为实际使用的分词器
tok = load_tokenizer("MODEL_NAME")
def count_tokens(text):
return len(tok.encode(text, add_special_tokens=False))
samples = [
("zh", "长文档分片翻译时,句末切分点更容易对齐。"),
("en", "Sentence boundaries are easier to align when splitting long documents."),
]
for lang, s in samples:
print(lang, "chars=", len(s), "tokens=", count_tokens(s))
字符数接近的中英文样本,token 数可能差出一段距离,差距大小取决于词表,必须用同一分词器实测。add_special_tokens=False 只是把正文量准,真实请求还会带上系统提示、模板符号和分片索引标记,所以判断“能不能整段提交”时要给窗口留出余量,而不是卡到上限。
用阶梯长度的句子定位输出开始异常的位置
构造阶梯长度的输入,从窗口的一个较小比例开始,按固定步长递增,每次只观察可复现的输出特征。
SENT = "用于测试的句子。"
def ladder(max_tokens, step, unit):
n, text = step, ""
while n <= max_tokens:
while count_tokens(text) < n:
text += unit
yield n, text
n += step
for n, text in ladder(max_tokens=WINDOW, step=WINDOW // 8, unit=SENT):
resp = translate(text) # 替换为你的调用
log(n, count_tokens(text), resp)
重点看四类特征:尾部缺失(译文最后一句对不上原文最后一句)、重复(同一句在译文里出现两次)、语言未转换(整段或片段仍为源语言)、报错或空返回。记录表建议包含这些字段:分片序号、输入字符数、输入 token 数、源语言、输出字符数、尾部是否完整、是否重复、是否保持源语言、是否报错、切分边界落在句末还是句中、备注。异常起点不是一个精确数字,而是一段长度区间,后续切分上限取区间下沿更稳。
比较按句号切、按空行切、按 token 上限切三种方式
import re
def split_by_sentence(text, limit):
parts = re.split(r"(?<=[。!?!?])", text)
out, buf = [], ""
for p in parts:
if count_tokens(buf + p) > limit and buf:
out.append(buf); buf = p
else:
buf += p
if buf: out.append(buf)
return out
def split_by_blank(text):
return [p for p in text.split("\n\n") if p.strip()]
def split_by_tokens(text, limit):
out, buf = [], ""
for ch in text:
if count_tokens(buf + ch) > limit and buf:
out.append(buf); buf = ch
else:
buf += ch
if buf: out.append(buf)
return out
三种方式的差别主要在边界落点:句号切把边界放在句末,空行切把边界放在段落之间,token 上限切可能落在句子中间。对比时逐片记录,不下定论。
- 按句号切:边界在句末;记录译文完整度、片首片尾是否错位、标点是否重复;适合段落整齐的正文。
- 按空行切:边界在段落间;记录段落数与原文是否一致、标题是否被并入正文;适合小标题、列表、对话。
- 按 token 上限切:边界可能落在句中;记录半句数量、代词被截断次数;只作为超长单句和无标点文本的兜底。
处理跨分片的指代与术语
分片边界最容易伤到代词和专有名词。两种做法可以并行:把上一片末尾带进下一片作为重叠窗口;把术语在译前替换成稳定标记,译后回填。
def window_split(chunks, overlap=1):
out = []
for i, c in enumerate(chunks):
head = chunks[i-1][-1] if i > 0 and overlap else ""
out.append((i, head + c))
return out
TERMS = {"Kubernetes": "⟦T1⟧", "灰度发布": "⟦T2⟧"}
def mask(text):
for k, v in TERMS.items():
text = text.replace(k, v)
return text
def unmask(text, target):
for k, v in TERMS.items():
text = text.replace(v, target[k])
return text
重叠窗口的验证方式:比较重叠区译文与非重叠区同一句译文,一致就只保留一份,不一致就记片号待人工确认,不强行去重。术语替换的验证方式:译前统计每个标记出现次数,译后重新统计,数量一致才回填目标语言术语;标记被改写或吞掉时记录片号,回退到原词译法。
把拼接顺序和标点还原写成固定流程
分片时给每片写入索引标记,形如 [[#12]],放在片首并要求原样保留;结构标记用 ⟦NL⟧ 表示换行、⟦CODE⟧…⟦/CODE⟧ 表示代码块,术语用 ⟦T1⟧ 一类占位符。拼接按固定顺序执行:
- 每片译文单独落盘,文件名带索引,便于缺片时补译。
- 读取所有分片,按索引排序合并;索引缺号就停下,不要按文件读取顺序硬拼。
- 还原结构与术语占位符,再统一处理标点:中文语境下的全角标点、句末标点补全、删除分片边界处多余的空行。
- 删除全部索引标记,输出合并后的完整文档。
- 分片数与译回片数一致,索引无缺号无重号。
- 每片首尾不残留 [[#n]] 索引标记。
- 术语占位符全部回填,数量与译前统计一致。
- 分片边界处无重复句、无半句。
- 按空行切时,段落数与原文一致;代码块、URL、数字未被翻译。