AI

Jak dostat LLM do Symfony aplikace, aby ji neshodil?

Volání LLM jako nespolehlivá externí služba: port a adaptér, timeout a max_duration, retry jen na správné chyby, rozpočet tokenů, strukturovaný výstup validovaný Symfony Validatorem, fallback, Messenger a prompt injection. Se Symfony AI 0.14.

// obsah 10
  1. 01 Čím je LLM horší než jiné API?
  2. 02 Kam to v aplikaci patří?
  3. 03 Kdy opakovat a kdy ne?
  4. 04 Proč nestačí „vrať JSON“?
  5. 05 Kolik to smí stát?
  6. 06 Co když AI nepojede?
  7. 07 Musí na to zákazník čekat?
  8. 08 Co když zákazník napíše „ignoruj instrukce“?
  9. 09 Jak to testovat a sledovat?
  10. 10 Zdroje

Volání LLM je obyčejný HTTP request na cizí službu.

V článku o retry patterns jsem řešil obecné API: timeouty, backoff s jitterem a circuit breaker. Pro jazykový model platí všechno z toho, jen má model pár vlastností navíc. Odpovídá v sekundách až minutách, účtuje každý token na vstupu i na výstupu, ve špičce vrací „overloaded“ a i se statusem 200 OK může poslat useknutou, odmítnutou nebo věcně špatnou odpověď. A text, který mu posíláte, může obsahovat pokyny, které model může poslechnout.

Vezměte si e-shop, který chce, aby AI roztřídila reklamace do kategorií a operátorovi k nim napsala krátké shrnutí. Nejjednodušší verze je controller, který zavolá model a výsledek uloží k reklamaci. Funguje to do chvíle, kdy API vrátí 529, odpověď se usekne v půlce JSONu nebo zákazník do reklamace napíše pokyn pro AI.

Pokud se vám líp kouká, než čte, je k článku i video. Má zhruba osm minut.

Čím je LLM horší než jiné API?

Většinu toho mají poskytovatelé přímo v dokumentaci:

  • Přetížení celé služby. Anthropic vrací 529 overloaded_error a píše k ní, že může nastat při vysokém provozu napříč všemi uživateli, takže ji nemusí způsobit váš provoz.
  • Limity v tokenech. Kromě requestů za minutu se počítají i vstupní a výstupní tokeny, takže jeden dlouhý dokument klidně vyčerpá minutový limit.
  • Dlouhé odpovědi. Bez streamování přijde odpověď až po dogenerování a oficiální SDK počítají s timeoutem až 10 minut. Timeout na pár sekund, jaký máte u běžného REST API, tu nestačí.
  • Chyba se statusem 200. Useknutá odpověď (stop_reason: max_tokens) i odmítnutí (stop_reason: refusal) přijdou jako normální odpověď 200. U odmítnutí Anthropic navíc upozorňuje, že vygenerované tokeny zaplatíte.
  • Cena za každý token. Smyčka, která volá model pořád dokola, nebo útočník, který posílá dlouhé texty, se projeví přímo na faktuře. OWASP tomu říká Denial of Wallet.

Za to, co model zákazníkovi řekne, navíc odpovídáte vy. V kauze Moffatt v. Air Canada (2024) dal chatbot na webu aerolinky zákazníkovi nepřesné informace o slevě a aerolinka se pak před tribunálem v Britské Kolumbii bránila, že za informace od chatbota neodpovídá (tribunál to shrnul tak, že z něj v podstatě dělá samostatnou právní entitu). V rozhodnutí stojí, že aerolinka odpovídá za všechny informace na svém webu a je jedno, jestli pocházejí ze statické stránky, nebo z chatbota (jakou technologii chatbot používal, rozhodnutí mimochodem neuvádí).

Kam to v aplikaci patří?

Za port, stejně jako každá jiná nespolehlivá závislost. Use case dostane rozhraní ComplaintClassifier a o tom, že za ním je model, nic neví. Adaptér je jediné místo, které zná Symfony AI, model, prompt a tokeny (v podstatě anti-corruption layer směrem k poskytovateli).

