# Un modelo que ya tenés, y una arquitectura que podés editar

> Desde la v0.6.27 el provider local embebido acepta el nombre de un modelo que ya está en tu caché de Ollama o de Hugging Face — no se descarga nada — y una arquitectura es un archivo de texto que lee el binario ya compilado, así que sumar un modelo dejó de esperar un release nuestro.

Published 2026-09-22 · https://synsema.org/es/blog/local-inference-architectures-as-files


El provider `local` corre un modelo cuantizado **dentro del proceso de Synsema**: sin servidor, sin
API key, sin socket — el único provider que funciona con la red denegada por completo. Eso ya
existía. Lo que cambia en la **v0.6.27** son las dos cosas que hacían incómodo empezar: había que
conseguir un `.gguf` y darle una ruta, y un modelo cuya arquitectura no tuviéramos compilada quedaba
directamente afuera.

## El modelo es un nombre, y no se descarga nada

```
SYNSEMA_LLM_PROVIDER=local

SYNSEMA_LLM_MODEL=/models/qwen2.5-3b-instruct-q4_k_m.gguf   # una ruta a un .gguf
SYNSEMA_LLM_MODEL=qwen3:0.6b                                # un model:tag que ya está en la caché de Ollama
SYNSEMA_LLM_MODEL=org/repo                                  # un repo que ya está en la caché de Hugging Face
```

**Ninguna de las tres baja un byte.** Si usás Ollama ya está — `ollama` no necesita ni estar
corriendo, se leen sólo sus archivos en disco. Un nombre que no está lista los tres lugares donde
buscó, con rutas reales, en vez de arrancar una descarga que nadie pidió.

`synsema llm status` dice qué hay de verdad en la máquina:

```
Modelos locales ya descargados (2):
  gemma3:270m                            ollama:gemma3:270m  sha256:735af2139dc6
  qwen3:0.6b                             ollama:qwen3:0.6b  sha256:7f4030143c1c
  Usá SYNSEMA_LLM_MODEL=<nombre> — no se descarga nada.
```

El sha256 sale gratis: el almacén de Ollama es direccionado por contenido, así que el hash de los
pesos sale del nombre del archivo y no de una segunda lectura. Importa más adelante, cuando quieras
decir *qué* pesos respondieron.

## Las arquitecturas son un archivo, no un release

Todo runtime que corre GGUF — llama.cpp, candle, Ollama — escribe cada arquitectura **a mano, en su
propio lenguaje**. Sumar una significa código, un pull request, una review y un release. Nuestro
propio PR a candle, de tres líneas y aprobado, quedó abierto más de dos meses, y con él dos
arquitecturas cuantizadas que necesitábamos. Copiar ese modelo nos convertiría a **nosotros** en el
cuello de botella de cualquiera que quiera correr un modelo al que no llegamos.

Así que en el motor escrito por nosotros una arquitectura es **un archivo de texto que el binario ya
compilado lee al arrancar**. Las operaciones — matmul, RMSNorm, RoPE, atención, SwiGLU — están
compiladas; lo que faltaba es el *orden* y los parámetros, y eso es data:

```
arch qwen3
kind decoder

prologue
  x = embed(token_embd.weight)

block
  h = rms_norm(x, blk.{i}.attn_norm.weight)
  q = matmul(h, blk.{i}.attn_q.weight)
  k = matmul(h, blk.{i}.attn_k.weight)
  v = matmul(h, blk.{i}.attn_v.weight)
  norm_heads(q, blk.{i}.attn_q_norm.weight, head_count)     # <- lo que qwen3 le agrega a llama
  norm_heads(k, blk.{i}.attn_k_norm.weight, head_count_kv)
  rope(q, head_count)
  rope(k, head_count_kv)
  a = attention(q, k, v)
  o = matmul(a, blk.{i}.attn_output.weight)
  add(x, o)
```

`block` corre una vez por capa con `{i}` reemplazado por el índice. Los tensores se llaman
**exactamente como los llama el GGUF**, así que escribir una definición es sobre todo copiar la
lista de tensores del archivo, y lo que el GGUF ya declara — cantidad de cabezas, largo del
embedding, la ventana deslizante — no se repite. Cuatro definiciones vienen embebidas en el binario
(`llama`, `qwen2`, `qwen3`, `gemma3`); `SYNSEMA_INFER_ARCHDEF=<directorio>` suma las tuyas, o
**reemplaza** las nuestras, sin ningún compilador en el medio:

