Egyre több vásárló érkezik ChatGPT-ből és Perplexityből, de vajon mit lát az oldalunkból egy nyelvi modell? Megcsináltuk az llms.txt-t a PadPad.hu-ra: miért nem statikus fájl lett belőle, mit hagytunk ki belőle szándékosan, és melyik cache-buktató ejt tőrbe mindenkit tesztelés közben.
Az elmúlt évben többször láttam a webshop analyticsjében, hogy egy vásárló a ChatGPT-ből vagy a Perplexityből érkezik. Nem sok ilyen volt, de a szám havonta nőtt. Ez elgondolkodtatott: ha egy nyelvi modell ajánlja a boltunkat, akkor mégis mi alapján teszi, mit lát egyáltalán az oldalunkból?
A válasz nem túl hízelgő. Egy Laravel webshop HTML-je tele van olyasmivel, ami egy embernek hasznos, egy botnak viszont zaj: sütibanner, navigációs menü hetven linkkel, Livewire-attribútumok, tracking scriptek, lábléc. A tényleges információ, hogy mit árulunk és kinek, valahol elvész ebben. A modellek context window-ja pedig véges. Ha rossz oldalakat olvas be, rossz választ ad, vagy meg sem említ minket.
Erre a problémára van egy javaslat, amit llms.txt-nek hívnak. Megcsináltuk a PadPad.hu-ra, és leírom, hogyan.
Mi ez pontosan
Jeremy Howard vetette fel 2024-ben, a specifikáció az llmstxt.org címen olvasható. A lényege egyszerű, tegyünk egy Markdown fájlt a domain gyökerébe, /llms.txt néven, amiben tömören leírjuk, mi ez az oldal, és felsoroljuk a fontos aloldalakat rövid magyarázattal.
Érdemes elhatárolni a két másik gyökérfájltól, mert könnyű összekeverni őket:
A
robots.txtarról szól, mit szabad bejárni. Engedélyezés és tiltás, semmi több.A
sitemap.xmlarról szól, mi létezik. Teljes URL-lista, gépi formátum, kontextus nélkül.Az
llms.txtarról szól, mi a lényeg és miért. Kurált, emberi nyelven megírt, szándékosan rövid.
A formátum kötött, de nem bonyolult. Egy # szintű cím, alatta egy > blockquote egymondatos összefoglalóval, aztán szabad szöveg, végül ## szintű szekciók linklistákkal, ahol minden link mellé kerül egy magyarázó mondat.
Egy fenntartást előre bocsátok, mert a cikk hitelessége ezen múlik: ez nem hivatalos szabvány. Sem az OpenAI, sem az Anthropic, sem a Google nem ígérte meg, hogy figyelembe veszi. Lehet, hogy sosem lesz belőle semmi. Fél óra munka viszont, és ha bejön, olcsón nyertünk.
Statikus fájl vagy generált route
Itt jött az első döntés, mert a kézenfekvő megoldás az lett volna, hogy egy public/llms.txt fájlt rakunk a public mappába és kész. Ezt ugyebár az Nginx kiszolgálja, nulla futásidejű költség és kész. Természetesen ezt elvetettük, mert a tartalom az oldalon változik és nem akartam mindig manuálisan ebbe a fájlba nyulkálni. Egy webshopnál a kategóriák változnak, a blogcikkek hetente jönnek, az információs oldalak átalakulnak. Egy kézzel karbantartott statikus fájl három hónap múlva nem elavult lesz, hanem hazudni fog. Márpedig egy AI-nak adott térkép esetében a pontatlanság rosszabb, mint a hiány szerintem.
Ez a megoldás helyett inkább route lett belőle, controllerrel és cache-sel. Az adatbázisból generálódik, tehát mindig szinkronban van azzal, ami tényleg az oldalon van. Fontos, hogy ez nem univerzális tanács. Ha valakinek statikus dokumentációs oldala van, ami félévente változik, a public/llms.txt teljesen rendben van, és butaság lenne controllert írni hozzá. A választás a tartalom változási sebességén múlik inkább.
Nézzük az implementációt
A route egy sor, és a fájlnévben lévő pont nem zavarja a Laravel routerét:
Route::get('llms.txt', LlmsTxtController::class)
->name('llms-txt');A controller invokálható, és ugyanazt a mintát követi, mint a projektben már meglévő Google Merchant feed. Ez tudatos döntés volt: ha egy kódbázisban már van bevált megoldás hasonló problémára, nem érdemes újat kitalálni mellé.
public function __invoke(): Response
{
$content = Cache::remember('llms-txt.v1', now()->addHours(6), function (): string {
return view('llms-txt', [
'categories' => Category::active()
->orderBy('name')
->get(['name', 'slug']),
'pages' => Page::query()
->orderBy('title')
->get(['title', 'slug']),
'blogPosts' => BlogPost::published()
->orderByDesc('published_at')
->limit(20)
->get(['title', 'slug', 'excerpt']),
])->render();
});
return response($content, 200, [
'Content-Type' => 'text/plain; charset=utf-8',
]);
}Néhány részlet, ami nem véletlen:
A get(['name', 'slug']) csak a szükséges oszlopokat hozza, feleslegesen nem terhelünk semmit. Teljes modelleket hidratálni egy szöveges kimenethez pazarlás, főleg ha a modell médiafájlokat és SEO-adatokat tartalmaz.
A Category::active() és a BlogPost::published() már létező scope-ok. Ha valaki holnap módosítja, hogy mi számít publikáltnak, az itt is automatikusan érvényesül. Ha kézzel írnék where('is_published', true) feltételt, az két hónap múlva eltérne a blogoldal logikájától. Ahol van értelme és tudsz, használj scope-okat (nem véletlenül találták ki).
A hat órás cache elég friss egy olyan tartalomhoz, ami naponta legfeljebb egyszer változik.
A sablon Blade view-ban él, mert a tartalom nagyjából nyolcvan százaléka fix próza, ezt controllerben string-összefűzéssel írni olvashatatlan lenne. Egy buktató viszont van vele: a Blade direktívák sortörései. Markdownban egy felesleges üres sor szétesett listát eredményez, tehát az @if és @foreach elhelyezésére figyelni kell.
Mit tettünk bele és mit hagytunk ki
Ez izgalmasabb kérdés volt, mint a kód, utána olvastam és természetesen a ChatGPT sem maradhatott ki, hogy megkérdezzem a "véleményét".
Bekerült egy konkrét összefoglaló arról, mit árulunk és kinek. Bekerültek azok az üzleti alapadatok, amiket egy botnak amúgy öt különböző oldalról kellene összeraknia: nyelv, pénznem, szállítási terület, fizetési módok. Bekerültek a fő funkcionális oldalak, nálunk az egérpad tervező és a tömeges rendelés, mert ezek különböztetnek meg minket. Aztán a kategóriák, a jogi és információs oldalak, és a legutóbbi húsz blogcikk kivonattal.
Kimaradt az összes termék, az több száz sor lenne, gyorsan elavulna, és pont erre való a sitemap meg a termékfeed, amikre a fájl végén mutatunk is. Kimaradt a kosár, a pénztár, a profil és a rendelések. Ezek felhasználóspecifikusak, nulla nyilvános információt tartalmaznak. Sőt, egy lépéssel tovább mentünk, és a fájl utolsó mondatában explicit kiírtuk, hogy ezek nem relevánsak. Ha már úgyis olvassa, mondjuk meg neki, hova ne menjen. Kimaradt a kulcsszóhalmozás is. A címzett egy nyelvi modell, ami az ilyesmit zajként kezeli, rosszabb esetben manipulációként.
A tesztelés
Egy generált fájl, amit senki nem néz meg, csendben eltörik az első modellrefaktornál. Ezért hat teszt született rá: HTTP 200 és a helyes content-type, a fő route-ok jelenléte, aktív kategória látszik és inaktív nem, publikált blogcikk látszik és vázlat nem, végül a sitemap és a feed hivatkozása.
Egy dologra érdemes odafigyelni cache-elt endpoint tesztelésénél:
beforeEach(function (): void {
Cache::forget('llms-txt.v1');
});Enélkül az első teszt által elmentett kimenet átszivárog a következőbe, és hamis zöldet kapunk. Ez a leggyakoribb hiba ezen a területen, és sokáig észrevétlen marad, mert a tesztek látszólag működnek.
Ami még kellett
A robots.txt-be került egy Allow: /llms.txt sor. Technikailag redundáns, mert alapból nincs tiltva, de dokumentálja a szándékot annak, aki később nézi. Érdemes ezenkívül szűrni a szerverlogokat AI-crawler user agentekre, mint a GPTBot, a ClaudeBot vagy a PerplexityBot. Enélkül fogalmunk sincs, lehívja-e valaha bárki a fájlt, és három hónap múlva nem tudunk mit kezdeni a kérdéssel, hogy megérte-e.
Összefoglalva
Fél óra munka, elenyésző futásidejű költség, hat teszt mögötte. Nem SEO-csodafegyver, és könnyen lehet, hogy sosem lesz belőle szabvány, bár én remélem.
Az igazi tanulság viszont nem az llms.txt. Hanem az, hogy minden gépi fogyasztásra szánt kimenet, legyen az sitemap, termékfeed vagy éppen ez, a kódból generálódjon, és legyen tesztekkel lefedve. A kézzel karbantartott fájlok nem hibaüzenettel halnak meg, hanem csendben elavulnak, és senki nem veszi észre.
Szeretnél hasonlót építeni a weboldaladra? Szívesen megcsináljuk neked, keress minket bizalommal: janes.hu/kapcsolat