Read OSS

Whisper 的 Token 语言:Tiktoken 如何编码文本、时间与任务

中级

前置知识

  • 第 1-2 篇文章
  • 了解 BPE 分词的基本概念

Whisper 的 Token 语言:Tiktoken 如何编码文本、时间与任务

Whisper 的分词器不只是把文本转成 token 那么简单——它是一套完整的协议,负责向模型传达任务指令和时间边界。通过一组精心设计的特殊 token,单个 decoder 便能完成转录、翻译、语言检测,并为每个片段标注精确的时间戳。这一切都由 whisper/tokenizer.py 负责协调——这是一个基于 OpenAI tiktoken 库构建的 395 行模块。

正如我们在架构概览中看到的,分词器位于解码系统与模型之间,定义了 TextDecoder 嵌入层映射到隐藏状态所使用的词表。在读懂第 4 篇的解码逻辑之前,理解 token 结构是必不可少的基础。

双词表与编码初始化

Whisper 以 tiktoken 文件的形式在 whisper/assets/ 中提供了两套 BPE 词表:

  • gpt2.tiktoken — 仅用于英语模型(带 .en 后缀)的 GPT-2 词表,包含约 50,257 个基础 token。
  • multilingual.tiktoken — 用于多语言模型的扩展词表,包含约 50,364 个基础 token。

get_encoding() 函数从这些文件构建 tiktoken Encoding 对象。它使用了 @lru_cache(maxsize=None) 装饰器,确保词表在每个进程中只被加载和解析一次:

@lru_cache(maxsize=None)
def get_encoding(name: str = "gpt2", num_languages: int = 99):
    vocab_path = os.path.join(os.path.dirname(__file__), "assets", f"{name}.tiktoken")
    ranks = {
        base64.b64decode(token): int(rank)
        for token, rank in (line.split() for line in open(vocab_path) if line)
    }
    n_vocab = len(ranks)

加载基础词表之后,函数会以编程方式构建特殊 token 集合——这才是真正有意思的部分。

特殊 Token 的架构设计

特殊 token 在 get_encoding() 中动态构建,其 ID 从基础词表末尾开始依次分配:

specials = [
    "<|endoftext|>",
    "<|startoftranscript|>",
    *[f"<|{lang}|>" for lang in list(LANGUAGES.keys())[:num_languages]],
    "<|translate|>",
    "<|transcribe|>",
    "<|startoflm|>",
    "<|startofprev|>",
    "<|nospeech|>",
    "<|notimestamps|>",
    *[f"<|{i * 0.02:.2f}|>" for i in range(1501)],
]

这一个列表定义了全部特殊 token 词表,其结构如下:

flowchart TD
    A["Base BPE Vocabulary\n~50k tokens"] --> B["<|endoftext|>\n(EOT)"]
    B --> C["<|startoftranscript|>\n(SOT)"]
    C --> D["Language Tokens\n<|en|>, <|zh|>, ... <|yue|>\n(up to 99 languages)"]
    D --> E["Task Tokens\n<|translate|>, <|transcribe|>"]
    E --> F["Control Tokens\n<|startoflm|>, <|startofprev|>\n<|nospeech|>, <|notimestamps|>"]
    F --> G["Timestamp Tokens\n<|0.00|> through <|30.00|>\n(1501 tokens, 0.02s resolution)"]

LANGUAGES 字典将 100 个 ISO 语言代码映射到对应的英文名称,从 "en"(英语)到 "yue"(粤语)。num_languages 参数控制实际包含的语言数量。这一点至关重要,因为不同版本的模型支持的语言数量不同,而词表大小也直接决定了某个模型是否属于 is_multilingual(参见第 1 篇:self.dims.n_vocab >= 51865)。

提示: 第 114-128 行TO_LANGUAGE_CODE 字典收录了一批别名映射,例如 "burmese""my""mandarin""zh""castilian""es"。这样用户就能以自然的语言名称传参,而不必记住 ISO 代码。

时间戳 Token 与片段边界

1501 个时间戳 token 是 Whisper 分词机制中最具特色的设计之一。它们以 20ms 的精度对 30 秒音频块中的时间位置进行编码:

  • <|0.00|> = 音频块起始
  • <|0.02|> = 20ms
  • <|0.04|> = 40ms
  • ...
  • <|30.00|> = 音频块结束

这些 token 在解码时与文本 token 混合出现,由 decoder 作为输出序列的一部分生成,通过相邻的时间戳对来标注片段边界:

sequenceDiagram
    participant D as Decoder Output
    Note over D: <|0.00|>
    Note over D: "Hello world."
    Note over D: <|2.56|>
    Note over D: <|2.56|>
    Note over D: "How are you?"
    Note over D: <|5.12|>
    Note over D: <|endoftext|>

