原llama.cpp 启动流程分析

本篇博客仅对于llama.cpp的启动流程与使用做一个简单的介绍,不涉及LLM深层次的解析。

我对于本机运行小型的大语言模型很感兴趣,因而查询到了llama.cpp,并在开发使用的过程中测试老旧GPU硬件通过Vulkan加速后推理速度提升明显,甚感兴奋。于是记录最近阅读llama.cpp源码中的启动流程,方便后期LLM应用开发参考。

有了LLM我真的变得很强,连从没有学过的C++也有胆凭借一点点的C基础来查看。^_^

llama.cpp支持cli与server两种启动方式,开发llama.cpp的应用,第一想到的就是cli模式,于是先从cli的启动源码看起。

llama-cli启动分析

直接查看llama-cli的启动文件,tools/cli/main.cpp:

int llama_cli(int argc, char ** argv);

int main(int argc, char ** argv) {
    return llama_cli(argc, argv);
}

从这里面可以看到入口方法是int llama_cli(int argc, char ** argv), 而这个方法是通过CMakeLists.txt从tools/cli/cli.cpp链接的:

# 1. 将 cli.cpp, cli-client.cpp, cli-context.cpp 编译为一个库 target: llama-cli-impl
add_library(llama-cli-impl cli.cpp
                           cli-client.cpp
                           cli-context.cpp)
# 2. 将 main.cpp 编译为可执行文件 llama-cli
add_executable(llama-cli main.cpp)
# 3. 链接阶段:将 llama-cli 与 llama-cli-impl 库链接在一起!
target_link_libraries(llama-cli PRIVATE llama-cli-impl)

于是打开cli.cpp,可以看到启动流程就做了大概三件事:

  1. 解析用户启动参数
  2. 初始化封装终端的上下文得到cli_context对象
  3. 使用终端上下文cli_context对象进行运行
int llama_cli(int argc, char ** argv) {
    common_params params;     // 初始化启动参数配置

    params.verbosity = LOG_LEVEL_ERROR; // by default, less verbose logs

    common_init(); // 终端控制及输出日志管理

    // 通过 common/arg.cpp 来管理的启动参数解析
    if (!common_params_parse(argc, argv, params, LLAMA_EXAMPLE_CLI)) {
        return 1;
    }

	//.... 平台相关终端处理 ....

    cli_context ctx_cli(params);   // 将启动参数 params 赋值给 cli_context 结构体,同时初始化封装用户的提示词 message。

    if (!ctx_cli.init()) {
        return 1;
    }

    return ctx_cli.run();
}

也就是说cli的核心在tools/cli/cli_context.h和tools/cli/cli_context.cpp中,它们负责管理终端运行的上下文。

先来看一下cli_context.h头文件看里面都定义了一些什么:

struct cli_context {
    common_params params;

    cli_client client;                // HTTP请求客户端用于请求server后端
    std::optional<cli_server> server; // 仅在未指定 --server-base 时设置

    // 连接到 `--server-base` 或启动一个本地 `llama-server` 子进程;
    // 需要 `argc`/`argv` 以便将与服务器相关的参数传递给子进程。
    bool init();

    // 运行交互式聊天循环,返回进程退出码
    int run();

    // 停止本地服务器子进程(如有)
    void shutdown();

    // 由 SIGINT 处理程序设置;中断处理完成后清除
    static std::atomic<bool> & interrupted();

private:

    //.... 省略  .....

	// 用户提示词封装的智能指针
    std::unique_ptr<cli_context_impl> impl;

其实从这里可以看出如果运行llama-cli不指定--server-base时,llama-cli就自己启动一个llama-server后端,实际上llama-cli只是一个使用cli_client对象进行HTTP协议访问的客户端程序。

为了进一步验证,接着上文cli.cpp中的代码查看ctx_cli.init()方法的实现,tools/cli/cli_context.cpp:

bool cli_context::init() {
	// ... 省略 打印加载模型及初始终端输出 ....

    // 判断启动参数中是否有server_base选项,如果有则直接使用server_base连接server
	// 如果没有则自己启动一个本地cli_server
	bool use_external_server = !params.server_base.empty();
    if (use_external_server) {
        std::string base = params.server_base;
        while (!base.empty() && base.back() == '/') {
            base.pop_back();
        }
        client.server_base = base;

        spinner.emplace("Connecting to server at " + base);
    } else {
        // ... 检查必填参数params.model.path是否存在,不存在要给予提示 ...

        // 最重要的一行代码,执行cli_server中的start方法
        if (!server->start(params)) {
            ui::show_error("server start failed");
            return false;
        }
		// 等待服务端运行成功
        if (!server->wait_ready(should_stop)) {
            if (!should_stop()) {
                ui::show_error("the server exited before becoming ready");
            }
            return false;
        }
        client.server_base = server->address();
    }

	// ...  省略 ...
}

这里的bool cli_context::init()方法,印证头文件中的注释,接下来就要看server->start(params)是如何执行的, tools/cli/cli-server.h:

struct cli_server {
	// ... 省略代码 ...

