HTTP vrstva a opakování požadavků
Každé volání providera prochází jedinou tenkou vrstvou, kterou můžeš vyměnit nebo obalit. Díky tomu se opakování po chybách, logování požadavků i cachování odpovědí řeší jednou pro celou aplikaci, aniž bys sáhl na kód, který s modelem mluví.
Tři problémy, jedno místo
Až budeš mít aplikaci chvíli v provozu, narazíš na tohle:
- Provider ti občas odpoví, že mu chodí požadavky příliš rychle za sebou, a volání spadne, přestože by za dvě vteřiny prošlo.
- Potřebuješ vidět, co se vlastně posílá ven a jak dlouho to trvá, protože něco odpovídá pomalu a ty nevíš co.
- Při vývoji spouštíš tentýž skript popadesáté a pokaždé za něj platíš, i když se vstup nezměnil.
Všechny tři se řeší na jednom místě, a to výměnou toho, co posílá HTTP požadavky. Klient providera totiž neposílá nic sám. Postará se o to objekt, který mu předáš druhým argumentem konstruktoru:
use AIAccess\Http;
$client = new AIAccess\Provider\OpenAI\Client(
$apiKey,
new Http\RetryClient(new Http\CurlClient),
);
Když druhý argument vynecháš, použije se AIAccess\Http\CurlClient. Knihovna má tři obaly, které se dají
libovolně skládat, protože každý z nich je sám o sobě zase HTTP klient.
RetryClient: Opakování po limitech a výpadcích
RetryClient zkouší neúspěšné požadavky znovu, ale jen když to má smysl:
$http = new Http\RetryClient(
new Http\CurlClient,
maxAttempts: 3,
initialDelay: 1.0,
maxDelay: 30.0,
);
Opakují se stavy 408 a 429 a serverové chyby od 500 výš, plus výpadky sítě, které nastaly dřív, než
dorazila odpověď. Mezi pokusy se čeká, doba se pokaždé zdvojnásobí a navíc se náhodně rozkolísá, aby ti tisíc
paralelních procesů nezaútočilo na providera v jednu chvíli. Když provider pošle hlavičku Retry-After,
řídí se podle ní.
Stejně poučné je, co se neopakuje. Chyby ve čtyřstovkách kromě 408 a 429 dopadnou podruhé úplně stejně, takže by opakování jen zdrželo. A ze serverových chyb jsou vyňaté 501 a 505, protože ty neříkají „teď ne“, ale „tohle neumím a nikdy umět nebudu“.
Nejzajímavější pravidlo se týká streamování: jakmile dorazil první kousek odpovědi, opakování se zakáže. Model už začal psát a ty už za to platíš; kdyby se požadavek přehrál, dostal bys odpověď dvakrát a zaplatil ji dvakrát.
ObservableClient: Vidět, co se děje
ObservableClient ohlásí každý požadavek a každou odpověď i s tím, jak dlouho trvala. Hodí se do logu,
do Tracy nebo do vlastního přehledu útraty.
$http = new Http\ObservableClient(
new Http\CurlClient,
onRequest: function (string $url, $payload): void {
Debugger::log("-> $url");
},
onResponse: function (Http\Response $response, float $elapsed): void {
Debugger::log(sprintf('<- %d za %.1f s', $response->getStatusCode(), $elapsed));
},
);
Všimni si, že do onRequest nedostáváš hlavičky. Není to opomenutí: hlavičky nesou API klíč a ten se
nesmí dostat do logu, kde ho uvidí každý, kdo má přístup k souborům.
U streamované odpovědi měří $elapsed celý přenos, tedy dobu, než model dopsal, ne dobu do
prvního slova.
CachingClient: Neplatit dvakrát za totéž
CachingClient si ukládá odpovědi na disk a stejný požadavek podruhé už neposílá. Je to nástroj pro
vývoj a testy, ne pro produkci. Model, který na tutéž otázku odpovídá pořád stejně, není v ostrém provozu
vlastnost, ale chyba.
$http = new Http\CachingClient(new Http\CurlClient, __DIR__ . '/temp/ai-cache', ttl: 3600);
Kešovací klíč se počítá z metody, URL, těla požadavku a hlaviček, přičemž autentizační hlavičky se z klíče vynechávají. Díky tomu tě přepnutí na jiný klíč nepřipraví o cache, ale hlavička, která mění chování API, ano. Ukládají se jen úspěšné odpovědi, takže si chybu nezapamatuje, a streamy ani nahrávání souborů cache nechává projít beze změny.
Na pořadí obalů záleží
Obaly se skládají do sebe a výsledek se liší podle toho, který je zvenku:
// loguje jen konečný výsledek: opakování proběhne uvnitř a ven se dostane až ono
$http = new Http\ObservableClient(new Http\RetryClient(new Http\CurlClient), onResponse: $log);
// loguje každý pokus včetně neúspěšných: logování je uvnitř smyčky opakování
$http = new Http\RetryClient(new Http\ObservableClient(new Http\CurlClient, onResponse: $log));
Ani jedno není špatně, jen je dobré vědět, kterou z těch dvou variant jsi napsal. Při ladění limitů chceš to druhé, v provozním logu spíš to první.
Timeouty a spojení
Samotný CurlClient má dvě nastavení času a jedno pro proxy:
$http = (new Http\CurlClient)->setOptions(connectTimeout: 10, requestTimeout: 180);
Výchozí tři minuty stačí na běžnou konverzaci, ale ne vždycky. Generování obrázku ve vysoké kvalitě s referencemi
trvá klidně několik minut, takže tam si requestTimeout zvedni; poznáš to podle
CommunicationException, která přijde přesně po vypršení limitu.
U streamované odpovědi platí něco jiného a stojí to za zapamatování: celkový časový strop se nepoužívá vůbec. Dlouhá odpověď legitimně teče minuty a useknout ji uprostřed by zahodilo text, který uživatel právě čte. Místo toho se hlídá ticho: když delší dobu nic nepřijde, spojení se vzdá. Mez je tedy „přestalo to téct“, ne „trvá to dlouho“.
Spojení navíc zůstává otevřené mezi požadavky. Nejvíc se to pozná u smyčky nástrojů, která je vlastně dávkou volání na tentýž server rychle po sobě; bez toho by se pro každé kolo znovu navazovalo TLS.
Vlastní implementace
Rozhraní Http\Client má jedinou metodu:
interface Client
{
function fetch(
string $url,
string|array|FormData|null $payload = null,
array $headers = [],
?string $method = null,
?\Closure $onChunk = null,
): Response;
}
Streamování se nepozná podle zvláštní metody, ale podle toho, jestli je zadaný $onChunk. Vlastní
implementaci oceníš hlavně v testech, kde chceš odpovědi předepsat místo volat API, ale i tehdy, když musíš požadavky
protáhnout něčím netypickým.
Kam dál
- Ošetření chyb – které chyby má smysl opakovat a proč
- Streamování – proč se u streamu měří ticho místo času
- Generování obrázků – kde se výchozí timeout nemusí vejít
- Provideři – co který umí a čím se liší