Проблема: многошаговые вопросы ломают одношаговые агенты

Возьмём конкретный вопрос: «Кто дольше прожил — автор "Мастера и Маргариты" или автор "Войны и мира"?» Чтобы ответить, нужно:

  1. Узнать автора «Мастера и Маргариты» → Михаил Булгаков (1891–1940, 49 лет)
  2. Узнать автора «Войны и мира» → Лев Толстой (1828–1910, 82 года)
  3. Сравнить продолжительность жизни → Толстой прожил дольше

Это multi-hop вопрос — каждый «прыжок» (hop) требует отдельного факта. LLM, пытающийся ответить напрямую, либо угадывает, либо путает детали. Проблема не в знаниях — в том, что нет явного механизма для последовательного сбора промежуточных фактов.

Self-Ask (Press et al., 2022) решает это просто: сначала разбей вопрос на части, потом отвечай на каждую часть по отдельности. Декомпозиция и ответ — разные когнитивные задачи, и их разделение резко улучшает точность.

📄 Откуда паттерн

Описан в статье «Measuring and Narrowing the Compositionality Gap in Language Models» (Press et al., 2022). Авторы показали, что явная декомпозиция через «Follow up:» / «Intermediate answer:» улучшает точность на multi-hop датасетах (Bamboogle, MuSiQue) на 20–40% по сравнению с прямым ответом. Особенно эффективно в связке с поиском.

Как работает Self-Ask: оригинальный формат и диаграмма

В оригинальной статье Self-Ask реализован как промптинговый паттерн: модель обучается через few-shot примеры выводить специальные префиксы — Follow up:, Intermediate answer: и финальное So the final answer is:. Агент парсит этот текст и при встрече «Follow up:» вызывает поиск.

Q: Кто дольше прожил — автор «Мастера и Маргариты» или «Войны и мира»?
Are follow up questions needed here? Yes.

Follow up: Кто написал «Мастер и Маргарита»?
Intermediate answer: Михаил Булгаков, жил 1891–1940 (49 лет).

Follow up: Кто написал «Войну и мир»?
Intermediate answer: Лев Толстой, жил 1828–1910 (82 года).

So the final answer is: Лев Толстой прожил дольше — 82 года против 49 лет у Булгакова.

Архитектура состоит из трёх фаз: декомпозиция исходного вопроса, последовательные ответы на каждый подвопрос (с учётом зависимостей), финальный синтез.

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
① ДЕКОМПОЗИЦИЯ ② ОТВЕТЫ НА ПОДВОПРОСЫ ③ СИНТЕЗ Вопрос Декомпозитор LLM Подвопрос 1 Ответ 1 Подвопрос 2 Ответ 2 Подвопрос 3 Ответ 3 зависит Синтезатор LLM Финальный ответ

Подвопросы могут быть независимыми (стрелки 1 и 2 можно выполнять параллельно) или зависимыми (подвопрос 3 требует ответов из 1 и 2). Граф зависимостей определяет порядок выполнения — подробнее в разделе про зависимости.

⚠️ Почему не просто ReAct?

ReAct хорошо справляется с задачами поиска данных, но декомпозицию вопроса делает неявно — через «думаю, что нужно узнать X, потом Y». Self-Ask делает декомпозицию явной структурой, которую можно проверить, переупорядочить и выполнить параллельно. Это особенно важно для вопросов с 4+ прыжками.

Анатомия хорошей декомпозиции

Качество Self-Ask полностью определяется качеством декомпозиции. Хороший подвопрос — атомарный (один факт), конкретный (не «расскажи об X», а «когда основана X»), и самодостаточный (или явно указывает на зависимость от другого подвопроса).

Плохая декомпозиция
Q: «Сколько лет между основанием Tesla и SpaceX?»

1. Расскажи о Tesla и SpaceX 2. Сравни даты основания — Подвопрос 1 не атомарный, ответ 2 не нужен
Хорошая декомпозиция
Q: «Сколько лет между основанием Tesla и SpaceX?»

1. В каком году основана Tesla? → 2003 2. В каком году основана SpaceX? → 2002 3. [зависит от 1,2] 2003 − 2002 = ? → 1 год

Три признака атомарного подвопроса:

один факт
Ответ — одно значение: число, имя, дата, «да/нет». Если нужно назвать несколько вещей — это уже не атомарный вопрос.
проверяем
Ответ можно найти в одном поиске, в одном запросе к БД, или вычислить одной операцией. Не требует собственной декомпозиции.
явные зависимости
Если для ответа нужен результат другого подвопроса — это указано в depends_on. Скрытые зависимости приводят к ошибкам порядка исполнения.

Полная реализация

Современная реализация Self-Ask заменяет парсинг текстовых префиксов структурированным инструментом декомпозиции. Это надёжнее и не зависит от точного форматирования вывода модели.

Инструмент декомпозиции

from dataclasses import dataclass, field
import anthropic

client = anthropic.Anthropic()


@dataclass
class SubQuestion:
    id: int
    question: str
    depends_on: list[int]
    answer: str = ""


DECOMPOSE_TOOL = {
    "name": "decompose_question",
    "description": (
        "Декомпозируй сложный вопрос на простые атомарные подвопросы. "
        "Каждый подвопрос должен отвечаться за один поиск или вычисление. "
        "Если вопрос простой и ответ известен напрямую — верни is_complex=false."
    ),
    "input_schema": {
        "type": "object",
        "properties": {
            "is_complex": {
                "type": "boolean",
                "description": "True если вопрос требует нескольких промежуточных фактов"
            },
            "sub_questions": {
                "type": "array",
                "items": {
                    "type": "object",
                    "properties": {
                        "id": {"type": "integer"},
                        "question": {
                            "type": "string",
                            "description": "Атомарный вопрос с одним конкретным ответом"
                        },
                        "depends_on": {
                            "type": "array",
                            "items": {"type": "integer"},
                            "description": "ID подвопросов, чьи ответы нужны для этого"
                        }
                    },
                    "required": ["id", "question", "depends_on"]
                }
            }
        },
        "required": ["is_complex", "sub_questions"]
    }
}


def decompose(question: str) -> list[SubQuestion]:
    """Возвращает список подвопросов или пустой список если вопрос простой."""
    response = client.messages.create(
        model="claude-opus-4-6",
        max_tokens=512,
        system=(
            "Ты аналитик, декомпозирующий multi-hop вопросы.\n"
            "Подвопрос атомарный = один факт, одна дата, одно имя.\n"
            "Каждый depends_on точно отражает реальную зависимость: "
            "подвопрос 3 зависит от 1 только если ответ 1 входит в текст вопроса 3."
        ),
        tools=[DECOMPOSE_TOOL],
        tool_choice={"type": "tool", "name": "decompose_question"},
        messages=[{"role": "user", "content": question}]
    )
    for block in response.content:
        if block.type == "tool_use" and block.name == "decompose_question":
            data = block.input
            if not data.get("is_complex"):
                return []
            return [
                SubQuestion(
                    id=sq["id"],
                    question=sq["question"],
                    depends_on=sq.get("depends_on", [])
                )
                for sq in data.get("sub_questions", [])
            ]
    return []

Ответ на подвопрос и синтез

def answer_sub_question(sub_q: SubQuestion, answers: dict[int, str]) -> str:
    """Отвечает на один подвопрос, передавая промежуточные ответы как контекст."""
    context = ""
    if sub_q.depends_on:
        deps = "\n".join(
            f"  - Подвопрос {dep_id}: {answers[dep_id]}"
            for dep_id in sub_q.depends_on
            if dep_id in answers
        )
        context = f"\n\nПромежуточные ответы:\n{deps}"

    response = client.messages.create(
        model="claude-opus-4-6",
        max_tokens=200,
        system="Отвечай кратко и конкретно: только факт, без лишних слов.",
        messages=[{
            "role": "user",
            "content": f"Вопрос: {sub_q.question}{context}"
        }]
    )
    return response.content[0].text.strip()


def synthesize(question: str, sub_qs: list[SubQuestion]) -> str:
    """Собирает финальный ответ из промежуточных ответов."""
    facts = "\n".join(
        f"  {sq.id}. {sq.question}\n     Ответ: {sq.answer}"
        for sq in sub_qs
    )
    response = client.messages.create(
        model="claude-opus-4-6",
        max_tokens=1024,
        system="Синтезируй собранные факты в связный ответ на исходный вопрос.",
        messages=[{
            "role": "user",
            "content": f"Исходный вопрос: {question}\n\nСобранные факты:\n{facts}"
        }]
    )
    return response.content[0].text