    // 在线程中启动 llama-server,并通过随机端口与其交互
    bool start(common_params & params) {
        port = common_http_get_free_port();  // 获取一个随机可用端口
        if (port <= 0) {
            fprintf(stderr, "failed to get a free port\n");
            exit(1);
        }

        is_alive.store(true, std::memory_order_release);

        common_params server_params = params; // copy
        server_params.port = port;

        th = std::thread([this, server_params]() mutable {
            // argc 和 argv 仅在路由器模式[router mode]下使用,目前可以暂时忽略它们
            int res = llama_server(server_params, 0, nullptr);
            if (res != 0) {
                fprintf(stderr, "llama_server exited with code %d\n", res);
            }
            is_alive.store(false, std::memory_order_release);
        });

        return true;
    }

    std::string address() const {
        return "http://127.0.0.1:" + std::to_string(port);
    }

    // 间隔200毫秒,请求llama-server中的/health 接口,直到接口返回200
    bool wait_ready(std::function<bool()> should_stop) {
        if (!alive()) {
            return false;
        }
        while (!should_stop()) {
            auto [cli, parts] = common_http_client(address());
            cli.set_connection_timeout(1, 0);
            auto res = cli.Get("/health");
            if (res) {
                if (res->status == 200) {
                    return true;
                }
                // 其他任何状态均表示服务器已启动,但尚未就绪
                // (例如,模型仍在加载时返回 503 状态码)
            }
            if (!alive()) {
                // in case server die permanently
                return false;
            }
            std::this_thread::sleep_for(std::chrono::milliseconds(200));
        }
        return true;
    }

	bool alive() const {
        return is_alive.load(std::memory_order_acquire);
    }
}

小结一下,这里的bool cli_context::init()方法实现就是使用系统的一个空闲可用的端口来运行一个llama-server服务。

服务器端llama-server启动成功了,接着就来看int cli_context::run()方法在做什么, tools/cli/cli_context.cpp:

int cli_context::run() {
	// ... 打印banner及使用指令 ...

	while (true) {
		// ... 省略代码 等待用户输入内容 及 检测是否是指令提示词 ...

        cli_timings timings;
        generated_content content;
        generate_completion(content, timings);  // <--- 核心生成对话响应方法

		// ... 省略代码 输出模型返回 ...
	}
}

其实看int cli_context::run()的方法,核心代码就是generate_completion(content, timings)这一行,用于调用模型得到输出及Token输出的速率,接着来看generate_completion(content, timings)的实现, tools/cli/cli_context.cpp:

bool cli_context::generate_completion(generated_content & content_out, cli_timings & timings) {
    // ... 省略代码 封装请求message ....

    std::string err = client.post_sse("/v1/chat/completions", body.dump(), should_stop, [&](const std::string & payload) {
        json chunk = json::parse(payload, nullptr, false);

        // ... 省略代码 解析响应数据 ...
    });

    // ... 省略代码 处理错误信息 ...
}

实际上核心的就是一点,就是请求运行在llama-server中的/v1/chat/completions接口,并将结果输出到终端中。

也就是说llama-cli只是一个运行在终端的HTTP请求客户端,用于通过HTTP SSE协议来请求 llama-server 的生成接口,将结果输出到终端中。

使用UML时序图绘制一下llama-cli的启动过程,一目了然。

llama-cli 启动流程 UML 时序图

sequenceDiagram
    autonumber
    actor User as 用户/终端
    participant Main as main / llama_cli
    participant Ctx as cli_context
    participant ServerObj as cli_server
    participant Client as cli_client / HTTP客户端
    participant ServerThread as llama_server (后台线程)

    User->>Main: 运行命令 (llama-cli)
    Main->>Main: common_params_parse() 解析参数
    Main->>Ctx: 实例化 cli_context 并调用 init()

    alt 未指定 --server-base (默认启动本地服务)
        Ctx->>ServerObj: start(params)
        ServerObj->>ServerObj: common_http_get_free_port() 获取空闲端口
        ServerObj-->>ServerThread: std::thread 启动 llama_server
        activate ServerThread
        ServerThread->>ServerThread: 加载 LLM 模型并监听端口

        Ctx->>ServerObj: wait_ready() 检查就绪状态
        loop 每 200ms 轮询健康状态
            ServerObj->>Client: common_http_client(address) 发起 HTTP 请求
            Client->>ServerThread: GET /health (检查服务就绪情况)
            ServerThread-->>Client: 返回 HTTP 响应 (503 加载中 / 200 就绪)
            Client-->>ServerObj: 返回响应结果
        end
        ServerObj-->>Ctx: wait_ready() 返回 true (就绪成功)
    else 指定了 --server-base
        Ctx->>Client: 设置外部服务地址 (server_base)
    end

