拆解 DeepSeek SuperAgent 的消息采集,消息明明发出去了,Agent 为什么没回应?
我是安徽最忧郁程序员无隅
前言
假设你在群里提到了机器人,请它补充退款场景的测试。消息已经发送,聊天窗口却一直没有回应:它没有收到,还是收到了以后没有继续处理?另一边,同一条评论经过不同入口被读取了两次,又会不会重复创建工作?
这些问题需要沿着消息的实际经过逐步检查。这篇文章从 DeepSeek SuperAgent 的公开实现出发,解释消息怎样进入系统、怎样识别重复,以及读取失败以后怎样继续。
以下机制依据公开研究原型 dsh-superagent-prototype 的提交 8c32a9d 核查,场景用于解释机制。源码与项目说明
一、只监听实时消息,为什么还不够?
先看最直接的办法:用户发送消息,平台推送事件,机器人接收后立即处理。飞书来源的代码确实启动了一个事件消费进程,监听 im.message.receive_v1。
这条路径的好处很容易理解:新消息出现以后,系统可以及时取得内容。不过,接收消息的进程也可能重启,网络连接也可能中断。假设用户恰好在重连期间补充了要求,仅仅恢复连接,还无法说明这段时间里的消息已经被读取。
因此,公开实现为飞书安排了三种相互补充的读取方式:
- 实时事件:及时接收平台推送的新消息。
- 历史补充:根据保存的读取位置,检查配置中的群聊,以及已经获知并允许接收的私聊。
- 订阅讨论轮询:持续查看系统已经跟进的讨论,发现可能遗漏的后续回复。
这里有个容易忽略的细节:群里的话题根消息和话题回复,读取方式有所区别。飞书官方文档说明,在普通对话群中,使用 chat 容器读取话题消息时只能取得根消息,话题中的回复需要使用 thread 容器。因此,系统还需要关注讨论本身,才能继续取得里面的补充内容。飞书历史消息接口
这几种方式能够互相补充,也各自受到条件限制。机器人需要具备相应的平台权限,系统配置需要包含对应的接收范围。历史补充首次遇到一个尚无读取位置的会话时,会记录当时的时间;后续才从保存的位置继续。它的作用范围与实际保存的进度有关。飞书采集实现
GitHub 来源主要采用定期读取。系统查看机器人账号的通知、已订阅的 Issue 和 Pull Request,并在相应配置开启时观察仓库里的新 Issue、评论或默认分支的持续集成状态。通知读取间隔至少为 60 秒,GitHub 要求更长间隔时,程序也会遵循返回的 X-Poll-Interval。这些入口各有明确的范围。GitHub 采集实现、GitHub 通知接口
接下来,新的问题出现了:同一条飞书消息,实时事件可能读到,历史补充也可能读到。两边返回的数据格式还不完全相同,后续代码怎样认出它们?
二、多个入口收到的消息,怎样统一处理?
假设“请补充退款场景的测试”这条消息先经过实时入口,稍后又被历史读取取得。文字内容相同,外层字段却可能不同:实时事件直接提供正文,历史接口则可能把正文放在 body.content 里的 JSON 字符串中。
如果每个后续模块都要分别处理这些格式,它们就需要不断了解平台细节。公开实现把转换工作放在来源适配代码中:实时消息、历史消息和话题回复,最终都会转换成统一的 Trigger 对象。飞书消息转换
可以把 Trigger 理解为“一件已经发生、可以记录和处理的外部事件”。后续模块需要的信息,都有明确的位置:
| 信息 | 对应字段 | 后续用途 |
|---|---|---|
| 事件的身份 | id | 判断这件事是否已经登记 |
| 来源和发送者 | source、actor | 确认事件来自哪里、由谁发出 |
| 正文和时间 | text、at | 理解内容,组织事件顺序 |
| 所属讨论 | threads | 找到相关上下文和已经跟进的工作 |
| 回复地址 | channel | 需要反馈时知道向哪里发送 |
| 平台特有信息 | meta | 保留群标识、评论编号、文件位置等细节 |
human 和 addressed 还会说明发送者是否被来源识别为普通用户,以及消息是否直接发给或提到了机器人。这些事实能够供后续判断使用,接收一条消息本身并不会直接决定是否开始工作。Trigger 类型定义
GitHub 评论也经过类似转换。对于行内评审意见,代码会把文件路径、行号和附近的差异内容带进正文,保留理解评论所需的信息。来源独有的字段仍然放在 meta,通用处理过程通过统一字段取得需要的内容。GitHub 消息转换
下面的图把采集入口和统一格式放在一起。可以关注汇合关系:各个入口分别产生消息,再转换成后续模块能够共同使用的对象。

