▲ Agentes en producción
El agente propone, el backend ejecuta.
Por César Soto · 7 min de lectura
▲ En este artículo
▲ En resumen
- La regla que sigo para poner un agente de IA frente a un backend transaccional cabe en una línea: el agente propone y el backend ejecuta.
- Todo lo que sale del agente cruza por un schema validado y, de ahí en adelante, lo procesa código determinista: un servicio de dominio con las mismas reglas que cualquier otro input.
- Con esa frontera, un mal output del modelo degrada la calidad de la respuesta y deja intacta la integridad de los datos.
Esta semana me preguntaron en LinkedIn cómo estructuro la frontera entre la lógica agéntica y los procesos transaccionales deterministas del backend. Aquí está la respuesta completa, con un ejemplo en TypeScript y Zod escrito para este artículo: un agente de soporte que propone un reembolso.
¿Dónde va la frontera entre el agente y el backend?
La frontera va en un schema validado: el agente propone y el backend ejecuta. Arriba del schema vive lo no determinista, que se puede repetir sin consecuencias. Abajo vive el código de siempre, con transacciones que ocurren una sola vez.
Si no valida, se repite la llamada al modelo. Todavía no se escribió nada.
La frontera se sostiene con cuatro piezas, y cada una responde a una forma distinta en que un agente puede fallar.
- Output tipado. El agente devuelve datos con una forma declarada, y lo que no cumple esa forma se descarta antes de tocar el sistema.
- Servicio de dominio. Quien escribe en la base es el mismo servicio que atiende a un formulario o a una API, con las mismas validaciones.
- Tools acotadas con el tenant inyectado. El servidor decide sobre qué datos opera el agente; el modelo no tiene forma de elegirlo.
- Idempotencia del lado determinista. La cola puede reintentar una llamada al modelo y nunca una transacción.
¿Qué es un output tipado?
Zod es una librería de TypeScript para declarar esa forma una sola vez y obtener dos cosas: la validación en runtime y el tipo estático. El schema de abajo es el contrato completo entre el agente y el backend.
import * as z from "zod";
// Lo único que el agente puede devolver: una propuesta.
export const RefundProposal = z.object({
orderId: z.string().min(1),
amountCents: z.number().int().positive(),
reason: z.enum(["damaged", "late", "duplicate_charge"]),
note: z.string().max(280),
});
// No hay tenantId ni userId: eso no lo decide el modelo.
export type RefundProposal = z.infer<typeof RefundProposal>;
El schema describe una propuesta, y eso se nota en lo que deja fuera. El motivo es un enum de tres valores, así que el agente no puede inventar un cuarto. El monto es un entero positivo en centavos. El cliente y el usuario no aparecen porque el servidor ya los conoce.
¿Qué pasa cuando el output del agente cruza la frontera?
El output entra como unknown y sale como un tipo o como un error; no existe un tercer camino. Esta función es la frontera completa.
export async function handleAgentOutput(
raw: unknown,
ctx: ServerContext,
) {
const parsed = RefundProposal.safeParse(raw);
if (!parsed.success) {
// Repetir aquí es seguro: todavía no se escribió nada.
return askModelAgain(parsed.error);
}
// De aquí en adelante, código determinista.
return refunds.propose({
...parsed.data,
tenantId: ctx.tenantId, // de la sesión
requestedBy: ctx.userId,
idempotencyKey: ctx.jobId, // una por job
});
}
Si el schema rechaza el output, se le devuelve el error al modelo y se intenta otra vez. Repetir esa llamada cuesta tokens y nada más. Si el schema lo acepta, la propuesta se completa con el contexto del servidor y pasa al servicio de dominio.
¿Quién escribe en la base de datos?
Escribe un servicio de dominio, el mismo que usaría una persona desde la interfaz. Para ese servicio, el agente es un cliente más y recibe el mismo trato que cualquier otro input.
export const refunds = {
async propose(input: ProposeRefundInput) {
return db.transaction(async (tx) => {
const done = await tx.refunds.findByKey(
input.tenantId,
input.idempotencyKey,
);
if (done) return done; // la cola reintentó: misma respuesta
// Reglas de negocio: aquí el schema ya no alcanza.
const order = await tx.orders.find(
input.tenantId,
input.orderId,
);
if (!order) throw new DomainError("order_not_found");
if (input.amountCents > order.refundableCents) {
throw new DomainError("amount_exceeds_refundable");
}
// El backend decide que esto lo aprueba una persona.
return tx.refunds.insert({
...input,
status: "pending_approval",
});
});
},
};
El schema valida la forma y el servicio valida el negocio. Un monto de 50,000 centavos tiene forma correcta, y solo el servicio sabe que la orden admite 30,000. Esa regla ya existía antes del agente, y por eso el agente no necesita una versión propia.
¿Cómo accede el agente a los datos del sistema?
Accede solo a través de tools acotadas, y el tenant se inyecta desde el servidor al construirlas. El modelo ve una firma con un único parámetro.
// El modelo ve esta firma. No hay tenantId que pueda llenar.
const GetOrderInput = z.object({
orderId: z.string().min(1),
});
export function buildTools(ctx: ServerContext) {
return {
getOrder: {
description: "Consulta una orden por su id.",
inputSchema: GetOrderInput,
execute: (input: z.infer<typeof GetOrderInput>) =>
orders.find(ctx.tenantId, input.orderId), // del servidor
},
};
}
Un prompt injection puede convencer al modelo de pedir la orden de otro cliente. La consulta se ejecuta de todos modos con el tenant de la sesión y regresa vacía. El aislamiento entre clientes queda garantizado por la firma de la tool y deja de depender de lo que diga el prompt.
¿Qué puede salir mal y dónde se detiene?
Cada tipo de fallo se detiene en una pieza distinta, y solo uno llega hasta el usuario.
| Qué sale mal | Dónde se detiene | Qué le pasa al sistema |
|---|---|---|
| El output no tiene la forma esperada | Schema | Se repite la llamada al modelo; no se escribió nada |
| La forma es válida y el dato es imposible en el negocio | Servicio de dominio | Error de dominio, igual que con un formulario |
| El agente pide datos de otro cliente | Tool con tenant inyectado | La consulta regresa vacía |
| El job corre dos veces | Llave de idempotencia | La segunda vez devuelve el resultado de la primera |
| La propuesta es válida y está equivocada | No se detiene en la frontera | Baja la calidad; los datos siguen íntegros |
“Un mal output degrada calidad, nunca integridad de datos.”
La última fila es la que queda abierta, y es un problema de calidad: se trabaja con evaluaciones, con mejores specs y con aprobación humana en las acciones que lo ameritan. En el ejemplo, el reembolso nace como pending_approval por esa razón.
¿La misma regla aplica al desarrollo con agentes?
Sí, es la misma frontera con otros nombres. En el desarrollo con agentes, el agente propone un PR y lo que ejecuta el merge son las compuertas: los revisores automáticos, la revisión humana proporcional al riesgo y QA.
En los dos casos el agente trabaja con libertad de un lado y el sistema conserva la última palabra del otro. Describo ese proceso en qué es un harness y en el método de PeakSyn.
¿Por dónde empezar en un sistema que ya existe?
- Elige una acción del agente que hoy escriba datos. Una sola, la que más te preocupe.
- Declara su schema. Deja fuera todo lo que el servidor ya sabe: cliente, usuario, permisos.
- Dirige la escritura al servicio que ya existe. Si el agente tiene su propio camino a la base, ese camino es el primero que se cierra.
- Quita los identificadores de cliente de las firmas de las tools. Inyéctalos desde la sesión.
- Agrega la llave de idempotencia en el servicio. Con su índice único.
Si quieres revisar cómo quedaría esta frontera en tu producto, puedes agendar una conversación de 30 minutos.
Preguntas frecuentes
¿El structured output del proveedor del modelo no resuelve esto?
Resuelve una parte. El structured output del proveedor reduce los errores de forma, y aun así valido en el servidor, porque el contrato es mío y debe sobrevivir a un cambio de modelo. Las reglas de negocio quedan fuera de cualquier schema y viven en el servicio de dominio.
¿Necesito Zod para aplicar esta regla?
No. Zod es la opción habitual en TypeScript; en Python el mismo papel lo cumple Pydantic, y cualquier validador de JSON Schema sirve. Lo que importa es que la forma se declare una vez y se valide en el servidor antes de usar el dato.
¿Entonces el agente nunca escribe en la base de datos?
Nunca escribe directo. Sus escrituras pasan por servicios de dominio que aplican las mismas validaciones que para cualquier otro input, dentro de una transacción con llave de idempotencia.
¿Qué pasa con acciones irreversibles, como cobrar o enviar un correo?
Se tratan como transacciones. El agente las propone, el backend las ejecuta una sola vez con una llave de idempotencia y, cuando el impacto lo amerita, quedan pendientes de la aprobación de una persona.
¿Esta frontera evita que el agente se equivoque?
No. Evita que un error del agente dañe los datos. Una propuesta válida y equivocada sigue siendo posible, y esa parte se trabaja con evaluaciones, specs y aprobación humana.
Referencias
¿Quieres aplicarlo en tu equipo? Hablemos 30 minutos.
Sin costo, en video, con César Soto.
Agenda una conversación