Проблема: последовательные вызовы тормозят агента

Представь: пользователь просит «Сравни погоду в Москве, Лондоне и Токио». Агент без parallel tool calling делает это так:

  1. Первый запрос к API → модель возвращает get_weather("Москва")
  2. Выполняем функцию → второй запрос → модель возвращает get_weather("Лондон")
  3. Выполняем функцию → третий запрос → модель возвращает get_weather("Токио")
  4. Выполняем функцию → четвёртый запрос → финальный ответ

Итого: 4 round-trip к API, время — сумма всех ожиданий. С parallel tool calling та же задача решается за 2 round-trip: в первом ответе модель возвращает все три вызова сразу, ты выполняешь функции параллельно и отдаёшь все результаты в одном сообщении.

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
ПОСЛЕДОВАТЕЛЬНО · 3 запроса к API · 4 round-trip API #1 API #2 API #3 API #4 get_weather(Москва) 800ms get_weather(Лондон) 600ms get_weather(Токио) 1 000ms 2 400ms итого ПАРАЛЛЕЛЬНО · 1 запрос к API · 2 round-trip API #1 API #2 get_weather(Москва) 800ms get_weather(Лондон) 600ms get_weather(Токио) 1 000ms 1 000ms итого ↑ в 2.4× быстрее 0ms 1 000ms 2 400ms

Теория: как работает parallel tool calling

Parallel tool calling — не отдельная функция API, которую нужно «включить». Это поведение самой модели: когда она видит, что несколько инструментов можно вызвать независимо друг от друга, она возвращает несколько блоков tool_use в одном ответе вместо одного.

На уровне протокола разница только в одном месте: в ответе модели несколько tool_use блоков, и все их tool_result должны вернуться в одном сообщении с ролью user.

Структура ответа с несколькими tool_use блоками

Response от Claude API (parallel tool call)
stop_reason: "tool_use"
content: [
  {
    type: "tool_use",
    id: "toolu_01A",
    name: "get_weather",
    input: { city: "Москва" }
  },
  {
    type: "tool_use",  ← второй блок в том же ответе
    id: "toolu_02B",
    name: "get_weather",
    input: { city: "Лондон" }
  },
  {
    type: "tool_use",  ← третий блок
    id: "toolu_03C",
    name: "get_weather",
    input: { city: "Токио" }
  }
]

Структура ответа с несколькими tool_result блоками

Все результаты нужно вернуть в одном сообщении с ролью user. Каждый блок tool_result связан со своим вызовом через tool_use_id. Порядок не важен, но все вызовы должны быть закрыты:

Сообщение с результатами всех трёх вызовов
{ role: "user", content: [
  {
    type: "tool_result",
    tool_use_id: "toolu_01A",
    content: '{"city":"Москва","temp":-3,"desc":"Снег"}'
  },
  { type: "tool_result", tool_use_id: "toolu_02B", content: '{"city":"Лондон","temp":8,"desc":"Дождь"}' },
  { type: "tool_result", tool_use_id: "toolu_03C", content: '{"city":"Токио","temp":12,"desc":"Ясно"}' }
] }
⚠️ Нельзя разбивать результаты по нескольким сообщениям

Если в ответе модели было 3 блока tool_use — все 3 блока tool_result обязаны вернуться в одном сообщении. API вернёт ошибку, если ты отправишь их по одному или пропустишь хотя бы один.

Обработка нескольких tool_use блоков

Код из предыдущего урока уже готов к параллельным вызовам — главное не делать next() для поиска первого блока, а обходить все блоки в цикле. Единственное новое требование: собрать все tool_result и отправить их вместе.

python
import json
import anthropic

client = anthropic.Anthropic()

def get_weather(city: str) -> dict:
    # Заглушка — в реальности HTTP-запрос к weather API
    data = {"Москва": (-3, "Снег"), "Лондон": (8, "Дождь"), "Токио": (12, "Ясно")}
    temp, desc = data.get(city, (0, "Неизвестно"))
    return {"city": city, "temp": temp, "description": desc}

TOOLS = [{
    "name": "get_weather",
    "description": "Возвращает текущую погоду в указанном городе.",
    "input_schema": {
        "type": "object",
        "properties": {
            "city": {"type": "string", "description": "Название города на русском языке"}
        },
        "required": ["city"]
    }
}]

HANDLERS = {"get_weather": get_weather}

