
← Voltar ao blog
Desenho de sistema para Lucas AI: prompts em camadas, recuperação lexical, memória de sessão, orçamentos de tokens e um classificador pré-vôo em uma superfície de bate-papo de portfólio limitada.
Este site é um portfólio, mas a superfície do Lucas AI foi intencionalmente construída como um pequeno produto: uma experiência dedicada /{locale}/ai, gasto previsível e respostas que permanecem em primeira pessoa apenas a partir de contexto estruturado — não um assistente genérico com ambição de escala web.
Este post é um mergulho técnico profundo em como esse sistema é estruturado: o que é montado por solicitação, por que é em camadas dessa forma e quais restrições são deliberadas.
Portfólios estáticos respondem bem a perguntas únicas, mas falham em profundidade. Perguntas de acompanhamento (“como você definiu isso?”, “qual foi a pilha de tecnologia?”) ou desaparecem em PDFs ou forçam um loop humano.
Integrações de bate-papo ingênuas corrigem o modelo de interação, mas criam novas falhas: contexto ilimitado, empregadores inventados, escopo silencioso e todas as perguntas cobradas contra o maior modelo que você configurou. Um portfólio não precisa de um chatbot de mundo aberto; ele precisa de uma interface estreita com uma base de fatos auditável.
Lucas AI fica nessa lacuna: UX limitada, conhecimento limitado, limites explícitos sobre o que “Lucas” pode reivindicar.
De ponta a ponta, uma solicitação ainda é um POST /api/chat com JSON (message, locale, sessionSummary / recentTurns opcionais). A rota possui política e gasto; o navegador possui UI de transcrição e persistência do lado do cliente (sessionStorage).
Camadas, em ordem de execução:
llama-3.1-8b-instant por padrão), não-streaming, max_tokens: 5, temperature: 0, veredito de uma palavra: OFF_TOPIC vs prosseguir. Se OFF_TOPIC, a API retorna mock SSE com uma recusa localizada para que o caminho do cliente corresponda a um fluxo normal. Em caso de erro ou tempo limite do classificador, o manipulador falha aberta e executa o modelo principal — portões baratos não devem bloquear tráfego legítimo. Os hosts podem definir CHAT_SKIP_CLASSIFIER para ignorar essa chamada inteiramente ao testar carga ou quando reduzir chamadas de provedor vale a pena perder o atalho.buildChatPrompt compõe a mensagem sistema; o modelo principal recebe sistema + mensagem do usuário atual (não um histórico de bate-papo com várias mensagens na linha).POST …/v1/chat/completions (padrão llama-3.3-70b-versatile no Groq, substitua GROQ_MODEL), resposta proxy como SSE para o cliente. max_tokens limitado via CHAT_MAX_TOKENS (256–8192, padrão 2048) liga UX à economia unitária.sessionStorage; cada solicitação pode anexar um resumo aparado e uma fatia de turnos recentes para que o servidor mantenha a continuidade sem armazenar bate-papo no lado do servidor.Alinhamento de locale (ainda política do lado do cliente): ao enviar, franc-min classifica a digitação do visitante (comprimento mínimo ~15 caracteres). Se o idioma detectado não corresponder ao mapeamento ISO 639-3 esperado do locale do site atual, a UI não chama silenciosamente a API — ela mostra um cartão de oferta para mudar de locale (router.push para o {locale}/ai correspondente) ou continuar no idioma atual. Isso mantém o locale no corpo JSON alinhado com o idioma que o usuário está realmente escrevendo, em vez de forçar o modelo a adivinhar. Uma mensagem pendente sobrevive a uma troca de locale via sessionStorage e envio automático após a montagem.
A mudança arquitetônica importante é a separação: lei central estável (quem é Lucas AI, voz, regras de aterrissagem) vs fragmentos dinâmicos (bloco de sessão, trechos recuperados, bloco de FAQ canônico opcional). Essa divisão mantém o prompt auditável e dá ao cache de prompt uma chance de lutar porque o prefixo permanece estável enquanto apenas o contexto relevante muda por turno.
O desenho evita “enviar tudo sempre”. Incorporar o perfil estruturado completo em cada solicitação escala mal: o custo de token cresce com o corpus, perguntas não relacionadas pagam por fatos não relacionados e você se aproxima dos limites do provedor à medida que o perfil cresce.
Em vez disso:
systemPrompt (corePromptText.ts) — regras estáveis: primeira pessoa, transparência (“não digitando ao vivo”), lei de aterrissagem, regras de matemática de duração, comportamento de locale. Mantido mínimo e fixo para que o cache de prefixo (onde o provedor deduplica o texto do sistema de abertura) tenha uma chance de ajudar.CONTEXT — anexados após o prefixo compartilhado, claramente delimitados (--- CONTEXT (canônico / FAQ) ---, memória de sessão, trechos recuperados) e fechados com --- Fim dos fragmentos de CONTEXT --- mais uma linha Locale do visitante: … para que o modelo responda no idioma do site.Separação de domínio é aplicada no momento da criação do trecho, não apenas na prosa. Um único objeto lucasPersonalContext é a fonte da verdade; buildKnowledgeChunkIndex deriva trechos com rótulos KnowledgeDomain (core_bio, mindset, working_style, strengths, experience, duration, portfolio_*, boundaries, etc.). Isso dá à recuperação uma superfície tipada para pontuar contra em vez de uma parede de texto indiferenciada.
O que o modelo é permitido tratar como fato é sempre o que os fragmentos inseriram nessa solicitação — escopo testável, não uma vibe.
Existem duas ideias diferentes de “roteamento” na pilha; confundi-las perde o desenho.
Intenção heurística (routeIntent em router.ts) não é uma chamada LLM. É uma classificação conduzida por palavras-chave e regex em baldes grosseiros (recruiter, engineering, project, blog, portfolio_meta, navigation_contact, conversational, general). Essa intenção apenas influencia a pontuação de trechos lexicais — a latência permanece plana, o custo é zero.
Exemplos:
navigation_contact — impulsiona indiretamente o comportamento adjacente ao contato por meio de audiências de trecho.portfolio_meta — impulsiona domínios portfolio_* em selectChunks.recruiter — impulsiona duration, strengths, trechos marcados por recrutador.conversational — evita sobre-buscar blocos de experiência pesados.Compromisso: heurísticas erram na rota. A recuperação compensa com um fallback: se nada pontuar acima de zero, injeta core-bio + boundaries para que o modelo ainda tenha aterrissagem mínima.
Classificador de tópico pré-vôo é a porta LLM: CAREER vs OFF_TOPIC com um prompt de sistema dedicado (buildClassifierPrompt). Isso é política (“isso é realmente o produto certo?”), não ajuste de recuperação. É mais flexível do que regex, mas custa uma viagem extra; portanto, ignora sinalizadores e comportamento de falha aberta.
“RAG” geralmente implica incorporações e um banco de dados vetorial. Esta implementação não usa esses. A recuperação é lexical: tokeniza a mensagem do usuário em um conjunto de palavras, pontua cada trecho por hits de palavras-chave + sobreposição de substring no texto do trecho, adiciona impulsos baseados em intenção (por exemplo, tags de audiência de recrutador, impulsiona domínios portfolio_* em selectChunks), classifica, então pega os principais trechos sob limites rígidos.
Trechos são unidades semânticas pequenas em relação ao perfil completo: por exemplo, um trecho por empregador (compactExperience), trechos separados para pipeline i18n vs recurso de bate-papo vs fatos de monorepo. Trechos pequenos importam porque:
maxChunkChars) sem descartar o perfil inteiro.Consultas em forma de blog são despriorizadas na recuperação (intent === "blog" aplica um viés negativo) porque o MDX do blog não é indexado como trechos de conhecimento aqui. O sistema não finge que a recuperação de artigo completo existe quando o corpus não está presente.
Relevância é código transparente: peso de palavra-chave 3, peso de token-no-corpo 0.35, impulsos de audiência/intenção por cima. Isso é fácil de auditar e barato de executar; o compromisso é sinonímia e paráfrase — se o usuário nunca sobrepõe lexicalmente o conteúdo do trecho, você depende de trechos de fallback.
Implementações ingênuas reproduzem todo o histórico ou incorporam todo o corpus. Ambos explodem custo e latência.
Estratégias concretas no código:
estimateTokens — heurística de comprimento de caractere / 4; nenhuma dependência de tokenizer; bom o suficiente para orçamento, não para contabilização de nível de cobrança.maxSessionBlockTokens: o resumo rolante + turnos recentes formatados são aparados iterativamente (fatia até 85% até abaixo do limite).maxRecentTurnPairs pares (padrão 3), cada mensagem limitada a maxCharsPerTurn (padrão 700). O cliente envia até oito mensagens anteriores; o servidor impõe o orçamento mais rigoroso.snippet do Usuário + snippet do Assistente), limitado pelo cliente (~2500 caracteres), então o servidor sanitizeSessionSummary (maxSessionSummaryChars, padrão 900). Nenhuma sumarização LLM no lado do servidor — custo previsível, mas barulhento se os usuários colarem paredes de texto (mitigado por limites).maxRetrievedChunks (padrão 5), orçamento maxRetrievedTokens (padrão 2200 estimado), parada antecipada quando adicionar outro trecho excederia o orçamento (com um piso de pelo menos dois trechos quando possível).id em um Set para que o mesmo bloco de empregador não seja duplicado.compressChunkText trunca com reticências quando um trecho excede maxChunkChars.maxSystemChars trunca a string do sistema montada com um marcador visível [sistema truncado].A tensão do produto é sempre riqueza de contexto vs custo. Os padrões intencionalmente favorecem prompt menores; os operadores ajustam por meio de variáveis de ambiente CHAT_MAX_*. CHAT_DEBUG_METRICS=1 registra uma linha JSON por solicitação (intenção, contagens de trecho, detalhamento aproximado de token) para que você possa ver o orçamento em produção sem adivinhar.
A reprodução completa do bate-papo no sistema não escala: o custo de token cresce linearmente com o comprimento da conversa e threads longas afogam o sinal recuperado.
Esta pilha usa um híbrido:
A continuidade é portanto melhor esforço, não reprodução literal — apropriada para uma superfície de perguntas e respostas de portfólio, não programação em par.
Circuito curto de FAQ canônico (tryCanonicalAnswer) — uma pequena biblioteca de padrões para perguntas recorrentes (“quem é você”, “por que Lucas AI”, “principais pontos fortes”, experiência pesada em frontend). Em caso de acerto, um bloco denso ### Fatos canônicos é injetado e a recuperação é limitada com mais força (fatia para no máximo três trechos) para evitar tokens redundantes.
Estatuto vs dinâmico é explícito na ordem de montagem: systemPrompt + data, então seções dinâmicas opcionais, então marcadores de fim + linha de locale. Essa ordem é deliberada para cache e para diffing humano quando o CONTEXT muda.
Classificador vs principal — o prompt do classificador é estritamente limitado (saída de uma palavra); o prompt principal carrega a lei comportamental completa. Manter esses separados evita a “deriva do classificador útil” e mantém a mensagem do sistema do grande modelo focada em voz e aterrissagem.
Não construído intencionalmente:
Simplificado de propósito:
Extensões futuras (se necessário): incorporações para conteúdo de blog uma vez indexado; armazenamento de sessão no lado do servidor se a continuidade entre guias deve ser confiável sem o cliente; reordenador se a cardinalidade do trecho crescer o suficiente para que a pontuação lexical se sinta frágil.
Lucas AI é melhor lido como uma superfície de produto com uma especificação: o objeto lucasPersonalContext e trechos derivados são o contrato, o sistema é a lei, os orçamentos de token são guardrails e o classificador mais o caminho de fluxo de tópico fora definem o que o produto se recusa a ser.
Se você está construindo algo semelhante, a alavanca não é o maior modelo geral — é decidir o que entra no prompt a cada turno e medir. Um portfólio não precisa de contexto infinito; ele precisa de respostas honestas e limitadas — e uma arquitetura chata o suficiente para mantê-las dessa forma.