惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Martin Fowler
Martin Fowler
WordPress大学
WordPress大学
月光博客
月光博客
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
大猫的无限游戏
大猫的无限游戏
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
博客园 - 聂微东
Apple Machine Learning Research
Apple Machine Learning Research
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
雷峰网
雷峰网
小众软件
小众软件
酷 壳 – CoolShell
酷 壳 – CoolShell
博客园 - 叶小钗
美团技术团队
宝玉的分享
宝玉的分享
Hugging Face - Blog
Hugging Face - Blog
阮一峰的网络日志
阮一峰的网络日志
A
About on SuperTechFans
Jina AI
Jina AI
D
Docker
Last Week in AI
Last Week in AI
MongoDB | Blog
MongoDB | Blog
Stack Overflow Blog
Stack Overflow Blog
Microsoft Azure Blog
Microsoft Azure Blog

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания
Ollama Cloud Client: когда модели слишком тяжелы для лока...
kbooo (Beeli · 2026-05-05 · via Все публикации подряд на Хабре

Уровень сложностиСредний

Время на прочтение6 мин

Охват и читатели625

Кейс

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

@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 — безопасный облачный провайдер. Разрабатываем облачные решения, чтобы вы предоставляли клиентам лучшие сервисы.