Урок 2. Первый инструмент своими руками

В прошлом уроке мы разобрали, как устроен процесс работы с инструментами. Теперь пора собрать первый рабочий пример руками.

Напомню четыре шага:

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

Проблема: Claude и арифметика

Языковые модели плохо считают в столбик. Попросим Claude перемножить два больших числа:

from anthropic import Anthropic
from dotenv import load_dotenv

load_dotenv()

client = Anthropic()

# Довольно простая арифметическая задача
response = client.messages.create(
    model="claude-haiku-4-5",
    messages=[{"role": "user", "content": "Умножь 1984135 на 9343116. В ответе выведи только результат"}],
    max_tokens=400
)
print(response.content[0].text)

При повторных запусках ответ будет разным. Вот один из полученных:

18593367726060

А правильный ответ такой:

18538003464660

Claude промахнулся всего-то на 55364261400.

Шаг 1: пишем функцию

Раз считать модель не умеет — дадим ей калькулятор. Первым делом пишем саму функцию и убеждаемся, что она работает сама по себе, без всякого Claude. Функция ждёт три аргумента: операцию («add», «multiply» и т. д.) и два операнда.

def calculator(operation, operand1, operand2):
    if operation == "add":
        return operand1 + operand2
    elif operation == "subtract":
        return operand1 - operand2
    elif operation == "multiply":
        return operand1 * operand2
    elif operation == "divide":
        if operand2 == 0:
            raise ValueError("Cannot divide by zero.")
        return operand1 / operand2
    else:
        raise ValueError(f"Unsupported operation: {operation}")

Функция намеренно примитивная: она справится с 234 + 213 или 3 * 9 и не более того. Нам важен сам процесс работы с инструментами, а не мощь калькулятора.

Проверяем, что она работает:

calculator("add", 10, 3)

calculator("divide", 200, 25)

Шаг 2: описываем инструмент

Теперь надо рассказать про эту функцию модели. Описание инструмента пишется в строго определённом формате и состоит из трёх полей:

  • name — имя инструмента. Должно совпадать с регулярным выражением ^[a-zA-Z0-9_-]{1,64}$.
  • description — подробное текстовое описание: что инструмент делает, когда его применять и как он себя ведёт.
  • input_schema — объект JSON Schema, описывающий ожидаемые параметры.
Описание инструмента — это тоже промпт-инжиниринг. Модель видит только ваши слова: по ним она решает, брать инструмент или нет и что подставить в аргументы. Расплывчатое description — расплывчатые вызовы. Пишите подробно: что делает, когда использовать, как ведёт себя.

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

{
  "name": "send_email",
  "description": "Sends an email to the specified recipient with the given subject and body.",
  "input_schema": {
    "type": "object",
    "properties": {
      "to": {
        "type": "string",
        "description": "The email address of the recipient"
      },
      "subject": {
        "type": "string",
        "description": "The subject line of the email"
      },
      "body": {
        "type": "string",
        "description": "The content of the email message"
      }
    },
    "required": ["to", "subject", "body"]
  }
}

Инструмент send_email ждёт три входа, и все три обязательные: to, subject и body — строки.

Ещё один пример — инструмент search_product:

{
  "name": "search_product",
  "description": "Search for a product by name or keyword and return its current price and availability.",
  "input_schema": {
    "type": "object",
    "properties": {
      "query": {
        "type": "string",
        "description": "The product name or search keyword, e.g. 'iPhone 13 Pro' or 'wireless headphones'"
      },
      "category": {
        "type": "string",
        "enum": ["electronics", "clothing", "home", "toys", "sports"],
        "description": "The product category to narrow down the search results"
      },
      "max_price": {
        "type": "number",
        "description": "The maximum price of the product, used to filter the search results"
      }
    },
    "required": ["query"]
  }
}

Здесь три входа:

  • обязательная строка query — название товара или ключевое слово;
  • необязательная строка category, значение которой должно быть одним из заранее заданных — обратите внимание на "enum";
  • необязательное число max_price, чтобы отфильтровать товары дороже определённой цены.
Квиз 1

Какие параметры инструмента search_product обязательны?

Описание нашего калькулятора

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

  • operation — только «add», «subtract», «multiply» или «divide»;
  • operand1 — число;
  • operand2 — тоже число.
