端侧大模型跑进 iPhone XR:我为什么没把它做成聊天助手

我用的是一台 iPhone XR 测试机,A12 芯片,3GB 运行内存。

App 里随包放了一个约 469MB 的 Qwen2.5-0.5B-Instruct Q4 GGUF 模型。它可以在真机加载,回答会一点点出现在屏幕上,中途可以取消,取消之后也能继续提问。

如下图所示:

这不是 Simulator 里跑出来的画面。当前记录里,模型第一次冷加载约 17.88 秒,第二次加载就会变成 0.28 秒左右;热启动后,第一个文本大约在 2.3 秒出现。每次自然完成的短回答约 62 个 token,token 可以先理解成模型生成文本时切出来的最小片段,它不严格等于一个汉字或一个单词。总耗时平均约 27.26 秒,生成速度约 2.27 tokens/s。

也就是说,端侧模型确实跑起来了,但我没有顺着这个结果,把它继续做成一个"本地聊天助手"。

因为跑通之后,XR 给我的答案比"手机能不能跑大模型 "具体得多:它能跑,但它更适合承担短、受限制、能检查结果的工作,而不是长篇聊天。

先把模型真正放进 App

这次验证的目标很简单:先排除下载、远程更新和多模型管理,把注意力放在 iPhone 上的推理链路。

所以第一阶段把 GGUF 直接放进 App Bundle,Swift 侧只负责找到这个资源,再把文件 URL 交给桥接层:

swift 复制代码
private func bundledModelURL() -> URL? {
    let fileName = "qwen2.5-0.5b-instruct-q4_k_m"
    return Bundle.main.url(
        forResource: fileName,
        withExtension: "gguf",
        subdirectory: "Resources/Models"
    )
    ?? Bundle.main.url(forResource: fileName, withExtension: "gguf")
}

这段代码本身不复杂,但它解决的是一个很实际的问题:模型不是在 Mac 的下载目录里"看起来存在",而是跟着 App 到了 iPhone 上,运行时可以拿到一个明确的本地文件。

模型文件找到之后,下一步才是让 iOS app 调用 llama.cpp 做推理。项目没有让 ViewController 直接碰 C++ 指针,而是先把 llama.cpp 打成 iOS 用的 llama.xcframework,再用一个 Objective-C++ 文件 LLMBridge.mm 把加载、生成、取消和释放收在一起。Swift 只调用一个很小的接口,UI 也就不需要知道模型上下文、词表和 token buffer 在哪里。

对 XR 来说,最重要的不是把参数调大,而是先承认运行内存有限。当前代码只加载一个模型,输入上下文固定为 512 个 token,单次最多生成 128 个 token:

objective-c 复制代码
llama_model_params modelParams = llama_model_default_params();
modelParams.n_gpu_layers = 99;
_model = llama_model_load_from_file(
    modelURL.fileSystemRepresentation,
    modelParams
);

llama_context_params contextParams = llama_context_default_params();
contextParams.n_ctx = 512;
contextParams.n_batch = 512;
contextParams.n_ubatch = 512;
contextParams.n_threads = 4;
_context = llama_init_from_model(_model, contextParams);

GGUF 可以先理解成适合 llama.cpp 读取的模型文件格式;

Q4 表示模型权重经过较低精度压缩,用更小的体积换取可接受的运行成本;

Metal 则是 Apple 让 App 使用 iPhone GPU 的图形与计算框架。

即便如此,469MB 的文件也不是运行时全部内存,模型、Metal 缓冲、上下文、App 页面和 iOS 系统都要占地方。

代码把可交给 GPU 的模型计算尽可能交给 Metal 执行,并不代表 XR 会突然变成高性能设备,这里的取舍只是让这台旧设备有机会稳定跑完整条链路。

真正容易出问题的,是第二次提问

第一次看到模型输出中文时,我以为部署工作已经完成了,实际跑下来,模型加载只是开始。

例如,每次独立提问之前,必须先清掉上一次生成留下的 KV Cache。它可以理解成模型为了继续计算而保留的一段临时记忆。当前项目里,每次新问题开始前都会清理:

objective-c 复制代码
_shouldCancel.store(false);

// 上一次提问留下的计算状态不能混进新的独立问题。
llama_memory_clear(llama_get_memory(_context), true);

const std::string formattedPrompt =
    "<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n"
    "<|im_start|>user\n" + std::string(prompt.UTF8String ?: "") +
    "<|im_end|>\n<|im_start|>assistant\n";

这里还有两个容易被忽略的点。

第一,Qwen2.5 是指令模型,用户输入要先按 ChatML 组织成一段对话格式,否则模型更像是在续写用户原话,而不是在回答问题。

第二,清理缓存不是为了让代码"看上去更干净"。如果上一次回答或者取消后的计算状态留在里面,下一次从位置 0 开始的新问题就可能和旧状态打架。对一个只做单轮问答的测试 App 来说,先把每次提问隔开,行为才可预测。

取消也不能只停在界面上,点击"取消"之后,生成循环必须真的有机会退出:

objective-c 复制代码
for (int32_t generatedCount = 0;
     generatedCount < kMaximumGeneratedTokens;
     generatedCount += 1) {
    if (_shouldCancel.load()) {
        break;
    }

    const llama_token token = llama_sampler_sample(
        sampler,
        _context,
        batch.n_tokens - 1
    );
    if (llama_vocab_is_eog(_vocabulary, token)) {
        break;
    }
}

这块取消标记由主线程写入,推理队列读取,它不会在生成过程中粗暴释放模型,避免一边还在计算、一边把底层对象销毁。

中文还有一个小细节,一个汉字的 UTF-8 字节有时会跨多个 token,桥接层会先把字节拼成完整字符,再交给 Swift,否则 UI 偶尔会出现半个字符或乱码。这样的细节不显眼,但它决定了"能跑一次 "能不能变成"反复使用时看起来正常"。

