$ sudo teach IT

В первом уроке модуля вы видели набросок агента с загадочной функцией llm_decide: модель сама решает, вызвать инструмент или дать ответ. Пора сделать эту функцию настоящей. Для этого у Responses API есть механизм function calling: вы описываете модели свои функции, и она отвечает не текстом, а просьбой «вызови get_weather с аргументом city="Казань"». Выполняет функцию ваша программа, а результат возвращается модели.

После урока у вас будет рабочий агент на чистом Python и пакете openai: с инструментами, циклом, лимитом шагов и обработкой ошибок. Каждый фреймворк, который вы изучите дальше, делает внутри то же самое, поэтому этот урок — ключ к пониманию всего курса.

Что уже умеем

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

Что вы узнаете в рамках статьи

  • как описать функцию для модели через параметр tools и JSON Schema;
  • как распознать в ответе запрос на вызов функции и прочитать аргументы;
  • как вернуть результат модели через function_call_output и call_id;
  • как собрать из этого цикл агента с ограничением max_steps;
  • как обрабатывать ошибки инструментов, чтобы агент не падал.

Ключевые термины

  • Function calling — механизм, при котором модель возвращает запрос на вызов функции с аргументами вместо текста.
  • Описание инструмента — словарь с именем функции, описанием и JSON Schema её параметров.
  • function_call — элемент ответа модели: имя функции, аргументы в виде JSON-строки и идентификатор вызова.
  • call_id — идентификатор конкретного вызова, по которому модель сопоставляет запрос и результат.
  • function_call_output — элемент, которым программа возвращает модели результат функции.
  • max_steps — предел числа обращений к модели в одном запуске агента.

Описание инструментов для модели

Модель не видит ваш код на Python. Она знает о функции только то, что вы написали в описании: имя, назначение и параметры. Параметры описываются JSON Schema — тем же форматом, в который Pydantic превращал классы в прошлом уроке.

def get_weather(city: str) -> str:
    # учебная заглушка; в реальном агенте здесь запрос к сервису погоды
    data = {"Казань": "+12 °C, облачно", "Сочи": "+21 °C, солнечно"}
    return data.get(city, "нет данных")

def calculate(expression: str) -> str:
    return str(eval(expression, {"__builtins__": {}}))  # только для учебного примера

tools = [
    {
        "type": "function",
        "name": "get_weather",
        "description": "Возвращает текущую погоду в городе. Используй, когда спрашивают о погоде.",
        "parameters": {
            "type": "object",
            "properties": {
                "city": {"type": "string", "description": "Название города, например Казань"},
            },
            "required": ["city"],
        },
    },
    {
        "type": "function",
        "name": "calculate",
        "description": "Вычисляет арифметическое выражение. Используй для любых расчётов.",
        "parameters": {
            "type": "object",
            "properties": {
                "expression": {"type": "string", "description": "Выражение на Python, например 12 * 9 / 5 + 32"},
            },
            "required": ["expression"],
        },
    },
]

TOOLS = {"get_weather": get_weather, "calculate": calculate}

Здесь две разные вещи. Список tools — описания для модели, он уходит в API. Словарь TOOLS — связь имени с настоящей функцией, его использует ваша программа. Описание — это по сути инструкция: чем точнее вы напишете, когда инструмент нужен и что означает каждый параметр, тем реже модель будет ошибаться в выборе.

Первый вызов функции

Передадим описания в запрос и посмотрим, что вернёт модель:

import json
from openai import OpenAI

client = OpenAI()

response = client.responses.create(
    model="gpt-6-luna",
    input="Какая погода в Казани?",
    tools=tools,
)

for item in response.output:
    if item.type == "function_call":
        print(item.name, item.arguments, item.call_id)

Что увидим в консоли:

get_weather {"city": "Казань"} call_a1b2c3

Текста в ответе нет — response.output_text будет пустым. Вместо этого в списке response.output лежит элемент типа function_call. Обратите внимание: item.arguments — это строка в формате JSON, а не словарь. Перед вызовом функции её нужно разобрать через json.loads. Модель может попросить и несколько вызовов в одном ответе, поэтому response.output всегда перебирают циклом.

Возвращаем результат модели

Модель попросила вызвать функцию, но сама её не выполняет. Выполняем мы и отправляем результат обратно. Как и в диалоге из урока 1.4, ведём историю в списке: добавляем в неё все элементы ответа модели, а затем результат с тем же call_id.

history = [{"role": "user", "content": "Какая погода в Казани?"}]
response = client.responses.create(model="gpt-6-luna", input=history, tools=tools)
history += response.output  # запрос модели на вызов функции