calculator_tool = {
    "name": "calculator",
    "description": "A simple calculator that performs basic arithmetic operations.",
    "input_schema": {
        "type": "object",
        "properties": {
            "operation": {
                "type": "string",
                "enum": ["add", "subtract", "multiply", "divide"],
                "description": "The arithmetic operation to perform."
            },
            "operand1": {
                "type": "number",
                "description": "The first operand."
            },
            "operand2": {
                "type": "number",
                "description": "The second operand."
            }
        },
        "required": ["operation", "operand1", "operand2"]
    }
}

Шаг 3: разбираем ответ Claude

Пока это просто питоновский словарь — Claude о нём ничего не знает. Чтобы рассказать, передаём список инструментов в параметре tools:

response = client.messages.create(
    model="claude-haiku-4-5",
    messages=[{"role": "user", "content": "Умножь 1984135 на 9343116. В ответе выведи только результат"}],
    max_tokens=300,
    # Рассказываем Claude про наш инструмент
    tools=[calculator_tool]
)

Теперь смотрим, что вернулось:

Message(
  id='msg_01UfKwdmEsgTh99wfpgW4NJ7',
  content=[ToolUseBlock(
      id='toolu_015wQ7Wipo589yT9B3YTwjF1',
      input={'operand1': 1984135, 'operand2': 9343116, 'operation': 'multiply'},
      name='calculator',
      type='tool_use')],
  role='assistant',
  stop_reason='tool_use',
  stop_sequence=None,
  type='message')

Ответ выглядит непривычно. Вместо обычного текста внутри content лежит блок ToolUseBlock, а stop_reason равен tool_use — значит, модель остановилась именно потому, что решила взять инструмент.

Из блока достаём имя инструмента и аргументы:

tool_name = response.content[0].name
tool_inputs = response.content[0].input

print("Инструмент, который хочет вызвать Claude:", tool_name)
print("С какими аргументами:", tool_inputs)

Дальше — просто берём это имя и эти аргументы и вызываем нашу функцию:

operation = tool_inputs["operation"]
operand1 = tool_inputs["operand1"]
operand2 = tool_inputs["operand2"]

result = calculator(operation, operand1, operand2)
print("РЕЗУЛЬТАТ:", result)

Получаем правильный 18538003464660. Мы не заставляли Claude считать — мы задали вопрос и дали доступ к инструменту, которым модель воспользовалась по своему усмотрению.

Квиз 2

О чём говорит stop_reason == "tool_use"?

Когда инструмент не нужен

Если вопрос не имеет отношения к инструменту, мы хотим обычного ответа. Обычно Claude так и делает, но иногда слишком рвётся применять то, что ему дали. Спросим про изумруды:

response = client.messages.create(
    model="claude-haiku-4-5",
    messages=[{"role": "user", "content": "Какого цвета изумруды?"}],
    max_tokens=400,
    tools=[calculator_tool]
)

И получим вот такое:

Message(
  id='msg_01Dj82HdyrxGJpi8XVtqEYvs',
  content=[ToolUseBlock(
      id='toolu_01Xo7x3dV1FVoBSGntHNAX4Q',
      input={'operand1': 0, 'operand2': 0, 'operation': 'add'},
      name='calculator',
      type='tool_use')],
  role='assistant',
  stop_reason='tool_use',
  type='message')

Claude просит сложить ноль с нулём, чтобы узнать цвет изумруда. Лечится это очень просто — правкой промпта или системным промптом в духе «инструменты есть, но пользуйся ими только при необходимости»:

response = client.messages.create(
    model="claude-haiku-4-5",
    system="У тебя есть инструменты, но используй их только при необходимости. "
           "Если инструмент не нужен — отвечай как обычно",
    messages=[{"role": "user", "content": "Какого цвета изумруды?"}],
    max_tokens=400,
    tools=[calculator_tool]
)

Теперь модель отвечает по-человечески и не пытается впихнуть калькулятор туда, где он не нужен:

'Изумруды зелёного цвета.'

И stop_reason стал end_turn вместо tool_use.

Квиз 3

Что означает stop_reason == "end_turn" в ответе с подключёнными инструментами?

Собираем всё вместе

Соберём функцию, описание инструмента и разбор ответа в один рабочий кусок:

def calculator(operation, operand1, operand2):
    if operation == "add":
        return operand1 + operand2
    elif operation == "subtract":
        return operand1 - operand2
    elif operation == "multiply":
        return operand1 * operand2
    elif operation == "divide":
        if operand2 == 0:
            raise ValueError("Cannot divide by zero.")
        return operand1 / operand2
    else:
        raise ValueError(f"Unsupported operation: {operation}")


