Ollama Cloud Client: когда модели слишком тяжелы для локального запуска. ai api.. ai api. DevOps.. ai api. DevOps. Java.. ai api. DevOps. Java. llm.. ai api. DevOps. Java. llm. ollama.. ai api. DevOps. Java. llm. ollama. react.. ai api. DevOps. Java. llm. ollama. react. server sent events.. ai api. DevOps. Java. llm. ollama. react. server sent events. spring boot.. ai api. DevOps. Java. llm. ollama. react. server sent events. spring boot. SSE.. ai api. DevOps. Java. llm. ollama. react. server sent events. spring boot. SSE. streaming.. ai api. DevOps. Java. llm. ollama. react. server sent events. spring boot. SSE. streaming. TypeScript.. ai api. DevOps. Java. llm. ollama. react. server sent events. spring boot. SSE. streaming. TypeScript. Блог компании beeline cloud.. ai api. DevOps. Java. llm. ollama. react. server sent events. spring boot. SSE. streaming. TypeScript. Блог компании beeline cloud. Веб-разработка.. ai api. DevOps. Java. llm. ollama. react. server sent events. spring boot. SSE. streaming. TypeScript. Блог компании beeline cloud. Веб-разработка. Программирование.

Привет. Меня зовут Николай Пискунов, я руководитель направления Big Data и эксперт курса Cloud DevSecOps по безопасной разработке от Академии вАЙТИ Beeline Cloud. Сегодня я хочу поделиться историей создания одного интересного проекта — клиента для облачного сервиса Ollama.

Ollama Cloud Client: когда модели слишком тяжелы для локального запуска - 1

Как всё начиналось

Вы знаете это чувство, когда хочешь поиграться с локальными LLM через Ollama, но твой старенький ноутбук начинает плавиться при попытке запустить что-то крупнее 7B параметров? Решение: у Ollama есть облачный API!

Почему бы не сделать удобный клиент, который будет работать как прокси между моими приложениями и облачными моделями? Так родился проект ollama-client.

Архитектура не так проста, как кажется

Проект состоит из двух частей: бэк на Spring Boot и фронт на паре React и TypeScript.

Бэкенд (Spring Boot 3.5.10)

java
@RestController

@RequestMapping("/api/chat")

public class ChatController {

@PostMapping(value = "/stream", produces = "text/event-stream;charset=UTF-8")

public SseEmitter streamChat(@RequestBody ChatRequest request) {
     // Вроде бы обычный стриминг...
     return chatService.streamChat(request);
}
}

На первый взгляд типичный REST-контроллер. Но дьявол, как обычно, в деталях. Посмотрите внимательно на WebConfig.java:

java

@Override

public void preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {

if (request.getRequestURI().contains("/api/chat/stream")) {
     response.setHeader("X-Accel-Buffering", "no");
     response.setHeader("Cache-Control", "no-cache");
     response.setHeader("Connection", "keep-alive");
}
}

Эти заголовки — ключ к пониманию того, с чем нам пришлось бороться. Nginx (который часто стоит перед приложениями) любит буферизировать ответы, убивая всю магию Server-Sent Events. А нам нужен живой поток!

Фронтенд (React + TypeScript)

typescript
export const useEventStream = ({ url, onComplete, onError }: UseEventStreamProps) => {
  // Казалось бы, используем EventSource...
  // Но нет, пришлось изобретать велосипед
};

Подводные камни облачного API

Когда я начал интеграцию с Ollama Cloud, меня ждал сюрприз: их API не совсем соответствует тому, что ожидает стандартный Spring AI клиент. Пришлось делать ручной парсинг стрима:

java

String line;

while ((line = reader.readLine()) != null) {

if (line.startsWith("data: ") && !line.equals("data: [DONE]")) {
     // Парсим каждый чанк вручную
     JsonNode chunk = objectMapper.readTree(line.substring(6));
     // ...
    }
}

Каждая модель в облаке имеет суффикс -cloud, и это тоже пришлось учитывать:

java

private String enhanceModelName(String modelName) {

// Если модель из облака — добавляем суффикс
    if (isCloudModel(modelName)) {
     return modelName + "-cloud";
}
return modelName;
}

