上一课词表把文字变成 token id 喂进模型,模型一路计算(M4a),最后在输出层吐出一排 logits——词表里每个 token 各对应一个原始分数,分数越高代表模型越"看好"它当下一个词。可下一个词只能有一个,怎么从几万个分数里挑出它?这一课讲的采样(sampling),就是"从一排分数到一个 token"的最后一步。
采样远不止"选最大的"那么简单。每次都选最高分,模型会死板、重复、毫无新意;可纯随机又会胡言乱语。真正的采样是一套裁剪 + 塑形 + 抽选的组合拳:先划掉没希望的候选、再调分布的软硬、压一压老重复的词,最后才按概率抽一个。llama.cpp 把这些手段做成一个个可插拔的采样器,串成一条采样链。
先看清采样的输入和输出。输入是一个候选数组:词表里每个 token 一条记录,含 token id、它的 logit(原始分数)、以及待会儿算出来的概率 p。输出是其中一个被选中的 id。采样要做的,就是在这个数组上一通操作,最后挑出一条。
这个候选数组在 llama.cpp 里叫 llama_token_data_array,每条记录是 llama_token_data。整条采样管线,本质上就是不断改写这个数组:有的采样器把某些候选的 logit 砸成负无穷(等于划掉),有的重新算概率、重新排序,最后一步从中选定一个、把它的下标记在数组的 selected 字段上。
于是管线大致长这样:先用惩罚压低重复,再用 top-k/top-p 砍掉长尾候选,接着用温度调节剩下分布的软硬,最后用 dist 按概率抽一个(或 greedy 直接取最大)。每一步只做一件小事,叠起来就是一套完整的采样策略。
要强调的是,这套管线只动 logits/概率、不碰模型本身。模型每步老老实实算出同一排 logits,至于怎么从中选词,全由采样这层说了算。所以"换个生成风格"根本不用动模型,调调采样参数即可——这也是同一个模型能时而严谨、时而天马行空的原因。
不妨把这排 logits 想象成一座高低起伏的山脉:模型越看好的词,峰就越高。采样要做的,就是按这座山的形状来取舍——只在高峰附近选(保守),还是连山脚的小丘也给点机会(发散)。后面的每个采样器,其实都在重塑这座山的轮廓,再决定从哪儿落子。这个画面记住了,后面的 top-k、温度就都好理解了。把这条链路用一个具体例子走一遍就清楚了:
// 简化自 include/llama.h struct llama_sampler_i { const char * (*name) (...); // 名字(可空) void (*accept)(llama_sampler *, llama_token); // 喂回选中 token(可空) void (*apply) (llama_sampler *, llama_token_data_array * cur_p); // 改/排候选(必需) void (*reset)(llama_sampler *); // 清状态(可空) }; struct llama_sampler { const llama_sampler_i * iface; llama_sampler_context_t ctx; };
看看一个采样器到底是什么。llama_sampler_i 就是一组函数指针:apply(核心,改写候选数组)、accept(把选中的 token 喂回来给有状态采样器记账)、reset(清状态),还有 name/clone/free。配上一块状态 ctx,就构成一个 llama_sampler。
这里 apply 是唯一必需的——它拿到候选数组,按自己的规则改一改(划掉一些、重排一下、重算概率)。accept 可空,只有"有记忆"的采样器才用得上:惩罚项要记住前面出过哪些 token、mirostat 要根据反馈调参,它们都靠 accept 把"刚选中的 token"收进自己的状态。
把采样器抽象成统一接口,最大的好处是可组合。既然它们长一个样,就能像积木一样排成一队,挨个作用在同一个候选数组上。下一节的"采样链",就是这种可组合性的直接产物。
顺带说状态 ctx:它是每个采样器私有的小账本。无状态的采样器(如 top_k)ctx 几乎是空的;有状态的(penalties/mirostat/grammar)则把历史、参数、反馈都存在这里。采样器之间互不干扰,各记各的账。
为什么接口里好几个函数都标着"可空"?因为不是每个采样器都用得上每件事。像 top_k 这种纯粹"裁一刀"的,根本不需要记忆,也就不必实现 accept;而 reset 只在复用同一个采样器跑多段生成时才有意义。把这些做成可选,让最简单的采样器可以只写一个 apply,既省事又清晰——接口只要求"必需的那件事",其余按需。
建一条空的采样链(llama_sampler_chain)。
按顺序加入 penalties、top_k、top_p、temp、dist……每个都是独立采样器。
对候选数组按加入顺序逐个 apply,最后一个(dist/greedy)选出 token。
把选中 token 喂回链,让有状态采样器(penalties 等)记住它。
采样链 llama_sampler_chain 本身也是一个采样器——它内部装着一串子采样器,它的 apply 就是按加入顺序把每个子采样器的 apply 挨个跑一遍。这是典型的"组合模式":一条链对外看也是一个采样器,对内是许多采样器的队列。
# 伪代码: 组一条采样链 chain = llama_sampler_chain_init(params) chain.add(llama_sampler_init_penalties(...)) # 压低重复 chain.add(llama_sampler_init_top_k(40)) # 留前 40 chain.add(llama_sampler_init_top_p(0.95, 1)) # 核采样 chain.add(llama_sampler_init_temp(0.8)) # 温度 chain.add(llama_sampler_init_dist(seed)) # 按概率随机选 id = llama_sampler_sample(chain, ctx, -1) # 跑全链 -> 返回 token id
用起来很直观:先 chain_init 建空链,再 chain_add 按想要的顺序把采样器一个个塞进去,最后 llama_sampler_sample 一把梭——它读出某位置的 logits、组成候选数组、跑完整条链、返回选中的 token id,并顺手把这个 token accept 回去。
顺序很重要。同样几个采样器,排列不同,结果可能不同:一般先做惩罚和裁剪(缩小候选集),再做温度(调软硬),最后才是 dist/greedy(真正选定)。把"选定"放最后,是因为前面每一步都在为这"临门一脚"准备一个更合理的候选分布。
这套"链"的设计,本质上是把"采样策略"变成了数据(一串采样器配置),而不是写死的代码。于是用户在命令行/配置里调几个参数,就能拼出千变万化的采样行为,引擎主干一行都不用改——又是一次"会变的部分集中起来"的体现。
举个顺序影响结果的例子。假如你把温度放在 top-p 之前,温度会先把分布整体烫平、再让 top-p 去圈范围,圈出来的核就偏大、偏发散;反过来先 top-p 圈定再升温,则是在一个已经收紧的小集合里调随机性,结果更可控。同样的零件、不同的次序,最终"性格"就有微妙差别——这正是把顺序交给用户配置的价值。
实际项目里,这条链常有个约定俗成的默认顺序。llama.cpp 的上层(common)大致按"惩罚 -> 裁剪(top-k/top-p/min-p 等)-> 温度 -> dist"来排。你不必死记,但记住那个大原则就够了:先缩小候选、再调软硬、最后才抽签。绝大多数采样策略,都是在这条主轴上加加减减。
| 采样器 | 作用 |
|---|---|
| greedy | 选 logit 最大的(argmax,确定) |
| dist | 按概率随机选(靠 seed) |
| top_k | 只留前 k 个候选 |
| top_p | 核采样:留累积概率达 p 的最小集 |
| min_p | 留概率不低于"最大值 × p"的候选 |
| temp | 缩放 logits,调随机性 |
| penalties | 压低重复/高频/已出现的 token |
| mirostat | 动态调温,稳住困惑度 |
来认认常用的几个采样器。它们各管一段:有的负责"裁"(缩小候选集),有的负责"塑"(改分布形状),有的负责"选"(最终拍板)。
再说"裁"的两位主力:top_k 留分数最高的固定 k 个、其余划掉;top_p(核采样)按概率从高到低累加、留到累计达 p 为止——候选数随分布自适应(分布尖时留得少、平时留得多)。两者常配合:先 top_k 砍掉长尾,再 top_p 自适应收口。
"塑"的代表是温度 temp:把 logits 除以一个温度值 T 再 softmax。T 小则分布更尖(更确定),T 大则更平(更随机)。它不改候选集,只改"软硬"。还有 penalties 压低重复、mirostat 动态调温稳住困惑度等,各有专长。
单独说说 penalties 这一类,因为它最贴近日常体验。它盯着最近生成过的 token,对老重复的词施加惩罚(调低 logit),于是模型不容易陷进"复读机"式的循环。常见的有重复惩罚、频率惩罚、存在惩罚几种口味,分别对应"出现过就罚""出现越多越罚""只要出现就一视同仁地罚"。调它们,能在"连贯"和"啰嗦"之间找平衡。
把采样接回主回路:每生成一个 token,llama_decode(L17)算出 logits,采样链从中选一个 id,这个 id 一边经词表(L20)变回文字显示、一边被包成新 batch 喂回 llama_decode 进入下一步。采样就是自回归循环里"挑下一个词"那一环。
还有一个和 grammar(L23)的衔接要先打招呼:语法约束本质上也是一个采样器(它的 apply 把不合语法的 token 划掉)。但它通常不塞进主链,而是作为独立对象、按 grammar_first 决定在链前还是链后单独施加——这一课熟悉了采样器接口,L23 再看 grammar 就水到渠成。
顺带点一个实用细节:要让随机生成可复现,关键在 dist 的那个随机种子 seed。固定 seed、固定采样参数,同一段提示就能跑出完全一样的结果——这在调试、对比实验时极有用。反过来,想要每次都不一样,让 seed 随时间变即可。确定性到底掌握在你手里。
最后澄清一个常见误解:采样调不出模型本来没有的能力。它只能在模型给出的那排 logits 上做文章——好的采样能让一个模型扬长避短(少出昏招、保持多样),但变不出模型压根学不会的知识。所以效果不好时,先分清是"模型不行"还是"采样没调好":前者要换模型/微调(L24),后者调调参数即可。
温度 T 的作用是缩放 logits:把每个分数除以 T,再 softmax 变概率。T=1 是原样;T 小于 1,大分数被进一步放大、小分数被压扁,分布更尖锐,模型更倾向选最可能的词(更确定、更保守);T 大于 1 则把差距拉平,分布更平坦,冷门词也有机会(更随机、更有创意)。
两个极端很有意思:T 趋近 0,分布尖到只剩最大那个,温度采样就退化成 greedy;T 很大时分布趋于均匀,几乎是瞎猜。所以温度是一个连续的"确定 <-> 随机"旋钮,greedy 不过是它的一个极端特例。
要记住温度只改软硬、不改候选集——它不删任何 token,只重新分配大家的概率。删候选是 top-k/top-p 的活。两类操作正交,组合才好用:先用 top-p 圈定合理候选集,再用温度调这个集合内部的随机程度。
top_k 留固定个数:把候选按分数排序,留前 k 个、其余划掉。简单直接,但有个毛病——分布尖时 k 个里混进很多没希望的,分布平时又可能把好候选挡在门外。它不看分布形状,只数个数。
top_p(核采样 nucleus)留累积概率达 p 的最小集合:按概率从高到低累加,加到超过 p 就停。它的候选数是自适应的——分布尖时可能只留两三个,分布平时可能留几十个。这种"按概率密度收口"往往比固定 k 更合理。
实践中常两者叠用:先 top_k(比如 40)砍掉绝大多数长尾、控制开销,再 top_p(比如 0.95)在剩下的里自适应收口。一个管"最多留多少",一个管"按质量留多少",配合起来既快又稳。
因为可组合 + 可配置 + 状态隔离。每个采样器是独立小部件、自带状态(penalties 记历史、mirostat 记反馈、dist 记随机数),顺序可调、增删自由。用户在配置里写一串采样器名字和参数,引擎照单拼出一条链——想要什么策略就拼什么,不必改一行引擎代码。
对比"一个写死的大采样函数":那样每加一种新手段都得改主函数、各种 if 越堆越多,参数也纠缠不清。拆成链之后,新增一种采样器只是多写一个独立实现,对已有的零影响。这正是 L11 算子、L16 建图积木一脉相承的"小部件组合出复杂行为"。
还有个细节:grammar(L23)这种采样器通常不进主链,而是按 grammar_first 在链前或链后单独施加。这说明"链"也不死板——它给特殊约束留了在合适位置插入的余地,足够灵活。
Last lesson the vocab turned text into token ids fed to the model; the model computes all the way through (M4a) and finally, at the output layer, emits a row of logits - one raw score per token in the vocab, a higher score meaning the model "favors" it more as the next word. But the next word can be only one, so how do you pick it from tens of thousands of scores? This lesson's topic, sampling, is that last step "from a row of scores to one token".
Sampling is far more than "pick the max". Always picking the top score makes the model rigid, repetitive, dull; but pure randomness babbles. Real sampling is a combo of prune + shape + draw: first cut hopeless candidates, then tune the distribution's softness, push down words that keep repeating, and only then draw one by probability. llama.cpp makes these means into pluggable samplers, strung into a sampler chain.
First, see sampling's input and output clearly. The input is a candidate array: one record per token in the vocab, holding the token id, its logit (raw score), and a probability p computed later. The output is one selected id among them. What sampling does is work over this array and finally pick one record.
This candidate array is called llama_token_data_array in llama.cpp, each record a llama_token_data. The whole pipeline is essentially repeatedly rewriting this array: some samplers slam certain candidates' logit to negative infinity (a strike-out), some recompute probabilities and re-sort, and the last step selects one, recording its index in the array's selected field.
So the pipeline looks roughly like this: penalties damp repeats first, top-k/top-p chop the long tail, then temperature tunes the softness of what remains, and finally dist draws one by probability (or greedy takes the max). Each step does one small thing; stacked together they are a complete sampling strategy.
Worth stressing: this pipeline touches only logits/probabilities, never the model itself. The model dutifully computes the same row of logits each step; how a word is chosen from them is entirely up to the sampling layer. So "change the generation style" needs no change to the model, just sampling parameters - which is why one model can be rigorous one moment and wildly imaginative the next.
Picture this row of logits as a mountain range of peaks and valleys: the more the model favors a word, the higher its peak. Sampling chooses by the shape of this range - pick only near the high peaks (conservative), or give the foothills a chance too (divergent). Every later sampler is really reshaping this range's outline before deciding where to land. Hold this picture, and top-k and temperature later all become easy. Walking one concrete example through this pipeline makes it click:
// simplified from include/llama.h struct llama_sampler_i { const char * (*name) (...); // name (nullable) void (*accept)(llama_sampler *, llama_token); // feed back chosen token (nullable) void (*apply) (llama_sampler *, llama_token_data_array * cur_p); // edit/rank candidates (required) void (*reset)(llama_sampler *); // clear state (nullable) }; struct llama_sampler { const llama_sampler_i * iface; llama_sampler_context_t ctx; };
See what a sampler actually is. llama_sampler_i is just a set of function pointers: apply (the core, rewrites the candidate array), accept (feeds the chosen token back so stateful samplers can keep tally), reset (clear state), plus name/clone/free. With a state blob ctx, it forms a llama_sampler.
Here apply is the only required one - it takes the candidate array and edits it by its own rule (strike some out, re-rank, recompute probabilities). accept is nullable, needed only by samplers with memory: the penalty must remember which tokens appeared before, mirostat must adjust by feedback; both use accept to take "the just-chosen token" into their state.
Abstracting samplers into a unified interface buys composability above all. Since they look alike, they can line up like blocks, each acting on the same candidate array. Next section's "sampler chain" is the direct product of this composability.
A word on the state ctx: it is each sampler's private ledger. A stateless sampler (like top_k) has an almost-empty ctx; a stateful one (penalties/mirostat/grammar) keeps history, parameters, feedback here. Samplers do not interfere with one another, each keeping its own books.
Why are several interface functions marked "nullable"? Because not every sampler needs everything. A pure "one cut" sampler like top_k needs no memory and thus need not implement accept; reset matters only when reusing one sampler across several generations. Making these optional lets the simplest sampler write just an apply - tidy and clear; the interface demands only "the required thing", the rest on demand.
Build an empty sampler chain (llama_sampler_chain).
Add penalties, top_k, top_p, temp, dist... in order; each is an independent sampler.
apply over the candidate array in add-order; the last (dist/greedy) picks the token.
Feed the chosen token back into the chain so stateful samplers (penalties etc.) remember it.
The sampler chain llama_sampler_chain is itself a sampler - it holds a list of child samplers, and its apply simply runs each child's apply in add-order. This is the classic "composite" pattern: a chain looks like one sampler outside, while inside it is a queue of many.
# pseudocode: build a sampler chain chain = llama_sampler_chain_init(params) chain.add(llama_sampler_init_penalties(...)) # damp repeats chain.add(llama_sampler_init_top_k(40)) # keep top 40 chain.add(llama_sampler_init_top_p(0.95, 1)) # nucleus chain.add(llama_sampler_init_temp(0.8)) # temperature chain.add(llama_sampler_init_dist(seed)) # draw by probability id = llama_sampler_sample(chain, ctx, -1) # run the whole chain -> return a token id
It is straightforward to use: chain_init an empty chain, chain_add samplers in the order you want, and finally llama_sampler_sample does it all - it reads a position's logits, forms the candidate array, runs the whole chain, returns the chosen token id, and conveniently accepts that token back.
Order matters. The same few samplers in a different arrangement can give different results: generally do penalties and pruning first (shrink the candidate set), then temperature (tune softness), and dist/greedy last (the actual selection). Putting "selection" last is because every earlier step is preparing a more reasonable candidate distribution for that "final kick".
This "chain" design essentially turns "the sampling strategy" into data (a list of sampler configs) rather than hardcoded code. So a user tuning a few parameters on the command line / config can assemble endlessly varied sampling behavior with not a line of the engine trunk changed - once more "gather the parts that vary".
An example of order affecting the result. If you put temperature before top-p, temperature first flattens the whole distribution and then top-p draws the range, so the nucleus comes out larger and more divergent; conversely, top-p fencing first then heating tunes randomness within an already-tightened small set, more controllable. Same parts, different order, subtly different "personality" - exactly the value of leaving order to user config.
In real projects this chain often has a conventional default order. llama.cpp's upper layer (common) roughly arranges "penalties -> pruning (top-k/top-p/min-p etc.) -> temperature -> dist". You need not memorize it, but the big principle suffices: shrink candidates first, tune softness next, draw last. The vast majority of sampling strategies are just additions and subtractions along this main axis.
| Sampler | What it does |
|---|---|
| greedy | pick the max logit (argmax, deterministic) |
| dist | draw randomly by probability (via seed) |
| top_k | keep only the top k candidates |
| top_p | nucleus: keep the smallest set with cumulative prob p |
| min_p | keep candidates with prob no less than "max x p" |
| temp | scale logits, tune randomness |
| penalties | damp repeated/frequent/seen tokens |
| mirostat | dynamically tune temperature to hold perplexity |
Meet the common samplers. Each owns a stage: some "prune" (shrink the candidate set), some "shape" (change the distribution's form), some "select" (the final call).
Then the two pruning mainstays: top_k keeps the fixed top k by score and strikes out the rest; top_p (nucleus) accumulates by probability from high to low, keeping until the cumulative reaches p - the candidate count is adaptive (few when the distribution is peaked, many when flat). The two often pair: top_k chops the long tail, then top_p closes adaptively.
The "shape" representative is temperature temp: divide logits by a temperature T then softmax. Small T makes the distribution peakier (more deterministic), large T flatter (more random). It does not change the candidate set, only the softness. There are also penalties to damp repeats, mirostat to dynamically tune temperature and hold perplexity, each with a specialty.
A word on the penalties family, since it is closest to everyday experience. It watches recently generated tokens and penalizes oft-repeated words (lowering their logit), so the model is less likely to fall into a "broken record" loop. Common flavors are repeat, frequency, and presence penalties - "penalize if seen", "penalize more the more it appears", "penalize once seen, flatly". Tuning them balances "coherent" against "verbose".
Connecting sampling back to the main loop: per generated token, llama_decode (L17) computes logits, the chain picks one id from them, and this id is both turned back into text via the vocab (L20) for display and wrapped into a new batch fed back to llama_decode for the next step. Sampling is the "pick the next word" link in the autoregressive loop.
One hand-off with grammar (L23) to flag early: a grammar constraint is essentially a sampler too (its apply strikes out tokens that break the grammar). But it usually does not go into the main chain; it is a separate object applied before or after the chain per grammar_first - having learned the sampler interface here, grammar in L23 will come naturally.
A practical detail in passing: to make random generation reproducible, the key is dist's random seed. Fix the seed and the sampling parameters, and the same prompt runs to identical results - invaluable for debugging and comparison experiments. Conversely, to vary each run, let the seed change with time. Determinism is firmly in your hands.
Finally, clear a common misconception: sampling cannot conjure abilities the model lacks. It can only work on the row of logits the model gives - good sampling lets a model play to its strengths (fewer blunders, kept diversity), but cannot invent knowledge the model never learned. So when results are poor, first tell "the model is weak" from "the sampling is mistuned": the former needs a different model / fine-tuning (L24), the latter just parameter tweaks.
Temperature T scales logits: divide each score by T, then softmax into probabilities. T=1 is as-is; T below 1 amplifies big scores further and squashes small ones, making the distribution peakier, the model leaning toward the most likely word (more deterministic, more conservative); T above 1 flattens the gaps, making it flatter so longshots get a chance (more random, more creative).
The two extremes are interesting: as T approaches 0, the distribution peaks down to just the max, and temperature sampling degenerates into greedy; at very large T the distribution nears uniform, almost blind guessing. So temperature is a continuous "deterministic <-> random" knob, and greedy is merely one extreme special case of it.
Remember temperature only changes softness, not the candidate set - it deletes no token, only redistributes everyone's probability. Deleting candidates is top-k/top-p's job. The two operations are orthogonal and combine well: use top-p to fence a reasonable candidate set, then temperature to tune the randomness within that set.
top_k keeps a fixed count: sort candidates by score, keep the top k, strike out the rest. Simple and direct, but with a flaw - when the distribution is peaked, many hopeless ones sneak into the k, and when it is flat, good candidates may be shut out. It does not look at the distribution's shape, only counts.
top_p (nucleus) keeps the smallest set whose cumulative probability reaches p: accumulate by probability from high to low, stopping once it passes p. Its candidate count is adaptive - maybe just two or three when peaked, dozens when flat. This "closing by probability density" is often more reasonable than a fixed k.
In practice the two are often stacked: top_k (say 40) chops the vast long tail and bounds cost, then top_p (say 0.95) closes adaptively among the rest. One governs "how many at most", the other "how many by quality"; together they are both fast and steady.
Because of composability + configurability + state isolation. Each sampler is an independent small part with its own state (penalties keeps history, mirostat keeps feedback, dist keeps the RNG), with adjustable order and free add/remove. A user writes a list of sampler names and parameters in config, and the engine assembles a chain to order - whatever strategy you want, without changing a line of engine code.
Compare "one hardcoded big sampling function": there every new sampling means edits to the main function, ever more ifs, tangled parameters. Split into a chain, adding a new sampler is just one more independent implementation, with zero impact on the existing ones. This is the same "small parts compose complex behavior" as L11's operators and L16's graph blocks.
One more detail: a sampler like grammar (L23) usually does not enter the main chain but is applied before or after per grammar_first. This shows the "chain" is not rigid either - it leaves room to insert special constraints at the right spot, flexible enough.