Ollama REST API: інтеграція у свій застосунок — Java, Python, JavaScript

Actualizado:
Ollama REST API: інтеграція у свій застосунок — Java, Python, JavaScript

Ollama — це не тільки CLI-інструмент для запуску моделей у терміналі. Це повноцінний локальний сервер з REST API, який слухає на порту 11434 і приймає запити від будь-якого застосунку — Spring Boot, Node.js, Python, або будь-якої мови з підтримкою HTTP. У цій статті — повний практичний розбір: які endpoint-и є, як їх викликати і як інтегрувати Ollama у реальний застосунок.

Якщо ще не встановив Ollama — почни з гайду зі встановлення на Mac, Windows і Linux. Якщо хочеш зрозуміти які моделі підходять для різних задач — стаття про вибір моделей Ollama у 2026.

📚 Зміст статті

🎯 Два API surface: /api/* нативний vs /v1/ OpenAI-сумісний

Коротка відповідь: Ollama має два незалежних API. Нативний /api/* — повний контроль: стрімінг з метаданими, управління моделями, ембединги, інспекція процесів. OpenAI-сумісний /v1/* — drop-in заміна для коду що вже працює з ChatGPT API. Для нових проєктів — обирай нативний. Для міграції існуючого коду — /v1/.

Якщо у тебе вже є код що звертається до OpenAI API — для переключення на локальний Ollama достатньо змінити один рядок: base_url = "http://localhost:11434/v1". Решта коду залишається без змін.

Нативний API (/api/*)

За офіційною документацією Ollama, після встановлення API доступний за адресою http://localhost:11434/api. Нативний API підтримує:

  • ✔️ POST /api/generate — генерація тексту по промпту
  • ✔️ POST /api/chat — чат з history і tool calling
  • ✔️ POST /api/embed — генерація ембедингів
  • ✔️ GET /api/tags — список встановлених моделей
  • ✔️ GET /api/ps — запущені моделі і використання VRAM
  • ✔️ POST /api/pull — завантаження моделі
  • ✔️ DELETE /api/delete — видалення моделі

OpenAI-сумісний API (/v1/*)

ML Journey пояснює: Ollama підтримує OpenAI-сумісний endpoint, який означає що будь-який інструмент, бібліотека або застосунок що працює з OpenAI API, можна підключити до локального Ollama однією зміною рядка. Це включає офіційні Python і JS SDK OpenAI, LangChain, LlamaIndex, Continue та сотні інших інструментів.

Endpoint Нативний (/api/*) OpenAI-сумісний (/v1/*)
Чат /api/chat /v1/chat/completions
Генерація /api/generate /v1/completions
Ембединги /api/embed /v1/embeddings
Список моделей /api/tags /v1/models
Управління моделями ✔️ Є ❌ Немає
Метадані стрімінгу ✔️ Повні ⚠️ Часткові
API key Не потрібен Будь-який рядок (ігнорується)

⚠️ На що звернути увагу — мій досвід

Коли я інтегрував Ollama у WebsCraft, я тричі наступив на одні і ті самі граблі. Ось що варто знати одразу — щоб не витрачати час на дебаг.

1. Назва моделі у /v1/ має збігатись точно

У реального OpenAI API назва моделі стабільна глобально: gpt-4 завжди є. В Ollama — модель має бути завантажена локально, і назва має збігатись з тим що показує ollama list.

Я кілька разів отримував загадковий 404 model not found просто тому що передавав "llama3" замість "llama3.2:3b". Перше правило при міграції коду з OpenAI на Ollama:

# Перевір точну назву перед тим як писати код
ollama list

# NAME                    ID              SIZE    MODIFIED
# llama3.2:3b             ...             2.0 GB  2 days ago
# nomic-embed-text:latest ...             274 MB  5 days ago

Якщо твій інструмент жорстко прописаний на gpt-3.5-turbo або іншу OpenAI-назву — можна скопіювати модель під потрібну назву:

# Створює аліас: тепер gpt-3.5-turbo вказує на llama3.2:3b
ollama cp llama3.2:3b gpt-3.5-turbo

2. Змінити контекстне вікно через /v1/ — неочевидно

OpenAI API не має параметра для зміни розміру контексту — він фіксований для кожної моделі. Тому через /v1/chat/completions передати num_ctx не можна: параметр просто ігнорується.

Я з'ясував це після того як довгі документи несподівано обрізались — модель мовчки відкидала частину контексту замість того щоб повернути помилку. Рішення: створити Modelfile з потрібним контекстом і використовувати нову назву:

# Створюємо Modelfile
FROM llama3.2:3b
PARAMETER num_ctx 16384

# Збираємо нову модель
ollama create llama3-16k -f Modelfile

# Тепер викликаємо через /v1/ з новою назвою
curl http://localhost:11434/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{
    "model": "llama3-16k",
    "messages": [{"role": "user", "content": "...довгий текст..."}]
  }'

Через нативний /api/chat цієї проблеми немає — там num_ctx передається прямо у options:

{
  "model": "llama3.2:3b",
  "messages": [...],
  "options": {
    "num_ctx": 16384
  }
}

3. Відповідь /v1/ і /api/ — різний формат

Якщо ти переключаєшся між нативним і OpenAI-сумісним API — пам'ятай що формат відповіді різний. Я кілька разів ловив KeyError у Python просто тому що плутав де response["message"]["content"], а де response.choices[0].message.content.

Поле Нативний /api/chat OpenAI-сумісний /v1/
Текст відповіді response["message"]["content"] response.choices[0].message.content
Кінець генерації response["done"] == true response.choices[0].finish_reason == "stop"
Статистика токенів eval_count, eval_duration usage.completion_tokens
Tool calls message.tool_calls choices[0].message.tool_calls

Моє правило: в одному проєкті використовую тільки один API surface. Якщо міграція з OpenAI — залишаю /v1/ і OpenAI SDK скрізь. Якщо новий проєкт — нативний API скрізь. Мікс двох підходів в одному коді гарантує плутанину при дебагу.

Висновок: Для нового проєкту — нативний API дає більше контролю. Для міграції існуючого OpenAI-коду — /v1/ без змін коду.

🎯 POST /api/generate: базова генерація тексту

Коротка відповідь: /api/generate — найпростіший endpoint: приймає модель і промпт, повертає текст. Не зберігає контекст між запитами. Підходить для одноразових задач: резюмування, переклад, класифікація.

Різниця між /api/generate і /api/chat: generate приймає рядок-промпт, chat приймає масив повідомлень з ролями. Для чат-боту — завжди /api/chat. Для batch-обробки — /api/generate зручніший.

Базовий запит через curl

# stream: false — повертає всю відповідь одразу
curl http://localhost:11434/api/generate \
  -H "Content-Type: application/json" \
  -d '{
    "model": "llama3.2:3b",
    "prompt": "Поясни що таке REST API в трьох реченнях.",
    "stream": false
  }'

Формат відповіді — всі поля

{
  "model": "llama3.2:3b",
  "created_at": "2026-05-01T10:00:00Z",
  "response": "REST API — це...",
  "done": true,
  "prompt_eval_count": 15,
  "prompt_eval_duration": 123456789,
  "eval_count": 42,
  "eval_duration": 987654321,
  "total_duration": 1234567890,
  "load_duration": 56789012
}

Що означає кожне поле:

  • ✔️ prompt_eval_count — кількість токенів у промпті (вхід)
  • ✔️ eval_count — кількість згенерованих токенів (вихід)
  • ✔️ eval_duration — час генерації у наносекундах
  • ✔️ load_duration — час завантаження моделі (0 якщо вже в пам'яті)
  • ✔️ total_duration — повний час від запиту до відповіді

Як обчислити tokens/sec з метаданих

Метадані відповіді дозволяють логувати реальну продуктивність моделі. Я використовую це у WebsCraft для моніторингу швидкості генерації залежно від навантаження:

# Python: обчислення tokens/sec
import requests

r = requests.post("http://localhost:11434/api/generate", json={
    "model": "llama3.2:3b",
    "prompt": "Що таке мікросервіси?",
    "stream": False
})
data = r.json()

tokens_per_sec = data["eval_count"] / (data["eval_duration"] / 1e9)
total_sec = data["total_duration"] / 1e9
prompt_tokens = data["prompt_eval_count"]
output_tokens = data["eval_count"]

print(f"Швидкість: {tokens_per_sec:.1f} tok/s")
print(f"Токени: {prompt_tokens} вхід → {output_tokens} вихід")
print(f"Загальний час: {total_sec:.2f}с")
// Java: обчислення tokens/sec через WebClient
@Service
public class OllamaGenerateService {

    private final WebClient ollamaWebClient;

    public record GenerateResult(String text, double tokensPerSec, int outputTokens) {}

    public Mono<GenerateResult> generate(String prompt) {
        var body = Map.of(
                "model", "llama3.2:3b",
                "prompt", prompt,
                "stream", false
        );

        return ollamaWebClient.post()
                .uri("/api/generate")
                .bodyValue(body)
                .retrieve()
                .bodyToMono(Map.class)
                .map(r -> {
                    var text = (String) r.get("response");
                    var evalCount = ((Number) r.get("eval_count")).intValue();
                    var evalDuration = ((Number) r.get("eval_duration")).longValue();
                    var tokPerSec = evalCount / (evalDuration / 1_000_000_000.0);
                    return new GenerateResult(text, tokPerSec, evalCount);
                });
    }
}

Основні параметри у options

{
  "model": "llama3.2:3b",
  "prompt": "Твій текст тут",
  "stream": false,
  "system": "Ти — технічний редактор. Відповідай коротко і по суті.",
  "options": {
    "temperature": 0.7,
    "num_ctx": 4096,
    "top_p": 0.9,
    "num_predict": 256
  }
}
  • ✔️ temperature — творчість відповіді: 0.1 точно/детерміновано, 0.9 варіативно
  • ✔️ num_ctx — розмір контекстного вікна (токени)
  • ✔️ num_predict — максимальна кількість токенів у відповіді
  • ✔️ top_p — nucleus sampling, зазвичай 0.9
  • ✔️ system — системний промпт (поза options, окреме поле)

🎯 POST /api/chat: чат-формат і збереження контексту

Коротка відповідь: /api/chat — основний endpoint для чат-застосунків. Приймає масив повідомлень з ролями (system, user, assistant), підтримує tool calling і стрімінг. Для збереження контексту між запитами — передавай повну историю повідомлень.

LLM не має пам'яті між запитами. «Пам'ять» чат-бота — це просто масив messages що ти передаєш з кожним запитом. Чим довша история — тим більше RAM і часу на відповідь.

Базовий запит

curl http://localhost:11434/api/chat \
  -H "Content-Type: application/json" \
  -d '{
    "model": "llama3.2:3b",
    "messages": [
      {
        "role": "system",
        "content": "Ти — помічник розробника. Відповідай українською, коротко."
      },
      {
        "role": "user",
        "content": "Що таке dependency injection?"
      }
    ],
    "stream": false,
    "keep_alive": "10m"
  }'

keep_alive: як довго тримати модель в пам'яті

За замовчуванням Ollama вивантажує модель з пам'яті через 5 хвилин після останнього запиту. Для чат-боту де запити приходять часто — це означає cold-start затримку на кожну нову сесію.

Я зіткнувся з цим у WebsCraft: перший запит нової сесії займав 8–12 секунд замість 1–2 — модель щоразу перевантажувалась. Параметр keep_alive вирішує це:

# Тримати модель в пам'яті 30 хвилин
{"keep_alive": "30m"}

# Тримати постійно (поки не перезапустити Ollama)
{"keep_alive": -1}

# Вивантажити одразу після відповіді (для batch-задач де RAM важлива)
{"keep_alive": 0}

# Можна також задати через змінну середовища (глобально для всіх моделей):
# OLLAMA_KEEP_ALIVE=30m ollama serve

Для продакшн чат-боту я використовую "keep_alive": "30m" — модель залишається гарячою між сесіями, але вивантажується якщо довго немає запитів.

Збереження контексту (multi-turn)

# Python: повний цикл multi-turn чату
import requests

OLLAMA_URL = "http://localhost:11434/api/chat"
MODEL = "llama3.2:3b"

messages = [
    {"role": "system", "content": "Ти — технічний асистент. Відповідай коротко."}
]

def chat(user_input: str) -> str:
    messages.append({"role": "user", "content": user_input})

    r = requests.post(OLLAMA_URL, json={
        "model": MODEL,
        "messages": messages,
        "stream": False,
        "keep_alive": "30m"
    })

    reply = r.json()["message"]["content"]
    messages.append({"role": "assistant", "content": reply})
    return reply

# Перший запит
print(chat("Що таке Spring Boot?"))
# Другий запит — модель «пам'ятає» перший
print(chat("Які його основні переваги?"))
# Третій — продовження контексту
print(chat("Покажи мінімальний pom.xml для нього")

Обрізання history: що робити коли контекст переповнюється

Якщо чат довгий — history росте і починає займати весь контекст моделі. Коли messages перевищують num_ctx, Ollama мовчки відкидає найстаріші повідомлення. Щоб контролювати це явно — реалізуй trimming вручну.

Я використовую простий підхід: зберігаю system prompt завжди, а user/assistant повідомлення обрізаю до останніх N пар:

def trim_history(messages: list, max_pairs: int = 10) -> list:
    """
    Зберігає system prompt і останні max_pairs пар user/assistant.
    max_pairs=10 → максимум 21 повідомлення (1 system + 20 user/assistant)
    """
    system = [m for m in messages if m["role"] == "system"]
    dialog = [m for m in messages if m["role"] != "system"]

    # Беремо останні max_pairs * 2 повідомлень (пара = user + assistant)
    trimmed = dialog[-(max_pairs * 2):]

    return system + trimmed

# Використання в циклі чату:
def chat_with_trim(user_input: str) -> str:
    messages.append({"role": "user", "content": user_input})

    # Обрізаємо перед кожним запитом
    trimmed = trim_history(messages, max_pairs=10)

    r = requests.post(OLLAMA_URL, json={
        "model": MODEL,
        "messages": trimmed,
        "stream": False
    })

    reply = r.json()["message"]["content"]
    messages.append({"role": "assistant", "content": reply})
    return reply

Альтернативний підхід — обрізати за токенами, а не за кількістю повідомлень. Але для більшості застосунків обмеження за кількістю пар — простіше і достатньо передбачувано.

Формат відповіді /api/chat

{
  "model": "llama3.2:3b",
  "created_at": "2026-05-01T10:00:00Z",
  "message": {
    "role": "assistant",
    "content": "Spring Boot — це..."
  },
  "done": true,
  "eval_count": 38,
  "eval_duration": 876543210,
  "total_duration": 987654321
}

Поля eval_count і eval_duration — ті самі що у /api/generate, дозволяють обчислити tokens/sec для моніторингу.

🎯 Стрімінг: навіщо і як реалізувати

Коротка відповідь: Стрімінг — це отримання відповіді токен за токеном, а не одним блоком. За замовчуванням Ollama стрімить. Для UI — обов'язково вмикай стрімінг: перший токен приходить за 1–3 сек, а без стрімінгу користувач чекає всю відповідь мовчки.

З stream: true перший токен з'являється на екрані за 1–3 секунди. З stream: false — весь текст з'являється після того як модель закінчила генерацію, тобто через 5–30 секунд залежно від довжини відповіді. Для інтерактивних застосунків — stream: true.

Реальний кейс: як це працює на AskYourDocs

Я реалізував стрімінг у своєму сервісі AskYourDocs — застосунку де користувач задає питання по своїх документах і отримує відповідь з локальної RAG-системи на базі Ollama.

Без стрімінгу перші версії сервісу виглядали так: користувач натискав «Надіслати», бачив спіннер 8–15 секунд, потім одразу з'являвся весь текст. Відчуття — наче застосунок завис. З стрімінгом перші слова з'являються вже через 1–2 секунди і відповідь «друкується» прямо на очах. Різниця в UX — разюча, навіть якщо загальний час генерації однаковий.

Архітектура: Ollama стрімить токени → Spring Boot читає NDJSON-потік через WebFlux → передає клієнту через SSE (Server-Sent Events) → JavaScript на фронтенді дописує токени в DOM по одному.

Стрімінг через curl

curl http://localhost:11434/api/chat \
  -d '{
    "model": "llama3.2:3b",
    "messages": [{"role": "user", "content": "Розкажи про мікросервіси"}]
  }'
# stream: true — за замовчуванням, можна не вказувати

Відповідь приходить як потік JSON-об'єктів, кожен на окремому рядку (NDJSON):

{"model":"llama3.2:3b","message":{"role":"assistant","content":"Мікро"},"done":false}
{"model":"llama3.2:3b","message":{"role":"assistant","content":"сервіси"},"done":false}
{"model":"llama3.2:3b","message":{"role":"assistant","content":" — це"},"done":false}
...
{"model":"llama3.2:3b","message":{"role":"assistant","content":""},"done":true,"eval_count":87}

Стрімінг у Python

import requests, json

def stream_chat(model: str, messages: list):
    r = requests.post(
        "http://localhost:11434/api/chat",
        json={"model": model, "messages": messages},
        stream=True
    )
    full_response = ""
    for line in r.iter_lines():
        if line:
            chunk = json.loads(line)
            token = chunk["message"]["content"]
            print(token, end="", flush=True)
            full_response += token
            if chunk.get("done"):
                break
    return full_response

stream_chat("llama3.2:3b", [
    {"role": "user", "content": "Поясни що таке Docker"}
])

Стрімінг у Spring Boot через SSE

Саме цей підхід я використовую в AskYourDocs: Spring Boot читає NDJSON від Ollama і відразу передає токени клієнту через Server-Sent Events. Фронтенд отримує токени і дописує їх у DOM без перезавантаження сторінки.

// OllamaStreamService.java
@Service
@RequiredArgsConstructor
public class OllamaStreamService {

    private final WebClient ollamaWebClient;

    /**
     * Стрімить токени від Ollama як Flux.
     * Кожен елемент — один токен відповіді моделі.
     */
    public Flux<String> streamChat(String userMessage) {
        var body = Map.of(
                "model", "llama3.2:3b",
                "messages", List.of(
                        Map.of("role", "system",
                               "content", "Відповідай українською, коротко."),
                        Map.of("role", "user", "content", userMessage)
                ),
                "stream", true,
                "keep_alive", "30m"
        );

        return ollamaWebClient.post()
                .uri("/api/chat")
                .bodyValue(body)
                .retrieve()
                .bodyToFlux(String.class)     // кожен рядок NDJSON
                .filter(line -> !line.isBlank())
                .mapNotNull(line -> {
                    try {
                        var node = new ObjectMapper().readTree(line);
                        var token = node.path("message").path("content").asText("");
                        var done = node.path("done").asBoolean(false);
                        return done ? null : token; // null завершує Flux
                    } catch (Exception e) {
                        return null;
                    }
                });
    }
}

