OpenAI 互換プロキシを Rust から Go に移した理由
Rust (axum) の OpenAI/Ollama 互換プロキシを Go に移しました。LLM 導入・AI システム開発で使う SSE、ルーティング、RAG の実装範囲と移行理由を説明します。
動いた範囲と 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 のクライアントを、一つの入口から使えるようにしました。
| 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
}
設定したルート別クライアントを探し、該当しなければデフォルトを使います。選択処理を 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 に引き継ぎました。
| 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 |
型定義を 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 版で型と API の仕様を決めたことが、Go への移行に役立ちました。
Go 版では NATS JetStream、Dagster sensor の pull subscribe と asset materialization、pgvector ANN + ColBERT rerank による RAG が動いています。3 ホストのローカル AI 基盤で、LLM をアプリケーションから利用する構成です。
その設計全記録はGo + NATS + Dagster によるAIオーケストレーション基盤にまとめている。
