Facsímil 06 · Completo

Construir y operar

Ingeniería de sistemas de IA, observabilidad, operación, handoffs y flujos reproducibles para que una solución viva bien fuera del ejemplo.

Contenido disponible
10 de 10 capítulos listos
Contenido completo, pendiente de revisión editorial final.
Estado editorial
Completo
Lectura web generada desde los capítulos Markdown originales.

Sobre esta edición

Esta página se genera desde capítulos Markdown propios del facsímil. Las fórmulas se renderizan con KaTeX, los mapas con Mermaid y las notas al pie se mantienen junto al texto para leer el facsímil como una pieza autónoma, no como una exportación del taller.

Capítulo 01

Facsímil 6 · Construir y operar

Capítulo 01: De prototipo a sistema operable

Qué deberías poder hacer al terminar

En una asignatura de ingeniería, este capítulo no debería evaluarse preguntando “¿qué es LLMOps?”. Eso sería demasiado fácil y demasiado poco útil. Lo que importa es si puedes transformar una capacidad de IA en un sistema revisable.

Al terminar, deberías poder hacer estas cinco cosas:

Resultado de aprendizajeEvidencia de que lo sabes hacer
Distinguir demo, prototipo y sistema operable.Explicas qué falta para pasar de una respuesta bonita a una capacidad mantenible.
Diseñar un manifest mínimo.Nombras versiones de modelo, prompt, política, dataset, runtime y owner.
Modelar una run como máquina de estados.Dices en qué estado está, qué transiciones son válidas y qué eventos la mueven.
Definir SLI, SLO y presupuesto de error.Conviertes “que vaya bien” en números: calidad, latencia, coste y errores.
Escribir un release gate.Bloqueas una versión que mejora una métrica pero rompe contrato, latencia, coste o rollback.

Este encuadre cambia el tono del facsímil: no basta con entender los nombres. Hay que saber diseñar la evidencia.

La pregunta que abre este facsímil

Hasta ahora hemos aprendido a reconocer piezas: modelos, APIs, RAG, embeddings, herramientas, agentes, memoria, permisos, trazas y evaluaciones. Eso ya es mucho. Pero todavía falta una pregunta incómoda: ¿qué hace que todo eso pueda vivir fuera de una demo?

Una demo puede responder bien tres veces delante de una persona paciente. Un sistema operable debe responder muchas veces, con usuarios distintos, datos cambiantes, coste medido, versiones controladas, límites claros, trazas revisables y una forma de volver atrás si una mejora aparente empeora el conjunto.

Este facsímil va de ese salto. No vamos a tratar producción como “subir algo a un servidor”. La vamos a tratar como una disciplina: construir sistemas de IA que puedan observarse, evaluarse, mantenerse, auditarse y mejorar sin que cada cambio sea una apuesta.

Qué no significa construir y operar

Construir y operar no significa envolver un prompt en una API y ponerle una URL bonita. Eso puede ser el primer envoltorio, pero no resuelve las preguntas importantes: qué versión contestó, qué contexto recibió, cuánto costó, qué ocurrió si falló, qué datos se guardaron, qué usuario quedó afectado y cómo sabemos si la versión nueva es mejor.

Tampoco significa montar una plataforma enorme desde el primer día. Un equipo pequeño puede operar bien con pocos artefactos: un manifest, un dataset de evaluación, trazas mínimas, configuración versionada, un gate de release y una decisión escrita. El problema no es empezar pequeño. El problema es empezar sin forma de medir ni revertir.

Y no significa que todo sea automático. Operar bien incluye decidir qué no debe automatizarse todavía, qué requiere revisión humana, qué se ejecuta en modo lectura, qué se prueba en sombra y qué se detiene cuando falta evidencia.

Qué sí significa operar un sistema de IA

Un sistema de IA está operado cuando podemos responder preguntas técnicas sin reconstruir la historia a mano:

PreguntaRespuesta que debería existir
¿Qué versión contestó?Modelo, prompt, herramienta, política, dataset y código versionados.
¿Qué vio el modelo?Contexto, documentos recuperados, mensajes relevantes y filtros aplicados.
¿Qué hizo el sistema?Llamadas a modelo, tools, validadores, colas, gates y salidas.
¿Cuánto costó?Tokens, tiempo, llamadas externas, coste total y coste por tarea aceptada.
¿Cómo falló?Error clasificado, traza con spans, estado final y causa probable.
¿Cómo se corrige?Cambio propuesto, eval de regresión, canary, rollback y owner.

Sculley y colaboradores llamaron la atención sobre una deuda técnica propia de los sistemas de aprendizaje automático: datos, configuraciones, dependencias y comportamiento pueden entrelazarse hasta hacer difícil entender qué cambió realmente.1 Amershi y su equipo mostraron que construir sistemas de ML exige prácticas específicas de ingeniería porque el comportamiento no depende solo del código: también depende de datos, experimentos, métricas, pipelines y evaluación continua.2

En sistemas con LLMs añadimos otra capa: prompts, contexto, herramientas, proveedores, modelos cambiantes, costes por token, respuestas variables y decisiones de producto. Por eso necesitamos una forma de pensar más parecida a ingeniería de sistemas que a “probar prompts”.

Requisitos antes de hablar de arquitectura

Una costumbre sana en ingeniería es no dibujar arquitectura antes de saber qué problema debe aguantar. En IA esto se olvida con facilidad porque el modelo produce algo visible muy pronto. Pero una respuesta visible no equivale a requisitos entendidos.

Para un sistema de IA, separaría los requisitos en tres grupos:

Tipo de requisitoPreguntaEjemplo en un asistente de soporte
Funcional¿Qué debe hacer el sistema?Responder dudas de matrícula con citas o abrir revisión si falta evidencia.
No funcional¿Con qué calidad operativa debe hacerlo?p95 menor de 2,5 s, coste menor de 0,04 euros por respuesta aceptada, salida JSON válida.
De cambio¿Cómo se modifica sin perder control?Prompt, modelo, retrieval y política versionados con eval previa y rollback probado.

En software clásico solemos distinguir requisitos funcionales y no funcionales. En IA añadiría siempre los requisitos de cambio, porque el sistema vive de ajustes: cambia el prompt, cambia el modelo, cambia el corpus, cambia el proveedor, cambia la política de abstención y cambia la evaluación. Si no modelas el cambio, el sistema parece estable hasta que alguien pregunta qué versión produjo una respuesta concreta.

Un requisito bien escrito no dice “que responda bien”. Dice algo como:

Para preguntas cubiertas por normativa vigente, el asistente debe devolver una respuesta con al menos una cita recuperable, schema válido, p95 menor de 2,5 s y coste por tarea aceptada menor de 0,04 euros. Si no hay evidencia suficiente, debe abstenerse y proponer siguiente paso.

Esta frase ya contiene producto, datos, calidad, latencia, coste, contrato y fallback. Es mucho más aburrida que una demo, y por eso mismo es más útil.

La fórmula mínima de operación

Ejemplo de fórmula. Una forma simple de recordar el salto es esta:

Sop=Cp+Rt+Ob+Ev+Gr+VcS_{op} = C_p + R_t + O_b + E_v + G_r + V_c
SímboloSignificadoEjemplo
SopS_{op}Sistema operable.Asistente interno que responde dudas de normativa con citas y control de coste.
CpC_pControl plane.Registro de modelo, prompt, flags, políticas y límites.
RtR_tRuntime.API, cola, llamada al modelo, tools, timeouts y reintentos.
ObO_bObservabilidad.Trazas, métricas, logs, coste y errores por ejecución.
EvE_vEvaluación.Dataset de regresión, métricas y revisión de casos difíciles.
GrG_rGates de release.Reglas para pasar de local a sombra, canary y producción.
VcV_cVersionado y cambio.SemVer, manifest, rollback, owner y decisión escrita.3

La fórmula no pretende medir físicamente el sistema. Sirve para algo más útil: comprobar qué pieza falta. Si tienes runtime sin observabilidad, no puedes depurar. Si tienes eval sin versionado, no puedes comparar. Si tienes control plane sin rollback, puedes cambiar rápido, pero no volver con calma.

Ejemplo de fórmula. También necesitamos una fórmula de decisión:

release_ok=calidad_oklatencia_okcoste_okcontrato_okrollback_okrelease\_ok = calidad\_ok \land latencia\_ok \land coste\_ok \land contrato\_ok \land rollback\_ok
TérminoQué compruebaEjemplo
calidad_okcalidad\_okLa salida cumple la rúbrica o métrica mínima.Al menos 44 de 50 casos pasan la eval privada.
latencia_oklatencia\_okLa respuesta cabe en el tiempo acordado.p95 menor o igual a 2,5 segundos.
coste_okcoste\_okEl coste por tarea aceptada está dentro del presupuesto.Menos de 0,04 euros por caso válido.
contrato_okcontrato\_okLa salida respeta schema, citas, permisos y abstención.JSON válido con fuente verificable o respuesta de no evidencia.
rollback_okrollback\_okExiste forma probada de volver a la versión anterior.Feature flag que restaura prompt_v12 y model_a.

Esta segunda fórmula sí debe convertirse en código, aunque el código empiece siendo pequeño.

SLI, SLO y presupuesto de error

Estas tres siglas vienen del mundo de la fiabilidad de servicios. Conviene traducirlas despacio porque, si solo damos el acrónimo, parecen burocracia. En realidad son una forma muy práctica de convertir “que vaya bien” en números que el equipo pueda discutir.

SiglaNombre completoTraducción útilPregunta que responde
SLIService Level IndicatorIndicador de nivel de servicio.¿Qué estamos midiendo realmente?
SLOService Level ObjectiveObjetivo de nivel de servicio.¿Qué valor consideramos aceptable?
Presupuesto de errorError budgetMargen de fallo permitido.¿Cuánto podemos fallar antes de frenar cambios?

El orden importa:

  1. Primero defines el SLI, porque sin indicador no sabes qué medir.
  2. Después defines el SLO, porque necesitas decidir qué valor mínimo aceptas.
  3. Por último calculas el presupuesto de error, porque quieres saber cuánto margen tienes antes de investigar, pausar cambios o volver a una versión anterior.

Un SLO no es una frase de marketing. Es un objetivo interno medible. Para escribirlo necesitamos primero un SLI, que es el indicador que medimos.

Ejemplo en lenguaje llano:

ConceptoVersión humanaVersión medible
SLI“De todas las runs, ¿cuántas salen aceptables?”runs_aceptadas / runs_totales
SLO“Queremos que casi todas salgan aceptables.”SLI_calidad >= 0.97
Presupuesto de error“Aceptamos un margen pequeño antes de parar.”1 - 0.97 = 0.03

Por ejemplo:

SLIcalidad=runs_aceptadasruns_totalesSLI_{calidad} = \frac{runs\_aceptadas}{runs\_totales}
SímboloSignificadoEjemplo
SLIcalidadSLI_{calidad}Proporción de ejecuciones aceptadas.9.650 de 10.000 runs pasan calidad y contrato.
runs_aceptadasruns\_aceptadasRuns que cumplen rúbrica, contrato y límites.9.650
runs_totalesruns\_totalesRuns evaluadas en una ventana temporal.10.000

Si el SLO de calidad es 97%, entonces:

presupuesto_de_error=1SLOpresupuesto\_de\_error = 1 - SLO
TérminoSignificadoEjemplo
SLOSLOObjetivo que prometemos internamente.0,97
1SLO1 - SLOMargen de error tolerado.0,03
Presupuesto en 10.000 runsRuns que pueden fallar antes de parar cambios.300

Esto no significa que 300 fallos “den igual”. Significa que el equipo decide de antemano cuánto margen tiene antes de congelar cambios, investigar o volver a una versión anterior. Sin presupuesto de error, cada fallo parece una anécdota o una crisis. Con presupuesto de error, las conversaciones se vuelven operativas.

En una ventana de 10.000 runs, el cálculo sería:

PasoCálculoResultado
ObjetivoSLO=0,97SLO = 0,9797% de runs aceptables
Margen10,971 - 0,970,03
Presupuesto10.000×0,0310.000 \times 0,03300 runs no aceptadas

Ese presupuesto se gasta. Si en dos días ya llevas 250 runs no aceptadas, quizá no conviene desplegar un prompt nuevo aunque la demo parezca mejor. Si en toda la semana llevas 20, tienes más margen para experimentar. Esa es la gracia: el presupuesto de error conecta calidad, operación y velocidad de cambio.

Para sistemas de IA suelo definir varios SLI a la vez:

SLIFórmula o mediciónQué captura
Calidad aceptadaruns_aceptadas / runs_totalesSi la salida cumple rúbrica, contrato y evidencia.
Latencia p95Percentil 95 de duración total.Si la cola de usuarios reales espera demasiado.
Coste por tarea aceptadacoste_total / runs_aceptadasSi el sistema es sostenible, incluyendo reintentos.
Abstención correctaabstenciones_correctas / casos_sin_evidenciaSi el sistema sabe no responder cuando falta base.
Error de contratosalidas_invalidas / runs_totalesSi el JSON, las citas o el formato rompen integraciones.

El punto docente es importante: una eval offline mide comportamiento con casos preparados; un SLO mide operación en una ventana concreta. Los dos se necesitan. La eval evita publicar una mala versión. El SLO detecta que el mundo cambió después.

Fecha de corte y alcance

Fecha de corte: 27 de mayo de 2026.
Fuentes consultadas en este facsímil hasta este punto: prácticas de ingeniería de ML, TFX, OpenTelemetry, W3C Trace Context, Harness Engineering, AGENTS.md, vocabulario normativo de RFC 2119/RFC 8174 y versionado semántico.

Lo estable es el método: versionar artefactos, ejecutar evals, observar runs, controlar coste, diseñar límites, introducir cambios gradualmente y preparar rollback. Lo cambiante son productos concretos, nombres de runtimes, modelos, precios, dashboards y SDKs.

Baylor y colaboradores describieron TFX como una plataforma de producción donde no basta entrenar: hay que validar datos, validar modelos, servirlos y monitorizarlos dentro de un pipeline.4 Esa idea se mantiene aunque trabajemos con LLMs y no con un clasificador clásico: la capacidad del modelo es solo una pieza de un sistema mayor.

El contrato operativo de una run

Aquí “contrato” no significa contrato legal. Significa acuerdo técnico explícito: qué entra, qué sale, qué estados existen, qué errores son posibles y qué garantías mínimas ofrece el sistema. Es la diferencia entre “esto suele responder así” y “esto debe cumplir estas condiciones para considerarse válido”.

Un contrato en software funciona como una promesa verificable entre partes:

ParteQué prometeQué puede comprobarse
Quien llamaEnviar datos con una forma válida.Campos obligatorios, tipos, tamaño y permisos.
El sistemaProcesar bajo reglas conocidas.Estado, límites, trazas, presupuesto y errores tipados.
Quien consume la salidaRecibir algo estable.Schema de respuesta, campos esperados y significado de cada estado.

Ejemplo sencillo: si una API promete devolver siempre answer, sources, confidence y needs_review, eso es parte del contrato. Si a veces devuelve texto libre, a veces JSON y a veces un campo llamado respuesta, no hay contrato estable; hay una costumbre frágil.

En una run de IA el contrato es todavía más importante porque el modelo puede generar salidas variables. El contrato no elimina toda incertidumbre, pero pone una frontera: si la salida no cumple estructura, citas, límites o política, no se entrega como resultado válido.

Llamaremos run a una ejecución completa: llega una petición, el sistema decide ruta, llama modelos o herramientas, valida salida y termina con respuesta, abstención, error recuperable o revisión humana.

Para que una run sea operable, no basta con guardar el texto final. Debe dejar un contrato mínimo:

CampoQué guardaPor qué importa
run_idIdentificador único de la ejecución.Permite buscar la historia completa.
trace_idIdentificador de traza que viaja entre servicios.Une API, cola, modelo, tool y validadores.
provider_request_idIdentificador devuelto por el proveedor, si existe.Permite cruzar tu traza con soporte o dashboard externo.
input_hashHuella de la entrada, no necesariamente el texto completo.Permite deduplicar sin exponer datos de más.
model_versionModelo o proveedor usado.Explica diferencias de comportamiento.
prompt_versionPlantilla e instrucciones usadas.Permite rollback de prompts.
context_manifestDocumentos, memoria o retrieval inyectado.Explica qué evidencia vio el modelo.
policy_versionReglas de permisos, abstención y límites.Evita decisiones invisibles.
budgetTokens, coste, pasos, tiempo y reintentos permitidos.Evita que una tarea pequeña se coma el sistema.
output_contractSchema, citas, formato y validadores.Hace comprobable la salida.
decisionaccepted, needs_review, retryable_error o blocked.Convierte texto en estado operativo.

OpenTelemetry define trazas como conjuntos de spans que representan unidades de trabajo, y su API permite crear spans, añadir atributos y propagar contexto.5 W3C Trace Context estandariza cómo llevar identificadores de traza entre servicios mediante cabeceras como traceparent.6 En castellano llano: si una petición atraviesa tres servicios, no quieres tres historias separadas. Quieres una sola historia con capítulos.

La run como máquina de estados

Si una run no tiene estados explícitos, acaba teniendo estados implícitos escondidos en logs, excepciones, mensajes de cola y flags sueltos. Eso dificulta depurar y enseñar el sistema. Para ingeniería, una run debe poder dibujarse.

stateDiagram-v2
    [*] --> received: llega petición
    received --> rejected: contrato de entrada inválido
    received --> queued: entrada válida
    queued --> running: worker disponible
    running --> waiting_tool: necesita tool o retrieval
    waiting_tool --> running: observación recibida
    waiting_tool --> retryable_error: timeout recuperable
    retryable_error --> queued: retry con presupuesto
    retryable_error --> failed: reintentos agotados
    running --> validating: salida candidata
    validating --> accepted: contrato y SLO cumplen
    validating --> needs_review: requiere revisión humana
    validating --> retryable_error: error recuperable
    validating --> blocked: política o presupuesto impiden seguir
    queued --> cancelled: cancelación del usuario o sistema
    running --> cancelled: cancelación del usuario o sistema
    accepted --> [*]
    needs_review --> [*]
    failed --> [*]
    blocked --> [*]
    cancelled --> [*]
    rejected --> [*]

La tabla de transiciones obliga a hablar con precisión:

EstadoQué significaEvento que lo mueveDato que debería quedar
receivedLa API recibió una petición.Validación de entrada.run_id, input_hash, usuario o tenant.
queuedLa tarea espera ejecución.Worker disponible o cancelación.Tiempo en cola y prioridad.
runningEl sistema está decidiendo o generando.Tool, salida candidata o error.Modelo, prompt, presupuesto consumido.
waiting_toolHay una dependencia externa pendiente.Respuesta, timeout o error recuperable.Tool, argumentos, timeout, intento.
validatingHay salida candidata pendiente de contrato.Validación pasa, falla o exige revisión.Schema, citas, coste, latencia, rúbrica.
acceptedLa salida puede entregarse.Fin.Output, versión, métricas finales.
needs_reviewUna persona debe revisar.Fin de la parte automática.Motivo y datos mínimos para revisar.
retryable_errorPuede intentarse otra vez con cuidado.Retry o fallo definitivo.Tipo de error, contador, espera.
blockedNo debe continuar por política o presupuesto.Fin.Regla que bloqueó y siguiente paso.

El detalle clave es que cada transición tiene causa. No basta con saber que terminó mal; queremos saber si terminó mal por entrada inválida, timeout, presupuesto agotado, contrato roto, falta de evidencia o revisión pendiente.

Taxonomía de fallos que sí ayuda a depurar

Cuando todo se llama “fallo del modelo”, nadie aprende. Una taxonomía útil separa dónde se rompió el sistema:

Tipo de falloSíntoma visibleCausa probablePrimera pregunta de depuración
Entrada inválidaLa API rechaza antes de llamar al modelo.Falta campo, tipo incorrecto o tamaño excesivo.¿El contrato de entrada está documentado y validado?
Retrieval insuficienteRespuesta se abstiene o cita poco.No hay documentos, chunking malo o filtro demasiado estrecho.¿El documento esperado aparece en top-k?
Contexto contaminadoLa respuesta mezcla asuntos no relevantes.Se inyectó demasiado contexto o memoria imprecisa.¿Qué fragmentos vio exactamente el modelo?
Salida fuera de contratoJSON inválido, campos extra o cita ausente.Schema débil, prompt ambiguo o postproceso incompleto.¿El validador bloquea antes de entregar?
Latencia excesivap95 o p99 se disparan.Cola, proveedor lento, contexto largo o tool pesada.¿Dónde está el span más largo?
Coste excesivoBuena calidad pero factura alta.Reintentos, modelo caro, top-k alto o salida larga.¿Cuál es el coste por tarea aceptada?
Estado incoherenteLa traza dice una cosa y la base de datos otra.Escrituras parciales o falta de idempotencia.¿Qué se guarda antes y después de cada efecto?
Cambio no trazableNo sabemos qué versión falló.Modelo, prompt o política sin manifest.¿Existe manifest por run?

Esta tabla es una herramienta docente. Obliga a dejar de hablar de “la IA falla” y empezar a localizar subsistemas: entrada, retrieval, contexto, modelo, contrato, runtime, estado, coste o cambio.

Dónde mirar si el error viene del proveedor

Cuando el sistema usa OpenAI, Anthropic, Gemini, Bedrock u otro proveedor, el primer impulso suele ser abrir la página de estado y esperar una respuesta tranquilizadora. Está bien mirarla, pero no basta. Una página de estado te dice si hay un problema visible a nivel de servicio; tu traza te dice si tu petición falló por entrada inválida, cuota, permisos, timeout, límite de tamaño, modelo no disponible, región, credenciales o contrato de salida.

Ejemplo de fórmula. La regla práctica es esta:

diagnostico=traza_local+error_proveedor+dashboard_proveedor+estado_serviciodiagnostico = traza\_local + error\_proveedor + dashboard\_proveedor + estado\_servicio
PiezaQué aportaQué no aporta
Traza localQué ruta tomó tu sistema, qué prompt/modelo/contexto usó y cuánto tardó.No demuestra por sí sola que el proveedor tenga una incidencia general.
Error del proveedorCódigo HTTP, tipo de error, mensaje y límites aplicados.No explica tu lógica de negocio ni tus validadores.
Dashboard del proveedorUso, facturación, límites, proyecto, claves o cuota según plataforma.No sustituye tus logs ni tus evals.
Estado del servicioIncidencias o mantenimiento publicados.Puede ser agregado, tardar en reflejar casos concretos o no distinguir tu modelo exacto.

La tabla operativa sería esta:

Si usas...Guarda siempre en tu trazaDónde mirar en el proveedorCómo interpretarlo
OpenAI APIx-request-id, X-Client-Request-Id si lo envías, modelo, endpoint, HTTP status, x-ratelimit-*, openai-processing-ms, tokens y timestamp UTC.Error codes, API reference: debugging requests, API Dashboard y status.openai.com.Si ves 400, revisa payload y schema. Si ves 401/403, credenciales, organización, proyecto o permisos. Si ves 429, mira límites y ritmo. Si ves 5xx, reintenta con backoff y cruza con estado del servicio. OpenAI recomienda registrar request IDs para depuración.7
Anthropic / Claude APIrequest-id, request_id del cuerpo si aparece, error.type, error.message, modelo, anthropic-version, status, tokens y timestamp UTC.Claude API errors, Claude Console y status.claude.com.invalid_request_error apunta a formato o contenido; authentication_error a clave; permission_error a permisos; request_too_large a tamaño; rate_limit_error a límite; timeout_error, api_error u overloaded_error suelen exigir retry controlado y consulta de estado. Anthropic documenta que cada respuesta incluye un identificador de petición útil para soporte.8
Gemini APIHTTP code, status como INVALID_ARGUMENT, RESOURCE_EXHAUSTED o UNAVAILABLE, mensaje, modelo, proyecto, región si aplica, cuota y timestamp UTC.Gemini API troubleshooting, AI Studio status y consola del proyecto si usas Google Cloud.INVALID_ARGUMENT suele ser petición mal formada; RESOURCE_EXHAUSTED suele apuntar a cuota o ritmo; UNAVAILABLE o 5xx requieren retry y comprobación de estado. La guía oficial separa problemas del backend de la API y de los SDKs cliente.9
Amazon Bedrockx-amzn-requestid, región, modelId, InvokeModel o endpoint usado, excepción AWS, status HTTP, cuenta/rol y timestamp UTC.Bedrock API error troubleshooting, CloudWatch/CloudTrail si lo tienes activado y AWS Health Dashboard.AccessDeniedException apunta a IAM; ValidationException a entrada; ThrottlingException o ServiceQuotaExceededException a cuota; ModelTimeoutException a tiempo de proceso; ServiceUnavailableException a disponibilidad. En Bedrock, la región y los permisos importan tanto como el modelo.10
Proveedor agregado o router propioID interno, proveedor final, modelo final, ruta elegida, error original si se conserva, retry, fallback y coste.Dashboard del agregador, página de estado del proveedor final y tus trazas.Si el router oculta el error original, estás ciego. Exige conservar upstream_provider, upstream_model, upstream_status y upstream_request_id cuando exista.
Modelo local con Ollama, vLLM, SGLang o similarID de run, modelo exacto, quant, tamaño de contexto, uso de KV cache, GPU/CPU, cola, memoria y logs del runtime.Logs del proceso, métricas del servidor, dashboard propio y pruebas sintéticas.Aquí no hay “estado del proveedor” que te salve. Si falla, mira memoria, colas, timeouts, modelo cargado, formato de pesos y presión de concurrencia.

OpenAI documenta códigos de error como autenticación, permisos, límites, cuota y errores internos, y recomienda consultar la página de estado si aparece un error interno persistente.11 Su página de estado separa componentes como APIs y muestra disponibilidad agregada, con la advertencia de que la disponibilidad de un cliente concreto puede variar por tier, modelo y funcionalidad.12 Claude mantiene una página de estado separada por componentes como Claude API, Console y Claude Code.13 AWS documenta que el AWS Health Dashboard permite revisar el estado de servicios de AWS desde una página pública de salud.14

Lo que yo exigiría a cualquier equipo es un pequeño “paquete de depuración” por error:

run_id: run_20260527_00142
trace_id: 4bf92f3577b34da6a3ce929d0e0e4736
provider: openai
provider_request_id: req_...
model: modelo_fijado_por_manifest
endpoint: /responses
http_status: 429
provider_error_type: rate_limit
timestamp_utc: 2026-05-27T14:12:08Z
latency_ms: 1830
tokens_in: 1840
tokens_out: 0
retry_count: 2
decision: retryable_error
payload_hash: sha256:...

Y una regla de higiene: al pedir ayuda al proveedor, comparte IDs, timestamps, modelo, endpoint, código de error y cabeceras relevantes. No pegues datos sensibles ni prompts completos si no hace falta. Tu sistema debe poder reproducir el contexto técnico sin exponer más información de la necesaria.

Anatomía visual de un sistema operable

De prototipo a sistema operable La respuesta del modelo es solo una salida: operación significa controlar entrada, versiones, runtime, evaluación, observabilidad y cambio. CONTROL PLANE: LO QUE PUEDE CAMBIAR SIN TOCAR EL RUNTIME Modelo proveedor · versión · contexto Prompt template · ejemplos · reglas Política permisos · abstención · límites Dataset eval casos · rúbrica · slices Flags canary · fallback · rollout Manifest hashes · owner · fecha RUNTIME: CÓMO SE EJECUTA UNA PETICIÓN REAL Entrada usuario · API · job idempotency_key Router clasifica tarea elige ruta y budget Cola y estado RUNNING · WAITING RETRYABLE · DONE run_state.json Orquestador modelo · RAG · tool reintentos · timeout Validadores schema · citas · policy coste · latencia · salida contract_ok? Decisión aceptar · revisar reintentar · bloquear Dependencias externas proveedor LLM · vector DB · base de datos · storage · servicios internos cada llamada debe tener timeout, error tipado y presupuesto Salida contractual JSON, cita, texto, diff, ticket, informe o abstención lo que se entrega debe ser verificable por otro sistema OBSERVABILIDAD Y EVALOPS: CÓMO SABEMOS QUÉ PASÓ Y SI MEJORA Trace trace_id · spans · eventos modelo, tool, validador Métricas p95 · errores · tokens coste por tarea aceptada Evals offline · sombra · canary slices y regresiones Release gate calidad · latencia · coste contrato · rollback Decisión escrita promover · pausar volver · simplificar REGLA DEL FASCÍCULO No publiques una capacidad de IA si no puedes decir qué versión respondió, qué contexto vio, qué contrato debía cumplir, cuánto costó, cómo se midió y cómo volverías a una versión anterior. IA para gente curiosa / Facsímil 06 / Capítulo 01 / 686f6c61

Este diagrama tiene una intención: separar piezas que suelen mezclarse. El control plane decide qué configuración existe. El runtime ejecuta una petición. La observabilidad permite reconstruirla. EvalOps decide si un cambio merece avanzar. Rollback evita que una versión mala se convierta en una semana de improvisación.

Cómo se ve en un proyecto real

Imagina un asistente interno para soporte universitario. Responde preguntas sobre matrícula, plazos, convalidaciones y documentación. En el facsímil 4 podríamos haberlo montado como RAG: buscar documentos, generar respuesta con citas y abstenerse si no hay evidencia. En el facsímil 5 podríamos haber añadido una tool para abrir un ticket o pedir revisión. Aquí preguntamos otra cosa: ¿cómo se opera cada lunes?

El equipo necesita decidir:

DecisiónPregunta concretaMala señalBuena señal
Versión¿Qué cambió hoy?“Hemos tocado el prompt un poco”.prompt:v1.8.2, retriever:v3, policy:v4.
Calidad¿Qué casos cubre la eval?Solo preguntas fáciles.Casos frecuentes, ambiguos, sin evidencia y con documentos contradictorios.
Latencia¿Qué espera el usuario?Media rápida, p99 invisible.p50, p95, p99 y tasa de timeout por ruta.
Coste¿Cuánto cuesta resolver una tarea válida?Precio por token sin reintentos.Coste por respuesta aceptada, incluyendo retrieval, tools y revisión.
Fallback¿Qué pasa si no hay evidencia?Inventar una respuesta amable.Abstención con siguiente paso y trazabilidad.
Cambio¿Cómo se despliega?Todo el tráfico al nuevo prompt.Sombra, canary, gate, owner y rollback.

El patrón se repite en banca, salud, educación, SaaS, soporte, legal, programación o administración pública: operar IA es convertir capacidad variable en comportamiento medible.

Pipeline CI/CD para sistemas de IA

En software clásico, CI/CD suele significar tests, build y despliegue. En IA añadimos artefactos que también cambian comportamiento: prompt, modelo, política, dataset, retrieval, tool schema y configuración de runtime.

El pipeline mínimo que pediría en clase sería este:

flowchart LR
    PR["Cambio propuesto<br/>prompt, modelo, policy, retrieval o tool"]
    STATIC["Checks estáticos<br/>schema, formato, referencias, secretos"]
    OFFLINE["Eval offline<br/>dataset fijo y slices"]
    SHADOW["Shadow run<br/>tráfico copiado sin afectar usuario"]
    CANARY["Canary<br/>porcentaje pequeño"]
    GATE["Release gate<br/>calidad, p95, coste, contrato, rollback"]
    PROD["Promote<br/>subir tráfico"]
    ROLLBACK["Rollback<br/>volver por flag"]

    PR --> STATIC
    STATIC --> OFFLINE
    OFFLINE --> SHADOW
    SHADOW --> CANARY
    CANARY --> GATE
    GATE -->|"pasa"| PROD
    GATE -->|"no pasa"| ROLLBACK
    PROD -->|"SLO se degrada"| ROLLBACK

Lo importante no es que todos los equipos tengan esta cadena completa el primer día. Lo importante es saber qué parte falta. Si no hay eval offline, el canary se convierte en experimento con usuarios. Si no hay shadow run, descubres diferencias cuando ya afectan. Si no hay rollback, cada despliegue es una promesa de que todo irá bien.

FaseQué pruebaQué no prueba
Checks estáticosQue el cambio está bien formado.Que responde bien.
Eval offlineQue no rompe casos conocidos.Que aguanta tráfico real.
Shadow runQue se comporta con entradas reales sin impactar.Que los usuarios aceptan la salida.
CanaryQue una fracción pequeña aguanta coste, latencia y calidad.Que todas las colas y segmentos están cubiertos.
PromoteQue el cambio merece subir tráfico.Que ya no hay que vigilarlo.
RollbackQue podemos volver atrás.Que entendimos la causa raíz.

Lo que un ingeniero informático debería exigir

Si este capítulo se convirtiera en una práctica de ingeniería del software, pediría estos artefactos:

ArtefactoQué contieneCómo se revisa
manifest.yamlVersiones de modelo, prompt, política, dataset y runtime.Diff revisable en PR.
eval_dataset.jsonlCasos de entrada, expected, rúbrica y segmento.Cobertura por tipo de caso.
run_trace.jsonlEventos de ejecución con trace_id, tiempos y atributos.Permite reconstruir fallos.
release_gate.pyCódigo que decide si una versión pasa.Tests unitarios y umbrales claros.
rollback.mdCómo volver a la versión anterior.Probado antes del lanzamiento.
decision.mdPor qué se promueve, pausa o simplifica.Firmado por owner técnico o de producto.

El formato AGENTS.md propone instrucciones de proyecto para agentes de código: comandos, estructura, convenciones y reglas específicas del repositorio.15 Fowler lo formula desde otra esquina con harness engineering: el entorno alrededor del agente importa tanto como el modelo, porque aporta instrucciones, contexto, herramientas, verificación y límites.16

La idea que nos llevamos al facsímil 6 es esta: la ingeniería no empieza cuando el modelo falla; empieza antes, diseñando cómo sabremos que falla.

Manos a la obra

Práctica: montar un kit operativo con AGENTS.md.

Kit ejecutable de este capítulo: kit descargable.

# Descomprime el ZIP del capítulo y ejecuta estos comandos dentro de esa carpeta
python3 ops/run_f6_practices.py --chapter c01 --write --fail-on-invalid

En esta práctica el entregable no es otro texto bonito sobre agentes. Es un kit pequeño que puedes colocar en un repositorio real y usar en una revisión técnica. La idea es que cualquier persona del equipo, y también cualquier agente de código, entienda qué puede tocar, cómo se valida, qué evidencia debe dejar y cuándo debe parar.

La estructura mínima sería esta:

AGENTS.md
ops/ai/should.md
ops/ai/manifest.yaml
ops/ai/release_gate.py
ops/ai/decision.md

AGENTS.md vive en la raíz del repositorio. No sustituye la documentación del producto, ni la arquitectura completa, ni las políticas de la organización. Su función es más concreta: convertir las reglas operativas del repo en instrucciones visibles, revisables y accionables. Si cambia después de un incidente o de una práctica, debe pasar por revisión igual que el código.

Un AGENTS.md útil para este capítulo podría empezar así:

# AGENTS.md

## Misión del repositorio

Este repositorio contiene un asistente RAG para soporte interno. El sistema debe responder con fuentes, respetar el contrato JSON de salida, registrar cada ejecución con trazas y permitir rollback por configuración.

## Mapa rápido

- `src/app/`: API pública y controladores.
- `src/rag/`: recuperación, reranking y armado de contexto.
- `src/policies/`: validadores de formato, coste, permisos y seguridad de datos.
- `evals/`: dataset de evaluación, rúbricas y resultados versionados.
- `ops/ai/`: comportamiento esperado, manifest, release gate, decisiones y plantillas de observabilidad.

## Comandos obligatorios antes de proponer un cambio

- `python ops/ai/release_gate.py`
- `pytest tests/rag tests/policies`
- `npm run lint`

Si un comando no existe en una copia local del proyecto, documenta el motivo en `ops/ai/decision.md` y no inventes una señal equivalente.

## Artefactos que siempre se versionan juntos

- Modelo o proveedor.
- Prompt del sistema.
- Plantilla de mensajes.
- Especificación de comportamiento (`ops/ai/should.md`).
- Política de herramientas.
- Dataset de evaluación.
- Umbrales de release.
- Contrato de salida.

Un cambio en cualquiera de esos artefactos puede cambiar el comportamiento del sistema aunque el código de aplicación no cambie.

## Evidencia mínima por ejecución

Cada run debe guardar:

- `run_id`
- `trace_id`
- `span_id` por paso relevante
- modelo y versión
- hash del prompt
- hash del dataset de evaluación
- proveedor y `provider_request_id` si existe
- latencia total y latencia por paso
- tokens de entrada y salida
- coste estimado
- decisión final del gate

## Paquete de depuración de proveedor

Cuando falle una llamada externa, registra este bloque en la traza o en el informe:

- `run_id`
- `trace_id`
- `provider`
- `provider_request_id`
- endpoint
- modelo
- región si aplica
- estado HTTP
- tipo de error normalizado
- mensaje reducido
- timestamp UTC
- reintentos realizados
- decisión tomada: reintentar, degradar, pausar o volver

No guardes claves, documentos completos ni datos personales sin necesidad técnica y base legal.

## Definition of Done

Un cambio de IA está terminado solo si:

- el manifest identifica versiones y owner
- `ops/ai/should.md` describe comportamientos medibles
- el release gate pasa
- hay traza de una ejecución representativa
- el contrato de salida se valida automáticamente
- existe plan de rollback
- `decision.md` explica por qué se promueve, se pausa o se vuelve

## Cuándo detenerse y pedir revisión

- Cambia el proveedor, modelo base o endpoint.
- Cambia un requisito marcado como DEBE en `ops/ai/should.md`.
- Sube el coste por tarea aceptada.
- Empeora p95 o tasa de timeouts.
- Aparecen errores de contrato.
- El cambio necesita datos nuevos o permisos nuevos.
- El sistema actúa sobre información con impacto académico, legal, sanitario o económico.

Entre AGENTS.md y el manifest vamos a poner una pieza que suele faltar: ops/ai/should.md. Si AGENTS.md dice cómo se trabaja en el repositorio, SHOULD.md dice cómo debería comportarse el sistema. No es una convención universal como README.md; es una convención útil para este libro y para equipos que quieren convertir expectativas difusas en criterios revisables.

El nombre está inspirado en el lenguaje de requisitos de las especificaciones técnicas: MUST, SHOULD, MAY y sus negaciones. RFC 2119 definió ese vocabulario para indicar niveles de obligación.17 RFC 8174 aclaró su uso cuando esas palabras aparecen en mayúsculas.18 Aquí lo traducimos a castellano de trabajo:

PalabraEn castellano de proyectoQué implica
MUSTDEBESi no se cumple, la versión no puede avanzar.
MUST NOTNO DEBESi aparece, se bloquea la versión o se vuelve atrás.
SHOULDDEBERÍAEs el comportamiento esperado; puede no cumplirse solo con motivo documentado.
MAYPUEDEEs una capacidad permitida, no obligatoria.

La gracia de SHOULD.md es que obliga a escribir comportamiento observable. “Responder bien” no sirve. “Responder con answer, sources, confidence y needs_review” sí sirve. “Usar fuentes” es flojo. “Toda afirmación sobre una política interna DEBE enlazar al menos un source_id y un chunk_id recuperado” ya se puede evaluar.

Un ops/ai/should.md bastante completo para esta práctica podría ser:

# SHOULD.md

## Para qué existe

Este archivo describe el comportamiento esperado del asistente RAG de soporte interno. No explica cómo contribuir al repositorio; eso vive en `AGENTS.md`. No fija una release concreta; eso vive en `manifest.yaml`. No decide si una versión pasa; eso vive en `release_gate.py` y `decision.md`.

`SHOULD.md` responde a otra pregunta: si este sistema funciona bien, ¿qué deberíamos observar?

## Alcance

El asistente ayuda a responder preguntas sobre políticas internas, procedimientos de soporte y documentación operativa indexada en el corpus autorizado.

Quedan fuera de alcance:

- decisiones que requieran aprobación humana
- interpretación legal definitiva
- cálculo de nóminas, sanciones o cambios administrativos
- respuestas sin documentación recuperada cuando la pregunta depende de una política interna
- acciones en sistemas externos sin confirmación explícita y registro de la run

## Lenguaje de requisitos

- DEBE: requisito obligatorio. Si no se cumple, la versión no pasa el gate.
- NO DEBE: comportamiento que bloquea la versión.
- DEBERÍA: comportamiento esperado. Si no se cumple, hay que explicar por qué.
- PUEDE: comportamiento permitido, pero no exigido para aprobar.

## Contrato de salida

Toda respuesta final DEBE ajustarse a este contrato lógico:

| Campo | Tipo | Obligatorio | Significado |
|---|---|---:|---|
| `answer` | string | Sí | Respuesta breve, útil y en castellano claro. |
| `sources` | array | Sí | Lista de fuentes usadas con `source_id`, `chunk_id` y título legible. |
| `confidence` | number | Sí | Valor entre 0 y 1 que resume confianza operativa, no verdad absoluta. |
| `needs_review` | boolean | Sí | Indica si una persona debe revisar antes de actuar. |
| `reason` | string | Sí | Motivo de la decisión cuando `needs_review=true` o `confidence < 0.75`. |
| `next_step` | string | Sí | Siguiente paso recomendado para la persona usuaria. |

El sistema NO DEBE devolver campos extra si el contrato del endpoint no los admite.

## Comportamiento que DEBE cumplir

1. Debe responder solo con apoyo del corpus cuando la pregunta sea sobre políticas internas.
2. Debe incluir al menos una fuente cuando afirme una regla, excepción, plazo, requisito o procedimiento.
3. Debe marcar `needs_review=true` si las fuentes se contradicen, faltan datos o la consecuencia afecta a una decisión académica, legal, sanitaria o económica.
4. Debe registrar `run_id`, `trace_id`, modelo, prompt, dataset, proveedor, latencia, tokens, coste estimado y decisión final.
5. Debe respetar el contrato JSON definido en `support-answer.schema.json`.
6. Debe mantener p95 por debajo de 2500 ms en evaluación y canary.
7. Debe mantener el coste por tarea aceptada por debajo de 0.04 EUR.
8. Debe permitir volver a la release anterior con una bandera de configuración.

## Comportamiento que NO DEBE aparecer

1. No debe inventar fuentes.
2. No debe presentar como hecho una respuesta que no esté sostenida por recuperación o herramienta válida.
3. No debe ocultar incertidumbre cuando las fuentes sean insuficientes.
4. No debe guardar claves, documentos completos ni datos personales si no son necesarios para depurar.
5. No debe repetir una llamada externa indefinidamente: todo reintento debe tener límite.
6. No debe mezclar resultados de dos versiones sin registrar qué versión produjo cada salida.

## Comportamiento que DEBERÍA cumplir

1. Debería pedir una aclaración si la pregunta no contiene el dato mínimo para responder.
2. Debería preferir la fuente más reciente cuando dos documentos válidos traten el mismo procedimiento.
3. Debería explicar el motivo de la respuesta en lenguaje breve, no con jerga interna del sistema.
4. Debería separar respuesta, fuente y siguiente paso.
5. Debería degradar con elegancia si falla una herramienta no esencial.
6. Debería registrar el segmento del caso: matrícula, pagos, becas, documentación, soporte técnico u otro.

## Comportamiento que PUEDE cumplir

1. Puede sugerir documentos relacionados si son relevantes.
2. Puede devolver una pregunta de seguimiento cuando no haya información suficiente.
3. Puede usar una herramienta externa si el manifest la permite y la run queda trazada.
4. Puede enviar el caso a revisión humana si la confianza operativa es baja.

## Ejemplos de comportamiento esperado

| Entrada | Comportamiento esperado | Por qué |
|---|---|---|
| “¿Cuándo acaba el plazo de matrícula?” | Responder con fecha, fuente y `source_id`. | Es una regla temporal; necesita fuente. |
| “Mi pago no aparece, ¿qué hago?” | Pedir identificador o recomendar canal de revisión; `needs_review=true` si falta dato. | Puede depender de estado administrativo real. |
| “Resume la política de becas” | Resumir solo documentos recuperados y listar fuentes. | El corpus manda sobre memoria del modelo. |
| “Cambia mi expediente” | No ejecutar acción; indicar siguiente paso autorizado. | El sistema informa, no modifica registros. |
| “No encuentro un documento” | Pedir nombre aproximado, curso o área; no inventar ruta. | Falta contexto para recuperar bien. |

## Rúbrica de calidad por respuesta

| Dimensión | 0 puntos | 1 punto | 2 puntos |
|---|---|---|---|
| Corrección | Contradice las fuentes. | Es parcialmente correcta. | Coincide con las fuentes relevantes. |
| Fundamentación | No cita fuentes. | Cita fuentes poco precisas. | Cita `source_id` y `chunk_id` adecuados. |
| Utilidad | No da siguiente paso. | Da un paso genérico. | Da un siguiente paso concreto y seguro. |
| Contrato | Rompe JSON o campos. | Cumple con avisos menores. | Cumple contrato sin errores. |
| Operación | No deja trazas. | Deja trazas incompletas. | Deja trazas suficientes para depurar. |

Una respuesta se considera aceptada si obtiene al menos 8 de 10 puntos, no rompe contrato y no incumple ningún DEBE.

## Cómo se convierte esto en evaluación

Cada requisito importante debe tener una prueba asociada:

| Requisito | Eval sugerida | Señal del gate |
|---|---|---|
| Fuentes obligatorias en políticas internas | `eval_sources_required` | `contract_errors == 0` y `source_coverage >= 0.98` |
| Incertidumbre explícita | `eval_uncertainty` | `needs_review_recall >= 0.90` |
| No inventar rutas ni documentos | `eval_no_fake_sources` | `fake_source_rate == 0` |
| Latencia p95 | `eval_latency` | `p95_latency_ms <= 2500` |
| Coste por tarea aceptada | `eval_cost` | `cost_per_accepted_eur <= 0.04` |

Si un requisito no puede evaluarse todavía, debe aparecer como deuda explícita en `decision.md`.

## Relación con el resto del kit

- `AGENTS.md` indica cómo trabajar en el repo.
- `SHOULD.md` indica cómo debe comportarse el sistema.
- `manifest.yaml` fija qué versión concreta intenta cumplir ese comportamiento.
- `release_gate.py` comprueba señales mínimas.
- `decision.md` documenta qué hacemos con la evidencia.

Si cambia `SHOULD.md`, cambia el contrato de comportamiento del sistema. Por tanto, también deben revisarse evals, manifest y gate.

El siguiente archivo fija la versión que vamos a evaluar. Este manifest no es decoración: es la pieza que permite reconstruir qué sistema estaba vivo cuando una salida fue aceptada.

system: support-rag
release: support-rag@1.8.0
owner:
  technical: equipo-plataforma-ia
  product: soporte-interno
model:
  provider: openai
  endpoint: /responses
  name: modelo_fijado_por_el_equipo
  version: 2026-05-27
prompt:
  id: support-system-prompt
  version: 1.8.0
  sha256: 7f0a9d0c4a1b
behavior:
  spec: ops/ai/should.md
  version: 1.0.0
  sha256: 59c3b16c8d2f
retrieval:
  corpus: soporte-politicas
  corpus_version: 2026-05-20
  embedding_model: embedding-model@2026-05
  top_k: 8
  reranker: reranker@2.1
contract:
  output_schema: support-answer.schema.json
  max_contract_errors: 0
eval:
  dataset: evals/support_regression.jsonl
  dataset_sha256: b14c21a0ee32
  min_quality: 0.86
  max_quality_drop: 0.02
  max_p95_latency_ms: 2500
  max_cost_per_accepted_eur: 0.04
rollback:
  strategy: feature_flag
  flag: support_assistant_version
  previous_release: support-rag@1.7.4
observability:
  trace_standard: w3c-trace-context
  required_ids:
    - run_id
    - trace_id
    - provider_request_id

Y ahora sí: el gate ejecutable. Guarda esto como ops/ai/release_gate.py. No depende de ningún proveedor ni de ninguna librería externa, porque la primera versión de una práctica operativa debe poder correr en cualquier máquina.

from dataclasses import dataclass
from statistics import mean


@dataclass(frozen=True)
class EvalRun:
    name: str
    model_version: str
    prompt_version: str
    policy_version: str
    quality_scores: list[float]
    latencies_ms: list[int]
    costs_eur: list[float]
    contract_errors: int
    rollback_plan: str | None


GATE = {
    "min_quality": 0.86,
    "max_quality_drop": 0.02,
    "max_p95_latency_ms": 2500,
    "max_cost_per_accepted_eur": 0.04,
    "max_contract_errors": 0,
}


def percentile(values, p):
    values = sorted(values)
    index = round((len(values) - 1) * p)
    return values[index]


def cost_per_accepted(run: EvalRun, min_case_score=0.80):
    accepted = sum(score >= min_case_score for score in run.quality_scores)
    if accepted == 0:
        return float("inf")
    return sum(run.costs_eur) / accepted


def summarize(run: EvalRun):
    return {
        "name": run.name,
        "model": run.model_version,
        "prompt": run.prompt_version,
        "policy": run.policy_version,
        "quality": round(mean(run.quality_scores), 3),
        "p95_latency_ms": percentile(run.latencies_ms, 0.95),
        "cost_per_accepted_eur": round(cost_per_accepted(run), 4),
        "contract_errors": run.contract_errors,
        "rollback_ready": bool(run.rollback_plan),
    }


def evaluate_gate(baseline: EvalRun, candidate: EvalRun):
    base = summarize(baseline)
    cand = summarize(candidate)

    checks = {
        "quality_min": cand["quality"] >= GATE["min_quality"],
        "quality_drop": cand["quality"] >= base["quality"] - GATE["max_quality_drop"],
        "latency_p95": cand["p95_latency_ms"] <= GATE["max_p95_latency_ms"],
        "cost": cand["cost_per_accepted_eur"] <= GATE["max_cost_per_accepted_eur"],
        "contract": cand["contract_errors"] <= GATE["max_contract_errors"],
        "rollback": cand["rollback_ready"],
    }

    decision = "PROMOTE" if all(checks.values()) else "DO_NOT_PROMOTE"

    trace = [
        {"span": "load_manifest", "attrs": {"candidate": candidate.name}},
        {"span": "compare_quality", "attrs": {"baseline": base["quality"], "candidate": cand["quality"]}},
        {"span": "check_latency", "attrs": {"p95_ms": cand["p95_latency_ms"]}},
        {"span": "check_cost", "attrs": {"eur": cand["cost_per_accepted_eur"]}},
        {"span": "check_contract", "attrs": {"errors": cand["contract_errors"]}},
        {"span": "decision", "attrs": {"result": decision, "failed": [k for k, ok in checks.items() if not ok]}},
    ]

    return {"baseline": base, "candidate": cand, "checks": checks, "decision": decision, "trace": trace}


baseline = EvalRun(
    name="baseline",
    model_version="model-a@2026-05-10",
    prompt_version="support-rag@1.7.4",
    policy_version="policy@3.1.0",
    quality_scores=[0.89, 0.91, 0.85, 0.88, 0.90, 0.87, 0.92],
    latencies_ms=[1200, 1350, 1410, 1490, 1600, 1710, 1850],
    costs_eur=[0.018, 0.020, 0.022, 0.019, 0.021, 0.020, 0.023],
    contract_errors=0,
    rollback_plan="feature flag support_assistant=baseline",
)

candidate_good = EvalRun(
    name="candidate_good",
    model_version="model-b@2026-05-27",
    prompt_version="support-rag@1.8.0",
    policy_version="policy@3.1.0",
    quality_scores=[0.91, 0.92, 0.88, 0.90, 0.91, 0.89, 0.93],
    latencies_ms=[1450, 1520, 1680, 1900, 2100, 2320, 2480],
    costs_eur=[0.021, 0.025, 0.027, 0.026, 0.024, 0.028, 0.030],
    contract_errors=0,
    rollback_plan="feature flag support_assistant=baseline",
)

candidate_bad = EvalRun(
    name="candidate_bad",
    model_version="model-c@2026-05-27",
    prompt_version="support-rag@1.9.0",
    policy_version="policy@3.1.0",
    quality_scores=[0.92, 0.91, 0.90, 0.92, 0.93, 0.89, 0.91],
    latencies_ms=[2100, 2600, 3100, 3400, 3700, 4200, 5100],
    costs_eur=[0.041, 0.046, 0.052, 0.049, 0.050, 0.055, 0.060],
    contract_errors=1,
    rollback_plan=None,
)

for candidate in [candidate_good, candidate_bad]:
    result = evaluate_gate(baseline, candidate)
    print("\n==", candidate.name, "==")
    print(result["candidate"])
    print(result["checks"])
    print(result["decision"])
    print(result["trace"][-1])

Salida esperada:

== candidate_good ==
{'name': 'candidate_good', 'model': 'model-b@2026-05-27', 'prompt': 'support-rag@1.8.0', 'policy': 'policy@3.1.0', 'quality': 0.906, 'p95_latency_ms': 2480, 'cost_per_accepted_eur': 0.0259, 'contract_errors': 0, 'rollback_ready': True}
{'quality_min': True, 'quality_drop': True, 'latency_p95': True, 'cost': True, 'contract': True, 'rollback': True}
PROMOTE
{'span': 'decision', 'attrs': {'result': 'PROMOTE', 'failed': []}}

== candidate_bad ==
{'name': 'candidate_bad', 'model': 'model-c@2026-05-27', 'prompt': 'support-rag@1.9.0', 'policy': 'policy@3.1.0', 'quality': 0.911, 'p95_latency_ms': 5100, 'cost_per_accepted_eur': 0.0504, 'contract_errors': 1, 'rollback_ready': False}
{'quality_min': True, 'quality_drop': True, 'latency_p95': False, 'cost': False, 'contract': False, 'rollback': False}
DO_NOT_PROMOTE
{'span': 'decision', 'attrs': {'result': 'DO_NOT_PROMOTE', 'failed': ['latency_p95', 'cost', 'contract', 'rollback']}}

Por último, redacta ops/ai/decision.md como una decisión técnica completa. Este archivo debe poder leerse dentro de dos meses y responder a una pregunta sencilla: por qué esta versión se puso en marcha o por qué se paró.

# Decisión de release: support-rag@1.8.0

## Resumen ejecutivo

Se promueve `candidate_good` a canary porque mejora la calidad media frente a `baseline`, mantiene la latencia p95 dentro del SLO, conserva el coste por tarea aceptada bajo presupuesto, no produce errores de contrato y tiene vuelta por feature flag.

La decisión no significa que el sistema sea perfecto. Significa que, con la evidencia disponible, merece exponerse a una fracción pequeña de tráfico real con observabilidad reforzada.

## Alcance del cambio

| Pieza | Antes | Después | Por qué importa |
|---|---|---|---|
| Release | `support-rag@1.7.4` | `support-rag@1.8.0` | Identifica la versión que se puede activar o desactivar. |
| Modelo | `model-a@2026-05-10` | `model-b@2026-05-27` | Cambia la distribución de respuestas, latencia y coste. |
| Prompt | `support-rag@1.7.4` | `support-rag@1.8.0` | Ajusta formato, criterio de respuesta y uso de fuentes. |
| Comportamiento esperado | `should.md@1.0.0` | `should.md@1.0.0` | No cambia; el candidato debe cumplir el mismo contrato de comportamiento. |
| Política | `policy@3.1.0` | `policy@3.1.0` | No cambia; reduce el número de variables en la comparación. |
| Dataset de eval | `evals/support_regression.jsonl` | Igual, hash `b14c21a0ee32` | Permite comparar baseline y candidato con el mismo conjunto. |
| Rollback | `support_assistant_version=baseline` | Igual | Permite volver sin redesplegar código. |

## Evidencia

El gate `ops/ai/release_gate.py` devuelve `PROMOTE`.

| Métrica | Baseline | Candidato | Umbral | Decisión |
|---|---:|---:|---:|---|
| Calidad media | 0.889 | 0.906 | `>= 0.86` | Pasa |
| Caída máxima permitida | referencia | +0.017 | `>= baseline - 0.02` | Pasa |
| Latencia p95 | 1850 ms | 2480 ms | `<= 2500 ms` | Pasa, cerca del límite |
| Coste por tarea aceptada | 0.0204 EUR | 0.0259 EUR | `<= 0.04 EUR` | Pasa |
| Errores de contrato | 0 | 0 | `<= 0` | Pasa |
| Vuelta preparada | Sí | Sí | Obligatoria | Pasa |

La traza de evaluación debe incluir `run_id`, `trace_id`, versión de modelo, versión de prompt, versión de `should.md`, versión de política, hash del dataset, latencia, coste, resultado de contrato y decisión final del gate.

## Lectura técnica

El candidato mejora calidad media sin romper contrato, pero se acerca al límite de latencia p95. Eso impide promoverlo directamente al 100%. La decisión correcta no es “publicar y olvidarse”, sino activar canary pequeño, vigilar latencia por segmento y conservar la versión anterior preparada.

La política no cambia. Esto es importante: si hubiéramos cambiado modelo, prompt y política a la vez, cualquier mejora o regresión sería más difícil de atribuir. Aquí la comparación es más limpia porque el cambio se concentra en modelo y prompt.

## Qué no demuestra esta evaluación

- No demuestra que todos los documentos del corpus estén bien indexados.
- No demuestra que el sistema aguante tráfico de hora punta.
- No demuestra que la calidad se mantenga en segmentos no representados en `support_regression.jsonl`.
- No demuestra que el coste mensual vaya a quedar dentro de presupuesto si sube el volumen.
- No demuestra que el proveedor mantenga la misma latencia durante toda la semana.

Por eso la decisión es canary con vigilancia, no promoción completa.

## Plan de despliegue

1. Activar `support_assistant_version=support-rag@1.8.0` para el 5% del tráfico.
2. Mantener el 95% en `support-rag@1.7.4`.
3. Comparar durante 24 horas: calidad aceptada, p95, p99, coste por tarea aceptada, errores de contrato, timeouts y tasa de revisión humana.
4. Subir al 25% solo si p95 queda por debajo de 2500 ms y no aparecen errores de contrato.
5. Promover al 100% solo con decisión nueva en este mismo archivo o en una entrada posterior.

## Plan de vuelta

Volver inmediatamente a `support-rag@1.7.4` si ocurre cualquiera de estas condiciones:

- p95 supera 2500 ms durante dos ventanas consecutivas de 15 minutos.
- p99 supera 5000 ms en cualquier ventana de 15 minutos.
- aparece al menos un error de contrato.
- el coste por tarea aceptada supera 0.04 EUR.
- aumenta la tasa de revisión humana respecto al baseline.
- faltan `trace_id` o `provider_request_id` en más del 1% de las runs.

La vuelta se hace cambiando `support_assistant_version` a `support-rag@1.7.4`. Después se conservan trazas, manifest, resultados del gate y muestra de entradas afectadas. La revisión posterior debe separar cuatro posibles causas: proveedor, modelo, prompt y retrieval.

## Seguimiento

| Responsable | Tarea | Cuándo |
|---|---|---|
| Owner técnico | Revisar métricas de canary y trazas lentas. | Primeras 24 horas. |
| Producto | Revisar salidas marcadas para revisión humana. | Primeras 24 horas. |
| Plataforma | Comprobar coste, timeouts y errores de proveedor. | Cada 4 horas durante canary. |
| Equipo de datos | Revisar si el dataset de eval cubre los casos que fallaron. | Tras el cierre del canary. |

## Decisión final

Estado: `PROMOTE_TO_CANARY`.

No se promueve al 100% todavía. La siguiente decisión deberá adjuntar resultados reales de canary, no solo eval offline.

La práctica enseña algo muy importante: el gate no pregunta si “nos gusta” la respuesta. Pregunta si una versión mejora o mantiene las dimensiones que importan. candidate_bad tiene más calidad media que el baseline, pero no debe publicarse: tarda demasiado, cuesta demasiado, rompe contrato y no trae rollback. Esto es ingeniería: no optimizar una métrica mientras el sistema se degrada alrededor.

Cómo corregiría esta práctica

Si esto fuera una entrega universitaria, no pondría toda la nota en que el código ejecute. Lo corregiría así:

CriterioPesoQué espero ver
AGENTS.md operativo15%Instrucciones de repo concretas: mapa, comandos, evidencia, límites y Definition of Done.
SHOULD.md verificable20%Comportamientos DEBE, NO DEBE, DEBERÍA y PUEDE convertidos en señales evaluables.
Manifest y versionado15%Modelo, prompt, comportamiento esperado, política, dataset, runtime, owner y rollback identificados.
Gate y eval25%Condiciones ejecutables que bloquean calidad, latencia, coste, contrato y rollback.
Trazas y depuración15%trace_id, spans, estados de run y paquete mínimo para depurar proveedor.
Decisión técnica10%decision.md explica promover, pausar, volver o simplificar con evidencia.

La rúbrica no busca burocracia. Busca que el alumno aprenda a entregar evidencia. En sistemas de IA, “lo probé y parece que va bien” no debería aprobar una práctica de ingeniería.

Cómo encaja todo

flowchart TD
    subgraph F6["Facsímil 6: construir y operar"]
      C1["Cap. 01<br/>prototipo a sistema operable"]
      C2["Cap. 02<br/>runtime, API, colas y estado"]
      C3["Cap. 03<br/>serving de modelos"]
      C4["Cap. 04<br/>escalado de inferencia"]
      C5["Cap. 05<br/>observabilidad"]
      C6["Cap. 06<br/>EvalOps y release gates"]
      C7["Cap. 07<br/>DataOps para IA"]
      C8["Cap. 08<br/>configuración y prompts como código"]
      C9["Cap. 09<br/>SLO, incidentes y rollback"]
      C10["Cap. 10<br/>laboratorio de operación"]
    end

    subgraph Antes["Piezas que ya traemos"]
      F4["Facsímil 4<br/>APIs, RAG, evals y trazas"]
      F5["Facsímil 5<br/>agentes, tools y orquestación"]
      F3["Facsímil 3<br/>modelos, inferencia y hardware"]
    end

    subgraph Despues["Lo que vendrá después"]
      F7["Facsímil 7<br/>evaluar e interpretar"]
      F8["Facsímil 8<br/>datos y decisión"]
      F9["Facsímil 9<br/>privacidad y gobernanza"]
      F11["Facsímil 11<br/>producto y UX"]
    end

    F4 -->|"aportar herramientas y evals"| C1
    F5 -->|"aportar acciones y estado"| C1
    F3 -->|"aportar límites de modelo"| C3
    C1 -->|"descomponer runtime"| C2
    C2 -->|"necesitar servir modelos"| C3
    C3 -->|"exigir escalado"| C4
    C4 -->|"emitir métricas"| C5
    C5 -->|"alimentar gates"| C6
    C6 -->|"depender de datos trazables"| C7
    C7 -->|"versionar configuración"| C8
    C8 -->|"hacer operación diaria"| C9
    C9 -->|"preparar práctica completa"| C10
    C6 -->|"conectar métricas profundas"| F7
    C7 -->|"conectar linaje y calidad"| F8
    C9 -->|"conectar controles y cumplimiento"| F9
    C1 -->|"impactar experiencia real"| F11

El mapa muestra que este facsímil no es un apéndice técnico. Es el puente entre entender piezas y poder sostenerlas en uso real.

Vocabulario aprendido

TérminoDefinición
AGENTS.mdArchivo de instrucciones operativas del repositorio para agentes y personas: mapa, comandos, evidencias, límites y Definition of Done.
SHOULD.mdEspecificación de comportamiento esperado: qué debe hacer el sistema, qué no debe hacer y cómo se evalúa.
MUST / SHOULD / MAYVocabulario de requisitos para separar obligaciones, expectativas y capacidades opcionales.
Manifest operativoDocumento versionado que fija qué modelo, prompt, política, dataset, contrato, runtime y rollback forman una release concreta.
Sistema operableSistema que se puede desplegar, observar, evaluar, limitar y revertir con evidencia.
Control planeCapa de configuración y gobierno: modelos, prompts, políticas, flags, datasets y límites.
RuntimeCapa que ejecuta peticiones reales con modelo, herramientas, colas, timeouts y validadores.
RunEjecución completa de una petición, desde entrada hasta decisión final.
SpanUnidad observable dentro de una traza: llamada al modelo, retrieval, tool, validador o gate.
SLIService Level Indicator: indicador medido, como calidad aceptada, p95, coste por tarea aceptada o error de contrato.
SLOService Level Objective: objetivo medible que fija qué nivel mínimo queremos sostener para un SLI.
Presupuesto de errorError budget: margen de fallo que permite un SLO antes de frenar cambios o investigar degradaciones.
Máquina de estadosLista de estados y transiciones válidas para una ejecución.
Release gateRegla que decide si un cambio avanza, se pausa o vuelve atrás.
CanaryExposición gradual de una versión nueva a una parte pequeña del tráfico.
Shadow runEjecución en paralelo que no afecta al usuario, usada para comparar.
Coste por tarea aceptadaCoste real por salida válida, incluyendo reintentos, herramientas y revisión.
RollbackVuelta preparada a una versión anterior.

Dónde solía tropezar yo

TropiezoPor qué es un problemaAntídoto
Creer que producción empieza al desplegarLlegas tarde a trazas, evals, límites y rollback.Diseñar manifest, eval y gate antes del primer rollout.
Medir solo calidad mediaOculta latencia, coste, errores de contrato y segmentos débiles.Mirar calidad por slice, p95, coste por tarea aceptada y errores tipados.
Versionar solo el códigoEl comportamiento cambia también por modelo, prompt, política, dataset y retrieval.Versionar todos los artefactos que cambian una salida.
No tener estado operativoNadie sabe si una run está ejecutando, esperando, reintentando o cerrada.Modelar estados y transiciones explícitas.
Confundir logs con observabilidadGuardar texto no basta para reconstruir causalidad.Usar trazas con IDs, spans, atributos y tiempos.
Aprobar por una sola métricaUna versión puede mejorar calidad media y romper latencia, coste o contrato.Exigir gates multidimensionales y presupuesto de error.

Antes de pasar página

  • ¿Puedes explicar la diferencia entre demo, prototipo y sistema operable?
  • ¿Puedes enumerar qué piezas componen el control plane de un sistema de IA?
  • ¿Puedes decir qué debería guardar una run mínima?
  • ¿Puedes justificar por qué el coste por tarea aceptada es más útil que el precio por token?
  • ¿Puedes convertir un criterio de release en condiciones ejecutables?
  • ¿Puedes escribir un AGENTS.md que indique comandos, evidencia mínima, límites y Definition of Done?
  • ¿Puedes escribir un SHOULD.md que separe comportamiento obligatorio, esperado y opcional?
  • ¿Puedes convertir un DEBE de SHOULD.md en una señal de evaluación dentro del gate?
  • ¿Puedes leer un manifest.yaml y reconstruir qué modelo, prompt, dataset y contrato se evaluaron?
  • ¿Puedes explicar por qué una mejora de calidad puede no merecer despliegue si rompe latencia, coste, contrato o rollback?
  • ¿Puedes dibujar la máquina de estados de una run y decir qué evento mueve cada transición?
  • ¿Puedes distinguir SLI, SLO y presupuesto de error con un ejemplo numérico?
  • ¿Puedes clasificar un fallo como entrada, retrieval, contexto, contrato, latencia, coste, estado o cambio?

Para saber más

En resumen

IdeaQué debes llevarte
Una demo no es un sistema.Un sistema operable deja versiones, trazas, métricas, contratos, gates y rollback.
La IA añade artefactos al cambio.No basta versionar código: también cambian modelo, prompt, contexto, política, dataset y runtime.
Operar es medir decisiones.Calidad, latencia, coste, contrato y rollback deben convertirse en criterios ejecutables.
Ingeniería es modelar fallos.Estados, transiciones, SLI, SLO, taxonomía de fallos y presupuesto de error hacen el sistema depurable.
El facsímil empieza aquí.A partir de ahora bajaremos pieza a pieza: runtime, serving, escalado, observabilidad, EvalOps, DataOps y operación diaria.

Notas

  1. Sculley, D. et al. (2015). Hidden Technical Debt in Machine Learning Systems. Advances in Neural Information Processing Systems, 28. https://papers.nips.cc/paper_files/paper/2015/hash/86df7dcfd896fcaf2674f757a2463eba-Abstract.html. Consultado el 27 de mayo de 2026.

  2. Amershi, S. et al. (2019). Software Engineering for Machine Learning: A Case Study. International Conference on Software Engineering: Software Engineering in Practice, 291-300. https://doi.org/10.1109/ICSE-SEIP.2019.00042. Consultado el 27 de mayo de 2026.

  3. Preston-Werner, T. (2026). Semantic Versioning 2.0.0. https://semver.org/. Consultado el 27 de mayo de 2026.

  4. Baylor, D. et al. (2017). TFX: A TensorFlow-Based Production-Scale Machine Learning Platform. Proceedings of the 23rd ACM SIGKDD International Conference on Knowledge Discovery and Data Mining, 1387-1395. https://doi.org/10.1145/3097983.3098021. Consultado el 27 de mayo de 2026.

  5. OpenTelemetry. (2026). Tracing API. https://opentelemetry.io/docs/specs/otel/trace/api/. Consultado el 27 de mayo de 2026.

  6. World Wide Web Consortium. (2021). Trace Context Level 2. https://www.w3.org/TR/trace-context-2/. Consultado el 27 de mayo de 2026.

  7. OpenAI. (2026). API reference: debugging requests. https://developers.openai.com/api/reference/overview#debugging-requests. Consultado el 27 de mayo de 2026.

  8. Anthropic. (2026). Errors. https://platform.claude.com/docs/en/api/errors. Consultado el 27 de mayo de 2026.

  9. Google. (2026). Gemini API troubleshooting guide. https://ai.google.dev/gemini-api/docs/troubleshooting. Consultado el 27 de mayo de 2026.

  10. Amazon Web Services. (2026). Troubleshooting Amazon Bedrock API Error Codes. https://docs.aws.amazon.com/bedrock/latest/userguide/troubleshooting-api-error-codes.html. Consultado el 27 de mayo de 2026.

  11. OpenAI. (2026). Error codes. https://developers.openai.com/api/docs/guides/error-codes. Consultado el 27 de mayo de 2026.

  12. OpenAI. (2026). OpenAI Status. https://status.openai.com/. Consultado el 27 de mayo de 2026.

  13. Anthropic. (2026). Claude Status. https://status.claude.com/. Consultado el 27 de mayo de 2026.

  14. Amazon Web Services. (2026). AWS Health Dashboard: Service health. https://docs.aws.amazon.com/health/latest/ug/aws-health-dashboard-status.html. Consultado el 27 de mayo de 2026.

  15. OpenAI. (2026). AGENTS.md. https://github.com/openai/agents.md. Consultado el 27 de mayo de 2026.

  16. Fowler, M. (2025). Harness Engineering for Coding Agent Users. https://martinfowler.com/articles/harness-engineering.html. Consultado el 27 de mayo de 2026.

  17. Bradner, S. (1997). Key words for use in RFCs to Indicate Requirement Levels. RFC 2119. https://www.rfc-editor.org/rfc/rfc2119. Consultado el 27 de mayo de 2026.

  18. Leiba, B. (2017). Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words. RFC 8174. https://www.rfc-editor.org/rfc/rfc8174. Consultado el 27 de mayo de 2026.

Capítulo 02

Facsímil 6 · Construir y operar

Capítulo 02: Arquitectura de runtime: API, colas, estado y contratos

Qué deberías poder hacer al terminar

En el capítulo anterior construimos el marco: manifest, AGENTS.md, SHOULD.md, gates, trazas y decisiones. Ahora bajamos un nivel. Si alguien llama a tu sistema de IA, ¿qué ocurre exactamente desde que entra la petición hasta que sale la respuesta?

Al terminar, deberías poder hacer esto:

Resultado de aprendizajeEvidencia de que lo sabes hacer
Diseñar la entrada de una API de IA.Separas usuario, tarea, payload, permisos, idempotencia y presupuesto.
Modelar una petición como una run.Distingues run_id, request_id, trace_id, estado, spans y resultado.
Explicar por qué una cola cambia la arquitectura.Calculas presión de cola, tiempo de espera y capacidad mínima.
Diseñar contratos de entrada y salida.Usas esquemas, validadores, errores tipados y versión de contrato.
Definir timeouts y streaming.Repartes presupuesto de latencia entre fases y decides qué se entrega progresivamente.

La idea central es sencilla pero potente: un runtime de IA no es una llamada al modelo; es una máquina de coordinación.

La petición que parece simple

Imagina un botón en una aplicación universitaria: “resumir expediente y proponer siguiente paso”. Desde la interfaz parece una acción limpia: pulsas, espera unos segundos y aparece una respuesta. Pero por debajo ocurren muchas cosas.

El sistema debe comprobar quién llama, qué permiso tiene, qué versión del asistente está activa, si esa petición ya llegó antes, qué documentos necesita recuperar, qué modelo debe usar, cuánto tiempo queda, qué salida será válida, qué hacer si el proveedor tarda demasiado y qué traza dejar para poder depurar.

Por eso este capítulo no empieza en el modelo. Empieza en el borde del sistema: la API. En ingeniería, los bordes importan porque ahí se convierten intenciones humanas en contratos técnicos.

Qué no es el runtime

Un runtime no es “el SDK del proveedor”. El SDK ayuda a llamar a un modelo, pero no decide por sí solo cómo versionas prompts, cómo deduplicas peticiones, cómo gestionas una cola, cómo validas una respuesta o cómo haces rollback.

Tampoco es solo “el servidor”. Puedes tener un servidor HTTP y seguir sin runtime operativo: si no hay estado de run, si no hay contrato, si no hay trazas y si no sabes qué hacer cuando una dependencia va lenta, tienes una puerta de entrada, no un sistema.

La diferencia práctica es esta:

ConfusiónQué falta
Endpoint HTTPFalta estado, idempotencia, validación, trazas y política de errores.
SDK del modeloFalta coordinación con retrieval, tools, colas, contratos y presupuesto.
Script que llama a una APIFalta operación: retries, timeouts, owners, métricas y rollback.
Worker sueltoFalta control de entrada, orden, prioridad, cancelación y deduplicación.

Qué sí es un runtime de IA

Un runtime de IA es la capa que ejecuta peticiones reales bajo reglas operativas. Recibe una intención, la convierte en una run, coordina pasos, conserva estado, llama a dependencias, valida salida y deja evidencia.

La API HTTP sigue siendo importante. RFC 9110 define la semántica de HTTP: métodos, estado, representación y comportamiento esperado de las peticiones.1 Pero una API de IA añade dimensiones que un CRUD clásico no siempre tenía tan visibles:

DimensiónPregunta que responde
Identidad¿Quién pide esto y con qué permisos?
Idempotencia¿Qué pasa si el cliente repite la petición?
Contexto¿Qué documentos, memoria o estado entran en la run?
Presupuesto¿Cuánto tiempo, coste y tokens puede gastar?
Contrato¿Qué forma debe tener la salida para ser aceptada?
Observabilidad¿Cómo reconstruimos lo ocurrido?
Degradación¿Qué hacemos si una dependencia no responde a tiempo?

Fecha de corte: 27 de mayo de 2026. Fuentes consultadas ese día: documentación oficial de OpenAI Responses API, streaming y salidas estructuradas; documentación de Anthropic Messages API y streaming; JSON Schema Draft 2020-12; OpenTelemetry Tracing API y convenciones GenAI; W3C Trace Context. Lo estable es la arquitectura: API, estado, colas, contratos, timeouts y trazas. Lo cambiante son endpoints concretos, eventos de streaming, nombres de modelos, SDKs y límites de proveedor.

El mecanismo por dentro

Una petición robusta suele pasar por estas fases:

  1. Entrada: llega una petición HTTP con usuario, payload, clave de idempotencia y presupuesto.
  2. Normalización: el sistema valida formato, permisos, tamaño y campos obligatorios.
  3. Creación de run: se asignan run_id, trace_id, versión de manifest y estado inicial.
  4. Planificación: se decide si la petición va directa, a cola, a worker especializado o a revisión.
  5. Ejecución: retrieval, herramientas, llamada al modelo, streaming y validación.
  6. Cierre: se guarda resultado, métricas, coste, contrato, estado final y decisión.

La latencia total se puede leer como suma de fases:

Ttotal=Tentrada+Tcola+Tretrieval+Ttools+Tmodelo+Tvalidacioˊn+TsalidaT_{\text{total}} = T_{\text{entrada}} + T_{\text{cola}} + T_{\text{retrieval}} + T_{\text{tools}} + T_{\text{modelo}} + T_{\text{validación}} + T_{\text{salida}}
SímboloSignificadoEjemplo
TentradaT_{\text{entrada}}Tiempo de autenticación, parseo y validación inicial.80 ms
TcolaT_{\text{cola}}Tiempo esperando a que un worker pueda ejecutar.600 ms
TretrievalT_{\text{retrieval}}Tiempo de buscar y preparar contexto.350 ms
TtoolsT_{\text{tools}}Tiempo consumido por herramientas externas.500 ms
TmodeloT_{\text{modelo}}Tiempo de prefill, generación y respuesta del modelo.1800 ms
TvalidacioˊnT_{\text{validación}}Tiempo de comprobar contrato y reglas.90 ms
TsalidaT_{\text{salida}}Tiempo de serializar, emitir eventos o guardar resultado.50 ms

Con esos números:

Ttotal=80+600+350+500+1800+90+50=3470 msT_{\text{total}} = 80 + 600 + 350 + 500 + 1800 + 90 + 50 = 3470 \text{ ms}

Si tu SLO (Service Level Objective, objetivo de nivel de servicio) era respuesta final <= 3000 ms, el problema no se arregla diciendo “el modelo es lento”. Tienes que mirar la descomposición. En este ejemplo, cola y modelo suman 2400 ms. Si además retrieval o tools se mueven un poco, el SLO cae.

Las colas tienen una ley muy útil para razonar. La ley de Little dice:

L=λWL = \lambda W
SímboloSignificadoEjemplo
LLNúmero medio de trabajos dentro del sistema o de una cola.24 runs
λ\lambdaTasa media de llegada de trabajos.6 runs/s
WWTiempo medio que un trabajo pasa dentro del sistema.4 s

La lectura sería:

24=6×424 = 6 \times 4

Little demostró esta relación para sistemas de colas bajo condiciones generales.2 Para nosotros la intuición es suficiente: si llegan más runs por segundo y cada una tarda más, la cola crece. Si la cola crece, sube TcolaT_{\text{cola}}. Si sube TcolaT_{\text{cola}}, el usuario siente que “la IA va lenta”, aunque el modelo no haya cambiado.

También necesitamos repartir presupuesto de tiempo:

BSLOBcola+Bretrieval+Btools+Bmodelo+Bvalidacioˊn+MB_{\text{SLO}} \geq B_{\text{cola}} + B_{\text{retrieval}} + B_{\text{tools}} + B_{\text{modelo}} + B_{\text{validación}} + M
SímboloSignificadoEjemplo
BSLOB_{\text{SLO}}Presupuesto total de latencia.3000 ms
BcolaB_{\text{cola}}Máximo tolerable de espera en cola.400 ms
BretrievalB_{\text{retrieval}}Máximo para recuperar contexto.300 ms
BtoolsB_{\text{tools}}Máximo para herramientas externas.500 ms
BmodeloB_{\text{modelo}}Máximo para modelo.1500 ms
BvalidacioˊnB_{\text{validación}}Máximo para validadores.100 ms
MMMargen para red, serialización y variabilidad.200 ms

Aquí:

3000400+300+500+1500+100+2003000 \geq 400 + 300 + 500 + 1500 + 100 + 200

La igualdad no es el objetivo. El objetivo es detectar que si tools tarda 900 ms, no puedes fingir que todo sigue dentro de presupuesto.

Anatomía visual de un runtime

Runtime de IA: una petición convertida en run operable API, idempotencia, estado, colas, workers, contratos, streaming y trazas en una misma arquitectura. Cliente / producto request_id idempotency_key payload + presupuesto + usuario API boundary 1. autenticar identidad 2. validar contrato de entrada 3. aplicar límites de tamaño 4. resolver manifest activo 5. crear o recuperar run Run store run_id trace_id estado actual manifest + prompt resultado + errores tipados Máquina de estados accepted queued running streaming validated closed Router y scheduler prioridad por tipo de tarea presupuesto de latencia y tokens modo síncrono, background o cola política de cancelación Colas fast rag tools dedupe · prioridad · backpressure · TTL Workers de ejecución retrieval tools model call timeouts · retries limitados · streaming Contratos entrada: schema + permisos salida: JSON + campos obligatorios errores: tipo, causa y acción versionado: contract@major.minor Gateway de modelo proveedor o modelo local stream events normalizados uso de tokens y coste provider_request_id Observabilidad trace_id propagado spans por fase TTFT, final latency, coste eventos de estado y decisión La arquitectura buena no promete que nada falle; promete que cada fase tiene contrato, estado, límite y evidencia. IA para gente curiosa / Facsímil 06 / Capítulo 02 / 686f6c61

El diagrama no pretende imponer una única arquitectura. Pretende darte una checklist mental. Si una petición no tiene run_id, no puedes seguirla. Si no tiene idempotencia, una repetición puede duplicar trabajo. Si no hay cola, los picos llegan directos al modelo. Si no hay contrato de salida, el frontend y los sistemas aguas abajo quedan expuestos a respuestas imprevisibles.

API de entrada: más que un prompt

Una API de IA debería recibir algo más estructurado que { "prompt": "..." }. Ese formato sirve para un tutorial. En un sistema real, la entrada debería distinguir intención, usuario, tarea, permisos, presupuesto y contrato esperado.

Un ejemplo mínimo:

{
  "request_id": "req_20260527_0001",
  "idempotency_key": "user42:support-summary:expediente-983:v1",
  "tenant_id": "universidad-demo",
  "actor": {
    "user_id": "user42",
    "role": "orientador"
  },
  "task": {
    "type": "support_summary",
    "input": {
      "case_id": "expediente-983",
      "question": "Resume la situación y propone siguiente paso."
    }
  },
  "budget": {
    "max_latency_ms": 3000,
    "max_input_tokens": 8000,
    "max_output_tokens": 700,
    "max_cost_eur": 0.03
  },
  "response_contract": "support-answer@1.2.0",
  "stream": true
}

Para no depender mentalmente de un único proveedor, conviene mirar cómo aparecen estas piezas en varias documentaciones:

PiezaFuente consultadaQué nos interesa para el runtime
Creación de respuestasOpenAI Responses API.3Entrada, modelo, herramientas, instrucciones y respuesta como recurso.
StreamingOpenAI streaming.4Eventos progresivos y experiencia de usuario.
Salida estructuradaOpenAI structured outputs.5Contrato de salida validable.
MensajesAnthropic Messages API.6Conversación stateless o multi-turn en formato de mensajes.
Streaming de mensajesAnthropic streaming.7Secuencia de eventos y tratamiento de bloques de contenido.

El punto de ingeniería no es memorizar el endpoint de hoy. Es diseñar una capa interna que pueda hablar con varios proveedores sin que el resto del producto dependa de cada detalle.

Identidad, permisos y tenant

La API boundary no solo valida JSON. También decide quién puede hacer qué sobre qué run. En sistemas de IA esto importa porque una run puede contener documentos recuperados, eventos internos, costes, decisiones y salidas que no deberían ser visibles para cualquiera.

Tres preguntas mínimas:

PreguntaQué compruebaEjemplo
Autenticación¿Quién eres?Token de sesión, API key, OAuth, certificado interno.
Autorización¿Qué puedes hacer?Crear runs, cancelar, leer eventos, ver fuentes, reintentar.
Tenancy¿De qué organización o espacio eres?tenant_id = universidad-demo.

Una regla práctica: cada operación sobre /runs/{run_id} debe comprobar que el actor pertenece al tenant correcto y tiene permiso para esa acción. No basta con que conozca el run_id. Un identificador no es una autorización.

OperaciónPermiso mínimoRiesgo si se olvida
POST /runsCrear run en ese tenant.Ejecutar tareas fuera de contexto.
GET /runs/{run_id}Leer run propia o del equipo autorizado.Exponer documentos, coste o decisión.
GET /runs/{run_id}/eventsLeer eventos de esa run.Filtrar trazas internas o fuentes.
POST /runs/{run_id}/cancelCancelar runs propias o administradas.Parar trabajo de otra persona.
POST /runs/{run_id}/retryReintentar bajo presupuesto y permiso.Duplicar coste o saltarse revisión.

En el run store, tenant_id y actor_id no son metadatos decorativos. Son parte de la frontera de seguridad y de auditoría. Si una traza no conserva tenant y actor, luego no sabes quién hizo qué ni qué podía ver.

Patrones de respuesta de una API de IA

En una API clásica, muchas veces esperas que una petición entre y salga con un 200 OK. En IA eso no siempre es lo mejor. Algunas tareas son cortas y pueden responder en la misma conexión. Otras requieren cola, streaming, revisión o ejecución en segundo plano. Diseñar bien el runtime empieza por elegir el patrón de respuesta adecuado.

PatrónRespuesta inicialCuándo encajaQué debe quedar guardado
Síncrono200 OK con resultado final.Tareas rápidas, baratas y de bajo impacto.run_id, contrato validado, coste y traza.
Aceptado y polling202 Accepted con run_id.Tareas lentas o con cola.Estado de run, endpoint de consulta y TTL.
Streaming200 OK con eventos SSE o stream equivalente.Experiencia interactiva o progreso visible.Eventos emitidos, primer evento, cierre y errores.
Background job202 Accepted y trabajo en cola.Procesos largos que no necesitan conexión abierta.Cola, prioridad, owner, reintentos y resultado.
Webhook202 Accepted y callback posterior.Integraciones entre sistemas.URL registrada, firma, intentos y confirmación de entrega.
HíbridoProgreso por streaming y resultado persistido.Tareas con UX viva y necesidad de auditoría.Stream, run final y contrato validado.

El patrón no es solo una decisión de backend. Afecta al usuario. Un 202 Accepted honesto dice: “he recibido tu trabajo, aquí tienes un identificador, vuelve a consultar”. Un streaming honesto dice: “estoy trabajando y estos son eventos de progreso”. Un 200 OK forzado en una tarea lenta suele terminar en timeouts, pantallas congeladas o reintentos del cliente que duplican coste.

Una API de runtime publicable podría tener esta forma:

EndpointMétodoPropósitoRespuesta normal
/runsPOSTCrear una run o recuperar una existente por idempotencia.201 Created o 200 OK si era replay.
/runs/{run_id}GETConsultar estado, resultado y metadatos.200 OK.
/runs/{run_id}/eventsGETLeer eventos de progreso por streaming.200 OK con text/event-stream.
/runs/{run_id}/cancelPOSTPedir cancelación cooperativa.202 Accepted.
/runs/{run_id}/retryPOSTReintentar una run cerrada con error recuperable.202 Accepted.
/runs/{run_id}/decisionGETVer por qué se cerró, se pausó o se envió a revisión.200 OK.

HTTP ya nos da códigos útiles, pero hay que usarlos con intención:

CódigoUso razonable en runtime de IA
200 OKResultado disponible o replay idempotente ya resuelto.
201 CreatedRun creada y aceptada.
202 AcceptedRun aceptada, pero todavía no terminada.
400 Bad RequestPayload inválido.
401/403Identidad ausente o permiso insuficiente.
404 Not Foundrun_id inexistente o no visible para ese actor.
409 ConflictLa run no admite esa transición de estado.
422 Unprocessable ContentEntrada bien formada, pero no cumple contrato semántico.
429 Too Many RequestsLímite por usuario, tenant o presupuesto.
503 Service UnavailableSistema saturado o dependencia crítica no disponible.

El cliente no debería tener que adivinar. Cada error debería incluir error_code, message, run_id si existe, trace_id si existe, retryable, retry_after_ms cuando aplique y una acción recomendada.

Estado: la run como fuente de verdad

Una run es una ejecución completa. No es lo mismo que una request HTTP, porque una request puede terminar rápido diciendo “aceptado” mientras la run sigue en background. Tampoco es lo mismo que una llamada al modelo, porque una run puede incluir retrieval, herramientas, validación, streaming, reintentos y cierre.

IdentificadorQué identificaQuién lo usa
request_idLa petición recibida por tu API.Cliente, gateway y logs HTTP.
idempotency_keyLa intención repetible que no debe duplicarse.API boundary y run store.
run_idLa ejecución de negocio que queremos seguir.Producto, soporte, evals y operación.
trace_idLa traza distribuida de todos los spans.Observabilidad.
provider_request_idLa llamada concreta a un proveedor/modelo.Depuración con proveedor o runtime.

Una máquina de estados mínima podría ser:

stateDiagram-v2
    [*] --> received
    received --> rejected: entrada inválida
    received --> accepted: contrato de entrada correcto
    accepted --> queued: no cabe ejecutar ahora
    accepted --> running: ejecución directa
    queued --> running: worker disponible
    running --> streaming: primer evento emitido
    streaming --> validating: salida completa
    running --> validating: sin streaming
    validating --> succeeded: contrato válido
    validating --> needs_review: salida dudosa
    running --> timed_out: presupuesto agotado
    queued --> cancelled: TTL agotado o usuario cancela
    timed_out --> fallback: existe respuesta alternativa
    fallback --> succeeded
    succeeded --> [*]
    needs_review --> [*]
    rejected --> [*]
    cancelled --> [*]

Cada transición debería guardar evento, timestamp y motivo. No basta con saber que una run terminó mal. Hay que saber si fue rechazada por contrato, cancelada por TTL, parada por timeout, enviada a revisión o cerrada correctamente.

Persistencia mínima del run store

El run store no tiene por qué ser sofisticado al principio, pero sí debe guardar lo suficiente para reconstruir una ejecución. Si solo guardas el texto final, pierdes la historia. Si guardas todo sin criterio, creas una base de datos difícil de proteger y consultar.

Un modelo mínimo podría tener cuatro tablas o colecciones:

TablaQué guardaCampos mínimos
runsLa entidad principal de ejecución.run_id, tenant_id, actor_id, state, manifest_version, created_at, updated_at.
run_eventsCambios de estado y eventos importantes.run_id, event_name, state, timestamp, attrs_json.
run_outputsResultado final o salida candidata.run_id, contract_version, output_json, validated, created_at.
run_errorsFallos normalizados.run_id, error_code, retryable, provider_request_id, message, created_at.

Y una tabla opcional cuando hay streaming:

TablaQué guardaCuándo merece la pena
run_stream_eventsEventos enviados al cliente, con orden.Si necesitas reanudar stream, auditar UX o depurar cortes de conexión.

Hay una decisión delicada: qué guardar de la entrada y la salida. En muchos sistemas no conviene guardar prompts completos o documentos enteros. Puede bastar con hashes, IDs de documentos, metadatos mínimos y una política clara de retención. Operar no significa guardar todo; significa guardar lo necesario para explicar y mejorar el sistema sin acumular datos de más.

Cancelar, reanudar y expirar

El ciclo de vida de una run se complica cuando el usuario cierra la pestaña, recarga, cancela o pierde conexión. Un runtime serio no debería depender de que la conexión HTTP sobreviva hasta el final.

SituaciónQué debería hacer el runtime
El usuario recarga la página.Recuperar por run_id o idempotency_key; no crear otra run.
Se corta el streaming.Mantener la run viva y permitir reconectar a /runs/{run_id}/events.
El usuario cancela.Marcar cancelling, avisar a workers y cerrar como cancelled si no hay fase irreversible.
La run espera demasiado en cola.Cerrar como expired si supera TTL.
El worker se cae.Reencolar si la fase es recuperable y no supera máximo de intentos.
El contrato falla.Cerrar como contract_failed y no mostrar resultado final.

Para reanudación hace falta distinguir dos cosas:

PiezaQué resuelve
run_idPermite consultar una ejecución ya creada.
resume_tokenPermite reconectar a un stream desde un punto conocido si el protocolo lo soporta.
last_event_idPermite saber qué eventos ya vio el cliente.
result_urlPermite recuperar resultado final aunque el stream se perdiera.

La cancelación también debe ser honesta. Cancelar una run que todavía está en cola es fácil. Cancelar una llamada al modelo ya enviada puede depender del proveedor o del runtime. Cancelar una acción externa que ya se ejecutó quizá no sea posible. Por eso una buena API no promete “cancelado” demasiado pronto; primero marca cancelling, luego confirma cancelled o explica que la fase ya no era cancelable.

Colas: cuando el tráfico deja de ser educado

La cola aparece cuando no quieres que todas las peticiones entren al runtime al mismo tiempo. En IA esto es especialmente importante porque cada petición puede tener coste muy distinto. Una FAQ corta y un análisis con 80.000 tokens no consumen lo mismo.

Las colas sirven para:

ProblemaCómo ayuda la cola
Picos de tráficoAbsorbe entradas y permite ejecutar a ritmo controlado.
Tareas lentasSepara respuesta inmediata de trabajo en background.
PrioridadesPermite colas por tipo: rápido, RAG largo, herramientas, revisión.
CosteEvita disparar llamadas caras sin control.
BackpressurePermite decir “no acepto más” antes de romper SLO.

Pero una cola también puede esconder problemas. Si nadie mira longitud, edad máxima y tiempo de espera, la cola se convierte en una sala de espera infinita. Una buena cola tiene TTL, prioridad, límites y métricas.

Una cola profesional suele añadir estas piezas:

PiezaQué resuelveSeñal que deberías medir
PrioridadNo todas las tareas tienen la misma urgencia.Tiempo de espera por prioridad.
Fairness por tenantUn cliente grande no debería ocupar toda la capacidad.Cuota usada por tenant.
BackoffReintentar demasiado rápido empeora la saturación.Intentos, retraso y causa.
Dead-letter queueLos trabajos que fallan siempre no bloquean la cola principal.Entradas en DLQ y motivo.
TTLUna tarea demasiado vieja quizá ya no sirve.Edad máxima en cola.
DedupeDos trabajos equivalentes no deberían gastar dos veces.Replays por idempotencia.
BackpressureEl sistema avisa antes de romperse.429, 503, cola llena y rechazos.

Un patrón típico de reintento es backoff exponencial con jitter:

dn=min(dmax,d02n)+ϵd_n = \min(d_{\max}, d_0 \cdot 2^n) + \epsilon
SímboloSignificadoEjemplo
dnd_nEspera antes del intento nn.4000 ms
d0d_0Espera inicial.500 ms
nnNúmero de intento, empezando en 0.3
dmaxd_{\max}Límite máximo de espera.10.000 ms
ϵ\epsilonPequeña variación aleatoria para evitar sincronización.120 ms

Sin jitter, muchos workers pueden reintentar a la vez y crear otro pico artificial. Con jitter, los reintentos se reparten un poco mejor. La idea importante: reintentar no es gratis. Cada reintento consume cola, coste, tokens y atención operativa.

Semántica de entrega: at-most-once y at-least-once

Cuando una cola entrega trabajos a workers, aparece una pregunta clásica: ¿qué garantiza la entrega? Hay tres vocabularios que conviene conocer:

SemánticaQué significaProblema práctico
At-most-onceComo máximo se procesa una vez.Puedes perder trabajos si el worker cae.
At-least-onceSe procesa una o más veces hasta confirmar.Puede duplicarse trabajo.
Exactly-onceParece que se procesa exactamente una vez.Es difícil y suele depender de transacciones e idempotencia.

En sistemas reales, muchas colas se diseñan alrededor de at-least-once: prefieren repetir antes que perder. Eso obliga a que los workers sean idempotentes. Si el mismo trabajo llega dos veces, no debería crear dos resultados incompatibles, gastar sin control o ejecutar dos acciones equivalentes.

La idempotencia se consigue combinando:

TécnicaQué evita
idempotency_keyDuplicar una intención del cliente.
run_id estableMezclar dos ejecuciones distintas.
Bloqueo por estadoEjecutar una transición no válida.
Resultado persistidoReutilizar salida ya validada.
Dedupe de colaNo encolar el mismo trabajo varias veces.

La lección es incómoda pero útil: no diseñes el worker suponiendo que cada mensaje llega una sola vez. Diseña como si pudiera repetirse.

Circuit breakers y bulkheads

Un runtime de IA depende de piezas lentas o variables: proveedor de modelo, vector DB, herramientas internas, base de datos, cola y red. Si una dependencia se degrada, no quieres que arrastre todo el sistema.

Dos patrones ayudan:

PatrónTraducciónUso en runtime de IA
Circuit breakerCortacircuitos.Si una dependencia falla demasiado, se deja de llamar durante un tiempo y se degrada.
BulkheadCompartimento estanco.Separar recursos para que una cola o dependencia no consuma toda la capacidad.

Ejemplo: si la herramienta de facturación está lenta, el runtime puede marcar casos como needs_review, responder con estado claro o usar una ruta sin herramienta. Lo que no debería hacer es llenar todos los workers esperando esa herramienta hasta que también fallen las preguntas sencillas.

Dependencia lentaCircuit breakerBulkhead
Vector DBReducir top_k o responder con revisión si no hay contexto.Cola separada para RAG.
Tool internaNo llamar durante una ventana corta si acumula errores.Workers separados para tools.
Proveedor de modeloCambiar a modelo alternativo o pausar runs nuevas.Presupuesto por proveedor/modelo.

No hace falta implementar todos estos patrones el primer día. Sí hace falta saber dónde irían. Si el runtime no tiene sitio para degradar, solo le queda fallar.

Contratos: entrada, salida y error

Antes de hablar de JSON Schema conviene parar un momento. En este capítulo, un contrato no es un documento legal ni una promesa vaga. Es una interfaz verificable. Dice qué forma debe tener una entrada, qué forma tendrá una salida, qué errores pueden aparecer y qué significan los estados del sistema.

Un contrato técnico sirve para que dos piezas puedan trabajar juntas sin conocerse por dentro:

ContratoPregunta que respondeEjemplo
Entrada¿Qué debe enviar el cliente para que aceptemos la petición?request_id, idempotency_key, task, budget.
Salida¿Qué puede esperar quien consume la respuesta?answer, sources, confidence, needs_review.
Error¿Cómo se informa un fallo de forma accionable?error_code, retryable, trace_id, retry_after_ms.
Estado¿Qué fases puede atravesar una run?queued, running, succeeded, cancelled.

La palabra clave es “verificable”. Si dices “el modelo responderá bien”, no hay contrato. Si dices “la respuesta debe ser JSON válido, sin campos extra, con al menos una fuente y confidence entre 0 y 1”, ya puedes validarlo antes de mostrarlo.

JSON Schema permite describir restricciones estructurales sobre documentos JSON: tipos, campos obligatorios, valores permitidos, mínimos, máximos y otras condiciones.8 En IA, un esquema no garantiza que el contenido sea correcto, pero sí elimina una clase entera de errores: respuestas que no se pueden parsear, campos que faltan, tipos incorrectos o claves inesperadas.

Ejemplo de contrato de salida:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "support-answer@1.2.0",
  "type": "object",
  "additionalProperties": false,
  "required": ["answer", "sources", "confidence", "needs_review", "next_step"],
  "properties": {
    "answer": { "type": "string", "minLength": 1 },
    "sources": {
      "type": "array",
      "minItems": 1,
      "items": {
        "type": "object",
        "required": ["source_id", "chunk_id", "title"],
        "additionalProperties": false,
        "properties": {
          "source_id": { "type": "string" },
          "chunk_id": { "type": "string" },
          "title": { "type": "string" }
        }
      }
    },
    "confidence": { "type": "number", "minimum": 0, "maximum": 1 },
    "needs_review": { "type": "boolean" },
    "next_step": { "type": "string", "minLength": 1 }
  }
}

El contrato de error también importa:

Código internoSignificadoQué debería hacer el cliente
input_invalidLa entrada no cumple contrato.Corregir payload.
idempotency_replayYa existe una run para esa intención.Recuperar run existente.
queue_fullEl sistema no acepta más trabajo ahora.Reintentar después o degradar.
budget_exceededTokens, coste o tiempo superan presupuesto.Reducir tarea o pedir confirmación.
contract_failedLa salida no cumple esquema.No mostrar como resultado final.
provider_unavailableDependencia externa no responde bien.Usar fallback o avisar con estado claro.

Un error útil no humilla al usuario ni esconde la causa técnica. Dice qué ocurrió, qué se puede hacer y qué identificador permite depurarlo.

OpenAPI permite describir una API HTTP: rutas, métodos, parámetros, esquemas, respuestas y seguridad.9 En un sistema de IA, OpenAPI no sustituye a SHOULD.md; lo traduce al borde técnico. SHOULD.md dice cómo debe comportarse el sistema. OpenAPI dice cómo se llama, qué acepta y qué devuelve.

Un fragmento mínimo sería:

openapi: 3.1.0
info:
  title: Support RAG Runtime API
  version: 1.2.0
paths:
  /runs:
    post:
      summary: Crear o recuperar una run por idempotencia
      operationId: createRun
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: "#/components/schemas/CreateRunRequest"
      responses:
        "201":
          description: Run creada
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Run"
        "200":
          description: Run existente recuperada por idempotencia
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Run"
        "429":
          description: Límite de entrada superado
          headers:
            Retry-After:
              schema:
                type: integer
components:
  schemas:
    CreateRunRequest:
      type: object
      required: [task, budget, response_contract]
      additionalProperties: false
      properties:
        task:
          type: object
        budget:
          type: object
        response_contract:
          type: string
    Run:
      type: object
      required: [run_id, state, trace_id]
      properties:
        run_id:
          type: string
        state:
          enum: [accepted, queued, running, streaming, succeeded, cancelled, expired, contract_failed, timed_out]
        trace_id:
          type: string

Este YAML no es “documentación bonita”. Sirve para generar clientes, validar compatibilidad, revisar cambios en PR y detectar si una nueva versión rompe consumidores.

Versionar contratos sin romper clientes

Un contrato no es estático. Cambia cuando el producto aprende. El problema no es cambiarlo; el problema es no saber si el cambio rompe a quien lo consume.

Una regla conservadora:

Cambio¿Compatible?Por qué
Añadir campo opcionalNormalmente sí.Los clientes antiguos pueden ignorarlo.
Añadir campo obligatorioNo.Los clientes antiguos no lo envían o no lo esperan.
Cambiar tipo de un campoNo.Rompe validadores y clientes generados.
Eliminar campoNo.Alguien puede depender de él.
Cambiar significado de un estadoNo.Aunque el nombre sea igual, la semántica cambia.
Añadir nuevo estado finalDepende.Los clientes deben saber qué hacer con estados desconocidos.
Endurecer validaciónPuede romper.Entradas antes aceptadas pasan a fallar.

Por eso conviene versionar contratos como support-answer@1.2.0 y escribir una política:

VersiónCuándo subir
PatchAclaraciones, ejemplos, descripciones, errores tipográficos.
MinorCampos opcionales o estados no finales que no rompen clientes preparados.
MajorCambios obligatorios, tipos distintos, estados finales nuevos o semántica incompatible.

Un buen cliente de runtime también debe ser robusto ante estados desconocidos: si recibe un estado que no entiende, no debería tratarlo como éxito. Mejor mostrar “estado no reconocido”, conservar run_id y pedir actualización del contrato.

Streaming: experiencia y operación a la vez

Streaming no significa solo “que se vea escribir”. En un runtime operable, streaming puede emitir eventos de estado:

run.accepted
run.queued
run.started
retrieval.completed
model.output_text.delta
model.completed
contract.validated
run.succeeded

Esto mejora la experiencia, pero también la operación. Si el usuario ve “buscando documentos” y luego “redactando respuesta”, entiende que el sistema está trabajando. Si soporte ve los mismos eventos en la traza, puede localizar dónde se quedó una run.

El streaming obliga a una decisión de producto: ¿qué puedes mostrar antes de validar? Para respuestas de bajo impacto, quizá puedes mostrar texto parcial. Para salidas que deben cumplir JSON estricto, quizá conviene transmitir eventos de progreso y no enseñar el resultado hasta validar contrato.

Polling, SSE, WebSocket y webhook resuelven problemas distintos:

OpciónDirecciónCuándo elegirlaCoste mental
PollingCliente pregunta cada cierto tiempo.Simplicidad y tareas no interactivas.Fácil, pero puede ser ineficiente.
SSEServidor envía eventos al cliente.Progreso unidireccional y streaming de texto.Simple para navegador y HTTP.
WebSocketComunicación bidireccional.Interacción viva, cancelación inmediata o colaboración.Más complejo de operar.
WebhookServidor llama a otro sistema.Integraciones sistema-sistema.Exige firma, reintentos y entrega verificable.

Para muchos productos de IA, SSE es suficiente: el cliente crea una run, abre /runs/{run_id}/events y recibe eventos. WebSocket tiene sentido cuando el cliente también debe enviar señales frecuentes durante la ejecución. Webhook encaja cuando no hay usuario mirando la pantalla y otro sistema debe recibir el resultado.

Observabilidad del runtime

OpenTelemetry define una API de trazas para representar operaciones como spans conectados.10 Además, sus convenciones semánticas para GenAI están en desarrollo y cubren señales propias de sistemas generativos como operaciones de modelo, agentes y herramientas.11 W3C Trace Context estandariza cómo propagar contexto de traza entre servicios.12

Una traza de runtime debería tener, como mínimo:

SpanAtributos útiles
api.receiverequest_id, tenant_id, endpoint, tamaño de payload.
input.validatecontrato, versión, resultado, errores.
run.createrun_id, trace_id, manifest, idempotency key.
queue.waitcola, prioridad, edad, posición aproximada.
retrieval.searchcorpus, versión, top_k, latencia, documentos usados.
tool.callnombre de herramienta, timeout, resultado, coste si aplica.
model.callproveedor, modelo, tokens, latencia, request id del proveedor.
output.validateschema, versión, errores de contrato.
run.closeestado final, coste total, decisión y duración.

Sin estos spans, depurar se convierte en mirar logs sueltos y construir una historia a mano.

En el día a día

Un equipo suele empezar con una función como answer(question). Después llega la realidad: hay usuarios, permisos, documentos, colas, coste, límites y errores. El salto profesional consiste en convertir esa función en un servicio con contrato.

En un proyecto real, el runtime suele tener estas piezas:

PiezaDecisión de ingeniería
API gatewayQué endpoints existen y cómo autentican.
Run storeDónde guardamos estado y resultados.
Queue brokerQué va síncrono y qué va a background.
WorkersQué tareas ejecuta cada tipo de worker.
Model gatewayCómo normalizamos proveedores y modelos locales.
Contract validatorQué salidas pasan o se bloquean.
Event streamQué eventos ve el cliente y qué eventos quedan internos.
Observability pipelineQué trazas, métricas y logs se conservan.

No hay una única implementación correcta. Puedes hacerlo con FastAPI y Redis, con Node y una cola gestionada, con serverless y colas cloud, o con un backend monolítico bien diseñado. Lo que no cambia es la responsabilidad: entrada clara, estado explícito, colas medibles, contratos validados y trazas.

Por qué debería importarte

Si el runtime está mal diseñado, los errores parecen inexplicables. El usuario dice “se quedó pensando”. Producto dice “a veces no responde”. Ingeniería mira un log y ve una llamada al modelo. Nadie sabe si el problema fue cola, retrieval, proveedor, contrato, timeout o frontend.

Una arquitectura de runtime buena reduce esa ambigüedad. No evita todos los problemas, pero convierte cada problema en una pregunta investigable:

SíntomaPregunta técnica
Tarda en empezar¿TTFT alto, cola larga o retrieval lento?
Responde incompleto¿Timeout, streaming cortado o contrato rechazado?
Duplica trabajo¿Falta idempotencia o dedupe de cola?
Coste inesperado¿Reintentos, contexto largo o routing incorrecto?
No se puede depurar¿Falta trace_id, spans o estado final?

Manos a la obra

Práctica: contrato de runtime ejecutable.

Kit ejecutable de este capítulo: kit descargable.

# Descomprime el ZIP del capítulo y ejecuta estos comandos dentro de esa carpeta
python3 ops/run_f6_practices.py --chapter c02 --write --fail-on-invalid

Vamos a construir una mini pieza operativa: un runtime que acepta peticiones, aplica idempotencia, crea runs, simula cola, valida contrato de salida y deja eventos. No pretende llamar a un modelo real. Pretende que entiendas la forma del sistema.

Guárdalo como ops/ai/runtime_contract.py. La práctica representa cuatro endpoints conceptuales:

Endpoint conceptualFunción que lo simula
POST /runssubmit()
GET /runs/{run_id}get_run()
POST /runs/{run_id}/cancelcancel()
worker internoprocess_next()
from dataclasses import dataclass, field
from time import time
from uuid import uuid4


REQUIRED_OUTPUT_FIELDS = {
    "answer": str,
    "sources": list,
    "confidence": float,
    "needs_review": bool,
    "next_step": str,
}

FINAL_STATES = {"succeeded", "rejected", "contract_failed", "timed_out"}
CANCELABLE_STATES = {"accepted", "queued"}


@dataclass
class Run:
    run_id: str
    request_id: str
    idempotency_key: str
    state: str
    created_at: float
    trace_id: str
    events: list[dict] = field(default_factory=list)
    result: dict | None = None


class Runtime:
    def __init__(self, max_queue_size=3):
        self.max_queue_size = max_queue_size
        self.runs_by_key: dict[str, Run] = {}
        self.queue: list[str] = []

    def get_run(self, run_id: str) -> Run | None:
        return next((run for run in self.runs_by_key.values() if run.run_id == run_id), None)

    def submit(self, request: dict) -> Run:
        self._validate_input(request)
        key = request["idempotency_key"]

        if key in self.runs_by_key:
            run = self.runs_by_key[key]
            self._event(run, "idempotency.replay", {"state": run.state})
            return run

        if len(self.queue) >= self.max_queue_size:
            run = self._new_run(request, state="rejected")
            self._event(run, "run.rejected", {"reason": "queue_full"})
            return run

        run = self._new_run(request, state="accepted")
        self.runs_by_key[key] = run
        self._event(run, "run.accepted", {"contract": request["response_contract"]})

        run.state = "queued"
        self.queue.append(run.run_id)
        self._event(run, "run.queued", {"queue_depth": len(self.queue)})
        return run

    def cancel(self, run_id: str) -> Run:
        run = self.get_run(run_id)
        if run is None:
            raise ValueError("run not found")

        if run.state not in CANCELABLE_STATES:
            self._event(run, "run.cancel_rejected", {"state": run.state})
            return run

        run.state = "cancelled"
        self.queue = [queued_id for queued_id in self.queue if queued_id != run_id]
        self._event(run, "run.cancelled", {"reason": "user_request"})
        return run

    def process_next(self, output: dict, latency_ms: int, max_latency_ms: int) -> Run | None:
        if not self.queue:
            return None

        run_id = self.queue.pop(0)
        run = next(run for run in self.runs_by_key.values() if run.run_id == run_id)
        run.state = "running"
        self._event(run, "run.started", {"queue_depth": len(self.queue)})

        if latency_ms > max_latency_ms:
            run.state = "timed_out"
            self._event(run, "run.timed_out", {"latency_ms": latency_ms, "budget_ms": max_latency_ms})
            return run

        errors = validate_output(output)
        if errors:
            run.state = "contract_failed"
            self._event(run, "contract.failed", {"errors": errors})
            return run

        run.state = "succeeded"
        run.result = output
        self._event(run, "contract.validated", {"schema": "support-answer@1.2.0"})
        self._event(run, "run.succeeded", {"latency_ms": latency_ms})
        return run

    def _new_run(self, request: dict, state: str) -> Run:
        return Run(
            run_id="run_" + uuid4().hex[:10],
            request_id=request["request_id"],
            idempotency_key=request["idempotency_key"],
            state=state,
            created_at=time(),
            trace_id="trace_" + uuid4().hex[:12],
        )

    def _event(self, run: Run, name: str, attrs: dict) -> None:
        run.events.append({"event": name, "state": run.state, "attrs": attrs})

    def _validate_input(self, request: dict) -> None:
        required = {"request_id", "idempotency_key", "task", "budget", "response_contract"}
        missing = sorted(required - set(request))
        if missing:
            raise ValueError(f"missing input fields: {missing}")


def validate_output(output: dict) -> list[str]:
    errors = []

    for field_name, field_type in REQUIRED_OUTPUT_FIELDS.items():
        if field_name not in output:
            errors.append(f"missing field: {field_name}")
            continue
        if not isinstance(output[field_name], field_type):
            errors.append(f"{field_name} must be {field_type.__name__}")

    extra = sorted(set(output) - set(REQUIRED_OUTPUT_FIELDS))
    for field_name in extra:
        errors.append(f"unexpected field: {field_name}")

    if "confidence" in output and not 0 <= output["confidence"] <= 1:
        errors.append("confidence must be between 0 and 1")

    if "sources" in output and len(output["sources"]) == 0:
        errors.append("sources must not be empty")

    return errors


request = {
    "request_id": "req_001",
    "idempotency_key": "user42:support-summary:expediente-983:v1",
    "task": {"type": "support_summary"},
    "budget": {"max_latency_ms": 3000},
    "response_contract": "support-answer@1.2.0",
}

valid_output = {
    "answer": "El expediente necesita revisión de pago antes de cerrar la matrícula.",
    "sources": [{"source_id": "policy_2026", "chunk_id": "c14", "title": "Política de matrícula"}],
    "confidence": 0.86,
    "needs_review": True,
    "next_step": "Revisar pago pendiente y adjuntar justificante si procede.",
}

invalid_output = {
    "answer": "Todo está correcto.",
    "sources": [],
    "confidence": 1.2,
    "needs_review": False,
    "next_step": "Cerrar caso.",
    "extra": "campo no contratado",
}

runtime = Runtime(max_queue_size=3)

first = runtime.submit(request)
duplicate = runtime.submit(request)
processed = runtime.process_next(valid_output, latency_ms=1800, max_latency_ms=3000)

second_request = {**request, "request_id": "req_002", "idempotency_key": "user42:support-summary:expediente-984:v1"}
second = runtime.submit(second_request)
bad = runtime.process_next(invalid_output, latency_ms=900, max_latency_ms=3000)

third_request = {**request, "request_id": "req_003", "idempotency_key": "user42:support-summary:expediente-985:v1"}
third = runtime.submit(third_request)
cancelled = runtime.cancel(third.run_id)

for run in [first, duplicate, processed, second, bad, third, cancelled]:
    print(run.run_id, run.request_id, run.state)
    print(run.events[-2:])

Salida esperada:

run_... req_001 succeeded
[{'event': 'contract.validated', ...}, {'event': 'run.succeeded', ...}]
run_... req_001 succeeded
[{'event': 'contract.validated', ...}, {'event': 'run.succeeded', ...}]
run_... req_001 succeeded
[{'event': 'contract.validated', ...}, {'event': 'run.succeeded', ...}]
run_... req_002 contract_failed
[{'event': 'run.started', ...}, {'event': 'contract.failed', ...}]
run_... req_002 contract_failed
[{'event': 'run.started', ...}, {'event': 'contract.failed', ...}]
run_... req_003 cancelled
[{'event': 'run.queued', ...}, {'event': 'run.cancelled', ...}]
run_... req_003 cancelled
[{'event': 'run.queued', ...}, {'event': 'run.cancelled', ...}]

Lo que debes mirar no son los identificadores exactos, porque se generan al ejecutar. Mira tres ideas:

  1. La petición duplicada no crea otra run: recupera la misma intención.
  2. La salida válida avanza a succeeded.
  3. La salida inválida no llega a producto: se queda en contract_failed.
  4. Una run en cola puede cancelarse sin ejecutar el worker.

Esto es pequeño, pero ya tiene forma profesional: entrada, idempotencia, estado, cola, cancelación, contrato y eventos.

Cómo encaja todo

Antes del mapa conceptual, conviene ver el flujo temporal. Este diagrama no muestra todas las ramas, pero sí la ruta típica de una run que entra, espera, ejecuta, valida y emite eventos:

sequenceDiagram
    autonumber
    participant C as Cliente
    participant API as API boundary
    participant RS as Run store
    participant Q as Cola
    participant W as Worker
    participant M as Gateway modelo
    participant V as Validador
    participant E as Event stream

    C->>API: POST /runs + Idempotency-Key
    API->>API: autenticar, autorizar y validar entrada
    API->>RS: buscar run por tenant + idempotency_key
    alt run existente
        RS-->>API: run existente
        API-->>C: 200 OK + run_id
    else run nueva
        API->>RS: crear run accepted
        API->>Q: encolar trabajo
        API-->>C: 202 Accepted + run_id
    end
    C->>E: GET /runs/{run_id}/events
    Q-->>W: entregar trabajo
    W->>RS: estado running
    W->>E: run.started
    W->>M: llamada a modelo o proveedor
    M-->>W: salida candidata + tokens + provider_request_id
    W->>V: validar contrato de salida
    alt contrato valido
        V-->>W: ok
        W->>RS: guardar output y estado succeeded
        W->>E: run.succeeded
    else contrato invalido
        V-->>W: errores
        W->>RS: guardar error y estado contract_failed
        W->>E: contract.failed
    end
    C->>RS: GET /runs/{run_id}
    RS-->>C: estado final + resultado o error

Lo importante está en los puntos de control: la API no acepta cualquier cosa, el run store evita duplicados, la cola desacopla entrada y ejecución, el worker actualiza estado, el modelo queda detrás de un gateway y el validador decide si la salida puede llegar al producto.

flowchart TD
    subgraph F6["Facsímil 6: construir y operar"]
      C1["Cap. 01<br/>sistema operable"]
      C2["Cap. 02<br/>runtime, API, colas y contratos"]
      C3["Cap. 03<br/>serving de modelos"]
      C4["Cap. 04<br/>escalado de inferencia"]
      C5["Cap. 05<br/>observabilidad"]
      C6["Cap. 06<br/>EvalOps y gates"]
      C9["Cap. 09<br/>SLO e incidentes"]
    end

    subgraph Antes["Piezas anteriores"]
      F4C02["F4 C02<br/>APIs de modelos"]
      F4C09["F4 C09<br/>RAG"]
      F5C03["F5 C03<br/>tools y contratos"]
      F5C11["F5 C11<br/>agentes operables"]
    end

    subgraph Runtime["Dentro de este capítulo"]
      API["API boundary"]
      RUN["run store"]
      QUEUE["colas"]
      WORKER["workers"]
      CONTRACT["contratos"]
      STREAM["streaming"]
      TRACE["trazas"]
    end

    F4C02 -->|"aporta endpoints y parámetros"| API
    F4C09 -->|"aporta contexto recuperado"| WORKER
    F5C03 -->|"aporta herramientas tipadas"| WORKER
    F5C11 -->|"aporta estado y permisos"| RUN
    C1 -->|"exige operación"| C2
    C2 -->|"recibe petición"| API
    API -->|"crea o recupera"| RUN
    RUN -->|"planifica"| QUEUE
    QUEUE -->|"entrega trabajo"| WORKER
    WORKER -->|"produce salida"| CONTRACT
    CONTRACT -->|"si es válida"| STREAM
    API -->|"propaga"| TRACE
    RUN -->|"emite eventos"| TRACE
    WORKER -->|"mide latencia y coste"| TRACE
    C2 -->|"necesita capacidad"| C3
    C3 -->|"presiona colas"| C4
    TRACE -->|"alimenta"| C5
    CONTRACT -->|"alimenta evals"| C6
    QUEUE -->|"afecta SLO"| C9

La conexión importante es esta: el runtime no es una pieza aislada. Es donde se encuentran las APIs del facsímil 4, los agentes del facsímil 5 y la operación que estamos construyendo ahora.

Vocabulario aprendido

TérminoDefinición
API boundaryBorde donde una petición externa se convierte en una petición interna validada.
AutenticaciónComprobación de identidad: quién llama al sistema.
AutorizaciónComprobación de permiso: qué puede hacer esa identidad.
TenantOrganización, cliente o espacio lógico al que pertenece una run.
Idempotency keyClave que identifica una intención repetible para evitar duplicados.
Run storeAlmacén donde vive el estado de cada ejecución.
At-most-onceSemántica de entrega donde un trabajo se procesa como máximo una vez, con riesgo de pérdida.
At-least-onceSemántica donde un trabajo puede procesarse más de una vez, por lo que exige idempotencia.
Queue depthNúmero de trabajos esperando en una cola.
BackpressureMecanismo para frenar entrada cuando el sistema no puede absorber más trabajo.
Dead-letter queueCola separada donde se envían trabajos que fallan repetidamente para no bloquear la cola principal.
BackoffEspera creciente entre reintentos para no saturar una dependencia.
Fairness por tenantReparto de capacidad para que un tenant no consuma todo el runtime.
Circuit breakerPatrón que deja de llamar temporalmente a una dependencia cuando acumula fallos.
BulkheadSeparación de recursos para que una parte lenta no arrastre a todo el sistema.
PollingPatrón donde el cliente consulta periódicamente el estado de una run.
SSEServer-Sent Events: canal HTTP donde el servidor envía eventos unidireccionales al cliente.
WebSocketCanal persistente bidireccional entre cliente y servidor.
WebhookCallback HTTP que recibe el resultado o cambio de estado cuando termina una run.
OpenAPIEspecificación que describe rutas, métodos, esquemas, respuestas y seguridad de una API HTTP.
WorkerProceso que toma un trabajo y ejecuta una fase.
Timeout budgetPresupuesto de tiempo repartido entre fases.
Contract validatorComponente que acepta o rechaza una entrada o salida según esquema.
TTFTTiempo hasta recibir el primer token o primer evento útil.
Trace contextContexto que permite conectar spans entre servicios.
GoodputTrabajo correcto dentro de SLO por unidad de tiempo.

Dónde solía tropezar yo

TropiezoPor qué es un problemaAntídoto
Empezar por el proveedorEl sistema queda acoplado a un SDK concreto.Diseñar primero contrato interno y gateway.
No usar idempotenciaUn retry del cliente puede duplicar trabajo y coste.Exigir idempotency_key en tareas relevantes.
Ver la cola como detalle invisibleLa cola puede comerse todo el SLO sin que el modelo sea lento.Medir profundidad, edad máxima y tiempo de espera.
No diseñar cancelaciónEl usuario cree que paró algo, pero el sistema sigue gastando.Separar cancelling, cancelled y fases no cancelables.
Reintentar sin backoffLos reintentos crean otro pico de carga.Backoff con jitter, límite de intentos y DLQ.
Mostrar streaming sin validarEl usuario puede ver contenido que luego el contrato rechaza.Separar eventos de progreso de resultado final validado.
Guardar logs sin estadoHay texto, pero no reconstrucción causal.Guardar run_id, estado, eventos y trace_id.
Tratar el contrato como formatoEl contrato no solo parsea; decide qué puede entrar y salir.Versionar entrada, salida y errores.

Antes de pasar página

  • ¿Puedes explicar por qué una request HTTP no es lo mismo que una run?
  • ¿Puedes diseñar una entrada con request_id, idempotency_key, presupuesto y contrato?
  • ¿Puedes explicar qué validan autenticación, autorización y tenancy?
  • ¿Puedes elegir entre 200 OK, 202 Accepted, polling, streaming, background job y webhook?
  • ¿Puedes diseñar endpoints mínimos para crear, consultar, cancelar y seguir eventos de una run?
  • ¿Puedes proponer una persistencia mínima con runs, run_events, run_outputs y run_errors?
  • ¿Puedes calcular TtotalT_{\text{total}} sumando fases de runtime?
  • ¿Puedes usar L=λWL = \lambda W para explicar por qué crece una cola?
  • ¿Puedes distinguir at-most-once y at-least-once, y justificar por qué el worker debe ser idempotente?
  • ¿Puedes explicar para qué sirven TTL, dead-letter queue, backoff y fairness por tenant?
  • ¿Puedes explicar cuándo usar circuit breaker o bulkhead?
  • ¿Puedes decir qué spans mínimos debería tener una traza de runtime?
  • ¿Puedes distinguir contrato de entrada, contrato de salida y contrato de error?
  • ¿Puedes decir qué cambios de contrato son compatibles y cuáles rompen clientes?
  • ¿Puedes explicar qué aporta OpenAPI además de JSON Schema?
  • ¿Puedes decidir cuándo usar streaming de progreso y cuándo esperar a validar salida?
  • ¿Puedes explicar qué haría el sistema si se repite una petición con la misma clave de idempotencia?
  • ¿Puedes convertir un fallo de contrato en una respuesta operativa útil para cliente y soporte?

Para saber más

En resumen

IdeaQué debes llevarte
El runtime coordina, no solo llama al modelo.Una petición real necesita API, estado, colas, contratos, workers, streaming y trazas.
El patrón de API cambia la experiencia.No es igual 200 OK que 202 Accepted, polling, streaming, webhook o background job.
La cola forma parte del producto.Si no mides espera, profundidad y TTL, la latencia aparece como misterio.
Los contratos protegen el sistema.Entrada, salida y error deben estar versionados y validados.
La run es la unidad operativa.run_id, trace_id, estado y eventos permiten reconstruir qué pasó.
La arquitectura prepara los siguientes capítulos.Serving, escalado, observabilidad y EvalOps dependen de este runtime.

Notas

  1. Fielding, R. T., Nottingham, M. y Reschke, J. (2022). HTTP Semantics. RFC 9110. https://datatracker.ietf.org/doc/html/rfc9110. Consultado el 27 de mayo de 2026.

  2. Little, J. D. C. (1961). A Proof for the Queuing Formula: L = λW. Operations Research, 9(3), 383-387. https://doi.org/10.1287/opre.9.3.383.

  3. OpenAI. (2026). Create a model response. https://developers.openai.com/api/reference/resources/responses/methods/create. Consultado el 27 de mayo de 2026.

  4. OpenAI. (2026). Streaming API responses. https://developers.openai.com/api/docs/guides/streaming-responses. Consultado el 27 de mayo de 2026.

  5. OpenAI. (2026). Structured model outputs. https://developers.openai.com/api/docs/guides/structured-outputs. Consultado el 27 de mayo de 2026.

  6. Anthropic. (2026). Messages API. https://platform.claude.com/docs/en/api/messages. Consultado el 27 de mayo de 2026.

  7. Anthropic. (2026). Streaming Messages. https://platform.claude.com/docs/en/api/streaming. Consultado el 27 de mayo de 2026.

  8. JSON Schema. (2020). JSON Schema Validation: A Vocabulary for Structural Validation of JSON. https://json-schema.org/draft/2020-12/json-schema-validation. Consultado el 27 de mayo de 2026.

  9. OpenAPI Initiative. (2025). OpenAPI Specification. https://spec.openapis.org/oas/latest.html. Consultado el 27 de mayo de 2026.

  10. OpenTelemetry. (2026). Tracing API. https://opentelemetry.io/docs/specs/otel/trace/api/. Consultado el 27 de mayo de 2026.

  11. OpenTelemetry. (2026). Semantic conventions for generative AI systems. https://opentelemetry.io/docs/specs/semconv/gen-ai/. Consultado el 27 de mayo de 2026.

  12. World Wide Web Consortium. (2021). Trace Context Level 2. https://www.w3.org/TR/trace-context-2/. Consultado el 27 de mayo de 2026.

Capítulo 03

Facsímil 6 · Construir y operar

Capítulo 03: Serving de modelos: workers, batching y capacidad

Qué deberías poder hacer al terminar

En el capítulo anterior diseñamos la entrada de una run: API, estado, cola, contrato, streaming y trazas. Ahora miramos la pieza que suele decidir si el sistema aguanta o se rompe: el serving de modelos.

Serving significa mantener modelos disponibles para responder peticiones reales. No es “tener un modelo descargado”. Es cargarlo, reservar memoria, aceptar tráfico, agrupar peticiones, repartir trabajo entre workers, medir latencia, controlar colas, limitar coste y decidir cuándo escalar.

Al terminar, deberías poder hacer esto:

Resultado de aprendizajeEvidencia de que lo sabes hacer
Separar runtime de producto y runtime de modelo.Distingues API boundary, cola de runs, scheduler, engine de inferencia y workers.
Explicar prefill, decode y KV cache.Puedes decir qué fase consume contexto, qué fase genera tokens y por qué crece la memoria.
Estimar memoria de un modelo servido.Calculas pesos, KV cache, batch, contexto y margen operativo.
Leer métricas de serving.Distingues TTFT, TPOT, throughput, cola, tokens de entrada, tokens de salida y goodput.
Dimensionar capacidad inicial.Puedes justificar réplicas, concurrencia, cola máxima y señal de escalado.
Elegir entre proveedor cloud, runtime propio y modo híbrido.Relacionas control, latencia, coste, privacidad, complejidad y mantenimiento.

La idea central: el modelo no “vive” en tu API; vive en una máquina de inferencia con memoria, scheduler, colas internas y límites físicos.

La demo que iba perfecta en mi portátil

Una demo suele empezar así: hacemos una llamada al modelo, esperamos unos segundos y la respuesta aparece. Si la demo la usa una persona, todo parece razonable. Si la usan cincuenta a la vez, aparece otra realidad: algunas peticiones esperan, otras tardan en empezar, unas consumen mucho contexto, otras generan muchas palabras y la GPU ya no trabaja como una calculadora aislada, sino como una estación compartida.

Ese cambio de escala altera la conversación. Ya no preguntamos solo “¿qué modelo responde mejor?”. Preguntamos “¿cuántas runs por minuto podemos atender?”, “¿cuánto tarda el primer token?”, “¿cuánta memoria se come el contexto?”, “¿qué ocurre si entran muchas peticiones largas?” y “¿qué medimos para saber si debemos escalar?”.

Por eso este capítulo se coloca justo después de API y colas. La cola puede aceptar trabajo, pero alguien tiene que ejecutarlo. Ese alguien no es una abstracción: es el sistema de serving.

Qué no es servir un modelo

Servir un modelo no es ejecutar un notebook. Un notebook ayuda a explorar, pero no resuelve concurrencia, colas, memoria, métricas, límites por usuario, reinicios ni despliegue.

Tampoco es simplemente exponer un endpoint compatible con OpenAI. La compatibilidad de API puede ser comodísima, pero no te dice si el runtime soporta tu modelo, si gestiona bien KV cache, si hace batching continuo, si el streaming respeta tu contrato o si la latencia en p95 entra en tu SLO.

Y tampoco es “usar GPU” sin más. La GPU puede estar cargada y aun así ofrecer mal servicio: quizá está llena de peticiones largas, quizá la KV cache fragmenta memoria, quizá el batch está mal configurado, quizá faltan réplicas, quizá el cuello está en cola o quizá el modelo está generando demasiados tokens.

ConfusiónLo que falta
“Tengo el modelo descargado”Falta runtime, memoria reservada, endpoint, scheduler y métricas.
“Responde en local”Falta concurrencia, contratos, colas, observabilidad y operación.
“La GPU está al 95%”Falta saber si produce tokens útiles dentro de SLO.
“Es compatible con OpenAI”Falta verificar streaming, errores, tools, JSON, límites y parámetros soportados.
“Añado otra réplica y ya está”Falta comprobar cuello real: cola, memoria, red, CPU, disco o modelo.

Qué sí es el serving de modelos

El serving de modelos es la capa que convierte capacidad de cómputo en inferencias disponibles para usuarios o sistemas. Mantiene el modelo cargado, decide qué peticiones entran juntas, ejecuta prefill y decode, conserva KV cache, emite tokens, expone métricas y devuelve resultados bajo un contrato.

Fecha de corte: 27 de mayo de 2026. Fuentes consultadas ese día: documentación estable de vLLM, servidor OpenAI-compatible de vLLM, documentación de SGLang, documentación de NVIDIA TensorRT-LLM, documentación y métricas de Hugging Face Text Generation Inference, MLPerf Inference, Kubernetes HPA, Prometheus y OpenTelemetry GenAI. Lo estable es el mecanismo: prefill, decode, KV cache, batching, colas, paralelismo, métricas y capacidad. Lo cambiante son versiones, flags, modelos soportados, aceleradores disponibles, límites de proveedor y nombres exactos de métricas.

Los runtimes modernos de inferencia no son envoltorios triviales. vLLM se presenta como una librería de inferencia y serving con servidor compatible con OpenAI, streaming, paralelismos y soporte de muchas arquitecturas.1 SGLang se describe como framework de serving de alto rendimiento para modelos de lenguaje y multimodales, con foco en baja latencia, alto throughput, RadixAttention, prefix caching y paralelismo multi-GPU.2 NVIDIA TensorRT-LLM ofrece APIs y runtimes para ejecutar engines optimizados en GPUs NVIDIA, con documentación sobre arquitectura, runtimes, cuantización y soporte de despliegue.3 Hugging Face TGI documenta serving, streaming por SSE, tensor parallelism, Prometheus/OpenTelemetry, continuous batching y métricas exportadas.4

No hace falta memorizar todos esos nombres. Sí hace falta entender qué problema resuelven: servir tokens de forma concurrente sin desperdiciar memoria ni perder control operativo.

El mecanismo por dentro: de la run al token

Una run que llega desde nuestra API no entra directamente al modelo. Normalmente pasa por varias capas:

  1. Gateway de modelo: traduce el contrato interno al formato del runtime o proveedor.
  2. Scheduler: decide cuándo entra la petición en ejecución.
  3. Prefill: procesa tokens de entrada y crea la KV cache inicial.
  4. Decode: genera tokens de salida de forma iterativa.
  5. Streaming: entrega deltas o eventos si el producto lo permite.
  6. Métricas: mide latencia, tokens, cola, memoria, errores y resultado.
  7. Cierre: devuelve salida al runtime de producto para validación y persistencia.

La división entre prefill y decode es fundamental.

FaseQué haceQué suele doler
PrefillLee todos los tokens de entrada y calcula las representaciones iniciales.Contextos largos, documentos grandes, prompts con mucho historial.
DecodeGenera tokens de salida uno a uno, reutilizando KV cache.Salidas largas, muchos usuarios concurrentes, baja velocidad por token.
KV cacheGuarda claves y valores de atención para no recalcular todo el contexto.Memoria de GPU, fragmentación, límites de batch y contexto.

En el facsímil 3, capítulo 03 vimos Q, K y V como piezas de atención. En el facsímil 4, capítulo 03 vimos tokens, contexto y caché. Aquí juntamos ambas ideas desde operación: cada token de contexto puede dejar rastro en memoria, y cada token generado necesita usar esa memoria una y otra vez.

La latencia de una petición de generación se puede aproximar así:

Trun=Tcola_producto+Tgateway+Tcola_serving+Tprefill+NoutTdecode/token+TvalidacioˊnT_{\text{run}} = T_{\text{cola\_producto}} + T_{\text{gateway}} + T_{\text{cola\_serving}} + T_{\text{prefill}} + N_{\text{out}} \cdot T_{\text{decode/token}} + T_{\text{validación}}
SímboloSignificadoEjemplo
Tcola_productoT_{\text{cola\_producto}}Espera en la cola de runs del producto.350 ms
TgatewayT_{\text{gateway}}Traducción, autenticación interna y envío al runtime de modelo.60 ms
Tcola_servingT_{\text{cola\_serving}}Espera dentro del engine de inferencia.240 ms
TprefillT_{\text{prefill}}Procesamiento de tokens de entrada.520 ms
NoutN_{\text{out}}Número de tokens generados.180 tokens
Tdecode/tokenT_{\text{decode/token}}Tiempo medio por token generado.18 ms/token
TvalidacioˊnT_{\text{validación}}Validación de contrato y cierre.90 ms

Con esos números:

Trun=350+60+240+520+18018+90=4500 msT_{\text{run}} = 350 + 60 + 240 + 520 + 180 \cdot 18 + 90 = 4500 \text{ ms}

Esto explica algo que se ve mucho en producto: dos peticiones al mismo modelo no tardan lo mismo. Una pregunta corta con salida breve puede volar. Un informe con diez documentos y salida larga puede multiplicar prefill, KV cache y decode.

Anatomía visual de un servicio de inferencia

Serving de modelos: convertir GPU en servicio operable La run no entra a un modelo suelto: pasa por gateway, scheduler, batch, prefill, decode, KV cache, métricas y escalado. Runtime de producto run_id · trace_id · contrato cola de runs · prioridad · TTL presupuesto de tokens y latencia Model gateway normaliza proveedor aplica parámetros limita tokens propaga trace_id Scheduler de inferencia admission batch continuo prioridad límites de memoria decide qué entra, qué espera y qué se corta por presupuesto Réplicas / workers W1 GPU W2 GPU W3 GPU réplicas con modelo cargado y health checks Dentro de una réplica Prefill lee entrada completa Decode genera token a token KV cache memoria por capas · contexto · batch · dtype Batching continuo A decode B prefill C decode D slot slot slot slot slot nuevas peticiones entran mientras otras terminan mejora utilización, pero compite por memoria y latencia Capacidad y límites VRAM pesos + KV + margen TTFT cola + prefill TPOT decode por token Goodput salidas útiles en SLO escalar por CPU rara vez basta: mira cola, memoria, tokens y p95 Observabilidad tokens entrada/salida queue duration TTFT · TPOT · p95 · p99 GPU memory · batch size Autoscaling cola por réplica edad máxima en cola memoria disponible goodput dentro de SLO Decisión de operación si sube TTFT: mirar cola, prefill y admisión si sube TPOT: mirar decode, batch y kernel si baja goodput: recortar tokens, escalar o degradar si sube memoria: limitar contexto, batch o paralelizar Servir bien no es llenar la GPU: es entregar tokens correctos, a tiempo, con memoria controlada y señales que expliquen cada decisión. IA para gente curiosa / Facsímil 06 / Capítulo 03 / 686f6c61

La figura muestra una separación que conviene respetar: el runtime de producto sabe de runs, contratos, permisos y trazas. El runtime de modelo sabe de batch, memoria, tokens y kernels. Si mezclamos ambas responsabilidades, acabamos con un sistema difícil de depurar: producto habla de usuarios y estados; inferencia habla de tokens, colas internas y GPU.

Memoria: el límite que aparece antes de que lo esperes

En serving, la memoria no la ocupan solo los pesos del modelo. Hay al menos cuatro bolsas:

Bolsa de memoriaQué contieneDe qué depende
PesosParámetros del modelo cargados en GPU o CPU.Número de parámetros y precisión.
KV cacheClaves y valores de atención por token activo.Capas, cabezas KV, dimensión, contexto, batch y dtype.
Activaciones y buffersTensores temporales del runtime.Kernels, batch, backend, paralelismo y optimizaciones.
Overhead operativoFragmentación, allocator, runtime, CUDA, servidor, margen.Implementación y configuración.

Una fórmula útil, aunque aproximada:

MtotalMpesos+MKV+Mbuffers+MmargenM_{\text{total}} \approx M_{\text{pesos}} + M_{\text{KV}} + M_{\text{buffers}} + M_{\text{margen}}
SímboloSignificadoEjemplo
MpesosM_{\text{pesos}}Memoria ocupada por pesos del modelo.14 GiB
MKVM_{\text{KV}}Memoria de KV cache para secuencias activas.16 GiB
MbuffersM_{\text{buffers}}Memoria temporal de kernels, logits, activaciones y runtime.3 GiB
MmargenM_{\text{margen}}Margen para fragmentación y variabilidad.5 GiB

Con esos valores:

Mtotal14+16+3+5=38 GiBM_{\text{total}} \approx 14 + 16 + 3 + 5 = 38 \text{ GiB}

En una GPU de 40 GiB, eso parece caber. En operación, “parece” no basta. Una petición más larga, un batch mayor, un modelo con más capas o un runtime que reserve buffers distintos puede llevarte al límite. Por eso capacidad no se calcula con una media bonita, sino con escenarios.

Para pesos:

Mpesos=PbSM_{\text{pesos}} = \frac{P \cdot b}{S}
SímboloSignificadoEjemplo
PPNúmero de parámetros.7.000 millones
bbBytes por parámetro.2 bytes en BF16/FP16
SSNúmero de shards si hay tensor parallel.1

Ejemplo:

Mpesos=7.000.000.0002114 GBM_{\text{pesos}} = \frac{7.000.000.000 \cdot 2}{1} \approx 14 \text{ GB}

Si el modelo se reparte en dos GPUs con tensor parallel, cada GPU no carga necesariamente “la mitad exacta de todo”, porque hay buffers y comunicación, pero la intuición de pesos por shard ayuda:

Mpesos_por_GPU142=7 GBM_{\text{pesos\_por\_GPU}} \approx \frac{14}{2} = 7 \text{ GB}

La KV cache se puede aproximar así:

MKV=2LcapasHKVDheadBdtypeTactivosM_{\text{KV}} = 2 \cdot L_{\text{capas}} \cdot H_{\text{KV}} \cdot D_{\text{head}} \cdot B_{\text{dtype}} \cdot T_{\text{activos}}
SímboloSignificadoEjemplo
22Guardamos K y V.2 tensores
LcapasL_{\text{capas}}Número de capas Transformer.32
HKVH_{\text{KV}}Número de cabezas KV.8
DheadD_{\text{head}}Dimensión de cada cabeza.128
BdtypeB_{\text{dtype}}Bytes por valor.2 bytes
TactivosT_{\text{activos}}Tokens activos en el batch: entrada + salida generada.16 secuencias x 8192 tokens

Con batch 16 y 8192 tokens activos por secuencia:

Tactivos=168192=131.072T_{\text{activos}} = 16 \cdot 8192 = 131.072 MKV=23281282131.072=17.179.869.184 bytes16 GiBM_{\text{KV}} = 2 \cdot 32 \cdot 8 \cdot 128 \cdot 2 \cdot 131.072 = 17.179.869.184 \text{ bytes} \approx 16 \text{ GiB}

Esta cuenta no pretende sustituir al profiler. Pretende darte criterio. Si duplicas contexto, sube KV. Si duplicas batch, sube KV. Si usas más capas o más cabezas KV, sube KV. Si reduces precisión de KV, puede bajar memoria, pero debes medir calidad y estabilidad.

El paper de PagedAttention nace precisamente de este problema: la KV cache de cada request es grande, crece y decrece dinámicamente, y una gestión ineficiente desperdicia memoria por fragmentación o duplicación. Sus autores comparan la idea con paginación de memoria de sistemas operativos y reportan mejoras de throughput al construir vLLM sobre ese enfoque.5

Batching: no todas las peticiones avanzan al mismo ritmo

En un servidor web clásico, hacer batch puede sonar extraño: cada petición es independiente. En inferencia de LLMs, agrupar peticiones puede mejorar muchísimo el uso de GPU, pero tiene matices.

El batch estático agrupa peticiones y las ejecuta juntas. El problema es que las generaciones no terminan a la vez. Si una petición necesita 40 tokens y otra 800, la corta puede quedar esperando a que la larga termine, o el runtime desperdicia huecos.

El batching continuo intenta resolverlo: en cada paso de generación, el scheduler recompone el conjunto de secuencias activas. Cuando una termina, entra otra. Hugging Face TGI documenta continuous batching como una optimización para aumentar throughput, junto con streaming, tensor parallelism y métricas de producción.6

La intuición:

EnfoqueVentajaCoste
Sin batchingSimple y fácil de razonar.GPU infrautilizada con tráfico concurrente.
Batch fijoMejor uso de GPU.Esperas por secuencias largas y lotes mal equilibrados.
Batching continuoMejor ocupación con peticiones variables.Scheduler más complejo y presión sobre KV cache.
Prefix cachingReutiliza prefijos compartidos.Requiere detectar y gestionar prefijos con cuidado.
Chunked prefillTrocea entradas largas para no bloquear decode.Añade complejidad de planificación.

Una métrica clave aquí es la diferencia entre throughput bruto y goodput:

throughput=tokens generadossegundos\text{throughput} = \frac{\text{tokens generados}}{\text{segundos}} goodput=tokens de respuestas vaˊlidas dentro de SLOsegundos\text{goodput} = \frac{\text{tokens de respuestas válidas dentro de SLO}}{\text{segundos}}
MétricaQué cuentaQué puede ocultar
ThroughputTokens por segundo, sin mirar si llegaron a tiempo.Puede ser alto con usuarios esperando demasiado.
GoodputTokens útiles dentro del objetivo de servicio.Exige definir qué significa “útil” y qué SLO aplica.
TTFTTiempo hasta primer token.Puede ser bueno aunque el final tarde demasiado.
TPOTTiempo por token durante decode.No incluye espera en cola ni validación final.
p95/p99Cola de latencias altas.La media puede parecer bien mientras p99 duele.

Dean y Barroso explicaron en The Tail at Scale que, en sistemas grandes, la cola de latencias altas importa mucho porque muchas operaciones dependen de varias piezas y basta una pieza lenta para que la experiencia final se degrade.7 En IA esto se nota enseguida: el usuario no vive la media, vive su petición.

Workers: una réplica no es solo un proceso

Un worker de serving suele ser una réplica con modelo cargado y capacidad asignada. Puede vivir en una GPU completa, en varias GPUs, en CPU para modelos pequeños, en una partición de acelerador o detrás de un proveedor cloud.

Lo importante es que cada worker tiene un contrato operativo:

PropiedadPregunta concreta
Modelo servido¿Qué model_id, versión, cuantización y plantilla de chat carga?
Capacidad¿Cuántas secuencias concurrentes acepta sin romper SLO?
Memoria¿Cuánta VRAM queda libre en escenarios largos?
Contexto máximo¿Qué límite real de entrada y salida se permite en producto?
Parámetros admitidos¿Acepta JSON, tools, logprobs, reasoning, imágenes o solo texto?
Health check¿Cómo sabemos que está vivo y realmente puede generar?
Warmup¿Se calienta el modelo antes de aceptar tráfico?
Drenaje¿Cómo deja de aceptar runs antes de reiniciar?

El warmup merece una línea propia. Una réplica puede responder al health check HTTP y aun así estar fría: modelo no cargado, kernels no inicializados o primera petición más lenta. Por eso muchos equipos separan:

CheckQué comprueba
LivenessEl proceso no está colgado.
ReadinessPuede aceptar tráfico.
Model readinessEl modelo está cargado, inicializado y con memoria suficiente.
Synthetic generationPuede generar una respuesta pequeña y medir latencia básica.

El drenaje también importa. Si matas una réplica con muchas secuencias activas, puedes cortar streams o perder trabajo. Un shutdown correcto suele marcar la réplica como no lista, deja de recibir nuevas peticiones, termina las activas dentro de un límite y luego se apaga.

Runtime propio, proveedor cloud o mezcla

No todo sistema necesita servir modelos propios. A veces usar una API cloud es lo correcto. Otras veces necesitas modelos locales, runtimes propios o una mezcla.

OpciónQué controlasQué delegasCuándo encaja
API de proveedorPrompt, parámetros, contrato de producto, evals y routing.Modelo, GPU, scheduling interno y capacidad base.Producto que prioriza velocidad de integración y operación simple.
Runtime propioModelo, versión, cuantización, hardware, scheduler y datos.Nada esencial de inferencia, salvo cloud si alquilas GPU.Necesitas control de pesos, coste, latencia, región o investigación aplicada.
OpenAI-compatible localContrato parecido a APIs conocidas.Depende del runtime concreto.Quieres cambiar proveedor por runtime propio con menos fricción.
HíbridoRutas cloud y rutas propias según tarea.Parte de la complejidad se reparte.Quieres fallback, coste controlado o separación por sensibilidad/tamaño.
Batch offlineEjecución diferida, no interactiva.Experiencia en tiempo real.Grandes volúmenes sin presión de latencia interactiva.

MLPerf Inference sirve como referencia de benchmarking de inferencia en datacenter, con resultados comparables bajo reglas específicas.8 No sustituye tus pruebas, pero recuerda algo sano: medir inferencia exige protocolo. Si comparas runtimes con prompts distintos, batch distinto, contexto distinto y SLO distinto, no estás comparando.

Dimensionar capacidad con números

Capacidad empieza con una pregunta concreta: ¿qué carga queremos soportar y con qué objetivo?

Ejemplo:

Variable de cargaValor
Llegadas medias2 runs/s
Pico esperado8 runs/s durante 5 minutos
Tokens de entrada p501.200
Tokens de entrada p958.000
Tokens de salida p50180
Tokens de salida p95700
SLO interactivop95 menor o igual a 6 s
Error budget mensual2% de runs fuera de SLO

Con la ley de Little, que ya usamos en el capítulo anterior:

L=λWL = \lambda W

Si llegan λ=8\lambda = 8 runs/s en pico y queremos que el tiempo medio dentro del sistema sea W=4W = 4 s:

L=84=32 runs concurrentesL = 8 \cdot 4 = 32 \text{ runs concurrentes}

Pero eso no significa “necesito 32 GPUs”. Significa que el sistema completo tendrá unas 32 runs en curso: algunas en cola, algunas en prefill, algunas en decode, algunas validando. Ahora hay que saber cuántas puede sostener cada réplica sin romper memoria ni p95.

Una aproximación inicial de réplicas:

R = \left\lceil \frac{\lambda_{\text{pico}} \cdot T_{\text{serving,p95}}} C_{\text{réplica}} \cdot U_{\text{objetivo}}} \right\rceil
SímboloSignificadoEjemplo
RRRéplicas necesarias.?
λpico\lambda_{\text{pico}}Llegadas pico.8 runs/s
Tserving,p95T_{\text{serving,p95}}Tiempo de serving p95 por run.4 s
CreˊplicaC_{\text{réplica}}Concurrencia útil por réplica.10 runs
UobjetivoU_{\text{objetivo}}Utilización objetivo, dejando margen.0,70
R=84100,70=4,57=5R = \left\lceil \frac{8 \cdot 4}{10 \cdot 0,70} \right\rceil = \left\lceil 4,57 \right\rceil = 5

Esta fórmula no te da la verdad. Te da una hipótesis para probar. Después haces load test con distribución realista de tokens y miras p50, p95, p99, memoria y coste.

Señales de autoscaling

Kubernetes HPA ajusta réplicas a partir de métricas observadas, como CPU, memoria o métricas personalizadas.9 Para IA, CPU no suele bastar. Puedes tener CPU tranquila y GPU saturada, KV cache llena o cola creciendo.

Prometheus recomienda buenas prácticas de nombres y etiquetas para métricas, con prefijos de aplicación y unidades coherentes.10 Esto parece burocracia hasta que tienes que escribir una alerta a las tres de la mañana. Una métrica mal nombrada se convierte en confusión.

Señales útiles para escalar serving:

SeñalQué indicaDecisión posible
serving_queue_depthHay más peticiones esperando.Añadir réplica o aplicar backpressure.
serving_queue_oldest_secondsLa petición más vieja espera demasiado.Priorizar, escalar o rechazar entrada nueva.
gpu_memory_available_bytesQueda poco margen de KV cache.Bajar batch, limitar contexto o añadir GPU.
ttft_seconds_p95Primer token llega tarde.Revisar cola, prefill, contexto y admisión.
tpot_seconds_p95Cada token tarda demasiado.Revisar decode, batch, kernels y modelo.
tokens_per_secondProducción bruta.Mirar junto a goodput, no sola.
goodput_runs_per_secondRuns válidas dentro de SLO.Escalar si cae durante tráfico normal.
contract_failure_ratioSalidas rechazadas por contrato.No se arregla con más GPU; mirar prompt, modelo o schema.

TGI expone métricas como tamaño de batch, duración de decode, duración de inferencia, tamaño de cola, duración total, tokens generados, longitud de entrada, tiempo medio por token y duración de cola.11 Aunque uses otro runtime, esa lista es pedagógica: te enseña qué mirar.

OpenTelemetry también trabaja convenciones semánticas para sistemas GenAI, de modo que proveedores, modelos, operaciones, tokens y atributos de inferencia puedan observarse con nombres compartidos.12 No todo estará cerrado para siempre, pero la dirección es clara: si no etiquetas bien la inferencia, luego no puedes compararla.

En el día a día

En un equipo real, este capítulo aparece cuando alguien dice “el modelo tarda” y nadie sabe qué significa “tarda”. Puede tardar en entrar al serving, tardar en prefill, tardar en generar, tardar en validar o tardar en llegar al cliente.

Una conversación seria separa síntomas:

SíntomaPregunta de ingeniería
Primer token lento¿Cola interna, prefill largo, modelo frío o scheduler saturado?
Tokens salen despacio¿Decode lento, batch demasiado grande, kernel, cuantización o modelo grande?
Memoria al límite¿KV cache, contexto, batch, pesos, buffers o fragmentación?
GPU alta pero usuarios esperando¿Throughput bruto alto, pero goodput bajo?
Funciona con 1 usuario y falla con 50¿No se probó distribución realista de concurrencia y tokens?
Escalado no mejora¿El cuello no era réplica de serving, sino cola, base de datos, red o contrato?

El trabajo del ingeniero no es adivinar. Es diseñar el sistema para que la respuesta esté en las métricas.

Por qué debería importarte

Porque muchas decisiones caras se esconden en serving. Un modelo pequeño bien servido puede dar mejor experiencia que un modelo grande mal configurado. Un contexto gigantesco puede disparar KV cache y empeorar p95. Una cola sin backpressure puede aceptar trabajo que nunca cumplirá SLO. Un autoscaler basado solo en CPU puede reaccionar tarde o mirar la señal equivocada.

Y porque este capítulo conecta directamente con presupuesto. En IA, coste no es solo “precio por token”. También es GPU alquilada, memoria desaprovechada, réplicas calientes, reintentos, colas largas, salidas descartadas por contrato y tiempo de ingeniería depurando sin señales.

Manos a la obra

Práctica: un calculador de capacidad inicial.

Kit ejecutable de este capítulo: kit descargable.

# Descomprime el ZIP del capítulo y ejecuta estos comandos dentro de esa carpeta
python3 ops/run_f6_practices.py --chapter c03 --write --fail-on-invalid

Vamos a construir un pequeño calculador que no pretende acertar al milímetro. Pretende obligarte a escribir tus supuestos: parámetros, precisión, capas, KV heads, contexto, batch, latencia, llegada pico y utilización objetivo.

Guárdalo como ops/ai/serving_capacity.py:

from dataclasses import dataclass
from math import ceil


BYTES_IN_GIB = 1024**3


@dataclass(frozen=True)
class ModelShape:
    name: str
    parameters_billion: float
    bytes_per_weight: float
    layers: int
    kv_heads: int
    head_dim: int
    bytes_per_kv_value: float


@dataclass(frozen=True)
class Workload:
    peak_runs_per_second: float
    serving_latency_p95_seconds: float
    useful_concurrency_per_replica: int
    target_utilization: float
    active_sequences: int
    active_tokens_per_sequence: int
    buffer_gib: float
    margin_gib: float


def weights_gib(model: ModelShape, tensor_parallel_shards: int = 1) -> float:
    total_bytes = model.parameters_billion * 1_000_000_000 * model.bytes_per_weight
    return total_bytes / tensor_parallel_shards / BYTES_IN_GIB


def kv_cache_gib(model: ModelShape, workload: Workload) -> float:
    active_tokens = workload.active_sequences * workload.active_tokens_per_sequence
    total_bytes = (
        2
        * model.layers
        * model.kv_heads
        * model.head_dim
        * model.bytes_per_kv_value
        * active_tokens
    )
    return total_bytes / BYTES_IN_GIB


def total_memory_gib(model: ModelShape, workload: Workload, tensor_parallel_shards: int = 1) -> float:
    return (
        weights_gib(model, tensor_parallel_shards)
        + kv_cache_gib(model, workload)
        + workload.buffer_gib
        + workload.margin_gib
    )


def needed_replicas(workload: Workload) -> int:
    numerator = workload.peak_runs_per_second * workload.serving_latency_p95_seconds
    denominator = workload.useful_concurrency_per_replica * workload.target_utilization
    return ceil(numerator / denominator)


def queue_warning(workload: Workload, replicas: int) -> str:
    useful_capacity = replicas * workload.useful_concurrency_per_replica * workload.target_utilization
    required = workload.peak_runs_per_second * workload.serving_latency_p95_seconds
    if useful_capacity < required:
        return "insuficiente: la cola crecera en pico"
    if useful_capacity < required * 1.25:
        return "ajustado: deja poco margen para p95 y reintentos"
    return "razonable para una primera prueba de carga"


model = ModelShape(
    name="demo-7b-bf16",
    parameters_billion=7.0,
    bytes_per_weight=2.0,
    layers=32,
    kv_heads=8,
    head_dim=128,
    bytes_per_kv_value=2.0,
)

workload = Workload(
    peak_runs_per_second=8.0,
    serving_latency_p95_seconds=4.0,
    useful_concurrency_per_replica=10,
    target_utilization=0.70,
    active_sequences=16,
    active_tokens_per_sequence=8192,
    buffer_gib=3.0,
    margin_gib=5.0,
)

replicas = needed_replicas(workload)

print("modelo:", model.name)
print("pesos GiB:", round(weights_gib(model), 2))
print("KV cache GiB:", round(kv_cache_gib(model, workload), 2))
print("memoria total GiB:", round(total_memory_gib(model, workload), 2))
print("replicas iniciales:", replicas)
print("lectura de cola:", queue_warning(workload, replicas))

Salida esperada:

modelo: demo-7b-bf16
pesos GiB: 13.04
KV cache GiB: 16.0
memoria total GiB: 37.04
replicas iniciales: 5
lectura de cola: ajustado: deja poco margen para p95 y reintentos

Este código enseña varias cosas:

Línea mentalQué te obliga a decidir
parameters_billion y bytes_per_weightTamaño de pesos y precisión.
layers, kv_heads, head_dimForma interna que condiciona KV cache.
active_sequences y active_tokens_per_sequenceBatch y contexto real, no el máximo teórico de la ficha.
buffer_gib y margin_gibEspacio para runtime, fragmentación y variabilidad.
peak_runs_per_second y p95Carga de producto, no solo benchmark de modelo.
target_utilizationMargen operativo para no ir siempre al límite.

Variaciones útiles:

  1. Cambia active_tokens_per_sequence de 8192 a 32768 y mira la KV cache.
  2. Cambia bytes_per_weight a 0.5 para simular pesos en 4 bits, pero deja la KV cache en 2 bytes.
  3. Sube peak_runs_per_second a 20 y mira cuántas réplicas aparecen.
  4. Baja target_utilization a 0.55 y observa el coste de ir con más margen.

La práctica importante no es “este número es exacto”. La práctica importante es no desplegar a ciegas.

Cómo encaja todo

Primero veamos el flujo temporal de una run que llega al serving:

sequenceDiagram
    autonumber
    participant R as Runtime producto
    participant G as Model gateway
    participant S as Scheduler serving
    participant W as Worker GPU
    participant K as KV cache
    participant M as Metricas

    R->>G: llamada con run_id, trace_id, input y presupuesto
    G->>G: normalizar parametros y limitar tokens
    G->>S: enqueue de inferencia
    S->>S: admission control y batch continuo
    S->>W: asignar secuencia a replica
    W->>K: reservar KV cache
    W->>W: prefill de tokens de entrada
    W->>M: registrar TTFT parcial y tokens de entrada
    loop por cada token generado
        W->>K: leer y actualizar KV cache
        W->>W: decode siguiente token
        W-->>G: delta si hay streaming
        W->>M: registrar TPOT y tokens de salida
    end
    W->>M: registrar latencia, memoria y estado final
    G-->>R: salida candidata + usage + provider_request_id

Ahora el mapa conceptual del capítulo:

flowchart TD
    subgraph F6["Facsímil 6: construir y operar"]
      C1["Cap. 01<br/>sistema operable"]
      C2["Cap. 02<br/>runtime y contratos"]
      C3["Cap. 03<br/>serving de modelos"]
      C4["Cap. 04<br/>observabilidad"]
      C6["Cap. 06<br/>gates y EvalOps"]
      C9["Cap. 09<br/>SLO e incidentes"]
    end

    subgraph Serving["Dentro del serving"]
      GW["model gateway"]
      SCH["scheduler"]
      PREF["prefill"]
      DEC["decode"]
      KV["KV cache"]
      BATCH["batching continuo"]
      CAP["capacidad"]
      MET["metricas"]
    end

    subgraph Previos["Conceptos anteriores"]
      F3C03["F3 C03<br/>Q K V y atención"]
      F4C03["F4 C03<br/>tokens, contexto y cache"]
      F4C05["F4 C05<br/>modelos locales"]
      F4C06["F4 C06<br/>cloud vs local"]
      F5C10["F5 C10<br/>evaluar agentes"]
    end

    C1 -->|"exige operar"| C2
    C2 -->|"envia runs"| GW
    GW -->|"normaliza llamada"| SCH
    SCH -->|"agrupa trabajo"| BATCH
    BATCH -->|"ejecuta"| PREF
    PREF -->|"construye"| KV
    KV -->|"alimenta"| DEC
    DEC -->|"produce tokens"| GW
    SCH -->|"limita"| CAP
    PREF -->|"mide"| MET
    DEC -->|"mide"| MET
    KV -->|"condiciona"| CAP
    MET -->|"alimenta"| C4
    CAP -->|"afecta"| C9
    GW -->|"devuelve salida candidata"| C2
    C6 -->|"usa resultados y coste"| MET

    F3C03 -->|"explica K y V"| KV
    F4C03 -->|"explica tokens y cache"| PREF
    F4C05 -->|"presenta runtimes locales"| GW
    F4C06 -->|"decide donde ejecutar"| CAP
    F5C10 -->|"compara trayectorias y coste"| MET

    classDef external fill:#FFFFFF,stroke:#111111,stroke-width:1.3,stroke-dasharray:5 5,color:#111111
    class F3C03,F4C03,F4C05,F4C06,F5C10 external

La relación más importante es esta: el capítulo 2 decide qué entra y con qué contrato; el capítulo 3 decide si hay capacidad real para ejecutarlo; el capítulo 4 nos dará señales para no operar a oscuras.

Vocabulario aprendido

TérminoDefinición
ServingCapa que mantiene modelos cargados y atiende inferencias con límites de memoria, latencia y capacidad.
Engine de inferenciaMotor que ejecuta el modelo, gestiona memoria, kernels, batch y generación.
SchedulerComponente que decide qué peticiones entran en ejecución y cuándo.
PrefillFase que procesa tokens de entrada antes de generar.
DecodeFase que genera tokens de salida paso a paso.
KV cacheMemoria que guarda claves y valores de atención para reutilizar contexto durante generación.
Batching continuoPlanificación que recompone el batch mientras unas secuencias terminan y otras entran.
Prefix cachingReutilización de KV cache cuando varias peticiones comparten prefijo.
Chunked prefillTécnica que divide entradas largas para que no bloqueen completamente otras generaciones.
TTFTTiempo hasta el primer token o primer evento útil.
TPOTTiempo por token generado durante decode.
ThroughputTrabajo completado por unidad de tiempo.
GoodputTrabajo correcto y dentro de SLO por unidad de tiempo.
Tensor parallelReparto de operaciones o pesos de una capa entre varias GPUs.
Pipeline parallelReparto de capas entre dispositivos, con comunicación entre etapas.
Data parallelRéplicas completas del modelo atienden tráfico distinto.
Model readinessSeñal de que el modelo está cargado y puede generar, no solo de que el proceso vive.
WarmupEjecución inicial para cargar pesos, inicializar kernels y reducir primera latencia.
DrenajeProceso de dejar de aceptar nuevas peticiones antes de apagar una réplica.
AutoscalingAjuste automático de réplicas a partir de métricas observadas.

Dónde solía tropezar yo

TropiezoPor qué es un problemaAntídoto
Medir solo tokens por segundoPuedes producir mucho y aun así llegar tarde.Medir goodput, TTFT, TPOT y p95/p99.
Confundir contexto máximo con contexto recomendableEl máximo puede caber, pero destruir coste y latencia.Diseñar límites por caso de uso y medir KV cache.
Escalar por CPUEl cuello puede estar en GPU, KV cache, cola o scheduler.Escalar con métricas de serving y cola.
Olvidar el warmupLa primera petición puede parecer un fallo de latencia.Separar liveness, readiness y model readiness.
Poner batch muy altoMejora throughput bruto, pero puede empeorar TTFT o memoria.Probar batch con distribución realista y SLO.
No distinguir prefill de decodeNo sabes si duele la entrada larga o la salida larga.Medir ambas fases por separado.
Comparar runtimes sin protocoloCada prueba cambia prompt, tokens, batch o hardware.Escribir un benchmark reproducible antes de decidir.
Tratar la GPU como infinitaKV cache y buffers aparecen antes que el entusiasmo.Calcular memoria con margen y probar p95.

Antes de pasar página

  • ¿Puedes explicar por qué servir un modelo no es lo mismo que tenerlo descargado?
  • ¿Puedes dibujar el camino runtime de producto -> gateway -> scheduler -> worker -> KV cache -> salida?
  • ¿Puedes explicar prefill y decode con tus palabras?
  • ¿Puedes decir por qué la KV cache crece con batch y contexto?
  • ¿Puedes estimar memoria de pesos con parámetros y bytes por peso?
  • ¿Puedes estimar memoria de KV cache con capas, cabezas KV, dimensión y tokens activos?
  • ¿Puedes distinguir throughput y goodput?
  • ¿Puedes explicar TTFT y TPOT sin mezclarlos?
  • ¿Puedes justificar por qué p95 y p99 importan más que una media cómoda?
  • ¿Puedes decir cuándo el batching continuo ayuda y cuándo puede complicar memoria o latencia?
  • ¿Puedes definir qué debería comprobar un health check de modelo?
  • ¿Puedes proponer señales de autoscaling mejores que CPU para inferencia?
  • ¿Puedes explicar por qué un contrato fallido no se arregla añadiendo GPU?
  • ¿Puedes usar el calculador de capacidad y cambiar supuestos para ver el impacto?
  • ¿Puedes decidir cuándo usar API cloud, runtime propio o arquitectura híbrida?

Para saber más

En resumen

IdeaQué debes llevarte
Serving es una capa operativa, no un endpoint bonito.Mantiene modelos cargados, agenda peticiones, gestiona memoria, genera tokens y emite señales.
Prefill y decode explican latencias distintas.Entrada larga duele en prefill; salida larga duele en decode.
La KV cache convierte contexto y batch en memoria real.Más tokens activos pueden limitar concurrencia antes que los pesos del modelo.
Batching mejora throughput, pero no es gratis.Puede subir utilización y también presión de memoria o TTFT.
Capacidad se dimensiona con supuestos escritos.Llegadas, p95, concurrencia útil, memoria, margen y goodput deben quedar explícitos.
Escalar exige mirar la señal correcta.CPU sola no basta para inferencia; hacen falta cola, TTFT, TPOT, memoria, tokens y SLO.

Notas

  1. vLLM Project. (2026). vLLM Documentation. https://docs.vllm.ai/en/stable/. Consultado el 27 de mayo de 2026. vLLM. (2026). OpenAI-Compatible Server. https://docs.vllm.ai/en/latest/serving/openai_compatible_server/. Consultado el 27 de mayo de 2026.

  2. SGLang. (2026). Welcome to SGLang. https://docs.sglang.io/index.html. Consultado el 27 de mayo de 2026.

  3. NVIDIA. (2026). TensorRT-LLM Documentation. https://docs.nvidia.com/tensorrt-llm/. Consultado el 27 de mayo de 2026.

  4. Hugging Face. (2026). Text Generation Inference. https://huggingface.co/docs/text-generation-inference/en/index. Consultado el 27 de mayo de 2026. Hugging Face. (2026). Text Generation Inference: Metrics. https://huggingface.co/docs/text-generation-inference/reference/metrics. Consultado el 27 de mayo de 2026.

  5. Kwon, W., Li, Z., Zhuang, S., Sheng, Y., Zheng, L., Yu, C. H., Gonzalez, J. E., Zhang, H. y Stoica, I. (2023). Efficient Memory Management for Large Language Model Serving with PagedAttention. Proceedings of the ACM Symposium on Operating Systems Principles. https://doi.org/10.48550/arXiv.2309.06180.

  6. Hugging Face. (2026). Text Generation Inference. https://huggingface.co/docs/text-generation-inference/en/index. Consultado el 27 de mayo de 2026.

  7. Dean, J. y Barroso, L. A. (2013). The Tail at Scale. Communications of the ACM, 56(2), 74-80. https://doi.org/10.1145/2408776.2408794.

  8. MLCommons. (2026). MLPerf Inference: Datacenter Benchmark. https://mlcommons.org/benchmarks/inference-datacenter/. Consultado el 27 de mayo de 2026.

  9. Kubernetes. (2026). Horizontal Pod Autoscaling. https://kubernetes.io/docs/concepts/workloads/autoscaling/horizontal-pod-autoscale/. Consultado el 27 de mayo de 2026.

  10. Prometheus. (2026). Metric and label naming. https://prometheus.io/docs/practices/naming/. Consultado el 27 de mayo de 2026.

  11. Hugging Face. (2026). Text Generation Inference: Metrics. https://huggingface.co/docs/text-generation-inference/reference/metrics. Consultado el 27 de mayo de 2026.

  12. OpenTelemetry. (2026). Semantic Conventions for Generative AI Systems. https://opentelemetry.io/docs/specs/semconv/gen-ai/. Consultado el 27 de mayo de 2026.

Capítulo 04

Facsímil 6 · Construir y operar

Capítulo 04: Observabilidad: logs, métricas, trazas y costes

Qué deberías poder hacer al terminar

En el capítulo 02 aprendimos a convertir una petición en una run. En el capítulo 03 vimos que el modelo se sirve dentro de un sistema con scheduler, KV cache, workers y límites de capacidad. Ahora toca una pregunta más incómoda: cuando algo va lento, caro, incorrecto o raro, ¿cómo lo sabemos sin adivinar?

Observabilidad no es “tener muchos logs”. Tampoco es abrir un dashboard enorme y esperar que una gráfica confiese. Observabilidad es diseñar el sistema para que cada run deje señales suficientes, estructuradas y útiles.

Al terminar, deberías poder hacer esto:

Resultado de aprendizajeEvidencia de que lo sabes hacer
Separar logs, métricas y trazas.Sabes cuándo usar un evento, una serie agregada o una historia completa de ejecución.
Diseñar una traza de una run de IA.Incluyes trace_id, spans, atributos, eventos, versiones, tokens, costes y estado final.
Definir SLIs y SLOs específicos de IA.Mides latencia, coste, salida válida, contrato, recuperación, tools y serving.
Evitar métricas imposibles de mantener.Controlas cardinalidad, etiquetas, nombres y retención.
Diseñar alertas accionables.Alertas por síntomas de usuario, burn rate y señales que alguien pueda corregir.
Calcular coste por run aceptada.No te quedas en precio por token; conectas coste con calidad y contrato.

La idea central: si una run no deja una historia reconstruible, no existe operativamente.

El problema que venimos a resolver

Imagina que un usuario dice: “ayer el asistente tardó mucho y al final me dio una respuesta que no servía”. Sin observabilidad, el equipo abre logs sueltos, busca por hora aproximada, mira si hubo error del proveedor, revisa alguna métrica de CPU y acaba con una hipótesis floja.

Con observabilidad bien diseñada, la pregunta cambia. Buscamos el run_id, abrimos la traza y vemos: cola de producto 800 ms, cola de serving 1200 ms, prefill largo por contexto de 38.000 tokens, dos reintentos de retrieval, salida rechazada una vez por contrato, coste final 0,18 EUR y estado succeeded_with_review.

La diferencia no es estética. La primera escena obliga a investigar a oscuras. La segunda permite razonar.

Qué no es observabilidad

Observabilidad no es acumular todo “por si acaso”. Guardar prompts completos, documentos, salidas y dumps internos sin criterio crea coste, riesgo de privacidad, ruido y deuda.

Tampoco es una lista interminable de métricas. Si nadie sabe qué significa una serie, quién la mantiene, qué umbral importa o qué acción provoca, esa métrica es decoración operativa.

Y no es alertar por cada anomalía. El libro de SRE de Google insiste en que monitorizar debe ayudar a entender qué está roto y por qué, pero las alertas que interrumpen a personas deben ser simples, accionables y con poco ruido.1

ConfusiónPor qué fallaMejor enfoque
Guardar todos los prompts completosAumenta coste, sensibilidad y ruido.Guardar IDs, hashes, tamaños, versiones y muestras controladas.
Medir solo latencia mediaOculta p95, p99 y usuarios que esperan demasiado.Usar histogramas, percentiles y SLO.
Alertar por causa internaPuede sonar grave sin afectar al usuario.Alertar por síntoma medible y accionable.
Mirar solo logsPierdes agregación y causalidad entre servicios.Combinar logs, métricas y trazas.
Mirar solo coste por tokenIgnora reintentos, fallos de contrato y revisión.Medir coste por run aceptada.

Qué sí es observabilidad en IA

Observabilidad es la capacidad de contestar preguntas de ingeniería con señales del sistema. En IA generativa, esas preguntas tienen una forma propia:

PreguntaSeñal necesaria
¿Qué versión respondió?model_id, prompt_version, manifest_version, contract_version.
¿Qué vio el modelo?IDs de documentos, chunks, tamaños, hashes y política de retención.
¿Cuánto costó?Tokens de entrada, tokens de salida, precio aplicado, reintentos y proveedor.
¿Dónde se fue la latencia?Spans de cola, retrieval, tools, prefill, decode, validación y salida.
¿La respuesta era válida?Estado de contrato, evaluadores, revisión y motivo de rechazo.
¿Se saturó el serving?Cola interna, TTFT, TPOT, memoria, batch y goodput.
¿Qué debería hacer soporte?run_id, estado final, error tipado, resumen de decisión y siguiente acción.

Fecha de corte: 27 de mayo de 2026. Fuentes consultadas ese día: Google SRE Book, Google SRE Workbook, OpenTelemetry traces, logs, metrics y convenciones semánticas GenAI, Prometheus metric naming, W3C Trace Context y documentación oficial de Grafana, Langfuse, LangSmith, Phoenix, Helicone, Braintrust, OpenInference, OpenLLMetry y Datadog LLM Observability. Lo estable es la arquitectura: logs, métricas, trazas, SLIs, SLOs, cardinalidad, sampling, alertas y coste. Lo cambiante son nombres exactos de atributos, SDKs, backends de observabilidad, productos comerciales y convenciones GenAI en evolución.

OpenTelemetry describe trazas como una forma de entender el camino de una petición a través de una aplicación, compuestas por spans relacionados.2 También define APIs para crear spans, asociarlos al contexto activo y registrar atributos, eventos y estado.3 Sus convenciones semánticas para GenAI incorporan atributos sobre sistemas generativos, operaciones, modelos y uso de tokens.4

Las tres señales principales

Los tres nombres clásicos son logs, métricas y trazas. No compiten: responden a preguntas distintas.

SeñalPregunta que respondeEjemplo en una run de IA
Log¿Qué evento ocurrió?contract.failed con campo ausente y versión de schema.
Métrica¿Cuánto ocurre en el tiempo?ai_run_latency_seconds_bucket por ruta y estado.
Traza¿Qué camino siguió esta run concreta?api.receive -> queue.wait -> retrieval.search -> model.call -> output.validate.

OpenTelemetry trata logs como registros con cuerpo, severidad, timestamp, atributos y contexto de traza cuando existe.5 Las métricas, en cambio, representan medidas agregables en el tiempo: contadores, histogramas, gauges y otros instrumentos.6

Una forma de pensarlo:

Si preguntas...Usa principalmente...Porque...
“¿Qué ocurrió en esta run?”TrazaNecesitas causalidad y orden.
“¿Cuántas runs fallan por contrato?”MétricaNecesitas agregación temporal.
“¿Qué campo faltaba en esta salida?”Log o evento de spanNecesitas detalle puntual.
“¿Se degradó el p95 tras desplegar?”Métrica + traza de muestraNecesitas tendencia y ejemplos.
“¿Qué documento se recuperó?”Traza con atributos controladosNecesitas contexto sin guardar contenido completo.

La traza de una run de IA

Una traza debería parecerse a una historia técnica. No una novela, no un volcado masivo. Una historia con capítulos claros.

trace_id = trace_8f21...
run_id   = run_20260527_0042

api.receive
  input.validate
  run.create
queue.wait
retrieval.search
  retrieval.rerank
tool.call
model.call
  model.prefill
  model.decode
output.validate
run.close

Cada span debe tener atributos. La regla: guarda lo necesario para depurar, evaluar y auditar sin conservar más contenido del necesario.

SpanAtributos útilesQué no conviene meter sin política
api.receivetenant_id, route, request_bytes, idempotency_replay.Payload completo.
input.validatecontract_version, valid, error_count.Datos sensibles de entrada.
retrieval.searchindex_version, top_k, query_tokens, source_ids.Texto completo de documentos.
tool.calltool_name, timeout_ms, result_state, retry_count.Secretos, credenciales o respuestas extensas.
model.callprovider, model_id, input_tokens, output_tokens, ttft_ms, tpot_ms.Prompt completo por defecto.
output.validatecontract_version, valid, missing_fields, extra_fields.Salida completa si contiene datos sensibles.
run.closefinal_state, latency_ms, cost_eur, accepted, review_required.Comentarios internos sin estructura.

El contexto de traza permite que varios servicios compartan la misma historia. W3C Trace Context estandariza cabeceras como traceparent para propagar identificadores de traza entre servicios.7 En nuestro runtime, eso significa que la API, la cola, el worker y el gateway de modelo no deberían inventar historias separadas.

Anatomía visual de observabilidad para IA

Observabilidad de IA: de run opaca a historia depurable Cada petición emite logs, métricas y trazas para explicar latencia, coste, calidad, contrato y capacidad. Run de producto run_id · trace_id · tenant prompt_version · model_id contrato · presupuesto · estado final Instrumentación crear spans por fase emitir eventos estructurados medir histogramas y contadores redactar o resumir contenido Collector / pipeline sampling · redacción · batch export a métricas, logs y trazas retención · coste · control de cardinalidad Backends logs eventos métricas series trazas spans consulta por run_id, trace_id, modelo, versión y estado Traza de una run api.receive queue.wait retrieval model.call validate Cuadro de mando mínimo latencia TTFT · p95 · p99 calidad contrato · eval · revisión coste tokens · EUR/run aceptada saturación cola · memoria · batch Alertas y SLO síntomas: usuario afectado burn rate: presupuesto consumido acción: owner y runbook silencio: si no hay acción, no alerta alertar menos, diagnosticar mejor Coste observable coste por llamada coste por run aceptada coste por tenant, tarea y versión coste de reintentos y salidas descartadas Higiene de datos hashes e IDs antes que contenido bruto redacción antes de exportar sampling con criterio de depuración retención diferente para logs, métricas y trazas Decisión operativa degradar ruta hacer rollback subir capacidad bloquear release con gate La observabilidad buena convierte una queja vaga en una ruta de diagnóstico: qué pasó, dónde, cuánto costó y qué hacemos ahora. IA para gente curiosa / Facsímil 06 / Capítulo 04 / 686f6c61

El SVG tiene muchas cajas porque la observabilidad también es arquitectura. No basta con “emitir algo”. Hay que decidir qué se instrumenta, cómo se transporta, dónde se guarda, cuánto tiempo vive, qué se muestra y qué acción provoca.

SLIs y SLOs para sistemas de IA

Ya vimos que un SLI es un indicador medible y un SLO es un objetivo interno sobre ese indicador. Aquí lo aplicamos a IA.

Las cuatro señales doradas de SRE son latencia, tráfico, errores y saturación.8 En IA conviene mantenerlas y añadir dos dimensiones: calidad operativa y coste.

SeñalSLI posibleSLO posible
LatenciaPorcentaje de runs con p95 menor o igual a 6 s.95% de runs interactivas terminan en 6 s o menos.
TráficoRuns por minuto por tarea, tenant y ruta.El sistema soporta pico esperado sin rechazos indebidos.
ErroresRuns con estado final no aceptado.Menos del 2% terminan en timed_out, contract_failed o provider_unavailable.
SaturaciónEdad máxima en cola y memoria disponible.Cola p95 menor o igual a 1 s y margen de memoria mayor al 15%.
CalidadSalidas aceptadas por contrato y eval.98% de respuestas publicadas pasan contrato y evaluación automática mínima.
CosteCoste por run aceptada.p95 de coste por run aceptada menor o igual a 0,08 EUR.

La fórmula del SLI de éxito operativo puede ser:

SLIaceptadas=Nruns aceptadasNruns totalesSLI_{\text{aceptadas}} = \frac{N_{\text{runs aceptadas}}}{N_{\text{runs totales}}}
SímboloSignificadoEjemplo
Nruns aceptadasN_{\text{runs aceptadas}}Runs que terminaron con salida válida, dentro de contrato y sin revisión bloqueante.9.700
Nruns totalesN_{\text{runs totales}}Runs evaluables en la ventana.10.000
SLIaceptadasSLI_{\text{aceptadas}}Proporción de runs aceptadas.0,97
SLIaceptadas=970010000=0,97SLI_{\text{aceptadas}} = \frac{9700}{10000} = 0,97

Si el SLO era 98%, estamos por debajo. No hace falta dramatizar: hace falta mirar la traza agregada y saber qué grupo pesa más.

Para coste:

Caceptada=Ctokens+Cserving+Ctools+CrevisioˊnNruns aceptadasC_{\text{aceptada}} = \frac{C_{\text{tokens}} + C_{\text{serving}} + C_{\text{tools}} + C_{\text{revisión}}}{N_{\text{runs aceptadas}}}
SímboloSignificadoEjemplo
CtokensC_{\text{tokens}}Coste de tokens de modelos o proveedor.120 EUR
CservingC_{\text{serving}}Coste de GPUs o runtime propio.80 EUR
CtoolsC_{\text{tools}}Coste de herramientas externas.20 EUR
CrevisioˊnC_{\text{revisión}}Coste estimado de revisión humana o soporte.30 EUR
Nruns aceptadasN_{\text{runs aceptadas}}Runs aceptadas en la ventana.5.000
Caceptada=120+80+20+305000=0,05 EURC_{\text{aceptada}} = \frac{120 + 80 + 20 + 30}{5000} = 0,05 \text{ EUR}

Esto cambia el debate. Un modelo más caro por token puede ser más barato por run aceptada si reduce reintentos, revisiones y salidas descartadas.

Burn rate: gastar el presupuesto de error

El SRE Workbook explica cómo convertir SLOs en alertas mirando el consumo del presupuesto de error, no solo un umbral aislado.9

Si tu SLO es 99%, tu presupuesto de error es 1%. Si en una ventana concreta fallan 5% de runs, consumes presupuesto cinco veces más rápido que lo permitido.

burn rate=error rate observadoerror rate permitido\text{burn rate} = \frac{\text{error rate observado}}{\text{error rate permitido}}
SímboloSignificadoEjemplo
Error rate observadoProporción real de runs fuera de SLO en una ventana.5%
Error rate permitidoProporción permitida por el SLO.1%
Burn rateVeces que consumes el presupuesto.5x
burn rate=0,050,01=5\text{burn rate} = \frac{0,05}{0,01} = 5

En IA, el “error” no tiene que ser solo HTTP 500. Puede ser:

Error operativoCómo se detecta
TimeoutEstado final timed_out.
Contrato fallidoEstado contract_failed.
Coste fuera de presupuestocost_eur > budget.max_cost_eur.
Latencia fuera de SLOlatency_ms > slo.max_latency_ms.
Salida no aceptadaEval mínima o revisión bloqueante.
Ruta degradada sin avisoFallback no comunicado o no trazado.

Aquí aparece una diferencia importante: no todo error técnico afecta igual al usuario, y no toda respuesta HTTP exitosa es éxito de producto. Una run puede devolver 200 OK y estar fuera de SLO porque costó demasiado, tardó demasiado o falló contrato semántico.

Métricas sin volverse loco con la cardinalidad

Prometheus recomienda nombres claros, unidades consistentes y etiquetas que mantengan significado estable.10 En IA, la tentación de etiquetar todo es enorme: run_id, user_id, prompt, document_id, tool_args, model_id, tenant_id, cost_eur...

No lo hagas así. Una métrica con demasiadas combinaciones de etiquetas se vuelve cara, lenta e inmanejable.

Etiqueta¿Buena para métrica?Mejor uso
routeMétrica.
model_familyMétrica.
final_stateMétrica.
tenant_tierA vecesMétrica si hay pocos valores.
run_idNoTraza o log.
user_idNoTraza con política de acceso, o hash.
prompt_textNoNo en métrica; contenido controlado aparte.
document_idNo si hay muchosTraza, log o agregación por corpus.
cost_eurNo como etiquetaValor numérico o atributo de span.

Nombres razonables:

ai_run_total
ai_run_latency_seconds_bucket
ai_run_cost_eur_bucket
ai_model_input_tokens_total
ai_model_output_tokens_total
ai_contract_failure_total
ai_serving_queue_seconds_bucket
ai_tool_call_total
ai_retrieval_documents_total

Cada métrica debería poder contestar algo. Si no sabes qué decisión habilita, todavía no es una buena métrica.

Logs: detalle sin ruido

Un log útil en IA debería ser estructurado. No:

algo falló llamando al modelo

Mejor:

{
  "event": "model.call.completed",
  "timestamp": "2026-05-27T18:30:00Z",
  "run_id": "run_42",
  "trace_id": "trace_abc",
  "span_id": "span_model",
  "provider": "local-vllm",
  "model_id": "qwen3-8b-instruct",
  "input_tokens": 4200,
  "output_tokens": 380,
  "latency_ms": 3100,
  "cost_eur": 0.021,
  "status": "ok"
}

El log debe ayudar a reconstruir un evento. No debe convertirse en un contenedor donde metemos todo lo que no supimos modelar.

Una regla práctica:

Guarda en logGuarda en trazaGuarda en métrica
Evento concreto y explicación breve.Causalidad completa de la run.Agregado temporal.
Error tipado y atributos.Duración por fase.Contadores, histogramas y gauges.
Decisión tomada.Relación padre-hijo entre pasos.SLO, p95, p99, burn rate.

Costes observables

El coste en IA tiene varias capas:

CosteEjemploSeñal
TokensEntrada y salida de modelo.input_tokens, output_tokens, precio aplicado.
ServingGPU propia, memoria y réplicas.Coste por hora, utilización y goodput.
HerramientasAPIs externas, búsquedas, OCR, bases de datos.tool_name, tool_cost_eur.
ReintentosLlamadas repetidas por timeout o contrato.retry_count, coste acumulado.
RevisiónTiempo humano o soporte.review_required, coste estimado.
AlmacenamientoLogs, trazas, métricas, datasets.Retención y volumen exportado.

El coste por token es solo una pieza. El coste por run aceptada te dice si la arquitectura sirve.

Comparación sencilla:

SistemaCoste totalRuns totalesRuns aceptadasCoste por aceptada
A100 EUR10.0005.0000,020 EUR
B160 EUR10.0009.5000,017 EUR

El sistema B parece más caro hasta que miras aceptadas. En producto, pagar menos por respuestas que luego se descartan no es ahorro; es trabajo inútil bien facturado.

Sampling y retención

No puedes guardar todo con el mismo detalle para siempre. Debes decidir qué se conserva, cuánto tiempo y con qué nivel.

SeñalRetención típicaRegla razonable
Métricas agregadasSemanas o meses.Largas para tendencias y capacidad.
Trazas completasMenos tiempo.Más detalle para fallos, canary y muestras.
Logs de eventosVariable.Retener eventos operativos, no contenido sensible sin motivo.
Prompts/salidas completasMuy controlado.Solo con consentimiento, redacción, muestreo o entorno de evaluación.
Datasets de evalVersionado largo.Sirven para comparar cambios.

Sampling no significa “tirar cosas al azar sin pensar”. Puedes muestrear:

EstrategiaCuándo usarla
100% de erroresPara investigar fallos de contrato, timeouts y rutas nuevas.
100% de canaryMientras una versión nueva está en prueba.
1-5% de éxitosPara tener muestra sana sin coste excesivo.
Tail samplingGuardar trazas lentas o caras tras ver el resultado.
Muestreo por tenant/tareaAsegurar cobertura de segmentos importantes.

La cola de latencias altas importa mucho. Dean y Barroso explican que los percentiles altos pueden dominar la experiencia de usuario cuando una operación depende de varias piezas.11 Por eso no basta con muestrear “runs normales”. También necesitamos mirar las lentas, caras y rechazadas.

De métricas a decisiones

Un dashboard bueno no enseña todo. Enseña lo que decide.

PanelPreguntaDecisión
Salud de run¿Cuántas terminan bien, mal o con revisión?Parar release, degradar ruta o investigar contrato.
Latencia¿Dónde se va el tiempo?Ajustar cola, serving, contexto o tools.
Coste¿Qué tarea/modelo/tenant dispara gasto?Cambiar routing, budgets o límites.
Calidad¿Baja la aceptación por versión?Rollback o gate de despliegue.
Serving¿Está saturado el modelo?Escalar, limitar batch/contexto o cambiar runtime.
Retrieval¿Se recupera demasiado o mal?Ajustar índice, top_k, reranker o corpus.

La ley de Little vuelve a aparecer:

L=λWL = \lambda W

Si ves que sube WW, el tiempo de espera, y la llegada λ\lambda no baja, entonces LL, trabajo acumulado, crece. En observabilidad eso se traduce en una alerta por cola vieja, no solo por CPU alta.12

Correlacionar por versión

En sistemas de IA, muchas averías no aparecen como “se cayó el servidor”. Aparecen como “desde esta mañana responde peor”, “ahora cuesta más”, “este tipo de casos ya no pasa contrato” o “el RAG recupera fuentes menos útiles”. Para investigar eso, cada señal debe poder conectarse con la versión que la produjo.

No basta con tener run_id. Necesitamos que métricas, trazas y logs incluyan las versiones que cambian el comportamiento.

VersiónDónde debería aparecerQué pregunta permite responder
model_idSpan model.call, métrica de coste y dashboard.¿El cambio viene del modelo?
prompt_versionRun, traza, logs de validación y evals.¿El prompt nuevo rompió formato o criterio?
contract_versionEntrada, salida, error y validador.¿Cambió el schema o falló la salida?
retrieval_index_versionSpan retrieval.search.¿El índice nuevo recupera peor?
embedding_model_idRetrieval y métricas de cobertura.¿Cambió el espacio vectorial?
tool_versionSpan tool.call.¿La tool cambió comportamiento o respuesta?
runtime_versionServing y worker.¿El runtime nuevo alteró latencia o streaming?
release_idToda la run.¿Qué despliegue estaba activo?

La regla de ingeniería es simple: si una pieza puede cambiar el resultado, debe estar versionada en la traza. Si no, el postmortem se convierte en arqueología.

Dashboard mínimo que sí usaría

Un dashboard mínimo no intenta enseñar todas las series. Intenta guiar una investigación en menos de dos minutos. Primero enseña síntomas; después permite bajar a causas.

Yo lo diseñaría con seis paneles:

PanelMétricas principalesFiltro obligatorioPregunta que responde
Salud de runsai_run_total, ratio de aceptadas, estados finales.task, route, release_id.¿El sistema está entregando resultados aceptables?
Latencia por fasep50/p95/p99 de API, cola, retrieval, model, validación.task, model_id, queue.¿Dónde se va el tiempo?
Costecoste por run, coste por aceptada, tokens, reintentos.tenant_tier, task, model_id.¿Qué está encareciendo el sistema?
Contratos y evalscontract_failed, score mínimo, revisión requerida.contract_version, prompt_version.¿La salida sirve o solo “responde”?
ServingTTFT, TPOT, cola de serving, memoria, batch, goodput.model_id, runtime_version.¿El cuello está en inferencia?
Retrieval y toolstop_k, chunks usados, tool duration, tool error, permisos.index_version, tool_name.¿El contexto o una herramienta explica el problema?

El dashboard no reemplaza la traza. Te dice dónde mirar. La traza te cuenta la historia de una run concreta.

Alertas concretas, no ruido

Una alerta buena tiene cinco partes: síntoma, umbral, ventana, owner y acción. Si falta la acción, probablemente no debería interrumpir a nadie.

Algunas reglas razonables para empezar:

AlertaCondiciónPrimera acción
Burn rate altoburn_rate_15m > 4 y al menos 30 runs evaluables.Abrir dashboard de salud, filtrar por release_id y task.
Contratos fallandocontract_failed_ratio_15m > 0.03.Comparar prompt_version, contract_version y ejemplos de trazas.
Coste fuera de presupuestocost_per_accepted_p95_30m > budget.Ver tokens, reintentos, routing y tareas dominantes.
Cola viejaqueue_oldest_seconds > queue_slo_seconds.Mirar llegadas, workers sanos, backpressure y serving.
TTFT altottft_p95_15m > ttft_slo.Revisar cola de serving, prefill, contexto y warmup.
TPOT altotpot_p95_15m > tpot_slo.Revisar decode, batch, memoria y runtime.
Trazas incompletastrace_missing_ratio_1h > 0.01.Revisar propagación de trace_id y collector.
Reintentos altosretry_per_run_p95 > 1.Localizar dependencia lenta y política de backoff.

Ejemplo expresado como pseudorregla:

alert: AIHighBurnRate
if: burn_rate_15m > 4 and ai_run_total_15m >= 30
for: 10m
owner: ai-runtime
action: revisar /dashboards/ai-runs filtrando release_id, task y final_state

Lo importante no es copiar esta sintaxis. Lo importante es que la alerta ya trae una hipótesis de trabajo.

Runbook operativo

Una alerta sin runbook deja al equipo improvisando. Un runbook no debe ser una enciclopedia; debe decir qué mirar primero, qué acción tomar y cuándo escalar la investigación.

Para este capítulo, un runbook mínimo podría ser:

SíntomaSeñalPrimera comprobaciónAcción inmediataAcción de fondo
Suben los timeoutstimed_out_ratio y p95 de latencia.¿Cola, retrieval, model o tool?Reducir entrada nueva, activar ruta degradada o ampliar workers.Ajustar SLO, capacidad y límites por tarea.
Fallan contratoscontract_failed_ratio.¿Cambió prompt, modelo o contrato?Rollback de prompt_version o bloquear release.Añadir casos al dataset de eval.
Sube coste por aceptadacost_per_accepted_p95.¿Tokens, reintentos, modelo o revisión?Limitar salida, routing a modelo menor o pedir confirmación.Rediseñar budgets y eval de coste.
Sube TTFTttft_p95.¿Prefill largo o cola de serving?Limitar contexto, bajar batch o calentar réplica.Separar rutas largas de interactivas.
Sube TPOTtpot_p95.¿Decode lento, memoria o runtime?Reducir salida máxima o mover tráfico.Revisar runtime, cuantización y hardware.
Faltan trazastrace_missing_ratio.¿Se perdió traceparent en cola o worker?Elevar sampling de errores y revisar collector.Test de propagación en CI.
Retrieval flojobaja cobertura o baja aceptación en RAG.¿Cambió índice, embedding o corpus?Volver a índice anterior o bajar confianza.Eval específica de retrieval.

Fíjate en el patrón: el runbook separa acción inmediata de acción de fondo. La inmediata protege el servicio. La de fondo evita repetir el problema.

Árbol de diagnóstico

Un árbol ayuda a que dos personas distintas investiguen de forma parecida. No sustituye criterio, pero evita saltar directamente al modelo.

flowchart TD
    START["Síntoma observado"] --> LAT["¿Es latencia?"]
    LAT -->|"sí"| TTFT["¿Sube TTFT?"]
    LAT -->|"no"| COST["¿Es coste?"]

    TTFT -->|"sí"| Q["Mirar cola producto y cola serving"]
    TTFT -->|"no"| TPOT["¿Sube TPOT?"]
    TPOT -->|"sí"| DEC["Mirar decode, batch, memoria y runtime"]
    TPOT -->|"no"| VAL["Mirar validación, tools y red"]

    COST -->|"sí"| TOK["Separar tokens, reintentos, tools y revisión"]
    COST -->|"no"| QUAL["¿Es calidad o contrato?"]
    QUAL -->|"contrato"| SCHEMA["Comparar prompt_version, model_id y contract_version"]
    QUAL -->|"calidad"| RAG["Mirar retrieval, evals y golden traces"]

    Q --> ACTION["Decidir: backpressure, escalar, degradar o rollback"]
    DEC --> ACTION
    VAL --> ACTION
    TOK --> ACTION
    SCHEMA --> ACTION
    RAG --> ACTION

La frase que me repetiría un ingeniero: no empieces por cambiar el prompt si la traza dice que el problema vive en cola, coste o contrato.

Golden traces

Igual que usamos datasets de evaluación, podemos guardar trazas representativas. Una golden trace no es “una traza bonita”; es una ejecución esperada que sirve como patrón.

Ejemplos:

Golden traceQué representaQué debería permanecer estable
support_summary_smallPregunta corta con un documento.1 retrieval, 1 model call, contrato válido, coste bajo.
rag_long_contextConsulta con varios documentos largos.Retrieval trazado, prefill visible, coste dentro de presupuesto.
tool_requiredRun que necesita una tool concreta.Tool llamada una vez, permiso correcto, salida validada.
contract_failureSalida intencionalmente inválida.El validador bloquea y no se publica.
fallback_routeRuta degradada cuando el modelo principal no cumple.Estado y motivo de fallback visibles.

Estas trazas sirven para dos cosas: enseñar a nuevos miembros cómo se ve una run sana, y detectar regresiones de observabilidad. Si actualizas el runtime y la golden trace ya no tiene model_id o contract_version, has perdido una señal crítica aunque la respuesta final parezca correcta.

Observabilidad específica de RAG y tools

RAG y tools son las dos zonas donde más se suele perder información útil. El modelo responde al final, pero la causa del problema puede estar antes.

Para RAG, yo exigiría:

SeñalPor qué importa
retrieval_index_versionPermite saber qué índice respondió.
embedding_model_idCambia el espacio semántico de búsqueda.
query_tokensUna query enorme puede explicar coste y ruido.
top_k y reranker_versionCambian cobertura y precisión.
source_ids y chunk_idsPermiten reconstruir evidencia sin guardar texto completo.
retrieval_latency_msSepara lentitud de búsqueda de lentitud de modelo.
empty_retrievalDetecta respuestas sin contexto real.

Para tools:

SeñalPor qué importa
tool_name y tool_versionUna tool puede cambiar aunque el modelo no cambie.
permission_scopePermite revisar si la acción estaba permitida.
args_schema_versionLos argumentos también tienen contrato.
duration_msSepara tools lentas de modelo lento.
result_stateDistingue éxito, timeout, cancelación o respuesta parcial.
retry_countExplica coste y latencia.
result_summaryResume sin guardar toda la salida.

La regla: si RAG o tools influyen en la respuesta, deben aparecer en la traza. Si no aparecen, el modelo cargará con culpas que quizá no son suyas.

Coste de observar

Observar también cuesta. Cuesta almacenamiento, ingestión, consultas, retención, mantenimiento y atención humana. La observabilidad mal diseñada puede volverse otro sistema que nadie entiende.

Costes típicos:

CosteCómo apareceControl
IngestaDemasiados logs o spans por token.Agregar eventos, muestrear y evitar contenido repetido.
CardinalidadEtiquetas con run_id, user_id o IDs infinitos.Mover IDs a trazas/logs, no a métricas.
RetenciónGuardar trazas completas demasiado tiempo.Retención por tipo de señal y criticidad.
ConsultaDashboards lentos o caros.Paneles mínimos y agregaciones previas.
PrivacidadContenido sensible en sistemas de observabilidad.Redacción, hashes, permisos y export control.
AtenciónAlertas sin acción.Owner, runbook y criterio de silencio.

Una buena pregunta antes de añadir señal es: “¿Quién la usará, para qué decisión, durante cuánto tiempo y con qué nivel de acceso?”. Si no sabemos responder, quizá la señal todavía no está madura.

Herramientas que se usan para observar sistemas de IA

Una herramienta de observabilidad no sustituye al contrato de instrumentación. Si tu run no emite run_id, trace_id, model_id, prompt_version, contract_version, tokens, coste y estado final, ninguna plataforma lo va a adivinar de forma fiable. Lo que sí hace una buena herramienta es recoger esas señales, conservarlas, cruzarlas y convertirlas en consultas, alertas y evaluaciones.

Yo lo separaría en tres capas: estándar de instrumentación, backend de observabilidad general y capa específica de IA.

Estándar e instrumentación

HerramientaQué resuelveProsContras
OpenTelemetryAPIs, SDKs, Collector, protocolo OTLP, trazas, métricas, logs y convenciones semánticas.Evita acoplar el código a un único proveedor; propaga contexto entre API, cola, worker, retrieval y modelo; tiene convenciones para sistemas generativos.No es “un dashboard”; hay que elegir backend, diseñar atributos y vigilar volumen. La auto-instrumentación no entiende del todo tu dominio.
OpenInferenceConvenciones e instrumentación pensadas para IA sobre la idea de OpenTelemetry.Útil para trazas de LLM, RAG, embeddings y agentes; ayuda a que distintas herramientas entiendan atributos parecidos.Si tu arquitectura tiene contratos propios, tendrás que mapear campos y completar atributos manualmente.
Traceloop / OpenLLMetryAuto-instrumentación para frameworks y proveedores de LLM exportando a OpenTelemetry.Acelera el primer paso: ves llamadas a modelos, prompts, latencias y coste sin escribir todos los spans a mano.Puede quedarse corta en decisiones de producto: permisos, estado final, versión de contrato o aceptación real suelen requerir spans propios.

La decisión práctica: usa OpenTelemetry como idioma común siempre que puedas. Después decide si quieres que Langfuse, Phoenix, Grafana, Datadog u otra plataforma lea ese idioma.

Backends generales de métricas, logs y trazas

HerramientaQué resuelveProsContras
PrometheusMétricas de series temporales y consultas PromQL.Muy bueno para SLIs, SLOs, alertas, histogramas, colas, workers y capacidad. Encaja bien con ai_run_latency_seconds o ai_contract_failure_total.No es una base de trazas. Si metes run_id, user_id o IDs infinitos como labels, rompes cardinalidad y coste.
GrafanaVisualización y cuadros de mando sobre varias fuentes.Permite construir un dashboard operativo con latencia, coste, errores de contrato, cola, tokens y burn rate.No corrige una mala instrumentación. Si nombres y etiquetas son pobres, el panel solo lo hace más visible.
Grafana TempoBackend de trazas distribuido.Útil para reconstruir una run completa por trace_id, saltando de API a worker, retrieval, modelo y validador.Requiere muestreo, retención y atributos bien pensados. Guardar todo al 100% puede salir caro.
Grafana LokiLogs indexados por etiquetas.Bueno para logs estructurados cuando quieres correlacionar eventos con una traza sin indexar todo el contenido.Si conviertes cada campo en etiqueta, vuelve el problema de cardinalidad. Hay que separar labels estables de contenido consultable.
Grafana MimirAlmacenamiento escalable de métricas compatible con Prometheus.Útil cuando Prometheus local se queda pequeño o necesitas retención y consulta de métricas a mayor escala.Añade operación y coste. Para proyectos pequeños puede ser demasiado pronto.
Datadog LLM ObservabilityAPM gestionado con vistas específicas para flujos LLM.Interesa si la empresa ya usa Datadog: une infraestructura, servicios, trazas LLM, costes y errores en una misma plataforma.Coste y dependencia de proveedor. Las decisiones de dominio siguen siendo tuyas: contrato, aceptación, versionado y datasets.

No hay una herramienta “correcta” universal. Hay una pila que encaja con tu tamaño, tus restricciones y tu forma de operar. Para un equipo pequeño, Prometheus + Grafana + OpenTelemetry puede ser suficiente. Para un equipo con producto en producción, un APM gestionado puede ahorrar mucho trabajo operativo. Para un curso o laboratorio, Phoenix o Langfuse dan mucha visibilidad sin montar una plataforma enorme.

Herramientas específicas de LLM, RAG y agentes

HerramientaQué resuelveProsContras
LangfuseTrazas LLM, prompts, sesiones, evaluaciones, datasets y coste.Muy útil para ver conversaciones, versiones de prompt, llamadas a modelo, scoring y regresiones de producto. Tiene opción cloud y open source.Hay que cuidar qué contenido se envía. Si ya tienes OpenTelemetry, decide qué vive en Langfuse y qué vive en el backend general.
LangSmithTrazas, datasets, experimentos y evaluación de aplicaciones LLM, especialmente en LangChain/LangGraph.Fuerte para depurar cadenas, agentes, RAG y comparar ejecuciones con datasets.Si no usas LangChain, puede seguir sirviendo, pero su ergonomía brilla más dentro de ese ecosistema.
Arize PhoenixObservabilidad open source para LLM, RAG, embeddings, datasets y evaluaciones.Muy buena para enseñanza y equipos que quieren ver trazas, spans y evals de RAG sin empezar con una plataforma cerrada.Requiere despliegue, gobierno de datos y disciplina de versionado igual que cualquier herramienta.
HeliconeProxy y observabilidad para llamadas a APIs LLM.Fácil para empezar: centraliza coste, latencia, usuario, proveedor y logs de llamadas sin tocar demasiado el código.Al ir por proxy, hay que revisar privacidad, disponibilidad, región y qué ocurre si ese proxy cae. No sustituye trazas internas de cola, retrieval o validador.
BraintrustEvals, experimentos, logging, datasets y comparación de prompts/modelos.Muy útil cuando el cuello de botella es medir calidad y comparar cambios, no solo ver latencia.No reemplaza Prometheus, Grafana u OpenTelemetry para operar infraestructura, colas y workers.

Estas herramientas suelen ser más cercanas al trabajo de IA: muestran prompt, respuesta, spans de RAG, scoring, datasets y versiones de experimento. Eso no las convierte en reemplazo del SRE clásico. En una arquitectura seria conviven: una herramienta LLM para entender calidad y una pila general para operar servicio.

Cómo elegir sin perderse

SituaciónElección razonablePor qué
Estás aprendiendo o montando un prototipo serio.OpenTelemetry en código + Phoenix o Langfuse.Ves trazas de IA pronto y aprendes el contrato sin casarte con una sola plataforma.
Ya tienes plataforma SRE en la empresa.OpenTelemetry hacia Datadog, Grafana, Honeycomb o similar + herramienta de evals.Aprovechas alertas, dashboards y permisos existentes, y añades evaluación LLM donde aporta.
Tu problema principal es coste de API.Helicone o trazas propias con coste por run_id y tenant_tier.Necesitas saber quién gasta, en qué tarea, con qué modelo y con qué resultado.
Tu problema principal es calidad.Braintrust, LangSmith, Phoenix o Langfuse con datasets versionados.Necesitas comparar prompts, modelos, RAG y contratos con casos repetibles.
Tu problema principal es latencia.OpenTelemetry + backend de trazas + métricas de TTFT, TPOT, cola y batch.La causa puede estar en prefill, decode, cola, retrieval o red, no solo en el modelo.
Tu problema principal es privacidad.Instrumentación propia con hashes/IDs + backend controlado + muestreo estricto.Conviene enviar solo metadatos, resúmenes y muestras autorizadas.

Mi regla para ingeniería sería esta: primero define qué decisión quieres poder tomar. Después eliges herramienta. Si empiezas por la herramienta, acabas midiendo lo que la interfaz trae por defecto, no lo que tu sistema necesita.

Lo que ninguna herramienta arregla por ti

DeudaPor qué sigue siendo tuya
No tener run_id estable.No podrás unir API, cola, modelo, tools y cierre de producto.
No versionar modelo, prompt, contrato e índice.No podrás explicar regresiones después de un cambio.
No separar métrica, log y traza.Acabarás metiendo IDs infinitos en métricas o texto sensible en logs.
No definir SLO.Tendrás datos, pero no criterio operativo.
No tener runbook.La alerta llegará, pero nadie sabrá qué hacer primero.
No tener datasets ni golden traces.Verás síntomas, pero no podrás comparar cambios de forma repetible.
No acordar retención y permisos.La observabilidad se convierte en otro lugar donde hay datos que nadie gobierna.

En el día a día

En un proyecto real, la observabilidad empieza en el diseño del contrato, no al final. Si la API no tiene run_id, no podrás correlacionar. Si el worker no propaga trace_id, tendrás dos historias. Si la llamada al modelo no guarda model_id, no sabrás qué versión falló. Si no guardas contract_version, no sabrás si falló el modelo o cambió el schema.

Una checklist mínima para producción:

CapaDebe emitir
APIrequest_id, run_id, trace_id, tenant, ruta, contrato, tamaño.
Colatiempo de espera, prioridad, TTL, reintentos, descarte.
Retrievalíndice, versión, top_k, documentos, latencia, cobertura.
Toolsnombre, argumentos resumidos, permisos, duración, salida resumida, error tipado.
Modeloproveedor, modelo, tokens, TTFT, TPOT, coste, estado.
Validadorschema, versión, campos ausentes, campos extra, resultado.
Cierreestado final, aceptada/no aceptada, coste total, latencia total, owner.
HerramientasOpenTelemetry, backend de métricas/trazas/logs y, si aporta valor, herramienta específica para LLM/RAG/evals.

Por qué debería importarte

Porque sin observabilidad todo se convierte en opinión. Producto cree que el modelo falla. Ingeniería cree que la cola está saturada. Soporte cree que el usuario escribió mal. Finanzas cree que el proveedor subió coste. Nadie tiene una historia compartida.

Con observabilidad, las conversaciones se vuelven verificables: “el 70% de las runs caras vienen de task=legal_summary con contexto p95 de 42.000 tokens”, “la versión prompt@1.8 duplicó contract_failed”, “el p99 subió por cola de serving, no por retrieval”, “el coste por aceptada bajó aunque subió el coste por llamada”.

Manos a la obra

Práctica: contrato de telemetría ejecutable.

Kit ejecutable de este capítulo: kit descargable.

# Descomprime el ZIP del capítulo y ejecuta estos comandos dentro de esa carpeta
python3 ops/run_f6_practices.py --chapter c04 --write --fail-on-invalid

Vamos a construir una herramienta pequeña que simula runs de IA y calcula señales operativas. La idea no es reemplazar OpenTelemetry ni Prometheus, sino entender el contrato mínimo que luego llevarías a esas herramientas.

Antes del script, escribe un contrato de instrumentación. Este archivo no ejecuta nada, pero obliga al equipo a acordar qué spans son obligatorios, qué atributos se permiten, qué métricas se exportan y qué campos no deben salir a observabilidad.

Guárdalo como ops/ai/observability.yaml:

version: observability@1.0.0
owner: ai-runtime

trace:
  required_context:
    - run_id
    - trace_id
    - tenant_tier
    - task
    - release_id
  required_spans:
    - api.receive
    - input.validate
    - queue.wait
    - retrieval.search
    - model.call
    - output.validate
    - run.close
  required_versions:
    - model_id
    - prompt_version
    - contract_version
    - retrieval_index_version
    - runtime_version

metrics:
  counters:
    - ai_run_total
    - ai_contract_failure_total
    - ai_tool_call_total
  histograms:
    - ai_run_latency_seconds
    - ai_run_cost_eur
    - ai_serving_queue_seconds
    - ai_model_ttft_seconds
    - ai_model_tpot_seconds
  gauges:
    - ai_serving_queue_depth
    - ai_gpu_memory_available_bytes

allowed_metric_labels:
  - task
  - route
  - final_state
  - model_family
  - tenant_tier
  - release_id

forbidden_metric_labels:
  - run_id
  - user_id
  - prompt_text
  - document_id
  - tool_args

logs:
  required_fields:
    - event
    - timestamp
    - run_id
    - trace_id
    - severity
  content_policy:
    prompt_text: hash_only
    retrieved_chunks: ids_only
    tool_result: summary_only
    user_identifier: salted_hash

sampling:
  keep_all:
    - final_state: contract_failed
    - final_state: timed_out
    - release_phase: canary
  keep_fraction:
    succeeded: 0.03
  tail_sampling:
    keep_if_latency_ms_gt: 6000
    keep_if_cost_eur_gt: 0.08

slos:
  interactive_runs:
    success_ratio: 0.98
    latency_p95_ms: 6000
    cost_p95_eur: 0.08

alerts:
  - name: AIHighBurnRate
    condition: burn_rate_15m > 4 and ai_run_total_15m >= 30
    owner: ai-runtime
    runbook: runbooks/ai/high-burn-rate.md
  - name: AIContractFailures
    condition: contract_failed_ratio_15m > 0.03
    owner: ai-runtime
    runbook: runbooks/ai/contract-failures.md

Después añade el script. Guárdalo como ops/ai/observability_contract.py:

from dataclasses import dataclass, field
from statistics import quantiles
from time import time


@dataclass
class Span:
    name: str
    start_ms: int
    end_ms: int
    attrs: dict

    @property
    def duration_ms(self) -> int:
        return self.end_ms - self.start_ms


@dataclass
class RunTelemetry:
    run_id: str
    trace_id: str
    task: str
    model_id: str
    prompt_version: str
    contract_version: str
    final_state: str
    accepted: bool
    input_tokens: int
    output_tokens: int
    cost_eur: float
    spans: list[Span] = field(default_factory=list)
    events: list[dict] = field(default_factory=list)

    @property
    def latency_ms(self) -> int:
        return max(span.end_ms for span in self.spans) - min(span.start_ms for span in self.spans)


def percentile(values: list[float], pct: int) -> float:
    if not values:
        return 0.0
    if len(values) == 1:
        return values[0]
    cuts = quantiles(values, n=100, method="inclusive")
    return cuts[pct - 1]


def summarize(runs: list[RunTelemetry], slo_latency_ms: int, slo_cost_eur: float, slo_success_ratio: float) -> dict:
    total = len(runs)
    accepted = [run for run in runs if run.accepted]
    rejected = [run for run in runs if not run.accepted]
    latencies = [run.latency_ms for run in runs]
    costs = [run.cost_eur for run in runs]
    outside_slo = [
        run for run in runs
        if (not run.accepted) or run.latency_ms > slo_latency_ms or run.cost_eur > slo_cost_eur
    ]

    success_ratio = len(accepted) / total if total else 0.0
    error_rate = len(outside_slo) / total if total else 0.0
    allowed_error_rate = 1 - slo_success_ratio
    burn_rate = error_rate / allowed_error_rate if allowed_error_rate > 0 else float("inf")
    cost_per_accepted = sum(costs) / len(accepted) if accepted else float("inf")

    return {
        "total_runs": total,
        "accepted_runs": len(accepted),
        "rejected_runs": len(rejected),
        "success_ratio": round(success_ratio, 4),
        "latency_p95_ms": round(percentile(latencies, 95), 2),
        "cost_p95_eur": round(percentile(costs, 95), 4),
        "cost_per_accepted_eur": round(cost_per_accepted, 4),
        "outside_slo": len(outside_slo),
        "burn_rate": round(burn_rate, 2),
        "page_candidate": burn_rate >= 4 and len(outside_slo) >= 3,
    }


def trace_report(run: RunTelemetry) -> list[str]:
    lines = [
        f"run={run.run_id} trace={run.trace_id} state={run.final_state}",
        f"model={run.model_id} prompt={run.prompt_version} contract={run.contract_version}",
        f"latency_ms={run.latency_ms} cost_eur={run.cost_eur:.4f} accepted={run.accepted}",
    ]
    for span in run.spans:
        lines.append(f"- {span.name}: {span.duration_ms} ms {span.attrs}")
    for event in run.events:
        lines.append(f"* event {event['name']}: {event['attrs']}")
    return lines


runs = [
    RunTelemetry(
        run_id="run_001",
        trace_id="trace_a",
        task="support_summary",
        model_id="local-qwen3-8b",
        prompt_version="prompt@1.4",
        contract_version="support-answer@1.2",
        final_state="succeeded",
        accepted=True,
        input_tokens=3400,
        output_tokens=260,
        cost_eur=0.031,
        spans=[
            Span("api.receive", 0, 30, {"route": "/runs"}),
            Span("queue.wait", 30, 180, {"queue": "interactive"}),
            Span("retrieval.search", 180, 420, {"top_k": 6, "index_version": "idx@7"}),
            Span("model.call", 420, 2400, {"ttft_ms": 610, "tpot_ms": 18}),
            Span("output.validate", 2400, 2480, {"valid": True}),
        ],
    ),
    RunTelemetry(
        run_id="run_002",
        trace_id="trace_b",
        task="support_summary",
        model_id="local-qwen3-8b",
        prompt_version="prompt@1.4",
        contract_version="support-answer@1.2",
        final_state="contract_failed",
        accepted=False,
        input_tokens=3900,
        output_tokens=310,
        cost_eur=0.036,
        spans=[
            Span("api.receive", 0, 35, {"route": "/runs"}),
            Span("queue.wait", 35, 260, {"queue": "interactive"}),
            Span("retrieval.search", 260, 590, {"top_k": 8, "index_version": "idx@7"}),
            Span("model.call", 590, 3100, {"ttft_ms": 900, "tpot_ms": 22}),
            Span("output.validate", 3100, 3200, {"valid": False}),
        ],
        events=[
            {"name": "contract.failed", "attrs": {"missing_fields": ["sources"], "extra_fields": []}},
        ],
    ),
    RunTelemetry(
        run_id="run_003",
        trace_id="trace_c",
        task="legal_summary",
        model_id="frontier-cloud",
        prompt_version="prompt@2.1",
        contract_version="legal-answer@1.0",
        final_state="succeeded",
        accepted=True,
        input_tokens=22000,
        output_tokens=720,
        cost_eur=0.184,
        spans=[
            Span("api.receive", 0, 40, {"route": "/runs"}),
            Span("queue.wait", 40, 840, {"queue": "long_context"}),
            Span("retrieval.search", 840, 1340, {"top_k": 12, "index_version": "idx@7"}),
            Span("model.call", 1340, 7850, {"ttft_ms": 2100, "tpot_ms": 34}),
            Span("output.validate", 7850, 8020, {"valid": True}),
        ],
    ),
    RunTelemetry(
        run_id="run_004",
        trace_id="trace_d",
        task="support_summary",
        model_id="local-qwen3-8b",
        prompt_version="prompt@1.5",
        contract_version="support-answer@1.2",
        final_state="timed_out",
        accepted=False,
        input_tokens=5000,
        output_tokens=120,
        cost_eur=0.044,
        spans=[
            Span("api.receive", 0, 35, {"route": "/runs"}),
            Span("queue.wait", 35, 1900, {"queue": "interactive"}),
            Span("model.call", 1900, 6200, {"ttft_ms": 2600, "tpot_ms": 40}),
        ],
        events=[
            {"name": "run.timed_out", "attrs": {"budget_ms": 5000}},
        ],
    ),
]

summary = summarize(
    runs,
    slo_latency_ms=6000,
    slo_cost_eur=0.08,
    slo_success_ratio=0.98,
)

print("resumen:", summary)
print()
print("traza de ejemplo:")
for line in trace_report(runs[1]):
    print(line)

Salida esperada:

resumen: {'total_runs': 4, 'accepted_runs': 2, 'rejected_runs': 2, 'success_ratio': 0.5, 'latency_p95_ms': 7747.0, 'cost_p95_eur': 0.163, 'cost_per_accepted_eur': 0.1475, 'outside_slo': 3, 'burn_rate': 37.5, 'page_candidate': True}

traza de ejemplo:
run=run_002 trace=trace_b state=contract_failed
model=local-qwen3-8b prompt=prompt@1.4 contract=support-answer@1.2
latency_ms=3200 cost_eur=0.0360 accepted=False
- api.receive: 35 ms {'route': '/runs'}
- queue.wait: 225 ms {'queue': 'interactive'}
- retrieval.search: 330 ms {'top_k': 8, 'index_version': 'idx@7'}
- model.call: 2510 ms {'ttft_ms': 900, 'tpot_ms': 22}
- output.validate: 100 ms {'valid': False}
* event contract.failed: {'missing_fields': ['sources'], 'extra_fields': []}

La práctica enseña algo que muchos dashboards esconden: el sistema puede tener solo cuatro runs y aun así mostrar tres problemas distintos. Una falla contrato, otra se pasa de coste y latencia, otra agota tiempo. La observabilidad buena no aplana todo en “error”; conserva la forma del problema.

Cómo encaja todo

Primero, el flujo de telemetría de una run:

sequenceDiagram
    autonumber
    participant API as API boundary
    participant R as Run store
    participant Q as Cola
    participant W as Worker
    participant M as Modelo
    participant C as Collector
    participant B as Backend observabilidad
    participant O as Operacion

    API->>C: span api.receive + atributos
    API->>R: crear run con trace_id
    R->>C: evento run.accepted
    Q->>C: metric queue_wait + queue_depth
    W->>C: span worker.run
    W->>M: llamada con trace_id propagado
    M-->>W: usage, latencia y provider_request_id
    W->>C: span model.call + tokens + coste
    W->>C: evento output.validate
    W->>R: guardar estado final
    C->>B: exportar logs, métricas y trazas
    O->>B: consultar run_id, p95, burn rate y coste

Y el mapa conceptual:

flowchart TD
    subgraph F6["Facsímil 6: construir y operar"]
      C1["Cap. 01<br/>sistema operable"]
      C2["Cap. 02<br/>runtime y contratos"]
      C3["Cap. 03<br/>serving y capacidad"]
      C4["Cap. 04<br/>observabilidad"]
      C5["Cap. 05<br/>routing y fallback"]
      C6["Cap. 06<br/>EvalOps y gates"]
      C9["Cap. 09<br/>SLO e incidentes"]
    end

    subgraph Capitulo["Dentro de este capítulo"]
      LOGS["logs estructurados"]
      MET["métricas"]
      TRACE["trazas"]
      SLI["SLI"]
      SLO["SLO"]
      COST["coste observable"]
      ALERT["alertas accionables"]
      DASH["cuadro de mando"]
      VER["correlación por versión"]
      RUNBOOK["runbook"]
      GOLD["golden traces"]
    end

    subgraph Previos["Conceptos anteriores y posteriores"]
      F4C03["F4 C03<br/>tokens y coste"]
      F4C09["F4 C09<br/>RAG"]
      F5C03["F5 C03<br/>tools"]
      F5C10["F5 C10<br/>evaluar agentes"]
      F7["F7<br/>evaluar e interpretar"]
    end

    C1 -->|"define qué operar"| C4
    C2 -->|"emite run_id y contratos"| TRACE
    C3 -->|"emite TTFT, TPOT y capacidad"| MET
    VER -->|"explica cambios"| TRACE
    LOGS -->|"explican eventos"| TRACE
    TRACE -->|"reconstruyen runs"| DASH
    TRACE -->|"compara patrones"| GOLD
    MET -->|"calcula"| SLI
    SLI -->|"sostiene"| SLO
    SLO -->|"activa"| ALERT
    ALERT -->|"consulta"| RUNBOOK
    COST -->|"prioriza"| DASH
    DASH -->|"guía"| C5
    ALERT -->|"alimenta"| C9
    TRACE -->|"alimenta"| C6

    F4C03 -->|"aporta tokens y cache"| COST
    F4C09 -->|"aporta fuentes y retrieval"| TRACE
    F5C03 -->|"aporta tools y permisos"| LOGS
    F5C10 -->|"convierte trazas en evaluación"| C6
    F7 -->|"profundiza métricas"| SLI

    classDef external fill:#FFFFFF,stroke:#111111,stroke-width:1.3,stroke-dasharray:5 5,color:#111111
    class F4C03,F4C09,F5C03,F5C10,F7 external

La observabilidad es el puente entre construir y mejorar. Sin señales, un cambio de prompt, modelo, índice o runtime se evalúa por sensaciones. Con señales, se evalúa por calidad, coste, latencia y trazabilidad.

Vocabulario aprendido

TérminoDefinición
ObservabilidadCapacidad de entender el estado interno de un sistema a partir de señales emitidas.
LogEvento estructurado que registra algo ocurrido.
MétricaMedida numérica agregable en el tiempo.
TrazaHistoria completa de una petición o run, formada por spans.
SpanUnidad de trabajo dentro de una traza.
AtributoDato clave-valor que describe un span, log o métrica.
Evento de spanMarca puntual dentro de un span, como contract.failed.
Trace contextInformación que permite propagar una traza entre servicios.
CardinalidadNúmero de combinaciones posibles de etiquetas en una métrica.
HistogramaMétrica que agrupa observaciones por rangos para calcular percentiles.
SLIIndicador medible del comportamiento de un servicio.
SLOObjetivo interno medible basado en un SLI.
Error budgetMargen permitido de incumplimiento del SLO.
Burn rateVelocidad a la que se consume el presupuesto de error.
SamplingSelección parcial de señales para controlar coste y volumen.
RetenciónTiempo durante el que se conserva una señal.
Coste por run aceptadaCoste total dividido entre runs que realmente sirven para producto.
Correlación por versiónCapacidad de filtrar señales por modelo, prompt, contrato, índice, runtime y release.
RunbookGuía operativa que indica qué mirar y qué hacer cuando aparece un síntoma.
Golden traceTraza representativa y versionada que enseña cómo debería verse una run sana o controlada.
Tail samplingMuestreo que decide conservar una traza después de ver que fue lenta, cara o fallida.
Coste de observabilidadCoste de ingesta, almacenamiento, consulta, retención y atención humana asociado a observar el sistema.
OpenTelemetryEstándar abierto para instrumentar y exportar logs, métricas y trazas.
PrometheusSistema de métricas de series temporales muy usado para SLIs, SLOs y alertas.
GrafanaPlataforma de visualización para dashboards y exploración de señales.
APMApplication Performance Monitoring: herramientas para observar servicios, latencia, errores, dependencias y recursos.
Observabilidad LLMCapa de herramientas centrada en prompts, respuestas, coste, trazas de RAG, agentes, datasets y evaluaciones.

Dónde solía tropezar yo

TropiezoPor qué es un problemaAntídoto
Guardar demasiado contenidoComplica privacidad, coste y lectura.Guardar IDs, hashes, tamaños, versiones y muestras controladas.
Medir solo la mediaLa media oculta la cola de usuarios que peor lo pasan.Usar histogramas, p95, p99 y trazas lentas.
Meter run_id como etiqueta de métricaExplota la cardinalidad y encarece el sistema.run_id vive en trazas y logs, no en series agregadas.
Alertar por todoEl equipo deja de confiar en las alertas.Alertar solo por síntomas accionables y SLO burn rate.
No propagar trace_idCada servicio cuenta una historia distinta.Propagar contexto desde API hasta worker y modelo.
Separar coste de calidadParece barato lo que luego se descarta.Medir coste por run aceptada.
No versionar prompts y contratosNo sabes qué cambio rompió qué.Emitir prompt_version, model_id y contract_version.
Pensar que observabilidad se añade al finalLuego no existen los puntos de instrumentación.Diseñar señales junto al contrato de runtime.

Antes de pasar página

  • ¿Puedes explicar con tus palabras la diferencia entre log, métrica y traza?
  • ¿Puedes diseñar una traza mínima para una run de IA?
  • ¿Puedes decir qué atributos debe tener model.call?
  • ¿Puedes explicar por qué run_id no debería ser etiqueta de métrica?
  • ¿Puedes definir un SLI de salida aceptada?
  • ¿Puedes calcular burn rate si el SLO es 99% y el error observado es 5%?
  • ¿Puedes explicar por qué HTTP 200 no siempre significa éxito de producto?
  • ¿Puedes diseñar una métrica de coste por run aceptada?
  • ¿Puedes decidir qué señales guardarías al 100% y cuáles muestrearías?
  • ¿Puedes nombrar una alerta accionable y una alerta que eliminarías?
  • ¿Puedes explicar por qué p95/p99 importan en IA interactiva?
  • ¿Puedes conectar observabilidad con EvalOps y gates?
  • ¿Puedes decir qué versiones debe emitir una run para investigar una regresión?
  • ¿Puedes diseñar un dashboard mínimo de seis paneles sin llenarlo de ruido?
  • ¿Puedes escribir una alerta con condición, owner y runbook?
  • ¿Puedes seguir el árbol de diagnóstico para separar TTFT, TPOT, contrato, coste y retrieval?
  • ¿Puedes explicar para qué sirve una golden trace?
  • ¿Puedes decir qué señales de RAG y tools no deberían faltar?
  • ¿Puedes justificar qué campos prohibirías como etiquetas de métricas?
  • ¿Puedes elegir entre OpenTelemetry, Prometheus/Grafana y una herramienta LLM específica según el problema?
  • ¿Puedes explicar por qué una herramienta no reemplaza run_id, versionado, SLO y runbook?
  • ¿Puedes comparar instrumentación por SDK, auto-instrumentación y proxy?

Para saber más

En resumen

IdeaQué debes llevarte
Observabilidad es reconstrucción, no acumulación.Debes poder explicar una run concreta sin guardar contenido de más.
Logs, métricas y trazas cumplen trabajos distintos.Logs explican eventos, métricas agregan comportamiento y trazas muestran causalidad.
En IA el éxito no es solo HTTP 200.Hay que medir contrato, latencia, coste, calidad, revisión y estado final.
La cardinalidad se diseña.Algunas cosas van en métricas; otras en trazas o logs.
Coste y calidad deben leerse juntos.El coste útil es coste por run aceptada, no solo precio por token.
Las alertas deben ser accionables.Un buen SLO y burn rate ayudan a alertar menos y mejor.
Las versiones son parte de la señal.Sin model_id, prompt_version, contract_version e índice no sabes qué cambio explicar.
El runbook convierte una alerta en trabajo.Una alerta útil trae owner, primera comprobación y acción inmediata.
RAG y tools deben dejar huella.Si influyen en la respuesta, deben aparecer en la traza con versión, duración y resultado resumido.
Las herramientas amplifican una buena instrumentación.OpenTelemetry, Grafana, Langfuse, Phoenix o Datadog ayudan, pero no inventan contratos, versiones ni SLOs.

Notas

  1. Ewaschuk, R. (2016). Monitoring Distributed Systems. En B. Beyer, C. Jones, J. Petoff y N. R. Murphy (eds.), Site Reliability Engineering. https://sre.google/sre-book/monitoring-distributed-systems/. Consultado el 27 de mayo de 2026.

  2. OpenTelemetry. (2026). Traces. https://opentelemetry.io/docs/concepts/signals/traces/. Consultado el 27 de mayo de 2026.

  3. OpenTelemetry. (2026). Tracing API. https://opentelemetry.io/docs/specs/otel/trace/api/. Consultado el 27 de mayo de 2026.

  4. OpenTelemetry. (2026). Semantic Conventions for Generative AI Systems. https://opentelemetry.io/docs/specs/semconv/gen-ai/. Consultado el 27 de mayo de 2026.

  5. OpenTelemetry. (2026). Logs. https://opentelemetry.io/docs/concepts/signals/logs/. Consultado el 27 de mayo de 2026.

  6. OpenTelemetry. (2026). Metrics. https://opentelemetry.io/docs/concepts/signals/metrics/. Consultado el 27 de mayo de 2026.

  7. World Wide Web Consortium. (2021). Trace Context Level 2. https://www.w3.org/TR/trace-context-2/. Consultado el 27 de mayo de 2026.

  8. Ewaschuk, 2016.

  9. Wilkinson, J. (2018). Alerting on SLOs. En B. Beyer, N. R. Murphy, D. Rensin, K. Kawahara y S. Thorne (eds.), The Site Reliability Workbook. https://sre.google/workbook/alerting-on-slos/. Consultado el 27 de mayo de 2026.

  10. Prometheus. (2026). Metric and label naming. https://prometheus.io/docs/practices/naming/. Consultado el 27 de mayo de 2026.

  11. Dean, J. y Barroso, L. A. (2013). The Tail at Scale. Communications of the ACM, 56(2), 74-80. https://doi.org/10.1145/2408776.2408794.

  12. Little, J. D. C. (1961). A Proof for the Queuing Formula: L = λW. Operations Research, 9(3), 383-387. https://doi.org/10.1287/opre.9.3.383.

Capítulo 05

Facsímil 6 · Construir y operar

Capítulo 05: Routing, fallback y presupuestos por tarea

Qué deberías poder hacer al terminar

En el capítulo 02 convertimos una petición en una run con estado y contrato. En el capítulo 03 vimos que servir modelos significa convivir con memoria, colas, prefill, decode y capacidad real. En el capítulo 04 aprendimos a observar una run para reconstruir qué ocurrió.

Ahora toca decidir por dónde debe ir cada run antes de ejecutarla.

No todas las tareas merecen el mismo modelo, la misma latencia, el mismo coste ni la misma estrategia de recuperación. Una pregunta corta de soporte no debería consumir el mismo presupuesto que un análisis legal largo. Una extracción JSON estricta no debería ir por una ruta que no soporta salida estructurada. Una tarea interactiva no debería quedarse esperando detrás de un lote nocturno. Y una dependencia lenta no debería provocar diez reintentos que empeoran el sistema.

Al terminar, deberías poder hacer esto:

Resultado de aprendizajeEvidencia de que lo sabes hacer
Diseñar un router operativo.Separas reglas, señales, catálogo de rutas, presupuesto y trazabilidad.
Definir presupuestos por tarea.Pones límites de latencia, coste, tokens, retries y salida esperada.
Explicar fallback sin vender atajos.Distingues degradar, reintentar, cambiar proveedor, cambiar modelo o parar con estado claro.
Calcular coste esperado de una ruta.Tienes en cuenta entrada, salida, probabilidad de reintento y aceptación final.
Evitar tormentas de reintentos.Usas timeout, backoff, jitter, circuit breaker y límites de concurrencia.
Instrumentar la decisión.Registras ruta elegida, alternativas descartadas, motivo y presupuesto consumido.

La idea central: un sistema serio no llama al modelo “a ver qué pasa”; decide una ruta bajo contrato, presupuesto y señales observables.

La petición que parece igual pero no lo es

Imagina dos usuarios escribiendo “resume este documento”. La frase parece la misma, pero las condiciones no lo son.

En el primer caso, el documento son cinco párrafos de una política interna y el usuario quiere una respuesta rápida. En el segundo, el documento son setenta páginas, la respuesta debe citar fuentes, el contrato exige JSON, el coste máximo es bajo y la tarea no es interactiva. Si mandamos ambas por la misma ruta, estamos fingiendo que el sistema no entiende su propio trabajo.

El router es la pieza que evita esa ficción. Mira la tarea, el contrato, el presupuesto, la salud de las rutas, la capacidad disponible, la sensibilidad de los datos, el histórico de calidad y decide: local o cloud, modelo pequeño o grande, streaming o batch, RAG o no RAG, salida breve o larga, fallback permitido o no.

Qué no es routing

Routing no es “si falla A, prueba B”. Eso es solo una parte pequeña, y a veces peligrosa, de la historia.

Tampoco es elegir siempre el modelo más potente. El modelo más potente puede ser demasiado caro, lento, innecesario o incompatible con el contrato de salida. En operación, “mejor” no significa “más grande”; significa suficiente para esta tarea bajo estas restricciones.

Y tampoco es esconder la decisión dentro de un prompt: “elige tú el mejor modelo”. Algunas decisiones pueden usar un clasificador o un modelo auxiliar, pero la política operativa no debería quedar enterrada en texto. Si cambia el presupuesto, si sube la cola, si una ruta no soporta JSON o si el usuario pertenece a un tenant con región obligatoria, eso debe vivir en una capa explícita.

ConfusiónQué falta
“Router es fallback”Falta selección inicial, política, coste, capacidad y trazabilidad.
“Uso siempre el modelo grande”Falta distinguir calidad suficiente de exceso de coste.
“Reintento hasta que salga”Falta presupuesto, backoff, límite y motivo de parada.
“Si el proveedor va lento, pruebo otro”Falta saber si el segundo cumple contrato, datos, región y parámetros.
“La ruta la decide el prompt”Falta una política auditable y versionada.

Qué sí es un router operativo

Ejemplo de fórmula. Un router operativo es una función de decisión:

ρ(t,c,b,h,o)r\rho(t, c, b, h, o) \rightarrow r
SímboloSignificadoEjemplo
ρ\rhoFunción de routing.Código, política o servicio que elige ruta.
ttTipo de tarea.support_summary, legal_rag, json_extract, batch_labeling.
ccContrato de salida y capacidades requeridas.JSON estricto, tools, visión, RAG, región, streaming.
bbPresupuesto disponible.5 segundos, 0,08 EUR, 8000 tokens, 1 retry.
hhSeñales de salud y capacidad.p95, cola, tasa de timeouts, coste p95, proveedor disponible.
ooObservabilidad histórica y evaluación.aceptación, calidad por ruta, fallos de contrato, golden traces.
rrRuta elegida.local_qwen_fast, cloud_frontier_json, rag_batch_worker.

La función puede ser simple o compleja. En un producto pequeño puede ser una tabla YAML con reglas. En un producto grande puede ser un servicio con feature flags, estadísticas por tenant, circuit breakers, evaluaciones offline, rutas canary y aprendizaje sobre resultados. Lo importante no es que sea sofisticado: es que sea explícito.

Fecha de corte del estado del arte

Fecha de corte: 28 de mayo de 2026.
Fuentes consultadas: Google SRE sobre sobrecarga y monitorización, AWS Builders Library sobre timeouts, retries, backoff y jitter, OpenRouter Provider Routing, Prompt Caching y Latency and Performance, LiteLLM Router, OpenAI Rate Limits, Latency Optimization, Prompt Caching, Batch API y Flex Processing, Anthropic Rate Limits, Prompt Caching y Cache Diagnostics, Prometheus Metric Naming y OpenTelemetry Semantic Conventions for Generative AI Systems.

Lo estable es el patrón de ingeniería: presupuestos, colas, retries limitados, backoff, jitter, degradación controlada, health signals, circuit breakers, observabilidad y SLOs. Lo cambiante son modelos disponibles, proveedores, precios, límites de cuota, parámetros soportados y sintaxis concreta de routers comerciales.

Google SRE describe la sobrecarga como un problema que exige decidir qué trabajo aceptar, retrasar o rechazar para proteger el servicio.1 AWS insiste en que timeouts, retries, backoff y jitter deben diseñarse juntos; reintentar sin control puede amplificar el problema que intentas resolver.2

En herramientas de IA, OpenRouter documenta selección de proveedor por precio, latencia, throughput, fallbacks, requisitos de parámetros y políticas de datos.3 LiteLLM documenta un router para balanceo, fallbacks, retries, cooldowns y selección entre despliegues.4 OpenAI y Anthropic documentan límites de tasa y cuota que obligan a tratar capacidad y presupuesto como parte de la integración, no como detalle posterior.56

La política de ruta por dentro

Un router maduro no decide con una sola señal. Combina restricciones duras y preferencias.

Primero filtra lo que no cumple:

Restricción duraPreguntaEjemplo
Contrato¿La ruta soporta lo que pide la salida?JSON estricto, tool calling, visión, streaming.
Presupuesto¿Cabe en coste, tiempo y tokens?max_cost_eur <= 0.08, max_latency_ms <= 6000.
Datos¿La ruta cumple región, retención y política interna?Local, UE, no almacenar contenido, solo IDs.
Capacidad¿La ruta puede aceptar trabajo ahora?Cola, cuota, workers, rate limit, cooldown.
Compatibilidad¿Soporta los parámetros necesarios?temperature, response_format, max_output_tokens.

Después ordena lo que sí cumple:

PreferenciaQué optimizaCuándo domina
Menor latenciaTiempo hasta respuesta útil.Chat interactivo, copilotos, soporte.
Menor costeCoste por run aceptada.Clasificación masiva, tareas de bajo margen.
Mayor calidadPrecisión, razonamiento, formato, evaluación.Tareas complejas, legales, técnicas o de decisión.
Mayor estabilidadMenos variación p95/p99.Productos con SLO estricto.
Menor exposición de datosMenos salida de datos del entorno controlado.Documentos internos o datos sensibles.

Ejemplo de fórmula. Podemos escribir una puntuación simple:

S(r)=wqQ(r)wcC(r)wlL(r)weE(r)wsσ(r)S(r) = w_q Q(r) - w_c C(r) - w_l L(r) - w_e E(r) - w_s \sigma(r)
SímboloSignificadoEjemplo
S(r)S(r)Puntuación de la ruta rr.0,71 para local_fast; 0,83 para cloud_json.
Q(r)Q(r)Calidad esperada normalizada.0,92 si pasa evals de extracción.
C(r)C(r)Coste esperado normalizado.0,30 si cuesta poco, 0,90 si es caro.
L(r)L(r)Latencia esperada normalizada.p95 de 2,5 s frente a un límite de 6 s.
E(r)E(r)Penalización por exposición de datos o restricciones.0 si todo queda local; 0,6 si sale a cloud sin necesidad.
σ(r)\sigma(r)Variabilidad o cola de cola larga.Penaliza rutas con p99 malo aunque p50 parezca bien.
wq,wc,wl,we,wsw_q, w_c, w_l, w_e, w_sPesos de decisión.En soporte pesa latencia; en extracción pesa contrato.

Esto no obliga a convertir el router en una fórmula rígida. La fórmula sirve para una idea: una ruta no se elige solo porque “responde bien”, sino porque equilibra calidad, coste, latencia, política y estabilidad.

Catálogo de rutas: la tabla que sostiene al router

Un router sin catálogo acaba siendo un if enorme. Al principio parece suficiente, pero pronto nadie sabe qué rutas existen, qué contrato soportan, qué coste tienen, qué dueño las mantiene o qué limitación ocultan.

El catálogo de rutas es un artefacto versionado. Puede vivir en YAML, base de datos, configuración de plataforma o servicio interno, pero debe responder preguntas concretas:

PreguntaCampo del catálogo
¿Qué ruta puedo usar para esta tarea?allowed_tasks, capabilities, contracts.
¿Qué ruta no debo usar con estos datos?data_policy, region, retention.
¿Cuánto cuesta antes de ejecutarla?price.input_1k, price.output_1k, tool_costs.
¿Qué SLO suele cumplir?latency.p50, latency.p95, ttft.p95, tpot.p95.
¿Qué límites tiene ahora mismo?rpm, tpm, concurrency, context_window.
¿Quién responde si se rompe?owner, runbook, dashboard.
¿Cómo se degrada?fallbacks, degraded_mode, stop_state.

Una versión mínima:

version: route-catalog@2026-05-27
owner: ai-runtime

routes:
  local_fast:
    provider: local
    model: qwen3-8b-instruct
    runtime: vllm
    owner: ai-platform
    allowed_tasks: [fast_triage, support_fast_answer]
    capabilities: [text, json]
    contracts: [triage_v2, short_answer_v1]
    context_window: 32768
    data_policy:
      region: local
      retention: none
      content_logging: ids_only
    price:
      input_1k_eur: 0.0002
      output_1k_eur: 0.0006
    observed:
      latency_p95_ms: 1800
      timeout_ratio_5m: 0.02
      contract_failure_ratio_24h: 0.012
    limits:
      max_concurrency: 24
      max_output_tokens: 700
    fallback:
      chain: [support_brief_static]
      stop_state: capacity_unavailable

  rag_medium_json:
    provider: cloud
    model: medium-json-2026-05
    owner: ai-runtime
    allowed_tasks: [support_summary, policy_qa]
    capabilities: [text, json, rag, citations]
    contracts: [answer_with_sources_v3]
    context_window: 131072
    data_policy:
      region: eu
      retention: none
      content_logging: hash_and_ids
    price:
      input_1k_eur: 0.002
      output_1k_eur: 0.006
    observed:
      latency_p95_ms: 4200
      timeout_ratio_5m: 0.05
      contract_failure_ratio_24h: 0.018
    limits:
      rpm: 600
      tpm: 1200000
      max_output_tokens: 1200
    fallback:
      chain: [rag_small_brief, batch_review]
      stop_state: no_route_under_budget

El catálogo también evita una trampa frecuente: pensar que “modelo” y “ruta” son lo mismo. Una ruta incluye modelo, proveedor, runtime, región, parámetros permitidos, contrato, límites, observabilidad, owner y fallback. Dos rutas pueden usar el mismo modelo y comportarse distinto porque cambian proveedor, cuantización, template, latencia, región o versión del gateway.

Presupuesto por tarea

Ejemplo de fórmula. Un presupuesto de tarea es un contrato operativo. No dice “usa poco”. Dice cuánto se puede gastar, esperar y reintentar.

Bt=Tmax,Cmax,Imax,Omax,Rmax,AmaxB_t = \langle T_{max}, C_{max}, I_{max}, O_{max}, R_{max}, A_{max} \rangle
SímboloSignificadoEjemplo
BtB_tPresupuesto para la tarea tt.Presupuesto de support_summary.
TmaxT_{max}Latencia máxima.6000 ms.
CmaxC_{max}Coste máximo.0,08 EUR.
ImaxI_{max}Tokens máximos de entrada.12.000 tokens.
OmaxO_{max}Tokens máximos de salida.600 tokens.
RmaxR_{max}Reintentos máximos.1 retry.
AmaxA_{max}Acciones o llamadas externas máximas.2 tools, 1 retrieval, 0 escrituras.

Ejemplo de presupuestos:

TareaLatenciaCosteTokensReintentosRuta preferida
support_fast_answer2500 ms0,02 EUR4000 entrada / 300 salida0modelo pequeño o caché.
support_summary6000 ms0,08 EUR12000 entrada / 600 salida1RAG + modelo medio.
legal_rag_review30000 ms0,60 EUR60000 entrada / 2000 salida1modelo fuerte + citas + revisión.
batch_labeling120000 ms0,01 EUR por item2000 entrada / 50 salida2lote barato, sin streaming.
json_extract_strict8000 ms0,05 EUR8000 entrada / 800 salida1ruta con salida estructurada fiable.

La clave está en el adjetivo “por tarea”. Un presupuesto global tipo “máximo 20.000 tokens” no dice casi nada. La operación necesita saber si esos tokens pertenecen a una conversación interactiva, a un lote, a una extracción o a un resumen con citas.

Cuotas y rate limits: capacidad externa también es arquitectura

Cuando usas proveedores externos, la capacidad no depende solo de tu código. También depende de cuotas de peticiones, tokens, concurrencia, región, tier, modelo y organización. OpenAI y Anthropic documentan límites de tasa que pueden expresarse en peticiones, tokens u otros contadores según producto, modelo o plan.78

Para un router, esos límites no son un error inesperado: son una entrada de decisión.

Ejemplo de fórmula. Una estimación sencilla:

λtE[It+Ot]TPMrutaα\lambda_t \cdot E[I_t + O_t] \le TPM_{ruta} \cdot \alpha
SímboloSignificadoEjemplo
λt\lambda_tRuns por minuto de la tarea tt.120 resúmenes por minuto.
E[It+Ot]E[I_t + O_t]Tokens esperados por run.7500 entrada + 500 salida.
TPMrutaTPM_{ruta}Tokens por minuto permitidos en la ruta.1.200.000 TPM.
α\alphaMargen operativo que no queremos superar.0,75 para dejar colchón.

Si 1208000=960000120 \cdot 8000 = 960000 tokens por minuto y la ruta tiene 1.200.000 TPM, parece caber. Pero con α=0,75\alpha = 0,75, el límite operativo sería 900.000 TPM. El router debería empezar a derivar parte del tráfico, pasar una clase a batch o bajar tokens de entrada.

Lo mismo para peticiones por minuto:

λtRPMrutaα\lambda_t \le RPM_{ruta} \cdot \alpha
SímboloSignificadoEjemplo
RPMrutaRPM_{ruta}Peticiones por minuto permitidas.600 RPM.
λt\lambda_tLlegada esperada.520 RPM.
α\alphaMargen de seguridad.0,80.

Con α=0,80\alpha=0,80, 600 RPM se convierten en 480 RPM operativos. Si llegan 520, no conviene esperar al error de cuota; conviene actuar antes.

SeñalAcción razonable del router
quota_remaining_tpm baja rápido.Reducir contexto, cambiar ruta o mandar tareas no urgentes a batch.
rpm_utilization supera margen.Activar backpressure o repartir entre rutas compatibles.
retry_after_ms cabe en presupuesto.Esperar con backoff y jitter.
retry_after_ms no cabe.Cambiar ruta o cerrar con estado claro.
context_window insuficiente.Comprimir, hacer RAG, dividir tarea o rechazar con explicación.

El punto de ingeniería: no esperes a que el proveedor te diga “no puedo”. Tu router debería saber antes si la ruta está cerca del límite.

Coste esperado y coste útil

El coste visible de una llamada no es el coste de producto. Si una ruta falla contrato, requiere reintento o produce una respuesta que se descarta, ha consumido dinero sin producir utilidad.

Ejemplo de fórmula. Una aproximación:

E[Crun]=Cin+Cout+pretry(Cinretry+Coutretry)+Ctool+CretrievalE[C_{run}] = C_{in} + C_{out} + p_{retry}(C_{in}^{retry} + C_{out}^{retry}) + C_{tool} + C_{retrieval}
SímboloSignificadoEjemplo
E[Crun]E[C_{run}]Coste esperado de una run.0,047 EUR.
CinC_{in}Coste de tokens de entrada.10.000 tokens de contexto.
CoutC_{out}Coste de tokens de salida.500 tokens generados.
pretryp_{retry}Probabilidad de necesitar reintento.0,08.
Cinretry,CoutretryC_{in}^{retry}, C_{out}^{retry}Coste de un intento adicional.Puede ser menor si recortamos contexto.
CtoolC_{tool}Coste de tools o servicios externos.Búsqueda, OCR, base vectorial, API externa.
CretrievalC_{retrieval}Coste de recuperación y reranking.Embeddings, vector DB, reranker.

Ejemplo de fórmula. Y el coste útil:

Cutil=CrunNaceptadasC_{util} = \frac{\sum C_{run}}{N_{aceptadas}}
SímboloSignificadoEjemplo
CutilC_{util}Coste por run aceptada.0,11 EUR por respuesta útil.
Crun\sum C_{run}Coste total de runs intentadas.11 EUR.
NaceptadasN_{aceptadas}Runs que pasan contrato y sirven al usuario.100.

Esta fórmula es incómoda porque revela rutas “baratas” que salen caras. Un modelo pequeño puede costar poco por token, pero si falla más el contrato, reintenta más o exige revisión, el coste útil puede subir.

Tipos de routing que sí aparecen en producción

No hay un único tipo de router. Hay familias.

TipoCómo decideSirve cuandoCuidado
Reglas estáticastask == X -> ruta Y.Producto pequeño, compliance claro, pocas tareas.Se queda rígido si crece el catálogo.
Routing por contratoFiltra por soporte de JSON, tools, visión, región o streaming.La salida tiene requisitos fuertes.Hay que mantener capacidades por ruta.
Routing por costeOrdena por coste estimado bajo una calidad mínima.Tareas masivas o de bajo margen.Barato no equivale a aceptable.
Routing por latenciaPrefiere p95/p99 menor o cola más corta.Experiencia interactiva.Puede elegir rutas más caras.
Routing por calidadUsa evals, golden traces y resultados históricos.Tareas donde el error cuesta más que la latencia.Necesita datasets vivos y versionados.
Routing por saludEvita rutas con timeouts, cuota agotada o cola alta.Operación real con proveedores y runtimes cambiantes.Sin observabilidad, se convierte en intuición.
Routing híbridoCombina reglas, score, salud, coste y contrato.Sistemas de IA en producción.Requiere trazabilidad y ownership.

Lo importante: un router puede empezar simple y evolucionar. Lo peligroso es empezar opaco.

Compatibilidad real entre proveedores

Una API compatible no garantiza comportamiento compatible. Dos rutas pueden aceptar un cuerpo parecido y aun así diferir en lo que importa.

OpenRouter documenta opciones para exigir proveedores que soporten parámetros concretos y para filtrar por políticas de datos, proveedores, cuantización, precio, latencia o throughput.9 Esa idea es general: antes de fallback o routing cruzado, hay que comprobar compatibilidad.

SuperficieQué compararPor qué importa
Salida estructuradaJSON mode, schema estricto, tool calling, validación.Una ruta puede “intentar JSON” y otra garantizar contrato.
StreamingFormato de eventos, fin de stream, errores parciales.El cliente puede depender de eventos concretos.
ToolsSchema, tool choice, llamadas paralelas, argumentos.Cambia cómo el modelo decide usar herramientas.
ContextoVentana, tokenizador, coste de entrada, compaction.El mismo texto no ocupa igual ni cabe igual.
Parámetrostemperature, top_p, seed, max_tokens, razonamiento.El nombre puede existir, pero su efecto no ser idéntico.
Seguridad y datosRegión, retención, logging, entrenamiento con datos.No todas las rutas sirven para todos los tenants.
ErroresCódigos, retry_after, timeouts, cuota.El gateway debe normalizar estados para el router.
PreciosInput, output, caché, tools, razonamiento, lote.El coste esperado necesita la factura completa.
VersionadoAlias flotante o versión fija.Un alias puede cambiar comportamiento sin cambiar tu código.

Por eso el catálogo debería distinguir supports_json_schema=true de usually_returns_json=true. Lo primero es una capacidad. Lo segundo es una esperanza.

Un ejemplo de matriz de compatibilidad:

RutaCapacidades declaradasEntornoFallback permitido
local_fastJSON estricto, streaming, sin tools. Contexto 32768.Local.Sí, a respuesta breve.
rag_medium_jsonJSON estricto, tools, streaming, RAG y citas. Contexto 131072.UE.Sí, a rag_small_brief.
frontier_reviewJSON estricto, tools, streaming y contexto largo.Proveedor.Solo si presupuesto lo permite.
batch_cheapJSON estricto, sin streaming, sin tools. Contexto 32768.Proveedor.No interactivo.

La matriz no es burocracia. Es lo que impide que un fallback rompa el contrato.

Fallback no significa “hacer cualquier cosa”

Fallback es una ruta alternativa con contrato. No es una excusa para devolver algo distinto sin avisar.

Hay varios tipos:

Tipo de fallbackQué cambiaEjemplo
Mismo modelo, otro proveedorCambia endpoint o región.Mismo modelo servido por otro proveedor compatible.
Otro modelo equivalenteCambia modelo manteniendo contrato.Modelo B que soporta JSON y calidad mínima.
Modelo menorReduce coste o latencia.Respuesta breve, menos contexto, menos razonamiento.
Ruta localEvita dependencia externa.Modelo local para resumen simple cuando cloud no conviene.
Ruta batchCambia expectativa temporal.“Lo dejamos procesando y avisamos cuando esté”.
Respuesta parcialDevuelve lo seguro y marca lo pendiente.“Puedo resumir, pero no validar citas ahora”.
Parada explícitaNo ejecuta más.budget_exhausted, contract_not_supported, capacity_unavailable.

La regla de oro: fallback debe ser igual o más conservador que la ruta principal. Si la ruta principal no puede ejecutar una acción con el contrato correcto, el fallback no debería inventar más capacidad. Debe reducir alcance, pedir confirmación, pasar a batch o cerrar con estado comprensible.

Reintentos, backoff y jitter

Un retry solo tiene sentido si el problema puede desaparecer al volver a intentar. Un error de contrato no se arregla repitiendo igual. Una cuota agotada no se arregla enviando más rápido. Una salida inválida quizá se arregla con un retry si cambiamos instrucción, temperatura o modelo, pero hay que presupuestarlo.

Política mínima:

CasoRetryMotivo
Timeout de red puntualSí, con backoff y límite.Puede ser transitorio.
Rate limitSí, si retry_after cabe en presupuesto.Si no cabe, cambiar ruta o parar.
JSON inválidoUna vez, con reparación o ruta más fiable.Repetir igual suele repetir el fallo.
Presupuesto agotadoNo.Ya no hay margen operativo.
Contrato no soportadoNo.La ruta era incorrecta.
Error de permisosNo.No es un problema temporal.

Backoff exponencial simple:

delayi=min(Dmax,D02i)+U(0,J)delay_i = \min(D_{max}, D_0 \cdot 2^i) + U(0, J)
SímboloSignificadoEjemplo
delayidelay_iEspera antes del retry ii.400 ms, 850 ms, 1700 ms.
D0D_0Espera inicial.250 ms.
DmaxD_{max}Espera máxima.3000 ms.
iiNúmero de retry.0, 1, 2.
U(0,J)U(0, J)Ruido aleatorio entre 0 y JJ.Jitter de 0 a 150 ms.

El jitter parece un detalle menor, pero evita que muchos clientes despierten a la vez y vuelvan a presionar la misma dependencia. En sistemas con IA, donde cada intento puede ser caro, el retry debe gastar presupuesto explícito.

Circuit breaker, cooldown y load shedding

Cuando una ruta empieza a fallar o a degradarse, el router no debería seguir probándola con todo el tráfico. Necesita memoria operativa.

MecanismoQué haceSeñal típica
Circuit breakerCierra temporalmente una ruta con demasiados fallos recientes.timeout_ratio_1m > 0.25.
CooldownEspera antes de volver a probar la ruta.30 segundos, 2 minutos, 10 minutos.
ProbeEnvía pocas runs de prueba antes de reabrir.1% del tráfico o tarea sintética.
Load sheddingRechaza o retrasa trabajo para proteger el sistema.Cola superior a umbral o SLO quemándose.
BackpressureIndica al cliente o cola que reduzca entrada.retry_after, cola batch, límite por tenant.

Esto conecta con Little:

L=λWL = \lambda W
SímboloSignificadoEjemplo
LLTrabajo medio dentro del sistema.240 runs esperando o ejecutándose.
λ\lambdaTasa de llegada.40 runs por minuto.
WWTiempo medio en el sistema.6 minutos.

Little no es una receta universal; es una alarma conceptual. Si la llegada sube y la capacidad no sube, el tiempo de espera crece. Un router que acepta todo “porque quizá sale” puede destruir el SLO. Dean y Barroso explicaron en The Tail at Scale que la cola larga de latencia importa porque el usuario vive los peores percentiles, no la media.10 Little formuló la relación clásica entre trabajo en sistema, llegada y espera.11

Árbol de decisión para elegir ruta

El árbol no sustituye al router, pero ayuda a revisar si la política tiene sentido. Léelo de arriba abajo:

flowchart TD
    START["Run entrante"] --> CONTRACT["¿Contrato claro?"]
    CONTRACT -->|"no"| REJECT1["rechazar: contract_missing"]
    CONTRACT -->|"sí"| DATA["¿La ruta debe cumplir región o retención?"]

    DATA -->|"sí"| FILTER_DATA["filtrar por data_policy"]
    DATA -->|"no"| CAP["filtrar por capacidades"]
    FILTER_DATA --> CAP

    CAP --> JSON["¿Necesita JSON estricto o tools?"]
    JSON -->|"sí"| COMPAT["exigir soporte declarado"]
    JSON -->|"no"| BUDGET["calcular presupuesto"]
    COMPAT --> BUDGET

    BUDGET --> TOKENS["¿Cabemos en tokens/contexto?"]
    TOKENS -->|"no"| COMPRESS["comprimir, RAG, dividir o parar"]
    TOKENS -->|"sí"| QUOTA["¿Cabemos en cuota y capacidad?"]

    QUOTA -->|"no"| ALT["buscar ruta compatible o batch"]
    QUOTA -->|"sí"| HEALTH["¿Ruta sana?"]

    HEALTH -->|"no"| BREAKER["cooldown / circuit breaker"]
    BREAKER --> ALT
    HEALTH -->|"sí"| SCORE["ordenar por calidad, coste, latencia y estabilidad"]

    SCORE --> CHOOSE["elegir ruta"]
    CHOOSE --> TRACE["emitir routing.decision"]
    TRACE --> EXEC["ejecutar"]
    EXEC --> VALID["validar salida"]

    VALID -->|"válida"| DONE["cerrar succeeded"]
    VALID -->|"inválida y queda presupuesto"| FB["fallback conservador"]
    VALID -->|"sin presupuesto"| STOP["cerrar con estado explicable"]
    FB --> TRACE
    ALT --> SCORE
    COMPRESS --> BUDGET

Fíjate en dos ramas: primero contrato, después presupuesto. Si el contrato falta, no hay router inteligente; hay adivinanza. Si el presupuesto no cabe, no hay “un intento más”; hay deuda operativa.

Anatomía visual de un router de IA

Router operativo: elegir ruta sin perder contrato ni presupuesto La decisión combina tarea, contrato, coste, latencia, capacidad, salud, datos y evaluación histórica. Run entrante task · tenant · priority response_contract budget · region · data policy Plano de decisión 1. filtrar rutas incompatibles 2. estimar coste, latencia y tokens 3. leer salud: cuota, cola, p95, cooldown 4. ordenar por score y política 5. emitir routing_decision trazable Señales que entran evals quality_score SLO p95 · burn coste EUR/run capacidad quota · queue OpenTelemetry · Prometheus · golden traces Salida de decisión route_id fallback_chain remaining_budget reason_code · alternatives Rutas candidatas: no todas cumplen lo mismo local_fast latencia baja coste controlado contexto menor sin tools complejas fallback: respuesta breve cloud_json JSON estricto mejor formato coste medio cuota variable fallback: reparar schema rag_strong recupera fuentes citas y evidencia prefill largo latencia mayor fallback: batch o parcial batch_cheap throughput alto sin streaming cola separada coste mínimo fallback: esperar o dividir frontier_review calidad alta coste alto razonamiento largo solo si compensa fallback: pedir confirmación Parada budget_exhausted contract_not_supported Presupuesto vivo latency_remaining_ms cost_remaining_eur retry_remaining cada intento consume margen y actualiza la traza Retry controlado timeout por fase, no solo global backoff exponencial + jitter circuit breaker si falla demasiado cooldown antes de volver a probar estado final si no cabe en presupuesto Observabilidad obligatoria routing.reason alternatives fallback_used budget_spent sin estas señales no hay aprendizaje operativo IA para gente curiosa / Facsímil 06 / Capítulo 05 / 686f6c61

El dibujo enseña la idea importante: el router no es una bifurcación simple. Es una frontera operativa. Antes de gastar tokens decide si una ruta cumple contrato, si cabe en presupuesto, si está sana y qué alternativa existe si algo no encaja.

Cómo se ve en producción

Una decisión de routing debería quedar registrada como dato estructurado. Algo así:

{
  "event": "routing.decision",
  "run_id": "run_20260527_0512",
  "task": "support_summary",
  "selected_route": "rag_medium_json",
  "reason": "meets_contract_under_budget",
  "budget": {
    "max_latency_ms": 6000,
    "max_cost_eur": 0.08,
    "max_retries": 1
  },
  "estimated": {
    "latency_p95_ms": 4200,
    "cost_eur": 0.041,
    "input_tokens": 7200,
    "output_tokens": 480
  },
  "alternatives": [
    {
      "route": "local_fast",
      "rejected_because": "does_not_support_required_citations"
    },
    {
      "route": "frontier_review",
      "rejected_because": "cost_above_task_budget"
    }
  ],
  "fallback_chain": ["rag_medium_json", "rag_small_brief", "batch_review"]
}

Esto no está para decorar logs. Sirve para responder preguntas:

PreguntaCampo que la responde
¿Por qué no usamos el modelo local?alternatives.rejected_because.
¿Por qué subió el coste?estimated.cost_eur, ruta seleccionada, tokens y retries.
¿Cuántas veces se activa fallback?fallback_chain, fallback_used, final_state.
¿Qué ruta incumple p95?selected_route + métricas de latencia.
¿Qué contrato expulsa rutas?response_contract y does_not_support_*.

Prometheus recomienda nombres de métricas y labels que permitan entender unidades y agregación sin crear cardinalidad inútil.12 OpenTelemetry mantiene convenciones semánticas para sistemas generativos, incluyendo atributos de modelo, operación y uso de tokens.13 En nuestro router, eso se traduce en no inventar métricas imposibles de mantener: ai_route_decision_total{task,route,reason}, sí; ai_route_decision_total{run_id}, no.

Herramientas y piezas que se suelen usar

Hay tres caminos frecuentes.

CaminoQué usasProsContras
Router propioCódigo interno, YAML, feature flags, métricas y gateway.Control total sobre contratos, coste, datos y trazas.Hay que mantener catálogo, salud, cuotas y fallbacks.
Router de proveedor/agregadorOpenRouter, gateway cloud o proveedor con múltiples endpoints.Reduce integración con muchos proveedores; aporta routing por precio, latencia o proveedor.No sustituye tu política de producto ni tus presupuestos internos.
Router de libreríaLiteLLM Router u otra capa compatible.Acelera balanceo, retries y fallbacks entre despliegues.Debes revisar semántica de parámetros, errores, streaming, tools y observabilidad.

Mi recomendación para ingeniería: aunque uses un router externo, conserva un router lógico propio. Ese router lógico decide tarea, contrato, presupuesto y política. Luego puede delegar la ejecución a OpenRouter, LiteLLM, un proveedor cloud o un runtime local. Si toda la decisión vive fuera de tu sistema, pierdes criterio de producto.

Optimizar el router: qué palancas existen

Optimizar no significa “usar siempre lo más barato”. Tampoco significa “usar siempre lo más rápido”. Optimizar significa mejorar una métrica bajo restricciones: coste por run aceptada, p95 de latencia, TTFT, tasa de contrato válido, tasa de fallback, cuota restante, calidad evaluada o experiencia percibida por el usuario.

La primera regla es elegir el objetivo:

ObjetivoOptimización correctaOptimización peligrosa
Bajar latencia interactivaReducir salida, usar modelo menor suficiente, streaming, caché de prefijo, ruta con p95 estable.Recortar contexto sin medir calidad.
Bajar costeBatch, flex, prompt caching, modelo menor, menos tokens, menos retries, cachear respuestas deterministas.Cambiar a ruta barata que sube fallo de contrato.
Mejorar throughputColas por clase, batch, rutas con más TPS, bajar saturación, repartir por cuota.Aumentar concurrencia hasta disparar p99.
Mejorar estabilidadCircuit breaker, cooldown, margen de cuota, rutas canary, percentiles p90/p99.Mirar solo p50 o media.
Mantener calidadEvals por tarea, golden traces, matriz de compatibilidad, fallback conservador.Cambiar modelo sin dataset de comparación.

OpenAI resume la optimización de latencia en principios bastante prácticos: procesar tokens más rápido, generar menos tokens, usar menos tokens de entrada, hacer menos peticiones, paralelizar, reducir la espera percibida y no usar un LLM cuando una técnica clásica basta.14 Para un router, eso se traduce en política.

PalancaQué cambiaSeñal que debe mejorarRiesgo
Modelo menor suficienteRuta más rápida y barata.TTFT, TPOT, coste.Baja calidad si no hay evals.
Menos salidamax_output_tokens, contrato más compacto, respuesta breve.Latencia de decode y coste de salida.Respuesta incompleta.
Menos entradaRAG más selectivo, limpiar HTML, resumir historial, deduplicar contexto.Coste de entrada y prefill.Perder evidencia necesaria.
Prefijo estableInstrucciones, ejemplos y tools al principio; datos variables al final.Cache hit rate, TTFT, coste de entrada.Cache miss silencioso si cambian timestamps o tool order.
Menos llamadasUnir pasos secuenciales en una respuesta estructurada.Latencia total y coste de red.Prompt demasiado complejo.
ParalelizarEjecutar pasos independientes a la vez.Latencia de pared.Más coste si luego cancelas trabajo.
StreamingPrimer token antes, usuario espera menos.TTFT y experiencia percibida.No reduce coste por sí mismo.
BatchLlevar tareas no urgentes a procesamiento asíncrono.Coste y cuota síncrona disponible.No sirve para interacción inmediata.
Flex o baja prioridadMenor coste a cambio de más espera o disponibilidad variable.Coste por run no urgente.Más timeouts o resource_unavailable.
Response cacheReutilizar respuesta exacta si entrada y contrato son repetibles.Latencia y coste casi cero en hits.Solo sirve si la respuesta puede repetirse.

Ejemplo de fórmula. Podemos expresar una run optimizada así:

TrunTqueue+IuncachedRprefill+ORdecode+Ttools+TnetworkT_{run} \approx T_{queue} + \frac{I_{uncached}}{R_{prefill}} + \frac{O}{R_{decode}} + T_{tools} + T_{network}
SímboloSignificadoEjemplo
TrunT_{run}Latencia total aproximada.4200 ms.
TqueueT_{queue}Espera en cola o proveedor.300 ms.
IuncachedI_{uncached}Tokens de entrada que realmente se procesan.1200 dinámicos + 0 cacheados.
RprefillR_{prefill}Velocidad de procesamiento de entrada.tokens/s en prefill.
OOTokens de salida generados.480 tokens.
RdecodeR_{decode}Velocidad de generación.tokens/s en decode.
TtoolsT_{tools}Tiempo de tools, RAG, validación o reranking.650 ms.
TnetworkT_{network}Red, gateway y serialización.120 ms.

La fórmula explica por qué reducir salida suele notarse más que recortar un poco de entrada en chats normales: el decode produce tokens de uno en uno. Pero en documentos largos, RAG grande o tools repetidas, el prefill, el caché y la recuperación sí pueden dominar.

Para coste con caché:

Cin=IfreshPin+Icache_writePwrite+Icache_readPreadC_{in} = I_{fresh}P_{in} + I_{cache\_write}P_{write} + I_{cache\_read}P_{read}
SímboloSignificadoEjemplo
IfreshI_{fresh}Tokens nuevos no cacheados.pregunta del usuario y chunks dinámicos.
PinP_{in}Precio normal de entrada.precio por 1M tokens.
Icache_writeI_{cache\_write}Tokens que se escriben en caché.system prompt largo o tools.
PwriteP_{write}Precio de escritura de caché.puede ser mayor que entrada normal.
Icache_readI_{cache\_read}Tokens leídos desde caché.prefijo estable reutilizado.
PreadP_{read}Precio de lectura de caché.normalmente menor que entrada normal.

La conclusión: cachear compensa cuando hay prefijos largos y repetidos. No compensa si cada prompt empieza distinto.

Qué indican los proveedores y servicios

Los proveedores no dicen todos lo mismo, pero sus recomendaciones convergen en algo: medir tokens, prefijos, latencia, cuota y modalidad de trabajo.

Proveedor o servicioQué ofrece o recomiendaUso en routing y métrica clave
OpenAILatency optimization, prompt caching automático, Batch API, Flex Processing y modelos pequeños para alto volumen.Interactivo con streaming y caché; batch/flex para evals o enriquecimiento. Mira TTFT, tokens cacheados, output tokens, coste por aceptada y finalización batch.
AnthropicPrompt caching automático o explícito, TTL de 5 minutos o 1 hora, cache diagnostics para misses.Separar prefijo estable, tools e historial. Mira cache_read_input_tokens, cache_creation_input_tokens, TTFT y divergencia de prefijo.
OpenRouterRouting por precio, throughput o latencia; percentiles recientes; max_price; filtros por parámetros; sticky routing y response caching.Elegir proveedor por p90/p99, precio máximo o soporte de JSON/tools. Mira p50/p90/p99, throughput, proveedor elegido, cache hit y fallbacks.
LiteLLMLoad balancing entre despliegues, cooldowns, fallbacks, timeouts, retries, estrategias por peso, rate-limit aware, latencia o coste.Gateway interno para unificar proveedores manteniendo política propia encima. Mira RPM/TPM por despliegue, cooldowns, retries y latencia añadida.
Runtime propiovLLM, SGLang, TGI u otro serving controlado por el equipo.Ruta local para datos sensibles, coste predecible o baja latencia si tienes capacidad. Mira TTFT, TPOT, memoria GPU, cola, KV cache y goodput.

OpenAI documenta que prompt caching funciona automáticamente en modelos recientes, que los hits dependen de prefijos exactos y que colocar contenido estático al principio ayuda a reducir latencia y coste.15 También documenta Batch API para trabajos asíncronos con menor coste, límites separados y ventana de finalización de 24 horas; eso lo convierte en una ruta distinta, no en una optimización invisible.16 Flex processing baja coste a cambio de más lentitud y posible falta temporal de recursos, así que debe vivir en rutas no urgentes y con timeouts acordes.17

Anthropic documenta prompt caching con caché automático o breakpoints explícitos, lectura de caché a menor coste y buenas prácticas como cachear contenido estable al principio del prompt.18 También ofrece cache diagnostics para diagnosticar misses por divergencias de modelo, system prompt, tools o historial.19 Esto es oro para ingeniería: si la métrica de caché cae, no quieres discutir; quieres saber qué byte cambió.

OpenRouter documenta routing por precio, throughput o latencia, preferencias por percentiles y filtros como max_price o soporte de parámetros.20 También documenta prompt caching con sticky routing para mantener caliente el mismo proveedor cuando la caché aporta ahorro.21 En su guía de latencia describe el overhead del gateway, cache warming, checks de saldo y fallback como factores operativos.22

LiteLLM documenta load balancing, cooldowns, fallbacks, timeouts, retries y estrategias como weighted pick, rate-limit aware, latency-based, least-busy y lowest-cost routing; además advierte que estrategias con tracking de uso pueden añadir latencia por Redis.23 Esa advertencia es importante: una optimización puede introducir su propio coste.

Orden práctico para optimizar sin romper calidad

Si yo tuviera que optimizar un router de producción, no empezaría tocando pesos a ciegas. Seguiría este orden:

PasoQué hacesPor qué
1. BaselineMides p50/p95/p99, TTFT, TPOT, tokens, coste, contrato y aceptación por ruta.Sin línea base no sabes si mejoras.
2. Separar clasesDistingues interactivo, batch, largo, RAG, tools y revisión.Cada clase optimiza otra cosa.
3. Reducir salidaAjustas contrato, longitud, schema y max_output_tokens.Suele tocar latencia y coste de forma directa.
4. Estabilizar prefijosMueves instrucciones, ejemplos y tools estables al principio.Mejora cache hit y reduce prefill repetido.
5. Podar entradaLimpias HTML, deduplicas chunks, mejoras RAG y compaction.Evita pagar contexto que no aporta.
6. Elegir modelo suficienteCambias a modelo menor solo si pasa evals.Latencia y coste bajan sin convertirlo en lotería.
7. Usar rutas asíncronasBatch o flex para lo que no exige respuesta inmediata.Libera cuota interactiva y baja coste.
8. Optimizar proveedorOrdenas por p90/p99, throughput, precio máximo y soporte de contrato.Aprovechas señales vivas del mercado.
9. Probar políticaShadow routing, golden traces, canary y rollback.Evita que una mejora media rompa casos críticos.

El criterio final no es “ha bajado la factura”. Es:

ΔCutil<0ΔLp950ΔQϵΔFcontractτ\Delta C_{util} < 0 \quad \land \quad \Delta L_{p95} \le 0 \quad \land \quad \Delta Q \ge -\epsilon \quad \land \quad \Delta F_{contract} \le \tau
SímboloSignificadoEjemplo
ΔCutil\Delta C_{util}Cambio en coste por run aceptada.Queremos que baje.
ΔLp95\Delta L_{p95}Cambio de latencia p95.No debería empeorar en rutas interactivas.
ΔQ\Delta QCambio de calidad evaluada.Permitimos caída mínima ϵ\epsilon si el producto lo acepta.
ΔFcontract\Delta F_{contract}Cambio en fallos de contrato.No puede superar el umbral τ\tau.
ϵ,τ\epsilon, \tauTolerancias explícitas.quality_drop <= 0.01, contract_fail_delta <= 0.002.

Esa fórmula evita una trampa muy común: declarar victoria porque el coste baja mientras la calidad cae o el contrato falla más.

Patrones que ayudan a no romper el sistema

PatrónDecisión concreta
Cola por clase de tareaSeparar interactivo, batch, largo, RAG y revisión.
Presupuesto decrecienteCada fase resta tiempo, coste y retries disponibles.
Fallback hacia menos alcanceSi no cabe la respuesta completa, entregar resumen breve o pasar a batch.
Circuit breaker por rutaSi una ruta degrada, dejar de enviarle casi todo el tráfico.
Canary de rutasProbar una ruta nueva con poco tráfico y comparar contra golden traces.
Shadow routingCalcular qué habría elegido una política nueva sin ejecutar la ruta.
Holdout de evaluaciónNo optimizar el router solo con los mismos casos que usas para ajustarlo.
IdempotenciaSi reintentas, que no dupliques efectos externos.

El último punto importa mucho. Reintentar una lectura es una cosa. Reintentar una acción con efecto en sistemas externos es otra. Si una tool escribe, cobra, publica, borra o modifica estado, el retry debe pasar por idempotency keys, confirmación o estado explícito.

Cómo probar una política de routing

Una política de routing también se testea. No basta con mirar una demo.

PruebaQué compruebaEjemplo
Unit test de restriccionesUna ruta incompatible nunca se elige.Si pide citas, local_fast queda descartada.
Test de presupuestoCoste, tokens y latencia bloquean rutas caras.frontier_review no entra en fast_triage.
Test de saludCooldown y timeout ratio cambian decisión.Si rag_medium_json degrada, pasa a batch o ruta breve.
Golden tracesLa decisión esperada se mantiene en casos clave.support_summary elige rag_medium_json con razón conocida.
Shadow routingPolítica nueva calcula decisión sin ejecutar.Comparas policy@1.7 contra policy@1.8.
CanaryPolítica nueva recibe poco tráfico real.5% de support_summary durante una hora.
Test de coste útilNo mejora solo precio por llamada.Baja coste por token, pero sube fallo de contrato: no se acepta.

Los tests deberían producir un informe. Algo así:

policy: router@1.8.0
cases: 120
changed_decisions: 14
expected_changes: 12
unexpected_changes: 2
cost_per_accepted_delta: -8.4%
contract_failure_delta: +0.3%
latency_p95_delta_ms: -410
decision: canary, not full rollout

Ese informe se parece más a ingeniería de software que a “probemos el prompt nuevo”. Y esa es la idea: el router es código de producción.

Manos a la obra

Práctica: una política de routing ejecutable.

Kit ejecutable de este capítulo: kit descargable.

# Descomprime el ZIP del capítulo y ejecuta estos comandos dentro de esa carpeta
python3 ops/run_f6_practices.py --chapter c05 --write --fail-on-invalid

Vamos a construir un router mínimo, pero útil. No llama a ningún proveedor. Simula rutas con capacidades, costes, latencias, salud y calidad. La gracia está en que toma una decisión explicable.

Primero, crea una política en YAML como ops/ai/routing_policy.yaml. Esta versión está resumida para el capítulo, pero conserva las piezas importantes:

version: router@1.8.0
default_margin:
  quota_alpha: 0.75
  max_timeout_ratio_5m: 0.20

tasks:
  support_summary:
    required: [text, json, rag, citations]
    budget:
      max_latency_ms: 6000
      max_cost_eur: 0.08
      max_input_tokens: 12000
      max_output_tokens: 700
      max_retries: 1
    weights:
      quality: 0.55
      latency: 0.20
      cost: 0.15
      queue: 0.05
      health: 0.05

routes:
  rag_medium_json:
    capabilities: [text, json, rag, citations]
    owner: ai-runtime
    fallback_chain: [rag_small_brief, batch_review]
    stop_state: no_route_under_budget

Después implementa un evaluador pequeño. Guárdalo como ops/ai/routing_policy.py:

from dataclasses import dataclass


@dataclass(frozen=True)
class Budget:
    max_latency_ms: int
    max_cost_eur: float
    max_input_tokens: int
    max_output_tokens: int
    max_retries: int


@dataclass(frozen=True)
class Task:
    name: str
    input_tokens: int
    required: set[str]
    budget: Budget
    weights: dict[str, float]


@dataclass(frozen=True)
class Route:
    route_id: str
    capabilities: set[str]
    latency_p95_ms: int
    cost_per_1k_input: float
    cost_per_1k_output: float
    expected_output_tokens: int
    quality_score: float
    timeout_ratio_5m: float
    queue_depth: int
    cooldown: bool = False


def estimate_cost(task: Task, route: Route) -> float:
    input_cost = (task.input_tokens / 1000) * route.cost_per_1k_input
    output_tokens = min(task.budget.max_output_tokens, route.expected_output_tokens)
    output_cost = (output_tokens / 1000) * route.cost_per_1k_output
    retry_margin = 1 + min(task.budget.max_retries, 1) * route.timeout_ratio_5m
    return (input_cost + output_cost) * retry_margin


def reject_reasons(task: Task, route: Route) -> list[str]:
    reasons = []

    missing = sorted(task.required - route.capabilities)
    if missing:
        reasons.append("missing_capabilities:" + ",".join(missing))

    if task.input_tokens > task.budget.max_input_tokens:
        reasons.append("input_tokens_above_budget")

    if route.expected_output_tokens > task.budget.max_output_tokens:
        reasons.append("output_tokens_above_budget")

    if route.latency_p95_ms > task.budget.max_latency_ms:
        reasons.append("latency_above_budget")

    if estimate_cost(task, route) > task.budget.max_cost_eur:
        reasons.append("cost_above_budget")

    if route.cooldown:
        reasons.append("route_in_cooldown")

    if route.timeout_ratio_5m > 0.20:
        reasons.append("route_unhealthy")

    return reasons


def score(task: Task, route: Route) -> float:
    cost = estimate_cost(task, route)
    latency_norm = route.latency_p95_ms / task.budget.max_latency_ms
    cost_norm = cost / task.budget.max_cost_eur
    queue_norm = min(route.queue_depth / 100, 1.0)
    timeout_norm = min(route.timeout_ratio_5m / 0.20, 1.0)

    return (
        task.weights.get("quality", 0.0) * route.quality_score
        - task.weights.get("latency", 0.0) * latency_norm
        - task.weights.get("cost", 0.0) * cost_norm
        - task.weights.get("queue", 0.0) * queue_norm
        - task.weights.get("health", 0.0) * timeout_norm
    )


def route_task(task: Task, routes: list[Route]) -> dict:
    evaluated = []
    for route in routes:
        reasons = reject_reasons(task, route)
        evaluated.append(
            {
                "route": route.route_id,
                "cost": round(estimate_cost(task, route), 4),
                "latency_p95_ms": route.latency_p95_ms,
                "quality": route.quality_score,
                "score": None if reasons else round(score(task, route), 4),
                "rejected_because": reasons,
            }
        )

    candidates = [item for item in evaluated if not item["rejected_because"]]
    if not candidates:
        return {
            "task": task.name,
            "selected_route": None,
            "final_state": "no_route_under_budget",
            "evaluated": evaluated,
        }

    selected = max(candidates, key=lambda item: item["score"])
    fallbacks = [
        item["route"]
        for item in sorted(candidates, key=lambda item: item["score"], reverse=True)
        if item["route"] != selected["route"]
    ]

    return {
        "task": task.name,
        "selected_route": selected["route"],
        "final_state": "route_selected",
        "reason": "highest_score_after_hard_constraints",
        "estimated_cost": selected["cost"],
        "estimated_latency_p95_ms": selected["latency_p95_ms"],
        "fallback_chain": [selected["route"], *fallbacks],
        "evaluated": evaluated,
    }


routes = [
    Route(
        route_id="local_fast",
        capabilities={"text", "json"},
        latency_p95_ms=1800,
        cost_per_1k_input=0.0002,
        cost_per_1k_output=0.0006,
        expected_output_tokens=260,
        quality_score=0.74,
        timeout_ratio_5m=0.02,
        queue_depth=18,
    ),
    Route(
        route_id="rag_medium_json",
        capabilities={"text", "json", "rag", "citations"},
        latency_p95_ms=4200,
        cost_per_1k_input=0.002,
        cost_per_1k_output=0.006,
        expected_output_tokens=520,
        quality_score=0.88,
        timeout_ratio_5m=0.05,
        queue_depth=31,
    ),
    Route(
        route_id="frontier_review",
        capabilities={"text", "json", "rag", "citations", "long_context"},
        latency_p95_ms=11500,
        cost_per_1k_input=0.006,
        cost_per_1k_output=0.018,
        expected_output_tokens=1200,
        quality_score=0.95,
        timeout_ratio_5m=0.03,
        queue_depth=12,
    ),
    Route(
        route_id="batch_cheap",
        capabilities={"text", "json", "batch"},
        latency_p95_ms=45000,
        cost_per_1k_input=0.0001,
        cost_per_1k_output=0.0003,
        expected_output_tokens=120,
        quality_score=0.68,
        timeout_ratio_5m=0.01,
        queue_depth=80,
    ),
]

tasks = [
    Task(
        name="support_summary",
        input_tokens=7200,
        required={"text", "json", "rag", "citations"},
        budget=Budget(6000, 0.08, 12000, 700, 1),
        weights={"quality": 0.55, "latency": 0.20, "cost": 0.15, "queue": 0.05, "health": 0.05},
    ),
    Task(
        name="fast_triage",
        input_tokens=1800,
        required={"text", "json"},
        budget=Budget(2500, 0.02, 4000, 300, 0),
        weights={"quality": 0.35, "latency": 0.35, "cost": 0.20, "queue": 0.05, "health": 0.05},
    ),
    Task(
        name="legal_rag_review",
        input_tokens=42000,
        required={"text", "json", "rag", "citations", "long_context"},
        budget=Budget(30000, 0.60, 60000, 2200, 1),
        weights={"quality": 0.70, "latency": 0.10, "cost": 0.10, "queue": 0.03, "health": 0.07},
    ),
]

for task in tasks:
    decision = route_task(task, routes)
    print(decision)

expected = {
    "support_summary": "rag_medium_json",
    "fast_triage": "local_fast",
    "legal_rag_review": "frontier_review",
}

for task in tasks:
    decision = route_task(task, routes)
    assert decision["selected_route"] == expected[task.name], decision

degraded_routes = [
    route if route.route_id != "rag_medium_json"
    else Route(**{**route.__dict__, "timeout_ratio_5m": 0.35})
    for route in routes
]

degraded = route_task(tasks[0], degraded_routes)
assert degraded["selected_route"] != "rag_medium_json"
print("policy_tests: ok")

Salida esperada:

{'task': 'support_summary', 'selected_route': 'rag_medium_json', 'final_state': 'route_selected', ...}
{'task': 'fast_triage', 'selected_route': 'local_fast', 'final_state': 'route_selected', ...}
{'task': 'legal_rag_review', 'selected_route': 'frontier_review', 'final_state': 'route_selected', ...}
policy_tests: ok

Lo importante no es el número exacto de score; lo importante es el recibo de decisión. Cada ruta descartada trae motivo. Cada ruta elegida trae presupuesto estimado. Si mañana una ruta entra en cooldown, el mismo script permite ver cómo cambia la decisión.

Cómo encaja todo

Primero, el ciclo operativo de una run con routing:

sequenceDiagram
    autonumber
    participant API as API boundary
    participant R as Router
    participant C as Catalogo rutas
    participant O as Observabilidad
    participant P as Proveedor/runtime
    participant V as Validador

    API->>R: task + contrato + presupuesto
    R->>C: consultar capacidades, coste y politica
    R->>O: leer salud, p95, cuota y fallos recientes
    R-->>API: routing_decision + fallback_chain
    API->>P: ejecutar ruta seleccionada
    P-->>API: salida, usage y estado
    API->>V: validar contrato
    V-->>API: valid / invalid
    API->>O: trace route, coste, latencia y resultado
    alt ruta no valida y queda presupuesto
        API->>R: pedir fallback con presupuesto restante
        R-->>API: siguiente ruta conservadora
    else no queda presupuesto
        API->>O: final_state budget_exhausted
    end

Y el mapa conceptual del capítulo:

flowchart TD
    subgraph F6["Facsímil 6: construir y operar"]
      C1["Cap. 01<br/>sistema operable"]
      C2["Cap. 02<br/>runtime y contratos"]
      C3["Cap. 03<br/>serving y capacidad"]
      C4["Cap. 04<br/>observabilidad"]
      C5["Cap. 05<br/>routing y presupuestos"]
      C6["Cap. 06<br/>EvalOps y gates"]
      C9["Cap. 09<br/>SLO e incidentes"]
    end

    subgraph Capitulo["Dentro de este capítulo"]
      TASK["tipo de tarea"]
      CONTRACT["contrato de salida"]
      CATALOG["catálogo de rutas"]
      BUDGET["presupuesto"]
      QUOTA["cuotas y límites"]
      COMPAT["compatibilidad"]
      ROUTER["router operativo"]
      HEALTH["salud de rutas"]
      SCORE["score y filtros"]
      FALLBACK["fallback conservador"]
      RETRY["retry, backoff y jitter"]
      OPT["optimización"]
      CACHE["prompt caching"]
      ASYNC["batch y flex"]
      TRACE["recibo de decisión"]
      TESTS["tests de política"]
    end

    subgraph Previos["Conceptos anteriores y posteriores"]
      F4C06["F4 C06<br/>cloud vs local"]
      F5C09["F5 C09<br/>orquestación"]
      F5C10["F5 C10<br/>evaluar agentes"]
      F7["F7<br/>evaluación"]
    end

    C2 -->|"define"| CONTRACT
    C3 -->|"limita"| HEALTH
    C4 -->|"mide"| HEALTH
    TASK -->|"seleccionar"| ROUTER
    CATALOG -->|"describir"| ROUTER
    CONTRACT -->|"filtrar"| ROUTER
    COMPAT -->|"validar"| CONTRACT
    BUDGET -->|"acotar"| ROUTER
    QUOTA -->|"limitar"| HEALTH
    HEALTH -->|"penalizar"| SCORE
    SCORE -->|"elegir"| ROUTER
    ROUTER -->|"emitir"| TRACE
    ROUTER -->|"preparar"| FALLBACK
    FALLBACK -->|"usar si queda"| BUDGET
    RETRY -->|"consumir"| BUDGET
    CACHE -->|"reducir prefill"| OPT
    ASYNC -->|"mover trabajo no urgente"| OPT
    OPT -->|"ajustar"| ROUTER
    TESTS -->|"validar"| ROUTER
    TRACE -->|"alimentar"| C6
    TRACE -->|"explicar"| C9

    F4C06 -->|"aporta rutas"| ROUTER
    F5C09 -->|"aporta orquestación"| ROUTER
    F5C10 -->|"aporta evaluaciones"| SCORE
    F7 -->|"profundiza calidad"| SCORE

    classDef external fill:#FFFFFF,stroke:#111111,stroke-width:1.3,stroke-dasharray:5 5,color:#111111
    class F4C06,F5C09,F5C10,F7 external

Routing es donde se juntan tres mundos: producto, infraestructura y evaluación. Producto define qué necesita la tarea. Infraestructura dice qué capacidad existe. Evaluación dice qué ruta funciona de verdad.

Vocabulario aprendido

TérminoDefinición
RouterComponente que selecciona una ruta de ejecución.
RutaCombinación de modelo, proveedor, runtime, contrato, cola y política.
Catálogo de rutasRegistro versionado de rutas, capacidades, costes, límites, owner y fallback.
FallbackRuta alternativa usada cuando la principal no cumple o no conviene.
Presupuesto de tareaLímite de coste, latencia, tokens, retries y acciones para una tarea concreta.
CuotaCapacidad asignada por proveedor o runtime, medida en tokens, peticiones o concurrencia.
Rate limitLímite operativo de uso por unidad de tiempo.
Matriz de compatibilidadTabla que compara qué rutas soportan contrato, tools, streaming, región y parámetros.
RetryNuevo intento controlado de una operación.
BackoffEspera creciente entre retries.
JitterAleatoriedad añadida al backoff para evitar reintentos sincronizados.
Circuit breakerMecanismo que deja de enviar tráfico a una ruta temporalmente degradada.
CooldownPeriodo de espera antes de volver a probar una ruta.
Load sheddingRechazar o retrasar trabajo para proteger el servicio.
BackpressureSeñal para que clientes o colas reduzcan entrada.
Coste útilCoste por run aceptada, no solo coste por llamada.
Prompt cachingReutilización de un prefijo estable del prompt para reducir latencia y coste de entrada.
Cache hitPetición que encuentra en caché el prefijo esperado.
BatchProcesamiento asíncrono de muchas peticiones que no necesitan respuesta inmediata.
Flex processingRuta de menor coste y menor prioridad, aceptando más espera o disponibilidad variable.
Sticky routingMantener una conversación o prefijo en el mismo proveedor para aumentar hits de caché.
Shadow routingEvaluar qué ruta elegiría una política nueva sin ejecutarla.
CanaryDesplegar una ruta o política a una fracción pequeña del tráfico.
Golden traceTraza representativa que sirve como referencia para comparar decisiones de routing.

Dónde solía tropezar yo

TropiezoPor qué es un problemaAntídoto
Fallback como improvisaciónCambia comportamiento sin contrato y confunde al usuario.Definir cadena de fallback por tarea y estado final si no cabe.
Reintentar todoAumenta coste y presión justo cuando una ruta está mal.Retry solo para errores recuperables, con backoff, jitter y presupuesto.
Optimizar precio por llamadaPuede subir el coste útil si fallan más respuestas.Medir coste por run aceptada y fallo de contrato por ruta.
No registrar alternativas descartadasLuego nadie sabe por qué el router eligió una ruta.Emitir routing.decision con reason y alternatives.
Tratar todas las tareas igualUna tarea batch y una interactiva no tienen el mismo SLO.Presupuesto por tarea y colas separadas.
Delegar toda la política en un agregadorPierdes criterio de producto y trazabilidad interna.Mantener router lógico propio aunque uses herramientas externas.
No probar la políticaCambia producción sin saber qué casos se mueven.Unit tests, golden traces, shadow routing y canary.
Optimizar una métrica aisladaBaja coste o latencia, pero sube fallo de contrato o baja calidad.Optimizar con coste útil, p95, calidad y contrato a la vez.
Romper el prefijo cacheableUn timestamp o cambio de orden en tools puede destruir el cache hit.Mantener prefijos estables y medir cache_read_input_tokens.

Antes de pasar página

  • ¿Puedes explicar por qué routing no es solo fallback?
  • ¿Puedes definir un presupuesto de tarea con latencia, coste, tokens y retries?
  • ¿Puedes decidir cuándo un retry tiene sentido y cuándo no?
  • ¿Puedes explicar backoff y jitter sin fórmulas opacas?
  • ¿Puedes distinguir coste por llamada de coste por run aceptada?
  • ¿Puedes diseñar una cadena de fallback conservadora para una tarea concreta?
  • ¿Puedes decir qué campos debería tener un evento routing.decision?
  • ¿Puedes explicar por qué run_id no debería ser label de una métrica de routing?
  • ¿Puedes conectar router con observabilidad, SLO y EvalOps?
  • ¿Puedes justificar cuándo usar router propio, OpenRouter, LiteLLM o una mezcla?
  • ¿Puedes diseñar un catálogo de rutas con capacidades, límites, owner y fallback?
  • ¿Puedes calcular si una tarea cabe en una cuota TPM/RPM con margen?
  • ¿Puedes explicar por qué una API compatible no garantiza comportamiento compatible?
  • ¿Puedes proponer tests para una política nueva antes de desplegarla?
  • ¿Puedes ordenar palancas de optimización sin tocar primero el modelo?
  • ¿Puedes explicar cuándo usar prompt caching, batch, flex, streaming o response cache?
  • ¿Puedes definir una condición de éxito que incluya coste, p95, calidad y contrato?

Para saber más

En resumen

IdeaQué debes llevarte
Routing es una decisión operativa.Combina tarea, contrato, presupuesto, salud, evaluación y trazabilidad.
El catálogo de rutas es el suelo del router.Sin capacidades, owner, límites, región y fallback versionados, la decisión se vuelve opaca.
Fallback debe conservar contrato.Una ruta alternativa no puede inventar permisos, formato o alcance.
El presupuesto vive durante la run.Cada retry, tool, token y espera consume margen.
Las cuotas externas también son arquitectura.RPM, TPM, concurrencia y región deben entrar antes de ejecutar.
Optimizar requiere elegir métrica objetivo.Prompt caching, batch, flex, streaming, menor salida o modelo suficiente sirven para problemas distintos.
Reintentar mal empeora el sistema.Timeout, backoff, jitter y circuit breaker protegen a usuarios y dependencias.
El coste útil manda más que el precio por token.Una ruta barata puede salir cara si falla contrato o requiere revisión.
La decisión debe quedar explicada.routing.decision permite aprender, depurar y mejorar la política.
Una política de routing se prueba como software.Unit tests, shadow routing, canary y golden traces evitan cambios a ciegas.

Notas

  1. Beyer, B., Jones, C., Petoff, J. y Murphy, N. R. (eds.). (2016). Handling Overload. En Site Reliability Engineering. https://sre.google/sre-book/handling-overload/. Consultado el 27 de mayo de 2026.

  2. Amazon Web Services. (2026). Timeouts, retries, and backoff with jitter. AWS Builders Library. https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/. Consultado el 27 de mayo de 2026.

  3. OpenRouter. (2026). Provider routing. https://openrouter.ai/docs/guides/routing/provider-selection. Consultado el 27 de mayo de 2026.

  4. LiteLLM. (2026). Router - Load Balancing. https://docs.litellm.ai/docs/routing. Consultado el 27 de mayo de 2026.

  5. OpenAI. (2026). Rate limits. https://developers.openai.com/api/docs/guides/rate-limits. Consultado el 27 de mayo de 2026.

  6. Anthropic. (2026). Rate limits. https://platform.claude.com/docs/en/api/rate-limits. Consultado el 27 de mayo de 2026.

  7. OpenAI, 2026, Rate limits.

  8. Anthropic, 2026, Rate limits.

  9. OpenRouter, 2026, Provider routing.

  10. Dean, J. y Barroso, L. A. (2013). The Tail at Scale. Communications of the ACM, 56(2), 74-80. https://doi.org/10.1145/2408776.2408794. Consultado el 27 de mayo de 2026.

  11. Little, J. D. C. (1961). A Proof for the Queuing Formula: L = λW. Operations Research, 9(3), 383-387. https://doi.org/10.1287/opre.9.3.383.

  12. Prometheus. (2026). Metric and label naming. https://prometheus.io/docs/practices/naming/. Consultado el 27 de mayo de 2026.

  13. OpenTelemetry. (2026). Semantic Conventions for Generative AI Systems. https://opentelemetry.io/docs/specs/semconv/gen-ai/. Consultado el 27 de mayo de 2026.

  14. OpenAI. (2026). Latency optimization. https://developers.openai.com/api/docs/guides/latency-optimization. Consultado el 28 de mayo de 2026.

  15. OpenAI. (2026). Prompt caching. https://developers.openai.com/api/docs/guides/prompt-caching. Consultado el 28 de mayo de 2026.

  16. OpenAI. (2026). Batch API. https://developers.openai.com/api/docs/guides/batch. Consultado el 28 de mayo de 2026.

  17. OpenAI. (2026). Flex processing. https://developers.openai.com/api/docs/guides/flex-processing. Consultado el 28 de mayo de 2026.

  18. Anthropic. (2026). Prompt caching. https://platform.claude.com/docs/en/build-with-claude/prompt-caching. Consultado el 28 de mayo de 2026.

  19. Anthropic. (2026). Cache diagnostics. https://platform.claude.com/docs/en/build-with-claude/cache-diagnostics. Consultado el 28 de mayo de 2026.

  20. OpenRouter, 2026, Provider routing.

  21. OpenRouter. (2026). Prompt Caching. https://openrouter.ai/docs/features/prompt-caching. Consultado el 28 de mayo de 2026.

  22. OpenRouter. (2026). Latency and Performance. https://openrouter.ai/docs/guides/best-practices/latency-and-performance. Consultado el 28 de mayo de 2026.

  23. LiteLLM, 2026, Router - Load Balancing.

Capítulo 06

Facsímil 6 · Construir y operar

Capítulo 06: EvalOps y gates de release

Qué deberías poder hacer al terminar

En el capítulo 04 aprendimos a observar una run. En el capítulo 05 construimos un router que decide ruta, presupuesto y fallback. Ahora falta una pieza incómoda: cómo impedimos que un cambio llegue a producción solo porque en una demo parecía funcionar.

Un sistema de IA cambia por muchos sitios: prompt, modelo, proveedor, parámetros, RAG, router, tool, contrato de salida, dataset, evaluador, runtime, política de coste o límite de latencia. Cada cambio puede mejorar una métrica y romper otra. EvalOps es la forma disciplinada de convertir esa incertidumbre en un proceso de ingeniería.

Al terminar, deberías poder hacer esto:

Resultado de aprendizajeEvidencia de que lo sabes hacer
Diseñar un pipeline EvalOps.Separas dataset, runner, evaluadores, scorecard, gate, canary y feedback.
Elegir qué evaluar según el cambio.No evalúas igual un prompt, un router, un RAG, una tool o un runtime.
Versionar evidencia.Guardas dataset_id, prompt_version, model_id, route_catalog_version, eval_policy_version y trace_schema_version.
Comparar baseline contra candidate.Miras calidad, coste, latencia, contrato, trazas y regresiones.
Definir gates discutibles.Los umbrales están escritos, tienen dueño, tolerancia y motivo.
Evitar autoengaño estadístico.Repites casos inestables, separas holdout y usas intervalos cuando la muestra es pequeña.
Conectar evals con producción.Las trazas que fallan vuelven al dataset y alimentan nuevas pruebas.

La idea central: una release de IA no debería pasar por intuición; debería pasar por evidencia versionada.

El cambio pequeño que no era pequeño

Imagina una pull request que dice: “mejoro el prompt para que el asistente sea más claro”. Parece inocente. Se revisa el texto, se prueba con tres preguntas y todo parece más amable.

Pero al desplegarlo aparecen efectos laterales: algunas respuestas son más largas, sube el coste medio, una salida JSON empieza a traer un campo extra, la latencia p95 empeora porque el prompt metió más contexto, y el router deriva más tareas al modelo caro porque el clasificador entiende peor la intención.

La conclusión no es “no cambies prompts”. La conclusión es más profesional: un cambio de IA tiene superficie operativa. Si no lo medimos antes, lo descubrimos después con usuarios, coste y soporte.

EvalOps pone una pregunta delante de cada cambio:

¿Qué evidencia mínima necesito para decir que esta versión puede avanzar?

Qué no es EvalOps

EvalOps no es abrir un notebook, mirar diez ejemplos y escribir “parece mejor”. Eso puede servir para exploración, pero no para release.

Tampoco es delegar toda la decisión en un modelo evaluador. Un evaluador puede ayudar, igual que una métrica puede ayudar, pero los criterios deben estar escritos. Si la rúbrica cambia sin versión, si el evaluador cambia sin control o si nadie revisa casos frontera, el número resultante solo da una sensación cómoda.

Y no es tener una tabla enorme de benchmarks públicos para decorar una presentación. Un benchmark externo puede orientar, pero tu producto vive con tus usuarios, tus documentos, tus contratos, tus tools, tus costes y tus límites de latencia. La evaluación útil combina referencias externas con datos propios.

ConfusiónQué falta
“Lo he probado y va bien”Dataset, repetición, comparación y trazas.
“El evaluador dice 8,7”Rúbrica, calibración, ejemplos de referencia y auditoría de errores.
“El benchmark sube”Casos propios, contrato de salida, coste y latencia real.
“Solo cambié el prompt”Ver si cambian tokens, formato, abstención, tools, ruta y estilo.
“Si falla, hacemos rollback”Saber qué métrica lo detecta, cuánto tarda y qué versión restaurar.

Qué sí es EvalOps

EvalOps es el ciclo que convierte una versión candidata en una decisión operativa.

Ejemplo de fórmula. Podemos representarlo como:

E=(D,R,J,M,G,S)E = (D, R, J, M, G, S)
SímboloSignificadoEjemplo
DDDatasets de evaluación.Golden, regresión, holdout, shadow y muestra de producción.
RRRunner reproducible.Ejecuta baseline y candidate con las mismas entradas.
JJEvaluadores.Código, reglas, LLM-as-judge, revisión humana, métricas RAG o métricas de tool.
MMMétricas.Calidad, groundedness, JSON válido, coste, p95, tokens, trazas completas.
GGGates.Condiciones para pasar a staging, canary o producción.
SSScorecard.Documento o artefacto firmado con versiones, resultados y decisión.

Lo importante: EvalOps no vive al final. Empieza cuando se define el cambio. Si no sabemos qué dataset protege una conducta, qué métrica mide el éxito o qué gate puede bloquear, estamos cambiando software sin contrato de calidad.

Fecha de corte del estado del arte

Fecha de corte: 28 de mayo de 2026.
Fuentes consultadas: documentación oficial de OpenAI sobre evals, graders, agent evals y trace grading; documentación de Google ADK sobre evaluación de agentes; LangSmith Evaluation; Arize Phoenix Evaluation; Ragas metrics; Braintrust Evaluation; Promptfoo; OpenTelemetry; Google SRE Workbook; y trabajos clásicos de ingeniería de ML como TFX y Software Engineering for Machine Learning.

Lo estable es el método: datasets versionados, experimentos comparables, evaluadores con criterios explícitos, trazas, gates, CI/CD, monitorización online y feedback desde producción. Lo cambiante son productos, APIs, paneles, nombres de métricas, modelos evaluadores, precios, límites de proveedor y benchmarks de moda.

OpenAI documenta la creación y ejecución de evals mediante API y dashboard, con graders y datasets para probar criterios concretos.1 Google ADK insiste en evaluar tanto respuesta final como trayectoria de un agente, porque los modelos introducen variabilidad y las aserciones deterministas no siempre bastan.2 LangSmith separa evaluación offline, antes de publicar, y online, sobre interacciones reales muestreadas.3

Phoenix organiza evals como señales de calidad que pueden adjuntarse a trazas y datasets, con evaluadores por código, modelos y revisión humana.4 Braintrust presenta un ciclo de evaluación que pasa por playgrounds, experimentos, CI/CD, scoring en producción y realimentación hacia datasets.5 Promptfoo, por su parte, ofrece un enfoque declarativo y de CI para comparar prompts, modelos y pipelines RAG.6

Qué evaluar según lo que cambias

Un error habitual es tener una única evaluación para todo. No funciona. Cada cambio toca una parte distinta del sistema.

CambioQué puede romperEvaluación mínima
PromptFormato, tono, longitud, abstención, uso de tools y coste.Golden set, contrato de salida, tokens, casos de regresión y revisión de trazas.
ModeloCalidad, latencia, coste, soporte de parámetros, contexto y estabilidad.Comparación baseline/candidate, p95, coste por tarea aceptada, repetición de casos dudosos.
ParámetrosVariabilidad, creatividad, longitud y cumplimiento de schema.Repeticiones por caso, flake rate, schema pass rate y distribución de longitud.
RAGRelevancia de chunks, groundedness, citas, cobertura y ruido.Context precision, context recall, faithfulness, citas válidas y evaluación por documento.
RouterRutas elegidas, coste, latencia, capacidad y degradación.Shadow routing, matriz de tareas, presupuesto, p95 por ruta y diff de decisiones.
ToolArgumentos, permisos, errores tipados, idempotencia y compensación.Contract tests, trazas de tool, validación de argumentos y casos de error controlado.
Contrato JSONCampos obligatorios, tipos, enums, extras y compatibilidad.Validación determinista, fixtures, migración de schema y ejemplos negativos.
RuntimeTimeouts, batching, retries, colas, memoria y throughput.Carga sintética, p95/p99, tasa de timeouts, goodput y comparación de trazas.
EvaluadorCambia la regla con la que medimos.Eval del evaluador, acuerdo humano, ejemplos calibrados y versionado de rúbrica.

La regla sencilla: si no sabes qué eval corresponde a un cambio, todavía no entiendes bien su superficie de riesgo técnico.

CI/CD real: dónde vive EvalOps

Para ingeniería informática, EvalOps no puede quedarse en una carpeta de experimentos. Tiene que entrar en el flujo normal de cambio: pull request, revisión, integración continua, release candidate, canary y producción.

GitHub Actions define workflows como procesos automatizados configurados en YAML y compuestos por jobs.7 GitLab CI/CD también usa un archivo YAML para declarar jobs, etapas y reglas de ejecución.8 La idea no depende de la plataforma: el gate de IA debe producir un estado automático, artefactos revisables y una decisión clara.

MomentoJobQué ejecutaQué artefacto deja
Pull requestevalops-smokeSchema, smoke set, contratos de tool y regresiones críticas.smoke_report.json, logs y diff de métricas.
Pull request con cambio de IAevalops-prGolden reducido, comparación baseline/candidate, coste estimado.Comentario en PR y scorecard parcial.
Nightlyevalops-nightlyGolden completo, repeticiones, tags lentos y métricas por segmento.Serie histórica, dataset failures y traces de muestra.
Release candidateevalops-releaseRegression completo, holdout autorizado, scorecard firmada.release_scorecard.json y decisión.
Canaryevalops-onlineSLIs online, sampling, burn rate, coste p95 y feedback.Dashboard, alerta y casos nuevos para dataset.

Un ejemplo mínimo en GitHub Actions:

name: evalops

on:
  pull_request:
    paths:
      - "prompts/**"
      - "ops/ai/**"
      - "rag/**"
      - "tools/**"
  schedule:
    - cron: "17 2 * * *"
  workflow_dispatch:

jobs:
  smoke:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - name: Install
        run: pip install -r requirements-dev.txt
      - name: Run smoke evals
        run: python ops/ai/evalops_release_gate.py --dataset evals/smoke.jsonl
      - name: Upload scorecard
        uses: actions/upload-artifact@v4
        with:
          name: evalops-scorecard
          path: output/evalops_scorecard.json

  release-gate:
    if: github.event_name == 'workflow_dispatch'
    needs: smoke
    runs-on: ubuntu-latest
    timeout-minutes: 60
    steps:
      - uses: actions/checkout@v4
      - name: Run release gate
        run: python ops/ai/evalops_release_gate.py --dataset evals/release.jsonl --strict

Qué debe importarte del YAML:

PiezaPor qué importa
pathsEvita gastar evaluaciones cuando el cambio no toca IA.
timeout-minutesUn gate que se queda colgado también rompe el flujo de ingeniería.
upload-artifactLa decisión debe dejar evidencia descargable.
workflow_dispatchPermite ejecutar una release candidate manual y trazable.
needsObliga a ordenar smoke antes de release gate.

El patrón equivalente en GitLab cambia la sintaxis, pero no la arquitectura:

stages:
  - test
  - eval
  - release

evalops_smoke:
  stage: eval
  image: python:3.12
  rules:
    - changes:
        - prompts/**/*
        - ops/ai/**/*
        - rag/**/*
        - tools/**/*
  script:
    - pip install -r requirements-dev.txt
    - python ops/ai/evalops_release_gate.py --dataset evals/smoke.jsonl
  artifacts:
    when: always
    paths:
      - output/evalops_scorecard.json

La pregunta de examen no debería ser “¿sabes escribir YAML?”. Debería ser: ¿qué job bloquea qué decisión y qué evidencia deja para revisarla?

Contrato JSONL de un caso de evaluación

Un dataset de EvalOps necesita un formato aburrido, versionable y fácil de revisar en Git. JSONL encaja bien porque cada línea es un caso independiente.

Ejemplo formateado para leer. En el archivo eval_cases.jsonl, cada objeto iría en una sola línea:

{
  "case_id": "support-json-001",
  "task": "support_triage",
  "input": {
    "message": "No puedo acceder a mi matrícula y ya pagué."
  },
  "expected": {
    "categoria": "matricula",
    "prioridad": "alta",
    "must_include": ["comprobar pago", "revisar expediente"]
  },
  "tags": ["json", "soporte", "alta_criticidad"],
  "rubric": {
    "quality_min": 0.85,
    "contract_required": true,
    "citation_required": false
  },
  "why_it_exists": "Protege clasificación urgente con salida estructurada.",
  "source": "manual",
  "source_trace_id": null,
  "owner": "ai-platform",
  "sensitivity": "low",
  "created_at": "2026-05-28"
}
{
  "case_id": "rag-policy-014",
  "task": "policy_qa",
  "input": {
    "question": "¿Puedo entregar fuera de plazo si tengo justificante?"
  },
  "expected": {
    "source_ids": ["policy-academic-2026#late-delivery"],
    "answer_must_be_grounded": true
  },
  "tags": ["rag", "policy", "citas"],
  "rubric": {
    "faithfulness_min": 0.90,
    "context_recall_min": 0.80,
    "abstain_if_missing": true
  },
  "why_it_exists": "Evita responder normativa sin fuente recuperada.",
  "source": "production_sample",
  "source_trace_id": "trace_20260527_7781",
  "owner": "academic-platform",
  "sensitivity": "medium",
  "created_at": "2026-05-28"
}

Campos mínimos:

CampoTipoRegla
case_idstringEstable, único y no reutilizable.
taskstringDebe mapear a una política de routing y gate.
inputobjetoEntrada reproducible, sin datos innecesarios.
expectedobjetoPuede ser salida exacta, propiedades, fuentes o condiciones.
tagsarrayPermite análisis por contrato, idioma, ruta, dificultad o criticidad.
rubricobjetoUmbrales por caso cuando no basta una regla global.
why_it_existsstringRazón pedagógica u operativa del caso.
sourcestringmanual, production_sample, incident, benchmark, synthetic.
source_trace_idstring o nullConecta producción con evaluación sin copiar toda la traza.
ownerstringEquipo responsable de cambiar o retirar el caso.
sensitivitystringlow, medium, high; decide retención y permisos.

Un buen dataset no solo pregunta “qué debe contestar”. También pregunta “qué propiedad de ingeniería protege este caso”.

Los datasets no son una carpeta de ejemplos

Un dataset de evaluación es un contrato de aprendizaje. Cada caso debería explicar por qué existe.

CampoQué guardaPor qué importa
case_idIdentificador estable.Permite seguir el caso durante meses.
inputEntrada que se ejecuta.Debe ser realista y reproducible.
expectedSalida, propiedades o fuente esperada.No siempre es una frase exacta; puede ser una rúbrica.
tagsTema, dificultad, ruta, cliente, idioma, contrato.Permite ver dónde mejora o empeora.
why_it_existsMotivo del caso.Evita acumular ejemplos sin intención.
sourceManual, producción, incidencia, laboratorio, benchmark.Da contexto y trazabilidad.
sensitivityQué puede guardarse y quién puede verlo.Protege datos y limita muestras.
ownerPersona o equipo responsable.Alguien debe decidir si el caso cambia.
created_from_trace_idTraza de origen, si viene de producción.Cierra el bucle entre operación y evaluación.

No todos los datasets cumplen el mismo papel:

DatasetTamaño típicoUsoCuidado
Smoke5-20 casosDetectar roturas obvias en segundos.No sirve para afirmar calidad.
Golden50-300 casosProteger comportamientos esenciales.Si lo ajustas demasiado, deja de generalizar.
RegressionCrece con el productoEvitar que vuelvan fallos corregidos.Debe etiquetar motivo y versión que lo arregló.
HoldoutReservadoMedir sin haber optimizado contra esos casos.No debe mirarse a cada iteración pequeña.
ShadowMuestra realVer qué pasaría con una candidata.Necesita anonimización, muestreo y coste controlado.
CanaryTráfico limitadoConfirmar señales reales antes de extender.Requiere rollback y SLOs claros.

Ragas lista métricas específicas para RAG como context precision, context recall, response relevancy, faithfulness, tool call accuracy y métricas tradicionales como exact match o ROUGE.9 Eso no significa que haya que usarlas todas. Significa que cada dataset debe decir qué propiedad mide.

Evaluadores como software

Un evaluador no es una autoridad mística. Es software. Y si es software, se versiona, se prueba, se observa y se puede equivocar.

Tipos de evaluadores:

TipoCuándo usarloEjemploRiesgo
DeterministaEl contrato es objetivo.JSON válido, enum permitido, campo obligatorio, regex, SQL ejecuta.No mide calidad semántica.
HeurísticoHay reglas baratas aproximadas.Longitud máxima, presencia de cita, número de tools, coste.Puede incentivar trucos superficiales.
Métrica clásicaHay referencia textual o ranking.Exact match, F1, ROUGE, Hit@k, MRR.No siempre captura utilidad real.
LLM-as-judgeLa calidad exige criterio lingüístico.Correctitud, groundedness, tono, completitud.Depende de rúbrica, modelo, temperatura y calibración.
Revisión humanaLa decisión tiene impacto alto o ambigüedad real.Casos límite, cambios de rúbrica, muestras de canary.Coste y variabilidad entre personas.

Qué debe tener un evaluador serio:

PropiedadQué significa
evaluator_versionSi cambia la rúbrica, cambia la versión.
input_schemaQué campos necesita para puntuar.
output_schemaQué devuelve: score, label, explanation, confidence, evidence.
calibration_setCasos donde ya sabemos qué debería decidir.
known_failuresCasos donde el evaluador suele confundirse.
cost_budgetCoste máximo por lote de evaluación.
ownerPersona o equipo que responde por la regla.

Un patrón útil es probar al evaluador antes de usarlo para aprobar releases:

CALIBRATION = [
    {
        "case_id": "judge-cal-001",
        "input": "Respuesta con cita correcta y fuente recuperada.",
        "expected_label": "pass",
    },
    {
        "case_id": "judge-cal-002",
        "input": "Respuesta segura pero sin fuente recuperada.",
        "expected_label": "fail",
    },
]

def test_evaluator_calibration(evaluator):
    labels = [evaluator(item["input"]).label for item in CALIBRATION]
    expected = [item["expected_label"] for item in CALIBRATION]
    assert labels == expected

La pregunta de ingeniería: si el evaluador cambia de opinión, ¿es porque la candidata mejoró, porque el evaluador cambió o porque el caso era ambiguo? Si no puedes separar esas tres cosas, tu gate tiene ruido.

La comparación baseline contra candidate

Una release seria no pregunta “¿la candidata es buena?”. Pregunta:

¿La candidata es suficientemente buena, no empeora lo importante y mejora lo que prometía mejorar?

Ejemplo de fórmula. Podemos escribir un gate de forma compacta:

G(vc)=1[QcQminQcQbδqL95,cL95,maxCcCmaxKcKmin]G(v_c)= \mathbb{1}[ Q_c \ge Q_{min} \land Q_c \ge Q_b - \delta_q \land L_{95,c} \le L_{95,max} \land C_c \le C_{max} \land K_c \ge K_{min} ]
SímboloSignificadoEjemplo
G(vc)G(v_c)Gate aplicado a la versión candidata.1 si pasa, 0 si no pasa.
QcQ_cCalidad de la candidata.0,91 de media ponderada.
QbQ_bCalidad del baseline.0,89 de la versión estable.
QminQ_{min}Calidad mínima absoluta.Nunca publicar por debajo de 0,85.
δq\delta_qTolerancia de caída aceptable.Permitir hasta 0,01 si gana mucho en coste.
L95,cL_{95,c}Latencia p95 de la candidata.3200 ms.
L95,maxL_{95,max}Límite p95 permitido.4500 ms.
CcC_cCoste medio o p95 por tarea aceptada.0,021 EUR.
CmaxC_{max}Presupuesto máximo.0,030 EUR.
KcK_cCumplimiento de contrato.99,4% JSON válido.
KminK_{min}Cumplimiento mínimo.99,0%.

La fórmula no reemplaza criterio. Lo fuerza a aparecer. Si decides aceptar una caída de calidad a cambio de menor coste, escríbelo. Si un contrato JSON no puede caer nunca por debajo de 99,5%, escríbelo. Si el gate cambia, versiona el cambio.

Estadística mínima para no engañarnos

En IA hay ruido. Una misma configuración puede producir respuestas distintas. Un evaluador puede variar. Un proveedor puede tener latencia distinta según hora. Una muestra pequeña puede parecer una mejora enorme por azar.

El primer indicador de inestabilidad es el flake rate:

F=NinestableNrepetidoF = \frac{N_{inestable}}{N_{repetido}}
SímboloSignificado
FFProporción de casos que cambian de resultado al repetir.
NinestableN_{inestable}Casos cuyo veredicto no fue consistente.
NrepetidoN_{repetido}Casos repetidos bajo la misma configuración.

Si F sube, no basta con mirar la media. Hay que repetir, separar por tags y entender qué se mueve.

Para proporciones pequeñas, por ejemplo “pasa contrato” o “acierta caso”, conviene mirar un intervalo. Una opción práctica es el límite inferior de Wilson:

Wlow=p^+z22nzp^(1p^)n+z24n21+z2nW_{low} = \frac{\hat{p} + \frac{z^2}{2n} - z\sqrt{\frac{\hat{p}(1-\hat{p})}{n}+\frac{z^2}{4n^2}}} {1+\frac{z^2}{n}}
SímboloSignificado
p^\hat{p}Proporción observada de éxitos.
nnNúmero de casos evaluados.
zzValor de confianza; 1,96 se usa a menudo para 95%.
WlowW_{low}Límite inferior conservador.

Ejemplo: 19 aciertos de 20 parecen un 95%. Pero con pocos casos, el límite inferior de Wilson es bastante más bajo. Eso nos recuerda que una muestra pequeña no da el mismo nivel de confianza que 950 aciertos de 1000.

Comparación pareada: misma entrada, dos versiones

En sistemas de IA, comparar medias agregadas puede engañar. Si la candidata mejora casos fáciles y empeora casos críticos, la media quizá sube y aun así el producto empeora.

La comparación pareada ejecuta cada caso contra baseline y candidate:

Δi=Sc(xi)Sb(xi)\Delta_i = S_c(x_i) - S_b(x_i)
SímboloSignificado
xix_iCaso evaluado.
Sb(xi)S_b(x_i)Score del baseline en ese caso.
Sc(xi)S_c(x_i)Score de la candidata en ese caso.
Δi\Delta_iDiferencia caso a caso.

Después clasificamos cada caso:

Resultado pareadoRegla típicaQué indica
WinΔi>ϵ\Delta_i > \epsilonLa candidata mejora de forma apreciable.
Tie$\Delta_i
LossΔi<ϵ\Delta_i < -\epsilonLa candidata empeora.
Contract lossBaseline cumple contrato y candidate no.Debe pesar más que una pérdida pequeña de estilo.

Scorecard pareada:

MétricaValorCómo leerla
wins42Casos donde candidate mejora.
ties118Casos equivalentes.
losses17Casos donde candidate empeora.
critical_losses3Casos que no deberían empeorar sin revisión.
contract_losses1Puede bloquear aunque la media mejore.

Si quieres una prueba estadística sencilla para etiquetas binarias, McNemar suele aparecer cuando los mismos casos se evalúan con dos clasificadores y solo importan los desacuerdos. Para un libro como este, basta con que el alumno entienda la intuición: los casos donde ambos aciertan o ambos fallan no distinguen versiones; la decisión vive en los desacuerdos.

Para scores continuos, una alternativa práctica es bootstrap sobre Δi\Delta_i: remuestrear deltas, calcular la media muchas veces y mirar si el intervalo cae claramente por encima de cero. No hace falta convertir esto en ceremonia, pero sí evitar frases como “subió 0,7 puntos” sin mirar variabilidad, tags y criticidad.

Matriz de criticidad por tarea

No todas las tareas merecen el mismo gate. Una recomendación interna reversible no necesita el mismo nivel que una extracción usada para facturación o una acción que modifica un sistema.

CriticidadSeñalGate mínimo
BajaRespuesta informativa, reversible, sin contrato fuerte.Smoke, calidad mínima, coste y latencia.
MediaSoporte, resumen, clasificación o RAG con fuente.Golden, regression, contrato, groundedness, coste p95 y revisión de pérdidas.
AltaSalida que alimenta procesos, usuarios o datos importantes.Holdout, comparación pareada, contrato estricto, revisión humana de pérdidas críticas y canary.
Muy altaAcción externa, cambio de estado o decisión sensible.Aprobación explícita, trazas completas, doble gate, rollback probado y muestreo online.

Variables para decidir criticidad:

VariablePregunta
Reversibilidad¿Se puede deshacer sin daño operativo?
Dependencia downstream¿Otra parte del sistema confía en esta salida?
Contrato¿Hay schema, tipo, enum o cita obligatoria?
Exposición de datos¿La run toca datos internos, personales o documentos sensibles?
Coste de error¿Qué pasa si una respuesta incorrecta se acepta?
Frecuencia¿Ocurre una vez al mes o miles de veces al día?
Supervisión¿Hay revisión humana antes de ejecutar la consecuencia?

Una política simple:

criticality_policy:
  support_summary:
    level: medium
    required_gates: [smoke, golden, regression, cost_p95]
  billing_extraction:
    level: high
    required_gates: [schema, paired_eval, holdout, human_review_losses]
  external_action:
    level: very_high
    required_gates: [schema, trace_complete, approval, canary, rollback_drill]

El buen diseño no consiste en poner todos los gates a todo. Consiste en que el nivel de prueba acompañe al impacto.

Anatomía visual de un pipeline EvalOps

EvalOps: publicar una versión con evidencia, no con intuición El cambio se ejecuta contra datasets versionados, genera trazas, se puntúa, pasa gates y vuelve a producción solo si deja una scorecard aceptable. Cambio propuesto prompt · modelo · RAG router · tool · runtime contrato · evaluador Manifest de release versiones que hacen repetible la prueba model_id · prompt_version · dataset_id route_catalog · eval_policy · schema Datasets cada caso sabe por qué existe golden regresión hold tags por tarea why_it_exists Runners comparables baseline y candidate reciben los mismos casos con trazas separadas baseline estable candidate nueva Evaluadores por capa mezcla de reglas deterministas, rúbricas, modelos evaluadores y revisión humana contrato schema · enums calidad rúbrica · evaluador RAG recall · faithfulness tools args · orden coste por aceptada p95 · timeout flake rate traza completa Scorecard de release una tabla única para revisar calidad, coste, latencia, contrato y evidencia métrica baseline candidate gate decisión calidad ponderada 0,89 0,91 ≥ 0,88 pasa JSON válido 99,6% 99,2% ≥ 99% pasa p95 latencia 2,9 s 3,8 s ≤ 4,0 s pasa coste p95 0,028 € 0,031 € ≤ 0,030 € bloquea Gate offline bloquea rápido si no cumple mínimos calidad · contrato · coste · latencia si falla: ticket con trazas y tags Shadow eval la candidata mira realidad sin responder trazas reales · coste limitado diff de ruta y de salida Canary porcentaje pequeño con rollback listo SLO · burn rate · coste p95 alerta accionable y dueño Producción monitorización online y muestreo feedback a datasets rollback si el SLO cae Las trazas problemáticas vuelven al golden, regression o shadow set con `why_it_exists`. Marca de agua editorial IA para gente curiosa / Facsímil 06 / Capítulo 06 / 686f6c61

Gates por entorno

No todos los gates tienen el mismo coste. Un gate de pull request debe ser rápido. Un gate nocturno puede tardar más. Un gate de prepublicación puede ejecutar datasets grandes. Un gate de canary ya mira señales reales.

EntornoObjetivoQué ejecutaTiempo razonable
PRDetectar roturas evidentes.Smoke, schema, tests de tool, casos de regresión críticos.Segundos o pocos minutos.
NightlyVer tendencias.Golden completo, repetición de casos inestables, comparación de costes.Minutos u horas.
PrepublicaciónDecidir si se avanza.Golden, regression, holdout controlado, scorecard y revisión.Lo que permita el proceso.
ShadowComparar con realidad sin afectar respuesta.Muestra real, diff de rutas, costes estimados y trazas.Horas o días.
CanaryConfirmar en uso limitado.SLIs online, alertas, feedback y rollback preparado.Depende del tráfico.

La clave es que cada entorno responda una pregunta distinta:

PreguntaEntorno
¿Rompimos algo obvio?PR.
¿La tendencia va bien?Nightly.
¿Podemos aprobar la candidata?Prepublicación.
¿Qué habría pasado con tráfico real?Shadow.
¿Qué pasa con un porcentaje pequeño de usuarios?Canary.

Herramientas: qué aporta cada una

Las herramientas no sustituyen el criterio, pero evitan que tengas que construirlo todo desde cero.

HerramientaQué aportaCuándo encaja
OpenAI Evals y GradersDatasets, criterios, graders y ejecución de evals integradas con modelos OpenAI.Cuando tu ciclo de mejora vive cerca de OpenAI y necesitas graders versionables.
Google ADK EvaluateEvaluación de respuesta final y trayectoria de agentes con test files y evalsets.Cuando construyes agentes con ADK y quieres evaluar tools y pasos intermedios.
LangSmith EvaluationDatasets, experimentos, comparación offline, evaluadores online y feedback loop.Cuando quieres unir trazas, datasets y experimentos en productos LangChain o LangGraph.
Phoenix EvalsEvals sobre trazas, datasets, RAG, tool use, evaluadores por código, modelo o revisión.Cuando quieres observabilidad y evaluación con fuerte integración OpenTelemetry.
BraintrustExperimentos, scorers, CI/CD, online scoring y datasets a partir de producción.Cuando el equipo quiere un workflow completo de evaluación y release.
RagasMétricas de RAG, tool use, SQL, comparación semántica y generación de testsets.Cuando el problema principal es retrieval, grounding o aplicaciones RAG.
PromptfooConfiguración declarativa, matriz de prompts/modelos, CLI, CI y métricas.Cuando quieres pruebas rápidas y reproducibles desde repositorio.
pytest + scripts propiosControl total, barato y cercano al código.Cuando el contrato es determinista o el equipo necesita empezar sin plataforma.

Una recomendación práctica: empieza con pytest y datasets pequeños si aún no tienes disciplina. Cuando el equipo empiece a comparar muchas variantes, guardar trazas, revisar casos y automatizar releases, una plataforma de evals deja de ser lujo y se vuelve infraestructura.

Coste de evaluar

Evaluar también consume dinero, tiempo y capacidad. Ignorarlo lleva a dos extremos malos: no evaluar casi nada porque “sale caro”, o evaluar todo con el modelo más potente hasta que el coste hace que el equipo apague el sistema.

Ejemplo de fórmula. El coste de un lote puede aproximarse así:

Ceval=i=1n(Crun,i+Cjudge,i+Ctools,i)C_{eval} = \sum_{i=1}^{n} (C_{run,i} + C_{judge,i} + C_{tools,i})
SímboloSignificado
CevalC_{eval}Coste total del lote de evaluación.
Crun,iC_{run,i}Coste de ejecutar el sistema en el caso ii.
Cjudge,iC_{judge,i}Coste de evaluadores con modelo o revisión.
Ctools,iC_{tools,i}Coste de búsquedas, bases de datos, runtimes o servicios externos.

Palancas de optimización:

PalancaQué ahorraCuidado
Smoke set pequeñoTiempo en PR.No sustituye golden completo.
Caché de outputsRepetir evaluaciones sin recalcular todo.Invalida si cambia modelo, prompt, datos o tool.
Evaluadores deterministas primeroLlamadas a modelos evaluadores.No captura calidad semántica.
Muestreo por tagsEjecutar más donde hubo cambios.Puede dejar zonas sin cobertura.
Batch nocturnoCoste y saturación diurna.Los fallos llegan más tarde.
Paralelismo limitadoTiempo de ejecución.Puede topar con rate limits o colas.
Evaluador pequeño calibradoCoste de evaluación.Debe compararse contra revisión o evaluador más fuerte.

Una política útil:

eval_budget:
  pull_request:
    max_cost_eur: 2
    max_runtime_minutes: 10
    datasets: [smoke, critical_regression]
  nightly:
    max_cost_eur: 40
    max_runtime_minutes: 120
    datasets: [golden, regression]
  release_candidate:
    max_cost_eur: 120
    max_runtime_minutes: 240
    datasets: [golden, regression, approved_holdout]

La frase importante: si el coste de evaluar no está presupuestado, el equipo acabará evaluando menos de lo que cree.

Privacidad y datasets de evaluación

Los mejores casos suelen venir de producción. También son los que más cuidado exigen. No necesitamos copiar todo lo que vio el usuario para aprender del fallo.

Una traza puede convertirse en caso de evaluación de varias formas:

TécnicaQué guardaCuándo sirve
ID de documentosource_ids, versión de índice y hash.Cuando el contenido está en un repositorio controlado.
Resumen técnicoPropiedad del fallo, no texto completo.Cuando basta con reproducir el patrón.
Redacción mínimaEntrada reducida que conserva el comportamiento.Cuando se puede quitar información sensible.
Caso sintético derivadoCaso nuevo creado a partir del fallo.Cuando la traza real no debe entrar al dataset.
Retención cortaGuardar muestra solo para diagnóstico.Cuando hay que revisar rápido y borrar después.

Campos recomendables:

CampoPara qué sirve
sensitivityDecide quién puede ver el caso.
retention_daysEvita datasets eternos con datos innecesarios.
redaction_statusraw, redacted, synthetic, metadata_only.
allowed_runnersDefine si puede ejecutarse local, cloud o solo entorno interno.
review_requiredObliga a revisión antes de entrar en golden o holdout.

La regla práctica: el dataset debe conservar la propiedad que queremos probar, no necesariamente el contenido original.

Scorecard: el documento que autoriza avanzar

La scorecard es el artefacto que impide discutir de memoria. Puede ser JSON, Markdown, una tabla en la plataforma o un registro en CI. Debe responder:

CampoPregunta que responde
candidate_version¿Qué estamos intentando publicar?
baseline_version¿Contra qué se compara?
change_summary¿Qué cambió y por qué?
datasets¿Con qué casos se midió?
evaluators¿Quién o qué puntuó?
metrics¿Qué números importan?
gates¿Qué umbrales decidían?
exceptions¿Qué aceptamos manualmente y con qué motivo?
owner¿Quién firma la decisión?
rollback_plan¿Cómo se revierte si empeora online?

Una scorecard útil no intenta demostrar que todo es perfecto. Intenta dejar una decisión auditada:

release_scorecard:
  candidate_version: assistant-runtime@2026.05.28-rc2
  baseline_version: assistant-runtime@2026.05.20
  datasets:
    - golden_support_es@v14
    - regression_json_contract@v8
    - rag_policy_holdout@v3
  gates:
    quality_weighted_min: 0.88
    json_valid_min: 0.99
    p95_latency_ms_max: 4500
    p95_cost_eur_max: 0.030
  decision: promote_to_canary
  owner: ai-platform
  rollback: restore assistant-runtime@2026.05.20 and route_catalog@v31

Runbook cuando falla un gate

Un gate que solo dice “fail” no ayuda. Debe abrir un camino de trabajo.

FaseAcciónDueño
1. ClasificarIdentificar si falló contrato, calidad, coste, latencia, trazas, RAG, tool o evaluador.Guardia de release o responsable de plataforma.
2. AislarSeparar casos por tags, ruta, modelo, proveedor, prompt y versión de dataset.Equipo que propuso el cambio.
3. ReproducirEjecutar baseline y candidate sobre casos fallidos con trazas completas.Ingeniería.
4. DecidirParar, corregir, aceptar excepción limitada o reducir alcance.Owner del producto y owner técnico.
5. RegistrarAñadir caso de regresión si el fallo era real.Quien corrige.
6. ReintentarLanzar de nuevo el gate con nueva versión.CI/CD.
7. VigilarSi llegó a canary, mirar SLO, coste y feedback.Operación.

Plantilla de incidencia de gate:

evalops_gate_failure:
  release_candidate: assistant-runtime@2026.05.28-rc2
  failed_gate: cost_p95_ok
  first_detected_in: evalops-release
  affected_tags: [rag, policy]
  baseline_version: assistant-runtime@2026.05.20
  candidate_version: assistant-runtime@2026.05.28-rc2
  evidence:
    scorecard: output/evalops_scorecard.json
    traces: output/failing_traces/
    cases: [rag-policy-014, rag-policy-027]
  decision: reduce_context_top_k_and_retry
  regression_cases_to_add: [rag-policy-027]
  owner: ai-platform

Una excepción también debe escribirse. A veces una candidata pierde en un caso poco importante pero arregla un problema mayor. Puede aceptarse, pero con alcance, fecha y dueño:

gate_exception:
  case_id: support-style-044
  reason: "La candidata usa un tono más breve; producto lo acepta para esta release."
  expires_at: "2026-06-15"
  owner: product-ai
  required_follow_up: "Revisar rúbrica de concisión en golden set."

Si una excepción no caduca, no es una excepción: es una nueva política sin admitir.

Manos a la obra

Práctica: un gate de release ejecutable.

Kit ejecutable de este capítulo: kit descargable.

# Descomprime el ZIP del capítulo y ejecuta estos comandos dentro de esa carpeta
python3 ops/run_f6_practices.py --chapter c06 --write --fail-on-invalid

Este ejemplo no llama a ningún proveedor. Eso es intencionado. Queremos practicar la parte que muchas veces falta: comparar baseline y candidate, calcular métricas, aplicar gates y producir una decisión en JSON.

Guárdalo como ops/ai/evalops_release_gate.py dentro de un proyecto real y ejecútalo en CI. Después podrás cambiar la parte de datos por resultados de OpenAI Evals, LangSmith, Phoenix, Braintrust, Promptfoo, Ragas o tu propio runner.

from __future__ import annotations

import json
import math
from dataclasses import dataclass, asdict
from statistics import mean
from typing import Iterable


@dataclass(frozen=True)
class EvalResult:
    case_id: str
    tags: tuple[str, ...]
    quality: float
    contract_ok: bool
    latency_ms: int
    cost_eur: float
    trace_complete: bool
    accepted: bool


@dataclass(frozen=True)
class GatePolicy:
    min_quality: float
    max_quality_drop: float
    min_contract_rate: float
    min_trace_rate: float
    max_p95_latency_ms: int
    max_p95_cost_eur: float
    min_wilson_contract_low: float


BASELINE = [
    EvalResult("c001", ("json", "support"), 0.91, True, 2300, 0.018, True, True),
    EvalResult("c002", ("rag", "policy"), 0.88, True, 3100, 0.022, True, True),
    EvalResult("c003", ("tool", "billing"), 0.86, True, 2800, 0.019, True, True),
    EvalResult("c004", ("json", "edge"), 0.84, True, 2600, 0.020, True, True),
    EvalResult("c005", ("rag", "policy"), 0.87, True, 3600, 0.024, True, True),
]

CANDIDATE = [
    EvalResult("c001", ("json", "support"), 0.93, True, 2500, 0.019, True, True),
    EvalResult("c002", ("rag", "policy"), 0.90, True, 3400, 0.023, True, True),
    EvalResult("c003", ("tool", "billing"), 0.89, True, 3000, 0.020, True, True),
    EvalResult("c004", ("json", "edge"), 0.85, True, 2700, 0.021, True, True),
    EvalResult("c005", ("rag", "policy"), 0.89, False, 4300, 0.031, True, False),
]

POLICY = GatePolicy(
    min_quality=0.88,
    max_quality_drop=0.01,
    min_contract_rate=0.99,
    min_trace_rate=0.99,
    max_p95_latency_ms=4500,
    max_p95_cost_eur=0.030,
    min_wilson_contract_low=0.80,
)


def p95(values: Iterable[float]) -> float:
    ordered = sorted(values)
    if not ordered:
        raise ValueError("p95 necesita al menos un valor")
    index = math.ceil(0.95 * len(ordered)) - 1
    return ordered[index]


def wilson_lower_bound(successes: int, total: int, z: float = 1.96) -> float:
    if total == 0:
        return 0.0
    p_hat = successes / total
    denominator = 1 + (z * z / total)
    center = p_hat + (z * z / (2 * total))
    spread = z * math.sqrt((p_hat * (1 - p_hat) / total) + (z * z / (4 * total * total)))
    return (center - spread) / denominator


def summarize(results: list[EvalResult]) -> dict[str, float]:
    total = len(results)
    contract_ok = sum(item.contract_ok for item in results)
    trace_ok = sum(item.trace_complete for item in results)
    accepted = [item for item in results if item.accepted]

    return {
        "cases": total,
        "quality_mean": round(mean(item.quality for item in results), 4),
        "contract_rate": round(contract_ok / total, 4),
        "contract_wilson_low": round(wilson_lower_bound(contract_ok, total), 4),
        "trace_rate": round(trace_ok / total, 4),
        "latency_p95_ms": round(p95(item.latency_ms for item in results), 2),
        "cost_p95_eur": round(p95(item.cost_eur for item in results), 4),
        "accepted_rate": round(len(accepted) / total, 4),
    }


def tag_regressions(
    baseline: list[EvalResult],
    candidate: list[EvalResult],
    min_delta: float = -0.02,
) -> list[dict[str, object]]:
    by_id = {item.case_id: item for item in baseline}
    regressions = []
    for new in candidate:
        old = by_id[new.case_id]
        delta = new.quality - old.quality
        if delta < min_delta or old.contract_ok and not new.contract_ok:
            regressions.append(
                {
                    "case_id": new.case_id,
                    "tags": list(new.tags),
                    "quality_delta": round(delta, 4),
                    "contract_went_from_ok_to_fail": old.contract_ok and not new.contract_ok,
                }
            )
    return regressions


def evaluate_release(
    baseline: list[EvalResult],
    candidate: list[EvalResult],
    policy: GatePolicy,
) -> dict[str, object]:
    base = summarize(baseline)
    cand = summarize(candidate)
    quality_delta = cand["quality_mean"] - base["quality_mean"]

    checks = {
        "quality_above_min": cand["quality_mean"] >= policy.min_quality,
        "quality_not_regressed": quality_delta >= -policy.max_quality_drop,
        "contract_rate_ok": cand["contract_rate"] >= policy.min_contract_rate,
        "contract_wilson_low_ok": cand["contract_wilson_low"] >= policy.min_wilson_contract_low,
        "trace_rate_ok": cand["trace_rate"] >= policy.min_trace_rate,
        "latency_p95_ok": cand["latency_p95_ms"] <= policy.max_p95_latency_ms,
        "cost_p95_ok": cand["cost_p95_eur"] <= policy.max_p95_cost_eur,
    }

    blocking = [name for name, passed in checks.items() if not passed]
    regressions = tag_regressions(baseline, candidate)

    if blocking:
        decision = "do_not_promote"
    elif regressions:
        decision = "manual_review_before_canary"
    else:
        decision = "promote_to_canary"

    return {
        "baseline": base,
        "candidate": cand,
        "quality_delta": round(quality_delta, 4),
        "checks": checks,
        "blocking_checks": blocking,
        "case_regressions": regressions,
        "decision": decision,
        "policy": asdict(policy),
    }


if __name__ == "__main__":
    report = evaluate_release(BASELINE, CANDIDATE, POLICY)
    print(json.dumps(report, ensure_ascii=False, indent=2))

Qué deberías mirar en la salida:

CampoCómo interpretarlo
quality_deltaSi sube poco pero empeora coste o contrato, no basta para publicar.
contract_wilson_lowCon pocos casos, una tasa aparente alta puede no dar confianza suficiente.
blocking_checksLista concreta de gates que no pasan. Debe crear trabajo accionable.
case_regressionsCasos concretos que deben volver al dataset de regresión si se corrigen.
decisionDecisión de proceso, no “verdad universal”: parar, revisar o canary.

El ejemplo tiene una candidata que mejora calidad media, pero falla contrato en un caso y supera el coste p95 permitido. Esa es la lección: una release puede parecer mejor en una dimensión y no estar lista para avanzar.

Kit operativo: tu primera implementación en 90 minutos

Si este capítulo se queda en “me parece razonable”, no hemos terminado. El objetivo es que puedas montar una primera versión de EvalOps en un repositorio real, aunque sea pequeña, y defenderla ante otra persona.

No vamos a construir una plataforma completa. Vamos a dejar cuatro artefactos:

ArtefactoRuta sugeridaPara qué sirve
Dataset mínimoevals/smoke.jsonlCasos pequeños que protegen lo esencial en cada PR.
Gate ejecutableops/ai/evalops_release_gate.pyScript que compara baseline/candidate y decide.
Workflow CI.github/workflows/evalops.ymlAutomatización que ejecuta el gate y guarda evidencia.
Scorecardoutput/evalops_scorecard.jsonResultado revisable por el equipo o por el alumno.

La estructura de carpetas:

mi-proyecto/
  evals/
    smoke.jsonl
    README.md
  ops/
    ai/
      evalops_release_gate.py
  output/
    .gitkeep
  .github/
    workflows/
      evalops.yml

Paso 1. Crea evals/smoke.jsonl con tres casos. En un proyecto real deberían venir de tareas importantes, no de ocurrencias sueltas:

{"case_id":"smoke-json-001","task":"support_triage","input":{"message":"No puedo acceder a mi matrícula."},"expected":{"contract_ok":true,"min_quality":0.85},"tags":["json","support"],"why_it_exists":"Comprueba que la clasificación básica no rompe el contrato.","owner":"ai-platform"}
{"case_id":"smoke-rag-001","task":"policy_qa","input":{"question":"¿Dónde consulto la política de entregas?"},"expected":{"contract_ok":true,"min_quality":0.80},"tags":["rag","policy"],"why_it_exists":"Comprueba que las respuestas con política conservan fuente o criterio de abstención.","owner":"ai-platform"}
{"case_id":"smoke-cost-001","task":"short_answer","input":{"message":"Resume este aviso en dos líneas."},"expected":{"contract_ok":true,"min_quality":0.75},"tags":["cost","latency"],"why_it_exists":"Comprueba que una tarea simple no se va a una ruta cara sin motivo.","owner":"ai-platform"}

Paso 2. Copia el script de Manos a la obra en ops/ai/evalops_release_gate.py. En esta primera versión los resultados están simulados dentro del script. Eso es aceptable para aprender la mecánica: primero entendemos gate, scorecard y decisión; luego conectamos el runner real.

Paso 3. Añade una salida a archivo. Al final del script, cambia el bloque principal por esto:

if __name__ == "__main__":
    report = evaluate_release(BASELINE, CANDIDATE, POLICY)
    print(json.dumps(report, ensure_ascii=False, indent=2))

    from pathlib import Path

    output_dir = Path("output")
    output_dir.mkdir(exist_ok=True)
    (output_dir / "evalops_scorecard.json").write_text(
        json.dumps(report, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )

    if report["decision"] == "do_not_promote":
        raise SystemExit(1)

Paso 4. Ejecútalo:

python ops/ai/evalops_release_gate.py

Salida esperada:

"decision": "do_not_promote"
"blocking_checks": [
  "contract_rate_ok",
  "contract_wilson_low_ok",
  "cost_p95_ok"
]

Que el comando falle con exit code 1 es correcto en esta práctica. El gate ha hecho su trabajo: la candidata mejora calidad media, pero no debería avanzar porque rompe contrato y coste.

Paso 5. Automatízalo en .github/workflows/evalops.yml:

name: evalops

on:
  pull_request:
    paths:
      - "evals/**"
      - "ops/ai/**"
      - "prompts/**"
  workflow_dispatch:

jobs:
  smoke:
    runs-on: ubuntu-latest
    timeout-minutes: 10
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - name: Run EvalOps smoke gate
        run: python ops/ai/evalops_release_gate.py
      - name: Upload scorecard
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: evalops-scorecard
          path: output/evalops_scorecard.json

Paso 6. Cambia la candidata para que pase. Por ejemplo, corrige el último caso simulado:

EvalResult("c005", ("rag", "policy"), 0.89, True, 3900, 0.026, True, True)

Vuelve a ejecutar. Ahora la decisión esperada debería ser:

"decision": "promote_to_canary"

Qué has construido:

PiezaResultado
DatasetUn smoke set pequeño con intención documentada.
RunnerUn script que calcula métricas y aplica gates.
ScorecardUn JSON que explica por qué pasa o no pasa.
CI/CDUn workflow que convierte la evaluación en estado de PR.
CriterioUna forma de decir “no avanza” aunque la media mejore.

Cómo adaptarlo a tu proyecto:

Si tu proyecto tiene...Cambia esto
RAGAñade source_ids, context_recall, faithfulness y casos sin fuente.
ToolsAñade tool_name, argumentos esperados y resultado de contrato.
Salida JSONValida con JSON Schema antes de puntuar calidad.
Modelos cloudRegistra provider, model_id, tokens y coste real.
Modelos localesRegistra runtime, cuantización, VRAM, TTFT y TPOT.
Varios tenantsSepara métricas por tenant o criticidad.

Entregable si esto fuera una práctica universitaria:

ArchivoQué se evalúa
evals/smoke.jsonlCasos con why_it_exists, tags y propietario.
ops/ai/evalops_release_gate.pyCódigo ejecutable, claro y sin dependencias innecesarias.
.github/workflows/evalops.ymlIntegración con CI y artefacto de scorecard.
output/evalops_scorecard.jsonDecisión, checks, bloqueos y métricas.
decision.mdExplicación breve: qué falló, qué cambiarías y si publicarías o no.

Una plantilla mínima para decision.md:

# Decisión de EvalOps

## Resumen

La candidata [pasa/no pasa] el gate porque...

## Evidencia

- Calidad media:
- Contrato:
- Coste p95:
- Latencia p95:
- Casos que empeoran:

## Decisión

[No promover / revisar manualmente / promover a canary]

## Siguiente cambio

El próximo cambio técnico será...

Lo importante no es que este kit sea perfecto. Lo importante es que ya tiene la forma profesional: datos, ejecución, evidencia, decisión y adaptación.

Cómo encaja todo

flowchart TD
  A["Cambio propuesto"] --> B["Manifest versionado"]
  B --> C["Datasets: smoke, golden, regression, holdout"]
  C --> D["Runner: baseline contra candidate"]
  D --> E["Evaluadores: código, rúbrica, evaluador, humano"]
  E --> F["Scorecard: calidad, contrato, coste, latencia, trazas"]
  F --> G{"¿Gate offline pasa?"}
  G -- "no" --> H["Bloquear release y abrir trabajo con casos concretos"]
  G -- "sí" --> I["Shadow eval con trazas reales"]
  I --> J{"¿Señales reales aceptables?"}
  J -- "no" --> H
  J -- "sí" --> K["Canary con SLO, alertas y rollback"]
  K --> L{"¿Canary estable?"}
  L -- "no" --> M["Rollback y caso nuevo de regresión"]
  L -- "sí" --> N["Producción gradual"]
  N --> O["Muestreo online y feedback"]
  O --> C

  C -. "cap. 04 observabilidad" .-> O
  F -. "cap. 05 routing y presupuesto" .-> K
  H -. "cap. 01 sistema operable" .-> B

Relación con otros capítulos

EvalOps se apoya en piezas que ya hemos construido:

CapítuloQué aporta a EvalOps
F6 · Capítulo 01La idea de pasar de prototipo a sistema operable con contratos y evidencia.
F6 · Capítulo 02Run, estado, contrato operativo, colas e idempotencia.
F6 · Capítulo 04Logs, métricas, trazas, SLI, SLO y presupuesto de error.
F6 · Capítulo 05Router, fallback, coste, latencia y presupuesto por tarea.
F5 · Capítulo 10Evaluación de agentes por trayectoria, tools, permisos y coste.
F4 · Capítulo 10Evaluación de RAG: retrieval, groundedness, abstención y citas.
F4 · Capítulo 13Laboratorio mínimo con notebooks, evals y trazas.

La diferencia es de nivel. En el facsímil 4 evaluamos una técnica concreta. En el facsímil 5 evaluamos agentes. Aquí evaluamos el proceso de publicar cambios.

Para entenderlo

Piensa en tres situaciones:

| Situación | Qué haría alguien improvisando | Qué hace EvalOps | |---|---| | Cambias el modelo por uno más barato. | Mira cinco respuestas y publica. | Compara calidad, p95, coste, contrato, rutas y tags críticos. | | El RAG trae más documentos. | Celebra que “hay más contexto”. | Mide recall, precision, ruido, coste, latencia y groundedness. | | El router manda más tareas a local. | Mira que baja la factura. | Verifica que las tareas locales siguen pasando golden y que el fallback cubre límites. |

El punto no es desconfiar de cada mejora. El punto es que la mejora pueda defenderse con datos.

Vocabulario aprendido

TérminoQué significa aquíCómo lo usarías en un proyecto
EvalOpsDisciplina de evaluación versionada antes, durante y después de publicar cambios de IA.Convertir cambios de prompt, modelo, RAG o tool en gates revisables.
BaselineVersión estable contra la que se compara la candidata.No decir “mejora” sin declarar qué versión estás superando.
CandidateVersión nueva que quiere avanzar.Llevar model_id, prompt, índice, contrato y ruta en el manifest.
Golden setCasos importantes y revisados que protegen comportamiento esperado.Evaluar regresiones de tareas críticas antes de canary.
HoldoutCasos reservados para evitar ajustar todo al mismo dataset.Detectar si has optimizado contra tus propios ejemplos.
ScorecardDocumento o JSON con métricas, gates, fallos y decisión.Adjuntarlo al PR o a la release para que otra persona pueda auditar.
GateRegla que bloquea, permite revisar o permite avanzar.Traducir calidad, contrato, coste, latencia y trazas en una decisión.
RegresiónCaso que antes pasaba y ahora falla, o señal que empeora fuera de umbral.Añadirlo al dataset para que el mismo fallo no vuelva silenciosamente.
Shadow evalEvaluación con trazas o entradas reales sin exponer la salida candidata.Medir comportamiento real antes de canary.
CanaryExposición controlada de la candidata a una parte del tráfico.Subir porcentaje solo si pasan SLO, contrato y coste.

Dónde solía tropezar yo

Durante mucho tiempo confundía “tener evals” con “estar evaluando bien”. Tenía un dataset, corría un script y miraba un score. Sonaba serio, pero faltaban cosas.

Me tropezaba en cinco sitios:

TropiezoQué aprendí a mirar
Optimizar contra el golden set.Si ajustas siempre contra los mismos casos, necesitas holdout.
Celebrar la media.Una media puede esconder una caída fuerte en un tag crítico.
No versionar evaluadores.Si cambia la rúbrica, cambió la regla del juego.
Ignorar coste y latencia.Una respuesta un poco mejor puede no compensar si duplica p95 o factura.
No devolver producción al dataset.Los casos reales que fallan son oro para la siguiente evaluación.

La frase que me sirve: una eval sin decisión operativa es solo una medición; una eval con gate, dueño y feedback empieza a ser ingeniería.

Antes de pasar página

Comprueba que puedes responder sin mirar:

  1. ¿Qué diferencia hay entre smoke, golden, regression, holdout, shadow y canary?
  2. ¿Por qué cambiar un prompt puede exigir medir coste, latencia y contrato, no solo calidad?
  3. ¿Qué significa comparar baseline contra candidate?
  4. ¿Por qué un modelo evaluador necesita rúbrica, calibración y versión?
  5. ¿Qué debería contener una scorecard de release?
  6. ¿Por qué un gate de PR no debe ejecutar lo mismo que un gate de prepublicación?
  7. ¿Cómo vuelve una traza de producción al dataset de evaluación?

En resumen

EvalOps es la práctica de convertir cambios de IA en decisiones de ingeniería. No pregunta si una respuesta parece bonita; pregunta si una versión candidata mejora o mantiene lo importante bajo contrato, presupuesto, trazas y umbrales explícitos.

El patrón completo es: manifest, datasets, runner, evaluadores, scorecard, gate offline, shadow, canary, producción y feedback. Cuando ese ciclo existe, cambiar prompts, modelos, rutas o tools deja de ser una apuesta y empieza a ser una operación defendible.

Para saber más

Notas

  1. OpenAI. (2026). Working with evals. https://developers.openai.com/api/docs/guides/evals. Consultado el 28 de mayo de 2026.

  2. Google. (2026). Why Evaluate Agents. https://adk.dev/evaluate/. Consultado el 28 de mayo de 2026.

  3. LangChain. (2026). LangSmith Evaluation. https://docs.langchain.com/langsmith/evaluation. Consultado el 28 de mayo de 2026.

  4. Arize Phoenix. (2026). Evaluation concepts. https://arize.com/docs/phoenix/evaluation/concepts-evals/evaluation. Consultado el 28 de mayo de 2026.

  5. Braintrust. (2026). Evaluate systematically. https://www.braintrust.dev/docs/evaluate. Consultado el 28 de mayo de 2026.

  6. Promptfoo. (2026). Intro. https://www.promptfoo.dev/docs/intro/. Consultado el 28 de mayo de 2026.

  7. GitHub. (2026). Workflow syntax for GitHub Actions. https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax. Consultado el 28 de mayo de 2026.

  8. GitLab. (2026). CI/CD YAML syntax reference. https://docs.gitlab.com/ci/yaml/. Consultado el 28 de mayo de 2026.

  9. Ragas. (2026). List of available metrics. https://docs.ragas.io/en/stable/concepts/metrics/available_metrics/. Consultado el 28 de mayo de 2026.

Capítulo 07

Facsímil 6 · Construir y operar

Capítulo 07: Cambios progresivos: shadow, canary y rollback

Qué deberías poder hacer al terminar

En el capítulo 06 dejamos una idea clara: una versión candidata no debería llegar a producción solo porque parece mejor. Debe pasar por evidencia: dataset, evaluadores, scorecard y gates. Ahora toca la siguiente pregunta: si el gate pasa, cómo la exponemos sin apostar todo el sistema a la primera tirada.

Los sistemas de IA tienen una superficie de cambio más amplia que muchas aplicaciones clásicas. Puedes cambiar código, prompt, modelo, cuantización, runtime, catálogo de rutas, índice RAG, política de abstención, contrato JSON, tool schema, coste máximo o umbral de fallback. Muchos de esos cambios no se ven como una nueva pantalla, pero cambian lo que el sistema responde.

Al terminar, deberías poder hacer esto:

Resultado de aprendizajeEvidencia de que lo sabes hacer
Separar deployment de release.Puedes desplegar una versión sin exponerla todavía.
Diseñar shadow run.Copias entradas reales sin producir efectos persistentes ni duplicar acciones.
Diseñar canary.Repartes tráfico de forma estable, medible y reversible.
Definir criterios de avance.Cada porcentaje tiene métricas, mínimos, duración y dueño.
Preparar rollback.Sabes volver por flag, configuración, ruta, prompt o versión.
Elegir estrategia.Distingues rolling, blue/green, dark launch, percentage rollout, guarded rollout y canary.
Construir un controlador mínimo.Simulas asignación por cohorte y decisión de avanzar, pausar o volver.

La idea central: un cambio serio no se “lanza”; se expone progresivamente, se mide por segmentos y conserva una salida de vuelta.

La release que no debería ser un salto al vacío

Imagina que el asistente de soporte usa prompt_v12, model_a, rag_index_2026_05 y router_policy_31. El capítulo 06 nos ayudó a decidir que una candidata, prompt_v13 con router_policy_32, pasa el gate offline. Mejora respuestas largas y reduce llamadas caras. Bien.

Pero producción no es el dataset. En producción hay usuarios con preguntas raras, tenants con documentos distintos, momentos de carga, límites de proveedor, colas, documentos recién subidos y prompts que combinan mal con ciertos casos. Si pasamos de 0% a 100%, cualquier sorpresa cae sobre todo el mundo.

Un rollout progresivo cambia la pregunta:

¿Qué porcentaje pequeño, durante cuánto tiempo y con qué métricas nos permite aprender sin comprometer todo el sistema?

Deployment no es release

En conversaciones de equipo se mezclan mucho estas dos palabras. Conviene separarlas:

ConceptoQué cambiaEjemplo
DeploymentEl código o configuración queda disponible.Desplegamos prompt_v13 y router_policy_32 en producción, pero desactivados.
ReleaseLa capacidad se expone a tráfico real.El 5% de support_summary usa prompt_v13.

OpenFeature describe los feature flags como decisiones evaluadas en runtime que permiten alterar el comportamiento sin desplegar código nuevo.1 Esa separación es la base de muchos cambios progresivos: el código puede estar desplegado y la release puede seguir controlada por una bandera, una regla de targeting o una política del router.

Kubernetes, por su parte, ofrece Deployments para gestionar Pods y ReplicaSets y actualizar el estado deseado de una carga de trabajo.2 Eso resuelve una parte: cómo llegan contenedores nuevos. Pero una release de IA también puede depender de configuración viva: qué modelo se usa, qué prompt está activo, qué índice RAG se consulta y qué porcentaje de tráfico entra en la variante.

Qué no es un canary

Canary no es “subir al 10% y esperar”. Tampoco es una excusa para saltarse EvalOps. Si una candidata no pasó eval offline, canary no la convierte en segura; solo desplaza el experimento a producción.

Canary tampoco debería ser aleatorio en cada petición. Si el mismo usuario o tenant cae a veces en baseline y a veces en candidate, será difícil comparar, depurar y explicar diferencias. Para muchas tareas conviene asignación estable por cohorte: usuario, tenant, tarea, conversación o documento.

Y rollback no es “ya veremos cómo volvemos”. Rollback debe estar probado antes de exponer tráfico. En IA, volver puede significar restaurar un prompt, bajar un porcentaje, cambiar ruta, recuperar un índice, volver al contrato anterior o desactivar una tool.

ConfusiónQué falta
“Canary es probar en producción”Falta gate offline, porcentaje, métricas, duración y rollback.
“Deployment y release son lo mismo”Falta flag, targeting o política que se pueda cambiar sin redesplegar.
“Si falla, hacemos rollback”Falta saber qué revertir, cuánto tarda y qué datos deja la versión candidata.
“Shadow es ejecutar dos veces y comparar”Falta evitar efectos persistentes, duplicar coste sin límite o llamar tools de escritura.
“Subimos por usuarios al azar”Falta cohortes estables y segmentación por tarea, tenant o criticidad.

Qué sí es progressive delivery en IA

Progressive delivery es una forma de publicar cambios reduciendo el radio de impacto. En IA generativa, el cambio puede estar en muchas capas:

CapaCambio progresivo posible
PromptActivar prompt_v13 por porcentaje, tarea o tenant.
ModeloComparar model_a y model_b con rutas separadas.
RAGProbar un índice nuevo en shadow antes de usarlo como fuente.
RouterEjecutar router_policy_32 en modo decisión sombra.
ToolActivar una tool primero en modo dry-run o solo lectura.
ContratoAceptar nuevo schema solo en consumidores preparados.
RuntimeMover porcentaje a un serving nuevo con límites de capacidad.

Ejemplo de fórmula. Podemos resumir una release progresiva como:

R=(vb,vc,W,M,G,B)R = (v_b, v_c, W, M, G, B)
SímboloSignificadoEjemplo
vbv_bVersión baseline.support-rag@1.8.0.
vcv_cVersión candidata.support-rag@1.9.0-rc1.
WWSecuencia de pesos de tráfico.0%, shadow, 1%, 5%, 25%, 50%, 100%.
MMMétricas observadas por paso.p95, coste, contrato, aceptación, fallback, calidad online.
GGGates de avance.Condiciones para pasar de un peso al siguiente.
BBPlan de rollback.Flag, configuración previa, ruta anterior e índice anterior.

Argo Rollouts define canary como una estrategia donde una versión nueva recibe un porcentaje pequeño del tráfico de producción, con pasos como setWeight y pause para controlar el avance.3 LaunchDarkly separa opciones como percentage rollouts, progressive rollouts, guarded rollouts y experiments según si se quiere asignar porcentaje fijo, aumentar tráfico con el tiempo, vigilar métricas o comparar variantes.4 Google Cloud Deploy describe canary como un despliegue progresivo que divide tráfico entre una versión ya desplegada y una nueva antes de llegar al despliegue completo.5

Estrategias de cambio progresivo

No existe una única estrategia buena. Hay familias, y cada una protege algo distinto.

EstrategiaQué haceSirve cuandoCuidado
Rolling updateSustituye instancias gradualmente.Cambio de código con compatibilidad clara.No controla comportamiento por usuario o tarea.
Blue/greenMantiene dos entornos y conmuta tráfico.Necesitas vuelta rápida a entorno anterior.Duplica infraestructura y no mide bien por segmentos.
Dark launchDespliega sin exponer al usuario.Quieres probar wiring, dependencias y observabilidad.No prueba aceptación real.
Shadow runEjecuta candidate en paralelo sin afectar salida.Quieres comparar con entradas reales.Debe evitar efectos persistentes y coste ilimitado.
Percentage rolloutEnvía un porcentaje fijo a candidate.Quieres muestra controlada.Sin métricas puede ser solo azar administrado.
Progressive rolloutSube porcentaje por pasos.Quieres aprender antes de ampliar.Cada paso necesita criterio de avance.
Guarded rolloutSube tráfico vigilando métricas.Quieres detener si empeoran señales.Depende de métricas fiables y umbrales claros.
Canary por cohorteExpone tenant, tarea o segmento específico.Quieres controlar impacto por dominio.Puede sesgar resultados si la cohorte no representa bien.

En IA, muchas veces combinamos varias. Por ejemplo:

  1. Dark launch de model_b en el runtime.
  2. Shadow run con entradas reales de support_summary.
  3. Canary al 1% por cohorte estable.
  4. Progressive rollout 5%, 25%, 50%.
  5. Guarded rollout con rollback automático si contrato, p95 o coste empeoran.

Matriz de elección de estrategia

La pregunta práctica no es “qué patrón está de moda”. La pregunta buena es: qué necesito aprender sin exponer más superficie de la necesaria. Esta matriz sirve para elegir.

CambioRiesgo principalEstrategia preferentePor qué
Prompt de respuesta libreCalidad, tono, longitud y coste.Shadow y canary por tarea.Puedes comparar salidas y después exponer solo una tarea.
Prompt con JSON estrictoFallo de contrato.Shadow con validador y canary pequeño.El contrato debe romperse antes de llegar al usuario.
Modelo nuevoLatencia, coste, calidad y límites de proveedor.Shadow, canary por cohorte y fallback.La misma entrada puede comportarse distinto en tokens, rutas y coste.
Índice RAG nuevoRecuperación peor o fuentes incorrectas.Shadow de retrieval y canary por tenant.La salida puede parecer correcta aunque la fuente haya cambiado.
Tool de lecturaLatencia, permisos y formato de argumentos.Dark launch, dry-run y canary por tarea.Conviene probar wiring y trazas antes de exponer.
Tool de escrituraEfectos persistentes.No usar shadow real con escritura; empezar con modo aprobación o dry-run.La versión candidata no debe duplicar acciones.
Runtime de inferenciaSaturación, colas y p95.Blue/green o canary de tráfico.Aquí importa tanto la arquitectura como el modelo.
Schema de salidaCompatibilidad con consumidores.Expand-contract.Los consumidores antiguos y nuevos deben convivir durante la migración.

Un alumno debería poder justificar su elección así: “uso shadow porque necesito comparar entradas reales sin cambiar salida; uso canary por tenant porque el índice RAG depende de documentos; uso rollback por flag porque el cambio está en prompt y ruta, no en código”.

Compatibilidad: dos versiones conviven

Una release progresiva no solo reparte tráfico. Durante un rato conviven dos mundos: baseline y candidate. Esa convivencia exige compatibilidad.

SuperficieQué puede romperRegla de compatibilidad
JSON de respuestaConsumidores esperan campos antiguos.Añadir campos antes de quitar campos.
Tool schemaEl modelo manda argumentos nuevos.Versionar schema y aceptar ambos durante la transición.
Índice RAGCitas apuntan a documentos con IDs distintos.Mantener alias o mapa de documentos.
Prompt templateCambia el formato esperado por evaluadores.Versionar plantilla y scorecard.
ModeloCambia tokenizer, límites o tool calling.Registrar model_id, tokenizer_id y límites por release.
Base de datosCandidate escribe datos que baseline no entiende.Usar migración expand-contract.

La regla expand-contract es sencilla:

  1. Expandir: añadir lo nuevo sin retirar lo antiguo.
  2. Migrar: mover tráfico y consumidores progresivamente.
  3. Contraer: quitar lo antiguo cuando ya no se usa y hay evidencia.

Checklist mínimo antes de canary:

PreguntaRespuesta esperada
¿Baseline puede leer lo que candidate escribe?Sí, o candidate no escribe todavía.
¿Candidate puede leer datos antiguos?Sí.
¿El contrato JSON tiene versión?Sí, schema_version o equivalente.
¿Los dashboards separan baseline y candidate?Sí, por variant y release_id.
¿El rollback sabe qué hacer con datos nuevos?Sí, ignorar, transformar o bloquear.

Asignación estable por cohorte

Para repartir tráfico no basta con llamar a random(). Necesitamos estabilidad. Si una conversación está en candidate, debe seguir en candidate mientras dure la prueba. Si un tenant entra en baseline, no debería saltar de variante por cada request.

Una forma común es usar hash determinista:

b(c,f)=H(cf)mod10000100b(c, f) = \frac{H(c \Vert f) \bmod 10000}{100} variant(c)={candidatesi b(c,f)<w baselinesi b(c,f)wvariant(c) = \begin{cases} candidate & \text{si } b(c, f) < w \ baseline & \text{si } b(c, f) \ge w \end{cases}
SímboloSignificadoEjemplo
ccClave de cohorte.tenant_42, user_91, conversation_abc.
ffNombre de la bandera o release.support_rag_v19.
HHFunción hash estable.SHA-256, MurmurHash, xxHash.
b(c,f)b(c,f)Bucket entre 0 y 100.3,71.
wwPeso de tráfico para candidate.5%.

Si w=5w=5 y el bucket es 3,71, ese contexto entra en candidate. Si mañana subimos a 25%, entrarán contextos nuevos, pero quienes ya estaban en candidate seguirán ahí. Esa propiedad hace que el rollout sea más depurable.

Qué medir en cada paso

El canary de una app clásica suele mirar errores HTTP, latencia, CPU y quizá conversión. En IA necesitamos más señales:

SeñalQué pregunta responde
contract_fail_rate¿La salida sigue cumpliendo schema, tipos y campos?
quality_proxy¿La calidad online o muestreada no cae?
acceptance_rate¿La salida se acepta, se usa o evita revisión?
fallback_rate¿El sistema cae más en rutas alternativas?
cost_p95_eur¿El coste por run aceptada se mantiene?
latency_p95_ms¿La experiencia interactiva sigue dentro del SLO?
timeout_rate¿Suben timeouts o cortes de generación?
route_mix_delta¿El router deriva demasiado a rutas caras o lentas?
trace_completeness¿La versión candidata deja trazas suficientes?
manual_review_rate¿Aumenta la necesidad de revisión?

Estas señales no deberían vivir en un informe manual. Deben salir de métricas y trazas filtrables por release_id, variant, rollout_step, task y tenant. OpenTelemetry define las trazas como una forma de seguir el camino de una petición mediante spans relacionados.6 Sus convenciones para GenAI añaden atributos propios de sistemas generativos.7

Para alertar durante canary, no basta con “parece que sube algo”. El SRE Workbook de Google recomienda razonar con SLOs y presupuesto de error.8 El libro de SRE también recuerda que proteger un servicio bajo carga implica decidir qué trabajo aceptar, retrasar o rechazar.9

Consultas observables reales

Decir “mide latencia y coste” es insuficiente. Un equipo necesita consultas reproducibles. Prometheus define PromQL como un lenguaje funcional para seleccionar y agregar series temporales en tiempo real.10 Con etiquetas como release_id, variant, task y tenant_tier, puedes preguntar cosas útiles.

Tasa de fallos de contrato por variante:

sum(rate(ai_contract_fail_total{release_id="support-rag@1.9.0-rc1"}[10m])) by (variant)
/
sum(rate(ai_run_total{release_id="support-rag@1.9.0-rc1"}[10m])) by (variant)

Latencia p95 de candidate:

histogram_quantile(
  0.95,
  sum(rate(ai_run_latency_seconds_bucket{
    release_id="support-rag@1.9.0-rc1",
    variant="candidate"
  }[10m])) by (le, task)
)

Coste p95 por tarea:

histogram_quantile(
  0.95,
  sum(rate(ai_run_cost_eur_bucket{
    release_id="support-rag@1.9.0-rc1"
  }[30m])) by (le, task, variant)
)

Ratio de fallback:

sum(rate(ai_fallback_total{release_id="support-rag@1.9.0-rc1"}[15m])) by (variant)
/
sum(rate(ai_run_total{release_id="support-rag@1.9.0-rc1"}[15m])) by (variant)

Si además guardas eventos analíticos en una tabla, la pregunta de negocio se puede revisar con SQL:

select
  variant,
  task,
  count(*) as runs,
  avg(case when accepted_by_user then 1 else 0 end) as acceptance_rate,
  avg(cost_eur) as avg_cost_eur,
  percentile_cont(0.95) within group (order by latency_ms) as latency_p95_ms
from ai_release_runs
where release_id = 'support-rag@1.9.0-rc1'
  and created_at >= now() - interval '2 hours'
group by variant, task
order by task, variant;

La señal importante para un ingeniero: no basta con instrumentar. Hay que diseñar nombres, etiquetas y cardinalidad para que las consultas salgan limpias cuando la release esté viva.

Ejemplo de fórmula. Un gate de avance por paso puede escribirse así:

As=1[KsKmaxL95,sLmaxC95,sCmaxQsQminTsTmin]A_s = \mathbb{1}[ K_s \le K_{max} \land L_{95,s} \le L_{max} \land C_{95,s} \le C_{max} \land Q_s \ge Q_{min} \land T_s \ge T_{min} ]
SímboloSignificado
AsA_sDecisión de avanzar en el paso ss.
KsK_sTasa de fallos de contrato en el paso.
KmaxK_{max}Máximo tolerado de fallos de contrato.
L95,sL_{95,s}Latencia p95 observada.
LmaxL_{max}Límite p95 del SLO.
C95,sC_{95,s}Coste p95 por run aceptada.
CmaxC_{max}Presupuesto máximo permitido.
QsQ_sCalidad online, evaluación muestreada o proxy aceptado.
QminQ_{min}Calidad mínima para avanzar.
TsT_sProporción de trazas completas.
TminT_{min}Mínimo de trazabilidad aceptable.

Si As=1A_s = 1, se puede avanzar al siguiente porcentaje. Si As=0A_s = 0, no se avanza. Dependiendo del motivo, se pausa, se baja porcentaje o se vuelve a baseline.

Anatomía visual de un rollout progresivo de IA

Progressive delivery para IA: exponer, medir y volver sin improvisar El deployment deja la versión disponible; la release decide quién la recibe, con gates, cohortes y rollback preparado. Cambio aprobado scorecard del cap. 06 candidate versionada rollback descrito Control plane flags, targeting, pesos y pasos baseline · candidate · weight cohorte · tenant · tarea Router de runtime decide variante y registra evidencia bucket variant release_id en cada traza Baseline versión estable prompt_v12 · model_a Candidate versión nueva prompt_v13 · model_b Shadow run candidate procesa entradas reales sin afectar la salida sin escritura coste limitado diff trazable compara salida, ruta, tokens, coste y contrato Canary por pasos sube tráfico si cada tramo deja señales aceptables 1% 5% 25% 50% pause · medir · decidir · avanzar Guarded rollout métricas deciden si se avanza, pausa o vuelve contrato p95 coste SLO · traces · feedback · error budget Observabilidad por release cada run lleva atributos para filtrar y comparar atributo ejemplo release_id support-rag@1.9.0-rc1 variant baseline · candidate rollout_step 5% cohort_key tenant · task · conversation Decisión por paso el porcentaje no sube si alguna señal bloquea advance pause rollback decisión escrita en `rollout_decision.json` Rollback preparado volver por flag, config, ruta o índice candidate_weight=0 route_catalog=v31 prompt_v12 · index anterior · tool off Marca de agua editorial IA para gente curiosa / Facsímil 06 / Capítulo 07 / 686f6c61

Shadow run sin efectos persistentes

Shadow run significa que la candidata recibe entradas reales, pero su salida no se entrega al usuario. Esto permite comparar sin cambiar la experiencia principal. En IA hay que diseñarlo con cuidado.

Reglas para shadow:

ReglaMotivo
No ejecutar tools con escritura.Evita duplicar tickets, correos, pagos, publicaciones o cambios de estado.
Usar dry-run cuando exista.Permite validar argumentos sin tocar sistemas externos.
Marcar cada traza como shadow=true.Facilita filtrar métricas y coste.
Limitar coste por día.Shadow puede duplicar llamadas de modelo.
No mezclar salida sombra con feedback de usuario.El usuario no vio esa salida; no puede aceptarla ni rechazarla.
Comparar por caso pareado.La misma entrada debe tener baseline y candidate.

La salida sombra sí sirve para medir:

ComparaciónQué puede revelar
Respuesta finalCandidate responde más largo, más corto, más caro o menos grounded.
ContratoCandidate rompe JSON o cambia campos.
RoutingCandidate elige rutas más caras o lentas.
ToolsCandidate intenta una tool que baseline no necesitaba.
TokensCandidate consume más contexto o salida.
LatenciaCandidate empeora prefill, decode o validación.

Shadow no prueba aceptación real. Prueba compatibilidad con realidad. Es una fase de ingeniería, no una encuesta.

Canary para IA: qué segmentos elegir

El 1% global puede sonar prudente, pero no siempre enseña lo correcto. Si tu producto tiene tareas muy distintas, el canary debe segmentarse.

SegmentoCuándo usarloEjemplo
Por tareaCada tarea tiene riesgo y coste distintos.support_summary, policy_qa, json_extract.
Por tenantLos documentos o normas cambian por cliente.3 tenants internos antes de externos.
Por idiomaLa calidad puede variar mucho.Español primero si el dataset está mejor cubierto.
Por canalNo es igual chat interactivo que batch.Canary en batch antes que en chat.
Por contratoJSON estricto merece gates más duros.Activar solo en respuestas no estructuradas al principio.
Por criticidadTareas de bajo impacto primero.Resumen interno antes que acción externa.

Un buen canary no busca la muestra más cómoda. Busca la muestra más informativa sin ampliar demasiado el impacto.

Tamaño mínimo de muestra y duración

Una release progresiva necesita suficientes observaciones para no decidir por ruido. El número exacto depende de la varianza, la criticidad y el volumen, pero conviene declarar mínimos antes de empezar.

FactorQué cambia
Tráfico bajoNecesitas más tiempo para llegar a un mínimo útil.
Salida muy variableNecesitas más casos o segmentar por tarea.
Tarea críticaNecesitas gates más estrictos y porcentaje más pequeño.
Coste altoShadow y canary deben tener presupuesto diario.
Contrato estrictoUn fallo puede pesar más que una media de calidad.
Tenants heterogéneosEl 1% global puede no representar a nadie.

Una pauta razonable para empezar:

PasoMínimo orientativoDuración mínimaQué miraría antes de avanzar
Shadow100-300 runs pareadas.1 ciclo de carga real.Diffs, contrato, coste y rutas.
Canary 1%200-500 runs.30-120 min según tráfico.Contrato, p95, fallback y trazas.
Canary 5%500-1500 runs.1-4 h.Segmentos por tarea y tenant.
Canary 25%1000-5000 runs.Medio día o 1 ciclo de uso.Presupuesto de error y coste p95.
Canary 50%Depende del negocio.1 ciclo relevante.Comparación estable y sin regresiones nuevas.

Esto no sustituye estadística formal. Es una forma de no fingir precisión: si solo tienes 12 runs, una tasa del 0% no significa “cero problemas”; significa “no hemos mirado bastante”.

Rollback no siempre es volver código atrás

En IA, rollback tiene varias capas. A veces no necesitas revertir todo el deployment. Basta con cambiar una bandera o restaurar una configuración.

CapaCómo vuelves
Promptprompt_version=prompt_v12.
Modelomodel_id=model_a o ruta anterior.
RAGindex_version=rag_index_2026_05.
Routerroute_catalog=route_catalog@31.
Tooltool_enabled=false o mode=read_only.
Contratoresponse_schema=schema_v4.
RuntimeEnviar tráfico al pool anterior.
CódigoRevertir deployment o cambiar imagen.

La decisión entre rollback y roll forward depende de la causa:

SituaciónMejor opción
Cambio de prompt rompe contrato.Rollback de prompt o schema gate.
Nuevo índice RAG trae documentos incorrectos.Rollback de índice.
Runtime nuevo tiene p95 malo.Volver al pool anterior.
Falla una condición menor y hay fix claro.Roll forward con parche y canary nuevo.
No sabemos la causa.Bajar a baseline, preservar trazas y analizar.

La frase importante: rollback no es castigo; es una capacidad de diseño.

Kill switch: parar sin reunión

Un kill switch es una decisión operativa preparada para detener una capacidad sin desplegar código. En IA generativa puede apagar una tool, bajar una variante a 0%, forzar modelo baseline o cambiar a modo solo lectura.

CampoPara qué sirve
enabledPermite cortar una capacidad completa.
candidate_weightBaja exposición sin tocar deployment.
read_onlyMantiene lectura y bloquea escritura.
fallback_modelFuerza proveedor o modelo estable.
max_cost_eur_per_runCorta rutas caras antes de agotar presupuesto.
reasonDeja trazabilidad de la decisión.
expires_atEvita que un apagado temporal se quede olvidado.

Plantilla mínima:

release_id: support-rag@1.9.0-rc1
controls:
  enabled: true
  candidate_weight: 5
  read_only: false
  fallback_model: model_a
  max_cost_eur_per_run: 0.04
  kill_switch:
    enabled: false
    reason: null
    owner: ai-platform
    expires_at: null

Ensayo obligatorio antes del canary:

  1. Activar kill_switch.enabled=true en staging.
  2. Comprobar que todo tráfico va a baseline.
  3. Comprobar que no se ejecutan tools de escritura.
  4. Ver en trazas kill_switch=true.
  5. Volver a activar candidate y verificar que el sistema recupera el comportamiento esperado.

Lifecycle de flags: limpiar también es operar

Los flags son útiles, pero también crean deuda si se quedan para siempre. LaunchDarkly documenta un ciclo de vida de flags con estados como live, ready for code removal, ready to archive, archived, deprecated y deleted, y recomienda planificar la retirada durante la creación del flag.11

Un flag de IA debería nacer con metadatos:

MetadatoEjemploMotivo
ownerai-platformAlguien responde por la bandera.
created_at2026-05-28Permite detectar flags antiguos.
expected_removal2026-06-15La retirada se planifica desde el inicio.
permanentfalseDistingue rollout temporal de interruptor permanente.
release_idsupport-rag@1.9.0-rc1Une flag, trazas, scorecard y decisión.
cleanup_issueOPS-1842La limpieza entra en el backlog real.

Estados recomendados:

EstadoQué significaAcción
plannedEstá definido, pero no se evalúa todavía.Revisar contrato y rollback.
shadowCandidate se ejecuta sin salida visible.Medir coste y compatibilidad.
canaryCandidate recibe parte del tráfico.Vigilar gates.
launchedCandidate ya es comportamiento por defecto.Abrir tarea de retirada si era temporal.
cleanupEl flag ya no decide nada útil.Quitar código muerto y archivar.
permanent_controlSe conserva como interruptor.Documentar quién puede cambiarlo.

La disciplina no termina cuando el rollout llega al 100%. Termina cuando el código, los dashboards y la documentación dejan de arrastrar caminos que nadie usa.

Manos a la obra

Práctica: controlador de rollout progresivo.

Kit ejecutable de este capítulo: kit descargable.

# Descomprime el ZIP del capítulo y ejecuta estos comandos dentro de esa carpeta
python3 ops/run_f6_practices.py --chapter c07 --write --fail-on-invalid

Vamos a construir un controlador mínimo que decide si un rollout avanza, se pausa o vuelve al baseline. No llama a Kubernetes, LaunchDarkly ni Argo. Eso vendría después. Primero queremos dominar la lógica.

Guárdalo como ops/ai/progressive_rollout.py.

from __future__ import annotations

import hashlib
import json
from dataclasses import asdict, dataclass


@dataclass(frozen=True)
class RolloutStep:
    name: str
    candidate_weight: int
    min_runs: int
    max_contract_fail_rate: float
    max_latency_p95_ms: int
    max_cost_p95_eur: float
    min_quality_score: float
    min_trace_rate: float


@dataclass(frozen=True)
class Observation:
    step: str
    runs: int
    contract_fail_rate: float
    latency_p95_ms: int
    cost_p95_eur: float
    quality_score: float
    trace_rate: float


STEPS = [
    RolloutStep("shadow", 0, 100, 0.010, 4500, 0.035, 0.86, 0.99),
    RolloutStep("canary-1", 1, 200, 0.008, 4500, 0.035, 0.87, 0.99),
    RolloutStep("canary-5", 5, 500, 0.006, 4300, 0.033, 0.88, 0.99),
    RolloutStep("canary-25", 25, 1000, 0.004, 4200, 0.031, 0.88, 0.995),
    RolloutStep("canary-50", 50, 2000, 0.003, 4000, 0.030, 0.89, 0.995),
]

OBSERVATIONS = [
    Observation("shadow", 160, 0.000, 3100, 0.024, 0.90, 1.000),
    Observation("canary-1", 240, 0.004, 3200, 0.025, 0.90, 0.996),
    Observation("canary-5", 620, 0.005, 3500, 0.027, 0.895, 0.995),
    Observation("canary-25", 1120, 0.009, 4100, 0.036, 0.892, 0.997),
]


def bucket(flag_key: str, cohort_key: str) -> float:
    digest = hashlib.sha256(f"{flag_key}:{cohort_key}".encode("utf-8")).hexdigest()
    return (int(digest[:8], 16) % 10000) / 100


def variant_for(flag_key: str, cohort_key: str, candidate_weight: int) -> str:
    return "candidate" if bucket(flag_key, cohort_key) < candidate_weight else "baseline"


def evaluate(step: RolloutStep, obs: Observation) -> dict[str, object]:
    checks = {
        "enough_runs": obs.runs >= step.min_runs,
        "contract_ok": obs.contract_fail_rate <= step.max_contract_fail_rate,
        "latency_ok": obs.latency_p95_ms <= step.max_latency_p95_ms,
        "cost_ok": obs.cost_p95_eur <= step.max_cost_p95_eur,
        "quality_ok": obs.quality_score >= step.min_quality_score,
        "trace_ok": obs.trace_rate >= step.min_trace_rate,
    }
    failed = [name for name, ok in checks.items() if not ok]

    if failed and ("contract_ok" in failed or "cost_ok" in failed):
        decision = "rollback_to_baseline"
    elif failed:
        decision = "pause_and_collect_more_evidence"
    else:
        decision = "advance_to_next_step"

    return {
        "step": asdict(step),
        "observation": asdict(obs),
        "checks": checks,
        "failed_checks": failed,
        "decision": decision,
    }


def simulate_assignments(candidate_weight: int, contexts: int = 1000) -> dict[str, int]:
    counts = {"baseline": 0, "candidate": 0}
    for i in range(contexts):
        tenant = f"tenant_{i:04d}"
        counts[variant_for("support_rag_v19", tenant, candidate_weight)] += 1
    return counts


def run_rollout() -> dict[str, object]:
    by_step = {obs.step: obs for obs in OBSERVATIONS}
    decisions = []

    for step in STEPS:
        obs = by_step.get(step.name)
        if obs is None:
            decisions.append({"step": step.name, "decision": "waiting_for_observations"})
            break

        report = evaluate(step, obs)
        report["assignment_sample"] = simulate_assignments(step.candidate_weight)
        decisions.append(report)

        if report["decision"] != "advance_to_next_step":
            break

    return {
        "release_id": "support-rag@1.9.0-rc1",
        "baseline": "support-rag@1.8.0",
        "flag_key": "support_rag_v19",
        "decisions": decisions,
        "final_decision": decisions[-1]["decision"],
    }


if __name__ == "__main__":
    print(json.dumps(run_rollout(), ensure_ascii=False, indent=2))

Ejecuta:

python ops/ai/progressive_rollout.py

Salida esperada resumida:

"step": "canary-25"
"failed_checks": [
  "contract_ok",
  "cost_ok"
]
"decision": "rollback_to_baseline"
"final_decision": "rollback_to_baseline"

Qué enseña este script:

PiezaAprendizaje
bucket()Repartir tráfico de forma estable por cohorte.
candidate_weightSubir exposición sin cambiar código.
RolloutStepDeclarar límites por tramo.
ObservationSeparar política de señales medidas.
evaluate()Convertir métricas en decisión operativa.
simulate_assignments()Comprobar que el reparto se aproxima al porcentaje esperado.

El caso está diseñado para algo muy real: shadow, 1% y 5% pasan; al 25% aparecen fallos de contrato y coste p95. La decisión profesional no es “sigamos un poco más”. Es volver a baseline, conservar trazas y abrir trabajo con casos concretos.

Qué te llevas para poner en práctica

Este capítulo debería dejarte algo más que una idea bonita. Al terminarlo, el alumno puede construir un paquete mínimo de release progresiva para cualquier sistema de IA: un chatbot interno, un RAG de soporte, un extractor JSON, un agente con tools o un servicio de inferencia propio.

ArtefactoPara qué sirve en un proyecto realQué debería contener
rollout_plan.jsonDeclarar la release antes de tocar tráfico.Baseline, candidate, flag, cohorte, pasos, gates y rollback.
progressive_rollout.pySimular y automatizar la decisión.Reparto estable, métricas de entrada y decisión de avanzar, pausar o volver.
Consultas PromQL/SQLMirar producción con preguntas concretas.Contrato, p95, coste, fallback, aceptación y trazas por variante.
rollback.mdVolver sin improvisar.Pasos exactos para restaurar prompt, modelo, índice, ruta y porcentaje.
kill_switch.yamlCortar una capacidad desde configuración.enabled, candidate_weight, read_only, fallback y owner.
decision.mdDejar rastro técnico de la decisión.Paso evaluado, evidencia, decisión y caso de regresión creado.
post-release-review.mdConvertir la release en aprendizaje.Métricas finales, segmentos afectados, limpieza y cambios al dataset.

Práctica recomendada para clase o trabajo individual:

  1. Elige un cambio concreto: prompt nuevo, modelo nuevo, índice RAG nuevo o tool nueva.
  2. Escribe baseline y candidate con nombres versionados.
  3. Decide la cohorte: tenant_id, user_id, conversation_id o task.
  4. Define pasos: shadow, 1%, 5%, 25%, 50%.
  5. Escribe tres gates mínimos: contrato, latencia p95 y coste p95.
  6. Añade una consulta observable por cada gate.
  7. Ejecuta el script con una observación que pase y otra que obligue a volver.
  8. Escribe decision.md como si fueras a enseñárselo a tu equipo.

El resultado práctico no es “saber qué es canary”. Es poder abrir un repositorio y dejar preparada una release que alguien pueda revisar, ejecutar y revertir.

Kit operativo: una release progresiva en un repo real

Para que esto salga del libro, crea estos archivos:

mi-proyecto/
  ops/
    ai/
      progressive_rollout.py
      rollout_plan.json
      kill_switch.yaml
      rollback.md
      post-release-review.md
  output/
    rollout_decision.json
  .github/
    workflows/
      progressive-release.yml

ops/ai/rollout_plan.json:

{
  "release_id": "support-rag@1.9.0-rc1",
  "baseline": "support-rag@1.8.0",
  "candidate": "support-rag@1.9.0-rc1",
  "flag_key": "support_rag_v19",
  "cohort_key": "tenant_id",
  "steps": ["shadow", "canary-1", "canary-5", "canary-25", "canary-50"],
  "rollback": {
    "candidate_weight": 0,
    "prompt_version": "prompt_v12",
    "route_catalog": "route_catalog@31",
    "rag_index": "rag_index_2026_05"
  }
}

Una versión más completa como manifiesto de release:

release_id: support-rag@1.9.0-rc1
owner: ai-platform
created_at: "2026-05-28"
baseline:
  app_version: support-rag@1.8.0
  prompt_version: prompt_v12
  model_id: model_a
  rag_index: rag_index_2026_05
candidate:
  app_version: support-rag@1.9.0-rc1
  prompt_version: prompt_v13
  model_id: model_b
  rag_index: rag_index_2026_06
cohort:
  key: tenant_id
  hash: sha256
  salt: support_rag_v19
steps:
  - name: shadow
    weight: 0
    min_runs: 300
    min_duration: 2h
  - name: canary-1
    weight: 1
    min_runs: 500
    min_duration: 2h
  - name: canary-5
    weight: 5
    min_runs: 1500
    min_duration: 4h
gates:
  contract_fail_rate_max: 0.006
  latency_p95_ms_max: 4300
  cost_p95_eur_max: 0.033
  quality_score_min: 0.88
  trace_rate_min: 0.99
controls:
  kill_switch:
    enabled: false
    owner: ai-platform
  read_only_tools: true
rollback:
  candidate_weight: 0
  prompt_version: prompt_v12
  model_id: model_a
  route_catalog: route_catalog@31
  rag_index: rag_index_2026_05
cleanup:
  expected_flag_removal: "2026-06-15"
  cleanup_issue: OPS-1842

ops/ai/rollback.md:

# Rollback de support-rag@1.9.0-rc1

## Cuándo se ejecuta

- Fallo de contrato por encima del umbral.
- Coste p95 por encima del presupuesto.
- Latencia p95 fuera de SLO.
- Trazas incompletas que impiden depurar.

## Cómo se vuelve

1. Poner `candidate_weight=0`.
2. Restaurar `prompt_version=prompt_v12`.
3. Restaurar `route_catalog=route_catalog@31`.
4. Restaurar `rag_index=rag_index_2026_05`.
5. Verificar que `release_id` nuevo aparece en trazas.
6. Crear casos de regresión con las trazas fallidas.

Workflow mínimo:

name: progressive-release

on:
  workflow_dispatch:

jobs:
  decide-rollout:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - name: Decide rollout step
        run: python ops/ai/progressive_rollout.py > output/rollout_decision.json
      - name: Upload rollout decision
        uses: actions/upload-artifact@v4
        with:
          name: rollout-decision
          path: output/rollout_decision.json

Qué debería entregar un alumno:

EntregableQué revisaría
progressive_rollout.pyAsignación estable, gates y decisión clara.
rollout_plan.jsonBaseline, candidate, pasos, cohorte y rollback.
rollback.mdProcedimiento concreto, no deseo genérico.
rollout_decision.jsonEvidencia de avance, pausa o vuelta.
decision.mdExplicación: por qué ese paso avanza o no.

Plantilla de decision.md:

# Decisión de rollout

## Estado

La release [avanza / se pausa / vuelve a baseline].

## Paso evaluado

- Release:
- Peso candidato:
- Runs observadas:
- Cohorte:

## Señales

- Contrato:
- Latencia p95:
- Coste p95:
- Calidad:
- Trazas:

## Decisión técnica

Explica por qué se toma esta decisión y qué cambio se hará ahora.

## Caso de regresión nuevo

Si se vuelve o pausa por fallo real, describe qué caso entra en `evals/regression.jsonl`.

Cómo adaptarlo:

ProyectoAdaptación
API de modelosEl flag elige proveedor/modelo/prompt.
RAGEl flag elige índice, chunker, reranker o top_k.
AgenteEl flag activa tool, handoff o política de permisos.
Serving localEl flag cambia pool, cuantización o runtime.
Producto multi-tenantLa cohorte debería ser tenant, no request.

Post-release review

Cuando una release llega al 100%, todavía queda trabajo de ingeniería. La revisión posterior evita que el equipo pierda aprendizaje.

PreguntaEvidencia que debería existir
¿Qué cambió realmente?Diff de prompt, modelo, índice, schema, rutas y config.
¿Qué aprendimos en shadow?Diffs representativos y regresiones nuevas.
¿Qué segmentos sufrieron más?Métricas por tarea, tenant, idioma o canal.
¿Qué gate fue más útil?Señal que habría detectado el problema antes.
¿Qué alerta sobró?Ruido que no cambió decisiones.
¿Qué queda por limpiar?Flags, dashboards, rutas antiguas, prompts y docs.
¿Qué entra al dataset?Casos reales convertidos en evaluación de regresión.

Plantilla corta:

# Post-release review

## Release

- Release:
- Fecha:
- Owner:
- Baseline:
- Candidate:

## Decisión final

La release terminó como [100% / rollback / roll forward / pausa].

## Evidencia

- Runs totales:
- Segmentos revisados:
- Métrica que más pesó:
- Incidencias detectadas:

## Aprendizaje técnico

Qué cambiaríamos en el próximo rollout.

## Acciones

- [ ] Añadir casos a evals.
- [ ] Retirar flag temporal.
- [ ] Actualizar runbook.
- [ ] Ajustar alerta o dashboard.

Cómo encaja todo

flowchart TD
  A["Cambio aprobado por EvalOps"] --> B["Deployment: versión disponible"]
  B --> C["Feature flag o política de release"]
  C --> D["Dark launch"]
  D --> E["Shadow run sin efectos persistentes"]
  E --> F{"¿Shadow deja señales aceptables?"}
  F -- "no" --> R["Rollback de configuración"]
  F -- "sí" --> G["Canary por cohorte estable"]
  G --> H["Métricas por release_id y variant"]
  H --> I{"¿Gate de paso pasa?"}
  I -- "sí" --> J["Subir peso: 1 · 5 · 25 · 50 · 100"]
  J --> H
  I -- "no" --> K{"¿Causa clara y fix pequeño?"}
  K -- "sí" --> L["Roll forward con nueva candidata"]
  K -- "no" --> R
  R --> M["Crear caso de regresión y decisión escrita"]

  subgraph F6["Facsímil 6"]
    O["Cap. 04 observabilidad"]
    P["Cap. 05 routing"]
    Q["Cap. 06 EvalOps"]
  end

  O -. "mide" .-> H
  P -. "decide ruta" .-> C
  Q -. "autoriza candidata" .-> A

Relación con otros capítulos

Este capítulo no sustituye a EvalOps. Lo continúa.

CapítuloQué aporta aquí
F6 · Capítulo 01Manifest, versión, release gate y rollback como idea de sistema operable.
F6 · Capítulo 04SLIs, SLOs, métricas, trazas y alertas para decidir.
F6 · Capítulo 05Router, rutas, presupuestos, shadow routing y canary de políticas.
F6 · Capítulo 06Datasets, gates y scorecard antes de exponer tráfico.
F5 · Capítulo 08Revisión humana y permisos cuando una acción requiere aprobación.

La cadena completa queda así: EvalOps decide que la candidata merece exposición; progressive delivery decide cómo se expone; observabilidad decide si sigue avanzando; rollback conserva una salida de vuelta.

Para entenderlo

Tres escenas sencillas:

SituaciónMala decisiónDecisión progresiva
Nuevo prompt más claro100% de tráfico el lunes.Shadow, 1%, revisar contrato y subir si p95/coste aguantan.
Nuevo índice RAGSustituir índice antiguo.Candidate usa índice nuevo en shadow y compara fuentes recuperadas.
Nuevo modelo baratoCambiar proveedor por defecto.Canary por tareas de baja criticidad, midiendo calidad y fallbacks.

No es miedo al cambio. Es respeto por la operación.

Vocabulario aprendido

TérminoDefinición operativaError típico
DeploymentLa versión está instalada o disponible.Creer que ya está expuesta al usuario.
ReleaseLa versión está recibiendo tráfico o afectando decisiones.Publicarla al 100% sin gates intermedios.
Dark launchLa versión se despliega sin uso visible.Confundir disponibilidad con validación.
Shadow runLa candidata procesa entradas reales sin producir efectos persistentes.Dejar que duplique emails, tickets o escrituras.
CanaryExposición pequeña y medible a una cohorte estable.Usar tráfico aleatorio por request y mezclar señales.
Cohorte estableGrupo asignado por hash de tenant, usuario, conversación o tarea.Cambiar variante en cada llamada y romper comparaciones.
Kill switchControl para apagar una capacidad sin redesplegar.Tener que tocar código durante una incidencia.
RollbackVuelta a una versión conocida y verificada.Pensar que solo es revertir código, olvidando prompt, índice o ruta.
Roll forwardNueva candidata que corrige el problema sin volver atrás.Usarlo sin evidencia suficiente porque “parece pequeño”.
Blast radiusPorción de usuarios, tenants o tareas expuesta al cambio.Medir solo porcentaje global y no criticidad.

Dónde solía tropezar yo

Me costó entender que canary no es una fase decorativa. Si no hay métricas, cohortes, gates y rollback, solo has cambiado el tamaño del salto.

TropiezoAntídoto
Confundir deployment y release.Preguntar: ¿la versión está disponible o expuesta?
Usar random() por request.Asignar por hash estable de cohorte.
Hacer shadow con tools de escritura.Dry-run, solo lectura o bloqueo explícito.
Mirar solo errores técnicos.Añadir contrato, coste, calidad, fallback y trazas.
No probar rollback.Ensayar vuelta antes de subir tráfico.

La frase que me habría ahorrado muchos sustos: un rollout no es una escalera automática; es una serie de decisiones con evidencia.

Antes de pasar página

Comprueba que puedes responder:

  1. ¿Cuál es la diferencia entre deployment y release?
  2. ¿Por qué un feature flag ayuda a publicar cambios de IA?
  3. ¿Qué debe evitar un shadow run para no duplicar efectos persistentes?
  4. ¿Por qué conviene asignar canary por cohorte estable?
  5. ¿Qué señales mirarías en un canary de RAG?
  6. ¿Qué capas puede tocar un rollback en IA además del código?
  7. ¿Qué archivo entregarías para demostrar que el rollback está preparado?

En resumen

IdeaPara llevarte
Deployment y release no son lo mismo.Puedes tener una versión desplegada pero no expuesta.
Shadow reduce incertidumbre.Compara con entradas reales sin afectar la salida visible.
Canary debe tener gates.Cada porcentaje necesita métricas, duración, dueño y criterio de avance.
Rollback es diseño.Volver por flag, prompt, modelo, índice o ruta debe estar probado antes.

Para saber más

Notas

  1. OpenFeature. (2026). Introduction. https://openfeature.dev/docs/reference/intro/. Consultado el 28 de mayo de 2026.

  2. Kubernetes. (2026). Deployments. https://kubernetes.io/docs/concepts/workloads/controllers/deployment/. Consultado el 28 de mayo de 2026.

  3. Argo Project. (2026). Canary Deployment Strategy. https://argoproj.github.io/argo-rollouts/features/canary/. Consultado el 28 de mayo de 2026.

  4. LaunchDarkly. (2026). Releasing features with LaunchDarkly. https://launchdarkly.com/docs/home/releases/releasing. Consultado el 28 de mayo de 2026.

  5. Google Cloud. (2026). Use a canary deployment strategy. https://cloud.google.com/deploy/docs/deployment-strategies/canary. Consultado el 28 de mayo de 2026.

  6. OpenTelemetry. (2026). Traces. https://opentelemetry.io/docs/concepts/signals/traces/. Consultado el 27 de mayo de 2026.

  7. OpenTelemetry. (2026). Semantic Conventions for Generative AI Systems. https://opentelemetry.io/docs/specs/semconv/gen-ai/. Consultado el 27 de mayo de 2026.

  8. Wilkinson, J. (2018). Alerting on SLOs. En B. Beyer, N. R. Murphy, D. Rensin, K. Kawahara y S. Thorne (eds.), The Site Reliability Workbook. https://sre.google/workbook/alerting-on-slos/. Consultado el 27 de mayo de 2026.

  9. Beyer, B., Jones, C., Petoff, J. y Murphy, N. R. (2016). Handling Overload. En Site Reliability Engineering. https://sre.google/sre-book/handling-overload/. Consultado el 27 de mayo de 2026.

  10. Prometheus. (2026). Querying Basics. https://prometheus.io/docs/prometheus/latest/querying/basics/. Consultado el 28 de mayo de 2026.

  11. LaunchDarkly. (2026). Reducing Technical Debt from Feature Flags. https://launchdarkly.com/docs/guides/flags/technical-debt. Consultado el 28 de mayo de 2026.

Capítulo 08

Facsímil 6 · Construir y operar

Capítulo 08: Handoffs operativos y revisión humana

Qué deberías poder hacer al terminar

En el capítulo 07 aprendimos a exponer cambios poco a poco. Ahora aparece una situación igual de importante: qué ocurre cuando una run no debería seguir sola.

Un sistema de IA real no vive solo en el modelo. Vive en colas, tickets, contratos, herramientas, permisos, revisiones, trazas y decisiones. A veces el sistema puede responder. A veces debe abstenerse. A veces debe preparar una acción, pero no ejecutarla. Y a veces debe entregar el caso a una persona con todo lo necesario para decidir sin reconstruir la historia desde cero.

Al terminar, deberías poder hacer esto:

Resultado de aprendizajeEvidencia de que lo sabes hacer
Diseñar un handoff operativo.Sabes qué campos mínimos debe entregar una run al pausarse.
Decidir cuándo pedir revisión.Usas reglas por confianza, efecto, coste, contrato, evidencia y permisos.
Construir una cola de revisión.Priorizas casos por impacto, antigüedad, SLA y falta de evidencia.
Diseñar una tarjeta de decisión.La persona ve entrada, propuesta, fuentes, trazas, coste y acciones disponibles.
Reanudar una run.Sabes continuar con approve, reject o needs_more_info sin perder estado.
Medir la cola.Defines SLI, SLO, backlog, aging, tasa de acuerdo y coste humano.
Convertir revisión en aprendizaje.Los casos revisados vuelven a evals, prompts, contratos y runbooks.

La idea central: pedir revisión no es fracasar; fracasar es pedirla sin contexto, sin prioridad y sin reanudación clara.

Cuando el sistema no debe seguir solo

Imagina un asistente interno que prepara respuestas para soporte universitario. La mayoría de preguntas son sencillas: horarios, plazos, enlaces, estado de una solicitud. Pero un día llega un caso con documentos incompletos, una norma que ha cambiado, un dato que contradice el expediente y una respuesta propuesta que podría cerrar el ticket.

El sistema puede tener una respuesta plausible. Eso no basta. La pregunta operativa es otra:

¿Tenemos evidencia suficiente para que esta acción siga automáticamente, o debemos entregar el caso a revisión?

Un handoff operativo evita dos extremos malos. El primero: automatizar de más y descubrir tarde que faltaba una condición. El segundo: mandar todo a revisión y convertir el sistema en una bandeja de espera. La ingeniería está en diseñar el punto medio.

Qué no es revisión humana

Revisión humana no es añadir un botón de “aprobar” al final de una pantalla. Si la persona no ve el motivo, los datos usados, la salida propuesta, las fuentes, los límites y el efecto de cada botón, está firmando a oscuras.

Tampoco es revisar todo. Si cada respuesta trivial pide intervención, la cola crece, el tiempo de respuesta empeora y la revisión pierde valor. La persona debe entrar donde aporta criterio: falta evidencia, hay efecto persistente, la confianza no alcanza el umbral, el contrato falla, el coste se sale del presupuesto o la acción afecta a un recurso sensible.

Y no es “que lo decida soporte” como cajón de sastre. Una cola sin SLO, prioridad, dueño y estado es deuda operativa. El sistema debe explicar qué necesita y qué pasará después de la decisión.

ConfusiónQué falta
“Lo revisa una persona y ya está”Falta evidencia estructurada, estado y reanudación.
“Todo lo dudoso va a revisión”Falta priorización y criterios de entrada.
“La persona corrige a mano”Falta convertir la corrección en dataset, regla o cambio de contrato.
“Aprobar es pulsar sí”Falta mostrar efecto, alcance y alternativa.
“La cola es una bandeja”Falta SLI, SLO, aging, backlog y dueño.

Qué sí es un handoff operativo

Un handoff operativo es una pausa con contrato. La run entrega un paquete de evidencia, queda en un estado recuperable y espera una decisión explícita.

Ejemplo de fórmula. Podemos representarlo así:

H=(x,y,a,e,r,q,t,s)H = (x, y, a, e, r, q, t, s)
SímboloSignificadoEjemplo
xxEntrada original.Pregunta del usuario y documentos consultados.
yySalida propuesta.Respuesta, JSON, diff o acción preparada.
aaAcción pendiente.Enviar, publicar, actualizar, derivar, cerrar ticket.
eeEvidencia disponible.Fuentes, trazas, validaciones, métricas, contrato.
rrRazón de revisión.Baja confianza, efecto persistente, falta de fuente, coste alto.
qqCola o persona responsable.soporte_n2, legal_ops, ai_platform.
ttTiempo objetivo de resolución.30 minutos, 4 horas, 1 día laboral.
ssEstado serializable de la run.run_state, trace_id, resume_token.

El sistema no debería decir solo “necesita revisión”. Debería decir: “necesita revisión por estas razones, con esta evidencia, antes de este tiempo, y estas son las decisiones posibles”.

Fecha de corte del estado del arte

Fecha de corte: 28 de mayo de 2026.
Fuentes consultadas: documentación oficial de OpenAI Agents SDK sobre human-in-the-loop y handoffs; documentación de Anthropic Claude Agent SDK sobre permisos, hooks y aprobaciones; documentación de Google ADK sobre callbacks y controles para agentes; LangGraph interrupts; OpenTelemetry Traces; NIST AI RMF; y trabajos académicos sobre ingeniería de ML, model cards y datasheets.

OpenAI Agents SDK describe un flujo donde una herramienta puede marcarse como pendiente de aprobación, la ejecución se pausa con interrupciones, el estado de la run se serializa y después se reanuda con aprobación o rechazo.1 También documenta handoffs como mecanismo para transferir control entre agentes especializados.2

Anthropic documenta permisos, reglas de allow/deny, modos de permiso, hooks y callbacks de aprobación para controlar cuándo una herramienta se ejecuta, se bloquea o pide una decisión.345

Google ADK presenta callbacks para observar, personalizar y controlar el comportamiento de agentes, y recomienda tratar los agentes como sistemas con controles explícitos, evaluación y límites.67 LangGraph usa interrupts para pausar grafos, pedir decisión humana y reanudar desde un checkpoint.8

Lo estable no es una API concreta. Lo estable es el patrón: detectar, pausar, empaquetar evidencia, decidir, reanudar, registrar y aprender.

Cuándo disparar revisión

Un buen sistema no pregunta a una persona por vergüenza. Pregunta porque una regla dice que ahí la revisión tiene valor.

DisparadorSeñal concretaDecisión típica
Baja confianzaconfidence < 0.72.Revisar antes de responder.
Contrato rotoJSON inválido, campo obligatorio ausente, enum inesperado.Bloquear y pedir corrección.
Falta de evidenciaNo hay fuente, cita o documento suficiente.Pedir más información.
Fuentes en tensiónDos documentos sostienen respuestas distintas.Revisar con expediente completo.
Efecto persistenteEnviar, publicar, cerrar, modificar o borrar.Aprobar antes de ejecutar.
Coste altoLa ruta supera presupuesto por tarea.Revisar si merece usar ruta cara.
Permiso insuficienteLa tool requiere scope que la run no tiene.Derivar a persona responsable.
Cola saturadaEl sistema entraría en demora no aceptable.Responder con estado claro o degradar.
CanaryCandidate toca casos nuevos.Muestrear revisión para aprender.
Usuario lo pideLa persona quiere contacto humano.Crear handoff explícito.

La regla práctica: cada disparador debe tener una salida concreta. Si una condición solo dice “mirar”, no es una política; es una nota suelta.

Fórmula sencilla de decisión

Ejemplo de fórmula. Podemos decidir revisión comparando el coste esperado de automatizar contra el coste de revisar:

R(x)={reviewsi pf(x)Cf(x)>Ch(x)+Cd(x) autosi pf(x)Cf(x)Ch(x)+Cd(x)R(x) = \begin{cases} \text{review} & \text{si } p_f(x) \cdot C_f(x) > C_h(x) + C_d(x) \ \text{auto} & \text{si } p_f(x) \cdot C_f(x) \le C_h(x) + C_d(x) \end{cases}
SímboloSignificadoEjemplo
R(x)R(x)Decisión para el caso xx.review o auto.
pf(x)p_f(x)Probabilidad estimada de fallo relevante.0,18.
Cf(x)C_f(x)Coste si el sistema se equivoca.80 EUR equivalentes o impacto operativo alto.
Ch(x)C_h(x)Coste de revisión humana.6 minutos de una persona.
Cd(x)C_d(x)Coste de demora.Retraso sobre SLA o experiencia.

Ejemplo numérico:

VariableValor
pf(x)p_f(x)0,18
Cf(x)C_f(x)80
Ch(x)C_h(x)6
Cd(x)C_d(x)3

Calculamos:

pf(x)Cf(x)=0,1880=14,4p_f(x) \cdot C_f(x) = 0{,}18 \cdot 80 = 14{,}4 Ch(x)+Cd(x)=6+3=9C_h(x) + C_d(x) = 6 + 3 = 9

Como 14,4>914{,}4 > 9, revisamos. No porque “dé miedo”, sino porque el coste esperado de seguir automáticamente supera el coste de parar y decidir mejor.

Esta fórmula no sustituye criterio profesional. Lo hace discutible. Si alguien quiere cambiar el umbral, tiene que hablar de probabilidades, costes, demoras y evidencia.

Anatomía visual de un handoff operativo

Handoff operativo: pausar con evidencia, decidir y reanudar La revisión humana funciona cuando la run entrega estado, motivo, evidencia, opciones y trazabilidad. Run de IA input · contexto · tools salida propuesta trace_id · run_state Policy engine decide auto · review · stop contrato confianza coste · permisos · SLA Evidence bundle lo mínimo para decidir sin reconstruir entrada propuesta fuentes traza Cola de revisión prioridad, SLA y dueño impacto · edad · evidencia owner · deadline Persona revisora aprueba rechaza pide datos reasigna Disparadores motivos explícitos para no seguir solo contrato roto falta evidencia coste alto efecto persistente cada disparador produce una acción Tarjeta de decisión la interfaz no oculta el efecto acción propuesta fuentes usadas coste y SLA botones permitidos send_email doc_14 · trace_7 0,018 EUR · 30 min approve · reject · info Decision record decisión firmada y reutilizable decision · reviewer · reason accepted_output · rejected_args dataset_candidate · runbook_link Reanudación la run continúa con estado approve reject needs_more_info Observabilidad la cola también tiene SLIs y SLOs review_wait_p95 backlog_size agreement_rate tiempo de espera casos pendientes consistencia Aprendizaje cada revisión alimenta el sistema eval regression prompt patch runbook Gobierno operativo quién decide y cómo se audita owner scope auditoría Marca de agua editorial IA para gente curiosa / Facsímil 06 / Capítulo 08 / 686f6c61

El contrato mínimo del handoff

Un handoff serio debería poder serializarse. Si no puede guardarse, moverse de cola, auditarse y reanudarse, dependerá demasiado de la memoria de una persona.

Un contrato útil contiene:

CampoMotivoEjemplo
request_idIdentifica la revisión.rev_20260528_001.
run_idUne revisión con ejecución.run_7f31.
trace_idPermite abrir la traza completa.trace_abc.
taskAgrupa métricas por caso de uso.support_reply.
proposed_actionDice qué se quiere hacer.send_email.
effectDescribe impacto técnico.external_message, db_update, none.
reason_codesExplica por qué se revisa.low_confidence, missing_source.
evidenceResume pruebas disponibles.Fuentes, validaciones, diffs, score.
missing_evidenceDice qué falta.policy_doc, user_confirmation.
optionsLimita decisiones posibles.approve, reject, needs_more_info.
deadline_atHace visible el SLO.2026-05-28T15:30:00Z.
resume_tokenPermite continuar la run.Token opaco o estado serializado.

Ejemplo:

{
  "request_id": "rev_20260528_001",
  "run_id": "run_7f31",
  "trace_id": "trace_abc",
  "task": "support_reply",
  "proposed_action": "send_email",
  "effect": "external_message",
  "confidence": 0.68,
  "cost_eur": 0.018,
  "reason_codes": ["low_confidence", "missing_source"],
  "evidence": {
    "draft": "Hemos revisado tu solicitud...",
    "sources": ["doc_matricula_2026"],
    "schema_valid": true,
    "policy_version": "handoff_policy@3"
  },
  "missing_evidence": ["expediente_actualizado"],
  "options": ["approve", "reject", "needs_more_info"],
  "deadline_at": "2026-05-28T15:30:00Z",
  "resume_token": "resume_run_7f31_step_12"
}

La persona no recibe “un caso raro”. Recibe un objeto de trabajo.

Máquina de estados del handoff

Para ingeniería, un handoff no debería ser un booleano needs_review=true. Debe tener una máquina de estados. Si no la tiene, aparecen dudas en producción: ¿se puede aprobar dos veces?, ¿qué pasa si caduca?, ¿quién puede reasignar?, ¿qué ocurre si la run ya se reanudó?

Una máquina mínima:

EstadoQué significaTransiciones válidas
runningLa run sigue trabajando.needs_review, completed, failed.
needs_reviewLa política exige revisión.queued, cancelled.
queuedEl caso está en cola con prioridad y deadline.assigned, expired, cancelled.
assignedUna persona o equipo lo tiene asignado.in_review, reassigned, expired.
in_reviewLa tarjeta está abierta o en edición.approved, approved_with_edits, rejected, needs_more_info.
needs_more_infoFalta evidencia concreta.queued, cancelled.
approvedLa acción puede ejecutarse como venía.resuming.
approved_with_editsLa acción puede continuar con cambios humanos.resuming.
rejectedLa acción no debe ejecutarse.resuming.
resumingUn worker está aplicando la decisión.closed, resume_failed.
resume_failedLa decisión existe, pero reanudar falló.resuming, closed_manual.
expiredSe incumplió el tiempo objetivo sin decisión.reassigned, cancelled, fallback_response.
closedEl caso terminó con traza y decision record.Estado terminal.

Reglas que conviene escribir:

ReglaPor qué importa
Una revisión cerrada no se modifica; se crea una revisión nueva.Conserva auditoría y evita reescribir historia.
approved no ejecuta directamente; pasa por resuming.Permite idempotencia y control de efectos.
expired no decide por sí solo.La caducidad activa una política: reasignar, degradar o responder con estado claro.
needs_more_info debe nombrar qué falta.Evita que la run vuelva a la cola sin mejora.
Cada transición emite un evento.La cola se puede auditar y medir.

Modelo de datos persistente

El contrato JSON sirve para transportar el caso. En producción hace falta persistencia. Un modelo relacional mínimo podría ser:

create table handoff_requests (
  request_id text primary key,
  run_id text not null,
  trace_id text not null,
  task text not null,
  queue_name text not null,
  state text not null,
  proposed_action text not null,
  effect text not null,
  confidence numeric not null,
  cost_eur numeric not null,
  reason_codes jsonb not null,
  evidence jsonb not null,
  missing_evidence jsonb not null,
  resume_token text not null,
  idempotency_key text not null unique,
  created_at timestamptz not null,
  deadline_at timestamptz not null,
  updated_at timestamptz not null
);

create table review_decisions (
  decision_id text primary key,
  request_id text not null references handoff_requests(request_id),
  decision_version integer not null,
  reviewer text not null,
  decision text not null,
  reason text not null,
  edited_output text,
  created_at timestamptz not null,
  unique (request_id, decision_version)
);

create table review_events (
  event_id text primary key,
  request_id text not null references handoff_requests(request_id),
  event_type text not null,
  actor text not null,
  payload jsonb not null,
  created_at timestamptz not null
);

create table review_assignments (
  assignment_id text primary key,
  request_id text not null references handoff_requests(request_id),
  queue_name text not null,
  assignee text,
  assigned_at timestamptz not null,
  released_at timestamptz
);

Consultas que un equipo debería poder hacer:

select state, count(*) as n
from handoff_requests
where created_at >= now() - interval '24 hours'
group by state
order by n desc;
select
  queue_name,
  percentile_cont(0.95) within group (order by extract(epoch from updated_at - created_at) / 60) as age_p95_minutes,
  avg(case when now() > deadline_at and state not in ('closed', 'cancelled') then 1 else 0 end) as active_breach_rate
from handoff_requests
where created_at >= now() - interval '7 days'
group by queue_name;

La base de datos no es burocracia. Es lo que permite reanudar después de un crash, medir la cola, auditar decisiones y aprender de patrones repetidos.

Idempotencia: aprobar dos veces no debe ejecutar dos veces

El punto más delicado para un ingeniero de IA no es pedir revisión. Es aplicar la decisión sin duplicar efectos.

Ejemplo de fallo realista:

  1. Una persona aprueba send_email.
  2. El worker ejecuta la tool.
  3. El proceso cae antes de marcar closed.
  4. El sistema reintenta.
  5. Si no hay idempotencia, envía dos correos.

Campos que evitan ese problema:

CampoQué protege
idempotency_keyLa misma acción aprobada no se ejecuta dos veces.
effect_idIdentifica el efecto externo creado: email, ticket, cambio, publicación.
decision_versionEvita aplicar una decisión antigua tras una corrección.
resume_tokenReanuda el punto correcto de la run.
state_versionBloquea carreras entre workers.
executed_atMarca cuándo se produjo el efecto.

Patrón recomendado:

1. Leer request y decisión dentro de una transacción.
2. Comprobar `state=resuming` y `decision_version` vigente.
3. Reservar `idempotency_key`.
4. Ejecutar tool con esa clave.
5. Guardar `effect_id`.
6. Marcar `closed`.
7. Emitir evento `handoff.closed`.

Si la tool externa soporta claves de idempotencia, úsala. Si no, crea tu propia tabla de efectos y no ejecutes dos veces la misma combinación (request_id, decision_version, proposed_action).

Colas, SLI y SLO de revisión

Una cola de revisión también se opera. Si no la medimos, solo veremos su dolor cuando alguien se queje.

SLIQué mideEjemplo
review_wait_p95Tiempo p95 hasta primera decisión.38 minutos.
backlog_sizeCasos pendientes por cola.71 casos.
deadline_breach_rateProporción fuera de SLO.4,2%.
needs_more_info_rateCasos que llegaron con evidencia insuficiente.19%.
approval_ratePorcentaje aprobado.63%.
reversal_rateDecisiones corregidas después.1,1%.
review_cost_per_caseCoste humano estimado por revisión.3,40 EUR.
agreement_rateConsistencia entre revisores en muestra doble.0,86.

Un SLO posible:

SLOreview=P(Tdecision30 min)0,95SLO_{review} = P(T_{decision} \le 30\text{ min}) \ge 0{,}95
SímboloSignificadoEjemplo
TdecisionT_{decision}Tiempo desde que se crea la revisión hasta primera decisión.18 minutos.
30 min30\text{ min}Objetivo máximo para la cola.Media hora.
0,950{,}95Proporción mínima dentro del objetivo.95 de cada 100 casos.

El presupuesto de revisión no es infinito. Si la cola crece, tienes tres palancas:

PalancaQué cambiaCuidado
Mejorar evidenciaMenos needs_more_info.Exige tocar prompts, retrieval o contrato.
Ajustar umbralesMenos casos llegan a cola.Puede aumentar automatización en casos discutibles.
Cambiar capacidadMás personas o turnos.Cuesta dinero y puede variar criterio.

Backpressure: qué hacer si la cola se satura

Una cola saturada no es solo un problema de soporte. Cambia el comportamiento del sistema de IA. Si el sistema sigue metiendo casos en revisión cuando nadie puede atenderlos, acumula deuda, incumple expectativas y oculta el fallo real.

Política de saturación:

SeñalUmbral ejemploAcción
backlog_sizeMás de 200 casos.Bajar automatización que genera revisiones dudosas.
review_wait_p95Más de 45 min.Reasignar cola o ampliar capacidad temporal.
deadline_breach_rateMás de 5%.Responder con estado claro en vez de prometer resolución inmediata.
needs_more_info_rateMás de 25%.Mejorar bundle antes de crear revisión.
agreement_rateMenos de 0,75.Pausar decisiones automáticas basadas en esa rúbrica.

Estrategias de degradación:

EstrategiaQué haceCuándo usarla
queue_only_criticalSolo entran casos de alto impacto.Backlog alto y poco personal.
auto_reject_low_evidenceNo crea revisión si falta evidencia básica.Muchos casos llegan vacíos.
fallback_responseInforma que el caso queda pendiente.La respuesta inmediata sería engañosa.
batch_reviewAgrupa casos parecidos.Muchos casos con mismo motivo.
threshold_raiseExige más confianza para actuar solo o más evidencia para revisar.Canary o incidente operativo.
route_to_specialistCambia cola según tema.La cola general bloquea casos especializados.

La regla: si la cola está llena, el sistema debe cambiar de comportamiento. No basta con quejarse del backlog.

La tarjeta de decisión

La persona revisora no debería tener que buscar en diez sistemas. La tarjeta debe responder rápido a siete preguntas:

PreguntaLo que debe ver
¿Qué quiere hacer el sistema?Acción concreta y efecto.
¿Por qué llegó a revisión?Motivos codificados y explicación corta.
¿Qué información usó?Fuentes, documentos, campos y trazas.
¿Qué falta?Evidencia ausente o dato que debe comprobarse.
¿Qué pasa si apruebo?Acción exacta y recurso afectado.
¿Qué pasa si rechazo?Mensaje a la run y siguiente estado.
¿Qué debo dejar escrito?Motivo, corrección, caso de regresión o enlace a runbook.

Botones sanos:

BotónQué hace
approveReanuda la run y ejecuta la acción propuesta.
approve_with_editsReanuda con una salida corregida por la persona.
rejectNo ejecuta la acción y devuelve motivo al sistema.
needs_more_infoPide datos concretos y deja la run pausada.
reassignCambia cola o persona responsable.

Botones peligrosos por diseño:

BotónPor qué lo evitaría
okNo dice qué ocurre.
continuarOculta si ejecuta, responde o solo guarda.
resolverMezcla decisión, ejecución y cierre.
saltarNo deja claro si rechaza, pospone o ignora.

Datos visibles, datos ocultos y privacidad

La tarjeta de revisión debe mostrar lo suficiente para decidir, pero no todo lo que el sistema sabe. Minimizar datos también es ingeniería.

Tipo de datoMostrar por defectoMotivo
Acción propuestaSí.Sin esto no hay decisión.
Efecto técnicoSí.La persona debe saber qué cambia.
Fuentes usadasSí, si tiene permiso.Permite comprobar evidencia.
Datos personales completosNo.Mostrar solo campos necesarios o redactados.
Prompt completoNormalmente no.Puede contener instrucciones internas o datos no necesarios.
Traza completaNo en tarjeta; sí enlazada.La tarjeta debe ser legible y la auditoría completa debe existir.
Coste y latenciaSí.Afecta decisión operativa.
Identidad de otras personasSolo si es necesario.Reduce exposición innecesaria.

Campos recomendados:

visible_to_reviewer:
  - proposed_action
  - effect
  - draft
  - sources
  - missing_evidence
  - reason_codes
  - cost_eur
  - deadline_at
redacted_by_default:
  - raw_prompt
  - full_trace_payload
  - credentials
  - unrelated_personal_data
  - provider_internal_metadata

Una revisión útil no necesita enseñar todo. Necesita enseñar lo necesario, con enlaces auditables para quien tenga permiso.

RACI operativo: quién puede decidir qué

La revisión humana se vuelve frágil cuando cualquiera puede aprobar cualquier cosa. Un RACI ayuda a separar responsabilidad.

CasoResponsibleAccountableConsultedInformed
Respuesta de soporte comúnSoporte nivel 1Responsable de soporteProductoUsuario final
Caso con norma ambiguaSoporte nivel 2Responsable académicoLegal o coordinaciónUsuario final
Cambio de configuración IAPlataforma IALead técnicoProducto y soporteEquipo
Tool con efecto externoEquipo dueño del sistemaOwner del procesoPlataforma IAAuditoría
Incumplimiento de SLOGuardia operativaResponsable de operaciónSoporteProducto

Traducción práctica:

RolPuede aprobarNo debería aprobar
Soporte nivel 1Respuestas con fuente y efecto bajo.Cambios persistentes o casos ambiguos.
Soporte nivel 2Casos con expediente, excepción o criterio.Cambios de infraestructura.
Plataforma IAReanudación técnica, rutas, políticas y gates.Interpretaciones de negocio sin owner.
ProductoCambios de experiencia y criterio funcional.Ejecución técnica sin evidencia.
Guardia operativaAcciones para proteger SLO y cola.Decisiones de contenido especializado.

Handoffs en SDKs y runtimes

La forma concreta cambia según la herramienta, pero el patrón se repite.

EcosistemaMecanismo relevanteCómo lo traduciría a operación
OpenAI Agents SDKTools con aprobación, interrupciones, RunState serializable y reanudación.Guardar pending approvals como cola y reanudar desde estado versionado.
OpenAI Agents SDKHandoffs entre agentes.Separar especialización de revisión: no todo handoff es humano.
Claude Agent SDKPermisos, allow/deny, modos y canUseTool.Declarar qué se permite, qué se bloquea y qué pide decisión en runtime.
Claude Agent SDKHooks.Interceptar tool calls para añadir política propia, logs y gates.
Google ADKCallbacks.Insertar controles antes/después de agente, modelo o tool.
LangGraphInterrupts y checkpoints.Pausar grafo, guardar estado y reanudar con decisión.
OpenTelemetryTrazas y spans.Unir revisión, run, tool, coste y decisión en una historia consultable.

OpenTelemetry define las trazas como una forma de seguir el camino de una petición mediante spans relacionados.9 En un handoff, la traza debería permitir responder: qué agente iba actuando, qué tool se propuso, qué política bloqueó, quién decidió y cómo se reanudó.

Calidad de la revisión

La revisión humana también se evalúa. Una persona puede tener prisa, interpretar una rúbrica de forma distinta o aprobar casos porque la interfaz no explica bien el efecto.

ControlQué detectaCómo se aplica
Doble revisión muestralInconsistencia entre personas.5% de casos van a dos revisores.
Rúbrica versionadaCambios de criterio.review_rubric@4.
Casos calibradosDeriva del equipo.Casos fijos con respuesta esperada.
Revisión de reversalsDecisiones corregidas después.Analizar por motivo y persona.
Tiempo por decisiónDecisiones demasiado rápidas o lentas.Mirar distribución, no solo media.
Feedback al sistemaMismo motivo aparece muchas veces.Abrir trabajo de prompt, RAG o contrato.

El acuerdo simple se calcula así:

A=NsameNdoubleA = \frac{N_{same}}{N_{double}}
SímboloSignificadoEjemplo
AATasa de acuerdo simple.0,86.
NsameN_{same}Casos donde dos revisores tomaron la misma decisión.86.
NdoubleN_{double}Casos revisados por dos personas.100.

No es una métrica perfecta, pero ayuda a detectar si la cola está midiendo el criterio del sistema o el humor del día.

Los trabajos sobre model cards y datasheets enseñan una idea que aquí encaja muy bien: documentar estructura, límites, datos, supuestos y uso previsto reduce ambigüedad operativa.1011 En revisión humana ocurre lo mismo: si la tarjeta no dice qué se está decidiendo, cada persona rellena huecos con su interpretación.

Qué te llevas para poner en práctica

Al terminar este capítulo, el lector debería poder montar una cola mínima de revisión para un sistema de IA.

ArtefactoPara qué sirve en un proyecto realQué debería contener
handoff_request.schema.jsonContrato de entrada a la cola.IDs, acción, efecto, evidencia, motivos, deadline y opciones.
handoff_policy.yamlReglas de revisión.Umbrales de confianza, coste, efectos y evidencia obligatoria.
handoff_queue.pySimulador operativo.Prioridad, decisión, SLA y mensaje de reanudación.
handoff_state_machine.mdEstados y transiciones.Qué estados existen, quién los cambia y qué eventos emiten.
handoff_tables.sqlPersistencia mínima.Requests, decisiones, eventos y asignaciones.
review_decision.mdRegistro humano.Decisión, motivo, cambios y caso de regresión.
review_dashboard.sqlConsultas de cola.Backlog, aging, breaches y tasa de aprobación.
privacy_review.yamlMinimización de datos.Campos visibles, redactados y enlazados.
raci_handoff.mdOwnership operativo.Quién puede aprobar cada tipo de caso.
runbook_handoff.mdProcedimiento del equipo.Quién revisa, cuándo, cómo escalar y cómo cerrar.

Práctica recomendada:

  1. Define dos acciones: una que pueda seguir sola y otra que requiera revisión.
  2. Escribe tres motivos de revisión con códigos estables.
  3. Diseña el contrato JSON.
  4. Ejecuta el script de cola con casos de ejemplo.
  5. Genera una decisión approve, una reject y una needs_more_info.
  6. Añade una clave de idempotencia para que una aprobación no duplique efectos.
  7. Define qué ocurre si la cola supera el SLO.
  8. Convierte al menos un caso revisado en entrada de regresión.

El resultado práctico no es “tenemos humanos en el bucle”. Es poder demostrar qué entra, por qué entra, quién decide, cuánto tarda y cómo se reanuda.

Reto de capítulo: una cola de revisión en 45 minutos

Este es el ejercicio que me gustaría que alguien pudiera llevarse del capítulo y hacer de verdad. No es leer el concepto: es montar una mini cola reproducible.

Escenario: tienes un asistente de soporte que prepara respuestas. Algunas pueden guardarse como nota interna; otras quieren enviar un mensaje externo. Tu trabajo es decidir qué sigue solo, qué va a revisión y qué decisión deja preparada la reanudación.

Archivos que vas a crear:

mi-proyecto/
  ops/
    ai/
      handoff_queue.py
  data/
    handoff_examples.jsonl
  output/
    handoff_queue_result.json

Datos de entrada en data/handoff_examples.jsonl:

{"request_id":"rev_001","run_id":"run_7f31","trace_id":"trace_abc","task":"support_reply","proposed_action":"send_email","effect":"external_message","confidence":0.68,"cost_eur":0.018,"created_at":"2026-05-28T13:45:00+00:00","sla_minutes":30,"evidence":{"draft":"Tu solicitud queda pendiente de revisar el expediente actualizado.","sources":["doc_matricula_2026"],"schema_valid":true,"contract_version":"support_reply_schema@4"},"missing_evidence":["expediente_actualizado"],"resume_token":"resume_run_7f31_step_12"}
{"request_id":"rev_002","run_id":"run_8a20","trace_id":"trace_def","task":"ticket_summary","proposed_action":"save_summary","effect":"internal_note","confidence":0.91,"cost_eur":0.006,"created_at":"2026-05-28T14:05:00+00:00","sla_minutes":240,"evidence":{"draft":"Resumen interno del caso con próximos pasos.","sources":["ticket_991"],"schema_valid":true,"contract_version":"summary_schema@2"},"missing_evidence":[],"resume_token":"resume_run_8a20_step_05"}
{"request_id":"rev_003","run_id":"run_9c10","trace_id":"trace_xyz","task":"policy_answer","proposed_action":"answer_user","effect":"external_message","confidence":0.74,"cost_eur":0.041,"created_at":"2026-05-28T13:20:00+00:00","sla_minutes":60,"evidence":{"draft":"Según la política vigente, el plazo ordinario termina el viernes.","sources":[],"schema_valid":true,"contract_version":"policy_answer_schema@7"},"missing_evidence":["fuente_normativa"],"resume_token":"resume_run_9c10_step_09"}

Comandos:

mkdir -p ops/ai data output
python ops/ai/handoff_queue.py --write
cat output/handoff_queue_result.json

Qué debería ocurrir:

CasoResultado esperadoPor qué
rev_001Entra en cola.Baja confianza, efecto externo y falta expediente.
rev_002Sigue automáticamente.Es nota interna, tiene evidencia y supera umbrales.
rev_003Entra primero en cola.Efecto externo, coste alto y falta fuente normativa.

La entrega mínima del alumno:

  1. Captura o salida de handoff_queue_result.json.
  2. Una decisión escrita para rev_003.
  3. Una frase explicando por qué rev_002 no necesita revisión.
  4. Una modificación de la política: subir o bajar min_confidence y justificar el efecto.
  5. Un caso nuevo añadido al JSONL que pruebe contract_failed.

Manos a la obra

Práctica: construir una cola mínima.

Kit ejecutable de este capítulo: kit descargable.

# Descomprime el ZIP del capítulo y ejecuta estos comandos dentro de esa carpeta
python3 ops/run_f6_practices.py --chapter c08 --write --fail-on-invalid

Guarda este script como ops/ai/handoff_queue.py. No necesita dependencias externas.

from __future__ import annotations

import json
import sys
from dataclasses import asdict, dataclass
from datetime import datetime, timedelta, timezone
from enum import Enum
from pathlib import Path


class Decision(str, Enum):
    APPROVE = "approve"
    APPROVE_WITH_EDITS = "approve_with_edits"
    REJECT = "reject"
    NEEDS_MORE_INFO = "needs_more_info"


@dataclass(frozen=True)
class HandoffRequest:
    request_id: str
    run_id: str
    trace_id: str
    task: str
    proposed_action: str
    effect: str
    confidence: float
    cost_eur: float
    created_at: str
    sla_minutes: int
    evidence: dict[str, object]
    missing_evidence: list[str]
    resume_token: str


@dataclass(frozen=True)
class ReviewDecision:
    request_id: str
    reviewer: str
    decision: Decision
    reason: str
    edited_output: str | None = None


REQUESTS = [
    HandoffRequest(
        request_id="rev_001",
        run_id="run_7f31",
        trace_id="trace_abc",
        task="support_reply",
        proposed_action="send_email",
        effect="external_message",
        confidence=0.68,
        cost_eur=0.018,
        created_at="2026-05-28T13:45:00+00:00",
        sla_minutes=30,
        evidence={
            "draft": "Tu solicitud queda pendiente de revisar el expediente actualizado.",
            "sources": ["doc_matricula_2026"],
            "schema_valid": True,
            "contract_version": "support_reply_schema@4",
        },
        missing_evidence=["expediente_actualizado"],
        resume_token="resume_run_7f31_step_12",
    ),
    HandoffRequest(
        request_id="rev_002",
        run_id="run_8a20",
        trace_id="trace_def",
        task="ticket_summary",
        proposed_action="save_summary",
        effect="internal_note",
        confidence=0.91,
        cost_eur=0.006,
        created_at="2026-05-28T14:05:00+00:00",
        sla_minutes=240,
        evidence={
            "draft": "Resumen interno del caso con próximos pasos.",
            "sources": ["ticket_991"],
            "schema_valid": True,
            "contract_version": "summary_schema@2",
        },
        missing_evidence=[],
        resume_token="resume_run_8a20_step_05",
    ),
    HandoffRequest(
        request_id="rev_003",
        run_id="run_9c10",
        trace_id="trace_xyz",
        task="policy_answer",
        proposed_action="answer_user",
        effect="external_message",
        confidence=0.74,
        cost_eur=0.041,
        created_at="2026-05-28T13:20:00+00:00",
        sla_minutes=60,
        evidence={
            "draft": "Según la política vigente, el plazo ordinario termina el viernes.",
            "sources": [],
            "schema_valid": True,
            "contract_version": "policy_answer_schema@7",
        },
        missing_evidence=["fuente_normativa"],
        resume_token="resume_run_9c10_step_09",
    ),
]


POLICY = {
    "min_confidence": 0.78,
    "max_cost_eur": 0.03,
    "review_effects": {"external_message", "db_update", "publish"},
    "required_evidence_keys": {"draft", "sources", "schema_valid", "contract_version"},
}


def request_from_dict(data: dict[str, object]) -> HandoffRequest:
    return HandoffRequest(
        request_id=str(data["request_id"]),
        run_id=str(data["run_id"]),
        trace_id=str(data["trace_id"]),
        task=str(data["task"]),
        proposed_action=str(data["proposed_action"]),
        effect=str(data["effect"]),
        confidence=float(data["confidence"]),
        cost_eur=float(data["cost_eur"]),
        created_at=str(data["created_at"]),
        sla_minutes=int(data["sla_minutes"]),
        evidence=dict(data["evidence"]),
        missing_evidence=list(data["missing_evidence"]),
        resume_token=str(data["resume_token"]),
    )


def load_requests(path: Path = Path("data/handoff_examples.jsonl")) -> list[HandoffRequest]:
    if not path.exists():
        return REQUESTS

    requests: list[HandoffRequest] = []
    for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
        if not line.strip():
            continue
        try:
            requests.append(request_from_dict(json.loads(line)))
        except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
            raise ValueError(f"línea {line_number} inválida en {path}") from exc

    return requests


def parse_time(value: str) -> datetime:
    return datetime.fromisoformat(value)


def review_reasons(req: HandoffRequest) -> list[str]:
    reasons: list[str] = []

    if req.confidence < POLICY["min_confidence"]:
        reasons.append("low_confidence")
    if req.cost_eur > POLICY["max_cost_eur"]:
        reasons.append("cost_over_budget")
    if req.effect in POLICY["review_effects"]:
        reasons.append("effect_requires_review")
    if req.missing_evidence:
        reasons.append("missing_evidence")
    if not set(POLICY["required_evidence_keys"]).issubset(req.evidence):
        reasons.append("incomplete_bundle")
    if req.evidence.get("schema_valid") is not True:
        reasons.append("contract_failed")

    return reasons


def priority_score(req: HandoffRequest, now: datetime) -> float:
    created = parse_time(req.created_at)
    deadline = created + timedelta(minutes=req.sla_minutes)
    age_minutes = max(0.0, (now - created).total_seconds() / 60)
    remaining_minutes = max(1.0, (deadline - now).total_seconds() / 60)
    reasons = review_reasons(req)

    effect_weight = {
        "external_message": 4.0,
        "db_update": 5.0,
        "publish": 5.0,
        "internal_note": 1.0,
        "none": 0.0,
    }.get(req.effect, 2.0)

    return (
        effect_weight
        + len(reasons) * 1.5
        + len(req.missing_evidence) * 0.8
        + min(age_minutes / max(req.sla_minutes, 1), 2.0)
        + 1.0 / remaining_minutes
    )


def should_auto_continue(req: HandoffRequest) -> bool:
    return not review_reasons(req)


def build_review_card(req: HandoffRequest, now: datetime) -> dict[str, object]:
    created = parse_time(req.created_at)
    deadline = created + timedelta(minutes=req.sla_minutes)
    return {
        "request_id": req.request_id,
        "run_id": req.run_id,
        "trace_id": req.trace_id,
        "task": req.task,
        "proposed_action": req.proposed_action,
        "effect": req.effect,
        "confidence": req.confidence,
        "cost_eur": req.cost_eur,
        "reasons": review_reasons(req),
        "missing_evidence": req.missing_evidence,
        "deadline_at": deadline.isoformat(),
        "minutes_to_deadline": round((deadline - now).total_seconds() / 60, 1),
        "priority_score": round(priority_score(req, now), 3),
        "options": [decision.value for decision in Decision],
        "evidence": req.evidence,
    }


def apply_decision(req: HandoffRequest, decision: ReviewDecision) -> dict[str, object]:
    if decision.decision == Decision.APPROVE:
        next_state = "resume_and_execute"
    elif decision.decision == Decision.APPROVE_WITH_EDITS:
        next_state = "resume_with_edited_output"
    elif decision.decision == Decision.NEEDS_MORE_INFO:
        next_state = "keep_paused_and_collect_evidence"
    else:
        next_state = "resume_without_action"

    return {
        "request_id": req.request_id,
        "resume_token": req.resume_token,
        "decision": decision.decision.value,
        "reviewer": decision.reviewer,
        "reason": decision.reason,
        "edited_output": decision.edited_output,
        "next_state": next_state,
        "trace_event": "handoff.review_decided",
    }


def write_result(result: dict[str, object], path: Path = Path("output/handoff_queue_result.json")) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")


def main(argv: list[str] | None = None) -> None:
    argv = argv or sys.argv[1:]
    now = datetime(2026, 5, 28, 14, 15, tzinfo=timezone.utc)
    requests = load_requests()
    cards = [build_review_card(req, now) for req in requests if not should_auto_continue(req)]
    cards.sort(key=lambda card: card["priority_score"], reverse=True)

    decision = ReviewDecision(
        request_id=cards[0]["request_id"],
        reviewer="soporte_n2",
        decision=Decision.NEEDS_MORE_INFO,
        reason="Falta fuente normativa o expediente actualizado antes de responder.",
    )

    selected = next(req for req in requests if req.request_id == decision.request_id)
    result = {
        "queue_size": len(cards),
        "auto_continue": [req.request_id for req in requests if should_auto_continue(req)],
        "next_card": cards[0],
        "review_result": apply_decision(selected, decision),
    }
    if "--write" in argv:
        write_result(result)
    print(json.dumps(result, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()

Ejecuta:

python ops/ai/handoff_queue.py

Salida esperada resumida:

"queue_size": 2
"auto_continue": [
  "rev_002"
]
"request_id": "rev_003"
"decision": "needs_more_info"
"next_state": "keep_paused_and_collect_evidence"

Qué enseña:

PiezaAprendizaje
HandoffRequestEl handoff es un contrato, no una conversación suelta.
review_reasons()Cada revisión nace de motivos codificados.
priority_score()La cola se ordena por impacto, antigüedad, SLA y evidencia.
build_review_card()La interfaz debería salir de datos estructurados.
apply_decision()Aprobar, rechazar o pedir datos cambia el estado de reanudación.

Kit operativo: llevarlo a un repo

Estructura mínima:

mi-proyecto/
  ops/
    ai/
      handoff_queue.py
      handoff_policy.yaml
      handoff_request.schema.json
      handoff_state_machine.md
      handoff_tables.sql
      privacy_review.yaml
      raci_handoff.md
      review_decision.md
      review_dashboard.sql
      runbook_handoff.md
  data/
    handoff_examples.jsonl
  output/
    handoff_queue_result.json

ops/ai/handoff_policy.yaml:

policy_id: handoff_policy@3
min_confidence: 0.78
max_cost_eur: 0.03
review_effects:
  - external_message
  - db_update
  - publish
required_evidence_keys:
  - draft
  - sources
  - schema_valid
  - contract_version
queues:
  support_n2:
    slo_minutes: 30
    owner: soporte
  ai_platform:
    slo_minutes: 120
    owner: plataforma-ia
decisions:
  - approve
  - approve_with_edits
  - reject
  - needs_more_info
backpressure:
  backlog_size_warn: 120
  backlog_size_critical: 200
  review_wait_p95_minutes_warn: 45
  deadline_breach_rate_max: 0.05
  saturation_mode: queue_only_critical
idempotency:
  key_template: "{request_id}:{decision_version}:{proposed_action}"
  require_effect_id: true

ops/ai/privacy_review.yaml:

visible_to_reviewer:
  - request_id
  - task
  - proposed_action
  - effect
  - draft
  - sources
  - reason_codes
  - missing_evidence
  - confidence
  - cost_eur
  - deadline_at
linked_with_permission:
  - trace_id
  - run_id
  - full_evidence
redacted_by_default:
  - raw_prompt
  - provider_payload
  - credentials
  - unrelated_personal_data
retention:
  review_record_days: 180
  raw_payload_days: 30

ops/ai/review_dashboard.sql:

select
  queue_name,
  count(*) as backlog_size,
  percentile_cont(0.95) within group (
    order by extract(epoch from first_decision_at - created_at) / 60
  ) as review_wait_p95_minutes,
  avg(case when first_decision_at > deadline_at then 1 else 0 end) as deadline_breach_rate,
  avg(case when decision = 'needs_more_info' then 1 else 0 end) as needs_more_info_rate
from ai_handoff_reviews
where created_at >= now() - interval '7 days'
group by queue_name
order by backlog_size desc;

Plantilla de ops/ai/review_decision.md:

# Decisión de revisión

## Identificación

- Request:
- Run:
- Trace:
- Tarea:
- Persona revisora:

## Decisión

- [ ] approve
- [ ] approve_with_edits
- [ ] reject
- [ ] needs_more_info

## Motivo

Explica la razón técnica de la decisión.

## Evidencia usada

- Fuentes:
- Trazas:
- Validaciones:
- Datos que faltaban:

## Reanudación

Qué debe hacer la run después de esta decisión.

## Aprendizaje

- [ ] Añadir a evals/regression.jsonl
- [ ] Ajustar prompt
- [ ] Ajustar política de handoff
- [ ] Ajustar contrato de salida
- [ ] Actualizar runbook

Qué entregaría un alumno:

EntregableCriterio de aceptación
Script ejecutadoProduce cola, caso automático y caso pendiente.
Política YAMLExplica umbrales, efectos y colas.
Tarjeta JSONPermite decidir sin mirar conversación completa.
SQL de dashboardCalcula backlog, p95 y breaches.
Decisión escritaJustifica qué ocurre al reanudar.
Caso de regresiónConvierte una revisión real en evaluación futura.
Máquina de estadosImpide transiciones ambiguas y dobles ejecuciones.
Modelo de datosPermite auditar, consultar y recuperar después de fallos.
Política de privacidadMuestra lo necesario sin exponer payloads completos.
Plan de backpressureDefine qué hace el sistema cuando la cola se satura.

Cómo encaja todo

flowchart TD
  A["Run de IA"] --> B["Policy engine"]
  B --> C{"¿Puede continuar?"}
  C -- "sí" --> D["Respuesta o acción automática"]
  C -- "no" --> E["Evidence bundle"]
  E --> F["Cola de revisión"]
  F --> G["Tarjeta de decisión"]
  G --> H{"Decisión humana"}
  H -- "approve" --> I["Reanudar y ejecutar"]
  H -- "approve_with_edits" --> J["Reanudar con corrección"]
  H -- "reject" --> K["Reanudar sin acción"]
  H -- "needs_more_info" --> L["Recoger evidencia"]
  L --> E
  I --> M["Decision record"]
  J --> M
  K --> M
  M --> N["Trazas y métricas"]
  M --> O["Evals y regresiones"]
  M --> P["Runbook y política"]
  F --> U{"¿Cola saturada?"}
  U -- "sí" --> V["Backpressure: degradar, reasignar o limitar"]
  U -- "no" --> G
  M --> W["Idempotencia y effect_id"]

  subgraph F6["Facsímil 6"]
    Q["Cap. 02 contratos"]
    R["Cap. 04 observabilidad"]
    S["Cap. 06 EvalOps"]
    T["Cap. 07 rollout"]
  end

  Q -. "define salida" .-> E
  R -. "observa cola" .-> N
  S -. "recibe casos" .-> O
  T -. "muestrea canary" .-> F

Relación con otros capítulos

CapítuloQué aporta aquí
F5 · Capítulo 08Permisos, autonomía y aprobación de tools.
F6 · Capítulo 02Estados needs_review, contratos de API y respuesta estructurada.
F6 · Capítulo 04Trazas, métricas, SLIs y SLOs.
F6 · Capítulo 06Casos revisados que vuelven a datasets y gates.
F6 · Capítulo 07Canary y revisión muestreada antes de ampliar exposición.

Amershi y colaboradores mostraron que los sistemas de ML en producción traen retos propios de datos, operación, monitorización y evolución.12 El NIST AI RMF organiza la gestión de sistemas de IA alrededor de gobernar, mapear, medir y gestionar.13 Un handoff operativo es una forma concreta de llevar esas ideas al día a día: no basta con confiar en el sistema; hay que diseñar cómo se detiene, cómo pide criterio y cómo aprende.

Para entenderlo

Tres situaciones concretas:

SituaciónMala salidaHandoff operativo
Respuesta de soporte sin fuente.“Creo que el plazo es el viernes”.Pausa, pide fuente normativa y deja needs_more_info.
Tool quiere cerrar un ticket.Cierra porque el resumen parece correcto.Crea tarjeta con acción, evidencia, efecto y botón approve.
Canary de prompt nuevo.Se mira el promedio y se sube al 50%.Muestrea revisiones de casos nuevos y añade regresiones.

La diferencia no está en “meter una persona”. Está en que la persona entre justo donde el sistema necesita criterio, y salga dejando datos que mejoran el siguiente ciclo.

Vocabulario aprendido

TérminoDefinición breve
Handoff operativoPausa estructurada de una run para que otra persona o sistema pueda decidir.
Evidence bundlePaquete de entrada, propuesta, fuentes, trazas, motivos y estado.
Approval requestSolicitud concreta de aprobación sobre una acción.
Review queueCola priorizada de casos pendientes de decisión.
Decision recordRegistro de qué se decidió, quién lo hizo y por qué.
Resume tokenIdentificador o estado que permite reanudar una run.
SLO de revisiónObjetivo medible de tiempo o calidad de la cola.
Agreement rateProporción de decisiones iguales entre revisores en muestra doble.

Dónde solía tropezar yo

Me costó entender que revisión humana no es una red de seguridad automática. Si llega tarde, sin contexto o sin criterio, solo traslada el problema a otra persona.

TropiezoAntídoto
Mandar demasiadas cosas a revisión.Codificar motivos y medir tasa por motivo.
Mandar casos sin evidencia suficiente.Exigir evidence y missing_evidence.
No medir la cola.Crear SLI/SLO de espera, backlog y breaches.
No reanudar bien.Guardar resume_token, decisión y siguiente estado.
No aprender de revisiones.Convertir casos en evals, reglas y runbooks.

La frase que me habría ahorrado muchas vueltas: un handoff no es un mensaje; es un contrato de continuidad.

Antes de pasar página

Comprueba que puedes responder:

  1. ¿Qué diferencia hay entre revisión humana y handoff operativo?
  2. ¿Qué campos mínimos debe incluir un evidence_bundle?
  3. ¿Cuándo pedirías revisión por efecto persistente?
  4. ¿Qué SLI usarías para medir una cola de revisión?
  5. ¿Por qué approve y approve_with_edits no deberían ser el mismo botón?
  6. ¿Cómo se reanuda una run después de una decisión?
  7. ¿Qué caso revisado añadirías a un dataset de regresión?

En resumen

IdeaPara llevarte
Revisar no es improvisar.La revisión debe tener contrato, evidencia, SLA y reanudación.
La cola se opera.Backlog, p95, breaches, acuerdo y coste humano son métricas del sistema.
La interfaz decide calidad.Si la tarjeta no muestra efecto y evidencia, la persona decide a ciegas.
Cada revisión debe enseñar algo.Los casos vuelven a evals, prompts, políticas y runbooks.

Para saber más

Notas

  1. OpenAI. (2026). Agents SDK: Human-in-the-loop. https://openai.github.io/openai-agents-python/human_in_the_loop/. Consultado el 28 de mayo de 2026.

  2. OpenAI. (2026). Agents SDK: Handoffs. https://openai.github.io/openai-agents-python/handoffs/. Consultado el 28 de mayo de 2026.

  3. Anthropic. (2026). Claude Agent SDK: Permissions. https://code.claude.com/docs/en/agent-sdk/permissions. Consultado el 28 de mayo de 2026.

  4. Anthropic. (2026). Claude Agent SDK: Hooks. https://code.claude.com/docs/en/agent-sdk/hooks. Consultado el 28 de mayo de 2026.

  5. Anthropic. (2026). Claude Agent SDK: Handle Approvals and User Input. https://code.claude.com/docs/en/agent-sdk/user-input. Consultado el 28 de mayo de 2026.

  6. Google. (2026). Callbacks: Observe, Customize, and Control Agent Behavior. https://adk.dev/callbacks/. Consultado el 28 de mayo de 2026.

  7. Google. (2026). Safety and Security for AI Agents. https://adk.dev/safety/. Consultado el 28 de mayo de 2026.

  8. LangChain. (2026). LangGraph Interrupts. https://docs.langchain.com/oss/python/langgraph/human-in-the-loop. Consultado el 28 de mayo de 2026.

  9. OpenTelemetry. (2026). Traces. https://opentelemetry.io/docs/concepts/signals/traces/. Consultado el 27 de mayo de 2026.

  10. Mitchell, M. et al. (2019). Model Cards for Model Reporting. Proceedings of the Conference on Fairness, Accountability, and Transparency, 220-229. https://doi.org/10.1145/3287560.3287596

  11. Gebru, T. et al. (2021). Datasheets for Datasets. Communications of the ACM, 64(12), 86-92. https://doi.org/10.1145/3458723

  12. Amershi, S. et al. (2019). Software Engineering for Machine Learning: A Case Study. Proceedings of the 41st International Conference on Software Engineering: Software Engineering in Practice, 291-300. https://doi.org/10.1109/ICSE-SEIP.2019.00042

  13. Tabassi, E. (2023). Artificial Intelligence Risk Management Framework (AI RMF 1.0). NIST AI 100-1. https://doi.org/10.6028/NIST.AI.100-1

Capítulo 09

Facsímil 6 · Construir y operar

Capítulo 09: Incidencias, postmortems y mejora continua

Qué deberías poder hacer al terminar

En el capítulo 08 diseñamos cómo pausar una run y pedir revisión. Ahora subimos un nivel: qué ocurre cuando el sistema completo entra en una situación operativa que exige coordinación.

Una incidencia de IA no siempre es “la API está caída”. Puede ser más sutil: sube el coste por run aceptada, el contrato JSON falla, el RAG cita documentos que no corresponden, el router manda demasiado tráfico al modelo caro, una cola de revisión se atasca, un canary degrada la calidad en un segmento o una tool empieza a devolver errores tipados.

Al terminar, deberías poder hacer esto:

Resultado de aprendizajeEvidencia de que lo sabes hacer
Declarar una incidencia de IA.Separas síntoma, impacto, severidad, servicio afectado y owner.
Priorizar respuesta.Clasificas por usuarios, tareas, coste, contrato, SLO y reversibilidad.
Coordinar roles.Distingues comandante, operaciones, comunicación, especialistas y documentación.
Mitigar sin perder evidencia.Proteges el servicio y conservas trazas, eventos y decisiones.
Calcular tiempos operativos.Mides detección, reconocimiento, mitigación y recuperación.
Escribir un postmortem útil.Incluyes impacto, línea temporal, causas contribuyentes y acciones verificables.
Convertir aprendizaje en trabajo.Creas casos de regresión, runbooks, alertas, cambios de política y gates.

La idea central: una incidencia no termina cuando vuelve la gráfica; termina cuando el sistema aprende algo verificable.

Cuando producción te enseña lo que faltaba

Imagina que un asistente de soporte llevaba dos semanas estable. De pronto, el coste p95 sube, la latencia se dispara y soporte empieza a recibir tickets de respuestas que tardan demasiado. Nadie cambió código de aplicación, pero sí se publicó un índice RAG nuevo y se abrió un canary de prompt.

Hay varias tentaciones: mirar logs al azar, culpar al modelo, revertir todo, escribir en el chat “lo estoy mirando” y esperar que alguien encuentre la causa. Eso no es operar. Operar es declarar el evento, acotar impacto, asignar roles, mitigar, preservar evidencia y dejar un registro que permita aprender.

La pregunta no es “quién lo rompió”. La pregunta útil es:

¿Qué señales vimos, qué decisiones tomamos, qué mitigó el impacto y qué cambio evitará que vuelva igual?

Qué no es gestionar una incidencia

Gestionar una incidencia no es correr más rápido que el dashboard. La velocidad sin coordinación suele crear cambios simultáneos, mensajes contradictorios y pérdida de evidencia.

Tampoco es buscar una causa única al final. En sistemas de IA, una incidencia suele tener causas contribuyentes: un cambio de datos, un umbral demasiado permisivo, un fallback que no estaba probado, una alerta tardía, un runbook incompleto o un canary que no segmentaba lo suficiente.

Y un postmortem no es un documento ceremonial. Si no produce acciones verificables, casos de regresión, alertas mejores o runbooks más claros, solo archiva una historia.

ConfusiónQué falta
“La incidencia termina cuando baja la alerta”Falta confirmar recuperación, cerrar comunicación y capturar aprendizaje.
“Postmortem es resumen”Falta impacto, línea temporal, causas contribuyentes y acciones con dueño.
“Revertimos y listo”Falta saber qué señal detectó tarde y qué dataset debe aprender.
“El modelo falló”Falta distinguir modelo, prompt, RAG, router, runtime, contrato y proveedor.
“Ya sabemos lo que pasó”Falta evidencia reproducible: trazas, métricas, eventos y decisiones.

Qué sí es una incidencia de IA

Para este libro, una incidencia de IA es:

Un evento operativo que degrada una propiedad importante del sistema: disponibilidad, latencia, coste, calidad, contrato, trazabilidad, permisos o confianza del flujo.

Ejemplo de fórmula. Podemos modelarla como:

I=(S,P,T,E,M,D,A)I = (S, P, T, E, M, D, A)
SímboloSignificadoEjemplo
SSServicio o capacidad afectada.support_rag, tool_gateway, review_queue.
PPPoblación afectada.18% de tenants, canal chat, tarea policy_answer.
TTVentana temporal.13:40-14:35 UTC.
EEEvidencia observada.Métricas, logs, trazas, tickets, decision records.
MMMitigaciones aplicadas.Rollback, fallback, bajar canary, desactivar tool.
DDDecisiones tomadas.Quién decidió, cuándo y por qué.
AAAcciones de mejora.Evals, alertas, runbooks, cambios de política.

Si falta DD, no sabremos por qué se actuó. Si falta EE, discutiremos recuerdos. Si falta AA, la incidencia puede repetirse con otro nombre.

Fecha de corte del estado del arte

Fecha de corte: 28 de mayo de 2026.
Fuentes consultadas: capítulos de Google SRE sobre monitorización, respuesta de emergencia, gestión de incidencias, postmortems, alerting on SLOs y sobrecarga; OpenTelemetry sobre trazas, logs, métricas y convenciones GenAI; NIST AI RMF; y trabajos de ingeniería de ML.

El libro de SRE de Google recomienda que las alertas que interrumpen a personas sean simples, accionables y orientadas a síntomas relevantes para usuarios.1 El SRE Workbook explica cómo alertar sobre SLOs usando consumo de presupuesto de error, no solo umbrales aislados.2

Google SRE también insiste en preparar la respuesta a emergencias antes de necesitarlas, practicarla y pedir ayuda cuando la situación supera a una persona.3 En gestión de incidencias, separa roles como comando, trabajo operativo, comunicación y planificación para evitar cambios descoordinados.4

Sobre postmortems, Google SRE los define como registros escritos de impacto, acciones tomadas, causas contribuyentes y seguimiento para evitar recurrencia, con una cultura centrada en aprender y mejorar sistemas.5 Lo estable es el método: declarar, coordinar, mitigar, documentar, aprender y verificar. Lo cambiante son herramientas concretas de guardia, chat, tickets, dashboards y proveedores.

Severidad: no todo merece el mismo ruido

La severidad debe estar definida antes de la incidencia. Si se decide en caliente, cada persona trae su propio umbral.

SeveridadImpactoEjemplo en IARespuesta
SEV1Servicio principal no cumple función crítica o hay impacto amplio.El asistente no responde o rompe contrato en producción para muchas tareas.Comando formal, mitigación inmediata, comunicación periódica.
SEV2Degradación importante, acotada o con workaround.p95 fuera de SLO en una tarea clave; coste p95 se duplica.Equipo operativo, owner, update regular, postmortem si supera umbral.
SEV3Problema limitado, sin impacto amplio.Una ruta RAG falla en un tenant o canal.Ticket prioritario, runbook, revisión posterior.
SEV4Anomalía o mejora preventiva.Alerta de tendencia, flake rate subiendo, cola creciendo.Trabajo planificado antes de que escale.

Para IA conviene clasificar por más dimensiones que “está caído”:

DimensiónPregunta
Disponibilidad¿El usuario puede completar la tarea?
Latencia¿La experiencia sigue dentro del SLO?
Contrato¿La salida sigue siendo parseable y compatible?
Calidad¿Aumentan rechazos, revisiones o quejas verificables?
Coste¿Se consume presupuesto de forma anómala?
Trazabilidad¿Podemos reconstruir lo ocurrido?
Reversibilidad¿Podemos volver a estado conocido sin pérdida?
Alcance¿Cuántos tenants, tareas, idiomas o canales afecta?

Fórmulas operativas que sí usaría

El primer grupo mide tiempos:

MTTA=1ni=1n(tiacktidetect)MTTA = \frac{1}{n}\sum_{i=1}^{n}(t^{ack}_i - t^{detect}_i) MTTR=1ni=1n(tirecovertistart)MTTR = \frac{1}{n}\sum_{i=1}^{n}(t^{recover}_i - t^{start}_i)
SímboloSignificadoEjemplo
MTTAMTTATiempo medio hasta reconocer la incidencia.7 minutos.
MTTRMTTRTiempo medio hasta recuperar.42 minutos.
tidetectt^{detect}_iMomento en que el sistema detecta el evento.13:43 UTC.
tiackt^{ack}_iMomento en que una persona o proceso lo reconoce.13:48 UTC.
tistartt^{start}_iInicio de la incidencia.13:40 UTC.
tirecovert^{recover}_iRecuperación confirmada.14:22 UTC.
nnNúmero de incidencias medidas.12.

Ejemplo de fórmula. El segundo grupo prioriza:

P=4U+3C+2L+2K+RP = 4U + 3C + 2L + 2K + R
SímboloSignificadoEjemplo
PPPuntuación de prioridad.23.
UUAlcance de usuarios o tenants afectados, de 0 a 5.3.
CCCriticidad de la tarea, de 0 a 5.4.
LLDegradación de latencia o disponibilidad, de 0 a 5.2.
KKDegradación de contrato/calidad, de 0 a 5.3.
RRPenalización por baja reversibilidad, de 0 a 5.1.

Ejemplo:

P=43+34+22+23+1=35P = 4\cdot3 + 3\cdot4 + 2\cdot2 + 2\cdot3 + 1 = 35

Con P=35P=35, probablemente no estamos ante un ticket normal. Necesitamos coordinación, mitigación y seguimiento.

También conviene recordar que las colas obedecen matemáticas. La ley de Little relaciona trabajos en sistema, tasa de llegada y tiempo medio.6 Si la cola de revisión o de serving crece, no basta con “esperar un poco”: o baja llegada, o sube capacidad, o aumenta tiempo de espera.

Tipos de incidencia propios de IA

TipoSíntomaPrimeras comprobaciones
ContratoJSON inválido, campos extra, enum incorrecto.contract_version, salida raw, validador, cambio de prompt/modelo.
CalidadMás rechazos, revisiones o casos corregidos.Muestras, judge calibrado, feedback, canary, dataset afectado.
RAGFuentes pobres, citas ausentes, documentos obsoletos.index_version, retrieval trace, top_k, reranker, fechas de documento.
RoutingRuta cara o lenta sube de golpe.route_catalog, distribución de tareas, fallback, proveedor.
CosteCoste p95 o total diario sube.Tokens, reintentos, contexto, modelo, cache hit rate.
ServingSaturación de workers, cola, KV cache, timeouts.Goodput, queue depth, GPU/CPU, batch size, p95/p99.
HandoffCola de revisión crece o caduca.Backlog, needs_more_info_rate, owners, SLO de cola.
TrazabilidadNo se puede reconstruir la run.trace_id, spans, sampling, logs, atributos obligatorios.

Dean y Barroso mostraron que la cola de la latencia importa especialmente en sistemas a gran escala: una parte pequeña de operaciones lentas puede dominar la experiencia.7 En IA esto se agrava por contexto largo, herramientas, colas de serving, reintentos y validación.

Taxonomía por capa: dónde mirar primero

Un ingeniero de IA necesita separar capas. Si todo se llama “fallo del modelo”, el postmortem no produce buenas acciones.

CapaQué puede degradarseSeñalesMitigación típica
ProductoUsuario no completa tarea.Tickets, abandono, baja aceptación.Respuesta de estado, handoff, rollback de experiencia.
ContratoConsumidor no parsea salida.contract_fail_rate, errores de schema.Schema anterior, validador estricto, prompt rollback.
PromptCambia formato, tono, abstención o longitud.Tokens, longitud, fallos de JSON, quejas.Prompt anterior, límite de salida, eval de regresión.
ModeloLatencia, coste, calidad o tool calling cambian.model_id, p95, coste, flake rate.Cambiar ruta, bajar esfuerzo, fallback de modelo.
RAGRecuperación trae ruido o documentos obsoletos.index_version, chunks, citas, recall manual.Índice anterior, bajar top_k, desactivar reranker.
RouterDemasiado tráfico va a ruta cara/lenta.route_mix, fallback, coste por tarea.Catálogo anterior, override por tarea.
Tool gatewayArgs inválidos, permisos o errores externos.tool_error_rate, args, scope, retries.Modo lectura, bloquear tool, handoff.
ServingSaturación, colas, timeouts, memoria.queue depth, p95/p99, goodput, GPU/CPU.Rate limit, autoscaling, reducir contexto.
ObservabilidadFaltan trazas o atributos.trace_completeness, sampling, logs.Subir sampling temporal, añadir atributos obligatorios.

Checklist de primera hora:

  1. ¿Qué cambió en las últimas 24 horas: prompt, modelo, índice, router, proveedor, política, código o tráfico?
  2. ¿El síntoma aparece por task, tenant, model_id, prompt_version, index_version o release_id?
  3. ¿La mitigación más reversible está clara?
  4. ¿Tenemos suficiente evidencia antes de borrar o sobrescribir estado?
  5. ¿Qué caso concreto debería entrar en regresión?

Queries reales durante la incidencia

Las incidencias se operan mejor con preguntas concretas. Ejemplos con PromQL:

histogram_quantile(
  0.95,
  sum(rate(ai_run_latency_seconds_bucket{task="support_reply"}[10m])) by (le, release_id, variant)
)
sum(rate(ai_contract_fail_total{task="support_reply"}[10m])) by (release_id, variant)
/
sum(rate(ai_run_total{task="support_reply"}[10m])) by (release_id, variant)
histogram_quantile(
  0.95,
  sum(rate(ai_run_cost_eur_bucket{task="support_reply"}[30m])) by (le, model_id, route_id)
)
sum(rate(ai_tool_error_total{tool_name="crm_lookup"}[10m])) by (error_type, release_id)

Y con SQL para eventos de producto:

select
  task,
  release_id,
  variant,
  count(*) as runs,
  avg(case when contract_valid then 0 else 1 end) as contract_fail_rate,
  avg(case when accepted_by_user then 1 else 0 end) as acceptance_rate,
  percentile_cont(0.95) within group (order by latency_ms) as latency_p95_ms
from ai_run_events
where created_at >= now() - interval '2 hours'
group by task, release_id, variant
order by contract_fail_rate desc, latency_p95_ms desc;

Estas consultas deberían vivir en el runbook. Si las inventas durante la incidencia, perderás minutos y quizá harás preguntas distintas cada vez.

Replay: reproducir antes de prometer causa

Una incidencia de IA no queda entendida hasta que podemos reproducir al menos una parte. No siempre se puede reproducir todo, pero sí guardar un paquete mínimo.

PiezaQué guardarPor qué
EntradaPrompt de usuario o payload redactado.Permite repetir el caso.
ContextoIDs de chunks, documentos, versiones y hashes.RAG cambia con el tiempo.
ConfiguraciónModelo, parámetros, prompt, router, tools.Sin versiones no hay comparación.
SalidaRespuesta raw, salida validada y errores.Permite medir contrato y calidad.
Trazatrace_id, spans y eventos.Reconstruye latencia, retries y tools.
DecisiónMitigación, rollback o handoff aplicado.Explica por qué terminó así.

Plantilla replay_case.json:

{
  "case_id": "inc_support_rag_2026_05_28_case_001",
  "source": "incident",
  "task": "support_reply",
  "release_id": "support-rag@1.9.0-rc1",
  "input_hash": "sha256:...",
  "prompt_version": "prompt_v13",
  "model_id": "model_b",
  "route_catalog": "route_catalog@32",
  "rag_index": "rag_index_2026_06",
  "expected": {
    "contract_valid": true,
    "must_cite_source": true,
    "max_latency_ms": 4200
  },
  "observed": {
    "contract_valid": false,
    "latency_ms": 6200,
    "missing_source": true
  }
}

Este archivo es el puente entre postmortem y EvalOps. Si no hay replay, el aprendizaje se queda en memoria oral.

Anatomía visual de una incidencia de IA

Incidencia de IA: detectar, coordinar, mitigar y aprender Una incidencia útilmente operada deja menos impacto ahora y más resiliencia después. Síntoma SLO · contrato · coste calidad · cola · trazas tickets · feedback Detección alerta accionable y severidad burn rate · p95 · fallos contrato scope · tenant · task · release_id Comando roles claros y documento vivo IC Ops Comms Plan Mitigación reducir impacto sin borrar huellas rollback · fallback · bajar canary rate limit · read-only · queue mode Recuperación SLO normal contrato estable coste controlado Evidencia preservada lo necesario para reconstruir trace_id · run_id release_id · model_id prompt · router · index decisiones · mitigaciones Comunicación estado claro sin saturar al equipo impacto · mitigación · siguiente update qué sabemos · qué no sabemos un canal, un documento, un owner Postmortem aprendizaje verificable impacto timeline causas acciones Mejora continua la incidencia alimenta el sistema regression cases alertas · runbooks gates y canary policy Roles nadie improvisa el organigrama IC: estado global y decisiones Ops: cambios del sistema Comms: updates y stakeholders Plan: acciones y seguimiento Preguntas de ingeniería el modelo es solo una pieza ¿qué cambió? ¿qué mitigó? ¿qué aprende? Artefactos todo deja una pieza reutilizable postmortem.md runbook.md evals.jsonl Marca de agua editorial IA para gente curiosa / Facsímil 06 / Capítulo 09 / 686f6c61

Roles: separar trabajo para pensar mejor

Durante una incidencia, mezclar todos los roles crea ruido. Google SRE describe roles como comando, trabajo operativo, comunicación y planificación.8 En IA los adaptaría así:

RolResponsabilidadNo debería hacer
Comando de incidenciaMantener estado global, severidad, decisiones y prioridades.Tocar todos los mandos técnicos a la vez.
OperacionesEjecutar mitigaciones: rollback, fallback, rate limit, desactivar ruta.Comunicar estimaciones no validadas.
Especialista IAAnalizar prompt, modelo, RAG, evals, router o tool.Cambiar producción sin coordinar.
ComunicaciónExplicar impacto, mitigación y próximo update.Especular causas antes de evidencia.
PlanificaciónRegistrar acciones, owners, follow-ups y postmortem.Perderse en debugging de bajo nivel.

La regla de oro: una persona coordina, pocas personas cambian producción, todas las decisiones quedan escritas.

Mitigación: proteger primero, explicar después

La mitigación reduce impacto. No siempre corrige la causa profunda. Está bien. Durante una incidencia, primero evitamos que el daño operativo crezca.

SíntomaMitigación inmediataEvidencia a preservar
Contrato JSON falla.Volver a prompt/schema anterior o activar validador estricto.Salidas raw, schema_version, modelo, prompt.
Coste sube.Bajar max_tokens, cambiar ruta, activar cache, pausar canary.Tokens, route mix, provider, retries.
Latencia p95 sube.Fallback a ruta ligera, limitar contexto, bajar batch, rate limit.Queue depth, spans, p95 por fase.
RAG trae fuentes pobres.Volver a índice anterior o bajar top_k.Queries, chunks, index_version, reranker.
Cola de revisión caduca.queue_only_critical, reasignar, respuesta de estado.Backlog, owners, needs_more_info_rate.
Tool externa falla.Modo solo lectura, fallback sin tool, abrir handoff.Tool args, error tipado, trace_id.

El libro de SRE sobre sobrecarga recuerda que un sistema debe decidir qué trabajo aceptar, retrasar o rechazar para protegerse.9 En IA esto incluye rechazar generación cara, pausar rutas, pedir revisión o responder con estado claro.

Rollback, roll forward o contención

No toda incidencia pide la misma respuesta. Para un ingeniero de IA, la decisión importante es elegir la acción menos destructiva que reduzca impacto.

SituaciónAcción preferenteMotivo
Prompt nuevo rompe contrato.Rollback de prompt.Cambio reversible y localizado.
Índice RAG nuevo trae fuentes malas.Rollback de índice o shadow de retrieval.No hace falta tocar todo el servicio.
Modelo nuevo degrada p95.Cambiar ruta o bajar porcentaje.Mantienes producto mientras analizas.
Tool externa falla.Contención: modo lectura o fallback sin tool.Evitas efectos parciales.
Coste sube por contexto largo.Límite de contexto, cache o ruta barata.Reduce sangrado mientras investigas.
Causa no entendida y alcance crece.Contención amplia y preservar evidencia.Prioriza impacto y trazabilidad.
Fix pequeño probado.Roll forward con canary nuevo.Evita volver a estado antiguo si el parche es más seguro.

Regla práctica:

si el cambio causante es conocido y reversible -> rollback específico
si el causante es desconocido y el impacto crece -> contención
si el fix es pequeño, probado y observable -> roll forward controlado

La peor respuesta suele ser cambiar tres cosas a la vez y perder la capacidad de saber cuál ayudó.

Guardia operativa y handoff de turno

Una incidencia puede durar más que una persona. El cambio de turno también necesita contrato.

oncall_handoff.md debería incluir:

# Handoff de guardia

## Estado actual

- Incidencia:
- Severidad:
- Servicio:
- Comando:
- Último update:
- Próximo update:

## Qué sabemos

- Síntomas confirmados:
- Cambios recientes:
- Mitigaciones aplicadas:
- Estado de SLO:

## Qué no sabemos todavía

- Hipótesis abiertas:
- Evidencia pendiente:

## No tocar sin coordinar

- Rutas:
- Índices:
- Prompts:
- Proveedores:

## Siguiente acción recomendada

Paso concreto para los próximos 15-30 minutos.

Esto parece pequeño, pero evita uno de los fallos más caros: que el turno nuevo repita investigación o revierta una mitigación que estaba funcionando.

Qué te llevas para poner en práctica

El alumno debería salir con un kit mínimo de incidencia para IA:

ArtefactoPara qué sirveQué contiene
incident_events.jsonlEntrada reproducible.Alertas, mitigaciones, cambios, recuperación y evidencias.
incident_review.pyAnálisis ejecutable.Severidad, timeline, MTTA, MTTR, mitigaciones y acciones.
severity_matrix.yamlCriterio previo.SEV1-SEV4 por disponibilidad, contrato, coste, calidad y alcance.
incident_state.mdDocumento vivo.Estado actual, owner, impacto, acciones y próximo update.
postmortem.mdCierre de aprendizaje.Impacto, línea temporal, causas contribuyentes y acciones.
action_items.csvSeguimiento.Acción, owner, fecha, verificación y estado.
regression_cases.jsonlVuelta a EvalOps.Casos derivados de la incidencia.
replay_case.jsonReproducción mínima.Entrada redactada, versiones, esperado y observado.
oncall_handoff.mdCambio de turno.Estado, hipótesis, mitigaciones y siguiente paso.
incident_queries.promqlConsultas listas.Latencia, contrato, coste y errores por release.

Qué capítulos necesitas tener frescos

La práctica es usable al terminar este capítulo, pero no aparece de la nada. Cada pieza viene de algo trabajado antes:

Parte de la prácticaCapítulo conectadoQué recupera
incident_events.jsonl con eventos trazables.F6 · Capítulo 04Logs, métricas, trazas, run_id, trace_id, SLI y SLO.
Mitigaciones como bajar canary o volver índice.F6 · Capítulo 07Rollback, canary, kill switch y release progresiva.
Casos que vuelven a regression_cases.jsonl.F6 · Capítulo 06EvalOps, regresiones, gates y scorecard.
Handoff de guardia y revisión humana.F6 · Capítulo 08Colas, decisiones, evidence bundle y reanudación.
Routing, fallback y contención.F6 · Capítulo 05Rutas, presupuestos, fallback y control de coste.

Si alguien termina el capítulo 09 sin dominar todo lo anterior, aún puede ejecutar el script. Pero para defender la decisión técnica —por qué SEV2, por qué rollback de índice, por qué crear regresión— necesita volver a esas piezas.

Reto de capítulo: analizar una incidencia de IA

Escenario: un canary de prompt y un índice RAG nuevo coinciden con subida de latencia, coste y fallos de contrato. Tu tarea es reconstruir la incidencia desde eventos, calcular tiempos, proponer severidad y generar acciones.

Archivos:

mi-proyecto/
  ops/
    ai/
      incident_review.py
  data/
    incident_events.jsonl
  output/
    incident_report.json

Datos de entrada en data/incident_events.jsonl:

{"ts":"2026-05-28T13:40:00+00:00","type":"change","message":"canary prompt_v13 sube al 25%","release_id":"support-rag@1.9.0-rc1","task":"support_reply","impact":0}
{"ts":"2026-05-28T13:43:00+00:00","type":"alert","message":"latency_p95_ms supera SLO","metric":"latency_p95_ms","value":6200,"threshold":4200,"task":"support_reply","impact":3}
{"ts":"2026-05-28T13:47:00+00:00","type":"alert","message":"contract_fail_rate supera umbral","metric":"contract_fail_rate","value":0.031,"threshold":0.006,"task":"support_reply","impact":4}
{"ts":"2026-05-28T13:49:00+00:00","type":"ack","message":"incidencia reconocida por ai-platform","actor":"ai-platform-oncall","impact":0}
{"ts":"2026-05-28T13:54:00+00:00","type":"mitigation","message":"candidate_weight baja de 25 a 5","action":"reduce_canary","impact":0}
{"ts":"2026-05-28T14:02:00+00:00","type":"mitigation","message":"rag_index vuelve a rag_index_2026_05","action":"rollback_index","impact":0}
{"ts":"2026-05-28T14:18:00+00:00","type":"recovery","message":"latency y contrato vuelven a SLO","metric":"all","value":1,"threshold":1,"impact":0}
{"ts":"2026-05-28T14:25:00+00:00","type":"followup","message":"crear regresión con salida JSON rota y chunk obsoleto","owner":"evalops","impact":0}

Comandos:

mkdir -p ops/ai data output
python ops/ai/incident_review.py --write
cat output/incident_report.json

Salida esperada:

CampoValor esperado
severitySEV2
mtta_minutes6
mttr_minutes38
primary_symptomsLatencia p95 y fallos de contrato.
mitigationsBajar canary y volver índice RAG.
postmortem_requiredtrue

Entrega mínima:

  1. incident_report.json.
  2. Una propuesta de postmortem.md.
  3. Dos acciones correctivas con owner y verificación.
  4. Un caso nuevo para regression_cases.jsonl.
  5. Una frase que explique qué señal debería haber alertado antes.
  6. Un replay_case.json con versiones de prompt, modelo, router e índice.
  7. Una decisión escrita: rollback, roll forward o contención, con motivo.

Comprobación de que la práctica está bien hecha:

ComprobaciónResultado esperado
Ejecuta sin dependencias externas.Solo necesita Python estándar.
Lee data/incident_events.jsonl.Si existe, usa esos eventos; si no existe, usa los eventos de ejemplo.
Escribe salida con --write.Crea output/incident_report.json.
Calcula tiempos.mtta_minutes = 6 y mttr_minutes = 38.
Propone severidad.severity = SEV2.
Conecta con EvalOps.Genera acciones recomendadas y pide caso de regresión.
Es defendible en clase/equipo.El alumno puede explicar síntoma, mitigación, replay y acción correctiva.

Manos a la obra

Práctica: construir el analizador.

Kit ejecutable de este capítulo: kit descargable.

# Descomprime el ZIP del capítulo y ejecuta estos comandos dentro de esa carpeta
python3 ops/run_f6_practices.py --chapter c09 --write --fail-on-invalid

Guarda este script como ops/ai/incident_review.py.

from __future__ import annotations

import json
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path


DEFAULT_EVENTS = [
    {"ts": "2026-05-28T13:40:00+00:00", "type": "change", "message": "canary prompt_v13 sube al 25%", "release_id": "support-rag@1.9.0-rc1", "task": "support_reply", "impact": 0},
    {"ts": "2026-05-28T13:43:00+00:00", "type": "alert", "message": "latency_p95_ms supera SLO", "metric": "latency_p95_ms", "value": 6200, "threshold": 4200, "task": "support_reply", "impact": 3},
    {"ts": "2026-05-28T13:47:00+00:00", "type": "alert", "message": "contract_fail_rate supera umbral", "metric": "contract_fail_rate", "value": 0.031, "threshold": 0.006, "task": "support_reply", "impact": 4},
    {"ts": "2026-05-28T13:49:00+00:00", "type": "ack", "message": "incidencia reconocida por ai-platform", "actor": "ai-platform-oncall", "impact": 0},
    {"ts": "2026-05-28T13:54:00+00:00", "type": "mitigation", "message": "candidate_weight baja de 25 a 5", "action": "reduce_canary", "impact": 0},
    {"ts": "2026-05-28T14:02:00+00:00", "type": "mitigation", "message": "rag_index vuelve a rag_index_2026_05", "action": "rollback_index", "impact": 0},
    {"ts": "2026-05-28T14:18:00+00:00", "type": "recovery", "message": "latency y contrato vuelven a SLO", "metric": "all", "value": 1, "threshold": 1, "impact": 0},
    {"ts": "2026-05-28T14:25:00+00:00", "type": "followup", "message": "crear regresión con salida JSON rota y chunk obsoleto", "owner": "evalops", "impact": 0},
]


@dataclass(frozen=True)
class IncidentEvent:
    ts: datetime
    type: str
    message: str
    impact: int
    data: dict[str, object]


def parse_ts(value: str) -> datetime:
    return datetime.fromisoformat(value).astimezone(timezone.utc)


def event_from_dict(raw: dict[str, object]) -> IncidentEvent:
    return IncidentEvent(
        ts=parse_ts(str(raw["ts"])),
        type=str(raw["type"]),
        message=str(raw["message"]),
        impact=int(raw.get("impact", 0)),
        data={k: v for k, v in raw.items() if k not in {"ts", "type", "message", "impact"}},
    )


def load_events(path: Path = Path("data/incident_events.jsonl")) -> list[IncidentEvent]:
    if not path.exists():
        return [event_from_dict(item) for item in DEFAULT_EVENTS]

    events: list[IncidentEvent] = []
    for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1):
        if not line.strip():
            continue
        try:
            events.append(event_from_dict(json.loads(line)))
        except (KeyError, TypeError, ValueError, json.JSONDecodeError) as exc:
            raise ValueError(f"línea {line_number} inválida en {path}") from exc
    return sorted(events, key=lambda event: event.ts)


def minutes_between(start: datetime, end: datetime) -> int:
    return int((end - start).total_seconds() // 60)


def classify_severity(events: list[IncidentEvent]) -> str:
    max_impact = max((event.impact for event in events), default=0)
    symptoms = {event.data.get("metric") for event in events if event.type == "alert"}
    duration = incident_duration_minutes(events)
    contract_failed = "contract_fail_rate" in symptoms
    latency_failed = "latency_p95_ms" in symptoms

    if max_impact >= 5 or duration >= 120:
        return "SEV1"
    if max_impact >= 4 or (contract_failed and latency_failed):
        return "SEV2"
    if max_impact >= 2:
        return "SEV3"
    return "SEV4"


def first_event(events: list[IncidentEvent], event_type: str) -> IncidentEvent | None:
    return next((event for event in events if event.type == event_type), None)


def incident_duration_minutes(events: list[IncidentEvent]) -> int:
    start = first_event(events, "change") or first_event(events, "alert") or events[0]
    recovery = first_event(events, "recovery") or events[-1]
    return minutes_between(start.ts, recovery.ts)


def build_report(events: list[IncidentEvent]) -> dict[str, object]:
    first_alert = first_event(events, "alert")
    first_ack = first_event(events, "ack")
    first_change = first_event(events, "change") or first_alert or events[0]
    recovery = first_event(events, "recovery") or events[-1]
    alerts = [event for event in events if event.type == "alert"]
    mitigations = [event for event in events if event.type == "mitigation"]
    followups = [event for event in events if event.type == "followup"]

    mtta = minutes_between(first_alert.ts, first_ack.ts) if first_alert and first_ack else None
    mttr = minutes_between(first_change.ts, recovery.ts)
    severity = classify_severity(events)

    return {
        "incident_id": "inc_support_rag_2026_05_28",
        "severity": severity,
        "postmortem_required": severity in {"SEV1", "SEV2"},
        "started_at": first_change.ts.isoformat(),
        "recovered_at": recovery.ts.isoformat(),
        "mtta_minutes": mtta,
        "mttr_minutes": mttr,
        "primary_symptoms": [event.message for event in alerts],
        "mitigations": [event.message for event in mitigations],
        "followups": [event.message for event in followups],
        "timeline": [
            {
                "ts": event.ts.isoformat(),
                "type": event.type,
                "message": event.message,
                "data": event.data,
            }
            for event in events
        ],
        "recommended_actions": [
            "añadir caso de regresión para salida JSON rota",
            "probar canary de índice RAG con shadow antes de subir al 25%",
            "crear alerta combinada de contrato y latencia por release_id",
            "actualizar runbook de rollback de índice",
        ],
    }


def write_report(report: dict[str, object], path: Path = Path("output/incident_report.json")) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")


def main(argv: list[str] | None = None) -> None:
    argv = argv or sys.argv[1:]
    events = load_events()
    report = build_report(events)
    if "--write" in argv:
        write_report(report)
    print(json.dumps(report, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()

Kit operativo: llevarlo a un repo

Estructura:

mi-proyecto/
  ops/
    ai/
      incident_review.py
      severity_matrix.yaml
      incident_state.md
      postmortem.md
      action_items.csv
      incident_dashboard.sql
      incident_queries.promql
      oncall_handoff.md
      replay_case.json
  data/
    incident_events.jsonl
  evals/
    regression_cases.jsonl
  output/
    incident_report.json

ops/ai/severity_matrix.yaml:

sev1:
  description: capacidad principal indisponible o contrato roto de forma amplia
  response: comando formal, updates cada 15 minutos, mitigación inmediata
sev2:
  description: degradación importante en tarea clave o coste/latencia fuera de SLO
  response: owner operativo, updates cada 30 minutos, postmortem si supera umbral
sev3:
  description: impacto limitado con workaround claro
  response: ticket prioritario, runbook, revisión posterior
sev4:
  description: tendencia o anomalía preventiva
  response: trabajo planificado

ops/ai/incident_state.md:

# Estado de incidencia

## Resumen

- ID:
- Severidad:
- Comando:
- Servicio:
- Inicio:
- Próximo update:

## Impacto

Qué usuarios, tareas, tenants o canales están afectados.

## Hipótesis actuales

Qué creemos y con qué evidencia.

## Mitigaciones aplicadas

- Hora:
- Acción:
- Resultado:

## Decisiones

- Hora:
- Persona:
- Decisión:
- Motivo:

## Evidencia preservada

- Dashboards:
- Trazas:
- Logs:
- Runs:
- Releases:

ops/ai/action_items.csv:

action_id,description,owner,due_date,verification,status
AI-001,Añadir regresión de JSON roto,evalops,2026-06-03,caso en evals/regression_cases.jsonl,open
AI-002,Crear alerta combinada contrato+latencia,ai-platform,2026-06-05,alerta con runbook,open
AI-003,Actualizar rollback de índice,ai-platform,2026-06-04,runbook probado en staging,open

ops/ai/incident_dashboard.sql:

select
  incident_id,
  severity,
  extract(epoch from acknowledged_at - detected_at) / 60 as mtta_minutes,
  extract(epoch from recovered_at - started_at) / 60 as mttr_minutes,
  postmortem_required
from ai_incidents
where started_at >= now() - interval '30 days'
order by started_at desc;

ops/ai/incident_queries.promql:

# Latencia p95 por release y variante
histogram_quantile(
  0.95,
  sum(rate(ai_run_latency_seconds_bucket{task="support_reply"}[10m])) by (le, release_id, variant)
)

# Fallos de contrato por release y variante
sum(rate(ai_contract_fail_total{task="support_reply"}[10m])) by (release_id, variant)
/
sum(rate(ai_run_total{task="support_reply"}[10m])) by (release_id, variant)

# Coste p95 por modelo y ruta
histogram_quantile(
  0.95,
  sum(rate(ai_run_cost_eur_bucket{task="support_reply"}[30m])) by (le, model_id, route_id)
)

ops/ai/replay_case.json:

{
  "case_id": "inc_support_rag_2026_05_28_case_001",
  "source": "incident",
  "task": "support_reply",
  "release_id": "support-rag@1.9.0-rc1",
  "prompt_version": "prompt_v13",
  "model_id": "model_b",
  "route_catalog": "route_catalog@32",
  "rag_index": "rag_index_2026_06",
  "expected": {
    "contract_valid": true,
    "must_cite_source": true,
    "max_latency_ms": 4200
  },
  "observed": {
    "contract_valid": false,
    "latency_ms": 6200,
    "missing_source": true
  }
}

Qué entregaría un alumno:

EntregableCriterio de aceptación
incident_report.jsonCalcula severidad, MTTA, MTTR, síntomas y mitigaciones.
incident_state.mdPermite coordinar mientras la incidencia está viva.
postmortem.mdExplica impacto, línea temporal y causas contribuyentes sin buscar culpables.
action_items.csvCada acción tiene owner, fecha y verificación.
regression_cases.jsonlLa incidencia vuelve a EvalOps.
severity_matrix.yamlLa severidad no se improvisa durante la incidencia.
replay_case.jsonPermite reproducir una parte del problema con versiones.
incident_queries.promqlEvita inventar consultas durante la incidencia.
oncall_handoff.mdOtro turno puede continuar sin reconstruirlo todo.

Postmortem: plantilla mínima

Un postmortem útil responde a preguntas concretas:

SecciónQué debe contener
ResumenQué ocurrió en tres o cuatro líneas.
ImpactoUsuarios, tareas, tiempo, coste, SLO y alcance.
Línea temporalEventos ordenados, con hora y fuente.
DetecciónQué alerta funcionó y cuál llegó tarde.
MitigaciónQué redujo impacto y cuánto tardó.
Causas contribuyentesFactores técnicos y de proceso que hicieron posible la incidencia.
Qué fue bienSeñales, runbooks o decisiones que ayudaron.
Qué fue difícilHuecos de observabilidad, coordinación o tooling.
AccionesCambios con owner, fecha y verificación.
Casos de regresiónQué entra en evals para que no se repita igual.

Acciones malas:

AcciónPor qué no sirve
“Mejorar monitorización”No dice qué señal, umbral, owner o runbook.
“Revisar prompts”No produce cambio verificable.
“Tener más cuidado”No modifica sistema ni proceso.

Acciones buenas:

AcciónVerificación
Añadir alerta contract_fail_rate por release_id y task.Alerta probada en staging con runbook.
Crear regresión con salida JSON rota de la incidencia.Caso en evals/regression_cases.jsonl y CI lo ejecuta.
Cambiar política de canary de índice RAG.No sube al 25% sin shadow de retrieval.

Cómo encaja todo

flowchart TD
  subgraph Antes["Lo que ya traíamos"]
    F3["Modelos y arquitectura (F3)"]
    F4["APIs, RAG y modelos locales (F4)"]
    F5["Agentes, tools y permisos (F5)"]
  end

  subgraph BaseF6["Facsímil 06 · Base operativa previa"]
    C1["Cap. 01 · Sistema operable"]
    C2["Cap. 02 · Runtime, colas y contratos"]
    C3["Cap. 03 · Serving y capacidad"]
    C4["Cap. 04 · Observabilidad"]
    C5["Cap. 05 · Routing y fallback"]
    C6["Cap. 06 · EvalOps y gates"]
    C7["Cap. 07 · Cambios progresivos"]
    C8["Cap. 08 · Handoffs y revisión"]
  end

  subgraph C9["Cap. 09 · Incidencias, postmortems y mejora continua"]
    Symptom["Síntoma operativo"]
    Alert["Alerta accionable"]
    Declare["Declarar incidencia"]
    Roles["Asignar roles"]
    State["Documento vivo"]
    Evidence["Preservar evidencia"]
    Mitigation["Mitigar impacto"]
    Decision{"¿Capacidad recuperada?"}
    Recheck["Reevaluar hipótesis"]
    CloseImpact["Cerrar impacto"]
    Postmortem["Postmortem"]
    Actions["Acciones verificables"]
  end

  subgraph Despues["Lo que prepara"]
    C10["Cap. 10 · Runbooks y continuidad"]
    F7["Evaluar, calibrar e interpretar (F7)"]
    F8["Ciencia de datos y datasets (F8)"]
    F9["Gobernanza y privacidad (F9)"]
  end

  F3 -->|"aportar modelo, latencia y coste a"| C3
  F4 -->|"aportar RAG, APIs y proveedores a"| C5
  F5 -->|"aportar tools, permisos y handoff a"| C8

  C1 -->|"definir qué significa operar"| C2
  C2 -->|"emitir contratos y eventos para"| C4
  C3 -->|"producir señales de capacidad para"| C4
  C4 -->|"detectar"| Alert
  C5 -->|"ofrecer rutas de mitigación a"| Mitigation
  C6 -->|"recibir regresiones desde"| Actions
  C7 -->|"reducir exposición con"| Mitigation
  C8 -->|"coordinar revisión y handoff en"| Roles

  Symptom -->|"activar"| Alert
  Alert -->|"abrir"| Declare
  Declare -->|"nombrar"| Roles
  Roles -->|"mantener"| State
  State -->|"guardar"| Evidence
  Evidence -->|"sostener"| Mitigation
  Mitigation -->|"comprobar"| Decision
  Decision -- "no" --> Recheck
  Recheck -->|"ajustar siguiente acción"| Mitigation
  Decision -- "sí" --> CloseImpact
  CloseImpact -->|"documentar"| Postmortem
  Postmortem -->|"crear"| Actions

  Actions -->|"actualizar"| C10
  Actions -->|"crear casos para"| C6
  Actions -->|"alimentar métricas y calibración en"| F7
  Actions -->|"mejorar datos de evaluación en"| F8
  Evidence -->|"dejar trazabilidad para"| F9

  classDef chapter fill:#ffffff,stroke:#111111,color:#111111,stroke-width:1.4px;
  classDef external fill:#f7f7f7,stroke:#777777,color:#111111,stroke-width:1.1px,stroke-dasharray: 5 4;
  class Symptom,Alert,Declare,Roles,State,Evidence,Mitigation,Decision,Recheck,CloseImpact,Postmortem,Actions,C1,C2,C3,C4,C5,C6,C7,C8 chapter;
  class F3,F4,F5,C10,F7,F8,F9 external;

Este mapa no intenta repetir toda la incidencia paso a paso. Su función es enseñar dónde vive el capítulo 09 dentro del libro: nace de observabilidad, routing, EvalOps, cambios progresivos y handoffs; convierte una degradación en evidencia y acciones; y deja preparado el capítulo 10, donde esas acciones se vuelven runbooks, continuidad y laboratorio de operación.

Relación con otros capítulos

CapítuloQué aporta aquí
F6 · Capítulo 04Señales, SLI, SLO, alertas y runbooks.
F6 · Capítulo 05Fallback, presupuestos, rate limits y degradación controlada.
F6 · Capítulo 06Casos de incidencia que vuelven a datasets y gates.
F6 · Capítulo 07Rollback, kill switch y post-release review.
F6 · Capítulo 08Handoffs, colas, SLO de revisión y decisiones humanas.

Amershi y colaboradores mostraron que los sistemas de ML en producción requieren procesos de ingeniería alrededor de datos, evaluación, monitorización y operación.10 El NIST AI RMF organiza el trabajo alrededor de gobernar, mapear, medir y gestionar sistemas de IA.11 Una incidencia bien cerrada toca los cuatro verbos: alguien gobierna, el equipo mapea impacto, mide señales y gestiona cambios.

Para entenderlo

Tres escenas:

SituaciónRespuesta débilRespuesta operativa
Sube latencia p95 tras canary.“Estamos mirando logs”.SEV2, bajar canary, guardar trazas y abrir postmortem.
RAG cita documentos obsoletos.Cambiar el índice sin dejar rastro.Rollback de índice, conservar queries y crear regresión.
Cola de revisión caduca.Pedir paciencia a soporte.Activar backpressure y revisar política de handoff.

La madurez no está en no tener incidencias. Está en que cada una reduzca la próxima.

Vocabulario aprendido

TérminoDefinición breve
IncidenciaEvento que degrada una propiedad operativa relevante.
SeveridadClasificación por impacto, alcance, duración y urgencia.
MTTATiempo hasta reconocer una incidencia.
MTTRTiempo hasta recuperar el servicio o capacidad.
Comando de incidenciaRol que mantiene estado global y coordina respuesta.
MitigaciónAcción para reducir impacto antes de corregir causa profunda.
PostmortemDocumento de aprendizaje y acciones verificables.
Acción correctivaTrabajo con owner y verificación que reduce recurrencia o impacto.

Dónde solía tropezar yo

Me costó aceptar que una incidencia bien operada puede parecer lenta al principio: parar, nombrar roles y escribir estado parece burocracia. Luego descubres que evita cambios cruzados, duplicidad de mensajes y pérdida de evidencia.

TropiezoAntídoto
Investigar sin declarar.Si afecta SLO o requiere otro equipo, declara pronto.
Cambiar varias cosas a la vez.Una mitigación, un owner, un registro.
Cerrar al recuperar.Cerrar solo cuando hay postmortem o decisión de no hacerlo.
Acciones vagas.Cada acción con owner, fecha y verificación.
No alimentar EvalOps.Cada incidencia debe crear o revisar casos de regresión.

La frase útil: la recuperación devuelve el servicio; el postmortem mejora el sistema.

Antes de pasar página

Comprueba que puedes responder:

  1. ¿Qué diferencia hay entre alerta, incidencia y postmortem?
  2. ¿Qué dimensiones usarías para severidad en un sistema de IA?
  3. ¿Qué roles separarías durante una incidencia?
  4. ¿Por qué mitigar no siempre corrige la causa profunda?
  5. ¿Qué datos necesitas para calcular MTTA y MTTR?
  6. ¿Qué acción correctiva considerarías verificable?
  7. ¿Cómo convertirías una incidencia en un caso de EvalOps?

En resumen

IdeaPara llevarte
Una incidencia de IA puede ser calidad, contrato, coste, latencia o trazabilidad.No mires solo caídas de servicio.
La respuesta necesita roles.Comando, operaciones, comunicación y planificación reducen caos.
Mitigar protege, postmortem aprende.Las dos cosas son necesarias y distintas.
Una acción sin verificación no cambia el sistema.Owner, fecha, prueba y enlace al caso de regresión.

Para saber más

Notas

  1. Ewaschuk, R. (2016). Monitoring Distributed Systems. En B. Beyer, C. Jones, J. Petoff y N. R. Murphy (eds.), Site Reliability Engineering. https://sre.google/sre-book/monitoring-distributed-systems/. Consultado el 27 de mayo de 2026.

  2. Wilkinson, J. (2018). Alerting on SLOs. En B. Beyer, N. R. Murphy, D. Rensin, K. Kawahara y S. Thorne (eds.), The Site Reliability Workbook. https://sre.google/workbook/alerting-on-slos/. Consultado el 27 de mayo de 2026.

  3. Baye, C. A. (2016). Emergency Response. En Site Reliability Engineering. https://sre.google/sre-book/emergency-response/. Consultado el 28 de mayo de 2026.

  4. Stribblehill, A. (2016). Managing Incidents. En Site Reliability Engineering. https://sre.google/sre-book/managing-incidents/. Consultado el 28 de mayo de 2026.

  5. Lunney, J. y Lueder, S. (2016). Postmortem Culture: Learning from Failure. En Site Reliability Engineering. https://sre.google/sre-book/postmortem-culture/. Consultado el 28 de mayo de 2026.

  6. Little, J. D. C. (1961). A proof for the queuing formula: L = λW. Operations Research, 9(3), 383-387. https://doi.org/10.1287/opre.9.3.383

  7. Dean, J. y Barroso, L. A. (2013). The Tail at Scale. Communications of the ACM, 56(2), 74-80. https://doi.org/10.1145/2408776.2408794

  8. Stribblehill, 2016.

  9. Beyer, B., Jones, C., Petoff, J. y Murphy, N. R. (2016). Handling Overload. En Site Reliability Engineering. https://sre.google/sre-book/handling-overload/. Consultado el 27 de mayo de 2026.

  10. Amershi, S. et al. (2019). Software Engineering for Machine Learning: A Case Study. Proceedings of the 41st International Conference on Software Engineering: Software Engineering in Practice, 291-300. https://doi.org/10.1109/ICSE-SEIP.2019.00042

  11. Tabassi, E. (2023). Artificial Intelligence Risk Management Framework (AI RMF 1.0). NIST AI 100-1. https://doi.org/10.6028/NIST.AI.100-1

Capítulo 10

Facsímil 6 · Construir y operar

Capítulo 10: Runbooks, continuidad y laboratorio de operación

Qué deberías poder hacer al terminar

Este capítulo cierra el facsímil 6. Ya hemos hablado de prototipos que pasan a sistemas, runtime, colas, serving, observabilidad, routing, EvalOps, cambios progresivos, revisión humana e incidencias. Ahora falta la pieza que separa un equipo que “sabe mucho” de un equipo que puede operar bajo presión: runbooks, continuidad y práctica reproducible.

Al terminar, deberías poder hacer esto:

Resultado de aprendizajeEvidencia de que lo sabes hacer
Escribir un runbook operable.Incluyes señales, consultas, decisión, comando, rollback, verificación y criterio de salida.
Definir continuidad.Separas RTO, RPO, modo degradado, recuperación y pérdida aceptable de estado.
Revisar readiness.Compruebas si SLO, observabilidad, EvalOps, rollback, handoff e incidencia están listos.
Diseñar un ensayo operativo.Practicas una degradación controlada sin improvisar datos ni decisiones.
Crear un kit reutilizable.Dejas archivos, comandos, salidas esperadas y criterios de aceptación.
Cerrar el facsímil con práctica real.Resuelves dos retos que juntan todo lo visto en construcción y operación.

La idea central: un sistema de IA no está listo cuando responde; está listo cuando alguien puede recuperarlo, explicarlo, medirlo y mejorarlo.

El cierre que convierte capítulos en operación

Imagina que mañana publicas un asistente RAG para soporte. Tiene tests, trazas, canary y un panel de coste. Parece suficiente. Pero llega una mañana mala: el proveedor principal degrada latencia, el índice nuevo trae documentos equivocados, la cola de revisión crece y una release candidata empezó a circular por una parte del tráfico.

La pregunta no es “¿quién sabe de esto?”. La pregunta operativa es:

¿Dónde está escrito qué mirar, qué cambiar, quién decide, cómo se vuelve a estado conocido y cómo sabemos que hemos recuperado?

Esa respuesta vive en runbooks. No como documento muerto, sino como contrato práctico entre ingeniería, producto, soporte y operación.

Qué no es un runbook

Un runbook no es una colección de notas sueltas. Tampoco es un tutorial para leer tranquilamente. Y desde luego no es una página que diga “mirar dashboard” o “reiniciar servicio” sin explicar cuándo, por qué y cómo comprobar el resultado.

Un runbook pobre suele tener tres síntomas:

SíntomaQué ocurre en producción
Dice qué hacer, pero no cuándo.Dos personas ejecutan acciones distintas ante el mismo síntoma.
Tiene comandos sin verificación.Nadie sabe si el cambio arregló algo o solo cambió la gráfica.
No versiona contexto.No se sabe qué modelo, prompt, índice, router o contrato estaba activo.
No define dueño.La decisión se queda flotando en un canal.
No tiene criterio de salida.La operación parece cerrada antes de recuperar SLO, contrato y evidencia.

Un runbook útil no quita pensamiento. Quita ruido. Deja espacio mental para lo difícil: interpretar señales, elegir mitigación y aprender.

Qué sí es un runbook operativo

Para este libro, un runbook operativo es:

Un procedimiento versionado que convierte una situación reconocible en señales, decisiones, acciones, verificación y aprendizaje.

Ejemplo de fórmula. Podemos representarlo así:

RB=(S,Q,D,A,V,E,C)RB = (S, Q, D, A, V, E, C)
SímboloSignificadoEjemplo
SSSeñales de entrada.Latencia p95, fallos de contrato, coste p95, cola, feedback.
QQConsultas preparadas.PromQL, SQL, enlace a trazas, panel de canary.
DDDecisión que debe tomarse.Rollback, fallback, modo degradado, pausa de canary.
AAAcciones concretas.Comando, cambio de flag, ruta alternativa, handoff.
VVVerificación.SLO recuperado, contrato válido, coste normal, traza completa.
EEEvidencia que se preserva.trace_id, release_id, model_id, prompt_version, index_version.
CCCriterio de cierre.Condiciones para dar por recuperada la capacidad.

Si falta QQ, la persona buscará a mano. Si falta VV, ejecutará sin saber si ayudó. Si falta CC, cerrará por cansancio, no por evidencia.

Fecha de corte del estado del arte

Fecha de corte: 28 de mayo de 2026.
Fuentes consultadas: capítulos de Google SRE sobre monitorización, respuesta de emergencia, gestión de incidencias, postmortems y sobrecarga; SRE Workbook sobre alerting basado en SLOs; AWS Builders Library sobre timeouts, retries y backoff; OpenTelemetry sobre trazas, logs y métricas; NIST AI RMF; y trabajos de ingeniería de ML.

Google SRE insiste en que las alertas que interrumpen a personas deben ser accionables, relevantes para el usuario y fáciles de interpretar.1 El SRE Workbook recomienda alertar usando consumo de presupuesto de error para conectar síntomas con SLOs, en vez de reaccionar a umbrales aislados.2

La preparación también forma parte del sistema. Google SRE trata la respuesta de emergencia como una habilidad que se entrena antes de necesitarla.3 En gestión de incidencias separa roles para coordinar decisiones, trabajo operativo, comunicación y planificación.4 Después, los postmortems deben convertir lo ocurrido en acciones verificables y aprendizaje del sistema.5

Lo estable no es la herramienta concreta de guardia, CI, dashboard o proveedor. Lo estable es el circuito: observar, decidir, actuar, verificar, documentar, practicar y mejorar.

Continuidad: RTO, RPO y modo degradado

Continuidad no significa que nada falle. Significa que el sistema tiene caminos pensados para seguir prestando lo esencial cuando una parte se degrada.

Dos siglas son importantes:

SiglaQué mideEjemplo en IA
RTOTiempo objetivo para recuperar una capacidad.“El asistente de soporte debe volver a responder en menos de 30 minutos”.
RPOPérdida máxima aceptable de estado o datos medida como tiempo.“Podemos perder como máximo 10 minutos de cola o memoria operativa”.

Si trecuperaciont_{recuperacion} es el tiempo real hasta recuperar y RTORTO el objetivo:

trecuperacionRTOt_{recuperacion} \le RTO
SímboloSignificadoEjemplo
trecuperaciont_{recuperacion}Minutos desde inicio hasta capacidad recuperada.24 minutos.
RTORTOMáximo aceptable acordado.30 minutos.

Si el sistema recibe λ\lambda trabajos por minuto y queda degradado WW minutos, el backlog esperado se aproxima como:

L=λWL = \lambda W
SímboloSignificadoEjemplo
LLTrabajos acumulados.180 solicitudes.
λ\lambdaLlegadas por minuto.6 solicitudes/minuto.
WWMinutos degradado.30 minutos.

Esta es una forma directa de usar la ley de Little para operación.6 Si la cola crece, no basta con desear que baje: o reducimos entrada, o subimos capacidad, o aceptamos más espera.

Runbooks por capas

Un sistema de IA se opera por capas. El runbook debe ayudar a distinguirlas:

CapaPreguntaSeñalesAcción típica
Producto¿El usuario completa la tarea?Conversión, aceptación, tickets, abandono.Modo degradado, estado claro, revisión.
Contrato¿La salida sigue siendo compatible?JSON inválido, campos ausentes, enum fuera de catálogo.Schema anterior, validador estricto, rollback de prompt.
Modelo¿Cambió calidad, latencia o coste?model_id, p95, coste, flake rate, errores.Cambiar ruta, reducir esfuerzo, fallback.
RAG¿El contexto recuperado sirve?index_version, chunks, citas, recall manual.Volver índice, bajar top_k, pausar reranker.
Runtime¿El sistema procesa bien?Queue depth, timeouts, goodput, workers.Rate limit, escalar, reducir contexto.
Handoff¿La revisión avanza?Backlog, aging, SLA, owner.Priorizar, reasignar, limitar entrada.
EvalOps¿La release es defendible?Scorecard, regresiones, canary, gates.Bloquear, revertir, añadir caso.
Observabilidad¿Podemos reconstruir?Trazas, logs, métricas, atributos.Subir sampling, añadir atributos obligatorios.

OpenTelemetry separa señales como trazas, logs y métricas para reconstruir comportamiento desde ángulos distintos.789 En IA, esas señales deben llevar atributos como task, model_id, prompt_version, route_id, release_id, trace_id e index_version.

Readiness: la pregunta antes de publicar

Una revisión de readiness responde una pregunta incómoda:

Si esto falla hoy a las 03:17, ¿tenemos lo necesario para entenderlo, limitarlo y recuperarlo?

Ejemplo de fórmula. Podemos convertir esa pregunta en una puntuación:

R=i=1nwicii=1nwiR = \frac{\sum_{i=1}^{n} w_i c_i}{\sum_{i=1}^{n} w_i}
SímboloSignificadoEjemplo
RRPuntuación de readiness entre 0 y 1.0,84.
wiw_iPeso de la comprobación ii.2 para rollback, 1 para documentación auxiliar.
cic_iResultado de la comprobación: 1 si pasa, 0 si falta.trace_id presente: 1.
nnNúmero de comprobaciones.26.

Una puntuación alta no garantiza que nada vaya mal. Indica algo más modesto y más útil: que el sistema tiene piezas para operar con método.

BandaDecisión
R0,90R \ge 0{,}90Listo para operar con seguimiento normal.
0,75R<0,900{,}75 \le R < 0{,}90Puede avanzar con condiciones escritas.
R<0,75R < 0{,}75No publicaría sin corregir huecos.

El NIST AI RMF propone gestionar riesgos de IA mediante funciones como gobernar, mapear, medir y gestionar.10 En este capítulo lo llevamos a ingeniería: no basta reconocer riesgos; hay que convertirlos en checks, runbooks, gates y evidencias.

Anatomía visual del cierre operativo

Readiness operativo de un sistema de IA No preguntamos si responde: preguntamos si se puede operar, recuperar y defender. Servicio IA prompt · modelo · RAG router · tools · runtime colas · handoff · evals Readiness review checklist ponderada antes de operar SLO trazas rollback EvalOps Runbooks procedimientos que ejecutan decisiones 1 · síntoma y severidad 2 · consulta preparada 3 · acción reversible 4 · verificación y cierre Continuidad RTO · RPO fallback · modo degradado recuperación verificada Señales mínimas latency_p95 · contract_fail_rate cost_p95 · queue_age · burn_rate trace_completeness · route_mix review_backlog · eval_regressions Evidencia versionada release_id · model_id prompt_version · index_version route_catalog · policy_version scorecard · postmortem · replay Acciones preparadas rollback de prompt, modelo o índice fallback de proveedor o ruta local rate limit · queue only critical handoff · comunicación · seguimiento Criterios entrada salida escalado aprendizaje Ensayo operativo se simula degradación controlada se mide tiempo de decisión se verifica rollback y fallback se actualiza runbook y dataset Laboratorio reto 1: readiness de un servicio reto 2: continuidad con varias capas respuesta paso a paso entregable defendible Mejora continua acción con owner y fecha caso nuevo de regresión scorecard actualizada nuevo ensayo programado Criterio final si no puedes practicar la recuperación, todavía no has terminado de construir el sistema. IA para gente curiosa / Facsímil 06 / Capítulo 10 / 686f6c61

Ensayos operativos: practicar antes de necesitarlo

Un ensayo operativo es una prueba controlada. No buscamos “romper cosas”; buscamos comprobar si el equipo sabe operar una situación concreta.

Ejemplos de ensayos útiles:

EnsayoQué se practicaCriterio de éxito
Proveedor lentoFallback, routing, timeouts y comunicación.p95 vuelve a SLO y coste no se dispara.
Índice RAG equivocadoRollback de índice y replay.Recuperas fuentes correctas y generas regresión.
Fallo de contrato JSONValidador, schema anterior y gate.La salida vuelve a ser parseable.
Cola de revisión llenaPriorización, modo degradado y SLA.Casos críticos no caducan.
Canary con peor calidadGate online y rollback.Se corta candidate sin afectar todo el tráfico.
Trazas incompletasSampling temporal y atributos obligatorios.Puedes reconstruir runs relevantes.

La AWS Builders Library recomienda tratar timeouts, retries y backoff como diseño explícito porque los reintentos pueden amplificar carga si se usan sin cuidado.11 En IA esto se nota enseguida: un retry sobre una ruta cara, una tool lenta o un contexto largo puede convertir una degradación pequeña en coste y cola.

Qué te llevas para poner en práctica

Este capítulo deja un kit que un alumno puede llevar a un repositorio real:

ArchivoPara qué sirve
ops/ai/readiness_manifest.jsonDescribe SLO, observabilidad, rollback, EvalOps, incidencia, continuidad y handoff.
ops/ai/operational_readiness.pyVerifica si el servicio tiene piezas mínimas para operar.
ops/ai/runbook_ai_service.mdProcedimiento para síntomas habituales.
ops/ai/continuity_drill.mdGuion de ensayo operativo.
ops/ai/slo_policy.yamlSLI, SLO, presupuesto y criterios de alerta.
ops/ai/rollback_plan.mdCómo volver a una versión conocida.
ops/ai/oncall_handoff.mdCómo transferir guardia o responsabilidad.
evals/regression_cases.jsonlCasos que protegen aprendizajes.
.github/workflows/ai-readiness.ymlGate automático que bloquea si el servicio no está listo.
output/operational_readiness.jsonResultado verificable del check.

La ingeniería de ML no termina en entrenar o invocar modelos. Amershi y colaboradores muestran que los sistemas de ML obligan a coordinar datos, código, configuración, evaluación y operación de forma continua.12 Este kit existe para eso: que el conocimiento del facsímil se convierta en archivos.

Reto de capítulo: comprobar readiness

Escenario: vas a publicar support-rag@2.0.0. Antes de avanzar, debes comprobar si el servicio tiene los mínimos para operar una incidencia o una degradación.

Estructura:

mi-proyecto/
  ops/
    ai/
      operational_readiness.py
      readiness_manifest.json
  output/
    operational_readiness.json

Comandos:

mkdir -p ops/ai output
python ops/ai/operational_readiness.py --write
cat output/operational_readiness.json

Salida esperada con el manifiesto completo:

{
  "service": "support-rag",
  "release": "support-rag@2.0.0",
  "score": 1.0,
  "gate": "ready",
  "passed_weight": 39,
  "total_weight": 39,
  "section_scores": {
    "identity": 1.0,
    "slo": 1.0,
    "observability": 1.0,
    "rollback": 1.0,
    "evalops": 1.0,
    "incident": 1.0,
    "continuity": 1.0,
    "handoff": 1.0
  },
  "missing": [],
  "next_actions": [
    "programar un ensayo operativo mensual",
    "ejecutar el gate de release antes del siguiente canary",
    "revisar runbook tras la próxima incidencia cerrada"
  ]
}

Manos a la obra

Práctica: construir el verificador.

Laboratorio ejecutable de este capítulo: kit descargable.

# Descomprime el ZIP del capítulo y ejecuta estos comandos dentro de esa carpeta
python3 ops/operational_readiness.py \
  --manifest contracts/readiness_manifest_complete.json \
  --output output/complete/operational_readiness.json \
  --decision-output output/complete/readiness_decision.md \
  --write

Guarda este script como ops/ai/operational_readiness.py.

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path
from typing import Any, Callable


DEFAULT_MANIFEST: dict[str, Any] = {
    "service": "support-rag",
    "release": "support-rag@2.0.0",
    "owner": "ai-platform",
    "slo": {
        "latency_p95_ms": 4200,
        "availability": 0.995,
        "contract_fail_rate_max": 0.006,
        "cost_p95_eur": 0.025
    },
    "observability": {
        "required_attributes": [
            "trace_id",
            "run_id",
            "task",
            "model_id",
            "prompt_version",
            "route_id",
            "release_id",
            "index_version"
        ],
        "dashboards": ["runtime", "quality", "cost"],
        "alerts": ["slo_burn_rate", "contract_fail_rate", "queue_age"]
    },
    "rollback": {
        "last_known_good": "support-rag@1.9.3",
        "rollback_command": "make rollback SERVICE=support-rag VERSION=support-rag@1.9.3",
        "tested_at": "2026-05-28T09:00:00Z"
    },
    "evalops": {
        "datasets": ["golden", "regression", "incident"],
        "release_gate": "ops/ai/release_gate.py",
        "min_quality_delta": -0.01
    },
    "incident": {
        "runbook": "ops/ai/runbook_ai_service.md",
        "severity_matrix": "ops/ai/severity_matrix.yaml",
        "oncall": "ai-platform-oncall"
    },
    "continuity": {
        "rto_minutes": 30,
        "rpo_minutes": 10,
        "fallback_routes": ["provider_b", "local_small_model"],
        "manual_mode": "review_queue_only"
    },
    "handoff": {
        "queues": ["support_n2", "ai_platform"],
        "approval_card": "ops/ai/approval_card.json"
    }
}


def get_path(document: dict[str, Any], dotted_path: str) -> Any:
    current: Any = document
    for part in dotted_path.split("."):
        if not isinstance(current, dict) or part not in current:
            return None
        current = current[part]
    return current


def is_positive_number(value: Any) -> bool:
    return isinstance(value, (int, float)) and value > 0


def has_items(*items: str) -> Callable[[Any], bool]:
    expected = set(items)

    def check(value: Any) -> bool:
        return isinstance(value, list) and expected.issubset(set(value))

    return check


def has_at_least(count: int) -> Callable[[Any], bool]:
    def check(value: Any) -> bool:
        return isinstance(value, list) and len(value) >= count

    return check


def non_empty(value: Any) -> bool:
    return bool(value)


def max_number(limit: float) -> Callable[[Any], bool]:
    def check(value: Any) -> bool:
        return is_positive_number(value) and value <= limit

    return check


CHECKS: list[dict[str, Any]] = [
    {"path": "service", "weight": 1, "label": "nombre del servicio", "check": non_empty},
    {"path": "release", "weight": 1, "label": "release versionada", "check": non_empty},
    {"path": "owner", "weight": 1, "label": "owner operativo", "check": non_empty},
    {"path": "slo.latency_p95_ms", "weight": 2, "label": "SLO de latencia p95", "check": is_positive_number},
    {"path": "slo.availability", "weight": 2, "label": "SLO de disponibilidad", "check": is_positive_number},
    {"path": "slo.contract_fail_rate_max", "weight": 2, "label": "SLO de contrato", "check": is_positive_number},
    {"path": "slo.cost_p95_eur", "weight": 1, "label": "presupuesto de coste p95", "check": is_positive_number},
    {
        "path": "observability.required_attributes",
        "weight": 3,
        "label": "atributos de traza obligatorios",
        "check": has_items("trace_id", "run_id", "model_id", "prompt_version", "release_id")
    },
    {"path": "observability.dashboards", "weight": 1, "label": "dashboards mínimos", "check": has_at_least(2)},
    {"path": "observability.alerts", "weight": 2, "label": "alertas accionables", "check": has_at_least(3)},
    {"path": "rollback.last_known_good", "weight": 2, "label": "última versión buena", "check": non_empty},
    {"path": "rollback.rollback_command", "weight": 2, "label": "comando de rollback", "check": non_empty},
    {"path": "rollback.tested_at", "weight": 1, "label": "rollback probado", "check": non_empty},
    {"path": "evalops.datasets", "weight": 3, "label": "datasets golden/regression/incident", "check": has_items("golden", "regression", "incident")},
    {"path": "evalops.release_gate", "weight": 2, "label": "gate de release", "check": non_empty},
    {"path": "incident.runbook", "weight": 2, "label": "runbook de incidencia", "check": non_empty},
    {"path": "incident.severity_matrix", "weight": 1, "label": "matriz de severidad", "check": non_empty},
    {"path": "incident.oncall", "weight": 1, "label": "guardia u owner de respuesta", "check": non_empty},
    {"path": "continuity.rto_minutes", "weight": 2, "label": "RTO definido", "check": max_number(60)},
    {"path": "continuity.rpo_minutes", "weight": 2, "label": "RPO definido", "check": max_number(15)},
    {"path": "continuity.fallback_routes", "weight": 2, "label": "rutas de fallback", "check": has_at_least(1)},
    {"path": "continuity.manual_mode", "weight": 1, "label": "modo manual o degradado", "check": non_empty},
    {"path": "handoff.queues", "weight": 1, "label": "colas de revisión", "check": has_at_least(1)},
    {"path": "handoff.approval_card", "weight": 1, "label": "tarjeta de aprobación", "check": non_empty}
]


def load_manifest(path: Path) -> dict[str, Any]:
    if not path.exists():
        return DEFAULT_MANIFEST
    return json.loads(path.read_text(encoding="utf-8"))


def evaluate(manifest: dict[str, Any]) -> dict[str, Any]:
    missing: list[dict[str, Any]] = []
    passed_weight = 0
    total_weight = 0
    sections: dict[str, dict[str, int]] = {}

    for item in CHECKS:
        value = get_path(manifest, item["path"])
        weight = int(item["weight"])
        first_key = str(item["path"]).split(".")[0]
        section = "identity" if first_key in {"service", "release", "owner"} else first_key
        sections.setdefault(section, {"passed_weight": 0, "total_weight": 0})
        sections[section]["total_weight"] += weight
        total_weight += weight
        if item["check"](value):
            passed_weight += weight
            sections[section]["passed_weight"] += weight
        else:
            missing.append({
                "path": item["path"],
                "label": item["label"],
                "weight": weight
            })

    score = round(passed_weight / total_weight, 4) if total_weight else 0.0
    if score >= 0.90:
        gate = "ready"
    elif score >= 0.75:
        gate = "ready_with_conditions"
    else:
        gate = "not_ready"

    section_scores = {
        section: round(values["passed_weight"] / values["total_weight"], 4)
        for section, values in sections.items()
        if values["total_weight"] > 0
    }

    next_actions = [f"corregir: {item['label']} ({item['path']})" for item in missing[:5]]
    if not next_actions:
        next_actions = [
            "programar un ensayo operativo mensual",
            "ejecutar el gate de release antes del siguiente canary",
            "revisar runbook tras la próxima incidencia cerrada"
        ]

    return {
        "service": manifest.get("service"),
        "release": manifest.get("release"),
        "score": score,
        "gate": gate,
        "passed_weight": passed_weight,
        "total_weight": total_weight,
        "section_scores": section_scores,
        "missing": missing,
        "next_actions": next_actions
    }


def write_report(report: dict[str, Any], path: Path) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")


def main() -> None:
    parser = argparse.ArgumentParser()
    parser.add_argument("--manifest", default="ops/ai/readiness_manifest.json")
    parser.add_argument("--write", action="store_true")
    parser.add_argument("--output", default="output/operational_readiness.json")
    parser.add_argument("--strict", action="store_true", help="sale con código 2 si el servicio no está listo")
    args = parser.parse_args()

    manifest = load_manifest(Path(args.manifest))
    report = evaluate(manifest)
    if args.write:
        write_report(report, Path(args.output))
    print(json.dumps(report, ensure_ascii=False, indent=2))
    if args.strict and report["gate"] != "ready":
        sys.exit(2)


if __name__ == "__main__":
    main()

Kit operativo: archivos mínimos

ops/ai/readiness_manifest.json:

{
  "service": "support-rag",
  "release": "support-rag@2.0.0",
  "owner": "ai-platform",
  "slo": {
    "latency_p95_ms": 4200,
    "availability": 0.995,
    "contract_fail_rate_max": 0.006,
    "cost_p95_eur": 0.025
  },
  "observability": {
    "required_attributes": [
      "trace_id",
      "run_id",
      "task",
      "model_id",
      "prompt_version",
      "route_id",
      "release_id",
      "index_version"
    ],
    "dashboards": ["runtime", "quality", "cost"],
    "alerts": ["slo_burn_rate", "contract_fail_rate", "queue_age"]
  },
  "rollback": {
    "last_known_good": "support-rag@1.9.3",
    "rollback_command": "make rollback SERVICE=support-rag VERSION=support-rag@1.9.3",
    "tested_at": "2026-05-28T09:00:00Z"
  },
  "evalops": {
    "datasets": ["golden", "regression", "incident"],
    "release_gate": "ops/ai/release_gate.py",
    "min_quality_delta": -0.01
  },
  "incident": {
    "runbook": "ops/ai/runbook_ai_service.md",
    "severity_matrix": "ops/ai/severity_matrix.yaml",
    "oncall": "ai-platform-oncall"
  },
  "continuity": {
    "rto_minutes": 30,
    "rpo_minutes": 10,
    "fallback_routes": ["provider_b", "local_small_model"],
    "manual_mode": "review_queue_only"
  },
  "handoff": {
    "queues": ["support_n2", "ai_platform"],
    "approval_card": "ops/ai/approval_card.json"
  }
}

ops/ai/runbook_ai_service.md:

# Runbook: support-rag

## Entrada

- Síntoma:
- Severidad:
- Servicio:
- Release:
- Trace o dashboard:

## Consultas preparadas

- Latencia p95 por release.
- Fallo de contrato por release y variante.
- Coste p95 por modelo y ruta.
- Cola de revisión por edad y prioridad.
- Trazas con `model_id`, `prompt_version`, `index_version` y `route_id`.

## Decisión

| Síntoma | Acción |
|---|---|
| JSON inválido | volver schema/prompt anterior |
| Latencia p95 fuera de SLO | cambiar ruta, reducir contexto o limitar entrada |
| Índice con fuentes pobres | volver índice anterior |
| Cola de revisión saturada | modo degradado y prioridad crítica |
| Coste p95 fuera de presupuesto | ruta barata, cache o límite de tokens |

## Verificación

- SLO recuperado durante 30 minutos.
- Contrato válido por encima del umbral.
- Coste p95 bajo presupuesto.
- Trazas completas para runs muestreadas.
- Acción correctiva creada si procede.

## Criterio de cierre

La operación se cierra cuando el servicio vuelve a SLO, la evidencia queda preservada y hay owner para las acciones pendientes.

ops/ai/continuity_drill.md:

# Ensayo operativo: degradación de soporte RAG

## Objetivo

Practicar recuperación ante latencia alta, índice RAG incorrecto y cola de revisión creciendo.

## Duración

45 minutos.

## Roles

- Coordinación:
- Operación:
- Observabilidad:
- EvalOps:
- Comunicación:

## Inyección controlada

1. Subir latencia artificial en ruta `provider_a`.
2. Cambiar `index_version` a un índice candidato.
3. Aumentar backlog de revisión con 20 casos no críticos.

## Decisiones esperadas

1. Fallback a `provider_b`.
2. Rollback de índice si las citas caen.
3. Modo `review_queue_only` para casos no críticos.
4. Caso nuevo en `evals/regression_cases.jsonl`.

## Evidencia

- Dashboard de latencia.
- Traces de tres runs.
- Scorecard de EvalOps.
- Registro de decisiones.

## Cierre

El ensayo termina cuando se recupera SLO, se documentan huecos y se abren acciones con owner.

Qué entregaría un alumno:

EntregableCriterio de aceptación
readiness_manifest.jsonDescribe servicio, owner, SLO, observabilidad, rollback, EvalOps, incidencia y continuidad.
operational_readiness.pyEjecuta sin dependencias externas y produce una decisión.
operational_readiness.jsonIncluye score, gate, faltantes y siguientes acciones.
runbook_ai_service.mdPermite operar síntomas concretos sin inventar el procedimiento.
continuity_drill.mdPractica una degradación con roles, señales y cierre.
evals/regression_cases.jsonlAñade al menos un caso nacido de una incidencia o ensayo.

Para que esto sea práctica de ingeniería y no solo documentación, el check debe poder vivir en CI. GitHub Actions define workflows declarativos con jobs y pasos ejecutables.13 Un ejemplo mínimo:

name: ai-readiness

on:
  pull_request:
    paths:
      - "ops/ai/**"
      - "prompts/**"
      - "rag/**"
      - "evals/**"
  workflow_dispatch:

jobs:
  readiness:
    runs-on: ubuntu-latest
    timeout-minutes: 5
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - name: Check operational readiness
        run: |
          python ops/ai/operational_readiness.py \
            --manifest ops/ai/readiness_manifest.json \
            --write \
            --strict
      - name: Show readiness report
        if: always()
        run: cat output/operational_readiness.json

El detalle importante es --strict: si el servicio queda en not_ready o ready_with_conditions, el job sale con código distinto de cero. Eso obliga a decidir en la pull request si se corrige el hueco, se documenta una excepción temporal o se retrasa la publicación.

Qué faltaría si lo revisa un equipo de ingeniería

La primera versión del capítulo ya tenía runbooks, continuidad, script y laboratorio. Para subirlo de nivel, añadiría estas piezas, porque son las que un equipo técnico suele echar de menos cuando intenta llevarlo a un repo real:

PiezaQué añadeCómo se comprueba
Gate en CIEl readiness deja de ser lectura y se convierte en control de cambio.Una PR falla si falta rollback, SLO, trazas o continuidad.
Score por áreaNo basta saber “82%”; hay que saber si falla observabilidad, EvalOps o handoff.section_scores en operational_readiness.json.
Matriz de dependenciasExplica qué ocurre si cae proveedor, índice, cola, tool o base de datos.Tabla con dependencia, modo degradado, RTO, RPO y owner.
Ensayo con relojMide cuánto tarda el equipo en decidir y recuperar.Timeline con minuto de detección, decisión, mitigación y verificación.
Restauración de estadoNo solo rollback de código: también cola, memoria, índice y configuración.Prueba de restauración con last_known_good y datos mínimos.
Evidencia de cierreEvita cerrar por sensación.SLO recuperado, trazas completas, regresión creada y acción con owner.
Rúbrica evaluableHace que el laboratorio sirva para universidad o revisión interna.Puntos por artefactos, ejecución, criterio técnico y explicación.

Una matriz de dependencias mínima:

DependenciaSi se degradaModo degradadoRTORPOOwner
Proveedor principalLatencia o errores suben.Ruta provider_b o modelo local pequeño.30 min0 minai-platform.
Índice RAGCitas pobres o documentos antiguos.Volver last_good_index.20 min10 minrag-team.
Cola de revisiónAging p95 supera SLO.Solo casos críticos y respuesta de estado.45 min0 minsupport-ops.
Configuración de routerMezcla de rutas se desvía.Catálogo anterior y límite por tarea.15 min0 minai-platform.
Dataset de regresiónGate pierde cobertura.Bloquear release hasta recuperar casos críticos.1 día0 casos críticosevalops.

La práctica queda mejor cuando el lector no solo “pasa un script”, sino que puede defender qué dependencia se degradó, qué modo reducido usó, qué perdió como máximo y qué evidencia deja.

Cómo encaja todo

flowchart TD
  subgraph F6["Facsímil 06 · Construir y operar"]
    C1["Sistema operable"]
    C2["Runtime, API, colas y contratos"]
    C3["Serving y capacidad"]
    C4["Observabilidad"]
    C5["Routing y fallback"]
    C6["EvalOps y gates"]
    C7["Canary, shadow y rollback"]
    C8["Handoff y revisión"]
    C9["Incidencias y postmortems"]
    C10["Runbooks y continuidad"]
    Lab["Laboratorio final"]
  end

  subgraph Antes["Lo que traíamos"]
    Agents["Agentes y tools (F5)"]
    RAG["RAG, APIs y modelos locales (F4)"]
    Models["Modelos y arquitectura (F3)"]
  end

  subgraph Despues["Capítulos futuros que prepara"]
    F7C1["F7 · Capítulos de métricas y evaluación"]
    F7C2["F7 · Capítulos de calibración e interpretación"]
    F8C1["F8 · Capítulos de datos, linaje y calidad"]
    F9C1["F9 · Capítulos de privacidad, controles y gobierno"]
  end

  Models -->|"servir mediante"| C3
  RAG -->|"aportar contexto a"| C5
  Agents -->|"exigir estado y tools en"| C2
  C1 -->|"definir base para"| C2
  C2 -->|"necesitar capacidad en"| C3
  C3 -->|"emitir señales a"| C4
  C4 -->|"alimentar decisiones de"| C5
  C5 -->|"bloquear o permitir cambios con"| C6
  C6 -->|"publicar con"| C7
  C7 -->|"pedir revisión en"| C8
  C8 -->|"coordinar durante"| C9
  C9 -->|"producir acciones para"| C10
  C10 -->|"convertir en práctica"| Lab
  Lab -->|"preparar prácticas evaluables para"| F7C1
  C6 -->|"alimentar métricas y regresiones en"| F7C1
  C4 -->|"dar trazas para interpretar"| F7C2
  C4 -->|"generar evidencia para"| F8C1
  C8 -->|"conectar decisiones humanas con"| F9C1
  C10 -->|"dejar controles operativos para"| F9C1

  classDef chapter fill:#ffffff,stroke:#111111,color:#111111,stroke-width:1.4px;
  classDef external fill:#f7f7f7,stroke:#777777,color:#111111,stroke-width:1.1px,stroke-dasharray: 5 4;
  class C1,C2,C3,C4,C5,C6,C7,C8,C9,C10,Lab chapter;
  class Agents,RAG,Models,F7C1,F7C2,F8C1,F9C1 external;

El mapa resume el motivo de este capítulo: runbooks y continuidad no son una sección administrativa. Son el punto donde todos los capítulos anteriores se convierten en práctica operable. Como los facsímiles 7, 8 y 9 todavía están planificados, no inventamos títulos cerrados de capítulos; dejamos nodos de capítulo futuro por tema, para que la relación quede clara sin fingir una estructura que aún puede cambiar.

Vocabulario aprendido

TérminoDefinición útil
RunbookProcedimiento operativo versionado para diagnosticar, decidir, actuar y verificar.
Readiness reviewComprobación previa que dice si un servicio está listo para ser operado.
RTOTiempo objetivo máximo para recuperar una capacidad.
RPOPérdida máxima aceptable de datos o estado medida como tiempo.
Modo degradadoServicio reducido que conserva lo esencial mientras se recupera lo completo.
Ensayo operativoPrueba controlada para practicar recuperación, roles, señales y decisiones.
Criterio de entradaSeñal que indica cuándo activar un runbook.
Criterio de salidaCondición verificable para cerrar una operación.
Paquete de evidenciasTrazas, métricas, logs, versiones y decisiones que reconstruyen lo ocurrido.
Last known goodÚltima versión conocida que funcionaba de forma aceptable.
GateRegla verificable que deja avanzar o bloquea un cambio.
Acción correctivaCambio con owner y verificación que reduce repetición de un problema.

Dónde solía tropezar yo

TropiezoPor qué pasaAntídoto
Escribir runbooks como notasParece suficiente cuando todo va bien.Convertir cada nota en señal, decisión, acción y verificación.
No practicar recuperaciónEl equipo confía en que sabrá hacerlo.Programar ensayos operativos con datos y roles.
Mezclar RTO con deseoSe promete recuperar rápido sin capacidad real.Medir cola, tiempo de decisión y comandos disponibles.
Olvidar RPOSe piensa solo en servicio, no en estado perdido.Definir qué memoria, cola o evento se puede reconstruir.
No conectar postmortem con EvalOpsEl aprendizaje queda en un documento.Cada incidencia relevante crea un caso de regresión o un gate.
Hacer checklist sin pesosTodo parece igual de importante.Ponderar rollback, trazas, SLO y continuidad más que detalles secundarios.

Antes de pasar página

Responde estas preguntas antes de cerrar el facsímil:

PreguntaVuelve a
¿Puedes explicar qué diferencia hay entre runbook, postmortem y checklist?Qué sí es un runbook operativo.
¿Tu servicio tiene RTO y RPO escritos?Continuidad: RTO, RPO y modo degradado.
¿Sabes qué ruta usar si el proveedor principal degrada latencia?Capítulo 05.
¿Tienes consultas preparadas para latencia, contrato, coste y cola?Capítulo 04.
¿Puedes volver a una versión conocida sin tocar tres cosas a la vez?Capítulo 07.
¿Una incidencia crea casos nuevos de regresión?Capítulo 06.
¿Tu cola de revisión tiene SLO y modo degradado?Capítulo 08.
¿Tu postmortem deja acciones con owner y verificación?Capítulo 09.
¿Puedes ejecutar el verificador de readiness y explicar el resultado?Manos a la obra.

En resumen

Idea fuerzaQué te llevas
Operar es diseñar recuperación.No basta con publicar; hay que saber volver, limitar y explicar.
El runbook debe ser ejecutable mentalmente.Señal, consulta, decisión, acción, verificación y cierre.
Continuidad tiene números.RTO, RPO, cola, capacidad y modo degradado.
Readiness es evidencia, no optimismo.SLO, trazas, rollback, EvalOps, handoff e incidencia deben existir.
La práctica importa.Ensayar antes reduce improvisación cuando aparece una incidencia real.
El laboratorio cierra el facsímil.Dos retos obligan a construir, justificar y adaptar lo aprendido.

Laboratorio

Un laboratorio, dentro de este libro, es un espacio de práctica guiada. Aquí juntamos el facsímil completo: sistema operable, runtime, observabilidad, routing, EvalOps, canary, rollback, handoff, incidencias y continuidad.

La intención no es que memorices nombres. La intención es que puedas llevarte un kit a un proyecto real y defenderlo ante alguien de ingeniería: qué has creado, cómo lo ejecutas, qué salida esperas, qué cambiarías en tu contexto y qué entregarías como evidencia.

Los dos retos incluyen resolución. El primero es más guiado y acotado: preparar un readiness review de un servicio de IA. El segundo junta varias capas: continuidad, degradación, fallback, EvalOps y postmortem.

Antes de empezar, fijamos una rúbrica. Esto ayuda a que el laboratorio sea útil para alumnado de ingeniería, pero también para un equipo que revisa una PR:

CriterioPesoQué se mira
Artefactos25%Manifiesto, runbook, script, informe, regresión y plan de continuidad existen y son coherentes.
Ejecución20%Los comandos se pueden ejecutar y producen salida verificable.
Criterio operativo25%La decisión distingue SLO, RTO, RPO, rollback, fallback, cola, evidencia y owner.
Trazabilidad15%Quedan versiones, trace_id, release_id, model_id, prompt_version e index_version donde toca.
Explicación15%La persona puede defender por qué publica, bloquea o publica con condiciones.

El entregable no es “un texto bonito”. El entregable es un pequeño paquete de ingeniería:

operacion-release/
  output/operational_readiness.json
  output/readiness_decision.md
  output/continuity_report.json
  output/ci_continuity_gate.json
  output/continuity_decision.md
  output/postmortem.md
  output/regression_case.json

El kit real está en:

kit/

Ahí tienes manifiestos, eventos, scripts, salidas esperadas y checker de entrega.

Reto 1: preparar readiness de un asistente RAG

Contexto

Trabajas con support-rag, un asistente que responde dudas internas usando documentos de soporte. El equipo quiere publicar support-rag@2.0.0, pero antes debes comprobar si puede operarse fuera de la demo.

Objetivo

Crear un manifiesto de readiness, ejecutar el verificador y justificar si publicarías, publicarías con condiciones o bloquearías.

Temas del facsímil

TemaDónde lo vimos
Servicio operableCapítulo 01.
Runtime y contratosCapítulo 02.
Serving y capacidadCapítulo 03.
ObservabilidadCapítulo 04.
Routing y fallbackCapítulo 05.
EvalOpsCapítulo 06.
RollbackCapítulo 07.
HandoffCapítulo 08.
IncidenciasCapítulo 09.
Runbooks y continuidadCapítulo 10.

Enunciado

Tienes este manifiesto incompleto:

{
  "service": "support-rag",
  "release": "support-rag@2.0.0",
  "owner": "ai-platform",
  "slo": {
    "latency_p95_ms": 4200,
    "availability": 0.995,
    "contract_fail_rate_max": 0.006
  },
  "observability": {
    "required_attributes": ["trace_id", "run_id", "task", "model_id"],
    "dashboards": ["runtime"],
    "alerts": ["slo_burn_rate"]
  },
  "rollback": {
    "last_known_good": "support-rag@1.9.3"
  },
  "evalops": {
    "datasets": ["golden"],
    "release_gate": "ops/ai/release_gate.py"
  },
  "incident": {
    "runbook": "ops/ai/runbook_ai_service.md",
    "oncall": "ai-platform-oncall"
  },
  "continuity": {
    "rto_minutes": 30
  },
  "handoff": {
    "queues": ["support_n2"]
  }
}

Debes:

  1. Ejecutar el verificador contra el manifiesto.
  2. Leer los faltantes.
  3. Completar el manifiesto.
  4. Volver a ejecutar.
  5. Escribir una decisión técnica.

Resolución paso a paso

Primero, ejecutamos:

# Descomprime el ZIP del capítulo y ejecuta estos comandos dentro de esa carpeta
python3 ops/operational_readiness.py --write

El resultado parcial tendrá una puerta como not_ready o ready_with_conditions, porque faltan piezas con peso alto:

HuecoPor qué importa
slo.cost_p95_eurSin presupuesto por run, el sistema puede cumplir calidad y arruinar coste.
Atributos de traza incompletosSin prompt_version y release_id no hay reconstrucción fiable.
Solo una alertaNo cubre contrato, cola ni degradación por release.
Rollback sin comando probadoSaber la versión buena no basta.
Dataset solo goldenFaltan regresiones e incidencias reales.
Sin matriz de severidadLa respuesta se decide tarde.
Sin RPO ni fallbackContinuidad incompleta.
Sin tarjeta de aprobaciónHandoff poco operativo.

Después completamos el manifiesto con los campos del kit operativo. La salida debería acercarse a:

python3 ops/operational_readiness.py \
  --manifest contracts/readiness_manifest_complete.json \
  --output output/complete/operational_readiness.json \
  --decision-output output/complete/readiness_decision.md \
  --write
{
  "service": "support-rag",
  "release": "support-rag@2.0.0",
  "score": 1.0,
  "gate": "ready",
  "passed_weight": 45,
  "total_weight": 45,
  "next_actions": []
}

Respuesta o solución

Mi decisión sería:

Publicaría support-rag@2.0.0 solo si el manifiesto completo pasa ready, el rollback se ha probado el mismo día o en la ventana acordada, y el gate de release ya comparó baseline contra candidate. Si el score queda entre 0,75 y 0,90, permitiría canary pequeño con condiciones escritas, no producción completa.

Por qué funciona

El reto obliga a conectar piezas que suelen vivir separadas: SLO, trazas, rollback, EvalOps, incidencia, continuidad y handoff. El score no sustituye criterio, pero hace explícito qué falta. Eso permite discutir ingeniería, no sensaciones.

Cómo explicarlo a otra persona

“Antes de publicar, no pregunto si el asistente responde bien en tres ejemplos. Pregunto si puedo observarlo, volver atrás, cambiar ruta, gestionar una cola, medir coste, recuperar estado y aprender de una incidencia. Si algo de eso falta, lo escribimos antes de abrir tráfico.”

Variaciones

  • Cambia rto_minutes de 30 a 90 y explica si sigue siendo aceptable.
  • Elimina regression de los datasets y decide si dejarías pasar una release.
  • Añade un proveedor local como fallback y escribe qué latencia aceptarías.

Reto 2: ejecutar una continuidad con tres degradaciones

Contexto

Tu equipo mantiene un asistente de soporte con RAG. Durante una mañana aparecen tres problemas a la vez: el proveedor principal tarda más, un índice candidato recupera documentos pobres y la cola de revisión empieza a crecer.

Objetivo

Diseñar la respuesta operativa: qué runbook activas, qué mitigación ejecutas primero, qué evidencia preservas, qué caso entra en EvalOps y qué entregas al cerrar.

Datos base

{"ts":"2026-05-28T09:00:00Z","type":"change","message":"canary support-rag@2.0.0 al 20%","release_id":"support-rag@2.0.0","index_version":"rag_index_2026_06"}
{"ts":"2026-05-28T09:08:00Z","type":"metric","metric":"latency_p95_ms","value":6900,"slo":4200,"route_id":"provider_a"}
{"ts":"2026-05-28T09:12:00Z","type":"metric","metric":"citation_acceptance_rate","value":0.71,"slo":0.90,"index_version":"rag_index_2026_06"}
{"ts":"2026-05-28T09:15:00Z","type":"metric","metric":"review_queue_age_p95_minutes","value":52,"slo":30,"queue":"support_n2"}
{"ts":"2026-05-28T09:18:00Z","type":"trace","trace_id":"tr_001","model_id":"model_b","prompt_version":"prompt_v14","route_id":"provider_a","index_version":"rag_index_2026_06"}

Resolución paso a paso

En el kit, el drill se ejecuta así:

# Descomprime el ZIP del capítulo y ejecuta estos comandos dentro de esa carpeta
python3 ops/run_continuity_drill.py --write
python3 -m json.tool output/ci_continuity_gate.json
cat output/continuity_decision.md

La primera ejecución queda en degraded_controlled: no está recuperado, pero sí deja trazas completas y propone mitigaciones. Después puedes ejecutar la variante recuperada:

python3 ops/run_continuity_drill.py \
  --events data/continuity_events_recovered.jsonl \
  --output-dir output/recovered \
  --write
python3 -m json.tool output/recovered/ci_continuity_gate.json

Paso 1: declarar situación.

CampoValor
Serviciosupport-rag.
Releasesupport-rag@2.0.0.
SíntomasLatencia p95 fuera de SLO, baja aceptación de citas, cola de revisión envejecida.
Severidad inicialSEV2 si afecta tarea clave y no hay recuperación inmediata.

Paso 2: elegir orden de mitigación.

OrdenAcciónMotivo
1Bajar canary de 20% a 5%.Reduce exposición sin tocar todo el servicio.
2Fallback de provider_a a provider_b para tarea crítica.Reduce latencia de ruta concreta.
3Volver rag_index_2026_06 a rag_index_2026_05.Las citas son señal directa de RAG pobre.
4Activar review_queue_only para casos no críticos.Protege cola humana.

Paso 3: preservar evidencia.

EvidenciaPor qué
trace_id=tr_001Reconstruye modelo, prompt, ruta e índice.
Métricas por release y rutaSepara candidate de baseline.
Muestra de respuestas con citasPermite crear regresión RAG.
Decisiones y horasEvita depender de memoria oral.
Scorecard del canaryAlimenta EvalOps.

Paso 4: verificar recuperación.

SLICriterio
latency_p95_ms< 4200 durante 30 minutos.
citation_acceptance_rate≥ 0,90 en muestra revisada.
review_queue_age_p95_minutes< 30 o tendencia clara a recuperar.
contract_fail_rateBajo umbral definido.
TrazasAtributos completos en runs muestreadas.

Paso 5: convertir aprendizaje en trabajo.

{
  "case_id": "reg_support_rag_citation_2026_05_28",
  "source": "continuity_drill",
  "task": "support_reply",
  "input": "Pregunta sobre política interna que requiere citar documento vigente",
  "expected": {
    "must_cite_current_document": true,
    "min_citation_acceptance_rate": 0.9,
    "max_latency_ms": 4200
  },
  "metadata": {
    "bad_index_version": "rag_index_2026_06",
    "last_good_index_version": "rag_index_2026_05",
    "release_id": "support-rag@2.0.0"
  }
}

Respuesta o solución

El cierre técnico que entregaría:

EntregableContenido
operational_readiness.jsonScore y faltantes si los hay.
incident_state.mdEstado, síntomas, decisiones, owners y siguiente update.
rollback_plan.mdBajar canary, cambiar ruta y volver índice.
evals/regression_cases.jsonlCaso de cita vigente y latencia máxima.
postmortem.mdImpacto, línea temporal, causas contribuyentes y acciones.
runbook_ai_service.mdNuevo apartado para degradación simultánea ruta/RAG/cola.

Acciones correctivas:

AcciónOwnerVerificación
Añadir alerta combinada latency_p95_ms + citation_acceptance_rate.ai-platform.Alerta con runbook enlazado.
Probar índice RAG candidato en shadow antes de canary.rag-team.Scorecard con muestra revisada.
Crear prioridad de cola para casos críticos cuando queue_age_p95 supera SLO.support-ops.Ensayo operativo repetido.

Validar la entrega

La solución de referencia se valida con:

# Descomprime el ZIP del capítulo y ejecuta estos comandos dentro de esa carpeta
python3 ops/check_student_submission.py --submission-dir solutions/reference --write

Para una entrega propia:

python3 ops/check_student_submission.py --submission-dir solutions/mi-equipo --write --fail-on-missing

La referencia obtiene 70/70. El checker no premia que todo sea optimista: premia que haya manifiesto completo, decisión de readiness, continuidad con trazas, gate, postmortem y caso de regresión.

Por qué funciona

No intenta resolver todo con una sola palanca. Separa capas: release/canary, proveedor/ruta, RAG/índice y cola/handoff. Esa separación mantiene reversibilidad. Además, convierte lo ocurrido en regresión, no solo en una conversación de cierre.

Cómo explicarlo a otra persona

“Cuando varias cosas van mal, no cambio todo a la vez. Bajo exposición, cambio la ruta lenta, vuelvo el índice conocido, protejo la cola y guardo evidencia. Después convierto el caso en evaluación para que la próxima release lo tenga que pasar.”

Variaciones

  • Cambia el síntoma de latencia por coste p95 y decide si el orden de mitigación cambia.
  • Supón que no existe provider_b; diseña un modo local reducido.
  • Añade un caso donde el contrato JSON falla al mismo tiempo que la cola crece.

Cierre del laboratorio

Si has completado los dos retos, ya no tienes solo una lectura del facsímil. Tienes un pequeño sistema operativo para IA: manifiesto, verificador, runbook, continuidad, decisiones, regresiones y cierre.

La pregunta final no es “¿funciona mi IA?”. La pregunta profesional es:

¿Puedo demostrar cómo la opero cuando deja de comportarse como esperaba?

Para saber más

Amazon Web Services. (2026). Timeouts, Retries, and Backoff with Jitter. AWS Builders Library. https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/

Amershi, S. et al. (2019). Software Engineering for Machine Learning: A Case Study. International Conference on Software Engineering: Software Engineering in Practice, 291-300. https://doi.org/10.1109/ICSE-SEIP.2019.00042

Baye, C. A. (2016). Emergency Response. En Site Reliability Engineering. https://sre.google/sre-book/emergency-response/

Dean, J. y Barroso, L. A. (2013). The Tail at Scale. Communications of the ACM, 56(2), 74-80. https://doi.org/10.1145/2408776.2408794

Ewaschuk, R. (2016). Monitoring Distributed Systems. En Site Reliability Engineering. https://sre.google/sre-book/monitoring-distributed-systems/

GitHub. (2026). Workflow Syntax for GitHub Actions. https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax

Little, J. D. C. (1961). A proof for the queuing formula: L = λW. Operations Research, 9(3), 383-387. https://doi.org/10.1287/opre.9.3.383

Lunney, J. y Lueder, S. (2016). Postmortem Culture: Learning from Failure. En Site Reliability Engineering. https://sre.google/sre-book/postmortem-culture/

OpenTelemetry. (2026). Logs. https://opentelemetry.io/docs/concepts/signals/logs/

OpenTelemetry. (2026). Metrics. https://opentelemetry.io/docs/concepts/signals/metrics/

OpenTelemetry. (2026). Traces. https://opentelemetry.io/docs/concepts/signals/traces/

Stribblehill, A. (2016). Managing Incidents. En Site Reliability Engineering. https://sre.google/sre-book/managing-incidents/

Tabassi, E. (2023). Artificial Intelligence Risk Management Framework (AI RMF 1.0). National Institute of Standards and Technology, NIST AI 100-1. https://doi.org/10.6028/NIST.AI.100-1

Wilkinson, J. (2018). Alerting on SLOs. En The Site Reliability Workbook. https://sre.google/workbook/alerting-on-slos/

Notas

  1. Ewaschuk, R. (2016). Monitoring Distributed Systems. En B. Beyer, C. Jones, J. Petoff y N. R. Murphy (eds.), Site Reliability Engineering. https://sre.google/sre-book/monitoring-distributed-systems/. Consultado el 27 de mayo de 2026.

  2. Wilkinson, J. (2018). Alerting on SLOs. En B. Beyer, N. R. Murphy, D. Rensin, K. Kawahara y S. Thorne (eds.), The Site Reliability Workbook. https://sre.google/workbook/alerting-on-slos/. Consultado el 27 de mayo de 2026.

  3. Baye, C. A. (2016). Emergency Response. En Site Reliability Engineering. https://sre.google/sre-book/emergency-response/. Consultado el 28 de mayo de 2026.

  4. Stribblehill, A. (2016). Managing Incidents. En Site Reliability Engineering. https://sre.google/sre-book/managing-incidents/. Consultado el 28 de mayo de 2026.

  5. Lunney, J. y Lueder, S. (2016). Postmortem Culture: Learning from Failure. En Site Reliability Engineering. https://sre.google/sre-book/postmortem-culture/. Consultado el 28 de mayo de 2026.

  6. Little, J. D. C. (1961). A proof for the queuing formula: L = λW. Operations Research, 9(3), 383-387. https://doi.org/10.1287/opre.9.3.383

  7. OpenTelemetry. (2026). Traces. https://opentelemetry.io/docs/concepts/signals/traces/. Consultado el 27 de mayo de 2026.

  8. OpenTelemetry. (2026). Logs. https://opentelemetry.io/docs/concepts/signals/logs/. Consultado el 27 de mayo de 2026.

  9. OpenTelemetry. (2026). Metrics. https://opentelemetry.io/docs/concepts/signals/metrics/. Consultado el 27 de mayo de 2026.

  10. Tabassi, E. (2023). Artificial Intelligence Risk Management Framework (AI RMF 1.0). National Institute of Standards and Technology, NIST AI 100-1. https://doi.org/10.6028/NIST.AI.100-1.

  11. Amazon Web Services. (2026). Timeouts, Retries, and Backoff with Jitter. AWS Builders Library. https://aws.amazon.com/builders-library/timeouts-retries-and-backoff-with-jitter/. Consultado el 27 de mayo de 2026.

  12. Amershi, S. et al. (2019). Software Engineering for Machine Learning: A Case Study. International Conference on Software Engineering: Software Engineering in Practice, 291-300. https://doi.org/10.1109/ICSE-SEIP.2019.00042.

  13. GitHub. (2026). Workflow Syntax for GitHub Actions. https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax. Consultado el 28 de mayo de 2026.