Read OSS

从 HTTP 请求到 Token:服务器与 CLI 工具

中级

前置知识

  • 第 1–4 篇文章
  • 对 HTTP API 和 REST 规范有基本了解

从 HTTP 请求到 Token:服务器与 CLI 工具

前面几篇文章介绍的内容——GGML 张量、计算图、KV 缓存、解码循环——都属于 libllama 的范畴,这是一个对调用方式没有任何假设的 C/C++ 库。而 tools/ 目录下的应用层,才是 llama.cpp 与真实用户打交道的地方:一个兼容 OpenAI 接口的 HTTP 服务器、交互式 CLI、量化工具,以及更多实用程序。

本文将重点介绍服务器基于 slot 的并发模型、任务队列架构、API 接口设计,以及 llama.cpp 中一个颇具意思的设计决策:CLI 并没有实现自己的推理循环,而是直接复用了服务器的整套基础设施。

服务器架构概览

服务器架构围绕三个核心组件展开,分散在 tools/server/ 目录下的多个头文件中:

server_context 是整个系统的调度中心。它定义于 tools/server/server-context.h,采用 pimpl(指针隐藏实现)模式,将内部细节封装在 server_context_impl 之后。它负责管理模型、上下文、slot 以及主事件循环。

server_queue 负责任务的提交与分发。它定义于 tools/server/server-queue.h,提供线程安全的任务队列,支持延迟任务(当所有 slot 都繁忙时进行排队)、优先级调度,以及在空闲期间自动卸载模型的休眠模式。

server_task 代表一个工作单元。它定义于 tools/server/server-task.h,任务携带类型信息(补全、嵌入、取消等)和输入参数,还可以包含子任务以支持多步骤操作。

flowchart TD
    HTTP["HTTP Request\n/v1/chat/completions"] --> ROUTES["Route Handler"]
    ROUTES --> TASK["Create server_task"]
    TASK --> QUEUE["server_queue.post()"]
    QUEUE --> LOOP["Main Event Loop"]
    LOOP --> SLOT["Assign to Slot"]
    SLOT --> DECODE["llama_decode()"]
    DECODE --> SAMPLE["Sample token"]
    SAMPLE --> RESULT["server_task_result"]
    RESULT --> SSE["SSE Stream / JSON Response"]

主事件循环在单线程(即"主循环"线程)中运行,按顺序处理队列中的任务。这是经过深思熟虑的设计——它避免了并发调用 llama_decode() 带来的复杂性,否则就需要维护多个上下文或精细的锁机制。并发能力由 slot 系统来实现。

Slot 系统

服务器通过"slot"抽象,在单个已加载模型上同时处理多个推理请求。每个 slot 代表一个独立的推理流,拥有各自的:

  • 提示词状态与已生成的 token
  • 采样参数和采样器链
  • 在 KV 缓存中的位置
  • 流式传输状态(用于 SSE 响应)

当新请求到来时,服务器将其分配给一个空闲 slot。主循环遍历所有活跃 slot,通过调用 llama_decode() 并传入各自的 batch 来推进每个 slot 的进度。这是一种协作式多任务机制——所有 slot 共享同一份模型权重和 llama_context,但各自在 KV 缓存中拥有独立的序列 ID。

sequenceDiagram
    participant R1 as Request 1
    participant R2 as Request 2
    participant Q as server_queue
    participant L as Main Loop
    participant S1 as Slot 0
    participant S2 as Slot 1
    participant CTX as llama_context

    R1->>Q: Completion task
    R2->>Q: Completion task
    Q->>L: Dequeue tasks
    L->>S1: Assign Request 1
    L->>S2: Assign Request 2
    loop Update cycle
        L->>S1: Prepare batch
        L->>S2: Prepare batch
        L->>CTX: llama_decode(combined batch)
        CTX-->>S1: Logits for seq 0
        CTX-->>S2: Logits for seq 1
        S1->>R1: Stream token (SSE)
        S2->>R2: Stream token (SSE)
    end

slot 数量可在启动时通过 --parallel N 配置。每增加一个 slot,就会占用一部分上下文窗口——在 4 个 slot 和 n_ctx=8192 的配置下,每个 slot 大约有 ~2048 个位置可用(实际实现中 KV 缓存的分区方式更为精细)。

提示: 如果服务器只为单个用户服务,--parallel 1 是最优选择。每增加一个 slot 都会缩短单请求可用的上下文长度,并增加内存消耗。对于多用户并发场景,将 --parallel 设置为预期并发数,并相应地扩大 --ctx-size

API 接口与路由注册

服务器提供三类 API,在 tools/server/server.cpp 中完成注册:

兼容 OpenAI 的端点:

  • POST /v1/chat/completions — 支持流式输出的对话补全
  • POST /v1/completions — 文本补全
  • POST /v1/embeddings — 生成嵌入向量
  • GET /v1/models — 列出可用模型

原生端点:

  • POST /completion — 原始补全,支持完整参数控制
  • POST /tokenize — 对文本进行分词
  • POST /detokenize — 将 token 还原为文本
  • GET /health — 服务器健康检查
  • GET /props — 服务器属性

兼容 Anthropic 的端点:

  • POST /v1/messages — Anthropic Messages API 格式

