35
0
0
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники
Назад

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

Время чтения 34 минуты
Нет времени читать?
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники
35
0
0
Нет времени читать?
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники

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

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

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

Вы знаете это чувство, когда хочешь поиграться с локальными 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! И помните: если ваш стриминг работает с первого раза — вы что-то делаете не так 😉

Комментарии0
Тоже интересно
Комментировать
Поделиться
Скопировать ссылку
Telegram
WhatsApp
Vkontakte
Одноклассники