Параллельный вызов функций и обработка сложных зависимостей

50 минут Урок 12

Введение: Когда одной функции недостаточно

Здравствуйте, коллеги. В предыдущих модулях мы научились учить Gemini нажимать на «кнопки» — вызывать отдельные функции. Это база. Но в реальной архитектуре энтерпрайз-уровня задачи редко бывают линейными и атомарными.

Представьте, что вы строите ассистента для финансового аналитика. Пользователь спрашивает: «Сравни цены акций Apple, Google и Microsoft и скажи, стоит ли мне перекладываться».

Если мы будем действовать по старинке (последовательно), диалог будет выглядеть как мучительное ожидание:

  1. Модель вызывает get_stock_price('AAPL') -> Ждем API -> Ответ.
  2. Модель вызывает get_stock_price('GOOGL') -> Ждем API -> Ответ.
  3. Модель вызывает get_stock_price('MSFT') -> Ждем API -> Ответ.
  4. Модель анализирует.

Это медленно. Это дорого (лишние токены на вход/выход). И это скучно для пользователя. Сегодня мы разберем параллельный вызов функций (Parallel Function Calling), который позволяет модели запросить все три котировки в одном ответе, и как работать со сложными зависимостями, когда результат одной функции критически необходим для запуска другой.

Анатомия параллелизма в Gemini

Современные версии модели (начиная с 1.5 Pro и переходя к нашему контексту Gemini 3) обладают достаточным «интеллектуальным ресурсом», чтобы предвидеть необходимость нескольких действий сразу.

Как это выглядит под капотом?
Вместо того чтобы вернуть один объект function_call, модель возвращает список (массив) таких объектов в рамках одного Candidate. Ваша задача как архитектора решения — не просто принять этот список, но и корректно его обработать.

Ключевые преимущества:

  • Снижение латентности: Вы делаете сетевые запросы к своим бэкендам асинхронно (одновременно), а не последовательно. Время ответа равно времени самой медленной функции, а не сумме всех функций.
  • Целостность контекста: Модель видит полную картину сразу, получив пакет данных, что снижает риск галлюцинаций при сравнении данных.

Давайте посмотрим, как это реализуется в коде.

python
import google.generativeai as genai
from google.protobuf import struct_pb2

# 1. Определение инструментов (Tools)
# Допустим, у нас есть функция получения погоды и новостей

def get_current_weather(location: str):
    """Возвращает текущую погоду в заданном городе."""
    # Имитация API запроса
    print(f"[API CALL] Запрос погоды для: {location}")
    return {"temperature": 25, "condition": "Sunny", "location": location}

def get_local_news(location: str):
    """Возвращает заголовки новостей для города."""
    # Имитация API запроса
    print(f"[API CALL] Запрос новостей для: {location}")
    return {"headline": "Открытие нового парка", "location": location}

tools_list = [get_current_weather, get_local_news]

# 2. Инициализация модели с инструментами
model = genai.GenerativeModel(
    model_name='gemini-1.5-pro-latest', # Используем актуальную версию
    tools=tools_list
)

# 3. Запуск чата с автоматическим вызовом функций (режим auto)
chat = model.start_chat(enable_automatic_function_calling=True)

# 4. Запрос, требующий параллельности
# Обратите внимание: мы спрашиваем про два города сразу
response = chat.send_message(
    "Какая погода сейчас в Лондоне и в Токио? И есть ли интересные новости в Токио?"
)

# В консоли мы увидим:
# [API CALL] Запрос погоды для: London
# [API CALL] Запрос погоды для: Tokyo
# [API CALL] Запрос новостей для: Tokyo

print(response.text)

Обработка «вручную»: Когда автоматики недостаточно

Пример выше использует enable_automatic_function_calling=True. Это удобно для прототипов. Но как Архитекторы ИИ-решений, вы часто будете работать в ситуациях, где вызов функции — это триггер для сложной бизнес-логики, требующей валидации, логирования или обращения к закрытым базам данных.

Когда вы обрабатываете вызовы вручную, алгоритм меняется:

  1. Отправляем промпт.
  2. Проверяем, содержит ли ответ части function_call.
  3. ВАЖНО: Итерируемся по ВСЕМ частям вызова функций. Модель может вернуть список [Weather(London), Weather(Tokyo), News(Tokyo)].
  4. Выполняем их (желательно асинхронно/параллельно на уровне вашего кода, например, через asyncio.gather в Python).
  5. Формируем список ответов function_response.
  6. Отправляем этот список обратно модели.

Если вы отправите ответы не в том порядке или забудете один из них, модель может запутаться («потерять контекст») или выдать ошибку.

python
# Продвинутый пример: Ручная обработка параллельных вызовов

response = chat.send_message("Сравни погоду в Париже и Берлине")
part = response.candidates[0].content.parts[0]

