ZaatarLABS
联系我们 →
← 全部文章
The Smart Budget · 工程

在Apple的设备端大模型上做一款理财应用,要付出什么

设备端模型是真的,是免费的,而且它会一本正经地对您撒谎。下面是与它正面交锋后留下来的架构。

阅读约11分钟

更新,2026年9月29日:本文描述的是iOS 26版本。自iOS 27上的Smart Budget 2.0起,应用的AI优先使用Apple的Private Cloud Compute,并以设备端模型作为后备——关于这一变化的更多内容。您的交易记录仍然永远不会传到我们或任何第三方手中。

Apple在iOS 26中推出了一个设备端语言模型。不需要API密钥,没有网络调用,不按token计费,也没有看得见的速率限制。对我这样的应用——一款全部前提就是您的数据永不离开手机的记账应用——来说,这不是锦上添花,而是那些“智能”功能能够存在的唯一途径。

我在它之上做了The Smart Budget。这是一份坦白的报告:这个模型真正擅长什么,它做过的那件差点毁掉整个功能的事,以及我吃过苦头之后最终采用的架构。文中所有代码都来自已发布的应用。

承诺与3B的现实

WWDC上的说法令人陶醉:构建在本地运行的LLM功能,发布,完事。其中很多确实如此。但iOS 26中的设备端模型是一个约30亿参数的模型。它不是装在口袋里的GPT-4。把它当成GPT-4来用,就会发布出让自己难堪的东西。

用了几周之后,我得出的分界是这样的:

3B模型真正擅长的:一次性的结构化提取。给它一个简短、范围严格限定的任务——“读这句话,从这五个意图中选一个”——再配上约束输出的schema,它表现出色。速度快、可靠,而且会老老实实待在您划定的界线之内。

它不擅长的:任何开放式的东西。多轮对话。既要自由发挥又要事实精确的文字。一旦您让它写关于数字的句子,它就会面不改色地编造出与数字相关的细节。

整个架构,都源于尊重这条分界。

行得通的模式:理解者 → 确定性的Swift → 叙述者

我最终定下的规则是:让模型理解语言,但绝不让它做数学或陈述事实。具体来说,应用里的每一次智能查询都分三个阶段运行,只有第一阶段会用到模型。

// Stage 1: LLM extraction (constrained). Falls back to a tiny keyword
// pass automatically if FM is unavailable / errors.
let question = await QuerySpecExtractor.extract(
    from: trimmed,
    previous: conversationContext?.lastQuestion,
    knownCategoryNames: knownCategoryNames,
    knownMerchantsByFrequency: knownMerchantNames
)
// Stage 2: deterministic query.
let answer = await FinanceQueries.answer(question, controller: controller)
// Stage 3: deterministic prose render.
let reply = FinanceNarrator.compose(answer: answer, userQuestion: trimmed)

第1阶段把“what did I spend on dining last month?”(上个月我在餐饮上花了多少?)变成一个有类型的FinanceQuestion——一个枚举意图、一个时间段、一个可选的商家或分类。第2阶段是纯粹的Core Data。第3阶段负责格式化结果。注意第2和第3阶段的共同点:没有模型。查询层的文件头注释直接写明了这一点:

/// `context.perform`. NO LLM involvement; no `Tool` protocol; no orchestration.

提取schema,以及让它变可靠的技巧

第1阶段使用DynamicGenerationSchema,它可以在运行时根据用户自己的数据构建输出schema。运行时这一点比看上去更重要。schema是针对每个问题组装的,而且——这就是技巧所在——只有当查询中出现了对应的信号时,某个属性才会出现在schema里:

var properties: [DynamicGenerationSchema.Property] = [
    .init(name: "intent", schema: intentSchema),
    .init(name: "period", schema: periodSchema),
    .init(name: "otherPeriod", schema: otherPeriodSchema, isOptional: true),
    .init(name: "limit", schema: limitSchema, isOptional: true),
]
if !merchantNames.isEmpty {
    let merchantSchema = DynamicGenerationSchema(
        name: "merchant",
        description: "Merchant name the user named. Copy EXACTLY from this list.",
        anyOf: merchantNames
    )
    properties.append(.init(name: "merchant", schema: merchantSchema, isOptional: true))
}

