跟着LAP啥都学 跟着LAP啥都学

第 10 章 训练-推理分离与 HTTP 服务

      +

      本章导读:把前九章的实验代码重组为训练与推理分离的可部署工程, 并以手写 HTTP 服务器对外提供推理服务。这一步把模型从「跑一次的脚本」 变成「7×24 的服务」,也是它第一次作为基础设施长期运行。

      学完本章你将能够:

      1. 理解训练与推理在资源画像与生命周期上的差异;

      2. 掌握 core 静态库 + 多可执行文件的工程组织方式;

      3. 实现 HTTP/1.1 请求解析与一请求一线程的并发模型;

      4. 分析推理服务的线程安全条件与扩展方向。

      产出与代码:chapters/chapter10_serving/(core/gpt_sft_core.h、train/src/sft_train.cpp、 serve/src/sft_serve.cpp、serve/src/sft_chat.cpp)。

      10.1 原理概述:从「脚本」到「服务」

      第 7 章的 chapter07 是一个从头跑到尾的 main 函数:加载权重 → 训练 → 生成 → 评分,一次运行就结束。教程阶段这样组织没有问题;真实部署不是 这样——没有人希望在修改训练代码时,正在处理请求的服务进程跟着重启。

      所以本章的第一件事不是写代码,是拆进程:训练归训练,推理归推理。

      Diagram

      拆分的动机有三条:

      1. 资源画像不同:训练需要大显存、数据集与优化器状态,跑完即退出; 推理只需要权重与常驻内存,7×24 在线。两种负载放进同一个进程, 资源调度互相牵制;

      2. 迭代解耦:修改训练脚本不触碰服务进程,反之亦然;

      3. 服务二进制更精简:推理只需要权重,不需要 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 必须匹配:small 与 medium 的张量形状不同,用 medium 的服务加载 small 的权重,torch::load 会直接抛出形状不匹配 异常(第 5 章 assign 的「让失败显式」原则在框架层面再次生效)。

      为什么不自动适配?.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++ 代码里,这种写法通常意味着数据竞争;这里恰好 安全,条件有三条,每一条都来自设计选择:

      1. LibTorch 的 forward() 是线程安全的,底层 aten 算子支持并发调用;

      2. 本服务只做推理,没有 zero_grad/step 这类有状态的写操作,参数 张量自始至终只读;

      3. 分词器同样只读;唯一的计数器 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 暴露三个端点:

      端点 方法 请求 响应

      /health

      GET

      —

      {"status":"ok","model":"chapter10_serving"}

      /v1/chat

      POST

      {"instruction":"…​","input":"?","max_tokens":128}

      {"response":"…​",…​}

      /v1/generate

      POST

      OpenAI 风格 {"messages":[{"role":"user","content":"…​"}]}

      同上

      /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,可直接对接现成客户端生态;systemd Restart=always 提供崩溃自启与开机自启。

      • 接口层与推理层解耦,教学实现可平滑替换为生产级框架(asio/httplib);工业级方案的下一步是连续批处理。

      自测清单

      能不回看就答出这些,本章就算过关:

      1. build.sh 产出三个目标:sft_train / sft_serve / sft_chat(core 静态库链接成功)

      2. sft_train 产出 gpt2-medium-sft.pth(约 1.4 GB,355M 参数 × 4B)

      3. /health 返回 ok;/v1/chat 对「主动句改被动」返回被动句

      4. OpenAI 风格 messages 请求与裸 instruction 请求都能正确推理

      5. 并发 curl × 10 不崩(一请求一线程 + eval 模式只读推理)

      6. --size 与 .pth 不匹配时报错清晰(形状不匹配异常)

      7. 能说出三个 socket 细节(SO_REUSEADDR / Content-Length / backlog)各对应什么故障

      8. 能说出为什么「只读推理」是一请求一线程安全的前提

      习题

      1. 给 /v1/chat 增加 temperature 与 top_k 请求参数,透传给第 5 章 的 generate——把采样参数的控制权交给客户端。

      2. 把一请求一线程换成固定大小线程池(std::thread 数组 + 任务队列), 压测对比 QPS。

      3. 加一个 POST /v1/load 端点:运行时热切换 .pth(提示:注意并发 forward 与参数替换的竞态,需要读写锁)。

      4. 用 wrk 或 ab 压测 /v1/chat,找到单卡吞吐上限;观察 GPU 利用率 与 batch 效果——并发请求没有自动批处理时,GPU 利用率为什么低?

      拓展阅读