Эфир 18.11 · Поток 318 ноября в 19:00 МСК — бесплатный практический эфир по ИИ-агентам · Живой Поток 3 стартует 23.11Эфир 18 ноября →Поток 3 (23.11)
РелизРазбор · На радарHabr AI→ оригинал

Разработчик собрал на KMP клиент для Ollama: два бага сериализации и стриминга

Разработчик показал, как подключить мобильное приложение на Kotlin Multiplatform к локальному серверу Ollama: общий UI на Compose, Koin для зависимостей, Ktor для сети. Сервер открывается для телефонов в сети одной переменной OLLAMA_HOST=0.0.0.0. Основное время ушло на два незаметных бага: пропадающее поле stream и ответ, который приходит целиком вместо потока. Оба решаются парой строк, если знать, где искать.

AI-обработка оригинала Habr AI; редакция Hamidun News
Разработчик собрал на KMP клиент для Ollama: два бага сериализации и стриминга
Источник: Habr AI. Коллаж: Hamidun News.
Слушать статью
Паспорт материала и цифры
ПараметрДанные
ПервоисточникHabr AI ↗
Главный редактор
Ключевой факт #1encodeDefaults = true возвращает stream=false в JSON-запрос
Ключевой факт #2Для потока нужен preparePost().execute, а не обычный post()
Для HTTPusesCleartextTraffic на Android, NSAllowsArbitraryLoads на iOS

Запустить локальную модель на ноутбуке сегодня просто. Сложнее сделать к ней нормальный мобильный клиент, который работает и на Android, и на iPhone, и не заставляет писать UI дважды. Автор статьи на Habr прошёл этот путь на Kotlin Multiplatform и честно описал, где споткнулся. Для тех, кто собирается делать то же самое, это полезнее любой теории.

Сервер: три команды и одна переменная

Ollama ставится через brew или установочный скрипт на Ubuntu. По умолчанию сервер слушает только localhost, поэтому эмулятор и реальный телефон в одной сети до него не достучатся. Чтобы открыть доступ, сервер запускают с OLLAMA_HOST=0.0.0.0:11434. Дальше модель загружается через ollama pull, а на маках с чипами Apple автор берёт MLX-сборки, например gemma4:12b-mlx. Команда ollama ps показывает, что модель целиком сидит в памяти GPU (в выводе PROCESSOR 100% GPU).

Проверить сервер можно обычным curl на /api/generate. Если в запросе стоит stream: false, ответ придёт одним JSON.

Клиент: Compose, Koin, Ktor

Проект на KMP с общим UI: Koin отвечает за зависимости и ViewModel, Ktor за сеть, Navigation3 за навигацию. Автор выбрал не глобальный startKoin, а composable KoinApplication. Контейнер создаётся внутри функции App(), поэтому @Preview в Android Studio не конфликтует с глобальным состоянием, а Swift-код, который сгенерировала студия, трогать не нужно. Цена такого решения: часть API недоступна, в том числе by inject(), by viewModel() и androidContext(). Как правильно разбивать на модули большое приложение при таком подходе, автор сам пока не выяснил.

Xcode и macOS для сборки под iOS всё равно нужны. KMP избавляет от UI на Swift, но не от инструментов Apple.

Две ловушки, которые стоили часов

Первая ловушка связана с сериализатором. Kotlinx.Serialization по умолчанию не пишет в JSON поля со значениями по умолчанию. Если у вас stream = false, поле просто пропадает из запроса, а Ollama без него включает потоковый режим. В итоге вы ждёте один ответ, а получаете россыпь JSON-строк. Лечится настройкой Json { encodeDefaults = true }. Найти причину помог Network Inspector в Android Studio.

