Dá para usar um LLM como o Claude na conciliação de pagamentos sem entregar a ele a decisão sobre dinheiro. O modelo entra só depois das regras determinísticas e trabalha sobre uma lista de candidatos que o SQL já filtrou. Ele devolve uma proposta em saída estruturada, e o código confere a soma, a tolerância e o estado de cada registro antes de gravar qualquer coisa. O que não passa nessa checagem vai para uma fila de revisão humana, junto com o motivo que o modelo escreveu.
Esse é o desenho que uso para atacar a parte chata da conciliação: as linhas de extrato que sobram todo dia e que alguém do financeiro acabaria casando na mão, olhando planilha.
Por que as regras de conciliação sempre deixam sobras?
Conciliação é o processo de bater o que o provedor (adquirente, gateway, banco) diz que liquidou com o que o seu sistema registrou. Escrevi sobre a base disso em conciliação de pagamentos no Laravel batendo o extrato do provedor com o banco todo dia. As regras clássicas casam por identificador: NSU (número sequencial único que a adquirente atribui à transação), ID da cobrança no gateway, end-to-end ID do Pix.
O problema é o que fica de fora. Os casos típicos de sobra são:
- Liquidação agregada: o provedor deposita um valor único que corresponde a várias vendas, menos taxas, e o extrato não traz o detalhamento.
- Identificador ausente ou truncado: o banco corta a descrição em poucos caracteres e o ID some.
- Valor ligeiramente diferente: tarifa cobrada depois, arredondamento de parcela, antecipação com desconto.
- Data deslocada: D+1 que virou D+3 por causa de feriado, ou captura registrada em um dia e liquidação em outro.
- Descrição em texto livre: transferências recebidas com "PAGTO REF PEDIDO 12 JOAO" e nada mais.
Dá para escrever regra para cada um desses, e eu escrevo para os padrões que se repetem. Mas a cauda longa é longa mesmo. É nessa cauda que um LLM rende: ele lê texto bagunçado, relaciona descrição com nome do cliente e percebe que três vendas somadas menos a taxa dão o depósito. Só que ele erra conta e inventa coisa, então não pode ter a última palavra.
Onde o LLM entra no pipeline de conciliação?
A ordem importa mais que o prompt. O pipeline que funciona fica assim:
- Match exato por identificador (NSU, ID externo). Barato, determinístico, resolve a maioria.
- Match por regras heurísticas (mesmo valor, mesma data, mesmo lojista, candidato único). Ainda determinístico.
- Geração de candidatos para a sobra via SQL: janela de data, faixa de valor, mesmo lojista, ainda não conciliados.
- Proposta do LLM sobre esses candidatos, com saída estruturada.
- Validação no código: aliases existem, soma bate dentro da tolerância, nenhum registro já foi conciliado.
- Aplicação automática ou revisão humana, conforme o tipo do match e a confiança.
O LLM nunca vê a base inteira. Ele recebe uma linha do extrato e no máximo algumas dezenas de candidatos. Isso limita o custo, limita o estrago de uma alucinação e deixa a resposta auditável: dá para reproduzir exatamente o que o modelo viu.
Como montar os candidatos antes de chamar o Claude?
A qualidade da proposta depende quase toda dos candidatos. Se o pagamento certo não está na lista, o modelo não tem como acertar e vai tentar casar com o que tiver. Por isso faço duas buscas: uma para match 1:1 (valor próximo) e outra para match agregado (valores menores que o depósito na mesma janela).
final class CandidateFinder { public function para(StatementLine $linha): Collection { $base = Payment::query() ->where('merchant_id', $linha->merchant_id) ->whereNull('reconciled_at') ->whereBetween('expected_settlement_date', [ $linha->posted_at->copy()->subDays(3)->toDateString(), $linha->posted_at->copy()->addDays(2)->toDateString(), ]); // Valores em centavos, sempre inteiros $tolerancia = max(500, intdiv($linha->amount, 50)); // R$ 5 ou 2% $umParaUm = (clone $base) ->whereBetween('net_amount', [$linha->amount - $tolerancia, $linha->amount + $tolerancia]) ->limit(10) ->get(); $agregado = (clone $base) ->where('net_amount', '<', $linha->amount) ->orderByDesc('net_amount') ->limit(25) ->get(); return $umParaUm->merge($agregado)->unique('id')->values(); } }
Dois detalhes que aprendi errando. Primeiro: trabalhe em centavos inteiros do começo ao fim. Mandar 1234.5 para o modelo e receber 1234.50 de volta como string é pedir problema. Segundo: não mande UUIDs para o LLM. Modelos ocasionalmente trocam um caractere de um identificador longo. Eu gero aliases curtos (c1, c2, ...) e guardo o mapa no código:
$mapa = []; $linhas = $candidatos->values()->map(function (Payment $p, int $i) use (&$mapa) { $alias = 'c' . ($i + 1); $mapa[$alias] = $p->id; return sprintf( '%s | liquida %s | líquido %d | bruto %d | %dx | cliente: %s | desc: %s', $alias, $p->expected_settlement_date, $p->net_amount, $p->gross_amount, $p->installments, $p->customer_name ?? '-', $p->description ?? '-', ); })->implode("\n");
O nome do cliente vai junto porque é ele que casa transferência com descrição em texto livre. Se a sua base tem dado sensível que não precisa ir para o modelo, este é o ponto de cortar: CPF, por exemplo, nunca entra nessa string.
Como pedir uma proposta de match com saída estruturada?
Saída estruturada é fazer o modelo responder num formato que o código consegue ler sem parsing frágil. No Claude, a forma mais previsível é declarar uma ferramenta (tool use) com JSON Schema e forçar o uso dela com tool_choice. O modelo não executa nada; ele só preenche os argumentos da ferramenta, e esses argumentos são a proposta.
$resposta = Http::withHeaders([ 'x-api-key' => config('services.anthropic.key'), 'anthropic-version' => '2023-06-01', ]) ->timeout(60) ->post('https://api.anthropic.com/v1/messages', [ 'model' => 'claude-sonnet-4-5', 'max_tokens' => 1024, 'system' => $this->prompt->system(), // regras fixas, versionadas 'tools' => [[ 'name' => 'propor_conciliacao', 'description' => 'Propõe quais candidatos correspondem à linha do extrato.', 'input_schema' => [ 'type' => 'object', 'properties' => [ 'tipo' => ['type' => 'string', 'enum' => ['um_para_um', 'um_para_varios', 'sem_match']], 'candidatos' => ['type' => 'array', 'items' => ['type' => 'string']], 'confianca' => ['type' => 'string', 'enum' => ['alta', 'media', 'baixa']], 'motivo' => ['type' => 'string'], ], 'required' => ['tipo', 'candidatos', 'confianca', 'motivo'], ], ]], 'tool_choice' => ['type' => 'tool', 'name' => 'propor_conciliacao'], 'messages' => [[ 'role' => 'user', 'content' => "Linha do extrato:\n{$linhaTexto}\n\nCandidatos:\n{$linhas}", ]], ]) ->throw() ->json(); $bloco = collect($resposta['content'])->firstWhere('type', 'tool_use'); $proposta = $bloco['input'] ?? null;
Algumas decisões aqui merecem justificativa:
sem_matché uma opção explícita. Sem ela, o modelo se sente obrigado a casar com alguma coisa. Deixo claro no system prompt que "sem match" é uma resposta correta e frequente.- Confiança categórica, não numérica. Um número de 0 a 1 vindo do LLM parece preciso, mas não é calibrado. Três níveis com critério escrito no prompt ("alta: identificador ou nome do cliente confirma; média: só valor e data; baixa: palpite") são mais úteis para decidir o fluxo.
motivoobrigatório. Não é para o código; é para a pessoa que vai revisar. Um motivo como "c3 + c7 + c9 somam 152.340, depósito 149.810, diferença compatível com taxa de 1,66%" economiza minutos de investigação por linha.- O modelo não faz a conta final. Ele pode citar a soma no motivo, mas quem decide se a soma bate é o código, logo abaixo.
O system prompt fica num arquivo versionado, e eu gravo a versão junto de cada proposta. Se uma mudança no prompt piorar o resultado, dá para identificar quais conciliações vieram dela, como descrevi ao falar de versionar prompts com rollout gradual e rollback.
Como validar o que o modelo devolveu antes de tocar no banco?
Esta é a parte que faz o desenho ser seguro. O modelo propõe; o validador decide se a proposta é sequer possível.
final class ProposalValidator { public function validar(array $proposta, array $mapa, StatementLine $linha, Collection $candidatos): ValidationResult { if ($proposta['tipo'] === 'sem_match') { return ValidationResult::semMatch(); } $aliases = collect($proposta['candidatos'])->unique(); if ($aliases->isEmpty() || $aliases->contains(fn ($a) => ! isset($mapa[$a]))) { return ValidationResult::rejeitada('alias inexistente ou vazio'); } if ($proposta['tipo'] === 'um_para_um' && $aliases->count() !== 1) { return ValidationResult::rejeitada('tipo 1:1 com mais de um candidato'); } $ids = $aliases->map(fn ($a) => $mapa[$a]); $soma = $candidatos->whereIn('id', $ids)->sum('net_amount'); $diferenca = $linha->amount - $soma; // Em agregados, a diferença tem que ser compatível com taxa: positiva não, negativa até X% $limite = max(500, intdiv($soma * 3, 100)); if (abs($diferenca) > $limite) { return ValidationResult::rejeitada("diferença de {$diferenca} centavos acima do limite"); } return ValidationResult::valida($ids->all(), $diferenca); } }
A proposta rejeitada não é descartada. Ela vai para a fila humana marcada como "o modelo sugeriu, mas a conta não fecha", porque às vezes o modelo enxergou a relação certa e faltou um candidato que não entrou na busca. Esse é um sinal valioso para ajustar a geração de candidatos.
Na aplicação, a checagem de estado acontece de novo dentro da transação, com lock, porque entre a geração de candidatos e a resposta do modelo outro processo pode ter conciliado o mesmo pagamento:
DB::transaction(function () use ($linha, $resultado, $proposta, $meta) { $pagamentos = Payment::whereIn('id', $resultado->ids) ->whereNull('reconciled_at') ->lockForUpdate() ->get(); if ($pagamentos->count() !== count($resultado->ids)) { throw new StaleProposalException($linha->id); } $conciliacao = Reconciliation::create([ 'statement_line_id' => $linha->id, 'source' => 'llm', 'difference' => $resultado->diferenca, 'reason' => $proposta['motivo'], 'model' => $meta['model'], 'prompt_version' => $meta['prompt_version'], ]); $pagamentos->each->update(['reconciled_at' => now(), 'reconciliation_id' => $conciliacao->id]); });
A diferença (taxa não prevista, desconto de antecipação) é gravada como valor próprio, e não absorvida. Ela precisa virar lançamento contábil depois; esconder a diferença dentro do match é o jeito mais rápido de o saldo deixar de fechar no fim do mês.
Quando aplicar automático e quando mandar para revisão humana?
Mesmo com a validação passando, nem toda proposta deve ser aplicada sozinha. Uso uma matriz simples:
| Tipo | Confiança | Validação | Ação |
|---|---|---|---|
| 1:1 | alta | passou, diferença zero | aplica automático |
| 1:1 | alta | passou, diferença > 0 | revisão humana |
| 1:1 | média/baixa | passou | revisão humana |
| 1:N | qualquer | passou | revisão humana |
| qualquer | qualquer | rejeitada | revisão humana com alerta |
| sem_match | — | — | fila de pendências |
Os agregados 1:N ficam sempre com humano no começo. A combinação de valores tem muitas soluções possíveis quando os candidatos são parecidos (três vendas de R$ 50 e uma de R$ 150 fecham o mesmo total), e o modelo pode escolher uma combinação válida aritmeticamente e errada no mundo real. Só depois de acumular histórico de acertos para um padrão específico de provedor eu considero liberar automático para ele.
Na tela de revisão, a pessoa vê a linha, os candidatos sugeridos, o motivo e a diferença, e confirma ou corrige com um clique. O mecanismo é parecido com o que uso para o agente pedir aprovação antes de agir, que detalhei em human-in-the-loop no Laravel. A diferença é que aqui não há conversa: é uma fila de trabalho do financeiro.
Quanto custa rodar conciliação com LLM todo dia?
Conciliação é batch por natureza: o extrato chega, você processa, ninguém está esperando resposta em tempo real. Isso abre duas otimizações grandes:
- Batch API do Claude: envio todas as linhas sobrantes do dia num único lote para
/v1/messages/batchese busco os resultados quando o processamento termina. O preço é menor que o das chamadas síncronas e o prazo de até 24 horas cabe tranquilamente numa conciliação D+1. - Prompt caching: o system prompt com as regras, os exemplos e a descrição dos padrões de cada provedor é idêntico em todas as chamadas. Marcando esse prefixo com
cache_control, as leituras seguintes ficam bem mais baratas.
Os detalhes de implementação dos dois estão em prompt caching e Batch API do Claude no Laravel. Para estimar antes de ligar em produção, multiplique o volume diário de sobras pelo tamanho médio do prompt (linha + candidatos + system) e jogue na calculadora de custo de IA, que compara modelos com e sem caching e batch.
Sobre escolha de modelo: começo com Sonnet porque o raciocínio sobre combinações de valores e descrições ambíguas é onde modelos menores tropeçam mais. Depois de ter um conjunto de casos avaliados, testo Haiku nos padrões mais simples (1:1 com descrição legível) e mantenho Sonnet nos agregados.
Como saber se o matching com IA está melhorando?
A fila de revisão humana é, de graça, um gerador de golden set. Golden set é um conjunto de casos com a resposta correta conhecida, usado para medir o sistema. Cada decisão humana (confirmou, corrigiu, rejeitou) vira um exemplo rotulado: a linha, os candidatos que o modelo viu e o match correto.
Com isso dá para acompanhar métricas que importam:
- Precisão das automáticas: das propostas aplicadas sem humano, quantas foram desfeitas depois. Esse número precisa ficar perto de zero; se subir, aperto a matriz.
- Taxa de aceitação na revisão: quantas sugestões o humano confirmou sem alterar. É o indicador de quanto trabalho o modelo está poupando.
- Recall de candidatos: em quantos casos corrigidos o pagamento certo nem estava na lista. Esse é um problema do SQL, não do prompt, e é o mais comum.
- Falso
sem_match: casos em que o modelo desistiu e o humano achou o par entre os candidatos.
Antes de trocar prompt ou modelo, rodo a versão nova contra esse conjunto e comparo. Sem isso, qualquer ajuste no prompt é palpite.
Conclusão
Se for implementar, comece pelo final do pipeline e não pelo prompt. Primeiro garanta que as regras determinísticas estão fazendo tudo o que conseguem e que a geração de candidatos traz o pagamento certo na maioria dos casos corrigidos; sem isso, nenhum modelo salva. Depois coloque o LLM só como sugestor, com tudo indo para revisão humana nas primeiras semanas, e use essas revisões para medir. Libere aplicação automática apenas para 1:1 com confiança alta e diferença zero, e só quando a taxa de desfazimento estiver comprovadamente baixa. O ganho real não é tirar o humano da conciliação: é fazer a pessoa do financeiro confirmar uma sugestão com motivo escrito em vez de caçar o par numa planilha.