9 min čteníJohnny UnarJohnny Unar

Shipujte prompty jako kód, jinak počítejte s regresí

LLM feature v produkci degradují potichu. Verzujte prompty, testujte je v CI, pouštějte canary proti shadow trafficu a udělejte rollback nudně spolehlivý.

tichá selhání jsou default

Týmy pořád berou prompty jako lístečky přilepené bokem k aplikaci, a pak se tváří překvapeně, když update modelu, změna system promptu nebo jeden nevinně vypadající refactor promění funkční feature ve stroj na support tickety. Tohle je amatérismus. Jestli prompt rozhoduje o tom, jestli zákazník dostane správnou klasifikaci faktury, jestli AI sales asistent správně kvalifikuje lead, nebo jestli document pipeline vytáhne správné VAT ID, pak je ten prompt produkční logika. Produkční logika má mít stejnou disciplínu jako aplikační kód.

Nejhorší na tom je, že LLM regrese většinou nevybuchnou nahlas. Nedostanete čistý stack trace. Přijde horší, zákeřnější varianta: support říká „ten feature je tenhle týden nějak horší“, konverze spadne o 7 % nebo si ops tým všimne, že pole, která se dřív extrahovala správně, se najednou vrací jako null, případně ještě hůř, sebevědomě špatně. Tenhle pattern jsme ve Steezru viděli při stavbě interních AI nástrojů i customer-facing workflow, hlavně kolem zpracování dokumentů a sales automatizace. Jedna úprava promptu zlepšila výkon na sample inputech, které všichni řešili, a potichu zhoršila extrakci na ošklivých reálných PDF s divným OCR a mixem jazyků.

Klasický software nás tohle naučil už dávno. Cokoliv mění chování, potřebuje verzování, testy, staged rollout a kill switch. Prompty nejsou výjimka. Pokud prompt žije v jednom řádku v databázi, který někdo edituje přes admin bez review, bez diffu a bez traceability, postavili jste si fabriku na regrese. Dejte ho do Gitu. Reviewujte ho v pull requestech. Když na jeho chování externě záleží, tagujte ho semantickou verzí. Verzi modelu držte vedle něj. Sampling parametry taky. Očekávané schema taky. Pak je tým schopný pod tlakem odpovědět na základní otázky: který prompt běžel, s jakým modelem, za jakých podmínek a kdy přesně se změnilo chování.

Nemusí z toho být akademické cvičení. Repo s prompty může být úplně obyčejný adresář v Next.js nebo Django monorepu, třeba ai/prompts/lead_qualifier/v3.2.1.yaml, commitnutý spolu se zbytkem aplikace. Nuda je dobře. Nuda přežívá incidenty.

udělejte prompty diffovatelné

První praktický krok je vybrat formát souboru, který zachová záměr a zároveň se dobře reviewuje. JSON funguje, YAML se čte líp a čisté .txt soubory jsou v pohodě do chvíle, než potřebujete metadata. Pak si lidi začnou vymýšlet komentářové konvence a celé se to rozpadne do bordelu. My většinou bereme YAML s explicitními poli, protože na jednom místě zachytí i kontrakt chování.

Na začátek stačí něco takového:

yaml
1id: lead_qualifier
2version: 3.2.1
3model: gpt-4.1-mini-2025-04-14
4temperature: 0
5max_output_tokens: 300
6owner: growth-team
7input_schema:
8 type: object
9 required: [company_name, website, notes]
10output_schema:
11 type: object
12 required: [score, reason, industry, employee_band]
13system: |
14 You classify inbound B2B leads for a SaaS sales team.
15 Return valid JSON only.
16 If data is missing, use null and explain uncertainty briefly.
17user_template: |
18 Company: {{ company_name }}
19 Website: {{ website }}
20 Notes: {{ notes }}
21stop: []

