Bridgeando async Swift a sync C: cuatro funciones para usar el LLM de Apple desde cualquier lenguaje

| Última modificación: 4 de mayo de 2026 | Tiempo de Lectura: 7 minutos
Premios Blog KeepCoding 2025

Co-Fundador de KeepCoding

El framework de Foundation Models de Apple (macOS 26) expone un LLM de ~3B parámetros que corre on-device, gratis, sin API key. Hay un problema: solo habla Swift. Y no cualquier Swift — Swift async.

¿Tu tooling está en Python? No hay binding. ¿En Rust? Tampoco. ¿Un script de shell? Ni opción. El LLM más barato del mundo (literalmente gratis) está encerrado detrás de dos barreras: el lenguaje y el modelo de concurrencia.

La solución obvia es montar un servidor HTTP local que exponga el modelo como una API REST, tipo Ollama. Pero eso es usar un cañón para matar una mosca. Un proceso corriendo, un puerto ocupado, JSON de ida y vuelta, un curl por cada pregunta trivial. Para clasificar un commit como fix o feat, no necesitas HTTP. Necesitas una función C.

La dylib de 4 funciones

Así que hice libfoundationmodels: una librería dinámica que compila el framework de Apple en una .dylib que exporta exactamente 4 funciones C:

int32_t fm_init(void);
int32_t fm_is_available(void);
int32_t fm_generate(system, prompt, output, output_len);
int32_t fm_classify(system, prompt, choices, output, output_len);

Bueno, 5 si cuentas fm_generate_json, pero la idea es la misma. Cuatro conceptos: inicializar, comprobar disponibilidad, generar texto libre, y clasificar forzando una respuesta entre opciones. Todo síncrono. Todo bloqueante. Buffer de entrada, buffer de salida, código de retorno. C de toda la vida.

Desde Python, usarlo es esto:

import ctypes

fm = ctypes.CDLL("libfoundationmodels.dylib")
fm.fm_init()

buf = ctypes.create_string_buffer(256)
fm.fm_classify(
    None,                                    # sin system prompt
    b"fix: handle nil response in OAuth",    # el texto a clasificar
    b"fix\nfeat\nrefactor\ntest\ndocs",      # las opciones
    buf, 256
)
print(buf.value.decode())  # "fix"

Eso es todo. Sin requests. Sin urllib. Sin JSON. Sin servidor corriendo. Una llamada a una función C que internamente despierta el Neural Engine de tu Mac, le pasa el texto, y te devuelve la respuesta en un buffer. Latencia típica: 200-800ms.

El problema interesante: async Swift en una API síncrona C

Aquí viene la parte divertida. El framework de Foundation Models es async:

let result = try await session.respond(to: prompt)

Ese await es el problema. C no sabe qué es await. C no tiene structured concurrency. C tiene funciones que entran por arriba, hacen algo, y salen por abajo. Punto.

Necesitas un bridge que convierta una llamada async en una llamada bloqueante. Es decir: el hilo de C tiene que quedarse parado esperando hasta que el async de Swift termine.

La solución clásica es un semáforo. Pero en Swift 6, con strict concurrency activado, usar un DispatchSemaphore dentro de código async es territorio peligroso. El compilador te va a mirar con cara de pocos amigos porque un semáforo puede bloquear un hilo del cooperative thread pool, y eso es exactamente lo que Swift 6 intenta prevenir.

Veamos cómo se resuelve:

private func blockingCall<T: Sendable>(
    _ body: @Sendable @escaping () async -> T?
) -> T? {
    let box = Mutex<T?>(nil)
    let semaphore = DispatchSemaphore(value: 0)

    Task {
        let value = await body()
        box.withLock { $0 = value }
        semaphore.signal()
    }

    semaphore.wait()
    return box.withLock { $0 }
}

Hay tres piezas que trabajan juntas:

  1. Mutex<T?> (del framework Synchronization de Swift, disponible desde macOS 15). Es un lock que protege el valor de retorno. ¿Por qué no un simple var? Porque el Task escribe desde un hilo y el semaphore.wait() lee desde otro. Sin el mutex, tienes un data race. Swift 6 te lo cantaría en la cara.

  2. DispatchSemaphore. El mecanismo de señalización: el hilo de C se bloquea en .wait(), y el Task hace .signal() cuando termina. Aquí el truco es que el semáforo bloquea el hilo que llama (el de C), no un hilo del cooperative pool. El Task corre libre en su propio hilo cooperativo.

  3. @_cdecl. El atributo que le dice al compilador de Swift: «exporta esta función con name mangling de C». Gracias a esto, fm_generate aparece en la tabla de símbolos de la .dylib como una función C normal, invocable desde cualquier lenguaje.

