RU ▾

Пример вызова инструментов Ollama API с OpenAI SDK

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

Обновлено

Иллюстрация того, как модель передаёт задачу функции и получает результат в цикле

Ключевые моменты

  1. Передайте схему функции в формате JSON в поле tools; модель ответит tool_calls вместо текста.
  2. Вы запускаете функцию, добавляете результат как сообщение role tool и снова вызываете API.
  3. Всегда парсите аргументы с защитой от сбоев, так как модель может вернуть некорректный или неожиданный JSON.
  4. Потоковые вызовы инструментов приходят фрагментами, которые нужно собрать по индексу.

Как выглядит цикл

Если вы ищете рабочий пример вызова инструментов Ollama API для хостингового эндпоинта, структура соответствует стандартной OpenAI. Вы описываете функции в массиве tools. Модель либо отвечает текстом, либо возвращает список tool_calls с именем функции и аргументами. Модель ничего не запускает. Ваш код запускает функцию, отправляет вывод обратно, и модель пишет финальный ответ.

Это делает один цикл использования инструмента равным двум вызовам API:

  1. Отправьте сообщение пользователя и определения инструментов.
  2. Получите сообщение ассистента с tool_calls. Запустите каждый вызов локально.
  3. Отправьте исходные сообщения, сообщение ассистента и одно сообщение role: "tool" для каждого вызова с результатом.
  4. Получите финальный текстовый ответ.

В примерах используется базовый URL https://api.ollamaapi.com/v1 и идентификатор модели uncensored. Эндпоинт представляет собой хостинговый сервис, поэтому ключ берётся со страницы API-ключ. Тот же код работает и с локальным сервером Ollama, если изменить базовый URL, ключ и имя модели, при условии поддержки инструментов локальной моделью.

Полный пример на Python

Пример даёт модели одну функцию, get_order_status, работающую на небольшом словаре. В реальном приложении здесь был бы запрос к базе данных или внутреннему сервису.

import json
from openai import OpenAI

client = OpenAI(base_url="https://api.ollamaapi.com/v1", api_key="YOUR_KEY")

ORDERS = {"A1001": "shipped", "A1002": "processing", "A1003": "delivered"}

def get_order_status(order_id: str) -> str:
    return ORDERS.get(order_id, "not found")

tools = [{
    "type": "function",
    "function": {
        "name": "get_order_status",
        "description": "Look up the status of an order by its ID.",
        "parameters": {
            "type": "object",
            "properties": {
                "order_id": {"type": "string", "description": "Order ID such as A1001"}
            },
            "required": ["order_id"],
        },
    },
}]

messages = [{"role": "user", "content": "Where is order A1002?"}]

first = client.chat.completions.create(
    model="uncensored",
    messages=messages,
    tools=tools,
    tool_choice="auto",
)
msg = first.choices[0].message

if msg.tool_calls:
    messages.append(msg)  # keep the assistant turn with its tool_calls
    for call in msg.tool_calls:
        args = json.loads(call.function.arguments)
        result = get_order_status(**args)
        messages.append({
            "role": "tool",
            "tool_call_id": call.id,
            "content": result,
        })
    final = client.chat.completions.create(
        model="uncensored",
        messages=messages,
        tools=tools,
    )
    print(final.choices[0].message.content)
else:
    print(msg.content)

Три детали важны здесь. Сообщение ассистента с tool_calls должно возвращаться в историю перед результатами инструментов, иначе следующий запрос будет отклонён или модель запутается. Каждое сообщение инструмента должно содержать tool_call_id вызова, на который оно отвечает. И второй запрос всё ещё должен включать tools, так как модель может захотеть вызвать другую функцию после получения первого результата.

Тот же запрос с curl

Тестирование с curl исключает SDK из процесса, что помогает понять, проблема в вашем коде или в запросе.

