結論

ローカル LLM 基盤の入口を OpenAI 互換 API に統一するために、Rust (axum) でプロキシを設計・プロトタイプ実装した。約 1,600 行の Rust コードで OpenAI/Ollama 互換のマルチバックエンドプロキシが動作し、SSE ストリーミング、モデル名ベースのバックエンド自動選択、レイヤードアーキテクチャが成立した。

ただし、当初の構想にあった NATS イベント中継、Dagster oneshot ジョブ、RAG オーケストレーション、Qdrant 意味キャッシュはスタブのまま残った。SSE 中継 + NATS 購読 + PostgreSQL/Qdrant 書き込みが同時に走る非同期コンテキストの管理が Rust では冗長であり、この先すべてを繋ぎ込む記述コストを見積もった結果、Go (Gin) への移行を判断した。

Rust で先に設計仕様と型契約を固めたことが、Go 移行後の実装速度に直結した。設計ドキュメントと trait 定義がそのまま仕様書として機能し、Go 側では goroutine + channel で同じ設計を素直に再実装できた。この記事は、そのGo 基盤の設計全記録の前日譚にあたる。


前提

  • 言語: Rust 2024 edition
  • フレームワーク: axum 0.8, tokio 1.48
  • シリアライズ: serde / serde_json
  • HTTPクライアント: reqwest 0.12
  • メッセージング(設計): async-nats 0.45(スタブ実装)
  • データストア(設計): sqlx 0.8 (PostgreSQL), qdrant-client 1.16(スタブ実装)
  • ロギング: tracing / tracing-subscriber
  • コンテナ: Docker (proxy + PostgreSQL 18 + Qdrant)
  • バインドアドレス: 0.0.0.0:8080

設計判断

レイヤードアーキテクチャ

Clean Architecture の依存方向ルールに従い、HTTP → Domain → Infra の 3 層に分離した。

  Client (CLI / IDE / API consumer)
    |
    v
HTTP Layer [bk/http/]
    |-- routes.rs       エンドポイント登録
    |-- handlers_chat.rs  OpenAI/Ollama チャットハンドラ
    |-- handlers_embeddings.rs  エンベディングハンドラ
    |-- error.rs        OpenAI互換エラー変換
    |
    v
Domain Layer [bk/domain/]
    |-- chat.rs     ChatCompletionRequest/Response, ChatRoute (Direct/Rag/Workflow)
    |-- rag.rs      RagParams, EmbeddingsRequest/Response
    |-- workflow.rs JobRunId
    |
    v
Service Layer [bk/services/]
    |-- ChatService trait     → HttpChatService (実装) / StubChatService (開発)
    |-- EmbeddingsService trait → StubEmbeddingsService
    |-- RagService trait      → StubRagService
    |
    v
Infrastructure Layer [bk/infra/]
    |-- llm.rs          HttpClient (バックエンド LLM 呼び出し)
    |-- qdrant_client.rs QdrantClient (ベクトル検索)
    |-- auth.rs         ApiKeyAuthorizer
  

Domain 層は外部依存を一切持たない。LLM バックエンドの追加やデータストアの変更は Infra 層の差し替えだけで完結する。この構造は Go 移行後もそのまま保持した。

OpenAI/Ollama 二重互換

プロキシとして最低限必要だったのは、OpenAI 互換と Ollama 互換の両方を一つの入口で提供することだった。

MethodPath形式
POST/v1/chat/completionsOpenAI 互換
POST/v1/embeddingsOpenAI 互換
GET/v1/modelsOpenAI 互換
POST/api/chatOllama 互換
GET/api/tagsOllama 互換
GET/healthヘルスチェック
GET/readyレディネス

Ollama 互換はハンドラ内で ChatRole の変換と request/response DTO のマッピングを行い、内部では同一の ChatService に合流する。

マルチバックエンドルーティング

リクエストされたモデル名のプレフィックスでバックエンドを自動選択する。

  // HttpChatService
fn pick_client(&self, model: &str) -> &HttpClient {
    // model prefix → route-specific client, fallback → default client
}
  

デフォルトの LLM クライアントに加えて、設定で追加したルート別クライアントをモデル名でマッチングする。マッチしなければデフォルトにフォールバック。この判断を Service 層に閉じ込めたことで、ルーティングロジックの変更が HTTP 層に波及しない。

