Empezar aquí
Índice de la sección
Lección 100.5 · práctica

Humano en el bucle

Pausa el agente antes de acciones irreversibles, guarda su estado, enseña a una persona exactamente qué va a pasar y reanuda con su decisión.

Publicado 40 min
Al terminar podrás
  • Clasificar las herramientas de un agente según su reversibilidad e impacto para decidir cuáles requieren aprobación.
  • Implementar la pausa del bucle con estado persistido y la reanudación con un tool_result que refleja la decisión humana.
  • Diseñar la petición de aprobación con los argumentos reales, el contexto necesario y un plazo de caducidad.
  • Evitar ejecuciones dobles con claves de idempotencia y registrar cada decisión.

Requiere: Seguridad: prompt injection y permisos

Al terminar esta lección tu agente se detendrá antes de reembolsar, borrar o enviar algo, guardará dónde estaba, pedirá a una persona que decida con la información justa y continuará con esa decisión, aunque pasen horas. Sabrás también qué acciones merecen esa pausa y cuáles no.

La idea

Humano en el bucle significa que una persona revisa y decide en un punto concreto del flujo del agente. El lugar más importante es antes de una acción irreversible o de alto impacto: mover dinero, borrar datos, enviar algo a un tercero, cambiar permisos. Es la última capa de defensa cuando todo lo demás falla, incluida una inyección de prompt que superó tus otras defensas (Seguridad: prompt injection y permisos).

Técnicamente, la pausa encaja de forma natural en el ciclo de herramientas. Cuando el modelo emite un tool_use para una herramienta sensible, tu código no la ejecuta. Guarda la conversación, avisa a una persona y termina la petición. Cuando la persona decide, cargas la conversación, construyes el Tool result con el resultado de la decisión y sigues el bucle.

flowchart LR
  M[Modelo] -- tool_use reembolsar --> P[Pausa: guardar estado]
  P --> H[Persona revisa]
  H -- aprueba --> E[Ejecutar]
  H -- rechaza --> R[tool_result con is_error]
  E --> C[Reanudar bucle]
  R --> C
  C --> M

Qué acciones necesitan aprobación

Pedir aprobación para todo es casi tan malo como no pedirla: la gente aprende a pulsar «Aprobar» sin leer. Clasifica tus herramientas con dos preguntas: ¿se puede deshacer? y ¿cuánto daño hace si sale mal?

Impacto bajoImpacto alto
ReversibleEjecuta y registra (crear un borrador, etiquetar un ticket)Ejecuta, registra y permite deshacer (cambiar una dirección de envío)
IrreversibleAprobación por umbral (reembolso bajo un importe)Aprobación siempre (reembolso alto, borrar una cuenta, enviar un correo a un cliente)

Los umbrales se definen en código, no en el prompt. Revísalos con datos: si el 100 % de las aprobaciones de una categoría se aceptan sin cambios durante meses, quizá ese umbral pueda subir. Si se rechazan muchas, el agente tiene un problema que hay que arreglar antes.

La pausa en código

El estado tiene que sobrevivir entre peticiones: la persona puede tardar minutos o un día. Guarda en tu base de datos el historial de mensajes, los tool_use pendientes y los resultados de las herramientas que sí se ejecutaron en ese mismo turno.

import uuid
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
import anthropic

MODEL = "claude-sonnet-5-5"
client = anthropic.Anthropic()
SENSIBLES = {"reembolsar", "borrar_cuenta", "enviar_correo"}

@dataclass
class Pendiente:
    id: str
    mensajes: list                 # historial hasta la respuesta con el tool_use
    resultados: list               # tool_result ya resueltos de ese mismo turno
    acciones: list                 # [{"tool_use_id", "nombre", "entrada", "clave"}]
    caduca: str = field(default_factory=lambda: (
        datetime.now(timezone.utc) + timedelta(hours=24)).isoformat())

def avanzar(sesion, mensajes: list, max_pasos: int = 10) -> dict:
    for _ in range(max_pasos):
        resp = client.messages.create(model=MODEL, max_tokens=1024, system=SYSTEM,
                                      tools=TOOLS, messages=mensajes)
        mensajes.append({"role": "assistant", "content": [b.model_dump(exclude_none=True) for b in resp.content]})
        if resp.stop_reason != "tool_use":
            return {"estado": "respondido",
                    "texto": "".join(b.text for b in resp.content if b.type == "text")}
        resultados, acciones = [], []
        for b in resp.content:
            if b.type != "tool_use":
                continue
            if b.name in SENSIBLES:
                acciones.append({"tool_use_id": b.id, "nombre": b.name, "entrada": b.input,
                                 "clave": uuid.uuid4().hex})   # clave de idempotencia
            else:
                salida, error = ejecutar(sesion, b.name, b.input)
                resultados.append({"type": "tool_result", "tool_use_id": b.id,
                                   "content": salida, "is_error": error})
        if acciones:
            p = Pendiente(uuid.uuid4().hex, mensajes, resultados, acciones)
            guardar_pendiente(p)                  # tu base de datos
            notificar_revisor(p)                  # cola, correo, panel interno
            return {"estado": "pendiente", "id": p.id}
        mensajes.append({"role": "user", "content": resultados})
    return {"estado": "limite_de_pasos"}

