System One en local: la API de Jev con Ollama y TypeScript
En febrero de 2024 alguien abrió el issue #2415 en el repositorio de Ollama. Pedía logprobs en la API, y lo justificaba en una línea: "This feature will be helpfull for apps, that use logprobs to measure model avareness and confidence".
Ese issue acumuló 95 comentarios y tardó 21 meses en cerrarse. El 28 de septiembre de 2026, Ollama 0.35.0 fue más lejos: sacó un endpoint /v1/systemone "based on TypeSafe's Jev API". Hoy montar un System One en local es un ollama pull nimble.
Lee la ficha de Nimble antes de celebrarlo: "A probability of 0.9 doesn't mean the answer is right 90% of the time on your data".
El modelo se descarga en minutos. La calibración no se descarga.
En corto: un System One en local devuelve choice, score y noul con probabilidades sacadas de los logprobs del modelo, no del texto que escribe. Ollama ya sirve esa API con modelos abiertos como Nimble, y tú puedes construirla en TypeScript sobre cualquier modelo con logprobs. Ninguna de las dos opciones viene calibrada de serie: la mides con tus datos (Brier, ECE) y la corriges con temperature scaling.
Si buscas si Jev es open source, está respondido en ¿Es Jev open source?. Aquí lo construimos y lo medimos.
¿Qué es un modelo System One?
Un modelo System One evalúa un estado (texto o JSON) y devuelve decisiones tipadas con probabilidades, en lugar de generar texto.
La clase la define TypeSafe, y Jev es su primer modelo. El nombre viene del Sistema 1 de Kahneman: juicio rápido e intuitivo, no razonamiento lento.
Tiene tres primitivas. choice elige entre opciones y devuelve la distribución completa. score coloca la respuesta en una escala ordenada y devuelve legend y probabilities. noul responde sí o no, y lo que llega es un objeto, {"type":"noul","noul":0.01}, no un número suelto.
Lo calibrado son las probabilities. El campo confidence resume lo concentrada que está la distribución, y TypeSafe lo aproxima, en su demo de tres opciones, como (3·p_max − 1)/2: sirve para enrutar, no es una probabilidad. Y la calibración se mide sobre grupos de predicciones; no garantiza que una respuesta concreta sea correcta.
System One en local con Ollama: lo que ya existe
Desde la 0.35.0, Ollama expone POST /v1/systemone con la misma forma de petición que Jev: state, model y questions. Es lo más parecido a un modelo tipo Jev en local. Los modelos son Nimble (9B, Bespoke Labs, Apache 2.0) y Tev1 (4B y 0.8B, Together AI); en la 0.35.1 llegaron Clef (27B) y Clef Flash (9B), de Cloudflare, y en la 0.40.4, Kev, de Jared Palmer (de 0.8B a 27B, con el runner MLX de Apple Silicon). Ojo: clef:cloud y clef-flash:cloud corren en la nube de Ollama, no en tu máquina.
ollama pull nimble
curl http://localhost:11434/v1/systemone -d '{
"model": "nimble",
"state": "Me cobraron dos veces la suscripción de octubre.",
"questions": {
"equipo": {
"type": "choice",
"instructions": "¿Qué equipo debe atender este ticket?",
"criteria": { "billing": "Cobros y reembolsos", "bug": "Errores del software", "account": "Acceso y cuenta" }
},
"reembolso": { "type": "noul", "instructions": "¿Pide un reembolso?" }
}
}39;
Así quedan las tres vías, con los datos publicados en octubre de 2026 en las fichas de Nimble y Tev1 y en la página de modelos de TypeSafe:
| Jev (API de TypeSafe) | Ollama /v1/systemone |
Tu System One con logprobs | |
|---|---|---|---|
| Dónde corre | Servidores de TypeSafe | Tu máquina | Tu máquina |
| Modelos | jev-1.13.0 |
Nimble 9B, Tev1 4B/0.8B, Clef 27B, Clef Flash 9B, Kev 0.8B–27B (Apple Silicon) | Cualquier modelo de Ollama con logprobs |
| Precisión publicada por Bespoke Labs (13 datasets, 3.880 decisiones) | 76,0 % | Nimble 75,7 % · Tev1 4B 73,3 % · Tev1 0.8B 63,5 % | Sin publicar: la mides tú |
| Calibración de serie | Entrenado para calibrar (RLCD) | Sus fichas no la prometen | Ninguna |
| Latencia publicada | "about 100 ms" de inferencia | Nimble: "under 100ms" en un MacBook Pro M5 Max | La mides tú |
| Coste | 0,042 USD por millón de tokens de entrada, salida gratis | Tu hardware | Tu hardware |
| Privacidad | El state viaja a la API |
No sale de tu máquina | No sale de tu máquina |
| Riesgo principal | Límites de uso que pueden cambiar sin aviso; el inglés es su idioma fuerte | Contexto corto: 8.192 tokens por pregunta en Nimble, unos 2.000 en Tev1 | Sesgo de posición y opciones fuera del top 20 |
La fila que más se malinterpreta es la de precisión. Que Nimble acierte casi tanto como Jev no dice si su 0,9 significa 90 %. Acierto y calibración son métricas distintas.
Cómo saca las probabilidades un System One local
No le pregunta al modelo cuánta confianza tiene: lee los logprobs. La confianza escrita en texto falla; Xiong et al. lo midieron: "LLMs, when verbalizing their confidence, tend to be overconfident".
El código de Ollama muestra la técnica que usa con Nimble y Tev1. Etiqueta cada opción con una letra (de la A a la Z: hasta 26 opciones, 24 en Tev1), lee la puntuación que el modelo da a cada letra como siguiente token y normaliza con softmax. score es el valor esperado sobre los niveles. noul es la probabilidad normalizada de true. Con esos dos modelos, su confidence es uno menos la entropía normalizada; con Kev usa la misma fórmula que TypeSafe. Ninguna es una probabilidad de acierto.
No es una idea nueva. En ese mismo issue #2415, un ingeniero de Crisp contaba: "At Crisp, we utilize log-probs with fine-tuned models for binary classification on highly complex queries".
Construye choice, score y noul con logprobs en Ollama
Con la API de logprobs en Ollama, disponible desde la 0.12.11, tienes un System One sobre cualquier modelo instruct. Este archivo no tiene dependencias:
// system-one.ts — Bun o Node 18+ · Ollama >= 0.12.11
const OLLAMA = "http://localhost:11434/api/chat";
const MODEL = "gemma3:4b";
const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
const FLOOR = -30; // logprob para una opción que no aparece en el top 20
export function softmax(logits: number[], T = 1): number[] {
const z = logits.map((l) => l / T);
const peak = Math.max(...z);
const e = z.map((v) => Math.exp(v - peak));
const sum = e.reduce((a, b) => a + b, 0);
return e.map((v) => v / sum);
}
// Un logprob por opción, leído del primer token que genera el modelo
export async function optionLogprobs(state: string, question: string, options: string[]) {
const prompt = [
state,
`Pregunta: ${question}`,
...options.map((o, i) => `${LETTERS[i]}) ${o}`),
"Responde solo con la letra de la opción.",
].join("\n");
const res = await fetch(OLLAMA, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: MODEL,
messages: [{ role: "user", content: prompt }],
stream: false,
logprobs: true,
top_logprobs: 20, // máximo que acepta Ollama
options: { temperature: 0, num_predict: 1 },
}),
});
if (!res.ok) throw new Error(`Ollama ${res.status}: ${await res.text()}`);
const data = await res.json();
const top: { token: string; logprob: number }[] = data.logprobs?.[0]?.top_logprobs ?? [];
const logits = options.map((_, i) => {
// "A" y " A" son tokens distintos: se suman sus probabilidades
const isLetter = new RegExp(`^\\s*${LETTERS[i]}(?![A-Za-z])`); // "A", " A", "A)"
const p = top
.filter((t) => isLetter.test(t.token))
.reduce((acc, t) => acc + Math.exp(t.logprob), 0);
return p > 0 ? Math.log(p) : FLOOR;
});
if (logits.every((l) => l === FLOOR)) throw new Error("El modelo no respondió con una letra");
return logits;
}
export async function choice(state: string, question: string, criteria: Record<string, string>) {
const keys = Object.keys(criteria);
const logits = await optionLogprobs(state, question, keys.map((k) => `${k}: ${criteria[k]}`));
const p = softmax(logits);
return {
type: "choice" as const,
choice: keys[p.indexOf(Math.max(...p))],
probabilities: Object.fromEntries(keys.map((k, i) => [k, p[i]])),
logits,
};
}
export async function score(state: string, question: string, levels: string[]) {
const logits = await optionLogprobs(state, question, levels.map((l, i) => `nivel ${i}: ${l}`));
const p = softmax(logits);
return {
type: "score" as const,
score: p.reduce((acc, pi, i) => acc + i * pi, 0), // valor esperado
legend: Object.fromEntries(levels.map((l, i) => [String(i), l])),
probabilities: Object.fromEntries(p.map((pi, i) => [String(i), pi])),
logits,
};
}
export async function noul(state: string, question: string) {
const logits = await optionLogprobs(state, question, ["false: no se cumple", "true: sí se cumple"]);
return { type: "noul" as const, noul: softmax(logits)[1], logits };
}
Las opciones son letras porque cada una es un token distinto: billing y bug podrían compartir el primero. Los logits se devuelven porque la calibración trabaja sobre ellos.
El FLOOR existe porque Ollama solo devuelve las 20 alternativas más probables. /v1/systemone no tiene ese límite: según el PR que lo introdujo, en GGUF puntúa cada candidata con una inferencia propia, y en MLX las puntúa directamente.
Con un modelo de razonamiento, añade think: false o el primer token puede ser del razonamiento. Y valida la forma de la respuesta antes de usarla: un schema de Zod sobre el JSON de Ollama te avisa en cuanto cambie un campo.
Cómo medir la calibración de un LLM en tu System One
La calibración de un LLM se mide comparando lo que el modelo dice con lo que pasa, sobre casos etiquetados por ti. Bastan dos números y una tabla:
// calibrate.ts
import { softmax } from "./system-one";
export type Sample = { logits: number[]; label: number }; // label = índice correcto
// Brier multiclase: de 0 (perfecto) a 2. Menor es mejor.
export function brier(samples: Sample[], T = 1) {
const total = samples.reduce((acc, s) => {
const p = softmax(s.logits, T);
return acc + p.reduce((sum, pi, i) => sum + (pi - (i === s.label ? 1 : 0)) ** 2, 0);
}, 0);
return total / samples.length;
}
// Diagrama de fiabilidad + ECE: ¿acierta un 80 % lo que dice al 80 %?
export function reliability(samples: Sample[], T = 1, bins = 10) {
const b = Array.from({ length: bins }, () => ({ n: 0, conf: 0, hits: 0 }));
for (const s of samples) {
const p = softmax(s.logits, T);
const top = Math.max(...p);
const k = Math.min(bins - 1, Math.floor(top * bins));
b[k].n++;
b[k].conf += top;
b[k].hits += p.indexOf(top) === s.label ? 1 : 0;
}
const ece = b.reduce(
(acc, x) => acc + (x.n ? (x.n / samples.length) * Math.abs(x.hits / x.n - x.conf / x.n) : 0), 0);
const table = b.map((x, i) => ({
tramo: `${Math.round((100 * i) / bins)}-${Math.round((100 * (i + 1)) / bins)} %`,
n: x.n,
dice: x.n ? +(x.conf / x.n).toFixed(2) : null,
acierta: x.n ? +(x.hits / x.n).toFixed(2) : null,
}));
return { ece, table };
}
// Temperature scaling: un solo parámetro T que minimiza la log-loss
export function fitTemperature(samples: Sample[]) {
let best = { T: 1, nll: Infinity };
for (let i = 5; i <= 100; i++) {
const T = i / 20; // de 0,25 a 5 en pasos de 0,05, sin error de coma flotante
const nll = -samples.reduce((acc, s) => acc + Math.log(softmax(s.logits, T)[s.label] + 1e-12), 0);
if (nll < best.nll) best = { T, nll };
}
return best.T;
}
El Brier castiga cada probabilidad mal puesta. El ECE agrupa las predicciones por tramos de confianza y mide la distancia entre lo que el modelo dice y lo que acierta. La tabla dice contra acierta es tu diagrama de fiabilidad: si está calibrado, las dos columnas se parecen.
La corrección es temperature scaling: divides los logits entre una T antes del softmax; con T > 1 se suavizan, con T < 1 se afilan. Guo et al. lo resumen así: "temperature scaling — a single-parameter variant of Platt Scaling — is surprisingly effective at calibrating predictions". En un noul también sirven Platt o la regresión isotónica, que pide más datos.
Lo que medirás sale de este script. Ajusta T con una mitad del set y evalúa con la otra:
// eval.ts — bun eval.ts (tickets.jsonl: {"state": "...", "label": "billing"} por línea)
import { choice } from "./system-one";
import { brier, reliability, fitTemperature, type Sample } from "./calibrate";
const criteria = { billing: "Cobros y reembolsos", bug: "Errores del software", account: "Acceso y cuenta" };
const keys = Object.keys(criteria);
const rows: { state: string; label: string }[] = (await Bun.file("tickets.jsonl").text())
.trim().split("\n").map((line) => JSON.parse(line));
for (let i = rows.length - 1; i > 0; i--) { // Fisher–Yates
const j = Math.floor(Math.random() * (i + 1));
[rows[i], rows[j]] = [rows[j], rows[i]];
}
const samples: Sample[] = [];
for (const row of rows) {
const label = keys.indexOf(row.label);
if (label === -1) throw new Error(`Etiqueta desconocida en tickets.jsonl: ${row.label}`);
const a = await choice(row.state, "¿Qué etiqueta encaja con este ticket?", criteria);
samples.push({ logits: a.logits, label });
}
const half = Math.floor(samples.length / 2);
const [fit, test] = [samples.slice(0, half), samples.slice(half)];
const T = fitTemperature(fit);
console.log({ T, brierAntes: brier(test), brierDespues: brier(test, T) });
console.log({ eceAntes: reliability(test).ece, eceDespues: reliability(test, T).ece });
console.table(reliability(test, T).table);
El arnés no depende de tu implementación. Para medir Nimble o Jev, cambia choice por un fetch a su API y usa keys.map((k) => Math.log(probabilities[k] + 1e-12)) como logits. Es la idea de un harness con Jev y veredicto calibrado: el activo no es el modelo, es el set que lo mide.
Cuándo NO construir un System One en local
Si no tienes casos etiquetados, no lo construyas. Sin set no hay Brier ni ECE, y unas probabilities sin medir son una sensación con decimales. Ahí un modelo entrenado para calibrar, como Jev, es mejor punto de partida (y también hay que medirlo).
El sesgo de selección (de posición) es real. Zheng et al. (ICLR 2024) muestran que los modelos "prefer to select specific option IDs as answers". Mitígalo permutando el orden de las opciones y promediando, que multiplica las llamadas.
El state es superficie de ataque. El PR que añadió /v1/systemone lo reconoce: "Nimble retains its existing prompt-injection routing failure in the task suite" (ollama#18606).
La calibración caduca: la T vale para un modelo, un prompt y unos datos. Si cambias uno, vuelves a medir.
Y los modelos pequeños tienen poco contexto: Tev1 trabaja con unos 2.000 tokens, y según el PR original el endpoint rechaza los prompts demasiado largos en vez de truncarlos.
Por dónde empezar con tu System One en local
Elige una decisión que hoy resuelves con un prompt que devuelve prosa: triaje, ruteo, detección de reembolsos. Etiqueta casos reales. Pásalos por Nimble y por tu implementación con el mismo eval.ts, y quédate con el de mejor Brier en test, no con el que más acierta.
Si aún no tienes Ollama, empieza por modelos de IA en local con Ollama. Y si esta decisión forma parte de un agente con tools y memoria, el ebook gratuito El Developer Agéntico cubre esa arquitectura completa.
Escribí Jev AI sobre una pregunta concreta: cuándo dejar de pedirle prosa a un modelo y pedirle una decisión tipada y calibrada. Si quieres diseñar bien preguntas, niveles y umbrales, ahí está el método entero.
Preguntas frecuentes
¿Puedo ejecutar Jev en local?
No: sus pesos no son públicos, como se explica en ¿Es Jev open source?. Lo más parecido es /v1/systemone de Ollama con Nimble, Tev1, Clef o Kev, que acepta la misma forma de petición. Sobre sus límites como modelo, lee arquitectura, capacidades y limitaciones de Jev.
¿Qué versión de Ollama necesito?
Para montar un System One en local leyendo logprobs con logprobs y top_logprobs, la 0.12.11 o posterior, publicada el 12 de noviembre de 2025. Para el endpoint /v1/systemone, la 0.35.0 o posterior. Clef y Clef Flash piden la 0.35.1, y Kev, la 0.40.4 y un Mac con Apple Silicon.
¿Por qué no le pido al modelo que me diga su confianza?
Porque la confianza que un modelo escribe en texto tiende a ser excesiva y no sale de ninguna distribución que puedas corregir. Los logprobs salen de la distribución real del siguiente token, se pueden normalizar sobre tus opciones y se pueden recalibrar con temperature scaling.
¿Qué ECE es aceptable?
No hay un umbral universal. Compara el ECE antes y después de calibrar, y entre modelos, siempre con el mismo set de test. Mira sobre todo los tramos de confianza donde tu código actúa sin revisión humana: ahí es donde una diferencia entre "dice" y "acierta" cuesta dinero.
¿Funciona igual con llama.cpp, vLLM o LM Studio?
La técnica sí, los parámetros cambian. llama-server devuelve las probabilidades del top N con n_probs en /completion. vLLM devuelve logprobs con un máximo de 20 por defecto, configurable con --max-logprobs. LM Studio añadió top_logprobs en la 0.3.39, pero en el endpoint /v1/responses.
Por Bezael Pérez — Developer senior con más de 15 años de experiencia y fundador de Dominicode.
¿Te resultó útil este artículo?
Compártelo con tu comunidad y ayuda a otros desarrolladores.
