Errores como observaciones

Errores como observaciones

30 min read

Todos los guiones del bloque, también el de la lección que escribió el bucle, tenían algo que un modelo de verdad no garantiza: sus llamadas estaban bien. Cumplían su esquema y pedían ficheros que existen, porque las escribía yo para eso. El bucle está hecho para llamadas así. Sabe terminar y sabe pararse en el tope, pero cuando una llamada sale mal, quien se entera es Python, no el modelo.

Y el modelo se equivoca. Escribe una llamada que su esquema no admite, o pide un fichero que no existe, y las dos cosas podría arreglarlas él si supiera que han pasado: corregir los argumentos, buscar la ruta buena o decirle al usuario que lo que pide no está. Esta lección decide qué le cuenta el harness cuando una llamada falla, y quién decide qué hacer después. Queda una tercera cuestión, que no se ve hasta que se piensa en ella. Tras un error, el modelo suele volver a pedir lo mismo, y repetir una llamada no siempre es inofensivo.

Mira primero una conversación con dos errores, sobre un harness que ya sabe contarlos. El entorno es el de siempre: la lista de la compra, la nota de la cena y las dos recetas de recetas/. El usuario pide Añade a lista.txt lo que falta para el salmorejo., y en recetas/ no hay ningún salmorejo.

  1. El modelo quiere leer dos ficheros y los pide en una sola llamada, t1, con un campo rutas que es una lista. leer no tiene ese campo: tiene ruta, de texto y obligatorio. El harness no ejecuta nada y contesta a t1 con una observación marcada como error, que dice por qué: no se ha ejecutado: falta el campo 'ruta'.
  2. El modelo corrige, con una llamada por fichero. t3, la lista, sale bien. t2, la receta, falla al ejecutarse, y su observación es otro error: no existe el fichero 'recetas/salmorejo.txt'. Las dos vuelven juntas, en el mismo mensaje, cada una con su identificador.
  3. El modelo no sabe qué recetas hay, y lo pregunta: lista recetas/ y recibe dos rutas, la del gazpacho y la de la tortilla.
  4. Ya no pide nada. Escribe que no hay receta de salmorejo, que no ha cambiado lista.txt, y pregunta si añade lo que falta para el gazpacho, que es lo más parecido.

Fíjate en quién ha tomado cada decisión. El harness no ha corregido el campo, no ha buscado un fichero parecido y no ha decidido que la tarea era imposible. Ha contado dos errores, y todo lo demás lo ha hecho el modelo al leerlos. Fíjate también en cómo termina. El agente no ha cumplido el pedido, y es la respuesta correcta: la alternativa era inventarse el salmorejo o escribir el gazpacho sin preguntar.

Un error es una observación

Con precisión, una llamada falla cuando no hace lo que promete su herramienta, y lo que vuelve entonces es un error: una observación con is_error verdadero. Hay dos maneras de fallar, y el harness las distingue porque ocurren en momentos distintos. La llamada puede no cumplir su esquema, y entonces no se ejecuta. O puede cumplirlo y fallar al ejecutarse, porque la función lanza una excepción.

ejecutar(llamada, entorno) -> tool_result pasa a tener este contrato:

  • Nunca lanza. Devuelve siempre un tool_result con el id de la llamada, falle lo que falle dentro.
  • Si la llamada no cumple su esquema, no la ejecuta, y devuelve un error cuyo contenido es no se ha ejecutado: seguido del motivo de la comprobación.
  • Si la cumple y la función lanza, devuelve un error cuyo contenido es el mensaje de la excepción.
  • Si la cumple y la función devuelve, lo de antes: is_error falso y lo devuelto como contenido.

El bucle no cambia, ni una línea. Llama a ejecutar una vez por llamada y añade lo que devuelve, que ahora es siempre un tool_result. Por eso cada llamada recibe su observación también cuando falla, aunque falle la primera de tres y las otras dos salgan bien. El invariante de la lección anterior, ninguna llamada sin su observación, pasa a valer con un modelo que se equivoca.

El contenido de un error lo lee el modelo, y es lo único que tiene para decidir, así que dice qué ha pasado en palabras que permitan arreglarlo: qué campo falta, qué fichero no existe. Para eso le falta un caso al contrato de leer, el que la lección de las cuatro herramientas dejó abierto. Si ruta no está en el entorno, leer falla con no existe el fichero 'ruta' y no cambia nada. Las otras tres no fallan nunca con una llamada que cumple su esquema. Un buscar sin coincidencias devuelve un texto vacío, y eso no es un error: la herramienta ha hecho lo que promete, y la respuesta es que no hay nada.

