前四部分,我们把一个 .gguf 文件怎么被加载、怎么建图、怎么推理、怎么采样和约束,整条内部机器拆了个遍(L14-L24)。可这些机器,外面的人到底要怎么驱动它?答案是 include/llama.h 里那套 C 函数——它是 llama.cpp 的总开关:llama-cli、llama-server,以及 Python / Go / Rust / Node 各种语言绑定,全都通过这同一套稳定的 C 接口去加载模型、喂入 token、取出结果。
从这一课起我们换个视角:前四部分讲的是"里面怎么转",从现在开始,我们沿着"外面怎么用"这条线,把整个项目重新串一遍。这一课先认识这套 C API 的三块基石——opaque 句柄(你只拿到指针,看不到内部)、典型调用序列(从初始化到释放的固定套路),以及 C++ 那层 RAII 包装(自动帮你释放句柄)。这三块东西看似零散,其实环环相扣:句柄是你操作的对象,调用序列是你操作的顺序,RAII 包装则替你收尾。把它们连起来,你就拿到了读懂任何上层代码的一把钥匙。
这套 C API 不会把模型的内部结构体摊开给你,而是只递给你几个 opaque 句柄(不透明句柄):你拿到的是一个指针,它指向的内容由库内部掌管,你看不到、也不该去碰它的字段。这种"只给指针、藏起实现"的好处后面专门讲,先认认这几个最常打交道的句柄。这么设计不是为难你:把字段藏起来,库才能在不惊动调用方的前提下随意调整内部布局,你也不会因为手一抖改了某个本不该碰的字段而把状态搞乱。你要做的,只是把句柄当成一张"取货凭证"——拿着它去调对应的函数,至于货架后面怎么摆,完全不用操心。
这里 llama_model 装的是只读的知识:模型权重一旦加载就不再改变(呼应 L14 的加载、L17 的只读共享),因此它能被多个会话安全地共用同一份——加载一次,到处推理,显存只占一份。这一点在服务端尤其值钱:一台机器上同时来了几十个请求,它们可以共享同一个 llama_model,各自只开一个轻量的 llama_context,而不必把几 GB 的权重复制几十份。把"重而不变"的权重和"轻而多变"的会话状态拆开,正是这套 API 省内存、能并发的关键。
而 llama_context 是每个会话各自的状态:它装着 KV cache、计算资源、logits 缓冲(呼应 L17 的上下文、L19 的 KV cache),一个会话开一个,彼此互不干扰。词表 llama_vocab 经 llama_model_get_vocab 从模型里取出(L20),采样器 llama_sampler 则是 L21 那条采样链。值得留意 llama_vocab 这个句柄:它从属于 model(毕竟分词规则是模型自带的),所以不用单独加载、也不用单独释放,取个指针来用即可。这种"从一个句柄派生出另一个句柄"的关系在 C API 里很常见,理清谁拥有谁,释放时才不会出错。
模型权重与元数据,加载后不再改变。可被多个 context 共享:加载一次、多处推理、省内存。
一个会话的私有状态:KV cache、计算缓冲、采样位置。一个会话一个,开销随上下文长度增长。
句柄是你拿到的,自然也得由你负责释放。在纯 C 里,这意味着手动调用对应的 _free:llama_free 放掉 context、llama_model_free 放掉 model、llama_backend_free 收尾全局后端,而且顺序不能乱(先放依赖方、再放被依赖方):context 是从 model 建出来的、依赖 model,所以必须先释放 context,再释放 model;反过来先放 model,context 就成了悬空指针。漏放会内存泄漏,乱放会崩溃——纯 C 的世界里,这些都得你自己盯着。
// C: 你拿到每个句柄, 也得自己释放 (include/llama.h) llama_model * model = llama_model_load_from_file(path, mparams); llama_context * ctx = llama_init_from_model(model, cparams); // ... 使用 ... llama_free(ctx); // 先放 context llama_model_free(model); // 再放 model // C++: include/llama-cpp.h 用 unique_ptr 包住句柄, 出作用域自动释放 llama_model_ptr model(llama_model_load_from_file(path, mparams)); llama_context_ptr ctx(llama_init_from_model(model.get(), cparams)); // ... 使用 ... 作用域结束自动调用 llama_free / llama_model_free
C++ 用户有更省心的选择。include/llama-cpp.h 这个仅 30 行左右的小头文件,给每个句柄定义了一个 std::unique_ptr 别名——llama_model_ptr、llama_context_ptr、llama_sampler_ptr、llama_adapter_lora_ptr,各自带一个会调用匹配 _free 的删除器。句柄一出作用域就自动释放,再不怕漏掉哪个、也不怕释放顺序写反。把句柄按"先建后毁"的栈顺序声明,析构就会自动按相反顺序进行——刚好满足前面说的"先放 context 再放 model"。等于把容易出错的手动管理交给编译器去保证,这正是 C++ RAII 的拿手好戏。
认识了句柄,再看它们怎么串起来用。几乎所有用 llama.cpp 的程序,骨架都是同一条流水线:先初始化后端,加载模型,建上下文,取词表,把文字分词,喂进去解码,拿到 logits,采样出一个 token,转回文字,循环,最后逐个释放。无论是几十行的最小示例,还是 llama-server 这种成熟服务,骨架都跳不出这条线;区别只在于服务端会把"加载"和"循环"拆到不同线程、再加上缓存与并发调度而已。先把这条主干刻进脑子,再看任何上层代码都不会迷路。
开头几步是一次性的准备:llama_backend_init 起全局后端;llama_model_load_from_file 按路径加载模型(如果模型拆成了多个分片文件,改用 llama_model_load_from_splits);llama_init_from_model 基于模型建出一个 context;llama_model_get_vocab 取出词表备用。这几步里,参数对象 mparams、cparams 决定了很多关键设置:模型参数里有要不要 mmap、放多少层到 GPU(呼应 L07 的 -ngl);上下文参数里有 n_ctx(上下文窗口多大)、n_batch(一批最多喂多少)等。换句话说,命令行上那些选项,最后都会变成这两个结构体里的字段传进来。
# 伪代码: 一次完整的 C-API 生成循环 (简化自 include/llama.h) llama_backend_init(); # 全局后端初始化 model = llama_model_load_from_file(path, mparams); # 多分片 -> llama_model_load_from_splits ctx = llama_init_from_model(model, cparams); # 旧名 llama_new_context_with_model (DEPRECATED) vocab = llama_model_get_vocab(model); # 只读词表 smpl = llama_sampler_chain_init(sparams); # 建采样链, 再 add top_k/top_p/temp/dist n = llama_tokenize(vocab, prompt, tokens, ...); # 文字 -> token id batch = llama_batch_get_one(tokens, n); # 最简单的单序列 batch while (more) { llama_decode(ctx, batch); # 跑一遍计算图 logits = llama_get_logits(ctx); # 每个词表 token 一个分数 id = llama_sampler_sample(smpl, ctx, -1); # 采样链选出下一个 token piece = llama_token_to_piece(vocab, id, ...); # token -> 文字片段 batch = llama_batch_get_one(&id, 1); # 把它喂回去, 继续循环 } llama_free(ctx); llama_model_free(model); llama_backend_free(); # 逐个释放
然后进入自回归主循环:llama_decode 跑一遍计算图,llama_get_logits 取出这一步每个 token 的分数,采样链经 llama_sampler_sample 挑出下一个 token,llama_token_to_piece 把它转回文字片段,再用 llama_batch_get_one 喂回去解码下一步。循环结束后,按 context -> model -> backend 的顺序逐个释放。这里能再次看到句柄拆分的意义:循环每转一圈,model 都纹丝不动,真正在变的只是 context 里的 KV 缓存和位置计数器——这也正是"重而不变的权重"和"轻而多变的状态"分家的好处。
光看流程图还不够具体。我们用一个最小的例子——给模型喂一个 "Hi"——把整条链真正走一遍,看清每一步手里到底拿着什么:下面这条追踪把抽象的函数名换成了具体的输入输出,顺着六个站走下来,你会发现每一步都只是"拿上一步的产物、调一个函数、得到下一步的产物",并不神秘。
退一步问:为什么对外暴露的偏偏是 C 接口,而不是更现代的 C++ 类?答案是 ABI 稳定性。C 的函数签名和内存布局,是各语言、各编译器之间最稳妥的"最小公约数",一旦定下来就很少变;而 C++ 的类布局、名字修饰会随编译器和版本漂移,根本不适合做跨语言的稳定边界。同一个 C++ 类,用 GCC 和 Clang 编出来的二进制接口都可能对不上;而 C 的调用约定几十年如一日地稳定,几乎每种编程语言都内建了"调用 C 函数"的能力,这才让一份引擎能被这么多语言复用。
稳定的 C 接口配上 opaque 指针,效果加倍:调用方只看见一个指针,看不见也碰不到背后的 C++ 类、字段布局、模板。于是库作者可以放心重构内部,只要那几十个 C 函数的签名不动,所有调用方就都安然无恙。这也是为什么 llama.cpp 内部能频繁地改算法、换数据结构、加新后端,外面用 Python 绑定的人却几乎从不需要跟着改代码。
C 函数签名、opaque 句柄、枚举值。这是各语言绑定依赖的契约,轻易不动。
指针背后的 C++ 类、数据布局、算法实现。可随时重构、优化,调用方毫无感知。
正是这套"稳定的表面 + 自由的内部",让 llama.cpp 能被嵌进几乎任何地方:Python、Go、Rust、Node 等语言的绑定,全都是对这同一套 C 函数做一层薄封装。一处稳定的 C ABI,撑起了上面整片多语言生态——这也是它能"到处跑"的根本原因。你在手机 App、桌面软件、云端服务里看到的各种"本地大模型",往下挖到底,调用的多半就是这几个 C 函数;正因为底座足够稳,这份投入才能一年年地复利式回报。
最后用两个折叠,补两块容易绊住新手的细节:分词出来的 token 到底怎么填进批次,以及为什么源码里总冒出 DEPRECATED。
分词得到的 token 不会直接喂给 llama_decode,而是先装进一个 llama_batch(呼应 L18 的批处理)。这个结构告诉 decode:这批有几个 token、它们的 id、各自在序列里的位置 pos、属于哪个序列 seq_id、以及哪些 token 需要在算完后输出 logits。之所以要打包成批,是因为 GPU 一次算一大片比逐个算高效得多;把零散的 token 拼成一个批,正是 L18 讲的"用并行换吞吐"在 API 层的落点。
// llama_decode 吃进去的输入结构 (简化自 include/llama.h) struct llama_batch { int32_t n_tokens; // 这批有多少个 token llama_token * token; // token id 数组 (L18/L20) llama_pos * pos; // 每个 token 在序列里的位置 llama_seq_id** seq_id; // 每个 token 属于哪个序列 int8_t * logits; // 1=该 token 输出 logits, 0=跳过 };
多数简单场景用不着手填这么多字段——llama_batch_get_one(tokens, n) 会替你把"单序列、从头排位置、只输出最后一个"的常见情形一次填好。只有要并行多序列、或自定义位置时,才需要自己逐字段填。那个 logits 字段是个标志数组:置 1 的 token 才会在 decode 后给出 logits,其余跳过以省算力。这也解释了一个常见疑问:明明喂进去 100 个提示词 token,为什么只在最后一个位置拿 logits?因为前 99 个只是来"填 KV 缓存"的,我们并不需要它们的预测分数,自然就把那些位置的 logits 标志设成 0。
翻开 include/llama.h,你会撞见 llama_new_context_with_model 被包在一个 DEPRECATED(...) 宏里。它就是今天 llama_init_from_model 的旧名字——语义完全一样,只是换了个更准确的名称。为了不破坏已有代码,旧符号被保留下来、只打上"已弃用"的标记。要分清"弃用"和"删除":弃用只是编译时给个警告,代码照样能编、能跑;而删除才是真正的断点,老代码会直接编不过。这个缓冲期就是留给大家从容迁移的。
这种演进在 C API 里很常见(llama_model_load_from_file 之于更早的 llama_load_model_from_file 也是一例)。读源码时养成一个习惯:看到 DEPRECATED(...) 包着的声明,就知道"这是为兼容保留的旧门面,新代码该用它后面 hint 里指向的那个新名字"。这样你既能读懂老教程,又不会在新项目里用错 API。一个稳定的库正是这样小步演进的:既不冻死接口、也不动辄推倒重来,而是用"弃用 - 保留 - 最终移除"这套节奏,让生态有时间跟上。
Across the first four parts we took apart the whole internal machine - how a .gguf file is loaded, how the graph is built, how it infers, samples, and is constrained (L14-L24). But how does the outside world actually drive that machine? The answer is the set of C functions in include/llama.h - llama.cpp's main switch: llama-cli, llama-server, and the Python / Go / Rust / Node bindings all load the model, feed in tokens, and read out results through this one stable C interface.
From this lesson on we switch angle: the first four parts were about "how it turns inside"; from now on we re-thread the whole project along "how you use it from outside". This lesson meets the three cornerstones of that C API - opaque handles (you only get a pointer, never the internals), the typical call sequence (the fixed arc from init to free), and the C++ RAII wrappers (which free your handles automatically). These three look scattered but interlock: the handles are what you operate on, the call sequence is the order you operate in, and the RAII wrappers clean up after you. Connect them and you hold a key to reading any higher-level code.
This C API does not spread the model's internal structs out for you; it only hands you a few opaque handles: what you get is a pointer whose contents the library owns internally - you cannot see, and should not touch, its fields. Why "just a pointer, implementation hidden" is good comes later; first meet the handles you deal with most. This is not to make life hard: hiding the fields lets the library reshuffle its internal layout without disturbing callers, and keeps you from corrupting state by poking a field you were never meant to touch. All you do is treat a handle like a claim ticket - carry it to the matching function, and never mind how the shelves behind the counter are arranged.
Here llama_model holds read-only knowledge: model weights never change once loaded (echoing L14's loading and L17's read-only sharing), so many sessions can safely share one copy - load once, infer in many places, only one copy in memory. This pays off especially on the server side: when dozens of requests arrive on one machine, they can share a single llama_model and each open only a lightweight llama_context, instead of duplicating the multi-GB weights dozens of times. Splitting the "heavy but constant" weights from the "light but changing" session state is the key to this API saving memory and scaling concurrently.
And llama_context is per-session state: it holds the KV cache, compute resources, and logits buffers (echoing L17's context and L19's KV cache), one per session, each isolated from the rest. The vocab llama_vocab is pulled from the model via llama_model_get_vocab (L20), and the sampler llama_sampler is L21's sampler chain. Note the llama_vocab handle: it belongs to the model (the tokenizer rules ship with the model, after all), so you neither load nor free it separately - you just take a pointer and use it. This "one handle derived from another" relationship is common in the C API, and being clear on who owns whom is what keeps your frees correct.
Model weights and metadata, unchanged after load. Shareable across contexts: load once, infer in many places, save memory.
One session's private state: KV cache, compute buffers, sampling position. One per session, cost grows with context length.
The handles are yours, so freeing them is your job too. In plain C this means calling the matching _free by hand: llama_free releases the context, llama_model_free the model, llama_backend_free tears down the global backend - and the order matters (free the dependent first, then what it depended on): a context is built from a model and depends on it, so you must free the context first and the model second; do it the other way and the context becomes a dangling pointer. Miss a free and you leak memory, get the order wrong and you crash - in plain C, all of this is on you to watch.
// C: you own every handle, and must free it yourself (include/llama.h) llama_model * model = llama_model_load_from_file(path, mparams); llama_context * ctx = llama_init_from_model(model, cparams); // ... use ... llama_free(ctx); // free the context first llama_model_free(model); // then the model // C++: include/llama-cpp.h wraps a handle in unique_ptr, freed at scope exit llama_model_ptr model(llama_model_load_from_file(path, mparams)); llama_context_ptr ctx(llama_init_from_model(model.get(), cparams)); // ... use ... scope end auto-calls llama_free / llama_model_free
C++ users get a tidier option. include/llama-cpp.h, a tiny header of about 30 lines, defines a std::unique_ptr alias for each handle - llama_model_ptr, llama_context_ptr, llama_sampler_ptr, llama_adapter_lora_ptr - each with a deleter that calls the matching _free. A handle is released the moment it leaves scope, so you never miss one or get the free order wrong. Declare the handles in stack order, built before destroyed, and destruction runs in reverse order automatically - exactly the "free the context, then the model" rule from earlier. You hand the error-prone manual bookkeeping to the compiler to enforce, which is precisely what C++ RAII is good at.
With the handles known, see how they string together. Almost every program using llama.cpp shares the same skeleton pipeline: init the backend, load the model, build a context, get the vocab, tokenize the text, feed it in to decode, take the logits, sample one token, turn it back into text, loop, and finally free things one by one. Whether it is a tiny example of a few dozen lines or a mature service like llama-server, the skeleton never escapes this line; the only difference is that the server splits "load" and "loop" across threads and adds caching and concurrent scheduling. Burn this trunk into your head and you will not get lost in any higher-level code.
The first few steps are one-time setup: llama_backend_init starts the global backend; llama_model_load_from_file loads the model by path (if the model is split into shard files, use llama_model_load_from_splits instead); llama_init_from_model builds a context from the model; llama_model_get_vocab pulls out the vocab for later. Across these steps the parameter objects mparams and cparams decide a lot: the model params carry whether to mmap and how many layers to offload to the GPU (echoing L07's -ngl); the context params carry n_ctx (how large the context window is), n_batch (how many tokens at most per batch), and more. In other words, those command-line options all end up as fields in these two structs passed in here.
# pseudocode: one full C-API generation loop (simplified from include/llama.h) llama_backend_init(); # global backend init model = llama_model_load_from_file(path, mparams); # multi-shard -> llama_model_load_from_splits ctx = llama_init_from_model(model, cparams); # old name llama_new_context_with_model (DEPRECATED) vocab = llama_model_get_vocab(model); # read-only vocab smpl = llama_sampler_chain_init(sparams); # build a chain, then add top_k/top_p/temp/dist n = llama_tokenize(vocab, prompt, tokens, ...); # text -> token ids batch = llama_batch_get_one(tokens, n); # simplest single-sequence batch while (more) { llama_decode(ctx, batch); # run the compute graph logits = llama_get_logits(ctx); # one score per vocab token id = llama_sampler_sample(smpl, ctx, -1); # the chain picks the next token piece = llama_token_to_piece(vocab, id, ...); # token -> text piece batch = llama_batch_get_one(&id, 1); # feed it back, keep looping } llama_free(ctx); llama_model_free(model); llama_backend_free(); # free one by one
Then comes the autoregressive main loop: llama_decode runs the graph once, llama_get_logits reads out a score per token for this step, the chain via llama_sampler_sample picks the next token, llama_token_to_piece turns it back into a text piece, and llama_batch_get_one feeds it back to decode the next step. When the loop ends, free in the order context -> model -> backend. Here you see the point of splitting handles again: on every turn of the loop the model never moves, and the only things that change are the KV cache and the position counter inside the context - exactly the payoff of separating the "heavy, constant weights" from the "light, changing state".
A flow chart is still not concrete enough. Let us walk one minimal example - feeding the model a single "Hi" - through the whole chain, to see exactly what we hold at each step: the trace below swaps abstract function names for concrete inputs and outputs, and walking the six stations you find every step is just "take the previous step's product, call one function, get the next step's product" - nothing mysterious.
Step back and ask: why expose a C interface rather than more modern C++ classes? The answer is ABI stability. C's function signatures and memory layout are the safest "lowest common denominator" across languages and compilers, and once fixed they rarely change; C++ class layouts and name mangling drift with compiler and version, unfit for a stable cross-language boundary. The same C++ class can produce binary interfaces that do not line up between GCC and Clang; C's calling convention, by contrast, has stayed stable for decades, and nearly every programming language has a built-in ability to "call a C function" - which is what lets one engine be reused from so many languages.
A stable C interface plus opaque pointers doubles the effect: the caller sees only a pointer, never the C++ classes, field layouts, or templates behind it. So the library authors can refactor the inside freely, and as long as those few dozen C function signatures hold, every caller stays unaffected. This is also why llama.cpp can frequently change algorithms, swap data structures, and add new backends internally, while people using the Python bindings outside almost never have to change their code along with it.
C function signatures, opaque handles, enum values. This is the contract every language binding depends on, rarely changed.
The C++ classes, data layouts, and algorithms behind the pointer. Refactor and optimize anytime, callers none the wiser.
It is exactly this "stable surface plus free interior" that lets llama.cpp be embedded almost anywhere: the Python, Go, Rust, and Node bindings are all a thin wrapper over this same set of C functions. One stable C ABI holds up the whole multi-language ecosystem above it - the root reason it "runs everywhere". The various "local LLMs" you see in phone apps, desktop software, and cloud services, dug all the way down, mostly call these same few C functions; because the base is stable enough, this investment pays compounding returns year after year.
Finally, two folds to fill in two details that often trip up newcomers: how tokenized tokens actually get filled into a batch, and why DEPRECATED keeps showing up in the source.
Tokens from tokenizing are not fed straight to llama_decode; they first go into a llama_batch (echoing L18's batching). This struct tells decode: how many tokens this batch carries, their ids, each token's position pos in the sequence, which sequence seq_id it belongs to, and which tokens should output logits after compute. The reason to pack into a batch is that a GPU computing one big slab at once is far more efficient than one token at a time; stitching scattered tokens into a batch is exactly where L18's "trade parallelism for throughput" lands at the API layer.
// the input struct llama_decode consumes (simplified from include/llama.h) struct llama_batch { int32_t n_tokens; // how many tokens this batch carries llama_token * token; // the token ids (L18/L20) llama_pos * pos; // position of each token in its sequence llama_seq_id** seq_id; // which sequence each token belongs to int8_t * logits; // 1=output logits for this token, 0=skip };
Most simple cases need not fill all these fields by hand - llama_batch_get_one(tokens, n) fills the common "single sequence, positions from the start, output only the last" case for you in one go. Only parallel multi-sequence work or custom positions need per-field filling. That logits field is a flag array: only tokens set to 1 yield logits after decode, the rest are skipped to save compute. This also answers a common question: when you feed in 100 prompt tokens, why take logits only at the last position? Because the first 99 are only there to "fill the KV cache" - we do not need their prediction scores, so we set the logits flag to 0 at those positions.
Open include/llama.h and you will run into llama_new_context_with_model wrapped in a DEPRECATED(...) macro. It is simply the old name of today's llama_init_from_model - identical semantics, just a more accurate name. To avoid breaking existing code, the old symbol is kept and merely tagged "deprecated". Tell "deprecated" apart from "removed": deprecation only emits a compile-time warning while the code still compiles and runs; removal is the real cutoff, where old code simply fails to build. That grace period is exactly what lets everyone migrate at a comfortable pace.
Such evolution is common in the C API (llama_model_load_from_file versus the earlier llama_load_model_from_file is another case). Build a habit when reading source: a declaration wrapped in DEPRECATED(...) means "this is an old facade kept for compatibility, new code should use the new name its hint points to". That way you can both read old tutorials and avoid using the wrong API in new projects. A stable library evolves in exactly these small steps: neither freezing the interface forever nor tearing it down on a whim, but using the "deprecate - keep - eventually remove" rhythm to give the ecosystem time to catch up.