🦙 llama.cpp 图解教程llama.cpp Visual Guide 第四部分 · llama 推理内部Part 4 · Inside llama inference 17 / 40
第四部分 · llama 推理内部Part 4 · Inside llama inference

上下文与会话Context & session

能建图的模型(L14-16)还差一个运行时,才能真正跑起来、记住对话进度、把结果交出去——这就是 llama_context。它是一个有状态的对象,持有这次会话的配置(cparams)、 KV cache(memory)、后端调度器(sched)和输出缓冲;llama_decode 跑一步前向、llama_get_logits_ith 取出结果。

这一课是把前面"静态的模型"激活成"会跑的推理"的关键一环。它也回答了一个很实际的问题:为什么 llama.cpp 把"模型"和"上下文"分成两个对象?想清楚这件事,你就明白了 llama-server 能同时服务很多用户的底层原因。

🔌 生活类比
如果 llama_model图纸 + 零件库(静态、只读、可共享),llama_context 就是施工现场(有状态、每个会话一个):现场放着这次施工的进度(KV cache)、工具调度(sched)、和产出(logits)。 同一份图纸,能同时开好几个工地——一个 model 配多个 context,各跑各的对话,谁也不影响谁。

model 与 context:只读知识 vs 有状态会话

这是这一课最该先想清的一刀切:权重是只读的知识,会话是有状态的进度,两者被故意分成两个对象。

llama_model(只读知识)

权重 · 超参 · 词表 · 只读 · 一份就够 · 可被多个 context 共享

llama_context(有状态会话)

cparams + KV cache + sched + logits · 有状态 · 每会话一个 · 记着这次对话算到哪

为什么要这么分?因为权重几个 GB、加载一次就不变,理应共享;而"对话进度"(KV cache、当前位置)是每个会话各不相同的状态,必须各存一份。把不变的知识和会变的状态拆开,一份权重就能撑起许多并发会话——这正是服务器多用户的根基。

打个比方:模型像一本字典(人人可查、内容不变),上下文像每个人手里的草稿纸(各写各的、互不干扰)。你绝不会给每个查字典的人各印一本字典,但每个人都需要自己的草稿纸。llama.cpp 这一刀,切的正是"共享的知识"和"私有的状态"。

"一个 model 配多个 context"不是空话,而是天天在发生的事。llama-server 同时服务多个用户时,就是一份权重 + 每个请求一个 context;甚至单个程序里想并行跑几条不同的对话,也是开几个 context。 它们共享那份只读的权重,各自维护自己的 KV 和进度,互不串扰。理解了这点,你就明白"加载一次、服务很多"是怎么做到的。

🔬 细节 / 源码对应
实现上,llama_context 内部持有一个指向 llama_model 的引用——它不复制权重,只是"借用"。所以新建一个 context 的代价很小:分配一些会话状态(主要是 KV cache 的空间),权重那几个 GB 一个字节都不用再读、不用再拷。这也是为什么 server 加一个并发连接,增量内存主要就是那一份 KV,而不是整个模型。

这"知识 vs 状态"的分法,其实是计算机里一条很通用的设计原则:把无状态、可共享的部分和有状态、需隔离的部分分开。Web 服务器把静态资源和会话 session 分开、数据库把只读快照和事务状态分开,都是同一个思路。 llama.cpp 把它用在了推理上:权重是无状态的"程序",context 是有状态的"进程"。

这里的"会话"(session)一词值得点明:它就是一次"连续的对话或生成过程"。同一个会话里,后面的话能记得前面说过什么(靠 KV cache);换一个会话,就是一张白纸重新开始。 所以 context 本质上承载的是"一次连贯对话的全部记忆"——它在,对话的上下文就在;它一释放,这次对话就被忘得干干净净。

顺带一提,"上下文"这个词在这里有两层意思容易混:一是 llama_context 这个对象,二是 n_ctx 那个"能记多少 token"的上下文长度。前者是装会话状态的容器,后者是这个容器能装下的对话有多长。本课讲的主要是前者,后者的细节留到 L19。

context 里有什么

掀开 llama_context 看看,它主要持有四样东西:配置、记忆、调度器、输出。

配置llama_cparams cparams
这次会话的参数:上下文多长、批多大、几个线程……
记忆llama_memory_ptr memory
KV cache(L19):记着这次对话先前每个 token 的 K/V
调度ggml_backend_sched_ptr sched
多后端调度器(L10):决定图的哪部分在 CPU/GPU 上算
输出buffer_view<float> logits / embd
输出缓冲:装这一步算出的 logits(下一 token 的分数)
// 简化自 src/llama-context.h
struct llama_context {
    llama_cparams          cparams;  // 这次会话的配置
    llama_memory_ptr       memory;   // KV cache 等(L19)
    ggml_backend_sched_ptr sched;    // 多后端调度(L10)
    buffer_view<float>     logits;   // 输出: 下一 token 的分数
};