def self_ask(question: str) -> str:
    """
    Self-Ask агент:
    1. Декомпозирует вопрос на атомарные подвопросы
    2. Отвечает на каждый по порядку (с учётом зависимостей)
    3. Синтезирует финальный ответ
    """
    # Шаг 1: Декомпозиция
    sub_qs = decompose(question)

    if not sub_qs:
        # Простой вопрос — отвечаем напрямую
        simple = SubQuestion(0, question, [])
        return answer_sub_question(simple, {})

    print(f"[Self-Ask] {len(sub_qs)} подвопросов:")

    # Шаг 2: Ответы в порядке зависимостей
    answers: dict[int, str] = {}
    for sq in topological_sort(sub_qs):
        print(f"  [{sq.id}] {sq.question}")
        sq.answer = answer_sub_question(sq, answers)
        answers[sq.id] = sq.answer
        print(f"       → {sq.answer[:80]}")

    # Шаг 3: Синтез
    return synthesize(question, sub_qs)


# Пример использования
result = self_ask(
    "Кто дольше прожил — автор «Мастера и Маргариты» или «Войны и мира»?"
)
print(result)

Вот как выглядит полная трассировка для конкретного примера:

Вопрос
Кто основал компании Tesla и SpaceX, и сколько лет между их основанием?
1 Кто основал Tesla и в каком году?
Элон Маск (совместно с Мартином Эберхардом и др.), 2003
2 Кто основал SpaceX и в каком году?
Элон Маск, 2002
3 Сколько лет между 2002 (SpaceX) и 2003 (Tesla)? зависит от ①②
1 год (SpaceX основана на год раньше Tesla)
Финальный ответ
Обе компании основал Элон Маск. SpaceX он основал в 2002 году, Tesla — в 2003 году совместно с другими соучредителями. Между их основанием ровно 1 год.

Реальная сила Self-Ask — в связке с инструментом поиска. Когда модель не знает ответа на атомарный подвопрос (актуальные данные, специфические факты), она вызывает поиск. Для этого меняем answer_sub_question на версию с инструментами:

SEARCH_TOOL = {
    "name": "web_search",
    "description": "Поиск актуальной информации в интернете",
    "input_schema": {
        "type": "object",
        "properties": {
            "query": {
                "type": "string",
                "description": "Краткий поисковый запрос, 3–7 слов"
            }
        },
        "required": ["query"]
    }
}


def answer_with_search(sub_q: SubQuestion, answers: dict[int, str]) -> str:
    """Отвечает на подвопрос с возможностью поиска (один шаг tool use)."""
    context = ""
    if sub_q.depends_on:
        deps = "\n".join(
            f"  - Подвопрос {d}: {answers[d]}"
            for d in sub_q.depends_on if d in answers
        )
        context = f"\n\nКонтекст:\n{deps}"

    messages = [{
        "role": "user",
        "content": f"{sub_q.question}{context}"
    }]

    # Первый вызов: модель решает — использовать поиск или ответить напрямую
    response = client.messages.create(
        model="claude-opus-4-6",
        max_tokens=256,
        system=(
            "Отвечай кратко. Для точных фактов, дат и актуальных данных "
            "используй поиск. Для простых вычислений — отвечай напрямую."
        ),
        tools=[SEARCH_TOOL],
        messages=messages
    )

    # Если модель вызвала поиск — выполняем и получаем финальный ответ
    for block in response.content:
        if block.type == "tool_use" and block.name == "web_search":
            search_result = run_search(block.input["query"])  # ваша реализация поиска
            messages.append({"role": "assistant", "content": response.content})
            messages.append({"role": "user", "content": [
                {"type": "tool_result", "tool_use_id": block.id, "content": search_result}
            ]})
            final = client.messages.create(
                model="claude-opus-4-6",
                max_tokens=200,
                tools=[SEARCH_TOOL],
                messages=messages
            )
            return final.content[0].text.strip()

    # Модель ответила без поиска
    return response.content[0].text.strip()