这时再看“统一格式”的价值,就具体了:后续代码可以使用 text 读取正文,使用 threads 找到讨论,使用 channel 找到反馈位置。每个来源负责把自己的信息解释清楚。
不过,字段统一以后,同一条消息仍然可能出现多次。接下来还需要处理它的身份。
三、为什么主动重复读取,还要专门去重?
重复读取有时是系统主动安排的。
假设某次读取用于计算后续起点的时间为 10:00:10。程序记录给下一轮的起点会向前保留五秒,也就是 10:00:05。源码中的计算非常直接:
export const cursorAt = (started: string): string =>
new Date(Date.parse(started) - 5000).toISOString()
为什么多读这几秒?因为远端消息到达、写入和本地发起读取之间可能存在延迟。让相邻读取范围保留一段重叠,可以增加边界附近消息被后续读取发现的机会。重读窗口实现
代价也很明确:上一轮读过的部分消息,下一轮会再次出现。加上实时事件和历史补充本身存在交集,系统必须能够识别“这条消息已经来过”。五秒只是有限的重叠范围,实际延迟超过这个范围时,还需要结合具体读取入口和保存进度判断覆盖情况。
飞书转换代码使用平台的 message_id 生成内部标识:
export const feishuTriggerId = (messageId: string): string => key('feishu', messageId)
同一个 message_id 从哪个入口到达,生成的标识都相同。这样,来源转换先把“同一件事”表达成相同的身份,接收入口再检查这个身份是否已经保存。稳定标识实现
真正的检查发生在 Intake.receive() 中:
if (this.repo.triggers.get(trigger.id)) return 'duplicate'
发现已有记录后,函数直接返回 duplicate,后面的登记和分发就不会再次执行。Engine.receive() 将这一过程放在仓库事务中,实际存储使用 SQLite。接收入口、事务入口、SQLite 存储
图中 A、B 位于两轮读取的重叠范围,所以会再次被读到。它们的消息标识保持一致,接收入口据此识别已有记录;C 则位于后续的新时间范围。

这里使用明确的返回结果,也方便调用者理解发生了什么:记录确实存在,这次读取遇到了重复。正常的重复读取可以继续进行,系统也能够把它与真正的读取错误分开处理。
还有一个值得注意的条件:事件身份怎样生成,会直接影响系统怎样理解内容更新。 GitHub 的评论和评审事件会将评论编号以及 updated_at 或 submitted_at 等版本信息加入内部标识。内容更新以后,新的版本可以再次进入处理过程。飞书这里展示的标识计算只使用 message_id;对于相同标识下的内容变化,单靠这段去重逻辑无法产生新的事件身份。评论版本处理
这部分解释的是消息进入系统时的登记与去重。后续发送回复、创建 Pull Request 等外部操作,还需要各自的状态记录和重复执行处理。
四、没有回应,究竟是哪一步出了问题?
回到开头的问题。用户没有看到回复,只能说明聊天窗口里尚未出现结果。要继续排查,需要先找到消息在系统里的位置。
第一种情况,消息还没有进入系统。 例如实时消费进程没有正常启动,或者历史读取受到平台权限限制。此时可以查看来源的健康状态和读取错误。公开实现会分别记录飞书实时接收、历史补充以及 GitHub 来源的状态;GitHub 的连续失败达到配置阈值后会报告异常,默认阈值为三次。
健康状态也有自己的证明范围。进程正在运行,说明接收过程具备运行条件;要确认某一条消息是否已经取得,还需要查找具体记录。来源状态与失败计数
第二种情况,消息已经取得,但接收范围将它过滤了。 假设平台推送了一条来自群 B 的消息,而系统只配置接收群 A。飞书来源的范围检查可以给出 chat_not_allowed 原因。
接下来发生的事情很有用:接收入口会保存这条 Trigger,再保存过滤原因,最后返回 filtered。以后查看记录,就可以知道消息确实到达过,同时知道它为什么没有继续进入后续处理。飞书接收范围、过滤记录
如果只留下一个“没有回复”的现象,排查时就需要重新猜测连接、权限和配置。保留过滤原因,让这些检查有了具体依据。
第三种情况,读取数据之后,解析或后续读取失败了。 这类错误需要继续区分范围。某一项消息无法通过结构校验时,validItems() 会跳过该项并增加计数;健康信息可以显示跳过了多少条。某个群或者讨论整体读取失败时,则记录对应错误,并按照读取进度安排后续恢复。逐项校验实现
这也说明,过滤记录、跳过计数和读取错误分别回答不同的问题。无法解析的原始消息,不会因为系统具有过滤记录,就自动获得完整的原文存档。
可以按照下面三个位置分别寻找证据。先确认消息是否进入系统,再检查过滤记录和读取错误,排查范围就能逐步缩小。

