Read OSS

HTTPリクエストからトークンへ:サーバーとCLIツールの仕組み

中級

前提知識

  • 第1〜4回の記事
  • HTTP APIとRESTの基本的な理解

HTTPリクエストからトークンへ:サーバーとCLIツールの仕組み

ここまで取り上げてきたGGMLテンソル、計算グラフ、KVキャッシュ、デコードループといった要素はすべて、libllama(ユーザーとのやり取り方法に関して特定の意見を持たないC/C++ライブラリ)の中に収まっています。tools/ にあるアプリケーション層こそが、llama.cppと実際のユーザーが接する場所です。OpenAI互換エンドポイントを持つHTTPサーバー、インタラクティブなCLI、量子化ツールなど、様々なツールがここに揃っています。

本記事では、サーバーのスロットベースの並行モデル、タスクキューのアーキテクチャ、APIの全体像、そしてllama.cppの設計の中でも特に興味深い決断の1つ(CLIが独自の推論ループを実装せず、サーバーのインフラをまるごと再利用している点)について解説します。

サーバーアーキテクチャの概要

サーバーのアーキテクチャは、tools/server/ 以下の複数のヘッダーファイルにまたがる3つのコンポーネントを中心に構成されています。

server_context は全体を統括するオーケストレーターです。tools/server/server-context.h で定義されており、pimpl(ポインタ・トゥ・実装)パターンを用いて内部実装を server_context_impl の背後に隠蔽しています。モデル、コンテキスト、スロット、メインイベントループを管理します。

server_queue はタスクの受付とルーティングを担います。tools/server/server-queue.h で定義されており、スレッドセーフなタスクキューとして機能します。すべてのスロットが使用中のときにタスクを保留する遅延実行、優先度のサポート、そしてアイドル時にモデルをアンロードするスリープモードを備えています。

server_task は作業の最小単位を表します。tools/server/server-task.h で定義されており、タスクのタイプ(補完、埋め込み、キャンセルなど)と入力パラメータを保持します。多段階処理のための子タスクを持つこともできます。

flowchart TD
    HTTP["HTTP Request\n/v1/chat/completions"] --> ROUTES["Route Handler"]
    ROUTES --> TASK["Create server_task"]
    TASK --> QUEUE["server_queue.post()"]
    QUEUE --> LOOP["Main Event Loop"]
    LOOP --> SLOT["Assign to Slot"]
    SLOT --> DECODE["llama_decode()"]
    DECODE --> SAMPLE["Sample token"]
    SAMPLE --> RESULT["server_task_result"]
    RESULT --> SSE["SSE Stream / JSON Response"]

メインイベントループはシングルスレッド(「メインループ」スレッド)で動作し、キューからタスクを一つずつ処理します。これは意図的な設計です——llama_decode() を並行して呼び出すには複数のコンテキストや慎重なロック制御が必要になるため、その複雑さを避けています。代わりに、並行性はスロットの仕組みで実現しています。

スロットシステム

サーバーは「スロット」という抽象化を通じて、単一のロード済みモデルに対する複数の並行推論リクエストを処理できます。各スロットは独立した推論ストリームを表し、それぞれが以下を持ちます。

  • プロンプトの状態と生成済みトークン
  • サンプリングパラメータとサンプラーチェーン
  • KVキャッシュ内の位置
  • ストリーミング状態(SSEレスポンス用)

新しいリクエストが来ると、サーバーはそれをアイドル状態のスロットに割り当てます。メインループはアクティブな全スロットを巡回し、それぞれのバッチを使って llama_decode() を呼び出すことで推論を進めます。これは協調的マルチタスキングの一形態です——すべてのスロットが同じモデルの重みと llama_context を共有しつつ、KVキャッシュ内ではそれぞれ独自のシーケンスIDを持ちます。

