Nível 8
Cache de prompts
- user
- assistant
- tool_result
EventBus
O problema
Um modelo não lembra de nada entre chamadas, então o loop reenvia tudo a cada turno: o system prompt, a lista de ferramentas e o histórico inteiro. Num agente que roda dez turnos, as instruções longas do alto viajam dez vezes, e são pagas dez vezes.
E deixam tudo mais lento: antes de escrever uma única palavra, o modelo precisa ler a requisição inteira de novo, desde o primeiro token.
A solução
Os provedores guardam uma memória de curta duração das requisições que acabaram de ler. Quando uma requisição nova começa exatamente como uma recente, eles reaproveitam a parte que coincide e só processam o resto. Essa parte é cobrada por uma fração do preço (muitas vezes um décimo, dependendo do provedor e do modelo), e a resposta começa antes.
A regra está na palavra começa: a coincidência é contada a partir do primeiro token e para no primeiro que é diferente. Um agente se encaixa naturalmente, porque cada requisição é a anterior mais algumas mensagens no final. Você só precisa não quebrar isso:
-
Algo que muda, lá no alto
a hora, um id de requisição, o nome do usuário
Mantenha o system prompt fixo. O que muda vai na última mensagem, no final.
-
Ferramentas que se mexem
outra ordem, uma ferramenta adicionada no meio do caminho
Monte a lista de ferramentas uma vez, numa ordem fixa, e mande a mesma a cada turno.
-
Reescrever o passado
editar, cortar ou resumir mensagens antigas
Só acrescente no final. Quando precisar reescrever, tudo o que vem depois desse ponto é pago de novo.
-
Trocar de modelo
um modelo mais barato para um turno
Cada modelo tem seu próprio cache. Uma troca o começa do zero.
Alguns provedores fazem cache sozinhos quando a requisição é longa o bastante: a OpenAI faz a partir de 1.024 tokens. Outros, como a Anthropic, só fazem cache onde você marca. De qualquer forma, o uso que eles devolvem diz quantos tokens de entrada vieram do cache, então dá para conferir.
O elenco
O mesmo elenco de sempre, numa noite de jogos.
- O Simon a requisição
- Cada rodada repete a sequência inteira e acrescenta algo no final, como cada turno reenvia a requisição.
- As luzes seus blocos
- Amarelas para o system prompt e as ferramentas, e depois uma por mensagem, nas cores do histórico.
- Astor o loop
- Toca a sequência inteira para o Oráculo a cada turno, e depois roda as ferramentas como sempre.
- O Oráculo o modelo
- Seu balão é o cache do provedor: as luzes que ele já conhece, a partir da primeira.
- O placar o uso
- Tokens de entrada enviados, lidos do cache e pagos, com os do cache a um décimo.
- A sala as ferramentas
- A prateleira de jogos e o telefone:
game_shelfeorder_pizza.
O código
Com astorlm: o agente monta seu system prompt uma vez e o reenvia sem mudanças a cada turno, com as
mesmas ferramentas na mesma ordem, e o histórico só cresce no final. Sua parte é deixar o que muda fora de
systemPrompt. Cada turn_end traz cacheReadTokens, que o
OpenAIProvider lê da resposta.
Do zero: o loop do nível 2, com o começo fixo montado uma vez fora do loop, e uma linha que registra
cached_tokens do uso de cada resposta.
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")
O que observar
- O cache não dura. Ele vive poucos minutos sem uso. Um agente que espera uma hora por uma pessoa, ou um heartbeat que bate a cada duas horas, paga de novo por inteiro a primeira requisição.
- Compactação e cache puxam para lados opostos. Encolher as mensagens antigas, o próximo nível, reescreve o passado, então cada token depois da primeira mudança é pago por inteiro no turno seguinte. Compacte poucas vezes, e em passos grandes.
- Prompts curtos não vão para o cache. Abaixo do mínimo do provedor, não há o que economizar. O cache compensa com system prompts longos, muitas ferramentas ou históricos longos: exatamente o que os agentes têm.
-
Meça. Se
cacheReadTokenscontinua em zero a partir do segundo turno, algo no começo está mudando. Compare duas requisições lado a lado e ache a primeira diferença. - Só economiza na entrada. Os tokens que o modelo escreve custam o mesmo. Nos agentes, a entrada costuma ser a maior parte da conta, então continua sendo a maior economia possível.