Без стриминга AI-ассистент неприемлем для пользователей. Ожидание 5–10 секунд пустого экрана перед появлением ответа — это не «медленно», это «сломано». Согласно исследованию пользовательского опыта, задержка более 2 секунд снижает вовлечённость на 40% и увеличивает отток на 25%. Наши инженеры решают эту задачу с помощью Server-Sent Events (SSE) или WebSocket: первый токен приходит через 300–600 мс, пользователь видит, что модель «думает». Мы реализуем побуквенный вывод текста с учётом особенностей мобильных платформ — iOS, Android и Flutter. В этой статье разберём ключевые технические аспекты: от парсинга SSE до рендеринга Markdown без артефактов. Наша команда имеет 10+ лет опыта в мобильной разработке и реализовала 500+ проектов, что гарантирует стабильность решения.
Как реализовать потоковый ответ AI в мобильном приложении?
Проблемы потокового ответа AI в мобильном приложении
Стриминг AI-ответа — не просто открыть socket. Вот реальные сложности:
- Парсинг SSE-потока на мобильном клиенте: iOS требует
AsyncBytes, Android — OkHttp +callbackFlow. Ошибка в парсинге приводит к потере данных или зависанию. - Рендеринг незавершённого Markdown: если ответ содержит
**жирный текст, а клиент рендерит его до закрывающего**, появляются артефакты. Мы используем буферизацию или отложенный рендеринг. - Отмена запроса: пользователь нажал «Стоп» — нужно корректно прервать поток и сохранить уже полученный текст в историю диалога. На iOS
Task.cancel()автоматически отменяетfor await, на Android —call.cancel(). - Разрывы соединения: мобильная сеть нестабильна. При обрыве мы сохраняем частичный ответ и предлагаем «Продолжить», отправляя новый запрос с контекстом.
Как работает парсинг SSE на iOS?
Большинство LLM API отдают стриминг через SSE (определение в MDN). Каждое событие — строка data: {json}, пустая строка — разделитель. Нативный способ на iOS — URLSession + AsyncBytes (iOS 15+):
func streamCompletion(request: URLRequest) -> AsyncThrowingStream<String, Error> {
AsyncThrowingStream { continuation in
Task {
let (bytes, response) = try await URLSession.shared.bytes(for: request)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
continuation.finish(throwing: APIError.badStatus)
return
}
for try await line in bytes.lines {
guard line.hasPrefix("data: ") else { continue }
let payload = String(line.dropFirst(6))
guard payload != "[DONE]" else {
continuation.finish()
return
}
if let data = payload.data(using: .utf8),
let chunk = try? JSONDecoder().decode(StreamChunk.self, from: data),
let delta = chunk.choices.first?.delta.content {
continuation.yield(delta)
}
}
}
}
}
Использование в ViewModel:
func sendMessage(_ text: String) {
Task { @MainActor in
currentResponse = ""
for try await token in streamCompletion(request: buildRequest(text)) {
currentResponse += token
}
}
}
@MainActor гарантирует обновление UI на главном потоке без явного DispatchQueue.main.async.
Android: OkHttp + EventSource
На Android нативного SSE-клиента нет. OkHttp — стандартный выбор:
class SSEClient(private val client: OkHttpClient) {
fun stream(request: Request): Flow<String> = callbackFlow {
val call = client.newCall(request)
call.enqueue(object : Callback {
override fun onResponse(call: Call, response: Response) {
response.body?.source()?.let { source ->
while (!source.exhausted()) {
val line = source.readUtf8Line() ?: break
if (line.startsWith("data: ")) {
val payload = line.removePrefix("data: ")
if (payload == "[DONE]") {
close()
return
}
// parse JSON, extract delta
trySend(extractDelta(payload))
}
}
}
close()
}
override fun onFailure(call: Call, e: IOException) = close(e)
})
awaitClose { call.cancel() }
}
}
callbackFlow — правильный способ превратить callback-based OkHttp в Kotlin Flow. trySend вместо send — не блокирует поток.
Для Flutter: используем dio с ResponseType.stream или dart:io HttpClient напрямую.
Почему важно буферизировать Markdown?
Если в ответе есть Markdown (жирный, код, списки), рендерить нужно аккуратно. Проблема: Markdown-парсер видит незавершённые конструкции — например, **жирный без закрывающего ** — и рендерит артефакты.
Два подхода:
- Рендерить только завершённые блоки — буфер накапливает до закрывающего токена, потом рендерит. Даёт чистый результат, но добавляет задержку.
- Рендерить как plain text во время стриминга, Markdown — после завершения — проще и надёжнее для большинства ассистентов.
На iOS — AttributedString с NSMarkdownParser для финального рендера, Text(currentResponse) во время стриминга. На Android — Markwon библиотека для финального рендера в TextView.
Отмена запроса и восстановление после сбоев
Пользователь нажал «Стоп» — нужно корректно отменить стриминговый запрос. На iOS: Task.cancel() автоматически отменяет URLSession.bytes — for await выбросит CancellationError. На Android: call.cancel() через OkHttp, flow.cancellation(). После отмены мы обязательно сохраняем уже полученный частичный ответ в историю диалога — пользователь видел текст, и он должен остаться.
Мобильная сеть нестабильна. Стриминговый запрос прерывается на середине ответа. Правильная реакция: показать то, что уже получено, и предложить «Продолжить». Сохранить lastTokenIndex или последний stop_reason нельзя — API не поддерживает возобновление с середины. Нужно генерировать заново, передав в контекст уже полученную часть ответа.
Сравнение протоколов: SSE vs WebSocket
| Критерий | SSE | WebSocket |
|---|---|---|
| Направление | Сервер → клиент | Двустороннее |
| Повторное соединение | Встроено (EventSource) | Нужно реализовать |
| Простота реализации | Высокая | Средняя |
| Поддержка на mobile | iOS: AsyncBytes, Android: OkHttp | Все платформы |
| Потоковая передача бинарных данных | Нет | Да |
Для AI-стриминга SSE достаточно. WebSocket оправдан, если нужна двусторонняя связь (например, стриминг аудио + текст).
Сравнение реализации на платформах
| Платформа | Способ | Библиотека | Ключевой класс |
|---|---|---|---|
| iOS | AsyncBytes | URLSession | AsyncThrowingStream |
| Android | OkHttp + callbackFlow | OkHttp | Flow<String> |
| Flutter | потоковый HTTP | dio / HttpClient | Stream<String> |
Этапы реализации потокового ответа под ключ
- Аналитика: выбор протокола и архитектуры, проектирование API-запросов.
- Реализация клиента: парсинг SSE, управление состоянием, отмена запроса.
- Рендеринг: настройка отображения текста, поддержка Markdown.
- Тестирование: проверка стабильности на слабых сетях (3G, Edge), edge-кейсы.
- Деплой: интеграция в существующее приложение, публикация в App Store / Google Play.
Что входит в работу
- Исходный код модуля стриминга для iOS / Android / Flutter.
- Интеграция с выбранным LLM API (OpenAI, Anthropic, локальная модель).
- Документация по доработкам и поддержке.
- Настройка аналитики для отслеживания ошибок и задержек.
- Обучение команды заказчика.
Ориентировочные сроки: 4–6 рабочих дней на одну платформу, 1–1,5 недели на обе. Для Flutter — 5–7 дней. Стоимость рассчитывается индивидуально, экономия средств от внедрения достигает 30–50%.
Наша команда имеет 10+ лет опыта в мобильной разработке и реализовала 500+ проектов с аудиторией от 100 000 пользователей. Гарантируем стабильную работу даже при нестабильном соединении. Если у вас есть вопросы или вы хотите заказать реализацию, свяжитесь с нами — оценим проект бесплатно. Получите консультацию уже сегодня.







