Capítulo 3: El Bucle Infinito
Tenemos un problema.
Ejecuta el script del Capítulo 2 dos veces. Di “Me llamo Alice”. Claude te saluda. Ejecútalo de nuevo y pregunta “¿Cuál es mi nombre?” Claude responde “No lo sé”.
Esto ocurre porque los LLM son sin estado. Tienen amnesia total. Cada solicitud es como si fuera la primera vez que te conocen.
Para construir un agente, necesitamos arreglar esto creando una 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.

El modelo ve la transcripción completa cada vez. Ese es el truco.
Vamos a implementar este bucle de contexto manualmente. Pero primero, necesitamos hacer que nuestro código sea comprobable.
El Problema de las Pruebas
Aquí hay una verdad difícil: no puedes probar una aplicación basada en LLM haciendo llamadas reales al LLM.
Las llamadas a la API son lentas (2-10 segundos cada una), costosas (dinero real por llamada) y no deterministas (podrías obtener una respuesta diferente cada vez). Imagina ejecutar una suite de pruebas que cuesta $5 y tarda 20 minutos. Nunca la ejecutarías.
La solución es la inyección de dependencias. En lugar de hacer una codificación estática de 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 una simulación 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 lo que 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 clases de datos para representar estas posibilidades.
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 ejecutar una herramienta. El id es un identificador único para el seguimiento (Claude lo necesita cuando le informamos los resultados). El name es qué herramienta ejecutar. El args es un diccionario de parámetros.
No usaremos ToolCall todavía—el cerebro no puede llamar a herramientas—pero lo definimos ahora porque es 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):
30 self.text = text # str or None
31 self.tool_calls = tool_calls or [] # list of ToolCall
Un Thought es lo que el cerebro devuelve después de pensar. Puede contener texto, llamadas a herramientas, ambos o ninguno. Esta abstracción nos permitirá reemplazar Claude por DeepSeek más adelante sin cambiar ningún otro código.
El Patrón del Cerebro Falso
Ahora podemos construir un cerebro falso para realizar pruebas.
El Contexto: Necesitamos un cerebro que devuelva respuestas predecibles, rastree cuántas veces fue llamado y registre qué conversación 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 toma una conversación y devuelve un Thought.
![]() |
Nota al margen: Este patrón—reemplazar una dependencia real con una simulación predecible para pruebas—se llama inyección de dependencias. El artículo de Martin Fowler “Mocks Aren’t Stubs”1 explica las variaciones (fakes, stubs, mocks, spies). Para las pruebas de LLM, normalmente todo lo que necesitas es un fake simple con respuestas predefinidas. |
Definiendo el É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!"
Observe que pasamos brain=brain al Agent. Esto es la inyección de dependencias en acción.
Prueba 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 correcta del mensaje
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 el verdadero cerebro.
El Contexto: Necesitamos una clase que envuelve la API de Claude. Debe manejar la autenticación, enviar el historial de conversación y analizar la respuesta para convertirla en un Thought.
El Código:
36 class Claude:
37 """Claude API - the brain of our agent."""
38
39 def __init__(self):
40 self.api_key = os.getenv("ANTHROPIC_API_KEY")
41 if not self.api_key:
42 raise ValueError("ANTHROPIC_API_KEY not found in .env")
43 self.model = "claude-sonnet-4-6"
44 self.url = "https://api.anthropic.com/v1/messages"
45
46 def think(self, conversation):
47 headers = {
48 "x-api-key": self.api_key,
49 "anthropic-version": "2023-06-01",
50 "content-type": "application/json"
51 }
52 payload = {
53 "model": self.model,
54 "max_tokens": 4096,
55 "messages": conversation
56 }
57
58 print("(Claude is thinking...)")
59 response = requests.post(self.url, headers=headers, json=payload, timeout=120)
60 response.raise_for_status()
61 return self._parse_response(response.json()["content"])
La Explicación Detallada:
- Líneas 39-41: Carga la clave API y falla rápidamente si no está presente.
- Líneas 43-44: Almacena la configuración. Haremos el modelo configurable más adelante.
- Línea 46: El método
think()es la interfaz del cerebro—igual queFakeBrain. - Líneas 52-55: La carga útil incluye
"messages": conversation—el historial completo, no solo el mensaje actual. Este es el bucle de contexto. - Línea 61: Analiza el formato de respuesta complejo de Claude para convertirlo en nuestro
Thoughtsimple.
Ahora el analizador de respuesta:
63 def _parse_response(self, content):
64 """Convert Claude's response format to Thought."""
65 text_parts = []
66 tool_calls = []
67
68 for block in content:
69 if block["type"] == "text":
70 text_parts.append(block["text"])
71 elif block["type"] == "tool_use":
72 tool_calls.append(ToolCall(
73 id=block["id"],
74 name=block["name"],
75 args=block["input"]
76 ))
77
78 return Thought(
79 text="\n".join(text_parts) if text_parts else None,
80 tool_calls=tool_calls
81 )
La API de Claude devuelve una lista de “bloques de contenido”. Cada bloque tiene un type —ya sea "text" o "tool_use". Recopilamos todos los bloques de texto en una única cadena y convertimos los bloques tool_use en objetos ToolCall.
La Clase Agent (Actualizada)
Ahora actualizamos el Agent del Capítulo 1 para aceptar un cerebro y mantener un historial de conversación.
El Código:
86 class Agent:
87 """A coding agent with conversation memory."""
88
89 def __init__(self, brain):
90 self.brain = brain
91 self.conversation = []
92
93 def handle_input(self, user_input):
94 """Handle user input. Returns output string, raises AgentStop to quit."""
95 if user_input.strip() == "/q":
96 raise AgentStop()
97
98 if not user_input.strip():
99 return ""
100
101 self.conversation.append({"role": "user", "content": user_input})
102
103 try:
104 thought = self.brain.think(self.conversation)
105 text = thought.text or ""
106 self.conversation.append({"role": "assistant", "content": text})
107 return text
108 except Exception as e:
109 self.conversation.pop() # Remove failed user message
110 return f"Error: {e}"
La explicación detallada:
- Líneas 89-91: Acepta un cerebro mediante inyección de dependencias. Inicializa una lista de conversación vacía.
- Línea 101: Añade el mensaje del usuario al historial antes de llamar al cerebro.
- Líneas 103-107: Llama al cerebro, extrae el texto, añade la respuesta al historial.
- Líneas 108-110: Si la llamada a la API falla, elimina el mensaje del usuario que acabamos de añadir. Esto mantiene la conversación en un estado válido.
Presta atención a la línea 101: añadimos el mensaje del usuario antes de llamar al cerebro. El cerebro necesita ver la conversación completa, incluyendo el mensaje actual.
El bucle principal (Actualizado)
El bucle principal es ahora solo un envoltorio de E/S ligero:
115 def main():
116 brain = Claude()
117 agent = Agent(brain)
118 print("⚡ Nanocode v0.2 (Conversation Memory)")
119 print("Type '/q' to quit.\n")
120
121 while True:
122 try:
123 user_input = input("❯ ")
124 output = agent.handle_input(user_input)
125 if output:
126 print(f"\n{output}\n")
127
128 except (AgentStop, KeyboardInterrupt):
129 print("\nExiting...")
130 break
131
132
133 if __name__ == "__main__":
134 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 verificable—podemos probar Agent.handle_input() directamente sin necesidad de simular input() o print().
Verificar que las Pruebas Pasen
Ejecuta las pruebas nuevamente:
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 probemos con el cerebro real:
1 python nanocode.py
Pruebe esta conversación:
1 ❯ I am building a Python agent.
2 (Claude is thinking...)
3
4 That sounds exciting! Building a Python agent is a great project...
5
6 ❯ What language am I using?
7 (Claude is thinking...)
8
9 You are using Python.
La lista de conversación está cumpliendo su función.
El Problema de la Ventana de Contexto
Quizás estés pensando: “¿Puedo mantener esto funcionando para siempre?”
No.
En cada iteración del bucle, la lista messages crece:
| Turno | Tokens Aproximados |
|---|---|
| 1 | 50 |
| 10 | 5.000 |
| 100 | 50.000 |
Eventualmente, alcanzas el límite de contexto—200k tokens para Claude Sonnet, 128k para DeepSeek, y tan bajo como 4k para algunos modelos locales. Si lo excedes, la API devuelve 400 Bad Request. Nuestro manejo de errores en la línea 108 captura esto y reporta el error, por lo que el agente no fallará silenciosamente. Pero la conversación está efectivamente bloqueada—cada mensaje subsiguiente también fallará, ya que el historial sigue siendo demasiado largo.
Por ahora, reiniciar el agente limpia el historial y te permite volver a empezar. Añadiremos una apropiada compactación de contexto—rastreando el uso de tokens desde la respuesta de la API y resumiendo automáticamente los mensajes antiguos antes de que desborden—cuando construyamos el bucle de retroalimentación en el Capítulo 9. Ahí es donde las conversaciones realmente se descontrolan, y donde la solución demostrará su valor.
Conclusión
Claude ahora recuerda—o mejor dicho, lo hemos engañado para que piense que lo hace. La lista de conversación crece con cada turno, y FakeBrain nos permite probar todo 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á directamente vinculado a la API de Anthropic. Si queremos añadir DeepSeek o un modelo local, tendríamos que duplicar mucho código.
https://martinfowler.com/articles/mocksArentStubs.html↩︎
