AIスタック0円: 詳細解説とコード例

この記事には短い動画があります:YouTubeTikTok

1. 概要

多くの人がAIシステム(エージェント、RAG、内部チャットボット)を構築するには、OpenAIやAnthropicのAPIに月々数千ドル支払う必要があると考えています。しかし、実際はそうではありません。8層で構成されたスタックがあり、各層に無料版またはセルフホステッド版があり、完全なAIシステムを構築・運用でき、費用は0円です。この記事では、そのスタックの各層を順に説明し、各層の役割を解説し、具体的なコード例を示します。最後には、各層にどのツールを選ぶべきか、そして「無料」の本当の限界がどこにあるかがわかるでしょう。

2. 「0円」が永遠の無料を意味しない理由

詳細に入る前に、明確にしておく必要があります。ここでの「0円」はソフトウェア/ライセンスの費用であり、無制限のインフラ費用ではありません。Vercelの無料枠は帯域幅に制限があり、Supabaseの無料枠はストレージに制限があり、ローカルでOllamaを実行するにはあなたのマシンのCPU/RAMが必要です。このスタックはプロトタイプ、学習、小規模な顧客デモに適しており、数百万ユーザー向けにスケールするためのものではありません。

注意: 実際のトラフィックがフリーティアを超えた場合、あなたはまだお金を払う必要があります。ただし、LLMベンダーにトークンで支払うのではなく、インフラ(コンピュート、ストレージ)に支払うだけです。これは、本番環境のためにこのスタックにコミットする前に理解しておくべき重要な違いです。

3. フロントエンド層: Next.js または Streamlit

UI層は複雑にする必要はありません。美しいUIが必要な場合は、Next.jsを使用し、Vercelの無料枠にデプロイします。AIパイプラインの素早いデモだけが必要な場合、Streamlitはフロントエンドを別途作成する必要がないため、はるかに高速な選択です。

# app.py
import streamlit as st
import requests

st.title("内部資料に関する問い合わせアシスタント")

question = st.text_input("質問を入力してください")

if st.button("質問する") and question:
    response = requests.post(
        "http://localhost:8000/ask",
        json={"question": question},
        timeout=30,
    )
    st.write(response.json()["answer"])

このコードは、入力フィールドと1つのボタンを作成します。ボタンをクリックすると、バックエンドAPI(ローカルで実行)を呼び出し、回答を表示します。streamlit run app.pyで実行すると、Streamlitはlocalhost:8501で自動的にホストされます。Reactを書く必要もなく、ビルドパイプラインも不要です。Next.jsに投資する前にアイデアを検証するのに適しています。

4. エージェント調整層: LangGraph

システムが「LLMを一度呼び出して結果を返す」だけではなく、複数のステップ(検索→統合→検証→回答)が必要な場合、オーケストレーターが必要です。LangGraphはエージェントを状態を持つグラフとしてモデル化し、分岐と繰り返しを可能にします。

<!-- ここではpom.xmlは適用されません。LangGraphはPythonライブラリだからです -->
pip install langgraph langchain-ollama
from langgraph.graph import StateGraph, END
from typing import TypedDict

class AgentState(TypedDict):
    question: str
    answer: str

def retrieve_step(state: AgentState) -> AgentState:
    state["answer"] = f"質問に関連するドキュメントを検索中: {state['question']}"
    return state

def generate_step(state: AgentState) -> AgentState:
    state["answer"] += " -> 回答を統合しました"
    return state

graph = StateGraph(AgentState)
graph.add_node("retrieve", retrieve_step)
graph.add_node("generate", generate_step)
graph.set_entry_point("retrieve")
graph.add_edge("retrieve", "generate")
graph.add_edge("generate", END)

app = graph.compile()
result = app.invoke({"question": "保証ポリシーとは何ですか?", "answer": ""})
print(result["answer"])
質問に関連するドキュメントを検索中: 保証ポリシーとは何ですか? -> 回答を統合しました

