Preparar la máquina: un modelo local y la API de mensajes
25 min read
El bloque anterior terminó con un agente que pasa su batería de tests sin haber hablado nunca con un modelo de lenguaje. Cada respuesta que recibía estaba escrita en un guion, y eso es lo que permitía probar el harness con todo lo demás fijo. Este último bloque pone en ese sitio un modelo de verdad, que corre en tu ordenador y no cuesta nada, y no introduce matemáticas nuevas: lo que queda es ingeniería, un programa que crece lección a lección. Ese programa ya no cabe en el navegador, y por eso el icono de código de la barra lateral está apagado en todo el bloque. Vive en un repositorio tuyo y se ejecuta en tu terminal.
Cambiar el guion por un modelo parece cosa de una línea, porque modelo es una función. Pero un
modelo de verdad no es una función de tu programa. Son unos gigabytes de pesos que otro programa, el
servidor, tiene cargados en memoria, y tu código sólo puede mandarle texto por un puerto de tu
máquina y leer el texto que vuelve. ¿Qué texto, exactamente? ¿Qué hace el servidor con una llamada
a leer, y qué recuerda de una vez a la siguiente? Antes de escribir el adaptador vas a hacer un
turno entero a mano, mirando lo que viaja.
El turno es este. Las flechas son las dos direcciones: lo que manda tu programa y lo que contesta el servidor.
→ system: Trabajas con los ficheros… messages: [user «¿Qué hay en lista.txt?»] tools: leer, escribir, listar, buscar
← content: [tool_use leer {"ruta": "lista.txt"}] stop_reason: tool_use
→ lo mismo, + [assistant: el tool_use de arriba] + [user: tool_result «pan, leche, patatas»]
← content: [text «El contenido de lista.txt es: pan, leche, patatas.»] stop_reason: end_turnLa primera petición, el texto que tu programa manda al servidor cada vez que llama al modelo,
lleva el prompt de sistema, la pregunta del usuario y las cuatro herramientas del bloque anterior.
Lo que vuelve es una respuesta con un solo bloque, una llamada a leer, y al lado stop_reason dice
por qué paró el modelo: ha pedido una herramienta.
Fíjate en lo que no ha pasado. Nadie ha leído lista.txt. El servidor no sabe que ese fichero existe, no tiene el entorno y no ejecuta nada: ha parado porque el modelo pidió algo, y ahora le toca al harness. La observación de la tercera línea la escribe quien haga de harness, y para que el modelo la vea hay que volver a mandarlo todo (el prompt de sistema, la pregunta, la respuesta) con ella detrás. De la primera petición, el servidor no se ha quedado con nada.
Lo que más enseña es lo que pasa cuando el harness no hace su parte. He mandado al servidor la
segunda petición sin el tool_result, con un mensaje del usuario que dice ¿Y bien? en su
lugar. No ha protestado. Ha devuelto end_turn, y el modelo ha contestado que lista.txt
contiene «Comprar leche, Comprar pan, Comprar huevos, Comprar fruta»: el contenido de un fichero que
nadie ha leído, inventado para tapar el hueco.
Lo que viaja cada vez que llamas al modelo
El protocolo es el formato de mensajes en que se ponen de acuerdo el harness y el modelo:
llamar al modelo es mandar una petición HTTP POST a la dirección del servidor seguida de
/v1/messages, con un objeto JSON en el cuerpo, y lo que vuelve es otro objeto JSON. La petición
lleva estos campos:
model, el nombre con el que el servidor conoce el modelo. En el curso, siemprelocal.max_tokens, la reserva de la lección sobre la ventana de contexto: lo más que puede escribir la respuesta.system, el prompt de sistema. Va en un campo propio, fuera de la lista de mensajes.messages, el historial: mensajes que alternan los rolesuseryassistanty empiezan poruser. El contenido de cada uno es una cadena o una lista de bloques.tools, las herramientas, cada una con suname, sudescriptiony suinput_schema.thinking, si el modelo puede escribir una cadena de pensamiento antes de responder. El modelo del curso no la escribe, y el campo la apaga también en uno que sí lo haría: en una CPU, esa cadena son minutos de espera en cada turno. Los modelos que razonan antes de responder quedan para el final del bloque.
Lo que vuelve trae tres campos que importan. content es la respuesta, una lista de bloques text
y tool_use. stop_reason dice por qué paró el modelo: end_turn si la respuesta no pide nada,
tool_use si pide alguna llamada, max_tokens si la cortó la reserva. Y usage cuenta los tokens
de entrada y los de salida. El resto (un id, el model, el role) el harness no lo usa.
Los bloques ya los conoces. Desde la lección sobre llamar herramientas, el agente los escribe con las claves inglesas del protocolo, y por eso una respuesta del servidor entra en M tal como llega. Lo único que no coincide son los mensajes:
| En M | En la petición |
|---|---|
("sistema", s) | el campo system, con s |
("usuario", c) | {"role": "user", "content": c} en messages |
("asistente", r) | {"role": "assistant", "content": r} en messages |
Lo que el servidor no hace
El servidor implementa modelo(mensajes, herramientas) -> respuesta y nada más. Su contrato con el
harness tiene cinco cláusulas, y las tres primeras dicen lo que no hace:
- No guarda nada entre peticiones. Cada una lleva M entero, y la siguiente lo vuelve a llevar
con un turno más. Lo que sí reaprovecha es cálculo:
cache_read_input_tokenscuenta los tokens del principio de la petición que ya tenía en su caché de claves y valores de la vez anterior. Es velocidad, no memoria: un principio que cambia en un solo carácter se vuelve a leer entero. - No ejecuta nada. Un
tool_usees lo que el modelo le pide al harness, ystop_reasontool_usequiere decir que el turno sigue en tu programa. - No comprueba que cada llamada tenga su observación. El protocolo pide que cada
tool_usereciba sutool_result, con el mismo id, en el mensaje del usuario que sigue. Una API en la nube rechaza la petición que no lo cumple; este servidor la acepta, y el modelo rellena el hueco. Que no falte nunca lo garantiza el bucle: es la segunda garantía del contrato de el agente del bloque anterior. - La ventana de contexto es del servidor, no de la petición. Ningún campo dice cuántos tokens
caben. Lo decide el servidor al cargar el modelo, y en el curso son 8192, fijados en un fichero
del repositorio. Una petición que no cabe vuelve con un error HTTP 400, request (… tokens)
exceeds the available context size (8192 tokens), y no se recorta. Para tu programa es una
excepción de
urlopen, y un fallo demodelosale del bucle sin que el harness pueda contestarlo. Evitar que llegue a pasar es la compactación, más adelante en el bloque. - La dirección es la única configuración. El código lee de
ANTHROPIC_BASE_URLdónde está el servidor, y si no está definida usa"http://localhost:11434". Ningún otro sitio del programa sabe con quién está hablando.
Tu máquina, paso a paso
Todo lo que sigue corre en tu terminal, contra un modelo local. En los comandos, python es tu
Python 3.11 o posterior: en macOS y en muchos Linux se llama python3, y en Windows, py.
Instala el servidor que fija el aviso del principio y comprueba la versión:
ollama --versionollama version is 0.40.13Después clona el primer punto de control del bloque, el sitio del que parten todas las lecciones que siguen. El enlace abre su página, y en ella está la dirección para clonarlo:
CheckpointAl empezar el bloqueCada lección tiene su etiqueta, y la de esta es b5-l1. --branch acepta una etiqueta igual que
una rama, y agente es la carpeta que se crea:
git clone --branch b5-l1 --depth 1 <la dirección del repositorio> agente
cd agente
git switch -c mi-agenteGit avisa de que la etiqueta «is not a commit» y de que no estás en ninguna rama. Es normal: una etiqueta es una foto fija. La última orden crea tu rama, donde quedará lo que escribas.
El punto de control contiene el agente del bloque anterior, pasado a ficheros, uno por pieza: guionizado.py, entorno.py
(las herramientas, su esquema y los bloques), permisos.py (el permiso y ejecutar), bucle.py,
agente.py y la batería en tests/. El entorno sigue siendo un diccionario en memoria, y las
herramientas, las cuatro de siempre. Y trae los dos ficheros de esta lección: el Modelfile y
protocolo.py.
El paso a ficheros cambia una cosa. En las celdas, el usuario que contesta a un permiso era una
función global que los tests sustituían por la suya. En un programa con módulos eso no funciona, y
el usuario pasa a ser un argumento, como el modelo: agente(modelo, pedido, entorno, tope=10, usuario=None), y sin nadie a quien preguntar, preguntar cuenta como denegar. Comprueba que el
harness sigue cumpliendo su contrato. Los tests corren sobre el modelo guionizado, como en el
bloque anterior, y no necesitan 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
----------------------------------------------------------------------
Ran 4 tests in 0.000s
OKAhora el modelo. Lo que descargas son sus pesos, guardados con unos 4 bits por parámetro en lugar de los 16 con que se publican, para que quepan en tu memoria (el curso no entra en cómo). El del curso tiene 4000 millones de parámetros y ocupa unos 2.5 GB:
ollama pull qwen3:4b-instruct
ollama create local -f ModelfileEl Modelfile dice de qué modelo partir y fija la ventana de contexto, y ollama create hace con
él un modelo que se llama local, sin copiar los pesos:
# El modelo local del curso, con la ventana de contexto del curso. Funciona con 8 GB de RAM.
# ollama create local -f Modelfile
FROM qwen3:4b-instruct
PARAMETER num_ctx 8192local es el único nombre de modelo que aparece en el código, y 8192 es la ventana con la que se
ha escrito el bloque: lo bastante pequeña como para que la veas llenarse cuando toque. Si tienes
más memoria, puedes cambiar la línea FROM por un modelo mayor y volver a crear local. El código
no cambia, pero las salidas esperadas del bloque son las de este modelo, y con otro no te puedo
prometer que se parezcan.
Por último, protocolo.py. Su primera mitad monta la petición del turno y la manda. enviar es
todo el cliente: un POST con urllib, de la biblioteca estándar, sin ninguna biblioteca del
proveedor, porque lo que hablas es un protocolo y no un producto.
"""Un turno a mano: el protocolo de mensajes, sin harness. Lección 32 del curso."""
import json
import os
import urllib.request
from entorno import HERRAMIENTAS
URL = os.environ.get("ANTHROPIC_BASE_URL", "http://localhost:11434") + "/v1/messages"
def enviar(peticion):
"""Manda una petición al servidor y devuelve lo que contesta, ya leído del JSON."""
datos = json.dumps(peticion).encode("utf-8")
req = urllib.request.Request(URL, data=datos, headers={"content-type": "application/json"})
with urllib.request.urlopen(req, timeout=300) as r:
return json.load(r)
peticion = {
"model": "local",
"max_tokens": 1024,
"thinking": {"type": "disabled"},
"system": "Trabajas con los ficheros del usuario mediante tus herramientas.",
"messages": [{"role": "user", "content": "¿Qué hay en lista.txt?"}],
"tools": HERRAMIENTAS,
}
r = enviar(peticion)
print(json.dumps(r, ensure_ascii=False, indent=2))La segunda mitad es el harness, y lo haces tú. Si la respuesta pide alguna llamada, el programa te
la enseña y espera a que escribas su observación, que en lista.txt sería
pan, leche, patatas. Después añade a la petición la respuesta del modelo y un mensaje del
usuario con un tool_result por llamada, y la manda otra vez:
llamadas = [b for b in r["content"] if b["type"] == "tool_use"]
if llamadas: # el modelo ha pedido algo, y nadie lo ha ejecutado
observaciones = []
for ll in llamadas: # el harness eres tú: escribe lo que contestaría
contenido = input("\n%s %s -> " % (ll["name"], json.dumps(ll["input"], ensure_ascii=False)))
observaciones.append({"type": "tool_result", "tool_use_id": ll["id"], "content": contenido})
peticion["messages"] += [{"role": "assistant", "content": r["content"]},
{"role": "user", "content": observaciones}]
r = enviar(peticion)
print(json.dumps(r, ensure_ascii=False, indent=2))Si te has perdido, protocolo.py está entero en el punto de control de esta lección:
protocolo.py al terminar esta lección
Un aviso antes de ejecutarlo: si en tu terminal ya está
definida ANTHROPIC_BASE_URL (algunas herramientas la definen), el programa hablará con esa
dirección y no con tu modelo. Bórrala de esa terminal antes de seguir.
Comprueba tu intuición
Ejecuta el turno. La primera petición tarda más, porque el servidor carga los pesos en
memoria. Cuando el programa te enseñe la llamada a leer, escribe la observación y pulsa Intro:
python protocolo.py{
"id": "msg_03ff8b57297866038532d021",
"type": "message",
"role": "assistant",
"model": "local",
"content": [
{
"type": "tool_use",
"id": "fl3ShdpPxtRXLg0saR67PRfqYgysFY8o",
"name": "leer",
"input": {
"ruta": "lista.txt"
}
}
],
"stop_reason": "tool_use",
"usage": {
"input_tokens": 453,
"cache_read_input_tokens": 0,
"output_tokens": 21
}
}
leer {"ruta": "lista.txt"} -> pan, leche, patatas
{
"id": "msg_ba312aa38dbd9ba20f9f2ed1",
"type": "message",
"role": "assistant",
"model": "local",
"content": [
{
"type": "text",
"text": "El contenido de lista.txt es: pan, leche, patatas."
}
],
"stop_reason": "end_turn",
"usage": {
"input_tokens": 21,
"cache_read_input_tokens": 473,
"output_tokens": 16
}
}Los ids cambian en cada ejecución, y el texto final puede no ser ese, porque el modelo sortea
cada token. Lo que
tiene que coincidir es la forma: una sola llamada, a leer con lista.txt, que vuelve con
tool_use, y un texto que vuelve con end_turn. Fíjate en dos números. Los 453 tokens de entrada
de la primera petición son casi todos el prompt de sistema y las cuatro herramientas, que ocupan
ventana en cada turno aunque la pregunta tenga siete palabras. Y la segunda petición, que lleva todo
lo de la primera y algo más, sólo ha leído 21 tokens nuevos: los otros 473 salieron de la caché.
Prueba también a contestar otra cosa, no existe el fichero o una lista distinta: el modelo se cree lo que le escribas, porque para él la observación es la única noticia que tiene del entorno.
La primera petición de protocolo.py vuelve con stop_reason tool_use y una llamada a leer sobre lista.txt. ¿Quién conoce en ese momento lo que contiene lista.txt?
La segunda petición de protocolo.py vuelve a mandar el prompt de sistema, la pregunta y la primera respuesta, y usage dice que 473 de sus tokens salieron de la caché del servidor. ¿Por qué hay que mandarlo todo otra vez?
Si una petición lleva una respuesta con un tool_use y, detrás, un mensaje del usuario sin su tool_result, el servidor local de esta lección la rechaza con un error.
Durante un turno has hecho de harness: ver que la respuesta pide leer, contestar con un
tool_result del mismo id y volver a mandarlo todo. El bucle del bloque anterior ya sabe hacer eso
durante muchos turnos, con el esquema, el permiso y el tope. Lo que no sabe es hablar este
protocolo: sus mensajes son tuplas con los roles en español y el prompt de sistema metido en la
lista.
Traducir lo uno a lo otro es la tabla de arriba, y escrita en Python es el adaptador. La lección
siguiente, sobre el bucle con un modelo real, lo escribe, lo pone detrás de modelo y corre el
agente entero contra el servidor. Los tests seguirán pasando sobre el guion. Lo que haga el modelo
con las herramientas, en cambio, ya no lo ha escrito nadie.
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.
- Messages
La referencia del protocolo campo a campo: todo lo que puede llevar una petición y todo lo que puede volver. La lección usa seis campos de ida y tres de vuelta; aquí están los demás, por si un día te hacen falta.
- Anthropic compatibility
Qué parte del protocolo habla el servidor de la lección y qué parte no. Es un subconjunto, y saber cuál es explica por qué una petición que la nube rechaza aquí pasa.
- Modelfile reference
El fichero de cuatro líneas con el que fijas la ventana de contexto, y el resto de parámetros que acepta. `num_ctx` es el que usa el bloque; los de muestreo los conoces del bloque 1.