流式显示不是让模型变快

模型生成时,文字会逐步出现在 UITextView 里。

为了不让每个 token 都抢占主线程,Swift 端先把文本放进一个小缓冲区,约 60ms 合并刷新一次:

swift 复制代码
try bridge.generate(fromPrompt: trimmedPrompt, tokenHandler: { token in
    guard accumulator.append(token) else { return }

    DispatchQueue.main.asyncAfter(deadline: .now() + flushInterval) {
        Task { @MainActor [weak self] in
            self?.flushPendingTokens()
        }
    }
})

private let tokenFlushInterval: DispatchTimeInterval = .milliseconds(60)

这样做能让界面更新更平稳,也不会让推理线程反复等待 UI,但它不能改变模型真正的生成速度。

XR 上约 2.27 tokens/s,意味着每个 token 大约需要 0.48 秒左右,短短 60 个 token,就需要接近半分钟,把 UI 刷新得再快,也无法显示还没有算出来的字。

这也是我没有继续把它往"聊天助手"方向推的第一个原因:开放式聊天很容易让回答越写越长,用户会一直等,而且当前 0.5B 模型在解释"量化模型"时出现过概念错误,它能组织出一段通顺的中文,但不代表它能稳定理解问题,更不代表它适合直接给出需要准确性的建议。

跑通不等于没有价值

说到这里,端侧模型并没有变得不值得做,恰恰相反,这次 XR 实验把它适合的位置看得更清楚了。

端侧模型的优势不在于回答得比云端模型更聪明,它更适合处理原始内容不想上传、没有网络也希望可用、输出很短、结果可以被代码检查的请求。

比如,用户说:

明天下午三点和张经理开会,提前半小时提醒。

没有必要让一个小模型写一段长回答,更合适的做法是让它把自然语言整理成有限字段:

json 复制代码
{
  "action": "create_calendar_event",
  "title": "与张经理开会",
  "start_time": "明天 15:00",
  "reminder_minutes": 30
}

模型只负责把人说的话整理出来。

比如:日期是否合法、日历能不能写入、最终是否创建事件,都应该由 App 的业务代码和用户确认决定。

这类任务的输出短,字段能检查,也不需要模型掌握最新的世界知识。即使设备没有网络,基础功能仍然可以完成。

对于 XR 这种设备,这比让模型回答几十行开放式问题更符合它的资源条件。

相反,长文档总结、实时新闻问答、复杂分析、医疗法律金融建议,都不应该因为"模型已经在手机上跑起来了"就硬交给本地 0.5B 模型。它们需要更大的模型、更长的上下文、更新的知识,很多情况下也需要检索、规则或人工确认。

这次验证还指向了端云协同

当前把 469MB 模型直接随 App 打包,是为了验证,不是为了直接上架。

它让首次运行足够确定,却也意味着每个用户都得下载这个模型,以后换模型,还要随着 App 版本一起更新。

真正做产品时,模型更适合在用户进入 AI 功能后按需下载,下载前需要检查设备、空间和网络,下载完要校验文件,用户也应该能看见模型占了多少空间,并且可以删除或更新它。

设备也不该被当成同一种机器,iPhone XR 可以保留 0.5B 的短任务,或者在需要时交给云端。

内存和性能更好的设备,再尝试 1.5B、3B 或更大的本地模型。

无论模型多大,是否值得留在本地,还是应该回到同一个问题:这个请求有没有因为离线、隐私、成本或服务降级而获得明确收益?

对我来说,在iPhone XR 部署端侧大模型的价值就在这里,它没有把 Qwen2.5-0.5B 变成万能助手,却让我看见了一个更实际的方向:把模型留给那些不联网也能完成、结果能被 App 检查、用户愿意用短结果换取本地处理的工作。

这台旧 iPhone 已经把端侧模型跑起来了,接下来不该急着让它多说几句话,而是先找到一件它真的值得做的事。

特别说明

本文的真机性能数据来自我写的OnDeviceLLM 工程代码、iPhone XR 真机、Qwen2.5-0.5B-Instruct Q4_K_M 模型得出的样本数据。它们用于说明这套工程和这台设备的结果,不代表其他模型、量化方式、iOS 版本或 iPhone 的通用性能。我后面会尝试用硬件配置更强的iPhone来跑训练量更大的模型。

相关推荐
Alluxio1 小时前
Alluxio + Anyscale Ray框架,实现跨区域训练数据读取速度20倍提升
人工智能·分布式·机器学习·缓存·ai
智讯天下1 小时前
垂类数据基础设施:骨码智元GOMAX LAB如何重塑AI后训练数据供给体系
大数据·人工智能
橘子星1 小时前
从零理解流式输出 —— 一个 Vue + DeepSeek 的前端实战
前端·javascript·人工智能
颜进强1 小时前
Claude Code - 18 从 Figma 到 React:一套让 AI 生成代码"不瞎编"的实战工作流
前端·后端·ai编程
疯狂学习GIS1 小时前
AI Agent 操作 QGIS 实测:Codex 与 Claude Code 能自己出图吗
人工智能·ai·大模型·gis·agent·qgis·codex
塔能物联运维1 小时前
液冷数据中心市场突破900亿元,两相液冷成AI算力散热新方向
大数据·人工智能
Sirius Wu2 小时前
OpenClaw Cron安全约束完整详解
人工智能·安全·aigc
GOWIN革文品牌咨询2 小时前
受众、内容、形式、渠道:B2B营销四象限的结构模型
大数据·人工智能
hqk2 小时前
玩Android×AI Coding:从产品到代码,完整跑通一套 AI 软件研发工作流
android·ai编程·vibecoding