这四样合起来,正好是"跑一步推理"所需的全部状态:cparams 说"按什么规格跑",memory 记"之前算过什么",sched 管"在哪块硬件上算",logits 接"算出来的结果"。 注意 memory 是个泛化的"记忆"抽象(不只是裸的 KV cache,还能是 recurrent、hybrid 等变体,L19 细讲),所以字段名是 memory 而非 kv_cache——这是为长上下文与新型架构留的余地。

还要点一句:context 持有权重——权重在 model 里,context 只引用它。这正是上一节"分开"的体现:一个轻量的 context 背着会话状态、指向那份重而只读的权重,于是开多个 context 几乎不额外占权重的内存。

为什么 sched(后端调度器)也要放进 context、而不是放进 model?因为调度是带会话状态的:它要管这次会话的中间张量内存怎么分配复用(L10 的 ggml-alloc)、图的哪部分在哪块设备上算。 不同会话各跑各的图,自然各需要一个调度器。把它放进 context,正好和"每会话一份状态"的设计对齐。

🔬 细节 / 源码对应
输出为什么是 buffer_view<float> 这种"视图",而不是一个普通数组?因为输出的大小是动态的——这一步标了几个位置要输出,就有几行 logits(每行 n_vocab 个数)。用一个轻量的视图指向底层缓冲,既能灵活表示"这一步有几行输出",又避免反复分配。llama_get_logits_ith 取的,就是这个视图里第 i 行。

顺带说说 context 的生死:它由 llama_init_from_model(model, cparams) 创建(这时就按 cparams 把 KV cache 等开好),用完由 llama_free 释放。model 活得久(整个服务期间),context 可以来去(一个请求一个)。 这种"长命的 model + 短命的 context"的生命周期搭配,正是服务器处理一波又一波请求的常态。

还有个实现细节:你传进去的 llama_context_params 会被拷一份存进 context(内部叫 llama_cparams)。这样 context 一旦建好,它这次会话的规格就定下来了,不会因为你后来改了外面那份参数而变。 每个 context 各自记着自己的规格,互不影响——这又是"每会话独立"的一处体现。

那个和 logits 并列的 embd 缓冲也顺带提一句:它装的是嵌入向量输出——做 embedding 任务(把整句话变成一个向量)时用它,而不是 logits。所以 context 的输出口其实有两个:要"下一个词"就看 logits,要"句子的向量表示"就看 embd。同一套 decode 机制,按任务取不同的输出。

cparams:怎么配这次会话

创建 context 时,你用 llama_context_params(cparams)告诉它这次会话怎么跑。这些参数大多是在显存与速度之间做权衡

参数含义
n_ctx上下文长度(能记多少 token;越大 KV cache 越占显存)
n_batch / n_ubatch逻辑 / 物理批大小(一次提交多少 / 一次真正算多少,L18)
n_seq_max最多并行几条序列
n_threads用几个 CPU 线程
type_k / type_vKV cache 的数据类型(可量化以省显存)
offload_kqv是否把 KV 相关计算放到 GPU
pooling_typeembedding 任务时怎么把 token 向量汇成句向量
// 简化自 include/llama.h 的 llama_context_params
struct llama_context_params {
    uint32_t n_ctx;      uint32_t n_batch;   uint32_t n_ubatch;
    uint32_t n_seq_max;  int32_t  n_threads;
    ggml_type type_k, type_v;   // KV 的量化类型(省显存)
    bool offload_kqv;           // KV 计算放 GPU?
};

这里最该上手感的是 n_ctxtype_k/type_v:它们直接决定 KV cache 吃多少显存。n_ctx 翻倍,KV cache 大致翻倍;把 type_k/type_v 从 16 位降到 8 位,KV 占用又能减半(代价是一点点精度)。 所以"能开多长上下文"不是模型单方面决定的,而是你按手头显存,在 n_ctx 和 KV 量化之间调出来的——这条线会在 L19 讲 KV cache 时再展开。

n_seq_max 这个参数关系到一个常被忽略的能力:一个 context 可以同时跑多条序列。比如批量给几个不同 prompt 各生成回答,可以放进同一个 context、用不同的 seq_id 区分(L18),共享这份权重和这套调度。 n_seq_max 就是上限。这让"一个 context 服务多个并发对话"成为可能,是比"一对话一 context"更省的玩法。

