ローカルLLMを実行する:いつセルフホストすべきか

1. 概要

クラウドLLMプロバイダーのAPIを呼び出すのは、AIをアプリケーションに組み込む最速の方法です。しかし、ますます多くのチームが自分たちのインフラ上でモデルを実行したいと考えています。これはラップトップ、社内サーバー、あるいはレンタルGPUかもしれません。

この記事では、「ローカルLLMを実行する」とは具体的に何を指し、なぜこのトレンドが注目されているのかを探ります。その後、ローカルモデルを構築し、Spring Boot アプリケーションから呼び出し、統合テストを書きます。最後に、セルフホストすべき時とそうでない時を判断するための基準を示します。

2. 「ローカルLLMを実行する」とは何か?

2.1. 三つの基本コンポーネント

セルフホストのLLMシステムは通常、3つのレイヤーで構成されます:

  • モデルの重み: モデルの重みのファイルです。通常、公開リポジトリからオープンウェイトモデルをダウンロードします。
  • 推論ランタイム: 重みをRAMまたはVRAMに読み込み、トークンを生成するソフトウェアです。Ollama、llama.cpp、vLLMなどが例です。
  • APIレイヤー: アプリケーションが呼び出すHTTPエンドポイント。多くのランタイムはOpenAI互換のAPIを提供するため、クライアント側のコードはほぼ変わりません。

アプリケーションは第3のレイヤーのみを認識します。これが重要なポイントで、後にローカルとクラウドを切り替える際、ビジネスロジックを書き直す必要がありません。

2.2. 量子化とメモリ

「自分のマシンで実行できるか」を決定する要因はメモリです。ウェイトに必要なメモリを粗く推定できます:

ウェイトメモリ ≈ パラメータ数 × パラメータあたりのバイト数

16-bit (FP16): 2 バイト / パラメータ
8-bit:         1 バイト / パラメータ
4-bit:       ~0.5 バイト / パラメータ

ウェイトに加えて、KVキャッシュのためのメモリが必要です。これはコンテキスト長と並行リクエスト数に応じて増加します。量子化は数値精度を低下させる技術です(例:16ビットから4ビット)。これでモデルサイズを大幅に削減できます。その代わり、回答品質がわずかに低下します。分類、要約、情報抽出などの多くのタスクでは、この低下は通常許容できるレベルです。

3. なぜセルフホストLLMが注目されているのか?

4つの主な動機があります:

  1. プライバシーと規制遵守: 顧客データ、契約書、または社内ソースコードはあなたのインフラを出ません。
  2. 予測可能なコスト: クラウドAPIはトークンごとに課金されます。負荷が大きく安定している場合、GPUマシンの固定コストがトークンベースの課金より安い場合があります。
  3. レイテンシーとオフライン実行の能力: インターネットを経由したラウンドトリップがなく、第三者のレート制限に依存せず、エアギャップ環境で実行可能です。
  4. 改善され続けるオープンウェイトモデル: 小さなモデルでも多くの特定タスクに十分です。ドメイン固有にファインチューニングすることもできます。

注: 「ローカル」はあなたのノートパソコンである必要はありません。プライベートVPC内のGPUクラスタもセルフホストと見なされます。重要なのはあなたがモデルとデータを制御していることです。

4. Ollamaでローカルモデルを構築する

Ollamaは始めるのに最適な選択肢です。ランタイムをパッケージング、モデル管理、HTTP APIを提供します。Docker Composeで実行できます:

services:
  ollama:
    image: ollama/ollama
    ports:
      - "11434:11434"
    volumes:
      - ollama-data:/root/.ollama
    restart: unless-stopped

volumes:
  ollama-data:

ollama-dataボリュームはダウンロード済みモデルを保持します。これにより、コンテナを再起動するたびにモデルを再ダウンロードする必要がありません。ポート11434はOllama APIのデフォルトポートです。

次に、モデルをダウンロードして試してみます:

docker compose up -d
docker compose exec ollama ollama pull llama3.2

curl http://localhost:11434/api/chat -d '{
  "model": "llama3.2",
  "messages": [{"role": "user", "content": "要約: 注文が3日遅れて配送され、顧客が払い戻しを希望しています。"}],
  "stream": false
}'

llama3.2をあなたのハードウェアに合う任意のモデルに置き換えることができます。"stream": falseを使用すると、結果は次のような単一のJSONで返されます:

{
  "model": "llama3.2",
  "message": { "role": "assistant", "content": "注文が3日遅れて配送されたため、顧客が払い戻しをリクエストしています。" },
  "done": true,
  ...
}

マシンがNVIDIA GPUを持つ場合、NVIDIA Container ToolkitをインストールしてコンテナにGPUを割り当てる必要があります。そうでない場合、モデルはCPUで実行されます。これで動きますが、はるかに遅いです。

