Zurück zum Blog

Vibe Engineering statt Vibe Coding: warum Agenten-Pipelines Komplexität verstecken

Wenn ein Agent ein Feature nicht in einer Session bauen kann, ist nicht das Modell zu schwach, sondern der Entwurf zu kompliziert.

Harro Krog8 Min. Lesezeit

Ich schreibe keine Issues für Agenten. Ich sage ihnen, was zu tun ist, und schaue mir an, was dabei herauskommt.

Das klingt nach einem Detail über Arbeitsweise. Tatsächlich ist es der Unterschied zwischen einem Produkt, das ich verstehe, und einem, das mir gehört, ohne dass ich weiß, was darin steht.

Die Regel, an der ich Entwürfe messe

Jede Software, die ich schreibe, sollte so einfach sein, dass ein Agent sie in einer Session bauen kann, wenn ich ihm explizit sage, wie das System aussehen soll.

Geht das nicht, ist das kein Modellproblem. Dann ist der Entwurf zu kompliziert.

Das ist derselbe Gedanke, der hinter dem Law of Demeter steht: Ein komplexes Produkt entsteht aus Teilen, die für sich genommen einfach sind. Vor Agenten war das eine Geschmacksfrage, über die man in Reviews gestritten hat. Jetzt ist es messbar. Die Sessiongrenze sagt dir innerhalb einer Stunde, ob dein Entwurf trägt, statt in einem halben Jahr.

Was Pipelines tatsächlich einsparen

Das verbreitete Gegenmodell geht andersherum: Issues schreiben, eine Pipeline darüberlaufen lassen, Rollen verteilen — ein Agent plant, einer entwirft, einer schreibt Code, einer prüft. Die Blätter des Baums bekommen kleinere Modelle, das senkt die Kosten. Auf einem Diagramm sieht das nach einer Fabrik aus.

Nur: ein Issue so zu formulieren, dass ein Agent es korrekt umsetzt, kostet ungefähr so viel Zeit, wie es ihm direkt zu sagen. Die Ersparnis liegt nicht im Schreiben. Sie liegt darin, dass danach niemand mehr hinschaut.

Das ist der eigentliche Tausch: Prozess gegen Verständnis. Der Preis fällt nicht sofort an, sondern nach zwanzig oder fünfzig abgearbeiteten Issues.

Der Vertrag, der eine Session überhaupt möglich macht

Damit ein Agent ein Feature in einem Durchlauf bauen kann, darf er unterwegs keine Grundsatzfragen beantworten müssen. Genau dafür liegt in meinem Website-Repository eine CLAUDE.md, und darin stehen keine Stilregeln, sondern Entscheidungen:

**Key rules**: - Import internal links from `@/i18n/navigation`, never `next/link`. - Add new routes to `routing.pathnames` in `src/i18n/routing.ts` — links are typed against it. - Never hardcode user-facing strings in components; add keys to both message files (`en.json` is the type source, so shapes must match). - Call `setRequestLocale(locale)` at the top of every page and layout. - Never hand-edit `content/posts/**` — those files are generated from the Obsidian vault and a manual edit is lost on the next publish.

Jede dieser Zeilen entfernt eine Entscheidung aus dem Lauf. Ohne die erste Regel wählt der Agent bei jeder neuen Seite zwischen zwei Link-Komponenten und trifft die Wahl mal so, mal so — je nachdem, welche Datei er zuletzt gelesen hat. Ohne die dritte landet deutscher Text in einer Komponente, weil es an dieser Stelle kürzer war. Ohne die fünfte schreibt er in eine generierte Datei, und die Änderung ist beim nächsten Veröffentlichen weg.

Keine dieser Fehlentscheidungen wäre ein Fehler im Sinne eines Tests. Jede einzelne wäre plausibel.

Dazu kommt eine Karte der Codebase unter docs/CODEBASE_MAP.md, einmal erzeugt und eingecheckt, mit Architektur, Datenflüssen und den Stellen, an denen etwas anders ist, als man erwarten würde. Der Agent muss sich das Repository nicht bei jedem Lauf neu erschließen, und ich muss es nicht bei jedem Lauf neu erklären.

Das ist der ganze Aufwand vorne: eine Vertragsdatei und eine Karte. Beides schreibt man einmal und pflegt es, wenn sich eine Entscheidung ändert.

Wie eine Session aussieht

Ein Beispiel aus derselben Codebase, das öffentlich nachvollziehbar ist:

feat(blog): add table of contents, reading progress, and sharing app/[locale]/blog/[slug]/page.tsx | 145 ++++++++++++++++---- app/globals.css | 18 ++++ content-collections.ts | 48 +++++++- messages/de.json | 11 +- messages/en.json | 11 +- src/components/blog/reading-progress.tsx | 58 ++++++++++ src/components/blog/share-links.tsx | 89 ++++++++++++++ src/components/blog/table-of-contents.tsx | 170 ++++++++++++++++++++++ src/lib/heading-slugs.ts | 172 ++++++++++++++++++++++ src/lib/remark-article-headings.ts | 59 ++++++++++ 10 files changed, 756 insertions(+), 25 deletions(-)

Zehn Dateien, dreiviertel tausend Zeilen, eine Einheit. Kein Ticketstapel, sondern ein Auftrag, in dem vor dem Lauf drei Dinge festgelegt waren:

  1. Woher die Überschriften-Ids kommen — aus einem Modul, das beide Seiten benutzen, nicht aus zwei Implementierungen, die zufällig dasselbe tun.
  2. Was das Inhaltsverzeichnis auf schmalen Bildschirmen ist — dieselbe Liste, zwei Darstellungen, weil die Seite an einer Stelle eine Spalte daneben frei hat und an der anderen nicht.
  3. Wo die Beschriftungen leben — in beiden Sprachdateien, nicht im Bauteil.

Das sind keine Anforderungen an das Feature. Das sind Entscheidungen über das System, in dem das Feature liegt. Sie zu treffen ist meine Arbeit und dauert Minuten. Sie nicht zu treffen kostet später Tage.

Verdrahtet sieht das am Ende so aus, in content-collections.ts:

transform: async (document, context) => { const mdx = await compileMDX(context, document, { remarkPlugins: [ remarkGfm, remarkCodeMeta, [remarkArticleHeadings, { title: document.title }], ], rehypePlugins: [rehypeHeadingSlugs], }); const [locale, ...rest] = document._meta.path.split("/"); return { ...document, locale, slug: rest.join("/"), mdx, headings: extractHeadings(document.content, document.title), ...estimateReadingMinutes(document.content), }; },

Zwei Zeilen in diesem Block sehen aus, als gehörten sie zusammen, und tun es bewusst nicht: compileMDX erzeugt das gerenderte Dokument samt Überschriften-Ids, extractHeadings erzeugt die Liste für das Inhaltsverzeichnis. Warum das getrennt bleiben muss, ist der interessanteste Teil der Session.

Die Entscheidung, die kein Test gefunden hätte

Der interessanteste Teil dieser Session steht heute als Kommentar in src/lib/heading-slugs.ts:

/** * Heading ids for article pages. * * The table of contents and the rendered headings have to agree on every id, or * a link in the sidebar scrolls nowhere. Rather than have one of them depend on * the other, both derive their ids from the same pure slugger walking headings * in document order: `rehypeHeadingSlugs` stamps them onto the compiled HTML, * `extractHeadings` reads the same list out of the Markdown source. * * They are deliberately independent. Collecting the list as a side effect of * the MDX compile looks tidier and is wrong: content-collections caches compiled * bodies, and on a cache hit the plugins never run, so the page would render * with heading ids and an empty table of contents. */

Der naheliegende Weg wäre gewesen, die Überschriftenliste beim Kompilieren des MDX einzusammeln. Ein Durchlauf, eine Quelle, weniger Code. Genau das hätte fast jeder Agent vorgeschlagen, und ich hätte es fast genommen.

Es ist trotzdem falsch, und zwar aus einem Grund, der im Feature selbst nicht vorkommt: Die Inhaltsbibliothek legt kompilierte Beiträge in einen Cache. Bei einem Treffer laufen die Plugins nicht. Der Artikel bekommt seine Ids also aus dem Cache, die Liste für das Verzeichnis wird aber nie erzeugt.

Wie sich dieser Fehler anfühlt:

  • Lokal beim ersten Bauen ist alles korrekt, weil der Cache kalt ist.
  • Der zweite Build zeigt einen Artikel mit Überschriften und ein leeres Inhaltsverzeichnis.
  • Die Tests bleiben grün, weil der Code genau das tut, was dort steht.
  • Ein Prüfagent findet nichts, weil es nichts zu finden gibt.

Die Lösung sind zwei bewusst voneinander unabhängige Wege über eine gemeinsame Regel. Die Regel selbst ist zehn Zeilen lang:

/** Two headings with the same words still need two ids: the second gets "-2". */ export function createHeadingSlugger() { const counts = new Map<string, number>(); return (text: string) => { const base = slugifyHeading(text); const count = counts.get(base) ?? 0; counts.set(base, count + 1); return count === 0 ? base : `${base}-${count + 1}`; }; }