    Main->>Ctx: 调用 run()
    loop 交互式生成循环
        User->>Ctx: 输入 Prompt
        Ctx->>Client: generate_completion()
        Client->>ServerThread: POST /v1/chat/completions (SSE 流式请求)
        ServerThread-->>Client: 实时返回 Chunk Token 响应
        Client-->>User: 终端渲染输出
    end

llama-server启动分析

接上面的cli在tools/cli_server.h中调用了llama_server(server_params, 0, nullptr)方法,在本地启动一个本地模型服务,接着我们就来查看这个方法里面究竟干了什么。

转到tools/server目录,发现这个server代码与cli完全不在一个级别上,它要复杂的多,在阅读server的代码之前,我们先要设定我们的目标,我们的目标就是查看本地模型具体是如何被启动的,以及模型在收到用户的提示词后,server又是如何生成响应的,只要明确了目标我们就不会在繁杂的代码中被绕晕。只要跟着程序执行的顺序一行行的分析,一切都会明了!

先查看llama_server()方法的精简实现与主流程,tools/server/server.cpp:

int llama_server(common_params & params, int argc, char ** argv) {
    bool is_run_by_cli = (argv == nullptr);

    // ... 省略代码:若指定了 hf_repo(-hf) 参数,则在此处解析并应用 Hugging Face 远程 Preset 配置 ...

    // 1. 模式判断:当未指定模型路径且未指定 HuggingFace 预设时,进入 Router(路由)模式
    // Router 模式充当网关代理,不直接加载模型权重,也不触碰 GPU
    const bool is_router_server = params.model.path.empty() && params.model.hf_repo.empty();

    common_params_print_info(params, !is_router_server);

    // 2. 初始化核心上下文对象
    server_context ctx_server;      // 核心推理上下文(管理 LLM 模型、Slots、Task 任务队列及推理循环)
    server_http_context ctx_http;    // HTTP 网络服务封装(基于 httplib,负责 TCP 监听与路由转发)

    if (!ctx_http.init(params)) {
        SRV_ERR("%s", "failed to initialize HTTP server\n");
        return 1;
    }

    // 3. API 路由管理与 Handler 挂载
    server_routes routes(params, ctx_server);

    if (is_router_server) {
        // Router 模式:初始化多模型路由管理器,将所有生成/推理接口重定向给代理 Proxy
        models_routes.emplace(params, argc, argv);
        routes.post_chat_completions = models_routes->proxy_post;
        // ... 省略 Router 模式专用的动态模型加载/卸载路由配置 ...
    }

    // 挂载标准 HTTP API 路由映射 (绑定 URL 路径到相应的 Handler 函数)
    ctx_http.get ("/health",              ex_wrapper(routes.get_health));               // 健康检查(公开端点)
    ctx_http.get ("/v1/models",           ex_wrapper(routes.get_models));               // 获取模型列表
    ctx_http.post("/v1/chat/completions", ex_wrapper(routes.post_chat_completions));    // Chat Completions 核心生成接口
    ctx_http.post("/tokenize",            ex_wrapper(routes.post_tokenize));            // Token 分词接口
    ctx_http.post("/detokenize",          ex_wrapper(routes.post_detokenize));          // Token 反分词接口
    // ... 省略其他扩展接口挂载 (如 /embeddings, /rerank, /slots, /cors-proxy 等) ...

    // 4. 定义资源清理回调函数
    std::function<void()> clean_up;

    // 5. 服务的真正启动与模型加载流程
    if (is_router_server) {
        // 【Router 模式】:启动 HTTP 监听,保持主线程存活,根据客户端请求按需动态拉起子进程
        clean_up = [&]() { ... };
        if (!ctx_http.start()) { clean_up(); return 1; }
        ctx_http.is_ready.store(true);

        shutdown_handler = [&](int) {};
    } else {
        // 【单模型模式 / CLI 启动模式】:
        clean_up = [&]() { ... };

        // 步骤 ①:优先启动 HTTP 服务线程(以便立刻响应 cli_server::wait_ready 的 /health 轮询)
        if (!ctx_http.start()) {
            clean_up();
            return 1;
        }

        // 步骤 ②:载入 GGUF 模型与推理上下文
        // load_model 会完成 GGUF 权重加载、分配 KV Cache、初始化 Slots 以及创建任务队列 (queue_tasks)
        if (!ctx_server.load_model(params)) {
            clean_up();
            if (ctx_http.thread.joinable()) { ctx_http.thread.join(); }
            return 1;
        }

        // 步骤 ③:更新路由元数据,并标记服务就绪(此后 /health 请求由返回 503 变为返回 200 OK)
        routes.update_meta(ctx_server);
        ctx_http.is_ready.store(true);
        shutdown_handler = [&](int) {};
    }

    // ... 省略代码:注册 SIGINT / SIGTERM / ConsoleCtrlHandler 操作系统信号处理句柄 ...

    // 5. 阻塞主线程运行服务与 Task 循环 (第二段 if-else 判断)
    if (is_router_server) {
        if (ctx_http.thread.joinable()) {
            ctx_http.thread.join(); // 保持主线程存活,直到服务停止
        }
        clean_up();
    } else {
        // 步骤 ④:主线程进入核心推理任务处理循环
        // start_loop() 将阻塞主线程,不断从 queue_tasks 队列中取出 HTTP 接口提交的 Task 进行 Batch 解码与推理
        ctx_server.start_loop();

        // 当收到终止信号退出 start_loop() 后,清理资源并退出
        clean_up();
        if (ctx_http.thread.joinable()) { ctx_http.thread.join(); }
    }

    return 0;
}

查看server的代码,我们关注点主要有如下几个部分:

  1. 如果启动中的参数没有指定模型路径且未指定 HuggingFace 预设时就进入router mode, 意思就是后期再根据请求按需加载模型。

  2. 初始化了两个核心对象,一个是server_context另一个是server_http_context,server_context主要负责模型的加载和推理任务,server_http_context主要负责HTTP接口的监听和处理。

  3. 加载完两个对象后,进行挂载请求路由,当用户请求不同的API时,通过路由,将用户的请求转发到相应的处理函数中进行处理,其中就包含我们之前看到的/v1/chat/completions接口,指定了他是由routes.post_chat_completions进行处理的,这里我们暂时先按住不表,先来查看模型是如何加载起来的。

