ما الذي تطلّبه بناء تطبيق مالي على نموذج Apple اللغوي الذي يعمل على الجهاز
النموذج الذي يعمل على الجهاز حقيقي، ومجاني، وسيكذب عليك بثقة تامة. إليك البنية التي صمدت حين تعاملتُ معه فعلًا.
تحديث، 29 سبتمبر 2026: يصف هذا المقال إصدار iOS 26. منذ Smart Budget 2.0 على iOS 27، صار الذكاء الاصطناعي في التطبيق يفضّل Private Cloud Compute من Apple، ويعود إلى النموذج الذي يعمل على الجهاز عند الحاجة — المزيد عن هذا التغيير. ولا تزال معاملاتك لا تصل إلينا ولا إلى أي طرف ثالث.
أطلقت Apple مع iOS 26 نموذجًا لغويًا يعمل على الجهاز. بلا مفتاح API، ولا اتصال بالشبكة، ولا فاتورة لكل رمز (token)، ولا حدّ استخدام يمكنك رؤيته. لتطبيق مثل تطبيقي — تطبيق ميزانية فكرته كلها أن بياناتك لا تغادر الهاتف أبدًا — ليس هذا ميزة إضافية لطيفة. إنه الطريقة الوحيدة التي يمكن أن توجد بها الميزات «الذكية» أصلًا.
بنيتُ The Smart Budget عليه. هذا هو التقرير الصادق: ما يجيده النموذج فعلًا، والشيء الوحيد الذي فعله وكاد يقتل الميزة، والبنية التي استقررتُ عليها بعد أن تعلّمتُ بالطريقة الصعبة. كل الشيفرة هنا من التطبيق المنشور.
الوعد مقابل واقع الـ 3B
الطريقة التي قدّمه بها WWDC مُسكِرة: ابنِ ميزات نماذج لغوية تعمل محليًا، وأطلقها، وانتهى الأمر. وكثير من ذلك صحيح. لكن النموذج الذي يعمل على الجهاز في iOS 26 نموذج من ~3 مليارات مُعامِل. ليس 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 تحوّل «كم أنفقتُ على المطاعم الشهر الماضي؟» إلى FinanceQuestion محدد النوع — نية من نوع enum، وفترة، وتاجر أو فئة اختياريان. المرحلة 2 هي Core Data عادي. المرحلة 3 تنسّق النتيجة. لاحظ ما تشترك فيه المرحلتان 2 و3: لا نموذج. ورأس طبقة الاستعلام يقولها صراحة:
/// `context.perform`. NO LLM involvement; no `Tool` protocol; no orchestration.
مخطط الاستخراج — والحيلة التي تجعله موثوقًا
تستخدم المرحلة 1 DynamicGenerationSchema، الذي يتيح لك بناء مخطط المخرجات وقت التشغيل من بيانات المستخدم نفسه. وجزء «وقت التشغيل» هذا أهم مما يبدو. يُجمَّع المخطط لكل سؤال على حدة، و— هذه هي الحيلة — لا توجد خاصية في المخطط إلا عندما تظهر في الاستعلام إشارة إليها:
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 غير موجودة في المخطط أصلًا — وبالتالي لا يستطيع النموذج بنيويًا أن يهلوس تاجرًا. أنت لا تطلب منه بلطف أن يتجنب اختراع تاجر؛ لقد جعلت اختراعه مستحيلًا. فك الترميز المقيَّد يحوّل «أرجوك التزم» إلى «لا تستطيع أن تخالف». ثم تشغّله:
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 الأصلية نموذجًا لغويًا. طلبتُ من النموذج الذي يعمل على الجهاز أن «يعيد صياغة الإجابة الحتمية في جملة أو جملتين دافئتين»، مع تعليمات صريحة بعدم تغيير أي رقم. أطاع التعليمات المتعلقة بالأرقام. ثم اخترع كل شيء حولها. من تعليق الشيفرة الفعلي الذي يقبع الآن حيث كانت تلك الميزة:
/// 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.
استدعاء الأدوات هو الخيار المُغري، لأن هذه هي الطريقة التي ستتبعها مع نموذج متقدّم من الطراز الأول. أما على الجهاز، فلم يستطع النموذج تنسيق الأدوات عبر الأدوار بشكل موثوق. الاستخراج إلى struct أقل بريقًا، وأمتن بكثير.
ولا تثق بسجل الجلسة كذاكرة أيضًا
تُغريك المحادثة متعددة الأدوار بالاعتماد على سجل محادثة النموذج كذاكرة. لا تفعل. نافذة السياق صغيرة وتمتلئ بسرعة. أحتفظ بحالة المحادثة في 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 رمزًا. هذه هي الميزانية كلها. وهذا أيضًا سبب تقليص قائمة التجّار المُمرَّرة إلى أداة الاستخراج مسبقًا إلى ~30 عنصرًا — فالقائمة الحقيقية قد تتجاوز 1000، وإلقاؤها كلها سيفجّر النافذة قبل أن يرى النموذج السؤال.
الضوابط التي تحتاجها فعلًا
كل استدعاء للنموذج يمر عبر عميل واحد يغلّف النتيجة في ناتج محدد النوع، ليقرّر كل مستدعٍ: إعادة المحاولة، أو العودة إلى المسار الحتمي، أو إظهار الخطأ. هذه هي الثلاثة التي أثبتت جدواها:
التراجع عند تجاوز حدّ المعدّل. لا تنشر Apple الحدود، فتكتشفها وقت التشغيل. تراجع أُسّي مع تفاوت عشوائي (jitter)، ثم الاستسلام ليتمكن المستدعي من العودة إلى البديل بدل أن يعلق:
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 رمزًا، فإعادة تجربة التعليمة نفسها لا تفيد بشيء — على المستدعي أن يقسّم العمل. لذلك يظهر كناتج مستقل ومميز، لا يُخلط مع الأعطال العابرة.
نفّذ كل شيء بالتسلسل. كل استدعاءات النموذج تمر عبر actor واحد، كي لا تُطلق ميزتان جلسات متوازية فتستنزفا ميزانية المعدّل غير المرئية بضعف السرعة. وهناك مفتاح رئيسي واحد — مفتاح في «الإعدادات» مقرون بـ AND مع توفّر النموذج على الجهاز — يجعل كل استدعاء، عند إيقافه، يعيد .unavailable فيسلك التطبيق كله مساره الحتمي: تحليل الرسائل النصية بالتعابير النمطية (regex)، ومصنِّف حتمي، وبديل للمحادثة بالكلمات المفتاحية. الطبقة الذكية تحسين، لا شيء يعتمد عليه التطبيق. والتطبيق قابل للاستخدام بالكامل مع إيقاف النموذج تمامًا.
الميزة التي بنيتها وأنهيتها ثم أخفيتها
هناك محادثة كاملة باسم «اسأل عن أي شيء» في الشيفرة — يمكنك أن تطرح أسئلتك عن أموالك بلغة طبيعية فتجيبك باستخدام المسار الموضح أعلاه. إنها منفّذة. وتُجمَّع (compile) دون أخطاء. وكل نقطة دخول إليها معطّلة بتعليق، وموسومة بـ 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. إطلاق المسار مع إخفاء المحادثة كان الفارق بين «عرض توضيحي ذكي» و«تطبيق أسمح لعائلتي باستخدامه».
ما كنت سأقوله لنفسي في اليوم الأول
- صمّم للنمط المقيَّد فورًا. النموذج للفهم، وSwift للحقيقة. لا تبدأ بروبوت محادثة ثم تحاول إلحاق الموثوقية به لاحقًا؛ ستعيد كتابته.
- لا تدع النموذج يقرّر حقيقة أبدًا. الاستخراج يدخل، والمنطق الحتمي والقوالب تخرج. إذا كان النموذج قادرًا على إخراج اسم تاجر، فسيُخرج يومًا ما اسمًا مزيفًا.
- اجعل الهلوسة مستحيلة بنيويًا، لا مجرد أمر غير مستحب. الخاصية غير الموجودة في المخطط لا يمكن اختراعها. قواعد التعليمات اقتراحات؛ أما المخططات فجدران.
- عامِل الطبقة الذكية كتحسين. أطلق التطبيق الحتمي أولًا، ثم دع النموذج يجعله ألطف حيث يمكن الوثوق به.
النموذج الذي يعمل على الجهاز أداة رائعة فعلًا حين تتوقف عن مطالبته بأن يكون ما ليس هو. ليس عقلًا تسلّمه المقود. إنه محلّل نوايا جيد جدًا وسريع جدًا، يصادف أنه يعمل مجانًا على الهاتف — وهذا، كما تبيّن، معظم ما يحتاجه تطبيق مالي خاص فعلًا.
The Smart Budget متاح الآن على iPhone (iOS 26، وiPhone 16 وأحدث). على iOS 26 يعمل كل ما سبق على الجهاز؛ وعلى iOS 27 يفضّل التطبيق Private Cloud Compute من Apple، ولا تزال معاملاتك لا تصل إلينا ولا إلى أي طرف ثالث. إن أردت جانب CloudKit من القصة — حسابا iCloud يتشاركان ميزانية واحدة دون خادم — فهو في المقال التالي.
— Omar