基于 cpp-httplib 客户端实现 LLM 模型下载与切换:Hugging Face 模型管理实战

原创2026-10-01 10:06:241,763 阅读
文章标签:后端网络Web框架

基于 cpp-httplib 客户端实现 LLM 模型下载与切换:Hugging Face 模型管理实战

导读

前几章利用 httplib::Server 完成了 REST 翻译 API 与 SSE 流式输出,但模型文件仍是第 1 章手动下载的单个 GGUF 文件。本章将启用 cpp-httplib 的客户端能力,从应用内部直接连接 Hugging Face,实现模型的一键下载、进度上报与运行时切换。读完本章你将掌握:TLS 客户端的构建配置、基于回调的大文件流式下载、通过 SSE 向客户端实时上报下载进度,以及在多线程服务器中使用互斥锁安全地热切换 LLM 模型。

目标效果:通过 HTTP 接口管理模型

本章完成后,模型管理功能会以两个新端点呈现。首先是用 GET /models 查询可用模型及其下载/选中状态:

curl http://localhost:8080/models
{
  "models": [
    {"name": "gemma-2-2b-it", "params": "2B", "size": "1.6 GB", "downloaded": true, "selected": true},
    {"name": "gemma-2-9b-it", "params": "9B", "size": "5.8 GB", "downloaded": false, "selected": false},
    {"name": "Llama-3.1-8B-Instruct", "params": "8B", "size": "4.9 GB", "downloaded": false, "selected": false}
  ]
}

其次是 POST /models/select 切换模型。若目标模型尚未下载,服务端会立即开始下载,并通过 SSE 持续推送进度(curl -N 让进度不被缓冲):

curl -N -X POST http://localhost:8080/models/select \
  -H "Content-Type: application/json" \
  -d '{"model": "gemma-2-9b-it"}'
data: {"status":"downloading","progress":0}
data: {"status":"downloading","progress":12}
...
data: {"status":"downloading","progress":100}
data: {"status":"loading"}
data: {"status":"ready"}

4.1 httplib::Client 基础:从只做服务端到同时发起 HTTPS 请求

此前代码只用到了 httplib::Server,而 cpp-httplib 本身是头文件库,同时内置了客户端能力(见 httplib.h 中 ClientImpl/Client 的定义)。由于 Hugging Face 走 HTTPS,客户端必须启用 TLS。cpp-httplib 的写法非常简洁——URL 携带 https:// 协议时,客户端会自动选择 SSL 实现:

#include <httplib.h>

// URL 含协议时自动使用 SSL 客户端
httplib::Client cli("https://huggingface.co");

// 自动跟随重定向(Hugging Face 会重定向到 CDN)
cli.set_follow_location(true);

auto res = cli.Get("/api/models");
if (res && res->status == 200) {
  std::cout << res->body << std::endl;
}

其中 set_follow_location(true) 与 set_read_timeout(...) 在 httplib.h 中有完整声明:set_read_timeout 同时支持秒级参数和 std::chrono::duration 重载,本章的下载函数正是利用 chrono 重载把读超时放宽到 1 小时。

构建期启用 TLS:CMake 中的 OpenSSL 配置

HTTPS 需要编译期开启 OpenSSL 支持,在 CMakeLists.txt 中加入:

find_package(OpenSSL REQUIRED)

target_link_libraries(translate-server PRIVATE OpenSSL::SSL OpenSSL::Crypto)
target_compile_definitions(translate-server PRIVATE CPPHTTPLIB_OPENSSL_SUPPORT)

# macOS: 读取系统证书链所需
if(APPLE)
  target_link_libraries(translate-server PRIVATE "-framework CoreFoundation" "-framework Security")
endif()

CPPHTTPLIB_OPENSSL_SUPPORT 是仓库约定的 TLS 开关:在 httplib.h 中,该宏控制 #include <openssl/ssl.h> 等头文件、SSL 相关类型与 SSLClient/SSLServer 的编译;CMakeLists.txt 也确认了项目自身构建时 HTTPLIB_IS_USING_OPENSSL 与 CPPHTTPLIB_OPENSSL_SUPPORT 的对应关系。macOS 上 OpenSSL 需要访问 Keychain 系统证书,因此还要链接 CoreFoundation 与 Security 框架(httplib.h 中的 CPPHTTPLIB_USE_CERTS_FROM_MACOSX_KEYCHAIN 逻辑就是为此准备的)。完整配置见 4.8 节。

