Nível 7
A mochila enche
- user
- assistant
- tool_result
EventBus
O problema
Um modelo só consegue ler uma certa quantidade de uma vez. Esse limite é a janela de contexto dele, contada em tokens (pedaços de palavras, de uns quatro caracteres cada): 8.000 em muitos modelos locais pequenos, algumas centenas de milhares nos grandes modelos hospedados. Tudo numa requisição precisa caber nela: o system prompt, cada mensagem até agora, cada resultado de ferramenta e espaço para a resposta.
O modelo não lembra nada entre chamadas, então o loop envia o histórico inteiro a cada turno. Um chat de suporte que consulta um pedido e busca num catálogo acumula milhares de tokens de resultados que o modelo já usou. Cada turno é mais lento e custa mais que o anterior. E um dia a requisição não cabe.
Esse é o Gulp, o estouro de contexto. Aí o provedor rejeita a requisição com um erro ou, pior, alguns servidores cortam em silêncio a parte mais antiga para caber. A parte mais antiga é onde o cliente disse de qual pedido estava falando.
A solução
Antes de cada chamada ao modelo, o loop confere o tamanho da requisição. Passada uma linha, colocada abaixo do limite real para que a resposta ainda tenha espaço, ele encolhe o histórico. Há três jeitos comuns de fazer isso:
-
Truncar resultados de ferramentas antigos
Trocar um resultado grande que o modelo já usou por uma nota de uma linha e uma prévia curta.
Quase de graça, e os resultados de ferramentas costumam ser a parte mais pesada do histórico. Se o modelo precisar dos detalhes de novo, ele chama a ferramenta outra vez.
-
Descartar trocas antigas
Remover os pedidos mais antigos com tudo o que veio depois deles, até o pedido seguinte.
Também é de graça, mas o modelo esquece completamente essa parte da conversa. Mantenha o primeiríssimo pedido, porque ele muitas vezes diz do que se trata o chat inteiro.
-
Resumir com o modelo
Enviar a parte antiga ao modelo uma vez e colocar o resumo dele no lugar dessas mensagens.
Mantém o sentido, mas custa uma chamada a mais, e um resumo pode deixar de fora, sem avisar, o único número que importava.
Seja qual for a escolha, valem as mesmas regras: comece pelas mensagens mais antigas, não mexa nos últimos pedidos e pare assim que couber. O astorlm faz as duas primeiras, nessa ordem. Primeiro ele trunca os resultados de ferramentas antigos, e só descarta mensagens se isso não bastou.
O elenco
O mesmo elenco de sempre, desta vez num poço de blocos que caem.
- O poço janela de contexto
- Tudo o que uma requisição consegue carregar. Se a pilha chegar ao topo, a requisição não cabe.
- Os blocos mensagens
- Um por mensagem, do tamanho dos seus tokens. O chão é o system prompt: ele vai em toda requisição e nunca é compactado.
- A linha vermelha limiar
- 80% da janela. Passou dela, o loop compacta antes de chamar o modelo.
- O martelo o compactador
- Reduz o resultado de ferramenta mais antigo a uma nota de uma linha, e tudo o que está acima assenta.
- KEEP keepRecentTurns
- Os dois últimos pedidos e tudo o que veio depois deles. O martelo nunca toca neles.
- SENT a conta
- Os tokens enviados até agora, somando todas as chamadas. Veja quanto cresce por turno antes e depois do martelo.
No painel EventBus, a linha compact marca o otimizador em ação. O astorlm não emite um evento para
isso; ele escreve “Context optimized” no seu logger. Repare onde ela cai: depois que o pedido entra no histórico,
antes do turn_start.
O código
Com astorlm: A compactação vem ligada por padrão, dimensionada a partir do provedor. Passe
contextOptimizer para definir a janela real, a linha e quantos pedidos recentes manter.
Do zero: O loop do nível 2, com um histórico que vive entre pedidos e uma chamada a
compact() antes de cada chamada ao modelo. O nível 1 trunca resultados de ferramentas antigos, o
nível 2 descarta trocas antigas inteiras.
import { OpenAIProvider, createLocalAgent, tool } from 'astorlm'
import { z } from 'zod'
const getOrder = tool({
name: 'get_order',
description: 'Everything about one order: items, shipping, invoice.',
schema: z.object({ order: z.number().int() }),
execute: async ({ order }) => loadOrder(order), // your code
})
const searchParts = tool({
name: 'search_parts',
description: 'Search the parts catalog, with stock and price for each match.',
schema: z.object({ query: z.string() }),
execute: async ({ query }) => searchCatalog(query), // your code
})
const createReturn = tool({
name: 'create_return',
description: 'Open a return for an order and ship a replacement part.',
schema: z.object({ order: z.number().int(), part: z.string() }),
execute: async ({ order, part }) => openReturn(order, part), // 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
}),
tools: [getOrder, searchParts, createReturn],
maxTurns: 10,
// Compaction is on by default, sized from the provider. OpenAIProvider assumes a
// 128,000-token window, so on a small local model, say how big it really is.
contextOptimizer: {
maxTokens: 8000,
compressThreshold: 0.8, // compact once the request passes 80% of the window
keepRecentTurns: 2, // never touch the last two requests, or anything after them
},
// There's no event for compaction: astorlm logs "Context optimized…" when it happens.
logger: console,
})
// One agent, one history: every run() adds to it, and the optimizer checks it before each model call.
await agent.run('Hi! My order #4471 came with a bent front wheel. Can you help?')
await agent.run('Is that same wheel in stock?')
const last = await agent.run('Great. Open a return for my order and ship me the new wheel.')
console.log(last.content)
// Compaction, from scratch. Plain fetch, no SDK.
// 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: 'user'; content: string }
| { role: 'assistant'; content: string | null; tool_calls?: ToolCall[] }
| { role: 'tool'; tool_call_id: string; content: string }
type ToolFn = (args: Record<string, string | number>) => Promise<string>
const tools: Record<string, ToolFn> = { get_order: getOrder, search_parts: searchParts, create_return: createReturn }
const toolSchemas = [/* one JSON Schema per tool */]
const SYSTEM = 'You are the support assistant of a bike shop.'
const WINDOW = { maxTokens: 8000, threshold: 0.8, keepRecentTurns: 2 }
// A rough count, about 4 characters per token. Good enough to decide when to compact.
function estimateTokens(messages: Message[]): number {
const chars = messages.reduce((sum, m) => sum + JSON.stringify(m).length, SYSTEM.length)
return Math.ceil(chars / 4)
}
// Where the protected part starts: the Nth user request from the end.
// With fewer requests than that, everything is recent and nothing can go.
function keepFrom(messages: Message[], keep: number): number {
let seen = 0
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i]!.role === 'user' && ++seen === keep) return i
}
return 0
}
export function compact(messages: Message[]): Message[] {
const limit = WINDOW.maxTokens * WINDOW.threshold
if (estimateTokens(messages) <= limit) return messages
const out = structuredClone(messages)
// Level 1: shrink old tool results to a one-line note, oldest first. Stop as soon as it fits.
for (let i = 0; i < keepFrom(out, WINDOW.keepRecentTurns); i++) {
const m = out[i]!
if (m.role !== 'tool' || m.content.startsWith('[Truncated')) continue
m.content = `[Truncated to save context: ${m.content.length} chars. Preview: ${m.content.slice(0, 150)}…]`
if (estimateTokens(out) <= limit) return out
}
// Level 2: drop the oldest exchanges whole, from one request up to the next.
// Keep the very first request, and never cut between a tool call and its result.
while (estimateTokens(out) > limit) {
const next = out.findIndex((m, i) => i > 1 && m.role === 'user')
if (next === -1 || next > keepFrom(out, WINDOW.keepRecentTurns)) break
out.splice(1, next - 1)
}
return out
}
// The history lives across requests: that's what fills up.
const messages: Message[] = []
export async function ask(prompt: string, maxTurns = 10): Promise<string> {
messages.push({ role: 'user', content: prompt })
for (let turn = 1; turn <= maxTurns; turn++) {
// Before every model call: does it still fit? The compacted history replaces the old one.
messages.splice(0, messages.length, ...compact(messages))
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: [{ role: 'system', content: SYSTEM }, ...messages], tools: toolSchemas }),
})
const [choice] = (await res.json()).choices
const reply: Message = choice.message
messages.push(reply)
if (choice.finish_reason !== 'tool_calls') return reply.content ?? ''
for (const call of reply.tool_calls ?? []) {
const run = tools[call.function.name]
let output = `Unknown tool: ${call.function.name}`
try {
if (run) output = await run(JSON.parse(call.function.arguments))
} catch (err) {
output = `Error: ${err instanceof Error ? err.message : err}`
}
messages.push({ role: 'tool', tool_call_id: call.id, content: output })
}
}
throw new Error(`No answer after ${maxTurns} turns`)
}
// The chat from the animation: three requests, one growing history.
await ask('Hi! My order #4471 came with a bent front wheel. Can you help?')
await ask('Is that same wheel in stock?')
console.log(await ask('Great. Open a return for my order and ship me the new wheel.'))
# Compaction, from scratch. Standard library only, no SDK.
import copy
import json
import math
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 = {"get_order": get_order, "search_parts": search_parts, "create_return": create_return}
TOOL_SCHEMAS = [...] # one JSON Schema per tool
SYSTEM = "You are the support assistant of a bike shop."
WINDOW = {"max_tokens": 8000, "threshold": 0.8, "keep_recent_turns": 2}
def estimate_tokens(messages):
"""A rough count, about 4 characters per token. Good enough to decide when to compact."""
chars = len(SYSTEM) + sum(len(json.dumps(m)) for m in messages)
return math.ceil(chars / 4)
def keep_from(messages, keep):
"""Where the protected part starts: the Nth user request from the end (0 if there are fewer)."""
seen = 0
for i in range(len(messages) - 1, -1, -1):
if messages[i]["role"] == "user":
seen += 1
if seen == keep:
return i
return 0
def compact(messages):
limit = WINDOW["max_tokens"] * WINDOW["threshold"]
if estimate_tokens(messages) <= limit:
return messages
out = copy.deepcopy(messages)
# Level 1: shrink old tool results to a one-line note, oldest first. Stop as soon as it fits.
for i in range(keep_from(out, WINDOW["keep_recent_turns"])):
m = out[i]
if m["role"] != "tool" or m["content"].startswith("[Truncated"):
continue
m["content"] = f"[Truncated to save context: {len(m['content'])} chars. Preview: {m['content'][:150]}...]"
if estimate_tokens(out) <= limit:
return out
# Level 2: drop the oldest exchanges whole, from one request up to the next.
# Keep the very first request, and never cut between a tool call and its result.
while estimate_tokens(out) > limit:
following = [i for i, m in enumerate(out) if i > 1 and m["role"] == "user"]
if not following or following[0] > keep_from(out, WINDOW["keep_recent_turns"]):
break
del out[1 : following[0]]
return out
def chat(messages):
request = urllib.request.Request(
f"{LLM['base_url']}/chat/completions",
data=json.dumps({
"model": LLM["model"],
"messages": [{"role": "system", "content": SYSTEM}, *messages],
"tools": 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)["choices"][0]
# The history lives across requests: that's what fills up.
messages = []
def ask(prompt, max_turns=10):
messages.append({"role": "user", "content": prompt})
for turn in range(1, max_turns + 1):
# Before every model call: does it still fit? The compacted history replaces the old one.
messages[:] = compact(messages)
choice = chat(messages)
reply = choice["message"]
messages.append(reply)
if choice["finish_reason"] != "tool_calls":
return reply.get("content") or ""
for call in reply.get("tool_calls", []):
name = call["function"]["name"]
run = TOOLS.get(name)
try:
output = run(**json.loads(call["function"]["arguments"])) if run else f"Unknown tool: {name}"
except Exception as err:
output = f"Error: {err}"
messages.append({"role": "tool", "tool_call_id": call["id"], "content": output})
raise RuntimeError(f"No answer after {max_turns} turns")
# The chat from the animation: three requests, one growing history.
ask("Hi! My order #4471 came with a bent front wheel. Can you help?")
ask("Is that same wheel in stock?")
print(ask("Great. Open a return for my order and ship me the new wheel."))
O que observar
- Informe a janela real. O OpenAIProvider do astorlm assume 128.000 tokens. Num modelo local de 8.000 tokens, o otimizador esperaria por uma linha que o modelo nunca alcança.
- Nunca separe uma chamada de ferramenta do seu resultado. Um resultado de ferramenta cuja chamada foi descartada faz a maioria das APIs rejeitar a requisição inteira. Descarte trocas inteiras, de um pedido até o seguinte.
- Truncar só é seguro se a ferramenta puder rodar de novo. Se um resultado não pode ser obtido duas vezes, como um comprovante de pagamento, guarde a parte que importa na resposta, ou salve fora do histórico.
- Diga a quem resume o que precisa sobreviver. Números de pedido, números de peça, decisões. Um resumo que se lê bem ainda pode perder o único fato de que o próximo turno precisa.
- A compactação só conta o que você envia. Estimar quatro caracteres por token é suficiente para decidir quando agir. Deixe espaço suficiente abaixo da linha para a resposta.