Skip to content

Tokenizers — the sixth registry axis

Text production (and, for AR families, prompt encoding) is a pluggable axis: a Tokenizer implementation is selected by the checkpoint converter's TokenizerSpec, never by engine-side path sniffing.

class Tokenizer(ABC):
    vocab_size: int
    special_ids: FrozenSet[int]          # stripped from decode output

    def decode(self, ids: Sequence[int]) -> str: ...
    def encode(self, text: str) -> List[int]: ...   # AED prompts / LLM / hotwords

A TokenizerSpec is kind + asset files + options; build_tokenizer(spec) resolves the kind through the registry (register_tokenizer(kind, factory)).

Built-in kinds

Kind Assets Used by Behaviour
symbol_table units.txt / tokens.txt WeNet CTC, icefall CTC id→piece join, ▁ = word boundary; default special_ids={0,1,2} — bit-compatible with the legacy Detokenizer
sentencepiece bpe.model icefall BPE ids == SentencePiece piece ids (oasr-server[tokenizers] extra)
huggingface tokenizer.json (+ tokenizer_config.json) Qwen2-Audio (speech-LLM) wraps the tokenizers fast runtime; see the added-token merge below
whisper tokenizer.json HF Whisper HF fast tokenizer + control-token stripping above eot_id
funasr_char tokens.json Paraformer ports FunASR's sentence_postprocess (CJK join, @@ subword merge, abbreviation join)

The tokenizer_config.json added-token merge (Qwen2-Audio trap)

Some HF checkpoints declare added special tokens only in tokenizer_config.json: added_tokens_decoder — Qwen2-Audio's audio and timestamp specials (3288 entries, ids 151643+) are absent from tokenizer.json. transformers merges them at load; the raw tokenizers runtime does not, and prompt encoding silently breaks (<|audio_bos|> BPE-splits into plain text). When the spec carries a tokenizer_config file, HuggingFaceTokenizer adds the missing entries the same way: in declared-id order (relying on the backend's sequential id assignment), with a loud warning if any id lands elsewhere.

Relationship to the engine

oasr/engine/decode/detokenize.py::Detokenizer is a thin adapter over this axis kept for backward compatibility: the engine injects the spec-built tokenizer when the checkpoint provides one; the legacy sniffed unit_table / sentencepiece_model paths build the same symbol_table tokenizer and remain decode-for-decode identical to the historical behaviour. Decode strategies reach the tokenizer via detok.tokenizer (the llm strategy requires encode for its chat template).

Per-request n-best transcripts (DecodingOptions.n_best / RequestOutput.nbest_texts) are detokenized through the same tokenizer by the executors on final outputs.

Which direction each kind supports

decode is universal; encode is not. A symbol_table tokenizer holds only an id→piece map, so it can render output but cannot turn a prompt back into ids — which is why Tokenizer.supports_encode exists. Test that, never hasattr(tok, "encode"): encode is abstract on the ABC, so the attribute is always present even on the kinds that raise from it.

kind supports_encode special_ids reports
symbol_table ✗ (decode-only) {0, 1, 2}
sentencepiece ✓ {0, 1, 2}
huggingface ✓ spec-provided (stripping delegates to skip_special_tokens)
whisper ✓ the whole control block, eot_id … vocab_size
funasr_char ✓ spec-provided

special_ids means exactly what decode strips, so filtering a hypothesis by it yields the tokens decode would keep. Whisper's decode drops every id at or above eot_id (language / task / timestamp markers), so it reports that whole range — reporting only {eot_id} would let a caller leak control markup.

Two decode variants, one contract

Beyond decode(ids) -> str, two methods answer the same question in pieces, and both are contractually required to concatenate back to it:

method who asks contract
decode_incremental(new_ids, state) streaming and AR partials, one chunk at a time state["text"] equals decode(all_ids), and so does concatenating every delta, except for a trailing incomplete character (below)
token_pieces(ids) word timings, once per finished hypothesis "".join(token_pieces(ids)) == decode(ids)

Both have a correct default on the ABC and exist to be overridden for speed, not for behaviour. The defaults re-render the accumulated prefix, so they are Θ(n²) in tokens. symbol_table overrides both: its rendering is piece-local, and token_pieces is C++ (csrc/tokenizers/symbol_table.cc) — with no Python twin, rather than rendering the same table a second way on the request path. Its only caller is the word grouping, which is C++ too, and both ship in every successful build. huggingface and sentencepiece share WindowedIncrementalTokenizer, which re-renders a bounded window of recent ids instead of the prefix, so a partial costs the same at any transcript length. funasr_char (@@ merges) is correct on the default and simply pays for it.

The window has one thing to get right for SentencePiece that it does not for byte-level BPE: SentencePiece drops the ▁ of the first visible piece of whatever it decodes, and stops only once a visible, non-byte piece has rendered. That is state from the start of the text, not from the window, so once the real text has passed that point the window is decoded behind a primer piece and the primer's text cut off again. Which ids end the rule is asked of SentencePiece per id rather than inferred from the piece types.

A byte-level tokenizer (huggingface, so Whisper and Qwen, or a SentencePiece model with byte fallback) splits a CJK character or an emoji across tokens, and the first ones render as U+FFFD until the last completes it. decode_incremental holds such a tail back from the delta, as HF's TextIteratorStreamer holds it back from a stream, so the completing token's piece is the character; state["text"] keeps it, and token_pieces flushes it onto the last token when a sequence ends mid-character. Diffing the U+FFFD in made the pieces carry it as a "word" and repeat the transcript so far.

decode deliberately stays in Python for every kind. It is the same handful of operations as token_pieces, but it produces the transcript itself and runs on every streaming partial: a second implementation there would be a correctness surface, where token_pieces only has to concatenate back to it — a property one test can check exhaustively.

The reason to care: token_pieces is what lets the word grouping cut words out of the rendered transcript rather than rebuild them from pieces, so it runs on the decode path for every request that asks for word timestamps. See docs/decoding.md § Word timings.