interface ComplaintClassifier
{
    /** @throws ClassificationFailed */
    public function classify(string $complaintText): ComplaintTriage;
}

final class ComplaintTriage
{
    public const CATEGORIES = ['damaged_in_transit', 'defective', 'wrong_item', 'missing_item', 'other'];

    #[Assert\NotBlank]
    #[Assert\Choice(choices: self::CATEGORIES)]
    public string $category = '';

    #[Assert\Choice(choices: ['low', 'normal', 'high'])]
    public string $urgency = '';

    /** Shrnutí reklamace pro operátora, česky, max. 200 znaků. */
    #[Assert\NotBlank]
    #[Assert\Length(max: 200)]
    public string $summary = '';

    /** Žádá zákazník vrácení peněz? (jen informace pro operátora, nic se neschvaluje) */
    public bool $customerAsksRefund = false;
}

DTO ComplaintTriage slouží dvakrát. Symfony AI z něj vygeneruje JSON Schema pro model (Choice se změní na enum, docblock na popis pole) a Symfony Validator podle stejných atributů zkontroluje, co model vrátil. ClassificationFailed je výjimka s důvodem selhání (třeba provider_error, truncated nebo budget_exhausted) a příznakem, jestli má smysl to zkusit znovu.

Symfony AI je podle vlastního README experimentální komponenta a na experimentální funkce se BC promise Symfony nevztahuje. První verze vyšla v prosinci 2025, aktuální 0.14 v září 2026 a CHANGELOG platformy má položky [BC BREAK] ve většině minor verzí (UPGRADE.md jen pro přechod z 0.13 na 0.14 má přes 180 řádků). Když je knihovna za portem, upgrade se dotkne jen jedné třídy. Patří tam i to, že katalog Anthropic bridge 0.14.0 ještě nezná nové modely jako claude-haiku-5-5. Za stejný port jde dát i oficiální Anthropic PHP SDK (zatím v betě) a use case otestujete s falešnou implementací portu, bez sítě.

Kód v článku je zkrácený z ukázky, která běžela na PHP 8.2 se Symfony AI 0.14 a komponentami Symfony 7.4. Skutečné API se v ní nevolalo. Místo Anthropicu odpovídal MockHttpClient s ručně napsanými odpověďmi ve tvaru Messages API, takže výstupy níž ukazují jen to, co s takovými odpověďmi udělá Symfony. Jak by odpověděl skutečný model, z nich nevyčtete, a počty tokenů v nich jsou vymyšlené.

Kdy opakovat a kdy ne?

Symfony AI sám nic neopakuje. Anthropic bridge přeloží 429 na RateLimitExceededException (s hodnotou retry-after), každou odpověď 5xx včetně 529 na ServerException a síťovou chybu nebo timeout nechá vyletět z HttpClientu jako TransportExceptionInterface. Timeout ani retry si bridge nenastavuje a použije HttpClient, který mu dáte. Opakování je tedy na vás, na úrovni HttpClientu nebo Messengeru. Rozhraní RetryableExceptionInterface, které přechodné chyby označí, je zatím jen ve vývojové větvi pro 0.15.

Podle dokumentace poskytovatelů má smysl opakovat 429 (u Anthropicu jen s hlavičkou retry-after), 500, 502, 503, 504, 529 a síťové chyby a timeouty, dokud nezačala přicházet odpověď. Opakovat nemá smysl 400, 401, 403, 404 ani 413 a taky useknutý nebo nevalidní výstup, protože stejný vstup nejspíš dopadne stejně (u useknutí to říká i docblock Symfony AI) a tokeny zaplatíte znovu.