// OllamaController.java — SSE endpoint для фронтенду
@RestController
@RequestMapping("/api/ai")
@RequiredArgsConstructor
public class OllamaController {

    private final OllamaStreamService streamService;

    /**
     * SSE endpoint: токени надходять по одному в браузер.
     * Фронтенд підключається через EventSource або fetch з ReadableStream.
     */
    @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> stream(@RequestParam String message) {
        return streamService.streamChat(message);
    }
}

Фронтенд: читання SSE через fetch

// Підключення до SSE endpoint і відображення токенів у реальному часі
async function streamAnswer(question, outputElement) {
  const controller = new AbortController(); // для скасування стрімінгу
  const url = `/api/ai/stream?message=${encodeURIComponent(question)}`;

  const res = await fetch(url, { signal: controller.signal });
  const reader = res.body.getReader();
  const decoder = new TextDecoder();

  outputElement.textContent = ""; // очищаємо перед відповіддю

  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      // SSE рядки мають формат "data: токен\n\n"
      const lines = decoder.decode(value).split("\n");
      for (const line of lines) {
        if (line.startsWith("data: ")) {
          const token = line.slice(6); // прибираємо "data: "
          outputElement.textContent += token;
        }
      }
    }
  } catch (err) {
    if (err.name !== "AbortError") console.error("Stream error:", err);
  }

  return controller; // повертаємо для можливості скасування
}