4.2 定义模型清单:以元数据驱动的多模型管理

应用可处理的模型由一个全局静态表定义,便于后续所有端点统一遍历:

struct ModelInfo {
  std::string name;       // 显示名
  std::string params;     // 参数量
  std::string size;       // GGUF Q4 量化后大小
  std::string repo;       // Hugging Face 仓库
  std::string filename;   // GGUF 文件名
};

const std::vector<ModelInfo> MODELS = {
  {
    .name     = "gemma-2-2b-it",
    .params   = "2B",
    .size     = "1.6 GB",
    .repo     = "bartowski/gemma-2-2b-it-GGUF",
    .filename = "gemma-2-2b-it-Q4_K_M.gguf",
  },
  {
    .name     = "gemma-2-9b-it",
    .params   = "9B",
    .size     = "5.8 GB",
    .repo     = "bartowski/gemma-2-9b-it-GGUF",
    .filename = "gemma-2-9b-it-Q4_K_M.gguf",
  },
  {
    .name     = "Llama-3.1-8B-Instruct",
    .params   = "8B",
    .size     = "4.9 GB",
    .repo     = "bartowski/Meta-Llama-3.1-8B-Instruct-GGUF",
    .filename = "Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf",
  },
};

repo 与 filename 两列被 download_model 用来拼装 Hugging Face 的下载 URL;name/params/size 则直接进入 GET /models 的 JSON 响应。这样模型清单、下载逻辑与列表接口由同一份数据驱动,新增模型只需在此追加一条记录。

4.3 模型存储位置:从项目目录迁移到用户目录

第 3 章之前模型放在项目内的 models/。管理多个模型时,改放到应用专用目录更合理:macOS/Linux 使用 ~/.translate-app/models/,Windows 使用 %APPDATA%\translate-app\models\:

std::filesystem::path get_models_dir() {
#ifdef _WIN32
  auto env = std::getenv("APPDATA");
  auto base = env ? std::filesystem::path(env) : std::filesystem::path(".");
  return base / "translate-app" / "models";
#else
  auto env = std::getenv("HOME");
  auto base = env ? std::filesystem::path(env) : std::filesystem::path(".");
  return base / ".translate-app" / "models";
#endif
}

环境变量未设置时回退到当前目录。目录在应用启动时通过 std::filesystem::create_directories 自动创建——该函数对已存在的目录不会报错,因此可安全地幂等调用。

4.4 重写模型初始化:启动即自动补齐默认模型

模型初始化从硬编码路径改为“目录 + 文件名 + 互斥锁”的组合。selected_model 记录当前加载的文件名,启动时加载 MODELS 首项;GET /models 与 POST /models/select 都会读写该变量:

int main() {
  auto models_dir = get_models_dir();
  std::filesystem::create_directories(models_dir);

  std::string selected_model = MODELS[0].filename;
  auto path = models_dir / selected_model;

  // 默认模型未下载时启动自动拉取
  if (!std::filesystem::exists(path)) {
    std::cout << "Downloading " << selected_model << "..." << std::endl;
    if (!download_model(MODELS[0], [](int pct) {
          std::cout << "\r" << pct << "%" << std::flush;
          return true;
        })) {
      std::cerr << "\nFailed to download model." << std::endl;
      return 1;
    }
    std::cout << std::endl;
  }
  auto llm = llamalib::Llama{path};
  std::mutex llm_mutex; // 保护模型切换期间的访问
  // ...
}

关键设计考量:cpp-httplib 的 Server 使用线程池并发执行处理器,若在另一线程正在执行 llm.chat() 时重写 llm,将导致未定义行为甚至崩溃。因此用 std::mutex 对 llm 的构造/重写与所有推理入口统一加锁。这样用户在首次启动时无需再用 curl 手动下载模型,控制台会实时显示进度(\r 回车刷新同一行)。

