我用的是一台 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来跑训练量更大的模型。