端侧 LLM 的工程边界:隐私、结构化输出与离线优先
云端大模型用久了,很容易忘记一个问题:用户的数据有没有必要离开设备?
大部分 AI 功能的实现路径都是「打包请求 → 发到云端 → 等结果回来」,因为这条路最短、模型能力最强。但这个方案有一个隐含假设:用户愿意把数据给到服务器。对于日历、笔记、健康、私信这类内容,这个假设并不成立。
iOS 26 的 FoundationModels 框架提供了另一条路:在设备上直接跑语言模型,不联网,不出境。我花了一段时间把它的用法摸透,顺便整理一下这套框架背后的工程取舍。
先检查可用性,再建会话
端侧模型不像 API 调用那样随叫随到。设备型号、系统版本、Apple Intelligence 开关状态都会影响模型是否可用。所以第一条规则是:任何操作之前先检查 model.availability,把每种不可用情形都明确处理。
switch SystemLanguageModel.default.availability {
case .available:
// 正常流程
case .unavailable(.deviceNotEligible):
// 设备不支持,展示降级 UI
case .unavailable(.appleIntelligenceNotEnabled):
// 引导用户去设置里开启
case .unavailable(.modelNotReady):
// 模型还在下载,展示等待态
}这和网络请求的错误处理逻辑是一样的:不要假设资源永远可用。区别在于端侧模型的不可用往往不是暂时的网络抖动,而是设备层面的硬约束,需要给用户一个明确的说法,而不是 loading 转圈转到超时。
@Generable:把「生成文本」升级为「生成类型」
端侧 LLM 的一个常见用法是从自然语言里抽取结构化数据——比如用户说「帮我记一下明天下午三点开会」,你需要拿到时间、事件类型、描述字段,而不是一大段文字。
传统做法是让模型返回 JSON,然后手动解析。这个方案很脆:模型输出格式一旦飘,解析就崩。FoundationModels 给了一个编译期安全的替代方案:用 @Generable 宏声明你期望的输出结构,框架会约束模型按这个结构生成。
@Generable(description: "从用户输入中提取的日程信息")
struct CalendarEvent {
var title: String
@Guide(description: "事件类型:meeting / task / reminder")
var category: String
@Guide(description: "优先级 1-5", .range(1...5))
var priority: Int
}
let response = try await session.respond(
to: userInput,
generating: CalendarEvent.self
)
// response.content.title / .category / .priority 直接可用@Guide 还能加语义约束(枚举范围、数值区间、数组长度),进一步收窄生成空间。我把这理解成「给模型加类型系统」——越具体的约束,输出越稳定,下游逻辑越简单。
快照流式输出,而不是增量 diff
流式输出在云端模型里几乎已经是标配——用户盯着光标闪比盯着 loading 转圈体验好得多。FoundationModels 的流式 API 有一点值得单独说:它返回的每一帧都是完整的当前状态(快照),而不是增量 token。
这个设计决策对结构化输出特别友好。如果是 delta 模式,你需要自己拼接流、解析中间状态;快照模式下,每一帧都是一个合法的 PartiallyGenerated 对象,字段可能还是 nil(还没生成到那里),但结构是完整的,可以直接绑定到 SwiftUI 的状态变量上。
for try await partial in session.streamResponse(to: prompt, generating: TripIdeas.self) {
partialResult = partial // 直接赋值,SwiftUI 自动更新 UI
}代价是带宽(每帧都是完整状态),但端侧全在内存里,没有网络开销,这个代价几乎可以忽略。
工具调用:让模型触发本地代码
FoundationModels 支持工具调用——模型决策「我需要做一个搜索」,然后触发你注册的 Swift 函数,拿到结果后继续推理。这套机制让端侧模型不再只是「回答问题」,而是可以「执行操作」。
实现方式是定义 Tool 协议的实现,参数同样用 @Generable 约束:
struct NoteSearchTool: Tool {
let name = "note_search"
let description = "在本地笔记库里搜索关键词"
@Generable
struct Arguments {
var keyword: String
var maxResults: Int
}
func call(arguments: Arguments) async throws -> ToolOutput {
let results = await LocalNoteStore.search(keyword: arguments.keyword, limit: arguments.maxResults)
return .string(results.map { $0.summary }.joined(separator: "\n"))
}
}整个调用链都在设备上完成,用户的笔记内容从未经过网络。这是端侧 AI 相较于云端方案最硬的差异化优势。
4096 token 上限是设计约束,不是 bug
端侧模型的上下文窗口只有 4096 token,instructions + prompt + output 加在一起不能超。初次遇到这个限制时可能会觉得憋屈,但换个视角看:这个约束在迫使你把大任务拆成小任务,每个会话只做一件事。
这反而是一个好的工程习惯:一个 session,一个清晰的输入输出边界。「把所有上下文都塞进一个 prompt」是大模型 API 时代的惯性思维,端侧的约束把这个坏习惯挡在了门外。
决定用端侧模型之前,先问:「这个功能处理的是什么数据?用户是否有合理的期望它不会离开设备?」如果答案是「是」,端侧就不只是一个技术选项,而是对用户的一个承诺。
一句话心法
云端模型解决能力问题,端侧模型解决信任问题;选哪个,取决于你的用户在乎的是什么。