// Використання:
const stopBtn = document.getElementById("stop");
const output = document.getElementById("answer");

const controller = await streamAnswer("Що таке Spring Boot?", output);

// Кнопка "Зупинити генерацію"
stopBtn.onclick = () => controller.abort();

Abort стрімінгу: кнопка "Зупинити"

В AskYourDocs я додав кнопку «Зупинити» — якщо відповідь занадто довга або модель пішла не туди. Реалізується через AbortController на фронтенді (показано вище) і скасування Flux на бекенді:

// Spring Boot: автоматично скасовує запит до Ollama
// коли клієнт відключається (браузер закрив SSE-з'єднання)
// WebFlux робить це автоматично через Flux.takeUntilOther або
// через onCancel оператор:

public Flux<String> streamChat(String userMessage) {
    return ollamaWebClient.post()
            .uri("/api/chat")
            .bodyValue(body)
            .retrieve()
            .bodyToFlux(String.class)
            .doOnCancel(() ->
                log.info("Клієнт відключився, стрімінг скасовано"))
            // ... решта операторів
}

WebFlux автоматично скасовує upstream запит до Ollama коли клієнт закриває SSE-з'єднання — модель зупиняє генерацію і звільняє RAM. Це важливо: без правильного скасування модель продовжує генерувати навіть після того як користувач закрив вкладку.

Ollama REST API: інтеграція у свій застосунок — Java, Python, JavaScript

🎯 POST /api/embed: ембединги для RAG

Коротка відповідь: /api/embed генерує числові вектори (ембединги) для тексту. Ці вектори потрібні для семантичного пошуку — основи RAG-архітектури. Найкраща локальна модель для ембедингів — nomic-embed-text.

