Урок 4. Полный цикл: запрос → вызов → результат → ответ

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

Полный цикл состоит из четырёх шагов:

  1. Даём модели инструменты и вопрос пользователя (запрос к API). Описываем инструменты: имя, описание, схема входа. Задаём вопрос, на который без инструмента не ответить.
  2. Модель просит вызвать инструмент (ответ API). Она сама решает, нужен ли инструмент, какой именно и с какими аргументами. В ответе появляется блок tool_use, а stop_reason становится "tool_use".
  3. Мы достаём аргументы, выполняем код и возвращаем результат (запрос к API). Разбираем запрос модели, запускаем настоящую функцию у себя и продолжаем разговор новым сообщением с блоком tool_result.
  4. Модель формулирует ответ по результату (ответ API). Получив данные, она отвечает на исходный вопрос пользователя.
Ключевая мысль урока: API не помнит ничего. Вся история — это список messages, который вы накапливаете сами. Каждый следующий запрос отправляет весь список заново.

Вот к какому результату мы идём. Пользователь спрашивает: «На 2024 год у кого больше «Оскаров» — у Кристофера Нолана или Бена Стиллера?» Модель делает два вызова инструмента подряд — статья про Нолана, статья про Стиллера, — а потом отвечает, что у Нолана два «Оскара» (за режиссуру и за лучший фильм — «Оппенгеймер»), а у Стиллера ни одного.

Другой пример: «Кто выиграл Masters 2024?» — один вызов инструмента, статья про турнир, ответ: Скотти Шеффлер, вторая победа, на четыре удара впереди дебютанта Людвига Оберга.

А на вопрос «Кто выиграл Masters 2021?» модель может ответить сразу, без инструмента — это событие внутри её знаний. К этому «может ответить сама» мы ещё вернёмся в конце урока.

Шаг 0. Функция поиска по Википедии

Прежде чем что-то говорить модели, напишем обычную функцию. Она ищет страницы Википедии по запросу, берёт первый результат и возвращает его содержимое.

import wikipedia

def get_article(search_term):
    results = wikipedia.search(search_term)
    first_result = results[0]
    page = wikipedia.page(first_result, auto_suggest=False)
    return page.content

Проверить можно так:

article = get_article("Superman")
print(article[:500])  # статья длинная, печатаем только начало

Обратите внимание: обработки ошибок тут нет — функция наивно верит, что статья найдётся. В учебном примере это допустимо, в бою — нет.

Шаг 1. Описание инструмента

Теперь опишем эту функцию на языке, который понимает модель, — JSON Schema. Инструмент простой: один строковый аргумент.

article_search_tool = {
    "name": "get_article",
    "description": "A tool to retrieve an up to date Wikipedia article.",
    "input_schema": {
        "type": "object",
        "properties": {
            "search_term": {
                "type": "string",
                "description": "The search term to find a wikipedia article by title"
            },
        },
        "required": ["search_term"]
    }
}

Шаг 2. Модель просит инструмент

Отправляем вопрос, на который без свежих данных не ответить, и передаём инструмент:

from anthropic import Anthropic

client = Anthropic()

messages = [{"role": "user", "content": "who won the 2024 Masters Tournament?"}]

response = client.messages.create(
    model="claude-sonnet-5",
    messages=messages,
    max_tokens=1000,
    tools=[article_search_tool]
)

В response.content приходит два блока. Первый — текст:

TextBlock(text='Okay, let me use the available tool to try and find information on who won the 2024 Masters Tournament:', type='text')

Второй — сам запрос на вызов:

ToolUseBlock(id='toolu_01MbstBxD654o9hE2RGNdtSr',
             input={'search_term': '2024 Masters Tournament'},
             name='get_article',
             type='tool_use')

В блоке tool_use три важные вещи:

  • id — уникальный идентификатор именно этого вызова. По нему потом сматчится результат.
  • name — какой инструмент вызывать.
  • input — аргументы по вашей input_schema.

И stop_reason у такого ответа равен "tool_use" — это ваш сигнал «модель остановилась и ждёт данных».

Шаг 3. tool_result — главный формат урока

Первое, что нужно сделать, — положить ответ модели в историю. Забудете — и следующий запрос окажется без контекста вызова.

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

Дальше достаём, что именно модель хочет вызвать:

# Простой, но хрупкий способ: берём последний блок ответа
tool_use = response.content[-1]
tool_name = tool_use.name
tool_input = tool_use.input

Проверяем имя и запускаем настоящую функцию:

if tool_name == "get_article":
    search_term = tool_input["search_term"]
    wiki_result = get_article(search_term)

Теперь — возврат результата. Он отправляется сообщением с ролью user (да, именно user — с точки зрения API это «внешний мир говорит модели») и содержит блок типа tool_result:

  • tool_use_id — тот самый id из запроса модели;
  • content — результат: строка (например, "15 degrees") или список вложенных блоков ([{"type": "text", "text": "15 degrees"}]);
  • is_error — необязательный флаг, если вызов упал с ошибкой.

