Привет. Меня зовут Николай Пискунов, я руководитель направления 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/