StateGraphは2つのノード(retrieveとgenerate)で構成された処理フローを定義し、各ノードが状態を受け取り、新しい状態を返します。set_entry_pointは開始ノードを指定し、add_edgeはステップを接続します。app.invoke(…)を呼び出すと、LangGraphはグラフを順序付けて実行し、最終的な状態を返します。すべてローカルで実行され、有料のAPIは呼び出されません。

5. RAGパイプライン層: Chroma と Qdrant

RAG(Retrieval-Augmented Generation)は、埋め込みを保存し、類似度で検索するためのベクトルストアが必要です。Chromaは組み込みで実行され、別のサーバーは不要です。プロトタイプに適しています。

pip install chromadb
import chromadb

client = chromadb.PersistentClient(path="./chroma_data")
collection = client.get_or_create_collection(name="policies")

collection.add(
    documents=["製品保証は購入日から12か月です。",
               "7日以内に、元のボックスがそのままあれば返品できます。"],
    ids=["doc1", "doc2"],
)

results = collection.query(query_texts=["保証ポリシー"], n_results=1)
print(results["documents"])
[['製品保証は購入日から12か月です。']]

PersistentClientはデータをディスクに保存するため、RAMだけではなく、再起動後もデータが保持されます。collection.addは自動的にドキュメントの埋め込みを計算します(Chromaはデフォルトの埋め込み関数があります)。collection.queryは質問に対して最も類似したドキュメントを見つけます。データセットがより大きい場合は、Dockerでローカルに実行するQdrantに切り替えます。APIは類似していますが、中規模スケーリングに向けて最適化されています。

6. LLM層: Ollama でローカルモデルを実行

これはAPIの請求から逃れるための中核層です。Ollamaを使用すると、Llama、Mistral、Gemmaをマシン上で直接実行でき、リクエスト数に制限はありません。

ollama pull llama3.1
ollama run llama3.1 "保証ポリシーを1つの文で要約してください。"
製品は購入日から12か月間保証されます。

コマンドollama pullはモデルをマシンにダウンロードします(モデルは一般的なCPU/GPUで実行するための量子化が行われています)。コマンドollama runはモデルを起動し、プロンプトを送信し、結果をターミナルに直接返します。OllamaはまたREST APIをlocalhost:11434で公開するため、PythonまたはJavaコードに統合できます。

バックエンドがSpring Bootの場合、RestClientを使用してOllamaを呼び出すのは非常に簡単です:

import org.springframework.web.client.RestClient;

public class OllamaClient {

    private final RestClient restClient = RestClient.create("http://localhost:11434");

    public String generate(String model, String prompt) {
        var response = restClient.post()
                .uri("/api/generate")
                .body(new OllamaRequest(model, prompt, false))
                .retrieve()
                .body(OllamaResponse.class);
        return response.response();
    }

    record OllamaRequest(String model, String prompt, boolean stream) {}
    record OllamaResponse(String response) {}
}

OllamaClientはOllamaへのHTTP呼び出しをカプセル化します。Spring 6のRestClientは古いRestTemplateに代わるもので、より簡潔に書けます。OllamaRequestはstream = falseを設定して、トークンをストリーミングするのではなく、完全なレスポンスを一度に受け取ります。デモの例ではこちらの方が処理しやすいです。

7. MCPを通じたツール利用

エージェントがLLMと会話するだけでは、実際には何もできません。MCPは通信プロトコルで、エージェントが外部ツール(ファイル読み取り、データベースクエリ、GitHub APIの呼び出し)を統一されたインターフェースで呼び出すことができ、各ツールのための個別の統合を作成する必要はありません。

{
  "mcpServers": {
    "filesystem": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-filesystem", "/data/docs"]
    },
    "github": {
      "command": "npx",
      "args": ["-y", "@modelcontextprotocol/server-github"]
    }
  }
}

