Capítulo 3: El Bucle Infinito

Tenemos un problema.

Imagina ejecutar el script del Capítulo 2 dos veces. Dices “Mi nombre es Alice.” Claude te saluda. Lo ejecutas de nuevo y preguntas “¿Cuál es mi nombre?” Claude dice “No lo sé.”

Esto se debe a que los LLMs son sin estado. Tienen amnesia total. Cada solicitud es la primera vez que se han conocido.

Para construir un agente, necesitamos solucionar esto creando memoria artificial.

La Ilusión de la Memoria

La “memoria” en un LLM no es un disco duro. Es un archivo de registro.

Cuando chateas con ChatGPT, no “recuerda” lo que dijiste hace 5 minutos. Entre bastidores, el código envía el historial completo de la conversación al modelo con cada nuevo mensaje.

Acumulación de contexto: el Turno 1 envía solo “Usuario: Hola” a la API. El Turno 2 envía el historial completo —“Usuario: Hola”, “Asistente: Hola”, “Usuario: ¿Cómo estás?”— a la API.
Figura 2. Acumulación de contexto: el Turno 1 envía solo “Usuario: Hola” a la API. El Turno 2 envía el historial completo —“Usuario: Hola”, “Asistente: Hola”, “Usuario: ¿Cómo estás?”— a la API.

El modelo ve la transcripción completa cada vez. Ese es el truco.

Implementemos este bucle de contexto manualmente. Pero primero, necesitamos que nuestro código sea testeable.

El Problema de las Pruebas

Aquí va una verdad incómoda: no puedes probar una aplicación impulsada por un LLM llamando realmente al LLM.

Las llamadas a la API son lentas (de 2 a 10 segundos cada una), costosas (dinero real por llamada) y no deterministas (puedes obtener una respuesta diferente cada vez). Imagina ejecutar un conjunto de pruebas que cuesta 5 dólares y tarda 20 minutos. Nunca lo ejecutarías.

La solución es la inyección de dependencias. En lugar de codificar en duro la llamada a la API dentro de nuestro agente, pasamos un objeto “cerebro”. En producción, el cerebro es Claude. En las pruebas, el cerebro es un objeto simulado que devuelve respuestas predecibles.

Estableceremos este patrón ahora, antes de escribir más código de producción.

Tipos de Respuesta

Antes de construir el cerebro, necesitamos definir qué devuelve. La API de Claude envía JSON complejo con múltiples bloques de contenido. Necesitamos objetos Python simples con los que trabajar.

El Contexto: Claude puede devolver texto, llamadas a herramientas, o ambos en una sola respuesta. Necesitamos objetos de datos simples para representar estas posibilidades. (Omitimos @dataclass deliberadamente: estas clases son lo suficientemente simples como para que el decorador ahorre unas pocas líneas a costa de ocultar lo que __init__ hace realmente.)

El Código:

17 class ToolCall:
18     """A tool invocation request from the brain."""
19 
20     def __init__(self, id, name, args):
21         self.id = id
22         self.name = name
23         self.args = args  # dict

ToolCall representa al cerebro pidiéndonos que ejecutemos una herramienta. El id es un identificador único para el seguimiento (Claude lo necesita cuando le devolvemos los resultados). El name indica qué herramienta ejecutar. El args es un diccionario de parámetros.

Todavía no usaremos ToolCall —el cerebro todavía no puede llamar herramientas— pero lo definimos ahora porque forma parte del tipo de respuesta Thought. Cuando agreguemos herramientas, Claude devolverá estos cuando quiera leer un archivo o ejecutar un comando.

26 class Thought:
27     """Standardized response from any Brain."""
28 
29     def __init__(self, text=None, tool_calls=None, thinking=None):
30         self.text = text  # str or None
31         self.tool_calls = tool_calls or []  # list of ToolCall
32         self.thinking = thinking  # str or None

Un Thought es lo que el cerebro devuelve después de pensar. Puede contener texto, llamadas a herramientas, ambos, o ninguno. El campo thinking captura el resumen del razonamiento del modelo — veremos de dónde viene eso cuando construyamos la clase Claude más adelante. Esta abstracción nos permitirá reemplazar Claude por DeepSeek en el futuro sin modificar ningún otro código.

El patrón FakeBrain

Ahora podemos construir un cerebro falso para las pruebas.

El contexto: Necesitamos un cerebro que devuelva respuestas predecibles, registre cuántas veces fue llamado y registre la conversación que recibió.

El código:

