前編の記事では、Rorkで生成したReact NativeアプリからWiFi経由でローカルOllamaサーバーに接続し、基本的なテキスト生成を動かす方法をお伝えしました。「動いた、でも本番に使えるレベルかというと…」と感じた方も多いのではないでしょうか。私も最初にプロトタイプを作ったとき、同じ気持ちでした。
ここで扱うのはそのギャップを埋めることを目的としています。具体的には、ストリーミングで応答を受け取る実装、会話履歴をSQLiteで管理してモデルにコンテキストを渡す仕組み、クラウドAPIへのフォールバック設計、そして個人開発者として持続可能な収益モデルとの組み合わせ方まで、私が実際のアプリ開発で学んだことを体系的に整理しています。
なぜローカルLLMが個人開発者にとって重要な選択肢なのか
この話を始める前に、少し立ち止まって考えてみたいことがあります。
私は個人でAI機能付きのアプリを3本運営しています。最初はOpenAIのAPIを使っていたのですが、DAUが1,000を超えたあたりから月額費用がじわじわと膨らみ始めました。ユーザーが増えることは嬉しい。でも収益より費用の方が伸びが早い — そういう状況に直面したとき、API費用の構造そのものを変える必要があると気づきました。
Ollama + ローカルモデルというアプローチは、その文脈で考えると意味が変わってきます。サーバーを自前で立てることになるので「コストがかかる」と思うかもしれませんが、実は月額数千円の小さなVPSでGemma 4の4Bモデルを動かせます。一方、OpenAI GPT-4oは100万トークンで数十ドルかかります。ユーザーが一定数いるアプリでは、この差は非常に大きい。
ローカルLLMには制約もあります。最新情報の欠如、推論速度の制限、小さなモデルの精度。ただし、「チャット補助」「テキスト要約」「アイデア出し」といった個人向けアプリの典型的なユースケースなら、Gemma 4の7Bモデルでも十分に実用的です。
本番実装に向けたアーキテクチャ設計
シンプルに始める、段階的に複雑さを足す
本番レベルのエッジAI実装には、以下のレイヤーが必要になります。
[Rork App (React Native)]
↓ HTTP streaming
[EdgeAI Gateway (FastAPI)]
├── Local: Ollama (Gemma 4 7B)
└── Cloud fallback: OpenAI / Gemini API
[Data Layer]
├── SQLite: 会話履歴・コンテキスト管理
└── AsyncStorage: 設定・モデル選択
まずローカルのみで動かし、フォールバックは後から足す — この順番が大切です。最初から複雑な分岐を設計すると、デバッグが大変になります。
モデルの選択基準
Ollamaで使えるGemmaファミリーのモデルは、用途に応じて選び分けます。
# EdgeAI Gateway側のモデルルーティング例
MODEL_ROUTING = {
"chat": "gemma4:7b", # 会話全般 — バランス重視
"summary": "gemma4:4b", # 要約・短文生成 — 速度重視
"analysis": "gemma4:12b", # 詳細分析 — 精度重視(リソース許容時)
}
def select_model(task_type: str, context_length: int) -> str:
"""コンテキスト長が長い場合は軽量モデルに切り替える"""
base_model = MODEL_ROUTING.get(task_type, "gemma4:7b")
if context_length > 4000 and base_model == "gemma4:12b":
return "gemma4:7b" # コンテキスト肥大化を防ぐため切り替え
return base_model
トークンストリーミングの実装
前編のサンプルコードは generate エンドポイントへのシンプルなPOSTでした。実際のチャットUIでは、ユーザーが送信したあとに応答が少しずつ流れてくる「ストリーミング」体験が不可欠です。Ollamaの /api/chat エンドポイントは stream: true をサポートしているので、React NativeのFetch APIで対応できます。
// hooks/useOllamaStream.js
import { useState, useCallback, useRef } from 'react';
const OLLAMA_HOST = __DEV__
? 'http://192.168.1.100:11434' // 開発: ローカルIP
: 'https://your-ollama-gateway.example.com'; // 本番: VPS/ゲートウェイ
export function useOllamaStream() {
const [streamingText, setStreamingText] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
const abortControllerRef = useRef(null);
const streamChat = useCallback(async (messages, onComplete) => {
abortControllerRef.current = new AbortController();
setIsStreaming(true);
setStreamingText('');
let fullText = '';
try {
const response = await fetch(`${OLLAMA_HOST}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gemma4:7b',
messages,
stream: true,
options: {
temperature: 0.7,
top_p: 0.9,
num_predict: 1024, // 最大トークン数を制限
},
}),
signal: abortControllerRef.current.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
// React NativeでのNDJSON(改行区切りJSON)ストリーム読み取り
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop() ?? ''; // 未完結の行を次回に持ち越す
for (const line of lines) {
if (!line.trim()) continue;
try {
const parsed = JSON.parse(line);
if (parsed.message?.content) {
fullText += parsed.message.content;
setStreamingText(fullText);
}
if (parsed.done) {
onComplete?.(fullText);
return;
}
} catch {
// JSONパースエラーは無視して継続
}
}
}
} catch (error) {
if (error.name === 'AbortError') {
// ユーザーによるキャンセル — 正常系として扱う
onComplete?.(fullText);
} else {
throw error;
}
} finally {
setIsStreaming(false);
}
}, []);
const cancelStream = useCallback(() => {
abortControllerRef.current?.abort();
}, []);
return { streamingText, isStreaming, streamChat, cancelStream };
}
このhooksを使うと、ストリーミング中のテキストをリアルタイムでUIに反映できます。cancelStream でユーザーが途中でキャンセルするUXも実装できます。
SQLiteによる会話履歴管理
単純な1問1答ではなく、会話の文脈を保持するためには履歴をローカルに保存してモデルに渡す必要があります。Expo SQLiteを使うことで、オフラインでも動く永続的な会話ストレージを作れます。
// services/ConversationDB.js
import * as SQLite from 'expo-sqlite';
class ConversationDB {
constructor() {
this.db = null;
}
async init() {
this.db = await SQLite.openDatabaseAsync('conversations.db');
await this.db.execAsync(`
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
title TEXT,
model TEXT,
created_at INTEGER,
updated_at INTEGER
);
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
conversation_id TEXT,
role TEXT CHECK(role IN ('user', 'assistant', 'system')),
content TEXT,
token_count INTEGER DEFAULT 0,
created_at INTEGER,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_messages_conv_id
ON messages(conversation_id, created_at);
`);
}
async createConversation(model = 'gemma4:7b') {
const id = `conv_${Date.now()}_${Math.random().toString(36).slice(2)}`;
const now = Date.now();
await this.db.runAsync(
'INSERT INTO conversations VALUES (?, ?, ?, ?, ?)',
[id, '新しい会話', model, now, now]
);
return id;
}
async addMessage(conversationId, role, content, tokenCount = 0) {
const id = `msg_${Date.now()}_${Math.random().toString(36).slice(2)}`;
const now = Date.now();
await this.db.runAsync(
'INSERT INTO messages VALUES (?, ?, ?, ?, ?, ?)',
[id, conversationId, role, content, tokenCount, now]
);
// 会話のタイトルを最初のユーザーメッセージから自動生成
if (role === 'user') {
const count = await this.db.getFirstAsync(
'SELECT COUNT(*) as cnt FROM messages WHERE conversation_id = ? AND role = ?',
[conversationId, 'user']
);
if (count.cnt === 1) {
const title = content.length > 30 ? content.slice(0, 30) + '…' : content;
await this.db.runAsync(
'UPDATE conversations SET title = ?, updated_at = ? WHERE id = ?',
[title, now, conversationId]
);
}
}
}
/**
* コンテキストウィンドウを考慮してメッセージを取得する
* totalTokens が maxTokens を超えた場合、古いメッセージから削る
*/
async getMessagesForContext(conversationId, maxTokens = 3000) {
const messages = await this.db.getAllAsync(
`SELECT role, content, token_count FROM messages
WHERE conversation_id = ?
ORDER BY created_at DESC
LIMIT 50`,
[conversationId]
);
// 新しい順に取得したので逆順にして、合計トークンが収まるまで古いものを削る
let totalTokens = 0;
const contextMessages = [];
for (const msg of messages) {
const estimated = msg.token_count || Math.ceil(msg.content.length / 3.5);
if (totalTokens + estimated > maxTokens) break;
totalTokens += estimated;
contextMessages.unshift({ role: msg.role, content: msg.content });
}
return contextMessages;
}
async getRecentConversations(limit = 20) {
return this.db.getAllAsync(
`SELECT * FROM conversations
ORDER BY updated_at DESC
LIMIT ?`,
[limit]
);
}
}
export const conversationDB = new ConversationDB();
これでコンテキストウィンドウを超えないよう自動的に古いメッセージを切り捨てながら、モデルに渡すメッセージ配列を組み立てられます。
クラウドAPIへのフォールバック設計
ローカルサーバーが使えない状況(外出先でWiFiなし、など)でもアプリが使えるよう、クラウドへのフォールバックは欠かせません。ただし、フォールバックするとAPI費用が発生するので、その判断ロジックを慎重に設計する必要があります。
// services/AIRouter.js
const LOCAL_TIMEOUT_MS = 3000; // 3秒でローカル接続を諦める
async function checkLocalAvailability(host) {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), LOCAL_TIMEOUT_MS);
const res = await fetch(`${host}/api/version`, {
signal: controller.signal,
});
clearTimeout(timer);
return res.ok;
} catch {
return false;
}
}
export class AIRouter {
constructor({ localHost, cloudApiKey, onRouteChange }) {
this.localHost = localHost;
this.cloudApiKey = cloudApiKey;
this.onRouteChange = onRouteChange;
this.currentRoute = 'unknown'; // 'local' | 'cloud' | 'unknown'
this.lastChecked = 0;
this.CHECK_INTERVAL_MS = 30_000; // 30秒ごとに再チェック
}
async resolveRoute() {
const now = Date.now();
if (now - this.lastChecked < this.CHECK_INTERVAL_MS) {
return this.currentRoute;
}
this.lastChecked = now;
const isLocalAvailable = await checkLocalAvailability(this.localHost);
const newRoute = isLocalAvailable ? 'local' : 'cloud';
if (newRoute !== this.currentRoute) {
this.currentRoute = newRoute;
this.onRouteChange?.(newRoute);
}
return newRoute;
}
async chat(messages) {
const route = await this.resolveRoute();
if (route === 'local') {
return this.chatLocal(messages);
} else {
return this.chatCloud(messages);
}
}
async chatLocal(messages) {
const response = await fetch(`${this.localHost}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'gemma4:7b',
messages,
stream: false,
}),
});
const data = await response.json();
return { text: data.message.content, source: 'local' };
}
async chatCloud(messages) {
// OpenAI互換API(Geminiでも構造は同じ)
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${this.cloudApiKey}`,
},
body: JSON.stringify({
model: 'gpt-4o-mini', // フォールバックは安いモデルを選択
messages,
max_tokens: 1024,
}),
});
const data = await response.json();
return { text: data.choices[0].message.content, source: 'cloud' };
}
}
onRouteChange コールバックでUIに「ローカルAI接続中」「クラウドAI使用中」のステータスを表示するのがおすすめです。ユーザーが費用の発生タイミングを把握できますし、透明性という観点でも好印象を与えられます。
チャット画面の完成実装
ここまでのパーツを組み合わせたチャット画面のコアをまとめます。
// screens/ChatScreen.js
import React, { useState, useEffect, useRef } from 'react';
import {
View, FlatList, TextInput, TouchableOpacity,
Text, StyleSheet, KeyboardAvoidingView, Platform,
} from 'react-native';
import { conversationDB } from '../services/ConversationDB';
import { useOllamaStream } from '../hooks/useOllamaStream';
import { AIRouter } from '../services/AIRouter';
const router = new AIRouter({
localHost: 'http://192.168.1.100:11434',
cloudApiKey: process.env.EXPO_PUBLIC_OPENAI_KEY,
onRouteChange: (route) => console.log(`AI route: ${route}`),
});
export default function ChatScreen({ conversationId: initialConvId }) {
const [messages, setMessages] = useState([]);
const [inputText, setInputText] = useState('');
const [convId, setConvId] = useState(initialConvId);
const [aiSource, setAiSource] = useState('');
const { streamingText, isStreaming, streamChat, cancelStream } = useOllamaStream();
const flatListRef = useRef(null);
useEffect(() => {
(async () => {
if (!convId) {
const id = await conversationDB.createConversation();
setConvId(id);
}
})();
}, []);
// ストリーミング中はアシスタントの仮メッセージを表示
const displayMessages = isStreaming
? [...messages, { role: 'assistant', content: streamingText, isStreaming: true }]
: messages;
const handleSend = async () => {
if (!inputText.trim() || isStreaming) return;
const userContent = inputText.trim();
setInputText('');
// UIに即時反映
const newUserMsg = { role: 'user', content: userContent };
setMessages((prev) => [...prev, newUserMsg]);
// DBに保存
await conversationDB.addMessage(convId, 'user', userContent);
// コンテキスト付きメッセージを取得
const contextMessages = await conversationDB.getMessagesForContext(convId);
// ルーターの状態を取得してUIに表示
const route = await router.resolveRoute();
setAiSource(route === 'local' ? '🏠 ローカルAI' : '☁️ クラウドAI');
if (route === 'local') {
// ローカルはストリーミング
streamChat(contextMessages, async (fullText) => {
const assistantMsg = { role: 'assistant', content: fullText };
setMessages((prev) => [...prev, assistantMsg]);
await conversationDB.addMessage(convId, 'assistant', fullText);
});
} else {
// クラウドは非ストリーミング(費用を最小化)
try {
const result = await router.chatCloud(contextMessages);
const assistantMsg = { role: 'assistant', content: result.text };
setMessages((prev) => [...prev, assistantMsg]);
await conversationDB.addMessage(convId, 'assistant', result.text);
} catch (e) {
console.error('Cloud API error:', e);
}
}
};
return (
<KeyboardAvoidingView
style={styles.container}
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
{aiSource ? (
<Text style={styles.sourceIndicator}>{aiSource}</Text>
) : null}
<FlatList
ref={flatListRef}
data={displayMessages}
keyExtractor={(_, i) => String(i)}
onContentSizeChange={() => flatListRef.current?.scrollToEnd()}
renderItem={({ item }) => (
<View style={[
styles.bubble,
item.role === 'user' ? styles.userBubble : styles.aiBubble,
]}>
<Text style={styles.bubbleText}>{item.content}</Text>
{item.isStreaming && <Text style={styles.cursor}>▌</Text>}
</View>
)}
/>
<View style={styles.inputRow}>
<TextInput
style={styles.input}
value={inputText}
onChangeText={setInputText}
placeholder="メッセージを入力..."
multiline
editable={!isStreaming}
/>
{isStreaming ? (
<TouchableOpacity style={styles.cancelBtn} onPress={cancelStream}>
<Text style={styles.btnText}>⏹</Text>
</TouchableOpacity>
) : (
<TouchableOpacity style={styles.sendBtn} onPress={handleSend}>
<Text style={styles.btnText}>送信</Text>
</TouchableOpacity>
)}
</View>
</KeyboardAvoidingView>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#F9FAFB' },
sourceIndicator: {
textAlign: 'center', fontSize: 11, color: '#6B7280',
paddingVertical: 4,
},
bubble: {
maxWidth: '80%', padding: 12, borderRadius: 16,
marginHorizontal: 16, marginVertical: 4,
},
userBubble: {
backgroundColor: '#3B82F6', alignSelf: 'flex-end',
borderBottomRightRadius: 4,
},
aiBubble: {
backgroundColor: '#FFFFFF', alignSelf: 'flex-start',
borderBottomLeftRadius: 4,
shadowColor: '#000', shadowOpacity: 0.06,
shadowRadius: 4, elevation: 2,
},
bubbleText: { fontSize: 15, lineHeight: 22, color: '#111827' },
cursor: { color: '#3B82F6', fontWeight: 'bold' },
inputRow: {
flexDirection: 'row', alignItems: 'flex-end',
padding: 12, borderTopWidth: 1, borderTopColor: '#E5E7EB',
backgroundColor: '#FFFFFF',
},
input: {
flex: 1, backgroundColor: '#F3F4F6', borderRadius: 20,
paddingHorizontal: 16, paddingVertical: 10,
fontSize: 15, maxHeight: 120, marginRight: 8,
},
sendBtn: {
backgroundColor: '#3B82F6', borderRadius: 20,
paddingHorizontal: 16, paddingVertical: 10,
},
cancelBtn: {
backgroundColor: '#EF4444', borderRadius: 20,
paddingHorizontal: 16, paddingVertical: 10,
},
btnText: { color: '#FFFFFF', fontWeight: '600', fontSize: 14 },
});
本番デプロイ時のエッジゲートウェイ設計
開発環境ではローカルIPに直接接続できますが、本番ではユーザーの自宅サーバーやVPSにホストするゲートウェイを用意するのが現実的です。FastAPIで作ったシンプルなゲートウェイの例です。
# gateway/main.py
from fastapi import FastAPI, HTTPException, Header
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
import httpx
import asyncio
import json
app = FastAPI()
OLLAMA_BASE = "http://localhost:11434"
class ChatRequest(BaseModel):
messages: list[dict]
model: str = "gemma4:7b"
stream: bool = True
max_tokens: int = 1024
@app.post("/v1/chat")
async def chat(req: ChatRequest, x_api_key: str = Header(None)):
# APIキー認証(シンプルな実装)
if x_api_key != "your-secret-key":
raise HTTPException(status_code=401, detail="Unauthorized")
# レート制限(実装例: ユーザーごとに1分間最大10リクエスト)
# 本番ではRedisを使うのが定石ですが、小規模ならメモリでも可
payload = {
"model": req.model,
"messages": req.messages,
"stream": req.stream,
"options": {
"num_predict": req.max_tokens,
"temperature": 0.7,
},
}
if req.stream:
async def generate():
async with httpx.AsyncClient(timeout=120.0) as client:
async with client.stream(
"POST",
f"{OLLAMA_BASE}/api/chat",
json=payload,
) as response:
async for line in response.aiter_lines():
if line:
yield line + "\n"
return StreamingResponse(generate(), media_type="application/x-ndjson")
else:
async with httpx.AsyncClient(timeout=60.0) as client:
response = await client.post(f"{OLLAMA_BASE}/api/chat", json=payload)
return response.json()
@app.get("/health")
async def health():
try:
async with httpx.AsyncClient(timeout=3.0) as client:
res = await client.get(f"{OLLAMA_BASE}/api/version")
return {"status": "ok", "ollama": res.json()}
except Exception as e:
return {"status": "degraded", "error": str(e)}
このゲートウェイをVPS(月額1,000〜2,000円程度)に立てることで、ユーザーはスマホのモバイル回線からでもローカルLLMを使えるようになります。ただし「ローカル」ではなく「自前サーバー」になるので、プライバシーポリシーの記述は変わります。
コスト設計と個人開発の収益モデル
ここが最もお伝えしたい部分です。エッジAIを使った個人開発アプリの収益モデルをどう設計するか。
私が実践している考え方は「AIコストをユーザーごとの変動費ではなく、固定費に変換する」というものです。
【従来のAPI型モデル】
ユーザー数 × 利用量 × トークン単価 = 変動費
→ DAUが増えるほど費用が増加。スケールに弱い。
【エッジAI型モデル】
VPS固定費(月3,000円) + ごく少量のクラウドフォールバック費 = ほぼ固定費
→ ユーザーが増えても費用はほぼ変わらない。スケールに強い。
この構造を活かしたプレミアム課金の設計例です。
// config/tiers.js
export const TIERS = {
free: {
name: 'フリー',
dailyMessages: 20, // 1日20メッセージ
model: 'gemma4:4b', // 軽量モデル
maxContextMessages: 10, // 直近10件のみ
cloudFallback: false, // クラウドフォールバック不可
},
premium: {
name: 'プレミアム',
dailyMessages: Infinity, // 無制限
model: 'gemma4:7b', // 標準モデル
maxContextMessages: 50, // 直近50件
cloudFallback: true, // 接続できない時はクラウド代替
price: { jpy: 580, usd: 5 },
},
};
無料ユーザーには軽量モデルで制限あり、プレミアムユーザーには標準モデルで無制限 — この差別化は、API課金型では難しいものです。API型では「メッセージ数制限 = コスト削減」という直接的な関係があるので、無制限にすると費用が際限なく増えます。エッジAI型なら、サーバーが耐えられる範囲でプレミアムユーザーに余裕を持って提供できます。
よくある実装上の落とし穴
落とし穴1: コンテキストの肥大化
会話が長くなるにつれてコンテキストウィンドウが溢れ、古いメッセージをどんどん切り捨てるのですが、切り捨て方が雑だと会話の一貫性が崩れます。私が有効だと感じたのは「切り捨てる前に要約をシステムプロンプトに追記する」アプローチです。
// 古いメッセージを要約してコンテキストを圧縮
async function summarizeAndCompress(convId) {
const allMessages = await conversationDB.getAllMessages(convId);
if (allMessages.length < 30) return; // まだ不要
const oldMessages = allMessages.slice(0, -20); // 直近20件以外
const summary = await router.chat([
{
role: 'system',
content: '以下の会話履歴を3文以内で要約してください。重要な事実や決定事項を優先してください。',
},
{ role: 'user', content: JSON.stringify(oldMessages) },
]);
// 要約をシステムプロンプトとして保存
await conversationDB.setSystemPrompt(
convId,
`【過去の会話の要約】\n${summary.text}`
);
}
落とし穴2: ストリーミング中の文字化け
日本語テキストのストリーミングでは、マルチバイト文字がチャンクの境界で分割されることがあります。TextDecoder の { stream: true } オプションを必ず指定してください。前掲のコードではすでに対応していますが、手書きで実装する場合は注意が必要です。
落とし穴3: バックグラウンドでの接続維持
iOSはバックグラウンドに移行したときにTCPコネクションを積極的に切断します。ストリーミング中にユーザーが別のアプリに切り替えると応答が途中で止まります。この場合は AppState APIで状態変化を検知し、バックグラウンド移行時にはストリーミングをキャンセルして非ストリーミングで再送するのが現実的な対処です。
個人開発者の視点から(実体験メモ)
全体を振り返って — エッジAIで個人開発の経済的持続性を高める
この記事でお伝えしたパターンを実装すると、以下が手に入ります。
- ストリーミングによる快適なチャット体験
- SQLiteを使った会話履歴の永続管理とコンテキスト最適化
- ローカル/クラウドの自動ルーティング
- 固定費型のコスト構造を活かしたプレミアム課金設計
個人開発者にとって、持続的に収益を得るためには「ユーザーが増えれば増えるほど費用が膨らむ」構造を変える点が肝心です。エッジAIはその有力な手段のひとつです。
次のステップとして、実際にVPSにOllamaゲートウェイを立ててみることをおすすめします。Hetzner Cloudなら月4ユーロ程度の小さなサーバーでGemma 4の4Bモデルを動かせます。そこから始めて、ユーザーの反応を見ながら7Bに上げていく — その段階的なアプローチが、個人開発には合っていると思います。