Ein agentischer Assistent in Laravel mit dem Anthropic PHP SDK: Tools, Model Lanes, Prompt Caching und Metering pro Tenant
Eine Chatbox ist kein Assistent. Ein Modell, das nur über Ihr Unternehmen reden kann, ist eine sehr teure Hilfeseite. Was den Assistenten in Zenpresa nützlich macht, ist, dass er Dinge tun kann: den Termin buchen, die Rechnung senden, die abgesprungenen Kunden finden, das Theme anwenden. Jede dieser Aktionen ist ein Tool, und das Design des Tool-Vertrags stellte sich als wichtiger heraus als die Wahl des Modells, der Prompt oder irgendetwas anderes, worauf wir Zeit verwendet haben.
Dieser Beitrag ist der Engineering-Bericht zu diesem Assistenten. Er läuft auf Laravel 12 und dem offiziellen Anthropic PHP SDK, sitzt innerhalb der im vorherigen Beitrag beschriebenen Database-per-Tenant-Architektur und muss für viele Betriebe gleichzeitig sicher, günstig und schnell sein. Prompts, exakte Limits und Modellkennungen lassen wir bewusst weg; die Muster sind der Punkt.
Der Tool-Vertrag
Jede Aktion, die der Assistent ausführen kann, implementiert ein PHP-Interface. Leicht vereinfacht, mit erfundenen Namen:
interface AssistantTool
{
/** snake_case-Name, der dem Modell exponiert wird */
public function name(): string;
/** Präskriptiv: "Rufe dies auf, wenn der Nutzer …" */
public function description(): string;
/** JSON Schema für die Eingabe */
public function inputSchema(): array;
/** Modul, das für den Tenant aktiviert sein muss, oder null */
public function moduleKey(): ?string;
/** Ist dieses Tool in diesem Deployment überhaupt nutzbar? */
public function isAvailable(): bool;
/** Braucht diese Aktion eine explizite menschliche Bestätigung? */
public function requiresConfirmation(): bool;
/** Darf dieser Nutzer es ausführen? Wird bei jedem Aufruf neu geprüft. */
public function authorize(User $user): bool;
/** Liefert summary, data, link und ein optionales diff */
public function execute(array $input, User $user): array;
}
Zwei Dinge an dieser Form haben sich über Dutzende Tools hinweg bewährt.
Erstens: Das Ergebnis ist einheitlich. Jedes Tool liefert eine kurze, menschenlesbare summary (dem Nutzer gezeigt und ans Modell zurückgespielt), ein data-Array (nur ans Modell zurückgegeben), einen optionalen Deep-link in die App und ein optionales diff mit Vorher/Nachher-Zustand. Die Chat-UI rendert diese vier Felder für jedes Tool auf dieselbe Weise, sodass ein neues Tool nie bedeutet, das Frontend anzufassen. Besonders das Diff ist das UI-Element, dem im ganzen Produkt am meisten vertraut wird: Wenn der Assistent den Preis einer Leistung ändert, sieht der Nutzer alt und neu nebeneinander.
Zweitens: Die Beschreibung ist präskriptiv, nicht deskriptiv. „Bucht einen Termin” ist eine Beschreibung. „Rufe dies auf, wenn der Nutzer einen bestimmten Kunden in ein bestimmtes Zeitfenster für eine bestimmte Leistung setzen will; rufe zuerst get_available_slots auf, wenn die Uhrzeit unsicher ist” ist eine Anweisung. Die zweite Art produziert dramatisch bessere Tool-Auswahl, und sie ist der Grund, warum sich unsere Tool-Beschreibungen wie ein Runbook lesen.
Eine Registry, die vom Abonnement gesteuert wird
Tools leben in einer Registry, die nach Namen indiziert ist. Wenn eine Konversation beginnt, baut die Registry die Liste, die das Modell sieht, indem sie nach drei Dingen filtert: isAvailable() (ist die Plattform dafür konfiguriert), moduleKey() (ist dieses Modul im Abonnement des Tenants) und authorize() für den aktuellen Nutzer. Ein Tenant ohne Marketing-Modul sieht nie ein „Kampagne senden”-Tool. Ein Mitarbeiter ohne Rechnungsberechtigung sieht nie „Rechnung erstellen”.
Das ist nicht nur Ordnungsliebe. Das Modell kann kein Tool aufrufen, von dem es nie erfahren hat, und ein Tool, von dem es nichts weiß, kann in seiner Beschreibung nicht verraten, dass das Feature existiert. Die Registry ist die eine Stelle, an der „was darf dieser Nutzer über den Assistenten tun” entschieden wird, und sie liest dieselben Feature-Flags und Berechtigungsprüfungen wie der Rest der App.
Bei jedem Aufruf autorisieren, serverseitig
Die Registry-Filterung passiert einmal pro Konversation. Autorisierung passiert bei jeder Tool-Ausführung, erneut, auf dem Server. Berechtigungen können sich mitten in der Konversation ändern, eine Konversation kann Stunden später fortgesetzt werden, und das Transkript des Modells ist für nichts eine Source of Truth.
Innerhalb von execute() wird die Eingabe mit denselben Laravel-Validierungsregeln validiert, die der äquivalente HTTP-Controller verwendet. Wir haben keinen separaten, lockereren Validator für den Assistenten geschrieben. Wenn die API sagt, ein Leistungspreis muss eine nicht-negative Ganzzahl in kleinsten Währungseinheiten sein, sagt das Tool dasselbe, und eine ValidationException wird in einen strukturierten Fehler verwandelt, den das Modell lesen und korrigieren kann. Das Modell trägt nie Autorität; es trägt eine Anfrage, die der Server genau so bewertet, als wäre sie von einem Button-Klick gekommen.
Model Lanes, pro Konversation gepinnt
Nicht jede Nachricht braucht dasselbe Modell. „Was war letzte Woche mein geschäftigster Tag?” braucht ein schnelles, günstiges Modell, das Tools korrekt aufruft. „Schreib den Hero-Text meiner Startseite wärmer” braucht ein Modell mit mehr Bandbreite. Wir nennen das Lanes: eine Standard-Lane und eine kreative Lane, jede mit eigenem Modell und eigenen Reasoning-Einstellungen, explizit gesetzt statt von einem Default geerbt.
Die Lane wird einmal gewählt, zu Beginn einer Konversation, von einem kleinen Klassifikator über die erste Nachricht. Danach ist sie für die Lebensdauer der Konversation gepinnt. Der Grund ist Prompt Caching: Gecachte Präfixe sind an ein Modell gebunden, ein Modellwechsel mitten in der Konversation wirft den Cache also weg und stellt beim nächsten Turn den gesamten System-Prompt und die Tool-Liste erneut in Rechnung. Pinning bedeutet, dass der zweite und jeder weitere Turn den Cache trifft. Die Kosten, gelegentlich für eine Nachricht in der „falschen” Lane zu sein, sind weit geringer als die Kosten eines Cache-Miss bei jeder Nachricht.
Prompt-Cache-Breakpoints, die tatsächlich treffen
Anthropics Prompt Caching berechnet gecachte Eingabe-Tokens zu einem Bruchteil des normalen Preises — aber nur, wenn das Präfix zwischen Requests byte-identisch ist. Der Request, den wir senden, ist so aufgebaut, dass die teuren, stabilen Teile zuerst kommen und die volatilen zuletzt:
- System-Prompt, identisch für jeden Tenant.
- Tool-Definitionen, aus der Registry generiert, stabil für einen gegebenen Satz aktivierter Module.
- Ein Cache-Breakpoint mit langer Time-to-Live nach den Tools.
- Tenant-spezifischer Kontext und die bisherige Konversation.
- Ein zweiter Cache-Breakpoint mit kurzer Time-to-Live am Ende der Nachrichten.
Der erste Breakpoint ist der, der sich auszahlt: Jeder Tenant mit demselben Satz aktivierter Module teilt sich dasselbe gecachte Präfix, weil nichts vor diesem Breakpoint tenant-spezifisch ist. Der zweite Breakpoint macht Multi-Turn-Konversationen günstig, weil jeder Turn nur die neuesten Nachrichten hinter ein Präfix hängt, das im vorherigen Turn gecacht wurde.
Die Invariante „nichts vor dem ersten Breakpoint ist tenant-spezifisch” wird durch einen Test erzwungen. Er baut das Präfix für zwei verschiedene Tenants und prüft, dass die Bytes identisch sind. Er ist genau einmal fehlgeschlagen — als jemand den Firmennamen für einen „freundlicheren” Ton in den System-Prompt gesetzt hat — und er hat die Regression gefangen, bevor die Cache-Hit-Rate in Produktion eingebrochen ist.
Streaming und die Tool-Schleife in PHP
PHP ist nicht die erste Sprache, zu der man greift, um eine Agent-Schleife zu streamen, und es funktioniert einwandfrei. Der Assistenten-Endpunkt liefert eine Laravel-StreamedResponse mit dem Content-Type text/event-stream und schreibt Server-Sent Events, während der Streaming-Iterator des SDK sie liefert. Text-Deltas gehen direkt an den Client. Wenn das Modell einen Tool-Aufruf ausgibt, führt die Schleife das Tool aus, hängt das Ergebnis ans Transkript und stellt den nächsten Request — alles innerhalb derselben HTTP-Response.
Die Schleife ist begrenzt. Ein Konversations-Turn kann die Tool-Schleife nur eine feste Anzahl von Malen durchlaufen, bevor der Server stoppt und dem Nutzer mitteilt, was er bis dahin getan hat. Ohne diese Grenze kann ein verwirrtes Modell mit einem fehlschlagenden Tool auf Ihre Kosten endlos kreisen.
Zwei Dinge mussten verifiziert statt angenommen werden. Erstens: Der Reverse Proxy darf nicht puffern. Die App setzt den Header, der Proxy-Buffering deaktiviert, und wir haben Ende-zu-Ende getestet, dass Tokens inkrementell im Browser ankommen — denn ein puffernder Proxy macht aus Streaming eine sehr lange Pause, gefolgt von allem auf einmal. Zweitens: Auf dem Client kann die eingebaute EventSource des Browsers weder Header noch Request-Body senden, also verwendet das Frontend fetch mit einem ReadableStream-Reader und parst die SSE-Frames selbst. Das sind rund vierzig Zeilen TypeScript, und sie mussten seither nicht geändert werden.
Metering pro Tenant
Jeder Request zeichnet Eingabe-Tokens, gecachte Eingabe-Tokens, Ausgabe-Tokens und das Modell auf, das sie bedient hat. Die Kosten werden aus einer Preistabelle berechnet und als Ganzzahl in Mikro-Dollar gespeichert, denn Fließkomma-Währungsbeträge im großen Maßstab sind der direkte Weg zu einem Abstimmungsprojekt.
Obergrenzen pro Tenant sind in Tokens, nicht in Dollar ausgedrückt. Das war eine bewusste Entscheidung: Wenn wir eine Konversation auf ein günstigeres Modell routen, bekommt der Tenant mehr Turns aus demselben Kontingent, und die Marge der Plattform verbessert sich gleichzeitig. Eine Dollar-Obergrenze würde Modell-Routing für den Kunden unsichtbar machen; eine Token-Obergrenze macht daraus ein Feature.
Das letzte Metering-Detail ist das, was am längsten gebraucht hat, bis es stimmte. Wenn ein Nutzer den Tab mitten in der Antwort schließt, bricht der Stream ab. Die Tokens sind vom Anbieter bereits generiert und berechnet worden. Also wird die Nutzung in einem finally-Block persistiert, der läuft, egal ob der Stream abgeschlossen wurde, fehlgeschlagen ist oder der Client die Verbindung getrennt hat — und der persistierte Datensatz trägt ein Flag, das den Abbruch vermerkt. Vor dieser Änderung hat der Zähler immer dann zu wenig gezählt, wenn Nutzer ungeduldig waren — also oft.
Dem Modell beibringen, wofür es kein Tool gibt
Der beste Bug-Report, den wir zum Assistenten bekommen haben, kam von einem Kunden, der „eine Sitzung buchen” eintippte und, korrekt, einen gebuchten Termin bekam. Nur nannte sein Betrieb Sitzungen anders, und gemeint war „füge meiner Kontaktseite einen Button ‚Sitzung buchen’ hinzu”. Der Assistent hatte ein Tool fürs Buchen und kein Tool zum Bearbeiten genau dieser Seite, also tat er das, was er konnte.
Die Lösung war nicht ein weiteres Tool. Es war eine Capability-Referenz: ein Dokument, das das Modell liest und das jedes Tool beschreibt, das es hat, jede Aufgabe, für die es kein Tool hat, und was in jedem dieser Fälle zu tun ist (erklären, den Nutzer zur richtigen Einstellungsseite verlinken oder eine Rückfrage stellen). Der Assistent sagt jetzt „Ich kann diese Seite nicht direkt bearbeiten, aber hier ist der Link und hier ist, was zu ändern ist”, statt zu raten.
Das Dokument wird durch einen Test ehrlich gehalten: Er enumeriert die Registry und lässt CI fehlschlagen, wenn ein registriertes Tool in der Referenz fehlt. Dokumentation, die ein Test erzwingt, ist Dokumentation, die wahr bleibt.
Was wir anders machen würden
Mit dem Diff-Feld anfangen. Wir haben das Vorher/Nachher-Diff erst nach dem Launch zum Tool-Ergebnis hinzugefügt, und es ist das eine Feature, das das Vertrauen am meisten gesteigert hat. Es hätte von Tag eins an im Vertrag stehen sollen.
Lanes von Anfang an pinnen. Anfangs haben wir jede Nachricht neu klassifiziert und zugesehen, wie die Cache-Hit-Rate hartnäckig niedrig blieb. Pinning war eine Einzeiler-Änderung mit überproportionaler Wirkung auf die Kosten.
Die Capability-Referenz vor den Tools schreiben. Zu beschreiben, was der Assistent nicht kann, hat zu klarerem Denken darüber gezwungen, was er können sollte.
Von Tag eins im finally messen. Jeder zu wenig gezählte Token ist Geld, das Sie bereits ausgegeben haben.
Wenn Sie etwas Ähnliches bauen: Der Beitrag zum Evaluations-Harness auf dieser Seite behandelt, wie man Tool-Auswahl und Sicherheit vor dem Deploy bewertet, und der Vergleich des OpenAI Agents SDK mit der Responses API ist ein nützlicher Kontrast, wenn Sie zwischen Anbieter-SDKs wählen. Alles zu Ratenbegrenzung und Budget-Durchsetzung vor dem Modell steht im Beitrag zur LLM-Kostenkontrolle.
Weiter in der Serie: Agent-Tool-Sicherheit: Prompt Injection, Out-of-Band-Bestätigungscodes und Idempotency Keys für geldbewegende Tools
FAQ: Agentische Assistenten in Laravel
Kann man eine streamende KI-Agent-Schleife in PHP bauen?
Ja. Laravels StreamedResponse mit dem Content-Type text/event-stream, gespeist vom Streaming-Iterator des Anthropic PHP SDK, liefert Token-Deltas als Server-Sent Events. Die Tool-Schleife läuft innerhalb derselben Response: Tool ausführen, Ergebnis anhängen, nächste Completion anfordern, bis zu einer festen Grenze. Zwei Dinge sind zu verifizieren: dass Ihr Reverse Proxy die Response nicht puffert, und dass der Client fetch mit einem ReadableStream statt EventSource verwendet, das weder Header noch Body senden kann.
Wie hält man Prompt Caching über viele Tenants hinweg effektiv?
Ordnen Sie den Request so, dass alles Stabile und Tenant-Unabhängige zuerst kommt: System-Prompt und Tool-Definitionen, gefolgt von einem langlebigen Cache-Breakpoint. Setzen Sie Tenant-Kontext und Konversationsverlauf dahinter, mit einem kurzlebigen Breakpoint am Ende. Pinnen Sie das Modell für die gesamte Konversation, weil gecachte Präfixe modellgebunden sind. Erzwingen Sie die Invariante „tenant-freies Präfix” mit einem Test, der das Präfix für zwei Tenants baut und Byte-Gleichheit prüft.
Warum KI-Nutzung in Tokens statt in Dollar begrenzen?
Weil es Routing belohnt. Wenn ein günstigeres Modell eine Konversation übernimmt, gibt eine Token-Obergrenze dem Tenant mehr Turns aus demselben Kontingent, während die Kosten der Plattform sinken. Eine Dollar-Obergrenze verbirgt diesen Vorteil vor dem Kunden. Intern werden die Kosten weiterhin in ganzzahligen Mikro-Dollar für die Abstimmung erfasst.
Wie verhindert man, dass das Modell Tools aufruft, die der Nutzer nicht verwenden darf?
Zweimal. Die Tool-Registry filtert, was das Modell sehen kann, nach Plattform-Verfügbarkeit, den aktivierten Modulen des Tenants und den Berechtigungen des Nutzers. Dann führt jede Tool-Ausführung die Autorisierung erneut aus und wendet dieselben Validierungsregeln an wie der äquivalente HTTP-Endpunkt. Das Transkript des Modells wird nie als Beleg für eine Berechtigung behandelt.
Was tut das Modell bei Aufgaben, für die es kein Tool gibt?
Es liest eine Capability-Referenz, die jedes Tool, jede Aufgabe ohne Tool und die richtige Reaktion für jeden Fall beschreibt: erklären, auf die passende Einstellungsseite verlinken oder nachfragen. Ein CI-Test enumeriert die Tool-Registry und schlägt fehl, wenn ein registriertes Tool in der Referenz fehlt, sodass das Dokument nicht vom Code abdriften kann.
Verwandte Artikel
LLM-API-Rate-Limiting und Kostenkontrolle: Token-Budgets, Per-Key-Throttling und Kosten-Dashboards verwalten
Stoppen Sie unkontrolliertes Wachstum der LLM-API-Kosten. Ein praktischer Leitfaden zu Rate Limiting, Per-User-Token-Budgets, Exact-Match-Caching und Kosten-Dashboards mit einem deploybaren Open-Source-Proxy.
Database-per-Tenant auf Laravel 12: Wie Zenpresa jeden Betrieb isoliert und Module als Composer-Pakete ausliefert
Warum Zenpresa jedem Tenant auf Laravel 12 eine eigene Postgres-Datenbank gibt, wie Module als Composer-Pakete kommen und welche Trade-offs und Bugs bleiben.
CI-Checks, die Linter nicht leisten können: API-Contract-Checks, Mobile-Pattern-Checks, Warning-Ratchets und Tests, die Stripe-Bugs festnageln
Vier CI-Checks, die Zenpresa nach echten Bugs ergänzt hat: API-Contract-Check über beide Seiten, Mobile-Pattern-Check für JSX, Warning-Ratchet, Stripe-Tests.