> Nivel 8 de Agent Harness Patterns, un recorrido de patrones sobre cómo funcionan los agentes de IA. Versión web: https://harnesspatterns.dev/es/patterns/prompt-caching · Todos los patrones (en inglés): https://harnesspatterns.dev/llms.txt

# Caché de prompts

En cada turno, el bucle le vuelve a mandar al modelo el pedido entero. Casi todo es igual que la vez anterior, y el proveedor puede recordarlo, siempre que no cambies cómo empieza.

## El problema

Un modelo no recuerda nada entre llamadas, así que el bucle reenvía todo en cada turno: el system prompt, la lista de herramientas y el historial entero. En un agente que corre diez turnos, las instrucciones largas de arriba viajan diez veces, y se pagan diez veces.

Y lo hacen más lento: antes de escribir una sola palabra, el modelo tiene que volver a leer el pedido entero, desde el primer token.

## La solución

Los proveedores guardan una memoria de corta duración de los pedidos que acaban de leer. Cuando un pedido nuevo empieza exactamente igual que uno reciente, reutilizan la parte que coincide y solo procesan el resto. Esa parte se cobra a una fracción del precio (muchas veces un décimo, según el proveedor y el modelo), y la respuesta empieza antes.

La regla está en la palabra *empieza*: la coincidencia se cuenta desde el primer token y se corta en el primero que es distinto. Un agente encaja naturalmente, porque cada pedido es el anterior más unos pocos mensajes al final. Lo único que tienes que hacer es no romperlo:

- **Algo que cambia, arriba de todo**
   la hora, un id de pedido, el nombre del usuario
   Mantén fijo el system prompt. Lo que cambia va en el último mensaje, al final.
- **Herramientas que se mueven**
   otro orden, una herramienta agregada a mitad de camino
   Arma la lista de herramientas una vez, en un orden fijo, y manda la misma en cada turno.
- **Reescribir el pasado**
   editar, recortar o resumir mensajes viejos
   Solo agrega al final. Cuando tengas que reescribir, todo lo que viene después de ese punto se paga de nuevo.
- **Cambiar de modelo**
   un modelo más barato para un turno
   Cada modelo tiene su propia caché. Un cambio la empieza de cero.

Algunos proveedores cachean solos cuando el pedido es lo bastante largo: OpenAI lo hace desde 1.024 tokens. Otros, como Anthropic, solo cachean donde lo marcas. En cualquier caso, el uso que devuelven dice cuántos tokens de entrada salieron de la caché, así que puedes comprobarlo.

## El elenco

El mismo elenco de siempre, en una noche de juegos.

- **El Simon** (el pedido): Cada ronda repite la secuencia entera y le agrega algo al final, como cada turno reenvía el pedido.
- **Las luces** (sus bloques): Amarillas para el system prompt y las herramientas, y después una por mensaje, con los colores del historial.
- **Astor** (el bucle): Le toca al Oráculo la secuencia entera en cada turno, y después ejecuta las herramientas como siempre.
- **El Oráculo** (el modelo): Su globo es la caché del proveedor: las luces que ya conoce, desde la primera.
- **El marcador** (el uso): Tokens de entrada enviados, leídos de la caché y pagados, con los cacheados a un décimo.
- **El living** (las herramientas): La repisa de juegos y el teléfono: `game_shelf` y `order_pizza`.

## El código

**Con astorlm:** el agente arma su system prompt una vez y lo reenvía sin cambios en cada turno, con las mismas herramientas en el mismo orden, y el historial solo crece al final. Tu parte es dejar lo que cambia fuera de `systemPrompt`. Cada `turn_end` trae `cacheReadTokens`, que `OpenAIProvider` lee de la respuesta.

**Desde cero:** el bucle del nivel 2, con su comienzo fijo armado una vez fuera del bucle, y una línea que registra `cached_tokens` del uso de cada respuesta.

**Con astorlm**

