capabilities 层:能力注册协议与五个能力的工具
看一个目录叫 capabilities/,正常的预期是「这里放着这个产品对外的所有能力」。DeepTutor 不是这样。这个目录里住着五个东西,产品对外的能力清单里有七个,两边只重合两项,且这两份名单分别写在两个不同的文件里。先把这件事说清楚,后面的协议才读得通。
以下行号与数字都对应我们采集的仓库快照:HEAD 456f9c2,采集日 2026-08-10,deeptutor/__version__.py 里的版本号是 1.5.11。你自己 clone 之后行号可能已经漂了,文件名与标识符是稳的,照着搜即可。
一、两个注册表,两种「能力」
第一份名单在 deeptutor/capabilities/registry.py:13-19,常量名 LOOP_CAPABILITIES,共 5 项,顺序是 Mastery → Solve → Obsidian → Subagent → ExploreContext。这五个是回合级的循环能力,挂在聊天的答案循环上。
第二份名单在 deeptutor/runtime/bootstrap/builtin_capabilities.py:3-11,常量名 BUILTIN_CAPABILITY_CLASSES,共 7 项:chat、deep_solve、deep_question、deep_research、math_animator、visualize、mastery_path。这七个是运行时的顶层能力,对应的是「这一轮走哪条主线」。
关键在于两份名单的实现落点并不在同一处:七项里只有 deep_solve 与 mastery_path 的实现类落在 deeptutor/capabilities/ 下,其余各项的实现类都不在这个目录里(builtin_capabilities.py:4-10)。
所以「capability」在这个仓库里是一个被复用了两次的词。你在 deeptutor/capabilities/ 里翻不到 research、翻不到 visualize,不是它们缺失,是它们不属于这一层。要按功能找代码,先确认自己找的是哪一份名单里的那个「能力」。
目录规模上,deeptutor/capabilities/ 有 25 个 .py 文件、合计 3838 行;最大的单文件是 mastery/tools.py 721 行,其次是 explore_context/explorer.py 531 行、obsidian/tools.py 361 行。光这三个文件加起来就有 1613 行,接近整个目录的四成——剩下的 22 个文件平分另外六成,单个都不大。
二、LoopCapability 协议:六个成员
协议定义在 deeptutor/capabilities/protocol.py:19-84,它是一个 Protocol,不是基类——满足结构即可,不需要显式继承。
它要求两个字段、四个方法:
| 成员 | 形态 | 职责 |
|---|---|---|
name | 字段 | 能力名 |
owned_tools | 字段 | 本能力注册并在激活时贡献出来的工具名 |
is_active | 方法 | 本回合是否参与 |
system_block | 方法 | 可选的系统提示词片段 |
augment_kwargs | 方法 | 为自己的工具注入服务端私有参数 |
pre_loop_seed | 方法 | 追加到初始用户消息种子里的文本 |
表里「职责」一列不是我们的解读,是转述 protocol.py:19-84 那段 docstring 的原文,你照着行号打开就能逐条比对。
owned_tools 是静态的,这一点在协议里被单独强调:静态才能让设置页在没有回合上下文的情况下把工具按归属分组。配套的 capability_tool_owners()(registry.py:38-44)就是干这件事的,它返回一份「工具名 → 所属能力名」的映射。
三、反直觉的第一处:协议是「只加不减」,而唯一的例外反着来
protocol.py:22-32 把普通 loop capability 的约定写得很死:它复用完整的聊天工具面,并在其上追加自己的工具;不裁剪、不压制既有工具面。换句话说,开了 solve 或 mastery 的那一轮,模型看到的内置工具和普通聊天那一轮是一样的,只是多了几个。
按直觉,「进入某个专门模式」通常意味着收窄——只留下这个模式该用的工具,免得模型跑偏。这里的默认恰恰相反:默认是加法。
而例外只有一个,写在 protocol.py:87-102:KnowledgeCapability。它把 exclusive_tools = True 写在类上,激活时用自己的工具 + ask_user 这个底线替换整个工具面。注意这里的措辞是「类上」——是否独占由类别归属决定,不是每个实例上的一个开关。
这个例外带出一个具体后果,源码里也标了对应 issue:owned_kbs()(protocol.py:104-113)用来把本能力独占的知识库从 rag 这一面里排除掉。原因是当一个独占型能力接管了整轮,用户同时选中的其它普通知识库如果不做处理,就会被静默丢弃——rag 面已经不在工具面里了。排除之后,那些不属于本能力的 KB 仍然可以走 rag(源码标注 issue #650)。
排查方向由此确定:如果你发现某一轮里「我明明选了两个知识库,只有一个起作用」,先去看这一轮是不是有独占型能力激活,而不是先怀疑检索本身。
还有一个可选的异步钩子 pre_loop(protocol.py:34-54):在答案循环的第一次 LLM 调用之前 await 一次,返回的 block 折进用户消息种子。它是可选的,没实现的能力不受影响。
四、反直觉的第二处:有一个能力,一个循环工具都不拥有
explore_context 这个能力不拥有任何答案循环工具,也不贡献系统 block,它只通过 pre_loop 钩子工作;read_source 这个工具只存在于它的前置循环里(deeptutor/capabilities/explore_context/capability.py:25-28)。
也就是说,按第二节那张表去衡量,它的 owned_tools 是空的、system_block 不产出——一个「什么都不注册」的能力。它做的全部事情发生在答案循环开始之前。
它存在的理由被直接写进了 docstring(explore_context/capability.py:13-23),两条:其一,聊天循环把「理解材料」和「回答用户」混在一起时,模型读到另一个 AI 的 ## Assistant 轮次,会采用对方的第一人称口吻;其二,弱模型经常根本不主动调 read_source。
这段 docstring 值得单独拎出来,是因为它把一个架构决策的动机写成了可读的文字,而不是留给读代码的人去猜。至于这个前置循环实际改善了什么,explorer.py 那 531 行我们没有读,也没有运行过这个项目,不作任何效果判断。
五、五个能力各自的工具
| 能力 | 工具数 | 工具名 | 出处 |
|---|---|---|---|
| mastery | 5 | mastery_status / mastery_quiz / mastery_grade / mastery_assess / mastery_build | mastery/tools.py:61-67 |
| obsidian | 9 | obsidian_search / _read / _list / _backlinks / _links / _tags / _create_note / _append / _set_property | obsidian/tools.py:26-36 |
| solve | 3 | solve_plan / solve_finish_step / solve_replan | solve/tools.py:26-30 |
| subagent | 1 | consult_subagent | subagent/tools.py:34 |
| explore_context | 0 | —(只有前置循环里的 read_source) | explore_context/capability.py:25-28 |
这张表最该读的是分布本身:从 9 到 0。obsidian 是典型的「一堆细粒度读写工具」,subagent 只有一个入口,explore_context 是零。工具数量多少不代表哪个能力更重要,它反映的是这个能力和模型之间的交互形状——是让模型自己反复挑动作,还是只给它一个口子。
几个各自的常量,都能在源码里直接核到:
- solve 的会话常量:
DEFAULT_MAX_REPLANS = 2、_MAX_STEPS = 12(步骤列表按这个数被截断)、_MAX_SESSIONS = 256(solve/session.py:21-22,50,80)。 - subagent 的
_FINISH_HEADROOM = 2(subagent/capability.py:29),是在 consult 预算之外,留给「写最终答案」的轮次余量。这个名字本身说明了一件事:预算不是一路花到零,得给收尾留位置。
关于 consult_subagent 需要如实补一句:它的另一端是一个外部连接的 agent。知识库类型里对应的是 SUBAGENT_KB_TYPE = "subagent"——指向一个连接的 agent,磁盘上没有路径,没有可索引可检索的内容(deeptutor/knowledge/kb_types.py:63)。DeepTutor 会去驱动你本机已装的 agent CLI,这部分实现在 services 一侧,我们另有一篇专门讲;在本文的语境里你只需要记住:这条链的另一端不是仓库内的一次纯函数调用,涉及本机执行环境,是否启用请自行评估。
六、mastery 这条线:服务端握着题目,模型不许改
mastery 是 5 个能力里代码量最大的一个(mastery/tools.py 721 行),也是第一节说的两处重合之一——另一处是 solve:七项顶层能力里,只有 deep_solve 与 mastery_path 的实现类落在 deeptutor/capabilities/ 下(builtin_capabilities.py:4-10)。换句话说,读 mastery 这条线,你会同时踩到两份名单。它的 manifest 写在 mastery/capability.py:58-67:name="mastery_path",stages=["responding"],cli_aliases=["mastery"],tools_used 是 5 个 mastery 工具再加 rag / read_source / ask_user。
这一层有两处「服务端不放手」的设计:
其一,路径 id 由服务端注入。 _mastery_path_id 是服务端注入的,模型永远不提供;而且每次调用新建 store 与 service,以避免并发共享对象(mastery/tools.py:10-13)。它的取值顺序在 mastery/capability.py:26-54:显式 mastery_path_id → 第一个 book reference → session_id,非 [A-Za-z0-9_-] 的字符统一替换成 _。
其二,题目的展示数据由服务端替换。 mastery loop 在 ask_user 调用时,用服务端持久化的挂起问题替换模型自拟的展示数据,防止模型改题号或重排 A/B/C(mastery/loop.py:16-43)。
这两处放在一起看是同一个思路:凡是「后面要拿来比对的东西」,都不经模型往返。至于对应的学习状态怎么落盘、掌握度怎么算、复习怎么排期,都在 deeptutor/learning/ 下,我们另有专文,本文不展开。
系统提示词方面,mastery 只有 en / zh 两个文件,用 wc -l 数出来各 14 行。提示词原文里有两条硬要求(mastery/prompts/en/system.md:2,8):目标背后是一道 “HARD mastery gate”,未清关不得前进;test-out 不是静默跳过,必须过闸门并记录。
这里必须说明边界:以上闸门规则、题型处理与 id 注入顺序,都是这个项目在代码里做出的实现选择,不是经过验证的教学结论。本文只陈述这些规则写在哪一行、怎么运作,不评价它们在学习科学上的有效性,也不承诺任何学习效果。工具层的题型白名单顺带记一下:_QUESTION_TYPES = ("choice", "short", "open"),题库类型映射是 choice→choice、open→written、其余→short_answer(mastery/tools.py:69,93-99)。
七、三处文档与代码对不上的地方
按纪律,只陈述差异、标明两处位置,不推断原因,也不据此评价项目。
deeptutor/capabilities/__init__.py:3-5的 docstring 写「每个 loop capability 住在自己的子包下(solve、mastery)」,括号里给了两个;registry.py:13-19的LOOP_CAPABILITIES是 5 个,子包另有 obsidian / subagent / explore_context。AGENTS.md:69写所有能力汇聚到deeptutor/capabilities/_shared.py的emit_capability_result(),但该文件不存在;实际实现在deeptutor/agents/_shared/capability_result.py,引用点见deeptutor/agents/chat/agent_loop.py:35。AGENTS.md:71写提示词位于capabilities/prompts/{en,zh}/<name>.yaml,但deeptutor/capabilities/prompts这个目录不存在(ls报 No such file);capabilities 下的提示词是每个能力自带的<cap>/prompts/{en,zh}/,例如deeptutor/capabilities/mastery/prompts/en/system.md。
以我们实读的仓库状态为准。说完就停。
八、你可以照着核的五步
- 打开
deeptutor/capabilities/registry.py:13-19与deeptutor/runtime/bootstrap/builtin_capabilities.py:3-11,把 5 项和 7 项两份名单并排放着,确认只有deep_solve与mastery_path落在capabilities/下。 - 打开
deeptutor/capabilities/protocol.py:22-32,确认「复用完整工具面 + 只加不减」这条约定是写在协议 docstring 里的,不是推断出来的。 - 打开
protocol.py:87-102与104-113,确认exclusive_tools = True写在KnowledgeCapability类上,以及owned_kbs()排除逻辑的注释里引用的 issue 编号。 - 打开
deeptutor/capabilities/explore_context/capability.py:13-28,确认这个能力的owned_tools为空、只走pre_loop,并把那段解释动机的 docstring 读完。 - 在
deeptutor/capabilities/下逐个 grep 五个能力的工具名常量(mastery/tools.py:61-67、obsidian/tools.py:26-36、solve/tools.py:26-30、subagent/tools.py:34),和第五节那张表对一遍。
需要说明的核对边界:mastery/tools.py 721 行我们只读了前 110 行与工具名、参数名的 grep 结果,五个工具各自的返回结构与参数校验细节未逐行核实;explore_context/explorer.py(531 行)以及 obsidian / subagent / solve 三个 tools.py 的实现体我们没有读。本文出现的所有常量都是源码中的默认配置,不是运行结果的保证。
本文依据 DeepTutor 官方仓库(github.com/HKUDS/DeepTutor)的 README、AGENTS.md、
pyproject.toml 与 deeptutor/ 下的源码整理,核对日 2026-08-10,对应仓库快照 456f9c2(版本 1.5.11)。
本文内容为仓库源码与文档口径,我们没有安装、部署或运行过该项目,也没有调用过其中任何一个模型 API,
因此不涉及生成质量、响应速度与教学效果的任何描述。
参数与默认值随版本变动,请以仓库最新代码与 --help 的实际输出为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。