Общий вид правильно оформленного сообщения:

{
  "role": "user",
  "content": [
    {
      "type": "tool_result",
      "tool_use_id": "toolu_01A09q90qw90lq917835lq9",
      "content": "The result of actually calling the tool goes here"
    }
  ]
}

Для нашего примера:

tool_response = {
    "role": "user",
    "content": [
        {
            "type": "tool_result",
            "tool_use_id": tool_use.id,
            "content": wiki_result
        }
    ]
}

messages.append(tool_response)

Список messages теперь выглядит так:

  • user: Кто выиграл Masters 2024?
  • assistant: хочу вызвать get_article с search_term «2024 Masters Tournament» (id=toolu_01Mbst…)
  • user: вот tool_result для tool_use_id=toolu_01Mbst… — текст статьи

Заметьте: id из ответа модели и tool_use_id в вашем следующем сообщении — одно и то же значение. Это и есть «ниточка», связывающая запрос и результат.

Квиз 1

С какой ролью отправляется результат вызова инструмента обратно модели?

Шаг 4. Финальный ответ

Отправляем обновлённый messages ещё раз — с теми же инструментами:

follow_up_response = client.messages.create(
    model="claude-sonnet-5",
    messages=messages,
    max_tokens=1000,
    tools=[article_search_tool]
)

print(follow_up_response.content[0].text)

И получаем что-то вроде: «Судя по статье в Википедии, Masters 2024 выиграл Скотти Шеффлер. В финальном раунде он…»

Все четыре шага пройдены.

Собираем в функцию

Чтобы не повторять это руками каждый раз, завернём всё в одну функцию:

def answer_question(question):
    messages = [{"role": "user", "content": question}]

    response = client.messages.create(
        model="claude-sonnet-5",
        messages=messages,
        max_tokens=1000,
        tools=[article_search_tool]
    )

    if response.stop_reason == "tool_use":
        tool_use = response.content[-1]
        tool_name = tool_use.name
        tool_input = tool_use.input
        # кладём запрос модели в историю
        messages.append({"role": "assistant", "content": response.content})

        if tool_name == "get_article":
            search_term = tool_input["search_term"]
            print(f"Claude хочет статью про {search_term}")
            wiki_result = get_article(search_term)
            tool_response = {
                "role": "user",
                "content": [
                    {
                        "type": "tool_result",
                        "tool_use_id": tool_use.id,
                        "content": wiki_result
                    }
                ]
            }
            messages.append(tool_response)
            # отвечаем модели
            response = client.messages.create(
                model="claude-sonnet-5",
                messages=messages,
                max_tokens=1000,
                tools=[article_search_tool]
            )
            print("Финальный ответ:")
            print(response.content[0].text)
    else:
        print("Инструмент не понадобился")
        print(response.content[0].text)

Проверяем:

answer_question("Who won the 2024 F1 Australian Grand Prix")
answer_question("Who stars in the movie Challengers?")
answer_question("Who wrote the book 'Life of Pi'")

На последнем вопросе всплывает проблема: модель всё равно лезет в Википедию, хотя «Жизнь Пи» вышла в 2001 году и она это прекрасно знает. Слишком услужливая. Починим промптом чуть ниже.

Цикл: несколько вызовов подряд

У функции выше есть более серьёзное ограничение: она обрабатывает ровно один вызов инструмента. А если спросить «Сколько «Оскаров» у Кристофера Нолана и больше ли это, чем «Эмми» у Бена Стиллера?» — модели понадобятся две статьи, одна за другой. После первого tool_result она снова вернёт stop_reason == "tool_use", и наш if это просто проигнорирует.

Лечится заменой if на while. Схема цикла:

1. messages = [ вопрос пользователя ]
2. отправить messages + tools
3. пока response.stop_reason == "tool_use":
       a. messages.append(ответ модели целиком)   # роль assistant
       b. для каждого блока tool_use в ответе:
              выполнить функцию с его input
              собрать блок tool_result с его tool_use_id
       c. messages.append({"role": "user", "content": [ tool_result… ]})
       d. отправить messages + tools заново → новый response
4. stop_reason больше не "tool_use" → это финальный текст, печатаем

Три правила, из которых цикл и состоит:

  • Выход — по stop_reason. Пока он "tool_use", модель ещё работает. Как только он другой — ответ готов.
  • История только растёт. Ничего не удаляем и не переписываем: messages — это память разговора, других её носителей нет.
  • Каждому tool_use — свой tool_result с тем же tool_use_id. Если модель попросила два вызова, вернуть нужно оба.
Квиз 2

По какому признаку цикл понимает, что пора остановиться?

Промпт: не вызывать инструмент зря

Вернёмся к «Жизни Пи». Излишнее рвение лечится системным промптом:

system_prompt = """
    You will be asked a question by the user.
    If answering the question requires data you were not trained on, you can use
    the get_article tool to get the contents of a recent wikipedia article about the topic.
    If you can answer the question without needing to get more information, please do so.
    Only call the tool when needed.
    """

Передаём его параметром system= в оба вызова messages.create — и в первый, и в тот, что после tool_result. После этого на «Жизнь Пи» модель отвечает сразу, а на «Кто написал музыку к фильму „Претенденты“?» — по-прежнему идёт в Википедию. То, что нужно.

Второй штрих — краткость. По умолчанию модель охотно объясняет, как пришла к ответу. Просим завернуть суть в теги:

prompt = f"""
    Answer the following question <question>{question}</question>
    When you can answer the question, keep your answer as short as possible
    and enclose it in <answer> tags
    """
messages = [{"role": "user", "content": prompt}]

Теперь в ответе приходит короткое <answer>…</answer> вместо абзаца рассуждений — а достать содержимое тегов легко регуляркой.

Квиз 3

Зачем добавлять в messages ответ модели с блоком tool_use, прежде чем отправлять tool_result?

Упражнения

Упражнение 4.1 — Переделать на цикл

Перепишите answer_question так, чтобы она выдерживала несколько вызовов инструмента подряд. Проверьте на вопросе: «Сколько «Оскаров» у Кристофера Нолана? Больше ли это, чем «Эмми» у Бена Стиллера?»

Решение упражненияСначала попробуйте сами — потом сверьтесь
import re

system_prompt = """
    You will be asked a question by the user.
    If answering the question requires data you were not trained on, you can use
    the get_article tool to get the contents of a recent wikipedia article about the topic.
    If you can answer the question without needing to get more information, please do so.
    Only call the tool when needed.
    """

def answer_question(question):
    prompt = f"""
    Answer the following question <question>{question}</question>
    When you can answer the question, keep your answer as short as possible
    and enclose it in <answer> tags
    """
    messages = [{"role": "user", "content": prompt}]

    response = client.messages.create(
        model="claude-sonnet-5",
        system=system_prompt,
        messages=messages,
        max_tokens=1000,
        tools=[article_search_tool]
    )

    while response.stop_reason == "tool_use":
        messages.append({"role": "assistant", "content": response.content})

        tool_results = []
        for block in response.content:
            if block.type == "tool_use" and block.name == "get_article":
                search_term = block.input["search_term"]
                print(f"Claude хочет статью про {search_term}")
                tool_results.append({
                    "type": "tool_result",
                    "tool_use_id": block.id,
                    "content": get_article(search_term)
                })

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

        response = client.messages.create(
            model="claude-sonnet-5",
            system=system_prompt,
            messages=messages,
            max_tokens=1000,
            tools=[article_search_tool]
        )

    text = response.content[0].text
    match = re.search(r"<answer>(.*?)</answer>", text, re.DOTALL)
    print(match.group(1).strip() if match else text)

Здесь сразу три улучшения: while вместо if, перебор всех блоков tool_use в ответе (а не только последнего) и извлечение содержимого тега <answer> регуляркой.

Упражнение 4.2 — Чат-бот в терминале

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

Решение упражненияСначала попробуйте сами — потом сверьтесь
def chat():
    print("Задайте вопрос. Для выхода введите 'quit'.")
    while True:
        question = input("\nВопрос: ").strip()
        if question.lower() in ("quit", "exit", "выход"):
            print("Пока!")
            break
        answer_question(question)

chat()

Обратите внимание: answer_question заводит свой messages на каждый вопрос — то есть бот отвечает без памяти о предыдущих репликах. Хотите настоящий диалог — вынесите messages наружу, в chat(), и передавайте внутрь. Тогда цикл вызовов инструментов будет жить внутри общей растущей истории.

Что запомнить

  • Цикл из четырёх шагов: инструменты + вопрос → tool_use → выполнение и tool_result → финальный ответ.
  • stop_reason == "tool_use" — сигнал, что модель ждёт данных. Это условие вашего while.
  • Результат возвращается сообщением с ролью user и блоком tool_result; tool_use_id должен совпадать с id из запроса модели.
  • API не хранит состояние: messages — единственная память, её нужно накапливать и слать целиком.
  • В историю кладётся и ответ модели с tool_use, и ваш tool_result — иначе связь между вызовом и результатом рвётся.
  • Излишнее рвение вызывать инструмент и многословность ответов лечатся промптом, а не кодом.

Дочитали и сделали упражнения? Зафиксируйте прогресс — отметка сохранится в вашем браузере.

Перевод и адаптация урока «The Complete Workflow» курса Tool Use © Anthropic, лицензия CC BY-NC 4.0. Перевод: Дарья Воронкина (@aishipuchka). Материал изменён: переведён на русский, названия моделей актуализированы, добавлены схема цикла, квизы и решения упражнений. Используется некоммерчески.