21 KiB
Análise de Velocidade — Secretária Virtual (WhatsApp + PDF)
Data: 2026-05-05 | Atualizado: 2026-05-05 (fixes aplicados — seção 9) Contexto: A lentidão nas respostas não é só inconveniência de UX — ela aumenta diretamente o risco de alucinações da IA. Veja seção 1.
1. Por que velocidade afeta alucinações
Quando a resposta demora, três caminhos de degradação se abrem:
| Causa | Mecanismo | Consequência |
|---|---|---|
| Timeout na extração do PDF | Sistema cai no fallback → LLM não recebe o conteúdo real | IA inventa dados que não estão no documento |
| Fallback de provider (Gemini → OpenAI → Claude) | Cada falha consume tempo; o 3º provider recebe um prompt diferente | Inconsistência de persona e tom |
| Sumarização na 5ª mensagem | 2 chamadas LLM encadeadas; se a 2ª expira, o resumo some | Próximas respostas sem contexto da conversa |
| Tool calling em loop (até 5 iterações) | Cada rodada = +2–4s; se a última expira, a resposta é truncada | Resposta cortada ou genérica sem dados reais |
| Fetch de mídia chegando vazio | extractPdfText retorna null → effectiveBody fica vazio |
IA não sabe o que o cliente enviou e inventa |
Regra geral: quanto mais o sistema espera por dados externos (LLM, DB, motor), maior a janela para timeout e maior a chance de o LLM responder com contexto incompleto.
2. Arquitetura do fluxo (visão atual)
WhatsApp
└─► Motor (newwhats.local:8008)
├─► setImmediate: downloadAndPersistMedia() ← assíncrono
└─► HTTP POST webhook → satélite (sistema-nuvem:4001)
│
├── 1. Log em nw_event_logs (DB)
├── 2. getPluginConfig() ← DB query TODA mensagem
├── 3. [PDF] fetchMediaBuffer() ← retry 4x, até 6.5s
├── 4. [PDF] extractPdfText() ← Gemini → OpenAI → Claude
├── 5. Follow-up detector (DB)
├── 6. Number role lookup ← HTTP ao motor (toda msg)
├── 7. Cooldown check (DB)
├── 8. Anti-loop check (DB)
├── 9. Smart-router (DB)
└── 10. autoReply()
├── buildContext() (DB, sequencial)
├── resolvePersona() (DB, sequencial)
├── brainRows injection (DB, sequencial)
└── POST /api/ext/v1/secretaria/ask
└─► Motor: brain.chat()
├── sec_conversations (DB)
├── sec_agents (DB, sequencial)
├── buildSystemPrompt()
│ ├── sec_brain_nodes (DB)
│ └── getCalendarContext() (DB, dentro do loop)
├── sec_messages (DB)
├── sec_messages INSERT user
├── sec_brain_nodes persona (DB, repetido!)
├── LLM call com tools (até 5 iterações)
├── sec_messages INSERT assistant
├── sec_messages COUNT
├── [se 5ª/10ª msg] summarize() ← outro LLM!
└── sec_conversations UPDATE
3. Gargalos identificados
🔴 CRÍTICO — fetchMediaBuffer (até 6,5s de espera pura)
Arquivo: sistema-nuvem/plugins/newwhats/media-transcribe.js:25–26
const DOWNLOAD_RETRIES = 4
const DOWNLOAD_BACKOFF = [500, 1000, 2000, 3000] // soma: 6.5s
Causa raiz: O motor dispara o webhook e o download de mídia simultaneamente
via setImmediate (MessageHandler.ts:547). O satélite começa a pedir o arquivo
com apenas 500ms de espera, mas o motor geralmente leva 800ms–2s para concluir
o download do WhatsApp.
Resultado prático: 3 das 4 tentativas são 404. O sistema espera
500 + 1000 + 2000 = 3500ms antes de conseguir o arquivo — mesmo quando o
download terminou cedo.
Impacto em alucinações: se todas as retries falharem, extractPdfText retorna
null, o webhook-receiver aborta (return) e a mensagem é ignorada silenciosamente.
O cliente fica sem resposta; se mandar outra mensagem cobrando, o contexto do PDF
se perde e a IA responde sem os dados reais do documento.
🔴 CRÍTICO — extractPdfText é sequencial ao context-builder
Arquivo: sistema-nuvem/plugins/newwhats/webhook-receiver.js (fluxo de DOCUMENT)
// Atual — sequencial:
const pdfText = await extractPdfText(...) // 2–8s (download + LLM)
// ... só depois:
const ctx = await buildContext(...) // ~200ms — poderia rodar em paralelo
const persona = await resolvePersona(...) // ~100ms — poderia rodar em paralelo
const brainRows = await dbq(...) // ~100ms — poderia rodar em paralelo
buildContext, resolvePersona e brainRows não dependem do texto do PDF.
Os ~400ms deles poderiam ser absorvidos dentro do tempo de extractPdfText.
🟠 ALTO — getPluginConfig() faz query ao banco em toda mensagem
Arquivo: sistema-nuvem/plugins/newwhats/webhook-receiver.js:44 e :207
const cfg = await getPluginConfig() // SELECT key, value FROM plugin_configs WHERE plugin_id = 'newwhats'
Disparado em toda mensagem recebida, incluindo as que serão descartadas (fromMe, grupos, etc.). A config muda raramente (admin altera via painel), mas o sistema trata como volátil.
Impacto: ~50–150ms por mensagem + carga desnecessária no banco.
🟠 ALTO — Number role lookup via HTTP ao motor (toda mensagem)
Arquivo: sistema-nuvem/plugins/newwhats/webhook-receiver.js linha ~350
const numRes = await fetch(`${motorUrl}/api/ext/v1/secretaria/numbers`, {
headers: { 'x-nw-key': integKey },
})
HTTP síncrono para o motor em cada mensagem para verificar se o número tem
role === 'notificacoes'. Os números não mudam com frequência.
Impacto: ~100–300ms por mensagem (rede local, mas ainda é round-trip HTTP).
🟠 ALTO — Queries sequenciais no brain.chat() do motor
Arquivo: newwhats.local/plugins/secretaria/brain.ts:35–64
// Sequencial (atual):
const conversation = await db('sec_conversations')... // query 1
const agent = await db('sec_agents')... // query 2
// dentro de buildSystemPrompt():
const nodes = await db('sec_brain_nodes')... // query 3
const calCtx = await this.getCalendarContext() // query 4 (dentro do loop de nodes)
// depois:
const personaNode = await db('sec_brain_nodes')... // query 5 — repete a mesma tabela!
const recentMessages = await db('sec_messages')... // query 6
Seis queries sequenciais antes de qualquer chamada ao LLM. agent depende de
conversation.agent_id, mas as demais podem ser paralelizadas após a primeira
consulta.
A query da personaNode (linha 62) busca na mesma tabela sec_brain_nodes
que buildSystemPrompt já consultou na linha 158 — query duplicada.
Impacto: 200–400ms de overhead de banco antes de cada chamada ao LLM.
🟠 ALTO — config.get('secretaria') chamado 2× por requisição com tools
Arquivo: brain.ts:333 (dentro de callAI) e brain.ts:589 (dentro de callAIWithTools)
// callAI():
const cfg = await this.config.get('secretaria') // leitura 1
// callAIWithTools() — chamado antes de callAI em fluxos com tools:
const cfg = await this.config.get('secretaria') // leitura 2 — sem cache
Se PluginConfigStore.get() faz I/O (DB ou arquivo), o custo dobra em toda
requisição que usa tool calling.
🟡 MÉDIO — Tool calling ativo para todas as mensagens
Arquivo: sistema-nuvem/plugins/newwhats/webhook-receiver.js linha ~450
tools: ['listar_horarios', 'agendar_horario', 'escalar_humano', 'encerrar_protocolo', 'human_api'],
Todas as conversas enviam as definições completas de 5 tools ao LLM, mesmo para mensagens que jamais precisarão de agendamento (ex: "qual o preço da cerveja?").
Overhead duplo:
- Tokens extras no prompt → resposta mais lenta
- Possíveis tool loops desnecessários →
MAX_ITER = 5iterações de LLM
Impacto em alucinações: o modelo pode invocar listar_horarios quando o cliente
está só perguntando o preço de um produto, gerando uma resposta fora de contexto.
🟡 MÉDIO — Sumarização na 5ª e a cada 10ª mensagem (LLM extra)
Arquivo: brain.ts:132
if (totalMsgs > 0 && (totalMsgs % 10 === 0 || totalMsgs === 5)) {
summary = await this.summarize(agent, recentMessages, userMessage, response)
}
Na 5ª mensagem da conversa, há duas chamadas LLM encadeadas: a resposta ao cliente + o resumo. O resumo usa modelo barato (haiku/flash), mas ainda adiciona 1–2s à latência percebida na primeira resposta "longa".
Impacto em alucinações: se o modelo de resumo alucinar no sumário, todas as
respostas seguintes recebem um contexto corrompido na seção
=== ESTADO ATUAL DA CONVERSA ===.
4. Latências estimadas (cenário PDF)
| Etapa | Tempo atual | Com todos os fixes |
|---|---|---|
fetchMediaBuffer (worst case com retries) |
3.500–6.500ms | 1.200–3.000ms |
| Extração PDF via Gemini/OpenAI | 1.500–3.000ms | 1.500–3.000ms |
getPluginConfig + number lookup |
200–450ms | ~0ms (cache) |
buildContext + persona + brain (sequencial) |
400–600ms | ~0ms (paralelo com PDF) |
DB queries no brain.chat() (sequencial) |
200–400ms | ~80ms (paralelo) |
| LLM response da secretaria (sem tools) | 2.000–4.000ms | 2.000–4.000ms |
| LLM response (com tool call, 1 iteração) | 4.000–8.000ms | 4.000–8.000ms |
| Total estimado (sem tools) | 8–15s | 4.5–8s |
| Total estimado (com tool call) | 12–22s | 7–14s |
5. Correções recomendadas
Fix 1 — Backoff do fetchMediaBuffer (trivial, impacto imediato)
Arquivo: media-transcribe.js:25–26
// ANTES:
const DOWNLOAD_RETRIES = 4
const DOWNLOAD_BACKOFF = [500, 1000, 2000, 3000]
// DEPOIS:
const DOWNLOAD_RETRIES = 3
const DOWNLOAD_BACKOFF = [1200, 2000, 3000]
Aguarda 1.2s na primeira tentativa (cobre ~90% dos casos onde o motor já terminou) e reduz o número de retries desnecessários de 3 para 2.
Fix 2 — Cache em memória para getPluginConfig
Arquivo: webhook-receiver.js (substituir função existente)
let _cfgCache = null
let _cfgExpiry = 0
async function getPluginConfig() {
if (_cfgCache && Date.now() < _cfgExpiry) return _cfgCache
const rows = await query(`SELECT key, value FROM plugin_configs WHERE plugin_id = 'newwhats'`)
_cfgCache = Object.fromEntries(rows.map(r => [r.key, r.value]))
_cfgExpiry = Date.now() + 60_000 // 60s — config muda raramente
return _cfgCache
}
// Expor para o painel forçar invalidação após salvar config:
function invalidatePluginConfigCache() { _cfgCache = null }
module.exports = { createWebhookReceiver, invalidatePluginConfigCache }
Fix 3 — Cache em memória para number lookup
Arquivo: webhook-receiver.js (dentro de handleMessageNew)
const _numberCache = new Map() // instanceId → { role, active, ts }
const NUMBER_CACHE_TTL = 5 * 60_000 // 5 min
async function getNumberRole(motorUrl, integKey, instanceId) {
const cached = _numberCache.get(instanceId)
if (cached && Date.now() - cached.ts < NUMBER_CACHE_TTL) return cached
try {
const res = await fetch(`${motorUrl}/api/ext/v1/secretaria/numbers`, {
headers: { 'x-nw-key': integKey },
})
if (!res.ok) return null
const nums = await res.json()
const info = Array.isArray(nums) ? nums.find(n => n.instance_id === instanceId) : null
const entry = { role: info?.role ?? null, active: info?.active ?? true, ts: Date.now() }
_numberCache.set(instanceId, entry)
return entry
} catch { return null }
}
Fix 4 — Extração PDF em paralelo com context-builder
Arquivo: webhook-receiver.js (bloco DOCUMENT dentro de handleMessageNew)
// ANTES (sequencial):
const pdfText = await extractPdfText({ motorUrl, integKey, messageId: msg.id, captionFromUser: body || null })
if (!pdfText) { ... return }
effectiveBody = body ? `${pdfText}\nMensagem do cliente: ${body}` : pdfText
// ... depois autoReply() → buildContext() → resolvePersona() → brainRows
// DEPOIS — extração em paralelo com preparação do contexto:
const [pdfText, ctxPrefetch] = await Promise.all([
extractPdfText({ motorUrl, integKey, messageId: msg.id, captionFromUser: body || null }),
buildContext(phone, 1, body || '', chatId).catch(() => null),
])
if (!pdfText) { ... return }
effectiveBody = body ? `${pdfText}\nMensagem do cliente: ${body}` : pdfText
// Passar ctxPrefetch diretamente para autoReply() — evita reconstruir
Requer refatorar
autoReply()para aceitarctxPrefetchopcional.
Fix 5 — Paralelizar queries no brain.chat()
Arquivo: newwhats.local/plugins/secretaria/brain.ts:35–65
// ANTES (6 queries sequenciais):
const conversation = await this.db('sec_conversations').where({ id: conversationId }).first()
const agent = await this.db('sec_agents').where({ id: conversation.agent_id }).first()
const nodes = await this.db('sec_brain_nodes').where(...).orderBy('sort_order')
const recentMessages = await this.db('sec_messages')...
const personaNode = await this.db('sec_brain_nodes').where({ type: 'persona' })... // duplicado!
// DEPOIS — 2 fases:
// Fase 1: busca conversa (necessária para agent_id)
const conversation = await this.db('sec_conversations').where({ id: conversationId }).first()
if (!conversation) throw new Error('Conversa não encontrada')
// Fase 2: tudo que depende de agent_id em paralelo
const contextWindow = 8 // valor padrão antes de ter o agent
const [agent, nodes, recentMessages] = await Promise.all([
this.db('sec_agents').where({ id: conversation.agent_id }).first(),
this.db('sec_brain_nodes').where({ agent_id: conversation.agent_id, active: true }).orderBy('sort_order'),
this.db('sec_messages')
.where({ conversation_id: conversationId })
.orderBy('created_at', 'desc')
.limit(contextWindow)
.then((rows: any[]) => rows.reverse()),
])
// personaNode é subset de nodes — extrair do resultado já carregado:
const personaNode = (nodes as any[]).find(n => n.type === 'persona')
// Elimina a query duplicada da linha 62!
Fix 6 — Tools seletivos por contexto da mensagem
Arquivo: webhook-receiver.js (dentro de autoReply)
// ANTES: sempre envia todas as tools
tools: ['listar_horarios', 'agendar_horario', 'escalar_humano', 'encerrar_protocolo', 'human_api'],
// DEPOIS: tools mínimas + scheduling só quando o contexto pede
const needsScheduling = /\b(agend|hor[áa]rio|consulta|marcar|agendar|reservar|data|dia\s+dispon)\b/i.test(effectiveBody)
const tools = [
...(needsScheduling ? ['listar_horarios', 'agendar_horario'] : []),
'escalar_humano',
'encerrar_protocolo',
'human_api',
]
Reduz tokens no prompt e elimina tool calls espúrios em conversas que não envolvem agendamento.
6. Relação entre cada gargalo e risco de alucinação
| Gargalo | Risco de alucinação | Motivo |
|---|---|---|
| fetchMediaBuffer falha total | ALTO | LLM não recebe o PDF; inventa conteúdo |
| Fallback Gemini → OpenAI → Claude | MÉDIO | Persona/instrução diferente por provider |
| context-builder sequencial | BAIXO | Atraso apenas, contexto chega completo |
| summarize() corrompido | ALTO | Todas as próximas respostas usam resumo errado |
| tool calls em loop (5 iter.) | MÉDIO | Resposta truncada ou sem dados da tool |
| config.get() lento | BAIXO | Atraso, não afeta contexto diretamente |
| Timeout geral >15s | ALTO | Cliente manda nova msg → IA responde sem contexto do PDF |
7. Priorização de implementação
| # | Fix | Esforço | Impacto | Alucinação |
|---|---|---|---|---|
| 1 | Backoff fetchMediaBuffer |
⭐ trivial | 2–3s ganhos | Evita PDF perdido |
| 2 | Cache getPluginConfig |
⭐ trivial | 150ms/msg | Não direto |
| 3 | Cache number lookup | ⭐ trivial | 200ms/msg | Não direto |
| 4 | Queries paralelas em brain.chat() |
⭐⭐ médio | 200ms/req | Não direto |
| 5 | PDF + context-builder em paralelo | ⭐⭐ médio | 400ms/PDF | Não direto |
| 6 | Tools seletivos | ⭐⭐ médio | 1–4s em msgs simples | Reduz tool calls errados |
| 7 | Eliminar query duplicada personaNode |
⭐ trivial | 80ms/req | Não direto |
8. Arquivos-chave para as correções
| Arquivo | Fixes aplicáveis |
|---|---|
sistema-nuvem/plugins/newwhats/media-transcribe.js |
Fix 1 (backoff) |
sistema-nuvem/plugins/newwhats/webhook-receiver.js |
Fix 2, 3, 4, 6 |
newwhats.local/plugins/secretaria/brain.ts |
Fix 5, 7 |
9. Causa raiz do "30+ segundos para Oi!" — Diagnóstico e fixes aplicados
Causa 1 — fetch sem timeout (PRINCIPAL)
Arquivo: brain.ts — todas as chamadas a OpenAI, Anthropic, Gemini, Ollama
// ANTES — sem timeout: se a API travar, a requisição espera para sempre
const res = await fetch('https://api.openai.com/v1/chat/completions', { ... })
// DEPOIS — máximo 25s; se ultrapassar, lança AbortError → fallback chain
const res = await fetch('https://api.openai.com/v1/chat/completions', {
signal: AbortSignal.timeout(25_000),
...
})
✅ APLICADO em 7 pontos: callProvider (OpenAI, Anthropic, Gemini, Ollama) +
openAIToolLoop + anthropicToolLoop + geminiToolLoop.
Impacto em alucinações: sem timeout, qualquer travamento da API deixava o LLM sem resposta enquanto o cliente enviava novas mensagens, criando contexto corrompido.
Causa 2 — Gemini 429 → espera de até 30s
Arquivo: brain.ts:458
// ANTES: espera 5s (default) ou até 30s (se API informar retry-in: 30s)
const waitMs = match ? Math.min(Math.ceil(...) * 1000, 30_000) : 5_000
// DEPOIS: espera máximo 8s, default 1.5s — Gemini com quota cai rápido ao próximo provider
const waitMs = match ? Math.min(Math.ceil(...) * 1000, 8_000) : 1_500
✅ APLICADO.
Antes: Gemini em quota → espera 5s → retry → falha → tenta OpenAI → 7-10s só no Gemini. Depois: Gemini em quota → espera 1.5s → retry → falha → tenta OpenAI → 3-5s.
Causa 3 — API key auth faz query Prisma em CADA requisição ao motor
Arquivo: plugins/ext-api/backend/apikey-auth.ts
Cada mensagem do satélite dispara 3 requisições HTTP ao motor:
GET /api/ext/v1/secretaria/numbers(verificar role)POST /api/ext/v1/secretaria/ask(chamar brain)POST /api/ext/v1/inbox/:chatId/send(enviar resposta)
Cada uma passava pelo middleware apiKeyAuth que fazia prisma.apiKey.findUnique()
— 3 queries ao PostgreSQL por mensagem, sem cache.
// DEPOIS — cache em memória de 5 minutos por chave
const cached = keyCache.get(key)
if (cached && Date.now() - cached.cachedAt < KEY_CACHE_TTL) {
req.extTenantId = cached.tenantId
next(); return
}
// Só vai ao banco se cache expirou
✅ APLICADO — elimina ~3 × 50ms = 150ms de queries por mensagem.
Causa 4 — getPluginConfig() e number lookup sem cache
Arquivo: webhook-receiver.js
// ANTES: query ao banco em TODA mensagem recebida
async function getPluginConfig() {
const rows = await query(`SELECT key, value FROM plugin_configs WHERE plugin_id = 'newwhats'`)
...
}
// DEPOIS: cache de 60s em memória
if (_cfgCache && Date.now() < _cfgExpiry) return _cfgCache
Igualmente, o lookup de role do número fazia HTTP ao motor em cada mensagem.
Substituído por getNumberRole() com cache de 5 minutos.
✅ APLICADO — elimina ~200-500ms por mensagem.
Resumo dos fixes aplicados em 2026-05-05
| Fix | Arquivo(s) modificado(s) | Ganho estimado |
|---|---|---|
AbortSignal.timeout(25_000) em todos os fetches LLM |
brain.ts + brain.js |
Elimina hangs de 30-∞s |
| Gemini retry wait: 5s→1.5s, max 30s→8s | brain.ts + brain.js |
-3.5s em quota errors |
| Cache de API key (5min) | apikey-auth.ts + apikey-auth.js |
-150ms/msg |
Cache de getPluginConfig (60s) |
webhook-receiver.js |
-100ms/msg |
| Cache de number role (5min) | webhook-receiver.js |
-200ms/msg |
| Total mensagem simples (ex: "Oi!") | -4 a 34s |
Pendentes (próximos fixes)
| Fix | Arquivo | Ganho |
|---|---|---|
Backoff fetchMediaBuffer: [1200, 2000, 3000] |
media-transcribe.js |
-3s/PDF |
Queries paralelas em brain.chat() |
brain.ts |
-200ms/req |
Eliminar query duplicada personaNode |
brain.ts |
-80ms/req |
| Tools seletivos por contexto | webhook-receiver.js |
-1-4s em msgs simples |
| PDF + context-builder em paralelo | webhook-receiver.js |
-400ms/PDF |