Echte Inferenz — MLXClient und das lokale Modell

Echte Inferenz — MLXClient und das lokale Modell

Artikel 2 hat den Protokoll-Handshake implementiert: zwei Endpoints, zwei Formate, Mock-Antworten. Jetzt hängt ein echtes Modell ans andere Ende. Der MLXClient verbindet unser Hummingbird-Gateway mit mlx_lm.server, einem in Python geschriebenen, OpenAI-kompatiblen Inferenz-Server, der MLX-Modelle auf Apple Silicon lädt. Nach diesem Artikel liefert das Gateway echte Antworten.

mlx_lm.server installieren und starten

Einmalige Einrichtung — Apple Silicon (M-Chip), macOS 26, Python 3.11+ vorausgesetzt.

1. Installation

uv tool install mlx-lm

2. Server starten

mlx_lm.server --model mlx-community/Qwen3-8B-4bit --port 8081

Beim ersten Start wird das Modell heruntergeladen (~5 GB). Danach startet der Server sofort. Der Default-Port von mlx_lm.server ist 8080; wir verwenden 8081, damit unser Gateway weiterhin auf 8080 laufen kann.

3. Verifikation

curl http://localhost:8081/v1/models

Liefert eine Liste der geladenen Modelle. Wenn das klappt, ist der Server bereit.

Modellempfehlungen

ModellRAMNotiz
mlx-community/Llama-3.2-3B-Instruct-4bit~3 GBSchnell, gut für 8-GB-Macs
mlx-community/Qwen3-8B-4bit~6 GBSweet Spot für Coding-Aufgaben
mlx-community/Mistral-7B-Instruct-v0.3-4bit~5 GBEnglisch-stark, gute Instruktionsbefolgung
mlx-community/Qwen3-14B-4bit~10 GBM3 Max+, merklich höhere Antwortqualität
Linux: Ollama als Backend

Alternative für Linux-Server — mlx_lm.server läuft nur auf Apple Silicon.

mlx_lm.server ist macOS-only. Auf Linux nimmt man stattdessen Ollama, das dieselbe OpenAI-kompatible API exponiert und dasselbe Gateway ohne Änderungen nutzt.

1. Ollama installieren

curl -fsSL https://ollama.com/install.sh | sh

2. Modell laden

ollama pull qwen3:8b

3. Gateway starten (zeigt auf Ollama)

swift run gateway \
  --mlx-url http://localhost:11434 \
  --mlx-model qwen3:8b

Ollama läuft standardmäßig auf Port 11434 und antwortet unter /v1/chat/completions. Das Gateway merkt den Unterschied nicht.

Zwei Server, ein Datenfluss

Ab diesem Artikel laufen zwei lokale Prozesse:

  • mlx_lm.server auf Port 8081: lädt das Modell, verwaltet den Kontext, rechnet Inferenz
  • swift-mlx-gateway auf Port 8080: nimmt Requests von Clients entgegen, übersetzt Formate, routet zum Backend

mlx_lm.server ist OpenAI-kompatibel: es akzeptiert POST /v1/chat/completions im OpenAI-Format. Das ist der Klebstoff. Unser Gateway spricht nach außen beide Protokolle (Anthropic und OpenAI), nach innen immer nur OpenAI. Die Konvertierung passiert im Gateway, bevor die Anfrage das erste Mal mlx_lm.server erreicht.

Datenfluss: Clients → Gateway → MLXClient → mlx_lm.server

mlx_lm.server nutzt das model-Feld im Request als HuggingFace-Repo-ID. Das heißt: was wir per --mlx-model angeben, muss exakt dem entsprechen, was mlx_lm.server beim Start geladen hat. Der Gateway sendet diesen Wert auch unverändert in /v1/models, damit Clients wissen, welches Modell verfügbar ist.

MLXClient

Der MLXClient ist ein Swift actor. Das ist keine Stilentscheidung, sondern eine Notwendigkeit: Route-Handler laufen auf verschiedenen Tasks, und ein actor sorgt dafür, dass der Zugriff auf den internen Zustand (URLSession-Konfiguration, Logger) serialisiert wird, ohne dass wir selbst Locks schreiben müssen.

//  Sources/gateway/MLX/MLXClient.swift

import Foundation
import Logging

enum MLXError: Error, LocalizedError {
    case invalidURL(String)
    case backendUnavailable(String)
    case inferenceError(Int, String)
    case decodingError(String)