Jeden takový soubor vám dá stabilní plochu pro review. V PR je hned vidět, že někdo změnil temperature z 0 na 0.7, zahodil instrukci „jen validní JSON“ nebo vyměnil gpt-4.1-mini-2025-04-14 za novější snapshot. To jsou změny chování a zaslouží si stejnou pozornost jako úprava SQL query v billing flow.

Chcete i lint pravidla. Opravdová. Ne stylistickou buzeraci, ale pravidla, která chytají opakující se failure modes. My třeba vynucujeme: prompt musí mít explicitní model, temperature musí být připnutá na 0, pokud k tomu není napsaný důvod, output prompty musí definovat schema, nesmí zůstat nevyřešené template proměnné, nesmí tam být protichůdné instrukce typu „buď stručný“ a hned potom „dej detailní zdůvodnění“, a nesmí existovat skrytá závislost na stavu aplikace, který není reprezentovaný ve vstupu. Stačí jednoduchý Python linter přes ruamel.yaml nebo pydantic. Když prompt poruší kontrakt, CI má spadnout.

Ještě jedna věc je důležitější, než si lidi myslí: držte system prompty a user template odděleně. Jakmile je začnete lepit dohromady po celém kódu, nikdo už nepozná, jestli regrese přišla z business logiky, změny formátování nebo výměny modelu. Oddělení dělá diffy čitelné. Čitelné diffy zabraňují hloupým incidentům.

testy, které opravdu pomáhají

Většina týmů slyší „testujte prompty“ a okamžitě udělá jednu ze dvou chyb. Buď to překomplikuje a cpe všude LLM-as-judge pipeline, nebo to vzdá s tím, že výstupy jsou přece probabilistické. Obojí míjí zjevný střed. Když systém navrhnete na determinismus, dostanete z deterministických testů překvapivě hodně hodnoty. Připněte verzi modelu. Nastavte temperature: 0. Vynucujte JSON output. Nejdřív validujte strukturu, až potom obsah.

Rozumná sada testů má několik vrstev. První jsou schema testy. Každý fixture musí vrátit parsovatelný JSON, který odpovídá očekávanému tvaru, bez markdown fence, bez omáčky před objektem, bez chybějících povinných polí. Druhá vrstva jsou invariant testy. Když vstup říká, že země je Německo, model by neměl vrátit currency: USD, pokud prompt explicitně neodvozuje něco jiného. Třetí vrstva jsou golden testy nad kurátorovaným datasetem, kde porovnáváte přesné nebo skoro přesné výstupy pro případy s vysokou hodnotou. Jakmile response dostatečně utáhnete do struktury, funguje exact match překvapivě dobře.

Příklad v pytestu může vypadat takhle:

python
1import json
2import pytest
3from jsonschema import validate
4from myapp.ai import render_prompt, call_model
5from myapp.prompts import load_prompt
6
7PROMPT = load_prompt("lead_qualifier", version="3.2.1")
8SCHEMA = PROMPT["output_schema"]
9
10@pytest.mark.parametrize("fixture", [
11 "tests/fixtures/leads/saas_us.json",
12 "tests/fixtures/leads/manufacturing_de.json",
13 "tests/fixtures/leads/agency_empty_notes.json",
14])
15def test_lead_qualifier_schema(fixture):
16 payload = json.load(open(fixture))
17 messages = render_prompt(PROMPT, payload)
18 raw = call_model(messages, model=PROMPT["model"], temperature=0)
19 data = json.loads(raw)
20 validate(data, SCHEMA)
21 assert data["score"] in ["low", "medium", "high"]

Pak přidejte regression fixtures pro bugy, které už vás něco stály. Pokud model jednou označil „family-owned industrial supplier“ jako „consumer ecommerce“, zmrazte ten případ navždy. Každý produkční incident se má proměnit v test. Pokaždé. U aplikačního kódu tohle inženýři dávno vědí, jen na to zázračně zapomenou ve chvíli, kdy do místnosti vstoupí slovo „AI“.