Вторая ловушка касается самого стриминга. Ответ приходил целиком в конце, и автор сначала подозревал буферизацию в OkHttp и даже разнёс код по платформам через expect/actual. Дело оказалось в другом: вместо post() нужно использовать preparePost() и читать тело через execute и bodyAsChannel построчно. Каждая строка это отдельный JSON-чанк с полями message, done и error. В клиенте всё это превращается в Flow событий: Token, Thinking, Done и Error. Отмена корутины закрывает соединение и останавливает генерацию. Отдельно приходят токены рассуждений (Thinking), если модель их отдаёт.

Ещё одна деталь: для /api/chat роль у каждого сообщения обязательна, а у /api/generate ролей нет, там просто prompt.

Что учесть на практике

Если сервер работает по HTTP, на Android нужен usesCleartextTraffic="true", а для iOS придётся добавить NSAllowsArbitraryLoads в Info.plist. Правда, эмулятор iPhone в эксперименте автора пропустил запросы и без этого. В продакшене HTTP без шифрования и сервер на 0.0.0.0 без авторизации использовать нельзя: в тексте про защиту нет ни слова, поэтому это домашний или внутренний стенд, а не готовая архитектура.

Проект пока сырой: интерфейс нужно доводить, истории чатов нет. Автор добавляет, что клиент для облачных моделей cloud.ru с картинками и аудио уже написан. Задача по переименованию проекта заняла у модели около пяти минут и примерно 50 рублей, но цена сильно зависит от выбранной модели.

Вывод для разработчика: если делаете мобильный клиент к Ollama, сразу включайте encodeDefaults, сразу берите preparePost для потоков и не ищите проблему в OkHttp. Исходники лежат на GitHub (app-z/OllameChatKmp), их можно взять как стартовую заготовку.

Сюжет

Вопросы и ответы по материалу

Что известно по теме: сервер: три команды и одна переменная?

Ollama ставится через brew или установочный скрипт на Ubuntu. По умолчанию сервер слушает только localhost, поэтому эмулятор и реальный телефон в одной сети до него не достучатся. Чтобы открыть доступ, сервер запускают с OLLAMA_HOST=0.0.0.0:11434. Дальше модель загружается через ollama pull, а на маках с чипами Apple автор берёт MLX-сборки, например gemma4:12b-mlx. Команда ollama ps показывает, что модель целиком сидит в памяти GPU (в выводе PROCESSOR 100% GPU).

Что известно по теме: клиент: Compose, Koin, Ktor?

Проект на KMP с общим UI: Koin отвечает за зависимости и ViewModel, Ktor за сеть, Navigation3 за навигацию. Автор выбрал не глобальный startKoin, а composable KoinApplication. Контейнер создаётся внутри функции App(), поэтому @Preview в Android Studio не конфликтует с глобальным состоянием, а Swift-код, который сгенерировала студия, трогать не нужно. Цена такого решения: часть API недоступна, в том числе by inject(), by viewModel() и androidContext().

Что известно по теме: две ловушки, которые стоили часов?

Первая ловушка связана с сериализатором. Kotlinx.Serialization по умолчанию не пишет в JSON поля со значениями по умолчанию. Если у вас stream = false, поле просто пропадает из запроса, а Ollama без него включает потоковый режим. В итоге вы ждёте один ответ, а получаете россыпь JSON-строк. Лечится настройкой Json { encodeDefaults = true }. Найти причину помог Network Inspector в Android Studio. Вторая ловушка касается самого стриминга.

ЖХ
Жемал Хамидун — CPO Alpina Digital, продакт-менеджер AlpinaGPT. Делает AI-продукты с 2019 года. Hamidun News — pet project, который вырос в production-агрегатор с 6000+ читателей.
✦ Единое AI-пространство · Nomad

Протестируйте новые LLM и мультимодальные модели в одном окне

Платформа Nomad AI (nomadnet.ai) даёт прямой доступ к Claude Opus 5.5, GPT-6, Gemini 3.1, DeepSeek и автономным агентам без VPN, зарубежных карт и зоопарка подписок.

Открыть Nomad AI →Автономный Nomad Agent
Что вы думаете?
Загружаем комментарии…