Ein neues Tool für deinen Agent ist schnell gebaut: etwa „öffentliche Posts einer Plattform abrufen“. Zwei Wochen läuft es in Produktion problemlos. Dann änderst du den Standardwert von limit von 25 auf 20 und ergänzt einen neuen Wert im Enum sort. Code angepasst, Tests grün, Merge erledigt.
Drei Tage später treten in Produktion vereinzelt Fehler auf. Das Modell ruft das Tool mit einem Enum-Wert auf, den du letzte Woche entfernt hast. Die Runtime-Validierung lehnt den Aufruf ab, der Stack Trace zeigt auf die Dispatch-Schicht. Du verbringst eine halbe Stunde damit, diesen Code anzustarren – obwohl dort keine einzige Zeile falsch ist. Die eigentliche Ursache liegt ganz woanders: Du hast die Funktionssignatur geändert, aber die Tool-Beschreibung, die das Modell liest, unverändert gelassen. Das Modell arbeitet noch mit dem alten Schema und erzeugt daher Aufrufe für eine Schnittstelle, die es so nicht mehr gibt.
Das ist Tool-Description Drift. In der Agent-Entwicklung ist das die häufigste und zugleich am schwierigsten nachzuvollziehende Fehlerklasse. Der Grund ist konkret: Fehlerbild und Ursache sitzen an unterschiedlichen Stellen. Sichtbar wird der Fehler in der Ausführungsschicht, verursacht wird er durch eine JSON-Datei, die niemand öffnet. Es geht hier deshalb nicht um „daran denken, beides synchron zu halten“. Das Ziel ist, die zweite Kopie strukturell abzuschaffen – damit sie gar nicht erst driften kann.
Warum Tool-Beschreibungen auseinanderlaufen
Der Kern des Problems ist simpel: Du pflegst zwei Wahrheiten.
Die erste ist der tatsächlich ausführbare Code: Funktionssignatur, Argumentvalidierung, Defaults und Enum-Einschränkungen. Diese Seite ist hart. Stimmt etwas nicht, schlägt sie lautstark fehl.
Die zweite ist die Tool-Beschreibung für das Modell: name, description und das JSON-Schema unter parameters. Diese Seite ist weich. Fehler darin führen nicht sofort zum Absturz. Das Modell erzeugt lediglich einen unpassenden Aufruf, der später in der Ausführungsschicht scheitert.
Solange ein Mensch beide Seiten konsistent halten muss, ist Drift nur eine Frage der Zeit. Du änderst ein Argument im Code und vergisst die Beschreibung. Oder du passt die Beschreibung an, aber nicht den Code. Oder du änderst beides, doch die Bedeutung stimmt trotzdem nicht mehr überein. Keiner dieser Fälle fällt beim Commit auf. Sie warten, bis das Modell zufällig genau den Unterschied berührt – und dann erinnerst du dich längst nicht mehr daran, was du vor zwei Wochen geändert hast. Die Lösung kann nur in eine Richtung gehen: Aus zwei Kopien wird eine.
Die Deklaration ist die Schnittstelle
Der entscheidende Perspektivwechsel: Du brauchst dieses JSON für Tool-Beschreibungen eigentlich nicht.
Die Parser-Deklaration einer Funktion enthält zusammen mit ihrem Docstring bereits alle Angaben, die eine Tool-Beschreibung benötigt. So sieht eine einfache argparse-Deklaration aus:
subreddit = commands.add_parser("subreddit", help="Query a public board's feed")
subreddit.add_argument("subreddit")
subreddit.add_argument(
"--sort",
choices=("hot", "new", "top", "rising", "controversial"),
default="hot",
)
subreddit.add_argument("--limit", type=int, default=25)
help liefert die Ein-Zeilen-Beschreibung des Befehls. choices definiert die Enum-Einschränkung, default den Standardwert und type den Parametertyp. Das positionale Argument ist ein Pflichtfeld. Alles, was das Modell zum Aufrufen des Tools braucht, steht bereits hier: Zweck des Befehls, verfügbare Parameter, Pflichtfelder, Enum-Werte und Defaults. Dieselbe Deklaration verwendet auch die Runtime zum Parsen und Validieren. Sie kann also nicht von der Ausführungslogik abweichen – sie ist die Ausführungslogik.
Schreib daher keine zweite Tool-Beschreibung mehr. Die richtige Haltung lautet: Dieses Dokument existiert nicht. Es gibt nur Code. Wenn du eine Tool-Beschreibung brauchst, leitest du sie aus dem Code ab. Die Projektion läuft ausschließlich in eine Richtung – von Code zu Beschreibung, nie umgekehrt.
Den gesamten Katalog aus Deklarationen erzeugen
Sobald die Deklaration als Schnittstelle gesetzt ist, darf keine Tool-Beschreibung mehr von Hand entstehen. Ein Deriver sollte sie alle erzeugen.
Seine Aufgabe ist rein mechanisch: Er durchläuft jeden Plattformkontext, importiert dessen Parser und überführt die argparse-Aktionsliste in drei unveränderliche Strukturen: Platform, Command und Parameter. Jedes Parameter enthält Name, Typ, Required-Flag, Enum-Werte, Default und Hilfetext. Damit entsteht ein Interface-Read-Model, das vollständig aus dem Code abgeleitet ist.
Von diesem Read-Model aus sind alle Ausgabeformate nachgelagert. describe --format json liefert die vollständige maschinenlesbare Schnittstelle für die Tool-Auswahl eines Agents. render_skill() erzeugt einen Capability-Katalog, den Menschen und Modelle lesen können. Auch die Anzahl der Befehle im Katalog ist keine manuell gepflegte Konstante, sondern wird direkt über sum(len(platform.commands)) berechnet. Aktuell sind es 22 Plattformkontexte und 241 Befehle – keiner davon wurde von Hand in den Katalog eingetragen.
Das schafft eine angenehme Eigenschaft: Eine neue Plattform bedeutet, einen Plattformkontext hinzuzufügen; der Katalog übernimmt sämtliche Befehle automatisch. Änderst du einen Parameter, bearbeitest du die Parser-Deklaration – und Enum sowie Default im Katalog aktualisieren sich selbst. Fälle wie „neuen Befehl geschrieben, aber nicht registriert“ oder „Parameter geändert, Katalog veraltet“ gibt es nicht mehr, weil Registrierung als manueller Arbeitsschritt nicht existiert. Der Katalog wird berechnet, nicht gepflegt.
(Dieser Impuls, abzuleiten statt zu pflegen, steckt auch hinter Set-Operationen zur Prüfung, was bei sprachübergreifenden Migrationen tatsächlich übertragen wurde. Darum geht es in dem Beitrag zu Cross-Language-Migrationen.)
CI macht Drift beim Commit sichtbar
Die Ableitung sorgt dafür, dass neue Befehle automatisch im Katalog landen. Eine Lücke bleibt jedoch: Jemand ändert eine Parser-Deklaration, führt den Deriver nicht erneut aus und committet den regenerierten Katalog nicht. Dann ist die Kopie im Repository wieder veraltet – und der Drift kommt durch die Hintertür zurück.
Die letzte Schranke gehört in die CI. Ihr Kern ist genau eine Assertion:
docs-check:
$(PYTHON) -c 'from pathlib import Path; from reverse.catalog import render_skill; \
path = Path("skill/SKILL.md"); \
assert path.read_text(encoding="utf-8") == render_skill(), \
"skill/SKILL.md is out of sync with the code; run make docs"'
Sie liest den im Repository versionierten Katalog und vergleicht ihn bytegenau mit einem aus dem aktuellen Code neu erzeugten Katalog. Bereits ein einziges abweichendes Zeichen lässt die CI fehlschlagen – mit dem klaren Hinweis, dass der Katalog nicht aktuell ist und make docs ausgeführt werden muss.
Der Wert dieser einen Zeile liegt darin, wann sie Drift findet. Früher war Drift ein Runtime-Geist: Zwei Wochen später explodierte etwas in Produktion, und der Stack Trace zeigte an die falsche Stelle. Jetzt erscheint beim Commit ein rotes X. Der Pull Request wird gestoppt, die Fehlermeldung benennt den veralteten Katalog, und dessen Regenerierung löst das Problem. Aus „dem am schwersten zu findenden Bug“ wird ein Compile-Fehler, den ein einziger Befehl beseitigt. Das ist der vollständige Kreislauf von Interface-as-Code: Die Deklaration ist die Quelle, der Capability-Katalog das Build-Artefakt und CI die Typprüfung. Du würdest ein Build-Artefakt weder von Hand schreiben noch eine Abweichung von seiner Quelle akzeptieren. Tool-Beschreibungen verdienen dieselbe Behandlung.
Was in Code gehört – und was das Modell entscheiden soll
Ableitung und CI stellen sicher, dass die Schnittstellenbeschreibung korrekt ist. Davor steht jedoch eine grundlegendere Entscheidung: Soll eine Fähigkeit als fester Code implementiert werden, oder soll das Modell sie jeweils selbst orchestrieren? Ist diese Aufteilung falsch, hilft auch eine perfekte Schnittstellenbeschreibung nicht weiter.
Betrachte Fähigkeiten in drei Ebenen.
Eine Low-Level-Primitive liest eine bestimmte Datenart oder führt eine klar abgegrenzte Aktion aus. Ihre Eingabe ist stabil, ihre Ausgabe strukturiert, und sie lässt sich isoliert testen. Diese Ebene ist reiner Code und benötigt keinerlei Reasoning. Der weitaus größte Teil der 241 Befehle gehört hierher.
Ein deterministischer Workflow ist ein strikt geordneter Ablauf innerhalb einer Plattform, mit gemeinsamem Zustand und eindeutigem Erfolgskriterium. Ein Beispiel ist die Creative-Pipeline creative-pipeline, die nacheinander Chancen findet, dann Top Ads untersucht, Creator zuordnet, ein Creative Brief erstellt und schließlich einen Generation-Preflight durchführt. Reihenfolge und Abhängigkeiten der Schritte stehen fest. Auch diese Ebene wird in Code eingefroren: Wenn die Reihenfolge ohnehin bestimmt ist, macht es den Ablauf langsamer und instabiler, das Modell jedes Mal neu planen zu lassen. Die Kennzeichnung braucht nur eine Zeile: Dem Befehl wird set_defaults(_command_level="workflow") gegeben. Das ist die einzige solche Zeile in der Codebasis. Deshalb zeigt der Katalog Workflows und Primitives auf zwei getrennten Ebenen.
Agent-Orchestrierung ist dagegen die Ebene für plattformübergreifende Recherche, Abwägungen zur Laufzeit und Umleitungen nach Fehlern. Diese Entscheidungen überlässt du dem Modell, denn der nächste sinnvolle Query hängt davon ab, was der vorherige ergeben hat. Das lässt sich nicht im Voraus festschreiben.
Der Test ist recht eindeutig. Braucht eine Fähigkeit stabilen Stage-Status, gemeinsamen Kontext oder Seiteneffekte bei der Generierung, gehört sie in Code. Geht es um Query-Erweiterung, plattformübergreifende Verifikation oder Rerouting nach einem Fehler, gehört sie zum Modell. Beide Fehlentscheidungen kosten dich. Eine Recherchehypothese im Client fest zu verdrahten, ist Over-Freezing – sobald sich die Plattform ändert, musst du wieder Code anfassen. Einen festen Ablauf jedes Mal vom Modell neu zusammensetzen zu lassen, ist Under-Freezing: Du sparst eine Modellentscheidung und kaufst dir dafür einen Haufen Instabilität ein.
Degradierung dem Modell überlassen: sechs Stage-Status
Damit die Orchestrierungsebene Entscheidungen treffen kann, müssen die Ergebnisse der unteren Ebene für das Modell lesbar sein. Ein undurchsichtiges Success-or-Failure-Boolean reicht nicht. Gib dem Modell nur success: false, und es kann beim nächsten Schritt lediglich raten.
Deshalb liefert jede Workflow-Stage einen Stage-Status statt eines Booleans. Davon gibt es sechs: completed, empty, ready, skipped, unavailable und blocked. Entscheidend sind vor allem die Unterschiede zwischen den Status, bei denen es nicht weiterging:
skippedbedeutet, dass der Operator diesen Schritt bewusst deaktiviert hat – etwa indem das Limit eines Collection-Pfads auf 0 gesetzt wurde. Das ist kein Fehler, und das Modell sollte keinen Retry versuchen.unavailablebedeutet, dass eine Abhängigkeit dieses Schritts vorübergehend nicht verfügbar ist, beispielsweise wegen eines Interface-Fehlers oder einer fehlenden Session. Das Modell kann den Schritt überspringen und fortfahren oder nach einer neuen Session fragen und später zurückkehren.blockedbedeutet, dass eine Voraussetzung nicht erfüllt ist, etwa weil die Recherche-Evidenz leer ist oder der Preflight fehlschlägt. Das Modell darf den nächsten Schritt nicht erzwingen, sondern sollte zurückgehen und die Evidenz ergänzen.
Nimm die Creative-Pipeline. Sie bewertet „Platform Preflight Ready“ und „Research Evidence Ready“ getrennt und bildet anschließend ready = platform_ready and research_ready. Fällt eines davon durch, gibt die Generierungs-Stage blocked zurück, ergänzt um eine blockers-Liste mit der konkreten Blockade. Wenn alle kommerziellen Suchergebnisse leer sind, wird der Generierungsjob schlicht nicht eingereicht.
Warum ist dieses Design für das Modell wichtig? Ein Orchestrierungsmodell, das seedance_generation: blocked zusammen mit blockers: [research_evidence_empty] liest, weiß, dass es zunächst Evidenz beschaffen muss, statt die Übermittlung erneut zu versuchen. Bei organic_discovery: skipped erkennt es die Nutzerabsicht statt eines Fehlers und lässt den Schritt in Ruhe. Eine als unavailable markierte Stage kann es gezielt umgehen. Sobald du „bewusst deaktiviert“, „vorübergehend nicht verfügbar“ und „Voraussetzung nicht erfüllt“ sauber trennst, kann das Modell den richtigen Degradierungspfad wählen. Wenn du alles in false zusammenfaltest, tritt selbst ein starkes Modell auf der Stelle.
Das passende Modell für jede Ebene
Der beschriebene Stack verlangt je nach Ebene völlig unterschiedliche Fähigkeiten von einem Modell. (Der Beitrag zum vierstufigen Reverse Engineering beschreibt dieselbe Vier-Ebenen-Tabelle im Reverse-Engineering-Kontext; hier wird sie auf den Agent-Stack übertragen.) Ordne Modelle nach Ebene zu, statt Kapazität zu verschwenden:
Aufgabe im Agent-Stack | Erforderliche Fähigkeit | Wahl | model id |
|---|---|---|---|
Das | Langer Kontext, liest den gesamten Katalog in einem Durchgang | Kimi K3 |
|
Orchestrierung: Stage-Status und Blocker lesen, Degradierung, Rerouting oder Fortsetzung entscheiden | Starkes Reasoning, trifft anhand des Status die richtige Entscheidung | Claude Opus 5 |
|
Modellfreundliche Tool-Beschreibungstexte aus Docstrings in großer Menge erzeugen | Günstig, bewältigt Hunderte Aufrufe mit hoher Parallelität | Claude Sonnet 5 |
|
Fehlerzuordnung bei Tool-Calls: Fehler und Deklaration lesen, Drift oder Upstream-Änderung unterscheiden | Mittleres Reasoning, erklärt anhand konkreter Felder | GPT-5.6 Sol |
|
Besonders wichtig ist die Orchestrierungsebene. Das Lesen von blocked und skipped, um den nächsten Schritt zu bestimmen, ist in diesem Ablauf der einzige Punkt, an dem ein Modellwechsel das Ergebnis sichtbar verändert. Denn genau hier wird geprüft, ob ein Modell anhand eines Status die richtige Entscheidung treffen kann. Ein schwächeres Modell behandelt skipped wie einen Fehler und versucht es erneut – oder sieht blocked und übermittelt trotzdem. Ein starkes Reasoning-Modell liest die blockers und routet präzise um. Das entspricht dem Unterschied, ob der Abschnitt mit Gegenbelegen in dem Beitrag zum Fingerprinting tatsächlich gegen die eigene These argumentiert: Kandidaten erzeugen kann jeder, schwierig ist das Urteil.
Du musst mir den Unterschied nicht glauben. Teste ihn:
Nimm eine echte Rückgabe aus einem deiner Workflows mit ihren
stagesundblockers– oder konstruiere eine Antwort mitblockedundblockers: [research_evidence_empty].Gib diese Antwort zusammen mit deinem Capability-Katalog, also dem
describe-JSON, und der Anweisung, die nächste Aktion zu bestimmen, getrennt anclaude-opus-5undgpt-5.6-sol.Achte auf genau einen Punkt: Trennt die vorgeschlagene nächste Aktion korrekt zwischen
blocked(zurück zur Evidenz),skipped(Nutzerabsicht, unverändert lassen) undunavailable(Session beschaffen oder umgehen)? Oder versucht das Modell,skippederneut auszuführen, als wäre es fehlgeschlagen?Der Anteil korrekter Degradierungspfade ist dein Auswahlkriterium. Er entscheidet, ob dein Agent bei einem echten Fehler im Kreis läuft oder selbstständig einen Weg daran vorbei findet.
Der eigentliche Haken: die Wechselkosten
Die vier Modelle stammen von drei Anbietern. Gerade bei Function Calling sind die Wechselkosten besonders hoch. OpenAIs tools / tool_calls und Anthropics tool_use / tool_result sind zwei unterschiedliche Formate. Wechselst du in der Orchestrierungsebene zu einem Modell mit besserem Urteilsvermögen, musst du den gesamten Tool-Dispatch- und Error-Parsing-Pfad neu schreiben. Das ist der eigentliche Grund, warum die meisten ein einzelnes Modell in der Orchestrierung festschreiben – selbst wenn es Stage-Status regelmäßig falsch interpretiert.
AIReiter nimmt dir diese Ebene ab. Ein Key, eine OpenAI-kompatible Schnittstelle, alle vier Modelle dahinter – und für den Wechsel änderst du nur das Feld model im Request-Body.
# Orchestration decision: hand the reasoning tier the catalog plus one blocked workflow response, ask for the next action
curl https://aireiter.com/api/v1/chat/completions \
-H "Authorization: Bearer $AIREITER_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "claude-opus-5",
"messages": [{"role": "user", "content": "<describe json> + <stages/blockers response> + decide the next action"}]
}'
# Generate tool-description text in bulk: change the model field, leave the rest
# "model": "claude-sonnet-5"
# Error attribution:
# "model": "gpt-5.6-sol"
Für natives Function Calling ergänzt du lediglich ein tools-Array. Das OpenAI-Tool-Protokoll wird unverändert durch diese Schnittstelle geleitet, daher bleibt der Modellwechsel eine Änderung in nur einem Feld. Wenn du bereits das OpenAI SDK nutzt, setze base_url auf https://aireiter.com/api/v1 und ändere sonst nichts. Beim Anthropic SDK verwendest du mit demselben Key POST /api/v1/messages.
Beim Preis liegen Claude-Modelle 30 % unter dem Listenpreis, GPT-Modelle bei der Hälfte, und Kimi K3 ist über denselben Key verfügbar. Für diesen Stack trifft der Rabatt genau die relevanten Stellen. Jeder Fortschritt der Orchestrierungsebene ist ein weiterer Aufruf der Reasoning-Ebene; damit ist sie die häufigste und teuerste Ebene im gesamten Agent. Der Claude-Rabatt greift genau dort. Die Tool-Beschreibungen aus 241 Docstrings in großer Menge zu generieren, ist parallelisierte Sonnet-Arbeit – ebenfalls rabattiert. Diese beiden Bereiche machen den Großteil der Kosten aus. Die GPT-5.6-Aufrufe für die Fehlerzuordnung sind deutlich seltener.
Ohne Anmeldung ausprobieren: Führe einige Runden manuell aus, gib beiden Modellen dieselbe
blocked-Antwort und prüfe selbst, welches korrekt degradiert, bevor du eines in die Orchestrierungsebene einbaust.
Fazit
Tool-Description Drift lässt sich nicht durch „Synchronisierung nicht vergessen“ heilen. Damit wird ein struktureller Fehler lediglich zur Frage persönlicher Disziplin umetikettiert. Die echte Lösung beseitigt die Zwei-Quellen-Struktur: Parser-Deklaration und Docstring sind die einzige Quelle, der Capability-Katalog ist ein daraus abgeleitetes Build-Artefakt, und eine CI-Assertion übernimmt die Typprüfung. Aus einem Runtime-Geist wird ein rotes X beim Commit.
Die Ableitung garantiert allerdings nur, dass die Beschreibung korrekt ist. Sie sagt nichts darüber aus, ob die Ebenen sinnvoll geschnitten sind. Welche Fähigkeiten du in Code einfrierst und welche du dem Modell zur Orchestrierung überlässt, sowie die sechs Stage-Status, mit denen das Modell „Retry oder Degradierung“ lesen kann, entscheiden darüber, ob dein Agent eigenständig laufen kann. Das Modell hat in diesem Stack zwei konkrete Aufgaben: Es trifft die Abwägung auf der Orchestrierungsebene und ordnet bei einem fehlerhaften Tool-Call die Ursache zu. Ob du eine Fähigkeit einfrierst und welchen Degradierungspfad du einschlägst, wird durch die Stage-Status und die CI entschieden, die du entwirfst – nicht durch das Modell.
Das ist dieselbe Haltung wie in den Beiträgen über set-basierte Migrationsabstimmung und über den Verzicht auf ein einheitliches Response Model: KI verkürzt die Dauer eines einzelnen Schritts, während das Urteil innerhalb der fest codierten Einschränkungen bleibt. Läuft alles reibungslos, bleibt nur noch der Modellwechsel als Reibungspunkt – ein Infrastrukturproblem, das eine einheitliche Schnittstelle löst.