5. Spring Bootからローカルモデルを呼び出す

このセクションの例は、実際のシナリオです:カスタマーサポートメッセージの返答ドラフトを作成します。メッセージデータは完全に内部インフラに保持されます。

5.1. 依存関係と設定

Spring Webに含まれているRestClientのみが必要です。追加のAIライブラリは必要ありません:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-test</artifactId>
    <scope>test</scope>
</dependency>

application.propertiesに設定を宣言します:

local-llm.base-url=http://localhost:11434
local-llm.model=llama3.2
local-llm.timeout=60s

タイムアウトは手厚く設定する必要があります。ローカルで実行されるモデル、特にCPUでは、長い応答を生成するのに数十秒かかる場合があります。

5.2. PropertiesとRestClient

@ConfigurationProperties(prefix = "local-llm")
public record LocalLlmProperties(String baseUrl, String model, Duration timeout) {}
@Configuration
@EnableConfigurationProperties(LocalLlmProperties.class)
public class LlmClientConfig {

    @Bean
    RestClient ollamaRestClient(RestClient.Builder builder, LocalLlmProperties props) {
        HttpClient httpClient = HttpClient.newBuilder()
                .connectTimeout(Duration.ofSeconds(5))
                .build();
        JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(httpClient);
        factory.setReadTimeout(props.timeout());

        return builder
                .baseUrl(props.baseUrl())
                .requestFactory(factory)
                .build();
    }
}

ここでの2種類のタイムアウトは異なる役割を果たします。短い接続タイムアウト(5秒)はOllamaがまだ実行されていない場合の迅速な検出を支援します。長い読み取りタイムアウトはモデルが応答を完成させるのを待つためです。

5.3. DTOとサービス

public record ChatMessage(String role, String content) {}

public record ChatRequest(String model, List<ChatMessage> messages, boolean stream) {}

@JsonIgnoreProperties(ignoreUnknown = true)
public record ChatResponse(ChatMessage message, boolean done) {}
@Service
public class SupportReplyService {

    private static final String SYSTEM_PROMPT =
            "あなたはカスタマーサポート担当者です。丁寧で簡潔に答えてください。";

    private final RestClient ollama;
    private final String model;

    public SupportReplyService(RestClient ollamaRestClient, LocalLlmProperties props) {
        this.ollama = ollamaRestClient;
        this.model = props.model();
    }

    public String draftReply(String customerMessage) {
        ChatRequest request = new ChatRequest(model, List.of(
                new ChatMessage("system", SYSTEM_PROMPT),
                new ChatMessage("user", customerMessage)
        ), false);

        ChatResponse response = ollama.post()
                .uri("/api/chat")
                .contentType(MediaType.APPLICATION_JSON)
                .body(request)
                .retrieve()
                .body(ChatResponse.class);

        if (response == null || response.message() == null) {
            throw new IllegalStateException("LLMが内容を返していません");
        }
        return response.message().content().strip();
    }
}

draftReplyの処理フローは以下の通りです:

  • サービスは音声トーンをガイドするsystemメッセージをマージし、顧客のメッセージをuserメッセージに追加します。
  • streamはfalseに設定されているため、Ollamaは応答全体を1つのレスポンスで返します。
  • @JsonIgnorePropertiesはサービスが使用しない統計フィールド(処理時間またはトークン数など)を無視します。
  • 空のレスポンスは理解しにくいNullPointerExceptionを公開するのではなく、明確な例外に変換されます。

6. 統合テスト

ユニットテストを実際のモデルに依存させるべきではありません。遅くて結果が不確定だからです。代わりに、MockRestServiceServerを使用してサービスが正しいリクエストを送信し、正しいレスポンスを読み取ることを確認します:

class SupportReplyServiceTest {

    private MockRestServiceServer server;
    private SupportReplyService service;

    @BeforeEach
    void setUp() {
        RestClient.Builder builder = RestClient.builder().baseUrl("http://localhost:11434");
        server = MockRestServiceServer.bindTo(builder).build();
        var props = new LocalLlmProperties("http://localhost:11434", "llama3.2", Duration.ofSeconds(60));
        service = new SupportReplyService(builder.build(), props);
    }

    @Test
    void givenCustomerMessage_whenDraftReply_thenReturnsAssistantContent() {
        server.expect(requestTo("http://localhost:11434/api/chat"))
                .andExpect(method(HttpMethod.POST))
                .andExpect(jsonPath("$.model").value("llama3.2"))
                .andExpect(jsonPath("$.stream").value(false))
                .andExpect(jsonPath("$.messages[1].content").value("注文はどこですか?"))
                .andRespond(withSuccess("""
                        {"model":"llama3.2",
                         "message":{"role":"assistant","content":"  ご注文を確認しています。  "},
                         "done":true,
                         "total_duration":123456}
                        """, MediaType.APPLICATION_JSON));

        String reply = service.draftReply("注文はどこですか?");

        assertThat(reply).isEqualTo("ご注文を確認しています。");
        server.verify();
    }
}

