El bucle con un modelo real
25 min read
Las dos mitades ya existen. Hay un servidor con un modelo cargado, que en la lección que preparó la máquina contestó a un turno montado a mano, y hay un agente, el del bloque anterior, que pasa su batería de tests sobre un modelo guionizado. Entre las dos queda un problema de formato y nada más: el agente guarda M como tuplas de Python, y el servidor lee el JSON del protocolo. Esta lección escribe la pieza que pasa de lo uno a lo otro, la pone donde estaba el modelo guionizado y deja que el harness corra solo.
¿Qué más hay que tocar? La respuesta que defiende la lección es que nada, y no es una respuesta cómoda. Contra el guion, el harness sólo recibía respuestas que alguien había escrito para que encajaran. Un modelo de verdad puede pedir una herramienta que no existe, mezclar texto y llamadas, quedarse sin reserva a mitad de una llamada, contestar en otro idioma o dar por hecha una tarea que no ha hecho. Si el harness aguanta todo eso sin cambiar una línea, hay que ver por qué. Y hay que ver qué deja de saber quien lo usa.
Esta es una ejecución entera del agente, con el pedido de la tortilla, sobre el modelo local. Cada turno dice cuánto tardó el modelo y cuántos tokens llevaba la petición; las flechas son lo que devuelve el modelo (←) y lo que el harness le contesta en el turno siguiente (→):
turno 1 15 s 460 tokens de entrada
← llamada leer {"ruta": "lista.txt"}
→ observación pan, leche, patatas
turno 2 10 s 501 tokens de entrada
← llamada buscar {"patron": "tortilla"}
→ observación
turno 3 19 s 535 tokens de entrada
← llamada escribir {"ruta": "lista.txt", "contenido": "pan, leche, patatas, huevos, cebolla"}
→ error no se ha ejecutado: el usuario no lo ha permitido
turno 4 13 s 603 tokens de entrada
← texto Lo siento, no pude realizar la acción solicitada. ¿Hay otra forma en la que puedo ayudarte?
fin · 9 mensajes en MEl modelo empieza bien: lee la lista. Después busca la receta con tortilla, y buscar
no encuentra nada, porque compara tal cual y el fichero dice Tortilla:, con mayúscula. Lo
que hace a continuación no lo ha escrito nadie. No lista la carpeta ni prueba otra búsqueda:
escribe la lista con huevos y cebolla, los dos ingredientes que faltan, sacados de lo que sabe de
tortillas y no de ninguna observación. Esta vez acierta. La escritura se deniega, porque en esta
ejecución no hay usuario a quien preguntar, y el modelo cierra con una disculpa que no dice qué ha
pasado.
Ahora mira la traza con el contrato del agente delante. Cada llamada ha tenido su observación en el mensaje siguiente. La escritura no ha cambiado el entorno. El bucle ha terminado, en cuatro turnos. Ninguna de las cinco garantías ha fallado, y el pedido no se ha hecho. Lo que ha fallado está en la otra lista, la de lo que el agente no promete: que la tarea se haga y que el texto final diga la verdad sobre el entorno.
El adaptador y lo que promete
El adaptador es la pieza que implementa la firma de modelo hablando con un servidor:
ModeloAPI(nombre="local", max_tokens=1024)
modelo(mensajes, herramientas) -> respuestanombre es el modelo que el servidor tiene cargado y max_tokens la reserva de cada respuesta. Una
instancia es una función con la firma de siempre, y su contrato tiene cinco cláusulas:
- Traduce M sin tocar el contenido. El contenido de los mensajes
sistemava al camposystem, unido y en su orden. Cada uno de los demás pasa amessages, en el mismo orden, con el rol en inglés (user,assistant) y su contenido tal cual, cadena o lista de bloques. Las herramientas van atoolssin cambios. Es la tabla de la lección anterior, y nada más. - Devuelve la respuesta tal como llega: el campo
content, una lista de bloques que el bucle añade a M sin copiarla ni reescribirla. Los ids de las llamadas los pone el servidor, y eltool_resultde cada una copia el suyo. - Nunca devuelve una respuesta cortada. Si
stop_reasonesmax_tokens, lanza una excepción. Es lo que prometió la lección del bucle: el bucle decide por los bloques, y una respuesta cortada puede no traer ningúntool_usesin estar terminada, o traer uno con los argumentos a medias. Fuera de ese caso,stop_reasonno se mira. - Un error del servidor es una excepción con su motivo. Una petición que no cabe en la
ventana de contexto (un 400), un modelo que el servidor no tiene (un 404) o un servidor apagado
salen de
modelo, y por tanto del bucle, que sólo deja salir eso. El texto que da el servidor va en la excepción, para que sepas qué ha pasado. - No recuerda nada entre llamadas. Guarda el
usagede la última petición para quien quiera mirarlo, y la siguiente no lo lleva. Como el servidor, cada vez recibe M entero.
La quinta es la que hace que el cambio sea un cambio de pieza y no una reescritura. El modelo
guionizado tampoco recordaba nada que no estuviera en M, aparte de su puntero, así que el bucle no
puede notar la diferencia. bucle.py, permisos.py, agente.py y entorno.py no cambian un
byte, y el agente sobre el modelo local es agente(ModeloAPI(), pedido, entorno).
Lo que el guion nunca hacía
Que el bucle no cambie no quiere decir que no tenga trabajo nuevo. Un modelo de verdad devuelve cosas que ningún guion del bloque anterior devolvía, y cada una cae en una garantía distinta, o en ninguna:
| Lo que devuelve el modelo | Quién lo recoge |
|---|---|
| texto y llamadas en la misma respuesta | el bucle: hay llamadas, así que sigue; el texto se queda en M |
| una llamada a una herramienta que no existe, o con un argumento de más | ejecutar: no cumple el esquema, vuelve como error y el modelo lo ve |
| una respuesta cortada por la reserva | el adaptador: lanza una excepción |
| una respuesta sin llamadas, en inglés, que no ha mirado el entorno | nadie: es una respuesta sin llamadas, y el bucle devuelve fin |
| un texto final que no cuenta lo que ha pasado | nadie: es lo que el agente no promete |
Las tres primeras filas son el contrato trabajando. Las dos últimas son trabajo para el resto del bloque, que va añadiendo capas al harness para que pasen menos.
Y hay una diferencia que no está en ninguna fila: el modelo local sortea cada token, así que el mismo pedido puede dar dos ejecuciones distintas. Por eso los tests del agente se quedan sobre el guion. Un test fija el modelo para que, si no pasa, la culpa sólo pueda ser del harness; lo que hace el modelo con el harness no se prueba, se mide, y eso lo hace un banco de pruebas, más adelante en el bloque.
El adaptador, en Python
El código parte del punto de control en que terminó la lección anterior:
CheckpointAl empezar esta lecciónTodo lo que sigue corre contra un modelo local, el del aviso del principio, y añade tres ficheros sin tocar
ninguno de los que ya tienes. El primero es api.py. Su mitad de arriba son la traducción de la
primera cláusula y el envío, que es el enviar de protocolo.py con dos cosas más: una clave
para cuando el servidor la pida, y el motivo del servidor en la excepción.
"""modelo(mensajes, herramientas) -> respuesta, sobre la API de mensajes. Lección 33 del curso.
El adaptador: traduce M al protocolo, manda la petición con urllib y devuelve la respuesta
tal como llega. Ninguna biblioteca del proveedor.
"""
import json
import os
import urllib.error
import urllib.request
URL = os.environ.get("ANTHROPIC_BASE_URL", "http://localhost:11434") + "/v1/messages"
CLAVE = os.environ.get("ANTHROPIC_API_KEY") # el servidor local no la pide
ROLES = {"usuario": "user", "asistente": "assistant"}
def peticion(nombre, max_tokens, mensajes, herramientas):
"""M en el protocolo: el prompt de sistema, a su campo; los demás, con el rol en inglés."""
p = {"model": nombre, "max_tokens": max_tokens, "thinking": {"type": "disabled"},
"system": "\n\n".join(c for rol, c in mensajes if rol == "sistema"),
"messages": [{"role": ROLES[rol], "content": c} for rol, c in mensajes if rol != "sistema"]}
if herramientas:
p["tools"] = list(herramientas)
return p
def enviar(p):
"""Un POST con la petición; lo que contesta el servidor, leído del JSON."""
cabeceras = {"content-type": "application/json"}
if CLAVE:
cabeceras.update({"x-api-key": CLAVE, "anthropic-version": "2023-06-01"})
req = urllib.request.Request(URL, data=json.dumps(p).encode("utf-8"), headers=cabeceras)
try:
with urllib.request.urlopen(req, timeout=300) as r:
return json.load(r)
except urllib.error.HTTPError as e: # un 400, un 404: el servidor dice por qué
raise RuntimeError("el servidor contesta %d: %s" % (e.code, e.read().decode("utf-8")))La mitad de abajo es la clase. Tiene la forma de ModeloGuionizado, un objeto que se llama como
una función, y lo único que hace además de traducir y enviar es la tercera cláusula:
class ModeloAPI:
"""modelo(mensajes, herramientas) -> respuesta, de un modelo que sirve otro programa."""
def __init__(self, nombre="local", max_tokens=1024):
self.nombre, self.max_tokens = nombre, max_tokens
self.usage = None # el de la última petición, para quien mire
def __call__(self, mensajes, herramientas=()):
r = enviar(peticion(self.nombre, self.max_tokens, mensajes, herramientas))
self.usage = r["usage"]
if r["stop_reason"] == "max_tokens": # una respuesta cortada no se devuelve
raise RuntimeError("la respuesta no cabe en max_tokens=%d" % self.max_tokens)
return r["content"]Dos cláusulas del contrato se pueden comprobar sin servidor, y van a tests/test_api.py: la
traducción, sobre un M de dos turnos, y la respuesta cortada, cambiando enviar por una que
devuelve lo que diría el servidor. Son tests del adaptador, no del agente, y como los del agente,
no necesitan un modelo:
"""El adaptador, sin servidor: la traducción de M y la respuesta cortada. Lección 33 del curso."""
import unittest
from unittest import mock
import api
from agente import SISTEMA, inicio
from entorno import HERRAMIENTAS, llamada, resultado
class TestAdaptador(unittest.TestCase):
def test_la_traduccion(self):
r = [llamada("t1", "leer", ruta="lista.txt")]
M = inicio("¿Qué hay en lista.txt?") + [("asistente", r), ("usuario", [resultado("t1", "pan")])]
p = api.peticion("local", 1024, M, HERRAMIENTAS)
self.assertEqual(p["system"], SISTEMA)
self.assertEqual([m["role"] for m in p["messages"]], ["user", "assistant", "user"])
self.assertEqual([m["content"] for m in p["messages"]], [c for _, c in M[1:]])
self.assertEqual(p["tools"], HERRAMIENTAS)
def test_la_respuesta_cortada(self):
cortada = {"content": [{"type": "text", "text": "pan, le"}], "stop_reason": "max_tokens",
"usage": {"input_tokens": 453, "output_tokens": 1024}}
with mock.patch("api.enviar", return_value=cortada):
with self.assertRaises(RuntimeError):
api.ModeloAPI()(inicio("¿Qué hay en lista.txt?"), HERRAMIENTAS)
if __name__ == "__main__":
unittest.main()Falta un programa que reciba un pedido y enseñe qué pasa en cada turno. correr.py lo hace sin
tocar el bucle, con un modelo que envuelve a otro: Traza le pasa cada M al modelo de verdad y
escribe lo que viaja. Las observaciones del turno anterior las encuentra en el último mensaje de M,
que es justo donde el bucle las deja.
"""Un pedido al agente, contra el modelo local, turno a turno. Lección 33 del curso.
python correr.py "¿Qué recetas hay y cuál lleva cebolla?"
"""
import json
import sys
import time
from agente import agente
from api import ModeloAPI
from entorno import ENTORNO
def linea(b):
"""Un bloque en una línea, y si es una observación, si es un error."""
if b["type"] == "text":
return "texto " + " ".join(b["text"].split())
if b["type"] == "tool_use":
return "llamada %s %s" % (b["name"], json.dumps(b["input"], ensure_ascii=False))
return ("error " if b["is_error"] else "observación ") + b["content"].replace("\n", " / ")
class Traza:
"""Un modelo que envuelve a otro y escribe lo que viaja. Para el bucle, es un modelo más."""
def __init__(self, modelo):
self.modelo, self.turno = modelo, 0
def __call__(self, mensajes, herramientas=()):
rol, contenido = mensajes[-1]
if rol == "usuario" and isinstance(contenido, list): # las observaciones de antes
for b in contenido:
print(" → " + linea(b))
self.turno, t = self.turno + 1, time.time()
r = self.modelo(mensajes, herramientas)
u = self.modelo.usage
print("turno %d %.0f s %d tokens de entrada" % (self.turno, time.time() - t,
u["input_tokens"] + u.get("cache_read_input_tokens", 0)))
for b in r:
print(" ← " + linea(b))
return rAl final del fichero, el agente con el modelo envuelto. El entorno es todavía el diccionario en memoria, una copia por ejecución, y no hay usuario: una escritura se deniega, como en la traza de arriba. Preguntarte de verdad llega con los permisos, más adelante en el bloque.
entorno = dict(ENTORNO)
motivo, texto, M = agente(Traza(ModeloAPI()), sys.argv[1], entorno)
print(motivo, "·", "%d mensajes en M" % len(M))
for ruta in sorted(entorno):
if entorno[ruta] != ENTORNO.get(ruta):
print("cambia", ruta, "->", entorno[ruta])Antes de correr nada, comprueba que no has tocado ningún fichero de los que había. Como clonaste el punto de control, git conoce la etiqueta de la que partes, y esta orden no tiene que imprimir nada:
git diff --stat b5-l1Si te has perdido, este es el punto de control al final de la lección, con el adaptador entero en
api.py:
Si algún día quieres un modelo más fuerte que el que cabe en tu máquina, el adaptador ya sabe
hablar con él. Una API en la nube que hable este protocolo es otra dirección: defines
ANTHROPIC_BASE_URL con la de tu proveedor y ANTHROPIC_API_KEY con tu clave (en una variable de
entorno, nunca en un fichero del repositorio), y creas el modelo con su nombre,
ModeloAPI(nombre=…). No cambia nada más. Es el único sitio del curso donde aparece esa variante,
porque cuesta dinero y el bloque se ha escrito para que no haga falta.
El agente, contra el servidor
Primero, los tests: los cuatro del agente sobre el guion y los dos del adaptador. Ninguno necesita el servidor:
python -m unittest -vtest_el_modelo_terco (tests.test_agente.TestAgente.test_el_modelo_terco) ... ok
test_la_escritura_sin_contenido (tests.test_agente.TestAgente.test_la_escritura_sin_contenido) ... ok
test_la_receta_de_la_web (tests.test_agente.TestAgente.test_la_receta_de_la_web) ... ok
test_la_tortilla (tests.test_agente.TestAgente.test_la_tortilla) ... ok
test_la_respuesta_cortada (tests.test_api.TestAdaptador.test_la_respuesta_cortada) ... ok
test_la_traduccion (tests.test_api.TestAdaptador.test_la_traduccion) ... ok
----------------------------------------------------------------------
Ran 6 tests in 0.003s
OKDespués, un pedido que el agente resuelve. Con el servidor en marcha (y sin una
ANTHROPIC_BASE_URL que apunte a otro sitio), ejecuta:
python correr.py "¿Qué recetas hay y cuál lleva cebolla?"turno 1 13 s 460 tokens de entrada
← llamada listar {"carpeta": "recetas/"}
← llamada buscar {"patron": "cebolla"}
→ observación recetas/gazpacho.txt / recetas/tortilla.txt
→ observación recetas/tortilla.txt: Tortilla: huevos, patatas, cebolla.
turno 2 19 s 556 tokens de entrada
← texto Las recetas disponibles son: 1. Gazpacho 2. Tortilla La receta que lleva cebolla es la **tortilla**, cuya lista de ingredientes es: huevos, patatas y cebolla.
fin · 5 mensajes en MLos segundos dependen de tu máquina, y la primera ejecución tarda más porque el servidor carga los pesos. El texto puede salir con otras palabras. Lo que tiene que coincidir es la forma: dos llamadas en la primera respuesta, contestadas en el orden en que se pidieron, y una respuesta sin llamadas en el segundo turno. Fíjate en los tokens: 460 para una pregunta de siete palabras, porque la mayor parte son el prompt de sistema y las cuatro herramientas, que viajan en cada turno.
Ahora uno que no resuelve:
python correr.py "¿Qué lleva el gazpacho?"turno 1 18 s 455 tokens de entrada
← texto I don't have access to any specific files or recipes that would contain information about what goes into gazpacho. You might want to check a recipe book or a culinary website for details on traditional gazpacho ingredients. Let me know if you'd like help with something else!
fin · 3 mensajes en MEn inglés, sin una sola llamada, y diciendo que no tiene acceso a ningún fichero con las herramientas delante. El harness no puede hacer nada: una respuesta sin llamadas es un final. Prueba también la tortilla, y otros pedidos tuyos; el modelo del curso es pequeño, y verlo equivocarse es parte de lo que esta lección enseña.
Pasas el agente del modelo guionizado al modelo local. ¿Qué ficheros del agente tienes que cambiar?
El servidor devuelve stop_reason max_tokens y una respuesta cuyo último bloque es un tool_use de escribir. ¿Por qué ModeloAPI lanza una excepción en lugar de devolver los bloques?
En la traza de la tortilla, buscar devuelve una observación vacía y en el turno siguiente el modelo escribe en lista.txt justo los dos ingredientes que faltan, huevos y cebolla. ¿Qué dice M de esa lista?
Ahora que el agente tiene un modelo de verdad, sus tests deberían correr contra él, porque contra el guion no prueban nada real.
El agente ya corre contra un modelo de verdad, y el bucle que lo mueve es el del bloque anterior,
sin una línea cambiada. Pero lo que mueve sigue siendo un juguete: un diccionario de cuatro
ficheros en memoria y cuatro herramientas pensadas para él. buscar compara tal cual, y bastó una
mayúscula para que el modelo dejara de leer y se pusiera a adivinar.
Un agente de programación trabaja sobre un repositorio en tu disco, y lo que necesita es leerlo, buscar en él, cambiar un fichero sin reescribirlo entero y ejecutar los tests. La lección siguiente, sobre las herramientas de ficheros, cambia el diccionario por los ficheros de verdad y le da al agente esas herramientas, cada una con un contrato que deja comprobar lo que ha hecho un modelo pequeño.
Further reading3 sources · 3 articles
Where this lesson comes from, and where to go next. None of it is needed to carry on with the course.
- Hexagonal architecture (ports and adapters)
El nombre del patrón de esta lección: el programa habla con una interfaz, y un adaptador por cada cosa que hay detrás, el de verdad y el de los tests. Cockburn lo cuenta con el mismo argumento.
- Qwen3-4B-Instruct-2507
La ficha del modelo del curso. Su plantilla de chat enseña qué hace el servidor con tu campo `tools`: lo escribe en el prompt de sistema, y el modelo contesta con un JSON entre dos marcas que el servidor convierte en `tool_use`.
- urllib.request
Todo el cliente del adaptador sale de aquí: `Request`, `urlopen` con su `timeout`, y `HTTPError`, que es la excepción que el adaptador convierte en un error con el motivo que da el servidor.