Der Tag, an dem ich aufhörte, Prompts zu schreiben
Wie vier Claude-Aufrufstellen, Monate auseinander gebaut, dasselbe Boilerplate dupliziert haben — und warum sie zu vereinheitlichen bedeutete, eine Grenze zu ziehen, die ich nicht verwischen wollte
KI-Systeme·Experte·8 Min. Lesezeit · 6. Juli 2026
Der Anfang
Die Foto-Anreicherung war die erste Claude-Aufrufstelle in dieser Codebasis: Vision ein Foto geben, Tags, Stimmung, Ort, eine Bildunterschrift zurückbekommen. Es funktionierte, also schrieb ich, als Monate später die Anschreiben-Idee auftauchte, ihren Claude-Aufruf auf dieselbe informelle Weise — eine Nachricht, ein JSON-Parse, etwas Validierung, fertig. Dann brauchte die CV-Zusammenfassungsgenerierung ihren eigenen Aufruf. Dann brauchte auch die Verhaltensprofil-Generierung einen. Vier Aufrufstellen, vier verschiedene Features, jede zu einem anderen Zeitpunkt im Leben der Plattform gebaut.
Die erste Lösung
Jede einzelne von Grund auf zu schreiben, fühlte sich jedes einzelne Mal vernünftig an, weil sich jede wie ein Einzelfall anfühlte. Fotografie brauchte Vision-spezifisches Prompting. Anschreiben brauchten Retry-Verhalten. CVs brauchten wieder etwas anderes. Es gab nie einen offensichtlichen Moment, an dem "schreib den Claude-Aufruf einfach inline, wie letztes Mal" aufhörte, die richtige Entscheidung zu sein — bis ich die vier Implementierungen tatsächlich nebeneinanderlegte.
Was kaputtging
Nebeneinandergelegt stellte sich heraus, dass drei der vier Aufrufstellen — Anschreiben, CV-Zusammenfassung und Verhaltensprofil — unabhängig voneinander genau dasselbe Muster implementiert hatten: createClaudeMessage → parseClaudeJson → enforceOutputContract → return. Nur die Foto-Anreicherung nutzte den extrahierten structuredGenerate<T>()-Wrapper; die anderen drei hatten ihn jeweils still neu erfunden.
Die Duplikation war nicht nur verschwendete Tastenanschläge. Es war inkonsistentes Verhalten ohne eine einzelne Ursache, die man beheben konnte. Die Fotografie trug eine versionierte Prompt-Konstante (PHOTO_VISION_PROMPT_VERSION), in der Datenbank persistiert; die anderen drei Domänen hatten Prompt-Text, der inline in der Generierungsdatei lag, ohne Version irgendwo. Die Fotografie erfasste vollständige Provenienz — Prompt-Version, Modellversion, Dauer, Token-Nutzung — über einen dedizierten AiProvenance-Typ; Anschreiben und CV-Zusammenfassung gaben model und usage ad hoc von callClaude() zurück, und das Verhaltensprofil gab überhaupt nichts zurück. Hätte ich beantworten müssen, "welche Prompt-Version hat diese CV-Zusammenfassung produziert", wäre die ehrliche Antwort für drei der vier Domänen gewesen: Ich konnte es nicht.
Dieselbe Art von Drift zeigte sich eine Ebene tiefer, in Infrastruktur, die nichts mit Prompts zu tun hatte. Drei unabhängige Vektor-DB-Verbindungsfabriken — je eine für Engineering-, CV- und Fotografie-Embeddings — implementierten dasselbe Lazy-Singleton-plus-PRAGMA-plus-Vec-Extension-Muster, unterschieden sich nur in den Variablennamen. Drei Hash-basierte Upsert-Funktionen führten denselben Hash-Check-dann-Embed-dann-Upsert-Tanz mit nahezu identischen Hash-Funktionen aus. Und drei Refresh-Jobs gaben drei unterschiedliche Stats-Formen für strukturell denselben Job zurück — und genau da versteckte sich das eigentliche Korrektheitsrisiko, nicht im Boilerplate selbst: Die Refresh-Funktion der Fotografie erfasste Fehler explizit (failed, failures), während die von Engineering und CV Fehler still verschluckten oder propagieren ließen, ohne ein äquivalentes Feld. Ich war davon ausgegangen, dass das ein Copy-Paste-Ordentlichkeitsproblem sei. Das war es nicht nur — zwei der drei Domänen hatten weniger Einblick in ihre eigenen Embedding-Fehler als die dritte, und nichts an der Duplikation machte das offensichtlich, bis ich alle drei nebeneinander gelesen hatte.
Die Entscheidung
Die verlockende Lösung war Disziplin: eine gedankliche Vorlage davon behalten, "wie eine Claude-Aufrufstelle aussehen sollte", und sie beim nächsten Mal treu kopieren. Das habe ich fast auf den ersten Blick verworfen, weil ich beim Schreiben der dritten Aufrufstelle die Details der ersten schon halb vergessen hatte, und eine Regel, die davon abhängt, sich über eine mehrmonatige Lücke hinweg richtig zu erinnern, ist keine Regel, sie ist eine Hoffnung.
Die andere verlockende Lösung war das genaue Gegenteil — ein gemeinsames Modul im Voraus entwerfen, bevor der Embedding-Code von Fotografie, CV und Engineering überhaupt existierte, und alles von Anfang an dazu passend machen. Das habe ich auch nicht getan, und ich glaube nicht, dass es funktioniert hätte: Die Extraktion geschah schrittweise, ein Verbraucher nach dem anderen, beginnend gerade mit der Fotografie, weil sie der einfachste, eigenständigste Fall war. Ich glaube nicht, dass ich die richtige gemeinsame Form korrekt hätte erraten können, ohne mindestens eine funktionierende, meinungsstarke Implementierung, aus der ich sie extrahieren konnte.
Was ich tatsächlich getan habe, war, structuredGenerate<T>() zu extrahieren — aufrufen, parsen, Vertrag durchsetzen, Provenienz anhängen, zurückgeben — als die eine wahre Version des Musters, das alle vier Aufrufstellen brauchten. Aber ich habe bewusst nicht alle vier darauf gezwungen. Die Anschreiben-Generierung fragt gezielt bei verbotener Formulierung oder Einstiegsregel-Verstößen erneut an und eskaliert bei jedem Retry mit dem genauen anstößigen Text — eine Form, die structuredGenerate() nicht hat, und ich habe entschieden, dass es sich nicht lohnt, dafür einen Retry-Hook hinzuzufügen: Die Retry-Logik ist domänenspezifisch genug, dass sie anzuflanschen entweder jeden anderen Aufrufer des gemeinsamen Wrappers verkomplizieren würde, oder eine anschreiben-förmige Ausweichmöglichkeit in eine Primitive einbauen würde, deren ganzer Wert darin liegt, einfach zu bleiben. Die CV-Zusammenfassungsgenerierung dagegen hatte genau einen kleinen Nachbearbeitungsschritt — eine generierte Zusammenfassung kürzen, ohne ihren erzählerischen Fluss zu brechen —, der sauber in einen generischen transformParsed-Hook passte, also migrierte sie. Zwei Aufrufer, zwei unterschiedliche Ergebnisse, entschieden danach, was jeder tatsächlich brauchte, nicht nach einer Regel, die sagt "alles migriert".
Die neue Architektur
Vier Claude-Aufrufstellen, ein gemeinsamer Mechanismus, wo er tatsächlich passt:
Foto-Anreicherung ──┐
CV-Zusammenfassung ─┼──▶ structuredGenerate<T>() (Aufruf → Parse → Vertrag → Provenienz)
│
Anschreiben ─┴──▶ manuelle Kette, dauerhaft
(Retry-mit-Eskalation bei Prompt-Verstößen)
Verhaltensprofil ────▶ manuelle Kette, noch nicht auf Migration geprüft
Unter der Aufrufstellen-Ebene förderte dieselbe Prüfung Embedding-Infrastruktur zutage, die tatsächlich gefahrlos vereinheitlicht werden konnte — ein einziges generateEmbedding()/generateEmbeddings(), ein findNearestVector()/knnRetrieve()-Paar, eine createVecDb()-Fabrik, ein applyDiversity() — weil dieser Code, anders als die Retry-Schleife, tatsächlich dreimal dasselbe tat, ohne einen domänenspezifischen Grund für die Unterschiede.
Was ich gelernt habe
Auch die eigene Retrieval-Pipeline von Engineering Behavior — sechs ineinandergreifende Stufen aus Entscheidungsbeförderung, semantischem Re-Ranking und Diversitätsdeckelung, alle tief spezifisch für Engineering-Decision-Konzepte — wurde für die gemeinsame Schicht geprüft und explizit ausgeschlossen. Sie zu abstrahieren hätte Komplexität hinzugefügt, ohne echte Duplikation zu entfernen, denn unter der oberflächlichen Ähnlichkeit zu CV- und Fotografie-Retrieval erledigt sie eigentlich nicht denselben Job. Das ist dieselbe Lektion wie die Anschreiben-Retry-Schleife, nur aus der anderen Richtung: Manchmal sind zwei Dinge, die aus der Distanz gleich aussehen, tatsächlich gleich, und manchmal nicht, und der einzige Weg, das herauszufinden, ist, beide Implementierungen tatsächlich zu lesen, bevor man entscheidet.
Ich habe auch gelernt, meiner eigenen ersten Einschätzung "das sieht dupliziert aus" nicht zu trauen. Die Vektor-DB-Fabriken waren tatsächlich kosmetische Duplikation — dieselbe Logik, andere Namen. Die Refresh-Job-Stats-Formen sahen auch kosmetisch aus, bis ich bemerkte, dass dieser "kosmetische" Unterschied tatsächlich zwei Domänen war, die still den Einblick in ihre eigenen Embedding-Fehler verloren, den eine dritte Domäne bereits gelöst hatte. Eine Duplikationsprüfung, die bei "das sieht gleich aus" aufhört, kann direkt an einer echten Verhaltenslücke vorbeigehen, die eine Zeile weiter unten sitzt.
Der Blick nach vorn
Nichts davon hat berührt, was tatsächlich in einen Prompt hineingeht. structuredGenerate() hat standardisiert, wie ein Claude-Aufruf geformt ist — geparst, validiert, versioniert, verfolgt —, aber der Prompt-Text selbst, und die Daten, die darin zusammengestellt werden, werden immer noch pro Domäne von Hand geschrieben, und nichts an dieser Extraktion schränkt ein, was diese Daten sind oder wie viel davon es gibt. Zu lösen, wie ein Aufruf gebaut wird, war notwendig. Es war nicht dasselbe Problem wie zu entscheiden, was dieser Aufruf sehen darf.
Fazit
Ich habe aufgehört, Prompts von Grund auf zu schreiben, nicht weil Prompts klüger wurden, sondern weil ich endlich vier Implementierungen nebeneinandergelegt habe und sah, dass drei davon derselbe Code waren, der andere Variablennamen trug — und dass die eine Stelle, an der sie sich wirklich unterschieden, die Retry-Logik, genau die Stelle war, die ich hätte in Ruhe lassen sollen, statt sie aus Prinzip zu vereinheitlichen.