如果用户没有提到商家,merchant属性根本就不在schema里——所以模型在结构上不可能幻觉出一个商家。您不是在客气地请它别编造商家,而是让编造变得不可能。约束解码把“请守规矩”变成了“您没法不守规矩”。然后运行它:

let session = LanguageModelSession(model: SystemLanguageModel.default)
let response = try await session.respond(
    to: prompt,
    schema: schema,
    includeSchemaInPrompt: true,
    options: GenerationOptions(temperature: 0.1)  // we want deterministic-ish picks
)

温度设为0.1,因为这是分类,不是写诗。而且即使有anyOf约束选项,事后还有一道双保险检查:模型返回的商家或分类,除非用户的原文里确实出现了匹配的词,否则一律丢弃。先信任,再核实,然后再核实一次——毕竟这是理财应用。

行不通的模式:让它来叙述

这一部分,我希望每位开发者在发布之前都读一读。

最初的第3阶段是一个LLM。我让设备端模型“把确定性的答案改写成一两句温暖的话”,并明确要求不得改动任何数字。它遵守了关于数字的要求,然后把数字周围的一切都编了出来。下面是如今占据那个功能原位置的代码注释原文:

/// The previous version asked the on-device Foundation Model to "rewrite the
/// deterministic answer in 1-2 warm sentences," with explicit rules against
/// changing numbers. The model honoured the numbers but invented EVERYTHING
/// ELSE — McDonald's transactions the user never made, merchant names like
/// "Bubble-bro.com" pulled out of thin air, fake per-item amounts. Pure
/// hallucination. On a small on-device model the prompt rules don't stick
/// reliably enough for a finance app where any invented detail destroys trust.

“Bubble-bro.com”。一个从未存在过的商家,出现在对某人真实消费的总结里,被当作事实呈现。而且是在一款记账应用里。没有任何巧妙的提示词能让这种事变得可以接受。数字对了也没用,如果围绕它的句子是虚构的。

所以第3阶段现在是一个字符串模板。不是因为模板优雅,而是因为模板不会撒谎:

static func compose(answer: FinanceAnswer, userQuestion: String) -> String {
    if answer.detail.isEmpty {
        return answer.summary
    }
    return answer.summary + "\n\n" + answer.detail.joined(separator: "\n")
}

(应用确实仍在少数地方让模型来叙述——洞察故事卡片——但只在下面那些严格的结果护栏之下,而且绝不在它可能编造交易的地方。)

在这之前,我还试过另外两种“显而易见”的架构,在3B模型上都失败了。原因保留在类型的文件头注释里:

/// **Why structured + LLM-extracted:** prior architectures using either
/// (a) `LanguageModelSession`-with-tools or (b) hand-written regex parser
/// both failed. (a) the on-device 3B model can't reliably orchestrate tool
/// calls across multi-turn chat; (b) regex didn't generalise to the long
/// tail of natural phrasings ("on what?", "what are these?", "and dining
/// only?"). The current architecture uses constrained DynamicGenerationSchema
/// for natural-language UNDERSTANDING (front of pipeline) + deterministic
/// Swift for everything else.

工具调用是最有诱惑力的那种,因为面对前沿模型时您就会这么做。但在设备端,模型无法在多轮之间可靠地编排工具调用。提取成结构体没那么光鲜,却稳健得多。

也别把会话记录当作记忆

多轮对话会诱使您依赖模型的会话记录来充当记忆。别这么做。上下文窗口很小,很快就会被填满。我用普通的Swift保存对话状态,并把上一个问题重新内联到每个新的提示词里:

/// **Why not use the model's session transcript for multi-turn memory:**
/// the on-device 4096-token context fills up fast and Apple's developer
/// guidance is that the transcript is an optimisation, not a memory system.
/// We maintain `ConversationContext` ourselves in Swift and inline the
/// previous spec into each turn's prompt — same effect, deterministic memory.

4096个token。这就是全部预算。这也是为什么交给提取器的商家列表会预先裁剪到大约30条——真实列表可能有1000多条,全部塞进去的话,模型还没看到问题,窗口就已经撑爆了。