  4. 启动http服务,这里如果启动的是router mode路由模式,则仅仅是启用server_http_context服务,管理HTTP的监听请求,不进行模型的加载(后期加载模型是根据HTTP请求的参数,再重新启动一个llama-server子进程来加载启动模型);如果是单模型模式,即指定了模型的路径参数,则使用ctx_server.load_model(params)加载模型。

  5. 如果是单模型模式,还会调用start_loop()方法,开启并驱动一个“生产者-消费者”模式的并发请求任务双端std::deque<server_task>队列 queue_tasks,不断从 queue_tasks 队列中取出 HTTP API 接口封装提交的 server_task 任务对象,进行 Batch 解码与推理。具体的代码在tools/server/server-queue.cpp文件的void server_queue::start_loop(int64_t idle_sleep_ms)方法实现(后面讲解推理返回时再来回看,现在暂时不表)。

从上面的启动流程来看,大致可以看到server的设计思路。我们关注的目标是模型的加载和推理:加载模型是通过ctx_server.load_model(params)实现的;而生成推理响应是通过请求/v1/chat/completionsAPI执行routes.post_chat_completions方法,封装server_task任务,在ctx_server.start_loop()启动的队列中处理的。大体的总流程就是这样的,现在我们来看看模型的加载。

ctx_server.load_model(params)方法可以在tools/server/server-context.cpp文件中定义的:

server_context::server_context() : impl(new server_context_impl()) {}

bool server_context::load_model(common_params & params) {
    return impl->load_model(params);
}

void server_context::start_loop() {
    auto & params = impl->params_base;
    impl->queue_tasks.start_loop(params.sleep_idle_seconds * 1000);
}

这里可以看到不管是模型的加载,还是模型服务任务的队列循环处理,都是通过impl结构体对象来实现的,impl的结构体是什么呢?可以查看tools/server/server-context.h中的impl定义类型为server_context_impl:

struct server_context {
    std::unique_ptr<server_context_impl> impl;
}

而server_context_impl结构体在tools/server/server-context.cpp中定义,定义类型如下:

struct server_context_impl {
    friend struct server_context;

public:
    llama_model * model_tgt = nullptr;

    mtmd_context * mctx = nullptr;
    const llama_vocab * vocab = nullptr;

    server_queue    queue_tasks;
    server_response queue_results;

    // ....

private:

    // ....

    bool load_model(common_params & params) {
        // ...
    }

    bool init() {
        // ...
    }

    // ....

对应的可以看到server_context_impl结构体有server_queue与模型对象及模型上下文对象等等。这里我单独的关注模型的加载及模型的推理响应,所以只保留了load_model()和init()方法。

现在,我们来看看load_model()实现的模型的加载函数:

bool load_model(common_params & params) {
    // ... 省略代码:初始化模型加载进度回调对象 (load_progress_text/mmproj/spec) 并触发 0% 进度通知 ...

    const bool is_resume = sleeping;
    params_base = params;

    const bool has_mmproj = !params.mmproj.path.empty();
    const bool has_spec   = params.speculative.has_dft() || ...;

    // ... 省略代码:若开启 fit_params,预先计算/预估多模态或 Speculative 草稿模型的 VRAM 显存开销 ...

    // 1. 加载常规 LLM 文本模型 (主模型与 Context)
    llama_init = common_init_from_params(params_base);
    model_tgt  = llama_init->model();
    ctx_tgt    = llama_init->context();
    vocab      = llama_model_get_vocab(model_tgt);

    // 2. (可选) 加载 Speculative 投机采样草稿模型
    if (has_spec) {
        spec_init = common_speculative_init_from_params(params_dft, model_tgt, ctx_tgt);
        model_dft = spec_init->model();
        ctx_dft   = spec_init->context();
    }

    // 3. 区分并加载多模态模型 (如 Vision 视觉/音频投影 mmproj 文件)
    if (has_mmproj) {
        mtmd_context_params mparams = mtmd_context_params_default();
        // ... 省略配置多模态 GPU/CPU 线程及 Token 门限等 mparams 参数细节 ...
        mctx = mtmd_init_from_file(mmproj_path.c_str(), model_tgt, mparams);
        if (mctx == nullptr) {
            SRV_ERR("failed to load multimodal model, '%s'\n", mmproj_path.c_str());
            return false;
        }
        SRV_INF("loaded multimodal model, '%s'\n", mmproj_path.c_str());
    }

    // ... 省略代码:校验 Context Shift (上下文平移) 与 SWA (滑动窗口注意力) 等模型特性支持情况 ...

    // 4. 初始化并发 Slots (槽位) 数组与对应的 KV Cache 内存空间
    slots.clear();
    for (int i = 0; i < params_base.n_parallel; i++) {
        slots.emplace_back();
    }

    for (int i = 0; i < params_base.n_parallel; i++) {
        server_slot & slot = slots[i];
        slot.id      = i;
        slot.ctx_tgt = ctx_tgt;
        slot.ctx_dft = ctx_dft;
        slot.mem.init(ctx_tgt, ctx_dft);             // 分配 Slot 专属 KV Cache 空间
        slot.n_ctx   = n_ctx_slot;
        slot.mctx    = mctx;                        // 关联多模态上下文 (若为多模态模型)
        slot.prompt.tokens.has_mtmd = mctx != nullptr;
        slot.reset();
    }

    // ... 省略代码:读取 LLAMA_TRACE 与 LLAMA_SERVER_SLOTS_DEBUG 调试环境变量,并初始化批量计算的 batch 容器 ...

    // 5. 初始化 Prompt Cache 提示词缓存池
    if (params_base.cache_ram_mib != 0) {
        prompt_cache = std::make_unique<server_prompt_cache>(params_base.cache_ram_mib, n_ctx);
    }

    // ... 省略代码:提取模型别名/模型名称 (model_name),设置上下文检查点等 ...

    // 6. 核心点:若不是从休眠状态恢复(即第一次初始化),则调用 init() 关联任务队列回调
    if (!is_resume) {
        return init();
    }

    return true;
}

load_model() 核心加载机制已做了简化,实际上做的就是根据启动参数判断是常规模型还是多模态模型(具有视觉功能),分别根据对应的参数进行加载,同时进行并发Slots的初始化及Prompt Cache提示词缓存池的初始化,最后通过调用init()来将queue_tasks关联三大核心处理回调,用于后期生成推理响应:

  1. 常规模型 vs 多模态模型 (Multimodal) 加载:

    • 常规 LLM 语言模型:通过 common_init_from_params(params_base) 从指定的 .gguf 文件初始化主模型 model_tgt 与推理上下文 ctx_tgt,并提取词表 vocab。
    • 多模态模型(mmproj 视觉/音频):当配置了 params.mmproj.path 时,会额外调用 mtmd_init_from_file(mmproj_path.c_str(), model_tgt, mparams) 来单独加载多模态投影(Vision Projector)上下文 mctx,并将该 mctx 指针挂载到各个并发 Slot 槽位上(slot.mctx = mctx),用以支持图像 Token 解析与跨模态特征融合。对应的多模态模型的处理在tools/mtmd目录中定义,详情可以转到那里去查看。
  2. 并发 Slots(槽位)与 KV Cache 内存分配:

    • 根据设定的并发数 params_base.n_parallel 循环创建 server_slot。
    • 对每一个 Slot 执行 slot.mem.init(ctx_tgt, ctx_dft),在其专属空间中划分并初始化 KV Cache 内存,确保多用户的并发推理对话彼此隔离、互不干扰。
  3. 枢纽连接:if (!is_resume) { return init(); } 任务队列回调句柄绑定:

    • 当模型首次完成加载后,代码会接着调用 init() 方法。在 init() 方法中完成了一项极其关键的操作——为并发任务队列 queue_tasks 关联三大核心处理回调。

这里先来看一下加载模型完成后调用的init()方法,在它其中定义了三个核心处理回调函数:

bool init() {
    // ... 省略代码:断言检查模型指针及推理上下文 (ctx_tgt, model_tgt) 是否合法 ...

    // 1. 核心关联:为并发任务队列 queue_tasks 注册三大处理回调函数 (Wiring up server queues)
    queue_tasks.on_new_task([this](server_task && task) {
        process_single_task(std::move(task));      // 当 HTTP API 发起新请求时:分发 Task 到空闲 Slot
    });
    queue_tasks.on_update_slots([this]() {
        update_slots();                            // 核心推理引擎循环:组装 Batch,调用原生 llama_decode 解码 Token
    });
    queue_tasks.on_sleeping_state([this](bool sleeping) {
        handle_sleeping_state(sleeping);           // 无任务时的空闲休眠与唤醒状态管理
    });

    metrics.init();

    // ... 省略代码:处理 --cache-idle-slots 选项以及解析前端 UI 配置 JSON (json_ui_settings) ...

    // ... 省略代码:解析并初始化对话模版 (Chat Template),配置 Reasoning 思考逻辑及组装 chat_params 参数包 ...
    {
        // ...
        // 核心的代码,负责确定、提取、补丁修正并编译大模型的“对话格式模板(Chat Template)”,以便后续将用户发送的 JSON 对话列表(System/User/Assistant)渲染为大模型能够理解的格式化 Prompt 字符串
        chat_templates = common_chat_templates_init(model_tgt, params_base.chat_template);
        // ...
    }

    return true;
}

这里主要设定了三大回调函数,如果想关注模型的推理响应结果的调用输出,可以直接关注绑定的update_slots()方法。

