Проблема: многошаговые вопросы ломают одношаговые агенты
Возьмём конкретный вопрос: «Кто дольше прожил — автор "Мастера и Маргариты" или автор "Войны и мира"?» Чтобы ответить, нужно:
- Узнать автора «Мастера и Маргариты» → Михаил Булгаков (1891–1940, 49 лет)
- Узнать автора «Войны и мира» → Лев Толстой (1828–1910, 82 года)
- Сравнить продолжительность жизни → Толстой прожил дольше
Это 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:» вызывает поиск.
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 лет у Булгакова.
Архитектура состоит из трёх фаз: декомпозиция исходного вопроса, последовательные ответы на каждый подвопрос (с учётом зависимостей), финальный синтез.
Подвопросы могут быть независимыми (стрелки 1 и 2 можно выполнять параллельно) или зависимыми (подвопрос 3 требует ответов из 1 и 2). Граф зависимостей определяет порядок выполнения — подробнее в разделе про зависимости.
ReAct хорошо справляется с задачами поиска данных, но декомпозицию вопроса делает неявно — через «думаю, что нужно узнать X, потом Y». Self-Ask делает декомпозицию явной структурой, которую можно проверить, переупорядочить и выполнить параллельно. Это особенно важно для вопросов с 4+ прыжками.
Анатомия хорошей декомпозиции
Качество Self-Ask полностью определяется качеством декомпозиции. Хороший подвопрос — атомарный (один факт), конкретный (не «расскажи об X», а «когда основана X»), и самодостаточный (или явно указывает на зависимость от другого подвопроса).
1. Расскажи о Tesla и SpaceX 2. Сравни даты основания — Подвопрос 1 не атомарный, ответ 2 не нужен
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)
Вот как выглядит полная трассировка для конкретного примера:
Интеграция с поиском
Реальная сила 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}]"
В 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
- Multi-hop вопросы: 2+ независимых факта в одном вопросе
- Исследовательские задачи с поиском
- Сравнение нескольких объектов по одному критерию
- Вопросы с числовыми вычислениями поверх найденных фактов
- Когда нужна прозрачность: видно, какие факты собраны
- Простые вопросы — одношаговые, декомпозиция не нужна
- Задачи с инструментами действия (файлы, 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() для сбора фактов, потом
critique_and_revise() для финального текста — хорошо работает
для исследовательских отчётов.
Типичные ошибки
depends_on: []. При параллельном исполнении подвопрос 3 запустится
раньше ответа на подвопрос 1 — и получит неверный контекст.
is_complex=false для прямых однофактовых вопросов и обрабатывай их отдельным путём.Шпаргалка
- 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: собрать факты → улучшить изложение
Практические задания
-
Multi-hop биографии.
Возьми 5 вопросов вида «Кто старше — X или Y?» (актёры, учёные, политики).
Реализуй
self_ask()с поиском через Wikipedia API. Сравни ответы напрямую (без декомпозиции) и через Self-Ask. Посчитай процент верных ответов в обоих случаях. -
Параллельная декомпозиция.
Реализуй
self_ask_parallel()черезasyncio. Возьми вопрос с 4 независимыми подвопросами и замерь время выполнения последовательной и параллельной версии. Покажи, что время ≈ max(t_i), а не sum(t_i). -
Self-Ask + Reflection.
Реализуй
research_and_write(topic: str): сначала Self-Ask собирает ключевые факты по теме (5–7 подвопросов), затем Reflection итеративно улучшает итоговый текст. Критерий критика: фактическая точность, структура, читаемость. Сравни результат с прямым «напиши статью о {topic}» без декомпозиции.