Context summarization en agentes IA: el patrón que escala
Tu agente de soporte lleva cuarenta mensajes de ida y vuelta con el cliente. El contexto supera los 40.000 tokens. En el mensaje cuarenta y uno, el modelo ya no recuerda el acuerdo que cerraron en el mensaje doce.
No es un fallo del LLM. Es consecuencia de una arquitectura que no escala.
La mayoría de equipos que construyen agentes conversacionales pasan el historial completo como un array de messages. En desarrollo funciona. En producción, cuando las conversaciones se alargan, empieza el problema.
Por qué pasar el historial completo es un error de arquitectura
El array de mensajes crece con cada turno. Cada llamada al modelo incluye todo lo anterior. Hay tres síntomas que aparecen antes o después:
El agente empieza a "olvidar" el principio. Los modelos dan más peso a lo reciente. Un contexto de 40.000 tokens donde los hechos clave están en el primer 10% es un contexto mal distribuido. El modelo los atiende menos.
El coste por llamada sube exponencialmente. Una conversación de veinte turnos puede costar diez veces más que la misma conversación si el historial crece sin control. Con volumen en producción, eso se convierte en dinero real.
Los errores de context length te asustan en el peor momento. El usuario está en medio de una gestión importante. La API devuelve context_length_exceeded. La conversación muere.
El patrón que resuelve esto tiene nombre: progressive summarization. No es nada nuevo en sistemas de información, pero su implementación para agentes LLM tiene matices que vale la pena conocer.
Tres estrategias ordenadas de peor a mejor
Truncación: el error más habitual
La solución de urgencia que tiene todo el mundo en el código:
function buildMessages(history: Message[], maxMessages = 20): Message[] {
return history.slice(-maxMessages);
}
Descarta los mensajes más antiguos. Simple, rápido, y destruye información crítica. Si el cliente dio su número de cuenta en el mensaje tres, en el mensaje veintitrés ya no existe para el agente. El modelo improvisa o pregunta de nuevo. El cliente se frustra.
Rolling window: mejor, pero insuficiente
Una variante algo más inteligente: mantener los últimos N mensajes por token, no por cantidad:
function trimToTokenBudget(
messages: Message[],
maxTokens: number,
estimatedTokensPerChar = 0.25
): Message[] {
let total = 0;
const result: Message[] = [];
for (const msg of [...messages].reverse()) {
const estimated = msg.content.length * estimatedTokensPerChar;
if (total + estimated > maxTokens) break;
result.unshift(msg);
total += estimated;
}
return result;
}
Mejor que truncar por número fijo, pero sigue perdiendo información. El primer mensaje donde el cliente explicó su problema puede ser exactamente el que el modelo necesita para no pedir que lo repita.
Progressive summarization: el patrón que escala
La idea es sencilla: cuando el historial supera un umbral, generamos un resumen de los mensajes más antiguos y los sustituimos por ese resumen. Los mensajes recientes se mantienen completos. El resultado:
- El modelo tiene acceso a toda la información relevante de la conversación
- El contexto nunca supera el umbral definido
- El coste por llamada es predecible
Implementación en TypeScript
import Anthropic from '@anthropic-ai/sdk';
interface Message {
role: 'user' | 'assistant';
content: string;
}
interface ConversationState {
summary: string | null;
recentMessages: Message[];
totalTurns: number;
}
const anthropic = new Anthropic();
async function summarizeMessages(messages: Message[]): Promise<string> {
const response = await anthropic.messages.create({
model: 'claude-haiku-4-5-20251001',
max_tokens: 512,
system:
'Eres un sistema de compresión de conversaciones. Resume los puntos clave de estos mensajes en 3-5 frases concisas. Preserva: decisiones tomadas, datos concretos mencionados (nombres, números, fechas), y el estado actual del problema. No incluyas saludos ni frases genéricas.',
messages: [
{
role: 'user',
content: `Resume esta conversación:\n\n${messages
.map((m) => `${m.role === 'user' ? 'Cliente' : 'Agente'}: ${m.content}`)
.join('\n')}`,
},
],
});
return response.content[0].type === 'text' ? response.content[0].text : '';
}
class ConversationManager {
private state: ConversationState = {
summary: null,
recentMessages: [],
totalTurns: 0,
};
// Umbral configurable: resumir cuando hay más de N mensajes recientes
private readonly maxRecentMessages: number;
constructor(maxRecentMessages = 12) {
this.maxRecentMessages = maxRecentMessages;
}
async addTurn(userMessage: string, agentResponse: string): Promise<void> {
this.state.recentMessages.push(
{ role: 'user', content: userMessage },
{ role: 'assistant', content: agentResponse }
);
this.state.totalTurns++;
if (this.state.recentMessages.length > this.maxRecentMessages) {
await this.compress();
}
}
private async compress(): Promise<void> {
// Tomamos la primera mitad del historial reciente para resumir
const toSummarize = this.state.recentMessages.slice(
0,
Math.floor(this.maxRecentMessages / 2)
);
const newSummary = await summarizeMessages(toSummarize);
// Si ya había un resumen previo, lo incorporamos al nuevo
const combinedSummary = this.state.summary
? `Contexto anterior: ${this.state.summary}\n\nActualización: ${newSummary}`
: newSummary;
this.state.summary = combinedSummary;
this.state.recentMessages = this.state.recentMessages.slice(
Math.floor(this.maxRecentMessages / 2)
);
}
buildMessagesForLLM(): Message[] {
const messages: Message[] = [];
// El resumen acumulado va como primer mensaje del sistema de contexto
if (this.state.summary) {
messages.push({
role: 'user',
content: `[Resumen de la conversación hasta ahora]\n${this.state.summary}`,
});
messages.push({
role: 'assistant',
content: 'Entendido. Continuamos desde ese punto.',
});
}
return [...messages, ...this.state.recentMessages];
}
getStats() {
return {
turns: this.state.totalTurns,
recentMessages: this.state.recentMessages.length,
hasSummary: !!this.state.summary,
summaryLength: this.state.summary?.length ?? 0,
};
}
}
Integración en una ruta de Next.js
// app/api/chat/route.ts
import { ConversationManager } from '@/lib/conversation-manager';
// En producción, persiste el estado en Redis o en tu BD
const sessions = new Map<string, ConversationManager>();
export async function POST(req: Request) {
const { sessionId, message } = await req.json();
let manager = sessions.get(sessionId);
if (!manager) {
manager = new ConversationManager(12);
sessions.set(sessionId, manager);
}
const messages = manager.buildMessagesForLLM();
const response = await anthropic.messages.create({
model: 'claude-sonnet-4-6',
max_tokens: 1024,
system: 'Eres el asistente de soporte de la empresa...',
messages: [...messages, { role: 'user', content: message }],
});
const agentReply =
response.content[0].type === 'text' ? response.content[0].text : '';
await manager.addTurn(message, agentReply);
return Response.json({
reply: agentReply,
stats: manager.getStats(),
});
}
Dos detalles importantes en producción: el ConversationManager se instancia por sesión y el estado debe persistir entre requests. Con la arquitectura serverless de Vercel, un Map en memoria no sobrevive entre invocaciones — necesitas Redis, Supabase o cualquier store externo.
Lo que cambia en producción
Con este patrón en producción, en agentes de soporte y automatización que construimos en DAILYMP, vemos resultados consistentes:
- Reducción del 80-90% en tokens por llamada en conversaciones de más de veinte turnos
- Coste por conversación predecible independientemente de la duración
- Zero errores de context length en producción
- Coherencia mantenida en conversaciones de cincuenta, cien o más turnos
La clave está en el modelo que usas para el resumen. Claude Haiku es suficientemente bueno y rápido para compresión de historial — no tiene sentido usar un modelo caro para esta tarea auxiliar. El coste de generar el resumen es marginal comparado con lo que ahorras en cada llamada principal.
Un detalle que suele sorprender a los equipos: el resumen no tiene que ser perfecto. No necesitas que el modelo de compresión entienda el dominio a fondo. Necesitas que preserve fechas, nombres, decisiones y el estado del problema. Eso lo hace bien cualquier modelo moderno con un buen system prompt de compresión.
Errores comunes al implementar el patrón
Resumir demasiado pronto. Si el umbral es bajo (cuatro o seis mensajes), el coste de compresión empieza a competir con el ahorro. En la práctica, diez a dieciséis mensajes recientes antes de comprimir funciona bien para la mayoría de casos de soporte.
No versionar el resumen. Si el sistema prompt del agente cambia, los resúmenes generados con la versión anterior pueden tener formato o foco distintos. Guarda la versión del prompt de compresión junto con el resumen. Invalida resúmenes obsoletos.
Perder el contexto del sistema. El resumen cubre el historial de mensajes, no el contexto de negocio del agente (reglas, catálogo, cliente específico). Ese contexto sigue viviendo en el system prompt principal, no en el resumen.
Para arquitecturas más complejas — agentes con herramientas, multitenancy, o sesiones que deben sobrevivir días — el patrón se puede extender con un store de hechos clave (key facts extraction) complementario al resumen. Es lo que implementamos en los servicios de integración IA cuando el agente necesita recordar información estructurada específica del usuario.
Cuándo necesitas esto
Si tu agente maneja conversaciones de más de diez turnos en producción y todavía no tienes context summarization, ya estás pagando de más por cada llamada y tus usuarios ya están notando incoherencias.
No hace falta una refactorización grande. El ConversationManager que ves arriba se puede añadir a un agente existente en pocas horas — y el retorno es inmediato en coste y en calidad de conversación.
¿Quieres implementarlo en tu agente? Escríbeme por WhatsApp y lo revisamos juntos.