agents 层的划分:三块主线与 8 个子包
读多 agent 项目的目录结构,最容易犯的错是照着包 docstring 画架构图。DeepTutor 的 deeptutor/agents/ 就是个现成例子:docstring 自述这一层分成三块,磁盘上却躺着 8 个子包,而真正跑 agent 循环的那部分代码压根不在这个目录里。
本文所有数字与行号对应我们采集的仓库快照:HEAD 456f9c2,采集日 2026-08-10,deeptutor/__version__.py 里的版本号是 1.5.11。行号会随版本漂移,但目录名、类名与统计口径是稳的,照着自己 clone 的那份数一遍即可。
一、先把这一层数出来
我们用的口径是 find deeptutor/agents -type f | wc -l 与 find deeptutor/agents -type f -exec wc -l {} + | tail -1,结果是 102 个文件、合计 20594 行。
这 102 个文件里:
.py63 个,合计 17498 行;.yaml38 个,全是提示词模板;- 另有 1 个
.md,是deeptutor/agents/vision_solver/prompts/geogebra.md(85 行)。
顶层极其干净:ls deeptutor/agents/*.py 只有两个文件,__init__.py(36 行)与 base_agent.py(769 行)。也就是说,这一层没有中间抽象层,子包里的 agent 直接继承顶层那一个基类(基类内部怎么工作,我们另有一篇专门讲)。
八个子包的规模如下,同样用 find <子目录> -type f | wc -l 与 -exec wc -l {} + | tail -1 得到:
| 子包 | 文件数 | 行数 |
|---|---|---|
research | 11 | 5830 |
chat | 13 | 4197 |
question | 17 | 4112 |
math_animator | 28 | 2327 |
visualize | 17 | 1984 |
notebook | 7 | 678 |
_shared | 4 | 377 |
vision_solver | 3 | 284 |
这张表值得你自己复算一遍,因为它能当校验和用:八个子包 100 个文件加顶层两个 py 正好是 102;八个子包 19789 行加上 __init__.py 的 36 行与 base_agent.py 的 769 行正好是 20594。数出来对不上,说明你手上的版本和我们采集的这一版不是同一个,后面的行号也别照抄。
再往下钻一层,最大的五个文件是 research/pipeline.py 2871 行、question/pipeline.py 2161 行、chat/agentic_pipeline.py 1563 行、research/utils/citation_manager.py 866 行、chat/agent_loop.py 864 行(find ... -exec wc -l {} + | sort -rn | head)。这五个文件加起来 8325 行,占了整层 20594 行的四成上下——重量集中在三个 pipeline 上,这是这一层最直观的形状。
二、docstring 说三块,磁盘上是八个
deeptutor/agents/__init__.py:1-21 的包 docstring 把模块划分写成 research / question / chat 三块,并额外说明 co_writer 和 book 是 deeptutor/ 下的独立顶层模块、不属于 deeptutor.agents 包。
而 find deeptutor/agents -type d 数出来是 8 个子包:_shared、chat、math_animator、notebook、question、research、vision_solver、visualize。
两处不一致,位置都很好定位:一处是包 docstring,一处是目录列表。按我们的纪律,只陈述差异、以实读的仓库状态为准,不推断原因、也不据此评价什么。
对读代码的人来说,有用的是把多出来的五个归位。按各自文件与类的 docstring 自述:math_animator 和 visualize 是产出侧(前者串联概念分析、设计、代码生成到渲染,后者是三阶段的可视化生成流程);vision_solver 是把一张数学题图片变成 GeoGebra 命令的单次视觉调用;notebook 是笔记记录的分析与摘要;_shared 则完全不是 pipeline——它的 docstring 写明放的是”跨 pipeline 的策略”,既不属于 capability-agnostic 的 core.agentic 引擎,也不属于任何单个 pipeline(deeptutor/agents/_shared/__init__.py:1-7)。
所以更贴近磁盘的读法是三层:三条主线 pipeline(research / question / chat)+ 四个专用产出或辅助模块 + 一个横切策略包。docstring 里的”三块”说的是主线,不是全部。
顺带一处同样可核实的差异:同一段 docstring 在 deeptutor/agents/__init__.py:5 写 “research: Deep research agents (DecomposeAgent, ResearchAgent, etc.)”,而 grep -rn "class DecomposeAgent\|class ResearchAgent" deeptutor/ 在全仓没有任何结果,find deeptutor/agents/research -type f 也显示 research 目录下没有 agents/ 子目录。说完就停。
三、__all__ 只导出三个名字
包的对外面还有一处容易被忽略:deeptutor/agents/__init__.py:25 的 __all__ 只有三个名字——BaseAgent、ChatAgent、SessionManager,而且通过 __getattr__ 懒加载(__init__.py:28-36)。
这意味着 8 个子包里绝大多数东西都不从包顶层导出,要用就得写全路径导入。这个设计的直接后果是:你不能靠 from deeptutor.agents import * 或者看 __all__ 来推断这一层有什么能力,那份清单只有三项,和实际的 11 个 agent 类、五个 pipeline 完全不是一回事。想知道有什么,只能去数目录。
四、反直觉的那一处:agents 层里没有 agent 循环
如果只看目录名,你会默认 deeptutor/agents/ 里装着”agent 怎么循环、工具怎么分发”的引擎。实际不是。
按事实卡记录的位置,工具分发与循环实现在 deeptutor/core/agentic/:8 个 py 文件、合计 2681 行,导出的关键符号是 dispatch_tool_calls、run_agentic_loop(core/agentic/loop.py:173)、run_labeled_step(core/agentic/labeled_step.py:104)与 LabelProtocol(core/agentic/loop.py:40)。单轮并行工具调用上限 MAX_PARALLEL_TOOL_CALLS = 8 也定义在那边(deeptutor/core/agentic/tool_dispatch.py:47),chat / question / research 三个 pipeline 都引用它。
_shared 的 docstring 之所以要专门说明自己”既不属于 core.agentic 引擎,也不属于单个 pipeline”,正是因为存在这条边界。把这三者摆在一起,这一层的职责分工就清楚了:
core/agentic/:与具体能力无关的循环与工具分发引擎,2681 行;agents/_shared/:跨 pipeline 的策略,377 行,其中tool_composition.py占 259 行,负责统一”哪些工具归用户开关、哪些归 pipeline 自动挂载”(这块的细则我们另有一篇专门讲);agents/<模块>/:具体某条主线的阶段编排与提示词。
所以要看”循环怎么转、一轮里工具怎么分发出去”,从 deeptutor/agents/ 往下翻是找不到的,得跨到 deeptutor/core/agentic/ 去;而”某个工具压根没被挂上来”则要回头看 agents/_shared/tool_composition.py 里的条件挂载表与 suppressed 移除清单。这两处分工是这一层最反直觉、也最容易浪费时间的地方。
需要如实说明一点:可条件挂载的工具里包含 exec(tools/exec_tool.py:57,挂载条件是 has_exec,见 _shared/tool_composition.py:46-57)。这类工具意味着系统在具备条件时会在你本机执行外部程序,不是纯文本往返。是否开启请结合自身环境判断,我们没有运行过,不对其行为下任何结论。
五、真正的对外单位是 capability,不是子包
数完目录之后还有一步:子包不等于对外能力。每个模块有一层 capability 壳,manifest 里写着阶段名、用到的工具与 CLI 别名。我们核到的有五个:
| capability | 定义位置 | stages | tools_used | 别名 |
|---|---|---|---|---|
ChatCapability | chat/capability.py:12 | 2 个 | 见源码 | chat |
DeepResearchCapability | research/capability.py:37 | 4 个 | rag / web_search / paper_search / code_execution | research |
DeepQuestionCapability | question/capability.py:28 | 2 个 | rag / web_search / code_execution | quiz |
VisualizeCapability | visualize/capability.py:50 | 9 个 | 空 | visualize、viz |
MathAnimatorCapability | math_animator/capability.py:19 | 6 个 | 空 | animate |
这张表怎么读:tools_used 为空的两个正好是产出侧模块——visualize 与 math_animator 的 manifest 里 tools_used 是空列表,没有声明任何检索或外呼类工具;它们的重量在生成与渲染那条链上(math_animator 侧还要经 ManimRenderService 走 Manim 渲染,math_animator/renderer.py:35)。运行时到底会不会走别的路径,我们没有运行过,不下结论。三条主线的 manifest 则各自声明了工具集合。
另外 VisualizeCapability 的 9 个 stage 是五个 capability 里最长的:除 analyzing / generating / reviewing 外,还有 concept_analysis / concept_design / code_generation / code_retry / summary / render_output 这 6 个产出侧阶段名(visualize/capability.py:33-60)。这里只照抄名单,不推断它们从哪来。
notebook 与 vision_solver 这两个子包我们没有核到对应的 capability 类,这里不下结论,你需要自己去对应目录里找 capability.py 确认。
阶段名与别名之外,五个 capability 最终统一走 emit_capability_result()(_shared/capability_result.py:24),它把用量摘要合并进 payload["metadata"]["cost_summary"] 再交给 stream(_shared/capability_result.py:31-46)。这个函数所在的位置也有一处文档差异:AGENTS.md:72-74 写它在 deeptutor/capabilities/_shared.py,但该文件不存在(ls 报 No such file),grep -rn "def emit_capability_result" deeptutor/ 全仓只有 deeptutor/agents/_shared/capability_result.py:24 这一处。以实读为准。
有一句话必须说清楚:上面这些阶段划分、轮数预算、工具白名单,都是这个项目的实现选择,写在源码常量里。DeepTutor 是教育类项目,但这些划分本身不是经过验证的教学结论,本文只描述参数写在哪一行、怎么组织,不评价其在学习科学上的有效性。
六、类清单的计数口径(别直接信 grep 行数)
想统计”这一层到底有多少个 agent”,最顺手的命令是 grep -rn "class .*(BaseAgent):" deeptutor/agents --include="*.py"。它会返回 12 行,但正确答案是 11 个——多出来的那一行是 deeptutor/agents/__init__.py:18 docstring 里的一段示例代码,不是真实的类定义,统计时要扣掉。
同样口径扩到全仓是 18 行减 1,即 17 个 BaseAgent 子类;deeptutor/agents/ 之外的 6 个分别在 deeptutor/book/agents/(IdeationAgent、SourceExplorer、SpineAgent、SpineSynthesizer)、deeptutor/co_writer/edit_agent.py(EditAgent)与 deeptutor/services/session/context_builder.py(_ContextSummaryAgent)。
另外还有两个类名带 Agent 但不继承基类的旁路,在 notebook 子包里;这个例外我们在讲基类那篇里展开过,做全局统计时记得别漏。
question/agents/__init__.py:1-7 的 docstring 还记录了一次收敛:目前只剩独立的单次调用 FollowupAgent,per-question / per-batch 的 idea_agent、generator 已在 Phase A→C 重构中被单一的 question.pipeline 模块取代。这解释了为什么 question 有 4112 行却只有一个 agent 类——重量都压在那个 2161 行的 pipeline 里。
七、你可以照着核的六步
- 跑一遍
find deeptutor/agents -type f | wc -l与... -exec wc -l {} + | tail -1,看是不是 102 / 20594;对不上就说明版本不同,后面的行号别照抄。 - 用上面那张子包表做校验和:八个子包文件数加 2、行数加 805(36 + 769),应当分别等于 102 与 20594。
- 打开
deeptutor/agents/__init__.py,把:1-21的 docstring 划分与find deeptutor/agents -type d的输出并排比一遍,自己确认三块与八个子包的差异。 - 看
__init__.py:25的__all__,确认只有三个名字,并意识到它不能用来推断这一层的能力清单。 - 跑
grep -rn "class .*(BaseAgent):" deeptutor/agents --include="*.py",数到 12 行时记得扣掉__init__.py:18那行 docstring 示例。 - 跨到
deeptutor/core/agentic/目录,确认run_agentic_loop与MAX_PARALLEL_TOOL_CALLS在那边而不是 agents 层。
边界说明:本文只做了目录、行数、类定义位置与 capability manifest 层面的核对,research/pipeline.py、question/pipeline.py、chat/agentic_pipeline.py 三个大文件的主体逻辑,以及 deeptutor/core/agentic/ 的循环内部实现,我们都没有逐行读;38 个提示词 yaml 的正文内容一条也没读,只统计了文件数。凡涉及”跑起来会怎样”的部分,本文一律不下结论。
本文依据 DeepTutor 官方仓库(github.com/HKUDS/DeepTutor)的 README、AGENTS.md、
pyproject.toml 与 deeptutor/ 下的源码整理,核对日 2026-08-10,对应仓库快照 456f9c2(版本 1.5.11)。
本文内容为仓库源码与文档口径,我们没有安装、部署或运行过该项目,也没有调用过其中任何一个模型 API,
因此不涉及生成质量、响应速度与教学效果的任何描述。
参数与默认值随版本变动,请以仓库最新代码与 --help 的实际输出为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。