Tecnología

Por qué el ai-service de Omnilude eligió una estructura de Assistant / Thread / Run

AAnonymous
13 min de lectura

Introducción

En este post quiero compartir cómo está implementado el ai-service de Omnilude. Hay una frase famosa que casi todos hemos escuchado alguna vez: "no reinventes la rueda". Yo también estoy de acuerdo con esa idea. Cuando diseñé la forma en que ai-service ejecuta IA, ya había usado la Assistants API de OpenAI (Beta), y esa experiencia no fue mala. Como tampoco tenía una idea claramente mejor en ese momento, la estructura inicial se adoptó de una manera bastante cercana a la imitación.

La estructura de la Assistants API de OpenAI era bastante simple. Tenía un esqueleto de assistant / thread / message / run / run step, y se podía entender Assistant como un preset, Thread como un contenedor de conversación y Run como la ejecución real. Ese eje conceptual aparece con claridad en el deep dive y en la FAQ oficiales de OpenAI.

Sin embargo, Omnilude no copió esa estructura tal cual. No estábamos construyendo un servicio que dependiera solo de OpenAI. Teníamos que manejar, dentro de una misma plataforma, varias rutas de ejecución: modelos locales, servidores compatibles con OpenAI, Anthropic, Ollama y LM Studio.

Por eso, el ai-service de Omnilude toma conceptos de OpenAI Assistants, pero su implementación real avanzó hacia una dirección más separada. En este artículo voy a explicar esa estructura de la forma más sencilla posible.

Primero, ¿qué guarda?

Dentro del ai-service de Omnilude hay varios objetos relacionados con IA. Por el nombre pueden parecer complejos, pero si se separan por función, resultan más simples de lo que parecen.

  • AiProvider: guarda qué proveedor se usa. Es el eje de OpenAI, Anthropic, Ollama, LM Studio, etc.
  • AiApiKey: guarda la clave de autenticación conectada a ese proveedor.
  • AiModel: guarda la información real del modelo, como nombre, soporte de reasoning, precio y contexto.
  • AiAssistant: define un preset de ejecución sobre para qué se usa el modelo y con qué instrucciones.
  • AiThread, AiMessage, AiRun, AiRunStep: guardan la conversación y el historial de ejecución.
  • AiAgent: es la capa superior que combina varios assistants en un flujo de trabajo.

Si lo reduzco a una sola frase, queda así.

Provider y Model son los ingredientes, Assistant es la receta, y Thread y Run son la orden real y el registro de preparación.

La relación se entiende mejor cuando se ve en un diagrama.

Ahí está también la parte que se parece a la vieja Assistants API de OpenAI. El esqueleto de Assistant, Thread, Message, Run y Run Step es parecido. La diferencia es que Omnilude saca provider / model / apiKey fuera del Assistant y los administra como activos separados.

¿Por qué dividirlo así?

La razón es simple. En Omnilude no quería tratar un Assistant como un simple bloque de prompt.

Si creamos un assistant, lo que realmente queremos guardar no es solo instructions.

  • qué proveedor usa
  • qué modelo usa
  • si soporta reasoning
  • si el response format es texto o JSON
  • cómo se fijan temperature y top-p
  • aun si el modelo cambia después, cuál se considera el punto de referencia original

Eso se ve directamente en la entidad real AiAssistant. modelId y primaryModelId están separados, y apiKeyId y primaryApiKeyId también. Pero esto no existe solo para recordar el modelo base original. La razón más importante es el fallback. Si cierto provider entra en pánico, si una key queda bloqueada o si el modelo actualmente conectado se vuelve inestable, el servicio tiene que seguir funcionando pasando a otro modelo y otra key. En otras palabras, separar el modelo y la key actuales del modelo y la key principales permite operar con más solidez incluso durante una falla.

Gracias a esta estructura, un assistant deja de ser un simple template de prompt. Se convierte en un preset de ejecución operable.