💡 实战
cparams 和模型本身的默认值也有联系。很多参数你可以填 0 表示"用模型的默认"——比如 n_ctx 填 0,就取模型训练时的上下文长度(L15 的 n_ctx_train)。这让你既能省心地用默认值,又能在需要时按显存覆盖它。配置的灵活性,就藏在这些"0 表示跟随模型"的约定里。

表里没列全的 cparams 还有一些专门用途,比如 pooling_type(做 embedding 任务时怎么把 token 向量汇成一个句向量)、各种 RoPE 缩放参数(把上下文外推到训练长度之外)。 普通文本生成多半用默认就行,但它们的存在说明:context 不只服务"生成下一个词",也能服务 embedding、长上下文外推等多种任务。

一个实用提醒:context 的内存占用,大头往往是 KV cache,而 KV cache 的大小由 n_ctx、层数、KV 头数、type_k/type_v 共同决定。所以当你发现显存不够,调小 n_ctx 或量化 KV,往往比换模型更立竿见影。这条经验,在 L19 会有更细的账。

为什么这些参数放在建 context 时配、而不是加载 model 时配?因为它们是"这次会话怎么跑"的事,而不是"模型是什么"的事。同一个 model,你可以用不同的 cparams 开多个 context:一个开长上下文、一个开短的,一个多线程、一个少线程,各按各的场景来。 把会话参数和模型解耦,正是为了这种灵活。

一步推理与取结果

万事俱备,跑推理就是反复调 llama_decode。它吃一个 batch(L18),内部把建图、执行、更新 KV 一气呵成,最后把 logits 放进 context 的输出缓冲。

llama_batch
这步喂的 token
(L18)
->
llama_decode
建图(L16)+执行(L10)
+更新 KV(L19)
->
logits 缓冲
写进 context
输出缓冲
->
llama_get_logits_ith
取第 i 个位置
(n_vocab 维)
# 伪代码: 一步推理(llama_decode 内部)
llama_decode(ctx, batch)              # 跑一步前向
#   -> 切 ubatch(L18) -> build_graph(L16) -> sched 执行(L10) -> 更新 KV(L19)
#   -> logits 写进 ctx 的输出缓冲
p = llama_get_logits_ith(ctx, i)    # 取第 i 个 token 的 logits(n_vocab 维)

取结果用 llama_get_logits_ith(ctx, i),拿到第 i 个 token 的 logits——一个 n_vocab 维的向量,是"下一个词"的未归一分数,接下来交给采样(L21)挑一个词。这就把 L16 的建图、L10 的执行、L19 的 KV, 通过 llama_decode 这一个函数串成了一步完整的推理

🌍 宏观理解
所以 llama_context 在整套机制里扮演的是"总指挥 + 状态本":它知道这次会话的所有配置、记着算到哪、调度着硬件、收着输出。理解了它,你就把前面那些零件(model、graph、backend、KV)真正组装成了一台能转的推理机

这里也顺势把 L03 的 prefill/decode 接上:两个阶段是调 llama_decode,区别只在喂的 batch。prefill 一次喂整段 prompt(多个 token),把它们的 K/V 一口气填进 KV cache,只取最后一个的 logits;decode 之后每次只喂一个新 token、取它的 logits。 同一个函数,两种节奏,全靠 batch 来表达。

把整个自回归循环画出来就是:llama_decode 算出 logits -> 采样(L21)挑一个 token -> 把这个新 token 包成一个新 batch 喂回 llama_decode -> 再出 logits…… 如此往复,逐字蹦出回答。 context 在这整个循环里一直在:它的 KV cache 一步步变长、它的输出缓冲一步步刷新。一句对话的生成,就是这个循环在一个 context 上转了很多圈。

🔬 细节 / 源码对应
补一句 llama_encode:有些模型(如带 encoder 的)需要先 encode、再 decode,所以 API 里既有 llama_decode 也有 llama_encode。对最常见的 decoder-only 大模型(L04),你基本只会用到 llama_decode。知道有这么个分工即可,不必深究。

所以这一课交付的,是一个从"零件"到"机器"的跃迁:前几课造出了 model(L14-15)和 graph(L16)这些零件,这一课用 context 把它们装进一个能反复转动的循环里。读到这儿,你已经能在脑子里跑通一次完整推理:加载模型 -> 建 context -> 反复 decode + 采样 -> 逐字输出。 剩下几课,是把这台机器的几个关键部件(batch、KV cache、采样……)再各自拆开看细节。

再强调一遍"状态在推进"这件事,因为它是理解自回归的关键。每次 llama_decode 不是从头重算,而是在 context 现有状态上往前走一步:KV cache 里已经有前面所有 token 的 K/V,这一步只需算新 token、把它的 K/V 追加进去。 正因为状态被 context 一直记着,decode 才能做到"每步只算一个 token"这么快(L03/L19)。

