第 10 章 训练-推理分离与 HTTP 服务
|
本章导读:把前九章的实验代码重组为训练与推理分离的可部署工程, 并以手写 HTTP 服务器对外提供推理服务。这一步把模型从「跑一次的脚本」 变成「7×24 的服务」,也是它第一次作为基础设施长期运行。 学完本章你将能够:
产出与代码: |
10.1 原理概述:从「脚本」到「服务」
第 7 章的 chapter07 是一个从头跑到尾的 main 函数:加载权重 → 训练 →
生成 → 评分,一次运行就结束。教程阶段这样组织没有问题;真实部署不是
这样——没有人希望在修改训练代码时,正在处理请求的服务进程跟着重启。
所以本章的第一件事不是写代码,是拆进程:训练归训练,推理归推理。
拆分的动机有三条:
-
资源画像不同:训练需要大显存、数据集与优化器状态,跑完即退出; 推理只需要权重与常驻内存,7×24 在线。两种负载放进同一个进程, 资源调度互相牵制;
-
迭代解耦:修改训练脚本不触碰服务进程,反之亦然;
-
服务二进制更精简:推理只需要权重,不需要 AdamW 动量(第 5 章 已验证)。分离后服务端不链接训练代码,加载更快。
训练与推理运行的是同一个模型 forward,为什么还要拆?因为拆分的判据
不是「代码是否复用」,而是「生命周期是否相同」。训练进程跑几小时就
结束,推理进程要连续运行数周。两个进程之间唯一的共享物是模型权重
——一个 .pth 文件就是训练侧与推理侧的全部契约,谁也不依赖谁的代码。
10.2 工程结构:core 静态库 + 两个可执行
第 2 到第 7 章的代码靠「章节间 include」复用(例如 chapter07 直接
引用 chapter02 的源码目录)。独立工程不再这样做:把稳定组件收进
core/,编译成静态库,供训练与服务两个可执行文件链接:
chapter10_serving/
├── CMakeLists.txt # 顶层:core 静态库 + train + serve
├── core/ # 共享核心(第 2–7 章组件的稳定版本)
│ ├── gpt_sft_core.h/.cpp # 统一 API 门面(本章重点)
│ ├── gpt.h / attention.h / bpe_tokenizer.* / safetensors.* / instruction.h
├── train/src/sft_train.cpp # 训练 CLI
├── serve/src/sft_serve.cpp # HTTP 服务
├── serve/src/sft_chat.cpp # 命令行对话
└── scripts/build.sh / run.sh
这套结构可以概括为一句话:算法一行未改,改的只是组织方式。分词器、
注意力、GPT 模型、safetensors 加载,这些在前九章反复验证过的组件
进了 core/,就不再属于任何一章,两个可执行文件共享同一份实现。
收益是直接的:共享代码只维护一份,训练侧的问题也不会波及服务侧。
统一 API 门面(core/gpt_sft_core.h)
代码收进 core 只是第一步。如果训练与服务两侧各自手写一遍「拼模板 → 生成 → 抽取回复」的流程,重复逻辑还是会扩散。门面(facade)解决的 就是这个问题:给两侧提供一组统一的最小接口。
namespace gpt_sft {
struct ModelConfig { // GPT-2 各规模一张表
int64_t emb_dim = 1024; // medium: 1024 / small: 768 / large: 1280
int64_t n_heads = 16; // medium: 16 / small: 12 / large: 20
int64_t n_layers = 24; // medium: 24 / small: 12 / large: 36
// ...
};
ModelConfig config_for_size(const std::string& size); // "small"/"medium"/...
ch4::GPTModel make_model(const ModelConfig& cfg);
ch4::GPTModel load_pretrained(const ModelConfig& cfg, const std::string& st_path);
double train_instruction(ch4::GPTModel& model, ...); // 第 7 章同款微调
std::string generate_response(ch4::GPTModel& model, ch2::BpeTokenizer& tokenizer,
const std::string& instruction,
const std::string& input,
int64_t max_new_tokens, const torch::Device& device);
}
generate_response 把第 7 章分散的多个步骤收成一行调用:内部依次
执行 format_input、贪心解码(eos_id=50256 停止)与 extract_response。
门面这个设计的核心是约束调用方的视野:训练进程与服务进程都不需要
知道 safetensors 键名映射、张量形状翻转这些细节。将来若把
generate_response 的内部实现改成温度采样,两个调用方一行代码都不用改。
反过来,如果 core 暴露全部内部接口,两侧迟早会绕过门面直接访问内部
实现;内部结构一旦变动,两侧代码都要跟着改。
10.3 训练 CLI:sft_train
第 7 章的训练逻辑包装成了一个参数化命令行工具(train/src/sft_train.cpp)。
用法如下:
./scripts/run.sh train --epochs 2 --out data/gpt2-medium-sft.pth
# 完整参数:
# --data <json> 指令数据集(默认 data/instruction-data.json)
# --weights <st> 预训练权重(默认 data/gpt2-medium.safetensors)
# --size <...> small|medium|large|xl(默认 medium)
# --epochs 2 --batch 8 --lr 5e-5
# --no-cuda 强制 CPU
运行流程与第 7 章一致:load_pretrained(safetensors 键名映射与形状
翻转)→ train_instruction(-100 掩码交叉熵)→ torch::save。
第 7 章 main 函数里的多个步骤,现在收进一个二进制加一组参数。运行
结束得到一个 .pth 文件——训练的全部产出,也是推理服务的唯一输入。
|
|
为什么不自动适配?.pth 里只有权重张量,没有架构信息,服务端只能
先按 --size 确定自己的架构,再去加载权重。而且,形状碰巧兼容的
错配会静默产出错误输出,比抛异常更难排查。一个便宜的防错做法:
训练完成后把规模写进文件名(如 gpt2-medium-sft.pth)。
10.4 推理服务:sft_serve
HTTP 最小知识:一个请求长什么样
手写 HTTP 服务器之前,先看 HTTP/1.1 的最小骨架。sft_serve 要解析
的就是这样的文本协议:
POST /v1/chat HTTP/1.1 ← 请求行:方法 + 路径 + 协议版本
Host: localhost:8080
Content-Type: application/json
Content-Length: 58 ← 告诉服务器 body 有多少字节
← 空行(\r\n\r\n)分隔头与体
{"instruction":"Convert the active sentence to passive: ..."}
响应同样是一段文本:状态行,加响应头(Content-Type、
Content-Length、Connection: close),加响应体。Connection: close
表示一问一答之后关闭连接。有了它,服务器的实现可以简化为
「读请求 → 发响应 → 关闭」,不必处理 keep-alive 的连接复用。
三个 socket 细节各有工程理由,每个都对应一类真实故障:
-
SO_REUSEADDR:服务重启时,处于 TIME_WAIT 状态的旧连接仍占用 端口,Linux 上要滞留 1–2 分钟才释放。不设置这个选项,重启后就要 等这么久才能重新 bind——「服务明明停了,端口却还占着」就是这类 故障; -
Content-Length截断 body:TCP 可能把两次请求的字节连在一起送达 (粘包),按头部声明的长度截断,才能保证解析出的 JSON 干净完整; -
listen(fd, 16)的 16 是等待被 accept 的连接队列的长度。推理请求处理慢 (数百毫秒),队列用来容纳「已到达但尚未被 accept」的连接,避免 瞬时并发把客户端直接拒绝。
手写 HTTP 服务器:不到 90 行
serve/src/sft_serve.cpp 没有使用任何 HTTP 库,直接用 POSIX socket
(sys/socket.h)实现:
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
addr.sin_port = htons(port);
bind(listen_fd, ...); listen(listen_fd, 16);
for (;;) { // 主循环:accept + 丢线程
int client = accept(listen_fd, nullptr, nullptr);
if (client < 0) continue;
std::thread([client, &model, &tokenizer, &device] {
handle_client(client, model, tokenizer, device);
}).detach(); // 一请求一线程
}
请求解析同样简单:按 \r\n\r\n 切分头部与请求体,按 Content-Length
截断 body,再用 nlohmann::json 解析;响应是手工拼接的 HTTP/1.1
字符串。这套实现面向教学演示;生产环境可替换为 asio 或 httplib
——接口层与推理层已经解耦,替换只动接口这一个文件。
手写一遍的价值不在生产可用,而在认知:请求解析、连接管理这些 网络框架替你做的事,你都亲手实现过一次。之后阅读任何网络框架, 都能判断它的行为是否符合预期。
并发与线程安全
每个请求由一个 std::thread(detach 模式)处理,所有线程共享同一个
model 对象。十个 curl 同时请求,就是十个线程在同一块内存上跑
forward。在一般 C++ 代码里,这种写法通常意味着数据竞争;这里恰好
安全,条件有三条,每一条都来自设计选择:
-
LibTorch 的
forward()是线程安全的,底层 aten 算子支持并发调用; -
本服务只做推理,没有
zero_grad/step这类有状态的写操作,参数 张量自始至终只读; -
分词器同样只读;唯一的计数器
requests用std::atomic<int>。
归纳起来:安全性来自「只读」。这也是下面 IMPORTANT 要警告的出发点。
「一请求一线程」只是线程模型谱系里最简单的一档:
| 模型 | 优点 | 缺点 |
|---|---|---|
一请求一线程(本章) |
实现最简单,隔离性好 |
高并发下线程创建开销与内存暴涨 |
固定线程池 + 任务队列 |
线程数可控,复用创建成本 |
需要队列管理;慢请求会占住工作线程 |
事件循环 / io_uring / asio |
单线程支撑海量连接(C10K) |
回调复杂度高;CPU 密集的 forward 会阻塞事件循环 |
LLM 推理还有一个特殊性:每个请求都是几百毫秒到几秒的 CPU/GPU 密集 计算。对这种负载,线程池的意义不在提高吞吐(瓶颈在 GPU),而在限制 并发上限,防止无界并发打爆显存——这正是练习 2 要你压测观察的现象。 工业级方案还有连续批处理(continuous batching):把并发到达的请求 动态拼成 batch 一起 forward,GPU 利用率成倍提升(OSDI 2022 的 Orca 系统提出,见拓展阅读)。那超出了本章范围;本章的目标是让你看懂 它在解决什么问题。
|
如果把这段代码改成「边服务边训练」(在线学习),一请求一线程模型 立刻不安全:参数张量会被并发读写。推理服务的「无状态」不是编码 习惯,而是并发正确性的前提。 |
REST API
sft_serve 暴露三个端点:
| 端点 | 方法 | 请求 | 响应 |
|---|---|---|---|
|
GET |
— |
|
|
POST |
|
|
|
POST |
OpenAI 风格 |
同上 |
/health 供监控探活;/v1/chat 是本项目自定义格式;/v1/generate
兼容 OpenAI 风格的 messages 数组。{role, content} 数组如今是
行业事实标准,大量现成客户端只认这种格式——兼容它,这些客户端
就能直接接入你的服务。
/v1/chat 的处理链路(handle_client):解析 JSON → 取
instruction/input/max_tokens(兼容 max_new_tokens 键名)→
若请求体是 OpenAI 风格 messages,抽取 role 为 user 的 content →
调用 generate_response → 包装成 JSON 返回。
要说明兼容的边界:这里兼容的只是 messages 形状,而非完整
OpenAI API——没有 system 角色注入,也没有流式输出。它能让现成客户端
跑起来,但不能替代官方接口。
10.5 编译运行与预期输出
按顺序执行以下命令:
cd chapters/chapter10_serving
../../scripts/download-data.sh && ../../scripts/download-weights.sh medium # 下载数据与权重(仓库根 scripts/)
./scripts/build.sh # 编译 core + train + serve
./scripts/run.sh train --epochs 2 --out data/gpt2-medium-sft.pth # 训练,产出 .pth
./scripts/run.sh serve --model data/gpt2-medium-sft.pth --port 8080 # 启动服务
服务启动后另开一个终端,依次验证三个端点:
curl --noproxy '*' localhost:8080/health
# {"status":"ok","model":"chapter10_serving"}
curl --noproxy '*' -X POST localhost:8080/v1/chat \
-d '{"instruction":"Convert the active sentence to passive: The chef cooks the meal every day."}'
# {"response":"The meal is prepared by the chef.","instruction":"...","input":""}
curl --noproxy '*' -X POST localhost:8080/v1/generate \
-d '{"messages":[{"role":"user","content":"What is the capital of France?"}]}'
# 响应结构与 /v1/chat 相同,response 字段为模型生成的回复
三条请求都返回正常,模型就从脚本变成了服务。
之后有两类使用方式要区分。临时验证与本地调试用 sft_chat:支持
单条指令(./scripts/run.sh chat --model … "指令")与交互模式
(Ctrl-D 退出)。要让它 7×24 常驻,用 README 附带的 systemd 单元
文件:Restart=always 让服务崩溃后自动拉起、开机自启,
LD_LIBRARY_PATH 指向 LibTorch/CUDA。至此,微调模型成为可由
systemd 托管的常驻服务——本章导读说的「模型从脚本变成服务」,在这里
完成。
本章小结
-
训练与推理在资源画像、生命周期、迭代节奏上的差异,决定了二者应当拆分为独立进程;训练的全部产出(
.pth)是推理服务的唯一输入,一个文件就是全部契约。 -
core 静态库 + train/serve 两个可执行文件,是章节代码走向工程形态的组织方式:共享代码归 core,进程私有代码归各自目录;统一 API 门面把「正确用法」固化成唯一用法。
-
裸 socket HTTP 服务演示了请求解析、一请求一线程并发模型与只读推理的线程安全条件——安全性来自「只读」,在线学习会当场破坏这个前提。
-
三个端点分工明确:
/health探活;/v1/chat为项目自定义格式;/v1/generate兼容 OpenAI 风格messages,可直接对接现成客户端生态;systemdRestart=always提供崩溃自启与开机自启。 -
接口层与推理层解耦,教学实现可平滑替换为生产级框架(asio/httplib);工业级方案的下一步是连续批处理。
自测清单
能不回看就答出这些,本章就算过关:
-
build.sh产出三个目标:sft_train / sft_serve / sft_chat(core 静态库链接成功) -
sft_train产出gpt2-medium-sft.pth(约 1.4 GB,355M 参数 × 4B) -
/health返回 ok;/v1/chat对「主动句改被动」返回被动句 -
OpenAI 风格
messages请求与裸instruction请求都能正确推理 -
并发 curl × 10 不崩(一请求一线程 + eval 模式只读推理)
-
--size与.pth不匹配时报错清晰(形状不匹配异常) -
能说出三个 socket 细节(SO_REUSEADDR / Content-Length / backlog)各对应什么故障
-
能说出为什么「只读推理」是一请求一线程安全的前提
习题
-
给
/v1/chat增加temperature与top_k请求参数,透传给第 5 章 的generate——把采样参数的控制权交给客户端。 -
把一请求一线程换成固定大小线程池(
std::thread数组 + 任务队列), 压测对比 QPS。 -
加一个
POST /v1/load端点:运行时热切换.pth(提示:注意并发 forward 与参数替换的竞态,需要读写锁)。 -
用
wrk或ab压测/v1/chat,找到单卡吞吐上限;观察 GPU 利用率 与 batch 效果——并发请求没有自动批处理时,GPU 利用率为什么低?
拓展阅读
-
上一章:第 9 章 LoRA —— 约 10 MB 适配器 + 本章服务, 构成「一基座多任务」的最小组合
-
socket(7) Linux 手册(SO_REUSEADDR、SOCK_STREAM、端口绑定的权威出处): https://man7.org/linux/man-pages/man7/socket.7.html
-
HTTP/1.1 规范 RFC 9112(请求行、头部、Content-Length 语义): https://www.rfc-editor.org/rfc/rfc9112
-
TCP 连接状态与 TIME_WAIT(RFC 9293): https://www.rfc-editor.org/rfc/rfc9293
-
OpenAI Chat Completions API 文档(
messages格式出处): https://platform.openai.com/docs/api-reference/chat -
systemd.service 手册(
Restart=always、常驻服务托管): https://www.freedesktop.org/software/systemd/man/systemd.service.html -
Yu et al., OSDI 2022, Orca: A Distributed Serving System for Transformer-Based Generative Models(连续批处理原始论文): https://www.usenix.org/conference/osdi22/presentation/yu
-
C10K 问题(Dan Kegel, 1999,事件循环路线的起点): http://www.kegel.com/c10k.html