  1. queue_tasks.on_new_task -> 绑定至 process_single_task(task),负责将 HTTP 接收到的新 Task 分配给空闲 Slot 并写入上下文。
  2. queue_tasks.on_update_slots -> 绑定至 update_slots(),这是整个推理引擎的关键所在!负责在 start_loop 循环中收集各个 Slot 生成的 Token、拼接 Batch 并调用底层 llama_decode 解码。
  3. queue_tasks.on_sleeping_state -> 绑定至 handle_sleeping_state(sleeping),负责无请求时的系统挂起与恢复。

以上基本上就回答了模型是怎么在server被加载的问题,接着就是来回答模型是怎么根据提示词生成推理响应的。回到cli的流程我们知道了模型被server加载后,会等待用户的输入,用户如果有输入就会封装用户的提示词等相关参数,将其通过/v1/chat/completions接口发送给server,而在server中处理这个接口请求的就是routes.post_chat_completions方法,接下来我们详细分析一下routes.post_chat_completions方法的实现(post_chat_completions方法实际上是通过server_routes routes(params, ctx_server)的构造函数,调用的init_routes()方法进行装配的),在tools/server/server-context.cpp是实现:

this->post_chat_completions = [this](const server_http_req & req) {
    auto res = create_response();
    std::vector<raw_buffer> files;
    json body = json::parse(req.body);

    // 解析 OpenAI 兼容的 HTTP 请求体:结合 init() 中初始化的 meta->chat_params 模板,
    // 将包含 system/user/assistant 消息列表的 JSON 渲染转换为模型底层 Prompt,并从中提取图片/音频等多模态文件数据
    json body_parsed = oaicompat_chat_params_parse(
        body,
        meta->chat_params,
        files);

    return handle_completions_impl(
        req,
        SERVER_TASK_TYPE_COMPLETION,
        body_parsed,
        files,
        TASK_RESPONSE_TYPE_OAI_CHAT);
};

routes.post_chat_completions就是封装了用户的请求,封装一下请求参数,接着调用handle_completions_impl方法来处理,我们继续在tools/server/server-context.cpp中查看handle_completions_impl的实现:

std::unique_ptr<server_res_generator> server_routes::handle_completions_impl(
            const server_http_req & req,
            server_task_type type,
            const json & data,
            const std::vector<raw_buffer> & files,
            task_response_type res_type) {
    auto res = create_response();
    auto completion_id = gen_chatcmplid();
    auto & rd = res->rd;

    // ... 省略代码:绑定 req 请求对象、提取 sse_ping_interval 心跳包间隔

    try {
        // 说明:OpenAI API 规范支持一次性传入多个 Prompts,或者请求参数中设置了 n > 1(要求模型针对同一个 Prompt 生成多条不同的回答选择 Choice)。此时 inputs 会有多个,for 循环会将每个分支封装成独立的 server_task,全部压入 tasks 列表。
        std::vector<server_task> tasks;
        const auto & prompt = data.at("prompt");

        // 1. 输入 Prompt 分词处理 (支持文本分词与多模态图片/音频 Prompt 分词)
        std::vector<server_tokens> inputs;
        if (res_type != TASK_RESPONSE_TYPE_NONE && ctx_server.mctx != nullptr) {
            inputs.push_back(process_mtmd_prompt(ctx_server.mctx, prompt.get<std::string>(), files));
        } else {
            inputs = tokenize_input_prompts(ctx_server.vocab, ctx_server.mctx, prompt, true, true);
        }

        // 2. 核心:封装 server_task 任务节点对象
        for (size_t i = 0; i < inputs.size(); i++) {
            server_task task = server_task(type);
            task.id     = rd.get_new_id();        // 分配唯一任务 ID
            task.tokens = std::move(inputs[i]);   // 存入分词后的 Token 序列

            // 评估与解析具体的采样与推理参数 (如 temperature, top_p, max_tokens, stop 停止词等)
            task.params = server_schema::eval_llama_cmpl_schema(
                    ctx_server.vocab, params, meta->slot_n_ctx, meta->logit_bias_eog, data);

            // OpenAI兼容
            task.params.res_type          = res_type;
            task.params.oaicompat_cmpl_id = completion_id;
            task.params.oaicompat_model   = meta->model_name;

            // ...省略:准备子任务...

            tasks.push_back(std::move(task));
        }

        // 3. 核心处理代码: 提交任务,将封装好的 tasks 推入并发队列 (即前面绑定的 queue_tasks)
        rd.post_tasks(std::move(tasks));
    } catch (const std::exception & e) {
        res->error(format_error_response(e.what(), ERROR_TYPE_INVALID_REQUEST));
        return res;
    }

    bool stream = json_value(data, "stream", false);

    // 4. 根据 stream 参数选择非流式响应或流式 (SSE) 响应输出
    if (!stream) {
        // 非流式:调用 rd.wait_for_all() 阻塞等待 Task 在主循环 (start_loop) 中生成完毕,组装完整 JSON 返回
        auto all_results = rd.wait_for_all(req.should_stop);
        // ... 省略代码:解析 all_results 组装单条/多条选择的非流式 JSON 响应结果 ...
        res->ok(arr[0]);
    } else {
        // 流式 (SSE):设置 Content-Type 为 text/event-stream,挂载 set_next 回调
        // 回调中不断通过 rd.next() 消费 queue_results 中生成的 Token 并格式化为 SSE Chunk (data: {...}) 输出给客户端
        res->status = 200;
        res->content_type = "text/event-stream";
        res->set_next([res_this = res.get(), res_type](std::string & output) -> bool {
            // ... 省略代码:循环从 rd.next() 读取新 Token 转化为 "data: ...\n\n" 发送,完成发送 "data: [DONE]\n\n" ...
        });
    }

    return res;
}

看起来handle_completions_impl方法很复杂,实际上做了三件事:第一件就是将输入的提示词根据加载的模型进行嵌入Token化(分为常规文本模型及多模态模型);第二步就是将请求的对象封装为server_task对象,提交给上文中启动的server_queue队列进行处理;第三件就是根据用户选择的流式响应或者非流式响应,将推理结果以不同的处理方式返回给最终用户。

接着我们再深入一下查看server在启动时ctx_server.start_loop()方法开启的队列是如何处理任务的,上文中提到server_context定义的load_model处可以直接看到start_loop()调用的是impl->queue_tasks.start_loop(params.sleep_idle_seconds * 1000)方法,我们直接进行查看server_queue结构体中定义的对应方法,在tools/server/server-queue.cpp中实现:

void server_queue::start_loop(int64_t idle_sleep_ms) {
    running = true;
    time_last_task = ggml_time_ms();

    // 设置最大超时时间为1秒,用于判断是否触发智能休眠
    constexpr auto max_wait_time = std::chrono::seconds(1);

    // 判断闲置超时时间,用于判定是否触发智能休眠
    auto should_sleep = [&]() -> bool {
        if (idle_sleep_ms < 0) return false;
        return (ggml_time_ms() - time_last_task) >= idle_sleep_ms;
    };

    // 核心主循环:在 start_loop 驱动下不断消费队列并触发推理
    while (true) {
        // 1. 弹栈提取新任务:遍历并发队列 queue_tasks 取出待处理的 server_task
        while (true) {
            std::unique_lock<std::mutex> lock(mutex_tasks);
            if (!running) return;
            if (queue_tasks.empty()) break; // 任务已全部弹出,跳出准备组装 Batch

            server_task task = std::move(queue_tasks.front());
            queue_tasks.pop_front();
            lock.unlock();

            // 核心回调①:触发 callback_new_task(task) -> 对应上文 init() 绑定的 process_single_task
            // 作用:寻找空闲 Slot、写入 Prompt Token 并关联 Slot 运行时状态
            callback_new_task(std::move(task));
        }

        // 核心回调②:触发 callback_update_slots() -> 对应上文 init() 绑定的 update_slots
        // 作用:收集各 Slot 生成的本轮 Token,合成 llama_batch,驱动原生 llama_decode 批量解码与 Token 推送
        callback_update_slots();

        {
            std::unique_lock<std::mutex> lock(mutex_tasks);
            time_last_task = ggml_time_ms(); // 刷新最后任务处理时间戳
        }

        // 3. 阻塞等待新 Task 或触发智能休眠 (Condition Variable 机制)
        while (true) {
            std::unique_lock<std::mutex> lock(mutex_tasks);
            if (!running || !queue_tasks.empty()) break; // 有新任务到来,立即跳出等待回到上方循环消费

            // 无任务,检查休眠状态
            if (should_sleep()) {
                sleeping = true;
                // 核心回调③:触发 callback_sleeping_state(true) -> 对应上文 init() 绑定的 handle_sleeping_state
                callback_sleeping_state(true); // 进入休眠状态

                // 使用条件变量阻塞等待新任务唤醒请求 (req_stop_sleeping == true) 或服务退出
                condition_tasks.wait(lock, [&]{
                    return (!running || req_stop_sleeping);
                });

                if (!running) break;

                callback_sleeping_state(false); // 唤醒恢复运行
                sleeping = false;
                time_last_task = ggml_time_ms();

                condition_tasks.notify_all(); // 通知 wait_until_no_sleep()
                break;
            } else {
                // 无任务时使用 condition_tasks.wait_for 阻塞等待(最多 1 秒超时),避免 CPU 空转
                bool res = condition_tasks.wait_for(lock, max_wait_time, [&]{
                    return (!queue_tasks.empty() || !running);
                });
                if (res) break; // 被新任务唤醒跳出等待
            }
        }
    }
}

start_loop 本质就是一个高效解耦网络 IO 与 GPU 推理计算的主事件循环。它通过开启并维护 queue_tasks 队列,使得 HTTP 请求接收(网络多线程)与底层神经网络解码(主线程单/多 Slot Batch 计算)可以并发高效地配合运行,完美诠释了生产者-消费者模式:

  1. 提取 Task 并匹配 Slot(callback_new_task -> process_single_task):

    • 消费 HTTP 线程推入 queue_tasks 双端队列中的 server_task。
    • 触发在 init() 中注册的 callback_new_task(即 process_single_task),寻找可用的并发槽位 server_slot,完成 Prompt 预加载(Prefill)。
  2. 连续批处理解码(callback_update_slots -> update_slots):

    • 触发在 init() 中注册的 callback_update_slots(即 update_slots)。
    • 收集所有在运行中的 Slot 槽位产生的下一个 Token,拼装成一个全局的 llama_batch 统一提交给 GPU 执行一次 llama_decode 解码,并将生成的结果 Chunk 推送到 queue_results 响应队列中供 HTTP 线程发送给客户端。
  3. 智能休眠与条件变量阻塞:

    • 条件变量防空转:当队列中无任务时,通过 condition_tasks.wait_for 挂起线程,防止死循环耗尽 CPU 资源。
    • 智能休眠:当闲置超时达到 idle_sleep_ms 门限,触发 callback_sleeping_state(true) 暂停不必要的资源开销,直至有新 HTTP 请求到达时通过条件变量 notify_all() 精准唤醒。

这里有必要解释一下代码中的 Slot(槽位) 是什么概念。我觉得如果熟悉后端开发,可以将其类比为** “数据库连接池”或“线程池”** : 大模型推理的过程,本质上是将用户的提示词先经过分词与 Embedding 转化为向量,再送入显存中基于 LLM 模型权重(Model Weights)逐层进行矩阵乘法计算。由于模型权重在显存中体积庞大(几 GB 到几十 GB),在显存中只有一份且被所有请求共享,不可能让单次请求独占整个模型权重而让其他请求排队死等。 因此,系统预先规划多个 Slot 就像在显存中切分出多条独立的“车道”:每个 Slot 独立维护属于自己的 KV Cache 内存空间,从而支持多个用户的并发对话互不干扰;而在后台主循环中,只需通过一次 llama_decode 批量计算(Batch Decoding),就能将所有 Slot 产生的 Token 一起提交给 GPU 计算,实现显存吞吐效率的最大化。

其实到这里我们算是回答了模型的推理响应是如何进行的这个问题,同样我们使用UML中的时序图来总结一下server的启动过程,让其流程更加的清晰:

llama-server 启动与请求/响应推理流程 UML 时序图

sequenceDiagram
    autonumber
    actor Client as HTTP客户端 / llama-cli
    participant Main as server.cpp / llama_server()
    participant HttpCtx as server_http_context (httplib网络服务)
    participant Routes as server_routes (路由映射层)
    participant SrvCtx as server_context_impl (load_model / init)
    participant Queue as server_queue (start_loop 任务队列)
    participant SlotEngine as server_slot & update_slots (KV Cache / llama_decode)

