DEV Community

Cover image for Cosas que estás sobreingeniando en tu agente de IA (y el LLM ya hace solo)
Juan Torchia
Juan Torchia

Posted on • Originally published at juanchi.dev

Cosas que estás sobreingeniando en tu agente de IA (y el LLM ya hace solo)

Hay una creencia instalada profundo en la comunidad dev que dice que los LLMs son cajas negras que necesitamos "domesticar" con infraestructura. Que si no envolvés el modelo en cinco capas de lógica propia, vas a perder el control. Que el retry manual, el manejo de contexto a mano, el parser artesanal de respuestas — todo eso es necesario porque "no podés confiar en el modelo".

Con todo respeto: está bastante equivocado.

No digo que los LLMs sean perfectos. Digo algo más específico y más incómodo: estamos reimplementando, en código frágil y difícil de mantener, funcionalidad que el modelo ya tiene incorporada. Y lo estamos haciendo porque nos da sensación de control. Esa sensación es una mentira cómoda.

Sé exactamente de lo que hablo porque lo hice. Abrí mi repo esta semana y encontré el cadáver.

Overengineering agentes IA LLM: el inventario del daño

El contexto: tengo un agente en producción que procesa consultas, mantiene conversación multi-turno y llama a herramientas externas. Sistema real, con usuarios reales, que procesa carga real. Lo construí hace ocho meses cuando recién estaba entendiendo cómo funcionan los agentes.

Esta semana leí el post de Dev.to sobre cosas que sobreingeniás en tus agentes. Fui directo al repo. Lo que encontré fue básicamente una colección de mis propias inseguridades convertidas en código.

Pecado #1: El sistema de retry artesanal

Este es el que más duele porque tiene tests. Tests de los que uno está orgulloso. Miren esto:

// Lo que escribí hace 8 meses — 87 líneas para hacer esto
class LLMRetryManager {
  private maxAttempts: number;
  private backoffMs: number;
  private contextWindow: ConversationContext[];

  constructor(config: RetryConfig) {
    this.maxAttempts = config.maxAttempts ?? 3;
    this.backoffMs = config.backoffMs ?? 1000;
    this.contextWindow = [];
  }

  // Manejaba truncamiento de contexto a mano
  private trimContext(messages: Message[]): Message[] {
    const MAX_TOKENS = 4000; // hardcodeado, obviamente
    let totalTokens = 0;
    const trimmed: Message[] = [];

    // Contaba tokens de manera completamente incorrecta
    for (const msg of messages.reverse()) {
      const estimatedTokens = msg.content.length / 4; // 💀
      if (totalTokens + estimatedTokens < MAX_TOKENS) {
        trimmed.unshift(msg);
        totalTokens += estimatedTokens;
      } else {
        break; // simplemente cortaba, sin preservar system prompt
      }
    }
    return trimmed;
  }

