TL;DR
Último artigo da série sobre construir o Bloom, um agente de coaching multiagente, em uma semana. Três temas se cruzam aqui: memória, modelos menores e validação.
- Memória: SQLite com fatos estruturados + resumos periódicos supera busca vetorial na escala do Bloom. Extração roda em background (fire-and-forget) para não travar a resposta, e a compressão dispara por limiar de fatos acumulados, não a cada turno. Mas o achado mais importante foi outro: o caminho de leitura — injetar fatos recentes e o último resumo no contexto — gerou mais sensação de continuidade do que qualquer refinamento na escrita. Histórico de conversa (curto prazo) e memória de fato/resumo (longo prazo) são coisas distintas, e confundi-las esconde a causa real de bugs de coerência.
- Modelos menores: trocar para modelos locais menores revelou que tamanho e instruction-following não escalam de forma linear — um salto pequeno de parâmetros pode significar a diferença entre vazamento de scaffold cru no chat e uma resposta limpa. Modelos pequenos também são mais propensos a fixação de script, mesmo diante de instrução explícita do usuário, e “calor” no tom (emoji, simpatia) não é o mesmo eixo que verbosidade — um pode vir sem o outro. Lição prática: ao trocar de modelo, rode de novo os transcripts de avaliação, porque nada garante que um comportamento correto sobreviva à troca.
- Validação: antes de qualquer reescrita grande, transcripts reais viraram um harness de avaliação com rubrica fixa, julgado por um LLM-como-juiz — transformando “parece melhor” em regressão mensurável. Mudanças foram estagiadas (prompt, depois tools, depois memória) para isolar causa e efeito, e telemetria passou a ser por chamada de LLM, não por turno, já que loops e delegação tornaram essa granularidade essencial.
A régua comum por trás de tudo: decidir deliberadamente o que é responsabilidade do código e o que é responsabilidade do julgamento do modelo — e verificar ativamente se essa fronteira não se moveu sem ninguém perceber.
Memória, pequenos modelos e como validar tudo isso
Artigo 4 de uma série sobre o que aprendi construindo um agente de coaching multiagente em uma semana
Fechei o artigo anterior com uma pergunta em aberto: quando o modelo por trás do agente muda, de um LLM grande para um modelo local menor, o equilíbrio cuidadosamente construído no prompt sobrevive à troca, ou regride silenciosamente?
Este último artigo da série reúne três assuntos que, à primeira vista, parecem distintos: como o Bloom lembra do aprendiz entre conversas, o que muda de fato quando o modelo por trás fica menor, e como saber — com evidência, não com sensação — se uma mudança de engenharia realmente melhorou o sistema. Os três se conectam por uma pergunta comum, que também é a pergunta que atravessou toda esta série: como validar decisões em um sistema que é, por natureza, probabilístico?
Memória não precisa de banco vetorial para parecer lembrança
A primeira tentação, ao pensar em “memória de longo prazo” para um agente, é ir direto para busca vetorial semântica. Para o Bloom, isso seria um exagero de engenharia. Na escala de um usuário por implantação, registros estruturados simples em SQLite, combinados com resumos periódicos gerados por LLM, dão conta de um histórico de escala anual sem qualquer necessidade de infraestrutura vetorial.
A busca vetorial é indicada para corpus genuinamente grande e que realiza busca semantica. Não é o default sensato para qualquer sistema com memória — é uma ferramenta específica, que precisa ser justificada pela escala real do problema, não adotada por reflexo.
Extração de memória em fire-and-forget
Depois de cada turno de conversa (ecoando no onboarding), uma chamada assíncrona em background extrai fatos estruturados da conversa — preferência, barreira, progresso, insight — e grava em uma tabela de memórias do usuário. Essa extração roda fora do caminho crítico de latência: o usuário nunca espera por ela para receber sua resposta.
É um padrão simples que separa duas responsabilidades: responder rápido agora, e acumular conhecimento sobre a pessoa ao longo do tempo. Misturar as duas coisas significa pagar, na latência de resposta, por um trabalho que nunca precisou estar no caminho crítico.
Veja como o MemoryService implementa a função extractAndStore como um worker assíncrono em segundo plano no memory.service.ts:
export class MemoryService {
// ...
public extractAndStore(userId: string, messages: any[], agentName: string): void {
// Executado em um escopo assíncrono interno sem await para evitar bloquear o fluxo de resposta
const run = async () => {
const conversation = messages
.map(m => `${m.role === 'coach' ? 'coach' : 'learner'}: ${m.content}`)
.join('\n');
const raw = await llmService.generate(EXTRACTION_PROMPT, conversation, []);
const cleaned = stripJsonMarkdown(raw);
let facts: Array<{ fact_type: string; content: string; confidence: number }>;
try {
facts = JSON.parse(cleaned);
if (!Array.isArray(facts)) return;
} catch {
return; // skip silencioso em caso de resposta LLM malformada
}
const validTypes = new Set(['preference', 'barrier', 'progress', 'insight']);
for (const fact of facts) {
if (!fact.fact_type || !fact.content || !validTypes.has(fact.fact_type)) continue;
await LearnerMemoryModel.create({
id: crypto.randomUUID(),
user_id: userId,
fact_type: fact.fact_type as 'preference' | 'barrier' | 'progress' | 'insight',
content: String(fact.content).slice(0, 500),
confidence: Math.min(1.0, Math.max(0.0, Number(fact.confidence) || 0.8)),
source_agent: agentName,
});
}
};
// Executa o processamento em background imediatamente e captura eventuais falhas
run().catch(err => console.error('[Memory] Extraction failed:', err));
}
}
Compressão disparada por limiar, não por turno
O Bloom só resume os fatos acumulados assim que atingem um teto (5 fatos brutos), em vez de resumir a cada turno ou deixar que acumulem sem controle. Isso evita ambos os extremos ruins: resumir a cada turno é custoso e redundante; nunca resumir gera uma pilha de fatos brutos ilegível e cara de reinjetar no contexto depois.
Veja como o summarize no MemoryService controla o gatilho de compressão:
const MIN_FACTS_TO_SUMMARIZE = 5;
const ARCHIVE_AFTER_DAYS = 30;
public async summarize(userId: string): Promise<void> {
const latestSummary = await MemorySummaryModel.findLatest(userId);
const since = latestSummary ? new Date(latestSummary.period_end) : new Date(0);
// Verifica se atingimos o limite de fatos
const facts = await LearnerMemoryModel.findSince(userId, since);
if (facts.length < MIN_FACTS_TO_SUMMARIZE) return; // Sai mais cedo se estiver abaixo do teto
const factLines = facts.map(f => `[${f.fact_type}] ${f.content}`).join('\n');
const summaryText = await llmService.generate(SUMMARIZATION_PROMPT, factLines, []);
// Grava o novo resumo narrativo
await MemorySummaryModel.create({
id: crypto.randomUUID(),
user_id: userId,
period_start: since.toISOString().split('T')[0],
period_end: new Date().toISOString().split('T')[0],
summary_text: summaryText.trim(),
fact_count: facts.length,
});
// Arquiva fatos antigos processados
await LearnerMemoryModel.archiveOlderThan(userId, ARCHIVE_AFTER_DAYS);
}
O caminho de leitura é a metade que mais importa
Se eu tivesse que escolher, entre escrita e leitura de memória, qual construir primeiro, a resposta que aprendi com o Bloom é leitura. Injetar “fatos recentes + último resumo” no contexto de cada especialista foi, isoladamente, a mudança que mais gerou sensação de continuidade na conversa — mais do que qualquer refinamento na extração ou na compressão.
Isso é contraintuitivo se você pensa em memória principalmente como um problema de capturar informação. Na prática, é um problema de usar essa informação no momento certo. Se você só puder construir uma direção primeiro, construa a leitura antes da escrita.
Veja o funcionamento do caminho de leitura por meio de injeção de contexto via MemoryService.buildContext:
public async buildContext(userId: string): Promise<string> {
const [profile, recentFacts, latestSummary] = await Promise.all([
LearnerProfileModel.findByUserId(userId),
LearnerMemoryModel.findRecent(userId, RECENT_DAYS, RECENT_LIMIT),
MemorySummaryModel.findLatest(userId),
]);
if (!profile) return '';
const details = [
profile.weekly_time_budget_hours != null ? `${profile.weekly_time_budget_hours}h/week` : null,
profile.best_time != null ? `best time: ${profile.best_time}` : null,
profile.confidence_score != null ? `confidence: ${profile.confidence_score}/10` : null,
].filter((d): d is string => d != null);
const goalLine = `**Goal:** ${profile.primary_goal} (${profile.goal_category})`
+ (details.length > 0 ? ` · ${details.join(' · ')}` : '');
const parts: string[] = [`## Learner Context\n${goalLine}`];
// Injeta fatos atômicos recentes
if (recentFacts.length > 0) {
const lines = recentFacts.map(f => `- [${f.fact_type}] ${f.content}`).join('\n');
parts.push(`**Recent patterns:**\n${lines}`);
}
// Injeta o resumo narrativo consolidado mais recente
if (latestSummary) {
parts.push(`**Historical summary (${latestSummary.period_start} – ${latestSummary.period_end}):**\n${latestSummary.summary_text}`);
}
return parts.join('\n\n');
}
Duas memórias diferentes, que não podem ser confundidas
O Bloom precisou de dois tipos de memória, e tratá-los como a mesma coisa teria sido um erro:
Histórico de conversa persistente — as últimas mensagens passadas em toda chamada ao LLM, para que “sim” ou “opção A” resolvam corretamente de um turno para o outro.
Memória de fato e resumo de longo prazo — um armazenamento separado, de horizonte muito mais longo, que sobrevive além da sessão atual.
Faltar só a primeira já quebra a coerência básica do diálogo — o sistema literalmente não entende a que “sim” o usuário está respondendo — independentemente de qualquer trabalho sofisticado feito na segunda. São migrações distintas, e tratá-las junto obscurece qual delas está causando qual problema.
Tamanho do modelo e instruction-following não são lineares
Ao comparar modelos menores e locais — pensando em contornar rate limits e custo — apareceu um dado que vale a pena carregar como lição para qualquer decisão futura de LLM: um modelo de 1 bilhão de parâmetros produziu vazamento visível de scaffold cru no chat (literalmente texto como “Here is my plan for you…” aparecendo para o usuário). Um modelo de cerca de 2 bilhões de parâmetros lidou com o mesmo system prompt de forma bem mais limpa.
A conclusão prática é que um salto aparentemente pequeno em tamanho de modelo pode significar uma diferença categórica de confiabilidade, não uma melhora suave e proporcional. Não dá para extrapolar linearmente ‘um pouco maior, um pouco melhor’.
Modelo local bom, porém lento, pode quebrar a experiência
Um modelo local mais pesado pode entregar respostas de qualidade equivalente e, ainda assim, fazer o produto parecer quebrado. Isso acontece porque, em uma aplicação conversacional como um chat, a latência é por sí só uma entrave na experiência, independentemente da qualidade do conteúdo da resposta. Isso ecoa diretamente a solução de streaming do artigo 2: multiplicar chamadas de LLM (por loops, por delegação) ou trocar para um modelo mais lento tem o mesmo efeito perceptível do outro lado da tela, e exige o mesmo tipo de mitigação.
Modelos pequenos são mais propensos a fixação de script
Ao testar modelos menores como llama3.2 ou gemma2, percebi que um deles se fixou em uma linguagem de scaffolding do próprio prompt, assumindo um enquadramento de carreira/profissional, mesmo depois de o usuário declarar, em linguagem simples e direta, um objetivo completamente diferente e não relacionado a trabalho.
Dica prática: ao trocar de modelo, rode de novo os transcripts de avaliação. Um comportamento que se sustentava perfeitamente bem em um modelo pode regredir silenciosamente em outro, especialmente qualquer coisa que dependa do modelo pesar corretamente o que o usuário disse contra o scaffolding do próprio prompt. É fácil assumir que, se o comportamento estava certo antes, vai continuar certo depois de uma troca de modelo. Essa suposição é exatamente o tipo de fronteira ‘decidido pelo código vs. decidido pelo modelo’, do primeiro artigo desta série, sendo cruzada silenciosamente, só que dessa vez pela troca do próprio modelo, e não por uma mudança de arquitetura ou de prompt.
Verbosidade e formatação são um eixo independente de capacidade
Um dos modelos testados era perceptivelmente “mais caloroso” — mais emoji, tom mais amigável — mas esse calor vinha empacotado com respostas muito mais longas e superestruturadas: bullets aninhados, blocos inteiros em negrito, quase um relatório em vez de uma fala de chat. Foi preciso instrução explícita de concisão para domar isso.
Esse achado conecta direto com o ponto do artigo anterior sobre limites suaves versus rígidos. Ali, o mesmo problema (resposta virando um muro de texto estruturado) apareceu por causa da ausência de um teto de tamanho no prompt. Aqui, aparece por causa de uma característica intrínseca do modelo em si. Duas causas raiz diferentes, o mesmo sintoma — o que reforça por que “diagnosticar a camada certa” (o próximo ponto) é tão importante.
Diagnosticar a camada certa antes de refatorar
Algumas interações estavam parecendo rígidas demais, a ponto de serem meio “robóticas”, com formatos estranho para um chat. Como princípio desenvovido no projeto, a primeira pergunta que passei a fazer foi: em qual camada está a causa: arquitetura, prompt, ou modelo de dados? No meu caso, as causas específicas eram de prompt e sem envolver qualquer mudança de arquitetura, o problema pode ser solucionado.
Estagiar mudanças para poder validar cada camada isoladamente
O refactor do Bloom seguiu uma sequência deliberada: prompts primeiro, depois o loop de tools, depois a camada de memória. Cada etapa foi validada isoladamente antes de passar para a próxima. Isso deixa de ser “burocracia” pois permite atribuir corretamente uma regressão (ou uma melhora) à mudança certa. Empacotar uma reescrita de prompt junto com uma mudança de arquitetura na mesma rodada de avaliação é a receita perfeita para nunca saber qual das duas causou o quê.
Construir o harness de avaliação antes de reescrever
Antes de qualquer reescrita maior, os transcripts reais de produção do Bloom, mesmo os insatisfatórios, viraram um conjunto fixo de avaliação, com uma rubrica cobrindo naturalidade, continuidade, aderência à estratégia conversacional pretendida, taxa de conselho prematuro e tom. Um LLM-como-juiz (LLM-as-a-judge) roda essa rubrica contra a versão “antes” e cada versão “depois”.
Isso transforma “parece melhor”, uma sensação subjetiva e perigosamente fácil de confundir com viés de confirmação, numa checagem de regressão de fato mensurável.
Revisão pós-lançamento: procurar o padrão raiz, não corrigir bug a bug
Uma revisão estruturada, depois do lançamento, encontrou quatro defeitos que pareciam completamente separados: problemas de grounding de dados, falta de honestidade sobre o estado real da sincronização de calendário, um gate de onboarding avançando sem resposta genuína, e o override de roteamento que descrevi no primeiro artigo desta série.
Os quatro eram instâncias do mesmo padrão: fabricar ou pular informação em vez de se ancorar no que de fato era conhecido. Corrigir a classe do padrão — e não cada sintoma isoladamente — é mais durável, e é também um lembrete de que vale a pena, depois de um lançamento, procurar deliberadamente por esse tipo de fio comum em vez de simplesmente fechar tickets um a um.
Telemetria por chamada, não só por turno
Assim que o número de chamadas de LLM por turno vira uma variável — por causa de loops ReAct e delegação, como vimos no artigo 2 — uma métrica única de custo ou latência “por turno” perde sentido sem granularidade por chamada individual. O Bloom passou a registrar latência, status, provedor e contagem de tokens por chamada (inclusive as chamadas dentro de um loop de tools), com endpoints para inspecionar e resetar essa telemetria durante o desenvolvimento. Sem um store inspecionável, não apenas logs, essa granularidade vira ruído em vez de sinal.
Dois níveis de teste, nenhum substitui o outro
Por fim, testes unitários por componente pegam uma tool quebrada ou uma regressão de parsing de prompt; testes end-to-end por fluxo completo pegam um bug de roteamento ou de transição de estado que só aparece atravessando múltiplos turnos de conversa. Um sistema agentico precisa dos dois níveis, já que nenhum sozinho, cobre o espaço de falhas do outro.
Fechando a série
Memória, escolha de modelo e processo de validação são, no fundo, sobre a mesma disciplina que abriu esta série: decidir deliberadamente o que é responsabilidade de código determinístico e o que é responsabilidade de julgamento do modelo — e verificar, ativamente, que essa fronteira não se moveu sem ninguém perceber.
Olhando para trás, quase todo bug feio ao longo da construção do Bloom-for-Learning — o override de estado do artigo 1, o vazamento de prompt em caminho de fallback do artigo 3, a categoria alucinada que o onboarding evitava forçar, a fixação de script de um modelo pequeno que vimos aqui — foi essa mesma fronteira sendo cruzada sem intenção, em contextos diferentes: roteamento, tom, memória, escolha de modelo.
O relatório do capstone termina com uma lista de trabalhos futuros que acho que vale mencionar como gancho de curiosidade, mais do que como promessa:
- estudos longitudinais de adesão comparando o Bloom contra um simples tracker de calendário
- modelos on-device menores (pensando em algo como Gemma 2B) para privacidade total e custo mais baixo
- expansão do suporte MCP para gerenciadores de tarefas e repositórios de documentos.
Não sei ainda se algum desses caminhos vira o próximo projeto. Mas o aprendizado real desta semana de construção não foi nenhuma feature específica do Bloom — foi essa régua que atravessou os quatro artigos, e que pretendo levar comigo para o próximo sistema agentico que eu construir, seja ele qual for.