```ts
import { OpenAIProvider, createLocalAgent, tool } from 'astorlm'
import { z } from 'zod'

const gameShelf = tool({
  name: 'game_shelf',
  description: 'List the board games on the living-room shelf.',
  schema: z.object({}),
  execute: async () => home.shelf(), // your code
})

const orderPizza = tool({
  name: 'order_pizza',
  description: 'Order pizza for delivery. Returns how long it will take.',
  schema: z.object({ size: z.enum(['medium', 'large']), count: z.number().int().min(1) }),
  execute: async ({ size, count }) => pizzeria.order(size, count), // your code
})

const agent = await createLocalAgent({
  // Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
  provider: new OpenAIProvider({
    baseURL: 'http://localhost:11434/v1', // e.g. Ollama's default address
    model: 'your-model', // e.g. 'llama3.1', 'gpt-4o-mini'
    apiKey: 'YOUR_API_KEY', // local servers usually ignore it
  }),
  // The start of every request. astorlm builds it once and resends it unchanged every turn.
  // Nothing that changes goes here: no clock, no request id, no user name.
  systemPrompt: HOUSE_RULES, // a long, fixed text: the more of it, the more the cache saves
  tools: [gameShelf, orderPizza], // same tools, same order, every turn
  maxTurns: 10,
})

// OpenAI caches long prompts on its own (from 1,024 tokens), and says how much it reused.
agent.on('event', (event) => {
  if (event.type !== 'turn_end' || !event.usage) return
  const { inputTokens, cacheReadTokens = 0 } = event.usage
  console.log(`turn ${event.turn}: ${cacheReadTokens} of ${inputTokens} input tokens from cache`)
})

// Need the time? Put it at the end, in the message, where it only changes what comes after it.
const now = new Date().toLocaleTimeString()
await agent.run(`Game night for four: see what games we have, and order pizza. (It is ${now}.)`)
```

**TypeScript**

```ts
// Prompt caching from scratch. Plain fetch, no SDK.
// There is nothing to build: the provider caches. Your job is to not break it.

// Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy…
const LLM = {
  baseURL: 'http://localhost:11434/v1', // e.g. Ollama's default address
  model: 'your-model', // e.g. 'llama3.1', 'gpt-4o-mini'
  apiKey: 'YOUR_API_KEY', // local servers usually ignore it
}

type ToolCall = { id: string; function: { name: string; arguments: string } }
type Message =
  | { role: 'system' | 'user'; content: string }
  | { role: 'assistant'; content: string | null; tool_calls?: ToolCall[] }
  | { role: 'tool'; tool_call_id: string; content: string }

const tools: Record<string, (args: Record<string, string>) => Promise<string>> = { game_shelf: gameShelf, order_pizza: orderPizza }

// 1. The fixed start, built once: same text and same tools, in the same order, every request.
const SYSTEM: Message = { role: 'system', content: HOUSE_RULES } // ✗ never `It is ${new Date()}` up here
const TOOL_SCHEMAS = Object.freeze([/* one JSON Schema per tool, always in this order */])

export async function runAgent(prompt: string, maxTurns = 10): Promise<string> {
  // 2. The history only grows at the end. Editing an old message breaks the cache from there on.
  const messages: Message[] = [SYSTEM, { role: 'user', content: prompt }]

  for (let turn = 1; turn <= maxTurns; turn++) {
    const res = await fetch(`${LLM.baseURL}/chat/completions`, {
      method: 'POST',
      headers: { 'content-type': 'application/json', authorization: `Bearer ${LLM.apiKey}` },
      body: JSON.stringify({ model: LLM.model, messages, tools: TOOL_SCHEMAS }),
    })
    const { choices, usage } = await res.json()

    // 3. Check it's working: how much of the input the provider read from its cache.
    const cached = usage?.prompt_tokens_details?.cached_tokens ?? 0
    console.log(`turn ${turn}: ${cached} of ${usage?.prompt_tokens} input tokens from cache`)

    const [choice] = choices
    const reply: Message = choice.message
    messages.push(reply)
    if (choice.finish_reason !== 'tool_calls' || reply.role !== 'assistant') return reply.content ?? ''

    for (const call of reply.tool_calls ?? []) {
      const output = await tools[call.function.name]!(JSON.parse(call.function.arguments || '{}'))
      messages.push({ role: 'tool', tool_call_id: call.id, content: output })
    }
  }
  throw new Error(`No answer after ${maxTurns} turns`)
}
```