La combinación es elegante porque cada pieza resuelve exactamente un problema: el mutex protege la memoria compartida, el semáforo sincroniza los hilos, y @_cdecl expone la interfaz. No hay magia — solo fontanería bien encajada.

Por qué funciona (y por qué no es un hack)

El argumento en contra de usar DispatchSemaphore en código Swift moderno es legítimo: si bloqueas un hilo del cooperative thread pool, puedes provocar un deadlock porque el pool tiene un número fijo de hilos. Si todos están bloqueados esperando semáforos, nadie puede ejecutar los Task que harían .signal().

Pero en este caso, el .wait() lo hace el hilo de C — un hilo externo que no pertenece al cooperative pool. El Task se ejecuta en el pool de Swift, hace su trabajo async, y señaliza. No hay riesgo de starvation del pool porque el hilo bloqueado no es del pool.

Es como un camarero (el hilo de C) que pide un plato a la cocina (el Task async) y se queda esperando en la barra. La cocina tiene sus propios cocineros (el thread pool) y nunca se bloquea porque un camarero esté esperando fuera. El camarero no está ocupando un fogón.

@_cdecl: el atributo que nadie documenta

Un apunte sobre @_cdecl. Nótese el guion bajo: @_cdecl, no @cdecl. El guion bajo significa «atributo interno, no estable, puede cambiar sin aviso». Lleva así desde Swift 2, y es la forma estándar de facto de exportar funciones Swift a C.

Swift Evolution aprobó la propuesta SE-0495 para formalizar @cdecl (sin guion bajo) como parte del lenguaje. En Swift 6.2 ya está el soporte inicial, y en 6.3 se estabiliza junto con una nueva familia de atributos @c para interop C/C++.

¿Significa que @_cdecl va a dejar de funcionar? No a corto plazo. Pero si estás construyendo algo que quieras mantener, migra a @cdecl en cuanto tu toolchain lo soporte. El comportamiento es el mismo; solo cambia el nombre.

Clasificación forzada: el truco de los choices

La función más útil de la librería no es fm_generate (texto libre). Es fm_classify:

fm_classify(
    "You classify git commit messages.",     // system prompt
    "fix: handle nil in OAuth refresh",      // texto a clasificar
    "fix\nfeat\nrefactor\ntest\ndocs\nchore", // opciones válidas
    buffer, sizeof(buffer)
);

Internamente, fm_classify hace algo que el framework de Apple no ofrece directamente a nivel C: construye un prompt que fuerza al modelo a elegir una de las opciones, y luego valida la respuesta:

let constrainedPrompt = """
    \(promptStr)

    You MUST reply with exactly one of these values, nothing else:
    \(choiceList.joined(separator: "\n"))
    """

// Después de generar, validar:
if choiceList.contains(where: {
    $0.caseInsensitiveCompare(raw) == .orderedSame
}) {
    return raw
}
// Fallback: buscar la opción dentro de la respuesta
return choiceList.first {
    raw.localizedCaseInsensitiveContains($0)
} ?? raw

Esto es constrained generation para pobres: no usas el guided generation de @Generable (que requiere definir un struct Swift), sino que le dices al modelo «elige una de estas» y luego verificas que lo hizo. Si el modelo responde «The answer is fix» en vez de «fix», el fallback lo detecta.

¿Es tan robusto como @Generable? No. Pero desde C no puedes definir un struct @Generable. Y para clasificación simple — que es el 80% de los casos de uso de tooling — funciona.

Smoke tests: 9 de 9

Los smoke tests son un fichero C de 78 líneas que verifica:

  1. Que fm_is_available() devuelve 0 o 1 (no basura)
  2. Que fm_init() retorna 0 si el modelo está disponible
  3. Que fm_classify devuelve bytes positivos y un buffer no vacío
  4. Que la clasificación produce una respuesta razonable
  5. Que fm_generate produce texto
  6. Que un buffer demasiado pequeño devuelve -3 (truncado), no un crash
  7. Que fm_generate_json produce JSON válido