sequenceDiagram
    participant R1 as Request 1
    participant R2 as Request 2
    participant Q as server_queue
    participant L as Main Loop
    participant S1 as Slot 0
    participant S2 as Slot 1
    participant CTX as llama_context

    R1->>Q: Completion task
    R2->>Q: Completion task
    Q->>L: Dequeue tasks
    L->>S1: Assign Request 1
    L->>S2: Assign Request 2
    loop Update cycle
        L->>S1: Prepare batch
        L->>S2: Prepare batch
        L->>CTX: llama_decode(combined batch)
        CTX-->>S1: Logits for seq 0
        CTX-->>S2: Logits for seq 1
        S1->>R1: Stream token (SSE)
        S2->>R2: Stream token (SSE)
    end

スロット数は起動時に --parallel N で設定できます。スロットを追加するたびに、コンテキストウィンドウの一部をそのスロットが占有します。たとえば4スロット・n_ctx=8192 の場合、各スロットが使えるのは実質〜2048ポジションです(KVキャッシュのパーティショニングの実装はより細かいですが)。

ヒント: 一人のユーザー専用にサーバーを動かす場合は --parallel 1 が最適です。スロットを増やすほど、1リクエストあたりのコンテキスト長が減り、メモリ使用量も増加します。複数ユーザーが同時接続する環境では、想定される同時接続数に --parallel を合わせ、それに応じて --ctx-size もスケールさせましょう。

APIの全体像とルート登録

サーバーは tools/server/server.cpp でセットアップされる3系統のAPIを公開しています。

OpenAI互換エンドポイント:

  • POST /v1/chat/completions — ストリーミング対応のチャット補完
  • POST /v1/completions — テキスト補完
  • POST /v1/embeddings — 埋め込みの生成
  • GET /v1/models — 利用可能なモデルの一覧

ネイティブエンドポイント:

  • POST /completion — 全パラメータを制御できる生の補完
  • POST /tokenize — テキストのトークン化
  • POST /detokenize — トークンの逆変換
  • GET /health — サーバーのヘルスチェック
  • GET /props — サーバーのプロパティ

Anthropic互換エンドポイント:

  • POST /v1/messages — Anthropic Messages API形式

各ルートハンドラーは共通のパターンに従っています。JSONリクエストをパースし、適切なパラメータで server_task を生成し、キューに投入して、server_response_reader で結果を待ちます。ストリーミングの場合、結果はServer-Sent Events(SSE)として送信されます。

ルートハンドラーは ex_wrapper() でラップされており、例外をキャッチして適切なHTTPエラーレスポンスに変換します——引数が不正な場合は 400、内部エラーの場合は 500 が返ります。

CLIによるサーバー内部の再利用

llama.cppの設計の中でも特に驚きをもたらすのが、tools/cli/cli.cpp に見られる次のコードです。

#include "server-context.h"
#include "server-task.h"

struct cli_context {
    server_context ctx_server;   // <-- the same server_context!
    json messages = json::array();
    // ...
};

インタラクティブなCLIは独自の推論ループを持っていません。代わりに cli_contextserver_context をラップし、HTTPハンドラーと同じタスク/キューインターフェース経由でやり取りします。CLIでメッセージを入力すると、server_task が生成されてサーバーのキューに投入され、server_response_reader で結果が読み取られます。

classDiagram
    class server_context {
        +load_model()
        +start_loop()
        +get_response_reader()
    }
    class server_http_context {
        Routes: /v1/chat/completions
        Routes: /v1/completions
        Creates server_tasks
    }
    class cli_context {
        +ctx_server: server_context
        +messages: json
        Creates server_tasks
    }
    server_context <-- server_http_context : "routes to"
    server_context <-- cli_context : "wraps"

この設計にはいくつかの利点があります。

  1. 機能の一致 — サーバーで利用できる機能(ツール呼び出し、マルチモーダル入力、文法制約、投機的デコードなど)はすべて、自動的にCLIでも使えます。

  2. 単一のコードパス — 推論パイプラインのバグ修正は、サーバーとCLIの両方に同時に適用されます。CLIが独自に乖離するリスクがありません。

  3. テストの容易さ — CLIはHTTPスタックを必要とせず、サーバーのコアロジックを実質的にインテグレーションテストする役割を果たします。