Якщо ще не розумієш що таке ембединги — почни з статті «Що таке Embeddings: як AI розуміє сенс тексту» перед тим як рухатись далі.

Як я використовую /api/embed у WebsCraft

У своєму RAG-пайплайні на WebsCraft я використовую nomic-embed-text через /api/embed для двох задач: індексації статей блогу при публікації і пошуку релевантних статей при запиті користувача до чат-бота.

Чому nomic-embed-text: розмірність 768 — достатньо для семантичного пошуку, швидка генерація (~50мс на чанк), мінімальне використання RAM (~274 МБ). При локальній розробці я можу запустити і ембединг-модель, і генеративну одночасно на Mac M1 з 16 ГБ — вони не конкурують за пам'ять. У продакшні через OpenRouter використовую openai/text-embedding-3-small, але локально для тестування — завжди nomic-embed-text.

Встановлення моделі для ембедингів

ollama pull nomic-embed-text

Запит через curl

curl http://localhost:11434/api/embed \
  -H "Content-Type: application/json" \
  -d '{
    "model": "nomic-embed-text",
    "input": "Spring Boot — це фреймворк для Java-застосунків"
  }'

Формат відповіді

{
  "model": "nomic-embed-text",
  "embeddings": [
    [0.1234, -0.5678, 0.9012, ...]
  ],
  "total_duration": 12345678,
  "load_duration": 1234567,
  "prompt_eval_count": 9
}

Поле embeddings — масив масивів (можна передати кілька текстів за раз). nomic-embed-text повертає вектор розмірністю 768.

Batch-ембединги (кілька текстів за раз)

curl http://localhost:11434/api/embed \
  -d '{
    "model": "nomic-embed-text",
    "input": [
      "Перше речення для ембединга",
      "Друге речення для ембединга",
      "Третє речення для ембединга"
    ]
  }'

Функція для RAG у Python

import requests
import numpy as np

def embed(texts: list[str], model: str = "nomic-embed-text") -> list[list[float]]:
    """Генерує ембединги для списку текстів."""
    r = requests.post(
        "http://localhost:11434/api/embed",
        json={"model": model, "input": texts}
    )
    return r.json()["embeddings"]

def cosine_similarity(a: list, b: list) -> float:
    """Косинусна подібність між двома векторами."""
    a, b = np.array(a), np.array(b)
    return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b)))

# Приклад: пошук найближчого документа
query = "Як налаштувати Spring Boot?"
docs = [
    "Spring Boot автоконфігурація спрощує налаштування",
    "Python Flask — легкий веб-фреймворк",
    "Maven — система збірки для Java-проєктів"
]

q_emb = embed([query])[0]
d_embs = embed(docs)

scores = [(doc, cosine_similarity(q_emb, d_emb))
          for doc, d_emb in zip(docs, d_embs)]
scores.sort(key=lambda x: x[1], reverse=True)
print(f"Найбільш релевантний: {scores[0][0]} ({scores[0][1]:.3f})")

Ембединги у Java через WebClient

// EmbeddingService.java
@Service
@RequiredArgsConstructor
public class EmbeddingService {

    private final WebClient ollamaWebClient;
    private static final String EMBED_MODEL = "nomic-embed-text";

    /**
     * Генерує ембединг для одного тексту.
     * Повертає вектор розмірністю 768 для nomic-embed-text.
     */
    public Mono<List<Double>> embed(String text) {
        return embedBatch(List.of(text))
                .map(embeddings -> embeddings.get(0));
    }

    /**
     * Batch-ембединги: кілька текстів за один запит.
     * Ефективніше ніж кілька окремих запитів.
     */
    public Mono<List<List<Double>>> embedBatch(List<String> texts) {
        var body = Map.of("model", EMBED_MODEL, "input", texts);

        return ollamaWebClient.post()
                .uri("/api/embed")
                .bodyValue(body)
                .retrieve()
                .bodyToMono(EmbedResponse.class)
                .map(EmbedResponse::embeddings);
    }

    /**
     * Косинусна подібність між двома векторами.
     */
    public double cosineSimilarity(List<Double> a, List<Double> b) {
        double dot = 0, normA = 0, normB = 0;
        for (int i = 0; i < a.size(); i++) {
            dot   += a.get(i) * b.get(i);
            normA += a.get(i) * a.get(i);
            normB += b.get(i) * b.get(i);
        }
        return dot / (Math.sqrt(normA) * Math.sqrt(normB));
    }

    // DTO для відповіді Ollama
    record EmbedResponse(List<List<Double>> embeddings) {}
}

// Використання в RAG-сервісі:
@Service
@RequiredArgsConstructor
public class RagService {

    private final EmbeddingService embeddingService;

    public Mono<String> findMostRelevant(String query, List<String> docs) {
        return embeddingService.embed(query).flatMap(queryVec ->
            embeddingService.embedBatch(docs).map(docVecs -> {
                double bestScore = -1;
                String bestDoc = "";
                for (int i = 0; i < docs.size(); i++) {
                    double score = embeddingService
                            .cosineSimilarity(queryVec, docVecs.get(i));
                    if (score > bestScore) {
                        bestScore = score;
                        bestDoc = docs.get(i);
                    }
                }
                return bestDoc;
            })
        );
    }
}

На практиці замість ручного cosine similarity краще використовувати векторну базу даних (pgvector, Chroma, Qdrant) — вони індексують вектори і шукають по мільйонах записів за мілісекунди. Ручне обчислення підходить для прототипів і невеликих колекцій до ~1000 документів.

Детальніше про вибір ембединг-моделей для RAG — у статті Embedding-моделі для RAG у 2026: яку обрати і порівняння.

🎯 Tool Calling: підключення зовнішніх функцій

Коротка відповідь: Tool calling — це можливість моделі «викликати» зовнішні функції. Модель не виконує функцію сама — вона повертає JSON з назвою функції і аргументами, а твій код виконує реальний виклик і передає результат назад. Підтримується через /api/chat з параметром tools.

Перед тим як читати далі — рекомендую прочитати статтю «Tool Use vs Function Calling: як це працює і при чому RAG» — там пояснено чому LLM описує функцію у JSON, а не виконує її, і повний цикл виклику з прикладами.

Яка модель підтримує tool calling

Не всі моделі підтримують tool calling. Підтримують: Llama 3.1/3.2/3.3, Qwen 2.5, Mistral 7B (v0.3+), DeepSeek R1.

ollama pull llama3.2:3b

Базовий запит з tools

curl http://localhost:11434/api/chat \
  -H "Content-Type: application/json" \
  -d '{
    "model": "llama3.2:3b",
    "messages": [
      {"role": "user", "content": "Яка зараз погода у Харкові?"}
    ],
    "tools": [
      {
        "type": "function",
        "function": {
          "name": "get_weather",
          "description": "Отримати поточну погоду для міста",
          "parameters": {
            "type": "object",
            "properties": {
              "city": {
                "type": "string",
                "description": "Назва міста"
              },
              "units": {
                "type": "string",
                "enum": ["celsius", "fahrenheit"],
                "description": "Одиниці температури"
              }
            },
            "required": ["city"]
          }
        }
      }
    ],
    "stream": false
  }'

Відповідь з tool_calls

{
  "message": {
    "role": "assistant",
    "content": "",
    "tool_calls": [
      {
        "function": {
          "name": "get_weather",
          "arguments": {
            "city": "Харків",
            "units": "celsius"
          }
        }
      }
    ]
  },
  "done": true
}

