Nivel 8
Caché de prompts
- user
- assistant
- tool_result
EventBus
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_shelfyorder_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.
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}.)`)
// 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`)
}
# 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
cacheReadTokenssigue 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.