Пасхалка и признание

А теперь самое интересное. Я должен признаться: на момент написания статьи стриминг в этом проекте работает с ошибкой. Да-да, вы не ослышались.

Проблема в кастомном хуке useEventStream. Посмотрите внимательно на код:

typescript

// Создаем URL с параметрами для POST запроса
// EventSource поддерживает только GET, поэтому нам нужно использовать
// другой подход или модифицировать бэкенд для поддержки GET с параметрами

Мы используем fetch вместо EventSource, но забыли правильно обработать завершение потока. В результате последний чанк может потеряться, а соединение закрывается преждевременно.

Как это исправить

Предлагаю сообществу помочь с решением. Вот правильная реализация хука:

typescript

export const useEventStream = ({ url, onComplete, onError }: UseEventStreamProps) => {

  const [stream, setStream] = useState('');
  const [isStreaming, setIsStreaming] = useState(false);
  const abortControllerRef = useRef<AbortController | null>(null);
  const startStream = useCallback(async (data: any) => {

if (abortControllerRef.current) {
   abortControllerRef.current.abort();
}
 
abortControllerRef.current = new AbortController();
setIsStreaming(true);
setStream('');
 
try {

   const response = await fetch(url, {
     method: 'POST',
     headers: { 'Content-Type': 'application/json' },
     body: JSON.stringify(data),
     signal: abortControllerRef.current.signal
   });
 
   const reader = response.body?.getReader();
   const decoder = new TextDecoder();
   let buffer = '';
 
   while (true) {

     const { done, value } = await reader.read();

     if (done) {
          // Важно: обрабатываем остаток в буфере
          if (buffer.trim()) {
         processLine(buffer);
       }
       break;
     }
 
     buffer += decoder.decode(value, { stream: true });
     const lines = buffer.split('n');
     buffer = lines.pop() || '';
 
     for (const line of lines) {
       if (line.startsWith('data: ')) {

         const jsonStr = line.slice(5).trim();
         if (jsonStr && jsonStr !== '[DONE]') {

           try {
             const data = JSON.parse(jsonStr);
             if (data.message) {
               setStream(prev => prev + data.message);
             }
           } catch (e) {
             console.error('Parse error:', e);
           }
         }
       }
     }
   }

} catch (error) {
   if (error.name !== 'AbortError') {
     onError?.(error);
   }

} finally {
   setIsStreaming(false);
   onComplete?.();
}
  }, [url]);
 
  const stopStream = useCallback(() => {
abortControllerRef.current?.abort();
  }, []);
 
  return { stream, isStreaming, startStream, stopStream };
};

Docker-оркестрация: всё под контролем

Проект полностью докеризирован. У нас два docker-compose файла:

  • docker-compose.local.yml — только PostgreSQL для локальной разработки;

  • docker-compose.yml — полный стек с бэкендом и фронтендом.

yaml

services:
  postgres:
   image: postgres:16-alpine
  environment:
   POSTGRES_DB: chatdb
   POSTGRES_USER: chatuser
   POSTGRES_PASSWORD: chatpass
volumes:
   - postgres_data:/var/lib/postgresql/data

Что дальше

Планы по развитию проекта:

  • Исправить стриминг.

  • Добавить сохранение истории чатов в БД (уже есть entity, осталось дописать логику).

  • Сделать выбор нескольких моделей в одном чате.

  • Добавить поддержку системных промптов.

Заключение

Этот проект — отличный пример того, как можно комбинировать современные технологии: Spring Boot 3, React с TypeScript, Docker — и при этом работать с передовыми AI-моделями через облачный API. Да, в нём есть баги, но разве не в этом прелесть open source? Мы учимся на ошибках и делаем продукты лучше вместе.

Ссылка на репозиторий: https://gitverse.ru/nickolden/ollama-client.

Жду ваших issue и pull request! И помните: если ваш стриминг работает с первого раза — вы что-то делаете не так 😉

Beeline Cloud — безопасный облачный провайдер. Разрабатываем облачные решения, чтобы вы предоставляли клиентам лучшие сервисы.

Автор: kbooo

Источник