Para versionar prompts no Laravel sem quebrar produção, trate cada prompt como código. Cada versão vira um arquivo imutável no repositório, com hash registrado. Uma tabela pequena aponta qual versão está ativa e qual está em canário. Toda chamada ao LLM grava a versão usada. Com isso dá para liberar um prompt novo para 10% dos clientes, comparar métricas e voltar atrás com um UPDATE, sem deploy.

O problema aparece cedo em qualquer produto com agente de IA. Alguém ajusta uma frase no prompt de sistema para corrigir um caso, e outros três casos que funcionavam param de funcionar. Ninguém sabe dizer qual texto estava em produção na terça-feira, quando o cliente reclamou. Foi esse tipo de situação que me fez parar de tratar prompt como string solta dentro de uma classe.

Onde guardar os prompts: código, arquivo ou banco?

Existem três lugares comuns, e cada um tem um custo diferente:

Abordagem Vantagem Problema
String dentro da classe PHP Simples, vai junto com o deploy Diff ruim, mistura texto longo com lógica, difícil de revisar
Arquivo .md no repositório Diff legível no PR, histórico no Git, revisável Trocar a versão ativa exige deploy (a menos que o ponteiro fique fora)
Banco com painel admin Troca sem deploy, não-devs podem editar Sem revisão, sem teste, sem histórico confiável; vira terra de ninguém

O que uso é um híbrido. O texto mora no repositório e passa por code review como qualquer outra mudança. O ponteiro para a versão ativa mora no banco, porque é ele que muda na hora de liberar ou reverter. Texto exige revisão. Escolher qual versão roda é uma decisão operacional.

A estrutura fica assim:

resources/prompts/
  agendamento-sistema/
    v1.md
    v2.md
    v3.md
  extrato-categorias/
    v1.md
  prompts.lock.json

Cada prompt tem um diretório e cada versão é um arquivo. O prompts.lock.json guarda o hash SHA-256 de cada versão publicada. Volto nele mais adiante.

Por que o modelo e os parâmetros fazem parte da versão?

Um prompt afinado para o Claude Sonnet nem sempre se comporta igual no Claude Haiku ou no GPT. O mesmo vale para temperature e max_tokens: uma versão que pede respostas mais longas e mantém o limite antigo de tokens vai cortar a resposta no meio. Por isso, para mim, versão de prompt é o conjunto texto + modelo + parâmetros.

// config/prompts.php
return [
    'agendamento-sistema' => [
        'v2' => ['model' => 'claude-haiku-4-5', 'max_tokens' => 1024, 'temperature' => 0.2],
        'v3' => ['model' => 'claude-sonnet-4-5', 'max_tokens' => 1024, 'temperature' => 0.2],
    ],
];

Trocar de modelo mantendo o mesmo texto também é uma versão nova. Parece burocrático, mas é a única forma de responder à pergunta "o que mudou entre ontem e hoje?" olhando só para um identificador.

Como carregar e renderizar o prompt com variáveis?

O prompt de sistema quase sempre tem dados do tenant (o cliente da plataforma): nome do negócio, fuso horário, regras de cancelamento. A primeira tentação é usar Blade, mas o {{ }} do Blade escapa HTML. Um & no nome do negócio vira & dentro do prompt. Dá para contornar com {!! !!}, só que aí você carrega um motor de templates inteiro para fazer substituição de string. Uso strtr e falho alto se sobrar placeholder:

final readonly class Prompt
{
    public function __construct(
        public string $name,
        public string $version,
        public string $text,
        public string $hash,
        public array $params,
    ) {}
}

final class PromptRegistry
{
    public function load(string $name, string $version, array $vars = []): Prompt
    {
        $raw = File::get(resource_path("prompts/{$name}/{$version}.md"));

        $replacements = [];
        foreach ($vars as $key => $value) {
            $replacements['{{' . $key . '}}'] = (string) $value;
        }

        $text = strtr($raw, $replacements);

        if (preg_match('/\{\{\s*\w+\s*\}\}/', $text, $m)) {
            throw new RuntimeException("Variável não preenchida em {$name}@{$version}: {$m[0]}");
        }

        return new Prompt(
            name: $name,
            version: $version,
            text: $text,
            hash: hash('sha256', $raw),
            params: config("prompts.{$name}.{$version}"),
        );
    }
}

Repare que o hash é calculado sobre o template cru, não sobre o texto renderizado. O hash identifica a versão. As variáveis mudam por tenant e não interessam aqui.

A exceção para placeholder vazio salva mais do que parece. Quando a v4 introduz {{politica_cancelamento}} e alguém esquece de passar a variável, o modelo recebe literalmente {{politica_cancelamento}} e inventa uma política. Sem a validação, o erro só aparece como resposta estranha no WhatsApp.