Guardar el contenido con model_dump(exclude_none=True) convierte los bloques en diccionarios que puedes serializar a JSON y volver a enviar tal cual.

Cuando la persona decide, se completa el turno: un tool_result por cada tool_use, los de las herramientas ya ejecutadas y los de las sensibles, todos en el mismo mensaje.

def resolver(sesion, pendiente_id: str, decisiones: dict[str, dict]) -> dict:
    p = cargar_pendiente(pendiente_id)
    if datetime.fromisoformat(p.caduca) < datetime.now(timezone.utc):
        decisiones = {a["tool_use_id"]: {"aprobada": False, "motivo": "caducada"} for a in p.acciones}
    resultados = list(p.resultados)
    for a in p.acciones:
        d = decisiones[a["tool_use_id"]]
        if d["aprobada"]:
            salida, error = ejecutar(sesion, a["nombre"], a["entrada"], clave=a["clave"])
        else:
            salida, error = f"Rechazada por revisión humana: {d.get('motivo', 'sin motivo')}", True
        registrar_decision(p.id, a, d, revisor=d.get("revisor"))
        resultados.append({"type": "tool_result", "tool_use_id": a["tool_use_id"],
                           "content": salida, "is_error": error})
    p.mensajes.append({"role": "user", "content": resultados})
    marcar_resuelto(p.id)
    return avanzar(sesion, p.mensajes)

Tres detalles de robustez:

  • Idempotencia. La clave se genera al pausar y se pasa a la herramienta. Si el revisor pulsa dos veces o tu servicio reintenta, la API de pagos reconoce la clave y no cobra ni reembolsa dos veces.
  • Caducidad. Una aprobación de hace una semana puede estar basada en datos que ya no son ciertos. Al caducar, se rechaza y el agente lo explica.
  • Rechazo como resultado. Un tool_result con is_error: True y el motivo permite que el modelo siga: explicar al usuario, ofrecer una alternativa o escalar.

Diseñar la petición de aprobación

La pausa solo protege si la persona puede decidir bien en poco tiempo. Una buena petición de aprobación incluye:

  1. Qué: la acción con sus argumentos exactos. «Reembolsar 89,90 € al pedido 4512 a la tarjeta terminada en 1234.»
  2. Por qué: lo que pidió el usuario, en sus palabras, y los datos que usó el agente (el pedido, la política aplicada).
  3. Consecuencias: si se puede deshacer y cómo.
  4. Opciones: aprobar, rechazar con motivo y, si tiene sentido, editar un argumento dentro de límites (por ejemplo, un importe menor).
  5. Plazo: cuándo caduca.

Evita la fatiga: agrupa en una sola revisión varias acciones del mismo caso, pide aprobación solo donde la tabla de arriba lo indica y mide cuánto tardan las revisiones y cuántas se rechazan. Los patrones de interfaz para estas pantallas están en UX de agentes y la práctica del día a día en Humano en el bucle.

Errores frecuentes

  • Pedir confirmación al modelo («¿Estás seguro?») en lugar de a una persona.
  • Ejecutar la acción y pedir aprobación después.
  • Guardar el estado solo en memoria y perderlo al reiniciar el servicio.
  • Olvidar los tool_result de las herramientas no sensibles del mismo turno al reanudar.
  • No tener claves de idempotencia y ejecutar dos veces una acción aprobada.
  • Pedir aprobación para todo y entrenar a los revisores a no leer.

Ejercicio

EjercicioPistas

Para la idempotencia en tu stub, guarda en un conjunto las claves ya usadas y devuelve el resultado anterior si llega una repetida. Para la caducidad, crea el Pendiente con una fecha ya pasada.

Resumen

  • Pide aprobación antes de lo irreversible o de alto impacto, según una tabla de reversibilidad e impacto en código.
  • La pausa es no ejecutar el tool_use, guardar el estado y reanudar con un tool_result por cada tool_use.
  • Usa claves de idempotencia, caducidad y registro de cada decisión.
  • Enseña al revisor los argumentos reales, el porqué y las consecuencias.
  • Pide pocas aprobaciones y buenas para evitar la fatiga.

Para seguir