ChatRoute による分岐設計

リクエストにカスタムヘッダ x-route を付けることで、処理パスを明示的に分岐する設計にした。

  pub enum ChatRoute {
    Direct,    // バックエンド LLM への直接転送
    Rag,       // ベクトル検索 + コンテキスト付与 + LLM
    Workflow,  // Dagster パイプラインへの委譲
}
  

加えて x-workspace(RAG ワークスペース ID)、x-pipeline(ワークフローパイプライン ID)、x-correlation-id(リクエスト追跡 ID)を拡張ヘッダとして定義した。これにより、同じ /v1/chat/completions エンドポイントで直接推論、RAG、ワークフローの 3 パスを表現できる。

SSE ストリーミング

レスポンスは 3 つの形式を想定した。

  pub enum ChatServiceResponse {
    Once(ChatCompletionResponse),       // 非ストリーム: JSON 一括
    Stream(Pin<Box<dyn Stream<...>>>),  // チャンク: OpenAI 互換
    StreamRaw(Pin<Box<dyn Stream<...>>>), // 生テキスト: バックエンド透過
}
  

Stream モードでは data: {...}\n\n 形式の SSE で逐次送信し、[DONE] センチネルで終了する。StreamRaw はバックエンドの SSE レスポンスをそのまま透過する。


実装

動作した部分

プロトタイプとして実際に動作確認できたのは以下の範囲だった。

  • /v1/chat/completions — OpenAI 互換のストリーミング / 非ストリーミング応答
  • /api/chat — Ollama 互換(内部で OpenAI 形式に変換)
  • /v1/models — バックエンドからモデル一覧を取得(OpenAI / Ollama 両形式対応)
  • マルチバックエンドルーティング — モデル名プレフィックスでクライアント自動選択
  • SSE ストリーミング — reqwest のバイトストリームから SSE ラインを解析して中継
  • 構造化ログ — tracing でハンドラごとにモデル名、ストリームフラグ、メッセージ数を記録

スタブのまま残った部分

以下は trait 定義と設計仕様は完成したが、実装はスタブのままだった。

機能状態設計上の位置
RAG コンテキスト構築StubRagService(固定文字列を返す)Qdrant + PostgreSQL pgvector 検索
エンベディングStubEmbeddingsService(固定応答)ONNX モデル推論
Workflow パイプラインChatRoute::Workflow のスタブDagster oneshot ジョブ起動
NATS イベント中継async-nats は依存に含むが未接続evt.chat.{trace_id} パブリッシュ
認証ApiKeyAuthorizer 定義済みだが検証ロジック未接続ハンドラで Ok(()) を返す
PostgreSQL 冪等ログsqlx は依存に含むが未接続idempotency_log / completions_cache

エラー形式

エラーは OpenAI 互換の JSON 形式に統一した。

  pub enum ApiError {
    Unauthorized,      // 401
    Forbidden,         // 403
    BadRequest(String), // 400 → validation_error
    NotFound(String),  // 404 → validation_error
    Backend(String),   // 502 → backend_error
    Internal(String),  // 500 → proxy_error
}
// → { "error": { "message": "...", "type": "...", "code": N } }
  

Docker Compose 構成

  services:
  proxy:    # Rust プロキシ :8080
    environment:
      LLM_BASE_URL: http://host.docker.internal:14434  # vLLM/Ollama
      DATABASE_URL: postgres://postgres:postgres@db:5432/openai
      QDRANT_URL: http://qdrant:6333
    depends_on: [db, qdrant]
  qdrant:   # ベクトル DB :6333
  db:       # PostgreSQL 18 :5432
  

マルチステージ Dockerfile で Rust 1.79 ビルド → Debian bookworm-slim ランタイムイメージに最適化。


NATS + Dagster の設計仕様(未実装)

プロトタイプでは実装に至らなかったが、設計仕様として固定した部分を記録する。Go 移行後にこの仕様をベースに実装を完了した。

通信フロー

  1. クライアント → /v1/chat/completions(stream=true)
2. Rust が trace_id を採番、SSE 開始
3. systemd / Quadlet 経由で dagster-<job>@{trace_id} を oneshot 起動
4. Dagster op が LLM / ツールを並列実行、evt.chat.{trace_id} に publish
5. Rust が subscribe → OpenAI chunk に整形 → SSE に流す
6. 完了時に PostgreSQL / Qdrant へ UPSERT
  