Ese es uno de los puntos en los que Omnilude se aparta de OpenAI Assistants. El Assistant de OpenAI daba la impresión de empaquetar model + instructions + tools en una sola unidad. Omnilude, en cambio, primero vuelve independientes al provider, al model y al api key, y luego diseña el assistant como una capa de composición construida sobre esos activos.

Assistant no termina como una entidad de base de datos

Aquí hay otro paso importante. Un AiAssistant guardado no se ejecuta directamente.

Justo antes de la ejecución, AiAssistantService combina de una vez la siguiente información.

  • los valores preset del propio assistant
  • la información del model conectado
  • la información del provider conectado
  • la api key cifrada guardada en el sistema

Después, ese resultado baja a un objeto de ejecución llamado RunnableAiAssistant.

Ese nombre importa bastante. En Omnilude se separa claramente el assistant guardado en la base de datos del assistant que realmente se ejecuta.

  • AiAssistant del lado DB: entidad que guarda configuración
  • RunnableAiAssistant del lado de ejecución: objeto listo para llamar al modelo

Separarlos así trae ventajas evidentes.

Primero, la capa de ejecución deja de preocuparse por entidades JPA. Segundo, las diferencias entre providers pueden esconderse dentro de implementaciones de RunnableAiModel. Tercero, las opciones de reasoning, el response format y los parámetros de sampling pueden interpretarse en un solo lugar al momento de ejecutar.

En resumen, Omnilude separa guardar un assistant como dato de ejecutar realmente ese assistant.

¿Cómo fluye una llamada real?

La ruta más simple para ejecutar un assistant de forma directa se ve, a grandes rasgos, así.

La pieza clave de este flujo es LlmGateway. En Omnilude, la mayoría de las ejecuciones directas ya pasan por ese gateway.

Y ese gateway hace bastante más de lo que parece.

  • aplica límites de tasa por provider
  • registra el inicio y el fin de la ejecución
  • crea y administra el estado de AiRun y AiRunStep
  • registra payloads de request y response
  • recoge tokens y costo

Es decir, no se limita a llamar assistant.chat(). Lo envuelve como una llamada de plataforma con trazabilidad de ejecución.

Esa diferencia pesa mucho. En cuanto uno empieza a usar funciones de IA un poco más en serio, deja de importar tanto qué prompt se usó y pasa a importar qué assistant produjo qué resultado, con qué modelo y a qué costo.

¿Por qué dejar Thread, Message y Run?

Aquí mucha gente puede hacerse una pregunta natural.

Si de todas maneras un assistant puede ejecutarse directamente, ¿por qué mantener Thread, Message y Run por separado?

Yo creo que esta parte es muy importante dentro de la estructura de Omnilude.

Assistant es solo un preset. Pero la experiencia real del usuario no termina en un preset. El usuario continúa la conversación, acumula mensajes, pide ejecución en un punto concreto y recibe el resultado de vuelta como otro mensaje.

En Omnilude ese flujo se separa así.

  • AiThread: la sala de conversación
  • AiMessage: cada turno entre usuario y assistant
  • AiRun: la solicitud de ejecución basada en un mensaje concreto
  • AiRunStep: el rastro interno y detallado de esa ejecución

Creo que este es un ejemplo bastante realista de cómo llevar al runtime de un producto la estructura que mostró OpenAI Assistants.

Por ejemplo, un cliente puede crear primero un thread, acumular ahí un user message y después llamar a /ai-runs. Desde ese momento ya no es solo una llamada de chat completion. Pasa a ser una unidad de ejecución con contexto conversacional.

¿Por qué importa esto?

Primero, permite conectar el historial de ejecución con la conversación. Segundo, permite gestionar desde qué punto reiniciar dentro del mismo thread. Tercero, permite añadir funciones extra como generación de título, favoritos o reset a nivel de thread.

Cuanto más conviertes la IA en producto, más tiempo sobreviven este tipo de interfaces frente a una llamada directa y aislada.

