El bucle
30 min de lectura
Desde la lección sobre llamar herramientas, un turno
del harness está resuelto de principio a fin: el modelo pide en bloques tool_use, el harness
comprueba cada uno contra el esquema de su herramienta, lo ejecuta y contesta con un tool_result
que lleva el mismo identificador. Lo que falta es el paso de un turno al siguiente. Allí lo daba
un for con el número de turnos escrito a mano, y ese número sólo lo conoce quien escribió el
guion.
Escrito, el bucle de un agente ocupa seis líneas, y las bibliotecas que construyen agentes suelen esconderlo detrás de un solo método. Por eso parece que no hay nada que pensar. Hay tres cosas, y ninguna se ve en el código. Quién decide que el trabajo ha terminado, si no es el harness. Qué impide que no termine nunca, si el modelo puede pedir sin parar. Y qué sabe el modelo en cada vuelta, si entre dos llamadas no guarda nada. Esta lección escribe el bucle con su contrato, que es la respuesta a las tres.
Mira el bucle trabajar antes de escribirlo. El entorno es el de la lección anterior: la lista de la compra, una nota y dos recetas dentro de recetas/. El usuario pide Añade a lista.txt lo que falta para las recetas., y el modelo, que no sabe cuántas recetas hay, empieza por preguntarlo. Avanza paso a paso y fíjate en tres cosas: cuándo crece M, la lista de mensajes; qué cambia en el entorno; y qué recibe el modelo cada vez que se le llama, que es lo que marca el botón «Lo que ve el modelo».
El modelo recibe 2 mensajes en el turno 1, 4 en el 2, 6 en el 3 y 8 en el 4. Cada turno que pide algo deja dos mensajes en M, su respuesta y sus observaciones, aunque pida tres lecturas a la vez como en el turno 2. Y en ningún paso recibe el entorno: lo que sabe de las recetas es lo que trajeron las observaciones de t2 y t3.
Fíjate también en lo que nadie ha escrito. Ningún código dice que hagan falta cuatro turnos: hasta que vuelve la observación de t1, con dos rutas, ni el harness ni el modelo saben cuántas recetas hay que leer. Y ninguna línea del harness decide que el trabajo está hecho. El bucle termina porque la cuarta respuesta no pide nada, y esa decisión es del modelo. Prueba ahora un tope de 3: el bucle para después de reescribir la lista, con el entorno ya cambiado y sin una palabra para el usuario.
El bucle y lo que promete
Con precisión, el bucle de un agente es este algoritmo, donde M es la lista de mensajes, el
estado del bucle, y tope el número máximo de turnos:
M = [sistema, usuario]
repetir como mucho tope veces:
r = modelo(M, herramientas)
M.append(asistente: r)
si r no contiene ningún tool_use:
devolver fin, el texto de r
M.append(usuario: [ejecutar(ll) para cada tool_use ll de r])
devolver topeCada vuelta es un turno. ejecutar es lo que hacía el harness en la lección anterior: comprueba
la llamada contra su esquema, ejecuta la función y devuelve su tool_result. Este bucle no cambia
en el resto del curso. Las lecciones que quedan del bloque cambian ejecutar y lo que la rodea, y
el bloque siguiente cambia modelo.
Tres maneras de parar
La primera es que la respuesta no pide nada. Es el final normal, y lo decide el modelo. El harness no podría decidirlo: no entiende el pedido del usuario, sólo lo transporta. La consecuencia es que un modelo que cree haber terminado antes de tiempo termina el bucle antes de tiempo, y ninguna línea del harness lo detecta.
La segunda es el tope, y la decide el harness. Es la única garantía de que el bucle termina
haga lo que haga el modelo, porque un modelo puede pedir sin fin: repetir una lectura que ya
tiene, insistir en una herramienta que no le da lo que busca. Al llegar, el bucle ejecuta las
llamadas del último turno y añade sus observaciones antes de salir, así que M queda con cada
llamada contestada. Lo que no hace es deshacer: lo que esas llamadas cambiaron se queda cambiado,
y el harness tiene que decirle al usuario que el agente no terminó. Para eso devuelve tope y no
un texto. El número se elige entre dos costes. Debe bastar para la tarea más larga que aceptes, y
cada turno de más es una llamada al modelo, que cuesta tiempo y, con un modelo servido por otros,
dinero.
La tercera es que el modelo deje de escribir por otra razón. El protocolo del bloque siguiente
dice, junto a cada respuesta, por qué paró el modelo (stop_reason): tool_use si espera
observaciones, end_turn si ha terminado y max_tokens si se le acabó la reserva. Las dos
primeras son lo que el bucle ya lee en los bloques. La tercera es una respuesta cortada, que puede
no traer ningún tool_use sin estar terminada, o acabar en una llamada con los argumentos a
medias. El bucle no puede tomarla por final ni ejecutarla. Como el modelo del curso devuelve sólo
los bloques, avisar del corte le toca a él: lanza una excepción en lugar de devolver, y el bucle se
para ahí. El modelo guionizado no la lanza nunca, porque sus respuestas están escritas enteras.
Lo que ve el modelo
Tres invariantes, que valen en cada turno.
El modelo sólo ve M. No guarda nada de una llamada a la siguiente: lo que sabe de lo que pidió y de lo que pasó está en M o no está en ninguna parte. Por eso M lo carga todo. Cada respuesta entra entera, porque en el turno siguiente el modelo tiene que leer lo que dijo, y cada observación entra, porque es la única forma de que sepa qué devolvieron sus llamadas.
Ninguna llamada sin su observación. Cuando el bucle llama al modelo, cada tool_use de M
tiene su tool_result en el mensaje siguiente. Es el invariante de la lección anterior, y ahora lo
mantiene el bucle, también al salir por el tope.
M sólo crece. El bucle añade y nunca reescribe ni quita, así que el M de cada turno es un principio del M final, y en el turno el modelo recibe mensajes. (Quitar mensajes cuando ya no caben en la ventana de contexto es la compactación, del bloque siguiente.)
Los tres se rompen juntos. Un harness que olvida añadir las observaciones llama al modelo con un
tool_use sin contestar, y el protocolo rechaza la petición. Uno que no añade nada llama siempre
con los mismos mensajes, y un modelo de verdad, que no recuerda haber pedido nada, lo vuelve a
pedir: el bucle da vueltas hasta el tope, ejecutando cada vez la misma llamada. El tope no arregla
ese error, pero impide que dure para siempre.
El bucle, sobre el modelo guionizado
Las dos celdas corren sobre el modelo guionizado, como todo el bloque. El entorno, las cuatro
herramientas, las funciones que fabrican bloques y la comprobación del esquema son los de la
lección anterior, y ahora se cargan con una línea de un fichero, entorno.py, como la clase del
modelo. La primera celda escribe ejecutar y el bucle, todavía sin tope, y lo corre sobre la
conversación del widget. Las observaciones que espera el guion las calculo yo, ejecutando sus
llamadas en orden sobre una copia del entorno: es lo que hace un harness correcto, y el modelo
guionizado falla si el bucle hace otra cosa.
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 ejecutar(ll, entorno): # una llamada: su esquema, y después la función
motivo = incumple(ll)
if motivo is not None:
raise ValueError(motivo) # hasta la lección sobre los errores
return resultado(ll["id"], FUNCIONES[ll["name"]](entorno, **ll["input"]))
def bucle(modelo, inicio, entorno):
M = list(inicio) # el estado del bucle: los mensajes
while True: # sin tope, todavía
r = modelo(M, HERRAMIENTAS)
M.append(("asistente", r)) # la respuesta entra entera
llamadas = [b for b in r if b["type"] == "tool_use"]
if not llamadas: # nada que ejecutar: ha terminado
return " ".join(b["text"] for b in r if b["type"] == "text"), M
M.append(("usuario", [ejecutar(ll, entorno) for ll in llamadas]))
INICIO = [("sistema", "Trabajas con los ficheros del usuario mediante tus herramientas."),
("usuario", "Añade a lista.txt lo que falta para las recetas.")]
LISTA = "pan, leche, patatas, tomates, pepino, pimiento, ajo, huevos, cebolla"
RESPUESTAS = [ # las cuatro respuestas del modelo guionizado: las he escrito yo
[texto("Miro qué recetas hay."), llamada("t1", "listar", carpeta="recetas/")],
[llamada("t2", "leer", ruta="recetas/gazpacho.txt"),
llamada("t3", "leer", ruta="recetas/tortilla.txt"), llamada("t4", "leer", ruta="lista.txt")],
[llamada("t5", "escribir", ruta="lista.txt", contenido=LISTA)],
[texto("He añadido a lista.txt tomates, pepino, pimiento, ajo, huevos y cebolla.")]]
e = dict(ENTORNO) # lo que el guion espera ver: sus llamadas, ejecutadas en orden
GUION = guion_de(INICIO, RESPUESTAS, [[ejecutar(b, e) for b in r if b["type"] == "tool_use"]
for r in RESPUESTAS])
modelo, entorno = ModeloGuionizado(GUION), dict(ENTORNO)
final, M = bucle(modelo, INICIO, entorno)
for i, (rol, contenido) in enumerate(M): # M entero, mensaje a mensaje
lineas = [contenido] if isinstance(contenido, str) else [muestra(b) for b in contenido]
print("%d %-10s" % (i, rol) + ("\n" + " " * 13).join(lineas))
print("\n%d turnos: %s" % (modelo.turno, final))
print("lista.txt:", entorno["lista.txt"])
La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador y se reutiliza en todas las lecciones.
M sale entero, con los nueve mensajes del widget, y el bucle devuelve el texto de la cuarta respuesta sin que nada en él cuente turnos. Cámbialo tú: borra la línea que añade las observaciones y mira en qué turno se queja el modelo guionizado y qué mensaje dice que no es el del guion. Un protocolo de verdad también rechazaría ese turno, por la llamada t1 sin su observación.
La segunda celda hace dos cosas con el mismo bucle. Primero lo corre con el modelo envuelto en una función que apunta cuántos mensajes recibe en cada turno, para ver el tercer invariante en números. Después le da un guion de doce respuestas que piden todas lo mismo, la lista de las recetas, sin que ninguna termine: lo que haría un modelo atascado.
vistos = []
def mirado(M, herramientas): # el mismo modelo, apuntando cuánto recibe
vistos.append(len(M))
return modelo(M, herramientas)
modelo = ModeloGuionizado(GUION)
bucle(mirado, INICIO, dict(ENTORNO))
print("mensajes que recibe el modelo en cada turno:", vistos)
PIDE = [[llamada("t%d" % i, "listar", carpeta="recetas/")] for i in range(1, 13)]
e = dict(ENTORNO) # un guion de doce respuestas que piden lo mismo
terco = ModeloGuionizado(guion_de(INICIO, PIDE, [[ejecutar(r[0], e)] for r in PIDE]))
try:
bucle(terco, INICIO, dict(ENTORNO))
except RuntimeError as error:
print("terco:", error, "· turnos:", terco.turno)
La primera ejecución descarga el intérprete de Python (~15 MB). Después queda en la caché del navegador y se reutiliza en todas las lecciones.
Los mensajes salen 2, 4, 6 y 8. El guion terco acaba con un RuntimeError en el turno 12, con
doce llamadas ejecutadas, y no porque el bucle haya decidido nada: se ha quedado sin guion. Un
modelo de verdad no se queda sin respuestas, y con él este bucle no terminaría nunca. Si en lugar
de listar pidiera escribir, cada vuelta reescribiría el fichero. El tope que lo impide es el
desafío de abajo.
Comprueba tu intuición
Cinco preguntas (quién termina el bucle, cuánto crece M, qué pasa si no crece, qué deja un tope y cuántos mensajes añade un turno) y un desafío: el tope que le falta a la primera celda.
En el widget, con el tope de 10, ¿qué hace que el bucle termine en el turno 4?
Un modelo hace una sola llamada por respuesta y termina, con un texto, en el turno 6. ¿Cuántos mensajes tiene M cuando el bucle devuelve?
Se acepta un margen de ±0.
Un harness llama al modelo, ejecuta lo que pide y no añade nada a M. ¿Qué pasa con un modelo de verdad?
En el widget, con un tope de 3, el bucle para en el turno 3. ¿Qué es cierto en ese momento?
Marca todas las opciones correctas. Se corrige todo o nada: no hay puntuación parcial.
El turno 2 de la traza pide tres lecturas, así que añade cuatro mensajes a M: la respuesta y una observación por llamada.
La primera celda escribe el bucle sin tope, y la segunda enseña lo que cuesta. Escribe bucle(modelo, inicio, entorno, tope) con el contrato entero de la lección. ejecutar y todo lo que carga entorno.py están en el código de partida.
Mempieza como una copia deinicio, que es del que llama y no se toca.- Cada turno llama a
modelo(M, HERRAMIENTAS)y añade la respuesta entera aM, como("asistente", r). - Si la respuesta no trae ningún
tool_use, devuelve("fin", texto, M), con el texto de sus bloquestextunidos por un espacio. - Si trae llamadas, añade un solo
("usuario", [...])con lo que devuelveejecutarpara cada una, en el orden de las llamadas, y vuelve a empezar. - Como mucho
topellamadas al modelo. Si la respuesta del último turno aún pide algo, ejecuta sus llamadas, añade sus observaciones y devuelve("tope", None, M).
La primera prueba es una conversación con el modelo guionizado; las demás miran qué mensajes recibe el modelo en cada turno.
La primera comprobación descarga el intérprete de Python (~15 MB); después queda en la caché del navegador. Este desafío se resuelve mejor con un teclado físico: en el móvil puedes leerlo y volver luego.
El bucle ya tiene la forma que tendrá en el resto del curso, y sus garantías no dependen del
modelo: termina, cada llamada recibe su observación y el modelo sabe lo que ha pasado porque está
en M. Lo que no garantiza es llegar vivo al final de un turno. ejecutar lanza ValueError con
una llamada que no cumple su esquema, leer lanza KeyError con una ruta que no existe, y
cualquiera de las dos atraviesa el bucle y para el programa. El usuario recibe un error de Python,
y el modelo, que podría haber pedido otra ruta, no se entera de nada.
La lección siguiente, sobre los errores como
observaciones, cambia sólo ejecutar: un fallo deja
de ser una excepción y pasa a ser un tool_result con is_error, que el modelo lee como cualquier
otra observación. Su guion, por primera vez, trae llamadas equivocadas.
Para profundizar2 fuentes · 1 paper, 1 artículo
De dónde sale lo de esta lección, y dónde seguir si quieres más. Nada de aquí hace falta para continuar el curso.
- WebGPT: Browser-assisted question-answering with human feedback
GPT-3 ajustado para buscar y navegar en un navegador de texto: escribe una orden, lee lo que vuelve y decide la siguiente, hasta que responde. Es el bucle de esta lección, con las órdenes todavía dentro del texto.
- Stop reasons and fallback
Los valores de `stop_reason` de un protocolo real y qué hacer con cada uno, incluida una llamada cortada por `max_tokens`. Su bucle de ejemplo no tiene tope: compáralo con el de la lección.