Llamar herramientas: salida estructurada con consecuencias
30 min read
La lección que separó el chat del agente acabó con un harness que acierta sin saber por qué. Para decidir si una respuesta pedía algo, la leía como JSON y buscaba un campo «pide», y lo que salía de ejecutarla se lo devolvía al modelo como un mensaje más del usuario, con un prefijo delante. La petición y la conversación compartían canal, y nada en el formato distinguía la una de la otra.
Esta lección le da a la llamada un formato propio, y el formato es la parte fácil: es una salida estructurada como las del bloque anterior, que el modelo ya sabe escribir. Lo que cambia es el precio de equivocarse. En el asistente sobre Marianela, una respuesta mal formada costaba un reintento. Una llamada mal formada se ejecuta, y a veces no falla: escribe donde no debía y deja el error para la herramienta siguiente. Así que la pregunta tiene dos mitades. Qué forma debe tener una llamada para que el harness la ejecute sin adivinar, y qué tiene que comprobar antes de ejecutarla.
Mira un agente con el formato nuevo antes de nombrar nada. El entorno tiene cuatro ficheros: la lista de la compra, una nota que dice El sábado vienen cuatro a cenar. y dos recetas, la de la tortilla y la del gazpacho, dentro de recetas/. El usuario pide Apunta en notas.txt qué recetas llevan patatas., y el harness le pasa al modelo, además de los mensajes, la descripción de cuatro herramientas: leer, escribir, listar y buscar.
- La respuesta del modelo ya no es un texto, sino una lista de tres cosas. Una frase, Busco «patatas» y leo las notas., y dos peticiones separadas, cada una con su etiqueta: t1 busca patatas en todos los ficheros y t2 lee notas.txt.
- El harness no lee la frase. Sabe cuáles son peticiones porque lo dicen ellas, no porque se parezcan a una. Antes de ejecutar cada una mira que la herramienta exista y que los argumentos tengan la forma declarada: buscar quiere un texto en un campo llamado «patron», y lo tiene.
- Ejecuta las dos y contesta con un solo mensaje que trae dos resultados, cada uno con la etiqueta de su petición. A t1, dos líneas: la de la tortilla y la de la lista de la compra, que también dice patatas. A t2, la nota de la cena.
- Con los dos delante, el modelo pide una sola cosa, t3: reescribir la nota con la línea de la cena y otra que dice que la tortilla lleva patatas. La lista de la compra no es una receta, y no la apunta. El harness comprueba, escribe y contesta a t3.
- La última respuesta es sólo una frase. No hay nada que ejecutar, y el harness para.
Fíjate en lo que ya no hace falta adivinar. Las etiquetas atan cada resultado a su petición aunque lleguen dos a la vez, y la frase del primer turno podría contener un JSON entero sin que nadie lo ejecutara. Y fíjate en de dónde sacó el modelo lo que hizo bien en el cuarto turno. Leyó la nota antes de reescribirla porque escribir sustituye el fichero entero, y eso no lo ha visto: se lo dijo la descripción de la herramienta.
Una herramienta, en dos mitades
La lección anterior llamó herramienta a una operación sobre el entorno que el harness sabe
ejecutar. Con precisión, una herramienta es lo que el modelo recibe de esa operación: un triple
(nombre, descripción, esquema). El nombre es la clave con la que la pedirá. La descripción es una
especificación, en el sentido de la lección que le
dio ese nombre: texto para el modelo que dice qué hace la operación, qué devuelve y qué
destruye. El esquema es un esquema de JSON, como en la salida
estructurada, y fija qué campos llevan los argumentos y de qué tipo es cada uno. Las
herramientas viajan en herramientas, el argumento de la firma que el bloque anterior dejó vacío,
con los nombres de campo del protocolo que el bloque siguiente mandará por la red: name,
description e input_schema.
La otra mitad es la función que hace el trabajo, y esa el modelo no la ve nunca. Es del harness: un diccionario que lleva de cada nombre a una función de Python. El modelo trabaja, por tanto, con lo que la descripción dice y no con lo que la función hace. Una descripción que miente sobre su función es un error que el modelo no puede detectar, porque no tiene otra cosa.
El entorno de este bloque es un diccionario de rutas a contenidos, y sus cuatro herramientas prometen esto:
| Herramienta | Argumentos | Devuelve | Cambia el entorno |
|---|---|---|---|
leer | ruta | el contenido entero del fichero | no |
escribir | ruta, contenido | cuántos caracteres ha escrito | sí: sustituye el fichero entero, o lo crea |
listar | carpeta | las rutas que empiezan por carpeta, en orden, una por línea | no |
buscar | patron | cada línea que contiene patron tal cual, con su ruta delante | no |
Los argumentos son siempre texto, y todos obligatorios. Una carpeta no existe por separado: es el
principio común de unas rutas, y recetas/ es el de dos. Tres herramientas sólo leen; la
única que escribe destruye lo que hubiera, y por eso su descripción lo dice. Qué pasa cuando
leer recibe una ruta que no existe queda fuera del contrato por ahora, y lo resuelve más adelante
la lección sobre los errores.
La llamada y la observación, unidas por un identificador
La respuesta del modelo pasa a ser una lista de bloques de contenido. (Desde aquí, un bloque es eso, y no una parte del curso como este bloque 4.) Hay tres tipos, y el curso no necesitará más:
{"type": "text", "text": "Busco «patatas» y leo las notas."}
{"type": "tool_use", "id": "t1", "name": "buscar", "input": {"patron": "patatas"}}
{"type": "tool_result", "tool_use_id": "t1", "content": "lista.txt: pan, …", "is_error": False}Un bloque tool_use es una llamada: id la identifica, name dice qué herramienta e input lleva
los argumentos. Un bloque tool_result es una observación: tool_use_id dice a qué llamada
contesta, content es lo que devolvió la función e is_error dice si cuenta un fallo. Las
llamadas viajan en la respuesta del modelo. Las observaciones, en un mensaje de rol usuario,
porque el protocolo no tiene más voces que el sistema, el usuario y el asistente, pero en bloques
que no son de texto. El identificador lo pone quien sirve el modelo (en las celdas, el guion), y el
harness lo copia sin inventar nunca uno.
El contrato del harness con el modelo son cuatro reglas, y valen en cada turno:
- Sólo se ejecutan los bloques
tool_use. Un bloque de texto no es una petición, diga lo que diga. - La respuesta entra entera en los mensajes, texto incluido, como mensaje del asistente.
- Cada
tool_userecibe exactamente untool_resultcon suid, todos en el mensaje que sigue a la respuesta y antes de cualquier otra cosa en él. - El modelo no se vuelve a llamar hasta que se cumple la regla 3.
Juntas dan el invariante que usará el resto del curso: cuando el harness llama al modelo, ninguna
llamada de la conversación está sin su observación. Un protocolo de verdad rechaza la petición que
lo incumple.
Lo que ata una observación a su llamada es el identificador, no la posición. El harness de las
celdas escribe las observaciones en el orden de las llamadas de todos modos, y el modelo guionizado
lo comprueba, porque compara mensajes enteros. En esta lección is_error es siempre falso; para qué
sirve el verdadero lo cuenta la lección sobre los errores.
Antes de ejecutar, el esquema
Una llamada es una salida estructurada, y la gramática que debe cumplir es el esquema de su
herramienta. El harness la comprueba antes de ejecutarla, y la comprobación es exacta. Una llamada
cumple cuando su name es el de una herramienta declarada, su input es un objeto, tiene cada
campo de required, no tiene ninguno fuera de properties (lo prohíbe
additionalProperties: false) y cada valor es del tipo que declara su campo.
Una llamada que no cumple no se ejecuta. Qué le dice entonces el harness al modelo es, otra vez, la
lección sobre los errores; hasta ella, el harness lanza ValueError y el programa se para.
¿Por qué comprobar, si el modelo sabe escribir JSON? Por lo que el bloque anterior separó entre pedir y forzar. Los modelos actuales están ajustados para escribir llamadas en este formato y aciertan casi siempre, pero eso es pedir. Forzar es la máscara con el esquema como gramática, y la máscara actúa sobre los logits, que sólo tiene quien sirve el modelo. Algunos la aplican si se lo pides; un harness que llama a un modelo servido por otros no puede, y sólo ve lo que llega. Y aunque llegue forzada, comprueba igual, porque lo que ejecuta es responsabilidad suya.
El esquema, en cambio, no ve la intención. Un escribir sobre notas.txt cuyo contenido
fuera sólo Llevan patatas: tortilla. cumpliría cada regla y borraría la cena del sábado. La
comprobación garantiza que la llamada se puede ejecutar. Si se debe ejecutar es otra pregunta, la
del permiso, que llega más adelante en el bloque.
Cuatro herramientas y una conversación con bloques
Las dos celdas corren sobre el modelo guionizado, como todo el bloque. Su guion trae las respuestas
escritas de antemano, ya en bloques, así que recibe herramientas y no lo lee; un modelo de verdad
sabría de cada herramienta exactamente lo que pone ahí. La primera celda escribe el entorno, las cuatro funciones y las cuatro
herramientas que las describen. Después hace, con las funciones y sin comprobar nada, tres llamadas
que un modelo podría escribir mal, y una cuarta que no tiene nada de malo.
ENTORNO = {"lista.txt": "pan, leche, patatas",
"notas.txt": "El sábado vienen cuatro a cenar.",
"recetas/gazpacho.txt": "Gazpacho: tomates, pepino, pimiento, ajo.",
"recetas/tortilla.txt": "Tortilla: huevos, patatas, cebolla."}
def leer(entorno, ruta): # las funciones son del harness: el modelo no las ve
return entorno[ruta]
def escribir(entorno, ruta, contenido):
entorno[ruta] = contenido
return "escritos %d caracteres en %s" % (len(contenido), ruta)
def listar(entorno, carpeta):
return "\n".join(r for r in sorted(entorno) if r.startswith(carpeta))
def buscar(entorno, patron):
return "\n".join("%s: %s" % (r, linea) for r in sorted(entorno)
for linea in entorno[r].split("\n") if patron in linea)
FUNCIONES = {"leer": leer, "escribir": escribir, "listar": listar, "buscar": buscar}
def objeto(**campos): # un esquema: estos campos, de texto y obligatorios
return {"type": "object", "required": list(campos), "additionalProperties": False,
"properties": {c: {"type": "string", "description": d} for c, d in campos.items()}}
HERRAMIENTAS = [ # lo que lee el modelo: nombre, descripción, esquema
{"name": "leer", "description": "Devuelve el contenido entero de un fichero.",
"input_schema": objeto(ruta="la ruta del fichero")},
{"name": "escribir", "description": "Sustituye el contenido entero de un fichero, o lo "
"crea si no existe. Lo que tuviera se pierde.",
"input_schema": objeto(ruta="la ruta del fichero", contenido="el contenido nuevo, entero")},
{"name": "listar", "description": "Las rutas que empiezan por carpeta, una por línea.",
"input_schema": objeto(carpeta="un principio de ruta, como recetas/; vacío, todas")},
{"name": "buscar", "description": "Las líneas que contienen patron tal cual, con su ruta.",
"input_schema": objeto(patron="el texto que buscar")}]
print(json.dumps(HERRAMIENTAS[0], ensure_ascii=False, indent=1), "\n")
entorno = dict(ENTORNO)
print(buscar(entorno, "patatas"), "\n")
for nombre, argumentos in [("borrar", {"ruta": "notas.txt"}), # no hay tal herramienta
("leer", {}), # falta la ruta
("escribir", {"ruta": 3, "contenido": "huevos"}), # 3 no es ruta
("listar", {"carpeta": ""})]:
try:
print(nombre, "->", FUNCIONES[nombre](entorno, **argumentos))
except Exception as e:
print(nombre, "->", 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 herramienta que no existe es un KeyError del diccionario de funciones, y la ruta que falta,
un TypeError: las dos paran el programa con un mensaje que habla de Python y no de la llamada. La tercera es la peor,
porque no falla. escribir acepta un 3 como ruta y crea un fichero que se llama 3. El error
aparece en la llamada siguiente, la de listar, que no puede ordenar un número entre rutas, y nada
en su mensaje apunta a la escritura que lo causó. El esquema habría parado las tres antes de tocar
nada.
La segunda celda es la traza del principio, con un harness que sigue las cuatro reglas. No comprueba nada, porque las llamadas del guion las he escrito yo y cumplen, y da tres turnos como mucho porque sé cuántos hay. La comprobación es el desafío de abajo.
exec(open_url("/courses/llm-agents/guionizado.py").read()) # ModeloGuionizado
# Necesita la celda anterior: ENTORNO, las cuatro funciones, FUNCIONES y HERRAMIENTAS.
def texto(t):
return {"type": "text", "text": t}
def llamada(id, nombre, **argumentos):
return {"type": "tool_use", "id": id, "name": nombre, "input": argumentos}
def resultado(id, contenido):
return {"type": "tool_result", "tool_use_id": id, "content": contenido, "is_error": False}
NOTAS = "El sábado vienen cuatro a cenar.\nLlevan patatas: tortilla."
INICIO = [("sistema", "Trabajas con los ficheros del usuario mediante tus herramientas."),
("usuario", "Apunta en notas.txt qué recetas llevan patatas.")]
RESPUESTAS = [ # las tres respuestas del modelo guionizado: las he escrito yo
[texto("Busco «patatas» y leo las notas."), llamada("t1", "buscar", patron="patatas"),
llamada("t2", "leer", ruta="notas.txt")],
[llamada("t3", "escribir", ruta="notas.txt", contenido=NOTAS)],
[texto("Hecho: he añadido a notas.txt que la tortilla lleva patatas.")]]
VISTO = [[resultado("t1", buscar(ENTORNO, "patatas")), resultado("t2", ENTORNO["notas.txt"])],
[resultado("t3", "escritos %d caracteres en notas.txt" % len(NOTAS))], None]
guion, M = [], INICIO # el guion: los mensajes que espera en cada turno
for r, v in zip(RESPUESTAS, VISTO):
guion, M = guion + [(M, r)], M + [("asistente", r), ("usuario", v)]
def muestra(b): # un bloque, en una línea
if b["type"] == "text":
return "text " + b["text"]
if b["type"] == "tool_use":
return "tool_use %s %s %s" % (b["id"], b["name"], json.dumps(b["input"], ensure_ascii=False))
return "tool_result %s %s" % (b["tool_use_id"], b["content"].replace("\n", " / "))
modelo, entorno, M = ModeloGuionizado(guion), dict(ENTORNO), INICIO
for turno in range(1, 4): # tres turnos: sé cuántos he escrito
r = modelo(M, HERRAMIENTAS) # una lista de bloques
obs = [resultado(b["id"], FUNCIONES[b["name"]](entorno, **b["input"]))
for b in r if b["type"] == "tool_use"] # sólo se ejecutan los tool_use
print("\n".join("turno %d %s" % (turno, muestra(b)) for b in r + obs))
M = M + [("asistente", r)] # la respuesta entra entera
if not obs:
break
M = M + [("usuario", obs)] # una observación por llamada, con su id
print("\nnotas.txt:", entorno["notas.txt"])
The first run downloads the Python interpreter (~15 MB). After that it stays in the browser cache and is reused across every lesson.
Mira el turno 1: dos tool_use en una respuesta y dos tool_result en el mensaje siguiente, cada
uno con el id de su llamada. El texto del principio se queda en la conversación, para que el
modelo lea en el turno 2 lo que dijo, y no se ejecuta. Cámbialo tú: en la línea que fabrica las
observaciones, pon "t1" en lugar de b["id"], y mira en qué turno se queja el modelo guionizado.
Un protocolo de verdad también rechazaría la conversación, porque t2 se queda sin su
observación.
Comprueba tu intuición
Cuatro preguntas (qué sabe el modelo de una herramienta, qué para el esquema, cómo se contesta a dos llamadas y quién puede forzar un esquema) y un desafío: la comprobación que la segunda celda se salta.
En la segunda celda, ¿qué sabe el modelo de la herramienta escribir?
Con las cuatro herramientas de la lección, ¿cuáles de estas llamadas no pasan la comprobación del esquema?
Select every correct option. This is graded all-or-nothing: there is no partial credit.
La respuesta del modelo trae tres bloques: un texto, un tool_use de id a7 que lee un fichero y otro de id b2 que lista una carpeta. ¿Qué manda el harness antes de volver a llamar al modelo?
Tu harness llama a un modelo que sirve otra empresa y le pasa las herramientas con sus esquemas. ¿Puede garantizar, con la máscara de la generación restringida, que cada llamada cumple su esquema?
La segunda celda ejecuta las llamadas sin comprobar nada, porque las del guion cumplen. Escribe la parte del turno que le toca al harness, con la comprobación: observaciones(respuesta, entorno) recibe la respuesta del modelo, una lista de bloques, y devuelve la lista de bloques tool_result del mensaje siguiente. Las funciones, HERRAMIENTAS y las que fabrican bloques están en el código de partida.
- Un
tool_resultpor cadatool_use, en el orden de las llamadas, con suidcomotool_use_id, lo que devuelve la función comocontenteis_erroraFalse. - Los bloques de texto no se ejecutan nunca. Una respuesta sin llamadas devuelve
[]. - Antes de ejecutar una llamada, compruébala contra su herramienta en
HERRAMIENTAS: el nombre está declarado,inputes un diccionario, no falta ningún campo derequired, no sobra ninguno fuera depropertiesy cada valor es del tipo que dice su campo. Si no cumple, lanzaValueErrorsin ejecutarla.
La primera prueba es una conversación con el modelo guionizado, que falla si tus observaciones no son las que espera.
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.
La conversación de la celda ya tiene la forma que tendrá en el resto del curso: el modelo pide en
bloques, y el harness comprueba, ejecuta y contesta a cada llamada por su identificador. Lo que la
sostiene sigue siendo un for que da tres vueltas porque yo sé que el guion tiene tres respuestas,
y que entre una vuelta y la siguiente no se pregunta nada. Un harness que no conoce el guion tiene
que saber cuándo ha terminado el modelo, qué hace si no termina nunca y qué ve el modelo en cada
llamada.
La lección siguiente, sobre el bucle, escribe ese for como un algoritmo con su contrato: las
condiciones con las que para, el tope de vueltas y lo que guarda en cada una. Todas las piezas que
entran en él están ya en esta lección.
Further reading4 sources · 1 paper, 3 articles
Where this lesson comes from, and where to go next. None of it is needed to carry on with the course.
- Toolformer: Language Models Can Teach Themselves to Use Tools
Un modelo que aprende solo dónde insertar una llamada a una calculadora o un buscador, y a usar lo que vuelve. Las llamadas aún van dentro del texto, como en la lección anterior: es el paso justo antes de los bloques.
- Function calling and other API updates
El anuncio que convirtió las herramientas en parte de la API: funciones descritas con un esquema de JSON y un modelo ajustado para llamarlas. Es el formato de esta lección en su primera versión pública.
- Handle tool calls
Los bloques `tool_use` y `tool_result` de la lección, con las reglas de un protocolo real: la observación va justo después de la llamada y antes que cualquier texto, y qué pasa si no.
- JSON Schema reference: object
`properties`, `required` y `additionalProperties`, las tres palabras del esquema que comprueba el desafío, con ejemplos que pasan y que no. El resto de la referencia es lo que un esquema de verdad puede pedir además.