Почему один проход — это мало
Когда LLM генерирует ответ за один заход, ей не на что опереться, кроме промпта: она выдаёт «первое, что пришло в голову». Нет шага, на котором модель посмотрела бы на свой текст критически. А ведь LLM умеет критиковать — попроси её оценить чужой текст, и она найдёт слабые места. Идея Reflection: дать модели оценить собственную работу и переписать с учётом замечаний.
Это разделение на две роли — ключевое. Генератор создаёт контент. Критик (рефлектор) смотрит на него свежим взглядом и выдаёт конкретную, действенную обратную связь: что улучшить, чего не хватает, где ошибка. Потом генератор переписывает с учётом критики. И так по кругу.
Сила не в том, что мы просим «напиши хорошо», а в отдельном шаге оценки. Критик работает с уже готовым текстом — ему есть что разбирать, и его замечания предметны. Эта обратная связь и двигает качество вверх от итерации к итерации.
Два актёра: генератор и критик
В простейшем виде оба актёра — это одна и та же модель с разными промптами (системными ролями). Генератору говорят «ты автор, напиши/перепиши», критику — «ты строгий редактор, найди недостатки и дай конкретные правки». Цикл выглядит так:
Состояние графа держит всё, что нужно циклу: исходную задачу, текущий черновик, последнюю критику и счётчик итераций (чтобы не крутиться вечно — урок про conditional edges и recursion_limit).
Узлы generate и reflect
Опишем состояние и оба узла. Генератор на первой итерации пишет с нуля, а на последующих — переписывает с учётом критики. Критик возвращает обратную связь и сигнал готовности.
from typing import TypedDict
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o-mini")
class State(TypedDict):
task: str # что написать
draft: str # текущий черновик
critique: str # последняя критика
iterations: int # счётчик доработок
def generate(state: State) -> dict:
if not state.get("draft"):
prompt = f"Ты автор. Напиши текст по задаче:\n{state['task']}"
else:
prompt = (f"Ты автор. Перепиши текст, устранив замечания.\n\n"
f"Задача: {state['task']}\n\nТекущий текст:\n{state['draft']}\n\n"
f"Замечания редактора:\n{state['critique']}")
draft = llm.invoke(prompt).content
return {"draft": draft, "iterations": state.get("iterations", 0) + 1}
def reflect(state: State) -> dict:
prompt = (f"Ты строгий редактор. Оцени текст по задаче «{state['task']}». "
f"Дай КОНКРЕТНЫЕ правки. Если текст уже отличный — ответь ровно 'APPROVE'.\n\n"
f"Текст:\n{state['draft']}")
return {"critique": llm.invoke(prompt).content}
«Сделай лучше» бесполезно — генератору не за что зацепиться. Проси у критика предметные правки: что добавить, что убрать, где ошибка. Чем конкретнее обратная связь, тем сильнее улучшение на следующей итерации. И заведи явный сигнал готовности (APPROVE), чтобы цикл знал, когда остановиться по существу, а не только по лимиту.
Критерий остановки и сборка
Главная опасность Reflection — бесконечная придирчивость: критик всегда найдёт, к чему прицепиться. Поэтому нужны два условия выхода: критик доволен (APPROVE) или исчерпан лимит итераций. Их и проверяет маршрутизатор.
from langgraph.graph import StateGraph, START, END
MAX_ITERS = 3
def should_continue(state: State) -> str:
if "APPROVE" in state["critique"]: # критик доволен
return "end"
if state["iterations"] >= MAX_ITERS: # исчерпан лимит
return "end"
return "revise" # ещё круг
builder = StateGraph(State)
builder.add_node("generate", generate)
builder.add_node("reflect", reflect)
builder.add_edge(START, "generate")
builder.add_edge("generate", "reflect") # черновик → на критику
builder.add_conditional_edges("reflect", should_continue,
{"revise": "generate", "end": END}) # критика → доработка / выход
graph = builder.compile()
result = graph.invoke({"task": "Объясни рекурсию новичку", "iterations": 0})
print(result["draft"]) # финальный, доработанный текст
print("итераций:", result["iterations"])
Без MAX_ITERS цикл может крутиться, пока критик каждый раз требует «ещё чуть-чуть». Это и дорого (каждая итерация — два вызова LLM), и бессмысленно — после 2–3 проходов прирост качества падает. Жёсткий лимит + сигнал APPROVE — обязательная пара. На совсем крайний случай оставь recursion_limit как аварийный тормоз.
Цена качества: баланс
Reflection — это компромисс «качество против стоимости и задержки». Каждая итерация = вызов генератора + вызов критика, то есть один проход цикла удваивает расход относительно одиночной генерации. Поэтому паттерн оправдан там, где качество важнее скорости и цены.
- генерация кода (есть что вычитывать)
- важные тексты: статьи, письма, отчёты
- задачи с чёткими критериями качества
- простые/фактологические ответы
- где важна низкая задержка (чат в реальном времени)
- когда нет внятного критерия «хорошо»
Self-reflection — генератор и критик это одна модель с разными промптами (наш случай). Отдельный критик — для критики берут другую (часто более сильную) модель. Reflexion — критик опирается не только на текст, но и на внешний сигнал: результаты тестов для кода, выдачу инструментов, проверку фактов. Последний вариант объединяет Reflection с ReAct из прошлого урока.
Типичные ошибки
Критик всегда найдёт замечание — без MAX_ITERS и сигнала APPROVE цикл не остановится. Всегда два условия выхода.
Если критик выдаёт «можно лучше» без конкретики, генератору не на что опереться, и качество не растёт. Промпт критика должен требовать предметные правки.
Частая ошибка — на доработке передать только задачу, забыв state["critique"]. Тогда модель пишет «с нуля» и игнорирует замечания. Генератор обязан получать и текущий черновик, и критику.
Удваивать стоимость и задержку ради простого ответа — расточительно. Включай рефлексию выборочно, для задач, где качество реально важно.
Шпаргалка
# Состояние: задача + черновик + критика + счётчик
class State(TypedDict):
task: str; draft: str; critique: str; iterations: int
# generate: пишет с нуля ИЛИ переписывает с учётом критики
def generate(state):
... # ВАЖНО: на доработке передавай draft + critique
return {"draft": ..., "iterations": state.get("iterations", 0) + 1}
# reflect: конкретные правки ИЛИ сигнал 'APPROVE'
def reflect(state):
return {"critique": ...}
# Остановка: критик доволен ИЛИ лимит
def should_continue(state):
if "APPROVE" in state["critique"]: return "end"
if state["iterations"] >= MAX_ITERS: return "end"
return "revise"
# Граф-цикл: generate → reflect → (revise→generate | end)
builder.add_edge(START, "generate")
builder.add_edge("generate", "reflect")
builder.add_conditional_edges("reflect", should_continue,
{"revise": "generate", "end": END})
# Правила:
# • ДВА условия выхода: APPROVE + MAX_ITERS
# • критика конкретная и действенная
# • генератор видит и черновик, и критику
# • применяй там, где качество > скорости/цены
Практическое задание
Собери самокритичного агента:
Задание: Reflection-писатель
- Собери граф
generate → reflectс циклом и состояниемtask / draft / critique / iterations. Задача — короткое эссе на заданную тему. - Сделай промпт критика строгим и конкретным, с сигналом
APPROVE. Промпт генератора на доработке должен включать и черновик, и критику. - Поставь
MAX_ITERS=3. Запусти и посмотри черезstream, как черновик меняется от итерации к итерации. - Сравни первый черновик и финальный — стало ли заметно лучше.
- Убери условие
APPROVEи убедись, что агент всегда доходит до лимита (критик не сдаётся) — это иллюстрирует, зачем нужен явный сигнал готовности. - Со звёздочкой: сделай «Reflexion для кода» — генератор пишет функцию, критик прогоняет её на тестах (через инструмент) и в критику кладёт реальные ошибки выполнения, а не только стилистику.