class FakeBrain:
    """Fake brain for testing - returns predictable responses."""

    def __init__(self, responses=None):
        self.responses = responses or [Thought(text="Fake response")]
        self.call_count = 0
        self.last_conversation = None

    def think(self, conversation):
        self.last_conversation = list(conversation)  # Store a copy
        if self.call_count < len(self.responses):
            response = self.responses[self.call_count]
            self.call_count += 1
            return response
        return Thought(text="No more responses")

Esto va en test_nanocode.py, no en el código de producción. Observa que FakeBrain tiene la misma interfaz que tendrá nuestro cerebro real: un método think() que recibe una conversación y devuelve un Thought.

An icon of a info-circle1

Aparte: Este patrón —reemplazar una dependencia real con un falso predecible para las pruebas— se llama doble de prueba. El artículo de Martin Fowler “Mocks Aren’t Stubs”1 explica las variaciones (fakes, stubs, mocks, spies). Para las pruebas de LLM, un fake simple con respuestas prefabricadas suele ser todo lo que necesitas.

Definición del éxito

Antes de escribir el código de producción, definamos cómo se ve el éxito. Estas pruebas guiarán nuestra implementación.

Prueba 1: El cerebro devuelve una respuesta

1 def test_handle_input_returns_brain_response():
2     """Verify handle_input returns the brain's response text."""
3     brain = FakeBrain(responses=[Thought(text="Hello from brain!")])
4     agent = Agent(brain=brain)
5     result = agent.handle_input("hi")
6     assert result == "Hello from brain!"

Observa que pasamos brain=brain al Agent. Esto es la inyección de dependencias en acción.

Test 2: La conversación se acumula

 1 def test_conversation_accumulates():
 2     """Verify conversation list grows with each interaction."""
 3     brain = FakeBrain(responses=[
 4         Thought(text="Response 1"),
 5         Thought(text="Response 2")
 6     ])
 7     agent = Agent(brain=brain)
 8 
 9     agent.handle_input("First message")
10     assert len(agent.conversation) == 2  # user + assistant
11 
12     agent.handle_input("Second message")
13     assert len(agent.conversation) == 4  # 2 users + 2 assistants

Después de cada intercambio, la conversación debe contener tanto el mensaje del usuario como la respuesta del asistente.

Prueba 3: Estructura de mensaje correcta

 1 def test_conversation_contains_correct_roles():
 2     """Verify conversation has correct role alternation."""
 3     brain = FakeBrain(responses=[Thought(text="AI response")])
 4     agent = Agent(brain=brain)
 5 
 6     agent.handle_input("User message")
 7 
 8     assert agent.conversation[0]["role"] == "user"
 9     assert agent.conversation[0]["content"] == "User message"
10     assert agent.conversation[1]["role"] == "assistant"
11     assert agent.conversation[1]["content"] == "AI response"

Los mensajes deben tener el formato exacto que Claude espera: {"role": "user", "content": "..."}.

Test 4: Brain recibe la conversación

 1 def test_brain_receives_conversation():
 2     """Verify brain.think is called with the conversation list."""
 3     brain = FakeBrain()
 4     agent = Agent(brain=brain)
 5 
 6     agent.handle_input("Test message")
 7 
 8     assert brain.last_conversation is not None
 9     assert len(brain.last_conversation) == 1
10     assert brain.last_conversation[0]["content"] == "Test message"

El cerebro debe recibir la conversación completa, no solo el mensaje actual.

Ejecuta estas pruebas ahora—todas deberían fallar:

1 pytest test_nanocode.py -v
1 FAILED test_nanocode.py::test_handle_input_returns_brain_response
2 FAILED test_nanocode.py::test_conversation_accumulates
3 ...

Bien. Ahora hagamos que pasen.

La Clase Claude

Ahora viene el verdadero cerebro.

El Contexto: Necesitamos una clase que envuelva la API de Claude. Debe gestionar la autenticación, enviar el historial de conversación y convertir la respuesta en un Thought. También habilitamos el pensamiento extendido: una característica por la que el modelo escribe apuntes internos antes de responder. Imagínalo como el modelo hablándose a sí mismo en un bloc de notas antes de hablar. Cuesta tokens adicionales, pero la mejora en calidad es significativa, especialmente cuando añadamos herramientas en el Capítulo 5, donde el modelo necesita razonar sobre qué herramienta usar y por qué.

El Código:

37 class Claude:
38     """Claude API - the brain of our agent."""
39 
40     def __init__(self):
41         self.api_key = os.getenv("ANTHROPIC_API_KEY")
42         if not self.api_key:
43             raise ValueError("ANTHROPIC_API_KEY not found in .env")
44         self.model = "claude-sonnet-4-6"
45         self.url = "https://api.anthropic.com/v1/messages"
46 
47     def think(self, conversation):
48         headers = {
49             "x-api-key": self.api_key,
50             "anthropic-version": "2023-06-01",
51             "content-type": "application/json"
52         }
53         payload = {
54             "model": self.model,
55             "max_tokens": 16000,
56             "thinking": {
57                 "type": "enabled",
58                 "budget_tokens": 10000
59             },
60             "messages": conversation
61         }
62 
63         response = requests.post(self.url, headers=headers, json=payload, timeout=120)
64         response.raise_for_status()
65         return self._parse_response(response.json()["content"])

El Recorrido:

  • Líneas 41-43: Carga la clave de API y falla de inmediato si no está presente.
  • Líneas 44-45: Almacena la configuración. Más adelante haremos que el modelo sea configurable.
  • Línea 47: El método think() es la interfaz del cerebro—igual que en FakeBrain.
  • Líneas 55-59: Habilitamos el extended thinking—el modelo produce un resumen de razonamiento antes de responder, lo que mejora la calidad en tareas complejas a costa de más tokens. budget_tokens limita la cantidad de tokens que el modelo puede gastar en razonamiento (10.000 aquí)—esos tokens cuentan en tu factura igual que los tokens de salida. En nuestra configuración, max_tokens cubre el total de la salida, incluyendo tanto el razonamiento como la respuesta, por lo que Anthropic requiere que supere a budget_tokens. Con 10.000 tokens de razonamiento y 16.000 de máximo, la respuesta en sí puede usar hasta 6.000 tokens.
  • Línea 60: El payload incluye "messages": conversation—el historial completo, no solo el mensaje actual. Este es el bucle de contexto.
  • Línea 65: Analiza el complejo formato de respuesta de Claude y lo convierte en nuestro sencillo Thought.

Ahora el analizador de respuestas:

67     def _parse_response(self, content):
68         """Convert Claude's response format to Thought."""
69         text_parts = []
70         tool_calls = []
71         thinking = None
72 
73         for block in content:
74             if block["type"] == "thinking":
75                 thinking = block["thinking"]
76             elif block["type"] == "text":
77                 text_parts.append(block["text"])
78             elif block["type"] == "tool_use":
79                 tool_calls.append(ToolCall(
80                     id=block["id"],
81                     name=block["name"],
82                     args=block["input"]
83                 ))
84 
85         return Thought(
86             text="\n".join(text_parts) if text_parts else None,
87             tool_calls=tool_calls,
88             thinking=thinking
89         )

La API de Claude devuelve una lista de “bloques de contenido”. Cada bloque tiene un type"thinking", "text" o "tool_use". El bloque de pensamiento llega primero y contiene un resumen del razonamiento del modelo; lo almacenamos en el Thought para que quien lo invoque pueda mostrarlo. Los bloques de texto se convierten en la respuesta, y los bloques tool_use se convierten en objetos ToolCall. El parser no imprime nada; simplemente convierte el JSON sin procesar en un Thought limpio.

La Clase Agent (Actualizada)

Ahora actualizamos el Agent del Capítulo 1 para que acepte un cerebro y mantenga el historial de conversación.

El Código:

 94 class Agent:
 95     """A coding agent with conversation memory."""
 96 
 97     def __init__(self, brain):
 98         self.brain = brain
 99         self.conversation = []
100 
101     def handle_input(self, user_input):
102         """Handle user input. Returns output string, raises AgentStop to quit."""
103         if user_input.strip() == "/q":
104             raise AgentStop()
105 
106         if not user_input.strip():
107             return ""
108 
109         self.conversation.append({"role": "user", "content": user_input})
110 
111         try:
112             thought = self.brain.think(self.conversation)
113             if thought.thinking:
114                 lines = thought.thinking.strip().split("\n")[:5]
115                 for i, line in enumerate(lines):
116                     prefix = "  💭 " if i == 0 else "     "
117                     print(f"\033[2m{prefix}{line}\033[0m")
118             text = thought.text or ""
119             self.conversation.append({"role": "assistant", "content": text})
120             return text
121         except Exception as e:
122             self.conversation.pop()  # Remove failed user message
123             return f"Error: {e}"