```
SYNSEMA_INFER_BACKEND=rust
SYNSEMA_INFER_ARCHDEF=./archdefs
```

```
Arquitecturas que corre el backend `rust` (4):
  gemma3 (20 pasos por capa, en el binario, sha 9e02d6e4ca46)
  llama (16 pasos por capa, en el binario, sha da3951d21c0d)
  qwen2 (19 pasos por capa, en el binario, sha d48ca19bf049)
  qwen3 (18 pasos por capa, ./archdefs/qwen3.archdef, sha 5b8d9db76a68)
```

Un archivo tuyo con el nombre de uno de los nuestros **gana**, y la línea lo muestra con su ruta y
su sha, así que una sustitución nunca es silenciosa.

### No hay control de flujo, y ahí está la propiedad de seguridad

Una definición **no tiene condicionales, ni loops, ni llamadas a funciones, ni forma de abrir un
archivo, un socket o el entorno**. Describe un grafo de multiplicaciones de matrices. Correr la
definición que escribió otra persona **no corre su código**: lo peor que puede hacer una hostil es
no cargar, o dar números mal con *tus* pesos, bajo los mismos límites de recursos que cualquier
modelo. Eso es lo que hace razonable aceptar una definición de un desconocido, y es exactamente por
qué los plugins nativos (`.so`/`.dll`) nunca fueron una opción acá: serían ejecución remota de
código con pasos extra.

Un test lo hace cumplir: veintiuna palabras entre las que están `if`, `while`, `for`, `exec`,
`import`, `open`, `http` y `env` se rechazan como operaciones que no existen. El día que se agregue
control de flujo la propiedad se termina, así que no se va a agregar.

Y un archivo roto **nunca cae a los nuestros**. La primera versión sí caía, y con un `silu` mal
tipeado el modelo respondía perfecto — usando nuestra definición. Quien escribió el archivo habría
jurado que corría el suyo. Ahora esa arquitectura queda no disponible y el error nombra el archivo y
la línea:

```
[local error: no se pudo cargar 'qwen3:0.6b': el modelo declara la arquitectura 'qwen3', que este
binario no conoce.
Conocidas: gemma3, llama, qwen2.
Definiciones que no cargaron:
  - ./archdefs/qwen3.archdef: línea 28: no existe la operación `siluu` — ¿quisiste decir `silu`?]
```

## Dos motores, y por qué cambiarías

| | `candle` (default) | `SYNSEMA_INFER_BACKEND=rust` |
|---|---|---|
| Arquitecturas | llama, qwen2, qwen3 — compiladas | las mismas más **gemma3**, cada una un archivo que podés leer y reemplazar |
| Una arquitectura nueva | pide un binario nuevo | pide un **archivo de texto** |
| SIMD | se elige **al compilar**: un build común corre el camino escalar | se elige **al correr** — AVX, AVX2+FMA, AVX-512, NEON |
| RAM | ~2,6× el tamaño del `.gguf` | **~1,1×** — el archivo se mapea y los pesos quedan cuantizados |

La fila del SIMD es la que se ve como número en una máquina cualquiera. candle elige sus kernels
AVX2 con `#[cfg(target_feature)]`, y el target x86-64 por defecto no habilita AVX2, así que el
binario que bajás corre el camino escalar salvo que se recompile para tu CPU — una recompilación que
midió **~3,1×** en prefill. El motor `rust` despacha según la CPU que encuentra, así que el binario
oficial usa las instrucciones de la máquina donde cae.

La fila de la RAM es la que hace que un modelo más grande que tu memoria **corra**: con los pesos
mapeados y cuantizados, un GGUF de 523 MB cuesta 583 MB residentes contra los 1.355 MB de candle.
Pagina, va lento, termina. La generación sigue 1,37× más lenta que candle (11,1 s contra 8,1 s para
40 tokens, medido sobre el mismo archivo) porque token a token el matmul es una sola fila, y ahí lo
que domina es mover memoria.

