Resolvida a identidade, a pergunta seguinte é o que vai no prompt. A resposta errada é tudo que couber, começando pelo mais recente, porque isso deixa a composição do contexto na mão do acaso: um cliente que mandou vinte áudios transcritos ontem apaga o fato relevante de três meses atrás. A composição precisa ser um orçamento com fatias reservadas por tipo de informação, e cada fatia é preenchida pela sua própria regra de prioridade. Assim, o pior caso deixa de ser a perda do que importa e passa a ser a perda de detalhe dentro de uma categoria.
Orcamento de janela em atendimento multicanal (exemplo com 8k tokens)
instrucao do sistema 800 fixo, nao negociavel
estado operacional 400 ticket aberto, canal, permissoes
fatos duraveis 1.200 ordenados por relevancia ao assunto atual
resumo de episodios 1.600 ultimos episodios, qualquer canal
episodio atual 3.200 turnos recentes deste canal, integral
reserva de saida 800 nao usar, e o teto da resposta
Regra de preenchimento
cada fatia so cede o que sobrou para a fatia seguinte, nunca puxa da anterior
fato duravel nunca e cortado por idade, so por irrelevancia ao assunto
episodio de outro canal entra resumido, nunca em transcricao
se o episodio atual nao cabe, comprime o meio e preserva inicio e fim
A regra de que uma fatia cede o excedente para a seguinte, mas nunca invade a anterior, é o que impede o efeito mais comum em produção: o histórico recente engolindo o espaço dos fatos. Ela também dá uma propriedade útil de operação, que é a previsibilidade do custo. Com fatias fixas, o token gasto por turno tem teto conhecido independentemente de quanto o cliente falou, e a fatura para de ser função do cliente mais falante. Sem isso, um único usuário com uma conversa de duzentos turnos define o custo médio da sua operação inteira.
// context-window.js
// Montagem da janela por orcamento com fatias por tipo. Cada fatia gasta
// o que precisa e passa a sobra adiante; nenhuma invade a anterior.
const BUDGET = {
systemInstruction: 800,
operationalState: 400,
durableFacts: 1200,
episodeSummaries: 1600,
currentEpisode: 3200,
};
// Aproximacao suficiente para orcamento. Use o tokenizer do provedor
// quando a margem importar mais que a latencia da contagem.
const estimateTokens = (text) => Math.ceil(text.length / 3.6);
const fill = (items, budget) => {
const selected = [];
let used = 0;
for (const item of items) {
const cost = estimateTokens(item.text);
if (used + cost > budget) continue; // pula o que nao cabe, segue tentando os menores
selected.push(item);
used += cost;
}
return { selected, used };
};
export const buildWindow = ({ channel, subject, state, facts, summaries, episode, scopes }) => {
let spare = 0;
// Fatos duraveis: ordenados por relevancia ao assunto atual, filtrados
// pelo escopo que o nivel de vinculo autoriza a expor.
const visibleFacts = facts
.filter((fact) => scopes.includes(fact.scope))
.sort((a, b) => relevance(b, subject) - relevance(a, subject));
const factsFill = fill(visibleFacts, BUDGET.durableFacts);
spare += BUDGET.durableFacts - factsFill.used;
// Resumos de episodios: mais recentes primeiro, de qualquer canal.
// O canal de origem entra no texto porque muda como a informacao e lida.
const summaryItems = summaries
.slice()
.sort((a, b) => b.endedAt - a.endedAt)
.map((item) => ({ ...item, text: `[${item.channel}] ${item.text}` }));
const summariesFill = fill(summaryItems, BUDGET.episodeSummaries + spare);
spare = BUDGET.episodeSummaries + spare - summariesFill.used;
// Episodio atual: preserva o inicio, onde o cliente disse o que quer,
// e o fim, que e o estado da conversa. O meio e o que se comprime.
const currentBudget = BUDGET.currentEpisode + spare;
const current = compressMiddle(episode.turns, currentBudget, estimateTokens);
return {
channel,
blocks: {
state,
facts: factsFill.selected,
summaries: summariesFill.selected,
turns: current.turns,
},
estimatedTokens:
BUDGET.systemInstruction +
BUDGET.operationalState +
factsFill.used +
summariesFill.used +
current.used,
droppedTurns: current.dropped,
};
};
// Corta do meio para fora, mantendo os primeiros e os ultimos turnos.
const compressMiddle = (turns, budget, estimate) => {
const costs = turns.map((turn) => estimate(turn.text));
const total = costs.reduce((a, b) => a + b, 0);
if (total <= budget) return { turns, used: total, dropped: 0 };
const kept = [];
let used = 0;
let head = 0;
let tail = turns.length - 1;
// Alterna entre inicio e fim para nao enviesar o corte para um dos lados.
while (head <= tail) {
const takeTail = kept.length % 2 === 1;
const index = takeTail ? tail : head;
if (used + costs[index] > budget) break;
kept.push({ index, turn: turns[index] });
used += costs[index];
if (takeTail) tail -= 1;
else head += 1;
}
kept.sort((a, b) => a.index - b.index);
return {
turns: kept.map((item) => item.turn),
used,
dropped: turns.length - kept.length,
};
};
const relevance = (fact, subject) => {
const overlap = fact.tags.filter((tag) => subject.tags.includes(tag)).length;
const ageDays = (Date.now() - fact.confirmedAt) / 86_400_000;
return overlap * 10 - Math.log1p(ageDays);
};
O detalhe do canal de origem entrar no texto do resumo não é cosmético. O modelo precisa saber que aquele trecho veio do telefone para tratar a transcrição com a incerteza que ela merece, e precisa saber que veio do chat web para confiar no número de pedido que foi digitado. Sem essa marca, a transcrição imprecisa de um áudio ruim entra no prompt com o mesmo peso de um dado que o cliente conferiu na tela, e o modelo repete o erro com confiança total.