Modelos
Nombrar un modelo
Agent("a", model="openai-compatible:qwen3-8b")
El prefijo dice quién normaliza la respuesta, no solo qué modelo se quiere. Es nuestro y no
viaja por el cable: al proveedor le llega qwen3-8b a secas.
Las tres puertas
El agente no sabe cuál tiene, y eso es la propiedad, no comodidad.
Sin inferencia — el doble
Por defecto. Ver Probar.
Transporte HTTP directo
from synaptum import HttpModel, LocalGateway
modelo = HttpModel("http://localhost:8080/v1", api_key=None)
gateway = LocalGateway(model=modelo, stream=modelo.stream, tools=[...])
Solo stdlib — urllib en un hilo, porque el núcleo no tiene dependencias y no las va a tener por
esto. Vale para cualquier endpoint OpenAI-compatible: vLLM, Ollama, LM Studio, llama.cpp o la API de
OpenAI.
Es transporte de desarrollo. En el camino gobernado la llamada al modelo no ocurre en este proceso: sale por la costura hacia quien tiene las credenciales y puede denegar. Un transporte dentro del proceso tiene la clave en su memoria y llama a quien le digan.
Un SDK de plataforma
Cuando la inferencia está gobernada y un SDK es la única puerta legítima —credenciales, catálogo, cuotas y facturación viven ahí— tiene prioridad. Ir al endpoint por detrás se salta todo eso.
from synaptum.providers.axonium import AxoniumModel # pip install synaptum[axonium]
puente = AxoniumModel()
gateway = LocalGateway(model=puente.complete, stream=puente.stream, tools=[...])
Esto no es un Provider: un Provider normaliza y no transporta, y un SDK así hace las dos
cosas. Lo que falta es traducir entre dos vocabularios que ya están normalizados.
Escribir un adaptador
Provider es un Protocol de tres funciones puras: to_wire, from_wire, stream_from_wire.
No abren conexiones ni leen credenciales.
Que la normalización sea separable del transporte es lo que permite ejercitarla contra un fichero —sin servidor, sin red, sin gastar— y es la razón de que el corpus dorado exista.
Los adaptadores se descubren por entry points, así que el núcleo no conoce a ninguno.
Usage tiene tres estados
Usage(input=100, output=20, cache_read=80, estimated=True)
# reasoning=None, cache_write=None ← nadie los midió
None |
Nadie lo midió. |
0 |
Se midió y fue cero. |
estimated=True |
Derivado, no reportado. |
Confundir None con 0 es lo que hace creer que escribir en caché es gratis, y el bucle decide mal
en cada compactación. Lo desconocido se propaga al sumar: si un tramo no midió un contador, el total
de ese contador es None — sumar solo lo conocido daría una cota inferior con aspecto de cifra
exacta.
input es inclusivo: contiene los tokens servidos desde caché, y cache_read dice cuántos.
Confirmado contra grabaciones reales, donde prompt_tokens == prompt_n + cache_n.
El prefijo estable
Los proveedores cachean prefijos exactos. Medido contra un despliegue real, con un prefijo de 2.477 tokens:
1ª llamada input=2477 cache_read=0 ← nada cacheado todavía
mismo prefijo input=2477 cache_read=2473 ← 99,8 % servido de caché
prefijo cambiado input=2479 cache_read=0 ← tres palabras al principio
Cambiar tres palabras al inicio tiró la caché entera: un prefijo que cambia en el token 10 invalida los 10.000 siguientes.
Lo que no debe cambiar dentro de un run, en este orden: el modelo, las instrucciones, el catálogo de herramientas —nombres, descripciones y esquemas, en su orden— y el formato de salida. Los mensajes van después y crecen; crecer al final no invalida nada.
Reanudar con otra configuración está prohibido
ConfigurationError: El run 'r1' se creó con otra configuración y reanudarlo con esta
mezclaría dos agentes en un mismo diario (herramientas: ['leer'] → ['leer', 'borrar']).
Reanudar es continuar ese run; una configuración distinta es otro run — usa un run_id nuevo.
Y el motivo de fondo no es el dinero. Que se pierda la caché es la consecuencia visible. La que hace daño es que la primera mitad del run la ejecutó una configuración y la segunda otra, y el journal lo registra como uno solo: una auditoría de «qué hizo el agente» devuelve una historia que ninguna configuración produjo nunca.
Reordenar herramientas cuenta como cambio, porque por el cable lo es.
Qué costó un run, y por qué
Usage dice cuánto. El informe de economía dice por qué, que es lo único accionable:
from synaptum import economy
informe = economy(await store.load("run-1"))
print(informe.report())
Run run-1 · 2 turnos
caché 43% de la entrada servida de caché
crecimiento +57 tokens de entrada por turno
prefijo estable durante todo el run
total entrada=411 salida=378 caché=177
Se calcula del journal, así que funciona sobre un run terminado, sobre uno reanudado y sin tener el agente delante. Un informe que solo se pudiera sacar mientras el run corre no serviría para lo único que hace falta: mirar ayer.
Lo que cada cifra responde
El acierto de caché se calcula sobre los totales, no como media de los turnos: una media pesaría
igual un turno de 50 tokens que uno de 50.000. Y si nadie lo midió dice sin medir, no 0 % —
confundirlas manda a alguien a arreglar lo que no está roto.
Las reescrituras de prefijo deberían ser cero. Reanudar con otra configuración ya está prohibido, pero dentro de un run el prefijo puede romperse solo: unas instrucciones con la fecha dentro lo reescriben en cada turno, y el síntoma es una caché que nunca arranca.
⚠ prefijo reescrito 1 vez — cada una tira toda la caché posterior
· 000001-model: instrucciones de sistema: cambiaron
El crecimiento dice cuánto sube la entrada por turno. Es la pendiente entre el primero y el último, no una regresión: con cinco puntos, una regresión da una cifra más precisa y no más cierta.
Lo que no hace
No exporta nada. Calcula y devuelve; quien quiera sacarlo de ahí decide con qué nombres y por
qué transporte. Las trazas de la estructura del bucle son otra cosa y viven en
synaptum.telemetry.
No habla de dinero. Los tokens se saben; los precios no, y convertirlos con una tarifa inventada daría una cifra con aspecto de exacta.
Streaming
async for evento in agente.stream(tarea, session=sesion):
if evento.kind == "text_delta":
print(evento.text, end="", flush=True)
Es el mismo bucle y el mismo journal que run(); lo único que cambia es que los fragmentos del
modelo se ceden intercalados entre la intención del paso y su resultado.
Va aparte de run() porque cambia el tipo de lo que se cede: quien consume run() hace match
sobre pasos sin una rama para lo que nunca va a llegar.
- Un paso reanudado no vuelve a emitir fragmentos. Ya se pagó; reproducir sus tokens como si estuvieran ocurriendo sería teatro.
- Un reintento vuelve a abrir el ciclo. Los fragmentos ya entregados no se retiran: se generaron y se pagaron.
Cancelar es dejar de iterar
No hay evento de cancelación, y no lo habrá: un canal que se está cerrando no es sitio para mandar el aviso de que se cierra. Cerrar el iterador cierra el cuerpo de la respuesta, y eso es lo que para la generación arriba.
flujo = agente.stream(tarea, session=sesion)
async for evento in flujo:
if suficiente(evento):
break
await flujo.aclose() # esto es la señal
Un turno que solo razona
Un modelo de razonamiento contesta a veces solo con su razonamiento: ni texto ni llamada a herramienta. Ese turno no se descarta —queda en el diario, con su razonamiento y su consumo— pero hay dos reglas sobre él, y las dos existen porque sin ellas el run se pierde o se vacía:
-
No sale al cable un sobre vacío. El razonamiento no se devuelve al proveedor, así que un mensaje del asistente que solo lo lleva no tiene nada que mandar — y
{"role": "assistant", "content": null}es un mensaje que el propio dialecto declara inválido: un servidor compatible con OpenAI lo rechaza con un 400 no reintentable, en el turno siguiente y solo cuando el modelo razonó sin hablar. Devolver el razonamiento en su lugar tampoco vale, y está medido contra un servidor real: el dialecto pidecontentotool_calls, y no acepta razonamiento como sustituto de ninguno de los dos. Tampoco se mandacontent: "": hay dialectos que rechazan un bloque de texto vacío, así que la forma «válida» dependería de quién esté al otro lado. No mandar nada vale en todos. -
Pero un turno que dijo la cadena vacía sí viaja, con
content: "". Que haya una parte de texto vacía no es lo mismo que que no haya ninguna: la primera dice «dijo la cadena vacía» y la segunda «no tiene nada que decir». El dialecto también las distingue —content: ""lo acepta el servidor;nullsintool_calls, no— así que colapsarlas cambiaría el cuerpo de peticiones que hoy funcionan.content = texto or Nonelas colapsa sin enterarse, y es como estaba escrito esto. -
Un turno que terminó solo y no trae respuesta no cierra el run. Se vuelve a preguntar, igual que con una salida que no valida. Cerrarlo devolvería la cadena vacía sin que nada falle, que es el peor de los dos resultados: lo acota
max_steps, y un modelo que nunca llega a contestar acaba enLimitExceeded, que dice lo que pasó.Solo si terminó solo. Un razonamiento que se queda sin tokens —
finish_reason: length— sí cierra el run: volver a preguntar eso es pedir la misma respuesta con el contexto más largo, se trunca igual y se paga cada intento. Un turno cortado no es un turno que no dijo nada, y la diferencia la cuentan el consumo y elfinish_reasondel paso.
Reintentos
Un error trae en su tipo si es reintentable — la decisión no es del bucle, la sabe quien habló con el proveedor. Cualquier 4xx salvo 429 no lo es; 429, 5xx, timeouts y fallos de red sí.
El bucle espera entre intentos, con espera creciente y jitter, y respeta el Retry-After del otro
extremo con un techo. Reintentar al instante no es reintentar: es repetir contra el mismo estado
roto. Y sin jitter, N agentes que caen por la misma razón reintentan a la vez y reconstruyen el pico
que los tiró.
Limits(max_retries=2, retry_base=0.5, max_retry_wait=30.0)
Salida estructurada
@dataclass
class Diagnostico:
causa: str
confianza: float
Agent("a", model=..., output=Diagnostico)
Una dataclass de stdlib basta; Pydantic es un extra para quien ya lo use.
El esquema viaja por dos caminos a la vez: en response_format y en las instrucciones. No todo
gateway admite el campo —algunos lo descartan avisando por warning— y entonces la restricción no
viaja, el modelo contesta en prosa y el error culpa al JSON. Decirlo también en el prompt cuesta
unos cientos de tokens y funciona con cualquier proveedor.
La validación ocurre dentro del reintento: un objeto mal formado no es un fallo del run, es una muestra mala, y el muestreo es estocástico.