この記事について

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 推論を使い分けている:

バックエンドホスト対応モデル例
vLLMcompute.home.arpa:8000qwen3-next:80b, qwen3-coder:30b
llama.cppcompute.home.arpa:8081nemotron-3-nano:30b
LM Studiocompute.home.arpa:1234lfm2.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_persistknowledge_embeddingknowledge_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 の流れ:

  1. ユーザクエリ抽出
  2. エンベディング生成(multi-bert-inference gRPC)
  3. pgvector でベクトル検索 + ColBERT リランキング
  4. 検索結果をコンテキストとして付与
  5. LLM バックエンドへ送信
  6. NATS にパイプラインイベント発行
  7. レスポンス返却

ベクトルストアは 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.ymlinference サービスとして追加:

  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段のパーサーチェーンで正規化する:

  1. ApplyToolCallParser – ツールコール正規化
  2. ApplyReasoningParser – thinking/推論タグ抽出
  3. ApplyVisionParser – 画像 base64 処理
  4. ApplyOutputParser – 出力フォーマット正規化

ミドルウェア

RequestContext -> Logger -> Recovery のチェーン。RequestContextX-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 リデザインでドメインの再分割に入っていくが、それはまた別の記事の話になる。