AI Chatbot

React NativeアプリにAIチャットボットを追加する方法(FastAPIバックエンド付き)

React Nativeのチュートリアルの多くはUI層で止まります。チャットバブルの描画やキーボード制御は丁寧に説明しながら、バックエンドについては「OpenAI APIをアプリから直接呼ぶ」とだけ説明して終わりにしてしまいます。

このアプローチには二つの根本的な問題があります。第一に、APIキーをモバイルバイナリに埋め込むと、逆コンパイルにより誰でも抽出できます。第二に、サーバー側の制御が一切できません:レート制限なし、ユーザーコンテキスト注入なし、ログなし、アップデートなしでのモデル切り替えも不可能です。

本記事ではプロダクション指向のアプローチを取ります。LLM接続をServer-Sent Events(SSE)ストリーミングで処理するFastAPIバックエンドを構築し、それをExpo(React Native)フロントエンドに接続して、トークン単位でのリアルタイム描画を実現します。

このパターンはJ-SOX監査証跡要件への対応、個人情報保護法(APPI)のデータ国内保管要件、および経済安全保障推進法に基づく重要インフラへのAI導入ガイドラインを満たすために特に有効です。バックエンド一層を挟むことで、プロバイダーの切り替え、監査ログの追加、データフローの完全なコントロールが可能になります。


システムアーキテクチャ

flowchart TD
  A["Mobile App Expo SDK 54"] --> B["FastAPI Backend"]
  B --> C["LLM Provider"]
  C --> D["SSE Stream"]
  D --> E["Chunked Fetch RN 0.81"]
  E --> F["Chat UI トークンを描画"]

FastAPIを選ぶ理由

FastAPIはWebSocket対応、認証ミドルウェアの依存性注入、そしてPythonベースのプライベートLLM(Ollama、vLLM、LiteLLM)との容易な統合を提供します。将来オンプレミスAIが必要になったとき、フロントエンドを一切変更せずにバックエンドのURLを変えるだけで対応できます。

SSEをWebSocketより優先する理由

SSEはHTTP/1.1互換で、リバースプロキシとの相性が良く、J-SOX監査のためのアクセスログ取得も標準的なWebサーバーで対応できます。


モデル選定ガイド(2026年6月時点)

モデル 入力 / 100万トークン 出力 / 100万トークン 適したユースケース
Claude Haiku 4.5 $1.00 $5.00 大量メッセージのチャットボット、FAQボット
Claude Sonnet 4.6 $3.00 $15.00 複雑な推論、営業アシスタント
GPT-5.4 $2.50 $15.00 OpenAIツールチェーンとの統合
DeepSeek V4 Flash $0.14 $0.28 コスト最優先のデプロイ

モバイルチャットボットの大多数——サポートボット、オンボーディングアシスタント、FAQハンドラー——にはClaude Haiku 4.5が最適です。コンテキストウィンドウ200Kトークン、会話1回あたりのコストはSonnetの約60分の1です。

文書合成や多段階推論が必要な場合(例:simpliDocのRAGレイヤーと連携した社内文書Q&Aボット)はSonnet 4.6への切り替えを検討してください。


Part 1: FastAPIバックエンド

セットアップ

mkdir chatbot-api && cd chatbot-api
python -m venv .venv && source .venv/bin/activate
pip install fastapi uvicorn anthropic python-dotenv

.envファイルを作成:

ANTHROPIC_API_KEY=your_key_here
MODEL_ID=claude-haiku-4-5
SYSTEM_PROMPT="あなたはAcme Corpのサポートアシスタントです。"

ストリーミングチャットエンドポイント

# main.py
import os
from fastapi import FastAPI, HTTPException, Header
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from typing import List
import anthropic
from dotenv import load_dotenv

load_dotenv()

app = FastAPI()
client = anthropic.Anthropic(api_key=os.getenv("ANTHROPIC_API_KEY"))
MODEL = os.getenv("MODEL_ID", "claude-haiku-4-5")
SYSTEM = os.getenv("SYSTEM_PROMPT", "あなたは親切なアシスタントです。")

class Message(BaseModel):
    role: str
    content: str

class ChatRequest(BaseModel):
    messages: List[Message]

def stream_response(messages: List[Message]):
    with client.messages.stream(
        model=MODEL,
        max_tokens=1024,
        system=SYSTEM,
        messages=[m.model_dump() for m in messages],
    ) as stream:
        for text in stream.text_stream:
            yield f"data: {text}\n\n"
    yield "data: [DONE]\n\n"

@app.post("/chat")
async def chat(request: ChatRequest, x_api_key: str = Header(...)):
    if x_api_key != os.getenv("APP_API_KEY"):
        raise HTTPException(status_code=401, detail="認証エラー")
    if not request.messages:
        raise HTTPException(status_code=400, detail="メッセージが空です")
    return StreamingResponse(
        stream_response(request.messages),
        media_type="text/event-stream",
        headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
    )

@app.get("/health")
async def health():
    return {"status": "ok", "model": MODEL}

ローカルで起動:

uvicorn main:app --reload --port 8000