4.5 GET /models 处理器:标注下载与选中状态

该端点遍历 MODELS,为每个模型附加 downloaded(文件是否存在于模型目录)与 selected(文件名是否等于 selected_model)两个状态字段:

svr.Get("/models",
        & {
  auto arr = json::array();
  for (const auto &m : MODELS) {
    auto path = get_models_dir() / m.filename;
    arr.push_back({
      {"name",       m.name},
      {"params",     m.params},
      {"size",       m.size},
      {"downloaded", std::filesystem::exists(path)},
      {"selected",   m.filename == selected_model},
    });
  }
  res.set_content(json{{"models", arr}}.dump(), "application/json");
});

downloaded 直接由文件系统事实推导(std::filesystem::exists),天然与 download_model 的落盘结果保持一致,无需额外状态登记。

4.6 大文件下载:回调式流式写入与原子替换

GGUF 模型动辄数 GB,无法整体装入内存。httplib::Client::Get 接受两个回调:content_receiver(逐块接收数据)与 progress(进度通知),返回 false 即可中止下载:

// content_receiver: 接收数据块的回调
// progress: 下载进度回调
cli.Get(url,
  & {       // content_receiver
    ofs.write(data, len);
    return true;  // 返回 false 则中断下载
  },
  & {        // progress
    int pct = total ? (int)(current * 100 / total) : 0;
    std::cout << pct << "%" << std::endl;
    return true;  // 返回 false 则中断下载
  });

这两个回调类型在 httplib.h 中有明确定义:ContentReceiver 为 bool(const char* data, size_t data_length),DownloadProgress 为 bool(size_t current, size_t total)(httplib.h)。Get 也提供带 Headers、Params、ResponseHandler 的多个重载(httplib.h),可按需选用。

基于此实现 Hugging Face 模型下载函数:

#include <filesystem>
#include <fstream>

// 下载模型并以 progress_cb 上报进度;progress_cb 返回 false 则中止
bool download_model(const ModelInfo &model,
                    std::function<bool(int)> progress_cb) {
  httplib::Client cli("https://huggingface.co");
  cli.set_follow_location(true);
  cli.set_read_timeout(std::chrono::hours(1));

  auto url = "/" + model.repo + "/resolve/main/" + model.filename;
  auto path = get_models_dir() / model.filename;
  auto tmp_path = std::filesystem::path(path).concat(".tmp");

  std::ofstream ofs(tmp_path, std::ios::binary);
  if (!ofs) { return false; }

  auto res = cli.Get(url,
    & {
      ofs.write(data, len);
      return ofs.good();
    },
    & {
      return progress_cb(total ? (int)(current * 100 / total) : 0);
    });

  ofs.close();

  if (!res || res->status != 200) {
    std::filesystem::remove(tmp_path);
    return false;
  }

  // 先写 .tmp 再重命名,避免下载中断的半成品被当作模型使用
  std::filesystem::rename(tmp_path, path);
  return true;
}

要点拆解:

  • URL 拼装:/ + repo + /resolve/main/ + filename 对应 Hugging Face 的 resolve/main 直链格式;
  • 超时:set_read_timeout(std::chrono::hours(1)) 为 GB 级文件预留充足读超时;
  • 原子性:数据先写入 <模型名>.gguf.tmp,仅当 HTTP 状态为 200 且完整接收后才 rename 为正式文件名。任何中断(超时、断连、回调返回 false)都会删除 .tmp,绝不产生“看似可用”的残缺模型;
  • 进度节流:完整版代码中进度回调使用 last_pct 记录上一次值,相同进度直接跳过,避免刷屏与无谓的 SSE 推送。

4.7 POST /models/select 处理器:SSE 贯穿下载→加载→就绪

选择请求一律以 SSE(text/event-stream)响应,无论目标模型是否已下载都保持同一格式,依次推送 downloading(下载进度)、loading、ready 事件:

svr.Post("/models/select",
         & {
  auto input = json::parse(req.body, nullptr, false);
  if (input.is_discarded() || !input.contains("model")) {
    res.status = 400;
    res.set_content(json{{"error", "'model' is required"}}.dump(),
                    "application/json");
    return;
  }

  auto name = input["model"].get<std::string>();

  // 从模型清单中查找
  auto it = std::find_if(MODELS.begin(), MODELS.end(),
    & { return m.name == name; });

  if (it == MODELS.end()) {
    res.status = 404;
    res.set_content(json{{"error", "Unknown model"}}.dump(),
                    "application/json");
    return;
  }

  const auto &model = *it;

  // 无论已下载与否,一律以 SSE 响应
  res.set_chunked_content_provider(
      "text/event-stream",
      &, model {
        // SSE 事件发送辅助函数
        auto send = & {
          sink.os << "data: " << event.dump() << "\n\n";
        };

        // 未下载则先下载(进度经 SSE 上报)
        auto path = get_models_dir() / model.filename;
        if (!std::filesystem::exists(path)) {
          bool ok = download_model(model, & {
            send({{"status", "downloading"}, {"progress", pct}});
            return sink.os.good(); // 客户端断开时中止下载
          });
          if (!ok) {
            send({{"status", "error"}, {"message", "Download failed"}});
            sink.done();
            return true;
          }
        }

        // 加载模型并切换
        send({{"status", "loading"}});
        {
          std::lock_guard<std::mutex> lock(llm_mutex);
          llm = llamalib::Llama{path};
          selected_model = model.filename;
        }

        send({{"status", "ready"}});
        sink.done();
        return true;
      });
});

这段代码值得注意的几个联动点:

  • 进度直通 SSE:download_model 的进度回调内部直接调用 send 发送 SSE 事件,是第 3 章 set_chunked_content_provider + sink.os 技巧的直接应用(set_chunked_content_provider 在 httplib.h 声明、httplib.h 实现,底层以 Transfer-Encoding: chunked 输出);
  • 断连即取消:进度回调返回 sink.os.good(),客户端一旦断开连接,sink.os 失效,download_model 随即中止下载——第 5 章 Web UI 的取消按钮正是利用这一机制;
  • 状态联动:selected_model 更新后,GET /models 返回的 selected 标志自动刷新;
  • 加锁切换:llm 的覆盖写操作由 llm_mutex 保护,而 /translate 与 /translate/stream 处理器内部也持有同一把锁(见完整代码),因此模型切换期间绝不会穿插推理调用。

4.8 完整代码

以下为在第 3 章代码基础上加入模型管理功能的完整工程。

完整代码(CMakeLists.txt)
cmake_minimum_required(VERSION 3.20)
project(translate-server CXX)
set(CMAKE_CXX_STANDARD 20)

include(FetchContent)

# llama.cpp
FetchContent_Declare(llama
    GIT_REPOSITORY https://github.com/ggml-org/llama.cpp
    GIT_TAG        master
    GIT_SHALLOW    TRUE
)
FetchContent_MakeAvailable(llama)

# cpp-httplib
FetchContent_Declare(httplib
    GIT_REPOSITORY https://github.com/yhirose/cpp-httplib
    GIT_TAG        master
)
FetchContent_MakeAvailable(httplib)

# nlohmann/json
FetchContent_Declare(json
    URL https://github.com/nlohmann/json/releases/download/v3.11.3/json.tar.xz
)
FetchContent_MakeAvailable(json)

# cpp-llamalib
FetchContent_Declare(cpp_llamalib
    GIT_REPOSITORY https://github.com/yhirose/cpp-llamalib
    GIT_TAG        main
)
FetchContent_MakeAvailable(cpp_llamalib)

find_package(OpenSSL REQUIRED)

add_executable(translate-server src/main.cpp)

target_link_libraries(translate-server PRIVATE
    httplib::httplib
    nlohmann_json::nlohmann_json
    cpp-llamalib
    OpenSSL::SSL OpenSSL::Crypto
)

target_compile_definitions(translate-server PRIVATE CPPHTTPLIB_OPENSSL_SUPPORT)

if(APPLE)
    target_link_libraries(translate-server PRIVATE
        "-framework CoreFoundation"
        "-framework Security"
    )
