跳过正文
  1. 文章/

React Native 端侧 AI:用 llama.cpp 跑 Qwen 1.7B

· loading · loading ·
仁才德
作者
仁才德
居住在韩国首尔的领导者和软件工程师

我的窗帘报价应用 Curtain Estimator 里有个 AI 助手,完全跑在手机上。数据一个字节都不出设备。用户用自然语言就能创建工作单、搜索客户、管理项目,开了飞行模式照样能用。

这篇文章讲讲我是怎么用 llama.rn(llama.cpp 的 React Native 绑定)和 Qwen 1.7B 把它做出来的。这个尺寸的模型,能干的活比我预想的多得多。

📝 更新: 现在的实现已经换成 Qwen3.5-2B-GGUF,替代了最初的 Qwen3-1.7B。另外我也不再用 /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 账单也随着用户数一路上涨。

另一条路是把模型直接放到用户手机上跑。我选了这条。

为什么现在做得成了
#

放在一年前我不会碰这个,但有三件事几乎同时起了变化。一是量化模型变小了:Q4_K_M 量化后的 Qwen 1.7B 只有 1.1 GB,比大多数游戏还小,下载一次就常驻应用存储。二是手机 GPU 够快了:llama.cpp 在 iOS 上走 Metal,Android 上走 Vulkan,iPhone 14 Pro 能跑到每秒 15 个 token 左右,实时流式输出绰绰有余。三是小模型真的能用了:Qwen 1.7B 能遵循结构化指令、解析 JSON、串起多步推理。业务逻辑要的正是这些能力,又没人指望它写诗。

选模型
#

定下 Qwen3-1.7B 之前,我试了四个小模型。

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 下两三分钟,LTE 大概五到八分钟。下载完成后,开着 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 倍左右。

流式推理
#

没人愿意盯着加载动画看十秒钟,所以响应按 token 逐个流出:

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);
  }
);

在近几年的手机上,首个 token 约 200ms(这是提示词处理的时间),之后每个 token 50-80ms。跟走网络的 API 一比,体感就是即时的。

小模型也玩得转的函数调用
#

GPT-4 那套函数调用靠 JSON schema,小模型在这里会栽跟头: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”

  1. 模型<action>{"type":"search_customers","query":"Smith"}</action>
  2. 系统[TOOL_RESULT] Found: Jane Smith (id:42), Bob Smith (id:89)
  3. 模型:“找到两位 Smith: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>

<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 个 token 左右,模型接着聊,也不会忘记之前建了哪些工作单、客户 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 }
  ]
});

contentraw 两份都存的好处是:聊天记录在界面上回放时干干净净,恢复会话时又能拿回包含 action 在内的完整上下文。用户可以像用 ChatGPT 那样,在历史下拉菜单里切换对话。

生产环境的性能数据
#

iPhone 14 Pro(A16 Bionic,6GB RAM):

  • 模型加载:约 3 秒
  • 首个 token:180-220ms
  • 流式输出:14-16 tokens/秒
  • 内存:约 1.8 GB

Samsung Galaxy S23(Snapdragon 8 Gen 2,8GB RAM):

  • 模型加载:约 4 秒
  • 首个 token:250-300ms
  • 流式输出:10-12 tokens/秒
  • 内存:约 2.1 GB

iPhone 11(A13,4GB RAM):

  • 模型加载:约 6 秒
  • 首个 token: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();
}, []);

电量消耗
#

端侧推理在电量上不是免费的。实测数据:

使用模式额外电池消耗
轻度(每天 5-10 次查询)每天 <1%
中度(每天 20-30 次查询)每天约 3-4%
重度(每天 50+ 次查询)每天约 6-8%

GPU 推理比 CPU 费电,但快得多。用户明显更喜欢秒回,所以我保留了 GPU。

上线两个月,约 200 名测试用户
#

查询的分布有点出乎意料:

  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%)。

最后这个编 ID 的毛病以前严重得多。后来我把工具结果从叙述句改成 customer_id=42 这样的显式字段,问题基本消失了。小模型就是需要把饭喂到这个程度。

成本账
#

端侧方案:基础设施 $0/月,除了首次那 1.1 GB 下载,每个用户的成本是 $0,用户再多也是这个数。

云端方案粗算一下:平均一次对话约 2,000 个 token,200 个用户每月 30 次对话就是 6,000 次,也就是每月 1200 万 token。GPT-3.5 大约 $18/月,GPT-4 是 $360/月,Claude 是 $180/月。这个规模不至于伤筋动骨,但端侧方案第一天就回了本,哪怕涨到一万用户,还是 $0。

得心里有数的几个限制
#

模型能力。 Qwen 1.7B 擅长结构化任务,但多跳的复杂推理、事实性知识(它不是搜索引擎)、创意写作、语言里的微妙之处,它都不行。围绕小模型擅长的事来设计功能,就不会失望。

设备要求。 底线是 3 GB RAM 加 2 GB 可用存储;实际上你需要 6 GB RAM、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 快 20% 左右,内存管理也更好,模型默认会备份到 iCloud(不想占用户备份空间的话,用 NSURLIsExcludedFromBackupKey 排除掉)。走 Vulkan/OpenCL 的 Android 机型间差异大得多,有些老 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. 跟踪 token 数量

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