Schluss damit, dass LLMs HTML schreiben: semantische JSON-Komponenten für KI-Reports
Warum ein Team KI-generierte Reports von rohem HTML auf ein begrenztes JSON-Vokabular umgestellt hat, das deterministischer Code rendert, und warum semantische Komponenten Layout-Primitive schlagen.
Ein Sprachmodell das finale HTML für das eigene Produkt schreiben zu lassen, ist ein guter Anfang und eine schlechte Dauerlösung. Nehmen wir ein Team, das KI-generierte Reports mit Kundeninsights baut. Die erste Version war schnell gemacht: Analyse an ein LLM geben, HTML anfordern. Ein paar Monate später hatte die Pipeline Dutzende Schritte, riesige Mengen generiertes Markup und Styling und Prompts, mit denen selbst ihre Autoren kaum noch zurechtkamen.
Ein besserer Prompt hat das nicht gelöst, ein größeres Modell auch nicht. Was funktionierte: dem Modell Freiheit wegnehmen. Es lieferte nicht mehr die Oberfläche, sondern strukturiertes JSON aus einem kleinen Satz semantischer Komponenten, und das Rendering übernahm ganz normaler Code. Das Ergebnis ist vorhersehbarer, günstiger im Betrieb und leichter weiterzuentwickeln.
Warum es überhaupt Reports gab
Das Produkt begann als Chat. Leute öffneten die App, fragten nach ihrem Kundenfeedback, und das Modell grub passende Insights aus. Technisch funktionierte das. In der Praxis lastete es viel auf den Nutzern: Sie mussten wissen, was sie fragen sollten, welche Daten es gab, wie man eine brauchbare Frage formuliert und welche Themen Aufmerksamkeit verdienten. Power-User kamen klar. Viele andere blieben hängen, und das lag nicht an den Antworten. Sie wussten schlicht nicht, was sie fragen sollten.
Also wechselte das Team von Pull zu Push. Ein Data Owner definiert eine Analyse einmal, und die Ergebnisse werden automatisch zugestellt. Zuerst per E-Mail, was für kurze wöchentliche Zusammenfassungen gut passte.
Ärger gab es, als die Analysen umfangreicher wurden. Lange Mails lassen sich schlecht überfliegen, Tabellen brechen auf kleinen Bildschirmen, und ausführliche Abschnitte lassen eine Nachricht überladen wirken. Ab einem gewissen Punkt ist E-Mail der falsche Container.
Daher HTML-Reports. Die Mail wurde zur Benachrichtigung, der Report zum Ziel. Eine Webseite bietet Platz für Abschnitte, Charts, Karten, Rankings und Details, und sie lässt sich leicht teilen. Die Pipeline war einfach:
Customer data
│
▼
AI analysis workflow
│
▼
Generated HTML + CSS
│
▼
Hosted report
│
▼
Email with link
Für die ersten Reports klappte das erstaunlich gut.
Rohes HTML ist ein toller Prototyp
Die erste Implementierung war schlicht: dem Modell Daten übergeben, ein Beispiel für das gewünschte Ergebnis zeigen und HTML anfordern. Am Anfang ist das verlockend. Modelle schreiben gutes HTML, und Infrastruktur braucht man kaum. Ein Layout ändern, einen Abschnitt ergänzen oder einen neuen Kartentyp ausprobieren heißt oft, einen Prompt zu bearbeiten statt Frontend-Komponenten zu bauen. Für kleine Reports war diese Flexibilität wertvoll.
Mit dem Wachstum zeigte sich die Schwäche. Ein großer Report-Workflow kam auf rund 40 Schritte. Etwa 30 holten oder bereiteten Daten auf, und ungefähr 10 existierten nur, um HTML zu generieren und zusammenzusetzen. Ein einzelner Report konnte Hunderte Kilobyte Markup und CSS enthalten.
In dieser Größenordnung verstärkten sich die Probleme gegenseitig. Das Modell musste einer komplizierten visuellen Spezifikation folgen, riesige Antworten schreiben und über viele Abschnitte hinweg konsistent bleiben. Winzige Prompt-Änderungen verschoben Layouts auf unerwartete Weise, und die schiere Menge an generiertem Markup machte alles unnötig teuer.
Der naheliegende Workaround war, die Seite in kleinere Jobs zu zerlegen:
Analysis
│
├── Generate sections 1–2 ─┐
├── Generate sections 3–4 ─┤
├── Generate sections 5–6 ─┼──► Combine HTML ─► Report
├── Generate sections 7–8 ─┤
└── Generate sections 9–10 ┘
Das behob die unmittelbaren Probleme auf Modellseite. Die Aufrufe wurden kleiner, es gab weniger Kontext zum Durchdenken, und Output-Limits waren keine Gefahr mehr.
Dafür entstand ein neues Problem. Jetzt musste jemand wissen, welcher Prompt welchen Teil der Seite erzeugt, wie die Fragmente zusammenpassen, welche Styles wohin gehören und wie sich eine Änderung an einer Stelle auf die anderen auswirkt. Einen Report zu pflegen hieß, eine verteilte HTML-Fabrik aus Prompts zu verstehen.
Es war kein Prompt-Problem
Es wäre leicht gewesen, das als Prompting-Thema zu behandeln: präzisere Anweisungen, bessere Beispiele, ein neueres Modell für konsistenteres Markup. Nichts davon hätte die eigentliche Ursache berührt, nämlich die Abstraktionsebene.
Das eine Extrem gibt dem Modell völlige Freiheit, beliebiges HTML und CSS auszugeben:
Data → LLM → arbitrary HTML/CSS
Das ist maximale Flexibilität bei nahezu null Vorhersehbarkeit.
Das andere Extrem ist ein festes Template, in das das Modell nur Inhalte einfüllt:
Data → LLM → fixed fields → hard-coded template
Das ist effizient und vorhersehbar, passt aber ebenfalls nicht, weil verschiedene Reports wirklich unterschiedliche Strukturen brauchen. Einer hat fünf Abschnitte, ein anderer zehn. Manche wollen Charts, manche Rankings, manche langen Text, manche Raster mit quantitativen Insights.
Gebraucht wurde etwas dazwischen. Das Modell sollte einen Report zusammenstellen, ohne ihn implementieren zu müssen. Diese Unterscheidung prägte das neue Design.
Was man von E-Mail und Slack lernen kann
Dasselbe Problem gibt es auch außerhalb von KI. E-Mail-Entwickler leben seit Jahren damit. Verlässliches HTML für Mails ist berüchtigt schwierig, weil Mailclients unterschiedliche und oft uralte Teilmengen von HTML und CSS unterstützen. Frameworks wie MJML antworten darauf mit einer Sprache auf höherer Ebene. Man beschreibt die Struktur, und das Framework erzeugt das hässliche Markup:
<mj-section>
<mj-column>
<mj-text>Hello world</mj-text>
</mj-column>
</mj-section>
Slacks Block Kit folgt einer ähnlichen Idee. Apps schicken Slack kein rohes HTML. Sie schicken strukturiertes JSON, das Abschnitte, Text, Bilder, Buttons und andere Elemente beschreibt, und Slack entscheidet, wie sie aussehen.
Das kam dem nahe, was das Team brauchte. Statt das Modell um so etwas zu bitten:
Create a blue card with a 24px title, two chips below it,
the percentage on the right and 16px padding.
wollten sie, dass es so etwas erzeugt:
{
"type": "QuantifiedInsight",
"title": "Delivery complaints increased",
"memoryCount": 184,
"share": 0.27
}
Die Verantwortung verschiebt sich komplett. Das Modell beschreibt, was der Inhalt bedeutet, und der Renderer kümmert sich um die visuelle Umsetzung.
Warum JSON zu Modellen passt
Als Nächstes stellte sich die Frage, wie die Report-Sprache aussehen sollte. Ein XML-Format im Stil von MJML wäre möglich gewesen, aber JSON gewann, und zwar weniger aus Entwicklergeschmack als wegen der Modelle. Aktuelle LLM-APIs sind sehr gut darin, strukturiertes JSON auszugeben, und JSON Schema kann die erwartete Form präzise beschreiben. Das liefert einen klaren Vertrag und erlaubt, den Output zu validieren, bevor irgendetwas gerendert wird.
Eine vereinfachte Definition der Komponente von oben:
{
"type": "object",
"properties": {
"type": {
"const": "QuantifiedInsight"
},
"title": {
"type": "string"
},
"memoryCount": {
"type": "integer",
"minimum": 0
},
"share": {
"type": "number",
"minimum": 0,
"maximum": 1
}
},
"required": [
"type",
"title",
"memoryCount",
"share"
]
}
Freiheit bleibt, aber sie ist begrenzt. Um diese Art von Insight zu erzeugen, muss das Modell einen Titel, eine Anzahl und einen Prozentwert liefern, wobei die Anzahl nicht negativ sein darf und der Prozentwert zwischen null und eins liegen muss.
Früher lief der Prompt auf „hier sind die Daten und ein paar Designtipps, jetzt bau die Seite“ hinaus. Der neue lautet: „Hier sind die Daten, und hier sind die Bausteine, die du verwenden darfst; beschreib den Report damit.“ So ein System lässt sich viel leichter steuern.
Adaptive Cards als Grundlage
Das ganze Format von Grund auf zu erfinden, war wenig reizvoll. Slacks Block Kit war eine nützliche Referenz, gehört aber zu Slacks Ökosystem. Microsofts Adaptive Cards passten besser: ein offenes, deklaratives JSON-Modell mit vorhandenen Konzepten für Text, Container, Spalten, Tabellen und Bilder, dazu Rendering-Bibliotheken, auf denen man aufbauen kann. Vor allem ließ es sich um eigene Elemente auf höherer Ebene erweitern.
Die Schichten sahen am Ende so aus:
AI-controlled
│
▼
Customer data ─► Analysis ─► Report JSON
│
─────────────────────────────┼────────────────────────────
│
Code-controlled
▼
Validation
│
▼
Deterministic renderer
│
▼
HTML
Diese Linie ist die zentrale Designentscheidung. Alles oberhalb davon darf generativ sein. Alles darunter sollte deterministisch sein: identisches JSON muss jedes Mal ein identisches Ergebnis liefern.
Generische Primitive waren immer noch zu locker
Anfangs bekam das Modell ziemlich generische UI-Bausteine. Es konnte einen Container anlegen, Text hinzufügen, Spalten einfügen und Elemente darin platzieren. Technisch funktionierte das.
Bald merkte das Team aber, dass es das alte Problem nur eine Ebene höher nachgebaut hatte. Wenn das Modell weiterhin über die Spaltenzahl, die Position eines Prozentwerts, die Reihenfolge der Chips und die Anordnung der Teile entscheidet, hat Inkonsistenz reichlich Spielraum. Ein Report kann scheitern, obwohl sein JSON völlig valide ist.
Stell dir eine Karte vor, die ein Insight mit absoluter Anzahl und Prozentwert zeigt. Eine generische Version könnte so aussehen:
{
"type": "InsightCard",
"chips": [
{
"label": "27%",
"style": "secondary"
},
{
"label": "184 memories",
"style": "primary"
}
]
}
Das Modell könnte die Chips vertauschen, einen doppelt ausgeben, den falschen Stil wählen oder einen ganz weglassen. Keine dieser Möglichkeiten bringt Nutzern irgendetwas.
Also wurde die Komponente semantisch:
{
"type": "QuantifiedInsight",
"title": "Delivery complaints increased",
"memoryCount": 184,
"share": 0.27
}
Das Array beliebiger Chips ist weg. Es gibt jetzt eine Anzahl von Erwähnungen und einen Anteil, und der Renderer weiß, wie und in welcher Reihenfolge er sie darstellt.
Dieser kleine Schritt bringt eine wichtige Lektion auf den Punkt: Semantische Komponenten schlagen generische Layout-Primitive. Ein ColumnSet sagt, wie etwas angeordnet ist. Ein QuantifiedInsight sagt, was etwas bedeutet. Je näher das Vokabular an der Sprache des Produkts liegt, desto weniger Implementierungsarbeit bleibt für das Modell übrig.
Rendering wird zu ganz normaler Software
Ein netter Nebeneffekt: Rendering ist kein KI-Problem mehr. Eine vereinfachte TypeScript-Version:
function renderElement(element: ReportElement) {
switch (element.type) {
case 'QuantifiedInsight':
return renderQuantifiedInsight({
title: element.title,
count: element.memoryCount,
percentage: element.share,
});
case 'Timeline':
return renderTimeline(element);
case 'Chart':
return renderChart(element);
default:
return renderStandardAdaptiveCardElement(element);
}
}
Eine echte Implementierung kann darunter weiterhin Adaptive Cards nutzen, wobei das eigene Element letztlich in Container, Textblöcke und Spalten übersetzt wird. Das Modell muss davon nichts wissen.
So entsteht eine saubere Abstraktionsgrenze. Brauchen später alle quantifizierten Insights andere Abstände, Schriften oder Farben, ändert man den Renderer einmal. Man muss nicht mehrere Prompts anpassen und hoffen, dass das Modell die neue Anweisung jedes Mal befolgt. Das System verhält sich wieder wie normale Software.
Das Vokabular gestalten
Eine Designfrage bleibt: Wie flexibel sollte jede Komponente sein? Ein schlechtes Extrem ist eine einzige Komponente mit Dutzenden optionaler Properties:
{
"type": "EverythingCard",
"icon": "...",
"rank": 1,
"percentage": 0.27,
"image": "...",
"timeline": true,
"compact": false
}
Das andere ist eine brandneue Komponente für jede winzige visuelle Variante.
Die Abwägung ähnelt dem Entwurf von React-Komponenten. Manchmal sind zwei Ansichten eine Komponente mit ein paar Props. Manchmal sind es verschiedene Konzepte, die getrennt bleiben sollten. Die Bedeutung ist der beste Schiedsrichter: Vermitteln zwei Blöcke dieselbe Idee, reicht vielleicht eine eingeschränkte Property; vermitteln sie Verschiedenes, verdienen sie wahrscheinlich eigene Komponenten. Ziel sind nicht möglichst wenige Komponenten, sondern ein Vokabular, das das Modell zuverlässig nutzen kann.
Weniger Freiheit, mehr nützliche Flexibilität
Das klingt zunächst paradox. Das Modell kann kein beliebiges HTML oder CSS mehr erzeugen, nicht frei entscheiden, wie jedes Element angeordnet wird, und manche Komponenten haben eine Form, die es nicht ändern kann. Trotzdem ist das ganze System flexibler geworden.
Der Grund: Nützliche Flexibilität ist etwas anderes als Implementierungsfreiheit. Nutzern ist wichtig, ob ein Report die richtigen Abschnitte, Insights, Daten und visuellen Formen aufnehmen kann. Ob ein Prozentwert aus einem <div> oder drei verschachtelten <span>-Elementen besteht, ist ihnen egal. Diese Art Freiheit bringt Variabilität und keinen Mehrwert.
Nachdem sie weg war, konnte man dem Modell gefahrlos mehr Mitsprache bei Entscheidungen geben, die zählen: welche Abschnitte relevant sind, welche Komponenten ein Insight am besten ausdrücken, welcher Inhalt wohin gehört und wie viele Komponenten ein Abschnitt braucht. Was es nicht kann, ist die Implementierung jeder Komponente bei jedem Lauf neu zu erfinden. Das ist eine gesündere Aufgabenteilung.
Das Muster lässt sich verallgemeinern
Die Lektion handelt eigentlich nicht von HTML. Sie handelt davon, wo generative KI in ein Softwaresystem gehört.
Für einen Prototyp ist es stark, das Modell das finale Artefakt ausgeben zu lassen. Man kommt schnell voran, spart sich Infrastruktur und lernt, was das Produkt braucht. In dieser Phase war rohe Generierung richtig. Schwierig wird es, wenn der Prototyp Teil eines wiederholbaren Systems wird, weil direkte Generierung dann schwerer zu kontrollieren ist, als sie wert ist. Ein guter nächster Schritt ist, das Modell in der Architektur eine Ebene höher anzusiedeln:
- Lass es nicht die Oberfläche bauen. Lass es die Oberfläche beschreiben.
- Lass es nicht das finale Dokument zusammensetzen. Lass es die semantische Struktur des Dokuments liefern.
- Lass es keine Details auf unterer Ebene festlegen. Gib ihm eine kleine Sprache für genau die Entscheidungen, die es treffen soll.
Das allgemeine Muster:
Natural language
│
▼
AI
│
▼
Small semantic language
│
▼
Deterministic software
│
▼
Final artifact
Sobald man so arbeitet, sind viele Fragen kein Prompt Engineering mehr, sondern wieder Softwaredesign. Welche Konzepte soll das Modell ausdrücken können? Wo wird Flexibilität wirklich gebraucht? Was kann weg? Wie kann das Schema ungültige oder unerwünschte Zustände ausschließen? Wo verläuft die Grenze zwischen Modell- und Code-Entscheidungen? Das sind vertraute Engineering-Probleme, und generative KI schafft sie nicht ab. Sie macht die Wahl guter Abstraktionen nur noch wichtiger.
Wie es weitergeht
Das Vokabular wird noch verfeinert. Der erste Report ist auf den neuen Ansatz umgezogen und wird mit dem Original verglichen, bevor weitere Workflows folgen. Das schrittweise Tempo ist gewollt: Ein Komponentensystem, das um einen einzigen Report herum gebaut wurde, ist vermutlich überangepasst, während eines, das über mehrere echte Reports hinweg funktioniert, allmählich zur Plattform wird. Manche Komponenten werden wohl verschwinden, andere einfacher werden, und neue semantische Elemente kommen hinzu.
Die Richtung ist aber klar. Das Modell entscheidet, was gesagt wird und welche Bausteine dafür zum Einsatz kommen. Die Software legt genau fest, wie sich diese Bausteine verhalten und gerendert werden. Diese Grenze hat ein wachsendes Knäuel aus HTML-Prompts in etwas verwandelt, das gewöhnlicher Software nahekommt. Eine gute Faustregel: Setz das Modell für Entscheidungen ein, die Intelligenz brauchen, und Code für Entscheidungen, die Gewissheit brauchen.