endif()
完整代码(main.cpp)
#include <httplib.h>
#include <nlohmann/json.hpp>
#include <cpp-llamalib.h>

#include <algorithm>
#include <csignal>
#include <filesystem>
#include <fstream>
#include <iostream>
#include <mutex>

using json = nlohmann::json;

// -------------------------------------------------------------------------
// 模型定义
// -------------------------------------------------------------------------

struct ModelInfo {
  std::string name;
  std::string params;
  std::string size;
  std::string repo;
  std::string filename;
};

const std::vector<ModelInfo> MODELS = {
  {
    .name     = "gemma-2-2b-it",
    .params   = "2B",
    .size     = "1.6 GB",
    .repo     = "bartowski/gemma-2-2b-it-GGUF",
    .filename = "gemma-2-2b-it-Q4_K_M.gguf",
  },
  {
    .name     = "gemma-2-9b-it",
    .params   = "9B",
    .size     = "5.8 GB",
    .repo     = "bartowski/gemma-2-9b-it-GGUF",
    .filename = "gemma-2-9b-it-Q4_K_M.gguf",
  },
  {
    .name     = "Llama-3.1-8B-Instruct",
    .params   = "8B",
    .size     = "4.9 GB",
    .repo     = "bartowski/Meta-Llama-3.1-8B-Instruct-GGUF",
    .filename = "Meta-Llama-3.1-8B-Instruct-Q4_K_M.gguf",
  },
};

// -------------------------------------------------------------------------
// 模型存储目录
// -------------------------------------------------------------------------

std::filesystem::path get_models_dir() {
#ifdef _WIN32
  auto env = std::getenv("APPDATA");
  auto base = env ? std::filesystem::path(env) : std::filesystem::path(".");
  return base / "translate-app" / "models";
#else
  auto env = std::getenv("HOME");
  auto base = env ? std::filesystem::path(env) : std::filesystem::path(".");
  return base / ".translate-app" / "models";
#endif
}

// -------------------------------------------------------------------------
// 模型下载
// -------------------------------------------------------------------------

// progress_cb 返回 false 则中止下载
bool download_model(const ModelInfo &model,
                    std::function<bool(int)> progress_cb) {
  httplib::Client cli("https://huggingface.co");
  cli.set_follow_location(true);  // Hugging Face 会重定向到 CDN
  cli.set_read_timeout(std::chrono::hours(1)); // 大模型预留长超时

  auto url = "/" + model.repo + "/resolve/main/" + model.filename;
  auto path = get_models_dir() / model.filename;
  auto tmp_path = std::filesystem::path(path).concat(".tmp");

  std::ofstream ofs(tmp_path, std::ios::binary);
  if (!ofs) { return false; }

  auto res = cli.Get(url,
    // content_receiver: 逐块接收并写入文件
    & {
      ofs.write(data, len);
      return ofs.good();
    },
    // progress: 上报进度(返回 false 则中止)
    &, last_pct = -1 mutable {
      int pct = total ? (int)(current * 100 / total) : 0;
      if (pct == last_pct) return true; // 相同进度则跳过
      last_pct = pct;
      return progress_cb(pct);
    });

  ofs.close();

  if (!res || res->status != 200) {
    std::filesystem::remove(tmp_path);
    return false;
  }

  // 下载完成后重命名
  std::filesystem::rename(tmp_path, path);
  return true;
}

// -------------------------------------------------------------------------
// 服务器
// -------------------------------------------------------------------------

httplib::Server svr;

void signal_handler(int sig) {
  if (sig == SIGINT || sig == SIGTERM) {
    std::cout << "\nReceived signal, shutting down gracefully...\n";
    svr.stop();
  }
}

