動いた範囲と Go 移行

ローカル LLM を OpenAI 互換 API から使うため、Rust (axum) でプロキシを実装しました。約 1,600 行で OpenAI/Ollama 互換、SSE ストリーミング、モデル名によるバックエンド選択が動きました。HTTP、Domain、Infra を分けています。

NATS 中継、Dagster oneshot、RAG、Qdrant 意味キャッシュはスタブのままでした。SSE、NATS 購読、PostgreSQL/Qdrant 書き込みを同時に扱うコードが複雑になったため、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

設計判断

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

HTTP → Domain → Infra の 3 層に分け、Clean Architecture の依存方向に揃えました。

  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 層には外部依存を置きません。バックエンドや保存先の変更は 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
}
  

設定したルート別クライアントを探し、該当しなければデフォルトを使います。選択処理を Service 層に置き、HTTP 層から分けています。

ChatRoute による分岐設計

x-route ヘッダで処理パスを選ぶ設計です。

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

x-workspace は RAG のワークスペース、x-pipeline はワークフロー、x-correlation-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 形式で送り、[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 の設計仕様(未実装)

以下は Rust 版では未実装の仕様です。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 移行の判断

Go に移した理由は、並行処理の実装量でした。

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

SSE 中継、NATS 購読、PostgreSQL/Qdrant 書き込みを同じリクエストで並行させます。Rust では Pin<Box<dyn Stream>>、tokio::select!、ライフタイムの管理が必要になり、記述とデバッグが増えました。

Go では各処理を goroutine で動かし、channel で結果を受け取る形にできました。

実行時オーバーヘッド

この用途では、主な待ち時間は LLM 推論の I/O でした。プロキシの CPU 処理が小さいため、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

型定義を 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 版で型と API の仕様を決めたことが、Go への移行に役立ちました。

Go 版では NATS JetStream、Dagster sensor の pull subscribe と asset materialization、pgvector ANN + ColBERT rerank による RAG が動いています。3 ホストのローカル AI 基盤で、LLM をアプリケーションから利用する構成です。

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