for item in response.output:
    if item.type == "function_call":
        args = json.loads(item.arguments)
        result = TOOLS[item.name](**args)
        history.append({
            "type": "function_call_output",
            "call_id": item.call_id,
            "output": result,
        })

response = client.responses.create(model="gpt-6-luna", input=history, tools=tools)
print(response.output_text)

Что увидим: «Сейчас в Казани +12 °C, облачно». Второй запрос получил всю цепочку: вопрос, запрос на вызов и результат. По call_id модель понимает, на какой из её вызовов пришёл ответ. Если забыть добавить response.output в историю, API вернёт ошибку: результат будет ссылаться на вызов, которого модель «не делала».

Цикл агента с max_steps

Осталось повторять эти шаги, пока модель не перестанет вызывать функции. Это и есть настоящая реализация run_agent из урока 1.1:

def run_agent(task: str, max_steps: int = 5) -> str:
    history = [{"role": "user", "content": task}]
    for step in range(max_steps):
        response = client.responses.create(
            model="gpt-6-luna",
            instructions="Ты помощник. Для фактов и расчётов используй инструменты.",
            input=history,
            tools=tools,
        )
        history += response.output
        calls = [item for item in response.output if item.type == "function_call"]
        if not calls:
            return response.output_text            # финальный ответ
        for call in calls:
            args = call.arguments
            try:
                args = json.loads(call.arguments)
                result = TOOLS[call.name](**args)
            except Exception as error:
                result = f"Ошибка инструмента: {error}"
            print(f"Шаг {step}: {call.name}({args}) -> {result}")
            history.append({"type": "function_call_output",
                            "call_id": call.call_id, "output": str(result)})
    return "Не удалось решить задачу за отведённое число шагов"

print(run_agent("Какая погода в Казани и сколько это в Фаренгейтах?"))

Что увидим в консоли:

Шаг 0: get_weather({'city': 'Казань'}) -> +12 °C, облачно
Шаг 1: calculate({'expression': '12 * 9 / 5 + 32'}) -> 53.6
В Казани +12 °C, облачно — это 53.6 °F.

Разберём ключевые решения. Признак окончания — отсутствие вызовов функций в ответе: модель решила, что данных достаточно. Ошибка в инструменте не роняет программу, а возвращается модели как результат: увидев «Ошибка инструмента», она может исправить аргументы и попробовать снова. А max_steps гарантирует, что даже запутавшийся агент остановится — каждый шаг стоит денег, и, как вы помните из урока 1.2, каждый следующий шаг дороже предыдущего.

Частые ошибки

  • Вызывать функцию с item.arguments напрямую. Это JSON-строка, а не словарь. Сначала json.loads, потом *args.
  • Не добавлять response.output в историю. Результат функции без исходного запроса на вызов API не примет. Добавляйте весь вывод модели перед function_call_output.
  • Путать call_id. Каждый результат должен идти с call_id своего вызова, иначе модель перепутает ответы при нескольких вызовах.
  • Размытые описания инструментов. Описание «погода» модели мало что говорит. Пишите, что делает функция, когда её вызывать и в каком формате параметры.

Наиболее частые вопросы

  • Модель сама выполняет мои функции? — Нет. Модель только просит вызвать функцию, выполняет её всегда ваша программа. Поэтому вы полностью контролируете, что происходит.
  • Можно ли вернуть результат не строкой? — Поле output ожидает строку. Словари и списки превращайте в строку через json.dumps.
  • Что, если модель вызвала несуществующую функцию? — В нашем цикле TOOLS[call.name] вызовет KeyError, он перехватится, и модель получит сообщение об ошибке.
  • Можно ли вместо истории использовать previous_response_id? — Да: тогда в input отправляются только function_call_output. Ручная история нагляднее, поэтому в уроке используем её.

Итоги

Поздравляем, вы написали настоящего AI-агента с нуля! Функция llm_decide, которая в первом уроке была условной, теперь работает: модель получает описания инструментов, сама выбирает нужный, ваша программа выполняет функцию и возвращает результат, а цикл с max_steps доводит задачу до ответа и не даёт агенту зациклиться. Вы прошли весь первый модуль: от токенов и стоимости до структурированных ответов и цикла агента.

Теперь вы видите, сколько служебного кода нужно даже простому агенту: описания на JSON Schema, разбор аргументов, история, обработка ошибок. Во втором модуле вы перейдёте на LangChain, где create_agent берёт эту рутину на себя, — и благодаря этому уроку будете точно понимать, что происходит у него внутри.

На следующий урок