トレードオフとして、CLIは実際には1スロットしか使わないにもかかわらず、サーバーの依存関係(タスクキュー、スロット管理など)をすべて引き継ぐことになります。

共通ユーティリティ層

common/ ディレクトリには、すべてのツールが利用する共有インフラが含まれています。

引数パースcommon/arg.hcommon_params_parse() は、各ツールが受け付ける数百ものCLIフラグ(モデルパス、コンテキストサイズ、GPUレイヤー数、サンプリングパラメータなど)を処理します。すべてのツールが同じ common_params 構造体を共有しています。

サンプリングラッパーcommon/sampling.h は、libllama の低レベルな llama_sampler チェーンを、設定・リセット・パラメータ管理を扱う高レベルなインターフェースでラップしています。

チャットテンプレートエンジンcommon/jinja/ ディレクトリには、チャットテンプレートを適用するためのJinja2互換テンプレートエンジンが含まれています。モデルのGGUFメタデータにチャットテンプレート(tokenizer.chat_template キー)が含まれている場合、このエンジンがユーザー/アシスタント/システムのメッセージをモデルが期待するフォーマットにレンダリングします。

flowchart TD
    subgraph "common/ utilities"
        ARG["arg.h / arg.cpp\nArgument parsing"]
        SAMP["sampling.h\nSampling wrappers"]
        CHAT["chat.h\nChat template application"]
        JINJA["jinja/\nJinja2 template engine"]
        LOG["log.h\nLogging utilities"]
    end
    subgraph "Tools"
        CLI["tools/cli"]
        SRV["tools/server"]
        QNT["tools/quantize"]
        BENCH["tools/bench"]
    end
    CLI --> ARG
    CLI --> CHAT
    SRV --> ARG
    SRV --> SAMP
    SRV --> JINJA
    QNT --> ARG
    BENCH --> ARG

ツールエコシステムとモデル変換

サーバーとCLI以外にも、llama.cppにはいくつかの専用ツールが含まれています。

tools/quantize/ — モデルを量子化フォーマット間で変換します(例:F16 → Q4_K_M)。GGUFファイルを読み込み、各テンソルを対象フォーマットに再量子化して、新しいGGUFファイルとして書き出します。データ依存の量子化を行うための重要度行列(--imatrix)にも対応しています。

tools/bench/ — プロンプト処理とトークン生成スループットのパフォーマンスベンチマークを行います。

tools/perplexity/ — テストコーパスに対してパープレキシティを計算し、モデルの品質を評価します。

tools/imatrix/ — キャリブレーションデータから重要度行列を計算します。量子化品質の向上に使用されます。

tools/tts/ — 音声合成モデルを使ったテキスト読み上げ(Text-to-Speech)機能です。

tools/mtmd/ — テキストに加えて画像や音声を処理するモデル向けのマルチモーダルツールです。

モデル変換convert_hf_to_gguf.py スクリプトは、新しいモデルをllama.cppに取り込む主要な方法です。HuggingFaceのモデルチェックポイント(PyTorchの .bin またはSafeTensorsの .safetensors)を読み込み、テンソル名をGGUFの命名規則にマッピングし、ハイパーパラメータをGGUFメタデータとして書き込み、.gguf ファイルを出力します。変換ツールが使用するPython製のGGUFリーダー/ライターは gguf-py/ ライブラリで提供されています。

ヒント: モデルを量子化する際、ほとんどのユースケースで品質とサイズのバランスが最も優れているのはQ4_K_Mです。積極的な量子化レベル(Q2_K以下)では、キャリブレーションデータセットと --imatrix を組み合わせることで最良の結果が得られます。

次のステップ

これでHTTPリクエストからトークン出力に至るまでのフルスタックを一通り見てきました。シリーズ最終回は実践的な内容に移ります。llama.cppに新しいモデルアーキテクチャを追加する手順をステップバイステップで解説し、GGUF変換からグラフ構築、テストに至るまで、触れるべきすべてのファイルを網羅します。