接收入口还有 stored 这一正常结果:消息已经保存,并进入对应的订阅分发或评估安排。它与 duplicate、filtered 一起,描述的是接收处理的结果。至于这条消息是否需要开始工作、工作何时完成,还要继续查看后续的判断和执行记录。
五、读取失败以后,下次从哪里继续?
知道发生了错误,下一步就要考虑恢复。这里先解释一个技术词:游标就是系统保存的读取位置。在这些采集过程中,它经常表现为一个时间,说明下次从哪里继续查询。
如果某次读取只完成一部分,马上把进度移动到后面,尚未取得的内容就可能被跳过。如果一直停在失败位置,其他读取又可能受到影响。怎样安排,需要先看这个读取位置管理的范围。
飞书历史补充按群保存进度。可以用三个群来理解:群 A 成功读取,推进 A 的进度;群 B 读取失败,保留 B 原来的位置;群 C 仍然可以继续读取,成功后推进 C 的进度。下一轮再次处理 B 时,仍从保留下来的位置开始。
订阅讨论也分别记录轮询错误。一个讨论失败后,可以保存它自己的 pollError,其他讨论仍然继续。前面已经成功登记的消息即使再次出现,也可以通过稳定标识识别重复。按群补充读取、独立讨论轮询
GitHub 通知读取的情况有所不同。通知接口里的 since 管理账号通知列表的时间范围,一条通知则可能指向某个 Issue 或 Pull Request。取得通知以后,还需要继续读取它指向的讨论内容。
假设这一轮通知涉及 #42、#57 和 #86。其中 #57 的具体内容读取失败,如果让账号通知的全局进度一直停在这里,后续通知就会被反复读取,失败问题也会持续影响整个读取过程。
公开实现会保存 #57 的编号、读取起点及错误重试信息,同时让全局通知游标继续推进。这两个状态在同一个事务中保存。下一轮先把已经保存的失败项加入处理集合,再加入新通知对应的项目,分别尝试读取。通知与失败项处理
下图展示普通可重试错误发生时的安排。左边每个群有独立进度,右边则把全局通知进度与失败项目的读取起点分别保存。

这里的读取起点很关键。只记录“#57 失败过”,还无法知道它缺少哪段讨论;把起点一并保留,后续才知道从哪里补充读取。先处理旧失败项,也给它们提供了再次尝试的机会,避免新通知持续到来时把它们一直排在后面。
重试仍有明确的停止条件。当前实现遇到 HTTP 410 时,会记录该讨论已删除并移除通知重试项;普通错误持续达到 24 小时,也会退出这一轮通知重试机制。对应讨论的读取游标仍然保留,后续通知或讨论轮询在条件满足时还可能再次尝试读取。重试条件与期限
订阅轮询同样存在范围条件:有进行中工作的讨论会继续被检查;没有进行中工作的讨论,根据最近活动和工作结束时间,采用 14 天的轮询范围。检查具体遗漏时,需要把这些条件与来源权限、初始化时间和实际错误记录一起查看。订阅轮询范围
参考资料
- dsh-superagent-prototype:项目说明。公开研究原型与团队内部版本存在差异,本文核查范围为该公开提交。
- 领域模型:Source、Trigger、Thread 等对象的职责
- 飞书:获取会话历史消息
- GitHub:通知接口与轮询要求
源码与文档核查日期:2026 年 11 月 13 日。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐


所有评论(0)