Rust (axum) で OpenAI 互換プロキシを設計・実装した経緯と Go 移行に至るまで
Rust (axum) で OpenAI/Ollama 互換プロキシを設計・プロトタイプ実装し、NATS + Dagster の統合を見据えた設計仕様を固めた経緯。SSE + NATS + PG の非同期コンテキスト管理コストから Go 移行を判断するまでの記録。
結論
ローカル 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 互換の両方を一つの入口で提供することだった。
| Method | Path | 形式 |
|---|---|---|
| POST | /v1/chat/completions | OpenAI 互換 |
| POST | /v1/embeddings | OpenAI 互換 |
| GET | /v1/models | OpenAI 互換 |
| POST | /api/chat | Ollama 互換 |
| GET | /api/tags | Ollama 互換 |
| 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 でそのまま再利用できた。
| Rust | Go |
|---|---|
| trait ChatService | interface ChatService |
| ChatCompletionRequest struct | ChatCompletionRequest struct |
| ChatRoute enum | ChatRoute const |
| ApiError enum | ApiError 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 |
| RAG | StubRagService | Knowledge Service (embed → pgvector ANN → rerank) |
| 認証 | 未接続 | RequestContext ミドルウェアで追跡 ID 管理 |
| テレメトリ | tracing ログのみ | Vector (Rust) → Prometheus + Loki + Grafana |
| ホスト構成 | 単一ホスト Docker Compose | 3 ホスト (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オーケストレーション基盤にまとめている。
