最近、Curtain Estimatorアプリに完全にオンデバイスで動作するAIアシスタントをリリースしました。APIコールなし、クラウド依存なし、完全なプライバシー。ユーザーは自然言語でジョブの作成、顧客の検索、プロジェクトの管理ができます—完全にオフラインで。
この記事では、llama.rn(llama.cppのReact Nativeバインディング)とQwen 1.7B(驚くほど優秀な小型言語モデル)を使ってどのように構築したかを説明します。
クラウドAIのプライバシー問題#
ほとんどのモバイルアプリのAI機能はこのように動作します:
- ユーザーがメッセージを入力
- アプリがOpenAI/Anthropic/Googleに送信
- レスポンスが返ってくる
- 請求が蓄積される
カーテン設置ビジネスアプリの場合、これは次を意味します:
- 顧客名、住所、電話番号 → サードパーティに送信
- プロジェクト詳細、見積もり、メモ → 外部サーバーに保存
- コンプライアンスの問題(GDPR、データレジデンシー要件)
- 使用量に応じて増加する月額APIコスト
代替案:ユーザーの電話でAIモデルを直接実行する。
なぜ今実現可能になったのか#
1年前なら非現実的でした。しかし3つのことが変わりました:
1. 量子化モデルが非常に小さい Qwen 1.7BのQ4_K_M量子化はわずか1.1 GB—ほとんどのゲームより小さいです。一度ダウンロードすればアプリのストレージに保存されます。
2. モバイルGPUが高速 llama.cppはMetal(iOS)とVulkan(Android)を活用してGPUアクセラレーションを行います。iPhone 14 Proでは約15 tokens/秒を達成—リアルタイムストリーミングに十分です。
3. 小型モデルが賢くなった Qwen 1.7Bは構造化された指示に従い、JSONをパースし、マルチステップ推論を行えます。ビジネスロジックに最適で、クリエイティブライティング向きではありません。
適切なモデルの選択#
Qwen3-1.7Bに決める前に4つの小型モデルをテストしました:
TinyLlama 1.1B(637 MB) 非常に高速ですが、構造化出力に苦戦します。顧客IDをハルシネーションしたり、必須フィールドを忘れたりすることが頻繁にありました。
Phi-3-mini(1.8 GB) 推論能力は強いですが、冗長すぎます。単純なクエリに対して200語以上の回答を生成しますが、20語で十分でした。
Gemma-2B(1.2 GB)
分類タスクでは高速かつ正確ですが、関数呼び出しが弱いです。ツールシステムに必要な<action>タグを確実に出力できませんでした。
Qwen3-1.7B(1.1 GB)✅
最適なバランス:信頼性のある構造化出力、指示を正確に遵守、<think>タグを使用したチェーン・オブ・ソート推論をサポート。
Q4_K_M量子化はk-meansクラスタリングと4ビット重みを使用—フル精度より75%小さく、品質損失はわずか約5%です。
統合:llama.rnのセットアップ#
llama.rnライブラリはReact Native用にllama.cppをラップしています。インストールは簡単です:
npm install llama.rn
cd ios && pod install次にモデルのダウンロードを設定します:
const MODEL_URL = "https://huggingface.co/unsloth/Qwen3-1.7B-GGUF/resolve/main/Qwen3-1.7B-Q4_K_M.gguf";
const MODEL_PATH = FileSystem.documentDirectory + "llama-models/Qwen3-1.7B-Q4_K_M.gguf";
const downloadModel = async () => {
const downloadResumable = FileSystem.createDownloadResumable(
MODEL_URL,
MODEL_PATH,
{},
(progress) => {
const pct = progress.totalBytesWritten / progress.totalBytesExpectedToWrite;
setDownloadProgress(pct);
}
);
await downloadResumable.downloadAsync();
};モデルは初回使用時にダウンロードされ、進捗を追跡できます。WiFiでは2〜3分、LTEでは5〜8分程度かかります。
ダウンロード後、GPUアクセラレーションでロードします:
const ctx = await initLlama({
model: MODEL_PATH,
n_ctx: 8192, // 8K context window
n_gpu_layers: 99, // Use GPU for all layers
});n_gpu_layers: 99が重要です—計算をCPUではなくMetal/Vulkanにオフロードすることで約5倍の高速化を実現します。
ストリーミング推論#
ユーザーは10秒のローディングスピナーではなく、リアルタイムのレスポンスを期待します。llama.rnはトークンごとのストリーミングをサポートしています:
let fullResponse = "";
await llamaContext.completion(
{
messages: [
{ role: "system", content: systemPrompt },
{ role: "user", content: "Create a job for John Smith" }
],
n_predict: 512,
temperature: 0.7,
top_p: 0.8,
},
(data) => {
// Called for each token
fullResponse += data.token;
setStreamingText(fullResponse);
}
);トークンがストリーミングされるとUIがリアルタイムで更新されます。最新のスマートフォンでは:
- 最初のトークン:約200ms(プロンプト処理)
- 後続のトークン:各約50-80ms
ネットワークバウンドのAPIと比べて即座に感じられます。
小型モデルのための関数呼び出し#
GPT-4の関数呼び出しはJSONスキーマを使用します。小型モデルはこれに苦戦します—不正なJSONを出力したり、必須フィールドを欠落させたりします。
代わりに、よりシンプルなXMLベースのプロトコルを使用しています:
const systemPrompt = `You are an AI assistant. When you need to take an action, output:
<action>{"type":"search_customers","query":"Smith"}</action>
Available actions:
- search_customers: {"type":"search_customers","query":"John"}
- create_job: {"type":"create_job","customer_id":5,"alias":"Living Room"}
- update_job: {"type":"update_job","job_id":"J-0042","status":"quoting"}
Rules:
1. ONE action tag per response, at the very end
2. When searching/creating → end with <action>
3. When just chatting → no action tag
`;なぜ純粋なJSONではなくXMLタグを使うのか?
- 明確なデリミタ:
<action>と</action>は曖昧さのない開始/終了マーカー - パースが簡単:シンプルな正規表現、JSONスキーマ検証不要
- 自己文書化:例が正確なフォーマットを示す
- 寛容性:モデルが余分なテキストを追加しても動作する
モデルがactionを出力したら、パースします:
function parseAction(text: string): Action | null {
const match = text.match(/<action>([\s\S]*?)<\/action>/);
if (!match) return null;
try {
return JSON.parse(match[1].trim());
} catch {
return null;
}
}次にそれを実行し、結果を会話に注入します:
const action = parseAction(modelResponse);
if (action) {
const result = await executeAction(action);
// Inject result as a system message
conversationHistory.push({
role: "user",
content: `[TOOL_RESULT] ${result.message}\n\n→ NEXT: Tell the user what happened.`
});
// Continue generation
await generateNextResponse();
}これによりマルチステップワークフローが可能になります。例えば:
ユーザー:「Create a job for Smith」
- モデル:
<action>{"type":"search_customers","query":"Smith"}</action> - システム:
[TOOL_RESULT] Found: Jane Smith (id:42), Bob Smith (id:89) - モデル:「Smithさんが2人見つかりました—JaneとBob。どちらですか?」
- ユーザー:「Jane」
- モデル:
<action>{"type":"create_job","customer_id":42}</action> - システム:
[TOOL_RESULT] Job J-0073 created - モデル:「完了しました!Jane Smithのジョブ J-0073を作成しました。」
<think>タグを使ったチェーン・オブ・ソート#
小型モデルは明示的な推論ステップの恩恵を受けます。Qwenはシンキングモードをサポートしています:
const systemPrompt = `Before each response, wrap your reasoning in <think> tags:
<think>
INTENT: what does the user want?
HAVE: what data do I already have?
NEED: what's still missing?
DECISION: call tool | ask user | just respond
</think>
Then output your actual response.`;モデルの内部推論:
<think>
INTENT: create a job
HAVE: customer query "Smith"
NEED: exact customer_id
DECISION: search first
</think>
<action>{"type":"search_customers","query":"Smith"}</action>UIから<think>タグを除去しますが、開発中はデバッグ用に表示します。これにより信頼性が劇的に向上します—モデルが自分でロジックを整理します。
会話とコンテキストの処理#
8Kコンテキストウィンドウはツール結果ですぐに埋まります。約30ターン後に上限に達しました。
解決策:会話圧縮。モデルに要約を求めます:
const compactPrompt = `Summarize this conversation in under 200 words.
Include: user's goal, customers/jobs created (with IDs), and any unfinished tasks.
${conversationHistory.map(m => `${m.role}: ${m.content}`).join('\n\n')}`;
const summary = await chat([{ role: "user", content: compactPrompt }]);
// Replace history with summary
conversationHistory = [
{ role: "user", content: `[CONTEXT SUMMARY]\n${summary}\n[END SUMMARY]` }
];50メッセージの会話が約150トークンに圧縮されます。モデルは作成されたジョブや顧客IDを失うことなく、要約から続行できます。
セッションの永続化#
チャットセッションはDjangoバックエンドに保存されます:
interface SessionMessage {
role: "user" | "assistant";
content: string; // Stripped for display
raw: string; // Full with <think> and <action> tags
}
await api.createAIChatSession({
organization_id: orgId,
title: firstUserMessage.slice(0, 60),
messages: [
{ role: "user", content: userText, raw: userText },
{ role: "assistant", content: cleanedResponse, raw: fullModelOutput }
]
});デュアルストレージ(content vs raw)により:
- UIリプレイ:チャット履歴にクリーンなメッセージを表示
- 正確な復元:actionを含むフルコンテキストで再開
ユーザーはChatGPTと同様に、履歴ドロップダウンで会話を切り替えられます。
パフォーマンス:本番環境のベンチマーク#
iPhone 14 Pro(A16 Bionic、6GB RAM):
- モデルロード:約3秒
- 最初のトークン:180-220ms
- ストリーミング:14-16 tokens/秒
- メモリ:約1.8 GB
Samsung Galaxy S23(Snapdragon 8 Gen 2、8GB RAM):
- モデルロード:約4秒
- 最初のトークン:250-300ms
- ストリーミング:10-12 tokens/秒
- メモリ:約2.1 GB
iPhone 11(A13、4GB RAM):
- モデルロード:約6秒
- 最初のトークン:400-500ms
- ストリーミング:6-8 tokens/秒
- メモリ:約2.2 GB(メモリ不足で時折クラッシュ)
推奨:スムーズな体験には6GB以上のRAM。4GBでも動作しますが、アプリがバックグラウンドに移行する際にモデルのアンロードが必要な場合があります。
メモリ管理#
アプリがメモリを使いすぎるとiOSがアプリを終了します。バックグラウンド移行時にモデルをアンロードします:
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextAppState) => {
if (nextAppState === "background") {
llamaContext?.release(); // Free ~2 GB
} else if (nextAppState === "active") {
loadModel(); // Reload when foregrounded
}
});
return () => subscription.remove();
}, []);これによりメモリ警告を防ぎ、アプリの応答性を維持します。
バッテリーへの影響#
オンデバイスで推論を実行するとバッテリーを消費します。実際のテスト結果:
| 使用パターン | 追加バッテリー消費 |
|---|---|
| 軽い使用(1日5-10クエリ) | 1日 <1% |
| 中程度(1日20-30クエリ) | 1日約3-4% |
| ヘビー(1日50以上のクエリ) | 1日約6-8% |
GPUアクセラレーションはCPUより高速ですがより多くの電力を使用します。このトレードオフは価値があります—ユーザーは即座のレスポンスを好みます。
実世界の結果#
約200人のベータユーザーで2ヶ月間の本番運用後:
最も一般的なユースケース:
- 「Create a job for [customer name]」 - クエリの68%
- 「Find jobs for [customer]」 - 15%
- 「Update job [ID] to [status]」 - 9%
- アプリに関する一般的な質問 - 8%
成功率: 91%のクエリが初回で正常に完了
失敗モード:
- 顧客が見つからない(ユーザーの名前のスペルミス) - 4%
- コンテキストオーバーフロー(非常に長い会話) - 3%
- ハルシネーションデータ(モデルが顧客IDを捏造) - 2%
明示的なcustomer_id=42フィールドを持つ構造化ツール結果を追加した後、ハルシネーション問題は劇的に改善しました。小型モデルにはこのスキャフォールディングが必要です。
コスト比較#
オンデバイス(現在):
- インフラストラクチャ:$0/月
- ユーザーあたりのコスト:$0(1回限りの1.1 GBダウンロード)
- スケール先:無制限のユーザー
クラウド代替案(推定):
- 平均会話:約2,000トークン
- 200ユーザー × 30会話/月 = 6,000会話
- 6,000 × 2,000 = 1,200万トークン/月
- OpenAI GPT-3.5:$18/月
- OpenAI GPT-4:$360/月
- Anthropic Claude:$180/月
オンデバイスアプローチは即座にコストを回収しました。スケール時(10,000ユーザー)でもコストは$0のままです。
知っておくべき制限事項#
1. モデルの能力 Qwen 1.7Bは構造化タスクに優れていますが、以下は苦手です:
- 複雑な推論(マルチホップ質問)
- 事実に関する知識(検索エンジンではない)
- クリエイティブなコンテンツ生成
- ニュアンスのある言語理解
小型モデルが得意とすることを中心に機能を設計してください。
2. デバイス要件 最小:3 GB RAM、2 GB空きストレージ 推奨:6 GB RAM、64ビットプロセッサ、GPUサポート
古いデバイス(iPhone 8、Galaxy S9)は苦戦します。機能検出とグレースフルフォールバックを検討してください。
3. モデルの更新 モデルを更新するには、ユーザーは1.1 GBを再ダウンロードする必要があります。ストレージパスにバージョンを付けました:
const MODEL_PATH = `${FileSystem.documentDirectory}llama-models/v2/Qwen3-1.7B-Q4_K_M.gguf`;新しいモデルバージョンを出荷する際にパスをインクリメントします。古いモデルはアンインストール時に自動削除されます。
プラットフォームの違い:iOS vs Android#
iOS(Metal):
- より高速な推論(Androidより約20%高速)
- より良いメモリ管理
- モデルがデフォルトでiCloudにバックアップされる(
NSURLIsExcludedFromBackupKeyで無効化可能)
Android(Vulkan/OpenCL):
- デバイス間のバラつきが大きい
- 一部の古いGPUはVulkanをサポートしない(CPUにフォールバック)
- ストレージは自動バックアップされない
両プラットフォームでテストしてください—パフォーマンスは大きく異なる場合があります。
デバッグのヒント#
1. 詳細ログを有効にする
const ctx = await initLlama({
model: MODEL_PATH,
n_ctx: 8192,
n_gpu_layers: 99,
verbose: true, // Logs every token + timings
});2. トークン数を追跡する
const tokens = fullResponse.split(/\s+/).length * 1.3; // Rough estimate
console.log(`Generated ${tokens} tokens in ${duration}ms`);3. メモリを監視する
import { MemoryInfo } from 'react-native-device-info';
const memoryUsage = await MemoryInfo.getUsedMemory();
console.log(`Memory: ${(memoryUsage / 1024 / 1024).toFixed(0)} MB`);4. オフラインでテストする
機内モードを有効にしてすべてが動作することを確認します。モデルはキャッシュからロードされ、推論は完了し、バックエンドの永続化のみがグレースフルに失敗するはずです。
次のステップ#
マルチモーダルサポート llama.cppはビジョンモデル(LLaVA、Qwen2-VL)をサポートしています。想像してみてください:「カーテン生地の写真です—ジョブに追加してください。」
ファインチューニング ドメイン固有のデータ(過去のジョブ記述、顧客とのやり取り)でLoRAアダプターをトレーニング。精度を20-30%向上させる可能性があります。
音声インターフェース Whisper.cppと組み合わせてオンデバイス音声認識。完全にオフラインの音声アシスタント。
連合学習 生データを共有せずにユーザー間でモデルの改善を集約。プライバシーを保護した個人化。
これを構築すべきか?#
適している場合:
- 機密データを扱うビジネスアプリ
- オフラインファーストのワークフロー
- 構造化タスク(分類、データ入力、検索)
- 大規模でコスト意識が高い場合
適さない場合:
- オープンエンドのチャット(Claude/GPT-4を使用)
- 知識集約型タスク(小型モデルはトレーニングデータが限られている)
- ローエンドデバイスのサポートが必要な場合
- 急速に変化するドメイン知識
まとめ#
オンデバイスAIは即座のレスポンス、オフライン機能、ゼロの限界コストを提供します。プライバシーはおまけです。
技術スタック:
- llama.rn:llama.cppのReact Nativeバインディング
- Qwen 1.7B Q4_K_M:コンパクトな指示追従モデル
- カスタムツールシステム:XMLベースの関数呼び出し
- ストリームファーストアーキテクチャ:トークンごとのUI更新
実装全体:約12時間の開発、$0/月のインフラコスト、オフラインで動作、完全なプライバシー。
機密データを扱うビジネスアプリを構築しているなら、これは今や現実的な選択肢です。ツールは動き、パフォーマンスは十分で、コストに勝るものはありません。
質問がありますか?フィードバック?私は@jaredlynskeyです。llama.rnライブラリはgithub.com/mybigday/llama.rnにあります。

