Llamar herramientas: salida estructurada con consecuencias

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.

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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:

HerramientaArgumentosDevuelveCambia el entorno
leerrutael contenido entero del ficherono
escribirruta, contenidocuántos caracteres ha escritosí: sustituye el fichero entero, o lo crea
listarcarpetalas rutas que empiezan por carpeta, en orden, una por líneano
buscarpatroncada línea que contiene patron tal cual, con su ruta delanteno

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:

  1. Sólo se ejecutan los bloques tool_use. Un bloque de texto no es una petición, diga lo que diga.
  2. La respuesta entra entera en los mensajes, texto incluido, como mensaje del asistente.
  3. Cada tool_use recibe exactamente un tool_result con su id, todos en el mensaje que sigue a la respuesta y antes de cualquier otra cosa en él.
  4. 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.

import json

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.

from pyodide.http import open_url
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_result por cada tool_use, en el orden de las llamadas, con su id como tool_use_id, lo que devuelve la función como content e is_error a False.
  • 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, input es un diccionario, no falta ningún campo de required, no sobra ninguno fuera de properties y cada valor es del tipo que dice su campo. Si no cumple, lanza ValueError sin 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.

Was this helpful?
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
    paperSchick, Dwivedi-Yu, Dessì, Raileanu, Lomeli, Zettlemoyer, Cancedda y Scialom, 2023arXiv:2302.04761EN

    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
    articleOpenAI, 2023OpenAIEN

    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
    articleAnthropicDocumentación de la API de ClaudeEN

    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
    articleJSON Schemajson-schema.orgEN

    `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.