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, юзер видит «привіт як спр» и на следующей секунде «привіт як справи?». Дёрганно.
Что работает :
- Копим токены в буфер.
- Триггер edit :
- прошло >1.1 сек с последнего edit, И
- в буфере есть граница предложения (
. ! ? \n) либо >180 новых символов.
- На edit отправляем весь накопленный текст с момента начала + добавляем «⌒» в конец как visual indicator «ещё печатается».
- На
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 просто не долетают.