Jedno upozornění ale platí. Alias modelu na straně providera může potichu driftovat. gpt-4.1-mini dnes se nemusí chovat úplně stejně jako minulý měsíc, podle vendora a semantiky endpointu. Pokud provider nabízí datované snapshoty, používejte je. Pokud ne, ukládejte si denně raw response z fixture sady a alertujte na posuny v distribuci, protože váš deterministický test právě přestal být tak úplně deterministický.

canary nad shadow daty

Unit testy chytí hloupé regrese. Canarying chytí ty drahé. Prompt může projít každým kurátorovaným fixture, který jste napsali, a stejně selhat na ošklivém long tailu, který se objeví jen v produkci: rozbitý OCR, uživatelé lepící do textarea celé emailové thready, názvy produktů kolidující s běžnými slovy, prostě všechen ten bordel, který nikdo nedá do čistého testovacího souboru.

Řešení je přímočaré: držte si shadow dataset postavený z reálného trafficu. Osekejte PII, zmrazte vstupy, připojte očekávané labely tam, kde je máte, a před rolloutem spočítejte současný prompt i kandidáta nad stejným korpusem. Nepotřebujete na to obří ML platformu. Když máte dataset v S3 nebo PostgreSQL, stačí nightly Django management command nebo GitHub Actions job.

Na canary runu nás většinou zajímají tři metriky: rate validních outputů, task-specific correctness a disagreement rate proti aktuálnímu produkčnímu promptu. Ta poslední zní trochu primitivně, ale je extrémně užitečná. Když kandidát najednou nesouhlasí ve 38 % případů, kde byla současná verze stabilní a support byl v klidu, zastavíte se a jdete to prohlédnout. Možná objevil reálné zlepšení. Možná se jen utrhl ze řetězu. Nejdřív se to vyšetří, až potom se shipuje.

Pomůže i jednoduchá rollout tabulka:

sql
1create table prompt_releases (
2 id bigserial primary key,
3 prompt_id text not null,
4 version text not null,
5 model text not null,
6 traffic_percent integer not null check (traffic_percent between 0 and 100),
7 status text not null check (status in ('shadow', 'canary', 'full', 'rolled_back')),
8 created_at timestamptz not null default now()
9);

Aplikace pak vybírá verze promptů přes konfiguraci, ne přes natvrdo zapsané konstanty rozházené po handlerech. V Next.js routě nebo v Django service vrstvě pošlete 5 % vhodných requestů na kandidátní verzi, pokud to feature dovolí, logujte outputy vedle sebe pro shadow execution a porovnávejte je offline. Když vám p95 latence skočí z 1,8 s na 4,9 s, protože je nový prompt dvakrát delší a spouští víc tool callů, je to stejně důležité. Regrese nejsou jen o přesnosti.

Canarying je přesně to místo, kde se engineering lead pozná od promptového kutila. Kutil shipuje pocity. Lead shipuje důkazy.

ci, které padá včas

Jestli se prompt otestuje jen tak, že product manager něco prokliká na stagingu, máte rozbitý proces. Změny promptů potřebují pipeline. Opravdovou. GitHub Actions, Buildkite, GitLab CI, vyberte si co chcete, tvar je pořád stejný: lint, validace renderu, unit testy nad fixtures, shadow evaluace nad nasamplovaným datasetem a pak deployment gate.

Štíhlý GitHub Actions job může vypadat třeba takhle:

yaml
1name: prompt-ci
2on:
3 pull_request:
4 paths:
5 - 'ai/prompts/**'
6 - 'tests/ai/**'
7 - '.github/workflows/prompt-ci.yml'
8jobs:
9 test-prompts:
10 runs-on: ubuntu-22.04
11 steps:
12 - uses: actions/checkout@v4
13 - uses: actions/setup-python@v5
14 with:
15 python-version: '3.12'
16 - run: pip install -r requirements.txt
17 - run: python scripts/lint_prompts.py
18 - run: pytest tests/ai -q
19 - run: python scripts/eval_shadow.py --base main --candidate HEAD --threshold-file ai/thresholds.yaml
20 env:
21 OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}