curl https://api.ollamaapi.com/v1/chat/completions \
  -H "Authorization: Bearer YOUR_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "uncensored",
    "messages": [{"role": "user", "content": "Where is order A1002?"}],
    "tools": [{
      "type": "function",
      "function": {
        "name": "get_order_status",
        "description": "Look up the status of an order by its ID.",
        "parameters": {
          "type": "object",
          "properties": {"order_id": {"type": "string"}},
          "required": ["order_id"]
        }
      }
    }],
    "tool_choice": "auto"
  }'

Успешный первый ответ имеет finish_reason, установленный в tool_calls, пустой или null content и массив tool_calls. Поле arguments — это строка, содержащая JSON, а не вложенный объект, поэтому в примере на Python используется json.loads.

Поле tool_choice управляет строгостью запроса. auto позволяет модели решить самостоятельно. Передача объекта с именем функции запрашивает именно эту функцию. Используйте auto, если нет причин, потому что принудительный вызов для сообщения, которому он не нужен, приводит к выдуманным аргументам.

Тот же цикл на Node

Версия Node использует официальный пакет openai и следует тем же шагам. Эта версия обрабатывает более одного вызова за ход с помощью Promise.all.

import OpenAI from "openai";

const client = new OpenAI({
  baseURL: "https://api.ollamaapi.com/v1",
  apiKey: process.env.API_KEY,
});

const handlers = {
  get_order_status: async ({ order_id }) =>
    ({ A1001: "shipped", A1002: "processing" })[order_id] ?? "not found",
};

const tools = [{
  type: "function",
  function: {
    name: "get_order_status",
    description: "Look up the status of an order by its ID.",
    parameters: {
      type: "object",
      properties: { order_id: { type: "string" } },
      required: ["order_id"],
    },
  },
}];

const messages = [{ role: "user", content: "Where is order A1001?" }];
const first = await client.chat.completions.create({
  model: "uncensored", messages, tools,
});
const msg = first.choices[0].message;

if (msg.tool_calls?.length) {
  messages.push(msg);
  const results = await Promise.all(msg.tool_calls.map(async (call) => {
    const args = JSON.parse(call.function.arguments);
    const out = await handlers[call.function.name](args);
    return { role: "tool", tool_call_id: call.id, content: String(out) };
  }));
  messages.push(...results);
  const final = await client.chat.completions.create({
    model: "uncensored", messages, tools,
  });
  console.log(final.choices[0].message.content);
} else {
  console.log(msg.content);
}

Потоковая передача вызовов инструментов

С stream: true ответ приходит по SSE, а вызовы инструментов приходят частями. Каждый чанк содержит запись delta.tool_calls с index. Первый фрагмент для индекса содержит ID вызова и имя функции. Последующие фрагменты добавляют срезы строки arguments. Вы объединяете срезы по индексу и парсите JSON только после завершения потока.

stream = client.chat.completions.create(
    model="uncensored",
    messages=[{"role": "user", "content": "Where is order A1003?"}],
    tools=tools,
    stream=True,
)

calls = {}
for chunk in stream:
    if not chunk.choices:
        continue
    delta = chunk.choices[0].delta
    for part in (delta.tool_calls or []):
        slot = calls.setdefault(part.index, {"id": "", "name": "", "args": ""})
        if part.id:
            slot["id"] = part.id
        if part.function and part.function.name:
            slot["name"] = part.function.name
        if part.function and part.function.arguments:
            slot["args"] += part.function.arguments

for slot in calls.values():
    print(slot["name"], json.loads(slot["args"]))

Помните, что потоковый запрос удерживает слот параллельных запросов до завершения потока. Полный ключ позволяет 8 запросов одновременно и 300 в минуту, пробный ключ — 2 и 60. Если вы запускаете циклы инструментов для многих пользователей параллельно, относите каждый открытый поток к первому числу.

Типичные проблемы и способы их решения

Некорректные или отсутствующие аргументы

Это модель с открытыми весами, а такие модели иногда возвращают аргументы, которые не являются валидным JSON, пропускают обязательное поле или добавляют лишнее. Оберните json.loads в блок try, проверьте по своей схеме и при ошибке отправьте ошибку обратно как сообщение инструмента. Модель обычно исправляет себя на следующем ходе. Ограничьте цикл несколькими итерациями, чтобы упорная ошибка не длилась вечно.