Повний цикл tool calling у Python

import requests, json

OLLAMA_URL = "http://localhost:11434/api/chat"
MODEL = "llama3.2:3b"

tools = [{
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "Отримати поточну погоду для міста",
        "parameters": {
            "type": "object",
            "properties": {
                "city": {"type": "string", "description": "Назва міста"}
            },
            "required": ["city"]
        }
    }
}]

def get_weather(city: str) -> str:
    return f"У {city}: +18°C, хмарно"

def chat_with_tools(user_message: str) -> str:
    messages = [{"role": "user", "content": user_message}]

    r = requests.post(OLLAMA_URL, json={
        "model": MODEL,
        "messages": messages,
        "tools": tools,
        "stream": False
    })
    assistant_msg = r.json()["message"]
    messages.append(assistant_msg)

    if assistant_msg.get("tool_calls"):
        for tool_call in assistant_msg["tool_calls"]:
            fn_name = tool_call["function"]["name"]
            fn_args = tool_call["function"]["arguments"]
            result = get_weather(**fn_args) if fn_name == "get_weather" else "unknown tool"
            messages.append({"role": "tool", "content": result})

        r2 = requests.post(OLLAMA_URL, json={
            "model": MODEL, "messages": messages, "stream": False
        })
        return r2.json()["message"]["content"]

    # Модель відповіла текстом — tool не викликано
    return assistant_msg["content"]

print(chat_with_tools("Яка зараз погода у Харкові?"))

Tool calling у Java через WebClient

// ToolCallingService.java
@Service
@RequiredArgsConstructor
public class ToolCallingService {

    private final WebClient ollamaWebClient;
    private static final String MODEL = "llama3.2:3b";
    private static final String OLLAMA_URL = "http://localhost:11434";

    // Опис інструменту у форматі JSON Schema
    private static final Map<String, Object> WEATHER_TOOL = Map.of(
        "type", "function",
        "function", Map.of(
            "name", "get_weather",
            "description", "Отримати поточну погоду для міста",
            "parameters", Map.of(
                "type", "object",
                "properties", Map.of(
                    "city", Map.of(
                        "type", "string",
                        "description", "Назва міста"
                    )
                ),
                "required", List.of("city")
            )
        )
    );

    public Mono<String> chatWithTools(String userMessage) {
        var messages = new ArrayList<>(List.of(
            Map.of("role", "user", "content", userMessage)
        ));

        // Крок 1: перший запит з tools
        return callOllama(messages, true)
            .flatMap(response -> {
                var msg = (Map<?, ?>) response.get("message");
                var toolCalls = (List<?>) msg.get("tool_calls");

                // Якщо модель не викликала tool — повертаємо текст
                if (toolCalls == null || toolCalls.isEmpty()) {
                    return Mono.just((String) msg.get("content"));
                }

                // Крок 2: виконуємо реальні виклики
                messages.add(msg);
                for (var tc : toolCalls) {
                    var fn = (Map<?, ?>) ((Map<?, ?>) tc).get("function");
                    var fnName = (String) fn.get("name");
                    var args = (Map<?, ?>) fn.get("arguments");
                    var result = executeFunction(fnName, args);
                    messages.add(Map.of("role", "tool", "content", result));
                }

                // Крок 3: фінальний запит з результатом
                return callOllama(messages, false)
                    .map(r -> (String) ((Map<?, ?>) r.get("message")).get("content"));
            });
    }

    private Mono<Map> callOllama(List<?> messages, boolean withTools) {
        var body = new HashMap<>();
        body.put("model", MODEL);
        body.put("messages", messages);
        body.put("stream", false);
        if (withTools) {
            body.put("tools", List.of(WEATHER_TOOL));
        }

        return ollamaWebClient.post()
                .uri("/api/chat")
                .bodyValue(body)
                .retrieve()
                .bodyToMono(Map.class)
                .timeout(Duration.ofSeconds(60));
    }

    // Реєстр функцій — додавай нові інструменти сюди
    private String executeFunction(String name, Map<?, ?> args) {
        return switch (name) {
            case "get_weather" -> getWeather((String) args.get("city"));
            default -> "Невідомий інструмент: " + name;
        };
    }

    private String getWeather(String city) {
        // Тут реальний виклик weather API
        return "У " + city + ": +18°C, хмарно";
    }
}

⚠️ Типова помилка: модель не викликала tool

Модель не зобов'язана викликати tool — вона може відповісти текстом навіть якщо tools передані. Це трапляється якщо:

  • ✔️ Питання не потребує зовнішніх даних на думку моделі
  • ✔️ Опис функції (description) нечіткий або не відповідає питанню
  • ✔️ Модель не підтримує tool calling (перевір список вище)

Тому завжди перевіряй чи є tool_calls у відповіді, і обробляй обидва випадки — і з викликом, і без:

# Python: правильна перевірка
assistant = response["message"]

if assistant.get("tool_calls"):
    # Модель хоче викликати tool — виконуємо
    ...
else:
    # Модель відповіла текстом — повертаємо як є
    return assistant["content"]

Якщо хочеш щоб модель завжди викликала конкретний tool — використовуй параметр tool_choice (підтримується через /v1/):

curl http://localhost:11434/v1/chat/completions \
  -d '{
    "model": "llama3.2:3b",
    "messages": [...],
    "tools": [...],
    "tool_choice": {"type": "function", "function": {"name": "get_weather"}}
  }'

Детальніше про те як модель вирішує коли викликати tool — у статті Як LLM вирішує коли викликати tool: механіка рішення.

🎯 Приклад на Java: WebClient + Spring Boot

Коротка відповідь: Для Spring Boot є два підходи: пряме звернення через WebClient (гнучко, без залежностей) або через Spring AI (зручніше, але додаткова бібліотека). Нижче — обидва варіанти з робочим кодом.

RestTemplate у Spring 6+ — deprecated. Використовуй WebClient для неблокуючих HTTP-запитів до Ollama — особливо важливо для стрімінгу відповідей у реальному часі.

⚠️ Важливо: код нижче — демонстраційний. Його мета — показати базову механіку взаємодії з Ollama API, а не готовий шаблон для продакшну. У кожного проєкту своя архітектура: інша структура пакетів, інша обробка помилок, інший спосіб зберігання конфігурації. Адаптуй під свої потреби.

Варіант 1: WebClient — без додаткових залежностей

Залежності у pom.xml:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

Конфігурація в application.properties (URL винесено з коду — не хардкодь у бінах):

ollama.base-url=http://localhost:11434
ollama.model=llama3.2:3b
ollama.timeout-seconds=60

Конфігурація WebClient:

// WebClientConfig.java
@Configuration
public class WebClientConfig {

    @Value("${ollama.base-url}")
    private String ollamaBaseUrl;

    @Bean
    public WebClient ollamaWebClient() {
        return WebClient.builder()
                .baseUrl(ollamaBaseUrl)
                .defaultHeader(HttpHeaders.CONTENT_TYPE,
                               MediaType.APPLICATION_JSON_VALUE)
                .codecs(c -> c.defaultCodecs()
                              .maxInMemorySize(10 * 1024 * 1024)) // 10MB
                .build();
    }
}

DTO для запиту і відповіді:

// OllamaChatRequest.java
public record OllamaChatRequest(
        String model,
        List<Message> messages,
        boolean stream
) {
    public record Message(String role, String content) {}
}