Threshold file je důležitý, protože quality gates mají být explicitní, ne schované v pocitu reviewera. Třeba valid_output_rate >= 0.995, accuracy_delta >= -0.01, latency_p95_ms <= 2500, cost_per_1k_requests_delta_usd <= 3.00. Když kandidát neprojde, CI má být červené. Zní to banálně, ale spousta týmů pořád merguje úpravy promptů jen proto, že formulace „působí jasněji“. Je mi jedno, jak to působí. Ukažte čísla.

Na flaky testy časem narazíte, většinou protože provider změnil chování, síťový výpadek způsobil retry s jiným outputem, nebo se údajně strukturovaná response zabalila do trojitých backticků a rozbila parser. Dobře. Aspoň tu křehkost odhalíte brzo. Retry přidávejte jen tam, kde business chování retry snese. U failů ukládejte raw request a response payloady do CI artifactů. Uložte prompt, model, parametry, input fixture i raw output. Nikdo nevydebuguje AssertionError: expected high got medium bez plného kontextu.

Jedno tvrdé pravidlo pomáhá hodně: PR, které mění jen prompt, by nemělo jít do produkce bez review od aspoň jednoho inženýra, který vlastní downstream metriku. Text promptu umí hýbat tržbami úplně stejně jako kód.

rollback musí být nudný

Každá produkční LLM feature potřebuje rollback cestu, která funguje ve dvě ráno pod stresem, když půlka týmu spí a člověk na on-callu čte logy jedním okem. Pokud vrácení promptu znamená rebuild aplikace, čekání na rollout containerů a modlení, že mezitím nikdo nezměnil provider config, váš incident proces stojí za nic.

Nejjednodušší návrh je registry tabulka s prompty plus resolution řízená konfigurací. Aplikace si řekne o lead_qualifier@active, služba ten alias přeloží na konkrétní verzi promptu a snapshot modelu, krátce to cachuje a u každého requestu zaloguje, na co přesně se to vyřešilo. Rollback je pak update v databázi nebo přepnutí feature flagu, ne redeploy.

V Django na to stačí něco takového:

python
1from django.core.cache import cache
2from myapp.models import PromptAlias
3
4def resolve_prompt(prompt_id: str, alias: str = "active"):
5 key = f"prompt:{prompt_id}:{alias}"
6 cached = cache.get(key)
7 if cached:
8 return cached
9 row = PromptAlias.objects.select_related("prompt_version").get(
10 prompt_id=prompt_id,
11 alias=alias,
12 )
13 resolved = {
14 "version": row.prompt_version.version,
15 "model": row.prompt_version.model,
16 "system": row.prompt_version.system,
17 "user_template": row.prompt_version.user_template,
18 }
19 cache.set(key, resolved, 30)
20 return resolved

Pak do feature path připojte kill switch. Když validace outputu padá nad určitý threshold, když disagreement vystřelí nahoru, když spadne user feedback nebo provider začne timeoutovat, aplikace má umět spadnout zpátky na předchozí verzi promptu, případně AI větev úplně vypnout a routovat request na bezpečnější rules-based variantu. Tohle je extra důležité hlavně v ERP a CRM workflow, kde špatná extrakce může hodiny potichu otravovat downstream data.

Viděl jsem týmy, které se posedle věnovaly naleštěným evaluation dashboardům, a pak zjistily, že neumí odpovědět na nejzákladnější otázku při incidentu: můžeme to vrátit hned teď? Postavte nejdřív tohle. Nudný rollback je lepší než krásný postmortem.

Johnny Unar

Napsal/a

Johnny Unar

Chcete s námi spolupracovat?

LLM feature v produkci degradují potichu. Verzujte prompty, testujte je v CI, pouštějte canary proti shadow trafficu a udělejte rollback nudně spolehlivý.