Entwicklung eines selbstkorrigierenden Dokumentations-Agenten mit LangGraph und Ollama
Ein lokaler Reflexionsprozess, bei dem ein Modell Python-API-Dokumente erstellt, ein zweites sie bewertet und ein drittes das Ergebnis abgleicht – als LangGraph-Schleife auf Ollama implementiert.
Quellen: Build a Self-Correcting AI Agent with LangGraph and Ollama
Das Muster wird Reflexion genannt. Seine Grundidee ist, dass große Sprachmodelle Fehler in Texten weitaus besser erkennen können als fehlerfreien Text auf einen Schlag zu verfassen, weshalb die Arbeit in verschiedene Rollen aufgeteilt wird. Ein wichtiger Punkt: Jede Rolle verwendet ein anderes lokales Modell. Ein Modell, das gleichzeitig schreibt und bewertet, neigt dazu, seine eigene Ausgabe durchzulassen, während ein separater Kritiker dies vermeidet.
Zuvor argumentierten Agenten, die ohne Framework von Grund auf entwickelt wurden, dass Tools wie LangGraph erst dann nützlich werden, wenn man eine vereinfachte Version manuell erstellt hat. Dieses Mal ist LangGraph eine bewusste Wahl, da ein Überarbeitungslauf mit bedingten Abbrüchen genau der zyklische Arbeitsablauf ist, für den es konzipiert wurde. Ollama stellt die Modelle bereit. Standardmäßig wird qwen3.5:9b als Generator, deepseek-coder:6.7b als Kritiker und llama3:8b als Entscheider verwendet, doch eine kleine JSON-Datei weist die Rollen zu, sodass auch beliebige lokal heruntergeladene Modelle verwendet werden können.
Das Ergebnis ist ein einziger Konsole-Befehl, reflexion. Er liest eine Quelldatei ein, führt die Schleife aus, gibt für jede Runde eine Leistungsübersicht aus und schreibt den formatierten Markdown auf die Festplatte. Die gesamte Pipeline passt in eine einzige Python-Datei mit etwa 360 Zeilen.
Die Implementierung besteht aus sieben Komponenten: dem Zustandsmodell des Arbeitsablaufs sowie dem Bewertungsschema des Kritikers, dem Generator-Knoten, dem Kritiker-Knoten, dem Router, der entscheidet, ob in einer Schleife weitergemacht oder beendet wird, dem Adjudicator-Knoten, der Verkabelung des Graphen sowie schließlich dem Laufzeitmodul mit Konfiguration und Bereinigung der Ausgabe.
Einrichtung
Der Code befindet sich in einem Repository namens local-reflexion-agent als ein Paket, src/reflexion/main.py, das über pyproject.toml als Konsole-Skript bereitgestellt wird. Klonen und im editierbaren Modus installieren:
git clone https://github.com/jfjensen/local-reflexion-agent.git
cd local-reflexion-agent
pip install -e .
Dadurch werden langgraph, langchain-core, langchain-community, langchain-ollama und pydantic eingebunden, und ein Konsole-Skript mit dem Namen reflexion wird registriert. Bestätigen Sie anschließend, dass Ollama lokal läuft, und laden Sie die drei in config.json aufgeführten Modelle herunter:
ollama pull qwen3.5:9b
ollama pull deepseek-coder:6.7b
ollama pull llama3:8b
Verschiedene Modelle sind in Ordnung. Die Rollen werden im config.json am Wurzelverzeichnis des Repositoriums festgelegt, was im Abschnitt zur Laufzeit weiter unten erläutert wird. Das Repositorium enthält drei kleine Beispielfächer in input/, sodass der Pipeline sofort ausgeführt werden kann:
reflexion --input input/source_function_a.py --output output/result.md --config config.json
Teil 1: Der Zustand und das Schema des Kritikers
Zuerst kommen zwei Datensätze. Einer definiert, was der Kritiker zurückgeben muss, und der andere definiert den Speicher, der durch das Graphen fließt.
Die Ausgabe des Kritikers ist die interessantere. Wenn man ein lokales 6,7-Billionen-Modell um eine Bewertung bittet, antwortet es fröhlich mit einem Absatz Prosa. Es werden jedoch Zahlen sowie eine umsetzbare Korrektur benötigt, weshalb ein Pydantic-Schema das Modell dazu zwingt, feste Felder auszufüllen:
class DocEvaluationSchema(BaseModel):
"""Pydantic schema used to force a structured JSON schema constraint out of our local critic model.
Deliberately contains only judgment fields: the overall average and the pass/fail decision are computed in code."""
technical_accuracy: int = Field(description="Score from 0-100 on accuracy.")
completeness: int = Field(description="Score from 0-100 covering parameter definitions.")
single_critical_fix: str = Field(description="The single absolute highest priority correction required.")
Mit einigen Hinweisen zum Schema:
- Das Feld
single_critical_fixist das Herzstück der Schleife. Anstelle einer Liste aller Fehler nennt der Kritiker die wichtigste Korrektur in höchster Priorität. Ein kleiner Generator kommt mit der Anweisung „Korrigiere genau diese eine Sache“ viel besser zurecht als mit einer Flut an gemischten Rückmeldungen. - Was weggelassen wird, ist genauso wichtig wie das, was enthalten ist. Eine frühere Version bat das Modell außerdem um einen Gesamtwert in Prozent sowie ein Bestehen/Fehlern-Flag, was sich als Fehler herausstellte – dazu mehr unter „Kritiker“. Kurz gesagt: Lassen Sie niemals ein Modell Rechenoperationen oder Schwellenwertvergleiche durchführen, die Python in einer einzigen Zeile bewältigen kann. Das Schema enthält nur jene Bewertungen, die tatsächlich vom Modell benötigt werden, während der Kritiker-Node die Durchschnittsberechnung sowie die Entscheidung für Bestehen oder Fehlern in reinem Code vornimmt.
Field-Beschreibungen nicht als reine Dekoration. Sie gelangen über die JSON-Schema-Beschränkungen zum Modell und fungieren als kleine Anweisungen.Danach kommt der Zustand. LangGraph überträgt ein Zustandsobjekt von Knoten zu Knoten, und jeder Knoten antwortet mit einem teilweisen Update desselben. Ein TypedDict definiert seine Struktur:
def append_scores(old: List[int], new: List[int]) -> List[int]:
return old + new
class AgentWorkflowState(TypedDict):
"""Memory tracking state for the heterogeneous local graph layout."""
source_code: str
generated_markdown: str
current_score: int
score_history: Annotated[List[int], append_scores]
required_fix: Optional[str]
iteration_count: int
is_approved: bool
final_adjudicated_markdown: Optional[str]
Die einzige nicht offensichtliche Zeile ist score_history. Der Typ Annotated[List[int], append_scores] weist LangGraph an, Aktualisierungen dieses Feldes mit dem Reducer append_scores zu kombinieren, anstatt sie zu überschreiben. Jede von der Kritikinstanz zurückgegebene Bewertung wird daher der Historie hinzugefügt. Am Ende einer Ausführung steht die vollständige Bewertungskurve über alle Überarbeitungsrunden zur Verfügung, was zeigt, dass es sich um die informativste Zeile handelt, die der Pipeline ausgibt.
Teil 2: Der Generator-Node
Ein LangGraph-Node ist nichts anderes als eine Funktion: Der Zustand wird eingegeben und ein Dictionary mit Aktualisierungen ausgegeben. Der Generator verfügt über zwei Modi. Im ersten Durchlauf wird die Dokumentation direkt aus dem Code erstellt. Jeder nachfolgende Durchlauf überarbeitet den vorherigen Entwurf entsprechend den Korrekturen der Kritikinstanz.
def content_generator_node(state: AgentWorkflowState) -> dict:
"""The Generator node: Focuses entirely on drafting/editing documentation text layout."""
current_iteration = state.get("iteration_count", 0)
target_model = MODEL_REGISTRY["GENERATOR"]
# ... console logging trimmed, see the repo for the full version
llm = ChatOllama(model=target_model, temperature=0.7)
if not state.get("generated_markdown"):
system_prompt = "You are an expert technical writer. Convert raw code snippets into clean, comprehensive API markdown documentation."
user_prompt = f"Generate Markdown documentation for the following source code:\n\n{state['source_code']}"
else:
system_prompt = "You are a precise editor rewriting technical documentation to fix a structural flaw."
user_prompt = (
f"Your previous documentation draft was scored {state['current_score']}/100.\n"
f"You must completely rewrite the documentation to fix this specific issue: {state['required_fix']}\n\n"
f"Original Code Context:\n{state['source_code']}\n\n"
f"Previous Draft:\n{state['generated_markdown']}"
)
messages = [SystemMessage(content=system_prompt), HumanMessage(content=user_prompt)]
response = llm.invoke(messages)
return {
"generated_markdown": response.content,
"iteration_count": current_iteration + 1
}
Was der Code tut:
Wahl des Modus.** Wenn generated_markdown leer ist, bedeutet das die erste Runde – in diesem Fall agiert das Modell als technischer Redakteur, der von der Rohquelle ausgeht. Liegt bereits ein Entwurf vor, wird das Modell als Editor eingesetzt und erhält die vorherige Bewertung, die einzige Korrektur des Kritikers, die ursprüngliche Quelle sowie seinen eigenen vorherigen Entwurf.
Die Anweisung zur Überarbeitung.** Dem Modell mitzuteilen, welche Bewertung es zuvor erhalten hat, ist ein kleiner Trick, der funktioniert. Eine konkrete Angabe wie „Ihre Bewertung beträgt 78/100“ motiviert es dazu, Dinge grundlegend zu ändern, anstatt nur leicht umzuformulieren. Die Anweisung fordert eine vollständige Neufassung, um ein spezifisches Problem zu beheben – solch präzise Anweisungen eignen sich hervorragend für kleine lokale Modelle.
Temperatur.** Der Generator arbeitet mit temperature=0.7, was den Entwürfen zwischen den Runden etwas Variabilität verleiht. Der Kritiker hingegen arbeitet mit einer Temperatur von null.
Der Rückgabewert. Nur die geänderten Felder werden zurückgegeben. LangGraph fügt sie in den Zustand ein, und iteration_count steigt pro Generationsrunde um eins an.
Teil 3: Der Critic-Node
Es handelt sich dabei um ein anderes Modell – standardmäßig deepseek-coder:6.7b – und es dauerte mehr Versuche, seine Prompt-Eingabe richtig zu gestalten, als bei allen anderen Modellen in dieser Version.
def content_critic_node(state: AgentWorkflowState) -> dict:
"""The Critic node: Cross-checks generator output using a different model with strict JSON formatting."""
target_model = MODEL_REGISTRY.get("CRITIC")
llm = ChatOllama(model=target_model, temperature=0.0)
structured_llm = llm.with_structured_output(DocEvaluationSchema)
system_prompt = (
f"You are a meticulous QA inspector evaluating API documentation against its source code. "
f"You are grading the DOCUMENTATION, not the source code. Never deduct points because the source code "
f"itself could be designed better, and never ask for the source code to be changed. "
f"Score by deduction: start from 100 and subtract points only for concrete errors you can verify by "
f"pointing at specific documentation text that contradicts the source code. "
f"Check that every 'raise' statement in the source is documented with its exact exception type and its "
f"exact quoted error message: a missing, denied, or misquoted exception is a major deduction. "
f"Recompute every numeric value shown in the documentation's examples against what the source code would "
f"actually return: a wrong number is a major deduction. "
f"If you find no verifiable errors, score 100. "
f"The single_critical_fix must point at the documentation text to change, not at the source code."
)
user_prompt = (
f"Evaluate the following markdown documentation relative to its raw source code.\n\n"
f"Raw Source Code:\n{state['source_code']}\n\n"
f"Generated Markdown Document:\n{state['generated_markdown']}"
)
evaluation: DocEvaluationSchema = structured_llm.invoke([
SystemMessage(content=system_prompt),
HumanMessage(content=user_prompt)
])
# The model provides the judgment scores; the arithmetic and the pass/fail decision are done here in code.
overall_score = round((evaluation.technical_accuracy + evaluation.completeness) / 2)
is_approved = overall_score >= QUALITY_SCORE_BAR
required_fix = sanitize_critical_fix(evaluation.single_critical_fix)
return {
"current_score": overall_score,
"score_history": [overall_score],
"required_fix": required_fix,
"is_approved": is_approved
}
Gestrukturierte Ausgabe. llm.with_structured_output(DocEvaluationSchema) ist die entscheidende Zeile. Sie beschränkt das Modell so, dass stattdessen ein validiertes DocEvaluationSchema-Objekt zurückgegeben wird und nicht freier Text. Das bedeutet keinen JSON-Parser, keine regulären Ausdrücke und keine Wiederholungslogik für fehlerhafte Ausgaben. Der Bewertungsprozess läuft mit temperature=0.0, denn eine Bewertungsmaschine sollte deterministisch sein oder zumindest so nah daran wie ein lokales Modell es erreichen kann.
Wie sich der Prompt entwickelt hat. Er durchlief drei Versionen, und die Historie ist vermutlich die wertvollste Lektion in diesem Zusammenhang.
- In der ersten Version wurde das Modell höflich gebeten, die Dokumentation zu analysieren und eine Bewertung zu geben. Das Ergebnis waren höfliche Bewertungswerte im Hochachtziger- bis Neunzigerbereich für Dokumente mit erfundenen Fehlernachrichten, weshalb der Prozess zu früh beendet wurde.
Halten Sie die Arithmetik außerhalb des Modells. Das erste Schema ließ das Modell einen Gesamtwert in Prozent sowie ein eigenes boolesches Ergebnis für Bestanden/Fehlgeschlagen erzeugen, wobei die Ausgaben ungewöhnlich waren. Einmal gab der Bewertende der technischen Genauigkeit 100 und der Vollständigkeit ebenfalls 100 und berichtete anschließend von einem „netto-Durchschnitt“ von genau 95 – was lediglich der in der Anweisung genannte Bestehensschwelle entsprach und kein Durchschnitt irgendwelcher Werte war. Ein anderes Mal erhielt ein Entwurf 97 Punkte gegenüber einer Schwelle von 95 in vier aufeinanderfolgenden Runden, wurde jedoch jedes Mal als fehlgeschlagen markiert. Alle vier Versuche gingen an Entwürfen vorbei, die bereits qualifiziert waren, weil der Router dem booleschen Ergebnis Glauben schenkte und das Protokoll ruhig festhielt, dass 97 unter der Schwelle von 95 lag.
Heute liefert das Modell nur die beiden Untergesamtpunktzahlen. Drei Zeilen Python erledigen den Rest: Sie berechnen den Durchschnitt, vergleichen ihn mit QUALITY_SCORE_BAR und setzen das Flag, dem der Router folgt. Der Vergleich einer Zahl mit einem Schwellenwert ist keine Frage des Urteils, und alles, was keine Frage des Urteils ist, sollte nicht an ein Modell delegiert werden.
Der sanitize_critical_fix-Schutzmechanismus. Während derselben vier-Runden-Testreihe brach die Rückmeldung des Kritikers in einer Runde in mehrere hundert wiederholte ../-Token zusammen, und diese wurden wortwörtlich in die nächste Anfrage des Generators als zu behebendes Problem eingefügt. Kleine Modelle, die unter Einschränkungen bezüglich der Struktur der Ausgabe arbeiten, verhalten sich gelegentlich so. Der Schutzmechanismus prüft, ob die Korrektur leer oder degeneriert ist (ein einziges wiederholtes Token oder Text, der nur aus wenigen unterschiedlichen Zeichen besteht), und wechselt bei positivem Ergebnis zu einer allgemeinen Anfrage, um die Dokumentation im Vergleich zur Quelle erneut zu überprüfen. Er ist nicht besonders ausgefeilt, verhindert aber, dass Unsinn eine ganze Runde beeinflusst. Die gesamte Funktion ist kurz:
def sanitize_critical_fix(fix: Optional[str]) -> str:
"""Guards against degenerate critic feedback (empty strings or runaway token repetition)."""
stripped = (fix or "").strip()
words = stripped.split()
is_degenerate = (
not stripped
or len(set(stripped)) <= 4
or (len(words) > 5 and len(set(words)) <= 2)
)
if is_degenerate:
return "Re-verify the documentation line by line against the source code and correct the weakest section."
return stripped
Jede Runde wird die Punktzahl als Liste mit einem einzigen Element zurückgegeben, und der zuvor definierte State-Reducer fügt sie der Historie hinzu. So entsteht allmählich die Kurve. Der Knoten gibt außerdem eine Rundenzusammenfassung aus, die die technische Genauigkeit, Vollständigkeit, die Gesamtpunktzahl im Vergleich zur Zielmarke sowie die Rückmeldungen des Kritikers anzeigt. Diese Ausgaben sind zwar aus dem Auszug entfernt, befinden sich aber im Repository; dem Verlauf der Punktzahlen von Runde zu Runde zu folgen, macht einen großen Teil des Spaßes aus.
Teil 4: Der Router
Generator und Kritiker sind nun fertig, daher muss nun etwas entscheiden, was nach jeder Kritik folgt. In LangGraph übernimmt diese Aufgabe eine Routing-Funktion, die den Zustand liest und den Namen des nächsten Knotens zurückgibt.
def execution_router(state: AgentWorkflowState) -> Literal["content_generator", "content_adjudicator"]:
"""Monitors workflow states and determines whether to repeat, loop, or step out to adjudication."""
if state["is_approved"]:
return "content_adjudicator"
if state["iteration_count"] >= MAX_ATTEMPTS:
return "content_adjudicator"
return "content_generator"
Hinweise zum Router:
- Der Schleifenprozess hat nur zwei Ausgänge: Entweder genehmigt der Kritiker den Entwurf oder wird die Versuchsgrenze erreicht. In jedem Fall gelangt der Entwurf zum Entscheider; in allen anderen Fällen wird er an den Generierer zurückgesendet.
- Diese Grenze verdient mehr Respekt, als es auf den ersten Blick scheint. Bei einer 95-Punkte-Marke und einem strengen Kritiker werden bestimmte Entwürfe niemals genehmigt, und ohne
MAX_ATTEMPTSwürde die Schleife Ollama unbegrenzt aufrufen. Vier Versuche sind eine willkürliche, aber praktikable Zahl, die geändert werden kann. - Der
Literal-Rückgabetyp geht über reine Dokumentation hinaus, da er mit der beim Aufbau des Graphen definierten Routing-Karte übereinstimmt. - Ebenso wie bei den anderen Knoten werden Konsoleausgaben aus dem Auszug entfernt. Im Repository protokolliert der Router die Anzahl der Versuche sowie die Punktzahlentwicklung vor jeder Entscheidung.
Teil 5: Der Entscheider-Knoten
Alles, was den Zyklus verlässt – egal ob es bestanden hat oder die Anzahl der Versuche erschöpft ist – erhält noch einen letzten Prüfversuch durch ein drittes Modell. Die Aufgabe des Urteilsmoduls besteht nicht darin, Inhalte hinzuzufügen. Es beseitigt Widersprüche, die sich im Laufe der Überarbeitungsrunden eingestellt haben, und entfernt alles, was konversationell klingt.
def content_adjudicator_node(state: AgentWorkflowState) -> dict:
"""The Adjudicator node: Runs outside the loop to resolve structural contradictions and finalize text."""
target_model = MODEL_REGISTRY["ADJUDICATOR"]
llm = ChatOllama(model=target_model, temperature=0.2)
system_prompt = (
"You are a senior technical documentation editor giving the final word on an API document "
"that has been through multiple revision rounds. Your job is to read the raw source code, "
"look at the latest draft, eliminate any conflicting statements, clean up semantic prose, and output the polished final markdown document. "
"Do not include any preamble, comments, conversational pleasantries, or explanations-return ONLY the markdown documentation."
)
user_prompt = (
f"Review and finalize this documentation template.\n\n"
f"Raw Source Reference Code:\n{state['source_code']}\n\n"
f"Latest Revision Draft:\n{state['generated_markdown']}\n\n"
f"Last Remaining Critic Concern:\n\"{state['required_fix']}\""
)
response = llm.invoke([SystemMessage(content=system_prompt), HumanMessage(content=user_prompt)])
return {"final_adjudicated_markdown": response.content}
Warum der Knoten existiert. Nach mehreren Runden des „vollständigen Umschreibens, um X zu beheben“, kann ein Entwurf interne Widersprüche enthalten: In einer Runde wird eine Aussage hinzugefügt, und eine spätere Umschreibung widerspricht ihr, ohne sie zu entfernen. Der Prüfer erhält den ursprünglichen Text, den neuesten Entwurf sowie die letzte offene Kritik des Evaluators und erhält die Anweisung, widersprüchliche Aussagen zu beseitigen. Zudem dient er als Ausweg für Entwürfe, die nie die Anforderungen erfüllt haben. Anstatt den rohen vierten Versuch zu versenden, liefert der Prozess zumindest eine harmonisierte Version.
Temperatur. Sie liegt bei temperature=0.2. Das ist niedrig, weil das Bearbeiten im Vordergrund steht, aber nicht null, da der Text dennoch umgeschrieben werden muss.
Nur Markdown. Lokale Modelle neigen dazu, mit „Sicher! Hier ist Ihre Dokumentation:“ zu beginnen und mit „Ich hoffe, das hilft.“ zu enden. Der Prompt verbietet beides. Allein der Prompt reicht aus, wie die Abschnitt zur Ausführung zeigt, nicht aus – daher wird in einem Nachverarbeitungsschritt alles entfernt, was durchdringt.
Teil 6: Verbindung des Graphen
Nun werden die Komponenten zusammengesetzt. Hier kommt LangGraph ins Spiel, denn die gesamte Topologie, einschließlich der Zyklen, lässt sich in wenigen Zeilen darstellen:
builder = StateGraph(AgentWorkflowState)
builder.add_node("content_generator", content_generator_node)
builder.add_node("content_critic", content_critic_node)
builder.add_node("content_adjudicator", content_adjudicator_node)
builder.set_entry_point("content_generator")
builder.add_edge("content_generator", "content_critic")
builder.add_conditional_edges(
"content_critic",
execution_router,
{
"content_generator": "content_generator",
"content_adjudicator": "content_adjudicator"
}
)
builder.add_edge("content_adjudicator", END)
agent_pipeline = builder.compile()
Knoten und Eingangspunkt. Die drei Knotenfunktionen sind unter Namen registriert, wobei der Generator als Eingangspunkt dient. Der Generator übergibt stets die Kontrolle an den Critic, und diese Verbindung ist bedingungslos.
Die bedingte Verbindung. add_conditional_edges verbindet den Router mit dem Critic. Sobald eine Kritik abgeschlossen ist, wird der Router ausgeführt und seine Antwort wird im Mapping gesucht, um den nächsten Knoten zu finden. Dieses Mapping erzeugt einen Kreislauf, denn content_critic kann wieder auf content_generator verweisen, der anschließend erneut content_critic versorgt.
Kompilierung. Durch Aufruf von builder.compile() entsteht ein Pipeline, der ausgeführt werden kann; dadurch wird der gesamte Kreislauf zu einem einzigen Aufruf von agent_pipeline.invoke(initial_state).
Jeder, der diesen Loop per Hand mit einer while-Anweisung, manuellem Zustandsmanagement und einem Versuchszähler geschrieben hat, wird bemerken, wie viel Verwaltungsaufwand verschwunden ist. Die Graphenbeschreibung dokumentiert sich außerdem auf eine Weise, wie es ein handgeschriebener Loop niemals kann.
Teil 7: Laufzeit, Konfiguration und Ausgabensäuberung
Zuletzt kommt die run()-Funktion hinter dem reflexion-Konsole-Skript. Sie analysiert Argumente, lädt die Konfiguration, führt den Pipeline-Prozess aus und säubert die Ausgabe. Der größte Teil der Argumentanalyse ist im Repository am besten nachzuvollziehen, doch zwei Aspekte verdienen besondere Aufmerksamkeit.
Zuerst kommt die Konfiguration. config.json liefert die Modellrollen sowie die Einstellungen für den Loop:
{
"model_registry": {
"GENERATOR": "qwen3.5:9b",
"CRITIC": "deepseek-coder:6.7b",
"ADJUDICATOR": "llama3:8b"
},
"quality_score_bar": 95,
"max_attempts": 4
}
Falls die Datei fehlt oder unlesbar ist, verwendet der Lader weiterhin diese Standardwerte, sodass der Prozess dennoch startet. Die Standardorte für Eingabe, Ausgabe und Konfiguration werden relativ zum installierten Paket bestimmt; bei einer editierbaren Installation bedeutet dies den Wurzelordner des Repositoriums. Wenn man daher reflexion aus jedem Verzeichnis ausführt, findet es die config.json-Datei sowie den Ordner input/ des Repositoriums, wobei die Optionen --input, --output und --config jeweils diese Standardeinstellungen überschreiben.
Zweitens die Bereinigung der Ausgabe, die bei der Arbeit mit lokalen Modellen notwendig wird:
raw_final_text = final_output.get("final_adjudicated_markdown", "")
clean_final_markdown = raw_final_text
# 1. Clean Preamble (Conversational text at the top)
if "#" in raw_final_text:
parts = raw_final_text.split("#", 1)
if len(parts[0].strip()) > 0 and len(parts[0].strip()) < 200:
clean_final_markdown = "#" + parts[1]
# 2. Clean Postscript (Conversational and meta review text at the bottom)
meta_prefixes = (
"note:", "note that", "i hope", "this concludes", "in conclusion",
"no formatting errors", "no contradictions", "no errors",
"let me know", "feel free", "please let me know"
)
meta_keywords = ("errors or contradictions", "contradictions were found", "errors were found")
lines = clean_final_markdown.rstrip().split("\n")
while lines:
last_line = lines[-1].strip()
if not last_line:
lines.pop()
continue
lowered = last_line.lower()
# Real content usually carries Markdown structure; meta chatter does not.
is_structural = lowered.startswith(("#", "|", "-", "*", ">", "`")) or lowered[0].isdigit()
is_meta = (not is_structural) and (
lowered.startswith(meta_prefixes) or any(k in lowered for k in meta_keywords)
)
if not is_meta:
break
lines.pop()
clean_final_markdown = "\n".join(lines).rstrip() + "\n"
So verhält es sich:
- Die Prüfung des Vorworts. Wenn vor dem ersten
#-Überschriftstext weniger als 200 Zeichen stehen, handelt es sich fast sicher um einen Einleitungstext wie „Hier ist das endgültige Dokument:“ und wird entfernt. Liegt die Länge über 200 Zeichen, bleibt der Text erhalten, da es sich um echten Inhalt handeln könnte. - Die Überprüfung des Nachworts. Der Code prüft Zeile für Zeile von unten nach oben. Eine Zeile wird entfernt, wenn sie wie unnötiger Kauderwelsch statt wie echter Inhalt aussieht: ein verbleibender Bewertungstext oder eine Abschlussnotiz, die mit „Hinweis:“ oder „Ich hoffe“ beginnt, wie zum Beispiel „Es wurden keine Formatierungsfehler oder Widersprüche gefunden.“, die bei einem Testlauf am Ende eines ansonsten guten Dokuments zurückgeblieben sind. Da es sich um eine Schleife und nicht um eine einzige Prüfung handelt, verschwinden mehrere solcher Abschlusszeilen in einer einzigen Durchlaufphase.
Sobald der Text bereinigt wurde, wird die finale Markdown-Datei auf dem Ausgabepfad gespeichert. Anschließend zeigt die Konsole eine Zusammenfassung des Vorgangs, einschließlich der gesamten Punktzahlentwicklung und der Anzahl der Durchläufe.
Ausprobieren
Drei Beispielaufgaben sind im Repository enthalten: eine Funktion zur Filterung von Metriken, eine Überprüfung der API-Signatur sowie ein Rechner für Zinseszinsen. Die Dokumentation der ersten Aufgabe sieht wie folgt aus:
reflexion --input input/source_function_a.py --output output/result.md --config config.json
Von dort aus ist der Lauf des Schleifens sichtbar. In jeder Runde gibt der Generator sich zu erkennen, der Kritiker druckt eine Bewertungstabelle aus und der Router gibt an, ob es sich um eine Schleife handelt oder ob der Prozess beendet wird.
Die Ergebnisse der Ausführungen mit allen drei Eingaben befinden sich im Verzeichnis output/, sodass die Erzeugnisse der Pipeline ohne weitere Ausführung überprüft werden können: ordentliche Parametertabellen, Beschreibungen der Rückgabewerte sowie Tabellen der ausgelösten Ausnahmen mit ihren genauen, zitierten Nachrichten. Zwei der drei Dateien enthalten einen kleinen Fehler vom unten beschriebenen Typ – eine angegebene Ausgabe, die von dem unterscheidet, was der Code tatsächlich zurückgibt, sowie ein Beispiel, bei dem eine Ausnahme ausgelöst wird, obwohl eigentlich ein Wert zurückgegeben werden sollte. Sie wurden absichtlich belassen, damit ihr Finden eine gute Übung darstellt.
Wenn etwas schiefgeht
Die Sache geht auch schief, und die untenstehende Liste stammt aus Fehlern, die bei verschiedenen Testläufen auftraten. In einem Fall nannte die Dokumentation den falschen Ausnahmetyp sowie eine erfundene Fehlermeldung, obwohl die Quelldatei einen TypeError mit einer spezifischen Zeichenkette auslöst. In einem anderen Fall wurde behauptet, es werde keine Ausnahme explizit ausgelöst. In einer Versionshistorientabelle gab es eine erfundene Zeile „0.1 initial release“ mit dem Datum „TBD“, wovon beides in der Quelldatei nicht vorkommt. In einem Beispiel wurde ein Ertragsverhältnis von etwa 0,86 angegeben, obwohl die richtige Antwort 0,667 ist. Und ein ordentliches Dokument endete mit dem eigenen Urteil des Kritikers: „Es wurden keine Formatierungsfehler oder Widersprüche gefunden.“
Dann gab es die Serie, in der vier aufeinanderfolgende Runden jeweils 97 Punkte gegen 95 Punkte erzielt wurden, obwohl behauptet wurde, der Entwurf sei noch nicht fertig – wobei eine Runde mit Feedback in Hunderte von ../-Tokens umschlug. Als der Kritik-Prompt zu stark in Richtung Härte gesteuert wurde, bewertete er einen größtenteils korrekten Entwurf viermal in Folge mit 0 von 100 Punkten und rechtfertigte diese Bewertung mit einer offensichtlich falschen Aussage über die Quelle. Der Kritiker ist weder ein fester Richter noch ein Rechner, sondern verstärkt einfach den Stimmungszustand, den ihm der Prompt gibt.
Zum Glück bilden die Fehler Cluster, und jedes Cluster weist in dem Code eine gezielte Abhilfe auf. Inhaltliche Fehler wie falsche Ausnahmen sowie fehlerhafte Arithmetik in den Dokumenten werden durch die benannten Überprüfungsmechanismen des Kritiker-Moduls bewältigt. Kalibrierungsfehler (freundliche Standardbestätigungen auf der einen Seite, extrem niedrige Werte auf der anderen) werden durch das auf Abzügen basierende Bewertungsverfahren sowie durch den Umzug der Berechnung von Durchschnitt und Schwellenwert aus dem Modell in den Python-Code des Kritiker-Knotens gelöst. Fehler bei der Ausgabe, Endfreigaben sowie verbleibende Überprüfungsergebnisse werden durch die Nachsatz-Prüfung bearbeitet, während degeneratives Feedback durch den Reinigungsmechanismus abgefangen wird.
Nichts davon macht den Loop fehlerfrei, da eine Anweisung im Prompt lediglich eine Bitte und keine Beweisgrundlage ist. Wenn die Bewertungen auch nach all diesen Maßnahmen weiter schwanken, ist das mächtigste Mittel das Modell selbst und nicht der Prompt. Der Kritiker hat die schwierigste Aufgabe im System, und das Ersetzen des CRITIC-Eintrags in config.json durch ein leistungsstärkeres Allzweckmodell ist eine einzeilige Änderung, die keinen Code erfordert.
Der Loop erzeugt zuverlässig ein weitaus besseres Dokument als ein einmaliger Prompt an denselben Generator, und die Bewertungshistorie liefert einen ehrlichen Überblick darüber, wie dieses Ergebnis erreicht wurde. Wenn die Ausführungen weiterhin die Versuchsgrenze erreichen, lohnt es sich, zwei Einstellungen anzupassen: quality_score_bar, deren Wert unter 95 fallen kann, sowie max_attempts, dessen Wert über 4 steigen kann.
Zusammenfassung
Das Ergebnis ist ein Einzel-Datei-Pipeline mit etwa 360 Zeilen, die folgendes leistet:
- Ein gemeinsamer Zustand sowie ein strukturiertes Bewertungsschema für den Kritiker, wobei die Durchschnittsberechnung und die Entscheidung über Erfolg in Python erfolgen und nicht vom Modell.
- Ein Entwurf, der von einem lokalen Modell verfasst und von einem anderen kritisiert wird, sodass kein Modell seine eigene Arbeit bewertet.
- Ein Router, der Entwürfe zur gezielten Überarbeitung zurückgibt, bis die Qualitätsanforderungen erfüllt sind oder die Versuche erschöpft sind.
- Ein drittes Modell dient als endgültiger Entscheider, um Widersprüche aus den Überarbeitungsrunden zu beseitigen.
- Umgangssprachliche Einleitungen und Nachträge werden aus dem endgültigen Markdown entfernt, bevor es auf die Festplatte geschrieben wird.
- Die Einstellungen befinden sich vollständig in einer kleinen Datei namens
config.json; daher erfordern Änderungen an Modellen oder Lockerung der Schleifenkeine Codeanpassungen.
Es gibt Raum, es zu erweitern. Der Kritiker könnte mehrere Bewertungskriterien bewerten, wobei der Router das schwächste auswählt, um es zu beheben, anstelle einer einzigen kritischen Korrektur. Ein vierter Knoten könnte die in der Dokumentation bereitgestellten Codebeispiele ausführen und tatsächliche Fehler wieder in den Kreislauf einspeisen, um die falschen Angaben aufzudecken, die dem Kritiker entgehen. Oder dieselbe Struktur aus Generator, Kritiker und Urteiler könnte für eine völlig andere Aufgabe eingesetzt werden, wie beispielsweise Übersetzung oder Erstellung von Tests, da nichts im Graphen speziell auf Dokumentation ausgerichtet ist.
Die Argumente für Frameworks sind hier gültig: Sie erweisen sich als nützlich, sobald ein Workflow echte Zyklen, Verzweigungen und einen gemeinsamen Zustand aufweist – und dieser Pipeline entspricht genau dieser Beschreibung. LangGraph übernahm die Teile, die niemand selbst umsetzen möchte, nämlich das Zusammenführen von Zuständen, die bedingte Routenwahl sowie den eigentlichen Loop. Zunächst dünne Versionen zu entwickeln macht klar, welche Teile des Frameworks tatsächlich Arbeit leisten, und diese Reihenfolge ist zu empfehlen.