Pero pulsar Run no significa que siempre corra un solo Assistant

Aquí Omnilude vuelve a separarse de OpenAI Assistants.

La impresión de OpenAI Assistants era relativamente clara: hay un Assistant, hay un Thread y Run ejecuta ese assistant sobre el thread.

Pero el /ai-runs de Omnilude hoy se parece más a una entrada de workflow. En la implementación real, AiRunController no llama al modelo de inmediato. Mete una tarea en la cola DTE. Luego ChatAgentTaskHandler toma esa tarea, lee los mensajes del thread y ejecuta el workflow de AiAgent.

Es decir, en el Omnilude actual, Run ya no se queda en la idea de ejecutar un solo assistant. Dependiendo del caso, puede ser el punto de partida para ejecutar un workflow que contiene varios assistants.

Visto así, la estructura se vuelve más clara.

  • Assistant: un preset de LLM
  • Agent: un workflow compuesto por varios assistants
  • Run: la unidad de ejecución que pone en marcha ese workflow

Por esa diferencia, el ai-service de Omnilude se siente un paso más cerca de una plataforma que de un simple repositorio de assistants.

No todas las rutas son exactamente iguales todavía

También quiero dejar una nota más realista. Aunque la estructura esté bien ordenada, hoy no todas las entradas siguen exactamente la misma ruta de ejecución.

Por ejemplo, el backoffice playground y la inferencia directa pasan por LlmGateway, así que el tracking y el registro de costo quedan bastante bien conectados. En cambio, la API interna de chat /system/ai/chat usa un camino más directo. Ese camino carga el assistant, aplica el rate limiter y llama al chat de forma directa, por lo que tiene una textura algo distinta frente a la ruta de tracking basada en gateway.

A mí, de hecho, eso me parece normal. Las plataformas reales rara vez nacen completamente ordenadas alrededor de un único patrón perfecto. Lo más común es que converjan poco a poco hacia una estructura compartida a medida que crecen los usos.

Lo importante es que el eje central ya está definido.

  • los objetos de gestión están separados
  • los objetos de ejecución se están unificando como RunnableAiAssistant
  • la conversación y el historial de ejecución quedan como Thread / Message / Run / RunStep
  • la orquestación superior queda en manos de AiAgent

Esta estructura me gusta bastante

Cuando vuelvo a mirar esta estructura, el ai-service de Omnilude no se siente simplemente como un servicio que llama bien a modelos.

Está intentando hacer cuatro cosas al mismo tiempo.

  • manejar varios providers y models dentro de una sola plataforma
  • convertir assistants en presets de ejecución reutilizables
  • conservar conversación y ejecución en una estructura de thread/message/run
  • después ampliar eso hacia workflows de agentes componiendo assistants

Creo que esta dirección es muy realista. Cuando empiezas a meter IA dentro de un producto, lo que acabas necesitando no son unas cuantas funciones para invocar modelos, sino una interfaz ejecutable.

Y esa interfaz se parece bastante a la estructura conceptual que OpenAI Assistants mostró en su momento. La diferencia es que Omnilude fue un paso más allá: separó mejor provider y model, y metió tracking y workflow con más fuerza dentro del diseño.

Cierre

En el ai-service de Omnilude, AiAssistant no es solo un almacén de prompts. Está mucho más cerca de un preset de ejecución que agrupa qué provider y qué model se deben llamar, cómo llamarlos, en qué formato recibir la respuesta y sobre qué runtime dejar registrado ese resultado.

Y AiThread, AiMessage, AiRun y AiRunStep son la interfaz de runtime que permite que ese preset funcione dentro del flujo real del producto. Cuando encima se suma AiAgent, el ai-service de Omnilude deja de ser algo que solo administra unos cuantos assistants. Empieza a convertirse en una plataforma capaz de operar workflows.

La próxima vez voy a seguir desde aquí, con la historia de cómo se combinan assistants para construir agentes.