// OllamaChatResponse.java
public record OllamaChatResponse(
        String model,
        Message message,
        boolean done
) {
    public record Message(String role, String content) {}
}

Сервіс з підтримкою стрімінгу:

// OllamaService.java
@Service
@RequiredArgsConstructor
public class OllamaService {

    private final WebClient ollamaWebClient;

    @Value("${ollama.model}")
    private String defaultModel;

    @Value("${ollama.timeout-seconds:60}")
    private int timeoutSeconds;

    // Звичайний запит (без стрімінгу)
    public Mono<String> chat(String userMessage) {
        var request = new OllamaChatRequest(
                defaultModel,
                List.of(new OllamaChatRequest.Message("user", userMessage)),
                false
        );

        return ollamaWebClient.post()
                .uri("/api/chat")
                .bodyValue(request)
                .retrieve()
                .bodyToMono(OllamaChatResponse.class)
                .timeout(Duration.ofSeconds(timeoutSeconds))
                .map(r -> r.message().content())
                .onErrorResume(e -> Mono.just("Помилка: " + e.getMessage()));
    }

    // Стрімінг (SSE для фронтенду)
    public Flux<String> chatStream(String userMessage) {
        var body = Map.of(
                "model", defaultModel,
                "messages", List.of(Map.of("role", "user", "content", userMessage)),
                "stream", true
        );

        return ollamaWebClient.post()
                .uri("/api/chat")
                .bodyValue(body)
                .retrieve()
                .bodyToFlux(String.class)
                .filter(line -> !line.isBlank())
                .map(line -> {
                    try {
                        var obj = new ObjectMapper().readTree(line);
                        return obj.path("message").path("content").asText("");
                    } catch (Exception e) {
                        return "";
                    }
                })
                .filter(token -> !token.isEmpty());
    }
}

REST-контролер:

// OllamaController.java
@RestController
@RequestMapping("/api/ai")
@RequiredArgsConstructor
public class OllamaController {

    private final OllamaService ollamaService;

    @PostMapping("/chat")
    public Mono<Map<String, String>> chat(@RequestBody Map<String, String> req) {
        return ollamaService.chat(req.get("message"))
                .map(r -> Map.of("response", r));
    }

    @GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> chatStream(@RequestParam String message) {
        return ollamaService.chatStream(message);
    }
}

// Тест:
// curl -X POST http://localhost:8080/api/ai/chat \
//   -H "Content-Type: application/json" \
//   -d '{"message": "Що таке Spring WebFlux?"}'

Варіант 2: Spring AI — мінімум коду

Залежності:

<dependency>
    <groupId>org.springframework.ai</groupId>
    <artifactId>spring-ai-starter-model-ollama</artifactId>
</dependency>

Конфігурація в application.properties:

spring.ai.ollama.base-url=http://localhost:11434
spring.ai.ollama.chat.options.model=llama3.2:3b
spring.ai.ollama.chat.options.temperature=0.7
spring.ai.ollama.init.pull-model-strategy=never

Сервіс через Spring AI — звичайний запит і стрімінг:

@Service
@RequiredArgsConstructor
public class SpringAiOllamaService {

    private final ChatClient chatClient;

    // Звичайний запит
    public String ask(String question) {
        return chatClient.prompt()
                .user(question)
                .call()
                .content();
    }

    // Стрімінг через Spring AI
    public Flux<String> stream(String question) {
        return chatClient.prompt()
                .user(question)
                .stream()
                .content();
    }
}

Коли що обирати:

  • ✔️ WebClient — повний контроль над запитом, стрімінг, налаштування таймаутів і обробка помилок. Без додаткових залежностей.
  • ✔️ Spring AI — швидкий старт і легке перемикання між провайдерами (Ollama → OpenAI → Anthropic) без зміни коду.

🎯 Приклад на Python

Коротка відповідь: Два підходи: нативна бібліотека ollama (простіше, більше функцій) або openai SDK з base_url (якщо вже є OpenAI-код).

⚠️ Важливо: приклади нижче — демонстраційні. Вони показують механіку роботи з API, а не готову архітектуру застосунку. У реальному проєкті додай обробку помилок, логування, конфігурацію через змінні середовища і відповідну структуру модулів.

Варіант 1: нативна бібліотека ollama

pip install ollama
import ollama

# Простий запит
response = ollama.chat(
    model="llama3.2:3b",
    messages=[{"role": "user", "content": "Що таке REST API?"}]
)
print(response["message"]["content"])

# Стрімінг
for chunk in ollama.chat(
    model="llama3.2:3b",
    messages=[{"role": "user", "content": "Розкажи про мікросервіси"}],
    stream=True
):
    print(chunk["message"]["content"], end="", flush=True)

# Ембединги
emb = ollama.embed(model="nomic-embed-text", input="Hello world")
print(f"Розмірність: {len(emb['embeddings'][0])}")

Варіант 2: OpenAI SDK (drop-in заміна)

pip install openai
from openai import OpenAI

# Єдина зміна відносно OpenAI: base_url і api_key (ігнорується)
client = OpenAI(
    base_url="http://localhost:11434/v1",
    api_key="ollama"
)

# Далі — стандартний OpenAI-код без змін
response = client.chat.completions.create(
    model="llama3.2:3b",
    messages=[
        {"role": "system", "content": "Ти — технічний асистент."},
        {"role": "user", "content": "Що таке Docker?"}
    ]
)
print(response.choices[0].message.content)

# Стрімінг через OpenAI SDK
stream = client.chat.completions.create(
    model="llama3.2:3b",
    messages=[{"role": "user", "content": "Розкажи про CI/CD"}],
    stream=True
)
for chunk in stream:
    if chunk.choices[0].delta.content:
        print(chunk.choices[0].delta.content, end="", flush=True)

🎯 Приклад на JavaScript / Node.js

⚠️ Важливо: приклади нижче — демонстраційні. Мета — показати базову механіку виклику Ollama API з JavaScript. У реальному застосунку структура буде інша: окремі модулі, обробка помилок, змінні середовища для URL і назви моделі.

Варіант 1: нативна бібліотека ollama

npm install ollama
import ollama from "ollama";

// Простий запит
const response = await ollama.chat({
  model: "llama3.2:3b",
  messages: [{ role: "user", content: "Що таке REST API?" }],
});
console.log(response.message.content);

// Стрімінг
const stream = await ollama.chat({
  model: "llama3.2:3b",
  messages: [{ role: "user", content: "Розкажи про мікросервіси" }],
  stream: true,
});
for await (const chunk of stream) {
  process.stdout.write(chunk.message.content);
}

// Ембединги
const emb = await ollama.embed({
  model: "nomic-embed-text",
  input: "Hello world",
});
console.log(`Розмірність: ${emb.embeddings[0].length}`);

Варіант 2: fetch API (без залежностей)

async function chatWithOllama(message) {
  const res = await fetch("http://localhost:11434/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      model: "llama3.2:3b",
      messages: [{ role: "user", content: message }],
      stream: false,
    }),
  });
  const data = await res.json();
  return data.message.content;
}

// Стрімінг через ReadableStream
async function streamChat(message, onToken) {
  const res = await fetch("http://localhost:11434/api/chat", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      model: "llama3.2:3b",
      messages: [{ role: "user", content: message }],
    }),
  });

  const reader = res.body.getReader();
  const decoder = new TextDecoder();

  while (true) {
    const { done, value } = await reader.read();
    if (done) break;
    const lines = decoder.decode(value).split("\n").filter(Boolean);
    for (const line of lines) {
      const chunk = JSON.parse(line);
      onToken(chunk.message.content);
      if (chunk.done) return;
    }
  }
}

