Estourar a quota não deveria significar truncar o histórico pelo começo, que é o comportamento padrão da maioria das implementações e o pior de todos. O começo da conversa costuma conter exatamente o que não pode ser perdido: quem é o cliente, qual é o pedido em questão, qual problema originou o atendimento. Cortar pelo começo produz o efeito mais irritante que um agente pode ter, que é esquecer no turno trinta o que o cliente explicou no turno dois.
A alternativa é tratar o histórico como três camadas com prioridades diferentes: fatos duráveis extraídos da conversa, resumo dos episódios antigos e transcrição literal dos turnos recentes. Quando a quota aperta, a transcrição literal é a primeira a encolher, o resumo é comprimido em seguida, e os fatos duráveis nunca saem. É a mesma ideia de um orçamento com fatias reservadas: cada camada tem um mínimo garantido e um máximo, e a folga é distribuída entre elas.
Alocacao da janela sob quota de 8.000 tokens de historico
CONVERSA CURTA (turno 4) CONVERSA LONGA (turno 47)
+----------------------+ +----------------------+
| fatos duraveis 400 | | fatos duraveis 900 | <- nunca cortado
+----------------------+ +----------------------+
| resumo 0 | | resumo 2.600 | <- comprimido
+----------------------+ +----------------------+
| transcricao 1.100 | | transcricao 4.500 | <- janela movel
+----------------------+ +----------------------+
| folga 6.500 | | folga 0 |
+----------------------+ +----------------------+
total: 1.500 total: 8.000 (no teto)
Turno 48 chega e nao cabe:
1. transcricao cede os turnos mais antigos ate o piso de 2.000
2. o que saiu da transcricao entra no resumo (custa 1 chamada barata)
3. o resumo e recomprimido se passar do seu teto de 3.000
4. fatos duraveis seguem intactos: sao 900 tokens que valem mais
que os 4.500 da transcricao inteira
O que o cliente percebe: nada, desde que o passo 2 rode antes do
descarte e nao depois. Resumir o que ja foi jogado fora e o bug
mais comum dessa implementacao.// context/quota.js
// Aloca a janela de historico por camadas com piso e teto por camada.
// Regra central: o que sai da transcricao passa pelo resumo antes de
// ser descartado, nunca depois.
const LAYERS = {
facts: { floor: 200, ceiling: 1200 },
summary: { floor: 0, ceiling: 3000 },
transcript: { floor: 2000, ceiling: Infinity },
};
export function createQuotaAllocator({ countTokens, summarize, metrics }) {
async function allocate({ conversationId, quotaTokens, facts, summary, transcript }) {
const factsTokens = Math.min(countTokens(facts), LAYERS.facts.ceiling);
let summaryText = summary;
let summaryTokens = countTokens(summaryText);
let kept = [...transcript];
const fits = () =>
factsTokens + summaryTokens + countTokens(kept) <= quotaTokens;
// Enquanto nao cabe, os turnos mais antigos saem da transcricao e
// sao absorvidos pelo resumo. O piso da transcricao garante que a
// conversa nao perca o contexto imediato do turno atual.
const evicted = [];
while (!fits() && countTokens(kept) > LAYERS.transcript.floor) {
evicted.push(kept.shift());
}
if (evicted.length > 0) {
// Uma chamada barata, com modelo pequeno: resumir e a operacao
// que preserva o valor do que seria simplesmente descartado.
summaryText = await summarize({ previous: summaryText, turns: evicted });
summaryTokens = countTokens(summaryText);
metrics.counter('context.turns_evicted_total', evicted.length, { reason: 'quota' });
}
// Se mesmo assim nao cabe, o resumo e quem cede: ele e reconstruivel
// a partir da conversa persistida, a transcricao recente nao e.
if (!fits() && summaryTokens > LAYERS.summary.floor) {
summaryText = await summarize({ previous: summaryText, turns: [], targetTokens: Math.max(
LAYERS.summary.floor,
quotaTokens - factsTokens - countTokens(kept),
) });
summaryTokens = countTokens(summaryText);
metrics.counter('context.summary_recompressed_total', 1, {});
}
const usedTokens = factsTokens + summaryTokens + countTokens(kept);
metrics.histogram('context.window_used_tokens', usedTokens, {
at_ceiling: usedTokens >= quotaTokens * 0.95 ? 'yes' : 'no',
});
return { facts, summary: summaryText, transcript: kept, usedTokens, evictedCount: evicted.length };
}
return { allocate, LAYERS };
}
O detalhe que faz essa implementação funcionar é a ordem: resumir antes de descartar. A versão ingênua corta os turnos antigos, envia o prompt e resume depois, o que na prática significa resumir a partir de um histórico que já perdeu o que interessava. Também vale notar que o resumo cede antes da transcrição recente, e não o contrário: o resumo pode ser reconstruído a partir da conversa persistida no banco, enquanto perder os turnos imediatamente anteriores quebra a coerência da resposta atual.