Python Telegram 机器人开发:ptb v20+ 异步架构与 FSM
从架构角度讲解 Telegram 机器人开发:没有真正的对话,只有不断到达的更新片段。涵盖状态存放位置、死胡同设计、错误可见性、数据最小化存储,以及五步动手练习与常见问题解答。
从架构角度讲解 Telegram 机器人开发:没有真正的对话,只有不断到达的更新片段。涵盖状态存放位置、死胡同设计、错误可见性、数据最小化存储,以及五步动手练习与常见问题解答。

选一个库只需要一个下午。每种主流语言都有不错的库,文档也过得去,用其中任何一种写一个回复命令的机器人都只要十五行代码。
所以这一步讲的不是库,而是让机器人比看起来更难的四个问题——而它们全都是同一个事实的结果:并不存在对话。只有不断到达的片段,以及你在片段之间记住的东西。
完成这一步后你将能够
- 说清多步对话的状态究竟存放在哪里。
- 设计死胡同,而这正是让机器人显得完整的大部分工作。
- 确保失败对你可见,而不是只有用户看得见。
- 决定该存什么,并且存得比你能存的少得多。
没有对话,只有更新
你的程序是一个被片段调用的函数:有人发了这段文字,有人按了那个按钮,有人加入了这个群组。每次调用到来时,都不记得之前发生过什么。
因此,一切感觉像对话的东西,都是你保存的状态,以某个人和某个聊天为键,在每个片段开始时读取。“先问名字,再问地址”并不是你代码里的一个序列——而是一条写着这个人正处在问地址这一步的备注,被写下来,并在他的下一条消息到来时被查出来。
这就让那条备注的存放位置成为第一个架构决策,也是最常被无意中做出的决策。把它放在内存里也是一个决策,即使它是默认行为,其后果见下面的测验。
你的机器人问了一个问题并等待回答。这个“等待”存放在哪里?
在消息线程中——回复与问题相关联
在你存储的状态中,以该用户和聊天为键,当他们下一条消息到达时读取
在库中,它会阻塞直到回复返回
没有任何东西在等待。你的机器人发了一条消息就返回了;过了一段时间,一条看起来无关的文字消息到达,唯一能知道它是对你问题的回答的方式,就是你写下的一条说明这一点的记录。各种库提供了让这看起来像序列的辅助工具,而它们为你保存的正是那条记录——通常默认放在内存里。理解底层机制很重要,因为本页后面每一个别扭的情况,都是记录与现实不一致的情况:这个人在另一个聊天里回答了,三天后才回答,或者回答了一个你已经改过的问题。
当人们填表填到一半时,你的服务器重启了。他们会经历什么?
一条错误消息,说明会话已丢失
机器人恢复后从上次中断的地方继续
什么都没有——他们的下一条消息不再匹配任何内容,所以机器人忽略它
如果状态在内存里,它就没了,这个人的下一条消息会作为一条普通文本到达,匹配不上任何处理器。从他们的角度看,机器人只是不再回应了,这与机器人坏掉无法区分。有两件事能解决这个问题,而且它们是分开的:如果这个流程值得恢复,就把状态存到能挺过重启的地方;并且始终为你没预料到的输入准备一个兜底回复,这样至少会说点什么,而不是什么都不说。第二点成本很低却更重要——一个说“我跟丢了,发送 /start 重新开始”的机器人是能用的机器人,而一个沉默的机器人是死掉的机器人。
同一个人发来的两条消息相隔不到一秒到达。你应该假设什么?
它们严格按顺序处理,一个接一个
它们可能同时被处理,并且可能不按顺序完成
第二条在处理第一条时被丢弃
现代机器人框架会并发处理更新,这正是让一个慢操作不会拖住其他所有人的原因。代价是,同一个人的两个更新可能同时在处理中,都读取状态,都写入状态,而第二个先完成。症状很奇怪:一个值被更旧的值覆盖,一个步骤被跳过,一次重复提交。结合第 091 步讲的的重试行为,这就是为什么操作应当可以安全地运行两次,以及为什么任何绝不能发生两次的事情都需要自己的防护,而不是假设顺序。
先设计死胡同
一个感觉完整的机器人和一个感觉坏掉的机器人之间的差别,几乎从来不在顺利路径上——顺利路径第一个下午就能跑通。差别在于其他所有情况,而有四个地方人们会留下未定义的行为。
每一步的意外输入。你要求输入一个数字,却收到一张照片、一个贴纸、一篇小作文,或者一个“hi”。每一步都需要一个回复来说明想要什么,因为替代方案是沉默,而沉默会被读作失败。
从每个状态都能逃出去。一个在任何地方都始终有效的命令,并且要说明这一点。人们经常半途放弃;没有出路,他们就会困在一个自己不再想要的表单里,而他们能找到的唯一补救办法就是拉黑机器人。
过期的按钮。总会有人按下三个月前某条消息上的按钮,那是一个已经不存在的流程,甚至可能是在你的机器人被重写之后。那次按下会像其他任何更新一样到达,应当给出一个解释而不是报错——并且应该编辑那条消息,让过期的按钮不再诱人。
超时。一段半途而废的对话应该过期,而不是永远搁在那里,既因为这个人已经走开了,也因为一条没有终点的状态记录是一个小泄漏,会变成大泄漏。
失败看起来像什么都没发生
网页出错时,人们会看到错误。机器人出错时,人们看到的是缺席——他们发了东西,却什么都没回来。他们会断定它坏了,或者自己做错了什么,而其中一部分人会干脆不再使用。
所以有两件事不是可选项。捕获失败并向用户说点什么,哪怕只是说出了问题、请他们重试。以及把详细信息发到你会看的地方——一个私有频道是传统的答案,而且确实很好,因为它把你的错误放在你已经在看的同一个应用里。
有一个教训是吃了亏才学会的:没有限速的错误频道,在事故期间会产生成千上万条相同的消息,并触发第 091 步讲的发送限制,到那时它恰好在最需要的时候停止工作。把重复的合并起来,或者只发一个计数。
相关的正常失败情况是被拉黑。人们会拉黑机器人;这很平常,不是错误。向拉黑了你的人发送会失败,正确的回应是把他标记为不可达并停止尝试,而不是永远重试。一个把拉黑当作临时故障的广播循环,会把生命浪费在重试那些最不可能回来的人身上。
现在试试——五分钟
四个步骤,而最后一个是大多数机器人从未被做过的。
- 写出回复一个命令的十五行版本 用你已经会用的任何语言。它立刻就能工作,这正是重点——难点从来不在这里。
- 加一个兜底回复,回应任何意外输入 在添加任何功能之前。这是对机器人手感影响最大的单项改动。
- 在对话中途重启它,看看用户看到什么 然后决定那个流程是需要能挺过重启的状态,还是只需要一条更好的消息。
- 把它交给一个没见过它的人 在旁边看着,不要帮忙。每一次犹豫都是一条缺失的消息,你两分钟内就会找到三条。
该存什么,以及你能少存到什么程度
人们发给机器人的一切都会到达你的服务器。保留它是一个选择,而大多数项目的默认做法是全都保留,因为删除需要做决定,而存储不需要。
弄清楚你真正需要什么,只存那些。一个回答问题的机器人通常除了当前对话什么都不需要。一个接单的机器人需要订单,而不是产生订单的那十四条消息。一个管理群组的机器人需要操作记录,而不是它所看到的对话副本——而且如果隐私模式关闭(第 086 步),它会看到大量内容。
原因并不抽象。一份所有人消息的日志,在任何事情出错的那天都是一份负担——服务器被入侵、同事离职、一个你没预料到的请求。你从未存储的数据不会泄露,不会被传唤,也不会被意外公开。你毫无理由存储的数据纯粹是负担。
由此得出两个实用习惯。给任何对话性数据设置过期时间,让存储自己清空,而不是无限增长。以及说明你保留了什么,写在机器人自己的描述里或某个命令里——一段诚实的话,几乎没有机器人有,而这恰恰是谨慎的人在用它之前会寻找的东西。
这个阶段的常见问题
我该用哪种语言或库?
用你已经在写的那种。每种广泛使用的语言都有成熟的库,它们的差别在于使用手感而非能力,而且它们都不会是你机器人成败的原因。在决定之前值得检查的是:这个库是否在积极维护,以及它的文档是否涵盖本页那些别扭的部分——对话状态、错误处理、重试——而不只是那个十五行的示例。
对话状态应该放在哪里?
从内存开始,当这个流程值得在重启后恢复时再搬走。对于一个回答一次性问题的机器人,放内存里是正确的,其他任何做法都是额外开销。对于一个多步表单、一次支付,或任何人们投入了精力的事情,它应该放在持久的地方——一个小型数据库或键值存储就足够了。错误不在于选错;而在于没注意到有人替你选了默认值。
我怎么防止一个慢操作拖住所有人?
不要在处理更新时做慢工作。先确认收到,把任务放进队列,让一个工作进程去做——与第 091 步讲的 webhook 规则相同的结构,原因也相同。如果工作很快但涉及外部调用,就给每个出站调用设置超时,因为许多库的默认行为是无限期等待,而一个无响应的服务就会变成一个不再回应任何人的机器人。
我可以用和生产环境相同的机器人来开发吗?
你会后悔的。正如第 091 步指出的,一个机器人只有一种投递模式,所以开发副本会和线上版本争抢更新。除此之外,对着线上机器人测试意味着你的实验对真实用户可见,一次糟糕的部署会发进真实频道。一个拥有自己 token、自己测试频道和自己群组的第二个机器人不花什么钱,而且是每个项目最终都会采用的配置。
我怎么给成千上万的用户发消息?
慢慢地发,并且用队列。发送速度受平台限制,被拉黑的用户会永久失败,而一个全速遍历列表的循环会被限流成一团乱麻,让你分不清谁收到了什么。可行的形态是一个以刻意控制的速度发送的任务,记录每个收件人的结果,把被拉黑的用户标记为不可达,并且能在被中断后恢复。第一次就把它做对,比调试那个天真版本更快。
我的机器人需要数据库吗?
比人们以为的要少。一个回答问题、转换内容或从其他服务获取数据的机器人可以完全无状态,这让部署、重启和推理都容易得多。当你确实有必须在对话之间记住的东西——订单、订阅、管理记录——再加存储,并且优先选择能装下它的最小的东西。一个拥有自己并不需要的数据库的机器人,白白招来了备份、迁移和故障模式。