长文档分片翻、短句整段翻、Index-Translate 的输入长度与切分边界

文章导读
长段落翻译出现丢句、错位或整段截断时,先别急着换模型。按顺序量三件事:这段输入在分词器里实际占多少 token、从多长开始输出变坏、分片边界切在句子的什么位置。量完之后,通常短句整段提交、长文档分片提交,切分点尽量落在句末或空行。把分片编号写进输入、把编号还原写进拼接流程,边界问题才可复现、可回退。
📋 目录
  1. 壹 在分词器里数出句子实际占用的 token 数
  2. 贰 用阶梯长度的句子定位输出开始异常的位置
  3. 叁 比较按句号切、按空行切、按 token 上限切三种方式
  4. 肆 处理跨分片的指代与术语
  5. 伍 把拼接顺序和标点还原写成固定流程
A A

长段落翻译出现丢句、错位或整段截断时,先别急着换模型。按顺序量三件事:这段输入在分词器里实际占多少 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 只是把正文量准,真实请求还会带上系统提示、模板符号和分片索引标记,所以判断“能不能整段提交”时要给窗口留出余量,而不是卡到上限。

用阶梯长度的句子定位输出开始异常的位置

构造阶梯长度的输入,从窗口的一个较小比例开始,按固定步长递增,每次只观察可复现的输出特征。

长文档分片翻、短句整段翻、Index-Translate 的输入长度与切分边界
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 上限切可能落在句子中间。对比时逐片记录,不下定论。

长文档分片翻、短句整段翻、Index-Translate 的输入长度与切分边界
  • 按句号切:边界在句末;记录译文完整度、片首片尾是否错位、标点是否重复;适合段落整齐的正文。
  • 按空行切:边界在段落间;记录段落数与原文是否一致、标题是否被并入正文;适合小标题、列表、对话。
  • 按 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⟧ 一类占位符。拼接按固定顺序执行:

  1. 每片译文单独落盘,文件名带索引,便于缺片时补译。
  2. 读取所有分片,按索引排序合并;索引缺号就停下,不要按文件读取顺序硬拼。
  3. 还原结构与术语占位符,再统一处理标点:中文语境下的全角标点、句末标点补全、删除分片边界处多余的空行。
  4. 删除全部索引标记,输出合并后的完整文档。
  • 分片数与译回片数一致,索引无缺号无重号。
  • 每片首尾不残留 [[#n]] 索引标记。
  • 术语占位符全部回填,数量与译前统计一致。
  • 分片边界处无重复句、无半句。
  • 按空行切时,段落数与原文一致;代码块、URL、数字未被翻译。