partners 是一层 IM 渠道:它接到了哪些地方
看到一个项目里有 partners 这么个包,第一反应通常是「这里面装着一套 partner 的运行逻辑」。DeepTutor 这个包不是。包的 docstring 第一句就把边界划掉了,剩下的事就好读多了。
以下行号与数字都对应我们采集的仓库快照:HEAD 456f9c2,采集日 2026-08-10,deeptutor/__version__.py 里的版本号是 1.5.11。你自己 clone 之后行号可能已经漂了,文件名、类属性名和常量名是稳的,照着搜即可。
一、这个包里没有 partner 引擎
deeptutor/partners/__init__.py:1-9 的包 docstring 写明:这里托管的是 channel(IM)层——聊天平台集成、把渠道与 agent 运行时解耦的消息总线,以及配置 schema。紧接着的 :5-8 补了一句更关键的:agent 运行时本身在 deeptutor.services.partners,复用 chat capability 的 agent loop(ChatOrchestrator → AgenticChatPipeline),没有独立的 partner 引擎。
这句话决定了你读这个包的姿势。12252 行、30 个 .py 文件(deeptutor/partners 全量统计,2026-08-10),全是「怎么把消息收进来、怎么把回复发出去、按什么配置发」,不包含「怎么想」。所以当你排查「partner 回答得不对劲」这类问题时,deeptutor/partners/ 大概率不是现场;而排查「消息根本没进来」「回复发不出去」「某个平台加不上」,现场就在这里。
顺带一个容易看漏的细节:这个包自带独立版本号,__init__.py:11 写的是 __version__ = "2.0.0",与项目本身的 1.5.11 不是同一套编号。我们没有核实这个包版本号在哪里被消费,这里只记录它存在且与项目版本不同。
API 那一侧的定位也对得上:deeptutor/api/routers/partners.py:1-8 的 router docstring 把 partner 描述为「由 chat agent loop 驱动的 IM 连接伙伴」。挂载点是 /api/v1/partners,32 条路由,而且它是唯一整体挂 require_admin 的业务 router(deeptutor/api/main.py:464-466)——这一条与本篇直接相关:partner 的增删起停不是普通用户能碰的。api 层的整体装配另有一篇专门讲,这里不展开。
二、16 个渠道模块,逐个对上号
deeptutor/partners/channels/ 下除 base.py、manager.py、registry.py、__init__.py 之外,我们数出来是 16 个渠道模块。每个模块都在类里声明了 name 与 display_name 两个属性,行数差距很大:
| 模块 | name / display_name | 行数 | 类属性位置 |
|---|---|---|---|
weixin.py | weixin / “WeChat” | 1564 | :147-148 |
feishu.py | feishu / “Feishu” | 1342 | :278-279 |
mochat.py | mochat / “Mochat” | 1062 | :280-281 |
telegram.py | telegram / “Telegram” | 1060 | :220-221 |
zulip.py | zulip / “Zulip” | 844 | :66-67 |
matrix.py | matrix / “Matrix” | 843 | :208-209 |
msteams.py | msteams / “Microsoft Teams” | 836 | :111-112 |
napcat.py | napcat / “QQ (NapCat)“ | 582 | :64-65 |
dingtalk.py | dingtalk / “DingTalk” | 527 | :127-128 |
discord.py | discord / “Discord” | 505 | :52-53 |
email.py | email / “Email” | 454 | :67-68 |
mattermost.py | mattermost / “Mattermost” | 438 | :68-69 |
wecom.py | wecom / “WeCom” | 381 | :50-51 |
slack.py | slack / “Slack” | 321 | :54-55 |
whatsapp.py | whatsapp / “WhatsApp” | 189 | :35-36 |
qq.py | qq / “QQ” | 186 | :67-68 |
这张表怎么读:行数不代表功能强弱,但它是个有用的先验——1564 行的 weixin.py 和 186 行的 qq.py 显然不在同一个复杂度量级上,接入前先打开看一眼各自处理了什么,比照着 README 的平台清单排期靠谱。至于每个平台的鉴权流程、媒体上传、消息格式转换,我们只读了 name / display_name / 配置模型和少量入口,没有逐行核实,这里不做任何平台之间的能力对比。
两个模块值得单独点名,因为它们涉及本机之外的东西:
whatsapp.py走 Node.js bridge。 默认bridge_url = "ws://localhost:3001",注释写明这个 bridge 用@whiskeysockets/baileys处理 WhatsApp Web 协议,Python 与 Node 之间用 WebSocket 通信(whatsapp.py:18-33)。也就是说这条渠道要另起一个本机 Node 进程,它不在 Python 包的依赖树里。qq.py基于botpySDK。 intents 为public_messages=True, direct_message=True,并显式关掉了 botpy 自带的文件日志(qq.py:29-46)。
另外,渠道基类里的语音转写走 Groq Whisper,没有 key 时返回空字符串(base.py:47-58;provider 实现在 deeptutor/partners/transcription.py,61 行)。这一条要如实说明:启用语音消息意味着音频会离开本机送到第三方服务,是否使用请结合你自己的合规要求判断。同理,每接一个 IM 渠道就意味着要在本机配置该平台的凭据,这些都是敏感数据。
三、渠道是怎么被发现的:零导入扫描 + 插件入口
registry.py 这个文件不长,但它决定了「你的部署里到底有几个渠道可选」。
发现机制是 pkgutil.iter_modules 扫描包目录,零导入——只看模块名不 import 模块,排除集合写死为 _INTERNAL = {"base", "manager", "registry"}(registry.py:14-25)。外部渠道插件另走 entry_points,group 名是 deeptutor.partners.channels;规则是内置优先,插件不能遮蔽同名内置(registry.py:40-51、54-58、80-84)。
真正有排查价值的是 discover_all_with_errors()(registry.py:63-78):它会把加载失败的内置渠道名连同 import 错误消息一起返回,注释里说明这么做是为了让「为什么少了 X」可诊断,而不是静默丢弃。
这一条直接对应一个常见现象:渠道模块在仓库里躺着,不等于你的环境里能用。文件存在是 16 个,能不能 import 成功取决于对应的第三方 SDK 装没装。而依赖是按 extra 分组的:pyproject.toml:131-156 的 .[partners] extra 里列了 telegram、wecom、lark(feishu)、dingtalk、slack、qq-botpy、socketio、zulip、websockets、aiohttp、PyJWT、qrcode 等;Matrix 渠道被拆到独立的 matrix / matrix-e2e extra(pyproject.toml:179-191)。email、mochat、mattermost、msteams、whatsapp、napcat、weixin 这几个渠道没有以自身命名的 SDK 包出现在 extra 里(其中 napcat / msteams / weixin 各有一条带指名注释的通用依赖,见 pyproject.toml:150-155),不能据此断言它们在 extra 里完全没有依赖覆盖。README 用的限定说法是「depending on installed extras and configured credentials」(README.md:511),但没有给出「哪个渠道属于哪个 extra」的映射表。
所以「某个渠道没出现」的判定动作很明确:先看它是不是被 discover_all_with_errors() 归到了失败集合里、错误消息是什么,而不是先去翻配置文件。如果它压根不在失败集合里,那就不是依赖缺失这个原因,得往别处找。
四、本篇最反直觉的一处:allow_from 为空 = 全部拒绝
base.py:116-124 的 is_allowed() 只有几行,语义是这样的:
allow_from为空 → 全部拒绝,并打一条 warningallow_from里含"*"→ 全部放行- 其余情况 → 按名单精确匹配
反直觉在第一条。按大多数人配白名单的经验,「没填」通常等于「不限制」;这里恰恰相反,没填就是一个都不放。这个方向本身是 fail-closed 的取值,对一个会接到公开 IM 平台上的组件来说是说得通的,但它带来的现象很具体:渠道能启动、日志里没有报错级别的异常,消息就是没反应。
判定动作:去日志里找 is_allowed() 打出的那条 warning(base.py:116-124)。找到它,说明是这个原因;找不到它,说明消息压根没走到访问控制这一步,那就不是这个原因,得回去看渠道有没有真的收到入站消息。
同一个文件里还有第二处「且」的逻辑,容易被当成开关:supports_streaming 是个 property,要求 config 里 streaming 为真 并且子类真正覆写了 send_delta(base.py:105-114)。也就是说,配置里把 streaming 打开,如果这个渠道的实现没覆写 send_delta,它照样不是流式。BaseChannel 是 ABC,抽象方法只有 start() / stop() / send() 三个(base.py:19、60-88),send_delta() 是可选覆写(base.py:90-103)——所以「哪些渠道支持流式」这件事,答案在各渠道模块里,不在配置文件里。我们没有逐个核实 16 个模块谁覆写了 send_delta,你要用的话自己 grep 一遍这个方法名即可。
五、总线、重试与落盘位置
消息总线 MessageBus 的实现比想象中朴素:两个 asyncio.Queue,一个 inbound 一个 outbound(deeptutor/partners/bus/queue.py:17-18)。会话的切分键是 InboundMessage.session_key,取值为 session_key_override 或 f"{channel}:{chat_id}"(deeptutor/partners/bus/events.py:21-24)。这一行值得记住:默认情况下,同一个平台同一个会话 id 就是同一条会话,跨平台不会串。
出站失败的退避是写死的常量 _SEND_RETRY_DELAYS = (1, 2, 4)(deeptutor/partners/channels/manager.py:23),schema 里的默认值是 send_max_retries: int = 3(deeptutor/partners/config/schema.py:50)。这两个数是代码里的默认配置,不是「你会不会丢消息」的保证——退避序列是三档,最大重试次数默认也是 3,两处是否总是一一对应,我们没有读 manager.py 的重试循环实现,不做推断。
配置 schema 这一层有两个约定要知道:渠道配置基类用 Pydantic 且启用了 camelCase 别名生成(alias_generator=to_camel, populate_by_name=True),ChannelsConfig 设了 extra="allow"(schema.py:12、47);DeliveryOverrides 里两个开关 send_progress / send_tool_hints 默认都是 True(schema.py:15-25)。extra="allow" 意味着你在配置里写错一个键名不会被拒,它会被原样收下——排查「我明明配了但没生效」时,先怀疑键名拼写和 camelCase 转换。
落盘位置是本篇第二处需要留神的设计。partner 的数据树锚定在 admin workspace 的 data/partners/,而不是当前用户的 path service;deeptutor/partners/config/paths.py:10-16 的注释解释说,这是因为 partner 运行在合成 scope 里、它的 workspace 就在这棵树下。每个 partner 的目录结构是 data/partners/{id}/(config / sessions / workspace)、.../workspace、.../sessions、.../media[/<channel>](paths.py:34-53)。
实际含义:你按「当前登录用户的目录」去找 partner 的会话和媒体文件,会找错地方。它不在 data/users/<uid>/ 下面。多用户与授权那一套机制另有一篇专门讲,本篇只交代 partner 数据落在哪棵树上。
六、README 与代码对不上的两处
按纪律,这里只陈述差异、标明位置,不推断原因、不评价:
- README 的渠道平台清单少列两个已存在的模块。
README.md:511写的是可连接 Feishu、Telegram、Slack、Discord、DingTalk、QQ/NapCat、WeCom、WhatsApp、Zulip、Mattermost、Matrix、Mochat 和 Microsoft Teams,未提及代码中同样存在的email(channels/email.py:67-68,454 行)与weixin(display_name为 “WeChat”,channels/weixin.py:147-148,1564 行)。代码侧我们数出来是 16 个模块。补充一句:README 全文除:511之外,WeChat只出现在社群二维码与徽章语境(README.md:39、README.md:188),没有被当作 partner 渠道介绍。 - README 版本记事写的是「15 channels」。
README.md:102的 v1.4.3 记事称 Partners 上了 15 个渠道;我们在 HEAD(456f9c2,版本 1.5.11)数到的是 16 个模块。该记事是历史条目,README 正文没有给出更新后的数字。
另外 AGENTS.md 的 Key Files 表里没有出现 deeptutor/partners/(表在 AGENTS.md:101-118,api 侧只列了 deeptutor/api/routers/unified_ws.py 一个文件)。以我们实读的仓库状态为准。说完就停。
七、你可以照着核的六步
- 打开
deeptutor/partners/__init__.py:1-11,确认这层只是 channel 层、agent 运行时在deeptutor.services.partners,以及包自带的__version__ = "2.0.0"。 ls deeptutor/partners/channels/*.py,去掉base/manager/registry/__init__之后数一遍,对照本文表格的 16 行。- 打开
channels/base.py:116-124,确认allow_from为空是拒绝而不是放行,并记下那条 warning 的措辞。 - 打开
channels/base.py:105-114,确认supports_streaming是「配置为真」与「子类覆写send_delta」的与逻辑,再 grep 一遍send_delta看哪些模块覆写了。 - 打开
channels/registry.py:63-78,确认discover_all_with_errors()会回传失败渠道名与 import 错误——这是排查「少了某个渠道」的第一现场。 - 打开
partners/config/paths.py:10-16与:34-53,确认 partner 的数据树在data/partners/{id}/而不在当前用户目录下。
需要说明的边界:本文只读了 partners 包的 docstring、channels/base.py、channels/registry.py、各渠道模块的类属性行与 config/ 下的 schema 与 paths;deeptutor/services/partners/ 下的 partner 运行时管理器、workspace 供给、凭据脱敏等实现不在本次核对范围,只从 import 与 docstring 间接引用过。partners.py 这个 router 我们也只提取了路由装饰器与挂载信息,处理函数体未逐一阅读,因此「某个端点具体做什么」多数未核实。文中出现的所有默认值都是源码中的默认配置,不构成对实际运行结果的保证。
本文依据 DeepTutor 官方仓库(github.com/HKUDS/DeepTutor)的 README、AGENTS.md、
pyproject.toml 与 deeptutor/ 下的源码整理,核对日 2026-08-10,对应仓库快照 456f9c2(版本 1.5.11)。
本文内容为仓库源码与文档口径,我们没有安装、部署或运行过该项目,也没有调用过其中任何一个模型 API,
因此不涉及生成质量、响应速度与教学效果的任何描述。
参数与默认值随版本变动,请以仓库最新代码与 --help 的实际输出为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。