您真正需要的护栏

对模型的每一次调用都经过同一个客户端,它把结果包装成有类型的结果,让每个调用方自行决定:重试、退回确定性路径,或者把错误呈现出来。真正派上用场的有三个:

速率限制退避。Apple没有公布限制,所以您只能在运行时摸清。带抖动的指数退避,然后放弃,让调用方退回备用路径,而不是一直挂着:

case .rateLimited:
    guard attemptsLeft > 1 else {
        logger.warning("FM rate-limited ... giving up so caller can fall back")
        return .rateLimited
    }
    let baseSeconds = pow(2.0, Double(attemptIndex + 1))   // 2s, 4s, 8s
    let jitter = Double.random(in: 0.75...1.25)            // ±25%
    try? await Task.sleep(nanoseconds: UInt64(baseSeconds * jitter * 1_000_000_000))
    return await generateOnce(..., attemptsLeft: attemptsLeft - 1, attemptIndex: attemptIndex + 1)

上下文溢出不可重试。如果撑爆了4096个token的窗口,用同样的提示词重试毫无作用——调用方必须拆分工作。所以它作为一种独立的结果呈现出来,而不是和临时故障混为一谈。

所有调用串行化。所有模型调用都汇集到同一个actor,这样两个功能就不能同时开启并行会话,把看不见的速率预算以两倍速度烧掉。另外还有一个总开关——设置中的一个开关与设备可用性做AND运算——关闭时,每次调用都返回.unavailable,整个应用走确定性路径:正则表达式解析短信、确定性的分类器、关键词聊天后备。智能层是增强,从来不是依赖。即使完全关闭模型,应用也完全可用。

我做完了、却又藏起来的功能

代码库里有一个完整的对话式“Ask Anything”(有问必答)聊天——您可以用自然语言询问关于钱的问题,它会用上面的流水线来回答。它已经实现,能编译通过。而它的每一个入口都被注释掉了,并标记为ASK-ANYTHING-DEFERRED-IOS27:

// ASK-ANYTHING-DEFERRED-IOS27: chat sheet presentation
// disabled. Implementation kept intact ...
// The 3B model in iOS 26 was unable to reliably extract
// intent + filters even with constrained DynamicGenerationSchema
// — revisit when iOS 27 / a larger on-device model lands.

这是整个项目中最艰难的决定。这个功能能用——大多数时候。但当这个功能是“关于您的钱,问我什么都行”时,“大多数时候”就是不及格。即使有上面所有这些约束,3B模型的意图提取也没有可靠到让我愿意拿整个应用的信任去赌。所以它就放在那里,已经完成,等待iOS 27带来的模型。发布流水线但藏起聊天,就是“聪明的演示”和“我愿意让自己家人用的应用”之间的区别。

我想对第一天的自己说的话

  1. 一开始就为受约束的模式做设计。模型负责理解,Swift负责事实。不要先做一个聊天机器人,再试图事后补上可靠性;您最终会重写它。
  2. 绝不让模型陈述事实。提取进来,确定性逻辑和模板出去。如果模型能输出商家名称,它迟早会输出一个假的。
  3. 让幻觉在结构上不可能发生,而不仅仅是不被鼓励。不在schema里的属性无法被编造。提示词规则只是建议;schema是墙。
  4. 把智能层当作增强。先发布确定性的应用,再让模型在值得信任的地方把它变得更好。

一旦您不再要求它成为它本来不是的东西,设备端模型就是一个真正出色的工具。它不是一个可以把方向盘交给它的大脑。它是一个非常好、非常快的意图解析器,恰好在手机上免费运行——而事实证明,这正是一款私密理财应用真正需要的大部分东西。

The Smart Budget现已在iPhone上推出(iOS 26,iPhone 16及以上机型)。在iOS 26上,上文所述的一切都在设备端运行;在iOS 27上,应用优先使用Apple的Private Cloud Compute,您的交易记录仍然永远不会传到我们或任何第三方手中。如果您想了解CloudKit那一侧的故事——两个iCloud账户在没有服务器的情况下共享一个预算——请看下一篇文章。

——Omar

作者:Omar Al Homaidi · The Smart Budget · @zaatarlabs