streamChat("Що таке GraphQL?", (token) => process.stdout.write(token));

🎯 Управління моделями і health check через API

Коли Ollama використовується не як локальний CLI-інструмент, а як сервер у реальному застосунку — виникають питання які виходять за межі простого чату: як перевірити чи Ollama запущений перед надсиланням запиту, як уникнути cold-start затримки на першому запиті сесії, як автоматично завантажити потрібну модель при старті застосунку, як моніторити скільки пам'яті займає модель у продакшні. Для всього цього є окремі endpoint-и.

Я використовую ці endpoint-и у WebsCraft для двох задач: health check при старті Spring Boot — перевіряю чи Ollama доступний перш ніж реєструвати AI-роути, і /api/ps у логах — щоб бачити коли модель вивантажується і скільки VRAM займає між запитами.

GET /api/tags — список встановлених моделей

curl http://localhost:11434/api/tags

# Відповідь:
{
  "models": [
    {
      "name": "llama3.2:3b",
      "size": 2019393423,
      "details": {
        "parameter_size": "3B",
        "quantization_level": "Q4_K_M"
      }
    }
  ]
}

Корисно при старті застосунку: перевір що потрібна модель встановлена, і якщо ні — завантаж через /api/pull (або поверни помилку).

GET /api/ps — запущені моделі і VRAM

curl http://localhost:11434/api/ps

# Відповідь:
{
  "models": [
    {
      "name": "llama3.2:3b",
      "size_vram": 2145386496,
      "expires_at": "2026-05-01T10:05:00Z"
    }
  ]
}

Корисно перед запитом: якщо модель вже завантажена (/api/ps не порожній) — перший запит буде без cold-start затримки. Поле expires_at показує коли модель вивантажиться з пам'яті (за замовчуванням через 5 хвилин після останнього запиту).

GET / — health check

curl http://localhost:11434/
# Повертає: "Ollama is running"

# Корисно в скриптах запуску:
if curl -s http://localhost:11434/ | grep -q "running"; then
  echo "Ollama готовий"
else
  echo "Ollama не запущений — запускаємо..."
  ollama serve &
fi

У Spring Boot можна зробити health check через @EventListener(ApplicationReadyEvent.class) — після старту застосунку перевіряй доступність Ollama і логуй результат:

@Component
@RequiredArgsConstructor
public class OllamaHealthChecker {

    private final WebClient ollamaWebClient;

    @EventListener(ApplicationReadyEvent.class)
    public void checkOllamaOnStartup() {
        ollamaWebClient.get()
                .uri("/api/tags")
                .retrieve()
                .bodyToMono(Map.class)
                .subscribe(
                    resp -> log.info("Ollama доступний, моделей: {}",
                                     ((List) resp.get("models")).size()),
                    err  -> log.warn("Ollama недоступний: {}", err.getMessage())
                );
    }
}

POST /api/pull — завантаження моделі через API

curl http://localhost:11434/api/pull \
  -d '{"name": "llama3.2:3b"}'

# Python з прогресом:
import requests, json

def pull_model(name: str):
    r = requests.post("http://localhost:11434/api/pull",
                      json={"name": name}, stream=True)
    for line in r.iter_lines():
        if line:
            status = json.loads(line)
            if "total" in status and "completed" in status:
                pct = 100 * status["completed"] / status["total"]
                print(f"\r{status['status']} {pct:.1f}%", end="")
            else:
                print(status.get("status", ""))

pull_model("nomic-embed-text")

Корисно в Docker entrypoint або CI/CD пайплайні — автоматично завантажуєш потрібні моделі при першому деплої, без ручного ollama pull на сервері.

🎯 Обробка помилок, таймаути, OLLAMA_HOST

Типові помилки і як їх обробляти

Помилка Причина Рішення
Connection refused :11434 Ollama не запущений Запустити ollama serve або додаток Ollama
404 model not found Модель не завантажена ollama pull model-name
Таймаут без відповіді Модель занадто велика / cold start Збільшити таймаут до 120с, або завчасно завантажити модель
500 out of memory Не вистачає RAM Обрати меншу модель або Q4 замість Q8
404 на /v1/chat/completions Переплутав /api/ і /v1/ OpenAI SDK → base_url = localhost:11434/v1
Відповідь обрізана посередині речення num_predict замалий (за замовч. 128) Збільшити num_predict або поставити -1 (без ліміту)

Остання помилка з таблиці — найнеочевидніша. Я сам натрапив на неї коли відповіді раптово обрізались посередині пояснення. Причина: за замовчуванням деякі збірки Ollama обмежують генерацію до 128 токенів. Рішення — явно вказувати num_predict:

# У запиті через /api/chat або /api/generate:
{
  "model": "llama3.2:3b",
  "messages": [...],
  "options": {
    "num_predict": -1   // -1 = без обмеження
    // або конкретне число:
    // "num_predict": 2048
  }
}

Рекомендовані таймаути

Python (requests):

# Кортеж (connect_timeout, read_timeout)
requests.post(url, json=body, timeout=(10, 120))
# 10с на з'єднання, 120с на читання відповіді
# Для великих моделей або довгих відповідей — збільшуй read до 300с

Java (WebClient):

// WebClient має два рівні таймаутів — обидва потрібні

// 1. Таймаут на рівні HTTP-клієнта (TCP з'єднання і читання)
@Bean
public WebClient ollamaWebClient() {
    HttpClient httpClient = HttpClient.create()
            .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 10_000) // 10с на з'єднання
            .responseTimeout(Duration.ofSeconds(120));            // 120с на відповідь

    return WebClient.builder()
            .baseUrl(ollamaBaseUrl)
            .clientConnector(new ReactorClientHttpConnector(httpClient))
            .codecs(c -> c.defaultCodecs().maxInMemorySize(10 * 1024 * 1024))
            .build();
}

// 2. Реактивний таймаут на рівні Mono/Flux (для конкретних запитів)
ollamaWebClient.post()
        .uri("/api/chat")
        .bodyValue(body)
        .retrieve()
        .bodyToMono(OllamaChatResponse.class)
        .timeout(Duration.ofSeconds(120))  // додатковий захист
        .onErrorMap(TimeoutException.class,
                e -> new RuntimeException("Ollama не відповів за 120с"));

⚠️ Для стрімінгу (bodyToFlux) таймаут на рівні Flux спрацьовує якщо між токенами проходить більше зазначеного часу — це не завжди те що потрібно. Для стрімінгу краще покладатись тільки на responseTimeout на рівні HttpClient.

OLLAMA_HOST — запуск на іншому хості

# Запустити Ollama доступним для мережі (не тільки localhost)
OLLAMA_HOST=0.0.0.0:11434 ollama serve

# Або в Docker:
docker run -e OLLAMA_HOST=0.0.0.0:11434 ollama/ollama

# У Python клієнті — замінити localhost на IP сервера:
client = OpenAI(base_url="http://192.168.1.100:11434/v1", api_key="ollama")

# У Java application.properties:
ollama.base-url=http://192.168.1.100:11434

⚠️ Увага: якщо відкриваєш Ollama назовні — додай авторизацію або обмеж доступ через firewall. За замовчуванням Ollama не вимагає авторизації — будь-хто в мережі може надсилати запити.

