AI & Agents11 min readMarian Engineering

ローカルLLMとクラウドLLMを同じ土俵に載せる — プロバイダ抽象化とブートストラップ設計

Ollama NDJSON / OpenAI互換SSE / Gemini を1つのストリーミング契約に正規化する

Key Takeaways

  • プロバイダ抽象化の主戦場はモデルではなくストリーミング形式(NDJSON vs SSE)の正規化
  • プロンプト構築・コンテキスト上限・中断処理は共通化し、プロバイダで変えるのは輸送だけ
  • デフォルトをローカル(Ollama)にし、プライベートなノートを外部に送らない選択を一級市民にする
  • セットアップウィザードは「状態→次の1手」の純粋関数として書き、UIから分岐ロジックを排除する

課題: LLMプロバイダは1つに決められない

ナレッジワークスペースが扱うのは私的なノートです。「ノートを外部APIに送りたくない」という要求は無視できず、ローカルLLMの選択肢が必須になります。一方で要約・埋め込みの品質はクラウドモデルが優位な場面も多い。つまり問題は「どのLLMを使うか」ではなく、複数のLLMを同じ契約で差し替え可能にする抽象化です。

Marianのチャットルートはリクエストボディのproviderフィールドで分岐します。デフォルトはローカル(Ollama)です。

// app/api/marian-ai/route.ts
type AiProvider = 'ollama' | 'openai-compatible'

export const POST = withEnvelopeStream<ChatRequest>('marian-ai', ({ payload, request }) => {
  const messages = buildProviderMessages(payload)
  if (payload.provider === 'openai-compatible')
    return streamOpenAiCompatible(messages, request.signal)
  return streamOllama(messages, request.signal)  // デフォルト
})
プロバイダ既定エンドポイント既定モデルストリーム形式
ollamahttp://localhost:11434gemma3:4bNDJSON
openai-compatiblehttp://localhost:8000/v1gpt-ossSSE
gemini (@google/genai)クラウドgemini-3.1-flash-liteSDK

ストリーミングの正規化: NDJSONとSSEを同じ顔にする

プロバイダ抽象化の実務は、ほぼストリーミング形式の差異の吸収です。Ollamaは1行1JSONのNDJSONで{message: {content}, done}を流し、OpenAI互換サーバーはSSEでdata: {choices: [{delta: {content}}]}を流し、終端は[DONE]という文字列です。

// Ollama: NDJSON
// {"message":{"content":"こん"}}\n{"message":{"content":"にちは"}}\n{"done":true}
// OpenAI互換: SSE
// data: {"choices":[{"delta":{"content":"こん"}}]}\n\n
// data: [DONE]

stream*関数はこれらをパースして「テキストチャンクのyield」という同一契約に正規化します。request.signal(AbortSignal)を下流のfetchへ伝播させるのも共通契約の一部で、ユーザーが生成を中断したらローカル/クラウドのどちらでも接続が切れます。エンベロープ・プロトコルとの統合はwithEnvelopeStreamが担い、trace等のメタデータはヘッダに載るためストリーム本文は汚れません。

プロンプト構築も全プロバイダ共通です。システムプロンプトで「選択されたコンテキストのみから答え、出典を本文で引用する」を指示し、ソースは1件2,400字まで、履歴は直近12メッセージに刈り込みます。プロバイダで変えるのは輸送だけ、内容は変えないのが原則です。

Geminiラッパー: JSONモードと使用量計測

クラウド側(@google/genai)のラッパーには、運用で必要になる2つの仕掛けがあります。generateJson<T>はJSONレスポンスモード+Markdownフェンス除去+パース失敗時のフォールバック値を一体化し、呼び出し側から「LLMが変なJSONを返した」例外処理を消します。使用量計測はMARIAN_USAGE_TELEMETRY=1のオプトインで、ベストエフォートでトークン数を記録します。

ブートストラップウィザード: 「Ollamaがない」から始まるUX

ローカルLLM対応の本当の難所はランタイムではなく、初回セットアップです。ユーザーのマシンにはOllamaが(1)入っていない、(2)入っているが起動していない、(3)起動しているがモデルがない、のどれかの状態があり得ます。Marianはこれを4つの真偽値からなる状態として検出し、次にやるべき1手を返す純粋関数として実装しました。

// lib/ai/ollama-bootstrap.ts
BootstrapState = {
  ollamaInstalled: boolean
  serverReachable: boolean
  hasChatModel: boolean
  hasEmbeddingModel: boolean
  declinedLocal?: boolean
}

nextBootstrapStep(state)
  → 'install' | 'start' | 'pull-models' | 'done' | 'use-cloud'

OS検出(navigator.platform+userAgentのパターンマッチ)→OS別インストールガイド(macOSはbrew install ollama、Linuxはインストールスクリプト、Windowsはインストーラ)→ollama serveの起動→推奨モデルのpull、という導線です。モデルpullの進捗はOllamaの/api/pullが返すレイヤーごとのcompleted/totalをパーセントに正規化してUIへ流します。

  • 推奨チャットモデル: llama3.2:3b(軽量・既定)、qwen2.5:7b(品質)、gemma3:4b(コンパクト)
  • 推奨埋め込みモデル: nomic-embed-text

重要なのはplanBootstrapI/Oを一切持たないことです。ヘルスチェックやpullの実行はUI層が行い、結果をstateに反映して再びplanを呼ぶ。ウィザードの全分岐(「インストール済みだが未起動」「チャットモデルだけある」「ローカルを拒否してクラウドへ」)が、モックなしの同期ユニットテストで網羅できます。

セットアップウィザードは「画面の連なり」ではなく「状態→次の1手」の純粋関数として書く。UIはその関数の出力を描画するだけの存在にする。

まとめ

ローカル/クラウドLLMの共存は、(1)provider 1パラメータでの分岐、(2)NDJSON/SSEを共通チャンク契約へ正規化、(3)AbortSignal伝播とエンベロープヘッダの共通化、(4)プロンプトはプロバイダ非依存、(5)セットアップは決定的ステートマシン、という積み重ねで実現しています。LLMはコモディティ化が進む一方で輸送形式は揃っていないため、抽象化の主戦場はモデルではなくストリームだというのが実装から得た結論です。

FAQ

ローカルLLMとクラウドLLMの切り替えはどう実装されているのか?
チャットAPIのリクエストボディにproviderフィールド(ollama / openai-compatible、デフォルトはollama)を持たせ、ルート内で対応するストリーム関数に分岐します。エンドポイントとモデルは環境変数(OLLAMA_BASE_URL、OPENAI_COMPATIBLE_BASE_URL等)で上書きでき、プロンプト構築・コンテキスト刈り込み・中断処理は全プロバイダ共通です。
OllamaとOpenAI互換APIのストリーミング形式の違いはどう吸収するのか?
OllamaはNDJSON({message:{content}}の行)、OpenAI互換はSSE(data: {choices:[{delta:{content}}]}、終端[DONE])という違いを、各stream関数がパースして「テキストチャンクの列」という同一契約に正規化します。呼び出し側はどちらのプロバイダでも同じコードでチャンクを消費できます。
Ollamaが未インストールのユーザーはどう扱われるのか?
ブートストラップウィザードが4つの真偽値(インストール済み/サーバー到達可能/チャットモデル有/埋め込みモデル有)の状態から次の1手(install / start / pull-models / done / use-cloud)を返します。OS別のインストールガイドとモデルpullの進捗表示つきで誘導し、ローカルを拒否した場合はクラウドにフォールバックします。この判定はI/Oゼロの純粋関数で全分岐がテスト済みです。