0

Versionamento de decisões de IA: respondendo “por que o sistema decidiu isso em março?” (2026 edicao)

English summary: How to version prompts, models, and policies so AI decisions stay auditable and reproducible — decision records, aliases versus pinned versions, prompt migrations, and rollback you have actually tested.

Publicado: 22 de setembro de 2026
Tempo de leitura: 16 min
Palavras-chave: reprodutibilidade, auditoria, versionamento, governança, rollback


A pergunta chega sempre da mesma forma, e sempre tarde demais:

"Por que o sistema recusou este caso em março?"

Você abre o banco. Encontra decision: rejected, confidence: 0.71, e um timestamp. Nada sobre qual prompt estava no ar, qual versão do modelo o alias apontava naquele dia, ou qual limiar a policy usava.

Você reconstrói de memória. Alguém lembra que o prompt mudou "por volta de fevereiro". O provedor não expõe mais a versão que estava ativa. A resposta final é uma reconstrução plausível — e uma reconstrução plausível não é uma auditoria.

O problema não é falta de log. É que o log guardou o resultado e não guardou o contexto que o produziu.

TL;DR

  • Uma decisão de IA tem cinco eixos de versão: entrada, prompt, modelo, policy e código.
  • Guarde os cinco junto do resultado. Sem isso, reprodução é reconstrução.
  • Alias (model-latest) é conveniência de upgrade e inimigo de reprodutibilidade. Fixe a versão.
  • Prompt é artefato versionado com hash, não string em variável de ambiente.
  • A policy — os cortes — muda mais que o prompt e é a que menos gente versiona.
  • Registro de decisão imutável: escreve uma vez, nunca atualiza.
  • Rollback só existe se foi exercitado. Documentado não conta.

Os cinco eixos

Toda decisão é uma função de cinco entradas. Mudou qualquer uma, a saída pode mudar.

EixoMuda com que frequênciaComo identificar
entradaa cada requisiçãohash do estado normalizado
promptsemanashash do template + versão semântica
modelofora do seu controleID exato, não alias
policydias a semanasversão do arquivo de cortes
códigoa cada deploycommit SHA

O eixo que mais surpreende é a policy. Times versionam prompt com cuidado, guardam o modelo, e deixam o limiar de confiança como uma constante que alguém ajusta em um PR sem changelog. Só que mudar confidence >= 0.65 para 0.70 altera o comportamento do sistema tanto quanto trocar de modelo — e não deixa rastro nenhum.

Prompt como artefato, não como string

A pior versão disso é o prompt dentro de uma variável de ambiente. Ele muda sem revisão, sem histórico e sem relação com o código que o consome.

Trate prompt como arquivo versionado com identidade derivada do conteúdo:

import hashlib
from dataclasses import dataclass
from pathlib import Path

PROMPTS_DIR = Path(__file__).parent / "prompts"


@dataclass(frozen=True)
class Prompt:
    name: str
    version: str          # semântica, escrita por humano: "triage-v3"
    template: str
    content_hash: str     # derivada, detecta edição sem bump de versão

    @classmethod
    def load(cls, name: str, version: str) -> "Prompt":
        path = PROMPTS_DIR / name / f"{version}.md"
        template = path.read_text()
        return cls(
            name=name,
            version=version,
            template=template,
            content_hash=hashlib.sha256(template.encode()).hexdigest()[:12],
        )

    @property
    def id(self) -> str:
        return f"{self.name}/{self.version}@{self.content_hash}"

O content_hash existe para pegar o caso mais comum de drift silencioso: alguém edita triage-v3.md para "só corrigir uma vírgula" e não sobe a versão. A versão semântica continua v3, o hash muda, e o registro mostra que houve duas coisas diferentes chamadas v3.

Um teste barato transforma isso em barreira:

def test_prompts_congelados():
    """Prompt publicado não muda sem bump de versão."""
    frozen = json.loads((PROMPTS_DIR / "lockfile.json").read_text())
    for prompt_id, expected_hash in frozen.items():
        name, version = prompt_id.split("/")
        actual = Prompt.load(name, version).content_hash
        assert actual == expected_hash, (
            f"{prompt_id} foi editado sem bump de versão "
            f"(esperado {expected_hash}, atual {actual})"
        )

Alias é conveniência, não garantia

model-latest resolve um problema real: você recebe melhorias sem tocar em código. E cria outro: o comportamento do seu sistema muda sem deploy do seu lado.

A escolha não é ideológica, é por ambiente:

MODEL_BY_ENV = {
    "dev":     "provider/model-latest",    # quer o novo cedo
    "staging": "provider/model-latest",    # detecta mudança antes da produção
    "prod":    "provider/model-1.13.0",    # fixo, promovido conscientemente
}

Staging no alias e produção fixa é o arranjo que dá o melhor dos dois: a mudança aparece primeiro onde não machuca, e a promoção para produção é um PR com eval anexado.