Bacha na 429 bez retry-after. Anthropic vrací 429 se stejným typem chyby i ve chvíli, kdy organizace vyčerpala měsíční limit útraty. Taková odpověď hlavičku retry-after nemá a podle dokumentace selhává, dokud se přístup neobnoví (rozlišit ji jde podle error.details.error_code s hodnotou enforced_spend_limit_reached). U Anthropicu je tedy 429 bez retry-after signál neopakovat. OpenAI naopak radí bez té hlavičky pokračovat exponenciálním backoffem s jitterem.

Další past je výchozí nastavení RetryableHttpClient. Podle dokumentace a zdrojáku opakuje 423, 425, 429, 502 a 503 pro jakoukoli metodu, ale 500, 504, 507, 510 a síťové chyby jen pro idempotentní metody. Volání LLM API je přitom POST, takže výchozí strategie u něj 500, 504 ani timeout nezopakuje a 529 nezná vůbec. Ukázka to prověřila se skutečným CurlHttpClient proti lokálnímu serveru (výchozí strategie proti vlastnímu seznamu [0, 429, 500, 502, 503, 504, 529], výběr řádků):

POST, max_retries=2, timeout=1 s, max_duration=2 s, tělo odpovědi se čte celé
429        | výchozí | 3× |    45 ms | HTTP 429
500        | výchozí | 1× |     1 ms | HTTP 500
500        | vlastní | 3× |    44 ms | HTTP 500
529        | výchozí | 1× |     1 ms | HTTP 529
529        | vlastní | 3× |    43 ms | HTTP 529
ticho 3 s  | výchozí | 1× |  1013 ms | TransportException: Idle timeout reached.
ticho 3 s  | vlastní | 2× |  2030 ms | TransportException: Max duration was reached.
kape 4 s   | vlastní | 1× |  2003 ms | TransportException: Operation timed out after 2000 milliseconds with 3 bytes received.

timeout určuje, jak dlouho smí spojení mlčet, a max_duration je rozpočet na všechny pokusy dohromady (před každým dalším pokusem se od něj odečte čas, který zabral ten předchozí). Čekání mezi pokusy (backoff i Retry-After) se do něj ale nepočítá. Proto řádek „ticho 3 s“ s vlastní strategií skončil už po dvou pokusech. Poslední řádek je odpověď, která vrátila 200 a tělo pak posílala tak pomalu, že ji ukončil max_duration. Neopakovala se ani s vlastním seznamem kódů, což sedí s doporučením poskytovatelů neopakovat request, ze kterého už část výstupu dorazila.

Ještě zrádnější je hlavička Retry-After. RetryableHttpClient ji upřednostní před strategií a max_delay ji nezastropuje. V ukázce, kde HttpClient 429 opakoval, přišla dvakrát 429 s retry-after: 7:

[http.info] Try #1 after 7000ms, status code: 429
[http.info] Try #2 after 7000ms, status code: 429

Volání trvalo celkem 14 sekund (latency_ms: 14030). Ve workeru to na 14 sekund zablokuje proces, v requestu zákazníka je to 14 sekund čekání na stránku. Tam bych proto 429 neopakoval vůbec a rovnou šel na fallback. Krátký max_duration na to nestačí, protože čekání podle Retry-After se do něj nepočítá.

V Symfony aplikaci se to nastaví přes scoped client. Konfigurace níž je složená z dokumentovaných klíčů a v ukázce neběžela, čísla jsou příklad pro worker:

# config/packages/framework.yaml
framework:
    http_client:
        scoped_clients:
            anthropic.client:
                base_uri: 'https://api.anthropic.com'
                timeout: 30          # idle: jak dlouho smí spojení mlčet
                max_duration: 60     # všechny pokusy, bez čekání mezi nimi
                retry_failed:
                    http_codes:
                        0: true      # síť a timeouty i pro POST
                        500: true
                        502: true
                        503: true
                        504: true
                        529: true    # Anthropic overloaded, ve výchozím seznamu není
                    max_retries: 2
                    delay: 500
                    multiplier: 2
                    max_delay: 8000
                    jitter: 0.2

# config/packages/ai.yaml
ai:
    platform:
        anthropic:
            api_key: '%env(ANTHROPIC_API_KEY)%'
            http_client: 'anthropic.client'