int main() {
  // 创建模型存储目录
  auto models_dir = get_models_dir();
  std::filesystem::create_directories(models_dir);

  // 默认模型未下载则自动拉取
  std::string selected_model = MODELS[0].filename;
  auto path = models_dir / selected_model;
  if (!std::filesystem::exists(path)) {
    std::cout << "Downloading " << selected_model << "..." << std::endl;
    if (!download_model(MODELS[0], [](int pct) {
          std::cout << "\r" << pct << "%" << std::flush;
          return true;
        })) {
      std::cerr << "\nFailed to download model." << std::endl;
      return 1;
    }
    std::cout << std::endl;
  }
  auto llm = llamalib::Llama{path};
  std::mutex llm_mutex; // 保护模型切换期间的访问

  // LLM 推理耗时较长,放宽超时(默认 5 秒)
  svr.set_read_timeout(300);
  svr.set_write_timeout(300);

  svr.set_logger([](const auto &req, const auto &res) {
    std::cout << req.method << " " << req.path << " -> " << res.status
              << std::endl;
  });

  svr.Get("/health", [](const httplib::Request &, httplib::Response &res) {
    res.set_content(json{{"status", "ok"}}.dump(), "application/json");
  });

  // --- 翻译端点(第 2 章)--------------------------------------------------

  svr.Post("/translate",
           & {
    // JSON 解析与校验(详见第 2 章)
    auto input = json::parse(req.body, nullptr, false);
    if (input.is_discarded()) {
      res.status = 400;
      res.set_content(json{{"error", "Invalid JSON"}}.dump(),
                      "application/json");
      return;
    }

    if (!input.contains("text") || !input["text"].is_string() ||
        input["text"].get<std::string>().empty()) {
      res.status = 400;
      res.set_content(json{{"error", "'text' is required"}}.dump(),
                      "application/json");
      return;
    }

    auto text = input["text"].get<std::string>();
    auto target_lang = input.value("target_lang", "ja");

    auto prompt = "Translate the following text to " + target_lang +
                  ". Output only the translation, nothing else.\n\n" + text;

    try {
      std::lock_guard<std::mutex> lock(llm_mutex);
      auto translation = llm.chat(prompt);
      res.set_content(json{{"translation", translation}}.dump(),
                      "application/json");
    } catch (const std::exception &e) {
      res.status = 500;
      res.set_content(json{{"error", e.what()}}.dump(), "application/json");
    }
  });

  // --- SSE 流式翻译(第 3 章)---------------------------------------------

  svr.Post("/translate/stream",
           & {
    auto input = json::parse(req.body, nullptr, false);
    if (input.is_discarded()) {
      res.status = 400;
      res.set_content(json{{"error", "Invalid JSON"}}.dump(),
                      "application/json");
      return;
    }

    if (!input.contains("text") || !input["text"].is_string() ||
        input["text"].get<std::string>().empty()) {
      res.status = 400;
      res.set_content(json{{"error", "'text' is required"}}.dump(),
                      "application/json");
      return;
    }

    auto text = input["text"].get<std::string>();
    auto target_lang = input.value("target_lang", "ja");

    auto prompt = "Translate the following text to " + target_lang +
                  ". Output only the translation, nothing else.\n\n" + text;

    res.set_chunked_content_provider(
        "text/event-stream",
        &, prompt {
          std::lock_guard<std::mutex> lock(llm_mutex);
          try {
            llm.chat(prompt, & {
              sink.os << "data: "
                      << json(std::string(token)).dump(
                           -1, ' ', false, json::error_handler_t::replace)
                      << "\n\n";
              return sink.os.good(); // 断开则中止推理
            });
            sink.os << "data: [DONE]\n\n";
          } catch (const std::exception &e) {
            sink.os << "data: " << json({{"error", e.what()}}).dump() << "\n\n";
          }
          sink.done();
          return true;
        });
  });

  // --- 模型列表(第 4 章)--------------------------------------------------

  svr.Get("/models",
          & {
    auto models_dir = get_models_dir();
    auto arr = json::array();
    for (const auto &m : MODELS) {
      auto path = models_dir / m.filename;
      arr.push_back({
        {"name",       m.name},
        {"params",     m.params},
        {"size",       m.size},
        {"downloaded", std::filesystem::exists(path)},
        {"selected",   m.filename == selected_model},
      });
    }
    res.set_content(json{{"models", arr}}.dump(), "application/json");
  });

  // --- 模型选择(第 4 章)--------------------------------------------------

  svr.Post("/models/select",
           & {
    auto input = json::parse(req.body, nullptr, false);
    if (input.is_discarded() || !input.contains("model")) {
      res.status = 400;
      res.set_content(json{{"error", "'model' is required"}}.dump(),
                      "application/json");
      return;
    }

    auto name = input["model"].get<std::string>();

    auto it = std::find_if(MODELS.begin(), MODELS.end(),
      & { return m.name == name; });

    if (it == MODELS.end()) {
      res.status = 404;
      res.set_content(json{{"error", "Unknown model"}}.dump(),
                      "application/json");
      return;
    }

    const auto &model = *it;

    // 无论已下载与否,一律以 SSE 响应
    res.set_chunked_content_provider(
        "text/event-stream",
        &, model {
          // SSE 事件发送辅助函数
          auto send = & {
            sink.os << "data: " << event.dump() << "\n\n";
          };

          // 未下载则先下载(进度经 SSE 上报)
          auto path = get_models_dir() / model.filename;
          if (!std::filesystem::exists(path)) {
            bool ok = download_model(model, & {
              send({{"status", "downloading"}, {"progress", pct}});
              return sink.os.good(); // 客户端断开时中止下载
            });
            if (!ok) {
              send({{"status", "error"}, {"message", "Download failed"}});
              sink.done();
              return true;
            }
          }

          // 加载模型并切换
          send({{"status", "loading"}});
          {
            std::lock_guard<std::mutex> lock(llm_mutex);
            llm = llamalib::Llama{path};
            selected_model = model.filename;
          }

          send({{"status", "ready"}});
          sink.done();
          return true;
        });
  });

  // 支持 `Ctrl+C`(SIGINT)与 `kill`(SIGTERM)优雅停机
  signal(SIGINT, signal_handler);
  signal(SIGTERM, signal_handler);

  std::cout << "Listening on http://127.0.0.1:8080" << std::endl;
  svr.listen("127.0.0.1", 8080);
}