最后用一句话收束 context 的角色:它是那个让"静态模型"变成"动态推理"的开关。没有它,model 只是一堆躺着的权重;有了它,权重才被一步步驱动起来、吐出一个个 token。下一课起,我们就钻进这台机器的具体部件,先从喂给 decode 的 batch 开始。

1 为什么 model 和 context 要分开? 点击展开

核心是"共享只读、各存状态"。权重是只读的、几个 GB,多个会话共用同一份最省内存(配合 L13 的 mmap,连物理内存都能跨进程共享);而 KV cache、当前位置这些是每会话不同的状态,必须各存一份。

分开之后,一台机器上一份权重就能撑起很多并发会话:每来一个用户/请求,新建一个轻量的 context(只背自己的 KV),权重那几个 GB 一动不动地被大家共享。这正是 llama-server 能多并发、省内存的根基。

反过来想,如果不分开、把权重和状态揉成一个对象,那每个会话都得复制一份几 GB 的权重——服务几十个用户就要几十份权重,根本扛不住。一个看似简单的"拆成两个对象"的设计,撑起了整个多用户服务的可行性。

2 logits 是什么?为什么只在某些 token 上有? 点击展开

logits 是模型对"下一个 token 该是谁"打出的一组未归一分数,长度等于词表大小 n_vocab。它还不是概率(没归一),但分数越高的词越可能被选中;采样(L21)就是拿这组 logits 去挑一个词。

关键是:不是每个 token 都要算 logits。算 logits 要做一次"隐藏向量 -> 词表大小"的大矩阵乘,挺贵。而 prefill 阶段把整段 prompt 过一遍时,中间那些 token 的 logits 根本用不到——我们只要最后一个 token 的 logits(用来预测下一个)。

所以哪些位置算 logits,由 batch 的输出标志控制(L18)。decode 阶段每步只新增一个 token、只它要 logits;prefill 整段只要末位。llama_get_logits_ith(ctx, i) 就是去取第 i 个被标记输出的位置的 logits。这套"按需算输出"的设计,省下了大量无用的大矩阵乘。

3 context 怎么把 L16/L10/L19 串成一步? 点击展开

llama_decode 是那个"总指挥"。它拿到这一步的 batch(L18),先用批处理逻辑把它切成物理可算的 ubatch;对每个 ubatch,调 build_graph(L16)搭出这一步的计算图。

然后把图交给 context 里的 sched(L10 的后端调度器)真正执行;执行过程中,注意力算子会把这一步新 token 的 K/V 写进 context 的 KV cache(L19),并读回历史 K/V。算完,把输出 logits 写进 context 的输出缓冲。

一圈下来,context 的状态就往前推进了一步:KV cache 多记了一个 token、输出缓冲有了新 logits。下一次 llama_decode 接着在这个状态上推进。正是 context 把这些散落的机制(建图、执行、KV、输出)攒在一起、按顺序驱动,才有了"一步接一步"的自回归生成。

✅ 关键要点
  • llama_model只读的权重/超参/词表(一份、可被多 context 共享);llama_context有状态的会话(每会话一个)。
  • context 持有 cparams(配置)+ memory(KV cache,L19)+ sched(后端调度,L10)+ logits(输出缓冲);不持有权重,只引用 model。
  • cparams 调 n_ctx/n_batch/type_k/type_v 等,多在显存与速度间权衡(n_ctx、KV 量化直接影响显存)。
  • llama_decode 跑一步前向(切 ubatch -> 建图 -> 执行 -> 更新 KV -> 出 logits);llama_get_logits_ith 取第 i 个 token 的 logits。
  • 分开 model 与 context = 一份权重撑多会话,是 llama-server 多并发的根基。
💡 设计洞察
把"不变的知识"(权重 model)和"会话的状态"(KV/进度 context)拆成两个对象——这一刀看似平常,回报却极大:一份几 GB 的权重能被许多会话共享,每个会话只额外背一份轻量的状态。 于是同一台机器、同一份模型,能同时服务很多用户。这正是"状态与数据分离"这条老道理在推理引擎里的又一次体现,也是从"能跑一个对话"到"能扛一个服务"之间,那道最关键的设计分水岭。下一课,我们就看喂给 llama_decode 的那个 batch 到底长什么样。

🧪 自测 · 想一想为什么这么设计

1. llama_model 和 llama_context 的区别是?
  1. 是一回事,只是名字不同
  2. context 存权重,model 存 KV
  3. model 是只读权重(可被多 context 共享),context 是有状态运行时(KV/sched/logits,每会话一个)
  4. model 有状态,context 只读
