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:

  1. 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.
  2. Identificador ausente ou truncado: o banco corta a descrição em poucos caracteres e o ID some.
  3. Valor ligeiramente diferente: tarifa cobrada depois, arredondamento de parcela, antecipação com desconto.
  4. Data deslocada: D+1 que virou D+3 por causa de feriado, ou captura registrada em um dia e liquidação em outro.
  5. 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:

  1. Match exato por identificador (NSU, ID externo). Barato, determinístico, resolve a maioria.
  2. Match por regras heurísticas (mesmo valor, mesma data, mesmo lojista, candidato único). Ainda determinístico.
  3. Geração de candidatos para a sobra via SQL: janela de data, faixa de valor, mesmo lojista, ainda não conciliados.
  4. Proposta do LLM sobre esses candidatos, com saída estruturada.
  5. Validação no código: aliases existem, soma bate dentro da tolerância, nenhum registro já foi conciliado.
  6. 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.
  • motivo obrigató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:

  1. Batch API do Claude: envio todas as linhas sobrantes do dia num único lote para /v1/messages/batches e 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.
  2. 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:

  1. 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.
  2. Taxa de aceitação na revisão: quantas sugestões o humano confirmou sem alterar. É o indicador de quanto trabalho o modelo está poupando.
  3. 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.
  4. 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.