is_error hace falta además del texto. El contenido de una lectura es lo que haya en el fichero, y un fichero puede contener la frase no existe el fichero 'x' sin que nada haya fallado. La marca dice si es un error, y el texto dice cuál. Es la separación que hizo la lección sobre llamar herramientas al dar a las peticiones un bloque propio, para que nadie tuviera que adivinarlas en un texto.

Lo que sigue lanzando es lo que no pertenece a ninguna llamada. Si es modelo el que falla (el modelo guionizado ante un mensaje que no es el del guion, una respuesta cortada por la reserva), no hay ningún tool_use al que contestar, y el único que podría leer el error es el que ha fallado. Esa excepción sale del bucle, hacia quien lo llamó.

Quién decide qué hacer con un error

Con un error delante, el modelo tiene tres salidas, y la traza usa las tres. Corregir la llamada y volver a pedirla, como en el turno 2. Pedir otra cosa que le dé lo que le falta, como el listar del turno 3. O dejarlo y decírselo al usuario, como en el 4. Elegir entre ellas exige entender el pedido, y el harness no lo entiende, por la misma razón por la que no decide cuándo termina el bucle.

Lo que el harness no hace es arreglarlo por su cuenta. Uno que, ante recetas/salmorejo.txt, leyera el fichero más parecido y devolviera el gazpacho sin decirlo le mentiría al modelo: la observación diría que el salmorejo lleva tomates, pepino, pimiento y ajo, y el modelo lo apuntaría en la lista convencido. Tampoco repite la llamada solo. Con el mismo entorno, la misma llamada falla igual, y lo único que cambia algo es una llamada distinta, que sólo puede escribir el modelo.

Que decida el modelo tiene un riesgo, y ya tiene remedio. Un modelo puede insistir turno tras turno en una llamada que falla. Antes, el primer error paraba el programa; ahora no para nada, y lo que acota esa insistencia es el tope de la lección anterior.

Repetir sin miedo: llamadas idempotentes

Tras un error, lo más frecuente es que el modelo vuelva a pedir la misma llamada o una casi igual. A veces la repite sin que haya fallado, porque no está seguro de que se ejecutara. Repetir es inofensivo con unas herramientas y no con otras, y la diferencia tiene nombre.

Una llamada es idempotente cuando ejecutarla dos veces seguidas deja el entorno igual que ejecutarla una. leer, listar y buscar lo son porque no cambian nada. escribir lo es aunque cambie el entorno, porque sustituye el fichero entero: la segunda escritura deja exactamente lo que dejó la primera. No es casualidad, porque así la definió la lección sobre llamar herramientas. Una herramienta añadir, que pegara el contenido al final del fichero, no lo sería: dos veces la misma llamada, dos veces la misma línea.

De ahí la regla para repetir. Una llamada que ha fallado se puede volver a pedir sin mirar cuando es idempotente, o cuando su error dice que no llegó a ejecutarse, que es lo que garantiza un error del esquema y por eso su contenido lo dice. Un error al ejecutarse no lo garantiza. Dice que la función no terminó, no que no hiciera nada antes de fallar. En este entorno no hay diferencia, porque la única herramienta que falla es leer, que no escribe. Un comando de verdad, en el bloque siguiente, puede cortarse con medio trabajo hecho, y ahí repetir una llamada que no es idempotente es repetir esa mitad.

Dos errores, sobre el modelo guionizado

Las dos celdas corren sobre el modelo guionizado, como todo el bloque, y su guion es el primero que trae llamadas equivocadas: las he escrito mal a propósito. La primera celda completa el contrato de leer y cambia la mitad de ejecutar que ya sabía por qué fallaba una llamada, la comprobación del esquema, que ahora contesta en lugar de lanzar. El bucle es el de la lección anterior, copiado sin tocar. Las observaciones que espera el guion las he escrito a mano, porque son las que debe dar un harness correcto, también en el turno 2.

from pyodide.http import open_url
exec(open_url("/courses/llm-agents/guionizado.py").read()) # ModeloGuionizado
exec(open_url("/courses/llm-agents/entorno.py").read()) # el entorno y sus herramientas