看答案与解析 点击展开
答案:C。权重只读、几个 GB,多会话共享同一份最省内存;KV cache、当前位置是每会话不同的状态,必须各存一份。所以 model 只读可共享、context 有状态每会话一个。
2. llama_decode 做什么?
  1. 直接采样出一个 token
  2. 释放内存
  3. 跑一步前向(建图 + 执行 + 更新 KV),算出 logits
  4. 加载模型
看答案与解析 点击展开
答案:C。llama_decode 吃一个 batch,内部切 ubatch(L18)-> build_graph(L16)-> sched 执行(L10)-> 更新 KV(L19)-> 把 logits 写进输出缓冲。采样是下一步(L21)的事。
3. 取第 i 个 token 的 logits 用哪个?
  1. llama_free
  2. llama_tokenize
  3. llama_get_logits_ith(ctx, i)
  4. llama_get_model
看答案与解析 点击展开
答案:C。llama_get_logits_ith(ctx, i) 取第 i 个被标记输出的位置的 logits——一个 n_vocab 维向量,交给采样(L21)挑词。
💭 发散思考(没有标准答案,动手或动脑想想)
  • 为什么把“权重”(model)和“会话状态”(context)分成两个对象?这对一台机器服务很多用户有什么好处?

A graph-able model (L14-16) still needs a runtime to actually run, remember conversation progress, and hand results out - that is llama_context. It is a stateful object holding this session's config (cparams), the KV cache (memory), the backend scheduler (sched), and output buffers; llama_decode runs one forward step, llama_get_logits_ith reads the result.

This lesson is the key link that activates "a static model" into "running inference". It also answers a very practical question: why does llama.cpp split "model" and "context" into two objects? Think this through and you understand the underlying reason llama-server can serve many users at once.

🔌 Analogy
If llama_model is a blueprint + parts library (static, read-only, shareable), llama_context is a construction site (stateful, one per session): the site holds this build's progress (KV cache), tool scheduling (sched), and output (logits). The same blueprint can run several sites at once - one model with several contexts, each running its own conversation, none disturbing the others.

model vs context: read-only knowledge vs stateful session

This is the cut to grasp first: weights are read-only knowledge, a session is stateful progress, and the two are deliberately split into two objects.

llama_model (read-only knowledge)

weights - hyperparameters - vocab - read-only - one is enough - shareable by many contexts

llama_context (stateful session)

cparams + KV cache + sched + logits - stateful - one per session - remembers where this conversation is

Why split this way? Because weights are several GB, loaded once and unchanging, and ought to be shared; while "conversation progress" (KV cache, current position) is per-session state that must be stored separately. Splitting unchanging knowledge from changing state, one copy of weights can hold up many concurrent sessions - the basis of multi-user serving.

An analogy: the model is like a dictionary (everyone consults it, its content fixed), the context like each person's scratch paper (each writes their own, none interfering). You would never print a separate dictionary per reader, but everyone needs their own scratch paper. llama.cpp's cut is exactly between "shared knowledge" and "private state".

"One model with several contexts" is no slogan but a daily reality. When llama-server serves many users at once, it is one copy of weights + one context per request; even within a single program, running several different conversations in parallel means opening several contexts. They share the read-only weights, each maintaining its own KV and progress, none interfering. Understand this and you see how "load once, serve many" is done.

🔬 Details / source
In implementation, llama_context holds a reference to llama_model internally - it copies no weights, just "borrows". So creating a context is cheap: allocate some session state (mainly KV cache space), with not a byte of those several-GB weights re-read or re-copied. This is why a server adding one concurrent connection adds memory mainly for that one KV, not the whole model.

This "knowledge vs state" split is actually a very general design principle in computing: separate the stateless, shareable parts from the stateful, must-isolate parts. Web servers separate static assets from session state, databases separate read-only snapshots from transaction state - the same idea. llama.cpp applies it to inference: weights are a stateless "program", the context a stateful "process".

The word "session" here is worth pinning down: it is one "continuous conversation or generation process". Within one session, later words remember what was said before (via the KV cache); a different session is a blank sheet starting over. So a context essentially carries "all the memory of one coherent conversation" - while it lives, the conversation's context lives; once freed, this conversation is forgotten clean.

By the way, "context" here has two easily-confused senses: one is the llama_context object, the other is n_ctx, the "how many tokens it can remember" context length. The former is the container holding session state, the latter how long a conversation that container can hold. This lesson is mainly about the former; the latter's details wait for L19.

What is inside a context

Open up llama_context and it mainly holds four things: config, memory, scheduler, output.