Beide Seiten rufen genau diese Funktion auf. Der Rehype-Durchlauf stempelt die Ids auf das kompilierte HTML:

/** Rehype plugin: stamps the matching id on every h2/h3. */ export function rehypeHeadingSlugs() { return (tree: any) => { const slug = createHeadingSlugger(); const walk = (node: any) => { // ... h2/h3 heraussuchen, Text einsammeln node.properties.id = node.properties.id || slug(title); for (const child of node.children ?? []) walk(child); }; walk(tree); }; }

Und die Liste für das Verzeichnis entsteht unabhängig davon aus dem Markdown, in derselben Lesereihenfolge und mit demselben Slugger:

export function extractHeadings(content: string, title: string): Heading[] { const withoutCode = content.replace(/^([ \t]*)(```|~~~)[\s\S]*?\n\1\2.*$/gm, ""); // ... Überschriftenzeilen parsen, Titel-Dublette entfernen, Ebenen schieben const slug = createHeadingSlugger(); return found .filter((heading) => TOC_LEVELS.includes(heading.level)) .map((heading) => ({ ...heading, id: slug(heading.title) })); }

Der erste Ausdruck in dieser Funktion ist auch so eine Entscheidung: Fenced Code fliegt raus, bevor irgendetwas geparst wird. Ein Kommentar in einem bash-Block beginnt mit # und ist keine Überschrift. Wer das vergisst, bekommt ein Inhaltsverzeichnis, in dem # Install dependencies als Kapitel steht — und auch das ist kein Fehler, den ein Test findet, sondern eine Regel, die jemand kennen muss.

Doppelter Durchlauf also, dafür eine gemeinsame Wahrheit über die Ids.

Vier Module hängen inzwischen an dieser einen Datei — der Kompilierschritt, die Überschriften-Umformung, das Inhaltsverzeichnis und der FAQ-Block. Alle vier benutzen denselben Slugger. Es gibt genau einen Ort, an dem sich die Regel ändern lässt.

Das ist das Muster, um das es in diesem ganzen Text geht: Der teuerste Fehler ist keiner. Er ist eine Entscheidung, die niemand getroffen hat, und dagegen hilft keine Pipeline, weil jede Stufe darin für sich genommen richtig gearbeitet hat.

Zwei Wahrheiten über denselben Zustand

Dasselbe Muster in klein, aus derselben Codebase: Die Beiträge unter content/posts/** werden aus einem Obsidian-Vault erzeugt. Der Vault ist die Quelle, das Repository ist Ziel.

Diese Regel steht an drei Stellen — in der CLAUDE.md, in docs/CONTENT_SOURCE.md und in der Kopfzeile jeder generierten Datei — und sie ist die einzige Regel im Projekt, die dreifach dokumentiert ist. Der Grund: Wer sie verletzt, bekommt keinen Fehler. Er bekommt einen korrigierten Text, der beim nächsten Veröffentlichen verschwindet, und die Ursache ist dann drei Tage alt.

Wenn ein Zustand an zwei Orten lebt, ist es keine Frage, ob sie auseinanderlaufen, sondern wann. In einer handgeschriebenen Codebase merkt man es, weil man beide Stellen selbst geschrieben hat. In einer über Issues gewachsenen merkt man es nicht, weil Issue 31 nichts von Issue 12 weiß.

Was mit Agenten sehr wohl funktioniert

Prüfen und aufräumen. Beides ist überprüfbar, und Agenten sind darin gut und geduldig:

  • Tests laufen lassen, die Anwendung durchklicken, Regressionen finden.
  • Toten Code entfernen, wenn klar definiert ist, was tot heißt.
  • Eine Wiederholung, die an fünf Stellen steht, in ein gemeinsames Bauteil ziehen.
  • Einen Commit in saubere Einheiten zerlegen, statt alles als „save" abzulegen.

Der letzte Punkt ist bei mir sichtbar: Der frühe Teil meiner Repository-Historie besteht aus save, save, esave. Der spätere aus feat(blog): …, refactor(sections): share the accordion row chrome, chore: remove dead code. Zwischen beiden liegt kein besseres Modell, sondern die Entscheidung, dass ein Commit eine Einheit ist.

Der Fehler ist nie die Delegation der Arbeit. Es ist die Delegation der Entscheidung.

Das Symptom, an dem man es von außen sieht

Feature-Hölle. Ein Produkt, das im Kern ein Web-Dashboard ist, bekommt zusätzlich Desktop- und Mobile-Anwendungen, weil das Bauen nichts mehr kostet und irgendein Kunde die Frage einmal gestellt hat.

Wenn Bauen billig wird, wird die teuerste Entscheidung, was nicht gebaut wird. Ein falsches Feature war früher durch seine Bauzeit gebremst. Heute ist es am Freitag fertig, und die Wartung dafür bleibt.

Was ich mache, wenn eine Session danebengeht

Ich lese nicht den Diff Zeile für Zeile. Ich stelle mir eine Frage: Kann ich in zwei Sätzen sagen, was sich am System geändert hat und warum?

Wenn nein, war der Auftrag schlecht, nicht der Lauf. Dann werfe ich das Ergebnis weg und starte neu mit den Entscheidungen, die vorher gefehlt haben. Das fühlt sich teuer an und ist die billigste Stelle im ganzen Ablauf — teurer wird es erst, wenn das Ergebnis bleibt und die fehlende Entscheidung mit ihm.

Was dieser Text nicht belegt

Ich habe keine Zahlen. Ich vergleiche nicht zwei Teams, die dasselbe Produkt einmal mit und einmal ohne Pipeline gebaut haben, und diesen Vergleich hat meines Wissens auch sonst niemand sauber angestellt. Was ich habe, ist meine eigene Codebase und eine Handvoll fremder, in die ich hineingesehen habe.

Was mich überzeugen würde: eine Codebase, die über sechs Monate ausschließlich über eine Issue-Pipeline gewachsen ist, und in der jemand ein neues Konzept quer durch das Datenmodell ziehen kann, ohne vorher drei Tage zu lesen. Wenn du so eine hast, zeig sie mir.

Baust du gerade etwas, bei dem der Entwurf wichtiger ist als das Tempo? 30 Minuten, unverbindlich: Termin buchen

Häufige Fragen

Was ist der Unterschied zwischen Vibe Coding und Vibe Engineering?

Vibe Coding heißt, ein Ergebnis zu beschreiben und das System dem Agenten zu überlassen. Vibe Engineering heißt, den Entwurf vorher selbst zu machen — Datenmodell, Schnittstellen, Zuständigkeiten, Quelle der Wahrheit — und den Agenten die Umsetzung schreiben zu lassen. In beiden Fällen tippt der Agent. Der Unterschied liegt darin, wer entscheidet, wie das System aussieht.

Warum ist eine Agenten-Pipeline mit Issues problematisch?

Weil der Aufwand, ein Issue präzise zu formulieren, ungefähr dem Aufwand entspricht, es dem Agenten direkt zu sagen. Gespart wird also kaum Zeit, aufgegeben wird dagegen der Blick auf das Ergebnis. Nach zwanzig abgearbeiteten Issues existiert ein Produkt, das niemand mehr vollständig im Kopf hat, und dieser Zustand ist teurer zu reparieren als jeder einzelne Fehler darin.

Sind Agenten, die ihre eigene Arbeit prüfen, sinnvoll?

Ja, das ist der Teil, der zuverlässig funktioniert. Tests laufen lassen, die Anwendung durchklicken, Regressionen finden, toten Code entfernen, eine Wiederholung in eine gemeinsame Komponente ziehen. Das sind überprüfbare Aussagen über den Ist-Zustand. Was Prüfagenten nicht leisten, sind Aussagen über den Entwurf. Ein falsches Datenmodell besteht jeden Test, weil es keinen Fehler enthält, sondern eine Entscheidung.

Woran erkenne ich, dass mein Entwurf zu kompliziert ist?

An der Sessiongrenze. Wenn ein Feature nicht in einem zusammenhängenden Durchlauf gebaut und danach verstanden werden kann, hat es zu viele Abhängigkeiten. Das ist derselbe Gedanke wie beim Law of Demeter: Ein komplexes Produkt soll aus Teilen bestehen, die einzeln einfach sind. Diese Eigenschaft war vor Agenten wünschenswert und ist jetzt messbar.

Was gehört in eine CLAUDE.md, damit ein Agent in einer Session fertig wird?

Entscheidungen, keine Stilregeln. Wo die Quelle der Wahrheit für jede Art von Inhalt liegt, welche Importe verboten sind und warum, welche Dateien generiert werden und deshalb nicht von Hand bearbeitet werden dürfen, und welcher Aufruf in jedem Seitenmodul stehen muss. Jede Zeile darin ist eine Frage, die der Agent sonst zur Laufzeit selbst beantworten müsste — und zwar jedes Mal anders.

Teilen
Termin buchen