Урок 2. Первый инструмент своими руками
В прошлом уроке мы разобрали, как устроен процесс работы с инструментами. Теперь пора собрать первый рабочий пример руками.
Напомню четыре шага:
- Даём Claude инструменты и промпт пользователя (запрос к API). Описываем набор инструментов — имена, описания, схемы входных данных — и присылаем вопрос, для ответа на который эти инструменты могут понадобиться.
- Claude решает воспользоваться инструментом (ответ API). Модель оценивает промпт, выбирает подходящий инструмент и входные данные, формирует корректный запрос на вызов. У ответа будет
stop_reasonсо значениемtool_use. - Достаём входные данные, выполняем код, возвращаем результат (запрос к API). На своей стороне вытаскиваем имя инструмента и аргументы, запускаем настоящий код, а результат отдаём обратно новым сообщением с блоком
tool_result. - Claude формулирует финальный ответ (ответ API), уже опираясь на результат работы инструмента.
Проблема: 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, чтобы отфильтровать товары дороже определённой цены.
Какие параметры инструмента 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 считать — мы задали вопрос и дали доступ к инструменту, которым модель воспользовалась по своему усмотрению.
О чём говорит 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.
Что означает 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 бывает слишком рьяным — системный промпт «используй инструменты только при необходимости» это лечит.
Дочитали и сделали упражнения? Зафиксируйте прогресс — отметка сохранится в вашем браузере.