configllama_cparams cparams
this session's params: how long the context, how big the batch, how many threads...
memoryllama_memory_ptr memory
KV cache (L19): remembers this conversation's prior tokens' K/V
schedulingggml_backend_sched_ptr sched
multi-backend scheduler (L10): decides which graph parts compute on CPU/GPU
outputbuffer_view<float> logits / embd
output buffer: holds this step's logits (the next token's scores)
// simplified from src/llama-context.h
struct llama_context {
    llama_cparams          cparams;  // this session's config
    llama_memory_ptr       memory;   // KV cache etc.(L19)
    ggml_backend_sched_ptr sched;    // multi-backend scheduling(L10)
    buffer_view<float>     logits;   // output: next token's scores
};

These four together are exactly the state needed to "run one inference step": cparams says "at what spec to run", memory remembers "what was computed before", sched manages "on which hardware to compute", logits receives "the computed result". Note memory is a generalized "memory" abstraction (not only a raw KV cache, but also variants like recurrent, hybrid, L19), so the field is named memory rather than kv_cache - room left for long context and new architectures.

One more point: the context does not hold the weights - those live in model, and the context only references it. This is exactly the previous section's "split" in action: a lightweight context carries session state and points at the heavy, read-only weights, so opening many contexts costs almost no extra weight memory.

Why is sched (the backend scheduler) also placed in the context, not the model? Because scheduling carries session state: it manages how this session's intermediate-tensor memory is allocated and reused (L10's ggml-alloc), and which graph parts compute on which device. Different sessions run their own graphs, so each naturally needs a scheduler. Placing it in the context aligns exactly with the "one bit of state per session" design.

🔬 Details / source
Why is output a buffer_view<float> "view" rather than a plain array? Because output size is dynamic - however many positions this step flags for output, that many rows of logits (each n_vocab numbers). A lightweight view pointing into an underlying buffer both flexibly expresses "how many output rows this step has" and avoids repeated allocation. llama_get_logits_ith reads the i-th row of this view.

A word on a context's life and death: it is created by llama_init_from_model(model, cparams) (opening the KV cache etc. per cparams then), and freed by llama_free when done. The model is long-lived (the whole service duration), the context comes and goes (one per request). This "long-lived model + short-lived context" lifecycle pairing is exactly how a server handles wave after wave of requests.

An implementation detail: the llama_context_params you pass in is copied into the context (internally llama_cparams). So once a context is built, its session spec is fixed, not changing because you later edit that outer params struct. Each context remembers its own spec, none interfering - another showing of "per-session independence".

The embd buffer alongside logits deserves a mention: it holds embedding vector output - used for embedding tasks (turning a whole sentence into one vector) instead of logits. So a context actually has two output ports: for "the next word" read logits, for "the sentence's vector representation" read embd. The same decode mechanism, different outputs by task.

cparams: configuring this session

When creating a context, you tell it how to run via llama_context_params (cparams). Most of these parameters trade off VRAM against speed.

parametermeaning
n_ctxcontext length (how many tokens it can remember; bigger = more KV cache VRAM)
n_batch / n_ubatchlogical / physical batch size (how much submitted / actually computed at once, L18)
n_seq_maxmax number of parallel sequences
n_threadshow many CPU threads
type_k / type_vKV cache data type (can be quantized to save VRAM)
offload_kqvwhether to put KV-related compute on the GPU
pooling_typehow to pool token vectors into a sentence vector for embedding tasks
// simplified from llama_context_params in include/llama.h
struct llama_context_params {
    uint32_t n_ctx;      uint32_t n_batch;   uint32_t n_ubatch;
    uint32_t n_seq_max;  int32_t  n_threads;
    ggml_type type_k, type_v;   // KV quant type(save VRAM)
    bool offload_kqv;           // KV compute on GPU?
};

The two to build intuition for are n_ctx and type_k/type_v: they directly decide how much VRAM the KV cache eats. Double n_ctx and the KV cache roughly doubles; drop type_k/type_v from 16-bit to 8-bit and KV usage halves again (at a little precision cost). So "how long a context you can open" is not decided by the model alone but tuned by you, given your VRAM, between n_ctx and KV quantization - a thread we pick up again in L19's KV cache.

n_seq_max relates to an often-overlooked capability: one context can run multiple sequences at once. For example, generating answers for several different prompts in a batch can go into the same context, distinguished by different seq_ids (L18), sharing these weights and this scheduling. n_seq_max is the upper bound. This makes "one context serving several concurrent conversations" possible, a more frugal approach than "one conversation per context".