イベントスキーマ

  {"type":"role","role":"assistant"}
{"type":"token","text":"...","task":"A"}
{"type":"tool_call","name":"search","arguments":"{...}"}
{"type":"usage","usage":{...}}
{"type":"finished","reason":"stop","winner":"llama3.1-8b"}
  

冪等設計

  • trace_id: セッション識別子
  • req_id = sha256(model + messages + params): リクエスト識別子
  • すべての副作用は UPSERT か unique 制約で重複吸収
  • finished は成果物コミット後に一度だけ送信

JetStream への段階移行

初期は NATS Core の軽さを優先し、確実受付・再送・リプレイが必要になった時点で JetStream に切り替える想定だった。Go 移行後は最初から JetStream を採用した。


Go 移行の判断

Rust プロトタイプを一通り動かした時点で、以下の判断に至った。

非同期コンテキストの管理コスト

SSE 中継 + NATS 購読 + PostgreSQL/Qdrant 書き込みが同一リクエスト内で同時に走る。Rust ではこれらを Pin<Box<dyn Stream>> + tokio::select! + ライフタイム管理で繋ぐ必要があり、記述量とデバッグコストが設計の本質と無関係な部分で膨らんだ。

Go の goroutine + channel はこのパターンに素直にフィットする。各非同期タスクを goroutine で起動し、channel で合流するだけで同等の制御フローが書ける。

実行時オーバーヘッド

このユースケースのボトルネックは LLM 推論の I/O wait であり、プロキシ層の CPU bound 処理は無視できるレベルだった。Rust のゼロコスト抽象化が活きる場面ではない。

設計資産の転用

Rust で固めた設計仕様と型契約は Go でそのまま再利用できた。

RustGo
trait ChatServiceinterface ChatService
ChatCompletionRequest structChatCompletionRequest struct
ChatRoute enumChatRoute const
ApiError enumApiError type + HTTP status mapping
レイヤードアーキテクチャinternal/transport, internal/domain, internal/infra

Rust 版の型定義がそのまま Go の構造体定義に変換でき、ルーティングロジック、エラー形式、拡張ヘッダの仕様もそのまま持ち越した。

移行後に変わったこと

項目Rust プロトタイプGo 本実装
NATSスタブ(async-nats 未接続)JetStream publish (fire-and-forget)
Dagster設計仕様のみdaemon sensor + asset materialization
RAGStubRagServiceKnowledge Service (embed → pgvector ANN → rerank)
認証未接続RequestContext ミドルウェアで追跡 ID 管理
テレメトリtracing ログのみVector (Rust) → Prometheus + Loki + Grafana
ホスト構成単一ホスト Docker Compose3 ホスト (storage / desktop / compute)
reranker設計構想のみmulti-bert-inference (Rust + ONNX Runtime) gRPC 連携

注意事項

  • Rust プロトタイプのコードは openai-api-proxy リポジトリに残っているが、Go 移行後はメンテナンスしていない
  • NATS、Dagster、RAG の設計仕様は Go 側で発展的に変更されている(NATS Core → JetStream、oneshot → sensor 駆動、Qdrant → pgvector + ColBERT rerank)
  • Ollama 互換エンドポイントは Go 版では Anthropic Messages API に置き換えた

検証

  • OpenAI 互換エンドポイントの動作確認: ストリーミング / 非ストリーミング両方で正常応答
  • Ollama 互換エンドポイントの DTO 変換が正しく動作
  • マルチバックエンドルーティング: モデル名プレフィックスによる振り分けを確認
  • Docker Compose で proxy + PostgreSQL + Qdrant の起動・接続を確認

次のアクション

Rust プロトタイプの役割は、設計仕様の確定と型契約の固定だった。その目的は達成した。

Go 移行後の本実装では、NATS JetStream によるイベント中継、Dagster sensor による pull subscribe + asset materialization、pgvector ANN + ColBERT rerank による RAG オーケストレーション、3 ホスト構成のローカル AI 基盤が稼働している。

その設計全記録はGo + NATS + Dagster によるAIオーケストレーション基盤にまとめている。