このconfig ファイルは2つのMCPサーバーを宣言しています。1つはファイルシステム(/data/docs内のファイルの読み書き)、もう1つはGitHub(イシューの作成、PRの読み取り)です。あなたのエージェント(LangGraph、Claude Code、またはMCPをサポートする他のクライアント)は、単にこれらのサーバーに接続し、標準的なスキーマに従ってツールを呼び出すだけで、各サービスのためのAPI wrapper を個別に作成する必要はありません。

注意: npxを使用してローカルで実行されるMCPサーバーはライセンス料を必要としませんが、一部のサーバーはアクセストークン(GitHubトークンなど)を要求します。そのトークンはまだ元のサービスから取得する必要があります。

8. コードエージェント: Aider

エージェントがリポジトリ内のコードを自動的に作成・編集する場合、Aiderはオープンソースのコマンドラインツールで、Ollamaを使用したローカルモデルとよく統合されます。

pip install aider-chat
aider --model ollama/llama3.1 --file src/main/java/OrderService.java

上記のコマンドはAiderを開き、Ollama経由のモデルllama3.1を使用するように指定し(有料APIは使用しません)、ファイルOrderService.javaをあらかじめコンテキストに読み込みます。ここから、ベトナム語または英語でリクエストを入力でき(「負の数量を検証する機能を追加」など)、Aiderは差分を生成し、同意すれば自動的にコミットします。

9. データ層: SQLite、DuckDB、Supabase

すべてのAIシステムがPostgresクラスターを必要とするわけではありません。SQLiteは小さな構造化ストレージに十分で、DuckDBはサーバーなしでファイル形式データ(CSV、Parquet)の分析に強力です。

import duckdb

con = duckdb.connect()
result = con.execute("""
    SELECT category, COUNT(*) AS total
    FROM read_csv_auto('orders.csv')
    GROUP BY category
    ORDER BY total DESC
""").fetchall()
print(result)
[('electronics', 120), ('books', 45), ('clothing', 30)]

DuckDBはread_csv_autoを使用してCSVファイルを直接読み取り、ETLや個別のデータベースへのインポートは不要です。SQLクエリは数十万行のデータに対してほぼ瞬座に実行されます。ほとんどの内部分析ユースケースに適しています。REST APIと認証が必要なバックエンドが必要な場合、Supabaseの無料枠はマネージドPostgres(リアルタイムとストレージ付き)を提供します。

10. デプロイメント: Docker、Cloudflare Workers、Hugging Face

Dockerでのコンテナ化により、dev マシンとホストマシン間で一貫性のあるデプロイが可能になります。

# docker-compose.yml
services:
  ollama:
    image: ollama/ollama
    ports:
      - "11434:11434"
    volumes:
      - ollama_data:/root/.ollama

  app:
    build: .
    ports:
      - "8000:8000"
    depends_on:
      - ollama

volumes:
  ollama_data:

このファイルは2つのサービスを定義しています。ollamaはLLMモデルを実行し、appはあなたのバックエンドで、推論のためにollamaに依存しています。docker compose upは2つを同時に起動し、ボリュームollama_dataはコンテナ再起動時にプルされたモデルを保持します。公開デモが必要な場合、フロントエンドをCloudflare Workers(エッジ、寛大な無料枠)にプッシュし、小さなモデルをHugging Face Spacesにプッシュして、他の人が独自のマシン設定なしでテストできるようにします。

11. スタック全体を組み立てる

実際の実行フロー:ユーザーがStreamlitで質問を入力→LangGraphがリクエストを受け取り、Chromaを呼び出して関連コンテキストを取得→コンテキストをプロンプトにマージしてOllamaに送信→ツール(ファイル読み取り、DBクエリ)が必要な場合、エージェントはMCP経由で呼び出す→結果がUIに返される。すべてが1台のマシンまたはコンテナ上で実行され、有料APIへのリクエストは送信されません。

