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

от автора

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

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

Вы знаете это чувство, когда хочешь поиграться с локальными 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@Overridepublic 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)

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

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

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

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

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

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

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

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

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

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

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

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

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

typescriptexport 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 — полный стек с бэкендом и фронтендом.

yamlservices:  postgres:   image: postgres:16-alpine  environment:   POSTGRES_DB: chatdb   POSTGRES_USER: chatuser   POSTGRES_PASSWORD: chatpassvolumes:   - 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 — безопасный облачный провайдер. Разрабатываем облачные решения, чтобы вы предоставляли клиентам лучшие сервисы.

ссылка на оригинал статьи https://habr.com/ru/articles/1031708/