Створення агента для автоматичної корекції документації за допомогою LangGraph та Ollama.
Місцевий конвеєр для обробки документації, у якому одна модель складає початкові документи API на Python, друга їх аналізує та видає зауваження, а третя узгоджує результати, з’єднані у цикл LangGraph на Ollama.
Джерела: Build a Self-Correcting AI Agent with LangGraph and Ollama
Цей підхід називається рефлексією. Його основна ідея полягає у тому, що ШІ здатні значно краще виявляти помилки в тексті, ніж одразу створювати бездоганний текст, тому робота ділиться на окремі завдання. Важливо одне: кожна роль використовує свою окрему локальну модель. Модель, яка водночас пише та оцінює, схильна проходити свої власні результати повз, тоді як окремий критик уникає цього.
Раніше агенти, створені з нуля без використання фреймворків, стверджували, що інструменти на кшталт LangGraph стають корисними лише після того, як ви вручну створите їхню спрощену версію. Цього разу LangGraph обрано навмисно, адже цикл корекцій із умовним завершенням — саме той циклічний процес, для якого він і створений. Ollama забезпечує роботу моделей. За замовчуванням використовуються qwen3.5:9b як генератор, deepseek-coder:6.7b як критик та llama3:8b як аджудикатор, проте невеликий JSON-файл визначає їхні ролі, тож будь-яка модель, яку ви завантажили локально, може їх замінити.
Результатом є одна команда консолі — reflexion. Вона читає вихідний файл, запускає цикл, виводить таблицю результатів для кожного раунду та зберігає опрацьований Markdown на диску. Увесь процес поміщається у єдиний файл Python довжиною близько 360 рядків.
Система складається з семи компонентів: стан робочого процесу та схема оцінки критика, вузол Генератора, вузол Критика, маршрутизатор, який вирішує, чи продовжувати цикл чи завершити його, вузол Арбітра, з’єднання між вузлами графа, а також модуль виконання з налаштуваннями та очищенням результатів.
Налаштування
Код знаходиться у репозиторії під назвою local-reflexion-agent у вигляді одного пакета src/reflexion/main.py, який є консольним скриптом завдяки файлу pyproject.toml. Клонуйте його та встановіть у режимі редагування:
git clone https://github.com/jfjensen/local-reflexion-agent.git
cd local-reflexion-agent
pip install -e .
Це включає langgraph, langchain-core, langchain-community, langchain-ollama та pydantic, а також реєструє скрипт консолі під назвою reflexion. Потім переконайтеся, що Ollama працює локально, та завантажте три моделі, зазначені в config.json:
ollama pull qwen3.5:9b
ollama pull deepseek-coder:6.7b
ollama pull llama3:8b
Різні моделі підходять. Ролі визначені у файлі config.json, який знаходиться у корені репозиторію, і ще раз описані у розділі про час виконання нижче. Репозиторій містить три невеликі зразкові файли у каталозі input/, тож пайплайн можна запустити відразу:
reflexion --input input/source_function_a.py --output output/result.md --config config.json
Частина 1: Держава та схема критика
Спочатку йдуть дві структури даних. Одна визначає, що має повернути критик, а інша — пам’ять, яка передається через граф.
Результат роботи критика є більш цікавим. Якщо запитати місцеву модель об’ємом 6,7 млрд параметрів про оцінку, вона з радістю відповість абзацем прози. Однак потрібні числа та одне конкретне рішення, тому схема Pydantic змушує модель заповнювати фіксовані поля:
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.")
Деякі моменти щодо схеми:
- Поле
single_critical_fixє основою циклу. Замість списку всіх проблем критик вказує одну найважливішу корекцію. Невеликий генератор набагато краще справляється з завданням „виправити цю одну річ“, ніж із купою змішаних відгуків. - Те, що не включено, має таке ж значення, як і те, що включено. У попередній версії також просили модель надати загальний відсоток та позначку про успіх/невдачу, що виявилося помилкою; це описано у розділі про критика. Коротко кажучи: ніколи не змушуйте модель виконувати арифметичні операції чи порівняння порогових значень, які Python може зробити за один рядок. Схема містить лише ті оцінки, які справді потрібні моделі, тоді як вузол критика виконує усереднення та саме прийняття рішення про успіх/невдачу у звичайному коді.
Field лише декоративними елементами. Вони потрапляють до моделі через обмеження JSON schema та функціонують як короткі інструкції.Далі йде стан. LangGraph передає один об’єкт стану від вузла до вузла, а кожен вузол відповідає частковою зміною цього об’єкта. TypedDict визначає його структуру:
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]
Єдина непроста для розуміння лінія — це score_history. Тип Annotated[List[int], append_scores] вказує LangGraph на те, що оновлення цього поля слід об’єднувати за допомогою редуктора append_scores, а не перезаписувати його. Таким чином кожен бал, який повертає критик, додається до історії. Наприкінці виконання доступна повна крива оцінок за всіма етапами редагування, що робить цю лінію найінформативнішою з усіх, які виводить пайплайн.
Частина 2: Вузол Generator
Вузол LangGraph — це просто функція: у неї входить стан, а виходить словник оновлень. У Generator є два режими. Під час першого проходу створюється чернетка документації безпосередньо з коду. Кожен наступний прохід вносить корективи до попередньої чернетки відповідно до правок, запропонованих критиком.
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
}
Що робить код:
Вибір режиму. Якщо generated_markdown порожній, це означає перший етап, тож модель діє як технічний автор, починаючи з необробленого вихідного тексту. Якщо існує чернетка, моделі надаються вказівки як редактору, і вона отримує попередній бал, одну корекцію від критика, оригінальний текст та свою власну попередню чернетку.
Запит на переробку. Інформування моделі про її попередній бал — це простий трюк, який ефективно діє. Конкретна фраза на кшталт „У вас 78/100“ спонукає її змінити текст, а не просто трохи перефразувати його. Інструкція вимагає повної переробки для усунення однієї конкретної проблеми, і такі чіткі вказівки добре підходять для невеликих локальних моделей.
Температура. Генератор працює з параметром temperature=0.7, що надає чернеткам певної різноманітності між етапами. Критик, як буде показано далі, працює з температурою нуль.
Значення, що повертається. Повертаються лише змінені поля. LangGraph об’єднує їх у стан, а iteration_count збільшується на один за кожен раунд ітерацій.
Частина 3: Вузол Critic
Це інша модель — за замовчуванням deepseek-coder:6.7b, і для формулювання правильного запиту знадобилося більше спроб, ніж для будь-чого іншого в цій системі.
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
}
Структурований вихід. Саме рядок llm.with_structured_output(DocEvaluationSchema) має вирішальне значення. Він обмежує роботу моделі так, щоб замість вільного тексту повертався перевірений об’єкт DocEvaluationSchema. Це означає відсутність необхідності у парсингу JSON, використанні регулярних виразів чи логіці повторних спроб у разі некоректного вихідного даних. Процес оцінки запускається з параметром temperature=0.0, адже система оцінювання має бути детермінованою або якомога ближчою до цього стану для локальної моделі.
Як еволюціонував запит. Він пройшов три версії, і саме історія їхньої зміни, ймовірно, є найціннішим уроком у цьому випадку.
- У першій версії моделі ввічливо просили проаналізувати документацію та поставити їй оцінку. Результатом були ввічливі оцінки у діапазоні 80–90 балів для документів із вигаданими повідомленнями про помилки, тому цикл завершувався занадто рано.
Не включайте арифметику до моделі. У першій схемі модель мала обчислювати загальний відсоток та сам булевий значення «пройшло/непройшло», причому результати були дивними. Одного разу оцінювач присвоїв технічній точності 100, повноті — також 100, а потім повідомив про «чистий середній» показник рівно 95, що було просто порогом успіху, зазначеним у завданні, а не середнім значенням чогось. Іншого разу він оцінив чернетку на 97 при пороговому значенні 95 протягом чотирьох спроб поспіль, але щоразу позначив її як непройшту. Усі чотири спроби були марними, адже модель вважала, що булеве значення є остаточним, а журнал спокійно зазначав, що 97 не досягає рівня 95.
Сьогодні модель надає лише два підбали. Три рядки коду на Python виконують усе інше: обчислюють середнє значення, порівнюють його з QUALITY_SCORE_BAR та встановлюють флаг, яким керується маршрутизатор. Порівняння числа з пороговим значенням — це не питання судження, і все, що не є справою судження, не повинно передаватися на обробку моделі.
Захист sanitize_critical_fix. Під час того самого пробігу у чотири раунди зворотний зв’язок від критика в одному з раундів перетворився на кілька сотень повторюваних токенів ../, які потім були вставлені слово в слово до наступного запиту генератора як проблема для виправлення. Малі моделі, що працюють під обмеженнями структурованого виводу, іноді поводяться таким чином. Цей захист перевіряє, чи є виправлення порожнім або деградованим (один повторюваний токен або текст, складений лише з кількох різних символів), і, якщо так, замінює його на універсальний запит щодо повторної перевірки документації порівняно з вихідним кодом. Це не надто складний механізм, але він запобігає тому, щоб непотрібний контент впливав на цілий раунд. Вся функція є короткою:
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
Кожного раунду оцінка повертається у вигляді списку, що містить один елемент, а редуктор стану, визначений раніше, додає його до історії. Саме так накопичується крива оцінок. Крім того, вузол виводить таблицю оцінок раунду, яка показує технічну точність, повноту, чистий бал порівняно з показником та відгуки критика. Ці виводи не включені до уривку, але зберігаються у репозиторії, і стеження за таблицею оцінок з раунду в раунд — це половина задоволення.
Частина 4: Маршрутизатор
Генератор та критик вже готові, тож тепер хтось має вирішувати, що буде далі після кожної критики. У LangGraph цю функцію виконує маршрутизаційна функція, яка читає стан та повертає назву наступного вузла.
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"
Примітки щодо маршрутизатора:
- Цей цикл має лише два виходи: критик схвалює проєкт або досягається ліміт спроб. У будь-якому разі проєкт надсилається до Оцінювача; у всіх інших випадках він повертається до Генератора.
- Цей ліміт заслуговує на більше поваги, ніж здається. З порогом у 95 балів та суворим критиком деякі проєкти ніколи не пройдуть, а без параметра
MAX_ATTEMPTSцикл буде нескінченно викликати Ollama. Чотири спроби — це довільне, але прийнятне значення, яке можна змінити. - Тип повернення
Literalє більш ніж просто документацією, адже він відповідає карті маршрутизації, визначеній під час налаштування графу. - Як і у випадку з іншими вузлами, записи з консолі видаляються з уривку. У репозиторії маршрутизатор фіксує кількість спроб та історію оцінок перед кожним рішенням.
Частина 5: Вузол Оцінювача
Усе, що виходить з циклу — чи то після успішного проходження, чи через вичерпання кількості спроб — проходить остаточну перевірку третьою моделлю. Завданням оцінювача не є додавати контент. Він усуває суперечності, які виникли під час кількох раундів корекцій, та видаляє все конверсаційне.
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}
Чому існує цей вузол. Після кількох раундів „повного переписування для виправлення X“ проект може містити внутрішні суперечності: на одному з етапів додається твердження, а на наступному етапі переписування суперечить йому, не видаляючи його. Арбітр отримує вихідний текст, останню версію проекту та останню залишену критикою проблему, отримуючи вказівку усунути суперечливі твердження. Цей процес також є способом відкидання проектів, які так і не пройшли перевірку. Замість того, щоб надсилати неопрацьовану четверту спробу, система принаймні надає узгоджену версію.
Температура. Вона встановлена на рівні temperature=0.2. Це низьке значення, оскільки основною задачею є редагування, але не нуль, тому що система все одно мусить переписувати текст.
Лише Markdown. Місцеві моделі люблять починати з фрази "Звісно! Ось ваша документація:" та закінчувати фразою "Сподіваюся, це допоможе." Пrompt забороняє обидва варіанти. Лише використання prompt недостатнє, як показує розділ про час виконання, тому крок після обробки допомагає усунути все, що може проскочити.
Частина 6: Підключення графу
Тепер елементи починають об’єднуватися. Саме тут LangGraph демонструє свою корисність, адже вся топологія, включаючи цикли, описується кількома рядками:
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()
Вузли та точка входу. Три функції вузлів реєструються під певними іменами, причому Генератор є точкою входу. Генератор завжди передає контроль Критику, і цей зв’язок є безумовним.
Функція add_conditional_edges під’єднує маршрутизатор до Критика. Після завершення процесу критики маршрутизатор запускається, а його відповідь шукається у таблиці зв’язків для визначення наступного вузла. Ця таблица створює цикл, оскільки content_critic може повернутися до content_generator, який знову подає дані для content_critic.
Компіляція. Виклик функції builder.compile() створює конвеєр, який можна запустити, тож весь цикл перетворюється на один виклик agent_pipeline.invoke(initial_state).
Кожен, хто коли-небудь писав такий цикл вручну, використовуючи оператор while, ручне керування станом та лічильник спроб, помітить, наскільки зменшилась кількість необхідних записів. Оголошення графа також само по собі є документацією у спосіб, якого ніколи не досягне ручно написаний цикл.
Частина 7: Час виконання, конфігурація та очищення результатів
Останньою є функція run(), яка знаходиться за скриптом консолі reflexion. Вона аналізує аргументи, завантажує конфігурацію, запускає обробку даних та очищує результати. Більша частина цього процесу, зокрема аналіз аргументів, краще розглядати у репозиторії, але два аспекти варто розглянути окремо.
Спочатку йде конфігурація. Файл config.json містить ролі моделей та налаштування циклу:
{
"model_registry": {
"GENERATOR": "qwen3.5:9b",
"CRITIC": "deepseek-coder:6.7b",
"ADJUDICATOR": "llama3:8b"
},
"quality_score_bar": 95,
"max_attempts": 4
}
Якщо файл відсутній або недоступний для читання, завантажувач використовує ці самі значення за замовчуванням, тож процес починається у будь-якому разі. Значення за замовчуванням для вхідних даних, результатів та конфігурації визначаються відносно встановленого пакету; у разі встановлення для редагування це означає кореневу директорію репозиторію. Тому запуск простого reflexion з будь-якої директорії знаходить файл config.json та папку input/ у репозиторії, тоді як параметри --input, --output та --config можуть їх змінити.
По-друге, очищення результатів, що стало необхідним під час роботи з локальними моделями:
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"
Як це працює:
- Перевірка вступного тексту. Якщо перед першим заголовком
#є текст, який має менше 200 символів, це майже напевно є вступом на кшталт „Ось остаточний документ:“ і його видаляють. Якщо текст довший за 200 символів, він залишається, оскільки це може бути справжній контент. - Перевірка тексту наприкінці. Код аналізує текст з останнього рядка вгору, по одному рядку. Рядок видаляється, якщо це схоже на побічні коментарі моделі, а не на справжній контент: залишки оцінок, примітки на кшталт „Примітка:“ чи „Сподіваюся…“, наприклад „Не виявлено помилок форматування чи суперечностей.“, які залишилися після тестового запуску в кінці іншого нормального документа. Оскільки це цикл, а не одноразова перевірка, кілька таких приміток можуть бути видалені за один прохід.
Після очищення остаточний формат Markdown зберігається у шляху виведення. Потім консоль показує підсумок виконання, включаючи всю історію оцінок та кількість ітерацій.
Спробувати
У репозиторії йдуть три зразки вхідних даних: функція для фільтрації метрик, перевірка підпису API та калькулятор складних відсотків. Документація до першої з них виглядає так:
reflexion --input input/source_function_a.py --output output/result.md --config config.json
Звідти можна побачити, як працює цей цикл. На кожному етапі Генератор оголошує про себе, Критик друкує таблицю оцінок, а Маршрутизатор визначає, чи триває цикл, чи він завершується.
Результати виконання з усіх трьох вхідних даних знаходяться у папці output/, тож можна переглянути результати роботи конвеєра без необхідності їх знову виконувати: оформлені таблиці параметрів, описи значень, що повертаються, та таблиці виниклих винятків із їхніми точними текстами повідомлень. У двох із трьох файлів є невеликі помилки, описані нижче — зазначений результат відрізняється від того, що повертає код, або ж виникає виняток там, де мав би повернутися результат. Їх залишили навмисно, щоб їх пошук став гарним вправою.
Коли щось йде не так
Іноді трапляються проблеми, і наведений нижче список складається з помилок, спостережених під час тестування. У одному випадку документація вказувала неправильний тип винятку та вигадане повідомлення про помилку, хоча в коді справді виникає TypeError із конкретним рядком тексту. У іншому випадку стверджувалося, що жоден виняток не створюється. У таблиці історії версій був рядок про „початкову версію 0.1“ з датою „TBD“, причому жоден із цих елементів не зустрічається у вихідному коді. У одному з прикладів було наведено коефіцієнт вихіду близько 0.86, тоді як правильна відповідь — 0.667. А один хороший документ закінчувався власним висновком рецензента: „Помилок форматування чи суперечностей не виявлено.“
Потім була серія, у якій система набрала 97 балів проти 95 у чотирьох раундах, попри те, що стверджувалася неготовність проєкту, а один раунд зворотного зв’язку перетворився на сотні токенів ../. А коли запит до критика став занадто різким, він оцінив переважно правильний проєкт як 0 з 100 у чотирьох раундах поспіль, обґрунтовуючи цей результат явно неправдивим твердженням щодо вихідного тексту. Критик — це не фіксований суддя та не калькулятор; він посилює будь-який настрій, який йому передає запит.
На щастя, збої групуються, і кожна група має спеціальні заходи мінімізації в коді. Збої з контентом, такі як неправильні винятки та помилки в арифметиці в документації, обробляються за допомогою спеціальних перевірок, передбачених у механізмі критика. Збої калібрування (надмірно лояльні оцінки з одного боку та надмірно низькі оцінки з іншого) вирішуються за допомогою процедури оцінювання, заснованої на вирахуваннях, а також шляхом переміщення обчислень середнього значення та порогових значень з моделі до коду на мові Python у вузлі критика. Збої у вихідних даних, підписах та залишкових вердиктах перегляду обробляються за допомогою функції постскрипту, а деградовані повідомлення від користувачів — за допомогою механізму фільтрації.
Жоден з цих факторів не робить цей цикл абсолютно надійним, адже інструкція у запиті є лише проханням, а не доказом. Коли після всіх цих заходів оцінки все одно змінюються, найефективнішим інструментом є сама модель, а не запит. Критик має найскладнішу роботу в системі, а заміна запису CRITIC у файлі config.json на потужнішу універсальну модель — це зміна, яка потребує лише одного рядка та не вимагає написання коду.
Цей цикл надійно створює значно кращий документ, ніж одноразовий запит до того ж генератора, а історія оцінок дає чесний запис про те, як цього досягнуто. Якщо під час виконання постійно досягається максимальна кількість спроб, варто налаштувати два параметри: quality_score_bar, значення якого може бути нижчим за 95, та max_attempts, значення якого може бути вищим за 4.
Об’єднання елементів
Результатом є однофайловий підхід, який складається приблизно з 360 рядків та включає:
- Спільний стан та структурована схема оцінки для критика, при цьому обчислення середнього значення та рішення про прийняття виконаної роботи відбувається у Python, а не моделлю.
- Чернетка, написана однією локальною моделлю та перевірена іншою, щоб жодна модель не оцінювала власну роботу.
- Маршрутизатор, який повертає чернетки для додаткової корекції, доки не буде досягнуто необхідної якості або не закінчаться спроби.
- Використовується третя модель як остаточний арбітр для вирішення суперечностей, що залишилися після кількох раундів корекції.
- Видаляє розмовні вступи та додатки з кінцевого формату Markdown перед збереженням його на диск.
- Налаштування зберігаються повністю у невеликому файлі
config.json, тому зміна моделей чи послаблення умов роботи не вимагає редагування коду.
Є можливість його розширити. Критик може оцінювати кілька параметрів за шкалою, а маршрутизатор обирає найслабший з них для виправлення замість однієї загальної корекції. Четвертий вузол може запускати приклади коду з документації та повертати реальні помилки назад у цикл, виявляючи хибні твердження, які залишаються непоміченими критиком. Або ж та сама структура з генератором, критиком та оцінювачем може використовуватися для зовсім іншої задачі, наприклад перекладу чи створення тестів, адже ніщо в цій схемі не є специфічним для документації.
Тут переваги фреймворків стають очевидними: вони реально корисні тоді, коли робочий процес має справжні цикли, гілки та спільний стан, і цей пайплайн відповідає цьому опису; LangGraph взяв на себе ті частини, які ніхто не хоче реалізовувати вручну, а саме об’єднання станів, умовне направлення та сам цикл. Спочатку створення легких версій допомагає зрозуміти, які частини фреймворку виконують реальну роботу, і саме такий порядок варто рекомендувати.