  async execute(prompt: string, attempt = 0): Promise<string> {
    try {
      const context = this.trimContext(this.contextWindow);
      const response = await callLLM(context, prompt);
      // guardaba respuesta en contexto local
      this.contextWindow.push({ role: 'assistant', content: response });
      return response;
    } catch (error) {
      if (attempt >= this.maxAttempts) throw error;
      // backoff exponencial que no era realmente exponencial
      await sleep(this.backoffMs * attempt);
      return this.execute(prompt, attempt + 1);
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Ochenta y siete líneas. Con tests. Para reimplementar, mal, lo que el SDK de OpenAI ya hace. Para reimplementar, peor, el manejo de contexto que el modelo gestiona cuando le pasás el array de mensajes correctamente.

Lo que debería ser:

// Lo que reemplazó las 87 líneas — con el SDK moderno
import OpenAI from 'openai';

const client = new OpenAI();

// El SDK maneja retry con backoff exponencial real por defecto
// maxRetries configurable, no necesitás reimplementarlo
async function callAgent(messages: OpenAI.ChatCompletionMessageParam[]) {
  // El modelo maneja el contexto — vos solo mantenés el array de mensajes
  // No necesitás contar tokens a mano para el flujo básico
  const response = await client.chat.completions.create({
    model: 'gpt-4o',
    messages, // el historial completo, el modelo sabe qué hacer con eso
    // Si necesitás controlar tokens, usás max_tokens en el output
    // No en el input que estás truncando artesanalmente
  });

  return response.choices[0].message.content;
}

// Para retry específico de tu lógica de negocio, sí — ahí tiene sentido
// Pero para errores de red y rate limiting: el SDK ya lo hace
Enter fullscreen mode Exit fullscreen mode

La diferencia no es solo líneas. Es que mi versión artesanal tenía un bug en el trimming que cortaba el system prompt en conversaciones largas. Tardé tres semanas en encontrar ese bug. El SDK no tiene ese bug porque lo escribió gente que entiende la API mejor que yo.

Pecado #2: El parser de respuestas estructuradas

Al LLM le pedía JSON. El LLM a veces mandaba JSON envuelto en markdown. Solución razonable: parsear. Mi solución real: 140 líneas de regex y fallbacks.

// El monstruo que construí
function parseStructuredResponse(raw: string): AgentAction {
  // intentaba quitar markdown
  let cleaned = raw.replace(/```
{% endraw %}
json\n?/g, '').replace(/
{% raw %}
```\n?/g, '');

  // intentaba encontrar el JSON dentro de texto
  const jsonMatch = cleaned.match(/\{[\s\S]*\}/);
  if (jsonMatch) {
    cleaned = jsonMatch[0];
  }

  try {
    return JSON.parse(cleaned);
  } catch {
    // fallback a regex específicos por campo — en serio
    const action = cleaned.match(/"action":\s*"([^"]+)"/);
    const params = cleaned.match(/"params":\s*(\{[^}]+\})/);
    // ... 80 líneas más de esto
  }
}
Enter fullscreen mode Exit fullscreen mode

La solución que el ecosistema ya tenía y yo ignoré: structured outputs.

import { zodResponseFormat } from 'openai/helpers/zod';
import { z } from 'zod';

// Definís el schema una vez
const AgentActionSchema = z.object({
  action: z.enum(['search', 'calculate', 'respond', 'ask_clarification']),
  params: z.record(z.string()),
  reasoning: z.string().optional(),
});

// El modelo garantiza la estructura — no necesitás parsear
const response = await client.beta.chat.completions.parse({
  model: 'gpt-4o-2024-08-06', // structured outputs requiere este modelo o más nuevo
  messages,
  response_format: zodResponseFormat(AgentActionSchema, 'agent_action'),
});

// Esto ya es tipado, ya está validado, ya es tu objeto
const action = response.choices[0].message.parsed;
// action.action es 'search' | 'calculate' | 'respond' | 'ask_clarification'
// TypeScript lo sabe. No hay parse. No hay regex.
Enter fullscreen mode Exit fullscreen mode

Ciento cuarenta líneas de regex frágil versus diez líneas de schema. Y el schema además documenta el contrato de la API.

Pecado #3: La orquestación manual de herramientas

Este es más sutil. Cuando implementé el sistema de tool calling, construí un loop de orquestación que decidía cuándo llamar herramientas, cómo interpretar los resultados, cuándo volver al modelo. Lógica de negocio real mezclada con plomería que el SDK ya maneja.

Hablé de esto tangencialmente en el post sobre agentes multi-agente como problema de sistemas distribuidos — la complejidad de coordinación tiende a acumularse en capas que no necesitaban existir.

El SDK moderno tiene client.beta.chat.completions.runTools() que maneja el loop completo. Vos registrás las herramientas, el modelo decide cuándo usarlas, el SDK ejecuta el loop, vos recibís la respuesta final. No reimplementás el protocolo.

Los errores comunes que te meten en este camino

Error 1: Desconfianza legítima generalizada. Hay cosas en las que no podés confiar en el modelo — razonamiento matemático complejo, fechas y horarios, información post-cutoff. Pero esa desconfianza legítima se generaliza a todo: "no puedo confiar en el modelo para manejar contexto", "no puedo confiar en el modelo para estructurar output". Ahí empieza el sobreingeniamiento.

Error 2: Construir para el modelo de hace dos años. GPT-3.5 de 2022 necesitaba mucho más scaffolding. Los modelos actuales son fundamentalmente más capaces de seguir instrucciones, mantener estructura, y manejar contexto. El código que escribiste para domesticar GPT-3.5 puede estar activamente empeorando tu experiencia con GPT-4o.

Error 3: No leer el changelog del SDK. Los SDKs de OpenAI, Anthropic, Google — todos actualizaron muchísimo en el último año. Funcionalidad que tenías que implementar a mano en 2023 existe como método en el SDK en 2025. Yo no lo leí. Pagué el precio en líneas de código.

Error 4: Orquestación prematura. Similar a lo que vi construyendo el experimento de sonificación de colectivos — la tentación de construir el sistema de coordinación antes de tener los casos de uso claros. Con agentes: construís el framework de retry, el manejo de estado, la orquestación — antes de saber qué problema específico estás resolviendo.

Error 5: Tests que validan la complejidad incorrecta. Mis tests del LLMRetryManager eran buenos tests de código malo. Validaban que mi sistema de retry funcionaba como yo lo diseñé — no validaban que el comportamiento del agente era correcto. Cuando borré el sistema de retry y usé el del SDK, los tests quedaron obsoletos. Eso me debería haber dicho algo antes.

Este patrón de sobreingeniería no es exclusivo de agentes IA. Lo vi en el ecosistema de runtimes de Rust para TypeScript — a veces la capa adicional de control introduce más problemas de los que resuelve.

FAQ: Overengineering en agentes IA

¿Cuándo SÍ tiene sentido un sistema de retry propio?
Cuando tu lógica de retry es específica del dominio de negocio, no de la red. El SDK maneja rate limits y errores transitorios de red. Vos manejás: "si el modelo dice que no tiene información suficiente, busco en la base de datos y reintento". Esa lógica es tuya. La otra es del SDK.

¿Structured outputs funciona con todos los modelos?
No. Requiere gpt-4o-2024-08-06 o posterior, y gpt-4o-mini-2024-07-18 o posterior de OpenAI. Para Anthropic, el approach es diferente — tool use con schema. Para modelos locales con Ollama, depende del modelo y la versión. Verificá compatibilidad antes de adoptar.

¿No es mejor tener control propio sobre el contexto para optimizar costos?
Sí, pero hay una diferencia entre optimización de contexto inteligente y trimming artesanal mal hecho. Para optimización real de costos en producción: usás embeddings para recuperación selectiva de contexto (RAG), no cortás el array a mano. El trimming manual que yo hacía no optimizaba costos — solo rompía conversaciones largas.

¿Qué pasa con la seguridad? ¿No necesito validar las respuestas del modelo antes de ejecutar acciones?
Absolutamente. Esta es la capa donde SÍ querés código propio. Validación de que la acción está en el conjunto permitido, que los parámetros cumplen invariantes de negocio, que el usuario tiene permisos para la acción solicitada. Eso es tuyo. Lo que no es tuyo: parsear el JSON que el modelo genera cuando podés usar structured outputs.

¿Vale la pena refactorizar código que funciona?
Depende de "funciona". Si funciona y no va a cambiar: tal vez no. Pero mi código "funcionaba" con un bug silencioso en conversaciones largas. La deuda técnica de reimplementar lo que el SDK hace es que cuando el SDK mejora — y mejoró mucho — vos no lo recibís automáticamente. Te quedás con tu implementación de hace dos años.

¿Hay casos donde el sobreingeniamiento de agentes es la decisión correcta?
Sí: cuando tenés restricciones muy específicas (no podés usar el SDK oficial, tenés requerimientos de compliance, necesitás soporte para modelos muy custom). O cuando la capa de abstracción que te dan no alcanza para tu caso de uso — hay escenarios de seguridad donde necesitás control fino sobre el protocolo. Pero esos son la excepción. La mayoría de los proyectos no están en ese caso.

Conclusión: el costo real del control ilusorio

Borré 340 líneas esta semana. Ochenta y siete de retry, ciento cuarenta de parsing, el resto de orquestación redundante. El sistema hace exactamente lo mismo. Los tests que importan siguen pasando. El bug de conversaciones largas — que descubrí revisando para este post — desapareció.

El costo no fue solo tiempo de escribir esas líneas. Fue tiempo de debuggear bugs que el SDK no tiene. Fue complejidad cognitiva cada vez que alguien nuevo toca el código. Fue la falsa sensación de que entendía qué estaba pasando porque yo lo había escrito.

Hay una variante de esto que vi en otros dominios — la tentación de construir desde cero porque confiar en algo externo da vértigo. Lo pensé cuando vi el display neumático con aire comprimido: a veces construir la capa más primitiva tiene sentido artístico o técnico. En producción con un deadline: casi nunca.

La pregunta que me hago ahora antes de escribir cualquier capa de infraestructura alrededor de un modelo: ¿esto ya existe en el SDK? ¿Ya existe en el modelo? Si la respuesta es sí y mi implementación no agrega algo específico de mi dominio, es sobreingeniería.

No es falta de control. Es elegir dónde gastás el control que tenés.


Este artículo fue publicado originalmente en juanchi.dev

Top comments (0)