本篇博客仅对于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,可以看到启动流程就做了大概三件事:
- 解析用户启动参数
- 初始化封装终端的上下文得到cli_context对象
- 使用终端上下文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的代码,我们关注点主要有如下几个部分:
如果启动中的参数没有指定模型路径且未指定 HuggingFace 预设时就进入router mode, 意思就是后期再根据请求按需加载模型。
初始化了两个核心对象,一个是
server_context另一个是server_http_context,server_context主要负责模型的加载和推理任务,server_http_context主要负责HTTP接口的监听和处理。加载完两个对象后,进行挂载请求路由,当用户请求不同的API时,通过路由,将用户的请求转发到相应的处理函数中进行处理,其中就包含我们之前看到的
/v1/chat/completions接口,指定了他是由routes.post_chat_completions进行处理的,这里我们暂时先按住不表,先来查看模型是如何加载起来的。启动http服务,这里如果启动的是router mode路由模式,则仅仅是启用
server_http_context服务,管理HTTP的监听请求,不进行模型的加载(后期加载模型是根据HTTP请求的参数,再重新启动一个llama-server子进程来加载启动模型);如果是单模型模式,即指定了模型的路径参数,则使用ctx_server.load_model(params)加载模型。如果是单模型模式,还会调用
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关联三大核心处理回调,用于后期生成推理响应:
常规模型 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目录中定义,详情可以转到那里去查看。
- 常规 LLM 语言模型:通过
并发 Slots(槽位)与 KV Cache 内存分配:
- 根据设定的并发数
params_base.n_parallel循环创建server_slot。 - 对每一个 Slot 执行
slot.mem.init(ctx_tgt, ctx_dft),在其专属空间中划分并初始化 KV Cache 内存,确保多用户的并发推理对话彼此隔离、互不干扰。
- 根据设定的并发数
枢纽连接:
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()方法。
queue_tasks.on_new_task-> 绑定至process_single_task(task),负责将 HTTP 接收到的新 Task 分配给空闲 Slot 并写入上下文。queue_tasks.on_update_slots-> 绑定至update_slots(),这是整个推理引擎的关键所在!负责在start_loop循环中收集各个 Slot 生成的 Token、拼接 Batch 并调用底层llama_decode解码。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 计算)可以并发高效地配合运行,完美诠释了生产者-消费者模式:
提取 Task 并匹配 Slot(
callback_new_task->process_single_task):- 消费 HTTP 线程推入
queue_tasks双端队列中的server_task。 - 触发在
init()中注册的callback_new_task(即process_single_task),寻找可用的并发槽位server_slot,完成 Prompt 预加载(Prefill)。
- 消费 HTTP 线程推入
连续批处理解码(
callback_update_slots->update_slots):- 触发在
init()中注册的callback_update_slots(即update_slots)。 - 收集所有在运行中的 Slot 槽位产生的下一个 Token,拼装成一个全局的
llama_batch统一提交给 GPU 执行一次llama_decode解码,并将生成的结果 Chunk 推送到queue_results响应队列中供 HTTP 线程发送给客户端。
- 触发在
智能休眠与条件变量阻塞:
- 条件变量防空转:当队列中无任务时,通过
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)
暂无评论,快来抢沙发吧~