Generation, tool calling, and KV cache management in Swift¶
Part 13 · MLX in Swift · Reference 02
Current release: mlx-swift-lm 3.32.3 (release manifest, checked 2026-10-07). It requires Swift tools 6.2 and declares macOS 14 / iOS 17 / tvOS 17 / visionOS 1. Its MLX 0.32.x dependency has its own toolchain requirements; MLX 0.32.3 declares Swift tools 6.3. Detailed API examples retain the 3cbf928 snapshot. SDK/platform gates below are separate from package versions.
Three OS floors do bite, and they are routinely confused:
- The core libraries —
MLXLMCommon,MLXLLM,MLXVLM,MLXEmbedders— run on iOS 17 / macOS 14. Everything in §2 through §9 of this guide is available there. - The Foundation Models bridge (
MLXFoundationModels,#huggingFaceLanguageModel) requires iOS 27.0 / macOS 27.0 / visionOS 27.0 and the 27 SDK. It is compiled out entirely on the 26 SDK by#if FoundationModelsIntegration && canImport(FoundationModels, _version: 2). - Apple's own
LLMBasicsample app declares iOS/macOS 26.2 and Swift 6; theNumericalsamples in the same repo require 26.5. Those are sample-project floors, not library floors.
✅ VERIFIED —
Package.swift:1-14(tools version, platforms),Package.swift:60-66(mlx-swift pin),Package.swift:44-59(theFoundationModelsIntegrationtrait),MLXHuggingFace/FoundationModelsMacros.swift:3(the SDK gate),Applications/LLMBasic/README.md. All read from the clone at HEAD3cbf928.
What this covers¶
Generate and stream text, prepare model input, render chat templates, handle tools, and manage KV caches. Python/Swift differences are explained where they affect behavior.
What this does not cover¶
Related references: Part 4, Part 11.
What you need¶
- Apple silicon. MLX Swift will build for the simulator but you are not measuring anything real there.
- A toolchain supporting the resolved package: mlx-swift-lm 3.32.3 declares Swift tools 6.2; MLX 0.32.3 declares 6.3. Foundation Models integration additionally requires the 27 SDK.
swift testdoes not work on this package. Usexcodebuild test -scheme mlx-swift-lm-Package -destination 'platform=macOS' -skipPackagePluginValidation. The flag is mandatory because mlx-swift 0.31.5 added aCudaBuildbuild-tool plugin thatxcodebuildrefuses to run non-interactively without it.✅ VERIFIED —
CONTRIBUTING.md:22-55; rationale from commitd242429.-skipMacroValidationon everyxcodebuildinvocation if you consumeMLXHuggingFace, which ships Swift macros.- A package pin. The repo's own README recommends
.package(url: "https://github.com/ml-explore/mlx-swift-lm", .upToNextMajor(from: "3.31.3")).
⚠️ Read this before you trust a signature below¶
API details retain source revision 3cbf928b5eb24190e8952725699ae6a3bb02824d. The inspected speculative-generation and TurboQuant bodies were incomplete; their unverified behavior remains marked beside the relevant sections.
Contents¶
- Where the Swift stack sits, and the 3.x break
- The generation API surface
GenerateParameters, samplers, and logit processorsTokenIterator: the thing all of it sits on- Input types:
UserInput,Chat.Message,LMInput, and the VLM path - Tokenizers and chat templates
- Tool calling: ten formats and why
- KV cache: eight types, one contract
- Two real Swift-side cache bugs
MLXEmbedders, and what a local RAG pipeline actually needs- Decision tables, silent-failure register, gap register
1. Where the Swift stack sits¶
1.1 Two repositories, and which one you actually depend on¶
If you learned MLX Swift before late 2025 you learned it wrong, in one specific way: the libraries moved.
✅ VERIFIED —
mlx-swift-examples/README.md:56-62, verbatim:
MLXLMCommon,MLXLLM,MLXVLMandMLXEmbeddershave moved to a new repository containing only reusable libraries: mlx-swift-lm.Previous URLs and tags will continue to work, but going forward all updates to these libraries will be done in the other repository. Previous tags are supported in the new repository.
The split happened in
mlx-swift-examplescommit0db7c5d(2025-11-11, "split out mlx-swift-lm (#441)").
So:
| Repo | What is in it now | You depend on it… |
|---|---|---|
ml-explore/mlx-swift |
The array framework, MLXArray, MLXNN, MLXOptimizers, MLXFast, Memory, WiredMemoryManager |
transitively |
ml-explore/mlx-swift-lm |
MLXLMCommon, MLXLLM, MLXVLM, MLXEmbedders, MLXHuggingFace, MLXFoundationModels, MLXGuidedGeneration |
directly — this is the package |
ml-explore/mlx-swift-examples |
Sample apps (LLMBasic, MLXChatExample, LLMEval, StableDiffusionExample, MNISTTrainer), CLIs (llm-tool, embedder-tool, image-tool), MLXMNIST, StableDiffusion |
as a reference, not a dependency |
The library repo ships nine products — MLXLLM, MLXVLM, MLXLMCommon, MLXEmbedders,
MLXHuggingFace, MLXFoundationModels, MLXGuidedGeneration, BenchmarkHelpers,
IntegrationTestHelpers.
✅ VERIFIED —
Package.swift:15-43.
⚠️ The dead links are real. Libraries/MLXLLM/README.md:68 and Libraries/MLXVLM/README.md:82
still point at ../../Tools/llm-tool, which lives in the other repository. Several per-library
READMEs and the shipped skills/ directory reference packages that do not exist —
swift-huggingface-mlx, swift-tokenizers-mlx, modules MLXLMHuggingFace, MLXLMTokenizers,
MLXEmbeddersHuggingFace. None of those are in Package.swift. Trust the root README.md and
Documentation.docc/using.md; treat the per-library READMEs and skills/* code snippets as stale.
✅ VERIFIED — the stale-import problem is stated in the research notes against
skills/mlx-swift-lm/SKILL.md,Libraries/MLXLMCommon/README.md,Libraries/MLXLLM/README.md,Libraries/MLXVLM/README.mdandLibraries/MLXEmbedders/README.md. Confirmed againstPackage.swift, which has exactly two dependencies:mlx-swiftandswift-syntax.
1.2 The 3.x break: tokenizers and downloading are yours now¶
main is a new major version, and the reason matters for every code sample below.
✅ VERIFIED —
README.md:1-19, verbatim:The
mainbranch is a new major version number: 3.x. In order to decouple from tokenizer and downloader packages some breaking changes were introduced.
In 2.x, mlx-swift-lm depended on swift-transformers and Hub directly. In 3.x it depends on
neither. Instead it declares two protocols and expects you to supply conformers:
public protocol Downloader: Sendable {
func download(
id: String,
revision: String?,
matching patterns: [String],
useLatest: Bool,
progressHandler: @Sendable @escaping (Progress) -> Void
) async throws -> URL
}
public protocol TokenizerLoader: Sendable {
func load(from directory: URL) async throws -> any Tokenizer
}
✅ VERIFIED —
Libraries/MLXLMCommon/Downloader.swift:16-36,Libraries/MLXLMCommon/TokenizerLoader.swift(the whole file).
You have two supported ways to satisfy them:
(a) Hand-roll conformances. Fine if you already have a download layer, or if you ship weights
in the app bundle and never touch the network. Downloader is not used at all when you load from
a local directory.
(b) Use the MLXHuggingFace macros over swift-huggingface + swift-transformers. This is
the path Apple's samples take. Seven freestanding expression macros; the two you will use are
#hubDownloader() and #huggingFaceTokenizerLoader().
The canonical Package.swift fragment, from the root README:
dependencies: [
.package(url: "https://github.com/ml-explore/mlx-swift-lm", .upToNextMajor(from: "3.31.3")),
.package(url: "https://github.com/huggingface/swift-huggingface", from: "0.9.0"),
.package(url: "https://github.com/huggingface/swift-transformers", from: "1.3.0"),
],
targets: [
.target(
name: "YourTargetName",
dependencies: [
.product(name: "MLXLLM", package: "mlx-swift-lm"),
.product(name: "MLXLMCommon", package: "mlx-swift-lm"),
.product(name: "MLXHuggingFace", package: "mlx-swift-lm"),
.product(name: "HuggingFace", package: "swift-huggingface"),
.product(name: "Tokenizers", package: "swift-transformers"),
]),
]
✅ VERIFIED —
README.md:63-100, verbatim.
⚠️ The macros expand to code that references symbols at your call site. #hubDownloader()
expands to a struct HubBridge: MLXLMCommon.Downloader that names HuggingFace.HubClient,
HuggingFace.Repo.ID and Foundation.Progress. If you forget import HuggingFace you get
"cannot find type" errors pointing at a line you did not write. The required imports are
documented at MLXHuggingFace/FoundationModelsMacros.swift:17-25: Foundation,
MLXHuggingFace, MLXFoundationModels, MLXLMCommon, HuggingFace, Tokenizers.
The 3.x migration diff from Apple's own samples, which is the shortest possible statement of the break:
-import Hub
+import HuggingFace
import MLX
+import MLXHuggingFace
- let hub =
- if let download { HubApi(downloadBase: download) } else { HubApi() }
- return try await modelFactory.loadContainer(hub: hub, configuration: modelConfiguration)
+ return try await modelFactory.loadContainer(
+ from: self.downloader,
+ using: #huggingFaceTokenizerLoader(),
+ configuration: modelConfiguration)
✅ VERIFIED —
mlx-swift-examplescommit357c97f, quoted from the research notes. The same commit bumped mlx-swift0.30.3 → 0.31.3and swift-transformers to.upToNextMajor(from: "1.3.0"), and added-skipMacroValidationto everyxcodebuildinvocation in CI.
1.3 The four-layer picture, and where you should be working¶
┌──────────────────────────────────────────┐
easiest, least │ ChatSession │ §2.2
control │ respond / streamResponse / streamDetails│
└──────────────────┬───────────────────────┘
│
┌──────────────────▼───────────────────────┐
│ ModelContainer.generate(input:params:) │ §2.3
│ MLXLMCommon.generate(input:…context:) │
└──────────────────┬───────────────────────┘
│
┌──────────────────▼───────────────────────┐
│ generateTask(...) → (stream, Task) │ §2.4
│ generateTokens(...) → AsyncStream<Token> │
└──────────────────┬───────────────────────┘
│
hardest, full ┌──────────────────▼───────────────────────┐
control │ TokenIterator — Sequence<Int> │ §4
│ model.prepare / callAsFunction / cache │
└──────────────────────────────────────────┘
The advice, stated once: start at ChatSession. It handles the chat template, the KV cache
across turns, the tool loop, cancellation and the detokenizer. Drop a layer only when you need
something it does not expose — a cache you own, custom stop logic, per-token telemetry, a
TokenIteratorProtocol of your own. Most apps never leave the top two layers. Apple's own
LLMBasic sample never leaves the first.
The Python correspondence:
| MLX Swift | mlx-lm (Python) | Note |
|---|---|---|
ChatSession |
no direct equivalent | Python's closest is mlx_lm.chat's REPL loop, which is a CLI, not an API |
MLXLMCommon.generate(input:…) -> AsyncStream<Generation> |
stream_generate(...) generator |
Swift yields Generation cases; Python yields GenerationResponse |
generateTokens(...) -> AsyncStream<TokenGeneration> |
generate_step(...) generator |
Swift wraps it; Python is the raw generator |
TokenIterator |
generate_step |
Swift's is a struct conforming to Sequence/IteratorProtocol |
GenerateParameters |
scattered kwargs + make_sampler / make_logits_processors |
Swift bundles what Python splits — §3.1 |
ModelContainer |
no equivalent | Python has no concurrency container; a (model, tokenizer) tuple is passed around |
That last row is the single biggest structural difference and it is the source of §9's first bug:
Swift has a concurrency-safe container holding a ModelContext struct, and Python has a list and
a tuple. Value semantics change the answer.
2. The generation API surface¶
2.1 The matrix¶
There are five non-deprecated entry points and they differ along three axes: what the stream
yields, whether you get a Task handle back, and whether the call throws before it
streams. Get this table into your head and the rest of §2 is detail.
| Function | Yields | Task handle | Throws | Use when |
|---|---|---|---|---|
generate(input:cache:parameters:context:wiredMemoryTicket:tools:) |
AsyncStream<Generation> |
no | ✅ (prefill) | one-shot text generation |
generateTokens(input:cache:parameters:context:includeStopToken:wiredMemoryTicket:) |
AsyncStream<TokenGeneration> |
no | ✅ | you want raw token IDs |
generateTask(promptTokenCount:modelConfiguration:tokenizer:iterator:wiredMemoryTicket:tools:) |
(AsyncStream<Generation>, Task<Void, Never>) |
✅ | no — you built the iterator | you must observe completion |
generateTokensTask(input:cache:parameters:context:includeStopToken:wiredMemoryTicket:) |
(AsyncStream<TokenGeneration>, Task<Void, Never>) |
✅ | ✅ | raw tokens and completion |
generateTokenTask(promptTokenCount:modelConfiguration:tokenizer:iterator:includeStopToken:wiredMemoryTicket:) |
(AsyncStream<TokenGeneration>, Task<Void, Never>) |
✅ | no | custom iterator, raw tokens |
✅ VERIFIED —
Libraries/MLXLMCommon/Evaluate.swift, non-deprecatedgenerate*overloads. The…Taskvariants take aconsuming TOKEN: TokenIteratorProtocolrather than anLMInput, which is why they cannot throw: the throwing part (prefill) already happened when you constructed the iterator. See §4.2.
Plus two speculative-decoding variants of each of the first two, taking either a draftModel: or
an mtpDrafter: — §2.6.
Why the throwing/non-throwing split matters. try generate(...) can throw before returning
the stream, because prefill runs inside TokenIterator.init. Once you hold the AsyncStream,
iteration is non-throwing — a failure mid-generation ends the stream rather than throwing at the
for await. That is the opposite of ChatSession.streamResponse(to:), which returns an
AsyncThrowingStream<String, Error> and does throw at the for try await. Two adjacent APIs,
two error models.
2.2 ChatSession — the layer you should start at¶
public final class ChatSession {
public var instructions: String?
public var processing: UserInput.Processing
public var generateParameters: GenerateParameters
public var additionalContext: [String: any Sendable]?
public var tools: [ToolSpec]?
public var toolDispatch: (@Sendable (ToolCall) async throws -> String)?
public let speculativeDecoding: SpeculativeDecodingConfig?
}
✅ VERIFIED —
Libraries/MLXLMCommon/ChatSession.swift(919 lines).
Eight initializers: {ModelContainer, ModelContext} × {plain, history: [Chat.Message],
cache: [KVCache]}, all sharing this tail of defaults:
instructions: String? = nil,
speculativeDecoding: SpeculativeDecodingConfig? = nil,
generateParameters: GenerateParameters = .init(),
processing: UserInput.Processing = .init(resize: CGSize(width: 512, height: 512)),
additionalContext: [String: any Sendable]? = nil,
tools: [ToolSpec]? = nil,
toolDispatch: (@Sendable (ToolCall) async throws -> String)? = nil
✅ VERIFIED —
ChatSession.swift, initializer defaults.
⚠️ Note the default processing: images are resized to 512×512 unless you say otherwise. For
a document-understanding VLM that is often too small to read text in the image and there is no
warning. Apple's MLXChatExample overrides it to 1024×1024 on its raw-UserInput path.
The public methods:
func respond(to prompt: String, role: Chat.Message.Role = .user,
images: consuming [UserInput.Image], videos: …, audios: …) async throws -> String
func respond(to prompt: String, role: … = .user, image: UserInput.Image? = nil,
video: … = nil, audio: … = nil) async throws -> String
func respond(to messages: consuming [Chat.Message]) async throws -> String
func streamResponse(to prompt: String, role: … = .user,
images: … = [], videos: … = [], audios: … = [])
-> AsyncThrowingStream<String, Error>
func streamResponse(to messages: consuming [Chat.Message]) -> AsyncThrowingStream<String, Error>
func streamDetails(to prompt: String, role: … = .user,
images: … = [], videos: … = [], audios: … = [])
-> AsyncThrowingStream<Generation, Error>
func streamDetails(to messages: consuming [Chat.Message]) -> AsyncThrowingStream<Generation, Error>
func clear() async // reset cache to .empty, keep instructions
func synchronize() async // wait for exclusive access to the KVCache
func saveCache(to url: URL) async throws // throws ChatSessionError.noCacheAvailable
✅ VERIFIED —
ChatSession.swiftpublic surface.
streamResponse yields String deltas; streamDetails yields Generation cases. If you want
tokens-per-second, tool calls, or a stop reason, you want streamDetails. If you want to append
text to a label, streamResponse is enough. Apple's LLMBasic uses the first, llm-tool eval
uses the second.
⚠️ ChatSession is not thread-safe. The source says so plainly: "Each session should be used
from a single task/thread at a time." ModelContainer is — that is the whole point of it —
and the session is deliberately built so that multiple distinct ChatSessions can run
concurrently against the same weights. The model is pulled out of the container via a
SendableBox, because as the source puts it, "the KVCache cannot be shared and that is the lock
that is held here."
✅ VERIFIED —
ChatSession.swiftdoc comment andstreamMapimplementation (ChatSession.swift:574-836).
2.3 ModelContainer.generate and the free function¶
Two spellings of the same thing:
// on the container — convenience, takes the lock for you
public func generate(input: consuming sending LMInput,
parameters: GenerateParameters,
wiredMemoryTicket: WiredMemoryTicket? = nil) async throws
-> AsyncStream<Generation>
// free function in MLXLMCommon — you already hold a ModelContext
public func generate(input: LMInput, cache: [KVCache]? = nil,
parameters: GenerateParameters,
context: ModelContext,
wiredMemoryTicket: WiredMemoryTicket? = nil,
tools: [[String: any Sendable]]? = nil) throws -> AsyncStream<Generation>
✅ VERIFIED —
Libraries/MLXLMCommon/ModelContainer.swift:145-229andLibraries/MLXLMCommon/Evaluate.swift.
Note the asymmetry: the container convenience does not take a cache: or tools:. If you want
to supply your own KV cache or pass tool schemas, you must go through the free function inside
perform. Apple's MLXChatExample does exactly that:
return try await modelContainer.perform { (context: ModelContext) in
let lmInput = try await context.processor.prepare(input: userInput)
let parameters = GenerateParameters(temperature: 0.7)
return try MLXLMCommon.generate(
input: lmInput, parameters: parameters, context: context)
}
✅ VERIFIED —
mlx-swift-examples/Applications/MLXChatExample/Services/MLXService.swift, verbatim.
Read that carefully, because it encodes a concurrency rule that is easy to get wrong: the stream
is constructed inside perform and consumed outside it. The container's own doc comment
explains why this is safe:
✅ VERIFIED —
ModelContainer.swift:191-197, verbatim:"Note: this is only visiting the model exclusively for the pre-fill time. Beyond that there is no shared mutable state. This means that there may be concurrent access to the model weights themselves (but they are already evaluated)."
ModelContainer's access methods, in full:
public func perform<R: Sendable>(_ action: @Sendable (ModelContext) async throws -> sending R)
async rethrows -> sending R
public func perform<V: Sendable, R: Sendable>(values: V,
_ action: @Sendable (ModelContext, V) async throws -> R) async rethrows -> sending R
public func perform<V, R: Sendable>(nonSendable values: consuming V,
_ action: @Sendable (ModelContext, V) async throws -> R) async rethrows -> sending R
public func update(_ action: @Sendable (inout ModelContext) -> Void) async
Plus conveniences: prepare(input:) -> LMInput, decode(tokenIds:) -> String,
encode(_:) -> [Int], and async computed properties configuration, processor, tokenizer,
modelDirectory (throws), tokenizerDirectory (throws).
✅ VERIFIED —
ModelContainer.swift:32-229.
The deprecated forms you will find in older tutorials: perform { (model, tokenizer) in },
perform(values:) { (model, tokenizer, v) in }, decode(tokens:), and
applyChatTemplate(messages:) on the container. All still compile; all warn.
⚠️ MLXArray is not Sendable. Every perform doc comment says callers must eval() before
returning an array across the isolation boundary. This is not pedantry — the underlying
MLXArray is lazily evaluated, so returning an unevaluated array hands another isolation domain a
promise that will be forced on the wrong thread. And MLX arrays are thread-affine: an array
constructed on a worker thread crashes the first time the engine thread touches it.
✅ VERIFIED —
ModelContainer.swiftdoc comments; the thread-affinity statement is quoted from mlx-lm PR #1588's body ("MLX arrays are thread-affine, so building one on a worker thread crashes the first time the engine thread touches it") — a Python-side finding that applies to the same C++ core.
2.4 generateTask and why early break is a trap¶
The doc comment on the stream-returning generate is one of the most load-bearing sentences in
the package:
✅ VERIFIED —
Evaluate.swift:1425-1429, verbatim:"if the stream is terminated early (e.g. break from the loop) computation will continue using the model, parameters, KVCache, etc. for some time (typically a few ms). This is typically OK for one-shot calls, but for 'chat session' type calls consider using
generateTask(...)so that the end of the generation task can be observed."
So: break out of for await generation in stream and the GPU keeps going for a few milliseconds
against a KV cache you are about to reuse. For a one-shot that is harmless. For a chat loop it is a
race. The fix is the …Task variants:
let (stream, task) = generateTask(
promptTokenCount: promptTokenCount,
modelConfiguration: context.configuration,
tokenizer: context.tokenizer,
iterator: iterator)
for await item in stream {
if shouldStop(item) { break }
}
task.cancel()
await task.value // ← now the cache is genuinely quiescent
ChatSession does exactly this internally: genTask.cancel() when the consumer terminates or the
enclosing task is cancelled, then await genTask.value.
✅ VERIFIED —
ChatSession.swiftstreamMap; commit2c1dd13explains the deadlock this ordering fixes.
2.5 The stream event types¶
public enum Generation: Sendable {
case chunk(String)
case info(GenerateCompletionInfo)
case toolCall(ToolCall)
public var chunk: String?
public var info: GenerateCompletionInfo?
public var toolCall: ToolCall?
@Sendable public static func collect(_ batch: [Generation]?, _ element: Generation)
-> [Generation]
}
public enum TokenGeneration: Sendable {
case token(Int)
case info(GenerateCompletionInfo)
public var token: Int?
public var info: GenerateCompletionInfo?
@Sendable public static func collect(…) -> [TokenGeneration]
}
public enum GenerateStopReason: Sendable { case stop; case length; case cancelled }
public struct GenerateCompletionInfo: Sendable {
public let promptTokenCount: Int
public let generationTokenCount: Int
public let promptTime: TimeInterval
public let generateTime: TimeInterval
public let stopReason: GenerateStopReason
public let proposedDraftTokens: Int? // MTP only
public let acceptedDraftTokens: Int? // MTP only
public let passthroughReason: String? // MTP sticky passthrough
public let speculativeDecodingTelemetry: SpeculativeDecodingTelemetry?
public var promptTokensPerSecond: Double
public var tokensPerSecond: Double
public func summary() -> String
}
✅ VERIFIED —
Evaluate.swift; the threeGenerationcases are independently confirmed bymlx-swift-examples/Applications/MLXChatExample/ViewModels/ChatViewModel.swift, which switches over all three.
Both collect statics exist for one purpose, documented in source as "Reducer that can be used
with throttle() to gather elements into a batch" — i.e. they are shaped for
swift-async-algorithms' throttle(for:reducing:), so a SwiftUI view can coalesce a 200 tok/s
stream into 20 UI updates per second without dropping content.
⚠️ SILENT FAILURE #1 — the first element of the stream can be a .toolCall, and a
"spinner until first text" UI hangs forever. When a model responds to a tool-enabled prompt by
emitting only a tool call, the stream yields .toolCall and then .info — zero .chunk
events. A view model that flips isLoading = false on the first .chunk never flips it.
✅ VERIFIED — the shape is visible in
mlx-swift-examples/Applications/LLMEval/ViewModels/ LLMEvaluator.swift:227-352, which explicitly checksfirst.toolCallbeforefirst.chunkon the first element and only enters the chunk loopif pendingToolCall == nil. Apple's sample handles it; a naive port will not.Safe default: switch exhaustively over
Generationand drive UI state from.info(which always arrives last) or from stream termination, never from "first.chunk".
The same defect exists on the Foundation Models side of the stack and is recorded independently in
this series' corrections register (C9 item o, from Apple's CoachModel.swift:67-72). Two
different frameworks, same trap.
2.6 Speculative decoding, in one page¶
Two mechanisms, both exposed as extra generate overloads:
// draft model: a small model proposes, the big model verifies
public func generate(input:cache:parameters:context:
draftModel: any LanguageModel,
draftCache: [KVCache]? = nil,
numDraftTokens: Int = 2,
wiredMemoryTicket:) throws -> AsyncStream<Generation>
// MTP: the model's own multi-token-prediction head proposes
public func generate(input:cache:parameters:context:
mtpDrafter: any MTPDrafterModel,
blockSize: Int = 4,
wiredMemoryTicket:) throws -> AsyncStream<Generation>
✅ VERIFIED —
Evaluate.swift.
Or, at the ChatSession layer, SpeculativeDecodingConfig:
public init(draftModel: ModelContainer, numDraftTokens: Int = 5,
memoryPolicy: SpeculativeDecodingMemoryPolicy? = nil)
public init(draftModelBytes: Int, numDraftTokens: Int = 5,
memoryPolicy: SpeculativeDecodingMemoryPolicy? = nil,
loadDraftModel: @escaping @Sendable () async throws -> ModelContainer)
⚠️ The default numDraftTokens differs between the two doors: 5 in SpeculativeDecodingConfig,
2 in the free generate(…draftModel:…) function. If you move a working configuration from one to
the other and the acceptance economics change, that is why.
✅ VERIFIED —
ChatSession.swift:43-122vsEvaluate.swift.
Memory gating is explicit, which is unusual and welcome:
public enum SpeculativeDecodingMemoryAction: Sendable, Hashable {
case allow, fallbackToDefault, fail
}
public struct SpeculativeDecodingMemoryPolicy: Sendable, Hashable {
public init(limitBytes: Int? = nil, additionalBytes: Int = 0,
action: SpeculativeDecodingMemoryAction = .fallbackToDefault)
public static var recommendedWorkingSet: Self // GPU.maxRecommendedWorkingSetBytes()
public func evaluate(mainModelBytes: Int, draftModelBytes: Int)
-> SpeculativeDecodingMemoryEvaluation
}
✅ VERIFIED —
Libraries/MLXLMCommon/SpeculativeDecoding.swift.modelWeightBytes(_:)ismodel.parameters().flattened().reduce(0) { $0 + $1.1.nbytes }.
Telemetry, if you want to know whether it is paying: SpeculativeDecodingTelemetry carries
roundCount, draftTokenCount, acceptedDraftTokenCount, targetModelCallCount,
draftModelCallCount, targetVerifiedTokenCount, emittedTokenCount, plus derived
rejectedDraftTokenCount, acceptanceRate, meanAcceptedDraftTokensPerRound,
meanEmittedTokensPerTargetCall.
The two hard constraints, stated once:
- Both models must share the same tokenizer.
SpeculativeTokenIteratoris a port of Python'sspeculative_generate_step()and inherits the requirement. - The KV cache must be trimmable — which rules out every hybrid / linear-attention model, and rules out sliding-window models after the window wraps. That second half is §9.2, and it is a bug, not a design.
⚠️ MTP drafters require a manual registration step and fail silently without it.
MTPDrafterTypeRegistry.shared is empty at bootstrap; you must
await Gemma4AssistantRegistration.register() before loading a Gemma 4 drafter. The stated reason
is a dependency cycle: "the drafter implementation (Gemma4AssistantDraftModel) lives in MLXVLM,
and importing it into MLXLMCommon's MTPDrafterTypeRegistry.shared would form a circular
dependency."
✅ VERIFIED —
Libraries/MLXVLM/Gemma4AssistantRegistration.swift, comment verbatim. A closely related silent failure is recorded in mlx-swift-lm PR #415: when themtpEmitFlagKeyopt-in is discarded by a protocol-extension default, "the target never emits drafter state — the MTP iterator silently falls back to single-token passthrough (no error, just no speedup)."
3. GenerateParameters, samplers, and logit processors¶
3.1 Every field, with its default¶
Swift bundles into one struct what Python splits across generate_step kwargs, make_sampler and
make_logits_processors. That is a genuine ergonomic win and one real hazard: the Swift defaults
are not the Python defaults, and the most important one is temperature.
public init(
maxTokens: Int? = nil, maxKVSize: Int? = nil, kvBits: Int? = nil,
kvGroupSize: Int = 64, quantizedKVStart: Int = 0, kvScheme: String? = nil,
temperature: Float = 0.6, topP: Float = 1.0, topK: Int = 0, minP: Float = 0.0,
repetitionPenalty: Float? = nil, repetitionContextSize: Int = 20,
presencePenalty: Float? = nil, presenceContextSize: Int = 20,
frequencyPenalty: Float? = nil, frequencyContextSize: Int = 20,
prefillStepSize: Int? = nil, seed: UInt64? = nil
)
✅ VERIFIED —
Libraries/MLXLMCommon/Evaluate.swift:54-169. All stored properties arepublic var, so you can mutate a parameters value after construction —ChatSession'sgenerateParametersproperty is designed for that, andllm-tool chatexposes it as/temperature,/topP,/maxTokensslash commands.
| Property | Type | Default | Notes |
|---|---|---|---|
prefillStepSize |
Int? |
nil |
nil ⇒ the model picks; the generic default is 512 |
maxTokens |
Int? |
nil |
nil ⇒ unlimited |
maxKVSize |
Int? |
nil |
non-nil switches every layer to RotatingKVCache |
kvBits |
Int? |
nil |
affine KV-quantization bit width |
kvGroupSize |
Int |
64 |
|
quantizedKVStart |
Int |
0 |
token offset at which quantization kicks in |
kvScheme |
String? |
nil |
overrides kvBits; see §8.5 |
temperature |
Float |
0.6 |
0 ⇒ ArgMaxSampler (greedy) |
topP |
Float |
1.0 |
active only when 0 < topP < 1 |
topK |
Int |
0 |
0 disables |
minP |
Float |
0.0 |
0 disables |
seed |
UInt64? |
nil |
reproducible sampling; inert at temperature == 0 |
repetitionPenalty |
Float? |
nil |
|
repetitionContextSize |
Int |
20 |
|
presencePenalty |
Float? |
nil |
|
presenceContextSize |
Int |
20 |
|
frequencyPenalty |
Float? |
nil |
|
frequencyContextSize |
Int |
20 |
⚠️ temperature defaults to 0.6, not 0. In Python, mlx_lm.make_sampler's temp
defaults to 0.0 — greedy. In Swift the default is sampled. If you are comparing Swift and Python
output for a port, or writing a test that expects determinism, you must pass
temperature: 0 explicitly. This is the single most common source of "the Swift port gives
different answers" reports that are not bugs.
✅ VERIFIED — Swift default from
Evaluate.swift:54-169; Python default frommlx_lm/sample_utils.py'smake_sampler(temp: float = 0.0, …).
⚠️ seed is inert at temperature == 0. Greedy decoding does not consult the RNG, so setting
a seed and getting identical output tells you nothing about reproducibility. And note the
initializer's parameter order: prefillStepSize and seed are last, after all the penalties.
If you are writing a positional call (don't), that will bite.
⚠️ maxTokens: nil means unlimited. llm-tool chat uses this deliberately — /maxTokens with
no argument parses to nil and removes the limit. In an app, an unlimited budget plus a model that
has entered a repetition loop is an unbounded memory and battery event. Set a ceiling.
The integration-test defaults, if you want a sane starting point that the maintainers actually run:
GenerateParameters(maxTokens: 200, temperature: 0), and maxTokens: 3000, temperature: 0 for the
long-coherence test.
✅ VERIFIED —
Libraries/IntegrationTestHelpers/IntegrationTestHelpers.swift.
3.2 How a sampler gets chosen¶
You never construct a sampler directly unless you want to. GenerateParameters.sampler() picks:
temperature == 0 → ArgMaxSampler()
any of topP/topK/minP active → TopPSampler(temperature:topP:topK:minP:seed:)
otherwise → CategoricalSampler(temperature:seed:)
✅ VERIFIED —
Evaluate.swift:171-184.
The protocols are two lines each:
public protocol LogitSampler {
func sample(logits: MLXArray) -> MLXArray
}
public protocol LogitProcessor {
mutating func prompt(_ prompt: MLXArray)
func process(logits: MLXArray) -> MLXArray
mutating func didSample(token: MLXArray)
}
✅ VERIFIED —
Evaluate.swift.
TopPSampler applies its filters in Python mlx-lm's order: top_p → min_p → top_k. This
is a deliberate port decision and it matters, because the operations do not commute: applying
top_k first and then top_p gives a different candidate set than the reverse. Masking is done
with -inf in original vocabulary order (so token identities are preserved), bfloat16 logits
are upcast to float32 before the softmax, and the whole thing runs inside
withRandomState(randomState) so a seeded sampler is genuinely reproducible within a process.
applyTopK is worth knowing about because it is the reason top-k is cheap here:
argPartition(-logprobs, kth: topK - 1, axis: -1)[0..., topK...] followed by putAlong — O(V),
no full sort. A naive implementation sorts the whole 150k-entry vocabulary every token.
✅ VERIFIED —
Evaluate.swift:245-327.
3.3 Penalty processors and the GPU-resident ring buffer¶
GenerateParameters.processor() returns nil unless at least one penalty is set and its
context size is greater than zero; otherwise it returns a
PenaltyProcessor(repetitionContext:presenceContext:frequencyContext:).
The public processor types are RepetitionContext, PresencePenaltyContext,
FrequencyPenaltyContext and the composite PenaltyProcessor.
The implementation detail worth internalising: all three share a TokenRing, a GPU-resident ring
buffer built out of MLX.where masks, specifically so that no CPU↔GPU synchronisation happens
per token.
✅ VERIFIED —
Evaluate.swift:348-400.
This is not a micro-optimisation. Every generation step calls asyncEval to pipeline the next GPU
evaluation; a single .item() read on the CPU side collapses that pipeline and serialises the
loop. A repetition-penalty implementation that keeps its window in a Swift Array and copies it to
the GPU each step will measurably halve your decode rate. If you write a custom LogitProcessor,
keep its state in MLXArrays and never call .item().
⚠️ SILENT FAILURE #2 — sampler parameters that do nothing. topP is active only when
0 < topP < 1; topK is disabled at 0; minP is disabled at 0.0. Setting topP: 1.0
(the default) or topK: 0 looks like a configuration and is a no-op. Worse, at
temperature: 0 every one of them is ignored, because ArgMaxSampler never consults them.
A config file that sets temperature: 0, topP: 0.9, topK: 40 is greedy decoding with three
decorative fields, and nothing warns.
Cheap check: run the same prompt twice with the same parameters and no seed. Identical output ⇒ you are greedy, whatever your
topPsays. Different output ⇒ you are sampling.
3.4 Determinism, and what you cannot have¶
Set temperature: 0 and you get deterministic output on one machine, in one process, against
one mlx-swift build. You do not get:
- Cross-device bit reproducibility. Community-measured (
mlx-lm#1280, 2026-07 research pass): the same model (mlx-community/Qwen3.6-35B-A3B-4bit), same prompt,temperature: 0, same seed, samemax_tokensproduced different generated-token counts on M5 Max versus M3 Ultra (7857 vs 6145 tokens on one AIME25 case, both reaching the correct answer). Attribute as community-measured, two machines, mlx 0.32.x, July 2026. Cross-device bit reproducibility is not a property MLX offers. - Batch-versus-single equivalence on M5-class hardware. Also community-measured
(mlx#3897, M5 base applegpu_g17g, 32 GB, macOS 26.5.2): mlx-lm's own
test_generate.py fails 8 of 28 tests on mx.allclose(batch_logprobs, single_logprobs) at
rtol=1e-5, with max |Δlogprob| ≈ 0.031–0.039. Two independent gen-17 mechanisms were separated
in that thread — a NAX attention-kernel divergence in fp16/bf16, and TF32 in fp32 GEMM. A
strict rtol=1e-5 batch-equivalence assertion cannot hold on gen-17, in any dtype.
- Speculative decoding that is bit-identical to sequential decoding. "Lossless" holds only up
to floating-point tie-breaks: three independent reproductions found exact bit-level ties in
bfloat16 (e.g. two candidates at logit 33.75, softmax 0.3828, logit_gap = 0.0 exactly),
where batched verify and sequential decode break the tie differently. This is expected
float non-associativity, not a bug; the resolution upstream was a docstring
(mlx-lm PR #1592), not a code change.
All three: community-measured, from the July 2026 issue-mining pass over
ml-explore/mlxandml-explore/mlx-lm. Hardware and OS as stated per bullet. Not Apple-published.
The falsifier recipe, if you suspect a real speculative-decoding bug rather than a tie: at the divergence index, replay through the plain sequential path and print both candidates' raw logits, probabilities and ranks. Gap ≈ 0.0 with both at top rank ⇒ benign tie. Materially non-zero gap with a dominant baseline token ⇒ a real accept/verify defect worth filing.
4. TokenIterator: the thing all of it sits on¶
4.1 The protocol¶
public protocol TokenIteratorProtocol: Sequence, IteratorProtocol where Element == Int {
var maxTokens: Int? { get }
var tokenCount: Int { get }
var promptPrefillTime: TimeInterval { get }
var speculativeDecodingTelemetry: SpeculativeDecodingTelemetry? { get } // default nil
mutating func discardGeneratedToken() // default no-op
}
✅ VERIFIED —
Evaluate.swift.
Four conformers ship: TokenIterator, SpeculativeTokenIterator,
MTPSpeculativeTokenIterator, and whatever you write. The …Task generation entry points are
generic over TOKEN: TokenIteratorProtocol, which is the extension point: if you want n-gram
self-speculation, or a constrained decoder, or a "stop when the JSON closes" iterator, you conform
to this protocol and hand it to generateTask.
discardGeneratedToken() exists for one reason: when the loop sees a stop token and
includeStopToken == false, it needs to tell the iterator "un-count that." The default is a
no-op, which is correct for simple iterators and wrong for anything maintaining a transcript.
4.2 Three initializers, and the one that costs money¶
@available(*, deprecated, message: "please use init(input:model:cache:parameters:)")
public init(prompt: MLXArray, model: any LanguageModel, cache: [KVCache]? = nil,
parameters: GenerateParameters) throws
public init(input: LMInput, model: any LanguageModel, cache: [KVCache]? = nil,
state: LMOutput.State? = nil, parameters: GenerateParameters) throws
public init(input: LMInput, model: any LanguageModel, cache: [KVCache]? = nil,
state: LMOutput.State? = nil,
processor: LogitProcessor?, sampler: LogitSampler,
prefillStepSize: Int? = nil, maxTokens: Int? = nil) throws
✅ VERIFIED —
Evaluate.swift.
⚠️ Prefill runs inside init. The body is
self.promptPrefillTime = try measure { try prepare(...) }. Constructing a TokenIterator is
the expensive, throwing part; iterating it is the cheap part. Two consequences:
- Your TTFT clock starts before
init, not before the firstnext(). If you timefor awaityou are measuring decode, not time-to-first-token. tryon the constructor is where prefill failures surface — OOM, an unsupported shape, a cancelled task. The stream itself does not throw.
⚠️ The third initializer explicitly disables cache quantization. Its source comment is
"No cache quantization for this direct initialization." If you drop to the sampler/processor
initializer to get custom sampling, you silently lose kvBits / kvScheme handling. That is a
correct-by-construction choice on the library's part — those parameters live on
GenerateParameters, which this initializer does not take — but it will surprise you if you moved
down a layer to get one feature and lost another.
✅ VERIFIED — source comment quoted from
Evaluate.swift.
4.3 state, and the M-RoPE trap¶
LMOutput.State is a heterogeneous typed dictionary:
public struct LMOutput {
public let logits: MLXArray
public let state: State?
public struct Key<T>: Identifiable, Sendable { public let id: String }
public struct State { public subscript<T>(_ key: Key<T>) -> T? { get set } }
}
✅ VERIFIED —
Libraries/MLXLMCommon/LanguageModel.swift.
It carries whatever a model needs to remember across a turn that is not KV. For Qwen VLMs that is
the M-RoPE positionIds / ropeDeltas. For MTP it is
mtpLastHiddenStatesKey, mtpSharedKVStatesKey, mtpEmitFlagKey.
The pattern is: seed it in, read it back out. ChatSession does exactly this:
let iterator = try TokenIterator(input: input, model: model, cache: cache,
state: lmState, parameters: parameters)
// … generate …
lmState = iterator.state
✅ VERIFIED —
ChatSession.swift:574-836.
⚠️ SILENT FAILURE #3 — dropping LMOutput.State between turns produces drifted positions in
VLMs, with no error. Three linked issues document this:
mlx-swift-lm#419(fixed, merged) — prefill'sLMOutput.Statewas dropped onTokenIterator's.logitspath. The one-line fix (self.state = result.statein the.logitsbranch ofprepare) landed as commit42f08a8.
mlx-swift-lm#420(closed completed 2026-08-28) — M-RoPE state dropped acrossChatSessionturns: "LMOutput.State(which carries the M-RoPEpositionIds/ropeDeltassince #239/#283) dies with each turn'sTokenIterator. On the next turn the Qwen VLM position branches see a warm cache with no rope deltas and recompute positions from zero." Fixed for Qwen3.5/3.6 by PR #399 (merged 2026-07-14); PR #448 wiring Qwen2.5-VL / Qwen2-VL merged 2026-07-30. Qwen3-VL remained unwired in the researched snapshot — the issue title named Qwen2.5-VL / Qwen3-VL; verify the closing implementation before removing the workaround.
mlx-swift-lm#443(closed 2026-08-10) —savePromptCache/loadPromptCachedropLMOutput.Stateentirely in the researched snapshot: "The safetensors layout has no slot for it,loadPromptCachereturns only([KVCache], metadata), and both cache-acceptingChatSessioninitializers hard-codestate: nil."
The magnitude, quantified in PR #399: on a tiny random-weight model, warm turn-2 logits diverged from a cold full prefill by 0.43 max-abs, against an 8.3e-07 decode-path noise floor. "At temp 0 on dense grounding prompts this can flip bbox output silently."
✅ VERIFIED — issue and PR text from the July 2026 issue-mining pass over
ml-explore/mlx-swift-lm. States re-checked viagh2026-09-04: issue #420 closed completed 2026-08-28 after PR #448 wired Qwen2.5-VL/Qwen2-VL and PR #475 wired Qwen3-VL; #443 closed 2026-08-10; PR #399 merged 2026-07-14. Re-check the released version you ship before relying on this.Safe default for VLMs today: if you are doing multi-turn grounding (bounding boxes, coordinates, "the thing on the left"), verify that your released version contains the #448/#475 family wiring and #443 fix before restoring a saved KV cache; otherwise prefer re-prefilling. Qwen3-VL warm reuse therefore inherits that released-version gate end to end. Text-only models are unaffected — they carry no state in
LMOutput.State.
4.4 What next() actually does¶
Three behaviours in the body are worth knowing because each one is a hard-won fix:
Everything is wrapped in autoreleasepool. Source comment: "a full model forward produces
hundreds of autoreleased wrapper objects … without this, long generations grow host memory without
bound."
asyncEval([token] + cache.flatMap { $0.state }) — the cache state is evaluated together
with the token. This is the Swift equivalent of the Python finding that a functional
(concatenate / slice-assign) cache leaks one Metal buffer per layer per step unless you
mx.eval the cache state each step. On the Python side that leak exhausts the 499,000 live-buffer
resource limit after roughly 11,300 generated tokens on a 43-layer model
(mlx-lm#1332, community-measured). Swift does the eval for you — as long as you use the shipped
iterator.
if tokenCount % 256 == 0 { MLX.Memory.clearCache() } — source comment: "Matches mlx-lm's
clear cadence."
✅ VERIFIED —
Evaluate.swift:757-791, comments verbatim.
step(previous:) wraps the model call in
withPreparedCache(cache, lengths: previous.sequenceLengths) and then calls
maybeQuantizeKVCache(cache:&cache, kvBits:kvGroupSize:quantizedKVStart:kvScheme:). That last
call is the site of the bug in §9.1. Remember where it is.
4.5 Cancellation, and why the check is where it is¶
The generation loop is:
The cancellation check is deliberately before next(), not after. The source explains:
✅ VERIFIED —
Evaluate.swift:1867-2001, comment verbatim:"next() calls asyncEval() to pipeline the next GPU evaluation, so checking after it (the previous
while let token = iterator.next()form) allowed one extra asyncEval to be submitted post-cancellation, which faults if the app has backgrounded (kIOGPUCommandBufferCallbackErrorBackgroundExecutionNotPermitted)."
The same hazard shows up in prefill, and the fix there is a cooperative check inside the chunk loop:
public func prepare(_ input: LMInput, cache: [KVCache], state: LMOutput.State?,
windowSize: Int?) throws -> PrepareResult {
let prefillStepSize = windowSize ?? 512
var y = input.text
try withPreparedCache(cache, lengths: y.sequenceLengths) {
var state: LMOutput.State? = state
while y.tokens.size > prefillStepSize {
try Task.checkCancellation() // ← commit 2b03485
autoreleasepool {
let input = y[.newAxis, ..<prefillStepSize]
let output = self(input, cache: cache.isEmpty ? nil : cache, state: state)
state = output.state
asyncEval(cache)
y = y[prefillStepSize...]
}
}
eval(cache)
}
return .tokens(y)
}
✅ VERIFIED —
Libraries/MLXLLM/LLMModel.swift, the default chunked prefill, verbatim. The rationale comment (LLMModel.swift:36-42) is the clearest statement of why this matters on iOS:"On iOS, GPU work submitted after the app moves to the background is rejected by the system ('Insufficient Permission'), and the resulting command-buffer error is thrown from a Metal completion handler where it cannot be caught, aborting the process."
Read that twice. The error is thrown from a Metal completion handler where it cannot be caught, and it aborts the process. This is not "your generation fails"; it is "your app crashes." Which is why the cancellation plumbing in this package is unusually thorough and why you should not route around it.
Related landed work: #382/PR #389 ("Cancelling generation can still submit one more GPU
evaluation (iOS/iPadOS crash)"), PR #423 (cooperative cancellation in the prefill loop), PR #413
(cancel the generation task when the consumer goes away).
4.6 A complete streaming program with correct cancellation¶
Everything above, assembled as a complete SwiftPM executable target with the dependencies from
§1.2 (shown as a contextual excerpt — the external MLX modules keep it outside the compile-verified
set). It streams to stdout, times TTFT, handles a tool call if one appears, and cancels cleanly on
SIGINT — including waiting for the generation task to actually finish before exiting.
// Sources/StreamDemo/main.swift
import Foundation
import HuggingFace
import MLX
import MLXHuggingFace
import MLXLLM
import MLXLMCommon
import Tokenizers
// ---------------------------------------------------------------- configuration
private let modelConfiguration = LLMRegistry.qwen3_4b_4bit
private let parameters = GenerateParameters(maxTokens: 512, temperature: 0)
private let promptText = "Explain what a KV cache is, in three sentences."
// Bound the MLX buffer pool. Every Apple sample does this; 20 MB is their number.
// See §11.2 for why the "as big as fits" instinct is wrong here.
Memory.cacheLimit = 20 * 1024 * 1024
// ---------------------------------------------------------------- load
let container = try await #huggingFaceLoadModelContainer(
configuration: modelConfiguration
) { progress in
FileHandle.standardError.write(
Data("\rdownload \(Int(progress.fractionCompleted * 100))%".utf8))
}
// ---------------------------------------------------------------- build the input
let chat: [Chat.Message] = [
.system("You are terse and precise."),
.user(promptText),
]
let userInput = UserInput(chat: chat)
let lmInput = try await container.prepare(input: userInput)
let promptTokenCount = lmInput.text.tokens.size
// ---------------------------------------------------------------- generate
// A holder so the signal handler can reach the task. `nonisolated(unsafe)` is the
// honest spelling: a signal handler is not an isolation domain.
nonisolated(unsafe) var generationTask: Task<Void, Never>?
signal(SIGINT, SIG_IGN)
let sigintSource = DispatchSource.makeSignalSource(signal: SIGINT, queue: .main)
sigintSource.setEventHandler { generationTask?.cancel() }
sigintSource.resume()
let start = Date.timeIntervalSinceReferenceDate
var firstTokenAt: TimeInterval?
var completionInfo: GenerateCompletionInfo?
// Build the iterator (this is where prefill happens, and where it can throw),
// then hand it to generateTask so we get a handle we can await.
let (stream, task) = try await container.perform { context -> (AsyncStream<Generation>, Task<Void, Never>) in
let iterator = try TokenIterator(
input: lmInput,
model: context.model,
cache: nil, // let the model build its own; see §8.4
parameters: parameters)
return generateTask(
promptTokenCount: promptTokenCount,
modelConfiguration: context.configuration,
tokenizer: context.tokenizer,
iterator: iterator)
}
generationTask = task
for await item in stream {
if firstTokenAt == nil { firstTokenAt = Date.timeIntervalSinceReferenceDate }
switch item {
case .chunk(let text):
print(text, terminator: "")
fflush(stdout)
case .toolCall(let call):
// A tool call can be the FIRST and ONLY event. See §2.5.
FileHandle.standardError.write(
Data("\n[tool call: \(call.function.name) \(call.function.arguments)]\n".utf8))
case .info(let info):
completionInfo = info
}
}
// The critical two lines. Without them the GPU may still be working against the
// KV cache when the process exits or when the next turn starts. See §2.4.
task.cancel()
await task.value
// ---------------------------------------------------------------- report
print("")
if let firstTokenAt {
let ttft = firstTokenAt - start
FileHandle.standardError.write(Data(String(format: "TTFT %.3f s\n", ttft).utf8))
}
if let completionInfo {
FileHandle.standardError.write(Data((completionInfo.summary() + "\n").utf8))
FileHandle.standardError.write(Data("stop reason: \(completionInfo.stopReason)\n".utf8))
}
FileHandle.standardError.write(Data((Memory.snapshot().description + "\n").utf8))
Six things this example is demonstrating, in order of how often they are gotten wrong:
task.cancel()thenawait task.valueafter the loop. Not before, not instead of.- TTFT is measured from before the
performblock, because prefill is insideTokenIterator.init(§4.2). Measuring from the firstfor awaititeration measures nothing. - The
.toolCallcase is handled explicitly and can arrive first (§2.5). - The stream is constructed inside
performand consumed outside it (§2.3). Memory.cacheLimitis set once at startup.MLX.GPU.set(cacheLimit:)— the idiom in every pre-2026 tutorial — does not exist in this codebase any more; it isMemory.cacheLimit,Memory.memoryLimit,Memory.snapshot().✅ VERIFIED —
mlx-swift-examplesresearch notes §4: the old spelling appears nowhere in that repo; the newMemoryAPI is used inLLMBasicApp.swift:12,MLXService.swift:56,LLMEvaluator.swift:105,LoRATrainingExample/ContentView.swift:181,StableDiffusionExample/ContentView.swift:146,149and every CLI'sMemoryArguments..infois captured rather than assumed.llm-tool's equivalent loop ends withfatalError("exited loop without seeing .info")— the stream is expected always to terminate with.info, and Apple's CLI treats its absence as a programming error. In app code, don'tfatalError; do notice.
Observed Memory.cacheLimit values across Apple's own samples, if you want a starting point:
| App | cacheLimit |
memoryLimit |
|---|---|---|
LLMBasic |
20 MB | — |
LLMEval |
20 MB | — |
MLXChatExample |
20 MB | — |
LoRATrainingExample |
32 MB | — |
StableDiffusionExample (low-memory device) |
1 MB | 3 GB |
StableDiffusionExample (normal) |
256 MB | — |
llm-tool / embedder-tool |
--cache-size MB |
--memory-size MB |
✅ VERIFIED — read from the named source files in
mlx-swift-examplesat HEAD378f244. These are configuration choices in Apple sample code, not benchmarks.
5. Input types¶
5.1 The pipeline, in one diagram¶
Chat.Message[] ─┐
[String: any] ─┼─▶ UserInput ──▶ UserInputProcessor.prepare(input:) ──▶ LMInput
String ─┘ │ │ │
│ │ ├─ text: LMInput.Text
images/videos/audios LLMUserInputProcessor ├─ image: ProcessedImage?
tools / additionalContext or a VLM processor ├─ video: ProcessedVideo?
processing (resize, px) (Qwen2VLProcessor, Gemma4Processor…) └─ audio: ProcessedAudio?
UserInputProcessor is a one-method protocol and it is where all the model-specific work lives:
public protocol UserInputProcessor: Sendable {
func prepare(input: UserInput) async throws -> LMInput
}
✅ VERIFIED —
Libraries/MLXLMCommon/UserInput.swift:450-452. The stand-in conformanceStandInUserInputProcessorthrowsUserInputError.notImplemented, which is what you get if a model type is registered without a processor.
You reach the processor through the container:
let lmInput = try await container.prepare(input: userInput) // convenience
// or, inside perform:
let lmInput = try await context.processor.prepare(input: userInput) // explicit
5.2 UserInput¶
public typealias Message = [String: any Sendable]
public struct UserInput {
public enum Prompt: CustomStringConvertible {
case text(String)
case messages([Message]) // model-specific dictionaries
case chat([Chat.Message]) // model-agnostic
}
public var prompt: Prompt // didSet re-derives images/videos/audios for .chat
public var images = [Image]()
public var videos = [Video]()
public var audios = [Audio]()
public var tools: [ToolSpec]?
public var additionalContext: [String: any Sendable]?
public var processing: Processing = .init()
}
✅ VERIFIED —
Libraries/MLXLMCommon/UserInput.swift.
Three prompt shapes, and which to use:
| Case | When | Cost |
|---|---|---|
.text(String) |
never, in 2026 | no roles, no template structure |
.messages([[String: any Sendable]]) |
you are hand-building the exact dicts a specific template wants | you own template compatibility |
.chat([Chat.Message]) |
default choice | a MessageGenerator converts to dicts for you |
The initializers:
init(prompt: String, images:videos:audios:tools:additionalContext:) // wraps into .chat([.user(...)])
init(messages: [Message], …)
init(chat: [Chat.Message], processing:tools:additionalContext:)
init(prompt: Prompt, images:videos:audios:processing:tools:additionalContext:)
⚠️ A source comment worth quoting because it explains a whole class of confusion:
// note: prompt.didSet is not triggered in init. Every initializer manually re-derives
images / videos / audios from the chat messages. If you construct a UserInput and then
mutate .prompt, the didSet fires and re-derives. If you mutate .images directly after
constructing from a chat, you now have media that the messages do not reference. Put media on
the Chat.Message, not on the UserInput, whenever you use the .chat path.
✅ VERIFIED —
UserInput.swift, comment verbatim.
Media types:
public enum Image {
#if canImport(CoreImage)
case ciImage(CIImage)
#endif
case url(URL)
case array(MLXArray)
public func asCIImage() throws -> CIImage // handles 0..1 scaling, planar→pixels, RGB→RGBA pad
}
public enum Video {
#if canImport(AVFoundation)
case avAsset(AVAsset)
#endif
case url(URL)
case frames([VideoFrame])
}
public enum Audio { case url(URL); case array(MLXArray) }
public enum AudioFormat: Sendable { case linearPCM }
public struct VideoFrame { public let image: Image; public let timeStamp: CMTime }
✅ VERIFIED —
UserInput.swift:79-173. The#if canImportguards are why this package compiles on Linux;Libraries/MLXLMCommon/Linux/containsCoreGraphics.swift,CoreMedia.swift,Logger.swift,String+Linux.swiftshims (commit65e28c2).
Processing knobs:
public struct Processing: Sendable {
public var resize: CGSize?
public var audio = AudioProcessing()
public var minPixels: Int? // per-call override of the model's min_pixels
public var maxPixels: Int? // per-call override of the model's max_pixels
public init(resize: CGSize? = nil, minPixels: Int? = nil, maxPixels: Int? = nil)
}
public struct AudioProcessing: Sendable {
public var sampleRate = 48_000.0
public var channels = 1
public var audioFormat: AudioFormat = .linearPCM
}
✅ VERIFIED —
UserInput.swift:189-222.
minPixels / maxPixels are the per-call escape hatch for Qwen-VL-family dynamic resolution. They
matter more than resize for those models, because the processor computes a token budget from the
pixel count. mlx-swift-lm PR #398 defaults Qwen3VL to a 1,280 vision-token budget per image
after issue #396, where uncapped resolution let the ViT allocate tens of gigabytes.
5.3 Chat.Message¶
public enum Chat {
public struct Message {
public var role: Role // .user .assistant .system .tool
public var content: String
public var images: [UserInput.Image]
public var videos: [UserInput.Video]
public var audios: [UserInput.Audio]
public var tool: Tool? // .calls([ToolCall]) or .result(id: String)
public static func system(_ content: String, images:…, videos:…) -> Self
public static func assistant(_ content: String, images:…, videos:…,
toolCalls: [ToolCall]? = nil) -> Self
public static func user(_ content: String, images:…, videos:…, audios:…) -> Self
public static func tool(_ content: String, id: String? = nil) -> Self
}
}
✅ VERIFIED —
Libraries/MLXLMCommon/Chat.swift.
MessageGenerator converts these to the raw [String: any Sendable] dicts a Jinja chat template
expects. The default emits ["role": …, "content": …] plus tool metadata: tool_calls (an array
of {type: "function", function: {name, arguments}, id?}) on assistant messages, and
tool_call_id on tool results.
Two generators ship: DefaultMessageGenerator and NoSystemMessageGenerator (which filters
.system messages out entirely). How the right one gets picked is a delightfully pragmatic piece
of code:
✅ VERIFIED —
Libraries/MLXLLM/Models/Llama.swift:186-202:LlamaModel.messageGenerator(tokenizer:)probes the chat template with a system message and catches the throw. If rendering a system message throws, it returnsNoSystemMessageGenerator; otherwiseDefaultMessageGenerator.
That is the library protecting you from a template that does not accept a system role. It is also a hint about §6: the template is the contract, and the library discovers the contract at runtime by trying things.
⚠️ SILENT FAILURE #4 — a trailing empty assistant message closes the assistant turn. If you
push a .assistant("") placeholder into the model input (a natural thing to do when your UI wants
a bubble to stream into), the chat template closes the assistant turn and generation misbehaves —
usually by producing a fresh user turn or an immediate EOS. ChatSession handles this internally.
The raw UserInput path does not. Apple's fix, added 2026-06-16:
// Exclude trailing empty assistant message so the chat template
// leaves the assistant turn open for generation (matching ChatSession behavior)
var inputMessages = messages
if let last = inputMessages.last, last.role == .assistant, last.content.isEmpty {
inputMessages.removeLast()
}
✅ VERIFIED —
mlx-swift-examples/Applications/MLXChatExample/Services/MLXService.swift, verbatim; commit378f244message: "Exclude trailing empty assistant message from model input."
5.4 LMInput and LMOutput¶
public struct LMInput {
public let text: Text
public let image: ProcessedImage?
public let video: ProcessedVideo?
public let audio: ProcessedAudio?
public struct Text {
public let tokens: MLXArray
public let mask: MLXArray?
public var sequenceLengths: [Int]? // from mask, else uniform for 2-D tokens
public subscript(indices: MLXArrayIndex..., stream:) -> Text // slices tokens AND mask
public subscript(text indices: MLXArrayIndex..., stream:) -> Text // slices tokens only
}
public struct ProcessedImage {
public let pixels: MLXArray
public let positionIds: MLXArray?
public let frames: [THW]?
}
public struct ProcessedVideo { /* identical shape */ }
public struct ProcessedAudio { public let features: MLXArray; public let mask: MLXArray? }
}
public struct THW: Sendable {
public let t, h, w: Int
public var values: (Int, Int, Int)
public var product: Int
}
public enum PrepareResult { case tokens(LMInput.Text); case logits(LMOutput) }
✅ VERIFIED —
Libraries/MLXLMCommon/LanguageModel.swift.
lmInput.text.tokens.size is your prompt token count. That is how Apple's LLMEval computes
it, and it is the number you want for TTFT-per-token and for sizing a KV budget (§8.8).
The two subscripts are subtle and worth calling out: the default one slices tokens and mask
together; the [text: …] one slices tokens only. Chunked prefill uses the first. If you write a
custom prefill and use the wrong one, your attention mask desynchronises from your tokens — and
because a mask mismatch usually produces plausible attention rather than a crash, you get
degraded output with no error.
5.5 The LanguageModel protocol, and where PrepareResult splits¶
public protocol LanguageModel: BaseLanguageModel {
func prepare(_ input: LMInput, cache: [KVCache], state: LMOutput.State?, windowSize: Int?)
throws -> PrepareResult
func callAsFunction(_ input: LMInput.Text, cache: [KVCache]?, state: LMOutput.State?) -> LMOutput
func callAsFunction(_ inputs: MLXArray, cache: [KVCache]?) -> MLXArray
func newCache(parameters: GenerateParameters?) -> [KVCache]
}
✅ VERIFIED —
LanguageModel.swift:238-266.
PrepareResult has two cases and the difference is the whole LLM/VLM split:
.tokens(LMInput.Text)— "I prefilled everything except this tail; now iterate normally." That is the LLM path (§4.5's chunked prefill returns.tokens(y))..logits(LMOutput)— "I ran the whole thing including the last position; here are the logits and the state." That is the VLM path, because a VLM must merge image embeddings with text embeddings before it can run a forward at all.
TokenIterator.prepare(input:windowSize:) handles both, and the .logits branch does
self.state = result.state — which is exactly the line that was missing in #419 (fixed in
commit 42f08a8). One missing assignment on one branch, and every VLM lost its M-RoPE deltas.
5.6 The VLM path in practice¶
UserInputProcessor.prepare(input:) for a vision model has to do a specific sequence, and
MediaProcessing (Libraries/MLXVLM/MediaProcessing.swift, 571 lines) supplies each step:
- sRGB tone-curve conversion —
MediaProcessing.inSRGBToneCurveSpace(_:). - Apply the user's
Processing—MediaProcessing.apply(_:processing:). - Resample —
resampleLanczos(_:to:)orresampleBicubic(_:to:). - Normalize —
normalize(_:mean:std:)with the model's per-channel constants. asMLXArray(_:colorSpace:).- Inject the model's image placeholder tokens into the text stream.
✅ VERIFIED —
Libraries/MLXVLM/MediaProcessing.swiftpublic surface; step list fromDocumentation.docc/porting.md's VLM processor section.
Step 1 is not cosmetic. mlx-swift-lm PR #411 fixed Qwen3VL specifically because the tone curve was not being applied: "linear-light values made dark content unreadable" (issue #410). A VLM fed linear-light pixels does not error; it just fails to see things in shadow.
Step 6 is where most porting bugs live. The processor must place exactly as many placeholder tokens as the vision tower will produce embeddings for. Get it wrong and shapes mismatch — which, on this stack, sometimes means a broadcast crash and sometimes means silently misaligned attention.
Chunked prefill matters more for VLMs than for LLMs. The VLM prepare should merge
image+text embeddings and then loop in windowSize ?? 512 chunks with asyncEval(cache), ending
with one .logits(result). The source comment states the failure mode plainly: "Single-pass
prefill allocates transient buffers proportional to prompt length and causes OOM on long prompts."
✅ VERIFIED — pattern and comment from
Libraries/MLXVLM/README.md:273-305.
How processors get selected, because this trips people:
- The VLM factory reads
preprocessor_config.jsonin preference toprocessor_config.json. - The declared
processor_classis then overridden for two model types:
let processorTypeOverrides: [String: String] = [
"mistral3": "Mistral3Processor",
"gemma4_unified": "Gemma4UnifiedProcessor",
]
with the source comment: "Mistral3 models ship with 'PixtralProcessor' in their config but need Mistral3Processor to handle spatial merging correctly."
✅ VERIFIED —
Libraries/MLXVLM/VLMModelFactory.swift:419-476.
⚠️ The VLM factory is tried before the LLM factory by the free-function loading path, and failures are swallowed — only the last error propagates. If a load fails with a confusing message, that message may be from the wrong factory.
✅ VERIFIED —
ModelFactory.swift:413-497; the registry usesNSClassFromStringdynamic discovery with VLM first, LLM second. Corollary: if neitherMLXLLMnorMLXVLMis linked into your binary you getModelFactoryError.noModelFactoryAvailablebefore the downloader is ever reached, because module-init is what registers the factories.
Finally, two device-side realities from Apple's samples that no amount of library correctness will fix for you:
CIImage(contentsOf:)ignores EXIF orientation. A photo from the camera roll arrives rotated. Apple's fix re-renders pixels throughUIGraphicsImageRendererbefore writing the JPEG.PhotosPicker'sloadTransferable(type: Data.self)is unreliable. Declare explicitTransferablewrappers withDataRepresentation(importedContentType: .image)/FileRepresentation(importedContentType: .movie)and copy the file out ofreceivedFile.filebefore the picker's temporary URL goes away.fileImporterURLs needstartAccessingSecurityScopedResource()/stop….
✅ VERIFIED — all three from
mlx-swift-examplescommit378f244("fix VLM image handling on iOS (PhotosPicker, EXIF, empty assistant trim) (#472)") andChatViewModel.swift:158-188.
6. Tokenizers and chat templates¶
6.1 The protocol is nine methods, and it returns token IDs¶
In 3.x the tokenizer is yours. The package declares what it needs and nothing more:
public protocol Tokenizer: Sendable {
func encode(text: String, addSpecialTokens: Bool) -> [Int]
func decode(tokenIds: [Int], skipSpecialTokens: Bool) -> String
func convertTokenToId(_ token: String) -> Int?
func convertIdToToken(_ id: Int) -> String?
var bosToken: String? { get }
var eosToken: String? { get }
var unknownToken: String? { get }
func applyChatTemplate(
messages: [[String: any Sendable]],
tools: [[String: any Sendable]]?,
additionalContext: [String: any Sendable]?
) throws -> [Int]
}
✅ VERIFIED —
Libraries/MLXLMCommon/Tokenizer.swift:6-21.
Read the return type of applyChatTemplate: [Int], not String. Unlike Python's
tokenizer.apply_chat_template(..., tokenize=False), the Swift protocol has no un-tokenized
rendering mode. You cannot ask for the rendered prompt text. That is the single most important
fact in this section, because it means the obvious way to inspect a template does not exist and you
have to go around (§6.4).
Extension defaults:
✅ VERIFIED —
Tokenizer.swift:23-54. TheskipSpecialTokens: falsedefault is deliberate and documented againstReasoningConfig.isSpecialToken: it is why reasoning delimiters render as literal<think>/</think>text in your output rather than vanishing.
One typed error exists: TokenizerError.missingChatTemplate (Tokenizer.swift:56-65). The
MLXHuggingFace bridge translates Tokenizers.TokenizerError.missingChatTemplate into it.
6.2 How templates get resolved¶
There is no template resolution logic in MLXLMCommon at all. The chain is:
ModelConfiguration.tokenizerSource ──▶ a directory on disk
│
downloaded with: │ ["*.json", "*.jinja"]
▼
TokenizerLoader.load(from: directory) ──▶ any Tokenizer
│
(#huggingFaceTokenizerLoader() ⇒ Tokenizers.AutoTokenizer.from(modelFolder:))
▼
tokenizer.applyChatTemplate(messages:tools:additionalContext:)
✅ VERIFIED — download patterns from
Libraries/MLXLMCommon/ModelFactory.swift:5-7:Loader macro expansion frompackage let tokenizerDownloadPatterns = ["*.json", "*.jinja"] package let modelDownloadPatterns = ["*.safetensors"] + tokenizerDownloadPatternsMLXHuggingFaceMacros/HuggingFaceIntegrationMacros.swift.
Note *.jinja in the patterns: a repo may ship chat_template.jinja separately from
tokenizer_config.json, and the downloader fetches both.
The tokenizer can come from a different repo than the weights. TokenizerSource is:
public enum TokenizerSource: Sendable, Equatable {
case id(String, revision: String? = nil)
case directory(URL)
}
with nil meaning "same directory as the model." When .id is used, the tokenizer download runs
with tokenizerDownloadPatterns and a no-op progress handler — so a slow tokenizer fetch looks
like a hang in your progress UI.
✅ VERIFIED —
Downloader.swift:48-57,ModelFactory.swift:228-263.
6.3 Stop tokens: four sources, one of which overwrites the others¶
Where generation decides to stop is assembled from four places, and the assembly rules are not symmetric.
At load time (LLMModelFactory._load, Libraries/MLXLLM/LLMModelFactory.swift:569-669):
- Start with
baseConfig.effectiveEOSTokenIds— the rooteos_token_idfromconfig.json, ortext_config.eos_token_idif the root has none. - If
generation_config.jsonhas aneos_token_id, replace the set entirely: stopStrings.formUnion(generationConfig?.stopStrings ?? []).
At generation time (buildStopTokenIds, Evaluate.swift:1170-1185), the loop builds
stopTokenIds as the union of:
modelConfiguration.eosTokenIds ∪ tokenizer.eosTokenId ∪ extraEOSTokens mapped through
convertTokenToId.
✅ VERIFIED — both, from the cited files.
⚠️ Step 2 is a replace, not a union, and it is deliberate — it matches Python mlx-lm. So a
model whose config.json lists three EOS ids and whose generation_config.json lists one ends up
with one. If a model stops too late (rambling past its turn) or too early, this is the first
thing to check.
⚠️ stopStrings == nil falls back to extraEOSTokens. ModelConfiguration has:
public var stopStrings: Set<String>?
public var effectiveStopStrings: Set<String> { stopStrings ?? extraEOSTokens }
so to disable stop strings you must set stopStrings: [] explicitly — nil does not mean
"none," it means "use the EOS token strings." This is a genuinely surprising default and it is the
kind of thing that makes a model appear to truncate mid-sentence when its EOS string happens to
occur in normal prose.
✅ VERIFIED —
Libraries/MLXLMCommon/ModelConfiguration.swift:16-184.
Stop-string handling itself is careful: StopStringFilter buffers text, sorts stop strings
longest-first, emits text up to the earliest match, and holds back the longest partial suffix
that could still complete a stop string. That last clause is why you never see half a stop string
leak into your UI — and also why a very long stop string introduces latency at the tail of every
generation.
✅ VERIFIED —
Evaluate.swift:2219-2303.
The LLMRegistry presets encode per-family knowledge you would otherwise have to discover:
static public let gemma3_1B_qat_4bit = ModelConfiguration(
id: "mlx-community/gemma-3-1b-it-qat-4bit", …, extraEOSTokens: ["<end_of_turn>"])
static public let gemma4_e4b_it_4bit = ModelConfiguration(
id: "mlx-community/gemma-4-e4b-it-4bit", …, extraEOSTokens: ["<turn|>"])
static public let qwen3_4b_4bit = ModelConfiguration(
id: "mlx-community/Qwen3-4B-4bit", …, extraEOSTokens: ["<|im_end|>"])
static public let llama3_2_3B_4bit = ModelConfiguration(
id: "mlx-community/Llama-3.2-3B-Instruct-4bit", …, extraEOSTokens: ["<|eot_id|>"])
✅ VERIFIED —
Libraries/MLXLLM/LLMModelFactory.swift,LLMRegistrystatics.
⚠️ A model you load by bare id gets none of that. AbstractModelRegistry.configuration(id:)
returns a fresh ModelConfiguration(id:) for unknown ids — so unknown models "just work,"
with empty extraEOSTokens, no toolCallFormat, and no reasoningConfig. Use
contains(id:) to find out whether you got a curated preset or a blank one.
6.4 ⚠️ SILENT FAILURE #5 — the chat template is the contract, and nothing checks it¶
This is the one to internalise. A mismatched, missing, or partially-honoured chat template produces fluent, plausible, degraded output. Not gibberish. Not an error. Slightly worse instruction-following, slightly worse tool use, slightly more rambling — the kind of quality loss that gets attributed to "the model just isn't very good" and never gets debugged.
Four distinct ways it happens on the Swift side:
(A) No template at all. If the tokenizer has no chat template, applyChatTemplate throws
TokenizerError.missingChatTemplate — this one is loud, and it is the good case. But if you have
built a UserInput with .text(String) instead of .chat([...]), or if you are calling
tokenizer.encode(text:) yourself, no template is applied and nothing throws. You are sending
raw text to an instruct-tuned model. It answers. It answers worse.
(B) The system message is silently dropped. NoSystemMessageGenerator exists precisely because
some templates reject a system role — and the library selects it by probing and catching the
throw (§5.3). That is the right behaviour, but the consequence is that your carefully written
system prompt can be discarded with no diagnostic. The model still responds; it just was never
told who it is.
✅ VERIFIED —
Libraries/MLXLLM/Models/Llama.swift:186-202.
(C) additionalContext keys the template does not read. additionalContext is
[String: any Sendable] and is passed straight through to the template renderer. Jinja ignores
unknown variables. So additionalContext: ["enable_thinking": false] on a model whose template
never references enable_thinking does exactly nothing — no error, no warning, and reasoning stays
on. ReasoningPromptStrategy encodes which models actually honour it:
public enum ReasoningPromptStrategy: Sendable, Equatable {
case templateFlag(key: String, defaultOn: Bool) // e.g. Qwen3 "enable_thinking"
case alwaysOn // DeepSeek-R1
case none
public func additionalContext(forThinkingEnabled thinkingEnabled: Bool?) throws
-> [String: any Sendable]?
}
and inference is by model_type prefix — qwen3* ⇒ .templateFlag(key: "enable_thinking",
defaultOn: true); deepseek_v3 / deepseek_r1, or a repo id containing deepseek-r1 or
r1-distill, ⇒ .alwaysOn; everything else ⇒ nil. Note the ReasoningError:
.cannotDisableReasoning — for .alwaysOn models, asking to turn thinking off is a typed error
rather than a silent no-op. That is the right design, and it exists for exactly two families.
✅ VERIFIED —
Libraries/MLXLMCommon/ReasoningConfig.swift, inference rules at lines 130-164. The doc explains whymodelIdis load-bearing in the DeepSeek rule: R1-Distill checkpoints reportmodel_typeasqwen2orllama, so type alone cannot identify them.
(D) The tool-call format is inferred from model_type, and can be wrong or absent. Covered in
full in §7.5. The short version: ToolCallFormat.infer returns nil for most model types, the
generation loop then uses configuration.toolCallFormat ?? .json, and a model that emits a
non-JSON format gets its tool calls left in the prose as text with stopReason == .stop and
toolCalls == [].
6.5 The render-and-eyeball recipe¶
Because applyChatTemplate returns [Int], you cannot print the rendered prompt directly. You
decode it back — and crucially you decode with skipSpecialTokens: false, which is the default,
so the special tokens are visible. That round trip is the verification.
// Sources/TemplateCheck/main.swift
import Foundation
import HuggingFace
import MLXHuggingFace
import MLXLLM
import MLXLMCommon
import Tokenizers
let container = try await #huggingFaceLoadModelContainer(
configuration: LLMRegistry.qwen3_4b_4bit)
// A probe conversation containing every element whose survival you care about.
let probe: [Chat.Message] = [
.system("SYSTEM_SENTINEL"),
.user("USER_SENTINEL"),
.assistant("ASSISTANT_SENTINEL"),
.user("SECOND_USER_SENTINEL"),
]
let probeTool: ToolSpec = [
"type": "function",
"function": [
"name": "TOOL_SENTINEL",
"description": "a probe tool",
"parameters": [
"type": "object",
"properties": ["x": ["type": "string", "description": "probe"]],
"required": ["x"],
] as [String: any Sendable],
] as [String: any Sendable],
]
await container.perform { context in
let generator = DefaultMessageGenerator()
let messages = generator.generate(messages: probe)
do {
let ids = try context.tokenizer.applyChatTemplate(
messages: messages,
tools: [probeTool],
additionalContext: ["enable_thinking": false])
// skipSpecialTokens defaults to FALSE — that is what makes this useful.
let rendered = context.tokenizer.decode(tokenIds: ids)
print("──── rendered prompt (\(ids.count) tokens) ────")
print(rendered)
print("──── checks ────")
for sentinel in ["SYSTEM_SENTINEL", "USER_SENTINEL", "ASSISTANT_SENTINEL",
"SECOND_USER_SENTINEL", "TOOL_SENTINEL"] {
print(rendered.contains(sentinel) ? " ✓ \(sentinel)" : " ✗ \(sentinel) ← DROPPED")
}
print(" thinking markers present: \(rendered.contains("<think>"))")
} catch let error as TokenizerError {
print("template error: \(error)") // .missingChatTemplate is the loud, good case
} catch {
print("unexpected: \(error)")
}
print("──── configuration ────")
print(" eosTokenIds : \(context.configuration.eosTokenIds)")
print(" extraEOSTokens : \(context.configuration.extraEOSTokens)")
print(" effectiveStopStrings: \(context.configuration.effectiveStopStrings)")
print(" toolCallFormat : \(String(describing: context.configuration.toolCallFormat))")
print(" reasoningConfig : \(String(describing: context.configuration.reasoningConfig))")
}
How to read the output:
| Symptom | Meaning | Fix |
|---|---|---|
✗ SYSTEM_SENTINEL |
template rejects or ignores a system role | fold your instructions into the first user message |
✗ TOOL_SENTINEL |
the template does not render tools | tool calling will not work; §7.8 |
<think> present when you asked for enable_thinking: false |
the template ignores that key | check reasoningConfig; the key may be named differently or the model may be .alwaysOn |
toolCallFormat: nil |
inference found nothing; the loop will assume .json |
set toolCallFormat: explicitly; §7.5 |
effectiveStopStrings unexpectedly non-empty |
stopStrings is nil, so it fell back to extraEOSTokens |
set stopStrings: [] to disable |
The whole thing throws .missingChatTemplate |
the tokenizer directory has no template | this is the good failure; supply one |
Run this once per model you ship. It costs one launch and it catches every variant of §6.4.
This recipe is composed from verified APIs (
applyChatTemplate,decode(tokenIds:),DefaultMessageGenerator,ModelConfigurationfields) rather than copied from a shipping source file. The APIs are ✅ VERIFIED as cited above; the assembly is this guide's.
6.6 Streaming detokenization¶
The last piece: turning token IDs back into text you can append to a label, incrementally.
The shipped NaiveStreamingDetokenizer:
- re-decodes the whole current segment each step (so it is O(T²) within a segment),
- returns
nilwhile a partial UTF-8 sequence is pending — detected bynew.last == "\u{fffd}", - restarts the segment on
"\n", which is what bounds the quadratic cost in practice.
✅ VERIFIED —
Tokenizer.swift:67-114.
The nil-while-pending behaviour is why you must never assume one token yields one chunk. A CJK
character, an emoji, or any multi-byte grapheme spans several tokens and the detokenizer correctly
emits nothing until it is complete. A UI that counts .chunk events as a progress signal will
appear to stall on non-Latin text. Count tokens (via generateTokens) if you need a progress
signal; count chunks only for display.
Python's equivalent has three implementations — NaiveStreamingDetokenizer,
SPMStreamingDetokenizer, BPEStreamingDetokenizer — selected by inspecting tokenizer.json's
decoder field. Swift ships only the naive one. For very long single-line outputs (a
minified JSON blob, say) the Swift path does measurably more work per token than Python's. No
number is published for the gap; if it matters to you, measure it.
🔴 GAP — the cost of Swift's naive-only detokenization is unquantified. What is unknown: how much decode throughput
NaiveStreamingDetokenizercosts versus a linear SPM/BPE detokenizer, on a long newline-free generation. What would resolve it: a benchmark generating ~4k tokens of newline-free output withgenerateTokens(raw ids, no detokenizer) versusgenerate(detokenized), same seed, same machine. Safe default: if you are generating long structured output with no newlines and decode looks slower than it should, usegenerateTokensand detokenize in batches yourself.
7. Tool calling: ten formats and why¶
7.1 Declaring a tool¶
public typealias ToolSpec = [String: any Sendable]
public protocol ToolProtocol: Sendable {
var schema: ToolSpec { get }
}
public struct Tool<Input: Codable, Output: Codable>: ToolProtocol {
public let schema: ToolSpec
public let handler: @Sendable (Input) async throws -> Output
public var name: String // reads schema["function"]["name"]
public init(name: String, description: String, parameters: [ToolParameter],
handler: @Sendable @escaping (Input) async throws -> Output)
public init(schema: ToolSpec, handler: @Sendable @escaping (Input) async throws -> Output)
}
✅ VERIFIED —
Libraries/MLXLMCommon/Tool/Tool.swift.
The synthesized schema is OpenAI-shaped, regardless of which of the ten wire formats the model will use to call it:
{"type":"function",
"function":{"name":"…","description":"…",
"parameters":{"type":"object","properties":{…},"required":[…]}}}
That separation is the design: one schema language in, ten serialisations out. You describe the tool once; the format machinery deals with how a given family spells the invocation.
Parameters:
public indirect enum ToolParameterType {
case string, bool, int, double
case array(elementType: ToolParameterType)
case object(properties: [ToolParameter])
case data // {"type":"string","contentEncoding":"base64"}
}
public static func required(_ name: String, type: ToolParameterType, description: String,
extraProperties: [String: any Sendable] = [:]) -> ToolParameter
public static func optional(_ name: String, type: ToolParameterType, description: String,
extraProperties: [String: any Sendable] = [:]) -> ToolParameter
✅ VERIFIED —
Tool/ToolParameter.swift; schema output verified againstTests/MLXLMTests/ToolTests.swift:41-90, which confirms that.optional("unit", …, extraProperties: ["enum": ["celsius","fahrenheit"]])mergesenuminto that property's schema.
extraProperties is the escape hatch for everything JSON Schema supports that the enum does not —
enum, default, minimum, pattern. Apple's LLMEval sample uses it exactly that way:
let currentWeatherTool = Tool<WeatherInput, WeatherOutput>(
name: "get_current_weather",
description: "Get the current weather in a given location",
parameters: [
.required("location", type: .string,
description: "The city and state, e.g. San Francisco, CA"),
.optional("unit", type: .string, description: "The unit of temperature",
extraProperties: ["enum": ["celsius", "fahrenheit"], "default": "celsius"]),
]
) { input in
let range = input.unit == "celsius" ? (min: -20.0, max: 40.0) : (min: 0, max: 100)
let temperature = Double.random(in: range.min ... range.max)
let conditions = ["Sunny", "Cloudy", "Rainy", "Snowy", "Windy", "Stormy"].randomElement()!
return WeatherOutput(temperature: temperature, conditions: conditions)
}
✅ VERIFIED —
mlx-swift-examples/Applications/LLMEval/Services/ToolExecutor.swift, verbatim. Input/output types are plainCodablestructs:struct WeatherInput: Codable { let location: String; let unit: String? },struct EmptyInput: Codable {}.
7.2 ToolCall, and executing one¶
public struct ToolCall: Hashable, Codable, Sendable {
public struct Function: Hashable, Codable, Sendable {
public let name: String
public let arguments: [String: JSONValue]
public init(name: String, arguments: [String: JSONValue])
public init(name: String, arguments: [String: any Sendable])
}
public let function: Function
public let id: String?
public init(function: Function, id: String? = nil)
public func execute<Input, Output>(with tool: Tool<Input, Output>) async throws -> Output
}
public enum ToolError: Error, LocalizedError {
case nameMismatch(toolName: String, functionName: String)
}
✅ VERIFIED —
Tool/ToolCall.swift.
execute(with:) JSON-encodes the arguments and does JSONDecoder().decode(Input.self, …), so
your Input type is the schema validator. A model that hallucinates an argument name gets a
DecodingError, which is exactly where you want that failure. It throws
ToolError.nameMismatch if you hand a call to the wrong tool.
Feeding results back: Encodable.toolResult (in Extensions/Encodable+toolResult.swift) encodes
any Encodable to a snake_case JSON string, returning "{}" on failure.
⚠️ "{}" on failure is a silent degradation. If your Output type fails to encode, the model
receives an empty object and cheerfully invents an answer. It is a small surface — plain Codable
structs rarely fail to encode — but if you put a non-encodable type in an Output, this is where
it goes quiet.
Tool-call IDs are generated by the format:
.mistral ⇒ the first 9 characters of a UUID with dashes stripped; everything else ⇒
"call_" + lowercased uuid. Mistral's template requires exactly-9-character alphanumeric ids, so
this is not arbitrary.
✅ VERIFIED —
ToolCallFormat.generateToolCallID().
7.3 The ten formats¶
Here is the complete enumeration. ToolCallFormat is
public enum ToolCallFormat: String, Sendable, Codable, CaseIterable — so you can iterate
ToolCallFormat.allCases and you can serialise it into a config file by its raw value.
✅ VERIFIED —
Libraries/MLXLMCommon/Tool/ToolCallFormat.swift:64-103. Wire examples are quoted from the doc comments on each case.
| # | Case | Raw value | Wire format | Parser |
|---|---|---|---|---|
| 1 | .json |
json |
<tool_call>{"name":…,"arguments":{…}}</tool_call> |
JSONToolCallParser(startTag: "<tool_call>", endTag: "</tool_call>") |
| 2 | .lfm2 |
lfm2 |
<\|tool_call_start\|>[func(arg='value')]<\|tool_call_end\|> |
PythonicToolCallParser |
| 3 | .xmlFunction |
xml_function |
<tool_call><function=name><parameter=key>value</parameter></function></tool_call> |
XMLFunctionParser |
| 4 | .glm4 |
glm4 |
func<arg_key>k</arg_key><arg_value>v</arg_value> |
GLM4ToolCallParser |
| 5 | .gemma |
gemma |
<start_function_call>call:name{key:value,k:<escape>str<escape>}<end_function_call> |
GemmaFunctionParser(escapeMarker: "<escape>") |
| 6 | .gemma4 |
gemma4 |
<\|tool_call>call:name{key:<\|"\|>value<\|"\|>}<tool_call\|> |
GemmaFunctionParser(escapeMarker: "<\|\"\|>") |
| 7 | .kimiK2 |
kimi_k2 |
functions.name:0<\|tool_call_argument_begin\|>{"key":"value"} |
KimiK2ToolCallParser |
| 8 | .minimaxM2 |
minimax_m2 |
<invoke name="f"><parameter name="k">v</parameter></invoke> |
MiniMaxM2ToolCallParser |
| 9 | .mistral |
mistral |
[TOOL_CALLS]get_weather [ARGS]{"location": "Tokyo"} |
MistralToolCallParser |
| 10 | .llama3 |
llama3 |
<\|python_tag\|>{ "name": …, "parameters": {…} } |
Llama3ToolCallParser |
Ten cases, eight distinct parser classes (GemmaFunctionParser serves cases 5 and 6 with different
escape markers).
The parser protocol:
public protocol ToolCallParser: Sendable {
var startTag: String? { get } // nil for inline formats
var endTag: String? { get }
func parse(content: String, tools: [[String: any Sendable]]?) -> ToolCall?
func parseEOS(_ toolCallBuffer: String, tools: [[String: any Sendable]]?) -> [ToolCall]
}
✅ VERIFIED —
Tool/ToolCallFormat.swift.
parseEOS exists because not every format has an end tag that arrives as text. Mistral's
[TOOL_CALLS] block is terminated by </s>, which is intercepted at the token-ID level and
never reaches the text stream. So the processor holds a buffer and, at end-of-stream, hands it to
parseEOS. Same for LFM2's pythonic form, which is closed by bracket balance rather than a tag.
7.4 Why there are ten, and why there will be more¶
It is tempting to read that table as chaos. It is not; it is a faithful record of an ecosystem fact, and the fact is worth stating explicitly because it drives every design decision downstream:
Every model family invented its own tool-call serialisation, independently, at roughly the same time, and none of them is going away.
The reasons are structural, not accidental:
- Tool calls are emitted by the decoder, so they must be tokenizer-friendly. A format built
from tokens the model already has (
<|python_tag|>,[TOOL_CALLS]) trains better than one that fragments into a dozen subword pieces. Every family's vocabulary is different, so every family's cheapest delimiter is different. - The format is baked into the fine-tune. By the time a checkpoint is published, the format is a property of the weights. You cannot ask Mistral to emit Qwen's format any more than you can ask it to speak a different language; it would be out of distribution.
- Some formats are not JSON on purpose. LFM2's pythonic
[func(arg='value')]and Gemma'scall:name{key:value}are shorter in tokens than the equivalent JSON, which matters when your tool-calling budget is competing with your answer budget in a 4k context. GLM4's<arg_key>/<arg_value>pairs avoid JSON escaping entirely. - Nested structure is hard.
xmlFunctionandminimaxM2use XML-ish nesting because a model emitting a deeply nested JSON object token-by-token has many more ways to produce something unparseable. Tag-delimited formats fail more gracefully.
The consequence for your code is the good news: you write Tool<Input, Output> once, and the
library's job is to know which of ten dialects the loaded model speaks. The consequence for your
testing is the bad news: a tool-calling app that works perfectly on Qwen3 can fail completely on
Gemma 4 with no error, because the format machinery silently guessed wrong (§7.5).
The Python side has the same problem and solves it differently, which is worth knowing if you work in both:
| MLX Swift | mlx-lm (Python) | |
|---|---|---|
| Format identity | a ToolCallFormat enum case |
a module in mlx_lm/tool_parsers/ |
| Count | 10 enum cases, 8 parser types | 10 parser modules |
| Detection input | model_type from config.json (+ vocab_size, rope_scaling for llama) |
substring matching on the chat-template text |
| Explicit override | ModelConfiguration.toolCallFormat |
tool_parser_type key in tokenizer_config.json |
| Streaming | ToolCallProcessor, a 4-state machine |
TextStateMachine + Aho–Corasick trie |
✅ VERIFIED — Swift from
Tool/ToolCallFormat.swift; Python frommlx_lm/tool_parsers/andmlx_lm/tokenizer_utils.py's_infer_tool_parser(chat_template).
The two detection strategies fail differently, and that difference is instructive. Python reads
the template — the thing that actually determines what the model will emit — so it is right more
often, but it is order-sensitive: _infer_tool_parser tests literal substrings in a fixed order,
and a template containing both <tool_call> and <function= gets qwen3_coder, not json_tools.
Swift reads model_type — a coarser signal, but a stable one that does not change when someone
edits a template. Neither is obviously better. Both can be overridden, and on both sides you
should override explicitly for anything you ship.
The two parser sets are close but not identical. Swift has .llama3 (the <|python_tag|> form);
Python's list as read does not name a llama3 module. Python has function_gemma, longcat,
qwen3_coder and glm47 as separate modules where Swift folds the corresponding shapes into
.gemma, .xmlFunction and .glm4. Do not assume a model that parses in one language parses in
the other.
7.5 Detection: ToolCallFormat.infer, rule by rule¶
The rules, in evaluation order:
Test on model_type |
Result | Note |
|---|---|---|
"llama" and (vocab_size >= 128000 or rope_scaling.rope_type == "llama3") |
.llama3 |
needs a secondary signal from configData |
"llama" without that signal |
nil |
Llama 2-era checkpoints have no tool format |
prefix lfm2 |
.lfm2 |
|
prefix glm4 |
.glm4 |
|
prefix gemma4 |
.gemma4 |
|
exactly gemma |
.gemma |
⚠️ exact equality, not prefix — see below |
prefix nemotron |
.xmlFunction |
|
prefix qwen3_5 |
.xmlFunction |
|
prefix qwen3_next |
.xmlFunction |
|
prefix mistral3 |
.mistral |
|
| anything else | nil |
✅ VERIFIED —
Tool/ToolCallFormat.swift,infer(from:configData:).
And then the load path does this:
toolCallFormat ← ToolCallFormat.infer(from: baseConfig.modelType, configData: configData)
// …only if configuration.toolCallFormat was not preset
and the generation loop does this:
✅ VERIFIED —
Libraries/MLXLLM/LLMModelFactory.swift:569-669(step 6) and theTextToolTokenLoopHandlerconstruction inEvaluate.swift.
⚠️ SILENT FAILURE #6 — nil becomes .json, and a non-JSON model's tool calls end up in your
prose. Read the two lines above together. Inference returns nil for most model types —
plain qwen2, qwen3, phi3, granite, olmo3, smollm3, everything not in the table. The
loop then parses with .json, looking for <tool_call>…</tool_call>. A model that emits some
other shape produces:
stopReason == .stop(normal termination),toolCalls == [],- and the tool-call text intact, inline, in the
.chunkstream — so it appears in your UI as the assistant reciting a function call at the user.
On affected releases there is no error or warning; the tool loop simply never fires.
✅ VERIFIED — this exact failure is documented end-to-end in
mlx-swift-lm#259for Gemma 4: "Net:stopReason == .stop,toolCalls == [], tool-call text intact in the prose."
⚠️ Older releases used exact equality for the gemma row where other families used
hasPrefix. Issue mlx-swift-lm#259 identified this as one of two root causes for Gemma 4 tool
calls not being extracted, because Gemma 4's model_type is "gemma4", not "gemma".
✅ FIXED — issue
mlx-swift-lm#259closed completed on 2026-09-14. PR #183 had already added the.gemma4inference case and current tags on 2026-05-22. PR #548 added bounded cross-dialect recovery and merged immediately before the issue closed. The source at3cbf928already showed the direct.gemma4path, but the later recovery fix makes the closure broader than the July source inspection. Keep the §7.8 check for apps pinned to an older release. Retain it as a regression test when changing templates or parsers.
The registry presets set the format explicitly where inference cannot:
static public let glm4_9b_4bit = ModelConfiguration(
id: "mlx-community/GLM-4-9B-0414-4bit", …, toolCallFormat: .glm4)
static public let lfm2_1_2b_4bit = ModelConfiguration(
id: "mlx-community/LFM2-1.2B-4bit", …, toolCallFormat: .lfm2)
static public let lfm2_8b_a1b_3bit_mlx = ModelConfiguration(
id: "mlx-community/LFM2-8B-A1B-3bit-MLX", defaultPrompt: "", toolCallFormat: .lfm2)
✅ VERIFIED —
LLMRegistrystatics inLibraries/MLXLLM/LLMModelFactory.swift.
Which is the actionable point: if you load a model by bare Hugging Face id rather than a
registry preset, you get toolCallFormat: nil unless infer happens to know your model_type.
7.6 ToolCallProcessor — the streaming state machine¶
public class ToolCallProcessor {
public enum Output: Sendable, Equatable {
case response(String)
case toolCall(ToolCall)
}
public var toolCalls: [ToolCall] = []
public init(format: ToolCallFormat = .json, tools: [[String: any Sendable]]? = nil)
public func processChunk(_ chunk: String) -> String? // displayable text, or nil
public func processChunkOutputs(_ chunk: String) -> [Output] // ordered API
public func drainToolCalls() -> [ToolCall]
public func processEOS()
public func processEOS(returnBufferedText: Bool = true) -> String?
public func processEOSOutputs() -> [Output]
}
✅ VERIFIED —
Libraries/MLXLMCommon/Tool/ToolCallProcessor.swift(856 lines; public surface plus the first 140 lines read).
Internals, from doc comments and symbol names: a four-state machine (normal,
potentialToolCall, collectingToolCall, collectingJSONToolCall), a bare-JSON fallback
enabled only for .json (supportsBareJSONFallback = format == .json, with
maxJSONFallbackBufferLength = 32_768), de-duplication by emittedToolCallIDs, and per-format
EOS handling for Mistral and LFM2.
🟡 RECONSTRUCTED — the state names, the fallback constants and the dedupe mechanism are read from symbol names and doc comments;
ToolCallProcessor.swiftlines 140-856 were not read line by line. Treat the existence of these mechanisms as verified and their exact heuristics as unverified. See §11.3.
⚠️ SILENT FAILURE #7 — mixing the two APIs on one processor instance. The source carries an
explicit warning: "Do not mix this API with processChunk, processEOS, or drainToolCalls() on
the same processor instance."
The two APIs exist because the older one loses ordering. processChunk returns displayable
text and accumulates tool calls in a separate array, so if a model emits
text → toolcall → text → toolcall, you get all the text in one stream and all the calls in
another, with no way to reconstruct the interleaving. processChunkOutputs returns
[Output] in the order the model produced them. That ordered API arrived with PR #456
(merged 2026-07-23), which also added quote-aware JSON scanning (so {"path": "a}b"} no longer
terminates early) and Qwen redundant-brace recovery for {{ …valid call… }}.
✅ VERIFIED — source warning verbatim; PR #456 contents from the issue-mining pass.
Use processChunkOutputs / processEOSOutputs for anything new. The only reason to touch the
older pair is compatibility with existing code.
7.7 The end-to-end loop, both ways¶
Way one — toolDispatch, the automatic loop. ChatSession runs the whole cycle:
struct EmptyInput: Codable {}
struct TimeOutput: Codable { let time: String }
let timeTool = Tool<EmptyInput, TimeOutput>(
name: "get_time",
description: "Get the current date and time including day of week.",
parameters: []
) { _ in TimeOutput(time: "Wed Feb 18 17:50:43 PST 2026") }
let session = ChatSession(
container, generateParameters: generateParameters,
tools: [timeTool.schema]
) { toolCall in
if toolCall.function.name == timeTool.name {
return try await toolCall.execute(with: timeTool).toolResult
}
return "Unknown tool: \(toolCall.function.name)"
}
for try await chunk in session.streamResponse(to: "What day of week is it?") {
print(chunk, terminator: "")
}
✅ VERIFIED —
Libraries/IntegrationTestHelpers/IntegrationTestHelpers.swift:260-290, verbatim. This is code the maintainers run in CI.
What ChatSession does under the hood on a tool call, and the ordering matters:
- Appends
.assistant("", toolCalls: pendingToolCalls)first, - then one
.tool(result, id: toolCall.id)per call, - then
continue restart— regenerating from the top of the loop.
✅ VERIFIED —
ChatSession.swift:574-836. Commit19de279explains why step 1 is not optional: Gemma 4's chat template forward-scans from the assistanttool_callsmessage. Omit it and the template cannot associate results with calls.
Way two — manual, when you need to see and approve calls. No toolDispatch; you drive:
let session = ChatSession(container, generateParameters: generateParameters,
tools: [weatherToolSchema])
var toolCalls: [ToolCall] = []
var responseText = ""
var info: GenerateCompletionInfo?
for try await generation in session.streamDetails(
to: "What is the weather in San Francisco?", images: [], videos: []
) {
switch generation {
case .chunk(let text): responseText += text
case .toolCall(let call): toolCalls.append(call)
case .info(let completion): info = completion
}
}
if !toolCalls.isEmpty {
// Show the user what the model wants to do; get consent; execute; then:
_ = try await session.respond(
to: [.tool("Foggy with a high in the low 60s, clearing later in the day")])
}
✅ VERIFIED —
IntegrationTestHelpers.swift:209-257, verbatim.
The manual form is what you want for anything with side effects — deleting a file, sending a
message, spending money. It is the same "tool as consent request" pattern this series records from
Apple's Foundation Models sample code (MovePhotoToStepTool → Yes/No UI → synthesized follow-up
turn); the shape transfers directly.
Dispatching by name across several tools, from Apple's sample:
func execute(_ toolCall: ToolCall) async throws -> String {
switch toolCall.function.name {
case currentWeatherTool.name:
return try await toolCall.execute(with: currentWeatherTool).toolResult
case addTool.name:
return try await toolCall.execute(with: addTool).toolResult
case timeTool.name:
return try await toolCall.execute(with: timeTool).toolResult
default:
return "Unknown tool: \(toolCall.function.name)"
}
}
✅ VERIFIED —
mlx-swift-examples/Applications/LLMEval/Services/ToolExecutor.swift, verbatim.
⚠️ Returning a string for an unknown tool, rather than throwing, is the right default. A model
that hallucinates a tool name gets told so and can recover. A throw ends the turn. Note this is the
opposite of the Foundation Models framework's documented failure mode, where a tool named in your
instructions but absent from the toolset produces an infinite loop and no error — here, the
default: case is your loop breaker. Keep it.
7.8 When your model's format is not one of the ten¶
This is the case the brief for this guide asked to be answered directly, because it is common and the library does not tell you it has happened.
Step 1 — find out what you actually got. Two lines, run once per model:
await container.perform { context in
print("toolCallFormat: \(String(describing: context.configuration.toolCallFormat))")
print("model_type : \(context.configuration.name)")
}
If it prints nil, the loop is going to use .json.
Step 2 — find out what the model actually emits. Generate with tools attached and print the raw text, not the parsed calls:
let session = ChatSession(container, generateParameters: .init(maxTokens: 256, temperature: 0),
tools: [myTool.schema])
var raw = ""
for try await g in session.streamDetails(to: "What's the weather in Tokyo?") {
if let c = g.chunk { raw += c }
if let t = g.toolCall { print("PARSED: \(t)") }
}
print("RAW OUTPUT:\n\(raw)")
If PARSED: never prints and RAW OUTPUT contains something that looks like a function call, you
have found your problem and you can read the format off the output.
Step 3 — pick one of four remedies, in order of preference.
(a) The format is one of the ten and detection just missed it. Set it explicitly. This is by far the most common case:
var config = LLMRegistry.shared.configuration(id: "org/SomeModel-4bit")
config.toolCallFormat = .xmlFunction // whichever the raw output matches
ModelConfiguration.toolCallFormat is a var and the load path only infers if it was not
preset. Because ToolCallFormat is Codable with stable raw values, you can also ship this as
app configuration rather than code.
(b) The format is close to .json but not tag-delimited. The .json parser has a bare-JSON
fallback (supportsBareJSONFallback = format == .json, buffer limit 32,768 characters), so a
model that emits a naked {"name": …, "arguments": {…}} with no <tool_call> wrapper is already
handled. Try .json before writing anything.
(c) Write a ToolCallParser. The protocol is four members (§7.3) and you already know the two
hard parts from the shipped parsers: startTag/endTag may be nil for inline formats, and
parseEOS must handle the case where your end marker is a special token that never appears as
text. Construct a ToolCallProcessor with it… and here you hit a limit:
🔴 GAP — there is no verified public API for registering a custom
ToolCallParserwith the generation loop.ToolCallProcessor.init(format:tools:)takes aToolCallFormatenum case, not a parser instance, andToolCallFormatis a closedString-backed enum you cannot extend. What is unknown: whether some other initializer or registry accepts a parser directly — the research pass readToolCallProcessor.swiftlines 1-140 plus a full public-symbol grep and did not find one. What would resolve it:grep -n "ToolCallParser" Libraries/MLXLMCommon/Tool/*.swiftagainst your checkout, plus readingToolCallProcessor's remaining initializers. Safe default until you have checked: do not route a custom parser through the generation loop. Use remedy (d).
(d) Parse it yourself, outside the loop. This always works and is not much code:
// Generate with tools in the prompt but let the loop treat everything as text.
// Then parse the raw output with your own logic.
let session = ChatSession(container, generateParameters: params, tools: [myTool.schema])
var raw = ""
for try await g in session.streamDetails(to: prompt) {
if let c = g.chunk { raw += c }
}
if let call = myCustomParse(raw) { // your parser
let result = try await call.execute(with: myTool).toolResult
_ = try await session.respond(to: [.tool(result, id: call.id)])
}
You lose streaming-time detection (you see the call only at end of turn) and you must supply
Chat.Message.assistant("", toolCalls: [call]) yourself if your model's template needs it (§7.7,
step 1 — Gemma 4 does). In exchange you own the parse completely.
(e) Change models. Not a joke. If tool calling is central to your feature, a model whose format
is on the supported list and whose registry preset sets toolCallFormat explicitly — LFM2, GLM-4,
Mistral 3, Nemotron, Qwen3.5 — will cost you far less than maintaining a parser.
7.9 What leaks in from the Foundation Models bridge¶
If you are also driving this model through LanguageModelSession via MLXFoundationModels, three
tool-calling defects are worth knowing because they are not in the layers above:
- Nested
@Generabletypes in tool arguments used to hard-fail. AnyToolwhoseArgumentscontained a nested@Generabletype failed at the first tool-calling turn withconstraintCompilationFailed("… json_schema_converter.cc:957: Check failed: … Cannot find field $defs …"). Root cause:GenerationSchemaemits$defsplus a root-anchored"$ref": "#/$defs/Traveler", the envelope buries the$defsinsidearguments, and xgrammar resolves JSON Pointers from the document root → dangling ref. "Flat tools (only primitive fields) work, which is presumably why this hasn't surfaced — demo-sized tools don't produce$defs." Fixed inml-explore/mlx-swift-lm#434(merged 2026-07-22). .toolCallingon a VLM-loaded model was a process-killing abort —Fatal error: SmallVector out of rangeatmlx-c/mlx/c/array.cpp:335. The tool-calling path hand-builtLMInput(tokens: MLXArray(toolAwareTokens)), a 1-D[N]array; every VLMprepareindexesdim(1). A second defect in the same path silently dropped image content from tool-calling prompts. Fixed in PR #435 (merged 2026-07-17) by routing throughcontext.processor.prepare(UserInput(chat:tools:additionalContext:)).- Before PR #456 (merged 2026-07-23) a session could issue at most one tool call. Multi-round
replay,
ToolCallingModeResolution(automatic / required / disallowed), and ordered streaming at parse boundaries all arrived in that ~3,200-line PR.
PRs #434, #435, #439, #453, #455, #456, and #464 are ancestors of the 3.32.3 release revision 3b339ad6. Their source changes are available in that release; this does not attest local runtime remediation. Keep compatibility workarounds for older pins and reproduce the affected path before removing one.
8. KV cache: eight types, one contract¶
This is the section everything else depends on. Every performance question, every "why does speculative decoding refuse to run", and both of §9's bugs trace back to which cache class got constructed and what contract it honours.
8.1 The protocol¶
public enum RoPEOffset { case scalar(Int); case batch(MLXArray) }
public protocol KVCache: Evaluatable {
var offset: Int { get }
var ropeOffset: RoPEOffset { get } // default .scalar(offset)
var maxSize: Int? { get }
func update(keys: MLXArray, values: MLXArray) -> (MLXArray, MLXArray)
var state: [MLXArray] { get set }
var metaState: [String] { get set }
var isTrimmable: Bool { get }
@discardableResult func trim(_ n: Int) -> Int
func makeMask(n: Int, windowSize: Int?, returnArray: Bool)
-> MLXFast.ScaledDotProductAttentionMaskMode
func copy() -> any KVCache
func prepare(lengths: [Int]?)
func prepare(lengths: MLXArray?)
func finalize()
}
public func withPreparedCache<Result>(_ cache: [any KVCache], lengths: [Int]?,
_ body: () throws -> Result) rethrows -> Result
✅ VERIFIED —
Libraries/MLXLMCommon/KVCache.swift(2,110 lines).
KVCache is a protocol whose conformers are classes — open class BaseKVCache: KVCache is the
shared base. That is a deliberate choice with a real cost, and §9.1 is that cost coming due: a
[KVCache] array is a value, and its elements are references. Replacing an element does not
mutate the object; mutating the object does not replace the element. Both operations exist and
they mean different things.
⚠️ open var ropeOffset is declared on the class, not only on the protocol extension — commit
616cae2. This looks like style; it is a correctness fix. If it were declared only in a protocol
extension, a subclass override would be statically shadowed and silently ignored when accessed
through a KVCache existential. That is a Swift-specific footgun with no Python analogue, and it
is worth remembering when you subclass anything in this file.
8.2 The eight concrete caches, mapped to Python¶
| # | Swift class | Python counterpart | What it is for | Trimmable? |
|---|---|---|---|---|
| 1 | KVCacheSimple (alias StandardKVCache) |
KVCache |
the default. Growable buffer, public var step = 256 |
✅ |
| 2 | RotatingKVCache |
RotatingKVCache |
ring buffer; init(maxSize:keep:step:), keep default 0, step default 256 |
only while offset < maxSize |
| 3 | QuantizedKVCache |
QuantizedKVCache |
packed data + scales + biases; init(groupSize: 64, bits: 8, mode: .affine) |
✅ |
| 4 | ChunkedKVCache: KVCacheSimple |
ChunkedKVCache |
init(chunkSize:), maybeTrimFront() |
✅ |
| 5 | ArraysCache |
ArraysCache |
generic slot list for SSM / linear-attention state; init(size:leftPadding:) |
❌ |
| 6 | MambaCache: ArraysCache |
(folded into ArraysCache in Python) |
init(leftPadding:) |
❌ |
| 7 | CacheList |
CacheList |
composite for hybrid stacks; init(_ caches: KVCache...) |
all(...) |
| 8 | TurboQuantKVCache |
none | Swift-only asymmetric key/value quantization; TurboQuantKVCache.swift (1,765 lines) + TurboQuantKernels.swift (2,367 lines) |
🔴 unverified |
✅ VERIFIED — Swift class list from
Libraries/MLXLMCommon/KVCache.swift. The count of eight is independently corroborated by the serialization class-name table (§8.6), which writes exactly eight names:"KVCache"(forKVCacheSimple),"RotatingKVCache","QuantizedKVCache","ChunkedKVCache","ArraysCache","MambaCache","CacheList","TurboQuantKVCache". Python counterparts frommlx_lm/models/cache.pyas documented in Part 12 guide 04 §4.1.
Three Python classes have no Swift counterpart, and their absence is load-bearing:
| Python class | Swift | Consequence |
|---|---|---|
ConcatenateKVCache |
— | no naive concatenate cache to trip over; Swift's simplest cache already preallocates in 256-token blocks |
BatchKVCache |
— | no batched generation in MLX Swift |
BatchRotatingKVCache |
— | same |
There is no batch_generate in MLX Swift. Python's continuous-batching server story
(BatchGenerator, left-padded decode, right-padded prefill) has no Swift equivalent at all. If you
need to serve many concurrent conversations from one Swift process, your options are: many
ChatSessions against one ModelContainer (which works — the container permits concurrent access
to already-evaluated weights and serialises only prefill), or mlx_lm.server in a sidecar. The
first is the supported path and it is not the same thing as batching: you get concurrency, not
throughput amplification, because each sequence still does its own decode step.
✅ VERIFIED — absence confirmed by the class inventory and by the concurrency design comment at
ModelContainer.swift:191-197. TheSerialAccessContainerrationale (SerialAccessContainer.swift:39-43) says it plainly: "Unlike anactor, this will guarantee exclusive access for the duration of the async call. This is important for things likeModelContainerthat have to perform async work but also need to prevent other callers for using any of the internal state."
Conversely, TurboQuantKVCache has no Python counterpart — it is a Swift-side addition, and a
substantial one (4,132 lines including its Metal kernels). §8.5 covers what it buys. Note the
maintainer's own position on it, which is worth reading before you build on it:
✅ VERIFIED —
davidkoskionmlx-swift-lm#294: "Interested, but see also: mlx-swift#405, mlx-swift-lm#287, #232, #160. I have a bit of a backlog I am working through. I don't know exactly how these turboquant PRs will land … ultimately I will have to select one of these to merge and it may be the one that looks the best or the one that is ready."
QuantizedKVCacheProtocol: KVCache adds groupSize, bits, mode, updateQuantized(keys:values:)
and getQuantizedState() — that is the interface attentionWithCacheUpdate dispatches on.
8.3 The trimmability contract¶
Almost everything interesting depends on one question: can this cache be rewound?
public func canTrimPromptCache(_ cache: [KVCache]) -> Bool
@discardableResult public func trimPromptCache(_ cache: [KVCache], numTokens: Int) -> Int
✅ VERIFIED —
KVCache.swift.canTrimPromptCacheisallSatisfy { $0.isTrimmable }.
Three consumers need it and each breaks differently when it is absent:
- Speculative decoding — rejected draft tokens must be rewound out of the main cache.
- Prompt-cache prefix reuse — reusing a stored 8k prefix for a 6k prompt requires trimming to the common prefix.
- Context-window management — dropping the oldest turns.
Two classes are never trimmable: ArraysCache and its subclass MambaCache. Their state is a
running scan, not a positional history, and a scan cannot be un-run. That covers Mamba, SSM,
RWKV and gated-delta / linear-attention layers, which means every hybrid 2026 architecture —
Qwen3.5, Qwen3.6, LFM2 / LFM2.5, Granite 4 hybrids, Nemotron-H, Jamba, Falcon-H1 — forfeits all
three capabilities above.
This is the same structural fact recorded elsewhere in this series on the Core AI side, where
trimKVCache returns -1 whenever extraStates is non-empty (corrections register C5). It is
not an MLX limitation and it is not a bug. It is a property of the architecture, and it should
inform model selection, not just tuning.
One class is trimmable until it isn't: RotatingKVCache.isTrimmable is offset < maxCacheSize,
and offset only ever grows. So a sliding-window model is trimmable right up to the moment the
window wraps, and the transition happens silently, mid-conversation. That is §9.2.
⚠️ trimPromptCache returns 0 rather than throwing when the cache is not trimmable. The
@discardableResult annotation makes ignoring the return value a one-character omission rather
than a warning. Check it. Every time.
let trimmed = trimPromptCache(cache, numTokens: n)
guard trimmed == n else {
// The rewind did NOT happen (or happened partially). You must not proceed
// as though it did — re-prefill, or fail the operation.
throw MyError.cacheRewindFailed(requested: n, actual: trimmed)
}
The Core AI side of this series records the same contract from the other direction: trimKVCache(to:)
returns the actual retained prefix, which may be length - 1 because the last generated token's
KV lags one step, so callers must prefill from the returned value, not the requested one
(corrections register C5). Different framework, identical lesson: the return value of a trim is
data, not decoration.
8.4 Where the cache actually gets created¶
You rarely construct a cache by name. Four doors:
(a) Implicitly, by passing cache: nil to TokenIterator or generate. The model builds its
own via newCache(parameters:).
(b) KVCacheDimensionProvider's default newCache:
parameters?.maxKVSize != nil → RotatingKVCache(maxSize: maxKVSize, keep: 4) per layer
otherwise → KVCacheSimple() per layer
✅ VERIFIED —
LanguageModel.swift:287-302.
⚠️ kvHeads.count is the layer count. The default newCache derives how many caches to build
from kvHeads.count, so a model that declares public let kvHeads: [Int] = [] and never assigns
it builds zero caches and crashes with "Index out of range." That is not hypothetical — commit
12d2da0 fixed exactly this in DeepSeek-V3.
⚠️ Note the keep: 4. The generic rotating cache keeps four attention-sink tokens. On the
Python side, keep > 0 is precisely the condition that makes a RotatingKVCache unquantizable
(NotImplementedError: Quantizing a RotatingKVCache with keep tokens is not supported) and
unmergeable for batching. Gemma 4 escapes it in Python only because gemma4_text.py passes
keep=0 explicitly.
🔴 GAP — whether Swift's
RotatingKVCachehas the samekeep > 0restriction is unverified. What is known: Swift'smaybeQuantizeKVCachedoes not convertRotatingKVCacheat all (§8.5), so the question may be moot today. What is unknown: whetherRotatingKVCache.toQuantized()— which exists — has akeepguard. What would resolve it: readingKVCache.swift'sRotatingKVCache.toQuantizedbody. Safe default: assume rotating caches are not quantized in Swift, because they demonstrably are not (§8.5), and do not combinemaxKVSizewithkvBitsexpecting both to apply.
(c) Explicit constructors:
public func makePromptCache(model: any LanguageModel, parameters: GenerateParameters? = nil)
-> [KVCache]
public func makePromptCache(model: any LanguageModel, maxKVSize: Int? = nil) -> [KVCache] // legacy
public func makePromptCacheWithLayerCount(numLayers: Int, maxKVSize: Int? = nil) -> [KVCache]
✅ VERIFIED —
KVCache.swift.
makePromptCache(model:parameters:) is the one to use. The maxKVSize: overload is marked legacy;
makePromptCacheWithLayerCount exists for the case where you have a layer count but no model
instance (deserialization, mostly).
(d) A model's own newCache. Any model can override it, and hybrid models must — that is where
CacheList(attentionCache, ssmCache) gets built.
8.5 Quantized KV: kvBits, kvScheme, and TurboQuant¶
Two knobs, and the second overrides the first:
public func resolveAffineScheme(_ scheme: String?) -> (bits: Int, groupSize: Int)?
// "affine4" -> (4, 64), "affine8" -> (8, 64)
public func maybeQuantizeKVCache(cache: inout [KVCache], kvBits: Int?, kvGroupSize: Int = 64,
quantizedKVStart: Int = 0, kvScheme: String? = nil)
public func quantizedScaledDotProductAttention(
queries:quantizedKeys:quantizedValues:scale:mask:
groupSize: Int = 64, bits: Int = 8, mode: QuantizationMode = .affine) -> MLXArray
✅ VERIFIED —
KVCache.swift.
Eligibility for conversion, per layer: the cache must be a plain KVCacheSimple, must not
already be quantized, and cache.offset > quantizedKVStart. The check recurses into CacheList
children.
⚠️ SILENT FAILURE #8 — kvScheme overrides kvBits, and unrecognized scheme strings are
silently ignored. Typo "turbo8v3" as "turbo8v33" and you get no quantization and no
message. This is a string-typed API in a language with enums; treat every scheme string as
something to validate at your own boundary:
let known: Set<String> = ["affine4", "affine8", "turbo0v4", "turbo0v3", "turbo0v2",
"turbo8v4", "turbo8v3", "turbo8v2", "turbo4", "turbo3", "turbo2"]
precondition(scheme.map(known.contains) ?? true, "unknown kvScheme \(scheme!)")
⚠️ SILENT FAILURE #9 — RotatingKVCache.toQuantized() exists and is never called. The source
carries the TODO: "RotatingKVCache.toQuantized() is not implemented yet, like in Python." So on
a sliding-window model (most Gemma layers), setting kvBits or kvScheme converts nothing.
Memory does not drop. Nothing warns. There is a one-time notice listing which layers stayed fp16 —
watch for it, because it is the only signal.
✅ VERIFIED — source TODO comment; the one-time notice is described in
Documentation.docc/kv-cache-quantization.md. The Python-side manifestation is worse: there,RotatingKVCache.to_quantized()is defined and raises, somlx_lm.server --kv-bits Nstarts cleanly, serves/health200, and crashes on the first inference request (mlx-lm#1573/#1583). Ahasattrguard does not help: presence ≠ implementation. Swift's "skip it silently" is less dramatic and arguably more dangerous.
The TurboQuant scheme table, verbatim from the package's own DocC article
(Documentation.docc/kv-cache-quantization.md:31-43). Naming is turbo<K-bits>v<V-bits>, where
0 means keys stay fp16:
| Scheme | Keys | Values | KV compression | Character |
|---|---|---|---|---|
affine8 |
8-bit affine | 8-bit affine | 1.88× | near-lossless on most models, full decode speed |
affine4 |
4-bit affine | 4-bit affine | 3.56× | collapses on some families; validate first |
turbo0v4 |
fp16 | 4-bit turbo | 1.58× | safest start; beats affine8 quality on most models tested |
turbo0v3 |
fp16 | 3-bit turbo | 1.66× | light value compression |
turbo0v2 |
fp16 | 2-bit turbo | 1.58׆ | aggressive value compression |
turbo8v4 |
8-bit affine | 4-bit turbo | 2.51× | conservative asymmetric |
turbo8v3 |
8-bit affine | 3-bit turbo | 2.75× | recommended default |
turbo8v2 |
8-bit affine | 2-bit turbo | 2.32׆ | memory-bound long context |
turbo4 / turbo3 / turbo2 |
turbo | turbo | up to 3.4׆ | maximum compression; key sensitivity varies strongly by family |
† boundary-layer protection auto-engages: the first and last two attention layers fall back to 8-bit affine.
✅ VERIFIED — table quoted from
Libraries/MLXLMCommon/Documentation.docc/kv-cache-quantization.md:31-43. These are the package maintainers' published numbers, not Apple's, and not independently reproduced by this guide.
Throughput, from the same article: "Measured on Qwen3-1.7B (M5 Max, fp16 150 tok/s): turbo8v3 114, turbo4 122, turbo0v4 102. Prefill stays raw fp16, so prefill throughput is unaffected."
Attribution: maintainer-measured, M5 Max, Qwen3-1.7B, published in the repo's DocC article at HEAD
3cbf928(2026-07-24). Not Apple-published. No macOS build or Xcode version is stated in the source, so this guide cannot supply one.
Quality, same source — WikiText-2 decode-time KL divergence versus an fp16 cache:
| Model | Scheme | KLD | Note |
|---|---|---|---|
| Mistral-7B | turbo4 |
0.040 | at 2.8× compression |
| Qwen3-1.7B | turbo4 |
2.65 → 0.15 | with per-dimension key calibration |
| Phi-4-mini | turbo4 |
2.76 → 0.036 | same |
| Qwen2.5-7B | turbo4 |
0.62 → 0.060 | same |
| Phi family | affine8 |
0.0004 | |
| Qwen2.5 | affine8 |
0.041 | versus turbo0v4 at 0.005 |
Read the Qwen3-1.7B row. turbo4 without key calibration is a KLD of 2.65 — that is not a
subtle degradation, that is a different model. Key sensitivity varies by family by more than an
order of magnitude, and there is no way to know which family you are in except to measure.
And the counter-intuitive part, from the Python side, which applies to the same kernels:
quantized KV can raise peak memory during prefill. Community-measured (mlx-lm#1587,
Llama-3.2-3B-Instruct-4bit, M4 Max 128 GB, macOS 27.0):
| Context | Case | Peak MLX memory | Decode |
|---|---|---|---|
| 8,000 tok | fp16 | 3.46 GB | 3.2 tok/s |
| 8,000 tok | q8 | 4.87 GB (+1.41 GB) | 2.6 tok/s |
| 32,000 tok | fp16 | 4.72 GB | 1.0 tok/s |
| 32,000 tok | q8 | 7.10 GB (+2.38 GB) | 0.7 tok/s |
| 32,000 tok | q4 | 6.53 GB (+1.81 GB) | 0.6 tok/s |
That thread ran a pre-registered discriminator on two rigs and reached a precise conclusion: it is
not resize churn (presizing closed only 1.5–3.8% of the inversion); it is the unfused quantized
attention path, which materialises a scores tensor proportional to
n_kv_heads × n_repeats × chunk_len × context × 4 bytes. Predicted delta between chunk 2048 and
512: 1.208 GB; measured: 1.216 GB.
The mitigation is a smaller prefill chunk. At prefill_step_size = 512 the inversion
disappeared entirely. In Swift that is GenerateParameters(prefillStepSize: 512).
Attribution: community-measured,
mlx-lm#1587, two independent rigs (M4 Max 128 GB / macOS 27.0, and M1 MBP 16 GB), mlx-lm 0.31.3, July 2026. Python-side measurement; the Swift path uses the same MLX kernels but this guide has not measured it in Swift.
The honest summary of kvBits: it is a capacity lever, not a throughput lever. Paired runs
on Qwen3-32B-4bit (community-measured, mlx-lm#1573) show int8 KV costing −7.4% decode at 0.5k
context, −3.1% at 4k, −2.7% at 16k, with greedy-argmax agreement of 0.9804 → 0.9990 as context
grows. As the thread puts it: "on a 4-bit dense model KV is only ~19% of decode-step bytes —
weights dominate, so halving KV bandwidth cannot pay for the compose/dequant overhead."
Use it when you need longer context in the same RAM. Do not use it hoping for speed.
⚠️ One family-specific hard stop: gpt-oss uses attention sinks, and quantized SDPA does not
support them. On the Python side this manifests as a silent client timeout — the generation
thread dies with 'Quantized SDPA does not support attention sinks', the request never returns,
and it presents as a network timeout during prefill rather than an error (mlx-lm#1438).
KV quantization must be off for that family.
8.6 Prompt caching to disk¶
public func savePromptCache(url: URL, cache: [KVCache], metadata: [String: String] = [:]) throws
public func loadPromptCache(url: URL) throws -> ([KVCache], [String: String])
✅ VERIFIED —
KVCache.swift.
The wire format is Python-compatible, which is the single most useful fact about it. Arrays are
flattened as "i.j"; metadata is keyed "0.i.j" (cache info), "1.key" (user metadata) and
"2.i" (class name). So a cache written by Swift can, in principle, be read by
mlx_lm.cache_prompt's consumer and vice versa — the class names are the shared vocabulary,
and that is why KVCacheSimple is written as "KVCache".
The eight names written: "KVCache", "RotatingKVCache", "QuantizedKVCache",
"ChunkedKVCache", "ArraysCache", "MambaCache", "CacheList", "TurboQuantKVCache".
One typed failure on restore: a RotatingKVCache whose stored maxSize is "None" throws
KVCacheError("RotatingKVCache with maxSize=None is not supported.").
🔴 GAP — cross-language round-tripping is untested. What is known: the format is documented in-source as Python-compatible and the class names line up for six of eight (Swift's
MambaCacheandTurboQuantKVCachehave no Python classes of those names). What is unknown: whether a file written bymlx_lm.cache_promptloads in Swift and produces identical logits. What would resolve it: write a cache in each language for the same model and prompt, load in the other, compare next-token logits. Safe default: treat prompt-cache files as same-language, same-model, same-quantization-settings artifacts, exactly as the Python guide advises.
At the ChatSession layer:
func saveCache(to url: URL) async throws // throws ChatSessionError.noCacheAvailable
// and a cache-accepting initializer:
ChatSession(container, cache: loadedCache, generateParameters: …)
⚠️ Two traps on restore, both documented in-source.
(1) Do not pass instructions again if the cache already encodes a system prompt. The source
warning: "they would be re-tokenized on each call … without matching KV state, producing
incoherent output." ChatSession prepends .system(instructions) on every turn when
instructions != nil, so a restored cache plus instructions means the system prompt is present
twice in the token stream and once in the KV — which is exactly the mismatch that produces fluent
nonsense.
(2) saveCache drops LMOutput.State (mlx-swift-lm#443, closed 2026-08-10; affected the
researched snapshot), so a restored VLM cache
has no M-RoPE deltas. §4.3.
8.7 Cross-turn reuse, and the attentionWithCacheUpdate footgun¶
Reuse across turns is the highest-leverage optimisation in this whole guide, and in
ChatSession you get it for free. Its internal state machine:
enum Cache {
case empty
case kvcache([KVCache], draftKVCache: [KVCache]?, state: LMOutput.State?)
case history([Chat.Message])
}
✅ VERIFIED —
ChatSession.swift:150-158.
Three states, and the middle one is the fast path: after the first turn the session holds a live KV
cache, and turn two prefills only the new tokens. clear() resets to .empty while keeping
instructions; the history: initializer starts in .history so the first generation rebuilds
from messages.
How much is it worth? This series records a Core AI-side, community-measured figure that is the best available order-of-magnitude: turn-2 TTFT 23.28 s → 0.230 s (101×) at 4k context with byte-identical greedy output, and 15.2× at 357 tokens (qwen3-0.6b, Mac) — with the mechanism being that trimming a KV cache is a single integer assignment, because attention is causal so rows at or beyond the retained position are overwritten before any query can read them.
Attribution: community-measured, from
notes/repos/john-rocky-models.mdvia this series' corrections register (C5). That measurement is on the Core AI runtime, not MLX Swift. The mechanism is identical; the number is not transferable. Treat it as "this is worth two orders of magnitude, measure your own."
And the constraint that governs model selection: linear-attention and hybrid models forfeit prefix caching entirely and must re-prefill every turn (§8.3).
The footgun. When you port or write a model, call attentionWithCacheUpdate and let it own the
cache:
let output = attentionWithCacheUpdate(
queries: queries, keys: keys, values: values,
cache: cache, scale: scale, mask: mask)
It routes on the cache type:
| Cache state | Route |
|---|---|
| no cache | plain MLXFast.scaledDotProductAttention |
TurboQuantKVCache, L > 1 && !isCompressed |
raw update + standard SDPA (prefill stays fp16) |
TurboQuantKVCache otherwise |
compressedAttention(...) |
QuantizedKVCacheProtocol |
updateQuantized + quantizedScaledDotProductAttention |
| anything else | cache.update + MLXFast.scaledDotProductAttention |
✅ VERIFIED —
Libraries/MLXLMCommon/AttentionUtils.swift:37-95.
⚠️ Do not call cache.update(...) yourself and then pass the result to
attentionWithCacheUpdate. The helper updates the cache itself, so the cache doubles and
attention is corrupted after the first token. This bit the codebase twice — DeepSeek V2 and
DeepSeek V3, commits 12d2da0 and 294c31f.
8.8 Sizing a KV cache, and the decision rule¶
The formula, from the package's own wired-memory documentation:
elements per token per layer = 2 * kvHeads * headDim
layer bytes = tokens * elements per token per layer * bytesPerElement
total KV bytes = layer bytes * numAttentionLayers
bytesPerElement: 2 for FP16/BF16, 1 for INT8, 0.5 for INT4.
✅ VERIFIED —
Documentation.docc/wired-memory.md:118-128.
Weight bytes, if you need them for a budget:
let context = try await LLMModelFactory.shared.load(configuration: config)
let weightBytes = context.model.parameters().flattened().reduce(0) { $0 + $1.1.nbytes }
✅ VERIFIED —
wired-memory.md:21-28. Maintainer-measured deltas from the same document, showing how close that estimate is to reality: Qwen3-4B-Sky-High-Hermes-4bit — nbytes 2,262,535,712; tensor files 2,262,637,937; active-after-load 2,264,337,376. Qwen3-Next-80B-A3B-Instruct-MLX-4bit — 44,844,060,160 / 44,844,286,608 / 44,844,101,616. Maintainer-measured, machine unstated.
The decision rule. This is the table to keep:
| Your workload | Cache you want | How to get it | Watch out for |
|---|---|---|---|
| One-shot, short prompt | KVCacheSimple |
default; pass cache: nil |
— |
| Multi-turn chat, fits in RAM | KVCacheSimple reused across turns |
ChatSession |
mutated in place; not thread-safe |
| Long document, memory-bound | RotatingKVCache |
GenerateParameters(maxKVSize: N) |
loses old context; kills trimming after wrap (§9.2); kills KV quantization (§8.5) |
| Long shared prefix, many queries | KVCacheSimple + savePromptCache |
§8.6 | model + quantization settings must match exactly; state is dropped (#443) |
| Capacity is the constraint | QuantizedKVCache |
kvBits: 8, quantizedKVStart: N |
peak memory can go up; not for rotating layers; not for gpt-oss |
| Capacity, and you have measured quality | TurboQuantKVCache |
kvScheme: "turbo8v3" |
Swift-only; internals unread by this guide; KLD varies 60× by family |
| Speculative decoding | KVCacheSimple only |
§2.6 | anything non-trimmable is refused; rotating breaks after wrap |
| SSM / hybrid / linear-attention model | whatever newCache gives you |
automatic | not trimmable — plan around it |
| Many concurrent conversations | one ModelContainer, many ChatSessions |
§8.2 | this is concurrency, not batching |
And the negative rule, stated once so it is easy to find: maxKVSize and kvBits together do
not do what you expect. The rotating caches are simply not converted, so you get a bounded cache
at full precision and no message about it.
9. Two real Swift-side cache bugs¶
These failure mechanisms affect caller-held caches and speculative rollback. Current GitHub state, source availability, and demonstrated remediation are separate observations; the records below state their boundaries.
9.1 maybeQuantizeKVCache replaces array elements instead of mutating objects¶
Current record: issue #312 remains open (2026-10-07). PR #453’s KVCacheStorage source change is included in 3.32.3; local runtime remediation remains unverified. The value-semantics failure below describes affected older implementations.
What happens¶
public func maybeQuantizeKVCache(cache: inout [KVCache], kvBits: Int?, kvGroupSize: Int = 64,
quantizedKVStart: Int = 0, kvScheme: String? = nil)
This is called on every step inside TokenIterator's generation loop, from step(previous:)
(§4.4). When cache.offset crosses quantizedKVStart mid-generation, it converts eligible
KVCacheSimple instances into QuantizedKVCache instances — by assigning into the array:
✅ VERIFIED —
mlx-swift-lm#312, quoted verbatim from the issue body:"
maybeQuantizeKVCacheis called on every step insideTokenIterator's generation loop. When thequantizedKVStartthreshold is crossed mid-generation, it replaces elements inTokenIterator's local copy of the cache array with newQuantizedKVCacheinstances. Because the function takescache: inout [KVCache], it replaces array elements rather than mutating the cache objects in place. The caller's array (inChatSession) still holds the originalKVCacheSimplereferences … The model loses all context generated after the quantization threshold."
Why it is a Swift bug and not a Python bug¶
Here is the Python line that does the same thing, from mlx_lm/generate.py:
for e, c in enumerate(prompt_cache):
if hasattr(c, "to_quantized") and c.offset >= quantized_kv_start:
prompt_cache[e] = c.to_quantized(group_size=..., bits=...)
It is the same element replacement. It is safe in Python only because a Python list is a
reference type and every holder shares one list object. ChatSession's equivalent in Python
would see the mutation because there is no "caller's copy" — there is one list.
Swift's [KVCache] is a value. inout gives the callee a copy-in/copy-out of that value, so
the assignment writes into TokenIterator's own array. ChatSession's array is a different value
holding the same object references — which are unchanged, because the objects were never mutated.
The quantized caches receive every subsequent update; the caches ChatSession holds receive none.
The model then generates against a KV cache that stopped growing at the threshold.
The observable symptom is the nastiest kind: generation continues, output stays fluent, and the model behaves as though everything after the threshold token never happened. In a long chat, that looks like the model "forgetting the middle of the conversation" — which is exactly what people expect a small model to do, so it does not get reported as a bug.
The secondary defect in the same function¶
The issue also identifies dead code: the guard !(firstQuantizable is QuantizedKVCache) can never
be false, because the candidate came from cache.first(where: { $0 is KVCacheSimple }) and
QuantizedKVCache inherits from BaseKVCache, not from KVCacheSimple. So the
already-quantized check does nothing. (The line number quoted in the issue is Evaluate.swift:1806;
line numbers from issue bodies drift — verify against your checkout.)
The maintainer's proposed fix, and why it is the right shape¶
✅ VERIFIED —
davidkoskion#312:"I think making a box type like this is probably the way to go — it will give us the most flexibility in terms of having behavior over the full KVCache and let us fix this problem. … I do think that a higher level type that represents the collection of
KVCacheinstances might be better. It would be nice to call itKVCachebut that name is taken for the per-layer ones. The drawback: it doesn't match the python plain-list implementation."
A box is a reference-typed indirection whose identity is stable across implementation swaps.
Swapping box.implementation mutates something every holder can see, because they all hold the
same box. That is the general remedy for this whole class of error, and the last sentence of the
quote names its cost: the Swift design would then diverge from the Python one, which makes ports
harder in exactly the way that produced the bug.
The class of error: "port the line, lose the semantics"¶
Write it down, because it will recur every time this stack is ported:
In a reference-semantics language, mutating a container and mutating its contents are the same operation. In a value-semantics language they are different operations that look identical at the call site.
Three places to look for the same shape:
- Any
inout [SomeClass]parameter that replaces elements. If any caller kept its own copy of the array, that caller is stale. - Any
structholding a[SomeClass]that is passed by value into a generation loop. Swift'sTokenIteratoris a struct. - Any Python
list[...]translated to a SwiftArray<...>in a port, where the Python code mutates the list itself rather than its elements.
Mitigation for affected older pins: avoid mid-generation KV quantization with a caller-held cache. The source change is in 3.32.3; validate your application’s reuse path before retiring the guard.
// SAFE: quantization threshold is never crossed mid-generation, because the whole
// cache is quantized from token 0 or never.
let params = GenerateParameters(kvBits: 8, quantizedKVStart: 0)
// ALSO SAFE: no KV quantization at all.
let params = GenerateParameters() // kvBits nil
// EXPOSED: crossing the threshold partway through a generation that reuses a
// caller-held cache (ChatSession, or your own [KVCache] passed to generate).
let params = GenerateParameters(kvBits: 8, quantizedKVStart: 4096)
⚠️ Note the trade-off you are making with quantizedKVStart: 0. The Python side measured
(mlx-lm#1566, M4 Pro 24 GB, mlx 0.32.0, community-measured) that quantizing from token 0 costs
−17.1% decode on Qwen2.5-0.5B-Instruct-4bit at a 512-token prompt, versus parity at
start=5000. Past the threshold the sign flips: at a 5,120-token prompt, start=5000 gave
+3.7% on that model and +18.3% on Llama-3.2-1B-4bit. So quantizedKVStart: 0 is the safe
setting and the slow one for short prompts. Pick it deliberately.
Also worth knowing: the library default and the CLI default disagree on the Python side —
generate_step() defaults quantized_kv_start=0 while the CLIs default to 5000. Swift's
GenerateParameters default is 0, matching the Python library. Whatever you were used to
from the command line is not what you get from the API.
9.2 SpeculativeTokenIterator discards trimPromptCache's return value¶
Issue: mlx-swift-lm#424. Status: OPEN as of 2026-07-29 (one comment, no maintainer fix).
What happens¶
RotatingKVCache.isTrimmable is offset < maxCacheSize, and offset only ever grows. So a
sliding-window cache is trimmable up until the window wraps, and then permanently is not.
trimPromptCache guards on canTrimPromptCache, which is allSatisfy { $0.isTrimmable } — a
whole-array predicate. So one wrapped sliding layer makes the entire rollback a no-op, and
because the function returns 0 rather than throwing, and is annotated @discardableResult, a
caller that ignores the value cannot tell.
SpeculativeTokenIterator.speculateRound() is such a caller:
✅ VERIFIED —
mlx-swift-lm#424, quoted from the issue body:"
SpeculativeTokenIterator.speculateRound()rewinds rejected drafts withtrimPromptCache(mainCache, numTokens: numDraft - accepted)and discards the result.trimPromptCacheguards oncanTrimPromptCache=allSatisfy { $0.isTrimmable }, so once one sliding layer wraps the whole rollback returns 0 silently and generation continues on a transcript containing tokens that were never emitted. On Gemma-family models the sliding window is small (e.g. 512), so a single long reply is enough.MTPSpeculativeTokenIteratoralready trims by the amount actually reported."
Read the consequence twice: generation continues on a transcript containing tokens that were never emitted. The rejected draft tokens are still in the KV cache. The model attends to them. The user never saw them. Every subsequent token is conditioned on text that does not exist.
Three compounding effects¶
The same issue records two more:
(2) Prefix reuse degrades to full re-prefill post-wrap. Once isTrimmable is false, the
prompt-cache path cannot trim to a common prefix, so it re-prefills. Silent, and it looks like a
performance regression with no cause.
(3) RotatingKVCache.trim() does not self-guard. Called directly on a wrapped buffer — as
opposed to through trimPromptCache — "it still decrements offset/idx and returns nonzero,
corrupting the circular-buffer mapping." So the safe-looking path (check the return value!) is
unsafe if you bypass the array-level guard. Use trimPromptCache, check its return, and never
call cache.trim(_:) on a rotating cache directly.
And a fourth, cross-cutting, contributed in-thread by NivDvir: for M-RoPE VLMs
(Qwen2-VL, Qwen2.5-VL) decode positions are cacheOffset + ropeDeltas, so the no-op rollback
also inflates the offset and generates at drifted positions — silent, and only after the wrap.
✅ VERIFIED — all four from
#424's body and comments.
The class of error: "a return value that reports partial success"¶
A function that returns "how much of what you asked for actually happened" is not optional to check.
@discardableResulton such a function is a loaded gun.
The signature is @discardableResult public func trimPromptCache(_ cache: [KVCache],
numTokens: Int) -> Int. It reports the actual trim. Ignoring it converts "I could not do that"
into "I did that."
This series has now recorded the identical contract in three places on three different stacks:
| Stack | API | What it returns on failure | Consequence of ignoring |
|---|---|---|---|
| MLX Swift | trimPromptCache(_:numTokens:) |
0 |
spec-decode transcript corruption (#424) |
| mlx-lm (Python) | trim_prompt_cache(cache, n) | 0 | server prefix reuse returns mismatched KV (mlx-lm#1494) |
| Core AI | trimKVCache(to:) | -1 when extraStates is non-empty; otherwise the actual retained prefix, which may be length - 1 | prefill from the wrong offset (this series' corrections register, C5) |
The Core AI row is the sharpest statement of the rule: the API returns the actual retained prefix, and callers must prefill from the returned value, not the requested one, because the last generated token's KV lags one step. Same lesson, three frameworks.
The mitigation you can apply today¶
Check the return, and treat a shortfall as a hard failure of the operation:
func rewind(_ cache: [KVCache], by n: Int) throws {
guard n > 0 else { return }
guard canTrimPromptCache(cache) else {
throw CacheError.notTrimmable // fail before you try
}
let actual = trimPromptCache(cache, numTokens: n)
guard actual == n else {
throw CacheError.partialTrim(requested: n, actual: actual)
}
}
And avoid the configuration that triggers it. Speculative decoding plus a sliding-window model is the combination at risk. Concretely:
- Do not combine
GenerateParameters(maxKVSize:)with speculative decoding.maxKVSizeis what makes the defaultnewCachebuildRotatingKVCachefor every layer. - Be careful with Gemma-family models specifically, because their sliding windows are small
(the issue names 512) and they use rotating caches whether or not you asked for one — the
architecture's own
newCachebuilds them. "A single long reply is enough." - Prefer MTP over draft-model speculation on such models if you must speculate:
MTPSpeculativeTokenIteratoralready trims by the amount actually reported, per the issue.
🔴 GAP — the exact accept/reject and rollback arithmetic in
SpeculativeTokenIteratoris unverified. What is unknown: hownumDraftTokensinteracts withmaxTokens, how bonus tokens are handled, and whether any other call site in that iterator ignores a trim result. Why:Evaluate.swift:864-1069was not read in the research pass. What would resolve it: reading that range. Safe default: the mitigations above do not depend on those details — they avoid the configuration entirely.
9.3 A third, for the same shelf: MLX.compile() and KVCacheSimple¶
Not required by the brief, but it belongs next to the other two because it is the third member of the family "Swift's type system makes a Python idiom unsound."
Issue: mlx-swift-lm#406. Status: OPEN as of 2026-07-29 (zero comments).
let previous = self.offset // Swift Int → baked as a constant in the compiled graph
self.offset += keys.dim(2)
self.keys?[.ellipsis, previous ..< self.offset, 0...] = keys
let returnedKeys = self.keys![.ellipsis, ..<self.offset, 0...]
innerState() does not include offset, and slice indices derived from a Swift Int are not
graph nodes. With shapeless: false, recompilation triggers on input array shape changes, not
on integer-constant changes. Result: the write position is frozen at the trace-time offset and the
attention window is frozen with it.
Observed, on Qwen2.5-7B-Instruct-4bit greedy: uncompiled produced 42 tokens then EOS; compiled
produced 64 tokens with a 4-token repeating cycle (3535, 11, 432, 4977, 1075, 11, 432, 4977,
1075, …).
✅ VERIFIED —
mlx-swift-lm#406, body and quoted repro.
The lesson: an integer that lives in Swift and controls an MLX slice is invisible to the compiler. It is the same shape as §9.1 — state that one layer thinks it owns and another layer cannot see — expressed against the graph compiler instead of against value semantics.
Safe default: do not MLX.compile() a decode step that uses KVCacheSimple. The suggested
fixes in-thread (a graph-traceable MLXArray offset, a functional cache step, or a dedicated
compile-friendly cache type) are all library changes, not caller-side workarounds.
9.4 How to detect all three in your own app¶
A single diagnostic, run against your actual configuration, catches every one:
/// Run once at startup, in DEBUG, with your real GenerateParameters.
func auditCacheConfiguration(container: ModelContainer,
parameters: GenerateParameters) async {
await container.perform { context in
let cache = makePromptCache(model: context.model, parameters: parameters)
let classes = cache.map { String(describing: type(of: $0)) }
let histogram = Dictionary(grouping: classes, by: { $0 }).mapValues(\.count)
print("cache layers: \(histogram)")
// (1) §9.2 — will speculative rollback and prefix reuse work?
print("canTrimPromptCache: \(canTrimPromptCache(cache))")
if !canTrimPromptCache(cache) {
print(" ⚠️ no rollback, no prefix reuse: do not use speculative decoding")
}
// (2) §9.2 — do we have rotating layers that will *stop* being trimmable?
if classes.contains(where: { $0.contains("Rotating") }) {
print(" ⚠️ rotating layers present: trimmability is TEMPORARY (issue #424)")
print(" maxSize per layer: \(cache.compactMap(\.maxSize))")
}
// (3) §9.1 — will the quantization threshold be crossed mid-generation?
if let bits = parameters.kvBits ?? parameters.kvScheme.map({ _ in 8 }),
parameters.quantizedKVStart > 0 {
print(" ⚠️ kvBits \(bits) with quantizedKVStart \(parameters.quantizedKVStart)")
print(" → mid-generation conversion; exposed to issue #312")
print(" → use quantizedKVStart: 0, or disable KV quantization")
}
// (4) §8.5 — will quantization actually apply to these layers?
let quantizable = classes.filter { $0.contains("KVCacheSimple") }.count
if parameters.kvBits != nil || parameters.kvScheme != nil {
print(" quantizable layers: \(quantizable)/\(cache.count)")
if quantizable < cache.count {
print(" ⚠️ \(cache.count - quantizable) layers will stay fp16 silently")
}
}
}
}
This diagnostic is composed from verified APIs —
makePromptCache(model:parameters:),canTrimPromptCache(_:),KVCache.maxSize,GenerateParametersfields — all ✅ VERIFIED as cited in §8. The assembly is this guide's, not shipped code.String(describing: type(of:))on a class instance is the pragmatic way to get the concrete cache class out of a[KVCache]of existentials.
What good output looks like for a dense text model with no KV quantization:
What "you are exposed" looks like for Gemma 4 with maxKVSize set:
cache layers: ["RotatingKVCache": 36]
canTrimPromptCache: true
⚠️ rotating layers present: trimmability is TEMPORARY (issue #424)
maxSize per layer: [1024, 1024, 1024, …]
quantizable layers: 0/36
⚠️ 36 layers will stay fp16 silently
Note that canTrimPromptCache says true in that second case — because the window has not
wrapped yet. That is the whole problem in one line of output.
10. MLXEmbedders¶
A local RAG pipeline in Swift needs two models in one process: an embedder to turn documents and
queries into vectors, and an LLM to answer over the retrieved passages. MLXEmbedders is the
first half, it ships in the same package, and it deliberately mirrors the LLM API shape — which
means most of §2 through §9 transfers.
10.1 The types¶
public protocol EmbeddingModel: BaseLanguageModel {
var vocabularySize: Int { get }
var poolingStrategy: Pooling.Strategy? { get } // default nil
var maxPositionEmbeddings: Int? { get } // default nil
func callAsFunction(_ inputs: MLXArray, positionIds: MLXArray?, tokenTypeIds: MLXArray?,
attentionMask: MLXArray?) -> EmbeddingModelOutput
}
public struct EmbeddingModelOutput {
public let hiddenStates: MLXArray?
public let pooledOutput: MLXArray?
}
public enum Pooling.Strategy: Sendable { case mean, cls, first, last, max, none }
✅ VERIFIED —
Libraries/MLXEmbedders/EmbeddingModel.swift.
⚠️ maxPositionEmbeddings: inputs beyond it are truncated with a warning. Not an error — a
truncation. A 2,000-token document handed to a 512-position model becomes a 512-token document and
your retrieval quality quietly drops. Chunk before you embed; do not rely on the model to tell
you loudly.
EmbedderModelContext { configuration, model, tokenizer, pooling } and EmbedderModelContainer
mirror ModelContext / ModelContainer — same perform, same update, plus async
poolingStrategy, modelDirectory, tokenizerDirectory.
PoolingConfiguration decodes sentence-transformers' 1_Pooling/config.json keys:
word_embedding_dimension, pooling_mode_cls_token, pooling_mode_mean_tokens,
pooling_mode_max_tokens, pooling_mode_lasttoken — all optional (commit efd498b), because
real repos ship partial files.
10.2 What is registered¶
EmbedderTypeRegistry.shared model types: bert, roberta, xlm-roberta, distilbert,
nomic_bert, qwen3, lfm2, gemma3, gemma3_text, gemma3n.
EmbedderRegistry presets: bge_micro, gte_tiny, minilm_l6, snowflake_xs, minilm_l12,
bge_small, multilingual_e5_small, bge_base, nomic_text_v1, nomic_text_v1_5, bge_large,
snowflake_lg, bge_m3, mixedbread_large, qwen3_embedding
(mlx-community/Qwen3-Embedding-0.6B-4bit-DWQ), lfm2_embedding_350m{,_4bit,_8bit},
lfm2_colbert_350m{,_4bit,_8bit}.
✅ VERIFIED —
Libraries/MLXEmbedders/ModelFactory.swift.
Apple's embedder-tool defaults to EmbedderRegistry.nomic_text_v1_5
(nomic-ai/nomic-embed-text-v1.5) — a reasonable default: 768-dimensional, English, small enough
to co-reside with a 4B LLM on a phone.
10.3 Embedding a batch, correctly¶
This is the shape from Apple's embedder-tool, and the padding detail in it is the part people get
wrong:
return try await container.perform { context in
let tokenizer = context.tokenizer
let encoded = texts.enumerated().compactMap { index, text -> (Int, [Int])? in
let tokens = tokenizer.encode(text: text, addSpecialTokens: true)
guard !tokens.isEmpty else { skippedIndices.append(index); return nil }
return (index, tokens)
}
// [PAD] (BERT standard), EOS (autoregressive like Qwen)
let padToken = tokenizer.convertTokenToId("[PAD]") ?? tokenizer.eosTokenId ?? 0
let maxLength = encoded.map { $0.1.count }.max() ?? 0
let padded = stacked(encoded.map { _, tokens in
MLXArray(tokens + Array(repeating: padToken, count: maxLength - tokens.count))
})
let mask = (padded .!= padToken)
let tokenTypes = MLXArray.zeros(like: padded)
let outputs = context.model(
padded, positionIds: nil, tokenTypeIds: tokenTypes, attentionMask: mask)
let poolingModule = resolvedPooler(for: context.pooling)
let pooled = poolingModule(outputs, mask: mask,
normalize: self.normalize, applyLayerNorm: self.applyLayerNorm)
pooled.eval()
…
}
✅ VERIFIED —
mlx-swift-examples/Tools/embedder-tool/EmbedderRuntime+Embedding.swift, verbatim; thepadTokenfallback chain arrived in commit44b14cf.
Four things this encodes:
- The pad-token fallback chain —
"[PAD]", then EOS, then0. BERT-family models have a real[PAD]; autoregressive embedders like Qwen3-Embedding do not and use EOS. Getting this wrong does not crash; it pollutes your mean-pooled vectors with whatever token id0happens to be. - The mask is derived from the pad token (
padded .!= padToken) and is then used twice — once as the attention mask, once by the pooler. Mean-pooling without the mask averages padding into every vector, and short documents get systematically worse embeddings than long ones. This is the classic silent RAG-quality bug. - Empty inputs are skipped, with their indices recorded, so the caller can reinsert placeholders. An empty token list would otherwise produce a NaN vector.
pooled.eval()before returning across the isolation boundary — §2.3's rule, applied.
embedder-tool also sanitizes vectors (NaN/Inf → 0) via
VectorOperations.sanitize / normalize / dotProduct / hasNonFiniteValues, and its README warns to
use the same --no-normalize setting for index and search. Cosine similarity between a
normalized and an unnormalized vector is not cosine similarity.
10.4 The pipeline, and the part that is actually hard¶
documents ──chunk──▶ EmbedderModelContainer ──▶ [Float] vectors ──▶ your store
│
query ────────────▶ EmbedderModelContainer ──▶ [Float] ──cosine top-k──┘
│
retrieved passages ──▶ Chat.Message.user(…) ──▶ ChatSession ──▶ answer
The retrieval half is ordinary Swift; embedder-tool stores a JSON [IndexEntry] of
{path, embedding} and does brute-force dot products, which is fine to a few thousand documents.
The hard part is memory, and it is not obvious. You now have two model weight sets resident, plus a KV cache that grows with the retrieved context you just stuffed into the prompt. Three concrete consequences:
(1) Set Memory.cacheLimit once, globally, and low. Every Apple sample uses 20 MB (§4.6).
The MLX buffer pool is process-wide, not per-container, and the instinct to "let it cache more
because we have two models" is wrong. The Python-side evidence for this is stark: in a churn test,
get_peak_memory() reported 1.00 GB while active + cache and the OS footprint both reported
60.19 GB — the pool had retained every freed buffer. Setting the cache limit to 0 in the same
test ended at 1.14 GB.
Attribution: community-measured,
mlx#3896, M5 Max 128 GB, mlx 0.32.0, Darwin 25.4.0, July 2026. Python-side; the allocator is shared C++ so the mechanism applies to Swift. Actionable corollary for Swift:Memory.snapshot()gives youactiveMemory,cacheMemoryandpeakMemoryseparately — gate memory-pressure logic onactiveMemory + cacheMemory, not onpeakMemory. Closure context (mlx#3896 closed 2026-08-08; checked 2026-08-23): the maintainer closed it by scoping the counter — peak memory is meant for measuring a single model's inference or training run with near-100% cache hits; for anything long-running he recommends theactive + cachecheck himself. A two-model RAG app is exactly the shape that scoping excludes.
(2) Load the embedder lazily and consider unloading it. Embedding is bursty (index once, query
occasionally); generation is sustained. Holding a 350 MB embedder resident for the 99% of the time
you are generating is a poor trade on a phone. NSCache<NSString, ModelContainer> — the idiom in
MLXChatExample — works here precisely because it lets the OS evict under pressure:
✅ VERIFIED —
mlx-swift-examples/Applications/MLXChatExample/Services/MLXService.swift. It works becauseModelContaineris a class.
(3) Retrieved context is KV context. Ten 500-token passages is 5,000 tokens of prefill on every query, and §8.8's formula tells you exactly what that costs in cache bytes. This is where prompt caching (§8.6) earns its keep — if your system prompt and tool schemas are stable, they are a shared prefix, and re-prefilling them per query is pure waste. Retrieved passages, being different every query, are not.
⚠️ And the constraint that governs model choice for the LLM half: if you picked a hybrid or linear-attention model (Qwen3.5, Qwen3.6, LFM2.5, Granite 4, Nemotron-H, Jamba) you have no prefix caching at all (§8.3) and every query re-prefills everything. For a RAG workload with a large stable system prompt, that is the difference between 0.2 s and 20 s to first token. Model architecture is a RAG design decision, not just a quality decision.
10.5 Where the Foundation Models path fits¶
If you are on iOS 27 / macOS 27, there is a second design available: keep the retrieval in Swift
but put the generation behind LanguageModelSession via MLXFoundationModels, or skip MLX for
generation entirely and use SystemLanguageModel with SpotlightSearchTool for retrieval. Which
is right depends on whether you need a specific model's behaviour (MLX) or want Apple to manage
the model lifecycle (Foundation Models). That comparison is
Part 4 and Part 2's Spotlight RAG guide; the only thing
this guide asserts is that the embedder half — MLXEmbedders — has no Foundation Models
equivalent, so if you want on-device dense retrieval over your own corpus you are using this
library either way.
11. Decision tables, silent-failure register, gap register¶
11.1 Which API do I want?¶
| I want to… | Use |
|---|---|
| Stream a chat reply into a SwiftUI label | ChatSession.streamResponse(to:) |
| …and also show tokens/sec or catch tool calls | ChatSession.streamDetails(to:) |
| One-shot completion, no conversation state | MLXLMCommon.generate(input:parameters:context:) inside perform |
| Supply my own KV cache | the free generate(input:cache:parameters:context:…) — the container convenience has no cache: |
| Observe when generation truly finishes | generateTask(...), then task.cancel(); await task.value |
| Raw token IDs (custom detokenizer, logprobs, telemetry) | generateTokens(...) / generateTokensTask(...) |
| Measure TTFT | build the TokenIterator yourself; the clock starts before init |
| Custom sampling or a custom stop rule | TokenIterator(input:model:cache:state:processor:sampler:prefillStepSize:maxTokens:) — note it disables KV quantization |
| Custom decoding strategy end to end | conform to TokenIteratorProtocol, hand it to generateTask |
| Run a tool loop automatically | ChatSession(…, tools:) { toolCall in … } |
| Approve tool calls before they run | streamDetails, collect .toolCall, then respond(to: [.tool(result, id:)]) |
| Embed text | EmbedderModelFactory.shared.loadContainer(...) + EmbedderModelContainer.perform |
| Serve many conversations | many ChatSessions, one ModelContainer — there is no batching in Swift |
11.2 The silent-failure register¶
Nine, collected. Every one of them produces plausible output or plausible behaviour, and none of them throws.
| # | § | What silently fails | How you notice | What to do |
|---|---|---|---|---|
| 1 | §2.5 | Stream yields .toolCall first and zero .chunk events |
a "waiting for first token" spinner never stops | drive UI state from .info or stream termination, never from first .chunk |
| 2 | §3.3 | topP/topK/minP are ignored at temperature: 0, and topP: 1.0 / topK: 0 are no-ops anyway |
output is deterministic despite a "sampling" config | run the prompt twice; identical output ⇒ greedy |
| 3 | §4.3 | LMOutput.State dropped across turns ⇒ M-RoPE positions recomputed from zero |
VLM grounding answers drift; turn-2 logits diverge by 0.43 max-abs vs 8.3e-07 noise floor | require releases containing #448/#475 and #443; otherwise re-prefill |
| 4 | §5.3 | A trailing empty .assistant("") closes the assistant turn in the template |
model starts a new user turn, or emits EOS immediately | trim it — ChatSession does; the raw UserInput path does not |
| 5 | §6.4 | Chat template missing, system role dropped, additionalContext keys ignored |
fluent output, worse instruction-following | the render-and-eyeball recipe, §6.5 |
| 6 | §7.5 | ToolCallFormat.infer returns nil ⇒ loop assumes .json ⇒ non-JSON calls stay in the prose |
stopReason == .stop, toolCalls == [], function-call text in the UI |
set toolCallFormat: explicitly; §7.8 |
| 7 | §7.6 | Mixing processChunkOutputs with processChunk/processEOS/drainToolCalls |
duplicated or lost tool calls | pick one API per processor instance |
| 8 | §8.5 | Unrecognized kvScheme string ignored; kvScheme also overrides kvBits |
memory does not drop, nothing says why | validate scheme strings at your own boundary |
| 9 | §8.5 | RotatingKVCache.toQuantized() is never invoked, so sliding-window layers stay fp16 |
maxKVSize + kvBits together do nothing for those layers |
watch for the one-time "layers stayed fp16" notice; don't combine them |
Plus the two §9 bugs, which are defects rather than design:
| Issue | Status 2026-08-07 | Silent symptom |
|---|---|---|
| #312 maybeQuantizeKVCache replaces elements, not objects | OPEN (2026-10-07); PR #453 included in 3.32.3, runtime remediation unverified | model loses all context after quantizedKVStart |
| #424 trimPromptCache return discarded in SpeculativeTokenIterator | OPEN | generation continues on a transcript containing never-emitted tokens |
11.3 The gap register¶
Everything this guide could not verify, with what would resolve it.
G1 — SpeculativeTokenIterator's body. Evaluate.swift:864-1069 was not read. Accept/reject
sampling details, bonus-token handling, and the numDraftTokens × maxTokens interaction are
unverified. Resolve: read that range. Safe default: §9.2's mitigations do not depend on it.
G2 — MTPSpeculativeTokenIterator.swift (≈500 lines). Sticky-passthrough triggers and the
passthroughReason strings are unverified — the guide asserts only that
GenerateCompletionInfo.passthroughReason exists and is String?. Resolve: read the file.
G3 — ToolCallProcessor.swift lines 140-856. The four state names, the bare-JSON fallback
constants and the dedupe mechanism come from symbol names and doc comments. Exact heuristics —
especially per-format EOS handling for Mistral and LFM2, and stripProtocolSpans semantics — are
unverified. Resolve: read the file.
G4 — TurboQuantKVCache.swift (1,765 lines) + TurboQuantKernels.swift (2,367 lines). Every
number in §8.5's scheme table is quoted from the package's DocC article, not independently
reproduced. Whether boundary-layer protection applies where the article says it does, and what
turbo* does on a rotating cache, are unverified. Resolve: read the files and re-run the KLD
table. Safe default: start at affine8 or turbo0v4 and measure your own model.
G5 — registering a custom ToolCallParser. No public API for it was found;
ToolCallProcessor.init(format:tools:) takes a closed enum case. Resolve:
grep -n "ToolCallParser" Libraries/MLXLMCommon/Tool/*.swift. Safe default: parse outside the
loop, §7.8(d).
G6 — cross-language prompt-cache round-tripping. The format is documented as Python-compatible and six of eight class names line up, but no round trip was tested. Resolve: write in each language, load in the other, compare next-token logits. Safe default: same-language artifacts only.
G7 — RotatingKVCache's keep > 0 behaviour in Swift. Python raises
NotImplementedError when quantizing a rotating cache with keep tokens; whether Swift's
toQuantized has the same guard is unverified, and possibly moot since maybeQuantizeKVCache
never calls it. Resolve: read RotatingKVCache.toQuantized. Safe default: assume rotating
caches are not quantized.
G8 — detokenizer cost. Swift ships only NaiveStreamingDetokenizer; Python has three.
The throughput gap on long newline-free output is unquantified. Resolve: benchmark
generateTokens versus generate on ~4k tokens of newline-free output. Safe default: use
generateTokens and batch-detokenize if decode looks slow on structured output.
G9 — issue #259's status versus the code. The issue (Gemma 4 tool calls never extracted) is
recorded OPEN, but the source at HEAD 3cbf928 shows both named root causes addressed — a
.gemma4 case with correct tags and a prefix gemma4 inference rule. Resolve: run §7.8's
step-2 probe against a Gemma 4 checkpoint. Safe default: set toolCallFormat: .gemma4
explicitly and verify with the probe.
G10 — the resolved mlx-swift version. Package.resolved is not committed to
mlx-swift-lm. The pin is .upToNextMinor(from: "0.31.4") and a commit message references
0.31.5; what SwiftPM actually resolves for you is whatever is newest in that range. Resolve:
swift package show-dependencies in your own project. This matters more than it looks —
several correctness fixes in §3.4 and §8.5 live in mlx core, and reach you through a four-hop
chain (below).
11.4 The four-hop fix chain¶
A thing worth internalising before you file a bug or wait for a fix:
ml-explore/mlx ──▶ ml-explore/mlx-c ──▶ ml-explore/mlx-swift ──▶ mlx-swift-lm
(C++/Metal) (C API) (Swift bindings) (this package)
✅ VERIFIED —
davidkoskionmlx-swift-examples#462: "GitHub automatically closed this because the root cause is fixed. We still need that to be in an mlx build, then an mlx-c build, then an mlx-swift build and finally the dependencies here and mlx-swift-lm need to point to the new tags."
Four tag bumps between a merged fix and your app. Expect lag, and when you are bisecting a
correctness problem, bisect the chain, not just this package. The mlx-swift-examples#462 case is instructive:
an iPhone 16 Pro (A18) produced gibberish from every LLM on iOS 26.2/26.2.1 while iPhone 17 and
M4 Max were fine; the reporter bisected to "2.29.1 works; main fails" and the root cause was
upstream in mlx-swift, not in the LM library at all.
11.5 Consolidated gotchas¶
The ones that do not have a section of their own:
swift testdoes not work —xcodebuild … -skipPackagePluginValidation.ModelFactoryError.noModelFactoryAvailableif neitherMLXLLMnorMLXVLMis linked; the registry usesNSClassFromStringand needs the module loaded.- VLM factory is tried before LLM; failures are swallowed and only the last error surfaces.
TokenIterator.initperforms prefill and can throw; it is the expensive call.- Early
breakfrom anAsyncStream<Generation>leaves work in flight — use the…Taskvariants. - Never call
cache.update()beforeattentionWithCacheUpdate— double update, corrupted cache. Bit DeepSeek V2 and V3. kvHeadsmust be populated per layer —newCachederives the layer count fromkvHeads.count.ChatSessionis not thread-safe;ModelContaineris.MLXArrayis notSendable—eval()before returning fromperform.ChatSession's defaultprocessingresizes images to 512×512.generation_config.json'seos_token_idreplaces (not unions) theconfig.jsonset.stopStrings == nilfalls back toextraEOSTokens— set[]to disable.temperaturedefaults to0.6, not 0. Python'smake_samplerdefaults to 0.seedis inert attemperature == 0.ModelRegistryis a deprecated typealias in bothMLXLLMandMLXVLM— ambiguous if you import both. UseLLMRegistry/VLMRegistry.MTPDrafterTypeRegistry.sharedis empty at bootstrap —await Gemma4AssistantRegistration.register()first.SendableBox.consume()fatalErrors on a second call.MLXFoundationModelscompiles to an empty library on the 26 SDK — consumers must#if canImport(FoundationModels, _version: 2)their own call sites, not just@available.preprocessor_config.jsonwins overprocessor_config.json, andmistral3/gemma4_unifiedoverride the declaredprocessor_class.MLX.GPU.set(cacheLimit:)is gone — it isMemory.cacheLimit/Memory.memoryLimit/Memory.snapshot().- Xcode ⌘⌥R with "Debug Executable" unchecked measurably improves throughput — Apple's own
LLMEvalREADME says so. Do not benchmark under the debugger.
✅ VERIFIED — 1–19 from the sources cited in their respective sections; 20 from
mlx-swift-examples/Applications/LLMEval/README.md:43: "You may also find that running outside the debugger boosts performance. You can do this in Xcode by pressing cmd-opt-r and unchecking 'Debug Executable'."
11.6 Cross-links¶
- Part 12 guide 04 — the Python counterpart to this guide. Read §4 (nine cache classes) alongside §8 here; the two inventories differ in exactly the ways that matter.
- Part 4 — putting an MLX model behind
LanguageModelSessionviaMLXFoundationModels, and the structured-generation constraint (grammar-constrained decoding needs logits, which GPU-pipelined backends do not expose). - Part 11 — why
head_dim ∈ {72, 96, 192, 256, 512}silently falls back to a composed SDPA, and what padding to a supported dim costs. - Part 1 — the backend decision table, and the known-bad-claims reference.
- Part 13's model-loading guide — factories, registries,
Downloader/TokenizerLoader, theMLXHuggingFacemacros, and conversion.
11.7 Sources¶
Everything asserted in this guide traces to one of these, read during the research pass behind it:
ml-explore/mlx-swift-lm @ 3cbf928b5eb24190e8952725699ae6a3bb02824d (2026-07-24).
Package.swift; README.md; CONTRIBUTING.md;
Libraries/MLXLMCommon/{Evaluate,ChatSession,KVCache,AttentionUtils,LanguageModel,ModelContainer,ModelFactory,ModelConfiguration,Chat,UserInput,Tokenizer,TokenizerLoader,Downloader,Load,BaseConfiguration,ReasoningConfig,SpeculativeDecoding,MTPDrafterModel,WiredMemoryPolicies}.swift;
Libraries/MLXLMCommon/Tool/{Tool,ToolCall,ToolCallFormat,ToolParameter,ToolCallProcessor}.swift;
Libraries/MLXLMCommon/Utilities/SerialAccessContainer.swift;
Libraries/MLXLMCommon/Documentation.docc/{using,upgrade,porting,developing,kv-cache-quantization,wired-memory}.md;
Libraries/MLXLLM/{LLMModelFactory,LLMModel}.swift, Libraries/MLXLLM/Models/Llama.swift;
Libraries/MLXVLM/{VLMModelFactory,VLMModel,Gemma4AssistantRegistration,MediaProcessing}.swift,
Libraries/MLXVLM/README.md;
Libraries/MLXEmbedders/{ModelFactory,EmbedderModelContainer,EmbeddingModel,Pooling}.swift;
Libraries/MLXHuggingFace/{Macros,FoundationModelsMacros}.swift,
Libraries/MLXHuggingFaceMacros/HuggingFaceIntegrationMacros.swift;
Libraries/IntegrationTestHelpers/IntegrationTestHelpers.swift;
Tests/MLXLMTests/{TestTokenizer,ToolTests,EvalTests,ChatSessionTests,KVCacheTests}.swift.
ml-explore/mlx-swift-examples @ 378f244 (2026-06-16).
Applications/LLMBasic/{ChatModel,ContentView,LLMBasicApp}.swift + README;
Applications/MLXChatExample/{Services/MLXService,ViewModels/ChatViewModel,ChatView}.swift;
Applications/LLMEval/{ViewModels/LLMEvaluator,Services/ToolExecutor,Models/ToolDefinitions}.swift
+ README; Tools/llm-tool/{LLMTool,Chat,LoraCommands}.swift;
Tools/embedder-tool/EmbedderRuntime+Embedding.swift; the 3.x migration diff in commit 357c97f.
Issue trackers, gh CLI pass dated 2026-07-27, ml-explore/{mlx, mlx-lm, mlx-swift-lm,
mlx-swift-examples}. Swift-side threads read in full or in part: #312, #406, #424, #420,
#443, #450, #259, #279, #294, #221, #432, #433, #441, #466, #474; PRs #358
(open), #383, #399, #411, #415, #434, #435, #438, #439, #455, #456, #464.
Python-side threads used for cross-language comparison and for numbers explicitly attributed as
community-measured: mlx#3896, mlx#3897, mlx-lm#1280, #1332, #1438, #1470, #1494,
#1566, #1573, #1583, #1587, #1588.
Release metadata: mlx-swift-lm 3.32.3, MLX 0.32.3, mlx-lm 0.32.0 (checked 2026-10-07). Source inclusion is recorded separately from runtime verification.
Last verified against source: 2026-07-28. mlx-swift-lm main moves weekly and this package's
issue tracker is active; treat every "OPEN" status here as a snapshot and re-check before you build
a workaround around it.