botfromzero
本章目录
    Telegram 机器人实战 第 4 部分 · 会让你翻车的地方

    第 7 章

    群里人一多,消息开始丢

    同一个群每分钟只能发 20 条,超了 Telegram 直接拒绝。加上昵称里一个下划线就能让消息发不出去——这一章处理所有「发不出去」的情况。

    约 22 分钟上线后的第一个玄学 bugaiogram 3.x · 最后验证 2026-08
    01

    消息发不出去的三种原因

    bot 在小群里跑得好好的,换到几百人的群就开始「偶尔没反应」。别急着怀疑自己的业务逻辑——发送这一步本身就会失败,而且有三种完全不同的原因:

    原因报错里的关键词什么时候撞上
    发太快TelegramRetryAfter
    Too Many Requests
    群人一多、或者定时任务批量推送时
    格式不对can't parse entities用了 Markdown / HTML 格式,而内容里有特殊字符——最常见的来源是用户昵称
    太长message is too long一条消息超过 4096 字符
    三种都不是你的业务逻辑写错了,但三种都会让用户觉得「这个 bot 有毛病」。
    02

    发太快:Telegram 的限制到底是多少

    官方没给一份完整的数字表,但实践中公认的大致边界是:

    全局每秒约 30 条
    同一个群每分钟约 20 条 —— 这条最容易撞
    同一个人的私聊每秒约 1 条

    「每分钟 20 条」意味着平均三秒才能发一条。一个 200 人的群同时签到,你要回 200 条消息——按这个速度得十分钟。

    超了会怎样?Telegram 不排队,直接拒绝,并告诉你「等 N 秒再来」。

    正确的应对:听它的,别硬来

    错误做法是立刻重试——那只会让情况更糟,你的请求会堆成雪球。正确做法是按它说的秒数等

    send.py · 带退避的发送aiogram 3.x
    import asyncioimport loggingfrom aiogram import Botfrom aiogram.exceptions import (    TelegramRetryAfter,    TelegramNetworkError,    TelegramForbiddenError,    TelegramBadRequest,)log = logging.getLogger(__name__)async def safe_send(bot: Bot, chat_id: int, text: str, retries: int = 3):    """发消息,自动处理限流和网络抖动。发不出去返回 None。"""    for attempt in range(retries):        try:            return await bot.send_message(chat_id, text)        except TelegramRetryAfter as e:            # Telegram 明确告诉你等多久,照做            log.warning("限流,等 %s 秒", e.retry_after)            await asyncio.sleep(e.retry_after)        except TelegramNetworkError:            # 网络抖动,指数退避:1、2、4 秒            wait = 2 ** attempt            log.warning("网络问题,%s 秒后重试", wait)            await asyncio.sleep(wait)        except TelegramForbiddenError:            # 用户把 bot 拉黑了,或者 bot 被踢出群。重试没意义            log.info("发不了 %s:对方不让发", chat_id)            return None        except TelegramBadRequest as e:            # 格式或长度不对。重试还是同样的错,直接放弃并记下来            log.error("消息本身有问题:%s", e)            return None    log.error("重试 %s 次仍然失败:%s", retries, chat_id)    return None
    ◆ 重点在于「哪些该重试,哪些不该」

    很多人写重试只 except Exception 然后无脑重来。但被拉黑和格式错误重试一万次也是同样的结果,白白浪费配额还拖慢别人的消息。
    分清「等一等能好的」和「等多久都不会好的」,比重试逻辑本身更重要。

    主动限速:别等被拒了再退

    退避是被动挨打。如果你知道自己要往同一个群发很多条(比如定时推送),主动放慢更好:

    send.py 追加 · 简易限速
    import timefrom collections import defaultdict_last_sent: dict[int, float] = defaultdict(float)MIN_GAP = 3.2          # 同一个群,两条之间至少隔这么久(20 条/分钟)async def paced_send(bot: Bot, chat_id: int, text: str):    """发之前先看看离上一条够不够久。"""    gap = time.monotonic() - _last_sent[chat_id]    if gap < MIN_GAP:        await asyncio.sleep(MIN_GAP - gap)    _last_sent[chat_id] = time.monotonic()    return await safe_send(bot, chat_id, text)

    time.monotonic() 而不是 time.time()——后者会被系统对时影响,可能突然跳一大截或者倒退。

    ✓ 更好的思路:别发那么多

    200 人签到就回 200 条,本身就是设计问题。改成每人签到只回一个短提示,或者干脆只在私聊回,群里的问题就消失了。
    限流代码是兜底,不是让你放心刷屏的许可证。

    03

    格式不对:昵称里的一个下划线就能炸

    第 3 章我特意让所有回复都用纯文本,就是为了绕开这件事。现在说清楚为什么。

    如果你用 parse_mode="MarkdownV2",那么这 18 个字符都必须转义:

    MarkdownV2 的特殊字符
    _ * [ ] ( ) ~ ` > # + - = | { } . !

    注意里面有 小数点减号。也就是说「价格 9.9 元」这句话直接发会失败。实测几个真实昵称:

    用户昵称转义后不转义会怎样
    小明_测试小明\_测试下划线被当成斜体标记,整条消息解析失败
    张三(备注)张三\(备注\)括号被当成链接语法
    价格 9.9 元价格 9\.9 元连小数点都得转
    正常中文昵称原样没事
    😀 emoji 用户原样emoji 不用转
    你自己测的时候用的是自己的昵称,多半没有特殊字符 —— 所以这个 bug 一定是上线之后才炸

    两个解法,先说更省事的那个

    解法一:用 HTML 格式,不用 Markdown。HTML 只有三个字符要转义(< > &),而且 aiogram 自带函数:

    推荐这个
    from aiogram import htmlfrom aiogram.enums import ParseModename = message.from_user.full_name   # 可能含任何字符await message.answer(    f"欢迎 <b>{html.quote(name)}</b>",    parse_mode=ParseMode.HTML,)

    html.quote() 处理掉那三个字符就安全了。凡是来自用户的内容,进模板之前都过一遍它。

    解法二:干脆不用格式。像第 3 章那样纯文本发,一个特殊字符都不用管。签到 bot 这种场景,加粗不加粗根本不影响体验。

    ⬤ 最危险的是「大部分时候正常」

    格式错误不会让程序崩,只会让某些用户收不到消息——恰好是昵称带特殊字符的那些人。你看日志一片正常,用户那边一脸问号。
    所以宁可不用格式,也别用了不转义。

    04

    太长:4096 字符

    /rank 只显示前十,撞不到。但只要有「导出全部记录」这类功能,迟早会撞。分片函数很短:

    send.py 追加
    LIMIT = 4096def split_text(text: str, limit: int = LIMIT) -> list[str]:    """按 4096 切开,优先在换行处切。"""    parts = []    while len(text) > limit:        cut = text.rfind("\n", 0, limit)        if cut <= 0:            cut = limit          # 一整段没换行,只能硬切        parts.append(text[:cut])        text = text[cut:].lstrip("\n")    if text:        parts.append(text)    return parts
    ✓ 实测过这几个边界

    刚好 4096(1 段)、4097(2 段)、900 行带换行的长文(在换行处切开)、10000 个连续字符没有换行(硬切 3 段)—— 每段都不超 4096
    写这类函数一定要测「刚好等于上限」和「上限 +1」,差一错误就藏在这儿。

    但更该问的是:为什么要发这么长?超过 4096 的内容,用文件发出去体验好得多:

    长内容改用文件
    from aiogram.types import BufferedInputFiledata = report_text.encode("utf-8")await message.answer_document(    BufferedInputFile(data, filename="记录.txt"),    caption="你的全部签到记录",)
    05

    全局错误处理:别让一条消息搞挂整个 bot

    到目前为止,任何一个 handler 抛异常,用户那边是完全没有反馈的——日志里一堆红字,他只看到 bot 装死。

    加一个全局兜底:

    bot.py 追加
    from aiogram.types import ErrorEvent@dp.errors()async def on_error(event: ErrorEvent) -> bool:    """任何 handler 抛出的异常都会到这里。"""    logging.exception("处理更新时出错: %s", event.exception)    # 尽量给用户一句反馈,别让他对着空气    msg = event.update.message or getattr(event.update.callback_query, "message", None)    if msg:        try:            await msg.answer("出了点问题,我已经记下来了。稍后再试试")        except Exception:            pass      # 连报错都发不出去,那就算了    return True      # True = 已处理,别再往上抛
    logging.exception会连完整堆栈一起记下来,比 logging.error 有用得多
    回复也包在 try 里错误处理本身也会失败(比如刚好撞上限流)。它挂了就真没救了
    return True告诉 aiogram「这个异常我接了」。不返回的话它会继续往上抛

    注意 @dp.errors() 要挂在 Dispatcher 上,不是 Router。

    06

    顺手把日志配好

    出了事要能查。第 2 章那句 logging.basicConfig(level=logging.INFO) 太简陋了——它不带时间戳,出事你连什么时候发生的都不知道:

    bot.py
    logging.basicConfig(    level=logging.INFO,    format="%(asctime)s %(levelname)-7s %(name)s: %(message)s",    datefmt="%m-%d %H:%M:%S",)# aiogram 自己的日志有点吵,调高一档logging.getLogger("aiogram.event").setLevel(logging.WARNING)

    第 10 章讲部署时会把日志接到 systemd,那时候这个格式就派上用场了。

    07

    验一遍

    怎么试应该看到
    在某个 handler 里故意写 1/0,然后触发它用户收到「出了点问题」,bot 没退出,日志里有完整堆栈
    把 Telegram 昵称改成 test_user,用 MarkdownV2 发一条带昵称的消息不转义 → 发送失败;用 html.quote + HTML 模式 → 正常
    循环给同一个群发 30 条日志里出现「限流,等 N 秒」,但最终都发出去了
    发一条 5000 字符的消息自动切成两条
    把 bot 从群里踢掉再让它发消息日志记一句「对方不让发」,不重试
    第一条最关键:验证一个坏 handler 不会拖垮整个进程

    这一章的要点

    1. 发送失败有三种:发太快、格式不对、太长,各有各的解法
    2. 限流要retry_after,不能立刻重试;网络问题用指数退避
    3. 分清能重试和不能重试的异常:被拉黑、格式错误,重试一万次也白搭
    4. 用 HTML 模式 + html.quote()别用 MarkdownV2(18 个特殊字符,小数点都算)
    5. @dp.errors() 全局兜底,错误处理本身也要包 try
    6. 最好的限流方案是少发消息,代码只是兜底
    ✓ 试着改一个

    把签到的回复改成:群里只回一个极短的确认,详细积分信息私聊发给他。这样群里的消息量降下来了,用户拿到的信息反而更全。
    提示:私聊要用 bot.send_message(user_id, ...),而且要用上 safe_send —— 如果用户没跟 bot 私聊过,会抛 TelegramForbiddenError,这时候要在群里提示他先私聊一下。

    消息发送遇到过什么问题?

    限流、转义、超长——这三类问题你撞到过哪种?报错原文贴进来最好。留不留联系方式都行。