Streaming LLM-ответов в Telegram. Где ломается edit-rate и как не ловить flood-wait.

«Печатает...» в TG — простая фича на бумаге. На production : 1 edit/sec лимит, message_too_long, форматирование съезжает, бот молчит 18 секунд и юзер уходит.

Cover · violet wireframe · streaming-telegram-pitfalls
⬡ TL;DR

Telegram editMessageText — 1 запрос/сек на сообщение, потом FLOOD_WAIT на 5–30 сек. Стримить каждый token — первая же реплика на 5+ секунд кладёт диалог. Правильно : батчить токены до границы предложения, апдейтить раз в 1.1 сек, держать sendChatAction(typing) в параллель. На ответе >4 096 символов разбивать на несколько сообщений, иначе MESSAGE_TOO_LONG.

Telegram Bot API наивно выглядит «стрим-friendly» : есть editMessageText, есть sendChatAction. На деле :

Telegram-лимиты которые ломают наивный стрим

  • 1 editMessageText / sec на chat_id+message_id. Превышение — 429 Too Many Requests с retry_after. Пока ждёшь, токены копятся в буфере.
  • 30 messages/sec на разные чаты, 20 в один. На стриме маловероятно, но если у вас 50 параллельных диалогов и все streaming — упрётесь.
  • MESSAGE_TOO_LONG на 4 096 символов. LLM может сгенерить и 8 000. Нужна логика «закрыть сообщение, начать новое».
  • parse_mode валидируется при каждом edit. Если в стриме прилетел незакрытый * или [can't parse entities, edit фейлится. Все следующие токены тоже не долетят пока не закроешь.

Стратегия батчинга по предложениям

Что НЕ работает : «edit раз в секунду по таймеру». Вы разрезаете middle of word, юзер видит «привіт як спр» и на следующей секунде «привіт як справи?». Дёрганно.

Что работает :

  1. Копим токены в буфер.
  2. Триггер edit :
    • прошло >1.1 сек с последнего edit, И
    • в буфере есть граница предложения (. ! ? \n) либо >180 новых символов.
  3. На edit отправляем весь накопленный текст с момента начала + добавляем «⌒» в конец как visual indicator «ещё печатается».
  4. На stream_end — финальный edit без индикатора.

Пример на Python (упрощённый) :

SENTENCE_END = re.compile(r'[.!?\n]\s')
buffer = ""
last_edit = 0
last_text = ""

async for token in llm.stream():
    buffer += token
    now = time.monotonic()
    has_boundary = SENTENCE_END.search(buffer[-50:]) is not None
    enough_new = len(buffer) - len(last_text) > 180
    can_edit = now - last_edit >= 1.1

    if can_edit and (has_boundary or enough_new):
        try:
            await bot.edit_message_text(
                buffer + " ⌒",
                chat_id, message_id,
                parse_mode=None,
            )
            last_edit = now
            last_text = buffer
        except RetryAfter as e:
            await asyncio.sleep(e.retry_after)

# финальный edit
await bot.edit_message_text(buffer, chat_id, message_id, parse_mode="MarkdownV2")

TG-бот со streaming под ваш use-case

От FAQ-бота до multi-agent системы с tool use. Streaming, voice, форматирование, payments — production-ready за 3–4 недели.

Кинуть бриф

«Печатает...» правильно

sendChatAction(typing) работает 5 секунд, потом исчезает. Если LLM думает 12 сек до первого токена — надо перевызывать каждые 4 секунды. Иначе юзер видит «бот молчит» и закрывает чат.

Правильный паттерн : запустить background-задачу typing_pulse сразу после получения сообщения от юзера, она тикает sendChatAction каждые 4 сек до момента когда прилетел первый стрим-токен. После этого — typing убираем, юзер видит «бот пишет ответ» (само сообщение начало появляться).

Markdown посреди стрима : что делать

Самая болезненная часть. LLM генерит **жирный** в стриме как **жи**жирн**жирный**жирный**. На каждом промежуточном edit с parse_mode=MarkdownV2 — can't parse entities.

Два решения :

  • Простое : промежуточные edit отправляем с parse_mode=None, финальный — с MarkdownV2. Стрим работает, формат появляется в конце. Минус : пользователь видит сырые ** до последнего edit.
  • Сложное : на каждом edit балансируем незакрытые маркеры. Если в буфере чётное число ** — ок, отправляем как есть. Если нечётное — временно дописываем закрывающий ** в конец перед отправкой. Аналогично для __, [, (. Реализуется в ~40 строках, работает безотказно.

В production используем второй вариант — первый ломает восприятие, особенно когда LLM пишет код в ``` блоках по 15 строк и юзер 8 секунд видит сырой markdown.

Если LLM генерит ответ >4 000 символов : останавливаем стрим текущего сообщения, делаем финальный edit, отправляем sendMessage с «[продолжение]» и продолжаем стримить в новое сообщение. Логика граничная, но без неё длинные code-explanation от Claude просто не долетают.

START

TG-бот со streaming
production-ready.

FAQ, sales, voice, payments — под ваш use-case за 3–4 недели. Фиксированная оценка после Discovery.