💡 Tip
cparams also relate to the model's own defaults. Many parameters you can set to 0 to mean "use the model's default" - e.g. n_ctx set to 0 takes the model's trained context length (L15's n_ctx_train). This lets you both rely on defaults effortlessly and override by VRAM when needed. Configuration flexibility hides in these "0 means follow the model" conventions.

cparams the table omits have specialized uses too, such as pooling_type (how to pool token vectors into one sentence vector for embedding tasks) and various RoPE scaling parameters (extrapolating context beyond the trained length). Plain text generation mostly uses defaults, but their existence shows the context serves not only "generate the next word" but also embedding, long-context extrapolation, and more.

A practical reminder: a context's memory is mostly the KV cache, whose size is set jointly by n_ctx, layer count, KV head count, and type_k/type_v. So when you hit a VRAM wall, shrinking n_ctx or quantizing KV is often more effective than swapping models. We will do this account in more detail in L19.

Why are these parameters configured at context creation, not at model load? Because they are about "how this session runs", not "what the model is". With one model, you can open several contexts with different cparams: one with long context, one short, one many-threaded, one few-threaded, each to its scenario. Decoupling session parameters from the model is exactly for this flexibility.

One inference step and reading the result

With everything ready, running inference is repeatedly calling llama_decode. It eats a batch (L18), internally does graph-building, execution, and KV update in one go, then puts the logits into the context's output buffer.

llama_batch
tokens fed this step
(L18)
->
llama_decode
build(L16)+execute(L10)
+update KV(L19)
->
logits buffer
into context's
output buffer
->
llama_get_logits_ith
get i-th position
(n_vocab-dim)
# pseudocode: one inference step (inside llama_decode)
llama_decode(ctx, batch)              # run one forward step
#   -> split ubatch(L18) -> build_graph(L16) -> sched execute(L10) -> update KV(L19)
#   -> logits written into ctx's output buffer
p = llama_get_logits_ith(ctx, i)    # get the i-th token's logits(n_vocab-dim)

Read the result with llama_get_logits_ith(ctx, i), getting the i-th token's logits - an n_vocab-dimensional vector, the unnormalized scores for "the next word", handed next to sampling (L21) to pick one. This strings L16's graph-building, L10's execution, and L19's KV, through this one llama_decode function, into one complete inference step.

🌍 Big picture
So llama_context plays the role of "conductor + state ledger" in the whole machine: it knows all this session's config, remembers where it is, schedules the hardware, and collects the output. Understand it and you have truly assembled those earlier parts (model, graph, backend, KV) into a running inference machine.

This is also where L03's prefill/decode connects: both phases call llama_decode, differing only in the batch fed. Prefill feeds a whole prompt at once (many tokens), filling their K/V into the KV cache in one go, taking only the last one's logits; decode then feeds one new token each time and takes its logits. One function, two rhythms, all expressed via the batch.

Drawing the whole autoregressive loop: llama_decode computes logits -> sampling (L21) picks a token -> that new token is wrapped into a new batch fed back to llama_decode -> logits again... and so on, popping out the answer word by word. The context is present throughout this loop: its KV cache grows step by step, its output buffer refreshes step by step. Generating one reply is this loop turning many times on one context.

🔬 Details / source
A note on llama_encode: some models (e.g. with an encoder) need to encode first, then decode, so the API has both llama_decode and llama_encode. For the most common decoder-only large models (L04), you basically only use llama_decode. Just know this division exists, no need to dig in.

So what this lesson delivers is a leap from "parts" to "machine": earlier lessons built parts like the model (L14-15) and graph (L16); this lesson uses the context to pack them into a loop that can turn repeatedly. By here you can run a full inference in your head: load model -> build context -> repeatedly decode + sample -> output word by word. The remaining lessons take this machine's key components (batch, KV cache, sampling...) apart one by one for the details.

Emphasize once more that "state advances", because it is the key to understanding autoregression. Each llama_decode is not a recompute from scratch but advancing one step on the context's existing state: the KV cache already holds all prior tokens' K/V, and this step only computes the new token and appends its K/V. Precisely because the context keeps the state throughout, decode can be so fast as "compute only one token per step" (L03/L19).

To close on the context's role in one sentence: it is the switch that turns "a static model" into "dynamic inference". Without it, the model is just a pile of resting weights; with it, the weights are driven step by step, popping out token after token. From the next lesson, we dig into this machine's concrete components, starting with the batch fed to decode.

1 Why split model and context? Click to expand