4.9 运行验证

CMakeLists.txt 新增 OpenSSL 配置后,需重新执行 CMake 再构建:

cmake -B build
cmake --build build -j
./build/translate-server

检查模型列表

curl http://localhost:8080/models

第 1 章下载的 gemma-2-2b-it 应显示 downloaded: true 与 selected: true。

切换到其他模型

curl -N -X POST http://localhost:8080/models/select \
  -H "Content-Type: application/json" \
  -d '{"model": "gemma-2-9b-it"}'

SSE 中会持续流出下载进度,完成后收到 "ready"。

跨模型对比翻译效果

用不同模型翻译同一例句:

# 用 gemma-2-9b-it 翻译(刚切换的模型)
curl -X POST http://localhost:8080/translate \
  -H "Content-Type: application/json" \
  -d '{"text": "The quick brown fox jumps over the lazy dog.", "target_lang": "ja"}'

# 切回 gemma-2-2b-it
curl -N -X POST http://localhost:8080/models/select \
  -H "Content-Type: application/json" \
  -d '{"model": "gemma-2-2b-it"}'

# 翻译同一句话
curl -X POST http://localhost:8080/translate \
  -H "Content-Type: application/json" \
  -d '{"text": "The quick brown fox jumps over the lazy dog.", "target_lang": "ja"}'

可以看到:同样的代码、同样的提示词,不同模型会产出不同的翻译结果。由于 cpp-llamalib 会自动为每个模型应用对应的聊天模板,业务代码无需任何改动,这正是“元数据驱动模型管理”带来的收益。

小结与下一步

至此,服务器已具备完整的功能闭环:REST API(/translate)、SSE 流式输出(/translate/stream)、模型下载与热切换(/models、/models/select)。其中模型管理这一环,本质上是把 cpp-httplib 的客户端能力(TLS 连接、重定向跟随、回调式流式下载、超时控制)与服务端能力(线程池并发、set_chunked_content_provider + DataSink、SSE)在同一进程内组合使用,再辅以互斥锁保证模型热切换的线程安全。下一步将添加静态文件服务,构建可在浏览器中直接操作的 Web UI(对应 ch05-web-ui),届时本节实现的进度上报与断连取消机制将成为前端交互的基础。

登录后查看全文
cpp-httplib