Почему один проход — это мало

Когда LLM генерирует ответ за один заход, ей не на что опереться, кроме промпта: она выдаёт «первое, что пришло в голову». Нет шага, на котором модель посмотрела бы на свой текст критически. А ведь LLM умеет критиковать — попроси её оценить чужой текст, и она найдёт слабые места. Идея Reflection: дать модели оценить собственную работу и переписать с учётом замечаний.

Это разделение на две роли — ключевое. Генератор создаёт контент. Критик (рефлектор) смотрит на него свежим взглядом и выдаёт конкретную, действенную обратную связь: что улучшить, чего не хватает, где ошибка. Потом генератор переписывает с учётом критики. И так по кругу.

ℹ️ Reflection ≠ «просто попроси лучше»

Сила не в том, что мы просим «напиши хорошо», а в отдельном шаге оценки. Критик работает с уже готовым текстом — ему есть что разбирать, и его замечания предметны. Эта обратная связь и двигает качество вверх от итерации к итерации.

Два актёра: генератор и критик

В простейшем виде оба актёра — это одна и та же модель с разными промптами (системными ролями). Генератору говорят «ты автор, напиши/перепиши», критику — «ты строгий редактор, найди недостатки и дай конкретные правки». Цикл выглядит так:

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
START generate пишет / правит черновик reflect критикует, даёт правки достаточно? / лимит END черновик → ок → END критика → доработать (iteration += 1) цикл крутится, пока критик не одобрит или не достигнут лимит итераций

Состояние графа держит всё, что нужно циклу: исходную задачу, текущий черновик, последнюю критику и счётчик итераций (чтобы не крутиться вечно — урок про conditional edges и recursion_limit).

Узлы generate и reflect

Опишем состояние и оба узла. Генератор на первой итерации пишет с нуля, а на последующих — переписывает с учётом критики. Критик возвращает обратную связь и сигнал готовности.

Состояние и два узла-актёра
python
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) или исчерпан лимит итераций. Их и проверяет маршрутизатор.

Маршрутизатор + сборка графа с циклом
python
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 из прошлого урока.

Типичные ошибки

Ошибка 1: нет лимита итераций

Критик всегда найдёт замечание — без MAX_ITERS и сигнала APPROVE цикл не остановится. Всегда два условия выхода.

Ошибка 2: расплывчатая критика

Если критик выдаёт «можно лучше» без конкретики, генератору не на что опереться, и качество не растёт. Промпт критика должен требовать предметные правки.

Ошибка 3: генератор не видит критику

Частая ошибка — на доработке передать только задачу, забыв state["critique"]. Тогда модель пишет «с нуля» и игнорирует замечания. Генератор обязан получать и текущий черновик, и критику.

Ошибка 4: Reflection на всё подряд

Удваивать стоимость и задержку ради простого ответа — расточительно. Включай рефлексию выборочно, для задач, где качество реально важно.

Шпаргалка

Reflection — всё в одном месте
python
# Состояние: задача + черновик + критика + счётчик
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-писатель

  1. Собери граф generate → reflect с циклом и состоянием task / draft / critique / iterations. Задача — короткое эссе на заданную тему.
  2. Сделай промпт критика строгим и конкретным, с сигналом APPROVE. Промпт генератора на доработке должен включать и черновик, и критику.
  3. Поставь MAX_ITERS=3. Запусти и посмотри через stream, как черновик меняется от итерации к итерации.
  4. Сравни первый черновик и финальный — стало ли заметно лучше.
  5. Убери условие APPROVE и убедись, что агент всегда доходит до лимита (критик не сдаётся) — это иллюстрирует, зачем нужен явный сигнал готовности.
  6. Со звёздочкой: сделай «Reflexion для кода» — генератор пишет функцию, критик прогоняет её на тестах (через инструмент) и в критику кладёт реальные ошибки выполнения, а не только стилистику.

Что дальше