def run_search(query: str) -> str:
    """Заглушка — замените реальным поиском (DuckDuckGo, Tavily, SerpAPI и т.д.)."""
    # import httpx
    # resp = httpx.get("https://api.tavily.com/search", params={"query": query, "api_key": ...})
    # return resp.json()["results"][0]["content"]
    return f"[результаты поиска для: {query}]"
💡 tool_choice: "auto" для поиска

В answer_with_search используем tool_choice по умолчанию (авто) — модель сама решает, нужен ли поиск. Это правильно: простые арифметические подвопросы («сколько лет между 2002 и 2003?») не требуют поиска, фактические — требуют. Принудительный поиск для всех подвопросов удваивает стоимость без пользы.

Граф зависимостей и топологический порядок

Поле depends_on задаёт ориентированный ациклический граф (DAG). Перед выполнением цикла нужно упорядочить подвопросы так, чтобы все зависимости шли перед зависящими — это топологическая сортировка:

def topological_sort(sub_qs: list[SubQuestion]) -> list[SubQuestion]:
    """Сортирует подвопросы в порядке зависимостей (зависимости — раньше)."""
    id_to_sq = {sq.id: sq for sq in sub_qs}
    visited: set[int] = set()
    result: list[SubQuestion] = []

    def visit(sq_id: int) -> None:
        if sq_id in visited:
            return
        visited.add(sq_id)
        for dep_id in id_to_sq[sq_id].depends_on:
            if dep_id in id_to_sq:
                visit(dep_id)
        result.append(id_to_sq[sq_id])

    for sq in sub_qs:
        visit(sq.id)

    return result

Независимые подвопросы (с depends_on: []) можно выполнять параллельно — это даёт ощутимый прирост скорости при 3+ подвопросах:

import asyncio


async def answer_async(sub_q: SubQuestion, answers: dict[int, str]) -> tuple[int, str]:
    """Async-обёртка над answer_sub_question для параллельного выполнения."""
    loop = asyncio.get_event_loop()
    result = await loop.run_in_executor(None, answer_sub_question, sub_q, answers)
    return sub_q.id, result


async def self_ask_parallel(question: str) -> str:
    """Self-Ask с параллельным выполнением независимых подвопросов."""
    sub_qs = decompose(question)
    if not sub_qs:
        return answer_sub_question(SubQuestion(0, question, []), {})

    answers: dict[int, str] = {}
    completed: set[int] = set()
    id_to_sq = {sq.id: sq for sq in sub_qs}

    while len(completed) < len(sub_qs):
        # Находим подвопросы, все зависимости которых уже отвечены
        ready = [
            sq for sq in sub_qs
            if sq.id not in completed
            and all(dep in completed for dep in sq.depends_on)
        ]

        if not ready:
            break  # цикл зависимостей — не должно случаться с корректным DAG

        # Отвечаем на все готовые подвопросы параллельно
        tasks = [answer_async(sq, answers) for sq in ready]
        results = await asyncio.gather(*tasks)

        for sq_id, answer in results:
            answers[sq_id] = answer
            id_to_sq[sq_id].answer = answer
            completed.add(sq_id)

    return synthesize(question, sub_qs)


# Запуск
result = asyncio.run(self_ask_parallel(
    "Кто дольше прожил — автор «Мастера и Маргариты» или «Войны и мира»?"
))
💡 Когда параллельность выгодна

При трёх независимых подвопросах с задержкой ~1 с каждый: последовательно — 3 с, параллельно — ~1 с. Выгода растёт линейно с числом независимых подвопросов. Зависимые подвопросы всё равно выполняются последовательно — у них нет выбора.

Когда применять Self-Ask

Self-Ask уместен
  • Multi-hop вопросы: 2+ независимых факта в одном вопросе
  • Исследовательские задачи с поиском
  • Сравнение нескольких объектов по одному критерию
  • Вопросы с числовыми вычислениями поверх найденных фактов
  • Когда нужна прозрачность: видно, какие факты собраны
Self-Ask избыточен
  • Простые вопросы — одношаговые, декомпозиция не нужна
  • Задачи с инструментами действия (файлы, API, код) — лучше ReAct
  • Длинные планы с непредсказуемым исполнением — лучше Plan-and-Execute
  • Вопросы, где все факты известны модели без поиска
  • Realtime-чат с жёсткими требованиями к latency