このテストは4つのポイントをチェックします:

  • リクエストはPOSTメソッドで正しいエンドポイント/api/chatに送信されます。
  • ボディには正しいモデル名とstream=falseが含まれます。
  • 顧客のメッセージは2番目のメッセージに位置します。
  • サービスは未知のフィールドtotal_durationを無視し、応答の前後の余分な空白を削除します。

応答品質をテストしたい場合は、Testcontainersなどを使用して、実際のモデルで実行する別のテストスイートに分離する必要があります。そのテストスイートは定期的に実行すべきであり、ビルドするたびには実行しないでください。

7. ローカルとクラウドの切り替え能力を保持する

OllamaはまたOpenAI互換のエンドポイント/v1/chat/completionsもサポートしています。この標準に従ってクライアントを記述すれば、ローカルとクラウドの切り替えはプロファイル設定の変更のみです:

# application-local.properties
llm.base-url=http://localhost:11434/v1
llm.model=llama3.2

# application-cloud.properties
llm.base-url=https://api.your-provider.example/v1
llm.model=your-cloud-model
llm.api-key=${LLM_API_KEY}

LLM呼び出しを独立したインターフェース(例:ReplyDrafter)の後ろに置くべきです。そうすると、機密データにはローカルモデルを使用し、高い品質が必要なタスクにはクラウドを呼び出すことができます。ビジネスロジックはモデルがどこで実行されているかを知る必要がありません。

8. セルフホストすべき時とそうでない時

8.1. セルフホストすべき場合

  • 機密データがある、または規制がデータを第三者に送信することを許可していない。
  • 大規模で安定した負荷により、トークンベースの費用がGPU所有コストを超える場合がある(運用コストを含めても)。
  • チケット分類、請求書からのフィールド抽出、ログ要約などの狭く繰り返されるタスク。小さいモデルはこれらを良くやる傾向があります。
  • オフライン実行が必要、またはエアギャップ環境やエッジで実行する必要がある。
  • 深いファインチューニングが必要、またはモデルバージョンを完全に制御して時間経過に伴う結果の安定性を確保する必要がある。

8.2. セルフホストすべきでない場合

  • 複雑なタスクに最高の推論能力が必要。クラウドの大規模モデルはこの領域で依然として優れています。
  • トラフィックが低いまたは散発的。GPUがほとんど遊んでいる場合、トークンベースの支払いよりもほぼ確実に高くなります。
  • チームがGPU運用、監視、オートスケーリング、ランタイムのセキュリティ更新の経験がない。
  • アイデア検証段階にある。クラウドAPIで価値を検証してからコストを最適化します。

シンプルなコスト比較方法:

セルフホストコスト / 月 = ハードウェア (減価償却またはレンタル) + 電気/ホスティング + 運用時間
クラウドコスト / 月   = 月あたりのトークン数 × トークンあたりの価格

実際の負荷レベルではセルフホストコストが明らかに低い場合のみセルフホストを選択します。期待される負荷レベルではなく。

8.3. よくある間違い

注: Ollamaはデフォルトではlocalhostのみをリッスンし、認証がありません。ポートをネットワークに公開する場合は、認証を持つリバースプロキシの後ろに置きます。インターネットに直接公開しないでください。

  • 直感によるモデル評価: 数文試して大丈夫に見えたら本番環境に展開。正しいタスク用に固定された評価データセットが必要です。
  • KVキャッシュを忘れる: モデルはVRAMに収まるが、コンテキストが長い場合や複数のリクエストが並行実行された場合にメモリが不足します。
  • タイムアウトが短すぎる: 起動直後の最初の呼び出しはモデル読み込みを待つ必要があるため、通常は非常に遅いです。アプリケーション起動時にモデルを「ウォームアップ」できます。
  • 本番環境に間違ったランタイムを選択: Ollamaは開発と中程度の負荷に非常に便利です。複数の同時ユーザーの場合、vLLMなどのスループット最適化ランタイムを検討してください。

9. 結論

セルフホストLLMはデフォルトでは「より安い」または「より良い」わけではありません。これはトレードオフです:データの制御、予測可能なコスト、低レイテンシーを得ます。その代わり、運用責任を負い、通常は小さいモデルを受け入れる必要があります。

この記事では、DockerでOllamaを構築し、RestClientを使用してSpring Bootから呼び出しました。実際のモデルなしで統合テストを書きました。ローカルとクラウドの間で設定を切り替える設計方法を知りました。最も実用的なアプローチは通常、両者を組み合わせることです:機密データと繰り返されるタスク用のローカルモデル、最高の能力が必要なタスク用のクラウド。

llmaiself-hostingspring-bootollama