    var errorDescription: String? {
        switch self {
        case .invalidURL(let url):
            return "Invalid MLX backend URL: \(url)"
        case .backendUnavailable(let reason):
            return "MLX backend unavailable: \(reason)"
        case .inferenceError(let status, let body):
            return "MLX backend returned HTTP \(status): \(body)"
        case .decodingError(let detail):
            return "Failed to decode MLX response: \(detail)"
        }
    }
}

actor MLXClient {
    private let baseURL: String
    private let session: URLSession
    private let logger: Logger

    init(baseURL: String, logger: Logger) {
        self.baseURL = baseURL.hasSuffix("/") ? String(baseURL.dropLast()) : baseURL
        self.logger = logger

        let config = URLSessionConfiguration.default
        config.timeoutIntervalForRequest = 120
        config.timeoutIntervalForResource = 300
        self.session = URLSession(configuration: config)
    }

    func complete(
        messages: [ChatMessage],
        model: String,
        maxTokens: Int = 1024,
        temperature: Double = 1.0
    ) async throws -> (text: String, inputTokens: Int, outputTokens: Int) {
        guard let url = URL(string: "\(baseURL)/v1/chat/completions") else {
            throw MLXError.invalidURL(baseURL)
        }

        let body = ChatCompletionRequest(
            model: model,
            messages: messages,
            maxTokens: maxTokens,
            temperature: temperature,
            topP: nil,
            stream: false,
            stop: nil,
            presencePenalty: nil,
            frequencyPenalty: nil,
            user: nil
        )

        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.httpBody = try JSONEncoder().encode(body)

        logger.debug("MLX request", metadata: [
            "model": .string(model),
            "messages": .string("\(messages.count)"),
        ])

        let (data, response): (Data, URLResponse)
        do {
            (data, response) = try await session.data(for: request)
        } catch let urlError as URLError {
            throw MLXError.backendUnavailable(urlError.localizedDescription)
        }

        guard let http = response as? HTTPURLResponse else {
            throw MLXError.backendUnavailable("Non-HTTP response")
        }
        guard http.statusCode == 200 else {
            let errorBody = String(data: data, encoding: .utf8) ?? "(no body)"
            throw MLXError.inferenceError(http.statusCode, errorBody)
        }

        let decoded: ChatCompletionResponse
        do {
            decoded = try JSONDecoder().decode(ChatCompletionResponse.self, from: data)
        } catch {
            throw MLXError.decodingError(error.localizedDescription)
        }

        let text = decoded.choices.first?.message.content ?? ""
        let inputTokens = decoded.usage.promptTokens
        let outputTokens = decoded.usage.completionTokens

        logger.debug("MLX response", metadata: [
            "input_tokens": .string("\(inputTokens)"),
            "output_tokens": .string("\(outputTokens)"),
        ])

        return (text: text, inputTokens: inputTokens, outputTokens: outputTokens)
    }
}

Drei Details sind erwähnenswert.

Die Timeouts: timeoutIntervalForRequest=120s gilt pro Request. Lokale Inferenz eines 8B-Modells dauert je nach Anfrage zwischen 2 und 60 Sekunden, in seltenen Fällen länger. 120 Sekunden ist ein praktikabler Grenzwert. timeoutIntervalForResource=300s ist die harte Gesamtobergrenze pro Connection.

Das Return-Tuple (text, inputTokens, outputTokens) bringt echte Token-Counts vom MLX-Tokenizer. Das sind keine Heuristiken mehr, sondern die tatsächlichen Werte aus der Modellantwort, die wir 1:1 an den Client weitergeben.

stream: false ist explizit gesetzt. mlx_lm.server defaultet zwar auf non-streaming, aber wir machen unsere Absicht klar. Streaming kommt in Artikel 4.

Formatkonvertierung: Anthropic zu OpenAI

mlx_lm.server spricht ausschließlich OpenAI. Anthropic-Anfragen müssen konvertiert werden, bevor sie das Backend erreichen. Die Funktion toOpenAIMessages(from:) macht genau das:

private func toOpenAIMessages(from request: MessageRequest) -> [ChatMessage] {
    var result: [ChatMessage] = []
    if let system = request.system {
        result.append(ChatMessage(role: .system, content: system.asText))
    }
    result.append(contentsOf: request.messages.map {
        ChatMessage(role: $0.role == .user ? .user : .assistant, content: $0.content.asText)
    })
    return result
}

