メインコンテンツへスキップ
  1. 投稿/

React Nativeでのオンデバイス AI:llama.cppでQwen 1.7Bを実行する

· loading · loading ·
ジャレッド リンスキー
著者
ジャレッド リンスキー
韓国に住むキウイ

最近、Curtain Estimatorアプリに完全にオンデバイスで動作するAIアシスタントをリリースしました。APIコールなし、クラウド依存なし、完全なプライバシー。ユーザーは自然言語でジョブの作成、顧客の検索、プロジェクトの管理ができます—完全にオフラインで。

この記事では、llama.rn(llama.cppのReact Nativeバインディング)とQwen 1.7B(驚くほど優秀な小型言語モデル)を使ってどのように構築したかを説明します。

クラウドAIのプライバシー問題
#

ほとんどのモバイルアプリのAI機能はこのように動作します:

  1. ユーザーがメッセージを入力
  2. アプリがOpenAI/Anthropic/Googleに送信
  3. レスポンスが返ってくる
  4. 請求が蓄積される

カーテン設置ビジネスアプリの場合、これは次を意味します:

  • 顧客名、住所、電話番号 → サードパーティに送信
  • プロジェクト詳細、見積もり、メモ → 外部サーバーに保存
  • コンプライアンスの問題(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」

  1. モデル<action>{"type":"search_customers","query":"Smith"}</action>
  2. システム[TOOL_RESULT] Found: Jane Smith (id:42), Bob Smith (id:89)
  3. モデル:「Smithさんが2人見つかりました—JaneとBob。どちらですか?」
  4. ユーザー:「Jane」
  5. モデル<action>{"type":"create_job","customer_id":42}</action>
  6. システム[TOOL_RESULT] Job J-0073 created
  7. モデル:「完了しました!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ヶ月間の本番運用後:

最も一般的なユースケース:

  1. 「Create a job for [customer name]」 - クエリの68%
  2. 「Find jobs for [customer]」 - 15%
  3. 「Update job [ID] to [status]」 - 9%
  4. アプリに関する一般的な質問 - 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にあります。