E mesmo com versão fixa, registre a versão efetiva que o provedor reportou:

def evaluate(self, state, questions) -> Evaluation:
    response = self.client.post(..., json={"model": self.model_id, ...})
    body = response.json()

    effective = body.get("model") or response.headers.get("x-model-version")
    if effective and effective != self.model_id:
        logger.bind(requested=self.model_id, effective=effective).warning(
            "provedor serviu versão diferente da solicitada"
        )
    return Evaluation(..., model_effective=effective or self.model_id)

Provedor servindo versão diferente da pedida é raro, e é exatamente o tipo de evento que você quer descobrir por log, não por comportamento estranho três semanas depois.

Policy versionada, porque cortes são decisão de produto

# policies/triage-v5.yaml
version: triage-v5
effective_from: 2026-09-15
author: anderson
rationale: >
  Corte de urgência baixado de 0.50 para 0.35 após eval de 22/09.
  Custo de FN estimado em 30x o custo de FP pelo time de suporte.
thresholds:
  urgent_probability: 0.35
  min_confidence: 0.65
  department_min_probability: 0.55
actions:
  above_urgent: page_on_call
  below_confidence: human_review
  default: normal_queue

O campo rationale é o que transforma o arquivo em documento de auditoria. Seis meses depois, ele responde "por que 0.35?" sem depender de ninguém lembrar da reunião.

Carregue a policy como objeto versionado e passe a versão adiante:

@dataclass(frozen=True)
class Policy:
    version: str
    urgent_probability: float
    min_confidence: float

    def decide(self, ev: Evaluation) -> tuple[str, str]:
        """Retorna (ação, regra que disparou) — a regra vira parte do registro."""
        if ev.confidence < self.min_confidence:
            return "human_review", "min_confidence"
        if ev.is_urgent >= self.urgent_probability:
            return "page_on_call", "above_urgent"
        return "normal_queue", "default"

Devolver qual regra disparou junto da ação é um detalhe pequeno com efeito grande. Ele responde não só "o que o sistema fez" mas "por qual caminho", que é a pergunta real de quem audita.

O registro de decisão

Uma tabela, imutável, escrita uma vez.

CREATE TABLE decision_record (
    id                uuid PRIMARY KEY,
    subject_type      text NOT NULL,          -- 'ticket'
    subject_id        uuid NOT NULL,
    decided_at        timestamptz NOT NULL DEFAULT now(),

    -- os cinco eixos
    input_hash        text NOT NULL,
    prompt_id         text NOT NULL,          -- triage/v3@a1b2c3d4e5f6
    model_requested   text NOT NULL,
    model_effective   text NOT NULL,
    policy_version    text NOT NULL,
    code_sha          text NOT NULL,

    -- o que o modelo disse
    raw_answer        jsonb NOT NULL,
    confidence        double precision,

    -- o que o sistema fez
    action            text NOT NULL,
    rule_fired        text NOT NULL,
    degraded          boolean NOT NULL DEFAULT false,
    source            text NOT NULL,          -- model | heuristic | human

    -- o que um humano fez depois
    human_override    text,
    overridden_at     timestamptz,
    override_reason   text
);

CREATE INDEX idx_decision_subject ON decision_record (subject_id, decided_at DESC);
CREATE INDEX idx_decision_prompt  ON decision_record (prompt_id, decided_at);
CREATE INDEX idx_decision_policy  ON decision_record (policy_version, decided_at);

Três decisões de schema que valem explicação.

input_hash em vez do input. Você quer poder verificar se a entrada era a mesma sem guardar PII indefinidamente. O hash prova identidade; o dado original vive na tabela de origem, sujeito à sua política de retenção.

Override humano na mesma linha. Colocar human_override aqui, e não em outra tabela, torna trivial a consulta que mais importa para qualidade: onde o modelo e o humano discordaram.

Imutável. O único UPDATE permitido é o do override. Decisão nova gera linha nova. Sem isso, o histórico é reescrito e a auditoria perde sentido.

def record_decision(conn, ctx: DecisionContext) -> None:
    conn.execute(
        """INSERT INTO decision_record
           (id, subject_type, subject_id, input_hash, prompt_id,
            model_requested, model_effective, policy_version, code_sha,
            raw_answer, confidence, action, rule_fired, degraded, source)
           VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)""",
        (uuid4(), ctx.subject_type, ctx.subject_id, ctx.input_hash,
         ctx.prompt.id, ctx.model_requested, ctx.model_effective,
         ctx.policy.version, CODE_SHA, Json(ctx.raw), ctx.confidence,
         ctx.action, ctx.rule_fired, ctx.degraded, ctx.source),
    )

O que isso te dá em troca

A tabela paga o custo do dia em que ela é consultada.

Reprodução literal:

SELECT prompt_id, model_effective, policy_version, code_sha, raw_answer, rule_fired
FROM decision_record
WHERE subject_id = %s ORDER BY decided_at;

Cinco identificadores e a resposta bruta. Você reconstrói o cenário exato, sem depender de memória.

Detectar mudança de distribuição por versão:

SELECT prompt_id, model_effective, policy_version,
       count(*)                          AS decisoes,
       avg((action = 'page_on_call')::int) AS taxa_urgente,
       avg(confidence)                   AS confianca_media
FROM decision_record
WHERE decided_at > now() - interval '60 days'
GROUP BY 1, 2, 3
ORDER BY min(decided_at);

Essa consulta é o painel de regressão mais barato que existe. Quando a taxa muda entre duas linhas, você já sabe qual eixo mudou junto.

Medir a qualidade pelo override humano:

SELECT policy_version,
       count(*) FILTER (WHERE human_override IS NOT NULL)::float / count(*) AS taxa_override,
       mode() WITHIN GROUP (ORDER BY rule_fired)
         FILTER (WHERE human_override IS NOT NULL)                          AS regra_mais_revertida
FROM decision_record
WHERE decided_at > now() - interval '30 days'
GROUP BY 1;

A regra mais revertida é o melhor candidato a ajuste que você vai encontrar — e ela sai de dado, não de opinião.

Migração de prompt sem reescrever história

Quando a versão nova entra, as decisões antigas permanecem com o prompt_id antigo. É isso que permite comparar em vez de substituir.

def promote(new_version: str, canary_pct: int = 10) -> None:
    """Promoção gradual: canário antes de todo o tráfego."""
    flags.set("triage.prompt_version.canary", new_version)
    flags.set("triage.prompt_version.canary_pct", canary_pct)


def prompt_for(subject_id: str) -> Prompt:
    canary = flags.get("triage.prompt_version.canary")
    if canary and hash_bucket(subject_id) < flags.get("triage.prompt_version.canary_pct", 0):
        return Prompt.load("triage", canary)
    return Prompt.load("triage", flags.get("triage.prompt_version", "v3"))

Com 10% no canário por três dias, a consulta de distribuição acima compara as duas versões no mesmo tráfego, no mesmo período — que é a comparação que um eval offline não consegue fazer.

Rollback: se não foi exercitado, não existe

Rollback documentado em wiki tem a mesma eficácia que backup nunca restaurado.

@pytest.mark.drill
def test_rollback_de_prompt(flags, decisions):
    """Exercício: reverter prompt e confirmar que o registro reflete a reversão."""
    flags.set("triage.prompt_version", "v3")
    before = run_traffic(50)
    assert all(d.prompt_id.startswith("triage/v3") for d in before)

    flags.set("triage.prompt_version", "v2")        # rollback
    after = run_traffic(50)

    assert all(d.prompt_id.startswith("triage/v2") for d in after)
    assert decisions.count() == 100, "decisões antigas foram sobrescritas"

A última asserção é a que mais importa: rollback não pode apagar o que já foi decidido. Se o registro tem 50 linhas em vez de 100, alguém implementou UPDATE onde devia ter INSERT.

Exercite os três: prompt, policy e modelo. Cada um por um caminho diferente — flag, arquivo e configuração — e cada um pode estar quebrado sozinho.

Checklist

  • Os cinco eixos são persistidos com cada decisão.
  • Prompt é arquivo versionado com hash de conteúdo, não variável de ambiente.
  • Existe lockfile de prompt validado em CI.
  • Produção usa versão fixa de modelo; staging usa alias.
  • A versão efetiva reportada pelo provedor é registrada e comparada.
  • Policy vive em arquivo versionado com rationale.
  • A regra que disparou é gravada junto da ação.
  • O registro de decisão é imutável, exceto pelo override humano.
  • input_hash substitui o input bruto para respeitar retenção.
  • Rollback de prompt, policy e modelo foi exercitado nos últimos 90 dias.

Conclusão

A diferença entre um sistema auditável e um sistema com logs é a diferença entre reproduzir e reconstruir.

Reconstruir é o que você faz quando guardou o resultado: junta memória, git blame e boa vontade, e produz uma narrativa plausível. Reproduzir é o que você faz quando guardou os cinco eixos: uma consulta devolve exatamente o cenário daquele dia.

O custo é uma tabela e alguns campos a mais por decisão. O retorno aparece inteiro de uma vez, no dia em que alguém pergunta por que o sistema decidiu aquilo em março — e você responde em trinta segundos, com evidência, em vez de trinta minutos de arqueologia.

Leituras relacionadas

Os exemplos usam PostgreSQL e Python. O conteúdo de raw_answer pode conter dados do usuário: aplique a mesma política de retenção e minimização que você aplica à tabela de origem.

Carregando publicação patrocinada...