Part 2: React Native チャットUI(Expo SDK 54)

React NativeでのSSEストリーミング

React NativeにはネイティブのEventSourceがありませんが、RN 0.79以降ではresponse.body.getReader()を使ったインクリメンタルな読み取りが可能です。fetchオプションにreactNative: { textStreaming: true }を追加するだけです。

// hooks/useChat.ts
import { useState, useCallback } from "react";

export interface Message {
  id: string;
  role: "user" | "assistant";
  content: string;
}

const API_URL = process.env.EXPO_PUBLIC_API_URL ?? "http://localhost:8000";
const API_KEY = process.env.EXPO_PUBLIC_APP_API_KEY ?? "";

export function useChat() {
  const [messages, setMessages] = useState<Message[]>([]);
  const [isStreaming, setIsStreaming] = useState(false);

  const sendMessage = useCallback(async (text: string) => {
    const userMessage: Message = { id: Date.now().toString(), role: "user", content: text };
    const updated = [...messages, userMessage];
    setMessages(updated);
    setIsStreaming(true);

    const assistantId = (Date.now() + 1).toString();
    setMessages((prev) => [...prev, { id: assistantId, role: "assistant", content: "" }]);

    try {
      const response = await fetch(`${API_URL}/chat`, {
        method: "POST",
        headers: { "Content-Type": "application/json", "x-api-key": API_KEY },
        body: JSON.stringify({ messages: updated.map(({ role, content }) => ({ role, content })) }),
        reactNative: { textStreaming: true },
      } as RequestInit);

      const reader = response.body?.getReader();
      const decoder = new TextDecoder();
      while (reader) {
        const { done, value } = await reader.read();
        if (done) break;
        const chunk = decoder.decode(value, { stream: true });
        for (const line of chunk.split("\n")) {
          if (line.startsWith("data: ")) {
            const token = line.slice(6);
            if (token === "[DONE]") break;
            setMessages((prev) =>
              prev.map((m) => m.id === assistantId ? { ...m, content: m.content + token } : m)
            );
          }
        }
      }
    } catch (err) {
      console.error("ストリームエラー:", err);
    } finally {
      setIsStreaming(false);
    }
  }, [messages]);

  return { messages, sendMessage, isStreaming };
}

Part 3: モバイル固有の注意点

ネットワーク切断への対応 — モバイル回線は不安定です。reader.read()ループをtry/catchで囲み、ストリームが[DONE]前に切れた場合は「タップして再試行」ボタンを表示し、受信済みのテキストは保持してください。

FlatListの再描画最適化 — トークンが届くたびにstateが更新されます。renderItemuseCallbackでメモ化し、removeClippedSubviewsを有効にしてください。

APIキーの保護 — ヘッダーパターンを使ってもキーはバンドル内に残ります。より高いセキュリティが必要な場合は短期トークンを実装してください:アプリは通常の認証でバックエンドにログインし、バックエンドがチャットエンドポイント用の15分有効トークンを発行します。J-SOX監査対応のためにはすべてのリクエストをログに記録することを推奨します。


プライベートLLMへの切り替え

経済安全保障推進法の観点から重要インフラへのAI導入を検討している場合、またはAPPIの越境データ移転規制への対応が必要な場合、このFastAPIパターンはオンプレミスLLMへの移行を容易にします:

# Anthropicクライアントをローカル推論サーバーに切り替える
from openai import OpenAI

client = OpenAI(
    base_url="http://your-private-llm:11434/v1",
    api_key="not-needed",
)

React Nativeアプリは一切変更不要です。


よくある質問

FastAPIバックエンドは必須ですか?React Nativeから直接LLM APIを呼べますか?

技術的には可能ですが、プロダクションでは推奨しません。モバイルバイナリに埋め込まれたAPIキーは逆コンパイルで抽出でき、サーバー側での制御も一切できなくなります。

React NativeにEventSourceがないのにストリーミングはどう動くのですか?

RN 0.79以降、fetchオプションにreactNative: { textStreaming: true }を追加することでresponse.body.getReader()がインクリメンタル読み取りに対応します。SSEのdata: プレフィックスを自分でパースします。

大量メッセージを処理するサポートボットにはどのモデルを使うべきですか?

まずClaude Haiku 4.5から始めてください。$1.00/M入力トークンで、このユースケース向けに設計されています。複雑な多段階推論や文書合成が必要になったときにSonnet 4.6へ移行してください。

Android・iOSの両方で同じストリーミングコードが動きますか?

はい。RN 0.81とExpo SDK 54では、チャンクfetchアプローチは両プラットフォームで動作します。


次のステップ

このシリーズの次は:

  • オンデバイスAI — バックエンドなしでデバイス上で量子化モデルを直接実行
  • チャットボットをデータに接続 — FastAPIバックエンドをRAGパイプラインと統合し、社内文書への質問対応を実現

React NativeにAI機能を追加するプロジェクトについてはSimplicoのチームまでご連絡ください。東南アジア・日本のクライアント向けにプロダクションモバイルAI機能を構築しています。