429 HttpClient neopakuje. Vyletí jako RateLimitExceededException a handler podle retry-after rozhodne mezi Recoverable a obyčejnou výjimkou (viz Messenger níž).

Pro request zákazníka bych měl druhý klient s max_duration v jednotkách sekund a retry jen na síť a 529. K tomu malé max_tokens u samotného volání.

Proč nestačí „vrať JSON“?

Anthropic má structured outputs (parametr output_config.format). Dokumentace slibuje odpovědi odpovídající schématu díky constrained decoding, jen dodává, že „ve většině případů“. Uvádí dva případy, kdy to neplatí: odmítnutí (status 200, výstup nemusí odpovídat schématu a tokeny zaplatíte) a useknutí na max_tokens (výstup může být neúplný). Navíc nezaručuje velikost písmen u hodnot enum.

Anthropic navíc ve schématu nepodporuje minLength, maxLength, minimum, maximum a pár dalších klíčových slov, a Symfony AI bridge je proto ze schématu vyhodí a připíše do popisu pole. Z #[Assert\Length(max: 200)] v ukázce odešlo tohle:

"summary": {"type":"string","description":"Shrnutí reklamace pro operátora, česky, max. 200 znaků.\n\n{\"minLength\":1,\"maxLength\":200}","nullable":false}

Anthropic u svých SDK dodává, že omezení pak vynutí až helper, který odpověď validuje proti původnímu schématu. V Symfony je to ValidatorSubscriber. U samostatné komponenty ho do event dispatcheru přidáváte sami, AI Bundle ho zaregistruje automaticky, pokud máte nainstalovaný symfony/validator. Bez něj je limit 200 znaků jen prosba v popisu.

invoke() vrací líný DeferredResult a odpověď se zpracuje (včetně vyhození výjimky za HTTP chybu) až v asObject(). try/catch kolem samotného invoke() by HTTP chyby nechytil, do try ale patří taky, protože sám hází chyby katalogu a modelu:

$deferred = null;

try {
    // invoke() hází jen chyby katalogu a modelu, HTTP chyby i parsování přijdou až v asObject().
    $deferred = $this->platform->invoke($this->model, $messages, [
        'response_format' => ComplaintTriage::class,
        'max_tokens' => $this->maxOutputTokens,
        'temperature' => 0,
    ]);
    $triage = $deferred->asObject();
} catch (RateLimitExceededException $e) {
    $retryAfter = $e->getRetryAfter();
    throw $this->fail($deferred, $started, new ClassificationFailed('rate_limited', true, $e->getMessage(), null !== $retryAfter ? $retryAfter * 1000 : null, $e));
} catch (ServerException $e) {
    throw $this->fail($deferred, $started, new ClassificationFailed('provider_error', true, $e->getMessage(), null, $e));
} catch (TransportExceptionInterface $e) {
    throw $this->fail($deferred, $started, new ClassificationFailed('timeout_or_network', true, $e->getMessage(), null, $e));
} catch (ValidationException $e) {
    // ... porušená pravidla poskládaná z $e->getViolations()
    throw $this->fail($deferred, $started, new ClassificationFailed('invalid_output', false, implode('; ', $violations), null, $e));
} catch (PlatformRuntimeException $e) {
    // Useknutý i nesmyslný výstup skončí stejnou výjimkou („Cannot deserialize the content…“).
    // Skutečný důvod je ve stop_reason surové odpovědi.
    // Bez stop_reason nepřišla odpověď modelu vůbec (401, 400 apod.).
    $stopReason = $this->rawStopReason($deferred);
    $reason = match (true) {
        null === $stopReason => 'provider_error',
        'max_tokens' === $stopReason => 'truncated',
        default => 'unparseable',
    };
    throw $this->fail($deferred, $started, new ClassificationFailed($reason, false, $e->getMessage().' (stop_reason='.($stopReason ?? 'null').')', null, $e));
} catch (PlatformExceptionInterface $e) {
    // Ostatní chyby Symfony AI (příliš dlouhý kontext, content filter, model mimo katalog): neopakovat.
    throw $this->fail($deferred, $started, new ClassificationFailed('provider_error', false, $e->getMessage(), null, $e));
}