def answer_question(question: str) -> str:
    docs = collection.query(query_texts=[question], n_results=2)
    context = "\n".join(docs["documents"][0])
    prompt = f"以下の情報に基づいて:\n{context}\n\n回答してください: {question}"
    import ollama
    response = ollama.generate(model="llama3.1", prompt=prompt)
    return response["response"]

この関数はRAGの「心臓」です。Chromaからコンテキストを取得し、プロンプトに埋め込み、Ollamaを呼び出して最終的な回答を生成します。これは最も基本的なretrieve-then-generateパターンであり、ユースケースがシンプルな場合は複雑なフレームワークは不要です。

12. データ処理部分のテスト作成

RAGパイプラインで最もテストしやすいのは、ベクトル化する前のテキスト分割(チャンキング)ステップです。Javaバックエンドがこのステップを処理する場合、シンプルなユニットテストは分割時のオフバイワンエラーを防ぎます。

import java.util.List;
import java.util.ArrayList;

public class DocumentChunker {

    public List<String> chunk(String text, int maxLength) {
        List<String> chunks = new ArrayList<>();
        for (int i = 0; i < text.length(); i += maxLength) {
            chunks.add(text.substring(i, Math.min(i + maxLength, text.length())));
        }
        return chunks;
    }
}
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;

class DocumentChunkerTest {

    @Test
    void shouldSplitTextIntoFixedSizeChunks() {
        DocumentChunker chunker = new DocumentChunker();

        List<String> chunks = chunker.chunk("すべての製品の保証は12か月です", 10);

        assertThat(chunks).hasSizeGreaterThan(1);
        assertThat(chunks.get(0)).hasSize(10);
        assertThat(String.join("", chunks)).isEqualTo("すべての製品の保証は12か月です");
    }
}

このテストは3つのことを確認します。長いテキストが複数のチャンクに分割されていること、最初のチャンクが要求された長さであること、すべてのチャンクを結合すると元のテキストと完全に一致すること(1文字も失われない)。これは、チャンキングロジックを自分で書いてテストを十分に行わないときに起こりやすい、サイレントなバグのタイプです。

13. よくあるエラーとこのスタックを使うべきでない場合

  • ローカルモデルは、マシンにGPUがない場合、クラウドAPIより何倍も遅いです。 通常のCPUでは、llama3.1 8Bのリクエストは5~15秒かかります。デモには許容範囲ですが、即座の応答が必要な製品には不適切です。
  • 無料枠には静かなレート制限があります。 Vercel、Supabase、Cloudflareはすべて月単位のリクエストを制限しており、制限を超えるとダッシュボードを監視しない限り、アプリはブロックされる可能性があります。
  • Chromaの組み込み版は複数インスタンス環境に適していません。 バックエンドを複数のポッドにスケールする場合、各ポッドが独立したChromaファイルを持つと不整合が生じます。その時点でQdrantサーバーまたはマネージドベクトルストアに切り替える必要があります。
  • ローカルMCPサーバーは攻撃表面を増やします。 ファイルシステムMCPサーバーはエージェントにファイル読み取り/書き込みを許可します。インターネットに誤って公開された場合、これは深刻なセキュリティ脆弱性です。

注意: SLAのある製品を構築している場合、安定した低遅延が必要な場合、または初期段階で大量のトラフィックが予想される場合、このスタック0円はプロトタイプ段階でのみ使用すべきです。本番環境に移行する際には、マネージドLLM APIまたは専用GPUインスタンスに料金を払うことを検討して、サービス品質を確保してください。

14. 結論

スタック0円はマーケティングトリックではありません。オープンソースツールと本当の無料枠の組み合わせで、RAG、エージェント、ツール利用、デプロイメントが完備されたAIシステムを構築でき、クレジットカードは不要です。本当の障壁は金銭ではなく、各層の統合方法を理解し、無料枠の制限を知り、品質を確保するために有料インフラに切り替えるべき時を知ることです。このスタックから始めて学習とアイデア検証を行い、製品が実際にスケール必要になったら段階的にアップグレードしてください。

llmragollamalanggraphmcp