其模式为:<|start_time|> 文本 token <|end_time|> <|start_time|> 文本 token <|end_time|> ...。当两个连续 token 都是时间戳时,它们共同标志一个片段边界——前一个片段的结束与下一个片段的开始。ApplyTimestampRules logit 过滤器(将在第 4 篇详述)负责强制执行这一语法规则:时间戳必须成对出现,必须单调递增,且在解码的第一步只允许生成时间戳 token。

20ms 的精度直接来源于模型的时序结构:每个音频 token 对应 16kHz 下的 320 个采样点,即 20ms(由 audio.py 中的 N_SAMPLES_PER_TOKEN = HOP_LENGTH * 2 推导而来)。因此,每个时间戳 token 都精确对应 encoder 输出序列中的一个位置。

SOT 序列与 Token 排列顺序

位于 第 131-166 行Tokenizer 数据类封装了 tiktoken encoding,并提供了访问特殊 token 的便捷接口。其 __post_init__ 方法会构建 sot_sequence——即告知模型执行何种任务的转录起始 token 序列:

def __post_init__(self):
    # ... populate self.special_tokens dict ...
    sot_sequence = [sot]
    if self.language is not None:
        sot_sequence.append(sot + 1 + langs.index(self.language))
    if self.task is not None:
        task_token = transcribe if self.task == "transcribe" else translate
        sot_sequence.append(task_token)
    self.sot_sequence = tuple(sot_sequence)

对于多语言英语转录任务,生成的序列为:

<|startoftranscript|>  <|en|>  <|transcribe|>

对于西班牙语→英语翻译任务:

<|startoftranscript|>  <|es|>  <|translate|>

输入 decoder 的完整 token 序列如下所示:

sequenceDiagram
    participant Prompt as Previous Context
    participant SOT as SOT Sequence
    participant Content as Generated Content

    Note over Prompt: <|startofprev|>
    Note over Prompt: [previous window tokens]
    Note over SOT: <|startoftranscript|>
    Note over SOT: <|language|>
    Note over SOT: <|task|>
    Note over Content: <|timestamp|>
    Note over Content: [text tokens]
    Note over Content: <|timestamp|>
    Note over Content: <|endoftext|>

<|startofprev|> token 及其前面的 prompt token 提供了上一个窗口的上下文(当启用 condition_on_previous_text 时,详见第 5 篇)。通过 sot_sequence_including_notimestamps 可以将 <|notimestamps|> token 追加到 SOT 序列末尾,从而完全禁用时间戳生成。

语言感知的分词策略

split_to_word_tokens() 方法根据语言类型将请求分发到不同的分词策略:

def split_to_word_tokens(self, tokens: List[int]):
    if self.language in {"zh", "ja", "th", "lo", "my", "yue"}:
        return self.split_tokens_on_unicode(tokens)
    return self.split_tokens_on_spaces(tokens)

对于中文、日语、泰语、老挝语、缅甸语和粤语——这些词语之间没有空格的语言——split_tokens_on_unicode() 方法会在每个合法的 Unicode 码点边界处进行切分。对于欧洲语言及其他以空格分隔的语言,split_tokens_on_spaces() 则会将不以空格开头的 BPE 子词 token 合并在一起,并将标点符号单独作为一个词处理。

这一区别在词级别时间戳(第 6 篇)中尤为重要——每个"词"都需要对应的起止时间。对于日语来说,每个字符本质上就是一个独立的词;而对于英语,["un", "believ", "able"] 这样的多个 BPE token 则需要合并为单个词 "unbelievable"

non_speech_tokens 属性返回生成时应被抑制的 token ID,例如音乐符号(♪♪♪)、说话人标签([DAVID])以及括号注释等。实现上对可能编码为多个 BPE token 的多字节 Unicode 符号做了特殊处理,只抑制此类序列的第一个 token,以防止这些序列被生成出来。

get_tokenizer 工厂函数

顶层的 get_tokenizer() 函数是创建配置好的 tokenizer 的公共 API,同样使用了 @lru_cache,相同参数的重复调用会直接返回已有实例:

@lru_cache(maxsize=None)
def get_tokenizer(
    multilingual: bool,
    *, num_languages: int = 99,
    language: Optional[str] = None,
    task: Optional[str] = None,
) -> Tokenizer:

该函数负责解析语言别名、选择合适的 encoding("multilingual""gpt2"),并对纯英语模型显式设置 language=Nonetask=None,以便在 SOT 序列中省略这些 token。

这里的缓存对性能至关重要:Tokenizer.__post_init__ 需要遍历所有特殊 token 字符串来构建 special_tokens 字典,而 get_encoding() 则要解析整个词表文件,两者都是开销不可忽视的操作,应当只执行一次。

下一步

理解了分词器的词表结构与特殊 token 设计之后,我们就可以深入研究 decoder 如何使用这些 token 了。第 4 篇将聚焦于解码系统——这是整个代码库中架构最为丰富的部分——涵盖 token 选择的 Strategy 模式、职责链式的 LogitFilter 管道,以及让自回归生成高效运行的 KV-cache 实现。