fail() zaloguje warning a vrátí výjimku. Pořadí catch je důležité, protože RateLimitExceededException, ServerException, ValidationException, ale i AuthenticationException (401) a BadRequestException (400) jsou potomci obecné RuntimeException ze Symfony AI. ExceedContextSizeException a ContentFilterException z ní nedědí a neznámý model padá už v invoke(), proto je na konci catch přes ExceptionInterface. V ukázce takhle skončily 401, příliš dlouhý prompt i neznámý model ve fallbacku s důvodem provider_error.

Useknutý výstup a odpověď prózou místo JSONu skončily stejnou hláškou Cannot deserialize the content into the "App\Complaint\ComplaintTriage" class. a rozlišil je až stop_reason (max_tokens u prvního, end_turn u druhého). Bez streamování totiž Symfony AI při max_tokens nehodí vlastní výjimku pro useknutí (MaxOutputTokensException je jen u streamu), jen do metadat zapíše finish_reason s hodnotou LENGTH. Anthropic radí zvýšit max_tokens (třída Claude ho nastaví na 1000, když ho nezadáte).

Ve scénáři s porušenými pravidly vrátil simulovaný model platný JSON s kategorií mimo seznam a shrnutím delším než 200 znaků. Validator zachytil obojí najednou: category: The value you selected is not a valid choice.; summary: This value is too long. It should have 200 characters or less. Skutečný model se structured outputs by hodnotu mimo enum vrátit neměl. Spíš by mohl vrátit hodnotu s jinou velikostí písmen (dokumentace to připouští hlavně u počátečního písmene slova po mezeře) a Choice to chytí taky. Délku poskytovatel nehlídá vůbec.

OWASP to v Top 10 pro LLM aplikace 2025 řadí pod LLM05 Improper Output Handling (v edici 2026 je to LLM10) a doporučuje brát model jako každého jiného uživatele, se zero trust přístupem. Takže Validator na DTO, autoescape v Twigu při zobrazení, parametrizované dotazy a Choice místo volného textu všude, kde se podle hodnoty větví kód.

Kolik to smí stát?

Cena jednoho volání vypadá zanedbatelně. Haiku 4.5 stojí podle ceníku Anthropicu 1 $ za milion vstupních a 5 $ za milion výstupních tokenů (ukázka počítá s Haiku 4.5, protože ho katalog bridge zná; Haiku 5.5 je podle stejného ceníku u promptů do 100 000 tokenů desetkrát levnější). Kdyby klasifikace měla 412 vstupních a 58 výstupních tokenů (čísla jsou ze simulace, naměřená nejsou), vyšla by cca na 0,0007 $. Horší je násobení, třeba smyčkou, opakovanými retry nebo útočníkem, který posílá dlouhé texty. OWASP to vede jako Unbounded Consumption (LLM10 v edici 2025, LLM06 v edici 2026).

V Symfony pomůžou tři věci: #[Assert\Length(max: …)] na textu od zákazníka ještě před voláním, max_tokens u každého volání a nad tím rozpočet přes symfony/rate-limiter. Metoda consume() umí odečíst libovolný počet, takže limiter může počítat rovnou tokeny místo requestů. Klíčem může být use case (denní strop), uživatel nebo tenant. Adaptér v ukázce si před voláním rezervuje nejhorší případ:

// Rozpočet: rezervuj nejhorší případ (vstup ~ znaky/3 + celý max_tokens) PŘED voláním.
$estimate = (int) ceil(mb_strlen(self::SYSTEM_PROMPT.$complaintText) / 3) + $this->maxOutputTokens;
$budget = $this->tokenBudget->create('complaint-triage')->consume($estimate);
if (!$budget->isAccepted()) {
    throw new ClassificationFailed('budget_exhausted', false, \sprintf('odhad %d tokenů, zbývá %d', $estimate, $budget->getRemainingTokens()));
}

