Operaciones, no reemplazos: Reductores CQRS para estado de agentes
Cuando el estado de tu agente se vuelve complejo, reemplazar todo en cada paso se rompe. Aprende a usar operaciones incrementales con reductores CQRS en LangGraph.
Tu agente tiene estado. Mucho estado. Y si estás en LangGraph, cada nodo devuelve un pedazo y un reductor decide cómo meterlo.
Los tutoriales te enseñan esto:
return { myField: newValue };
Y funciona. Para demos. Para strings. Para números.
Pero un día tu agente maneja un objeto anidado - columnas con tarjetas, conteos derivados, estructuras que se ramifican tres niveles - y reemplazar todo en cada paso se convierte en un problema real.
El problema
Tienes un tablero Kanban. Algo así:
interface Board {
columns: Column[];
}
interface Column {
columnId: string;
title: string;
cardCount: number; // derivado - siempre cards.length
cards: Card[];
}
interface Card {
cardId: string;
title: string;
priority: number;
}
El usuario dice: “Mueve T-42 de Backlog a In Progress.”
Con reemplazo total, tu herramienta tiene que:
- Leer el tablero entero.
- Buscar Backlog, buscar T-42.
- Sacar T-42 de Backlog.
- Meter T-42 en In Progress.
- Recalcular
cardCounten ambas columnas. - Devolver el tablero completo.
Si otra herramienta ejecuta en paralelo y también devuelve un tablero completo, una sobreescribe la otra. Si el LLM alucina una forma inválida - campos faltantes, tipos incorrectos - tu estado se corrompe y nadie se entera hasta que algo explota aguas abajo.
Esto no es teórico. Pasa en producción.
Operaciones, no reemplazos
En vez de reemplazar, emite una operación. La operación dice qué cambió, no cómo queda todo.
// Reemplazo: la herramienta reconstruye el tablero entero
return { board: fullBoard };
// Operación: la herramienta dice qué mover
return { board: { type: "move", cardId: "T-42", fromColumn: "backlog", toColumn: "in_progress" } };
El reductor decide cómo aplicar ese cambio al estado actual.
La idea viene de CQRS - Command Query Responsibility Segregation. El “Command” es la operación. La “Query” es el reductor leyendo estado actual para aplicarla. No necesitas implementar CQRS completo. Solo necesitas la parte que importa: comandos pequeños y explícitos en vez de reemplazos globales.
Define tus operaciones
interface BoardMoveOp {
type: "move";
cardId?: string; // omitir = mover toda la columna
fromColumn: string;
toColumn: string;
}
interface BoardRemoveOp {
type: "remove";
cardId?: string; // omitir = limpiar toda la columna
fromColumn: string;
}
type BoardOp = BoardMoveOp | BoardRemoveOp;
Dos operaciones. Claras. Pequeñas. Cada una dice exactamente qué quiere hacer.
Y se combinan con el reemplazo total para cubrir ambos casos:
type BoardUpdate = Board | BoardOp;
buildBoard devuelve un Board completo - está creando desde cero. moveCard devuelve un BoardMoveOp - está modificando algo que ya existe. Ambos fluyen por el mismo reductor.
El reductor
El reductor es donde todo se conecta. Recibe estado actual + update, decide si es operación o reemplazo, y actúa:
function boardReducer(
current: Board | null,
update: BoardUpdate,
): Board | null {
// Operación incremental
if (isBoardOp(update)) {
if (!current) return current;
const cloned = structuredClone(current);
try {
if (update.type === "move") applyMoveOp(cloned, update);
else if (update.type === "remove") applyRemoveOp(cloned, update);
} catch {
// Operación inválida: log, mantener estado, seguir vivo
console.warn("Operación inválida, estado preservado");
return current;
}
return cloned;
}
// Reemplazo total: validar antes de aceptar
if (!isValidBoard(update)) {
console.warn("Tablero inválido, estado preservado");
return current;
}
return update;
}
Cosas que están pasando aquí:
Deep clone antes de mutar. Nunca mutes el estado actual. structuredClone, muta la copia, devuelve la copia. Si algo falla, el original queda intacto. Sin excepciones.
Validación en la frontera. Operaciones y reemplazos se validan antes de tocar estado. Si el LLM alucina { type: "teleport", from: "mars" }, se rechaza. El estado queda bien.
Rechazar y preservar. Operaciones inválidas no rompen el flujo. Se loguean, se rechazan, y el estado queda como estaba. El agente puede reintentar. Esto solo es posible porque el reductor tiene control total sobre qué entra y qué no.
Cómo se aplican las operaciones
function applyMoveOp(board: Board, op: BoardMoveOp): void {
const fromCol = board.columns.find(c => c.columnId === op.fromColumn);
const toCol = board.columns.find(c => c.columnId === op.toColumn);
if (!fromCol || !toCol) {
throw new Error(`Columna no encontrada`);
}
if (op.cardId) {
const idx = fromCol.cards.findIndex(c => c.cardId === op.cardId);
if (idx === -1) throw new Error(`Tarjeta ${op.cardId} no encontrada`);
const [card] = fromCol.cards.splice(idx, 1);
toCol.cards.push(card);
} else {
// Mover toda la columna (reordenar posiciones)
const idx = board.columns.findIndex(c => c.columnId === op.fromColumn);
// ... lógica de reordenamiento
}
recalcColumn(fromCol);
recalcColumn(toCol);
}
function applyRemoveOp(board: Board, op: BoardRemoveOp): void {
const fromCol = board.columns.find(c => c.columnId === op.fromColumn);
if (!fromCol) throw new Error(`Columna no encontrada`);
if (op.cardId) {
const idx = fromCol.cards.findIndex(c => c.cardId === op.cardId);
if (idx === -1) throw new Error(`Tarjeta no encontrada`);
fromCol.cards.splice(idx, 1);
} else {
fromCol.cards = [];
}
recalcColumn(fromCol);
}
function recalcColumn(col: Column): void {
col.cardCount = col.cards.length;
}
La mutación es sobre la copia - ya hicimos structuredClone en el reductor. Los errores se capturan arriba. Los conteos siempre se recalculan.
¿Por qué cardCount si es lo mismo que cards.length? Porque el LLM toma decisiones basándose en cardCount. Si está desactualizado, el agente decide mal. Los campos derivados se recalculan donde se muta - en el reductor, no en el render.
Por qué las operaciones componen
El reductor se ejecuta secuencialmente. Dos herramientas que emiten operaciones en el mismo step:
// Tool 1: { type: "move", cardId: "T-42", fromColumn: "backlog", toColumn: "in_progress" }
// → Reductor aplica, estado actualizado
// Tool 2: { type: "remove", cardId: "T-15", fromColumn: "backlog" }
// → Reductor aplica de nuevo, estado actualizado
Cada operación ve el estado después de la anterior. No hay conflictos. No hay merge manual. No hay lectura obsoleta.
Con reemplazo total, ambas leen el mismo estado original, cada una produce su versión, y la segunda sobreescribe la primera. Adiós T-42.
Cuándo reemplazar y cuándo operar
Reemplazo total cuando creas estado desde cero. buildBoard genera un tablero nuevo. No hay estado previo. Devuelve un Board completo. Cualquier bootstrap donde el campo es null.
Operaciones cuando modificas estado existente. moveCard mueve una tarjeta. removeCard saca una tarjeta. Cualquier edición incremental.
La unión BoardUpdate = Board | BoardOp deja que ambos pasen por el mismo reductor. Sin bifurcaciones en el código que llama.
Validación con Zod en la frontera
Los tipos de TypeScript no te salvan cuando los datos vienen de un LLM. Funcionan en compilación. En ejecución, necesitas algo más.
const MoveOpSchema = z.object({
type: z.literal("move"),
cardId: z.string().optional(),
fromColumn: z.string(),
toColumn: z.string(),
});
const RemoveOpSchema = z.object({
type: z.literal("remove"),
cardId: z.string().optional(),
fromColumn: z.string(),
});
const BoardOpSchema = z.discriminatedUnion("type", [
MoveOpSchema,
RemoveOpSchema,
]);
function isBoardOp(value: unknown): value is BoardOp {
const result = BoardOpSchema.safeParse(value);
if (!result.success) {
console.warn("Operación inválida", { error: result.error.message });
return false;
}
return true;
}
z.discriminatedUnion con el campo type. Si el LLM manda un type que no es "move" o "remove", Zod lo rechaza. No toca el estado. No hay escape.
Y TypeScript marca el else como inalcanzable si cubres todos los tipos de operación. Sin switch sin default, sin casos olvidados.
Wireup en LangGraph
LangGraph expone reductores personalizados vía withLangGraph():
const BoardStateSchema = z.object({
messages: withLangGraph(z.custom<BaseMessage[]>(), MessagesZodMeta),
board: withLangGraph(
z.custom<Board | null>(),
{
default: () => null,
reducer: {
schema: z.custom<BoardUpdate>(),
fn: boardReducer,
},
},
),
phase: z.enum(["setup", "editing", "review", "complete"]).default("setup"),
});
Cuando un nodo devuelve { board: someOp }, LangGraph no reemplaza. Llama a boardReducer(currentBoard, someOp).
El default es null. La primera vez que buildBoard corre, devuelve un Board completo. El reductor lo valida y lo guarda. A partir de ahí, moveCard manda operaciones incrementales.
Lo que los tutoriales no te dicen
CQRS en reductores no es algo que LangGraph te da listo. Lo construyes encima de su mecanismo de canales. Pero es lo que hace que un agente con estado complejo funcione en producción:
- Estado anidado con campos derivados → operaciones incrementales
- Múltiples herramientas tocando el mismo campo → reducción secuencial
- Datos de un LLM → validación Zod en la frontera
- Operaciones inválidas → rechazar y preservar, no explotar
Definir tipos de operación y escribir un reductor toma más tiempo que return { myField: newValue }. Pero se paga una vez. Los bugs de estado corrupto se pagan cada vez que el LLM decide inventar un campo.
TL;DR
| Situación | Qué hacer |
|---|---|
| Crear desde cero | Reemplazo total (Board) |
| Modificar existente | Operación (BoardMoveOp, BoardRemoveOp) |
| Múltiples herramientas, mismo campo | Reductor secuencial |
| Datos de un LLM | z.discriminatedUnion en la frontera |
| Campos derivados | Recalcular en cada mutación |
| Operación inválida | Log, rechazar, preservar estado |
Lo que esto habilita: la UI como ventana al estado del agente
Aquí viene la parte que los tutoriales ni mencionan.
Tu agente tiene un board en su estado. Un componente de React (o cualquier frontend) se suscribe a ese estado y lo renderiza. El usuario ve columnas con tarjetas. Ahora el usuario dice “mueve T-42 a In Progress” y el agente ejecuta moveCard. La herramienta devuelve una operación: { type: "move", cardId: "T-42", fromColumn: "backlog", toColumn: "in_progress" }.
El reductor aplica la operación. El estado se actualiza. La UI se re-renderiza con el tablero actualizado.
Pero aquí está la clave: la UI y el agente comparten el mismo estado. No hay sincronización manual. No hay endpoints PATCH separados. No hay “la UI hace PUT /board y después el agente hace PUT /board y oops se perdieron los cambios del usuario”.
El flujo es:
- La UI renderiza desde el estado del agente.
- El usuario interactúa → el agente ejecuta una herramienta → la herramienta emite una operación → el reductor actualiza el estado → la UI se actualiza.
- El agente razona → ejecuta otra herramienta → emite otra operación → el reductor aplica sobre el estado actualizado → la UI se actualiza de nuevo.
Todo fluye por el mismo canal. La UI no es una capa separada que hay que mantener en sincronía. Es una ventana al estado del agente. Y porque las operaciones son incrementales y el reductor las valida, la UI nunca ve estado corrupto - nunca renderiza un tablero con cardCount: 5 y tres tarjetas.
Pero hay algo más. Porque la UI conoce las operaciones, puede emitirlas directamente. Un drag-and-drop en la UI puede generar un BoardMoveOp. El usuario reordena columnas, la UI emite la operación, el reductor la aplica, y el agente ve el tablero actualizado en su siguiente razonamiento. No necesitas dos sistemas de estado - uno para la UI y otro para el agente. Un solo reductor, un solo estado, dos fuentes de cambios que se compositionan naturalmente.
Esto también habilita el flujo humano-en-el-loop. El agente propone un tablero, el usuario lo revisa, rechaza y dice “cambia esto”. El agente ejecuta moveCard con los ajustes. La operación fluye por el mismo reductor. Sin casos especiales. sin código separat para “el usuario editó” vs “el agente editó.”
Y porque el reductor rechaza operaciones inválidas, no importa quién emita el cambio - la UI, el agente, o un webhook externo. Si la operación no pasa la validación, el estado se preserva. La fuente de verdad siempre es el estado dentro del grafo.
Esto es lo que CQRS en reductores realmente habilita: colaboración en tiempo real entre humano y agente sobre un estado compartido, con garantías de consistencia, sin duplicación de lógica, y sin que el LLM pueda corromper el estado porque el reductor es la frontera.
Si tu agente solo chatea, no necesitas esto. Pero si tu agente y un humano trabajan juntos sobre un estado complejo - un tablero, un calendario, un plan, una distribución - las operaciones incrementales con un reductor CQRS son lo que hace que funcione sin explotar.
Lecturas
- LangGraph - CustomReducers - Reductores personalizados en canales de estado.
- CQRS - Martin Fowler - El artículo original. No es sobre agentes, pero la teoría es la misma.
- Zod discriminated unions - La API que usamos para validar operaciones en la frontera.
- LangGraph - StateGraph - Grafos de estado con canales y reductores personalizados.