calculator_tool = {
    "name": "calculator",
    "description": "A simple calculator that performs basic arithmetic operations.",
    "input_schema": {
        "type": "object",
        "properties": {
            "operation": {
                "type": "string",
                "enum": ["add", "subtract", "multiply", "divide"],
                "description": "The arithmetic operation to perform.",
            },
            "operand1": {"type": "number", "description": "The first operand."},
            "operand2": {"type": "number", "description": "The second operand."},
        },
        "required": ["operation", "operand1", "operand2"],
    },
}


def prompt_claude(prompt):
    messages = [{"role": "user", "content": prompt}]
    response = client.messages.create(
        model="claude-haiku-4-5",
        system="У тебя есть инструменты, но используй их только при необходимости. "
               "Если инструмент не нужен — отвечай как обычно",
        messages=messages,
        max_tokens=500,
        tools=[calculator_tool],
    )

    if response.stop_reason == "tool_use":
        tool_use = response.content[-1]
        tool_name = tool_use.name
        tool_input = tool_use.input

        if tool_name == "calculator":
            print("Claude хочет вызвать калькулятор")
            operation = tool_input["operation"]
            operand1 = tool_input["operand1"]
            operand2 = tool_input["operand2"]

            try:
                result = calculator(operation, operand1, operand2)
                print("Результат вычисления:", result)
            except ValueError as e:
                print(f"Ошибка: {str(e)}")

    elif response.stop_reason == "end_turn":
        print("Claude не стал использовать инструмент")
        print("Claude ответил:")
        print(response.content[0].text)

Проверяем на трёх разных запросах:

prompt_claude("У меня было 23 курицы, 2 улетели. Сколько осталось?")

prompt_claude("Сколько будет 201 умножить на 2")

prompt_claude("Напиши мне хайку про океан")

Упражнения

Упражнение 2.1 — Описать инструмент по функции

Потренируемся писать корректное описание инструмента. Дана функция:

def inventory_lookup(product_name, max_results):
    return "эта функция ничего не делает"
    # Трогать её не нужно

Вызывать её предполагается так:

inventory_lookup("AA batteries", 4)

inventory_lookup("birthday candle", 10)

Ваша задача — написать соответствующее описание инструмента в правильном формате. Считайте оба аргумента обязательными.

Решение упражненияСначала попробуйте сами — потом сверьтесь
inventory_lookup_tool = {
    "name": "inventory_lookup",
    "description": "Looks up the inventory for a given product and returns "
                   "a list of matching items that are currently in stock.",
    "input_schema": {
        "type": "object",
        "properties": {
            "product_name": {
                "type": "string",
                "description": "The name of the product to search for."
            },
            "max_results": {
                "type": "number",
                "description": "The maximum number of results to return."
            }
        },
        "required": ["product_name", "max_results"]
    }
}

Имя инструмента совпадает с именем функции, оба аргумента перечислены в required, у каждого есть тип и человеческое описание. Чем яснее написано description, тем реже модель промахивается с аргументами.

Упражнение 2.2 — Ассистент-исследователь

Соберём ассистента для ресёрча. Пользователь вводит тему, а на выходе получает список ссылок на статьи Википедии, сохранённый в markdown-файл. Можно было бы попросить Claude сразу выдать URL, но с адресами модель ненадёжна: она их выдумывает, а реальные статьи могли переехать после даты обучения. Поэтому подключим инструмент, который ходит в настоящий API Википедии.

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

Вот две готовые функции:

import wikipedia

def generate_wikipedia_reading_list(research_topic, article_titles):
    wikipedia_articles = []
    for t in article_titles:
        results = wikipedia.search(t)
        try:
            page = wikipedia.page(results[0])
            title = page.title
            url = page.url
            wikipedia_articles.append({"title": title, "url": url})
        except:
            continue
    add_to_research_reading_file(wikipedia_articles, research_topic)

def add_to_research_reading_file(articles, topic):
    with open("output/research_reading.md", "a", encoding="utf-8") as file:
        file.write(f"## {topic} \n")
        for article in articles:
            title = article["title"]
            url = article["url"]
            file.write(f"* [{title}]({url}) \n")
        file.write(f"\n\n")