Слишком общие описания

Модель выбирает функцию, читая её имя и описание. Описание вроде "получает данные" не даёт ей ничего для работы. Скажите, что делает функция, что означают входные данные и включите пример значения в описание параметра.

Слишком много инструментов одновременно

Каждое определение инструмента считается входными токенами в каждом запросе. При цене $0.15 за 1M входных токенов десять инструментов по 150 токенов добавляют 1 500 токенов, что составляет $0.000225 за запрос — мало, но не ноль при больших объёмах. Важнее, что длинное меню делает выбор менее надёжным. Предоставляйте только инструменты, релевантные текущему шагу.

Слишком большие результаты инструментов

Всё, что вы возвращаете, подаётся обратно как вход. Обрезайте большие строки базы данных или веб-страницы до полей, которые нужны модели. Контекстное окно составляет 100 000 токенов в сумме, поэтому очень большой результат может вытеснить разговор.

Смешивание инструментов с JSON-режимом

JSON-режим (response_format с json_object) доступен, но используйте его для финального ответа, а не для ходов, где вы хотите, чтобы модель вызвала функцию. Протестируйте комбинацию на своих промптах перед тем, как полагаться на неё.

Переход с локального Ollama и что проверить

Если вы построили свой первый цикл инструментов против локального сервера Ollama, почти ничего не меняется. Замените базовый URL на https://api.ollamaapi.com/v1, используйте реальный ключ и установите модель в uncensored. Нативные маршруты Ollama, такие как /api/chat, здесь отсутствуют. Доступны только POST /v1/chat/completions и GET /v1/models, поэтому код, написанный для нативного API, требует вызова в стиле OpenAI.

Перед запуском проверьте этот короткий чек-лист:

  • Убедитесь, что ключ работает с GET /v1/models.
  • Логируйте заголовок X-Request-Id для каждого вызова, чтобы плохой запуск можно было отследить.
  • Следите за X-Balance-USD в заголовках ответа, если запускаете циклы инструментов без присмотра.
  • Обрабатывайте HTTP 429: при rate_limited или concurrency ждите и учитывайте заголовок Retry-After: 5 при перегрузке.
  • Установите жёсткий лимит итераций цикла.

Цикл вызова функций делает как минимум два запроса на вопрос пользователя, поэтому стоимость масштабируется. Например, 2 000 циклов с 1 200 входными токенами и 150 выходными токенами на запрос, по два запроса каждый, дают 4 000 запросов, 4,8 млн входных токенов и 0,6 млн выходных токенов. Это $0,72 плюс $0,30, итого $1,02. На странице цен есть варианты пополнения, а в документации перечислены поддерживаемые поля запроса. Новые пользователи могут попробовать всё это с бесплатным пробным балансом $0,50, для которого не нужна карта.

Вопросы и ответы

Поддерживает ли эндпоинт вызов функций?

Да. Он принимает tools и tool_choice, возвращает tool_calls, потоково передаёт их как дельты и принимает результаты обратно как сообщения с role: tool.

Почему второй запрос завершается ошибкой после вызова инструмента?

Обычная причина — отсутствие сообщения assistant или tool message без соответствующего tool_call_id. Сначала добавьте ход assistant, содержащий tool_calls, затем одно tool message для каждого вызова.

Может ли модель вызывать несколько инструментов за один ход?

Она может возвращать список с более чем одной записью, поэтому перебирайте все элементы и отправляйте один результат для каждого. Не предполагайте, что список содержит один элемент.

Нужна ли для этого библиотека Ollama?

Нет. Достаточно официальных SDK OpenAI. Укажите в базовом URL эндпоинт, и используйте модель uncensored.

До вашего ключа — одна форма

Создайте аккаунт, скопируйте ключ, измените базовый URL. Вот и вся настройка.

Получить API-ключЧитать документацию