Odhad „znaky děleno třemi“ je hrubý a v dokumentaci ho nenajdete. Když limiter odmítne, use case jde rovnou na fallback a model se vůbec nevolá (ukázka vypsala HTTP requestů: 0, čas: 0 ms). Když je odhad větší než celý limit, FixedWindowLimiter::consume() nevrátí odmítnutí a hodí \InvalidArgumentException, takže vstup je potřeba omezit dřív. Pozor jen na to, že rezervace je na jedno logické volání a HTTP retry pod adaptérem ji nevidí. Scénář s trojí 529 udělal tři requesty a z rozpočtu ubral jednou.

Co když AI nepojede?

Reklamace se k operátorovi musí dostat i bez AI. Use case proto chytá ClassificationFailed a sáhne po záložní implementaci stejného portu:

final class TriageComplaint
{
    public function __construct(
        private readonly ComplaintClassifier $ai,
        private readonly ComplaintClassifier $fallback,
    ) {
    }

    public function __invoke(string $complaintText): array
    {
        try {
            $triage = $this->ai->classify($complaintText);
            $source = 'ai';
        } catch (ClassificationFailed $e) {
            $triage = $this->fallback->classify($complaintText);
            $source = 'fallback('.$e->reason.')';
        }

        return [
            'source' => $source,
            'queue' => 'reklamace.'.$triage->category,
            'priority' => $triage->urgency,
            'summary' => $triage->summary,
            'refund' => $triage->customerAsksRefund ? 'zákazník žádá -> rozhodne operátor' : 'nežádá',
        ];
    }
}

Záloha v ukázce (KeywordFallbackClassifier) třídí podle pár kořenů slov („rozbit“, „poškoz“…) a zbytek pošle do obecné fronty. Třídí hůř, ale reklamace se neztratí. Takhle to vypadalo, když simulovaný Anthropic vrátil třikrát po sobě 529:

[http.info] Try #1 after 195ms, status code: 529
[http.info] Try #2 after 348ms, status code: 529
HTTP requestů: 3, čas: 568 ms
=> {"source":"fallback(provider_error)","queue":"reklamace.defective","priority":"normal","summary":"Sluchátka po týdnu nefungují, levé nehraje.","refund":"nežádá"}

Fallback v ukázce prošel pro všech sedm důvodů, které adaptér rozlišuje. Záložní cestou může být i jiný model, stránka bez AI části (popis produktu od dodavatele) nebo rovnou fronta operátora. O refundaci výstup AI nerozhoduje vůbec. Use case z něj převezme jen informaci, že ji zákazník chce, a rozhodnutí nechá operátorovi.

Symfony AI má pro výpadky FailoverPlatform, ve verzi 0.14 ale na HTTP chybu nepřepne. Chytá jen výjimky z invoke() a výsledek je líný, takže chyba poskytovatele (v ukázce 529) vyletí až v asText() nebo asObject(), mimo její try, a záložní platforma se nepoužije. Stejné chování popisuje issue #1391, které bot zavřel kvůli neaktivitě, a otevřený návrh #1953 chce FailoverPlatform nahradit novým dekorátorem. Dokud se to nezmění, patří fallback do use casu nebo adaptéru, kde se chytá výjimka z asObject().

Musí na to zákazník čekat?

Na klasifikaci reklamace čekat nemusí. Controller reklamaci uloží, dispatchne zprávu ClassifyComplaint s ID reklamace a model zavolá až worker. Pomalá odpověď, rate limit ani retry-after v sekundách pak zákazníka nezdrží. Zprávu je dobré dispatchnout ve stejné transakci jako uložení reklamace (s Doctrine transportem to vlastně dělá outbox).

Handler překládá důvod selhání na výjimky Messengeru:

public function __invoke(string $complaintText): ComplaintTriage
{
    try {
        return $this->ai->classify($complaintText);
    } catch (ClassificationFailed $e) {
        if (!$e->retryable) {
            // Stejný vstup dá stejnou chybu (useknutý / nevalidní výstup, rozpočet): neopakovat.
            throw new UnrecoverableMessageHandlingException($e->getMessage(), 0, $e);
        }
        if (null !== $e->retryAfterMs) {
            // Recoverable s retryDelay: pozor, Messenger ji opakuje BEZ ohledu na max_retries.
            throw new RecoverableMessageHandlingException($e->getMessage(), 0, $e, $e->retryAfterMs);
        }

        // Ostatní přechodné chyby: obyčejná výjimka -> retry_strategy transportu (max_retries, delay, jitter).
        throw new \RuntimeException($e->getMessage(), 0, $e);
    }
}

Dokumentace Messengeru u RecoverableMessageHandlingException píše, že zprávu opakuje donekonečna a max_retries ignoruje. Kdybyste na ni namapovali každou RateLimitExceededException, 429 kvůli vyčerpanému limitu útraty by se točila ve frontě bez konce. Proto handler dává Recoverable jen tehdy, když retry-after přišel. V ukázce se handler volal přímo, bez workeru, a HttpClient v ní ještě 429 opakoval:

429 retry-after 7  -> RecoverableMessageHandlingException (retryDelay 7000 ms), handler běžel 14032 ms
529 overloaded     -> RuntimeException, handler běžel 691 ms
max_tokens         -> UnrecoverableMessageHandlingException, handler běžel 3 ms

První řádek ukazuje vnořené retry. HttpClient dvakrát čekal 7 sekund a teprve pak předal Messengeru výjimku s dalšími 7 sekundami. U 429 s retry-after z toho handler udělá RecoverableMessageHandlingException, takže Messenger počet pokusů neomezuje. U 529, kterou handler posílá jako obyčejnou výjimku, jsou to se dvěma opakováními v HttpClientu a výchozími třemi v Messengeru čtyři doručení po třech requestech, tedy až dvanáct. OpenAI v dokumentaci výslovně radí vnořené smyčky započítat, aby se requesty nenásobily. Proto v konfiguraci výš 429 v HttpClientu není a ve workeru bych nechal krátký HTTP retry na síť a 5xx včetně 529 a delší čekání podle retry-after přenechal Messengeru.

Messenger navíc zprávu může doručit znovu. Zápis výsledku má být idempotentní (třeba UPDATE complaint SET triage = … WHERE id = ? AND triage IS NULL) a před voláním modelu je dobré se podívat, jestli výsledek už uložený není, protože další volání znamená další platbu. Zprávy, které nevyšly ani po všech pokusech, skončí ve failure transportu (pokud ho máte nastavený, jinak se zahodí) a po výpadku poskytovatele je pošlete znovu přes messenger:failed:retry.

Co když zákazník napíše „ignoruj instrukce“?

Text zákazníka je pro model vstup jako každý jiný a může obsahovat pokyny. Adaptér ho proto posílá mezi značkami <complaint> a v system promptu modelu říká, že jde o data od zákazníka a případné instrukce v nich nemá plnit. Odpovídá to doporučení OWASP oddělit a označit externí obsah. Garantovat, že to model respektuje, ale nikdo neumí.

Ve scénáři s injekcí simulovaný model pokyn ze zákazníkova textu „poslechl“. Jestli by ho poslechl i skutečný model, ukázka neříká, OWASP ale počítá s tím, že může. Výstup prošel validací, protože tvarem byl v pořádku:

=> {"source":"ai","queue":"reklamace.defective","priority":"high","summary":"Refundace schválena.","refund":"zákazník žádá -> rozhodne operátor"}