Der Anthropic-system-Prompt ist ein eigenes Top-Level-Feld. OpenAI kennt kein solches Feld; der System-Prompt kommt als erste Message mit role: "system". Wir fügen ihn an den Anfang des Arrays ein.

AnthropicContent.asText normalisiert den content-Wert zu einem String, egal ob er als einfacher String oder als Block-Array ankam. Das ist der Moment, an dem der strukturelle Unterschied zwischen den Protokollen aus Artikel 2 relevant wird: was der Client schickt, muss für das Backend eingeebnet werden.

OpenAI-Anfragen benötigen keine Konvertierung; payload.messages wird direkt an mlxClient.complete() übergeben.

Fehler-Mapping mit withMLXError

MLXError kennt vier Fehlerfälle. Damit sie als sinnvolle HTTP-Antworten beim Client ankommen, werden sie in Hummingbird-HTTPError übersetzt:

private func withMLXError<T>(_ operation: () async throws -> T) async throws -> T {
    do {
        return try await operation()
    } catch let error as MLXError {
        switch error {
        case .backendUnavailable:
            throw HTTPError(.serviceUnavailable, message: error.localizedDescription)
        case .inferenceError:
            throw HTTPError(.badGateway, message: error.localizedDescription)
        case .invalidURL, .decodingError:
            throw HTTPError(.internalServerError, message: error.localizedDescription)
        }
    }
}

503 Service Unavailable: mlx_lm.server ist nicht erreichbar. Connection refused, Timeout, DNS-Fehler — alles landet hier. Der Client weiß: das Backend ist temporär nicht verfügbar, es lohnt sich, es nochmal zu versuchen.

502 Bad Gateway: mlx_lm.server ist erreichbar, antwortet aber mit einem Fehler-Statuscode. Das kann ein Modell-interner Fehler sein oder ein Request, den das Modell ablehnt.

500 Internal Server Error: Fehler auf unserer Seite. Ungültige URL-Konfiguration oder ein Response, den wir nicht dekodieren konnten.

Nicht-MLXError-Fehler (z.B. Hummingbird-eigene Decode-Fehler) werden vom withMLXError-Wrapper nicht abgefangen und propagieren normal aufwärts.

Router und Application

buildRouter bekommt jetzt mlxClient und modelID als Parameter. Die Route-Handler sind dadurch deutlich schlanker:

// Additions and changes in Application+build.swift
protocol AppArguments {
    var host: String { get }
    var port: Int { get }
    var logLevel: Logger.Level { get }
    var mlxURL: String { get }
    var mlxModel: String { get }
}

func buildApplication(_ args: some AppArguments) async throws -> some ApplicationProtocol {
    var logger = Logger(label: "swift-mlx-gateway")
    logger.logLevel = args.logLevel

    let mlxClient = MLXClient(baseURL: args.mlxURL, logger: logger)
    let router = buildRouter(mlxClient: mlxClient, modelID: args.mlxModel)
    // ...
}
// Anthropic Messages API route (aus Router+build.swift)
router.post("v1/messages") { request, context -> MessageResponse in
    let payload = try await request.decode(as: MessageRequest.self, context: context)
    try validate(payload)
    let messages = toOpenAIMessages(from: payload)
    let result = try await withMLXError {
        try await mlxClient.complete(
            messages: messages,
            model: modelID,
            maxTokens: payload.maxTokens,
            temperature: payload.temperature ?? 1.0
        )
    }
    return buildAnthropicResponse(
        text: result.text,
        model: modelID,
        inputTokens: result.inputTokens,
        outputTokens: result.outputTokens
    )
}

Fünf Schritte: decode, validate, convert, complete, build response. Die Komplexität liegt in den extrahierten Helpers, nicht im Route-Body.

Die zwei neuen CLI-Argumente in App.swift:

@Option(help: "Base-URL des lokalen MLX-Inferenz-Servers (mlx_lm.server)")
var mlxURL: String = "http://localhost:8081"

@Option(help: "MLX-Modell-ID (muss mit mlx_lm.server --model übereinstimmen)")
var mlxModel: String = "mlx-community/Qwen3-8B-4bit"

