MVP Factory
ai startup development

On-device LLM function calling on iPhone with Core ML

KW
Krystian Wiewiór · · 6 min read

Meta 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 cleanarchitecture


TL;DR

Running structured tool-use with a quantized LLM on-device is possible today — but only if you treat the Neural Engine’s memory ceiling as a first-class constraint, not an afterthought. Serialize your tool schemas into the prompt prefix, enforce token budgets at the KV-cache layer, and lean on constrained decoding to make output parsing deterministic. Skip any one of these and you will hit OOM on an A16 or watch your latency spiral past the 2-second UX cliff.


The problem with on-device function calling

Cloud-hosted LLMs handle function calling gracefully because they run on hardware with tens of gigabytes of VRAM and a grammar enforcement layer baked into the serving stack. On an iPhone, you have none of that. An INT4 quantized 3B model occupies roughly 2 GB of RAM, leaving the OS with a brutally thin margin before the jetsam daemon starts evicting your process.

Most teams make the same mistake: they port a server-side function-calling pipeline directly to Core ML without rethinking the token economy. They serialize a verbose OpenAI-style tool schema, bloat the context window, and then wonder why inference crashes on an A16 Bionic under mild memory pressure.

On an iPhone 14 Pro (A16), the Neural Engine peaks at roughly 17 TOPS, with a practical sustained throughput of 40–60 tok/s for an INT4 model at 2K context. Push context to 4K and throughput drops 30–40%, while peak memory allocation climbs past the 3 GB iOS kill threshold on devices with 6 GB physical RAM under load.


Schema serialization: compress or pay the price

Your tool schema must live inside the prompt, which means every token it consumes is a token stolen from reasoning. The standard JSON Schema representation of even a moderate tool set runs 300–500 tokens. That is dead weight.

In my experience building production systems with on-device models, the right approach is a two-pass serialization strategy:

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

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

Pass 2 — Token-efficient injection (runtime):

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

This custom compact notation cuts schema token cost by 60–70% compared to pretty-printed JSON. You lose human readability in the prompt, but the model — fine-tuned on this format — does not care.


Token budget enforcement on A16/A17

Set hard limits before you call MLModel.prediction. The KV cache grows linearly with context length and does not shrink mid-inference. Budget enforcement must happen upstream.

The figures below assume a 3B parameter, 32-layer transformer with 32 attention heads and a 3072 hidden dimension — a common INT4 deployment target for on-device inference. KV cache scaling will differ for other architectures.

Context lengthKV cache (INT8)Peak RSS (est.)Safe on A16 6 GB?
512 tokens~64 MB~2.1 GBYes
1024 tokens~128 MB~2.3 GBYes
2048 tokens~256 MB~2.6 GBMarginal
4096 tokens~512 MB~3.2 GBNo — risk OOM

Enforce this in a TokenBudgetGuard that measures the serialized prompt, reserves headroom for the expected output, and hard-truncates if needed before the model sees a single token. A 1024-token ceiling covers the vast majority of real-time function-calling interactions and keeps you well inside the safe zone.


Constrained decoding: making output parsing deterministic

Without a grammar engine like Outlines or llama.cpp’s GBNF, raw output from a quantized model is unreliable JSON at best, hallucinated structure at worst. You do not need a full grammar engine, though. Soft-constrained prefix injection combined with a repair post-processor gets you most of the way there:

// 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 alone — across a structured function-calling benchmark using a single model checkpoint, greedy decoding, and a fixed compact schema format. Results will vary with temperature, schema complexity, and model version; treat these figures as directional rather than universal.


Latency ceiling: what actually fits on iPhone

Target the 1.5-second perceived latency budget for interactive use. The hardware generation you target meaningfully changes your output token headroom, so it is worth being explicit about the difference:

ChipNeural Engine TOPSSustained tok/s (INT4, 2K ctx)Practical RAM ceiling
A16 Bionic (iPhone 14 Pro)~17 TOPS40–55 tok/s~2.8 GB before jetsam risk
A17 Pro (iPhone 15 Pro)~35 TOPS55–75 tok/s~3.2 GB before jetsam risk

At 60 tok/s on an A17 Pro and a 1024-token ceiling, you have roughly 90 tokens of output budget before exceeding the 1.5-second target. That is sufficient for a well-structured tool call — but zero room for verbosity. On an A16 you are tighter; plan for 70-token output ceilings in latency-sensitive paths.

For background inference patterns where you want to surface timely micro-interactions without hammering the CPU during active inference windows, Apple’s Core ML sample code repository provides reference implementations worth studying.


What to actually do

Three things matter here, and none of them are optional.

Compress your tool schemas aggressively. A compact custom notation injected at the prompt prefix cuts 60–70% of schema tokens. Fine-tune on this format — the upfront cost is real, but so is the payoff at inference time.

Enforce a hard 1024-token context budget upstream, before Core ML allocates KV cache. Put this logic in a TokenBudgetGuard, not scattered across your inference loop where it will eventually get skipped under pressure.

Use constrained prefix injection plus a JSON repair post-processor. You do not need a full grammar engine to exceed 95% parse reliability on A16/A17 hardware today — but validate that claim against your own model checkpoint and schema surface area before shipping.


Share: Twitter LinkedIn