# Проверка на наличие вызова функций
if part.function_call:
    # В реальности response.parts может содержать несколько function_call
    # Нам нужно собрать их все
    function_calls = []
    for p in response.candidates[0].content.parts:
        if p.function_call:
            function_calls.append(p.function_call)
            print(f"Модель хочет вызвать: {p.function_call.name} с аргументами {p.function_call.args}")
    
    # ЭТАП ВЫПОЛНЕНИЯ (здесь должна быть ваша бизнес-логика)
    # Для примера выполняем синхронно, но в продакшене тут нужен asyncio
    responses = []
    for fc in function_calls:
        result = get_current_weather(fc.args['location'])
        
        # Формируем ответ для Gemini
        # Важно вернуть имя функции, чтобы модель знала, к чему относится ответ
        responses.append(
            genai.protos.Part(function_response=genai.protos.FunctionResponse(
                name=fc.name,
                response={'result': result}
            ))
        )
    
    # Отправляем результаты обратно модели
    final_response = chat.send_message(responses)
    print(final_response.text)

Сложные зависимости: Последовательность против Параллелизма

Параллелизм — это прекрасно, но не всегда применимо. Существует класс задач, называемый зависимыми цепочками (Dependency Chains).

Пример: «Найди последний заказ пользователя john_doe и оформи возврат по этому заказу».

Здесь мы не можем запустить refund_order(order_id) одновременно с get_last_order(user_id), потому что у нас еще нет order_id. Это классическая проблема курицы и яйца, если пытаться распараллелить всё подряд.

Паттерн «Мыслитель» (Reasoning Loop)

Для решения таких задач модель должна работать итеративно. Gemini 3 отлично справляется с этим, если правильно настроить системный промпт или структуру инструментов.

Как модель понимает, что делать?
1. Шаг 1: Модель видит запрос. Понимает, что для возврата нужен ID. Видит функцию поиска ID. Вызывает get_last_order.
2. Шаг 2: Вы возвращаете результат (например, {"id": "ORD-123", "status": "delivered"}).
3. Шаг 3: Модель получает контекст. Теперь у неё есть аргумент для следующей функции. Она вызывает refund_order("ORD-123").

Главная ошибка новичков — пытаться заставить модель «угадать» ID или пытаться сжать два шага в один промпт без промежуточного возврата управления.

python
# Пример зависимых функций

# База данных (mock)
users_db = {"alice": "user_999"}
orders_db = {"user_999": ["order_55a", "order_77b"]}

def find_user_id(username: str):
    """Находит ID пользователя по имени."""
    return users_db.get(username, None)

def get_user_orders(user_id: str):
    """Возвращает список заказов по ID пользователя."""
    if not user_id:
        return "Error: User ID required"
    return orders_db.get(user_id, [])

# Сценарий:
# User: "Какие заказы были у Alice?"
# 1. Модель не может вызвать get_user_orders('alice'), так как нужен ID.
# 2. Модель должна сначала вызвать find_user_id('alice').

# В Gemini это происходит естественно в цикле чата.
# Если enable_automatic_function_calling=True, библиотека сама сделает 2 цикла (turn).

chat = model.start_chat(enable_automatic_function_calling=True)
response = chat.send_message("Покажи заказы пользователя Alice")

# Под капотом:
# -> Call find_user_id('alice')
# <- Return 'user_999'
# -> Call get_user_orders('user_999')
# <- Return ['order_55a', 'order_77b']
# -> Final Answer

Обработка ошибок в сложных цепочках

Что произойдет, если в цепочке из 5 параллельных функций одна упадет с ошибкой 500? Или если в последовательной цепочке пользователь не найден?

Как архитектор, вы должны реализовать стратегии отказоустойчивости:

  • Partial Failure (Частичный отказ): При параллельном вызове, если get_weather('Tokyo') вернул ошибку, а get_weather('London') успешен, не нужно крашить весь чат. Верните модели JSON с ошибкой для Токио. Gemini достаточно умна, чтобы сказать: «Вот погода в Лондоне, а данные по Токио сейчас недоступны».
  • Graceful Degradation (Плавная деградация): В последовательных цепях возвращайте понятные описания ошибок, а не стектрейсы. Вместо NullReferenceException верните {"error": "User not found", "suggestion": "Ask for correct spelling"}. Это позволит модели попросить пользователя уточнить имя, вместо того чтобы галлюцинировать.

Упражнение

Создайте систему для планирования командировки. Вам нужно определить 3 функции (mock): 1. `get_flight_price(city)` 2. `get_hotel_price(city)` 3. `calculate_total(flight, hotel)`. <br><br>Задача: Напишите код (структуру), который обрабатывает запрос пользователя 'Сколько будет стоить поездка в Париж?'. <br><br>Учтите, что: <br>- Функции 1 и 2 должны вызываться параллельно (они независимы). <br>- Функция 3 (если вы решите доверить расчет модели, а не делать это внутри) или финальный ответ модели зависят от результатов первых двух.

Вопрос

У вас есть сценарий: Ассистент должен забронировать столик в ресторане, но сначала нужно проверить наличие свободных мест. Функция `book_table` требует `reservation_token`, который возвращает функция `check_availability`. Какой тип вызова функций выберет Gemini и почему?