    %% 阶段一:服务启动与模型加载
    rect rgb(240, 245, 255)
    note over Main,SrvCtx: 阶段一:服务启动与模型加载
    Client->>Main: 启动服务进程/线程 llama_server(params)
    Main->>HttpCtx: init(params) 初始化 TCP 端口监听
    Main->>Routes: 实例化 server_routes 并绑定 /health, /v1/chat/completions 等路由
    Main->>HttpCtx: start() 启动 HTTP 异步监听线程 (此时 /health 返回 503 加载中)
    Main->>SrvCtx: load_model(params) 载入大模型
    activate SrvCtx
    SrvCtx->>SrvCtx: 1. common_init_from_params 加载 GGUF 文本模型 (model_tgt & ctx_tgt)
    SrvCtx->>SrvCtx: 2. (可选) mtmd_init_from_file 加载多模态 Vision Projector (mctx)
    SrvCtx->>SrvCtx: 3. 循环创建 n_parallel 个 server_slot 并分配专属 KV Cache 显存
    SrvCtx->>SrvCtx: 4. 初始化 Prompt Cache 提示词缓存池 (server_prompt_cache)
    SrvCtx->>SrvCtx: 5. 调用 init() 绑定三大队列回调 (on_new_task, on_update_slots, on_sleeping_state)
    SrvCtx->>SrvCtx: 6. common_chat_templates_init 加载 Chat Template 对话模板
    deactivate SrvCtx
    Main->>HttpCtx: is_ready.store(true) 标记就绪 (此后 /health 返回 200 OK)
    Main->>Queue: ctx_server.start_loop() 阻塞主线程进入事件大循环
    activate Queue
    end

    %% 阶段二:HTTP 请求接收与 Task 封装提交
    rect rgb(245, 255, 245)
    note over Client,Queue: 阶段二:请求接收与 Task 任务队列入队
    Client->>HttpCtx: POST /v1/chat/completions (发送对话请求 JSON, stream=true)
    HttpCtx->>Routes: 触发 post_chat_completions(req)
    Routes->>Routes: oaicompat_chat_params_parse 结合 Chat Template 渲染生成 Prompt
    Routes->>Routes: handle_completions_impl 封装 server_task (设置采样参数与唯一 ID)
    Routes->>Queue: rd.post_tasks(tasks) 将 Task 推入 queue_tasks 队列
    end

    %% 阶段三:队列消费、Continuous Batching 解码与流式响应
    rect rgb(255, 245, 240)
    note over Queue,SlotEngine: 阶段三:start_loop 消费、批处理解码与 SSE 流式返回
    Queue->>SrvCtx: 触发 callback_new_task -> process_single_task(task)
    SrvCtx->>SlotEngine: 分配空闲 Slot,为 Slot 加载 Prompt Token 并标记为 PROCESSING
    Queue->>SlotEngine: 触发 callback_update_slots -> update_slots()
    loop 每轮解码大循环 (Continuous Batching)
        SlotEngine->>SlotEngine: 收集所有活跃 Slot 产生的新 Token,拼装为全局 llama_batch
        SlotEngine->>SlotEngine: 调用原生 llama_decode(ctx, batch) 驱动 GPU 神经网络矩阵乘法解码
        SlotEngine->>HttpCtx: 将生成的结果 Chunk Token 写入 queue_results
        HttpCtx-->>Client: HTTP SSE 实时流式推送 data: {"choices": [...]}\n\n
    end
    SlotEngine->>HttpCtx: 检测到 EOS 结束符/达到 max_tokens,推入完成帧
    HttpCtx-->>Client: 推送流式结束标志 data: [DONE]\n\n
    end

如果要进一步了解模型的推理计算过程,可以继续深入查看tools/server/server-context.cpp中的void update_slots()方法。这里我就不深入了,因为这对于我这个初学者来说很不友好,我的目的只是简单的查看llama.cpp如何被应用。

如果还是抑制不住对于llama.cpp的推理过程感兴趣可以查看它的示例程序,如 examples/simple/simple.cpp,或者 examples/simple-chat/simple-chat.cpp,里面的examples目录还有更多好玩的例子,同时docs目录还有一些文档说明可以看一下。

尾声

以上就是我对于llama.cpp的一些浅显的理解,主要集中在如何应用llama.cpp进行本地大模型的应用。因为现阶段确实有很多优秀的开源小参大模型值得我们去尝试,比如飞桨PaddleOCR-VL-1.6-GGUF,混元翻译Hy-MT2-1.8B等等,这些量化后的小参LLM在特定任务下表现十分亮眼。我也因此开发了一个小工具用于调用这些LLM在本机上运行:LinguaAlpaca,欢迎大家交流学习。

网友评论 (0)

暂无评论,快来抢沙发吧~

发表评论

目录