Вызов инструментов¶
Шаг 1¶
В предыдущем разделе мы научились получать от модели структурированный анализ обращения. И теперь модель выбирает категорию, извлекает номер заказа и товар, отмечает недостающие сведения, а уже Python-код проверяет результат и решает, нужна ли проверка человеком.
Но даже правильного анализа недостаточно, если ответ зависит от данных, которых нет в тексте клиента и в знаниях модели, так как модель не может это знать. Например если клиент спрашивает:
Номер заказа есть в сообщении, но его актуальный статус хранится во внутренней системе магазина. Модель не может достоверно определить, передан ли заказ курьеру, находится ли он в пункте выдачи или уже доставлен. Если попросить её просто ответить, она сможет только признать отсутствие данных либо попытаться пообщаться с вами:
Поэтому мы в этом разделе подключим к модели ограниченный Python-инструмент, который читает статус заказа из подготовленного источника. Начнём с одного сценария, который просто получает данные, но ничего не изменяет.
Что такое Tool Calling?¶
Tool Calling, или вызов инструментов, это способ позволить модели запросить у приложения данные или действие через заранее описанный контракт.
Слово «вызов» иногда создаёт неверное впечатление, будто модель напрямую запускает Python-функцию. На самом деле модель не получает доступ к исходному коду, памяти процесса или базе данных. Она возвращает специальный структурированный ответ, а именно название нужного инструмента и аргументы, с которыми предлагает его вызвать. Например:
Модель не выполняет:
get_order_status("8041")
Модель возвращает предложение:
инструмент = get_order_status
аргументы = {"order_id": "8041"}
После этого управление находится у приложения. Python-код должен самостоятельно решить:
- существует ли такой инструмент
- разрешён ли он в текущем сценарии
- соответствуют ли аргументы ожидаемой схеме
- можно ли выполнять операцию
- как передать результат обратно модели
Полный базовый цикл состоит из двух обращений к модели и одного локального вызова Python-функции.
Первый запрос нужен не для получения окончательной фразы, а для выбора инструмента и подготовки аргументов. Если инструмент не требуется, модель может сразу вернуть обычный текст. Например на сообщение «Какой номер заказа нужно указать?» внешний источник данных не нужен.
Если модель запросила инструмент, приложение выполняет его и делает второй запрос. На этот раз модель получает фактический результат и превращает его в понятный пользователю ответ. Она не должна самостоятельно угадывать статус между первым и вторым запросами.
То есть модель выбирает подходящий инструмент из списка, который приложение передало в конкретном запросе. Она ориентируется на название, описание, схему аргументов и developer-инструкцию. Но окончательное право выполнения остаётся у приложения. Даже если API вернул корректно оформленный запрос на вызов инструмента, Python-код не должен автоматически искать функцию по имени среди всех объектов программы. Ведь имя приходит из вероятностного ответа и считается недоверенными данными.
В этом разделе мы добавим в проект отдельную команду для вопроса о заказе. Пользователь сможет ввести свободную формулировку, модель при необходимости запросит get_order_status, приложение проверит и выполнит вызов, а затем модель сформирует ответ на основе реальных данных инструмента. Давайте перейдем к практике в следующем шаге.
Шаг 2¶
В предыдущем шаге мы разобрали общий цикл работы Tool Calling, то есть модель может предложить инструмент и аргументы, но реальное выполнение происходит только в Python-приложении.
Теперь подготовим первую контролируемую возможность и добавим чтение статуса заказа по его номеру. На этом шаге не будем обращаться к модели, а сначала создадим обычную функцию, проверим её вход и результат, а затем опишем тот же вход в формате Tool Schema.
Tool Schema (схема инструмента) — это описание того, какие параметры принимает инструмент, каких они должны быть типов и что этот инструмент делает. Именно по этой схеме LLM понимает, как правильно сформировать вызов инструмента.
При этом Tool Calling не превращает модель в источник бизнес-данных. Полезная работа всё равно должна существовать на стороне приложения, а именно запрос к API, чтение базы, вычисление или другая разрешённая операция. И если функция непонятна или небезопасна без LLM, добавление tool schema не исправит её. Поэтому сначала определяем обычный контракт:
В учебном проекте пока нет базы заказов, поэтому мы будем использовать небольшой словарь в памяти, чтобы сосредоточиться на Tool Calling. Позже такой источник можно заменить репозиторием или HTTP-клиентом, не меняя смысл самого инструмента.
Создаём пакет инструментов¶
В каталоге app создайте пакет tools и пустой файл __init__.py:
Затем создайте app/tools/order_status.py:
from enum import StrEnum
from typing import Any
from pydantic import BaseModel, ConfigDict, Field
GET_ORDER_STATUS_TOOL_NAME = "get_order_status"
class OrderStatus(StrEnum):
PROCESSING = "processing"
IN_TRANSIT = "in_transit"
READY_FOR_PICKUP = "ready_for_pickup"
DELIVERED = "delivered"
class GetOrderStatusArguments(BaseModel):
model_config = ConfigDict(extra="forbid")
order_id: str = Field(
pattern=r"^\d{4}$",
description="Номер заказа из четырёх цифр.",
)
class OrderStatusResult(BaseModel):
order_id: str
found: bool
status: OrderStatus | None
message: str
ORDER_DATA: dict[str, OrderStatus] = {
"8041": OrderStatus.IN_TRANSIT,
"7718": OrderStatus.DELIVERED,
"9912": OrderStatus.READY_FOR_PICKUP,
}
def get_order_status(
arguments: GetOrderStatusArguments,
) -> OrderStatusResult:
status = ORDER_DATA.get(arguments.order_id)
if status is None:
return OrderStatusResult(
order_id=arguments.order_id,
found=False,
status=None,
message="Заказ с таким номером не найден.",
)
return OrderStatusResult(
order_id=arguments.order_id,
found=True,
status=status,
message="Статус заказа успешно получен.",
)
ORDER_STATUS_TOOL: dict[str, Any] = {
"type": "function",
"function": {
"name": GET_ORDER_STATUS_TOOL_NAME,
"description": (
"Возвращает актуальный статус заказа "
"по его четырёхзначному номеру. "
"Используй инструмент только для вопроса "
"о статусе или местонахождении заказа."
),
"strict": True,
"parameters": (
GetOrderStatusArguments.model_json_schema()
),
},
}
Разбираем входные аргументы. GetOrderStatusArguments описывает единственный аргумент order_id. Мы не принимаем произвольную строку, а хотим сделать так, чтобы номер состоял ровно из четырёх цифр.
extra="forbid" запрещает дополнительные поля. Если в аргументах окажется, например, customer_name, Pydantic отклонит объект. Это важно, даже если лишнее поле не используется функцией, то приложение должно исполнять только известный контракт, а не молча принимать всё, что вернула модель. То есть мы получаем следующие правила вызова:
GetOrderStatusArguments(order_id="8041")
# корректно
GetOrderStatusArguments(order_id="ABC")
# ошибка: строка не соответствует шаблону
GetOrderStatusArguments(
order_id="8041",
customer_name="Иван",
)
# ошибка: дополнительное поле запрещено
Мы уже использовали Pydantic для проверки структурированных ответов. Здесь применяется тот же принцип что и ранее, а именно то, что результат модели нельзя считать доверенным только потому, что он похож на JSON. Перед выполнением инструмента приложение создаст GetOrderStatusArguments и получит либо типизированные аргументы, либо понятную ошибку.
Теперь давайте обсудим то, а почему результат тоже типизирован. Ведь OrderStatusResult задаёт стабильный формат результата инструмента. Для найденного заказа found=True, а status содержит значение перечисления. Для неизвестного номера found=False и status=None. Неизвестный заказ в данном сценарии не является техническим сбоем. Функция успешно выполнила поиск и получила нормальный бизнес-результат «не найдено». Поэтому она возвращает объект, а не выбрасывает исключение.
Теперь давайте посмотрим на сам ORDER_STATUS_TOOL, это как раз и есть Tool Schema, которое принимаетChat Completions API. Обратите внимание, данная функция описывается объектом с type="function". Внутри function находятся имя, описание, строгий режим и JSON Schema аргументов.
name— технический идентификатор. По нему приложение позже сопоставит запрос модели с разрешённой функцией. Название должно быть стабильным, поэтому вынесено вGET_ORDER_STATUS_TOOL_NAMEи используется и в схеме, и в проверке.descriptionпомогает модели решить, когда применять инструмент. Здесь мы явно ограничиваем назначение вопросами о статусе или местонахождении заказа, ведь инструмент не предназначен для отмены, возврата денег или изменения адреса.parametersсодержит уже знакомую JSON Schema. Мы строим её из Pydantic-модели, чтобы не поддерживать два отдельных описания одного входа.strict=Trueпросит API строго придерживаться схемы при формировании аргументов.
До интеграции с API вы можете проверить что данный код работает правильно. Можете создать файл в корне проекта и запустить его:
from app.tools.order_status import (
GetOrderStatusArguments,
get_order_status,
)
known = get_order_status(
GetOrderStatusArguments(order_id="8041")
)
print(known.model_dump_json(indent=2))
unknown = get_order_status(
GetOrderStatusArguments(order_id="1234")
)
print(unknown.model_dump_json(indent=2))
Для 8041 функция вернёт in_transit. Для 1234 выполнение тоже завершится успешно, но found будет равно false.
Затем отдельно проверьте неправильный номер:
Pydantic должен отклонить значение до вызова get_order_status().
Давайте подведем итоги этого шага. У нас появилась одна ограниченная возможность чтения и два представления её контракта, а именно типизированные аргументы для приложения и tool schema для модели. При этом источник данных пока учебный, но граница уже устроена так, чтобы его можно было заменить.
В следующем шаге мы расширим LLMClient и он научится передавать список tools и сохранять tool calls из ответа, но по-прежнему не будет выполнять Python-функции самостоятельно.
Шаг 3¶
У нас уже есть функция get_order_status() и схема, по которой модель сможет подготовить её аргументы. Но текущий LLMClient умеет работать только с обычным текстом, потоковой генерацией и структурированными ответами.
Теперь добавим отдельный режим для Tool Calling и уже клиент будет передавать список доступных инструментов в API и преобразовывать запросы модели в наши типы. Выполнять функции внутри LLMClient не будем, ведь его ответственность заканчивается на сетевом протоколе. При этом общий цикл остаётся тем же, но конкретные поля различаются: здесь используются messages, assistant.tool_calls и сообщение с ролью tool.
Как выглядит ответ с tool call?¶
При обычном ответе полезный текст находится в choice.message.content, а finish_reason обычно равен stop.
Если модель выбрала инструмент, content может быть равен None, а ужеfinish_reason получает значение tool_calls, и запросы находятся в choice.message.tool_calls.
{
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "get_order_status",
"arguments": "{\"order_id\":\"8041\"}"
}
}
]
}
}
Обратите внимание, arguments — это строка с JSON, а не готовый Python-словарь. Клиент сохранит строку без самостоятельного разбора. И уже проверять её по конкретной Pydantic-модели должен сервис, который знает выбранный инструмент. А поле id связывает запрос инструмента с будущим результатом. Когда приложение выполнит функцию, оно отправит сообщение с ролью tool и тем же tool_call_id. Без этой связи модель не поймёт, к какому запросу относится результат.
Перейдем к практике и начнем с расширения общих типов. Откройте app/llm/client.py. Импорт Any уже появился при добавлении структурированных ответов, а рядом добавьте Literal:
Существующий псевдоним Message разрешает только строковые значения:
Но для Tool Calling этого недостаточно. Сообщение assistant будет содержать список tool_calls, а его content может быть равен None. Замените псевдоним и добавьте тип описания инструмента:
dict[str, Any] здесь отражает транспортный формат Chat Completions API. Доменные данные от этого менее типизированными не становятся, ведь аргументы конкретного инструмента по-прежнему будут проверяться отдельной Pydantic-моделью.
Перед LLMResult добавьте тип одного запроса инструмента:
В LLMResult после refusal добавьте поле:
@dataclass(frozen=True)
class LLMResult:
text: str | None
refusal: str | None
tool_calls: tuple[ToolCall, ...]
model: str
finish_reason: str | None
prompt_tokens: int | None
completion_tokens: int | None
total_tokens: int | None
response_id: str
Кортеж может быть пустым, а значит что обычная генерация и структурированные ответы продолжат возвращать тот же LLMResult, просто без запросов инструментов.
Идем дальше. Метод _to_result() уже является общей точкой преобразования ChatCompletion. Добавим чтение новых данных именно туда, чтобы не дублировать разбор ответа.
После получения choice и usage создайте кортеж:
tool_calls = tuple(
ToolCall(
id=tool_call.id,
name=tool_call.function.name,
arguments=tool_call.function.arguments,
)
for tool_call in (choice.message.tool_calls or [])
)
Затем передайте его в LLMResult. Полностью обновлённый метод выглядит так:
@staticmethod
def _to_result(response: ChatCompletion) -> LLMResult:
if not response.choices:
raise LLMProviderError(
"Провайдер вернул ответ без вариантов продолжения."
)
choice = response.choices[0]
usage = response.usage
tool_calls = tuple(
ToolCall(
id=tool_call.id,
name=tool_call.function.name,
arguments=tool_call.function.arguments,
)
for tool_call in (choice.message.tool_calls or [])
)
return LLMResult(
text=choice.message.content,
refusal=choice.message.refusal,
tool_calls=tool_calls,
model=response.model,
finish_reason=choice.finish_reason,
prompt_tokens=(
usage.prompt_tokens if usage is not None else None
),
completion_tokens=(
usage.completion_tokens if usage is not None else None
),
total_tokens=(
usage.total_tokens if usage is not None else None
),
response_id=response.id,
)
Теперь нам нужно добавить публичный метод генерации с инструментами, поэтому рядом с generate_structured() добавьте:
def generate_with_tools(
self,
messages: list[Message],
tools: list[ToolDefinition],
tool_choice: Literal["auto", "none"] = "auto",
) -> LLMResult:
try:
return self._generate_with_tools_once(
messages=messages,
tools=tools,
tool_choice=tool_choice,
)
except openai.APIError as error:
raise self._convert_error(error) from error
Метод использует уже знакомую границу ошибок. Сетевые исключения SDK по-прежнему преобразуются в ошибки LLMClient, поэтому прикладному сервису не придётся зависеть от openai.APIError. А tool_choice="auto" разрешает модели самой выбрать между обычным ответом и запросом инструмента. Значение none пригодится после выполнения функции, ведь второй ответ должен завершить сценарий текстом, а не запросить ещё один инструмент.
Теперь нам нужно передать tools в Chat Completions API, для этого перед _to_result() добавьте внутренний метод:
def _generate_with_tools_once(
self,
messages: list[Message],
tools: list[ToolDefinition],
tool_choice: Literal["auto", "none"],
) -> LLMResult:
response = self._client.chat.completions.create(
model=self._settings.model,
messages=messages,
temperature=self._settings.temperature,
max_completion_tokens=(
self._settings.max_output_tokens
),
tools=tools,
tool_choice=tool_choice,
parallel_tool_calls=False,
)
return self._to_result(response)
Новый аргумент tools передаёт модели только те возможности, которые разрешены в этом запросе. Само наличие Python-функции в проекте ничего не меняет, ведь пока её схема не находится в tools, модель не может сформировать для неё штатный tool call.
parallel_tool_calls=False запрещает модели запрашивать несколько функций параллельно в одном ответе. В текущем разделе мы строим сценарий максимум с одним вызовом, поэтому ограничение задаётся уже на уровне запроса. Позже приложение всё равно проверит количество элементов, ведь внешнему ответу нельзя доверять только на основании переданного параметра.
И возможно у вас возник вопрос, а почему не добавляем tools в обычный _generate_once()? Но ответ довольно прост, чат, суммаризация и финальный структурированный анализ уже имеют понятные контракты. Если передать инструменты в общий _generate_once(), модель сможет неожиданно запросить функцию в сценарии, который не умеет обрабатывать tool calls.
Отдельный метод делает новую возможность явной:
llm_client.generate(messages)
# ожидаем обычный текст
llm_client.generate_structured(...)
# ожидаем JSON по схеме
llm_client.generate_with_tools(...)
# допускаем обычный текст или tool call
Так вызывающий код заранее понимает возможные исходы и обязан их обработать.
После generate_with_tools() сервис не должен проверять только text. Возможны два нормальных пути:
finish_reason="stop",tool_callsпуст — модель сразу вернула текстfinish_reason="tool_calls", кортеж содержит один запрос, а значит приложению нужно проверить и выполнить инструмент
Отказ модели, остановка по длине, неизвестная причина завершения, пустой список при tool_calls и несколько запросов в нашем ограниченном сценарии будут ошибками.
Подводя итоги, в этом шаге мы научили LLMClient передавать модели описания доступных инструментов и получать от неё запросы на их вызов — tool_calls. При этом клиент только обменивается данными с моделью, ведь он не запускает Python-функции и не проверяет, разрешён ли выбранный инструмент.
В следующем шаге мы создадим сервис, который свяжет этот механизм с функцией get_order_status(). Он проверит название инструмента и его аргументы, выполнит функцию, а затем передаст результат модели, чтобы она сформировала окончательный ответ пользователю.
Шаг 4¶
Теперь все отдельные части готовы. У нас есть инструмент get_order_status(), строгая схема его аргументов и метод LLMClient.generate_with_tools(), который умеет получать запросы инструментов от модели.
В этом шаге мы соединим их в один прикладной сценарий. Сервис примет свободный вопрос пользователя, разрешит модели выбрать инструмент, проверит tool call, выполнит одну Python-функцию и сделает второй запрос для финального ответа. Но перед тем как начать это, давайте обсудим и еще раз разберем то, что мы написали и общую суть взаимодействия.
Собираем уже изученные части в один сценарий¶
До этого мы разбирали Tool Calling по частям:
- Во втором шаге мы написали функцию
get_order_status()и описали её для модели - В третьем шаге мы научили
LLMClientпередавать это описание модели и получатьToolCall
Теперь соединим эти части. Сначала ещё раз коротко пройдём весь путь, чтобы дальше было понятно, зачем нужен каждый фрагмент кода.
Представим, что пользователь спрашивает: «Где мой заказ 8041?». Приложение отправляет модели две вещи:
messages— инструкции и вопрос пользователяtools— описание доступного инструмента
В tools находится только описание, это имя get_order_status и схема его аргументов.
Первый запрос выглядит так:
first_result = llm_client.generate_with_tools(
messages=messages,
tools=[ORDER_STATUS_TOOL],
tool_choice="auto",
)
Тут tool_choice="auto" означает, что модель сама определяет, нужен ли ей инструмент. Модель, температура и лимит ответа здесь не указаны, потому что LLMClient уже берёт их из настроек.
Дальше возможны два варианта:
- если номера заказа нет, модель возвращает обычный текст и просит его указать
- если номер есть, модель возвращает запрос на вызов инструмента
Запрос на вызов приходит в виде уже знакомого ToolCall:
Важно, это ещё не статус заказа. Модель только просит приложение вызвать get_order_status() с номером 8041.
И вот в этом шаге мы должны написать сервис, который должен проверить имя инструмента и аргументы, вызвать Python-функцию и получить реальный статус. Затем он отправит этот статус модели вторым запросом, чтобы модель написала понятный ответ пользователю. Давайте реализуем эту цепочку в сервисе.
Но начнем мы с отдельного версионируемого промпта. Механизм PromptTemplate нам уже знаком, поэтому сосредоточимся на правилах нового сценария.
Создайте каталог app/prompts/order_status_assistant. В файле v1_0_0.py добавьте:
from app.prompts.template import PromptTemplate
ORDER_STATUS_ASSISTANT_PROMPT_V1_0_0 = PromptTemplate(
prompt_id="order_status_assistant",
version="1.0.0",
body="""
Ты помогаешь клиенту узнать статус заказа.
Правила:
- Для получения актуального статуса используй только
инструмент get_order_status.
- Не придумывай статус заказа и не делай вывод по памяти.
- Если номер заказа не указан, попроси клиента назвать
четырёхзначный номер и не вызывай инструмент.
- После получения результата объясни его кратко и ясно.
- Если заказ не найден, сообщи об этом без догадок.
- Не утверждай, что можешь отменить заказ, вернуть деньги
или изменить данные заказа.
""",
required_variables=frozenset(),
)
Для других версионируемых промптов мы уже использовали registry.py. Сохраним ту же структуру и здесь. Создайте app/prompts/order_status_assistant/registry.py:
from app.prompts.order_status_assistant.v1_0_0 import (
ORDER_STATUS_ASSISTANT_PROMPT_V1_0_0,
)
from app.prompts.template import (
PromptTemplate,
PromptTemplateError,
)
ACTIVE_ORDER_STATUS_ASSISTANT_VERSION = "1.0.0"
_ORDER_STATUS_ASSISTANT_PROMPTS: dict[
str,
PromptTemplate,
] = {
ORDER_STATUS_ASSISTANT_PROMPT_V1_0_0.version: (
ORDER_STATUS_ASSISTANT_PROMPT_V1_0_0
),
}
def get_order_status_assistant_prompt(
version: str | None = None,
) -> PromptTemplate:
selected_version = (
ACTIVE_ORDER_STATUS_ASSISTANT_VERSION
if version is None
else version.strip()
)
try:
return _ORDER_STATUS_ASSISTANT_PROMPTS[
selected_version
]
except KeyError as error:
available = ", ".join(
_ORDER_STATUS_ASSISTANT_PROMPTS
)
raise PromptTemplateError(
"Неизвестная версия order_status_assistant: "
f"{selected_version}. Доступны: {available}."
) from error
def list_order_status_assistant_versions() -> tuple[str, ...]:
return tuple(_ORDER_STATUS_ASSISTANT_PROMPTS)
Реестр пока содержит одну версию, но уже предоставляет привычный способ получить активный или явно выбранный вариант. Подробно этот механизм мы разбирали раньше, поэтому здесь не будем повторять его устройство.
Теперь в app/prompts/order_status_assistant/__init__.py создайте публичный импорт:
from app.prompts.order_status_assistant.registry import (
ACTIVE_ORDER_STATUS_ASSISTANT_VERSION,
get_order_status_assistant_prompt,
list_order_status_assistant_versions,
)
ORDER_STATUS_ASSISTANT_PROMPT = (
get_order_status_assistant_prompt()
)
__all__ = [
"ACTIVE_ORDER_STATUS_ASSISTANT_VERSION",
"ORDER_STATUS_ASSISTANT_PROMPT",
"get_order_status_assistant_prompt",
"list_order_status_assistant_versions",
]
И вот теперь сама Инструкция и описание инструмента для модели дополняют друг друга. Описание инструмента помогает выбрать функцию, а developer инструкции задаёт правила всего сценария, а именно не выдумывать статус, попросить номер при его отсутствии и не обещать действий, которых у сервиса нет.
Важно не смешивать эти два механизма. Текст промпта сам по себе не создаёт инструмент и не описывает его параметры для API. И наоборот, схема в tools не заменяет правила поведения ассистента. В первом запросе мы передадим и developer инструкции, и ORDER_STATUS_TOOL.
Теперь давайте напишем сам сервис. Создайте app/services/order_status_assistant.py и добавьте импорты:
from dataclasses import dataclass
from pydantic import ValidationError
from app.llm.client import (
LLMClient,
LLMResult,
Message,
ToolCall,
)
from app.llm.errors import LLMClientError
from app.prompts.order_status_assistant import (
ORDER_STATUS_ASSISTANT_PROMPT,
)
from app.tools.order_status import (
GET_ORDER_STATUS_TOOL_NAME,
GetOrderStatusArguments,
ORDER_STATUS_TOOL,
get_order_status,
)
Ниже создайте ошибки:
class OrderStatusAssistantError(RuntimeError):
pass
class UnknownToolError(OrderStatusAssistantError):
pass
class InvalidToolArgumentsError(OrderStatusAssistantError):
pass
class UnexpectedToolFlowError(OrderStatusAssistantError):
pass
Разные классы сохраняют причину отказа. Неизвестное имя, неправильные аргументы и нарушение ожидаемого протокола — это разные проблемы, даже если консоль сначала покажет их одинаково.
Затем добавьте результат успешного сценария:
@dataclass(frozen=True)
class OrderStatusAnswer:
text: str
tool_used: bool
order_id: str | None
model: str
prompt_id: str
prompt_version: str
total_tokens: int | None
response_ids: tuple[str, ...]
Поле tool_used показывает, вызывало ли приложение инструмент get_order_status(). Если пользователь не указал номер заказа, модель просто попросит его уточнить, а tool_used будет равно False. Если инструмент был вызван, в order_id сохраним номер заказа, с которым выполнялся запрос.
А теперь начинаем писать сам сервис и начнем с метода, который собирает сообщения. В том же файле добавьте класс:
class OrderStatusAssistant:
def __init__(self, llm_client: LLMClient) -> None:
self._llm_client = llm_client
self._instruction = (
ORDER_STATUS_ASSISTANT_PROMPT.render()
)
def _build_messages(
self,
user_text: str,
) -> list[Message]:
return [
{
"role": "developer",
"content": self._instruction,
},
{
"role": "user",
"content": (
"<customer_request>\n"
f"{user_text}\n"
"</customer_request>"
),
},
]
Вопрос пользователя и инструкция передаются модели отдельными сообщениями. Готовый LLMClient передаётся в сервис при его создании, поэтому сервису не нужно самостоятельно настраивать подключение к модели.
Сообщения для модели готовы. Теперь добавим основной метод answer(), который отправит их модели вместе с описанием доступного инструмента. Для этого используем generate_with_tools(), созданный в предыдущем шаге:
def answer(self, user_text: str) -> OrderStatusAnswer:
text = user_text.strip()
if not text:
raise OrderStatusAssistantError(
"Запрос не должен быть пустым."
)
messages = self._build_messages(text)
try:
first_result = self._llm_client.generate_with_tools(
messages=messages,
tools=[ORDER_STATUS_TOOL],
tool_choice="auto",
)
except LLMClientError as error:
raise OrderStatusAssistantError(
f"Не удалось обработать запрос: {error}"
) from error
В этом коде мы сначала проверяем, что пользователь передал непустой вопрос, а затем собираем сообщения. Вместе с ними передаём описание инструмента get_order_status. Значение tool_choice="auto" позволяет модели самой решить: ответить сразу или запросить вызов инструмента. Результат первого обращения сохраняем в first_result.
После первого обращения модель может сразу вернуть текст — например попросить пользователя указать номер заказа. Поэтому продолжаем метод answer() и обрабатываем этот вариант:
if first_result.refusal is not None:
raise OrderStatusAssistantError(
f"Модель отказалась отвечать: "
f"{first_result.refusal}"
)
if first_result.finish_reason == "stop":
if first_result.tool_calls:
raise UnexpectedToolFlowError(
"Текстовый ответ неожиданно содержит tool calls."
)
reply = self._require_text(first_result)
return self._build_answer(
text=reply,
tool_used=False,
order_id=None,
results=(first_result,),
)
Если модель вернула готовый текст, метод собирает OrderStatusAnswer и завершает работу.
Если модель не вернула готовый текст, значит дальше мы ожидаем запрос на вызов инструмента. Этот код также продолжаем писать внутри метода answer():
if first_result.finish_reason != "tool_calls":
raise UnexpectedToolFlowError(
"Неожиданная причина завершения: "
f"{first_result.finish_reason}."
)
if len(first_result.tool_calls) != 1:
raise UnexpectedToolFlowError(
"Ожидался ровно один tool call."
)
tool_call = first_result.tool_calls[0]
if tool_call.name != GET_ORDER_STATUS_TOOL_NAME:
raise UnknownToolError(
f"Неизвестный инструмент: {tool_call.name}."
)
Сначала проверяем, что модель действительно запросила инструмент. Затем убеждаемся, что запрос только один, поскольку в этом сценарии разрешено не более одного вызова.
После этого берём запрос из first_result.tool_calls и проверяем его имя. Сейчас приложение разрешает только get_order_status, поэтому любой другой инструмент считаем ошибкой. При этом сама Python-функция ещё не вызывается — сначала нужно проверить её аргументы.
После проверки имени нужно разобрать аргументы, которые прислала модель. Этот код также продолжаем писать внутри метода answer():
try:
arguments = (
GetOrderStatusArguments.model_validate_json(
tool_call.arguments
)
)
except ValidationError as error:
raise InvalidToolArgumentsError(
"Модель вернула некорректные аргументы "
"для get_order_status."
) from error
tool_result = get_order_status(arguments)
Аргументы приходят от модели в виде JSON-строки. Метод model_validate_json() преобразует её в GetOrderStatusArguments и одновременно проверяет номер заказа. Если JSON составлен неправильно или данные не соответствуют схеме, выполнение остановится с ошибкой.
После успешной проверки приложение вызывает get_order_status() и сохраняет полученные данные в tool_result. До этого модель только предлагала вызвать инструмент. Реальное выполнение Python-функции происходит именно на этой строке:
Теперь приложение знает статус заказа, но это ещё не готовый ответ пользователю. Модель остановилась на просьбе вызвать инструмент, поэтому нужно сообщить ей результат и попросить продолжить ответ.
Для этого дополняем список messages двумя сообщениями:
assistant— запрос инструмента, который вернула модельtool— результат выполненияget_order_status()
В итоге история будет выглядеть так:
developer: правила работы ассистента
user: "Где заказ 8041?"
assistant: вызови get_order_status для заказа 8041
tool: заказ 8041 передан в доставку
Результат передаётся с ролью tool, а не user, потому что это ответ инструмента, а не новое сообщение пользователя. Поле tool_call_id помогает модели понять, к какому запросу относится этот результат.
Теперь добавим запрос инструмента и результат его выполнения в messages, а затем снова обратимся к модели. Этот код продолжаем писать внутри answer():
messages.extend(
[
self._build_tool_call_message(
first_result,
tool_call,
),
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": tool_result.model_dump_json(),
},
]
)
try:
final_result = self._llm_client.generate_with_tools(
messages=messages,
tools=[ORDER_STATUS_TOOL],
tool_choice="none",
)
except LLMClientError as error:
raise OrderStatusAssistantError(
f"Не удалось сформировать ответ: {error}"
) from error
Метод _build_tool_call_message() добавляет в историю запрос инструмента, который сделала модель. Следом добавляется сообщение с ролью tool: в нём находится результат работы get_order_status(). Метод model_dump_json() преобразует этот результат в JSON-строку, которую можно передать через API.
После этого повторно вызываем модель. Теперь она видит вопрос пользователя, свой запрос инструмента и полученный статус заказа. Значение tool_choice="none" запрещает ей запрашивать инструмент ещё раз и во втором ответе мы ожидаем только готовый текст для пользователя. Если второй запрос завершится ошибкой, преобразуем её в OrderStatusAssistantError.
Во втором ответе ожидаем получить только готовый текст для пользователя. Перед его возвращением проверим, что модель действительно завершила работу правильно. Этот код также остаётся внутри answer():
if final_result.refusal is not None:
raise OrderStatusAssistantError(
f"Модель отказалась отвечать: "
f"{final_result.refusal}"
)
if final_result.finish_reason != "stop":
raise UnexpectedToolFlowError(
"Модель не вернула финальный текст. "
f"Причина: {final_result.finish_reason}."
)
if final_result.tool_calls:
raise UnexpectedToolFlowError(
"Финальный ответ содержит новый tool call."
)
reply = self._require_text(final_result)
return self._build_answer(
text=reply,
tool_used=True,
order_id=arguments.order_id,
results=(first_result, final_result),
)
Сначала проверяем, не отказалась ли модель отвечать. Затем убеждаемся, что она завершила ответ с причиной stop и не запросила ещё один инструмент.
Метод _require_text() получает непустой текст ответа. После этого _build_answer() собирает итоговый результат сервиса. Значение tool_used=True показывает, что инструмент был вызван, а в order_id сохраняется номер проверенного заказа. Оба результата модели передаются в results, чтобы сохранить идентификаторы запросов и посчитать общее количество токенов.
После answer() добавьте методы:
@staticmethod
def _build_tool_call_message(
result: LLMResult,
tool_call: ToolCall,
) -> Message:
return {
"role": "assistant",
"content": result.text,
"tool_calls": [
{
"id": tool_call.id,
"type": "function",
"function": {
"name": tool_call.name,
"arguments": tool_call.arguments,
},
}
],
}
@staticmethod
def _require_text(result: LLMResult) -> str:
if result.text is None:
raise UnexpectedToolFlowError(
"Модель не вернула текст."
)
text = result.text.strip()
if not text:
raise UnexpectedToolFlowError(
"Модель вернула пустой текст."
)
return text
@staticmethod
def _build_answer(
text: str,
tool_used: bool,
order_id: str | None,
results: tuple[LLMResult, ...],
) -> OrderStatusAnswer:
token_values = [result.total_tokens for result in results]
total_tokens = (
None
if any(value is None for value in token_values)
else sum(
value
for value in token_values
if value is not None
)
)
return OrderStatusAnswer(
text=text,
tool_used=tool_used,
order_id=order_id,
model=results[-1].model,
prompt_id=ORDER_STATUS_ASSISTANT_PROMPT.prompt_id,
prompt_version=(
ORDER_STATUS_ASSISTANT_PROMPT.version
),
total_tokens=total_tokens,
response_ids=tuple(
result.response_id for result in results
),
)
Каждый запрос к API выполняется отдельно. Модель не помнит первый запрос автоматически. Поэтому при втором обращении мы должны снова передать всю необходимую историю:
Пользователь: Где заказ 8041?
Модель: Нужно вызвать get_order_status для заказа 8041
Инструмент: Заказ передан в доставку
_build_tool_call_message() создаёт вторую строку этой истории — сообщение о том, что модель запросила get_order_status. Следом мы добавляем результат функции с ролью tool. Оба сообщения связываются через одинаковый tool_call_id.
Если передать только результат функции, модель не поймёт, на какой запрос инструмента он отвечает. Поэтому во втором запросе нужны и просьба модели вызвать инструмент, и результат его выполнения.
_build_answer() выполняет другую задачу: он собирает результат уже для нашего приложения:
OrderStatusAnswer(
text="Заказ передан в доставку",
tool_used=True,
order_id="8041",
total_tokens=...,
response_ids=(...),
)
Иногда модель отвечает сразу — тогда обращение к API было одно. Если понадобился инструмент, обращений было два. И _build_answer() подходит для обоих случаев, складывает потраченные токены и сохраняет идентификаторы всех запросов. Это пригодится для логов и поиска проблем.
Я понимаю что данный код может быть слишком сложным, поэтому давайте разберем его. Метод answer() это один полный сценарий обработки вопроса. Внутри него возможны два пути: модель отвечает сразу или сначала просит приложение вызвать инструмент.
Сначала _build_messages() создаёт два сообщения: инструкцию для модели и вопрос пользователя. Затем первый generate_with_tools() отправляет их вместе с описанием ORDER_STATUS_TOOL.
После этого возможны два пути.
Если модель вернула готовый текст, значит инструмент не понадобился. Например, пользователь не указал номер заказа, и модель попросила его уточнить. В этом случае _build_answer() сразу собирает результат с tool_used=False, после чего answer() завершает работу.
Если модель вернула tool call, приложение не запускает функцию сразу. Сначала оно проверяет:
- что модель запросила только один инструмент;
- что этот инструмент называется
get_order_status; - что аргументы содержат корректный номер заказа.
Только после этих проверок вызывается get_order_status(). Полученный результат добавляется в messages вместе с запросом инструмента, который сделала модель.
Затем выполняется второй generate_with_tools(). Теперь модель видит статус заказа и формирует обычный текст для пользователя. Новые инструменты во втором запросе запрещены с помощью tool_choice="none".
В конце _build_answer() собирает текст, номер заказа, расход токенов и идентификаторы запросов в один OrderStatusAnswer.
Таким образом, модель только предлагает вызвать инструмент. Все проверки и сам вызов Python-функции выполняет приложение.
Подводя итоги этого шага можем сказать, что мы реализовали минимальный, но полный сценарий Tool Calling и в следующем шаге мы подключим сервис к консоли и проверим три пользовательских сценария: известный заказ, неизвестный заказ и отсутствие номера.
Шаг 5¶
В предыдущем шаге мы создали OrderStatusAssistant. Он умеет обработать вопрос двумя способами:
- если данных недостаточно, сразу попросить пользователя уточнить номер заказа
- если номер указан, вызвать
get_order_status(), получить статус и подготовить окончательный ответ
Теперь подключим этот сервис к консоли и проверим весь сценарий в работающем приложении.
Сейчас обычные сообщения пользователя обрабатывает ChatService. Он хранит историю разговора и отвечает без вызова инструментов. Но проверка заказа устроена сложнее, ведь модель может запросить инструмент, приложение выполняет Python-функцию, а затем ещё раз обращается к модели. Если сразу встроить эту логику в обычный чат, придётся одновременно разбираться с хранением сообщений tool в истории.
Чтобы пока не смешивать две задачи, добавим отдельную команду /order. Она выполнит одну проверку заказа и не изменит историю обычного чата.
Позже мы создадим общий механизм для нескольких инструментов и тогда модель сможет выбирать их прямо в обычном чате без отдельных команд.
Откройте app/console.py и добавьте импорт:
from app.services.order_status_assistant import (
OrderStatusAnswer,
OrderStatusAssistant,
OrderStatusAssistantError,
)
Теперь создайте функцию, которая будет выводить результат проверки заказа:
def print_order_status_answer(result: OrderStatusAnswer) -> None:
print(f"\nAssistant: {result.text}")
print(
"Инструмент использован: "
f"{'да' if result.tool_used else 'нет'}"
)
if result.order_id is not None:
print(f"Номер заказа: {result.order_id}")
print(
f"Промпт: {result.prompt_id}@{result.prompt_version}"
)
print(f"Всего токенов: {result.total_tokens}")
print(
"ID ответов: "
f"{', '.join(result.response_ids)}"
)
Кроме ответа модели, функция показывает техническую информацию:
- вызывался ли
get_order_status() - какой номер заказа проверялся
- какая версия промпта использовалась
- сколько токенов было потрачено
- какие запросы к модели участвовали в операции
Количество response_ids помогает увидеть, какой путь выполнился. Если модель сразу попросила уточнить номер, обращение к API было одно. Если понадобился инструмент, обращений было два.
Теперь передаём новый сервис в консоль, для этого добавьте OrderStatusAssistant в параметры run_console():
def run_console(
service: ChatService,
analyzer: SupportRequestAnalyzer,
order_status_assistant: OrderStatusAssistant,
app_env: str,
configured_model: str,
) -> None:
Затем обновите подсказку с доступными командами:
print(
"Команды: /new — новый диалог, "
"/analyze — анализ обращения, "
"/order — статус заказа, /exit — выход."
)
Теперь внутри цикла while, после обработки /analyze, добавьте обработку новой команды:
if command == "/order":
request_text = input("Вопрос о заказе: ")
try:
order_result = order_status_assistant.answer(
request_text
)
except OrderStatusAssistantError as error:
print(f"Ошибка проверки заказа: {error}")
continue
print_order_status_answer(order_result)
continue
Когда пользователь вводит /order, консоль просит написать вопрос и передаёт его в OrderStatusAssistant. Обратите внимание на последний continue. Благодаря ему вопрос о заказе не попадёт в ChatService и не сохранится в истории обычного разговора.
А теперь откройте main.py и добавьте импорт:
После создания request_analyzer создайте сервис проверки заказа:
Затем передайте его в run_console():
run_console(
chat_service,
request_analyzer,
order_status_assistant,
app_env=settings.app_env,
configured_model=settings.model,
)
Создавать новый LLMClient не нужно. Все три сервиса могут использовать один настроенный клиент:
При этом каждый сервис отвечает только за свой сценарий.
И давайте наконец запустите наш проект и сразу выполним команду:
Затем задайте вопрос без номера:
Модель не может проверить заказ, потому что не знает его номер. Поэтому она должна попросить пользователя указать четырёхзначный номер и не вызывать инструмент.
Ожидаемый результат:
Текст модели может отличаться. Важно проверить поведение:
- модель попросила номер
get_order_status()не вызывался- к модели обратились один раз
Теперь проверим существующий заказ. Снова введите /order и задайте вопрос:
Теперь сценарий будет состоять из нескольких шагов:
- Модель увидит номер
8041. - Модель запросит
get_order_status. - Приложение проверит имя инструмента и аргументы.
- Функция найдёт заказ и вернёт его статус.
- Приложение отправит результат модели.
- Модель подготовит ответ пользователю.
Ожидаемый ответ:
Два идентификатора означают, что к модели обратились два раза:
- Первый запрос → модель попросила вызвать инструмент
- Вызов функции → приложение получило статус заказа
- Второй запрос → модель сформулировала ответ
Сам get_order_status() выполняется локально и не является отдельным обращением к модели.
А теперь проверим несуществующий заказ. Снова введите /order и задайте вопрос:
Номер 1234 имеет правильный формат, поэтому проверка аргументов пройдёт успешно и функция будет вызвана.
Но в учебном хранилище такого заказа нет. Поэтому get_order_status() вернёт результат с found=False.
Это не ошибка. Инструмент выполнился правильно и сообщил, что заказ не найден. Модель должна просто передать эту информацию пользователю.
Почему это ещё не AI-агент?¶
AI-агент — это система, в которой модель может сама выбирать последовательность действий для достижения цели: вызвать инструмент, оценить результат и решить, что делать дальше. При этом приложение всё равно ограничивает доступные инструменты и количество шагов.
Наш сценарий проще. Модель может только ответить сразу или один раз запросить get_order_status(). Дальнейший порядок заранее задан Python-кодом:
Модель не составляет план, не выбирает несколько инструментов и не решает, нужно ли сделать ещё один шаг. Поэтому это ограниченный сценарий вызова инструмента, а не AI-агент. Tool Calling может быть частью агента, но один вызов инструмента сам по себе агентом не является.
И давайте еще поговорим о том, а когда вызов инструментов не нужен. Обычно Tool Calling нужен, когда модель должна понять вопрос пользователя и выбрать подходящий источник данных. В остальных случаях можно использовать более простой подход.
И давайте подведем итоги этого раздела. В нем мы реализовали полный сценарий вызова одного инструмента:
Напомню, что модель не запускает get_order_status() самостоятельно. Она только предлагает вызов и передаёт аргументы, а уже проверку и выполнение контролирует приложение.
Пока у нас только один инструмент, его достаточно проверять обычным условием. В следующем разделе мы создадим реестр и общий ограниченный цикл, а затем подключим их к обычному чату. После этого модель сможет выбирать подходящий инструмент при обработке сообщения пользователя без отдельной команды /order.
Шаг 6¶
Что именно делает модель при Tool Calling?
Правильный ответ
- ✅ Предлагает имя разрешённого инструмента и аргументы
Шаг 7¶
Из каких частей состоит описание инструмента-функции (function tool), которое мы передаём LLM, чтобы модель понимала, что это за функция и как её вызывать?
Правильный ответ
- ✅ JSON Schema аргументов
- ✅ Описание назначения
- ✅ Стабильное имя функции
Шаг 8¶
Что означает finish_reason = "tool_calls"?
Правильный ответ
- ✅ Модель запросила вызов одного или нескольких инструментов вместо готового ответа
Шаг 9¶
Зачем после выполнения инструмента перед следующим запросом к модели сохранять в истории вызов инструмента (tool call) и полученный от инструмента результат?
Правильный ответ
- ✅ Чтобы модель увидела контекст вызова и сформировала понятный финальный ответ
Шаг 10¶
Описание инструмента получения статуса заказа¶
Перед вызовом инструмента модель должна знать его название, назначение и допустимые аргументы. Эти сведения передаются в виде обычного Python-словаря с описанием function tool. В этой задаче вы подготовите такое описание для инструмента, который получает статус заказа по четырёхзначному номеру.
Задача¶
Реализуйте функцию build_order_status_tool(). Она не должна обращаться к модели или искать заказ. Функция должна только создать и вернуть словарь с описанием инструмента get_order_status.
Возвращаемый словарь имеет два уровня:
- на верхнем уровне находятся ключи
typeиfunction; - значением
functionявляется вложенный словарь с ключамиname,description,strictиparameters; - значением
parametersявляется JSON Schema аргументов инструмента.
Требования¶
- Не изменяйте имя функции
build_order_status_tool. Функция не принимает аргументов. - Верните Python-словарь, а не JSON-строку.
- На верхнем уровне словаря сохраните строку
"function"по ключу"type". - По ключу
"function"сохраните вложенный словарь с описанием инструмента. - Во вложенном словаре задайте
name="get_order_status"иstrict=True. - Значение
descriptionдолжно быть непустой строкой и содержать словастатусизаказ. Регистр букв не имеет значения. - По ключу
parametersсохраните JSON Schema сtype="object". - В
propertiesобъявите только одно полеorder_id. Для него задайтеtype="string"и шаблонpattern="^\d{4}$". - Добавьте
order_idв списокrequired. - Запретите неизвестные аргументы с помощью
additionalProperties=False.
Пример результата¶
tool = build_order_status_tool()
print(tool["type"])
print(tool["function"]["name"])
print(tool["function"]["parameters"]["required"])
print(tool["function"]["parameters"]["additionalProperties"])
Результат:
Заготовка функции уже находится в редакторе. Дополнительные импорты и сторонние библиотеки не нужны.
Правильный ответ
def build_order_status_tool() -> dict:
return {
"type": "function",
"function": {
"name": "get_order_status",
"description": "Получить статус заказа по его номеру.",
"strict": True,
"parameters": {
"type": "object",
"properties": {
"order_id": {
"type": "string",
"pattern": r"^\d{4}$",
},
},
"required": ["order_id"],
"additionalProperties": False,
},
},
}
Шаг 11¶
Проверка и выполнение запроса статуса заказа¶
Модель не вызывает Python-функцию самостоятельно: она возвращает предложение вызвать инструмент и передаёт аргументы в виде JSON-строки. Приложение должно проверить имя инструмента и аргументы до обращения к данным. В этой задаче вместо внешней системы используется словарь со статусами заказов.
Задача¶
Реализуйте функцию execute_order_status_call(tool_call, statuses). Она должна проверить полученный вызов get_order_status, найти заказ в словаре statuses и вернуть сообщение роли tool с результатом.
tool_call является словарём с тремя полями: id содержит идентификатор вызова, name — имя инструмента, а arguments — JSON-строку с аргументами. Словарь statuses сопоставляет номер заказа его статусу.
Требования¶
- Не изменяйте имя функции
execute_order_status_callи её аргументы. - Разрешайте только вызов с
tool_call["name"] == "get_order_status". Для другого имени выбросьтеValueError. - Преобразуйте
tool_call["arguments"]с помощьюjson.loads(). Если строка не является корректным JSON или на верхнем уровне получен не словарь, выбросьтеValueError. - В разобранных аргументах должен находиться ровно один ключ
order_id. Дополнительные и отсутствующие поля не допускаются. - Значение
order_idдолжно быть строкой ровно из четырёх цифр. При нарушении этого правила выбросьтеValueError. - Найдите номер заказа в
statuses. Если он найден, подготовьте данные{"order_id": номер, "found": True, "status": найденный статус}. - Если номера нет в
statuses, подготовьте данные{"order_id": номер, "found": False, "status": None}. - Преобразуйте подготовленные данные в JSON-строку с помощью
json.dumps(). - Верните словарь сообщения с
role="tool",tool_call_id, равным исходномуtool_call["id"], иcontent, содержащим JSON-строку результата. Вcontentдолжен находиться именно текст JSON, а не Python-словарь.
Пример результата¶
call = {
"id": "call-1",
"name": "get_order_status",
"arguments": '{"order_id": "8041"}',
}
statuses = {"8041": "in_transit"}
message = execute_order_status_call(call, statuses)
print(message["role"])
print(message["tool_call_id"])
print(json.loads(message["content"]))
Результат:
Импорт json и заготовка функции уже находятся в редакторе. Настоящий API и внешнее хранилище не используются.
Правильный ответ
import json
import re
def execute_order_status_call(tool_call: dict, statuses: dict[str, str]) -> dict:
if tool_call["name"] != "get_order_status":
raise ValueError
try:
arguments = json.loads(tool_call["arguments"])
except (json.JSONDecodeError, TypeError):
raise ValueError
if not isinstance(arguments, dict):
raise ValueError
if set(arguments) != {"order_id"}:
raise ValueError
order_id = arguments["order_id"]
if (
not isinstance(order_id, str)
or len(order_id) != 4
or not order_id.isdigit()
):
raise ValueError
if order_id in statuses:
result = {
"order_id": order_id,
"found": True,
"status": statuses[order_id],
}
else:
result = {
"order_id": order_id,
"found": False,
"status": None,
}
return {
"role": "tool",
"tool_call_id": tool_call["id"],
"content": json.dumps(result),
}
Шаг 12¶
Ответ модели после выполнения инструмента¶
Сценарий tool calling может состоять из двух обращений к модели. В первом ответе модель либо сразу возвращает текст, либо предлагает вызвать инструмент. Если инструмент вызван, приложение выполняет его, добавляет результат в историю сообщений и просит модель сформулировать окончательный ответ.
Задача¶
Реализуйте функцию answer_with_order_tool(client, user_text, tool_definition, statuses). Она должна обработать прямой текстовый ответ или один вызов инструмента get_order_status, а затем вернуть пользователю итоговый текст.
Фейковый client предоставляет метод generate_with_tools(). Каждый его результат содержит finish_reason, text и последовательность tool_calls.
Требования¶
- Не изменяйте имя функции и набор аргументов.
user_textдолжен быть строкой с хотя бы одним непробельным символом. Иначе выбросьтеValueError. Для сообщения пользователя используйте текст без внешних пробелов.- Создайте список сообщений с одним user-сообщением, содержащим очищенный
user_text. - Выполните первый вызов
client.generate_with_tools()с именованными аргументамиmessages,tools=[tool_definition]иtool_choice="auto". - Если первый результат имеет
finish_reason == "stop", в нём не должно быть tool calls, аtextдолжен быть непустой строкой. Верните очищенный текст сразу, не выполняя второй запрос. - Продолжать сценарий с инструментом можно только при
finish_reason == "tool_calls"и ровно одном элементе вtool_calls. Любое другое сочетание завершите сValueError. - Единственный вызов должен иметь имя
get_order_status. Егоargumentsдолжны быть JSON-объектом ровно с одним полемorder_id, содержащим строку из четырёх цифр. При ошибке выбросьтеValueError. - Найдите заказ в
statusesи подготовьте JSON-результат с полямиorder_id,foundиstatus. Для неизвестного заказа используйтеfound=Falseиstatus=None. - Добавьте к истории assistant-сообщение, которое сохраняет исходный вызов инструмента, а затем tool-сообщение с исходным идентификатором вызова и JSON-строкой результата.
- Выполните второй вызов
client.generate_with_tools()с обновлённымиmessages, тем же спискомtoolsиtool_choice="none". Значение"none"запрещает модели запрашивать ещё один инструмент. - Второй результат должен иметь
finish_reason == "stop", непустой строковыйtextи пустой списокtool_calls. Иначе выбросьтеValueError. - Верните итоговый текст второго ответа без внешних пробелов. Выполнять третий запрос нельзя.
Пример результата¶
Фейковый клиент сначала предлагает вызвать get_order_status для заказа 8041, а после получения результата возвращает текст:
answer = answer_with_order_tool(
fake_client,
" Где заказ 8041? ",
tool_definition,
{"8041": "in_transit"},
)
print(answer)
Результат:
В этом сценарии первый запрос выполняется с tool_choice="auto", а второй — с tool_choice="none".
Импорт json, заготовка функции и фейковый LLM-клиент уже предоставлены. Настоящий API-ключ и сетевой доступ не нужны.
Правильный ответ
import json
import re
def answer_with_order_tool(client, user_text, tool_definition, statuses) -> str:
if not isinstance(user_text, str):
raise ValueError
user_text = user_text.strip()
if not user_text:
raise ValueError
messages = [
{
"role": "user",
"content": user_text,
}
]
first_result = client.generate_with_tools(
messages=messages,
tools=[tool_definition],
tool_choice="auto",
)
if first_result.finish_reason == "stop":
if first_result.tool_calls:
raise ValueError
if not isinstance(first_result.text, str) or not first_result.text.strip():
raise ValueError
return first_result.text.strip()
if (
first_result.finish_reason != "tool_calls"
or len(first_result.tool_calls) != 1
):
raise ValueError
tool_call = first_result.tool_calls[0]
if tool_call.name != "get_order_status":
raise ValueError
try:
arguments = json.loads(tool_call.arguments)
except (json.JSONDecodeError, TypeError):
raise ValueError
if not isinstance(arguments, dict):
raise ValueError
if set(arguments) != {"order_id"}:
raise ValueError
order_id = arguments["order_id"]
if (
not isinstance(order_id, str)
or len(order_id) != 4
or not order_id.isdigit()
):
raise ValueError
if order_id in statuses:
tool_result = {
"order_id": order_id,
"found": True,
"status": statuses[order_id],
}
else:
tool_result = {
"order_id": order_id,
"found": False,
"status": None,
}
messages.append(
{
"role": "assistant",
"tool_calls": [tool_call],
}
)
messages.append(
{
"role": "tool",
"tool_call_id": tool_call.id,
"content": json.dumps(tool_result),
}
)
second_result = client.generate_with_tools(
messages=messages,
tools=[tool_definition],
tool_choice="none",
)
if (
second_result.finish_reason != "stop"
or not isinstance(second_result.text, str)
or not second_result.text.strip()
or second_result.tool_calls
):
raise ValueError
return second_result.text.strip()