def leer(entorno, ruta): # su contrato entero: también la ruta que no existe
if ruta not in entorno:
raise FileNotFoundError("no existe el fichero %r" % ruta)
return entorno[ruta]
FUNCIONES["leer"] = leer
def error(id, contenido): # una observación que cuenta un error
return {"type": "tool_result", "tool_use_id": id, "content": contenido, "is_error": True}

def ejecutar(ll, entorno): # el esquema ya no lanza: contesta
motivo = incumple(ll)
if motivo is not None:
return error(ll["id"], "no se ha ejecutado: " + motivo)
return resultado(ll["id"], FUNCIONES[ll["name"]](entorno, **ll["input"])) # esto aún lanza

def bucle(modelo, inicio, entorno, tope): # el de la lección anterior, sin tocar
M = list(inicio)
for turno in range(tope):
r = modelo(M, HERRAMIENTAS)
M.append(("asistente", r))
llamadas = [b for b in r if b["type"] == "tool_use"]
if not llamadas:
return "fin", " ".join(b["text"] for b in r if b["type"] == "text"), M
M.append(("usuario", [ejecutar(ll, entorno) for ll in llamadas]))
return "tope", None, M

INICIO = [("sistema", "Trabajas con los ficheros del usuario mediante tus herramientas."),
("usuario", "Añade a lista.txt lo que falta para el salmorejo.")]
RESPUESTAS = [ # el guion, con dos llamadas equivocadas: lo he escrito yo
[texto("Leo la receta y la lista."), llamada("t1", "leer", rutas=["recetas/salmorejo.txt", "lista.txt"])],
[llamada("t2", "leer", ruta="recetas/salmorejo.txt"), llamada("t3", "leer", ruta="lista.txt")],
[llamada("t4", "listar", carpeta="recetas/")],
[texto("No hay receta de salmorejo, sólo de gazpacho y de tortilla. No he cambiado lista.txt.")]]
VISTO = [[error("t1", "no se ha ejecutado: falta el campo 'ruta'")], # lo que el guion espera
[error("t2", "no existe el fichero 'recetas/salmorejo.txt'"), resultado("t3", ENTORNO["lista.txt"])],
[resultado("t4", listar(ENTORNO, "recetas/"))], None]
modelo, entorno = ModeloGuionizado(guion_de(INICIO, RESPUESTAS, VISTO)), dict(ENTORNO)
print(muestra(ejecutar(RESPUESTAS[0][1], entorno)), "· is_error")
try:
bucle(modelo, INICIO, entorno, 10)
except Exception as e:
print("turno %d: %s: %s" % (modelo.turno, type(e).__name__, e))

The first run downloads the Python interpreter (~15 MB). After that it stays in the browser cache and is reused across every lesson.

La primera línea es el error del turno 1 como observación, con el motivo que ya daba la comprobación. El modelo guionizado la acepta y escribe las llamadas corregidas, y la conversación muere en ese mismo turno 2: leer lanza FileNotFoundError, que atraviesa ejecutar y el bucle, y la lectura de la lista, que habría salido bien, no llega a ejecutarse. Que ese error también vuelva como observación es el desafío de abajo.

La segunda celda deja los errores y mira la repetición. Ejecuta cada herramienta una vez sobre una copia del entorno y dos veces sobre otra, y compara. A las cuatro del bloque les añade una quinta, añadir, que no es del entorno y está sólo para comparar.

# Necesita la celda anterior: ENTORNO y las cuatro funciones, con leer ya completa.
def añadir(entorno, ruta, contenido): # no es del bloque: escribir, pero al final
entorno[ruta] = entorno.get(ruta, "") + contenido
return "añadidos %d caracteres a %s" % (len(contenido), ruta)

def una_y_dos(f, **argumentos): # el entorno tras ejecutar f una vez, y tras dos
una, dos = dict(ENTORNO), dict(ENTORNO)
f(una, **argumentos)
f(dos, **argumentos)
f(dos, **argumentos)
return una, dos

PRUEBAS = [(leer, {"ruta": "lista.txt"}), (listar, {"carpeta": "recetas/"}),
(buscar, {"patron": "patatas"}),
(escribir, {"ruta": "lista.txt", "contenido": "pan, leche, patatas, tomates"}),
(añadir, {"ruta": "lista.txt", "contenido": ", tomates"})]
for f, argumentos in PRUEBAS:
una, dos = una_y_dos(f, **argumentos)
print("%-9s idempotente: %-5s lista.txt tras dos: %s" % (f.__name__, una == dos, dos["lista.txt"]))

