到这里,M4a 的模型已经能"算"了——加载(L14)、定架构(L15)、建图(L16)、装进上下文(L17)、按批处理(L18)、用 KV cache 高效自回归(L19),最后吐出一排 logits。可有个根本问题一直被绕过去:模型从头到尾只认数字(token id),它根本不认识"你好"这两个字。文本怎么变成数字、数字又怎么变回文本?这一课的主角 llama_vocab(词表),就是文本世界和 token 世界之间唯一的翻译官。
它干两件互逆的事:tokenize 把字符串切成一串 token id 喂进模型;detokenize/token_to_piece 把模型吐出的 token id 还原成文字片段、拼回人能读的句子。一次完整的对话生成,进口要过它(把你的话变成 id),出口也要过它(把模型选出的 id 变成字)。没有这层翻译,"模型只会算数字"和"人类只说文字"这两个世界就永远接不上。
模型内部全是数字。它的输入是一串 token id、输出也是一个 token id 的概率分布;从头到尾,它碰不到、也不需要字符串。词表就横在文本世界和 token 世界的边界上,进出两头都得过它这一关——这是它必须存在的根本原因。
为什么不直接按"字"或"字母"喂模型?因为太细则序列太长(一个汉字若干字节、一句话上千步)、太粗则词表爆炸(穷举所有词不现实)。子词(subword)切分是个折中:常见词整块给一个 id,生僻词拆成几个常见片段。于是词表大小可控(几万),序列长度也合理。
这也解释了为什么"同一句话,不同模型切出的 token 数不一样"。切分规则是模型训练时就定死的、随权重一起存在 GGUF 里(L13 的自描述);用的时候必须用同一套词表,错一套,切出来的 id 就对不上模型学过的东西,输出立刻变成乱码。
换个角度看,词表其实是模型和人之间"约定俗成"的接口。模型在训练时反复见过的每一个片段,都对应词表里的一个 id;它学到的所有规律,都建立在"这些片段"之上。所以词表一旦定下,就等于划定了模型"认得的世界"——它能流利处理的,永远是这张词表切得出来的片段的组合。
也正因如此,词表的好坏会直接影响表现。一套切得好的词表,能让常用表达只占很少的 token,让模型把注意力花在"意思"而不是"拼写"上;切得糙则会把简单的词拆得七零八落,既浪费序列长度、又抬高学习难度。可以说,分词是模型训练和推理共同的起跑线。
不妨用一个数字建立直觉:一个英文单词平均约切成 1.3 个 token,一个汉字常占 1 到 2 个 token,而一段几百字的提示词,往往对应上千个 token。模型的"上下文长度"(L17 的 n_ctx)数的就是 token、不是字符;你能塞进多少对话,最终由分词后的 token 数决定。想明白这点,才能理解为什么"同样几屏文字,有时就超了上下文"。
// 简化自 src/llama-vocab.h struct llama_vocab { uint32_t n_tokens() const; // 词表大小(L15 的 n_vocab 来源) int32_t tokenize(const char * text, ...) const; // 文本 -> token id int32_t token_to_piece(llama_token id, char * buf, ...) const; // id -> 文本片段 private: struct impl; // pimpl: 藏起分词器实现 std::unique_ptr<impl> pimpl; };
llama_vocab 对外是一套统一接口:n_tokens() 给词表大小、tokenize 编码、token_to_piece 解码,还有一堆查询单个 token 属性的方法。但它把"具体用哪种分词算法"的实现细节,全藏在一个私有的 impl 结构里——这就是 pimpl(pointer to implementation)手法。
为什么要 pimpl?因为分词算法五花八门(下一节细讲),每种的内部数据结构、合并规则都不同。把它们统统塞进 impl,对外只露 tokenize/token_to_piece 这层薄薄的接口,于是用的人完全无感:不管底下是 SPM 还是 BPE,调用方式一模一样。换算法、改实现,都不会惊动上层代码。
这和你前面见过的解耦是一个味道:L14 的 loader 把"解析格式"和"使用模型"分开,L17 的 context 把"只读知识"和"会话状态"分开。这里则是把"分词的脏活"和"统一的接口"分开。一道清晰的边界,让复杂性被关在盒子里。
再具体看这套接口的分工。编码这头,tokenize 要处理的细节其实不少:要不要加空格前缀、要不要规范化、遇到连续空白怎么合并——这些都是不同分词器各自的"脾气",但全被收进了 impl。上层只管把字符串递进去、把 id 取出来,完全不必关心底下在折腾什么。
解码那头同样有讲究。token_to_piece 不是简单"查表取字符串",它还要处理特殊 token 该不该显示、字节 token 怎么按 UTF-8 拼、首词要不要补空格这些琐碎规则。把它们也一并封进词表,是为了让"还原文本"在任何模型上都一致正确——你只管循环调用、拼接结果。
| 类型 | 算法 | 代表模型 |
|---|---|---|
| SPM | SentencePiece(字节级 BPE + 字节回退) | LLaMA |
| BPE | 字节级 byte-pair 合并 | GPT-2 / Qwen |
| WPM | WordPiece | BERT |
| UGM | Unigram | T5 |
| RWKV | 贪心匹配 | RWKV |
| PLAMO2 | Aho-Corasick + 动态规划 | PLaMo-2 |
主流分词算法就那么几种,enum llama_vocab_type 把它们一一列出。它们的差别在"怎么把词拆成子词、怎么合并",但对上层都是同一个 tokenize。GGUF 的 tokenizer 元数据(L13)决定这个模型用哪种。
SPM(SentencePiece)是 LLaMA 系的传统,基于字节级 BPE 且自带字节回退;BPE(byte-pair encoding)是 GPT-2 系的字节级合并;WPM(WordPiece)是 BERT 系;UGM(Unigram)是 T5 系;RWKV 用贪心匹配;还有较新的 PLAMO2(Aho-Corasick + 动态规划)。这些缩写背后,是不同的历史生态和语言/效率取舍。
为什么有这么多?因为不同模型家族沿用各自生态的工具链,而每种算法在多语言、代码、压缩率上各有长短。llama.cpp 不强求统一,而是用一套接口(pimpl)把它们都兼容进来——这正是它能跑几十种模型的工程基础之一。
举个直观的例子体会差异。"unhappiness"这个词,BPE 可能切成"un"+"happiness"或"un"+"happy"+"ness",靠的是训练时统计出来的高频合并;Unigram 则从一个大候选集里、按概率挑出最可能的一种切分。两条路线殊途同归,都想用尽量少的片段覆盖尽量多的文本,只是挑片段的"哲学"不同。
对中文这种没有天然空格的语言,分词更见功夫。字节级方案会先把汉字降到 UTF-8 字节再合并,于是不依赖"词的边界"也能工作——这也是为什么一个主要用英文训练的模型,往往也能磕磕绊绊地处理中文:因为最底层它认的是字节,而不是某种语言的"词"。
还有一个常被问到的点:词表该做多大?太小则每个词都得拆成很多片段、序列变长、推理变慢;太大则嵌入表和输出层都跟着膨胀、显存吃紧。所以词表大小是个折中,主流模型大多落在几万到十几万这个区间。它一旦定下,就深深影响着模型的体量与速度——又一次印证"词表和模型是绑在一起的"。
词表里除了普通的"文字片段 token",还有一类特殊 token:BOS(序列开始)、EOS(序列结束)、EOT(一轮结束)、PAD/SEP/UNK 等。它们不对应具体文字,而是控制标记,由访问器 token_bos()/token_eos()/token_eot() 取出。比如模型生成出 EOS,就意味着"我说完了",上层据此停止。
那遇到词表里压根没有的字符怎么办(生僻字、emoji)?靠字节回退(byte fallback):把这个字符按 UTF-8 拆成若干字节,每个字节映射到一个形如 <0xF0> 的字节 token(带 LLAMA_TOKEN_ATTR_BYTE 属性)。于是任何 UTF-8 文本最差也能逐字节编码,永远不会"无法编码"。这一手解决的是经典的 OOV(未登录词)难题。一次往返大致如下:
# 伪代码: tokenize 往返 ids = vocab.tokenize("Hello", add_special=True) # 可自动前置 BOS # ids = [<bos>, 9906, ...] text = "" for id in ids: text += vocab.token_to_piece(id) # 逐 token 还原拼接
把上面这段 "Hello" 真正走一遍,编码就具体了:
tokenize 时可以让它自动前置 BOS(由前面那个 get_add_bos() 标志控制);解码时则逐个 token 调 token_to_piece 把片段拼回去。注意字节 token 还原时要按 UTF-8 把几个字节拼起来才是一个完整字符——这也是为什么解码要逐步累积、而不是"一个 token 一个字"。
特殊 token 之所以重要,是因为它们承载着"文字之外"的结构信息。一段对话里,谁说的、一轮在哪结束、要不要停下,都靠这些标记界定(后面 L22 的对话模板,正是在大量使用它们)。可以把普通 token 看成"内容"、特殊 token 看成"标点和段落标记"——少了后者,模型就分不清对话的骨架。
这里还要点出一个细节:判断"该不该停"靠的不是单一的 EOS,而是一组"生成结束"(EOG)标记。不同模型用的结束标记不一样,有的用 EOS、有的用 EOT、有的两者皆可;词表里用一个专门的判断(是否属于 EOG 集合)来统一处理。上层只要问一句"这个 token 是不是结束符",就能正确收尾,而不必记住每个模型的具体约定。
从 C API 用词表,路径很直白:先 llama_model_get_vocab(model) 从模型拿到词表,再 llama_vocab_n_tokens(vocab) 问大小、llama_tokenize 编码、llama_token_to_piece/llama_detokenize 解码。这些函数都收一个 const llama_vocab *。
为什么要这么大动干戈地改名?因为这些操作本质上是词表的方法,而不是模型的——把它们从 llama_* 统一收进 llama_vocab_*,名实相符,也呼应了内部 llama_vocab 已经独立成型这件事。改名虽然烦,但让 API 更清晰。
把这一课接回主线:你输入的文字,先经 L22 的对话模板拼好格式,再经词表 tokenize 成 id,进模型算出 logits(L17),由采样器(L21)在这张词表的 token 空间里选出下一个 id,最后再经 token_to_piece 变回文字显示给你。词表正是这条回路一进一出的两道门。
一句话或一段提示词。
先拼好格式,再切成 token id 序列。
前向一遍,输出每个 token 一个分数。
在这张词表的 token 空间里挑下一个。
把 id 还原成文字,显示给你。
顺带澄清一个常见疑惑:tokenize 的结果是不是唯一的?对确定的词表和同一套规则,答案是肯定的——同样的输入永远切出同样的 id 序列,这正是编码/解码能可靠往返的前提。采样(L21)带来的随机性,发生在"选下一个 token"那一步,和分词无关;分词本身是完全确定的。
最后留一个串起全局的视角:词表是这套推理系统里少数"人能直接看懂"的部分。权重是一堆浮点、计算图是一串算子,唯独词表,你能把 id 一个个查回文字、亲眼看到模型"读到了什么、想说什么"。调试模型行为时,先把 token 打印出来看看,常常是最快的入手点。
直接原因是历史与生态:LLaMA 系沿用 SentencePiece,GPT 系用字节级 BPE,BERT 系用 WordPiece,T5 系用 Unigram。每个模型家族训练时用什么,推理时就得用什么——词表和权重是配套的,换不得。
更深一层是取舍:不同算法在多语言覆盖、对代码/数字的友好度、压缩率(同样文本切成多少 token)上各有高下。比如字节级 BPE 对任何语言都鲁棒(先降到字节),WordPiece 对英文形态友好。没有银弹,所以百花齐放。
llama.cpp 的态度是全都支持:用 pimpl 把各算法的实现差异藏起来,对上层暴露同一个 tokenize。于是它不挑模型——这正是一个"通用推理引擎"该有的样子,和 L15 表驱动支持多架构是同一种胸怀。
解决 OOV(out-of-vocabulary,未登录词)。任何固定词表都不可能穷尽世界上所有字符(新 emoji、生僻字、各种符号层出不穷)。没有兜底机制的话,遇到没见过的字符就只能丢一个 <UNK>,信息彻底丢失。
字节回退的兜底很优雅:UTF-8 本身就是字节序列,把任意字符拆成 1-4 个字节,每个字节对应一个 <0xXX> token(共 256 个,必然覆盖)。于是"词表外"这个概念被消灭了——再罕见的字符也能被无损编码,只是占的 token 多一点。
代价值得一提:一个生僻字可能占 3-4 个字节 token,比常见字"贵"几倍。所以模型处理大量生僻字/某些语言时,token 消耗会明显偏高——这也是有些语言"显得更费 token"的底层原因之一。
因为词表大小是词表的属性,由 tokenizer 决定,而不是网络结构的属性。L15 的 llama_hparams 描述"网络多少层、多宽、几个头";词表描述"token 空间多大、怎么切分"。两者职责不同,理应分家。
实践中确实有这个坑:L15 特意强调过 n_vocab 不在 hparams 里,权威来源是 llama_vocab::n_tokens()。虽然嵌入层和输出层的形状要用到词表大小(它们的一个维度就是 n_vocab),但这个数的"主人"是词表。
这种"谁的属性归谁管"的划分,让代码各司其职:改词表不动 hparams、改网络结构不动词表。边界清晰,是这套代码能长期维护的隐形功臣——你在 L14(loader vs 模型)、L17(model vs context)已经反复见到同一种纪律。
By now M4a's model can "compute" - loading (L14), architecture (L15), graph-building (L16), context (L17), batching (L18), efficient autoregression via the KV cache (L19), finally emitting a row of logits. But one basic question kept getting skipped: from start to finish the model only knows numbers (token ids); it has no idea what the characters "hi" are. How does text turn into numbers, and numbers back into text? This lesson's star, llama_vocab (the vocabulary), is the sole translator between the text world and the token world.
It does two inverse jobs: tokenize cuts a string into a list of token ids to feed the model; detokenize/token_to_piece turns the token ids the model emits back into text pieces, reassembled into a human-readable sentence. A full chat generation passes through it on the way in (your words become ids) and on the way out (the chosen ids become characters). Without this translation, "the model only does numbers" and "humans only speak text" never connect.
Inside, the model is all numbers. Its input is a list of token ids and its output is a probability distribution over a token id; from end to end it never touches, and never needs, strings. The vocabulary sits exactly on the border between the text world and the token world, and both directions must pass through it - that is why it must exist.
Why not feed the model by "character" or "letter" directly? Too fine and the sequence is too long (one CJK glyph is several bytes, one sentence thousands of steps); too coarse and the vocab explodes (enumerating all words is hopeless). Subword splitting is the compromise: common words get one id whole, rare words split into a few common pieces. So the vocab size stays manageable (tens of thousands) and the sequence length stays reasonable.
This also explains why "the same sentence, split by different models, yields a different token count". The splitting rules are fixed at training time and stored with the weights in GGUF (L13's self-description); at use time you must use the same vocab - the wrong one and the ids no longer match what the model learned, and the output instantly turns to garbage.
From another angle, the vocabulary is really an agreed-upon interface between the model and people. Every piece the model saw repeatedly during training maps to one id in the vocab; all the patterns it learned are built on "those pieces". So once the vocab is fixed, it delimits the model's "known world" - what it handles fluently is always combinations of pieces this vocab can produce.
For that reason the vocab's quality directly affects performance. A well-cut vocab lets common expressions take very few tokens, letting the model spend attention on "meaning" rather than "spelling"; a crude one shatters simple words into fragments, wasting sequence length and raising the learning difficulty. Tokenization is, in a sense, the shared starting line of both training and inference.
Build intuition with a number: an English word averages about 1.3 tokens, a CJK glyph often takes 1 to 2 tokens, and a prompt of a few hundred characters often maps to over a thousand tokens. The model's "context length" (L17's n_ctx) counts tokens, not characters; how much conversation you can fit is ultimately decided by the post-tokenization token count. Grasp this and you see why "the same few screens of text sometimes overflows the context".
// simplified from src/llama-vocab.h struct llama_vocab { uint32_t n_tokens() const; // vocab size (source of L15's n_vocab) int32_t tokenize(const char * text, ...) const; // text -> token id int32_t token_to_piece(llama_token id, char * buf, ...) const; // id -> text piece private: struct impl; // pimpl: hides the tokenizer internals std::unique_ptr<impl> pimpl; };
Outwardly llama_vocab is one unified interface: n_tokens() gives the vocab size, tokenize encodes, token_to_piece decodes, plus a batch of methods to query a single token's attributes. But it hides all the "which tokenizer algorithm exactly" implementation detail inside a private impl struct - this is the pimpl (pointer to implementation) idiom.
Why pimpl? Because tokenizer algorithms vary wildly (next section), each with different internal data structures and merge rules. Stuffing them all into impl and exposing only the thin tokenize/token_to_piece interface means the caller feels nothing: whether SPM or BPE underneath, the call is identical. Swapping algorithms or changing internals never disturbs the upper code.
This is the same flavor of decoupling you have seen before: L14's loader splits "parse the format" from "use the model", L17's context splits "read-only knowledge" from "session state". Here it splits "the dirty work of tokenizing" from "the unified interface". A clear boundary keeps the complexity locked in a box.
Look more concretely at this interface's division of labor. On the encoding side, tokenize handles quite a few details: whether to add a space prefix, whether to normalize, how to merge consecutive whitespace - each tokenizer's own "temperament", all gathered into impl. The upper layer just hands in a string and takes out ids, never minding what churns below.
The decoding side is equally subtle. token_to_piece is not a plain "look up a string"; it also handles whether a special token should show, how byte tokens join by UTF-8, whether the first word needs a leading space. Sealing these into the vocab too keeps "restoring text" consistently correct across any model - you just call in a loop and concatenate.
| Type | Algorithm | Example model |
|---|---|---|
| SPM | SentencePiece (byte-level BPE + byte fallback) | LLaMA |
| BPE | byte-level byte-pair merges | GPT-2 / Qwen |
| WPM | WordPiece | BERT |
| UGM | Unigram | T5 |
| RWKV | greedy matching | RWKV |
| PLAMO2 | Aho-Corasick + dynamic programming | PLaMo-2 |
There are only a handful of mainstream tokenizer algorithms, and enum llama_vocab_type lists them out. They differ in "how to split a word into subwords and how to merge", but to the upper layer they are all the same tokenize. GGUF's tokenizer metadata (L13) decides which one this model uses.
SPM (SentencePiece) is the LLaMA-family tradition, based on byte-level BPE with built-in byte fallback; BPE (byte-pair encoding) is the GPT-2-family byte-level merging; WPM (WordPiece) is the BERT family; UGM (Unigram) is the T5 family; RWKV uses greedy matching; and the newer PLAMO2 (Aho-Corasick + dynamic programming). Behind these abbreviations lie different historical ecosystems and language/efficiency trade-offs.
Why so many? Because different model families inherit their own ecosystem's toolchain, and each algorithm has strengths and weaknesses across multilingual coverage, code, and compression rate. llama.cpp does not force uniformity; it makes them all compatible behind one interface (pimpl) - one of the engineering foundations for running dozens of models.
A concrete example brings out the difference. The word "unhappiness", BPE might cut into "un"+"happiness" or "un"+"happy"+"ness", relying on high-frequency merges counted at training time; Unigram instead picks, from a large candidate set, the most probable single split by probability. The two routes converge - both want to cover the most text with the fewest pieces - they just differ in the "philosophy" of choosing pieces.
For a language like Chinese with no natural spaces, tokenization shows its craft. Byte-level schemes first drop a glyph to UTF-8 bytes and then merge, so they work without relying on "word boundaries" - which is also why a model trained mostly on English can often stumble through Chinese: at the bottom it knows bytes, not any language's "words".
Another frequently asked point: how big should the vocab be? Too small and every word splits into many pieces, lengthening sequences and slowing inference; too large and the embedding table and output layer bloat with it, straining VRAM. So vocab size is a compromise, and mainstream models mostly land between tens of thousands and a hundred-odd thousand. Once fixed, it deeply shapes the model's size and speed - once more proof that "the vocab and the model are bound together".
Beyond ordinary "text-piece tokens", the vocab has a class of special tokens: BOS (begin sequence), EOS (end sequence), EOT (end of turn), PAD/SEP/UNK, etc. They map to no specific text but are control markers, fetched by accessors token_bos()/token_eos()/token_eot(). For example, when the model emits EOS it means "I am done", and the upper layer stops accordingly.
So what about a character the vocab simply does not have (rare glyphs, emoji)? Byte fallback: split the character into its UTF-8 bytes and map each byte to a byte token shaped like <0xF0> (carrying the LLAMA_TOKEN_ATTR_BYTE attribute). So any UTF-8 text can, worst case, be encoded byte by byte, and is never "unencodable". This solves the classic OOV (out-of-vocabulary) problem. A round-trip looks roughly like:
# pseudocode: a tokenize round-trip ids = vocab.tokenize("Hello", add_special=True) # may auto-prepend BOS # ids = [<bos>, 9906, ...] text = "" for id in ids: text += vocab.token_to_piece(id) # rebuild piece by piece
Walk that "Hello" through it for real and encoding gets concrete:
tokenize can auto-prepend BOS (controlled by that get_add_bos() flag); decoding then calls token_to_piece per token to stitch pieces back. Note that restoring byte tokens means joining several bytes by UTF-8 to form one complete character - which is why decoding accumulates step by step, not "one token, one character".
Special tokens matter because they carry structural information "beyond the text". In a conversation, who spoke, where a turn ends, whether to stop - all are delimited by these markers (L22's chat template, a later lesson, uses them heavily). Think of ordinary tokens as "content" and special tokens as "punctuation and paragraph marks" - without the latter, the model cannot tell the conversation's skeleton.
One more detail to call out: deciding "whether to stop" relies not on a single EOS but on a set of "end-of-generation" (EOG) markers. Different models use different end markers - some EOS, some EOT, some either; the vocab handles them uniformly with a dedicated test (whether a token belongs to the EOG set). The upper layer need only ask "is this token a terminator", finishing correctly without memorizing each model's specific convention.
Using the vocab from the C API is straightforward: first llama_model_get_vocab(model) to get the vocab from the model, then llama_vocab_n_tokens(vocab) for the size, llama_tokenize to encode, llama_token_to_piece/llama_detokenize to decode. These all take a const llama_vocab *.
Why such a sweeping rename? Because these operations are essentially vocabulary methods, not model ones - folding them from llama_* into llama_vocab_* makes name match substance, echoing how llama_vocab has internally become its own thing. Renames are annoying but make the API clearer.
Connecting this lesson back to the main line: your input text is first formatted by L22's chat template, then tokenized into ids by the vocab, enters the model to compute logits (L17), the sampler (L21) picks the next id in this vocabulary's token space, and finally token_to_piece turns it back into text shown to you. The vocab is exactly the two gates, in and out, of this loop.
A sentence or a prompt.
First format it, then cut into a token id sequence.
One forward pass, one score per token.
Pick the next one in this vocab's token space.
Turn the id back into text, shown to you.
A common doubt worth clearing: is tokenize's result unique? For a fixed vocab and the same rules, yes - the same input always cuts into the same id sequence, which is exactly the premise that encoding/decoding round-trips reliably. The randomness sampling (L21) brings happens at the "pick the next token" step, unrelated to tokenization; tokenization itself is fully deterministic.
One last whole-picture view: the vocab is one of the few parts of this inference system "a human can read directly". Weights are a pile of floats, the compute graph a chain of operators; only the vocab lets you look ids back into text and see with your own eyes "what the model read, what it wants to say". When debugging model behavior, printing the tokens first is often the fastest way in.
The direct reason is history and ecosystem: the LLaMA family inherits SentencePiece, the GPT family uses byte-level BPE, the BERT family uses WordPiece, the T5 family uses Unigram. Whatever a model family trains with, it must infer with - vocab and weights are a matched set, not swappable.
One layer deeper is trade-offs: algorithms differ in multilingual coverage, friendliness to code/numbers, and compression rate (how many tokens the same text becomes). Byte-level BPE is robust for any language (it drops to bytes first); WordPiece is friendly to English morphology. No silver bullet, hence the variety.
llama.cpp's stance is support them all: hide each algorithm's implementation difference behind pimpl and expose the same tokenize upward. So it is not picky about models - exactly what a "general inference engine" should be, the same breadth as L15's table-driven multi-architecture support.
It solves OOV (out-of-vocabulary). No fixed vocab can exhaust every character in the world (new emoji, rare glyphs, all kinds of symbols keep appearing). Without a fallback, an unseen character can only yield a single <UNK>, losing the information entirely.
Byte fallback's safety net is elegant: UTF-8 is itself a byte sequence, so split any character into 1-4 bytes, each mapping to a <0xXX> token (256 of them, guaranteed to cover). The concept of "out of vocab" is thus abolished - even the rarest character is encoded losslessly, just at a few more tokens.
The cost is worth noting: a rare glyph may take 3-4 byte tokens, several times "pricier" than a common one. So a model processing lots of rare glyphs / certain languages spends noticeably more tokens - one underlying reason some languages "seem more token-hungry".
Because the vocab size is a property of the vocabulary, decided by the tokenizer, not a property of the network structure. L15's llama_hparams describes "how many layers, how wide, how many heads"; the vocab describes "how big the token space is, how to split". Different responsibilities, rightly separated.
In practice this is a real trap: L15 deliberately stressed that n_vocab is not in hparams, the authoritative source being llama_vocab::n_tokens(). Although the embedding and output layers' shapes use the vocab size (one of their dimensions is n_vocab), that number's "owner" is the vocab.
This "whose property, whose responsibility" division lets the code stay clean: changing the vocab does not touch hparams, changing the network does not touch the vocab. Clear boundaries are the invisible hero of long-term maintainability - the same discipline you saw again and again in L14 (loader vs model) and L17 (model vs context).