def run_agent(user_message: str) -> str:
    messages = [{"role": "user", "content": user_message}]

    for _ in range(10):
        response = client.messages.create(
            model="claude-opus-4-6",
            max_tokens=1024,
            tools=TOOLS,
            messages=messages
        )

        if response.stop_reason == "end_turn":
            return response.content[0].text

        if response.stop_reason == "tool_use":
            # Собираем результаты ВСЕХ tool_use блоков
            tool_results = []
            for block in response.content:
                if block.type != "tool_use":
                    continue
                handler = HANDLERS.get(block.name)
                try:
                    result = handler(**block.input) if handler else {"error": f"unknown tool: {block.name}"}
                except Exception as e:
                    result = {"error": str(e)}

                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,         # каждый ID уникален
                    "content": json.dumps(result, ensure_ascii=False)
                })

            # Возвращаем ВСЕ результаты в одном сообщении
            messages.append({"role": "assistant", "content": response.content})
            messages.append({"role": "user",      "content": tool_results})

    raise RuntimeError("Превышен лимит итераций")

print(run_agent("Сравни погоду в Москве, Лондоне и Токио"))
# Модель сгенерирует 3 tool_use блока за один ответ

Конкурентное выполнение функций через asyncio

Parallel tool calling сокращает количество round-trip к Claude API. Но если сами функции делают HTTP-запросы — они по-прежнему выполняются последовательно в синхронном коде. Чтобы выжать максимум, нужно запускать их конкурентно через asyncio.gather().

API round-trip
Parallel tool calling сокращает число запросов к Claude: 2 вместо 4. Это экономит сетевые задержки и токены на повторных передачах истории.
asyncio.gather
Конкурентное выполнение сокращает суммарное время работы функций: вместо 2400ms — 1000ms (время самого медленного запроса).
Итог
Оба подхода вместе дают максимальный эффект. Меньше запросов к API + меньше времени ожидания внутри каждого шага.
python
import json
import asyncio
import anthropic

client = anthropic.AsyncAnthropic()    # async-клиент

# Async-версии инструментов
async def get_weather(city: str) -> dict:
    await asyncio.sleep(0)             # в реальности: await httpx.AsyncClient().get(...)
    data = {"Москва": (-3, "Снег"), "Лондон": (8, "Дождь"), "Токио": (12, "Ясно")}
    temp, desc = data.get(city, (0, "Неизвестно"))
    return {"city": city, "temp": temp, "description": desc}

ASYNC_HANDLERS = {"get_weather": get_weather}

async def execute_tools_concurrently(tool_use_blocks: list) -> list:
    """Запускаем все функции конкурентно через asyncio.gather"""
    async def run_one(block):
        handler = ASYNC_HANDLERS.get(block.name)
        try:
            result = await handler(**block.input) if handler else {"error": f"unknown: {block.name}"}
        except Exception as e:
            result = {"error": str(e)}
        return {
            "type": "tool_result",
            "tool_use_id": block.id,
            "content": json.dumps(result, ensure_ascii=False)
        }

    # Все функции стартуют одновременно — ждём самую медленную
    return await asyncio.gather(*[run_one(b) for b in tool_use_blocks])


async def run_agent_async(user_message: str) -> str:
    messages = [{"role": "user", "content": user_message}]

    for _ in range(10):
        response = await client.messages.create(
            model="claude-opus-4-6",
            max_tokens=1024,
            tools=[{
                "name": "get_weather",
                "description": "Погода в городе.",
                "input_schema": {
                    "type": "object",
                    "properties": {"city": {"type": "string", "description": "Город на русском"}},
                    "required": ["city"]
                }
            }],
            messages=messages
        )

        if response.stop_reason == "end_turn":
            return response.content[0].text

        if response.stop_reason == "tool_use":
            tool_blocks = [b for b in response.content if b.type == "tool_use"]

            # Конкурентное выполнение: все функции параллельно
            tool_results = await execute_tools_concurrently(tool_blocks)

            messages.append({"role": "assistant", "content": response.content})
            messages.append({"role": "user",      "content": tool_results})

    raise RuntimeError("Превышен лимит итераций")


# Запуск
result = asyncio.run(run_agent_async("Сравни погоду в Москве, Лондоне и Токио"))
print(result)

Когда модель использует параллельные вызовы

Модель сама решает — вызывать инструменты последовательно или параллельно. Понимание этой логики помогает писать описания инструментов так, чтобы модель чаще делала правильный выбор.