--mlx-model muss exakt mit der Modell-ID übereinstimmen, die mlx_lm.server beim Start mit --model geladen hat. Der Gateway sendet diesen Wert sowohl im Anthropic- als auch im OpenAI-Format an mlx_lm.server weiter und zeigt ihn unter /v1/models an. Was Clients in ihrem Request als model-Feld schicken, ignoriert der Gateway: er nimmt immer den per --mlx-model konfigurierten Wert für den Backend-Aufruf.

Starten und testen

Zwei Terminals:

# Terminal 1: MLX-Backend
mlx_lm.server --model mlx-community/Qwen3-8B-4bit --port 8081

# Terminal 2: Gateway
swift run gateway \
  --mlx-url http://localhost:8081 \
  --mlx-model mlx-community/Qwen3-8B-4bit

Anthropic-Endpoint:

curl -s -X POST localhost:8080/v1/messages \
  -H "Content-Type: application/json" \
  -d '{
    "model": "mlx-community/Qwen3-8B-4bit",
    "max_tokens": 1024,
    "messages": [{"role": "user", "content": "Schreib einen Haiku über Swift."}]
  }' | jq '.content[0].text'

Das Modell antwortet mit einem echten Haiku. Token-Counts in der Response sind die tatsächlichen Werte aus dem MLX-Tokenizer.

Wichtig bei Reasoning-Modellen wie Qwen3 oder DeepSeek-R1: max_tokens muss großzügig dimensioniert sein. Der Reasoning-Trace (denken vor der Antwort) verbraucht selbst schon mehrere hundert Tokens. Mit 256 Tokens reicht es oft nicht für die eigentliche Antwort. 1024 ist eine sinnvolle Untergrenze; bei komplexen Anfragen 2048 oder mehr.

Qwen3 unterstützt zusätzlich einen Schalter, der das Reasoning komplett überspringt: den Marker /no_think an die User-Message anhängen. Der Tokenizer erkennt das, gibt einen leeren Thinking-Block zurück und springt direkt zur Antwort. Praktisch für kurze Anfragen, bei denen das Reasoning nicht gebraucht wird:

{"role": "user", "content": "Schreib einen Haiku über Swift. /no_think"}

OpenAI-Endpoint:

curl -s -X POST localhost:8080/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "mlx-community/Qwen3-8B-4bit",
    "messages": [{"role": "user", "content": "Erkläre Structured Concurrency in zwei Sätzen."}]
  }' | jq '.choices[0].message.content'

Claude Code gegen das lokale Backend:

export ANTHROPIC_BASE_URL=http://localhost:8080
claude

Anders als in Artikel 2 kommen jetzt echte Antworten. Das Modell versteht Kontext, folgt Instruktionen und gibt sinnvolle Ausgaben.

Verhalten ohne laufendes Backend

Wenn mlx_lm.server nicht läuft und eine Anfrage eingeht:

curl -s -i -X POST localhost:8080/v1/messages \
  -H "Content-Type: application/json" \
  -d '{"model":"x","max_tokens":256,"messages":[{"role":"user","content":"test"}]}'
HTTP/1.1 503 Service Unavailable
{"error":{"message":"MLX backend unavailable: Could not connect to the server."}}

Das Gateway crasht nicht. Es protokolliert den Fehler, antwortet dem Client mit 503, und ist sofort wieder bereit für den nächsten Request. Wenn mlx_lm.server danach gestartet wird, funktioniert der nächste Request ohne Gateway-Neustart.

Commit und Tag

git add .
git commit -m "article-03: MLXClient — echte Inferenz via mlx_lm.server"
git tag article-03
git push origin main --tags

Sechs Dateien, echte Inferenz

DateiÄnderung
App.swift--mlx-url, --mlx-model hinzugefügt
Application+build.swiftMLXClient erstellt, an Router übergeben
Router+build.swiftMock-Funktionen entfernt, Konvertierung + Fehler-Mapping
MLX/MLXClient.swiftNeu: actor-basierter HTTP-Client
Models/Anthropic.swiftUnverändert
Models/OpenAI.swiftUnverändert

Die Codable-Typen aus Artikel 2 sind der stabile Vertrag. Sie wurden für Artikel 3 kein einziges Mal angefasst.

In Artikel 4 kommt Streaming. Die Antwort erscheint Token für Token statt als Block, wie es Claude Code und andere Tools erwarten.

Quellen