KI-Workflows entwerfen, die in der Produktion funktionieren
Ein praktischer Leitfaden zum Aufbau robuster KI-Pipelines. Lerne Pipeline-Architektur, Schrittsequenzierung, Verzweigungslogik, Fehlerbehandlung und Prompt-Chaining von Ingenieuren, die diese Systeme ausgeliefert haben.
Warum die meisten KI-Workflows in der Produktion scheitern
Hier ist etwas, das wir auf die harte Tour gelernt haben: Eine KI dazu zu bringen, in einer Demo etwas Beeindruckendes zu machen, ist einfach. Sie dazu zu bringen, dasselbe zuverlässig zu tun, tausende Male am Tag, mit echten Daten und echten Grenzfällen? Da stoßen die meisten Teams an ihre Grenzen.
Wir haben KI-Workflows für Dokumentenverarbeitung, Kundenservice-Automatisierung, Content-Generierung und Datenanalyse gebaut. Dabei haben wir jeden denkbaren Fehler gemacht. Dieser Leitfaden ist das, was wir uns gewünscht hätten, dass uns jemand vorher gesagt hätte.
Der Unterschied zwischen einem Prototyp und einem Produktionssystem ist nicht das Modell, das du verwendest. Es ist alles drum herum.
Lass mich dir zeigen, wie wir heute tatsächlich KI-Workflows entwerfen, nachdem wir diese Lektionen gelernt haben.
Die Anatomie einer Produktions-KI-Pipeline
Stell dir einen KI-Workflow als eine Reihe von Verarbeitungsstufen vor, jede mit einer spezifischen Aufgabe. Hier ist die Grundstruktur, die wir verwenden:
Eingabe → Validierung → Vorverarbeitung → KI-Verarbeitung → Nachverarbeitung → Validierung → Ausgabe
↓ ↓ ↓ ↓ ↓
[Fehler] [Transformation] [LLM-Aufruf] [Parsen] [Qualitäts-
Handler + Anreicherung + Tools + Format prüfung]
Jede Stufe hat klare Ein- und Ausgaben sowie Fehlermodi. Lass sie uns aufschlüsseln.
Stufe 1: Eingabevalidierung und Normalisierung
Vertraue eingehenden Daten niemals. Niemals. Das haben wir gelernt, als ein Kunde uns ein "Textdokument" schickte, das eigentlich eine 50MB-Binärdatei war. Die Pipeline verschluckte sich, die Queue staute sich, und wir verbrachten ein Wochenende mit der Reparatur.
const validateInput = async (input) => {
const checks = {
exists: input !== null && input !== undefined,
sizeOk: Buffer.byteLength(input, 'utf8') < MAX_INPUT_SIZE,
formatOk: isValidFormat(input),
contentOk: !containsMaliciousPatterns(input)
};
const failures = Object.entries(checks)
.filter(([_, passed]) => !passed)
.map(([check]) => check);
if (failures.length > 0) {
throw new ValidationError(`Eingabe fehlgeschlagen: ${failures.join(', ')}`);
}
return normalizeInput(input);
};
Was du prüfen solltest:
- Dateigrößenlimits (setze sie niedriger als du denkst)
- Formatvalidierung (ist es wirklich JSON, nicht nur
.jsonbenannt?) - Zeichenkodierung (UTF-8-Probleme werden dich verfolgen)
- Content-Sicherheit (sende keine bösartigen Inhalte an dein LLM)
Stufe 2: Vorverarbeitung und Anreicherung
Rohe Eingaben gehen selten direkt an ein LLM. Normalerweise musst du sie transformieren, Kontext hinzufügen oder in Chunks aufteilen.
| Vorverarbeitungsaufgabe | Wann verwenden | Beispiel |
|---|---|---|
| Chunking | Lange Dokumente über Kontextlimits | 100-seitiges PDF in 2000-Token-Chunks aufteilen |
| Anreicherung | Zusätzlicher Kontext nötig | Kundenhistorie vor Support-Ticket-Verarbeitung hinzufügen |
| Extraktion | Nur Teile der Eingabe relevant | Nur das "description"-Feld aus JSON-Payload ziehen |
| Transformation | Formatkonvertierung nötig | HTML zu Markdown für saubere Verarbeitung konvertieren |
| Deduplizierung | Wiederholter Content verschwendet Tokens | Doppelte Absätze aus gescraptem Content entfernen |
Hier ist eine Chunking-Strategie, die wir tatsächlich verwenden:
const chunkDocument = (text, options = {}) => {
const {
maxTokens = 2000,
overlap = 200,
preserveParagraphs = true
} = options;
const chunks = [];
let currentChunk = '';
const paragraphs = text.split(/\n\n+/);
for (const para of paragraphs) {
const combined = currentChunk + '\n\n' + para;
if (estimateTokens(combined) > maxTokens) {
if (currentChunk) {
chunks.push(currentChunk.trim());
// Overlap für Kontextkontinuität behalten
currentChunk = getLastNTokens(currentChunk, overlap) + '\n\n' + para;
} else {
// Einzelner Absatz zu lang, erzwungener Split
chunks.push(...forceSplitParagraph(para, maxTokens));
currentChunk = '';
}
} else {
currentChunk = combined;
}
}
if (currentChunk.trim()) {
chunks.push(currentChunk.trim());
}
return chunks;
};
Der Overlap ist entscheidend. Ohne ihn gehen Informationen verloren, die Chunk-Grenzen überspannen. Das haben wir gelernt, als unser Dokumenten-Summarizer immer wieder wichtige Punkte verpasste, die zufällig zwischen Chunks fielen.
Den KI-Verarbeitungskern entwerfen
Hier passieren die eigentlichen LLM-Aufrufe. Aber ein einzelner Aufruf reicht selten für komplexe Aufgaben.
Das Prompt-Chain-Pattern
Anstatt eines riesigen Prompts, der alles versucht, teile komplexe Aufgaben in fokussierte Schritte auf:
[Verstehen] → [Planen] → [Ausführen] → [Verifizieren] → [Formatieren]
Beispiel: Kundenbeschwerde verarbeiten
const processComplaint = async (complaint) => {
// Schritt 1: Verstehen - Schlüsselinformationen extrahieren
const analysis = await llm.call({
system: `Extrahiere strukturierte Informationen aus Kundenbeschwerden.
Rückgabe als JSON mit: issue_type, urgency, customer_emotion, key_details`,
user: complaint
});
// Schritt 2: Planen - Antwortstrategie bestimmen
const strategy = await llm.call({
system: `Basierend auf dieser Beschwerdeanalyse, bestimme die Antwortstrategie.
Berücksichtige: Lösungsoptionen, Eskalationsbedarf, Kompensationsberechtigung`,
user: JSON.stringify(analysis)
});
// Schritt 3: Ausführen - Antwort generieren
const response = await llm.call({
system: `Schreibe eine Kundenantwort nach dieser Strategie.
Ton: empathisch, professionell. Länge: 2-3 Absätze.`,
user: `Strategie: ${JSON.stringify(strategy)}\nOriginale Beschwerde: ${complaint}`
});
// Schritt 4: Verifizieren - Qualitätsprüfung
const verification = await llm.call({
system: `Prüfe diese Kundenantwort. Checke:
- Adressiert alle Anliegen? - Angemessener Ton? - Umsetzbare nächste Schritte?
Rückgabe: {approved: boolean, issues: string[]}`,
user: response
});
if (!verification.approved) {
return await regenerateWithFeedback(response, verification.issues);
}
return response;
};
Jeder Schritt ist einfacher, testbarer und leichter zu debuggen. Wenn etwas schiefgeht, weißt du genau, welche Stufe fehlgeschlagen ist.
Wann verketten vs. wann parallelisieren
Nicht alles muss sequentiell sein. So entscheiden wir:
| Pattern | Verwenden wenn | Beispiel |
|---|---|---|
| Sequentielle Kette | Jeder Schritt hängt vom vorherigen Output ab | Verstehen → Planen → Ausführen |
| Parallele Ausführung | Schritte sind unabhängig | Mehrere Dokumente gleichzeitig analysieren |
| Map-Reduce | Items verarbeiten, dann aggregieren | Jeden Chunk zusammenfassen, dann Zusammenfassungen kombinieren |
| Bedingte Verzweigung | Verschiedene Pfade für verschiedene Eingaben | Einfache Anfragen vs. komplexe Analyse |
Beispiel parallele Ausführung:
const analyzeDocuments = async (documents) => {
// Alle Dokumente parallel verarbeiten
const analyses = await Promise.all(
documents.map(doc => analyzeDocument(doc))
);
// Reduce: In finalen Report kombinieren
const combinedReport = await llm.call({
system: 'Synthetisiere diese einzelnen Analysen zu einem kohärenten Bericht',
user: analyses.map((a, i) => `Dokument ${i + 1}:\n${a}`).join('\n\n---\n\n')
});
return combinedReport;
};
Verzweigungslogik: Zum richtigen Handler routen
Eingaben aus der realen Welt variieren stark. Eine "Kundennachricht" könnte eine Beschwerde, eine Frage, ein Lob oder Spam sein. Jede braucht unterschiedliche Behandlung.
Klassifizierungsbasiertes Routing
const routeCustomerMessage = async (message) => {
const classification = await llm.call({
system: `Klassifiziere diese Kundennachricht in genau eine Kategorie:
- complaint: Kunde drückt Unzufriedenheit aus
- question: Kunde sucht Informationen
- feedback: Allgemeines Feedback oder Vorschläge
- urgent: Sicherheitsprobleme, rechtliche Drohungen, Executive-Eskalation
- spam: Irrelevante oder automatisierte Nachrichten
Gib nur den Kategorienamen zurück.`,
user: message
});
const handlers = {
complaint: handleComplaint,
question: handleQuestion,
feedback: handleFeedback,
urgent: escalateToHuman,
spam: markAsSpam
};
const handler = handlers[classification.toLowerCase()] || handleUnknown;
return await handler(message);
};
Konfidenzbasiertes Routing
Manchmal ist die KI unsicher. Baue diese Unsicherheit in dein Routing ein:
const routeWithConfidence = async (input) => {
const result = await llm.call({
system: `Analysiere und route diese Anfrage. Rückgabe als JSON:
{
"category": "string",
"confidence": 0.0-1.0,
"reasoning": "warum diese Kategorie"
}`,
user: input
});
if (result.confidence < 0.7) {
// Niedrige Konfidenz - menschliche Eingabe holen oder Fallback nutzen
return await handleLowConfidence(input, result);
}
if (result.confidence < 0.9 && isHighStakes(result.category)) {
// Mittlere Konfidenz bei wichtigen Entscheidungen - verifizieren
return await verifyThenRoute(input, result);
}
// Hohe Konfidenz - automatisch fortfahren
return await routeToHandler(result.category, input);
};
Fehlerbehandlung: Weil Dinge kaputtgehen werden
KI-Workflows scheitern auf Arten, die traditionelle Software nicht kennt. Das LLM könnte ungültiges JSON zurückgeben, Informationen halluzinieren oder einfach... Anweisungen nicht befolgen. Plane dafür.
Die Fehler-Hierarchie
class AIWorkflowError extends Error {
constructor(message, stage, recoverable = true) {
super(message);
this.stage = stage;
this.recoverable = recoverable;
}
}
class ValidationError extends AIWorkflowError {
constructor(message) {
super(message, 'validation', true);
}
}
class LLMError extends AIWorkflowError {
constructor(message, type) {
super(message, 'llm_call', type !== 'rate_limit');
this.type = type; // 'timeout', 'rate_limit', 'invalid_response', 'refused'
}
}
class OutputError extends AIWorkflowError {
constructor(message) {
super(message, 'output', true);
}
}
Retry-Strategien, die funktionieren
Verschiedene Fehler brauchen verschiedene Retry-Ansätze:
| Fehlertyp | Retry-Strategie | Max. Retries | Backoff |
|---|---|---|---|
| Rate Limit | Warten und retry | 5 | Exponentiell mit Jitter |
| Timeout | Sofort retry | 3 | Linear |
| Ungültige Antwort | Retry mit Feedback | 2 | Keins |
| Modell verweigert | Umformulieren und retry | 2 | Keins |
| Server-Fehler | Warten und retry | 3 | Exponentiell |
const withRetry = async (operation, options = {}) => {
const {
maxRetries = 3,
backoffMs = 1000,
backoffMultiplier = 2,
shouldRetry = () => true
} = options;
let lastError;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await operation();
} catch (error) {
lastError = error;
if (!shouldRetry(error) || attempt === maxRetries) {
throw error;
}
const delay = backoffMs * Math.pow(backoffMultiplier, attempt);
const jitter = Math.random() * delay * 0.1;
await sleep(delay + jitter);
}
}
throw lastError;
};
// Verwendung mit LLM-Aufrufen
const safeLLMCall = async (params) => {
return await withRetry(
() => llm.call(params),
{
maxRetries: 3,
shouldRetry: (error) => {
if (error.type === 'rate_limit') return true;
if (error.type === 'timeout') return true;
if (error.type === 'server_error') return true;
return false;
}
}
);
};
Selbstheilung für ungültige Antworten
Wenn das LLM Müll zurückgibt, kannst du es manchmal reparieren:
const parseWithRecovery = async (llmResponse, expectedSchema) => {
// Erster Versuch: direktes Parsen
try {
const parsed = JSON.parse(llmResponse);
if (validateSchema(parsed, expectedSchema)) {
return parsed;
}
} catch (e) {
// JSON-Parse fehlgeschlagen, weiter zur Wiederherstellung
}
// Wiederherstellungsversuch: LLM bitten, seine Ausgabe zu reparieren
const fixed = await llm.call({
system: `Die folgende Antwort sollte gültiges JSON sein, das diesem Schema entspricht:
${JSON.stringify(expectedSchema)}
Repariere die Antwort zu gültigem JSON. Gib NUR das reparierte JSON zurück.`,
user: llmResponse
});
try {
const parsed = JSON.parse(fixed);
if (validateSchema(parsed, expectedSchema)) {
return parsed;
}
} catch (e) {
// Immer noch kaputt
}
// Letzter Fallback: extrahieren was geht
return extractPartialData(llmResponse, expectedSchema);
};
Monitoring und Observability
Du kannst nicht reparieren, was du nicht siehst. Hier ist, was wir tracken:
Schlüsselmetriken
| Metrik | Warum wichtig | Alert-Schwelle |
|---|---|---|
| Latenz (p50, p95, p99) | User Experience, Timeout-Risiko | p95 > 10s |
| Erfolgsrate | Gesamtgesundheit | < 95% |
| Token-Verbrauch | Kostenkontrolle | > 150% der Baseline |
| Retry-Rate | Versteckte Instabilität | > 10% |
| Klassifizierungsverteilung | Drift erkennen | Signifikante Abweichung von Baseline |
| Output-Qualitäts-Scores | Degradation erkennen | Durchschnitt < 0.8 |
Strukturiertes Logging
Jeder Workflow-Lauf sollte nachverfolgbare Logs produzieren:
const runWorkflow = async (input, context) => {
const runId = generateRunId();
const startTime = Date.now();
const log = (stage, data) => {
logger.info({
runId,
stage,
timestamp: Date.now(),
elapsed: Date.now() - startTime,
...data
});
};
try {
log('start', { inputSize: input.length });
const validated = await validate(input);
log('validated', { valid: true });
const processed = await process(validated);
log('processed', {
tokensUsed: processed.usage.total,
model: processed.model
});
const output = await format(processed);
log('complete', {
success: true,
outputSize: output.length,
totalTime: Date.now() - startTime
});
return output;
} catch (error) {
log('error', {
error: error.message,
stage: error.stage,
recoverable: error.recoverable
});
throw error;
}
};
Alles zusammenfügen: Ein vollständiges Beispiel
Bauen wir einen Dokumentenanalyse-Workflow, der alles verwendet, was wir besprochen haben:
class DocumentAnalysisPipeline {
constructor(options = {}) {
this.maxChunkSize = options.maxChunkSize || 3000;
this.concurrency = options.concurrency || 5;
}
async run(document) {
// Stufe 1: Validieren
const validated = await this.validate(document);
// Stufe 2: Vorverarbeiten
const chunks = await this.preprocess(validated);
// Stufe 3: Parallele Analyse mit Concurrency-Kontrolle
const analyses = await this.analyzeChunks(chunks);
// Stufe 4: Synthetisieren
const synthesis = await this.synthesize(analyses);
// Stufe 5: Qualitätsprüfung
const final = await this.qualityCheck(synthesis, document);
return final;
}
async validate(document) {
if (!document || typeof document !== 'string') {
throw new ValidationError('Dokument muss ein nicht-leerer String sein');
}
if (document.length > 1000000) {
throw new ValidationError('Dokument überschreitet maximale Größe');
}
return document;
}
async preprocess(document) {
return chunkDocument(document, {
maxTokens: this.maxChunkSize,
overlap: 200
});
}
async analyzeChunks(chunks) {
const results = [];
// In Batches verarbeiten für Concurrency-Kontrolle
for (let i = 0; i < chunks.length; i += this.concurrency) {
const batch = chunks.slice(i, i + this.concurrency);
const batchResults = await Promise.all(
batch.map((chunk, idx) => this.analyzeChunk(chunk, i + idx))
);
results.push(...batchResults);
}
return results;
}
async analyzeChunk(chunk, index) {
return await withRetry(async () => {
const result = await llm.call({
system: `Analysiere diesen Dokumentabschnitt. Extrahiere:
- Schlüsselthemen
- Wichtige Fakten und Zahlen
- Bemerkenswerte Zitate oder Aussagen
- Fragen oder Informationslücken
Rückgabe als strukturiertes JSON.`,
user: `Abschnitt ${index + 1}:\n\n${chunk}`
});
return parseWithRecovery(result, ANALYSIS_SCHEMA);
});
}
async synthesize(analyses) {
const combined = analyses.map((a, i) =>
`Abschnitt ${i + 1}:\n${JSON.stringify(a, null, 2)}`
).join('\n\n---\n\n');
return await llm.call({
system: `Synthetisiere diese Abschnittsanalysen zu einer umfassenden Dokumentzusammenfassung.
Struktur:
1. Executive Summary (2-3 Sätze)
2. Schlüsselerkenntnisse (Aufzählungspunkte)
3. Wichtige Details
4. Lücken oder Fragen
5. Empfehlungen`,
user: combined
});
}
async qualityCheck(synthesis, originalDocument) {
const check = await llm.call({
system: `Überprüfe diese Analyse auf Qualität. Prüfe:
- Genauigkeit: Spiegelt sie das Originaldokument wider?
- Vollständigkeit: Sind wichtige Punkte abgedeckt?
- Klarheit: Ist sie gut organisiert und klar?
Rückgabe: {score: 0-1, issues: string[], approved: boolean}`,
user: `Analyse:\n${synthesis}\n\nOriginal (erste 2000 Zeichen):\n${originalDocument.slice(0, 2000)}`
});
if (!check.approved) {
// Zur Überprüfung loggen, aber nicht fehlschlagen
logger.warn({ issues: check.issues, score: check.score });
}
return {
analysis: synthesis,
qualityScore: check.score,
qualityIssues: check.issues
};
}
}
Häufige Fallstricke und wie du sie vermeidest
Nach dem Bau dutzender dieser Systeme sind hier die Fehler, die wir am häufigsten sehen:
1. Keine Timeout-Grenzen
Jeder LLM-Aufruf braucht ein Timeout. Setze sie aggressiv.
// Schlecht: Kein Timeout
const result = await llm.call(params);
// Gut: Explizites Timeout
const result = await Promise.race([
llm.call(params),
new Promise((_, reject) =>
setTimeout(() => reject(new Error('Timeout')), 30000)
)
]);
2. Token-Limits ignorieren
Verbrauch tracken und Budgets setzen:
const tokenBudget = {
max: 10000,
used: 0,
canSpend(amount) {
return this.used + amount <= this.max;
},
spend(amount) {
this.used += amount;
if (this.used > this.max * 0.8) {
logger.warn('Token-Budget zu 80% verbraucht');
}
}
};
3. Kein Fallback für kritische Pfade
Hab immer einen Plan B:
const processWithFallback = async (input) => {
try {
return await primaryProcess(input);
} catch (error) {
if (error.recoverable) {
return await simplifiedProcess(input);
}
// Kritischer Pfad - für manuelle Bearbeitung einreihen
await queueForManualReview(input, error);
return { status: 'queued_for_review' };
}
};
Was kommt als nächstes
KI-Workflows werden immer ausgefeilter. Hier ist, wohin wir die Dinge gehen sehen:
- Bessere Tool-Nutzung: Modelle werden besser darin zu entscheiden, wann und wie externe Tools zu verwenden sind
- Längerer Kontext: Größere Kontextfenster bedeuten weniger Chunking-Kopfschmerzen
- Schnellere Inferenz: Latenz sinkt, ermöglicht komplexere Echtzeit-Workflows
- Spezialisierte Modelle: Fine-tuned Modelle für spezifische Aufgaben übertreffen Allzweck-Modelle
Aber die Grundlagen ändern sich nicht. Validiere Eingaben, behandle Fehler elegant, überwache alles und hab immer einen Fallback. Baue auf diesen Prinzipien auf, und deine KI-Workflows werden den Kontakt mit der realen Welt überstehen.
Wenn du KI-Workflows baust und an Wände stößt, haben wir dein Problem wahrscheinlich schon gesehen. Melde dich, und lass es uns gemeinsam lösen.
Topics covered
Ready to implement agentic AI?
Our team specializes in building production-ready AI systems. Let's discuss how we can help you leverage agentic AI for your enterprise.
Start a conversation