Stworzenie agenta do samokorygującej dokumentacji przy użyciu LangGraph i Ollama
Lokalny pipeline do przetwarzania informacji, w którym jeden model tworzy szkic dokumentacji API w Pythonie, drugi je ocenia, a trzeci łączy uzyskane wyniki, połączony w pętlę LangGraph na Ollama.
Źródła: Build a Self-Correcting AI Agent with LangGraph and Ollama
Ten wzorzec nazywa się refleksją. Jego założeniem jest to, że modele językowe doskonale potrafią wykrywać błędy w tekście, ale trudno im od razu napisać tekst bez zarzutów, dlatego praca jest podzielona na różne role. Istotny jest jeden szczegół: każda rola korzysta z innego lokalnego modelu. Model, który zarówno pisze, jak i ocenia, ma tendencję do akceptowania własnych wyników, natomiast oddzielny krytyk zapobiega temu.
Wcześniej stworzone agenty od zera, bez użycia żadnych frameworków, argumentowały, że narzędzia takie jak LangGraph okazują się przydatne dopiero po ręcznym stworzeniu wersji uproszczonej. Tym razem LangGraph jest świadomym wyborem, ponieważ pętla rewizji z warunkowymi wyjściami to dokładnie taki cykliczny proces, do którego został stworzony. Ollama obsługuje modele – domyślnie używane są qwen3.5:9b jako generator, deepseek-coder:6.7b jako krytyk oraz llama3:8b jako sędzia, ale mały plik JSON określa role, więc może to być cokolwiek, co zainstalowałeś lokalnie.
Wynikiem jest jedna komenda konsoli, reflexion. Odczytuje on plik źródłowy, uruchamia pętlę, wyświetla tabelę wyników dla każdego rundy i zapisuje ulepszony format Markdown na dysku. Cały ten proces mieści się w jednym pliku Pythona liczącym około 360 linii.
Budowa systemu składa się z siedmiu elementów: stanu procesu oraz schematu oceny krytyka, węzła Generator, węzła Critic, routera decydującego o kontynuowaniu pętli lub jej zakończeniu, węzła Adjudicator, połączeń w grafie oraz wreszcie warstwy wykonawczej zajmującej się konfiguracją i oczyszczaniem wyników.
Ustawianie
Kod znajduje się w repozytorium o nazwie local-reflexion-agent jako jeden pakiet, src/reflexion/main.py, dostępny jako skrypt konsolowy za pośrednictwem pliku pyproject.toml. Sklonuj go i zainstaluj w trybie edytowalnym:
git clone https://github.com/jfjensen/local-reflexion-agent.git
cd local-reflexion-agent
pip install -e .
To wprowadza langgraph, langchain-core, langchain-community, langchain-ollama oraz pydantic, a także rejestruje skrypt konsolowy o nazwie reflexion. Następnie należy potwierdzić, że Ollama działa lokalnie, i pobrać trzy modele wymienione w config.json:
ollama pull qwen3.5:9b
ollama pull deepseek-coder:6.7b
ollama pull llama3:8b
Różne modele są w porządku. Role są przypisane w pliku config.json znajdującym się u korzenia repozytorium, co zostanie ponownie omówione w sekcji dotyczącej czasu wykonywania poniżej. Repozytorium zawiera trzy małe pliki przykładowe w katalogu input/, dzięki czemu pipeline może zostać uruchomiony od razu:
reflexion --input input/source_function_a.py --output output/result.md --config config.json
Część 1: Stan i schemat krytyka
Najpierw przychodzą dwie struktury danych. Jedna określa, co krytyk musi zwrócić, a druga definiuje pamięć, która przemieszcza się po grafie.
Wynik działania krytyka jest bardziej interesujący. Jeśli poprosisz lokalny model o wielkości 6,7 mld parametrów o ocenę, chętnie odpowie akapitem prozy. Potrzebne są natomiast liczby wraz z jedną praktyczną sugestią naprawczą, dlatego schemat Pydantic zmusza model do wypełnienia ustalonych pól:
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.")
Kilka uwag na temat schematu:
- Pole
single_critical_fixstanowi sedno pętli. Zamiast listy wszystkich problemów, osoba oceniająca wskazuje jedną najważniejszą korektę. Mały generator radzi sobie znacznie lepiej z zadaniem „popraw to jedno” niż z mnóstwem mieszanych opinii. - To, co zostaje pominięte, ma takie samo znaczenie jak to, co jest uwzględnione. W wcześniejszej wersji model był również proszony o ogólny procent oraz flagę zatwierdzenia/niezatwierdzenia, co okazało się błędem – opisano to w sekcji dotyczącej osoby oceniającej. Krótko mówiąc: nigdy nie zlecaj modelowi wykonywania obliczeń lub porównań progowych, które w Pythonie można zrealizować w jednej linii kodu. Schemat zawiera tylko te oceny, które są rzeczywiście konieczne dla modelu, natomiast węzeł osoby oceniającej wykonuje średniowanie oraz samą decyzję o zatwierdzeniu/niezatwierdzeniu, używając prostego kodu.
Field jako elementu dekoracyjnego. Trafiają one do modelu w ramach ograniczeń schematu JSON i pełnią rolę małych instrukcji.Następnie przychodzi stan. LangGraph przekazuje obiekt stanu z węzła na węzeł, a każdy węzeł odpowiada częściową aktualizacją tego obiektu. TypedDict określa jego 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]
Jedynym nieoczywistym elementem jest score_history. Typ Annotated[List[int], append_scores] mówi LangGraphowi, aby łączył aktualizacje tego pola z reduktorem append_scores zamiast je nadpisywać. Każda ocena zwrócona przez krytyka jest więc dodawana do historii. Pod koniec procesu dostępna jest pełna krzywa ocen z wszystkich rund rewizji, co okazuje się najbardziej informacyjnym elementem wyświetlanym przez ten pipeline.
Część 2: Węzeł Generator
Węzeł LangGraph to nic innego jak funkcja: do niej wpływa stan, a na wyjściu pojawia się słownik z aktualizacjami. Generator ma dwa tryby działania. Pierwsza iteracja tworzy szkic dokumentacji bezpośrednio na podstawie kodu. Każda kolejna iteracja poprawia wcześniejszy szkic w odpowiedzi na korekty wprowadzone przez krytyka.
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
}
Co robi kod:
Wybór trybu. Jeśli generated_markdown jest pusty, oznacza to pierwszą rundę, więc model działa jako autor tekstu technicznego, wychodząc od surowego źródła. Jeśli istnieje wersja robocza, model jest traktowany jako redaktor i otrzymuje poprzedni wynik oceny, jedną sugestię krytyka, oryginalne źródło oraz swoją własną wcześniejszą wersję roboczą.
Instrukcja rewizji. Podanie modelowi jego wcześniejszej oceny to mały trik, który działa skutecznie. Konkretne stwierdzenie typu „otrzymałeś 78/100 punktów” skłania go do zmiany treści zamiast tylko lekkiego przeredagowania. Instrukcja wymaga całkowitego przepisania tekstu w celu naprawy jednego konkretnej problemu, a takie precyzyjne polecenia dobrze pasują do małych modeli lokalnych.
Temperatura. Generator działa przy użyciu wartości temperature=0.7, co zapewnia pewną różnorodność wersji roboczych pomiędzy rundami. Krytyk, jak widać poniżej, działa przy temperaturze zero.
Wartość zwracana. Zwracane są tylko zmienione pola. LangGraph łączy je z aktualnym stanem, a iteration_count wzrasta o jedno za każdą rundę iteracji.
Część 3: Węzeł Critic
To inny model, domyślnie deepseek-coder:6.7b, a jego prompt wymagał więcej prób, aby zostać poprawnie sformułowany, niż jakikolwiek inny w tym zestawieniu.
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
}
Ustrukturyzowany wynik. Kluczowa jest linia llm.with_structured_output(DocEvaluationSchema). Ona ogranicza działanie modelu w taki sposób, aby zamiast tekstu wolnego powracał zweryfikowany obiekt DocEvaluationSchema. Dzięki temu nie ma konieczności parsowania JSON, używania regex ani logiki ponawiania prób w przypadku nieprawidłowego wyniku. Proces oceny odbywa się przy temperature=0,0, ponieważ narzędzie oceniające powinno być deterministyczne lub jak najbliższe temu stanowi w przypadku modeli lokalnych.
Jak ewoluował prompt. Przeszedł przez trzy wersje, a historia tych zmian jest prawdopodobnie najcenniejszą lekcją w tym kontekście.
- Pierwsza wersja uprzejmie prosiła model o analizę dokumentacji i jej ocenę. Wynikiem były uprzejme oceny na poziomie 80–90 punktów dla dokumentów zawierających wymyślone komunikaty o błędach, więc pętla zakończyła się zbyt wcześnie.
Nie włączaj arytmetyki do modelu. W pierwszym schemacie model miał obliczać ogólne procenty oraz sam błąd logiczny typu „przeszedł/nie przeszedł”, a wyniki były dziwne. Pewnego razu oceniający przyznał modelowi 100 punktów za dokładność techniczną i 100 za kompletność, a następnie podał „średnią netto” wynoszącą dokładnie 95, co było po prostu progiem sukcesu określonym w instrukcjach, a nie średnią czegokolwiek innego. Innym razem przyznał modelowi 97 punktów w czterech kolejnych rundach, przy progu 95, mimo to za każdym razem zaznaczył to jako nieudane. Wszystkie cztery próby poszły na marne, ponieważ model uznał błąd logiczny za prawdziwy, a zapisy w dzienniku spokojnie stwierdzały, że 97 nie osiągnęło poziomu 95.
Dziś model dostarcza tylko dwa podwyniki. Trzy linijki kodu w Pythonie zajmują się wszystkim innym: obliczają średnią, porównują ją z QUALITY_SCORE_BAR i ustawiają flagę, której kieruje się router. Porównywanie liczby z progiem nie wymaga osądu, a wszystko, co nie wymaga osądu, nie powinno być delegowane modelowi.
Zaawansowanie sanitize_critical_fix. Podczas tego samego testu trwającego cztery rundy, informacje zwrotne od krytyka w jednej z rund zamieniły się w kilkaset powtarzających się tokenów ../, które zostały dosłownie wklejone do następnego promptu generatora jako problem do naprawienia. Małe modele działające pod ograniczeniami strukturyzowanego wyjścia czasami zachowują się w ten sposób. To zaawansowanie sprawdza, czy naprawa jest pusta lub zdegenerowana (jeden powtarzający się token lub tekst składający się z zaledwie kilku różnych znaków), a jeśli tak, zastępuje ją ogólnym żądaniem ponownej weryfikacji dokumentacji w porównaniu z oryginałem. Nie jest to zaawansowane rozwiązanie, ale zapobiega temu, by niepotrzebne dane wpłynęły na całą rundę. Cała funkcja jest krótka:
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
Każdego rundy wynik jest zapisywany jako lista zawierająca jeden element, a wcześniej zdefiniowany reduktor stanu dodaje go do historii. W ten sposób kumuluje się krzywa wyników. Node wyświetla również kartę wyników rundy, pokazującą dokładność techniczną, kompletność, wynik netto w porównaniu z wyznaczoną granicą oraz opinie krytyka. Te wyświetlenia nie znajdują się w fragmencie tekstu, ale są dostępne w repozytorium, a śledzenie karty wyników z rundy na rundę stanowi połowę przyjemności.
Część 4: Router
Generator i krytyk zostały już stworzone, więc teraz coś musi decydować, co nastąpi po każdej krytyce. W LangGraph tę funkcję pełni router, który odczytuje stan i zwraca nazwę następnego węzła.
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"
Uwagi dotyczące routera:
- Pętla ma tylko dwa wyjścia: krytyk zatwierdza projekt lub osiągnięto limit prób. W obu przypadkach projekt trafia do Adjudicatora; we wszystkich pozostałych przypadkach wraca do Generatora.
- Ten limit zasługuje na większy szacunek, niż się wydaje. Przy progu 95 punktów i surowym krytyku niektóre projekty nigdy się nie przejdą, a bez
MAX_ATTEMPTSpętla nadal będzie nieograniczenie wywoływać Ollamę. Cztery próby to arbitralna, ale przydatna wartość, którą można zmienić. - Typ zwracany
Literalto coś więcej niż tylko dokumentacja, ponieważ odpowiada mapie routingu zdefiniowanej podczas konfiguracji grafu. - Podobnie jak w przypadku innych węzłów, wydruki z konsoli są pomijane w fragmencie tekstu. W repozytorium router rejestruje liczbę prób oraz historię wyników przed każdą decyzją.
Część 5: Węzeł Adjudicator
Cokolwiek opuszcza pętlę, czy to po udanym przetworzeniu, czy po wyczerpaniu liczby prób, otrzymuje ostatnią ocenę od trzeciego modelu. Zadaniem tego modelu nie jest dodawanie treści – ma on za zadanie rozwiązywać sprzeczności, które pojawiły się podczas kolejnych rund edycji, oraz usuwać wszelkie elementy o charakterze konwersacyjnym.
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}
Dlaczego węzeł istnieje. Po kilku rundach „całkowitego przepisania w celu naprawy X” projekt może zawierać wewnętrzne sprzeczności: w jednej rundzie dodaje się jakieś twierdzenie, a późniejsze przepisanie je zaprzecza, nie usuwając go. Rozjemca otrzymuje oryginał, najnowszy projekt oraz ostatni nierozwiązany zarzut krytyka, z poleceniem usunięcia stwierdzeń, które się ze sobą kolidują. Służy on również jako wyjście dla projektów, które nigdy nie przejdły oceny. Zamiast wysyłać surową czwartą próbę, proces przynajmniej dostarcza wersję skonsolidowaną.
Temperatura. Działa przy wartości temperature=0.2. Jest to niska wartość, ponieważ zadaniem jest edycja, ale nie zerowa, ponieważ nadal trzeba przepisać tekst.
Tylko Markdown. Lokalne modele lubią zaczynać od „Oczywiście! Oto twoja dokumentacja:” i kończyć na „Mam nadzieję, że to pomoże.” Prompt zabrania obu takich sposobów formułowania zapytań. Samo sformułowanie promptu okazuje się niewystarczające, jak pokazuje sekcja dotycząca czasu wykonywania, dlatego konieczny jest krok postprocesowania, aby usunąć wszystko, co mogło przejść niezauważone.
Część 6: Łączenie elementów grafu
Teraz łączy się poszczególne elementy. To właśnie tutaj LangGraph sprawdza się najlepiej, ponieważ cała topologia, włączając cykle, zajmuje zaledwie kilka linijek kodu:
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()
Węzły i punkt wejścia. Trzy funkcje węzłów są zarejestrowane pod określonymi nazwami, przy czym Generator pełni rolę punktu wejścia. Generator zawsze przekazuje kontrolę do Critica, a ta łączność jest bezwarunkowa.
Warunkowa łączność. Funkcja add_conditional_edges łączy router z Criticem. Gdy krytyka zostanie zakończona, uruchamia się router, a jego odpowiedź jest sprawdzana w tabeli mapowania w celu znalezienia następnego węzła. Ta tabela mapowania tworzy błędne koło, ponieważ content_critic może prowadzić z powrotem do content_generator, który ponownie dostarcza dane do content_critic.
Kompilacja. Wywołanie metody builder.compile() generuje łańcuch operacji, który można uruchomić, dzięki czemu cały cykl staje się jednym wywołaniem agent_pipeline.invoke(initial_state).
Każdy, kto ręcznie pisał taki pętlę, używając instrukcji while, ręcznego zarządzania stanem oraz licznika prób, zauważy, jak dużo pracy administracyjnej zniknęło. Deklaracja wykresu dokumentuje się również w sposób, jakiego pętla napisana ręcznie nigdy nie potrafi.
Część 7: Czas wykonywania, konfiguracja i oczyszczanie wyników
Ostatnią częścią jest funkcja run() znajdująca się w skrypcie konsolowym reflexion. Analizuje ona argumenty, ładuje konfigurację, uruchamia proces i oczyszcza wyniki. Większość jej elementów, zwłaszcza analiza argumentów, jest najlepiej przedstawiona w repozytorium, ale dwa aspekty zasługują na szczególną uwagę.
Najpierw przychodzi konfiguracja. Plik config.json dostarcza role modeli oraz ustawienia pętli:
{
"model_registry": {
"GENERATOR": "qwen3.5:9b",
"CRITIC": "deepseek-coder:6.7b",
"ADJUDICATOR": "llama3:8b"
},
"quality_score_bar": 95,
"max_attempts": 4
}
Jeśli plik nie istnieje lub jest nierozbieralny, loader używa tych samych domyślnych wartości, dzięki czemu proces może się rozpocząć. Domyślne lokalizacje danych wejściowych, wyjściowych oraz konfiguracji są określane względem zainstalowanego pakietu, co w przypadku instalacji umożliwiającej edycję oznacza korzeń repozytorium. Dlatego uruchomienie prostego reflexion z dowolnego katalogu pozwala znaleźć plik config.json oraz folder input/ w repozytorium, natomiast parametry --input, --output i --config mogą je przejąć.
Po drugie, czyszczenie wyników, które stało się konieczne przy pracy z lokalnymi modelami:
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"
Sposób działania:
- Weryfikacja wstępu. Jeśli przed pierwszym nagłówkiem
#znajduje się tekst składający się z mniej niż 200 znaków, z dużym prawdopodobieństwem jest to fraza otwierająca „Oto ostateczny dokument:” i zostaje usunięta. Jeśli tekst ma więcej niż 200 znaków, zostaje zachowany, ponieważ może to być rzeczywisty treść. - Przegląd dopisków. Kod sprawdza tekst, idąc od ostatniej linii w górę, linia po linii. Linia jest usuwana, jeśli wygląda jak niepotrzebne komentarze zamiast treści: pozostałe oceny, dopiski rozpoczynające się od słów „Uwaga:” lub „Mam nadzieję”, takie jak „Nie znaleziono błędów formatowania ani sprzeczności”, które zostały pozostawione na końcu inaczej przyzwoitego dokumentu po jednym teście. Ponieważ jest to pętla, a nie pojedyncza weryfikacja, kilka takich dopisków znika już przy pierwszym przeglądzie.
Po oczyszczeniu ostateczny tekst w formacie Markdown jest zapisywany na określonej ścieżce. Następnie konsola pokazuje podsumowanie procesu, w tym pełną historię wyników oraz liczbę iteracji.
Próba użycia
W repozytorium znajdują się trzy przykładowe dane wejściowe: funkcja do filtrowania metryk, sprawdzanie podpisu API oraz kalkulator odsetek składanych. Dokumentacja pierwszej z nich wygląda następująco:
reflexion --input input/source_function_a.py --output output/result.md --config config.json
Stamtąd widać, jak działa pętla. Za każdym razem Generator się prezentuje, Krytyk wyświetla tabelę wyników, a Router informuje, czy pętla się kontynuuje, czy kończy.
Wyniki wykonywań na wszystkich trzech wejściach znajdują się w folderze output/, dzięki czemu produkty tego procesu można przejrzeć bez konieczności ponownego uruchamiania czegokolwiek: uporządkowane tabele parametrów, opisy wartości zwracanych oraz tabele wywołanych wyjątków wraz z ich dokładnymi komunikatami. Dwa z trzech plików zawierają małe błędy opisane poniżej – deklarowane wyniki różniące się od tych, które zwraca kod, oraz przykłady sytuacji, w których powinno dojść do wywołania wyjątku, podczas gdy kod zwraca wartość inną. Zostały one umieszczone tam celowo, więc ich znalezienie stanowi dobrą ćwiczenie.
Gdy coś idzie nie tak
Zdarzają się błędy, a poniższa lista pochodzi z awarii zaobserwowanych podczas różnych testów. W jednym przypadku dokumentacja wskazała niewłaściwy typ wyjątku oraz wymyśloną wiadomość o błędzie, mimo że źródło generuje TypeError z określoną łańcuchem znaków. W innym przypadku stwierdzono, że nic nie zostało wyraźnie wywołane. Tabela historii wersji zawierała wymyślny wiersz „0.1 initial release” z datą „TBD”, których żaden nie występuje w źródle. W jednym z przykładów podano współczynnik wydajności bliski 0,86, podczas gdy poprawna odpowiedź to 0,667. A jeden z przyzwoitych dokumentów kończył się własnym werdyktem krytyka: „Nie znaleziono błędów formatowania ani sprzeczności.”
Następnie pojawił się wynik 97 w porównaniu z 95 w ciągu czterech rund, mimo że twierdzono, iż projekt nie jest gotowy, a jedna runda zwróceń przerodziła się w setki tokenów ../. Gdy prompt krytyka został zbyt mocno skierowany ku surowości, ocenił w większości poprawny projekt na 0 z 100 przez cztery kolejne rundy, broniąc tego wyniku fałszywym stwierdzeniem na temat źródła. Krytyk nie jest ani stałym sędzią, ani kalkulatorem – wzmacnia po prostu nastrój, jaki nadaje mu prompt.
Szczęśliwie błędy występują w grupach, a każda grupa ma w kodzie dedykowane rozwiązanie. Błędy związane z treścią, takie jak błędne wyjątki czy nieprawidłowe obliczenia w dokumentacji, są radzone przez nazwane procedury weryfikacyjne używane przez krytyka. Błędy kalibracji (zbyt uprzejme potwierdzenia z jednej strony, zbyt niskie wartości zerowe z drugiej) są rozwiązywane za pomocą procedury oceniania opartej na dedukcji oraz przeniesieniem obliczeń średniej i progu decyzyjnego z modelu do kodu węzła krytyka napisanego w Pythonie. Błędy wynikowe, ostateczne zatwierdzenia oraz pozostałe werdykty recenzji są obsługiwane przez procedurę przeglądu postscript, a zdegenerowane informacje zwrotne – przez mechanizm ochronny sanitize.
Żadne z tych rozwiązań nie czyni pętli całkowicie niezawodną, ponieważ instrukcja w promptzie jest jedynie prośbą, a nie dowodem. Gdy wyniki nadal się zmieniają pomimo tych środków, najskuteczniejszym elementem jest sam model, a nie prompt. Krytyk ma najtrudniejszą rolę w tym systemie, a zastąpienie wpisu CRITIC w pliku config.json silniejszym modelem uniwersalnym to zmiana wymagająca zaledwie jednej linijki kodu.
Pętla w sposób niezawodny tworzy znacznie lepszy dokument niż prompt użyty tylko raz dla tego samego generatora, a historia wyników dostarcza uczciwego zapisu tego, jak do tego doszło. Jeśli liczba prób nadal przekracza ustalony limit, warto dostosować dwie ustawienia: quality_score_bar, którą można obniżyć poniżej 95, oraz max_attempts, którą można podnieść powyżej 4.
Podsumowanie
W rezultacie otrzymujemy pipeline składający się z jednego pliku zawierającego około 360 linijek, który:
- Wspólny stan oraz ustrukturyzowany schemat oceny dla recenzenta, przy czym obliczenia średniej i decyzji o akceptacji są wykonywane w Pythonie, a nie przez model.
- Projekt napisany przez jeden lokalny model i skrytykowany przez inny, dzięki czemu żaden model nie ocenia własnej pracy.
- Router, który zwraca projekty do dalszych poprawek, dopóki nie zostanie osiągnięty wymagany poziom jakości lub nie skończą się próby.
- Uruchamia trzeci model jako ostatecznego sędziego w celu rozwiązania sprzeczności pozostałych po rundach poprawek.
- Usuwa wstępy i zakończenia konwersacyjne z końcowego pliku Markdown przed zapisaniem go na dysku.
- Ustawienia są przechowywane wyłącznie w małym pliku
config.json, więc zmiana modeli lub złagodzenie procedur nie wymaga edycji kodu.
Istnieje możliwość jego rozszerzenia. Krytyk mógłby ocenić kilka wymiarów rubryki, a router wybrałby najslabszy element do naprawy zamiast dokonywać tylko jednej korekty. Czwarty węzeł mógłby uruchamiać przykłady z udokumentowanego kodu i wracać rzeczywiste błędy do pętli, dzięki czemu można by wykryć fałszywe twierdzenia, których nie dostrzega krytyk. Albo ta sama struktura Generatora, Krytyka i Adjudatora mogłaby być wykorzystana do zupełnie innej zadania, takiego jak tłumaczenie lub generowanie testów, ponieważ nic w tym grafie nie jest specyficzne dla dokumentacji.
Tutaj sprawdzają się zalety frameworków: zyskują one na wartości, gdy proces pracy posiada rzeczywiste pętle, gałęzie oraz wspólny stan, a ten pipeline idealnie odpowiada temu opisowi; LangGraph zajmuje się tymi elementami, których nikt nie chce ręcznie implementować, mianowicie łączeniem stanów, warunkowym kierowaniem przepływu i samą pętlą. Tworzenie najpierw lżejszych wersji pozwala jasno zobaczyć, które części frameworku wykonują rzeczywistą pracę, i taka kolejność zasługuje na polecenie.