agent-gateway 構築記 -- Phase 1 リアルタイム知識パイプラインと埋め込みサービスの統合
agent-gateway の Phase 1 構築記録。Go + Gin による OpenAI 互換ゲートウェイの設計から、NATS + Dagster の知識パイプライン、multi-bert-inference のコンテナ化と gRPC 統合までの約2週間。
この記事について
agent-gateway は LLM と RAG 機能を OpenAI 互換 HTTP API で提供するプロキシゲートウェイで、homelab の AI インフラの中核になるものとして設計した。この記事は Phase 1 – リアルタイム基盤の構築記録で、3月初頭から中旬の約2週間の話になる。
後にv3 リデザインでドメイン分割、全レイヤーリファクタで Clean Architecture 適用をやっているが、この記事はそれ以前の「まず動くものを組み上げた」段階の記録だ。
アーキテクチャの全体像
Go + Gin で組んだレイヤードアーキテクチャ。各層の責務を明確に分離する設計にした:
Transport Layer (Gin) [internal/transport/http/]
-- middleware: RequestContext -> Logger -> Recovery
-- v1/ handlers (OpenAI 互換エンドポイント)
Presentation Layer [pkg/openai/]
-- OpenAI 互換 / Anthropic 互換 request/response DTO
Domain Layer [internal/domain/]
|-- knowledge/ RAG オーケストレーション
|-- llm/ マルチバックエンド LLM ルーティング
|-- vectorstore/ ベクトル検索
+-- pipeline/ パイプライン連携 (NATS pub/sub, Dagster トリガー)
Infrastructure Layer [internal/infra/]
|-- vllm/ vLLM クライアント
|-- llamacpp/ llama.cpp クライアント
|-- lmstudio/ LM Studio クライアント
|-- postgres/ PostgreSQL + pgvector
|-- nats/ NATS JetStream pub/sub
|-- dagster/ Dagster ジョブトリガー
+-- reranker/ ColBERT リランカークライアント
3つの LLM バックエンドを設定で切り替える。homelab では用途に応じて GPU 推論と CPU 推論を使い分けている:
| バックエンド | ホスト | 対応モデル例 |
|---|---|---|
| vLLM | compute.home.arpa:8000 | qwen3-next:80b, qwen3-coder:30b |
| llama.cpp | compute.home.arpa:8081 | nemotron-3-nano:30b |
| LM Studio | compute.home.arpa:1234 | lfm2.5-1.2b-instruct-mlx |
40種以上のモデルカタログを管理していて、モデル名のパターンマッチで自動的にバックエンドを振り分ける。qwen3- で始まるモデル名は vLLM、nemotron- は llama.cpp、という具合だ。
NATS による fire-and-forget イベント駆動
gateway の設計で一番こだわったのはリクエスト処理とデータ永続化の分離だった。chat completion のレスポンスを返すパスに永続化処理を入れたくない。
解決策は NATS への fire-and-forget publish。gateway はリクエストを処理してレスポンスを返した後、非同期でイベントを NATS に投げる。永続化は Dagster が非同期に消費する:
ユーザ -> gateway -> LLM バックエンド -> レスポンス返却
\-> NATS publish (fire-and-forget)
\-> Dagster sensor -> JetStream pull -> PostgreSQL
publish するトピック:
pipeline.knowledge.chat.persist -- チャット永続化
pipeline.knowledge.embedding -- 埋め込み生成イベント
pipeline.knowledge.retrieve -- ベクトル検索イベント
pipeline.knowledge.flow.lineage -- フロー DAG リネージ追跡
pipeline.knowledge.tool_call -- ツールコールログ
テレメトリは別経路で、Vector が NATS から subscribe して Prometheus と Loki に転送する。
Dagster 側の NATS パイプライン
model-foundry の Dagster 側に NATS JetStream consumer を使ったアセットとジョブを実装した:
- センサー: NATS JetStream の durable consumer でトピックをポーリング
- アセット: 受け取ったイベントを DuckDB テーブルとして materialization
- ジョブ:
knowledge_chat_persist、knowledge_embedding、knowledge_lineage
JetStream の pull consumer は再配送の可能性があるので、永続化側で冪等性を担保する:
INSERT INTO chat_pairs (correlation_id, prompt, response, model, created_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (correlation_id) DO NOTHING;
RAG オーケストレーションフロー
chat completion リクエストに対する RAG の流れ:
- ユーザクエリ抽出
- エンベディング生成(multi-bert-inference gRPC)
- pgvector でベクトル検索 + ColBERT リランキング
- 検索結果をコンテキストとして付与
- LLM バックエンドへ送信
- NATS にパイプラインイベント発行
- レスポンス返却
ベクトルストアは PostgreSQL + pgvector。document_chunks テーブルに embedding vector(256) カラムと HNSW インデックス(cosine)を持たせている:
CREATE TABLE document_chunks (
id SERIAL PRIMARY KEY,
document_id TEXT NOT NULL,
chunk_index INT NOT NULL,
content TEXT NOT NULL,
embedding vector(256),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_chunks_embedding ON document_chunks
USING hnsw (embedding vector_cosine_ops);
cache hit なら既存の埋め込みから検索してコンテキストを付与。cache miss なら埋め込みを生成して保存してから LLM に渡す。
multi-bert-inference のコンテナ化
RAG のコア部分であるエンベディングとリランキングは、Rust の gRPC サーバー multi-bert-inference が担当している。もともと desktop.home.arpa でスタンドアロンプロセスとして動かしていたが、devstack に組み込むためにコンテナ化した。
3ステージ Containerfile
# Stage 1: 依存のみをビルドしてキャッシュ
FROM rust:1.94.0-slim-trixie AS deps
RUN apt-get update && apt-get install -y \
protobuf-compiler pkg-config libssl-dev g++
WORKDIR /app
COPY Cargo.toml Cargo.lock build.rs proto/ ./
RUN mkdir src && echo "fn main() {}" > src/main.rs && \
cargo build --release && rm -rf src
# Stage 2: 実際のソースをビルド
FROM deps AS builder
COPY src/ src/
RUN cargo build --release && strip target/release/multi-bert-inference
# Stage 3: 最小ランタイム
FROM debian:trixie-slim
RUN apt-get update && apt-get install -y ca-certificates curl && \
rm -rf /var/lib/apt/lists/*
RUN useradd -r -s /bin/false app
USER app
COPY --from=builder /app/target/release/multi-bert-inference /usr/local/bin/
EXPOSE 50051 3000
ENTRYPOINT ["multi-bert-inference"]
ポイントは Stage 1 のダミー main.rs によるキャッシュ層。Cargo の依存ビルドだけを先にやっておくことで、ソース変更時の再ビルドが高速になる。モデルファイルはイメージに含めず、ボリュームマウントで渡す設計にした。モデル更新のたびに数GBのイメージを再ビルドするのは避けたい。
podman build -t desktop.home.arpa/multi-bert-inference .
devstack への組み込み
devstack/desktop/podman-compose.yml に inference サービスとして追加:
inference:
image: desktop.home.arpa/multi-bert-inference
ports:
- "50051:50051"
- "3800:3000"
volumes:
- /Users/ksh3/Development/multi-bert-inference/models:/app/models:ro,z
environment:
RUST_LOG: multi_bert_inference=info
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:3000/healthz || exit 1"]
interval: 10s
timeout: 3s
retries: 3
restart: unless-stopped
gRPC ポート 50051 と REST ヘルスチェックポート(3000 -> 3800 でマッピング)を公開。モデルディレクトリは read-only マウント。
gRPC クライアントとグレースフルデグラデーション
agent-gateway 側の推論サービス接続は internal/infra/inference/client.go で管理する。3つのサービスクライアントを提供:
EmbeddingServiceClient:Embed()(256次元)とEmbedContext()(late chunking)RerankServiceClient:Rerank()(ColBERT MaxSim)SearchServiceClient:MaxSimSearch()
推論サービスへの接続は任意にした。起動時に接続できなくてもゲートウェイは動く:
var inferClient *inference.Client
if strings.TrimSpace(cfg.InferenceGRPCAddr) != "" {
var inferErr error
inferClient, inferErr = inference.NewClient(ctx, cfg.InferenceGRPCAddr)
if inferErr != nil {
slog.Warn("inference gRPC unavailable", "err", inferErr, "addr", cfg.InferenceGRPCAddr)
} else {
slog.Info("inference gRPC connected", "addr", cfg.InferenceGRPCAddr)
orc.SetRerankerRepository(inference.NewRerankRepository(inferClient))
defer inferClient.Close()
}
}
推論サービスが落ちていれば embed/rerank だけが無効になり、LLM プロキシや他のエンドポイントは通常通り動く。開発中にすべてのサービスを立ち上げる必要がないのは地味に重要だった。
Dagster からの埋め込み呼び出し
Dagster 側も同じ gRPC エンドポイントを叩く。EmbeddingResource が protobuf メッセージを直接エンコード・デコードして /search.EmbeddingService/Embed を呼び出す設計で、knowledge_events と chat_pairs アセットで256次元の密な埋め込みを生成している。
col-bert-api: INT8 の罠と FP32 への切り替え
multi-bert-inference と並行して、col-bert-api という candle-onnx ベースの axum API も動かしていた。ここで INT8 量子化モデルが罠にはまった。
onnx/model_int8.onnx をロードして推論を実行すると、実行時エラー。FP32 版(onnx/model.onnx)では同じコードが正常に動作する。原因は CPU の命令セットで、INT8 量子化が要求する i8/VNNI 系の命令セットに対応していなかった。
// 変更前: INT8 優先フォールバック
// 1. onnx/model_int8.onnx -> 2. onnx/model_fp16.onnx -> 3. onnx/model.onnx
// 変更後: FP32 固定
let model = candle_onnx::read_file("onnx/model.onnx")?;
量子化モデルは推論コストを下げるための手段だが、ハードウェアとの互換性を確認せずに使うと「ロードはできるが推論で落ちる」という分かりにくい障害になる。README の「Prefer provided INT8 models if available」という記述も FP32 ベースに書き換えた。
Phase 1 で完成した構成
実装済みエンドポイント
| エンドポイント | 状態 |
|---|---|
POST /v1/chat/completions | 完了 |
POST /v1/messages (Anthropic API) | 完了 |
POST /v1/responses + GET/DELETE | 完了 |
GET /v1/models + GET /v1/models/:id | 完了 |
POST /v1/embeddings | 完了 |
POST /v1/moderations | 完了 |
GET /healthz | 完了 |
LLM パーサーチェーン
LLM の出力は4段のパーサーチェーンで正規化する:
ApplyToolCallParser– ツールコール正規化ApplyReasoningParser– thinking/推論タグ抽出ApplyVisionParser– 画像 base64 処理ApplyOutputParser– 出力フォーマット正規化
ミドルウェア
RequestContext -> Logger -> Recovery のチェーン。RequestContext は X-Correlation-ID ヘッダを抽出するか自動生成し、以降のログに紐づける。
サービストポロジ
desktop.home.arpa
-- agent-gateway :8080
-- NATS :4222
-- multi-bert-inference :50051
compute.home.arpa
-- vLLM :8000
-- llama.cpp :8081
-- LM Studio :1234
-- PostgreSQL :5432
-- Dagster :3300
storage.home.arpa
-- Prometheus :9090
-- Loki :3100
-- MinIO :9000
Phase 1 の目標だった「リアルタイム基盤」はこれで一通り揃った:
- gateway -> NATS publish (fire-and-forget)
- Dagster sensor -> JetStream pull -> PostgreSQL
- Vector -> NATS subscribe -> Prometheus/Loki
- pgvector ANN 検索 + ColBERT リランク
ここからv3 リデザインでドメインの再分割に入っていくが、それはまた別の記事の話になる。