**Python**

```python
# Prompt caching from scratch. Standard library only, no SDK.
# There is nothing to build: the provider caches. Your job is to not break it.
import json
import urllib.request

# Any OpenAI-compatible endpoint: OpenAI, Ollama, LM Studio, vLLM, a proxy...
LLM = {
    "base_url": "http://localhost:11434/v1",  # e.g. Ollama's default address
    "model": "your-model",  # e.g. "llama3.1", "gpt-4o-mini"
    "api_key": "YOUR_API_KEY",  # local servers usually ignore it
}

TOOLS = {"game_shelf": game_shelf, "order_pizza": order_pizza}

# 1. The fixed start, built once: same text and same tools, in the same order, every request.
SYSTEM = {"role": "system", "content": HOUSE_RULES}  # never f"It is {datetime.now()}" up here
TOOL_SCHEMAS = (...)  # one JSON Schema per tool, always in this order

def chat(messages):
    request = urllib.request.Request(
        f"{LLM['base_url']}/chat/completions",
        data=json.dumps({"model": LLM["model"], "messages": messages, "tools": list(TOOL_SCHEMAS)}).encode(),
        headers={"Content-Type": "application/json", "Authorization": f"Bearer {LLM['api_key']}"},
    )
    with urllib.request.urlopen(request) as response:
        return json.load(response)

def run_agent(prompt, max_turns=10):
    # 2. The history only grows at the end. Editing an old message breaks the cache from there on.
    messages = [SYSTEM, {"role": "user", "content": prompt}]

    for turn in range(1, max_turns + 1):
        data = chat(messages)

        # 3. Check it's working: how much of the input the provider read from its cache.
        usage = data.get("usage") or {}
        cached = (usage.get("prompt_tokens_details") or {}).get("cached_tokens", 0)
        print(f"turn {turn}: {cached} of {usage.get('prompt_tokens')} input tokens from cache")

        choice = data["choices"][0]
        reply = choice["message"]
        messages.append(reply)
        if choice["finish_reason"] != "tool_calls":
            return reply.get("content") or ""

        for call in reply.get("tool_calls", []):
            output = TOOLS[call["function"]["name"]](**json.loads(call["function"]["arguments"] or "{}"))
            messages.append({"role": "tool", "tool_call_id": call["id"], "content": output})

    raise RuntimeError(f"No answer after {max_turns} turns")
```

## Qué vigilar

- **La caché no dura.** Vive unos pocos minutos sin uso. Un agente que espera una hora a una persona, o un heartbeat que late cada dos horas, vuelve a pagar entero su primer pedido.
- **La compactación y la caché tiran para lados opuestos.** Achicar los mensajes viejos, el próximo nivel, reescribe el pasado, así que cada token después del primer cambio se paga entero en el turno siguiente. Compacta pocas veces, y en pasos grandes.
- **Los prompts cortos no se cachean.** Por debajo del mínimo del proveedor, no hay nada que ahorrar. La caché rinde con system prompts largos, muchas herramientas o historiales largos: justo lo que tienen los agentes.
- **Mídelo.** Si `cacheReadTokens` sigue en cero desde el segundo turno, algo del comienzo está cambiando. Compara dos pedidos lado a lado y busca la primera diferencia.
- **Solo ahorra en la entrada.** Los tokens que escribe el modelo cuestan lo mismo. En los agentes, la entrada suele ser la mayor parte de la cuenta, así que sigue siendo el ahorro más grande que hay.

## Patrones relacionados

- [0 · Tu caja de herramientas](https://harnesspatterns.dev/es/patterns/your-toolkit.md)
- [2 · El bucle del agente](https://harnesspatterns.dev/es/patterns/agent-loop.md)
- [9 · La mochila se llena](https://harnesspatterns.dev/es/patterns/compaction.md)
- [15 · Observabilidad y evaluaciones](https://harnesspatterns.dev/es/patterns/observability.md)
