Урок 6. Чат-бот с несколькими инструментами
До сих пор мы давали Claude один инструмент. Теперь поднимем ставку: дадим набор инструментов и посмотрим, как модель сама выбирает нужный. Соберём простого чат-бота поддержки для вымышленного магазина электроники TechNova. У него будет четыре инструмента:
get_user— найти пользователя по email, логину или телефону;get_order_by_id— найти заказ по его ID;get_customer_orders— получить все заказы клиента;cancel_order— отменить заказ по ID.
Бот получится очень ограниченным, но он показывает ключевые механики работы с несколькими инструментами: маршрутизацию, ведение истории диалога, поведение модели, когда данных не хватает.
Фейковая база данных
Прежде чем звать Claude, нужен «бэкенд». Заведём класс FakeDatabase с парой списков и методами доступа. В настоящем продукте здесь была бы реальная база — но логика вокруг неё не изменилась бы.
class FakeDatabase:
def __init__(self):
self.customers = [
{"id": "1213210", "name": "John Doe", "email": "john@gmail.com",
"phone": "123-456-7890", "username": "johndoe"},
{"id": "2837622", "name": "Priya Patel", "email": "priya@candy.com",
"phone": "987-654-3210", "username": "priya123"},
{"id": "8259147", "name": "Megan Anderson", "email": "megana@gmail.com",
"phone": "666-777-8888", "username": "manderson"},
# ...ещё несколько клиентов
]
self.orders = [
{"id": "24601", "customer_id": "1213210", "product": "Wireless Headphones",
"quantity": 1, "price": 79.99, "status": "Shipped"},
{"id": "13579", "customer_id": "1213210", "product": "Smartphone Case",
"quantity": 2, "price": 19.99, "status": "Processing"},
{"id": "47652", "customer_id": "8259147", "product": "Smartwatch",
"quantity": 1, "price": 199.99, "status": "Processing"},
# ...ещё несколько заказов
]
Теперь четыре метода — ровно те, что станут инструментами:
def get_user(self, key, value):
if key in {"email", "phone", "username"}:
for customer in self.customers:
if customer[key] == value:
return customer
return f"Couldn't find a user with {key} of {value}"
else:
raise ValueError(f"Invalid key: {key}")
def get_order_by_id(self, order_id):
for order in self.orders:
if order["id"] == order_id:
return order
return None
def get_customer_orders(self, customer_id):
return [order for order in self.orders if order["customer_id"] == customer_id]
def cancel_order(self, order_id):
order = self.get_order_by_id(order_id)
if order:
if order["status"] == "Processing":
order["status"] = "Cancelled"
return "Cancelled the order"
else:
return "Order has already shipped. Can't cancel it."
return "Can't find that order!"
Обратите внимание на cancel_order: отменить можно только заказ в статусе Processing. Уже отгруженный — нельзя. Это правило живёт в коде, а не в промпте, и это правильно: бизнес-логику нельзя доверять формулировкам.
Проверим руками, что база работает:
db = FakeDatabase()
db.get_user("email", "john@gmail.com") # клиент John Doe
db.get_user("username", "adavis") # клиент по логину
db.get_customer_orders("1213210") # все заказы клиента
db.get_order_by_id("47652") # статус Processing
db.cancel_order("47652") # "Cancelled the order"
db.get_order_by_id("47652") # теперь статус Cancelled
Схемы четырёх инструментов
Дальше описываем инструменты в JSON-схемах — как в предыдущих уроках. Начнём с самого простого, для get_order_by_id:
tool1 = {
"name": "get_order_by_id",
"description": "Retrieves the details of a specific order based on the order ID. "
"Returns the order ID, product name, quantity, price, and order status.",
"input_schema": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "The unique identifier for the order."
}
},
"required": ["order_id"]
}
}
Как всегда: имя, описание и перечень входов. Здесь вход один — order_id, и он обязателен.
Теперь схема посложнее — для get_user. У метода два аргумента: key (одно из трёх значений: «email», «username», «phone») и value — то, что ищем.
tool2 = {
"name": "get_user",
"description": "Looks up a user by email, phone, or username.",
"input_schema": {
"type": "object",
"properties": {
"key": {
"type": "string",
"enum": ["email", "phone", "username"],
"description": "The attribute to search for a user by (email, phone, or username)."
},
"value": {
"type": "string",
"description": "The value to match for the specified attribute."
}
},
"required": ["key", "value"]
}
}
Главное здесь — enum. Он фиксирует допустимый набор значений key, и модель не сможет придумать четвёртый вариант вроде «name». Всегда, когда параметр принимает значение из закрытого списка, описывайте его через enum.
Оставшиеся две схемы устроены так же — их удобно сначала написать самому, а потом сверить:
tools = [
tool2, # get_user
tool1, # get_order_by_id
{
"name": "get_customer_orders",
"description": "Retrieves the list of orders belonging to a user "
"based on a user's customer id.",
"input_schema": {
"type": "object",
"properties": {
"customer_id": {
"type": "string",
"description": "The customer_id belonging to the user"
}
},
"required": ["customer_id"]
}
},
{
"name": "cancel_order",
"description": "Cancels an order based on a provided order_id. "
"Only orders that are 'processing' can be cancelled",
"input_schema": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "The order_id pertaining to a particular order"
}
},
"required": ["order_id"]
}
}
]
Зачем в схеме get_user у параметра key стоит enum?
Маршрутизация вызовов
Claude возвращает имя инструмента и словарь аргументов. Превратить это в реальный вызов метода — наша работа. Нужна функция-маршрутизатор:
def process_tool_call(tool_name, tool_input):
if tool_name == "get_user":
return db.get_user(tool_input["key"], tool_input["value"])
elif tool_name == "get_order_by_id":
return db.get_order_by_id(tool_input["order_id"])
elif tool_name == "get_customer_orders":
return db.get_customer_orders(tool_input["customer_id"])
elif tool_name == "cancel_order":
return db.cancel_order(tool_input["order_id"])
Это и есть вся «магия» нескольких инструментов: модель решает что вызвать, а маршрутизатор знает, как это исполнить. Добавить пятый инструмент = добавить схему в tools и ветку в process_tool_call.
Один ход вручную
Прежде чем городить цикл, разберём один ход по шагам. Спросим: «Can you look up my orders? My email is john@gmail.com».
import anthropic
import json
client = anthropic.Client()
MODEL_NAME = "claude-sonnet-5"
messages = [{"role": "user",
"content": "Can you look up my orders? My email is john@gmail.com"}]
response = client.messages.create(
model=MODEL_NAME,
max_tokens=4096,
tools=tools,
messages=messages
)
В ответе Claude хочет вызвать get_user — из четырёх инструментов он выбрал единственный, которому хватает данных (у нас есть email, но нет ни ID клиента, ни ID заказа).
Теперь обработаем ответ: добавим его в историю, выполним инструмент и вернём результат.
# Кладём ответ Claude в историю диалога
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason == "tool_use":
tool_use = response.content[-1] # наивно: считаем, что вызов один
tool_name = tool_use.name
tool_input = tool_use.input
print(f"Claude хочет вызвать инструмент {tool_name}")
print(json.dumps(tool_input, indent=2))
# Реально исполняем инструмент на нашей базе
tool_result = process_tool_call(tool_name, tool_input)
# Возвращаем результат Claude — сообщением с ролью user
messages.append({
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": tool_use.id,
"content": str(tool_result),
}
],
})
else:
# Инструмент не нужен — просто печатаем текст
print("\nПоддержка TechNova: " + response.content[0].text)
Три момента, которые легко упустить:
- Ответ модели попадает в
messagesцеликом, какresponse.content, — иначе следующий запрос потеряет контекст вызова. - Результат инструмента отправляется ролью user в блоке
tool_resultс тем жеtool_use_id, что пришёл от модели. response.content[-1]— упрощение: мы предполагаем, что за ход вызывается ровно один инструмент. В общем случае надо перебирать все блоки типаtool_use.
Отправляем второй запрос с обновлённой историей:
response2 = client.messages.create(
model=MODEL_NAME,
max_tokens=4096,
tools=tools,
messages=messages
)
Теперь у Claude есть карточка клиента вместе с его id, и он хочет вызвать get_customer_orders — то есть подставляет результат первого инструмента во вход второго. Именно так выглядит цепочка вызовов. Оговорка: мест, где модель может ошибиться, здесь много — это только начало.
Как результат работы инструмента попадает обратно к Claude?
Цикл диалога
Разговаривать с ботом удобнее в консоли. Соберём весь код выше в одну функцию с бесконечным циклом — её лучше запускать отдельным скриптом:
def simple_chat():
user_message = input("\nUser: ")
messages = [{"role": "user", "content": user_message}]
while True:
# Если последнее сообщение от ассистента — спрашиваем пользователя
if messages[-1].get("role") == "assistant":
user_message = input("\nUser: ")
messages.append({"role": "user", "content": user_message})
response = client.messages.create(
model=MODEL_NAME,
max_tokens=4096,
tools=tools,
messages=messages
)
messages.append({"role": "assistant", "content": response.content})
if response.stop_reason == "tool_use":
tool_use = response.content[-1]
tool_name = tool_use.name
tool_input = tool_use.input
print(f"====== Claude вызывает инструмент {tool_name} ======")
tool_result = process_tool_call(tool_name, tool_input)
messages.append({
"role": "user",
"content": [
{
"type": "tool_result",
"tool_use_id": tool_use.id,
"content": str(tool_result),
}
],
})
else:
print("\nПоддержка TechNova: " + response.content[0].text)
Обратите внимание на условие в начале цикла. Новую реплику у пользователя мы спрашиваем только тогда, когда последнее сообщение — от ассистента. Если последним был tool_result, цикл идёт на следующий круг без участия человека: модель получает результат и продолжает работу. Так цепочка «найди пользователя → найди его заказы → отмени заказ» проходит за один ход пользователя.
Один список messages живёт всё время работы — это и есть память бота. Ничего дополнительного для «истории диалога» не нужно: и реплики, и вызовы инструментов, и их результаты лежат в одном списке.
Бот уже вызывает правильные инструменты, но отвечает так себе: он рассказывает пользователю, какие инструменты собирается дёрнуть. Клиенту это знать незачем.
Системный промпт и его правки
Первое, что стоит сделать, — объяснить модели, кто она и в чём её работа:
system_prompt = """
You are a customer support chat bot for an online retailer called TechNova.
Your job is to help users look up their account, orders, and cancel orders.
Be helpful and brief in your responses.
"""
И не забыть передать его в параметр system при вызове messages.create. После этого ассистент знает, что он поддержка TechNova, и перестаёт болтать про инструменты. Можно ещё и явно запретить их упоминать.
Поиграв со скриптом подольше, вы упрётесь в неприятную проблему. Пользователь пишет: «помогите отменить заказ, номера я не знаю». Правильное поведение — переспросить email, телефон или логин. А бот вместо этого вызывает get_user с выдуманным email — и, разумеется, никого не находит.
Лечится это тоже промптом:
system_prompt = """
You are a customer support chat bot for an online retailer called TechNova.
Your job is to help users look up their account, orders, and cancel orders.
Be helpful and brief in your responses.
You have access to a set of tools, but only use them when needed.
If you do not have enough information to use a tool correctly, ask a user
follow up questions to get the required inputs.
Do not call any of the tools unless you have the required data from a user.
"""
Теперь бот сначала уточняет данные и только потом лезет в базу. Это общее правило: если модель вызывает инструмент с придуманными аргументами, чаще всего ей просто не сказали, что можно переспросить.
Прячем размышления в теги
Мощные модели вроде Opus, работая с инструментами, часто выписывают ход мыслей в тегах вида <thinking> или <reflection> прежде чем ответить. Для качества это скорее плюс, для пользователя — ужас: он видит внутреннюю кухню.
Простое решение — попросить модель обернуть пользовательскую часть ответа в отдельные теги, а потом вырезать только её:
system_prompt = """
...
In each conversational turn, you will begin by thinking about your response.
Once you're done, you will write a user-facing response.
It's important to place all user-facing conversational responses in
<reply></reply> XML tags to make them easy to parse.
"""
А в коде добавляем извлечение:
import re
def extract_reply(text):
pattern = r'<reply>(.*?)</reply>'
match = re.search(pattern, text, re.DOTALL)
if match:
return match.group(1)
else:
return None
И в ветке «инструмент не нужен» печатаем не весь текст, а только вырезанное:
else:
model_reply = extract_reply(response.content[0].text)
print("\nПоддержка TechNova: " + f"{model_reply}")
Всё: размышления остаются в логе, пользователь видит чистый ответ.
Зачем просить модель класть ответ в теги <reply>…</reply>?
Ограничения демо
Это учебная демонстрация workflow с инструментами, а не готовый продукт. Что с ней не так:
- Ассистент выдаёт фразы вроде «номер заказа есть в письме-подтверждении», не зная, правда ли это. Реальному боту нужен большой пласт знаний о вашей компании — как минимум.
- Нет аутентификации: любой, кто знает чужой email, логин или телефон, отменит чужой заказ. В продакшене без проверки личности такое недопустимо.
- Обработка ошибок минимальна:
get_order_by_idможет вернутьNone,get_user— строку «не нашли», аprocess_tool_callпри неизвестном имени инструмента молча вернётNone. Всё это надо валидировать. - Бота нужно жёстко протестировать на всех сценариях, а не «поиграть пару раз в консоли».
Упражнения
Упражнение 6.1 — Инструмент «два в одном»
Опишите новый инструмент get_user_info, который объединяет get_user и get_customer_orders: принимает email, телефон или логин и возвращает данные пользователя вместе с историей заказов за один вызов.
Решение упражненияСначала попробуйте сами — потом сверьтесь
Схема повторяет get_user — те же key с enum и value:
{
"name": "get_user_info",
"description": "Looks up a user by email, phone, or username and returns "
"the user's details together with their full order history.",
"input_schema": {
"type": "object",
"properties": {
"key": {
"type": "string",
"enum": ["email", "phone", "username"],
"description": "The attribute to search for a user by."
},
"value": {
"type": "string",
"description": "The value to match for the specified attribute."
}
},
"required": ["key", "value"]
}
}
Метод в базе и ветка в маршрутизаторе:
def get_user_info(self, key, value):
user = self.get_user(key, value)
if isinstance(user, dict):
return {"user": user, "orders": self.get_customer_orders(user["id"])}
return user # строка «не нашли» — отдаём как есть
# в process_tool_call:
elif tool_name == "get_user_info":
return db.get_user_info(tool_input["key"], tool_input["value"])
Смысл упражнения — сократить цепочку из двух вызовов до одного. Меньше ходов туда-обратно — меньше мест, где модель может свернуть не туда.
Упражнение 6.2 — Обработка ошибок
Сделайте так, чтобы бот не падал и не врал, когда инструмент отработал неудачно: заказ не найден, имя инструмента неизвестно, в аргументах не хватает поля.
Решение упражненияСначала попробуйте сами — потом сверьтесь
Ключевая идея: ошибка — это тоже результат инструмента. Её надо не глотать, а вернуть модели текстом, чтобы та объяснила пользователю или переспросила.
def process_tool_call(tool_name, tool_input):
try:
if tool_name == "get_user":
return db.get_user(tool_input["key"], tool_input["value"])
elif tool_name == "get_order_by_id":
order = db.get_order_by_id(tool_input["order_id"])
return order if order else f"No order found with id {tool_input['order_id']}"
elif tool_name == "get_customer_orders":
return db.get_customer_orders(tool_input["customer_id"])
elif tool_name == "cancel_order":
return db.cancel_order(tool_input["order_id"])
else:
return f"Unknown tool: {tool_name}"
except KeyError as e:
return f"Missing required argument: {e}"
except ValueError as e:
return f"Invalid input: {e}"
В API есть и штатный способ пометить неудачу — флаг is_error в блоке tool_result:
{
"type": "tool_result",
"tool_use_id": tool_use.id,
"content": "No order found with id 00000",
"is_error": True,
}
Проверьте на живых сценариях: несуществующий номер заказа, отмена уже отгруженного заказа, попытка найти пользователя по несуществующему полю.
Что ещё стоит доделать самостоятельно: научить бота обновлять email и телефон пользователя (и метод в FakeDatabase, и инструмент), а затем — заменить FakeDatabase на настоящую базу.
Что запомнить
- Несколько инструментов = список схем в
tools+ функция-маршрутизатор, которая по имени вызывает нужный код. Модель решает «что», вы отвечаете за «как». - Закрытый набор значений параметра описывайте через
enum. - История диалога — один список
messages: реплики, вызовы инструментов и их результаты лежат вместе. - Результат возвращается ролью
userв блокеtool_resultс тем жеtool_use_id. - Цикл спрашивает пользователя только после ответа ассистента — иначе крутится дальше сам и выстраивает цепочку вызовов.
- Системный промпт решает две типовые беды: болтовню про инструменты и вызовы с выдуманными аргументами («не хватает данных — переспроси»).
- Ход мыслей прячем в теги и печатаем только содержимое <reply>.
- Бизнес-правила (что можно отменить) живут в коде, а не в промпте. Аутентификация — обязательна, если действие что-то меняет.
Куда двигаться дальше:
- Промптинг в реальных задачах — как доводить промпты до продакшена.
- Промпт-инжиниринг — базовый курс, если хочется закрыть фундамент.
Дочитали и сделали упражнения? Зафиксируйте прогресс — отметка сохранится в вашем браузере.