**Cambiar de motor cambia el texto generado.** Los dos son correctos — aproximan los mismos números
de manera distinta — así que el motor es parte de lo que declarás para reproducir una salida, al
lado del binario y de los pesos. candle sigue siendo el default mientras existan los dos.

## Si corriste un GGUF de llama, Mistral o Gemma antes de la v0.6.27, sus respuestas estaban mal

Éste es el arreglo que vale leer incluso si nada de lo anterior te interesa. Los GGUF cuyo
`tokenizer.ggml.model` es `llama` — llama 1 y 2, Mistral, Gemma — guardan **rangos** de token, no
log-probabilidades, y el tokenizador los segmentaba con un Viterbi que maximiza una suma de
puntajes. `The capital of France is` entraba al modelo como **once fragmentos en vez de cinco
palabras**. Nada fallaba. El modelo simplemente respondía mal, y no había manera de notarlo desde la
salida.

Ahora es el algoritmo de referencia — fusionar el par vecino de mejor puntaje, como hace llama.cpp —
con reconocimiento literal de los tokens especiales, fallback a bytes y `add_space_prefix` leído de
los metadatos. La familia BPE (qwen, llama 3) nunca estuvo afectada.

Gemma 3 traía dos más: su activación en el MLP era `silu` y es `gelu_pytorch_tanh` (copiado del
`quantized_gemma3` de candle, que la hardcodea mientras su propio `gemma3` sin cuantizar la lee de
la config), y `<start_of_turn>` no se reconocía, así que un GGUF de gemma caía a modo plano y se
portaba como un modelo base. Con los tres arreglados, el motor genera token por token lo que genera
Ollama para el mismo prompt.

## Lo que cuesta, en serio

Está hecho para **prompts cortos**. El prefill en CPU es de ~12 tok/s, así que un prompt de 1000
tokens tarda ~90 s en un 0.5B; la generación es de ~11 tok/s en un 0.5B y ~5 tok/s en un 3B con
cuatro hilos. La carga del modelo — 7 s para un 0.5B, ~35 s para un 3B — se paga **una vez por
proceso**, así que bajo `serve` el primer request la paga y el resto la reusa (medido: 8,2 s →
1,3 s).

Dicho de otro modo: para un clasificador, un router, un reescritor, un resumen de tres párrafos, en
una máquina sin GPU y sin salida a internet, es la herramienta correcta. Para un prompt de 4.000
tokens con una respuesta larga en CPU no lo es, y ninguna configuración lo va a volver.

Esto es todo junto en una laptop, con un modelo que ya estaba en la caché de Ollama:

```
$ SYNSEMA_LLM_PROVIDER=local SYNSEMA_LLM_MODEL=gemma3:270m SYNSEMA_INFER_BACKEND=rust synsema run probe.syn
1) Apple
2) Sweet
tokens: 35
provider vivo: true
```

5,4 s de reloj para dos llamadas `reason`, carga del modelo incluida. Nada descargado, ninguna clave
en ningún lado.

## Decir qué corrió

Con `SYNSEMA_LLM_TEMPERATURE=0` (el default) la generación es greedy y repetible. Para decir *qué
exactamente* produjo una salida hacen falta tres cosas juntas, y `synsema llm status --json` las
lleva bajo una clave `inference`, con los sha completos:

| | De dónde sale |
|---|---|
| los pesos | `models_on_disk[].digest` |
| la arquitectura | `architectures[].sha256` y `.origin` |
| el motor | `backend` — candle y `rust` producen texto distinto con los mismos pesos |

La procedencia sólo sirve si se puede comparar, y comparar prosa no es comparar.

## Quién elige

El programa `.syn` nunca nombra un modelo, una arquitectura ni una ruta, y no puede hacer que el
motor lea un archivo que la configuración no habilitó. Pide generar texto; todo lo de arriba es
configuración de despliegue. Descubrir las cachés de Ollama y de Hugging Face le ofrece candidatos a
quien escribe esa configuración — no le abre el disco al programa.

La página del manual es
[Inferencia local](https://synsema.dev/es/0.6.x/52a-local-inference). El slot del juez recibió el
mismo tratamiento en el mismo release — un checkpoint en disco respondiendo preguntas tipadas sin
red y sin clave — y eso está en
[Correr el juez local con Laya](/es/blog/run-the-judge-locally-with-laya).

