title: "4. モデルの取得・管理機能を追加する" order: 4
3章まででサーバーの翻訳機能は一通り揃いました。しかし、モデルファイルは1章で手動ダウンロードした1つだけです。この章ではcpp-httplibのクライアント機能を使い、アプリ内からHugging Faceのモデルをダウンロード・切り替えできるようにします。
完成すると、こんなリクエストでモデルを管理できるようになります。
# 利用可能なモデル一覧を取得
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}
]
}
# 別のモデルを選択(未ダウンロードなら自動で取得)
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"}
これまではhttplib::Serverだけを使ってきましたが、cpp-httplibはクライアント機能も備えています。Hugging FaceはHTTPSなので、TLS対応のクライアントが必要です。
#include <httplib.h>
// URLスキームを含めると自動でSSLClientが使われる
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;
}
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を定義すると、httplib::Client("https://...")がTLS接続を行います。macOSではシステム証明書ストアにアクセスするため、CoreFoundationとSecurityフレームワークのリンクも必要です。完全なCMakeLists.txtは4.8節にあります。
アプリが扱えるモデルの一覧を定義します。翻訳タスクで検証済みの4モデルを用意しました。
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",
},
};
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
}
環境変数が未設定の場合はカレントディレクトリにフォールバックします。このディレクトリはアプリ起動時に自動作成します(create_directoriesは既に存在していてもエラーになりません)。
モデルの初期化をmain()の先頭で書き換えます。1章ではパスをハードコードしていましたが、ここからはモデルの切り替えに対応します。現在ロード中のファイル名はselected_model変数で管理します。起動時はMODELSの先頭エントリーをロードします。この変数はGET /modelsやPOST /models/selectのハンドラから参照・更新します。
cpp-httplibはスレッドプールでハンドラを並行実行します。そのため、モデル切り替え中(llmの上書き中)に別スレッドでllm.chat()が走るとクラッシュします。std::mutexで排他制御を入れておきます。
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; // モデル切り替え中のアクセスを保護する
// ...
}
初回起動時にユーザーがcurlで手動ダウンロードしなくても済むようにしています。4.6節のdownload_model関数を使い、進捗をコンソールに表示します。
GET /modelsハンドラモデル一覧に「ダウンロード済みか」「選択中か」の情報を付けて返します。
svr.Get("/models",
[&](const httplib::Request &, httplib::Response &res) {
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");
});
GGUFモデルは数GBあるため、全体をメモリに載せるわけにはいきません。httplib::Client::Getにコールバックを渡すと、チャンクごとにデータを受け取れます。
// content_receiver: データチャンクを受け取るコールバック
// progress: ダウンロード進捗コールバック
cli.Get(url,
[&](const char *data, size_t len) { // content_receiver
ofs.write(data, len);
return true; // falseを返すと中断
},
[&](size_t current, size_t total) { // progress
int pct = total ? (int)(current * 100 / total) : 0;
std::cout << pct << "%" << std::endl;
return true; // falseを返すと中断
});
これを使って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,
[&](const char *data, size_t len) {
ofs.write(data, len);
return ofs.good();
},
[&](size_t current, size_t total) {
return progress_cb(total ? (int)(current * 100 / total) : 0);
});
ofs.close();
if (!res || res->status != 200) {
std::filesystem::remove(tmp_path);
return false;
}
// .tmpに書いてからリネームすることで、DLが途中で止まっても
// 不完全なファイルがモデルとして使われるのを防ぐ
std::filesystem::rename(tmp_path, path);
return true;
}
/models/selectハンドラモデルの選択リクエストを処理します。レスポンスは常にSSEで返し、ダウンロード進捗 → ロード → 完了のステータスを順に通知します。
svr.Post("/models/select",
[&](const httplib::Request &req, httplib::Response &res) {
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(),
[&](const ModelInfo &m) { 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で応答する(DL済みでも未DLでも同じ形式)
res.set_chunked_content_provider(
"text/event-stream",
[&, model](size_t, httplib::DataSink &sink) {
// SSEイベント送信ヘルパー
auto send = [&](const json &event) {
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, [&](int pct) {
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;
});
});
いくつか補足します。
download_modelの進捗コールバックから直接SSEイベントを送っています。3章のset_chunked_content_provider + sink.osの応用ですsink.os.good()を返すので、クライアントが接続を切るとダウンロードも中断します。5章で追加するキャンセルボタンで使いますselected_modelを更新すると、GET /modelsのselectedフラグに反映されますllmの上書きをllm_mutexで保護しています。/translateや/translate/streamのハンドラも同じmutexでロックするので、モデル切り替え中に推論が走ることはありません(全体コードを参照)3章のコードにモデル管理機能を追加した完成形です。
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、SSEストリーミング、モデルのダウンロードと切り替え。次の章では静的ファイル配信を追加して、ブラウザから操作できるWeb UIを作ります。
Next: Web UIを追加する