私が開発しているCurtain Estimator(カーテン見積もりアプリ)のAIアシスタントは、すべて端末の中で動いています。データは一切外に出ません。ユーザーは普通の言葉でジョブを作成したり、顧客を検索したり、案件を管理したりできて、機内モードでもそのまま使えます。
この記事では、llama.rn(llama.cppのReact Nativeバインディング)とQwen 1.7Bという小型モデルでどう実装したかを紹介します。この小ささのわりに、想像以上によく働いてくれるモデルです。
📝 更新: 現在この実装では、当初のQwen3-1.7Bに代えてQwen3.5-2B-GGUFを使っています。また「/no_think」メッセージという小技をやめて、
chat_template_kwargsパラメータで思考モードを正式に無効化するようにしました:chat_response = client.chat.completions.create( model="Qwen/Qwen3.5-27B", messages=messages, max_tokens=32768, temperature=0.7, top_p=0.8, presence_penalty=1.5, extra_body={ "top_k": 20, "chat_template_kwargs": {"enable_thinking": False}, }, )プロンプトに細工をするより、ずっとすっきり制御できます。
クラウドAIを使いたくなかった理由#
モバイルアプリのAI機能は、たいてい同じ作りです。ユーザーがメッセージを入力し、アプリがOpenAIやAnthropicやGoogleへ送信し、応答が返ってきて、請求額が積み上がっていく。
カーテン施工業者が業務に使うアプリでこれをやると、困ったことになります。顧客の氏名・住所・電話番号が第三者に渡り、案件の詳細や見積もり、メモが外部のサーバーに置かれる。GDPRやデータレジデンシーといった、できれば関わりたくない論点も増えます。しかもAPI料金はユーザー数に比例して膨らんでいきます。
それなら、モデルをユーザーの端末で動かせばいい。というわけで、そうしました。
いま実現できるようになった理由#
1年前なら手を出さなかったと思いますが、ほぼ同時期に3つの変化がありました。まず、量子化モデルが小さくなったこと。Q4_K_M量子化のQwen 1.7Bは1.1 GBで、大半のゲームより小さく、一度ダウンロードすればアプリのストレージに収まります。次に、モバイルGPUが速くなったこと。llama.cppはiOSではMetal、AndroidではVulkanを使い、iPhone 14 Proなら毎秒15トークンほど出ます。リアルタイムのストリーミングには十分です。そして、小型モデル自体が実用的になったこと。Qwen 1.7Bは構造化された指示に従い、JSONを解析し、複数ステップの推論もこなします。ビジネスロジックに必要なのはまさにこのプロファイルで、詩を書かせるわけではありませんから。
モデル選び#
Qwen3-1.7Bに落ち着くまでに、4つの小型モデルを試しました。
TinyLlama 1.1B(637 MB)は最速でしたが、顧客IDを捏造したり必須フィールドを落としたりと、構造化出力がどうにも安定しませんでした。Phi-3-mini(1.8 GB)は推論力こそ十分なものの、とにかく話が長い。20語で済む答えが200語の作文になって返ってきます。Gemma-2B(1.2 GB)は分類には速くて正確でしたが、関数呼び出しが弱く、ツールシステムに必要な<action>タグを安定して出せませんでした。Qwen3-1.7B(1.1 GB)がちょうどいい塩梅でした。構造化出力が信頼でき、指示に正確に従い、<think>タグでのチェーン・オブ・ソートにも対応しています。
Q4_K_M量子化は4ビット重みとk-meansクラスタリングの組み合わせで、フル精度より約75%小さく、品質低下は5%程度です。
llama.rnのセットアップ#
llama.rnはllama.cppをReact Native向けにラップしたライブラリで、導入自体は簡単な部類です:
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秒間スピナーを眺めたい人はいないので、応答はトークン単位でストリーミングします:
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);
}
);最近の端末なら、最初のトークンまで約200ms(これはプロンプト処理の時間です)、以降は1トークンあたり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
`;XMLタグにした理由は地味です。<action>と</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;
}
}actionが出てきたら実行し、結果を会話に差し戻します:
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><think>タグはUIに出す前に取り除きますが、開発中はデバッグ用にそのまま表示しています。効果は劇的で、アクションを確定する前にモデルが自分でロジックを整理してくれるようになります。
長い会話を8Kに収める#
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とrawを両方持たせておくと、チャット履歴はUI上できれいに再生できて、セッションを再開するときはアクションを含む完全なコンテキストが戻ってきます。ユーザーは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(メモリ不足で時折クラッシュ)
私の目安は、快適に使うならRAM 6GB以上。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より電力を食いますが、そのぶんずっと早く終わります。ユーザーは明らかに即応を好むので、GPUのままにしています。
運用2ヶ月、ベータユーザー約200人#
クエリの内訳は少し意外でした:
- 「Create a job for [customer name]」 - クエリの68%
- 「Find jobs for [customer]」 - 15%
- 「Update job [ID] to [status]」 - 9%
- アプリに関する一般的な質問 - 8%
クエリの91%は一発で成功します。失敗の内訳は、ユーザーが名前のスペルを間違えて顧客が見つからないケースが4%、会話が長すぎてコンテキストが溢れるケースが3%、モデルが顧客IDを勝手に作ってしまうケースが2%です。
最後のID捏造は、以前はもっとひどい状態でした。ツール結果を散文ではなくcustomer_id=42のような明示的なフィールドで返すようにしたら、ほぼ消えました。小型モデルには、ここまで噛み砕いた足場が必要なのです。
コスト#
オンデバイス版はインフラ費$0/月。ユーザーあたりのコストも、初回の1.1 GBダウンロードを除けば$0で、ユーザーが何人増えてもそのままです。
クラウド版をざっくり見積もると、平均的な会話が約2,000トークン、200ユーザー×月30会話で6,000会話、つまり月1,200万トークン。GPT-3.5なら月$18、GPT-4なら月$360、Claudeなら月$180といったところです。この規模なら破産する額ではありませんが、オンデバイス方式は初日から元が取れていて、ユーザーが10,000人になっても$0のままです。
知っておくべき制限#
モデルの能力。 Qwen 1.7Bは構造化タスクが得意な一方、マルチホップの複雑な推論、事実知識(検索エンジンではありません)、創作、ニュアンスの読み取りは苦手です。小型モデルの得意分野に沿って機能を設計すれば問題になりません。
デバイス要件。 最低ラインはRAM 3 GBと空きストレージ2 GB。現実的にはRAM 6 GB、64ビットプロセッサ、GPUサポートが欲しいところです。iPhone 8やGalaxy S9のような古い端末は厳しいので、機能検出をして緩やかにフォールバックさせてください。
モデルの更新。 モデルを差し替えると、ユーザーは1.1 GBを再ダウンロードすることになります。私はストレージパスにバージョンを含めています:
const MODEL_PATH = `${FileSystem.documentDirectory}llama-models/v2/Qwen3-1.7B-Q4_K_M.gguf`;新しいモデルを出すときはパスを上げるだけです。古いモデルはアンインストール時に自動で消えます。
iOSとAndroidの違い#
MetalのiOSはAndroidより2割ほど速く、メモリ管理も上手で、モデルはデフォルトでiCloudにバックアップされます(ユーザーのバックアップ容量を食いたくなければNSURLIsExcludedFromBackupKeyで除外できます)。Vulkan/OpenCLのAndroidは端末ごとの差がずっと大きく、古いGPUにはVulkan非対応でCPUにフォールバックするものもあり、ストレージの自動バックアップもありません。両方でテストしてください。良いAndroid端末と悪いAndroid端末の差は、プラットフォーム間の差より大きいくらいです。
デバッグのヒント#
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を組み合わせた完全オフラインの音声アシスタントや、生データを端末から出さずにユーザー横断でモデルを改善する連合学習も見えています。
作るべきか?#
機密データを扱うビジネスアプリ、オフラインファーストのワークフロー、分類やデータ入力のような構造化タスクが中心のアプリなら、そして規模が大きくなったときのAPI費用が気になるなら、答えはイエスです。今日から作れます。
自由なチャットが必要ならClaudeやGPT-4を使ってください。知識勝負のタスク(小型モデルは本当に物を知りません)、ローエンド端末のサポートが必須の場合、モデル更新が追いつかない速さでドメイン知識が変わる場合も同様です。
実装にかかったのは12時間ほど。月々の運用費は$0で、飛行機の中でも動き、顧客データは顧客の手元から出ません。文句のつけようがない、というのが正直なところです。
質問やフィードバックは@jaredlynskeyまで。llama.rnライブラリはgithub.com/mybigday/llama.rnにあります。