Первая функция ждёт тему исследования — например, «The history of Hawaii» или «Pirates across the world» — и список возможных названий статей, который сгенерирует Claude. Через пакет wikipedia она находит реальные страницы и собирает список словарей с названием и URL. Затем вызывает add_to_research_reading_file, которая дописывает markdown-ссылки в файл output/research_reading.md. Имя файла пока зашито в код, и функция считает, что файл уже существует.

Claude может передать, например, такой список названий — часть из них настоящие, часть нет:

["Piracy", "Famous Pirate Ships", "Golden Age Of Piracy", "List of Pirates", "Pirates and Parrots", "Piracy in the 21st Century"]

Ваша задача — реализовать функцию get_research_help, которая принимает тему и желаемое количество статей. Примеры вызовов:

get_research_help("Pirates Across The World", 7)

get_research_help("History of Hawaii", 3)

get_research_help("are animals conscious?", 3)

Что нужно сделать:

  • написать описание инструмента для функции generate_wikipedia_reading_list;
  • реализовать get_research_help: составить промпт с темой и нужным количеством названий, рассказать Claude про инструмент, отправить запрос;
  • проверить, вызвал ли Claude инструмент. Если да — передать сгенерированные названия и тему в generate_wikipedia_reading_list;
  • открыть output/research_reading.md и посмотреть, получилось ли.

Стартовый код:

import wikipedia

def generate_wikipedia_reading_list(research_topic, article_titles):
    wikipedia_articles = []
    for t in article_titles:
        results = wikipedia.search(t)
        try:
            page = wikipedia.page(results[0])
            title = page.title
            url = page.url
            wikipedia_articles.append({"title": title, "url": url})
        except:
            continue
    add_to_research_reading_file(wikipedia_articles, research_topic)

def add_to_research_reading_file(articles, topic):
    with open("output/research_reading.md", "a", encoding="utf-8") as file:
        file.write(f"## {topic} \n")
        for article in articles:
            title = article["title"]
            url = article["url"]
            file.write(f"* [{title}]({url}) \n")
        file.write(f"\n\n")

def get_research_help(topic, num_articles=3):
    # Реализуйте эту функцию!
    pass
Решение упражненияСначала попробуйте сами — потом сверьтесь
article_search_tool = {
    "name": "generate_wikipedia_reading_list",
    "description": "Takes a research topic and a list of potential Wikipedia "
                   "article titles, finds the real articles among them and "
                   "saves their links to a reading list file.",
    "input_schema": {
        "type": "object",
        "properties": {
            "research_topic": {
                "type": "string",
                "description": "The overall research topic, e.g. 'History of Hawaii'."
            },
            "article_titles": {
                "type": "array",
                "items": {"type": "string"},
                "description": "A list of potential Wikipedia article titles "
                               "related to the research topic."
            }
        },
        "required": ["research_topic", "article_titles"]
    }
}


def get_research_help(topic, num_articles=3):
    prompt = (f"Мне нужна помощь со сбором материалов по теме «{topic}». "
              f"Придумай {num_articles} названий статей Википедии по этой теме.")

    response = client.messages.create(
        model="claude-haiku-4-5",
        messages=[{"role": "user", "content": prompt}],
        max_tokens=500,
        tools=[article_search_tool],
    )

    if response.stop_reason == "tool_use":
        tool_use = response.content[-1]
        tool_input = tool_use.input
        generate_wikipedia_reading_list(
            tool_input["research_topic"],
            tool_input["article_titles"],
        )

Обратите внимание на тип array с items — так в JSON Schema описывается список строк. И на description: именно из него модель понимает, что article_titles — это её собственные догадки, а проверка на реальность произойдёт внутри инструмента.

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

  • Описание инструмента состоит из трёх полей: name, description и input_schema в формате JSON Schema.
  • description — это промпт. Модель решает по нему, брать инструмент и что подставить в аргументы, поэтому пишите подробно.
  • Инструменты передаются в запрос параметром tools. Пока вы этого не сделали, Claude о них не знает.
  • Если модель решила вызвать инструмент, stop_reason равен tool_use, а в content лежит блок с именем инструмента и аргументами.
  • Код выполняете вы у себя: достаёте имя и аргументы, вызываете функцию, получаете результат.
  • Claude бывает слишком рьяным — системный промпт «используй инструменты только при необходимости» это лечит.

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

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