9 aserciones, 9 pasan. En un Mac con Apple Intelligence activado, make test tarda unos 3 segundos. Si no tienes Apple Intelligence, los tests de generación se skipean y solo se verifica que fm_is_available() devuelve 0. La librería no crashea en hardware sin soporte — simplemente devuelve -1.

¿Por qué no un servidor HTTP?

Es la pregunta obvia. Ollama, LM Studio, llama.cpp — todos exponen modelos como servidores HTTP locales. ¿Por qué una dylib C es mejor?

Servidor HTTP dylib C
Latencia ~10-50ms overhead (TCP + JSON parse) ~0 (llamada a función)
Proceso Necesita un daemon corriendo Se carga bajo demanda
Dependencias Puerto libre, HTTP client Una línea de ctypes/extern "C"
Integración HTTP desde cualquier lenguaje FFI desde cualquier lenguaje
Overhead memoria Proceso separado (~50-200MB) Se carga en tu proceso

Para un servicio que atiende múltiples clientes concurrentes, un servidor HTTP tiene sentido. Pero para tooling de desarrollo — un pre-commit hook, un script de CI local, una barra de menú — no necesitas un servidor. Necesitas una función que llamas, que te devuelve una respuesta, y que desaparece.

Es la diferencia entre instalar PostgreSQL para guardar una lista de la compra y usar SQLite. A veces la solución sencilla es la correcta.

Cómo usarlo en tu lenguaje

La librería produce un fichero: libfoundationmodels.dylib. Un header: foundationmodels.h. Cualquier lenguaje con soporte FFI para C puede usarla:

  • Python: ctypes.CDLL("libfoundationmodels.dylib")
  • Rust: extern "C" { fn fm_classify(...) -> i32; }
  • Go: // #cgo LDFLAGS: -lfoundationmodels + import "C"
  • Ruby: FFI::Library con ffi_lib "foundationmodels"
  • Node.js: ffi-napi o node-ffi

El patrón es idéntico en todos: cargar la dylib, declarar las firmas, llamar a la función, leer el buffer. Si sabes usar ctypes en Python o extern "C" en Rust, ya sabes usar esto.

Lo que complementa (y lo que no reemplaza)

Esta librería no reemplaza a Ollama ni a llama.cpp. No ejecuta modelos arbitrarios, no soporta LoRA, no tiene streaming. Es un wrapper minimalista para el modelo que Apple ya te dio, para el caso de uso específico de tooling donde quieres clasificación rápida, generación corta, o JSON estructurado sin montar infraestructura.

Si el post anterior era «tu Mac tiene un LLM gratis y no lo usas», este es «y ahora lo puedes usar desde cualquier lenguaje, no solo Swift». La capa 1 de la arquitectura de modelos por capas acaba de abrirse a todo el ecosistema.

Cuatro funciones C. Una dylib. Sin servidor. Sin API key. Sin dependencias. A veces la mejor herramienta es la que no necesita manual de instrucciones.

Pruébalo

git clone https://github.com/frr/libfoundationmodels
cd libfoundationmodels
make test       # 9 smoke tests (~3s)
make examples   # ejemplo en C
python3 examples/classify.py  # ejemplo en Python

Requisitos: Apple Silicon, macOS 26, Apple Intelligence activado. Si no tienes macOS 26, los tests se skipean en vez de fallar.


Read this article in English.

Noticias recientes del mundo tech


¡CONVOCATORIA ABIERTA!

Desarrollo de apps móviles ios & Android

Full Stack Bootcamp

Clases en Directo | Profesores en Activo | Temario 100% actualizado

Descárgate también el informe de tendencias en el mercado laboral 2026.

Fórmate con planes adaptados a tus objetivos y logra resultados en tiempo récord.
KeepCoding Bootcamps
Resumen de privacidad

Esta web utiliza cookies para que podamos ofrecerte la mejor experiencia de usuario posible. La información de las cookies se almacena en tu navegador y realiza funciones tales como reconocerte cuando vuelves a nuestra web o ayudar a nuestro equipo a comprender qué secciones de la web encuentras más interesantes y útiles.