Como garantir que uma versão publicada nunca seja editada?

A regra central é a imutabilidade: publicou, não mexe mais. Se a v3 precisa de ajuste, nasce a v4. Sem isso, o identificador v3 nos logs deixa de significar alguma coisa.

Para não depender de disciplina, o prompts.lock.json mapeia nome@versão para o hash, e um teste quebra o CI se alguém editar um arquivo já publicado:

{
  "agendamento-sistema@v2": "9f2c...",
  "agendamento-sistema@v3": "41ab..."
}
it('não altera versões de prompt já publicadas', function () {
    $lock = json_decode(File::get(resource_path('prompts/prompts.lock.json')), true);

    foreach ($lock as $ref => $hash) {
        [$name, $version] = explode('@', $ref);
        $raw = File::get(resource_path("prompts/{$name}/{$version}.md"));

        expect(hash('sha256', $raw))->toBe($hash, "{$ref} foi editado; crie uma nova versão");
    }
});

Um comando Artisan de dez linhas, que lê os arquivos e escreve o lock, resolve a hora de publicar. Enquanto a versão está em rascunho, ela fica fora do lock e você edita à vontade.

Como liberar um prompt novo só para parte dos clientes?

Rollout gradual (ou canário) é liberar uma versão nova para uma fração do tráfego antes de ela virar padrão. O estado fica em uma tabela prompt_rollouts com name, stable_version, canary_version e canary_percent, cacheada por um minuto para não ir ao PostgreSQL a cada mensagem:

final class PromptRollout
{
    public function versionFor(string $name, string $stickyKey): string
    {
        $rollout = Cache::remember("prompt-rollout:{$name}", 60, fn () =>
            DB::table('prompt_rollouts')->where('name', $name)->first()
        );

        if ($rollout->canary_version && $rollout->canary_percent > 0) {
            $bucket = crc32("{$name}:{$stickyKey}") % 100;

            if ($bucket < $rollout->canary_percent) {
                return $rollout->canary_version;
            }
        }

        return $rollout->stable_version;
    }
}

O bucket precisa ser determinístico. Sortear com random_int a cada chamada faria o mesmo cliente alternar entre versões, e aí as métricas ficam impossíveis de ler. Incluir o nome do prompt no crc32 evita que os mesmos tenants caiam sempre no canário de todos os prompts.

A chave de aderência (stickyKey) envolve uma decisão:

  1. Por tenant: todos os atendimentos de um negócio usam a mesma versão. É mais fácil de investigar quando um cliente reclama, mas a distribuição fica irregular se você tem poucos tenants grandes.
  2. Por conversa: distribui melhor o tráfego, mas o mesmo negócio vê comportamentos diferentes em conversas paralelas.

Uso tenant para prompts de agente e conversa para tarefas pontuais, como classificação.

Posso trocar a versão do prompt no meio de uma conversa?

Não deveria. Se a v3 conduziu as primeiras cinco mensagens e a v4 assume na sexta, o histórico foi gerado sob outras instruções e o modelo pode se contradizer. Por isso a versão é fixada na conversa na primeira mensagem:

if ($conversa->prompt_version === null) {
    $conversa->prompt_version = $rollout->versionFor('agendamento-sistema', (string) $conversa->tenant_id);
    $conversa->save();
}

$prompt = $registry->load('agendamento-sistema', $conversa->prompt_version, [
    'nome_negocio' => $tenant->nome,
    'fuso' => $tenant->timezone,
]);

O efeito colateral é que um rollback não atinge conversas em andamento: elas terminam na versão com que começaram. Se a versão nova estiver causando dano real, como agendar em horário errado, também limpo o prompt_version das conversas abertas com essa versão. É um UPDATE com where, e vale deixá-lo pronto num comando antes de precisar.

O que registrar em cada chamada ao LLM?

Sem a versão no log, o canário não serve para nada. Toda chamada grava nome, versão e hash ao lado dos tokens:

$response = Http::withHeaders([
        'x-api-key' => config('services.anthropic.key'),
        'anthropic-version' => '2023-06-01',
    ])
    ->timeout(60)
    ->post('https://api.anthropic.com/v1/messages', [
        'model' => $prompt->params['model'],
        'max_tokens' => $prompt->params['max_tokens'],
        'temperature' => $prompt->params['temperature'],
        'system' => $prompt->text,
        'messages' => $mensagens,
    ])
    ->throw();

LlmCall::create([
    'tenant_id' => $tenant->id,
    'prompt_name' => $prompt->name,
    'prompt_version' => $prompt->version,
    'prompt_hash' => $prompt->hash,
    'model' => $prompt->params['model'],
    'input_tokens' => $response->json('usage.input_tokens'),
    'output_tokens' => $response->json('usage.output_tokens'),
    'stop_reason' => $response->json('stop_reason'),
    'latency_ms' => (int) ($response->handlerStats()['total_time'] ?? 0) * 1000,
]);

