P2 · Engenharia de Confiança · Arquitetura Produtiva & Roteamento Determinístico
A tool errada não é uma aproximação aceitável
285 ferramentas, 30 queries, um domínio bancário de missão crítica. Como uma barreira pré-execução de quatro camadas levou 7 execuções incorretas a zero — e como a solução evoluiu de uma prova de conceito (PoC) para um blueprint de arquitetura produtiva de alta performance com desacoplamento Cold/Hot Path e SLAs sub-2ms.
Medido no benchmark do MVP · 30 queries · 53 exemplos de treino · tools mockadas · offline
Saída determinística de python -m candidate_starter.run_case medida no benchmark do MVP —
não é precisão do sistema em produção nem economia financeira total. A capacidade é escolher a ferramenta certa;
a confiança é saber quando não executar nenhuma.
- Em uma frase
- Um roteador para agente bancário que escolhe 2 ferramentas entre 285 e bloqueia a execução quando a decisão não é confiável o bastante. A capacidade é escolher a ferramenta certa; a confiança é saber quando não executar nenhuma.
- Formato
- Case técnico
- Profundidade
- Leitura aprofundada · ~3 min para a ideia central · ~31 min completo
Prefere o essencial antes? Ler a síntese (~1 min)
Resumo em vídeo
O case em um vídeo
"A Máquina da Confiança: Estudo de Caso em Desenvolvimento Agêntico Bancário" — visão geral antes do aprofundamento técnico: o problema, o Crash Silencioso, a barreira de quatro guardas e o que os números provam — e o que não provam.
01 · O Enunciado
O projeto: o que foi pedido num cérebro de agente bancário
Construir o cérebro de roteamento de um agente de atendimento de um banco digital. Antes de qualquer chamada a LLM caro, o sistema decide o caminho mais barato e seguro para responder cada mensagem.
Os três pilares da arquitetura
| Pilar | Problema de Engenharia | Componente no Código |
|---|---|---|
| 1 · Router | Classificar a query em FAST_PATH (saudação/FAQ) ou AGENT (precisa de tool + raciocínio). |
candidate_starter/router.py |
| 2 · Tool Retrieval | Selecionar as 2 ferramentas mais relevantes entre 285 antes de montar o prompt do LLM. | candidate_starter/retrieval.py |
| 3 · Evaluation Harness | Medir qualidade, custo, latência e segurança de forma determinística offline. | candidate_starter/harness.py |
A restrição inegociável do domínio
"Este é um domínio bancário. Uma tool incorreta não é uma aproximação aceitável.
Nunca execute uma tool se a decisão estiver abaixo dos limiares de segurança.
Nunca remova os quality gates apenas para aumentar recall ou economia.
Não use expected_tool do dataset dentro da lógica de produção.
Esse campo só pode ser usado pelo harness para avaliar offline."
A Fronteira: produção não vê o gabarito
O que separa engenharia de ajuste ao gabarito é uma linha só: expected_tool
vive na camada de avaliação e nunca na lógica de produção.
Decide com sinais disponíveis no momento do pedido: confiança do router, score do retriever, margem relativa e o verbo da query. Nada mais.
expected_tool do eval_dataset.json só é lido
aqui, offline, para pontuar o Hit Rate depois que a decisão já foi tomada.
grep -n "expected_tool" candidate_starter/retrieval.py candidate_starter/router.py candidate_starter/taxonomy.py
# Saída: zero resultados.
02 · Ciclo de Vida & Decisão de Engenharia
Duas Estratégias, Um Domínio: Agilidade de Prototipagem vs Robustez Enterprise
Em engenharia de software sênior, uma solução técnica não existe no vácuo. O repositório oficial
case-agents implementa e documenta duas abordagens arquiteturais ativas em branches paralelas,
demonstrando a transição deliberada de uma Prova de Conceito (PoC) exploratória para um sistema de governança de missão crítica.
Abordagem 1: Solução Enxuta (Lean PoC)
Branch:feature/solucao-enxuta
Foco estrito em velocidade de experimentação e footprint mínimo. Implementa o roteador
e o retriever em Python direto: calibração Platt, colapso por capacidade e guarda de direção existem, mas embutidos em router.py e retrieval.py, sem módulo de taxonomia e sem ADRs.
Ideal para validação rápida de viabilidade de produto e benchmark de partida.
Prós & Trade-offs:
- Pró: Código enxuto (~560 linhas em quatro módulos e um script), fácil de auditar e entregar em poucos dias.
- Pró: Baixo atrito cognitivo para times em estágio inicial de produto.
- Contra: O harness da branch imprime 26,3% de Precision@2, porque compara o nome da variante com o nome canônico; recontado por capacidade, são 20/20 no top-2 (ver Estatística no Código).
- Contra: 16,7% de abstenções (5 de 30), e a guarda de direção só cobre leitura→escrita nas ferramentas que o prefixo do nome revela (167 das 285 ferramentas).
Abordagem 2: SDD Enterprise & Confiança
Branch:main (antiga solucao-sdd-vibe)
Foco em confiabilidade bancária, conformidade e ciclo de vida industrial. Adota Spec-Driven Development (SDD), 9 ADRs formais, taxonomia canônica de capacidades, calibração Platt Scaling, barreira de 4 camadas (incluindo Guarda de Direção) e o blueprint para inferência compilada de alta performance.
Prós & Trade-offs:
- Pró: 100% de Hit Rate@2 e zero execuções incorretas garantidas formalmente.
- Pró: Proteção comprovada contra danos ao cadastro bancário do cliente (G4).
- Pró: Pronta para escala industrial: manifesto
APM.ymle ADR-009 (Rust/Go). - Contra: Exige disciplina contínua de governança do catálogo e testes de paridade.
A lição da dualidade: Na fase de validação (PoC), a Solução Enxuta é tentadora pela velocidade.
Contudo, em finanças ou saúde, o custo de uma execução incorreta em produção supera infinitamente o custo da governança.
A branch main existe para demonstrar como transpor essa barreira com rigor de engenharia.
03 · A Falha Medida
O Crash Silencioso: a falha que 54 testes não pegaram
Numa iteração intermediária (commit c8f30d2), o projeto exibia:
54 testes no verde, router 100%, Hit Rate@2 de 100%, zero execuções incorretas e status aprovado com louvor.
Uma revisão externa, porém, solicitou um teste de colisão entre vocabulário de leitura e escrita.
Query do Cliente: "Qual e o email cadastrado na minha conta?" — uma LEITURA.
Margem (0.4795 − 0.2554) / 0.4795 = 0.467 >> 0.25 → a guarda de margem não pega. Confiança alta, score alto, margem folgada: EXECUÇÃO INCORRETA — o agente altera o cadastro de quem só perguntou.
A query é de leitura; a capacidade de escrita é descartada antes do ranking. Margem (0.3891 − 0.1792) / 0.3891 = 0.539 > 0.25 → executa a consulta, corte seco, sem transição.
Nenhuma das três guardas antigas pegou: o erro não é um empate — é uma decisão confiante e errada. Isso ilustra a tese central do pilar Engenharia de Confiança: o erro mais perigoso em IA não gera exceção de código. Ele passa no verde e toma uma decisão ruinosa com margem folgada.
04 · Diagnóstico Estrutural
Por que nenhum peso de vetorização resolve o problema
Quando o problema apareceu, a primeira tentativa foi ajustar a combinação de pesos entre busca lexical e busca por intenção (score = (1−α)·lexical + α·intent). A demonstração matemática abaixo prova por que isso é impossível:
Query: "Quero mudar o e-mail vinculado a minha conta" (ESCRITA)
consultar_email_vinculado_conta -> lexical = 0.8457 | intent = 0.4400
atualizar_email -> lexical = 0.1983 | intent = 0.3200
Para a escrita vencer por ajuste de α:
(1 - α)·0.1983 + α·0.3200 > (1 - α)·0.8457 + α·0.4400
α·(0.3200 - 0.4400 - 0.1983 + 0.8457) > 0.8457 - 0.1983
0.5274·α > 0.6474 => α > 1.227 (IMPOSSÍVEL, pois α ∈ [0, 1])
A lição estrutural: Leitura e escrita sobre o mesmo objeto compartilham todos os substantivos (e-mail, conta, cadastro).
A diferença entre consultar e alterar é uma só palavra: o verbo. Num saco de palavras com TF-IDF, esse verbo é diluído entre dez tokens. A direção da operação não é recuperável por similaridade textual — precisa ser uma restrição semântica declarada.
05 · A Solução Rígida
A Barreira Pré-Execução de 4 Camadas
Quatro guardas determinísticas encadeadas. A query só avança para o LLM e para a execução de tools se passar por todas as quatro travas. Se qualquer guarda reprovar, o sistema desvia para abstenção ou atendimento humano.
A assimetria deliberada da Guarda 4
- Query de LEITURA + Tool de ESCRITA: Descarte total da tool. Alterar o cadastro de quem apenas perguntou é dano irreversível ao cliente.
- Query de ESCRITA + Tool de LEITURA: Rebaixamento de score, não descarte. Consultar para quem pediu alteração é tarefa não cumprida — recuperável na mensagem seguinte.
06 · O Pipeline do MVP
A Arquitetura em 4 Camadas (Validação Matemática em Python)
O fluxo completo do pedido até a autorização de execução no motor de validação:
- Query do usuário
-
Router — TF-IDF + LogisticRegression + Platt scaling
FAST_PATH→ resposta local, custo zero de LLMAGENTsegue abaixo - Taxonomia — governança do catálogo: capacidade ≠ endpoint
- Retriever —
top-kcapacidades distintas - Barreira — 4 guardas em ordem: G1 · G2 · G3 · G4 falha em qualquer guarda → abstenção ou atendimento humano
- LLM — chamado só depois que a barreira autorizou
- Execução da ferramenta
TfidfVectorizer(ngram_range=(1,2)) + LogisticRegression calibrado via CalibratedClassifierCV(method="sigmoid") em Platt scaling.
Resolve o problema de subconfiança por regularização L2 em dataset pequeno sem inflar artificialmente as probabilidades.
Declaração de 14 capacidades canônicas e 45 variantes com modo de operação (read ou write).
Mapeia o vocabulário do usuário (aliases) para intenções e resolve duplicações semânticas do catálogo.
Combinação de score BM25F: score = 0.5·lexical + 0.5·intent.
O glossário de aliases é indexado em campo separado para evitar que a normalização L2 dilua os termos originais. Realiza colapso por capacidade devolvendo top-k canônicos distintos.
Quality Gate tri-estado (APROVADO, REPROVADO, INDETERMINADO).
Valida as 4 guardas antes do mock de execução. Se não houver query transacional no benchmark, o resultado é marcado como INDETERMINADO por segurança.
07 · Estatística no Código
As cinco técnicas estatísticas do case e o Python que as implementa
Quem decide, neste pipeline, não é um modelo de linguagem. São cinco técnicas clássicas de estatística e de recuperação de informação, cada uma respondendo a um problema concreto do atendimento bancário. Cada técnica abaixo tem duas camadas: o conceito, em linguagem simples, e o trecho de Python que o implementa.
Os trechos são da branch feature/solucao-enxuta (commit
e2dcd7f),
a versão simplificada: quatro módulos e um script de execução, cerca de 560 linhas, sem
taxonomy.py e sem ADRs. Onde a main faz diferente, uma nota avisa.
Os números vêm da saída de python -m candidate_starter.run_case nessa branch e de
recontagens offline sobre o mesmo relatório. Não são os números da main citados no resto
da página.
| Técnica | Problema operacional | Onde no código |
|---|---|---|
| 1 · TF-IDF + regressão logística | Decidir, barato e em milissegundos, se a mensagem precisa de agente. | router.py · QueryRouter |
| 2 · Platt scaling + validação cruzada estratificada | Fazer o número "0,75" significar uma probabilidade de acerto. | router.py · fit() |
| 3 · Cosseno multi-campo + colapso por capacidade | Achar 2 ferramentas entre 285, sem devolver duas duplicatas. | retrieval.py · ToolRetriever.search |
| 4 · Limiares, margem relativa e guarda de direção | Saber quando não executar. | harness.py · run_harness; retrieval.py |
| 5 · Métricas de avaliação e economia | Provar que funciona sem contar como ganho o que é só abstenção. | harness.py · compute_* |
1 · Classificação supervisionada: TF-IDF e regressão logística
Problema operacional. Saudações e perguntas de FAQ ("Qual o horário de atendimento?") não precisam
de LLM nem de ferramenta. Responder isso localmente tem custo zero de LLM. O router precisa separar
FAST_PATH de AGENT em milissegundos, antes de qualquer chamada cara.
Conceito. Classificação supervisionada é ensinar com exemplos já rotulados: aqui, 53 mensagens marcadas
FAST_PATH (25) ou AGENT (28). O modelo aprende o que separa as duas
classes e aplica isso a mensagens novas, em dois passos: transformar texto em números e aprender uma fronteira entre as classes.
O TF-IDF dá a cada termo um peso que cresce com a frequência dele na mensagem (TF) e diminui com o número de mensagens do conjunto que o contêm (IDF). "De" aparece em quase tudo e pesa pouco; "bloquear" aparece em poucas e pesa muito. Com n-grams de 1 a 2 termos, pares como "segunda via" também viram termos. Cada mensagem vira um vetor esparso (quase tudo zero) de comprimento 1.
A regressão logística transforma uma soma ponderada dos termos em uma probabilidade, entre 0 e 1, de a
mensagem ser AGENT. A regularização L2 penaliza pesos grandes, para o modelo não
decorar 53 exemplos; C controla a força (1,0 é o padrão do scikit-learn e é o valor usado).
É um classificador linear e leve, e cada termo tem um coeficiente que dá para ler, o que importa numa auditoria.
tf-idf(t, d) = tf(t, d) × idf(t) idf(t) = ln((1 + n) / (1 + df(t))) + 1
vetor(d) = tf-idf(d) / ‖tf-idf(d)‖₂ (norma L2 = 1)
P(AGENT | x) = σ(w·x + b) = 1 / (1 + e^−(w·x + b))
o treino minimiza: ½‖w‖² + C · Σ log-loss(yᵢ, P(xᵢ)) (C = 1,0)
self._vectorizer = TfidfVectorizer(
ngram_range=(1, 2),
min_df=1,
preprocessor=normalize,
)
self._base_clf = LogisticRegression(C=1.0, max_iter=1000, random_state=42)
def normalize(text: str) -> str:
if not text:
return ""
# 1. Lowercase
text = text.lower()
# 2. Remover acentos
text = remove_accents(text)
# 3. Variantes ortográficas do domínio (antes de remover pontuação se houver hífens)
for pattern, replacement in DOMAIN_VARIANTS:
text = re.sub(pattern, replacement, text)
# 4. Remover caracteres especiais (manter apenas letras, números e espaços)
text = re.sub(r"[^a-z0-9\s]", " ", text)
# 5. Colapsar múltiplos espaços
text = re.sub(r"\s+", " ", text).strip()
return text
normalize() é o preprocessor do vetorizador, então o mesmo tratamento
(minúsculas, sem acento, variantes de domínio como "segunda via" → segundavia, sem pontuação) vale
no treino e na consulta. Se os dois fossem normalizados de jeitos diferentes, o vocabulário aprendido não casaria com a
pergunta real.
Medido nesta branch:
- Vocabulário de 378 termos (unigramas e bigramas) sobre os 53 exemplos de treino.
- Router 30/30 no benchmark; matriz de confusão FAST_PATH 10 · 0 / AGENT 0 · 20.
predict(), já com a calibração da técnica 2: mediana de 2,2 ms e p95 de 4,4 ms em 200 chamadas locais (Windows, scikit-learn 1.9).
2 · Calibração de probabilidades: Platt scaling e validação cruzada estratificada
Problema operacional. O limiar de 0,75 da guarda G1 (seção A Barreira) só é uma política
de risco se "0,75" significar "o modelo acerta em cerca de 75% dos casos". Uma regressão logística regularizada, com 53 exemplos e
378 termos, encolhe os pesos e devolve probabilidades tímidas, perto de 0,5, mesmo quando a classe está certa. Sem calibração,
nesta branch, 20 das 20 mensagens AGENT do benchmark ficam abaixo de 0,75 (a maior confiança é 0,713),
embora o router acerte todas. O limiar deixaria de separar decisão boa de decisão ruim e só descartaria cobertura.
Conceito. Calibrar é corrigir a escala do número sem mudar o que o modelo decide. O escalonamento de Platt treina uma segunda regressão logística, de uma única variável, sobre o score bruto f do modelo. Os parâmetros A e B são estimados por máxima verossimilhança em previsões feitas sobre exemplos que o modelo não viu no treino (out-of-fold: previsões sobre a parte dos dados deixada de fora em cada rodada). Estimá-los nos próprios exemplos de treino mediria a memória do modelo, não a capacidade de generalizar.
A validação cruzada estratificada divide os 53 exemplos em 3 dobras que preservam a proporção de classes; cada
dobra serve uma vez de conjunto não visto. Sem embaralhar (shuffle=False), as dobras são as mesmas em
qualquer máquina, e os testes são reproduzíveis. O código não chama StratifiedKFold diretamente: passar
cv=3 a um classificador faz o scikit-learn usar esse divisor (conferido na versão 1.9). O número de
dobras vem da menor classe: 3 se ela tem ao menos 3 exemplos, 2 se tem 2, e sem calibração abaixo disso, para o
fit() não quebrar em conjuntos minúsculos de teste.
Por que sigmoide e não isotônica. A calibração isotônica é não paramétrica: com poucos dados ela sobreajusta e
satura a confiança perto de 1, o problema oposto. A sigmoide tem dois parâmetros e é estável. A main
mediu essa comparação no
ADR-007.
Platt: p(AGENT | f) = 1 / (1 + exp(A·f + B)) A, B por máxima verossimilhança, em dados fora da dobra
Dobras: StratifiedKFold(n_splits=3, shuffle=False) → 3 modelos calibrados; a probabilidade final é a média
# Contar menor número de amostras por classe
unique_labels, counts = np.unique(labels, return_counts=True)
min_class_count = int(np.min(counts))
if min_class_count >= 3:
cv = 3
elif min_class_count >= 2:
cv = 2
else:
cv = None
if cv is not None:
self._clf = CalibratedClassifierCV(estimator=self._base_clf, method="sigmoid", cv=cv)
else:
self._clf = self._base_clf
self._clf.fit(X, labels)
X_q = self._vectorizer.transform([query])
probs = self._clf.predict_proba(X_q)[0]
best_idx = np.argmax(probs)
route = str(self.classes_[best_idx])
confidence = float(probs[best_idx])
Medido nesta branch:
- Sem calibração, 20 de 20 mensagens
AGENTficam abaixo de 0,75. Com calibração, 1 de 20 (0,732). -
No benchmark inteiro, G1 barrou 2 mensagens: "Troquei de número, preciso atualizar o telefone cadastrado"
(
AGENT, 0,732) e "Quais documentos preciso para abrir uma conta?" (FAST_PATH, 0,718). Nesta branch, G1 vale para as duas rotas. - A menor confiança aceita é 0,753: folga de 0,003 sobre o limiar. A calibração corrigiu a escala, mas a margem continua estreita.
-
O repositório não mede a calibração em si (Brier score, erro esperado de calibração, curva de confiabilidade), só o efeito dela no limiar.
Com 20 queries
AGENT, uma curva de confiabilidade teria poucos pontos por faixa.
3 · Recuperação vetorial: similaridade de cosseno, score multi-campo e colapso por capacidade
Problema operacional. O catálogo tem 285 ferramentas. Pôr todas no prompt de um LLM estoura o contexto, confunde o modelo e custa caro, então o retriever escolhe as 2 mais relevantes antes de qualquer chamada. E o catálogo tem duplicatas: 8 ferramentas diferentes fazem "obter a fatura".
Conceito. A similaridade de cosseno mede o ângulo entre dois vetores: 1 quando têm o mesmo vocabulário ponderado, 0 quando não compartilham nenhum termo. Como o TF-IDF já entrega vetores de comprimento 1, o cosseno é só o produto escalar, ou seja, uma multiplicação de matriz esparsa entre a query e as 285 ferramentas.
O score combina dois campos com peso igual. O documento da ferramenta é nome + descrição + categoria; o
campo de "intenção" é nome + descrição. Manter campos separados, em vez de concatenar tudo, existe para
que a normalização L2 não dilua os termos originais quando se acrescentam sinônimos ao mesmo texto
(o ADR-006
da main documenta esse problema). Uma lista de palavras funcionais (a,
de, para, quero…) sai do vocabulário antes, para uma
query curta não pontuar só por preposições.
Um limite desta branch. Os dois campos saem do mesmo texto: diferem só pela categoria. Medido, a correlação entre as
duas similaridades é de pelo menos 0,97, e o top-1 do score combinado é idêntico ao do campo único nas 30 queries. Aqui, α = 0,5
praticamente não muda o resultado. Um segundo campo só ajuda quando traz informação nova, como o glossário escrito à mão da
main.
O colapso por capacidade agrupa as duplicatas com um dicionário embutido (CANONICAL_TOOL_MAP:
49 ferramentas em 12 capacidades) e guarda, por grupo, o maior score (max-pooling, uma agregação por máximo). Assim
k=2 devolve duas capacidades distintas, não duas variantes da mesma. Depois, o código ordena por score
decrescente e corta em k. Candidatos com score 0 são descartados: uma query sem nenhuma palavra em comum com
o catálogo devolve lista vazia. Empates seguem a ordem do catálogo, porque o sorted do Python é estável.
Isso é determinístico para o mesmo arquivo, mas não é um desempate explícito (a main desempata pelo nome da
capacidade).
cos(q, d) = (q · d) / (‖q‖ · ‖d‖) = q · d (vetores TF-IDF com ‖v‖₂ = 1)
score(ferramenta) = (1 − α) · cos(q, documento) + α · cos(q, intenção) α = 0,5
score(capacidade) = max { score(t) : t pertence à capacidade } (colapso por máximo)
doc_texts = []
intent_texts = []
for tool in tools:
# Document text: name + description + category
doc_text = f"{tool.name} {tool.description} {tool.category}"
doc_texts.append(doc_text)
# Intent text: name.replace("_", " ") + description
intent_text = f"{tool.name.replace('_', ' ')} {tool.description}"
intent_texts.append(intent_text)
self._doc_matrix = self._doc_vectorizer.fit_transform(doc_texts)
self._intent_matrix = self._intent_vectorizer.fit_transform(intent_texts)
doc_sims = cosine_similarity(q_doc_vec, self._doc_matrix)[0]
intent_sims = cosine_similarity(q_intent_vec, self._intent_matrix)[0]
combined_scores = (1 - self.alpha) * doc_sims + self.alpha * intent_sims
# Apply direction guard & canonical collapsing
canonical_best: Dict[str, Tuple[str, float]] = {}
for idx, tool in enumerate(self._tools):
score = float(combined_scores[idx])
# … (guarda de direção: técnica 4)
if score <= 0:
continue
canon_group = get_canonical_name(tool.name)
if canon_group not in canonical_best or score > canonical_best[canon_group][1]:
canonical_best[canon_group] = (tool.name, score)
# Sort matches by score descending
sorted_matches = sorted(canonical_best.values(), key=lambda item: item[1], reverse=True)
top_k_matches = [ToolMatch(name=name, score=score) for name, score in sorted_matches[:k]]
Medido nesta branch: recontando por capacidade (get_canonical_name sobre o resultado), a
capacidade esperada está no top-1 em 19 das 20 queries transacionais e no top-2 em 20 das 20. O relatório
impresso mostra outro número. A explicação está na técnica 5.
4 · Decisão sob incerteza: limiares, margem relativa e guarda de direção
Problema operacional. Escolher a melhor ferramenta não basta: o sistema precisa saber quando não executar. Um erro que passa em silêncio (ver O Crash Silencioso) custa mais do que uma pergunta de confirmação.
Conceito. Em vez de agir sempre sobre o melhor candidato, o pipeline testa três sinais estatísticos, em ordem, e só
executa se todos passarem. Nenhum deles lê o expected_tool do gabarito.
- G1 · confiança: a probabilidade calibrada do router é ≥ 0,75.
- G2 · relevância mínima: o score do top-1 é ≥ 0,10, um piso contra casamentos por acaso.
-
G3 · margem relativa:
(s₁ − s₂) / s₁ ≥ 0,25. Mede quanto o primeiro candidato vence o segundo em proporção do próprio score. É relativa para não depender da escala: uma diferença de 0,03 pesa diferente quando o top-1 vale 0,18 e quando vale 0,80.
# Guard Rail 1: Router confidence >= 0.75
confidence = route_result.confidence if route_result.confidence is not None else 1.0
if confidence < 0.75:
row["final_status"] = "HUMAN_FALLBACK_LOW_CONFIDENCE"
abstentions += 1
elif route_result.route == "FAST_PATH":
fast_path_answer(query)
else:
retrieval_result = retriever.search(query, k=k)
# …
# Guard Rail 2: Top-1 score >= 0.10
top1_score = top_k_matches[0].score if top_k_matches else 0.0
if top1_score < 0.10:
row["final_status"] = "ABSTAIN_LOW_SCORE"
abstentions += 1
else:
# Guard Rail 3: Margin relative >= 0.25
top2_score = top_k_matches[1].score if len(top_k_matches) > 1 else 0.0
margin = (top1_score - top2_score) / top1_score if top1_score > 0 else 0.0
if margin < 0.25:
row["final_status"] = "AMBIGUOUS_CONFIRMATION"
abstentions += 1
else:
# Executa a tool top-1
mock_tool_execution(top_k_names[0], query)
A margem em três queries reais, todas desviadas por G3 no benchmark:
| Query | Top-1 e top-2 (score) | Margem | Resultado |
|---|---|---|---|
| "Perdi meu cartão na rua, preciso bloquear agora" | bloquear_cartao 0,180 · bloquear_cartao_temporariamente 0,177 |
0,017 | AMBIGUOUS_CONFIRMATION |
| "Mudei de endereço, como atualizo minha entrega?" | alterar_endereco 0,474 · consultar_endereco_cadastrado 0,434 |
0,084 | AMBIGUOUS_CONFIRMATION |
| "Chegou meu cartão novo, como faço para desbloquear?" | instrucoes_desbloqueio_cartao_novo 0,546 · confirmar_recebimento_cartao_novo 0,420 |
0,231 | AMBIGUOUS_CONFIRMATION |
A terceira falha por 0,019: 0,25 é uma política, não uma constante da natureza. Nesta branch os três limiares são literais no
código; na main são constantes nomeadas, e a margem é parâmetro do harness.
G4 · guarda de direção. Ela não é uma medida de similaridade, e o motivo é estatístico: leitura e escrita sobre o
mesmo dado dividem quase todos os termos (a seção Por que nenhum peso resolve mostra a
aritmética). Então a direção entra como sinal declarado: verbos e palavras-chave da query dizem se ela é de leitura ou de escrita, e o
prefixo do nome da ferramenta (consultar_, atualizar_…) diz de que tipo ela é.
Query de leitura com ferramenta de escrita: score zerado.
def infer_query_direction(query_norm: str) -> str:
"""Inferir direção da query: 'read', 'write', ou 'unknown'."""
has_read = any(re.search(rf"\b{kw}\b", query_norm) for kw in READ_QUERY_KEYWORDS)
has_write = any(re.search(rf"\b{kw}\b", query_norm) for kw in WRITE_QUERY_KEYWORDS)
if has_read and not has_write:
return "read"
if has_write and not has_read:
return "write"
return "unknown"
def infer_tool_direction(tool_name: str) -> str:
"""Inferir direção da ferramenta pelo prefixo."""
if tool_name.startswith(READ_TOOL_PREFIXES):
return "read"
if tool_name.startswith(WRITE_TOOL_PREFIXES):
return "write"
return "unknown"
# …
# Direction guard rule: read query + write tool -> score = 0
if query_direction == "read" and tool_direction == "write":
score = 0.0
Medido e limitado nesta branch:
-
Leitura protegida. "Qual e o email cadastrado na minha conta?" resolve
confirmar_email_cadastrado(leitura) em primeiro.atualizar_emailnão aparece entre os três primeiros. -
Escrita→leitura não é tratada. A especificação enxuta previa rebaixar a ferramenta de leitura, mas o código não faz nada nesse
sentido. "Quero mudar o e-mail vinculado à minha conta" executa
consultar_email_vinculado_conta(score 0,664): não altera nada, mas não cumpre o pedido. - Só enxerga o que o nome revela. Por prefixo, 82 das 285 ferramentas são de leitura, 85 são de escrita e 118 ferramentas (41%) não revelam direção pelo nome e passam sem restrição.
-
Leitura agrupada sob escrita. O dicionário de colapso põe
consultar_email_vinculado_contaeconfirmar_email_cadastrado, ferramentas de leitura, sobatualizar_email, que é de escrita. Amainproíbe isso por invariante: leitura e escrita não se agrupam.
5 · Métricas de avaliação e economia
Problema operacional. Sem medição, "funciona" é opinião. E uma métrica mal lida é pior que nenhuma: a economia de custo sobe quando o sistema se abstém mais, mesmo que atenda menos.
Conceito. A acurácia é a proporção de acertos: acertos divididos pelo total. A matriz de confusão 2×2 separa os dois erros
possíveis, que custam coisas diferentes: mandar uma FAQ ao agente gasta LLM à toa; mandar um pedido transacional ao
FAST_PATH dá ao cliente uma resposta genérica no lugar da operação. O código inicializa todas as células com zero,
então a estrutura fica completa mesmo sem erros cruzados.
O hit rate@k é a fração das queries em que a ferramenta esperada aparece entre as k primeiras. Não é a Precision@K
clássica (relevantes entre as k, divididos por k): com uma só ferramenta relevante e k = 2, a Precision@2 máxima seria 0,5. A função se
chama compute_precision_at_k, mas o que ela calcula é hit rate.
A economia é a redução percentual contra o baseline (mandar toda mensagem ao LLM, com todas as ferramentas no prompt), com guarda para baseline zero. No MVP, custos e latências vêm de constantes de mock (US$ 0,03 por chamada do baseline, US$ 0,01 por chamada do agente), então o percentual mede a estrutura do pipeline, não um preço real.
acurácia = acertos / total
hit rate@k = (queries com a ferramenta esperada no top-k) / (queries avaliadas)
economia % = (baseline − pipeline) / baseline × 100 (0 se baseline = 0)
correct = sum(1 for yt, yp in zip(y_true, y_pred) if yt == yp)
accuracy = correct / len(y_true)
matrix = {l1: {l2: 0 for l2 in labels} for l1 in labels}
for yt, yp in zip(y_true, y_pred):
if yt in matrix and yp in matrix[yt]:
matrix[yt][yp] += 1
# …
def compute_precision_at_k(hits: List[int]) -> float:
"""Calcula Precision@K a partir de uma lista de 0/1 (acertou ou não a tool)."""
if not hits:
return 0.0
return float(sum(hits) / len(hits))
# …
cost_savings = 0.0
if baseline_cost_usd > 0:
cost_savings = ((baseline_cost_usd - smart_cost_usd) / baseline_cost_usd) * 100.0
Saída de python -m candidate_starter.run_case na branch enxuta, 30 queries:
| Métrica | Valor | Origem |
|---|---|---|
| Acurácia do router | 100% (30/30) · matriz 10 · 0 / 0 · 20 | impresso |
precision_at_k (k = 2) |
26,3% (5/19) | impresso |
| Capacidade esperada, por capacidade | top-1 19/20 · top-2 20/20 | recontagem offline |
| Abstenções | 5 (16,7%): 2 por G1, 0 por G2, 3 por G3 | impresso; divisão por guarda, recontagem |
| Execuções | 16, das quais 15 na capacidade esperada | recontagem offline |
| Custo: pipeline / baseline | US$ 0,16003 / US$ 0,90000 → economia de 82,2% | impresso |
Por que 26,3% não é a qualidade da recuperação. O ToolMatch.name que o retriever devolve é o nome da
variante que casou (por exemplo verificar_saldo_conta_corrente), e o expected_tool
do dataset é o nome canônico (consultar_saldo). A comparação por string falha mesmo com a capacidade certa.
Recontando por capacidade, o resultado é 19/20 e 20/20. O denominador também é 19, não 20: a query barrada por G1 nunca chegou ao retriever e
ficou fora da conta.
Por que 82,2% é maior que os 77,8% do resto da página. Cinco mensagens foram desviadas por abstenção e não chamaram LLM. É o
mesmo efeito descrito em Economia sem taxa de sucesso é risco disfarçado: a economia sobe quando o sistema faz
menos. A execução que sai da capacidade esperada é "Manda o pdf da minha fatura atual" → reenviar_fatura_email, que o
dicionário não agrupa em consultar_fatura. E as 15 certas incluem a query de e-mail, cuja ferramenta de leitura o
dicionário agrupa sob atualizar_email. A branch enxuta não calcula execuções incorretas nem o ponto de equilíbrio do
custo de atendimento humano: essas duas contas só existem na main.
Branch enxuta × main, técnica por técnica
| Ponto | Branch enxuta | main |
|---|---|---|
| Dobras da calibração | cv = 3 (menor classe ≥ 3) |
até 5: min(5, menor classe) |
| Segundo campo do score | derivado de nome e descrição: quase redundante | glossário escrito à mão por capacidade, indexado em campo separado |
| Colapso por capacidade | dicionário embutido: 49 variantes, 12 capacidades | taxonomy.py: 14 capacidades, 45 variantes, com invariantes testadas |
| Desempate | ordem estável do catálogo | direção, depois −score, depois nome da capacidade |
| Piso de score (G2) | no harness; o retriever só descarta score ≤ 0 | min_score no retriever, com 0,10 passado por quem o monta |
| Direção (G4) | prefixo do nome; só leitura→escrita zera | mode declarado (59 de 285); leitura→escrita descarta, escrita→leitura rebaixa |
| Métricas | precision_at_k por nome exato |
hit rate@1 e @k, execuções corretas e incorretas, ponto de equilíbrio, quality gate tri-estado |
As cinco técnicas são simples e baratas nas duas branches. O que separa a enxuta da main não é uma técnica mais
sofisticada, e sim o que cerca cada uma: um campo com informação nova, a direção declarada, um contrato de métrica que compara capacidade com
capacidade e um desempate explícito.
08 · Da Prova de Conceito à Produção
Arquitetura Multi-Linguagem de Alta Performance (ADR-009 & RFC)
O pipeline em Python do MVP provou a solidez matemática do motor: 100% de precisão e zero execuções incorretas.
Entretanto, transferir pods Python diretamente para o caminho crítico de produção bancária (atendendo dezenas de milhares
de requisições por segundo com SLA p99 < 1-3 ms) esbarra em limitações incontornáveis:
o Global Interpreter Lock (GIL), o jitter imprevisível de Garbage Collection (GC)
e o custo de dispatch dinâmico.
A resposta de engenharia formalizada no ADR-009 e no RFC de Arquitetura é o desacoplamento estrito entre Cold Path e Hot Path:
Cold Path: Pesquisa & MLOps
Python 3.12 · Offline / CI
Mantém o ecossistema Python onde ele é imbatível: engenharia de features, treino com scikit-learn,
calibração via CalibratedClassifierCV (Platt Scaling), SHAP e geração de artefatos determinísticos.
- Exportação ONNX: Grafo de inferência linear compilado (
modelo_router.onnx). - Matriz Esparsa CSR: Os 285 documentos do catálogo são pré-computados e serializados em
doc_matrix.csr. - Vocabulário Determinístico: Dicionário TF-IDF indexado com pesos IDF (
vocab_tfidf.json). - Imutabilidade Bancária: Artefatos versionados em Object Storage assinados com hash SHA-256.
- Isolamento Rígido: O runtime de produção nunca importa Python, numpy ou scikit-learn.
Hot Path: OmniRoute Gateway
Rust / Go · Online StatelessPonto de entrada único stateless via gRPC ou HTTP/2. Motor de inferência embutido (in-process), eliminando saltos de rede, serialização intermediária ou chamadas IPC.
- Zero Garbage Collection: Rust opera sem coletor de lixo, eliminando jitter e garantindo latência p99 determinística (< 1-3 ms).
- Zero-Allocation no Hot Path: Pools estáticos e buffers pré-alocados via
tokioe arenas de memória. - Guarda de 4 Camadas em RAM: Router, Retriever (multiplicação de matriz esparsa paralela) e Guard Rails executados em < 10 µs.
- Container Distroless: Binário estático único (~20 MB) com superfície de ataque mínima e memory safety comprovada pelo compilador.
- Slot-Filling Enxuto: O LLM só é invocado quando a ferramenta já foi isolada e autorizada.
Matriz de Decisão Tecnológica: Rust vs Go
O documento de arquitetura compara detalhadamente as opções de engenharia para o OmniRoute Gateway:
| Critério de Avaliação | Rust (Recomendado Primário) | Go (Alternativa Secundária) |
|---|---|---|
| Gerenciamento de Memória | Zero GC (Ownership & Borrow Checker). Sem pausas imprevisíveis. | Garbage Collector com pausas sub-milissegundos, mas com risco de jitter sob alto volume de strings. |
| Latência p99 | Sub-milissegundo (< 1 ms) determinística e previsível. | 2 ms a 5 ms (pequenas oscilações durante coletas do GC). |
| Concorrência | tokio (I/O não-bloqueante) + rayon (paralelismo de dados). |
Goroutines nativas e canais. Excelente concorrência I/O. |
| Álgebra Linear & ONNX | Crate ort (bindings oficiais ONNX Runtime de zero-overhead) + sprs (CSR). |
onnxruntime-go via CGo (custo de troca de contexto Go ↔ C). |
| Conformidade e Segurança | Prevenção formal de data races e buffer overflows em tempo de compilação. | Memory safe, porém com risco de alocações excessivas no heap. |
| Artefato de Entrega | Binário estático (~15-25 MB), container distroless. |
Binário estático (~20-30 MB), container distroless. |
O Ecossistema de Decisões de Arquitetura (9 ADRs)
Cada escolha estrutural foi registrada segundo o padrão Architecture Decision Record (ADR), garantindo que qualquer novo membro do time compreenda o contexto, alternativas descartadas e consequências:
Arquitetura Multi-Linguagem & Desacoplamento Cold/Hot Path
Segregação do pipeline Python offline para inferência compilada em Rust/Go com latência p99 < 1-3 ms.
Guarda de Direção (Leitura vs Escrita)
Contenção definitiva do Crash Silencioso: queries de leitura nunca executam tools de escrita.
Calibração Platt Scaling & Guarda de Margem
Platt Scaling em estimador logístico, eliminando 45% de falsos negativos por subconfiança.
Taxonomia de Capacidades e Colapso Canônico
Colapso de duplicatas operacionais redundantes, elevando o Hit Rate@2 de 35% para 100%.
Alinhamento APM & OmniRoute Gateway
Manifesto corporativo de empacotamento em conformidade com LGPD, PCI-DSS v4.0 e BACEN 4893.
Fundamentos: Normalização, Router, Ranking e Harness
Normalização Unicode NFKD canônica, separação léxica e harness determinístico de métricas.
09 · Governança de Catálogo
O catálogo é o problema, não a vetorização
O diagnóstico inicial de baixo recall (35% Hit Rate@2) parecia uma falha do modelo. O exame do registrador de ferramentas revelou a causa real: duplicações semânticas acumuladas por times diferentes ao longo de anos.
consultar_fatura -> "Gera o PDF ou linha digitável da fatura do mês atual."
gerar_linha_digitavel_fatura -> "Gera a linha digitável da fatura ... para pagamentos."
enviar_pdf_fatura_atual -> "Envia o PDF da fatura atual ... para o cliente."
consultar_valor_fatura_mes_atual -> "Consulta o valor da fatura ... do mês atual."
Para um retriever lexical simples, a variante hiperespecífica sempre vence a canônica porque repete palavra por palavra da query.
A solução foi introduzir o conceito de Capacidade Canônica com Colapso: o retriever agrupa as variantes sob a mesma capacidade e devolve em k=2 duas capacidades distintas.
A variante específica que casou é preservada em matched_variant como trilha de auditoria para o runtime.
10 · Evolução Medida
Economia sem taxa de sucesso é risco disfarçado
Conforme a Lei de Goodhart ("quando uma métrica vira meta, ela deixa de ser uma boa métrica"), otimizar cegamente a economia de custos levou o pipeline inicial a abster-se de quase metade das chamadas, inflando artificialmente a porcentagem de economia.
| Métrica / Iteração | Commit inicial (fc830dc) |
Iteração com Crash (c8f30d2) |
Versão publicada (1f780c0) |
|---|---|---|---|
| Testes Automatizados | 35 | 54 | 72 passed, 0 skipped |
| Hit Rate@2 | 35% | 100% | 100% |
| Execução Correta Top-1 | 20% (4/20) | 95% (19/20) | 100% (20/20) |
| Execuções Incorretas | 7 incorretas | 0 (mas vulnerável) | 0 incorretas |
| Leitura Resolvendo Escrita | Não medido | Sim (margem 0.47) | Impossível (G4) |
| Fallback por Baixa Confiança | 9 abstenções | 0 | 0 |
| Economia de LLM medida (benchmark) | 87,8% (inflada) | 78,9% | 77,8% (líquida real) |
| Status do Harness | REPROVADO | APROVADO | APROVADO NO BENCHMARK |
Por que a economia caiu de 87,8% para 77,8%? Na primeira versão, 9 abstenções indevidas evitavam chamadas de LLM, inflando a economia enquanto entregavam apenas 20% de sucesso. A versão final resolve 20 de 20 queries transacionais com 100% de acerto e paga LLM por todas elas.
O que 77,8% mede — e o que não mede. É a redução do custo de chamadas de LLM
entre o pipeline inteligente e o baseline "tudo pro LLM", no benchmark offline. Não é economia financeira
total do sistema: o custo do fallback humano, por exemplo, é premissa e não medição — por isso o relatório
publica cost_savings_pct_on_resolved, deferred_to_human e
human_fallback_breakeven_cost_usd lado a lado.
11 · O Processo
Como foi desenvolvido: o método agêntico com crítica incorporada
O desenvolvimento não seguiu uma linha reta. Duas rodadas foram impulsionadas por revisão adversarial externa, onde cada apontamento gerou um ciclo de diagnóstico, medição em ADR (Architecture Decision Record) e suíte de testes estendida. A terceira não teve revisor: apareceu porque o estado estacionário foi testado em vez de aceito no verde.
Crítica ao Commit fc830dc
Diagnóstico de recall de 35%, 7 execuções incorretas e economia inflada. Produziu as ADRs 0006 e 0007, elevando a suíte de 35 para 54 testes.
Reavaliação Adversarial do Commit c8f30d2
Mapeamento de 8 riscos remanescentes. O teste de colisão leitura/escrita recomendado expôs o Crash Silencioso. Gerou a ADR-0008 (guarda de direção) e somou os testes de colisão à suíte.
A saga da procedência do relatório — commits 0d2c9ae … 1f780c0
Regenerar o candidate_report.json e rodar a suíte de novo revelou a contagem com um 1 skipped a mais. O skip não era condicional benigno — era um teste desistindo em silêncio. Corrigido, a suíte fecha em 72 passed, 0 skipped.
A procedência que contaminava a própria medição
O relatório de métricas é versionado — e um JSON versionado envelhece em silêncio: o código muda, o arquivo fica, e quem lê acredita em números que o commit atual não produz mais. O projeto tem um teste que recomputa as decisões contra o código vigente. Esse teste teve três defeitos da mesma família, todos com o relatório sujando a medição que deveria auditá-lo:
-
Asserção insatisfazível. Exigia
snapshot.git_commit == HEAD. Como commitar o relatório cria um commit novo, osnapshotsempre aponta para o anterior — a asserção nunca poderia passar. A propriedade realmente auditável é outra: entre o commit que gerou o relatório e o HEAD, nada fora dereports/mudou — e a falha passa a nomear os arquivos atrasados. -
git_dirtyse auto-sujava.build_snapshot()contavareports/na verificação de árvore suja. Como o relatório é escrito pela própria função, da segunda execução em diante ele se declarava irreprodutível por causa do arquivo que acabara de gerar. -
_git().strip()comia o espaço do prefixo de status. O filtro fatiavaline[3:]do--porcelain, mas o.strip()na saída remove o espaço inicial da primeira linha: o caminho saía deslocado (eports/...), o filtro nunca casava e o teste voltava a pular em silêncio. Resolvido delegando o filtro ao próprio git (pathspec':(exclude)reports'), sem parsing.
Uma suíte verde com 1 skipped parecia saudável. Não era — o skip era um teste desistindo em silêncio, e só apareceu porque o estado estacionário foi testado (regenerar o relatório, rodar a suíte de novo) em vez de aceitar o verde. É a terceira instância da tese desta página: métricas verdes não são cobertura.
Princípios que governaram as decisões
- Medir antes de editar: Nenhuma alteração foi feita sem antes registrar a tabela de métricas antes/depois na ADR.
- Nunca mover a trave: Soluções fáceis (como subir parâmetros de probabilidade para mascarar erros) foram sumariamente rejeitadas.
- Testes de Ausência de Efeito: Testes que garantem que
mock_tool_executionnunca é chamada se qualquer guarda reprovar. - Testar o estado estacionário: Não basta a suíte passar uma vez — ela tem de continuar passando depois que o sistema escreve os próprios artefatos.
12 · Especificação Executável
A Suíte de 72 Testes como Especificação do Sistema
Oito arquivos de teste divididos por invariantes de segurança e integridade. A parte mais valiosa não verifica um valor retornado; verifica que nada aconteceu:
def test_a_read_request_can_never_resolve_to_a_write_capability(retriever):
"""Antes da guarda de direcao, "Qual e o email cadastrado na minha conta?"
devolvia atualizar_email (ESCRITA) em 1o lugar com margem relativa de 0.47 —
acima do limiar de 0.25: a guarda de margem NAO pegava."""
for query in read_requests: # 5 consultas puras
for m in retriever.search(query, k=3).matches:
assert CAPABILITY_MODE.get(m.name) != "write"
Categorias da Suíte de Testes
- Ausência de efeito:
mock_tool_executionnunca é chamada quando qualquer guarda reprova (executed.assert_not_called()). - Integridade da Taxonomia: 7 invariantes de autoria (existência no catálogo, ausência de colisão, leitura ≠ escrita, direção declarada e herdada).
- Generalização sem Ajuste ao Gabarito: 12 paráfrases escritas com vocabulário totalmente ausente do dataset oficial (9/12 no top-1, 11/12 no top-2).
- Estabilidade e Procedência: Embaralhar a ordem do catálogo não altera decisão alguma; o relatório em JSON corresponde exatamente ao commit e código vigentes — e essa checagem não pode pular (foi o defeito da rodada 3).
13 · Transparência Técnica
Limitações Reais do Projeto (O que o benchmark não cobre)
Publicar limitações é o que separa engenharia de propaganda. As 7 restrições abaixo estão registradas no README do repositório oficial:
- Aprovado neste benchmark, não para produção bancária viva: 30 queries, 53 exemplos de treino e tools mockadas. Tráfego real com adversários exige camadas de MFA, autorização e idempotência fora deste escopo.
- A guarda de direção só protege o que está declarado: 59 das 285 ferramentas têm
mode(14 capacidades canônicas + 45 variantes). As outras 226 passam sem restrição de direção. O lugar arquitetural correto paramodeé o próprio registry — é metadado do endpoint, não da taxonomia. - Teto de recuperação lexical em paráfrase: O recall de paráfrases puras sem palavras-chave atinge 9/12 no top-1. O próximo degrau exige embeddings densos/reranker.
- Taxonomia mantida manualmente: 14 capacidades e 45 variantes configuradas em código Python sobre 285 ferramentas.
- As guardas 2 e 3 não são mais exercitadas pelo benchmark oficial: Com 20/20 e zero abstenções, elas passam a ser provadas só pelos testes de ausência de efeito. Um benchmark que nunca aciona a rede de segurança não demonstra que ela funciona.
- Folga de calibração do router é estreita: A query
AGENTmenos confiante registra 0.7674 contra o limiar de 0.75 — 0.017 de folga, registrada como risco. (Já as margens das decisões corretas ficam entre 0.592 e 0.741 contra o limiar de 0.25: essa, sim, folgada.) - Custo de fallback humano é premissa, não medição: O número confiável é o ponto de equilíbrio, que é derivado das medições.
14 · Ecossistema & Links
Referências Conceituais do Site
Engenharia de Confiança
- A Engenharia da Confiança — O modelo ISM e a contenção do Crash Silencioso.
- Engenharia de Agentes de IA — Os Cinco Pilares e o Espectro de Autonomia.
- Agent Ready — A superfície do site legível por agentes.
Fundação & Método
- Formulação de Problemas — Reformular o problema de retrieval para governança de catálogo.
- Knowledge OS Enterprise — Context Lakes e conhecimentos explícitos.
- O Artífice Invisível — Tornando visível o trabalho de engenharia por trás do 100%.