The core is "share read-only, store state separately". Weights are read-only and several GB, and many sessions sharing one copy saves the most memory (with L13's mmap, even physical memory can be shared across processes); while KV cache and current position are per-session state that must be stored separately.

Split this way, one copy of weights on a machine holds up many concurrent sessions: each user/request gets a new lightweight context (carrying only its own KV), while those several GB of weights stay shared and untouched. This is the basis for llama-server being concurrent and memory-frugal.

Conversely, if not split - if weights and state were one object - every session would copy several GB of weights; serving dozens of users would need dozens of weight copies, simply unsustainable. A deceptively simple "split into two objects" upholds the feasibility of the whole multi-user service.

2 What are logits? Why only on some tokens? Click to expand

logits are a set of unnormalized scores the model assigns for "who the next token should be", of length equal to the vocab size n_vocab. They are not yet probabilities (not normalized), but higher-scoring words are more likely to be chosen; sampling (L21) takes these logits to pick a word.

The key: not every token needs logits computed. Computing logits means a big "hidden vector -> vocab size" matmul, quite costly. And when prefill passes a whole prompt through, the middle tokens' logits are simply unused - we only want the last token's logits (to predict the next one).

So which positions compute logits is controlled by the batch's output flag (L18). In decode each step adds one token and only it needs logits; prefill needs only the final position. llama_get_logits_ith(ctx, i) gets the logits of the i-th flagged-output position. This "compute output on demand" design saves a lot of useless big matmuls.

3 How does context string L16/L10/L19 into one step? Click to expand

llama_decode is the "conductor". It takes this step's batch (L18), first uses batching logic to split it into physically-computable ubatches; for each ubatch, it calls build_graph (L16) to assemble this step's compute graph.

Then it hands the graph to the context's sched (L10's backend scheduler) to actually execute; during execution, attention operators write this step's new token's K/V into the context's KV cache (L19) and read back historical K/V. When done, it writes the output logits into the context's output buffer.

One round and the context's state advances by one step: the KV cache has remembered one more token, the output buffer has new logits. The next llama_decode continues advancing on this state. It is exactly the context gathering these scattered mechanisms (graph-building, execution, KV, output) and driving them in order that produces "step after step" autoregressive generation.

✅ Key points
  • llama_model is read-only weights/hyperparameters/vocab (one copy, shareable by many contexts); llama_context is a stateful session (one per session).
  • The context holds cparams (config) + memory (KV cache, L19) + sched (backend scheduling, L10) + logits (output buffer); it holds no weights, only references the model.
  • cparams tunes n_ctx/n_batch/type_k/type_v etc., mostly trading VRAM against speed (n_ctx and KV quantization directly affect VRAM).
  • llama_decode runs one forward step (split ubatch -> build graph -> execute -> update KV -> emit logits); llama_get_logits_ith reads the i-th token's logits.
  • Splitting model and context = one copy of weights holds up many sessions, the basis of llama-server's concurrency.
💡 Design insight
Splitting "unchanging knowledge" (weights, model) from "session state" (KV/progress, context) into two objects - this seemingly ordinary cut pays off enormously: one copy of several-GB weights can be shared by many sessions, each carrying only a lightweight bit of state. So the same machine, the same model, can serve many users at once. This is another showing of the old truth "separate state from data" in an inference engine, and the most crucial design watershed between "can run one conversation" and "can hold up a service". Next lesson, we look at what that batch fed to llama_decode actually looks like.

🧪 Self-test - think about the design

1. What is the difference between llama_model and llama_context?
  1. they are the same thing, just different names
  2. context stores weights, model stores KV
  3. model is read-only weights (shareable by many contexts); context is a stateful runtime (KV/sched/logits, one per session)
  4. model is stateful, context is read-only
Show answer & explanation click to expand
Answer: C. Weights are read-only and several GB, so sharing one copy across sessions saves the most memory; KV cache and current position are per-session state stored separately. So model is read-only/shareable, context is stateful/per-session.
2. What does llama_decode do?
  1. directly samples a token
  2. frees memory
  3. runs one forward step (build graph + execute + update KV), producing logits
  4. loads the model
Show answer & explanation click to expand
Answer: C. llama_decode eats a batch, internally splitting ubatch (L18) -> build_graph (L16) -> sched execute (L10) -> update KV (L19) -> write logits to the output buffer. Sampling is the next step's job (L21).
3. Which call gets the i-th token's logits?
  1. llama_free
  2. llama_tokenize
  3. llama_get_logits_ith(ctx, i)
  4. llama_get_model
Show answer & explanation click to expand
Answer: C. llama_get_logits_ith(ctx, i) reads the logits of the i-th flagged-output position - an n_vocab-dimensional vector handed to sampling (L21) to pick a word.
💭 Open questions (no single right answer - just think or try)
  • Why split 'weights' (model) and 'session state' (context) into two objects? What does this gain for one machine serving many users?