Параллельно
Независимые данные. Погода в трёх городах, курсы трёх валют, профили трёх пользователей — каждый вызов не зависит от результата другого.
Параллельно
Один инструмент, разные аргументы. Модель часто вызывает get_weather × 3 параллельно, если видит в запросе несколько городов.
Последовательно
Зависимые вызовы. Если второй инструмент использует результат первого — модель вызовет их по одному. Пример: сначала search(), затем summarize(результат).
Последовательно
Неясная независимость. Если из описания инструментов непонятно, можно ли вызывать их вместе — модель перестрахуется и пойдёт последовательно.
💡 Помоги модели принять правильное решение

В description инструмента явно укажи, что он не имеет побочных эффектов и не зависит от других вызовов: «Каждый вызов независим. Можно вызывать одновременно с другими инструментами этого типа». Это подсказка модели, что инструменты безопасно выполнять параллельно.

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

❌ Берём только первый tool_use
block = next(
  b for b in response.content
  if b.type == "tool_use"
)
# обрабатываем только block
Остальные tool_use блоки проигнорированы. API получит tool_result только для одного ID — вернёт ошибку.
✅ Обходим все tool_use блоки
tool_results = []
for block in response.content:
  if block.type != "tool_use":
    continue
  tool_results.append(...)
Все блоки обработаны, все tool_result в одном ответе. API доволен.
❌ Отправляем результаты по одному
for block in tool_use_blocks:
  result = execute(block)
  messages.append(
    {"role":"user","content":[result]}
  )
  call_api(messages)  # ← отдельный запрос!
Нельзя. Все tool_result для одного шага обязаны быть в одном сообщении. API вернёт ошибку.
✅ Все результаты в одном сообщении
tool_results = []
for block in tool_use_blocks:
  tool_results.append(execute(block))

messages.append({
  "role": "user",
  "content": tool_results  # все сразу
})
Один список, одно сообщение, один API-запрос. Модель видит все результаты вместе.
❌ Синхронный код для async функций
for block in tool_use_blocks:
  result = fetch_data(block.input)
  # каждый вызов ждёт предыдущего
# 800 + 600 + 1000 = 2400ms
Параллельный tool calling снизил число API-запросов, но функции всё равно работают по очереди. Выигрыш неполный.
✅ asyncio.gather для конкурентности
results = await asyncio.gather(*[
  fetch_data(b.input)
  for b in tool_use_blocks
])
# max(800, 600, 1000) = 1000ms
Все функции стартуют одновременно. Ждём только самую медленную.

Шпаргалка

Ответ API
Несколько tool_use в response.content — норма. Обходи все блоки циклом, не бери первый через next().
tool_result
Все результаты — в одном сообщении role: "user". Каждый блок связан с вызовом через уникальный tool_use_id.
asyncio
asyncio.gather() для конкурентного выполнения функций. Используй AsyncAnthropic() и await везде в цепочке.
Подсказка модели
В description инструмента укажи, что вызовы независимы — это помогает модели принять решение о параллельном вызове.
Зависимые данные
Если второй вызов зависит от результата первого — модель сама пойдёт последовательно. Parallel calling не сломает логику.
Итог по скорости
Parallel tool calling: меньше API round-trip. asyncio.gather: меньше времени на выполнение функций. Вместе — максимальный эффект.

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

  1. Проверь параллельность. Реализуй инструмент get_weather(city) с искусственной задержкой через asyncio.sleep(random.uniform(0.5, 1.5)). Запроси агента: «Погода в Москве, Лондоне, Токио, Берлине и Париже». Измерь время синхронной и асинхронной версии через time.time(). Сколько выходит ускорение?
  2. Разные инструменты параллельно. Добавь второй инструмент get_exchange_rate(currency). Запроси: «Какой курс доллара и евро, и что у них с погодой в столицах?». Посмотри сколько и каких блоков tool_use модель вернёт за один ответ. Обработай все корректно.
  3. Зависимые вызовы. Создай два инструмента: search_user(name) и get_user_orders(user_id). Запроси: «Покажи заказы пользователя Иван Петров». Убедись, что модель вызывает их последовательно (сначала ищет пользователя, потом берёт его ID для заказов), а не параллельно. Сравни с запросом на двух разных пользователей — там ли модель пойдёт параллельно?
Следующий шаг

Ты освоил весь цикл tool calling: протокол, описание схем и параллельные вызовы. Следующая тема — Обработка ошибок при вызове инструментов: что делать когда функция падает, как передавать ошибки модели и строить устойчивых агентов.