每个路由处理器遵循相同的处理模式:解析 JSON 请求、创建携带相应参数的 server_task、将其提交到队列,再通过 server_response_reader 等待结果。对于流式响应,结果以 Server-Sent Events(SSE)的形式发送。

所有路由处理器都包裹在 ex_wrapper() 中,它会捕获异常并转换为对应的 HTTP 错误响应——参数非法时返回 400,内部错误时返回 500

CLI 复用服务器内部实现

llama.cpp 中最出人意料的架构决策,体现在 tools/cli/cli.cpp 中:

#include "server-context.h"
#include "server-task.h"

struct cli_context {
    server_context ctx_server;   // <-- the same server_context!
    json messages = json::array();
    // ...
};

交互式 CLI 没有实现自己的推理循环。cli_context 直接包装了一个 server_context,并通过与 HTTP 处理器完全相同的任务/队列接口与之交互。当你在 CLI 中输入一条消息时,它会创建一个 server_task,将其提交到服务器队列,并通过 server_response_reader 读取结果。

classDiagram
    class server_context {
        +load_model()
        +start_loop()
        +get_response_reader()
    }
    class server_http_context {
        Routes: /v1/chat/completions
        Routes: /v1/completions
        Creates server_tasks
    }
    class cli_context {
        +ctx_server: server_context
        +messages: json
        Creates server_tasks
    }
    server_context <-- server_http_context : "routes to"
    server_context <-- cli_context : "wraps"

这一设计带来了几个显著优势:

  1. 功能对齐 — 服务器支持的所有功能(工具调用、多模态输入、语法约束、推测解码)在 CLI 中自动可用,无需额外实现。

  2. 统一代码路径 — 推理管道中的 bug 修复可以同时作用于服务器和 CLI,不存在两者逐渐出现分歧的风险。

  3. 简化测试 — CLI 实际上充当了服务器核心逻辑的集成测试,而无需启动完整的 HTTP 栈。

其代价是:CLI 需要承载服务器依赖(任务队列、slot 管理等)的额外开销,尽管它实际上只使用一个 slot。

公共工具层

common/ 目录提供了所有工具共享的基础设施:

参数解析common/arg.h 提供 common_params_parse(),负责处理各工具所接受的数百个 CLI 参数(模型路径、上下文大小、GPU 层数、采样参数等)。所有工具共用同一个 common_params 结构体。

采样封装common/sampling.h 在 libllama 底层 llama_sampler 链的基础上,提供了更高层的接口,统一处理配置、重置和参数管理。

对话模板引擎common/jinja/ 目录包含一个兼容 Jinja2 的模板引擎,用于应用对话模板。当模型在其 GGUF 元数据中(通过 tokenizer.chat_template 键)提供对话模板时,该引擎会将用户/助手/系统消息渲染为模型期望的格式。

flowchart TD
    subgraph "common/ utilities"
        ARG["arg.h / arg.cpp\nArgument parsing"]
        SAMP["sampling.h\nSampling wrappers"]
        CHAT["chat.h\nChat template application"]
        JINJA["jinja/\nJinja2 template engine"]
        LOG["log.h\nLogging utilities"]
    end
    subgraph "Tools"
        CLI["tools/cli"]
        SRV["tools/server"]
        QNT["tools/quantize"]
        BENCH["tools/bench"]
    end
    CLI --> ARG
    CLI --> CHAT
    SRV --> ARG
    SRV --> SAMP
    SRV --> JINJA
    QNT --> ARG
    BENCH --> ARG

工具生态与模型转换

除了服务器和 CLI,llama.cpp 还提供了多个专用工具:

tools/quantize/ — 在不同量化格式之间转换模型(例如 F16 → Q4_K_M)。它读取 GGUF 文件,按目标格式对每个张量重新量化,并输出新的 GGUF 文件。支持通过 --imatrix 使用重要性矩阵进行数据驱动的量化。

tools/bench/ — 对 prompt 处理和 token 生成吞吐量进行性能基准测试。

tools/perplexity/ — 通过在测试语料上计算困惑度(perplexity)来评估模型质量。

tools/imatrix/ — 从校准数据中计算重要性矩阵,用于提升量化质量。

tools/tts/ — 使用语音合成模型实现文字转语音功能。

tools/mtmd/ — 用于支持图像和音频输入的多模态模型工具。

模型转换convert_hf_to_gguf.py 脚本是将新模型引入 llama.cpp 的主要途径。它读取 HuggingFace 模型检查点(PyTorch .bin 或 SafeTensors .safetensors),将张量名称映射为 GGUF 约定,把超参数写入 GGUF 元数据,最终输出 .gguf 文件。gguf-py/ 库提供了转换器所使用的 Python GGUF 读写工具。

提示: 在量化模型时,Q4_K_M 通常是质量与体积之间最好的平衡点。在使用激进量化级别(Q2_K 及以下)时,建议配合校准数据集使用 --imatrix 以获得最佳效果。

下一步

至此,我们已经走完了从 HTTP 请求到 token 输出的完整链路。本系列的最后一篇文章将转向实战:手把手带你为 llama.cpp 贡献一个新的模型架构,涵盖从 GGUF 转换、计算图构建到测试验证,每一个需要修改的文件都会逐一讲解。