Сравнение с другими паттернами

Критерий ReAct Plan-and-Execute Reflection Self-Ask
Цель Действия с инструментами Сложные многошаговые задачи Качество ответа Сборка фактов для ответа
Структура Thought→Action→Obs loop Планировщик → шаги → синтез Generate→Critique→Revise Декомпозиция → ответы → синтез
Параллельность нет да нет да
Требует инструментов обязательно обычно нет опционально
Прозрачность средняя высокая средняя высокая
Лучше всего для Поиск, расчёты, API Отчёты, длинные задачи Написание, код Multi-hop QA, исследования
🔗 Self-Ask + Reflection = точность + качество

Self-Ask собирает правильные факты. Reflection улучшает то, как они изложены. Комбинация: сначала self_ask() для сбора фактов, потом critique_and_revise() для финального текста — хорошо работает для исследовательских отчётов.

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

Неатомарные подвопросы
«Расскажи о жизни и творчестве автора» — это не один факт. Модель отвечает эссе, из которого потом сложно извлечь нужное для зависимого подвопроса.
→ В промпте и примерах явно укажи: «каждый подвопрос — один конкретный факт, дата или число».
Скрытые зависимости
Подвопрос 3 формулируется как «В каком году родился [человек из подвопроса 1]?», но depends_on: []. При параллельном исполнении подвопрос 3 запустится раньше ответа на подвопрос 1 — и получит неверный контекст.
→ Явно указывай depends_on при любом упоминании «ответа подвопроса N» в тексте вопроса.
Избыточная декомпозиция
Простой вопрос «Когда основана Tesla?» разбивается на 3 подвопроса: «Что такое Tesla?», «Кто её основал?», «В каком году это произошло?». Это тратит токены и добавляет задержку без пользы.
→ Используй is_complex=false для прямых однофактовых вопросов и обрабатывай их отдельным путём.
Синтез без контекста подвопросов
Синтезатор получает только финальные ответы без формулировок самих подвопросов. Он не знает, что именно означает «2002» или «Элон Маск» в контексте задачи.
→ Передавай синтезатору пары вопрос–ответ, не просто ответы.
Цикл зависимостей (circular deps)
Подвопрос 1 зависит от 2, а 2 зависит от 1. Топологическая сортировка зависает или бросает ошибку. LLM иногда генерирует такие циклы при плохом промпте.
→ Добавь проверку на циклы после декомпозиции и при обнаружении — запроси повторную декомпозицию или упрости зависимости.

Шпаргалка

  • Self-Ask = Decompose → Answer each → Synthesize
  • Атомарный подвопрос = один факт, одна дата, одно имя
  • tool_choice: "tool" + DECOMPOSE_TOOL → структурированная декомпозиция
  • depends_on: [] → независимые подвопросы → параллельное исполнение
  • depends_on: [1, 2] → ответ зависит от 1 и 2 → после них в порядке
  • Топологическая сортировка гарантирует правильный порядок для зависимых подвопросов
  • Синтезатор получает пары «вопрос + ответ», не просто ответы
  • is_complex=false → простой вопрос → ответ напрямую без декомпозиции
  • Поиск подключается как обычный tool в answer_sub_question, не в декомпозиторе
  • Self-Ask + Reflection: собрать факты → улучшить изложение

Практические задания

  1. Multi-hop биографии. Возьми 5 вопросов вида «Кто старше — X или Y?» (актёры, учёные, политики). Реализуй self_ask() с поиском через Wikipedia API. Сравни ответы напрямую (без декомпозиции) и через Self-Ask. Посчитай процент верных ответов в обоих случаях.
  2. Параллельная декомпозиция. Реализуй self_ask_parallel() через asyncio. Возьми вопрос с 4 независимыми подвопросами и замерь время выполнения последовательной и параллельной версии. Покажи, что время ≈ max(t_i), а не sum(t_i).
  3. Self-Ask + Reflection. Реализуй research_and_write(topic: str): сначала Self-Ask собирает ключевые факты по теме (5–7 подвопросов), затем Reflection итеративно улучшает итоговый текст. Критерий критика: фактическая точность, структура, читаемость. Сравни результат с прямым «напиши статью о {topic}» без декомпозиции.