❓ Часті питання (FAQ)

Чим /api/generate відрізняється від /api/chat?

/api/generate приймає рядок-промпт і повертає текст. /api/chat приймає масив повідомлень з ролями (system, user, assistant) і підтримує tool calling. Для чат-боту і застосунків з контекстом — завжди /api/chat. Для batch-генерації без контексту — /api/generate зручніший.

Як зберегти контекст між запитами?

Ollama не зберігає контекст автоматично. Для multi-turn чату передавай повну историю повідомлень у кожному запиті: після кожної відповіді додавай її у масив messages і передавай весь масив у наступний запит.

Який таймаут встановити для запитів?

Залежить від розміру моделі і довжини відповіді. Для 3B-моделей — 30–60 секунд. Для 8B — 60–120 секунд. Для першого запиту після запуску (cold start) — додай ще 10–30 секунд на завантаження моделі в пам'ять.

Чи потрібен API key для Ollama?

Для нативного API (/api/*) — ні, авторизація не потрібна. Для OpenAI-сумісного (/v1/*) — деякі SDK вимагають передати api_key, але Ollama його ігнорує. Передавай будь-який рядок: "ollama".

Як запустити Ollama API у Docker?

docker run -d -p 11434:11434 ollama/ollama — і API буде доступний на http://localhost:11434. Для GPU-прискорення: docker run --gpus all -p 11434:11434 ollama/ollama.

Чи можна використовувати Ollama у Spring Boot без Spring AI?

Так. WebClient або RestClient достатньо для прямих HTTP-запитів до Ollama API. Spring AI зручніший якщо плануєш перемикатись між провайдерами (Ollama → OpenAI → Anthropic) без зміни коду. Для простої інтеграції — WebClient цілком достатній.

Як дізнатись скільки токенів/сек видає модель?

Ollama повертає метадані у кожній відповіді — поля eval_count (кількість згенерованих токенів) і eval_duration (час у наносекундах). Ділиш одне на інше:

# Python
data = requests.post(...).json()
tok_per_sec = data["eval_count"] / (data["eval_duration"] / 1e9)
print(f"{tok_per_sec:.1f} tok/s")

Для 3B-моделі на Mac M1 — очікуй 20–30 tok/s. Для 8B — 10–15 tok/s. Якщо отримуєш менше 5 tok/s — модель занадто велика для заліза або частково свопить на диск.

Чому модель не викликає tool навіть якщо tools передані?

Модель не зобов'язана викликати tool — вона може відповісти текстом якщо вирішить що зовнішні дані не потрібні. Три найчастіші причини: нечіткий description функції (модель не розуміє коли її викликати), питання не потребує зовнішніх даних на думку моделі, або модель не підтримує tool calling (перевір список підтримуваних: Llama 3.1+, Qwen 2.5, Mistral v0.3+). Завжди перевіряй наявність tool_calls у відповіді і обробляй обидва варіанти — з викликом і без.

✅ Висновки

Ollama REST API — простий і потужний інструмент для інтеграції локального AI у будь-який застосунок. Ось головне:

  • ✔️ Два API surface: нативний /api/* для повного контролю, /v1/* як drop-in заміна для OpenAI-коду
  • ✔️ /api/chat — основний endpoint: підтримує history, tool calling і стрімінг
  • ✔️ Стрімінг — за замовчуванням: вмикай для UI, вимикай для batch-задач
  • ✔️ /api/embed — для RAG: nomic-embed-text + /api/chat = повний локальний RAG-пайплайн
  • ✔️ Java + WebClient: неблокуючі запити, підтримка стрімінгу через Flux
  • ✔️ Обробка помилок: завжди встановлюй таймаути і обробляй Connection refused

У своїх проєктах — WebsCraft і AskYourDocs — я використовую саме ці endpoint-и: /api/embed для індексації контенту, /api/chat зі стрімінгом для відповідей користувачам, /api/ps і health check для моніторингу. Головне що я зрозумів після кількох місяців роботи з Ollama API: він не вимагає складної інфраструктури — curl, WebClient або fetch достатньо щоб побудувати повноцінний AI-застосунок без жодного зовнішнього API-ключа.

Наступний крок: якщо хочеш побудувати повноцінний RAG-пайплайн з Ollama — стаття RAG з Ollama: від пайплайну до продакшну. Якщо потрібно порівняння коли Ollama виграє перед хмарними API — Ollama vs ChatGPT vs Claude: яка задача вимагає хмари.

📖 Джерела

Останні статті

Читайте більше цікавих матеріалів

Ollama REST API: інтеграція у свій застосунок — Java, Python, JavaScript

Ollama REST API: інтеграція у свій застосунок — Java, Python, JavaScript

Ollama — це не тільки CLI-інструмент для запуску моделей у терміналі. Це повноцінний локальний сервер з REST API, який слухає на порту 11434 і приймає запити від будь-якого застосунку — Spring Boot, Node.js, Python, або будь-якої мови з підтримкою HTTP. У цій статті — повний практичний...

Ollama vs ChatGPT vs Claude: яка задача вимагає хмари

Ollama vs ChatGPT vs Claude: яка задача вимагає хмари

Питання «Ollama чи ChatGPT?» — неправильне питання. Правильне: «яку задачу я зараз вирішую — і де її краще вирішувати?» Ця стаття не про те, що краще. Вона про те, як обирати без фанатизму. Якщо ще не знайомий з Ollama — почни з вступної статті про те, що таке Ollama і навіщо вона...

DeepSeek V4 Pro у 2026: повний розбір — архітектура, бенчмарки і коли переходити вигідно

DeepSeek V4 Pro у 2026: повний розбір — архітектура, бенчмарки і коли переходити вигідно

TL;DR за 30 секунд: DeepSeek V4 Pro — найбільша open-weight модель у світі: 1.6T параметрів (49B активних), контекст 1M токенів, MIT-ліцензія. Вийшла 24 квітня 2026 як preview. Коштує $3.48/M output токенів — у 7 разів дешевше за GPT-5.5 і в 6 разів дешевше за Claude Opus 4.7. На...

Міграція з deepseek-chat на DeepSeek V4: що зламається до 24 липня

Міграція з deepseek-chat на DeepSeek V4: що зламається до 24 липня

TL;DR за 30 секунд: 24 липня 2026 о 15:59 UTC назви deepseek-chat і deepseek-reasoner перестануть працювати назавжди — без попереджень і без grace period. Будь-який код, який їх використовує, поверне помилку. Це не косметична зміна: V4 — нова архітектура з іншою поведінкою за...

Що означає GPT-5.5 для ринку AI у 2026 році

Що означає GPT-5.5 для ринку AI у 2026 році

У лютому 2026 за 48 годин зникло $285 мільярдів з капіталізації технологічних компаній. Не через рецесію. Не через провальну звітність. Через одне питання, яке інвестори поставили собі одночасно: якщо AI-агент робить роботу десяти людей — навіщо платити за десять місць у...

GPT-5.5 vs GPT-5.4: що  змінилося у 2026 році

GPT-5.5 vs GPT-5.4: що змінилося у 2026 році

OpenAI випустив GPT-5.5 лише через шість тижнів після GPT-5.4 — і це не черговий патч. Спойлер: перша повністю перетренована базова модель з часів GPT-4.5 дає реальний стрибок у агентних задачах і довгому контексті, але у hallucinations не покращилась — і коштує на 20% дорожче, а...