El recorrido:

  • Líneas 97-99: Acepta un cerebro mediante inyección de dependencias. Inicializa una lista de conversación vacía.
  • Línea 109: Agrega el mensaje del usuario al historial antes de llamar al cerebro.
  • Líneas 112-120: Llama al cerebro, muestra hasta cinco líneas de pensamiento en texto atenuado (\033[2m es el código de escape ANSI para atenuar, \033[0m lo restablece), extrae la respuesta y la agrega al historial.
  • Líneas 121-123: Si la llamada a la API falla, elimina el mensaje del usuario que acabamos de agregar. Esto mantiene la conversación en un estado válido.

Presta atención a la línea 109: agregamos el mensaje del usuario antes de llamar al cerebro. El cerebro necesita ver la conversación completa, incluido el mensaje actual.

El Bucle Principal (Actualizado)

El bucle principal ahora es simplemente una capa delgada de E/S:

128 def main():
129     brain = Claude()
130     agent = Agent(brain)
131     print("⚡ Nanocode v0.2 (Conversation Memory)")
132     print("Type '/q' to quit.\n")
133 
134     while True:
135         try:
136             user_input = input("❯ ")
137             output = agent.handle_input(user_input)
138             if output:
139                 print(f"\n{output}\n")
140 
141         except (AgentStop, KeyboardInterrupt):
142             print("\nExiting...")
143             break
144 
145 
146 if __name__ == "__main__":
147     main()

Toda la lógica está en la clase Agent. El bucle simplemente lee la entrada, llama a handle_input() e imprime el resultado. Esta separación hace que el agente sea testeable: probamos Agent.handle_input() directamente sin necesidad de simular input() ni print().

Verifica que las pruebas pasen

Ejecuta las pruebas de nuevo:

1 pytest test_nanocode.py -v
1 test_nanocode.py::test_handle_input_returns_brain_response PASSED
2 test_nanocode.py::test_conversation_accumulates PASSED
3 test_nanocode.py::test_conversation_contains_correct_roles PASSED
4 test_nanocode.py::test_brain_receives_conversation PASSED

Todo en verde. Las pruebas verifican nuestra implementación sin realizar una sola llamada a la API.

Probar la Memoria

Ahora prueba con el cerebro real:

1 python nanocode.py

Prueba esta conversación:

 1 ❯ I am building a Python agent.
 2   💭 The user is telling me about their project. They want to build
 3      a Python agent. I should respond helpfully and ask what kind
 4      of agent they're building.
 5 
 6 That sounds exciting! What kind of agent are you building?
 7 
 8 ❯ What language am I using?
 9   💭 The user previously said they are building a Python agent.
10      The answer is Python.
11 
12 You are using Python.

La lista de conversación está haciendo su trabajo.

El Problema de la Ventana de Contexto

Quizás estés pensando: “¿Puedo mantener esto corriendo para siempre?”

No.

En cada iteración del bucle, la lista messages crece:

Turno Tokens Aproximados
1 50
10 5.000
100 50.000

En algún momento, alcanzas el límite de contexto—200k tokens para Claude Sonnet, 128k para DeepSeek, y tan solo 4k en algunos modelos locales. Si lo superas, la API devuelve 400 Bad Request. Nuestro manejo de errores en la línea 121 captura esto e informa el error, así que el agente no fallará en silencio. Pero la conversación queda efectivamente bloqueada—cada mensaje posterior también fallará, ya que el historial sigue siendo demasiado largo.

Por ahora, reiniciar el agente borra el historial y te permite retomar el hilo. Añadiremos la compactación de contexto adecuada—rastreando el uso de tokens desde la respuesta de la API y resumiendo automáticamente los mensajes antiguos antes de que se desborden—cuando construyamos el bucle de retroalimentación en el Capítulo 9. Ahí es donde las conversaciones realmente explotan, y donde la solución se ganará su lugar.

Conclusión

Claude ahora recuerda—o más bien, lo hemos engañado para que crea que recuerda. La lista de conversación crece con cada turno, y FakeBrain nos permite probar todo el sistema sin gastar un centavo.

Ambos patrones se mantendrán a lo largo del resto del libro. Cada cerebro que construyamos (Claude, DeepSeek, Ollama) implementará la misma interfaz think(), y FakeBrain los probará a todos.

Un cabo suelto: nuestro código está cableado directamente a la API de Anthropic. Si quisiéramos añadir DeepSeek o un modelo local, tendríamos que duplicar una gran cantidad de código.


  1. https://martinfowler.com/articles/mocksArentStubs.html↩︎