DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on Originally published at mvpfactory.io

Wiring iOS Core ML to a Quantized On-Device LLM for Real-Time Function Calling

---
title: "On-Device LLM Function Calling on iPhone with Core ML"
published: true
description: "Wire Core ML to a quantized on-device LLM for function calling on iPhone — covering schema compression, token budgets, and constrained decoding on A16/A17."
tags: ios, swift, mobile, architecture
canonical_url: https://mvpfactory.co/blog/on-device-llm-function-calling-ios-core-ml
---

## What We Will Build

By the end of this tutorial you will have a working on-device function-calling pipeline using a quantized LLM running entirely on the Neural Engine. Specifically, you will learn how to serialize tool schemas without bloating the context window, enforce hard token budgets before Core ML allocates KV cache, and get reliable JSON output without a full grammar engine.

Here is the gotcha that will save you hours: the failure mode is never "the model is wrong." It is almost always "you ran out of memory and did not know it until production."

## Prerequisites

- Xcode 15+, targeting iOS 17+
- A Core ML-converted INT4 quantized model (3B parameter range)
- Familiarity with `MLModel.prediction` and Swift `Codable`
- Basic understanding of transformer context windows

---

## Step 1 — Compress Your Tool Schemas Before They Touch the Prompt

Let me show you a pattern I use in every project. Your tool schema must live inside the prompt, which means every token it consumes is a token stolen from reasoning. A standard JSON Schema representation of even a moderate tool set runs 300–500 tokens. That is dead weight on a device with a 2K context ceiling.

Use a two-pass serialization strategy.

**Pass 1 — Canonical form (server-side, at build time):**

Enter fullscreen mode Exit fullscreen mode


swift
struct ToolSchema: Codable {
let name: String
let params: [String: String] // type-only, no descriptions
}


**Pass 2 — Token-efficient injection (runtime):**

Enter fullscreen mode Exit fullscreen mode


swift
func serializeForPrompt(_ tools: [ToolSchema]) -> String {
tools.map { "($0.name)|($0.params.map { "($0.key):($0.value)" }.joined(separator: ","))" }
.joined(separator: "\n")
}


This compact notation cuts schema token cost by 60–70% compared to pretty-printed JSON. The model does not care about human readability — but your memory ceiling does.

---

## Step 2 — Enforce a Hard Token Budget Before Inference

The docs do not mention this, but the KV cache grows linearly with context length and does not shrink mid-inference. Budget enforcement must happen upstream of `MLModel.prediction`, not inside your inference loop.

The figures below are for a **3B parameter, 32-layer transformer with 32 attention heads and a 3072 hidden dimension** — a common INT4 deployment target:

| Context length | KV cache (INT8) | Peak RSS (est.) | Safe on A16 6 GB? |
|---|---|---|---|
| 512 tokens | ~64 MB | ~2.1 GB | Yes |
| 1024 tokens | ~128 MB | ~2.3 GB | Yes |
| 2048 tokens | ~256 MB | ~2.6 GB | Marginal |
| 4096 tokens | ~512 MB | ~3.2 GB | No — OOM risk |

Implement a `TokenBudgetGuard` that measures the serialized prompt, reserves headroom for expected output, and hard-truncates before the model sees a single token. A 1024-token ceiling covers the vast majority of real-time function-calling interactions.

---

## Step 3 — Use Constrained Prefix Injection for Reliable JSON Output

Without a grammar engine like Outlines or llama.cpp's GBNF, raw output from a quantized model is unreliable JSON at best. Here is the minimal setup to get this working without a full grammar engine:

Enter fullscreen mode Exit fullscreen mode


swift
// Force the model to begin with the opening brace
let constrainedPromptSuffix = "\nRespond ONLY with valid JSON: {"

// Post-process: repair common errors (trailing commas, unquoted keys)
let repaired = JSONRepair.repair(rawOutput)
let parsed = try JSONDecoder().decode(ToolCall.self, from: repaired.data(using: .utf8)!)


Internal testing showed ~97% parse reliability with this approach versus ~81% with naive prompting — across a structured function-calling benchmark using a single model checkpoint and greedy decoding. Treat these as directional figures; validate against your own schema surface area before shipping.

---

## Gotchas

**Latency is chip-dependent — plan your output token ceiling accordingly.**

| Chip | Neural Engine TOPS | Sustained tok/s (INT4, 2K ctx) | Practical RAM ceiling |
|---|---|---|---|
| A16 Bionic (iPhone 14 Pro) | ~17 TOPS | 40–55 tok/s | ~2.8 GB before jetsam risk |
| A17 Pro (iPhone 15 Pro) | ~35 TOPS | 55–75 tok/s | ~3.2 GB before jetsam risk |

At 60 tok/s on an A17 Pro with a 1024-token ceiling, you have roughly 90 output tokens before crossing the 1.5-second UX cliff. On an A16, plan for 70-token output ceilings in latency-sensitive paths. Zero room for verbosity in either case.

**Do not port your server-side pipeline directly.** Cloud function calling works because of abundant VRAM and grammar enforcement baked into the serving stack. An INT4 3B model already occupies ~2 GB of RAM on-device. The jetsam daemon is not forgiving.

**Fine-tune on your compact schema format.** The upfront cost is real, but so is the payoff at inference time.

---

## Conclusion

Three things are non-negotiable for on-device function calling on A16/A17: compress schemas to 60–70% fewer tokens, enforce a hard 1024-token budget in a `TokenBudgetGuard`, and use constrained prefix injection with a JSON repair post-processor. Skip any one of these and you will hit OOM or blow past your latency budget.

For background inference patterns — for instance, surfacing timely micro-interactions during idle moments the way [HealthyDesk](https://play.google.com/store/apps/details?id=com.healthydesk) surfaces desk-break nudges between active sessions — Apple's [Core ML sample code repository](https://developer.apple.com/machine-learning/core-ml/) provides solid reference implementations worth studying.

All three constraints are load-bearing. Treat them that way.
Enter fullscreen mode Exit fullscreen mode

Top comments (0)