Создание агента для самокоррекции документации с использованием LangGraph и Ollama
Местная система обработки, в которой одна модель составляет документацию к Python API, вторая её проверяет, а третья согласовывает результаты, объединённые в цикл 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. Местные модели предпочитают начинать с фразы «Конечно! Вот ваша документация» и заканчивать фразой «Надеюсь, это поможет». Приказ запрещает оба варианта. Одного лишь задания недостаточно, как показано в разделе с параметрами выполнения, поэтому необходим шаг постобработки для устранения всего, что могло проскочить.
Часть 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 берет на себя те части, которые никто не хочет реализовывать вручную — а именно объединение состояний, условное направление выполнения и сам цикл. Сначала создание упрощенных версий позволяет ясно понять, какие компоненты фреймворка выполняют реальную работу, и такой порядок действий стоит рекомендовать.