Se você já registra consumo para medir tokens e limitar o custo de IA por cliente, basta acrescentar três colunas na mesma tabela. Gravo o hash junto da versão por precaução: se algum dia o lock falhar, o hash denuncia que duas "v3" diferentes rodaram.

Como saber se a versão nova é melhor que a antiga?

Com a versão no log, a comparação vira uma query. As métricas que acompanho durante o canário:

  1. Tokens médios de entrada e saída: um prompt 40% maior pesa em toda chamada. Antes de subir o percentual, jogo os números na calculadora de custo de IA para ver o impacto mensal.
  2. stop_reason = max_tokens: se cresce na versão nova, a resposta está sendo cortada.
  3. Falhas de validação: JSON fora do schema, tool call com argumento inválido, resposta rejeitada pelas suas regras de negócio.
  4. Taxa de handoff para humano: num agente de atendimento, é o melhor indicador indireto de que o modelo não resolveu o caso.
  5. Latência p95: trocar Haiku por Sonnet melhora a qualidade e piora o tempo de resposta. Às vezes vale, às vezes não.
select prompt_version,
       count(*) as chamadas,
       avg(input_tokens) as media_input,
       avg(output_tokens) as media_output,
       avg(case when stop_reason = 'max_tokens' then 1 else 0 end) as taxa_corte,
       percentile_cont(0.95) within group (order by latency_ms) as p95_ms
from llm_calls
where prompt_name = 'agendamento-sistema'
  and created_at > now() - interval '3 days'
group by prompt_version;

Métrica de produção mostra regressões grandes, mas não pega o erro sutil, como a resposta que parece certa e agenda no dia errado. Para isso a versão nova passa antes por um conjunto de evals, ou seja, casos de entrada com critérios de aprovação, rodados contra a API real. Detalhei como montar esse dataset no post sobre testar código que usa LLM com Http::fake, respostas gravadas e evals. Aqui o ponto é o encaixe no fluxo: eval não roda em todo PR, porque custa dinheiro e demora. Ela roda num job manual do CI, disparado quando um arquivo novo aparece em resources/prompts/, e o canário só passa de 0% depois que ela fica verde.

Versionar prompt atrapalha o prompt caching?

Um pouco, e é bom saber antes. O cache de prompt do Claude depende de o prefixo ser idêntico byte a byte. Durante o canário você tem dois prefixos, e cada um aquece o próprio cache, então a taxa de acerto cai enquanto as duas versões convivem. Em percentuais baixos o impacto é pequeno. Num 50/50 que dura semanas, ele aparece na fatura.

O detalhe que mais pesa é onde ficam as variáveis. Se {{nome_negocio}} está na primeira linha, cada tenant tem um prefixo diferente e o cache quase não acerta entre tenants. Deixo as instruções estáticas no topo e os dados do tenant no fim do prompt de sistema, ou num bloco separado. Falei mais disso em prompt caching e Batch API do Claude no Laravel.

Como fazer rollback de um prompt em produção?

Com o ponteiro no banco, o rollback é uma linha:

update prompt_rollouts
set canary_percent = 0
where name = 'agendamento-sistema';

Em até um minuto o cache expira e as conversas novas voltam para a versão estável. Se quiser efeito imediato, faça um Cache::forget no mesmo comando Artisan que atualiza a tabela. Prefiro o comando ao SQL direto porque ele também registra quem mudou, quando e por quê. Numa fintech, esse histórico costuma ser cobrado depois.

Promover o canário a estável segue o mesmo caminho: stable_version = 'v4', canary_version = null. A v3 continua no repositório e no lock, pronta para voltar se algo aparecer na semana seguinte.

Checklist para começar amanhã

Se hoje seus prompts são strings dentro de services, a migração cabe em um dia, nesta ordem:

  1. Mova cada prompt para resources/prompts/{nome}/v1.md sem alterar uma vírgula e gere o lock.
  2. Crie o PromptRegistry e troque as strings pela chamada a load, com a validação de placeholder.
  3. Adicione prompt_name, prompt_version e prompt_hash na tabela de log das chamadas.
  4. Crie prompt_rollouts com a v1 como estável e o canário zerado.
  5. Fixe a versão na conversa para os prompts de agente.
  6. Só então escreva a v2, rode as evals e libere para 5% dos tenants.

Os passos 1 a 4 não mudam nenhum comportamento em produção, então dá para fazer o deploy sem medo. A partir daí, a próxima mudança de prompt chega com versão, métrica e rollback, e quando o cliente reclamar de uma resposta você consegue abrir o arquivo exato que gerou aquela mensagem.