The first run downloads the Python interpreter (~15 MB). After that it stays in the browser cache and is reused across every lesson.

Las cuatro del bloque dejan lo mismo con una ejecución que con dos. añadir deja tomates dos veces en la lista. Si el modelo la repitiera sin saber si la primera había llegado a escribir, la lista de la compra acabaría así, y ninguna de las dos llamadas tendría nada mal.

Comprueba tu intuición

Cuatro preguntas (qué hace el harness con un error, cómo se contesta a un turno con un error y un acierto, cuándo un resultado vacío es un error y qué llamadas se pueden repetir) y un desafío: la otra mitad de ejecutar.

El harness ejecuta leer con recetas/salmorejo.txt y la función lanza FileNotFoundError. ¿Qué hace el harness?

Una respuesta trae dos llamadas: a lee un fichero que no existe y b lee lista.txt. ¿Qué mensaje añade el bucle a M?

buscar con el patrón salmorejo devuelve un texto vacío, porque ninguna línea del entorno lo contiene. Su observación debe llevar is_error verdadero.

Imagina una herramienta añadir(ruta, contenido) que pega contenido al final del fichero. ¿Tras cuáles de estas observaciones puede el modelo volver a pedir la llamada, corregida si hace falta, sin riesgo de que el contenido acabe dos veces en el fichero?

Select every correct option. This is graded all-or-nothing: there is no partial credit.

La primera celda convierte en observación el error del esquema, y la conversación muere en el turno 2, cuando leer lanza. Completa ejecutar(ll, entorno) para que no lance nunca.

  • Si la llamada no cumple su esquema, lo que ya hace: no la ejecuta y devuelve un error con "no se ha ejecutado: " y el motivo.
  • Si la cumple y la función lanza una excepción, cualquiera, devuelve error(id, str(e)): is_error verdadero y el mensaje de la excepción como contenido.
  • Si la función devuelve, lo de siempre: resultado(id, lo que devuelve).
  • La función se llama una vez como mucho. Repetir es decisión del modelo.

leer con su contrato completo, error y el bucle de la lección anterior están en el código de partida, y el bucle no se toca. La primera prueba es la conversación de la celda, entera, con el modelo guionizado.

The first run downloads the Python interpreter (~15 MB); after that it stays in the browser cache. This challenge is much easier to solve on a physical keyboard: on a phone, read it and come back later.


ejecutar ya no se rompe. Contesta a cada llamada con lo que pasó, y el modelo sabe de sus equivocaciones tanto como de sus aciertos. Pero ejecuta todo lo que cumple su esquema, y el esquema mira la forma y no la intención: un escribir bien formado que borra la nota de la cena pasa la comprobación y se ejecuta sin que nadie lo pregunte. Y el modelo lee cada observación como información fiable, también cuando trae un fichero que alguien escribió para que lo leyera un modelo.

La lección siguiente, sobre los permisos, pone una decisión antes de ejecutar: permitir, preguntar al usuario o denegar, según lo que la herramienta puede hacer. Y trata lo que vuelve de una herramienta como lo que es, contenido que no ha escrito el usuario, porque el agente no obedece a los ficheros que lee.

Was this helpful?
Further reading3 sources · 1 paper, 2 articles

Where this lesson comes from, and where to go next. None of it is needed to carry on with the course.

  • Reflexion: Language Agents with Verbal Reinforcement Learning
    paperShinn, Cassano, Berman, Gopinath, Narasimhan y Yao, 2023arXiv:2303.11366EN

    Agentes que, tras fallar una tarea, escriben qué salió mal y lo leen en el intento siguiente. Es el error como observación llevado un paso más allá: del turno siguiente al intento siguiente.

  • Writing effective tools for agents — with agents
    articleKen Aizawa (Anthropic), 2025Anthropic EngineeringEN

    Cómo diseñar herramientas para un modelo, con ejemplos de mensajes de error que no ayudan y de otros que dicen qué corregir, que es lo que esta lección le pide al contenido de un error.

  • Designing robust and predictable APIs with idempotency
    articleBrandur Leach, 2017StripeEN

    La idempotencia en una API de pagos: qué hacer cuando una petición se corta y no sabes si llegó a cobrarse. Es el error a mitad de la ejecución, con dinero en lugar de ficheros.