Škodu zastavilo až to, že use case podle výstupu AI jen vybírá frontu a prioritu a refundaci nikdy neschvaluje. Nejhorší dopad je tak reklamace v prioritní frontě s matoucím shrnutím pro operátora. OWASP LLM01:2025 k tomu píše, že není jasné, jestli proti prompt injection existuje spolehlivá ochrana. Edice 2026 dodává, že model nerozlišuje mezi instrukcemi a daty, a proto pro něj neexistuje čistá obdoba parametrizovaných dotazů.

Shrnutí „Refundace schválena.“ ale operátor uvidí. Text od AI bych proto v UI označil a nepouštěl ho do kanálů, které vypadají jako rozhodnutí firmy. Cursor na to narazil v dubnu 2025, když jeho supportní AI bot odpověděl uživateli smyšleným pravidlem. Spoluzakladatel Michael Truell pak napsal, že žádné takové pravidlo nemají, a odpovědi AI v e-mailové podpoře od té doby označují.

Je to stejný princip jako u MCP serveru, kde hranici taky drží oprávnění a doménová pravidla v kódu.

Jak to testovat a sledovat?

Testy mají smysl na třech úrovních:

  • Use case s falešnou implementací portu. Bez Symfony AI, rychlé a stabilní, a prověří fallback i to, že AI nic neschvaluje.
  • Adaptér s MockHttpClient a skutečným bridgem, včetně PlatformSubscriber a ValidatorSubscriber. Sem patří 429, 529, max_tokens a nevalidní výstup. InMemoryPlatform na to nestačí, protože přeskočí structured output i validaci (v ukázce skončila na UnexpectedResultTypeException). MockPlatformFactory s předaným dispatcherem validaci spustí.
  • Evaly kvality modelu. Sada anonymizovaných reklamací s očekávanou kategorií, puštěná proti skutečnému modelu při změně promptu nebo modelu. Anthropic označuje hodnocení kódem za nejrychlejší a nejspolehlivější a klasifikace do Choice se na něj hodí (kategorie buď sedí, nebo ne).

Do logu patří u každého volání use case, verze promptu, požadovaný i skutečně použitý model (podle toho se počítá cena), request-id, tokeny, finish_reason, latence a u selhání důvod. Úspěšné volání v ukázce (hodnoty jsou ze simulované odpovědi):

[app.info] llm.call ok {"use_case":"complaint_triage","prompt_version":"triage-v3","model_requested":"claude-haiku-4-5","model_reported":"claude-haiku-4-5-20251001","request_id":"req_sim_1","input_tokens":412,"output_tokens":58,"finish_reason":"end_turn","latency_ms":51}

Plný prompt a odpověď obsahují osobní údaje zákazníků, takže bych logoval spíš verzi promptu a hash vstupu. Alert na nárůst tokenů nebo podílu fallbacků pak napoví, že se něco děje.

Prompt caching u Anthropicu (Symfony AI ho zapíná automaticky) zlevní a zrychlí opakovaný začátek requestu, odpověď se ale generuje pokaždé znovu. Má navíc minimální délku podle modelu (u Haiku 4.5 je to 4 096 tokenů) a kratší prompt se bez chyby zpracuje necachovaný. Cache celých odpovědí si jako každou jinou cache uděláte v adaptéru. Klíčem je hash verze promptu, modelu a vstupu a ukládají se jen validní výsledky (výjimka z callbacku CacheInterface::get() se do cache nezapíše). CachePlatform ze Symfony AI 0.14.0 bych na to zatím nepoužil. Klíč skládá z UUID instance MessageBag (v7, pro každou instanci jiné), takže nová MessageBag se stejným textem odpověď v cache nenajde (issue #2192 je otevřené). A podle běhu ukázky ignoruje options, takže stejná instance s jinou teplotou nebo max_tokens vrátí starou odpověď.


Model se občas splete nebo neodpoví a aplikace s tím má počítat stejně jako u každé jiné cizí služby. Hlavně ať se reklamace k operátorovi dostane i bez AI.

Zdroje

Michal Katuščák
Michal Katuščák

Navrhuji a vyvíjím aplikace nad Symfony, zajímám se o architekturu softwaru. Žiju v Českých Budějovicích.