agents 层的划分:三块主线与 8 个子包

2026-08-10

读多 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 -lfind deeptutor/agents -type f -exec wc -l {} + | tail -1,结果是 102 个文件、合计 20594 行

这 102 个文件里:

  • .py 63 个,合计 17498 行;
  • .yaml 38 个,全是提示词模板;
  • 另有 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 得到:

子包文件数行数
research115830
chat134197
question174112
math_animator282327
visualize171984
notebook7678
_shared4377
vision_solver3284

这张表值得你自己复算一遍,因为它能当校验和用:八个子包 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_writerbookdeeptutor/ 下的独立顶层模块、不属于 deeptutor.agents 包。

find deeptutor/agents -type d 数出来是 8 个子包:_sharedchatmath_animatornotebookquestionresearchvision_solvervisualize

两处不一致,位置都很好定位:一处是包 docstring,一处是目录列表。按我们的纪律,只陈述差异、以实读的仓库状态为准,不推断原因、也不据此评价什么。

对读代码的人来说,有用的是把多出来的五个归位。按各自文件与类的 docstring 自述:math_animatorvisualize 是产出侧(前者串联概念分析、设计、代码生成到渲染,后者是三阶段的可视化生成流程);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__ 只有三个名字——BaseAgentChatAgentSessionManager,而且通过 __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_callsrun_agentic_loopcore/agentic/loop.py:173)、run_labeled_stepcore/agentic/labeled_step.py:104)与 LabelProtocolcore/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 移除清单。这两处分工是这一层最反直觉、也最容易浪费时间的地方。

需要如实说明一点:可条件挂载的工具里包含 exectools/exec_tool.py:57,挂载条件是 has_exec,见 _shared/tool_composition.py:46-57)。这类工具意味着系统在具备条件时会在你本机执行外部程序,不是纯文本往返。是否开启请结合自身环境判断,我们没有运行过,不对其行为下任何结论。

五、真正的对外单位是 capability,不是子包

数完目录之后还有一步:子包不等于对外能力。每个模块有一层 capability 壳,manifest 里写着阶段名、用到的工具与 CLI 别名。我们核到的有五个:

capability定义位置stagestools_used别名
ChatCapabilitychat/capability.py:122 个见源码chat
DeepResearchCapabilityresearch/capability.py:374 个rag / web_search / paper_search / code_executionresearch
DeepQuestionCapabilityquestion/capability.py:282 个rag / web_search / code_executionquiz
VisualizeCapabilityvisualize/capability.py:509 个visualizeviz
MathAnimatorCapabilitymath_animator/capability.py:196 个animate

这张表怎么读:tools_used 为空的两个正好是产出侧模块——visualizemath_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)。这里只照抄名单,不推断它们从哪来。

notebookvision_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/IdeationAgentSourceExplorerSpineAgentSpineSynthesizer)、deeptutor/co_writer/edit_agent.pyEditAgent)与 deeptutor/services/session/context_builder.py_ContextSummaryAgent)。

另外还有两个类名带 Agent 但不继承基类的旁路,在 notebook 子包里;这个例外我们在讲基类那篇里展开过,做全局统计时记得别漏。

question/agents/__init__.py:1-7 的 docstring 还记录了一次收敛:目前只剩独立的单次调用 FollowupAgent,per-question / per-batch 的 idea_agentgenerator 已在 Phase A→C 重构中被单一的 question.pipeline 模块取代。这解释了为什么 question 有 4112 行却只有一个 agent 类——重量都压在那个 2161 行的 pipeline 里。

七、你可以照着核的六步

  1. 跑一遍 find deeptutor/agents -type f | wc -l... -exec wc -l {} + | tail -1,看是不是 102 / 20594;对不上就说明版本不同,后面的行号别照抄。
  2. 用上面那张子包表做校验和:八个子包文件数加 2、行数加 805(36 + 769),应当分别等于 102 与 20594。
  3. 打开 deeptutor/agents/__init__.py,把 :1-21 的 docstring 划分与 find deeptutor/agents -type d 的输出并排比一遍,自己确认三块与八个子包的差异。
  4. __init__.py:25__all__,确认只有三个名字,并意识到它不能用来推断这一层的能力清单。
  5. grep -rn "class .*(BaseAgent):" deeptutor/agents --include="*.py",数到 12 行时记得扣掉 __init__.py:18 那行 docstring 示例。
  6. 跨到 deeptutor/core/agentic/ 目录,确认 run_agentic_loopMAX_PARALLEL_TOOL_CALLS 在那边而不是 agents 层。

边界说明:本文只做了目录、行数、类定义位置与 capability manifest 层面的核对,research/pipeline.pyquestion/pipeline.pychat/agentic_pipeline.py 三个大文件的主体逻辑,以及 deeptutor/core/agentic/ 的循环内部实现,我们都没有逐行读;38 个提示词 yaml 的正文内容一条也没读,只统计了文件数。凡涉及”跑起来会怎样”的部分,本文一律不下结论。


本文依据 DeepTutor 官方仓库(github.com/HKUDS/DeepTutor)的 README、AGENTS.mdpyproject.tomldeeptutor/ 下的源码整理,核对日 2026-08-10,对应仓库快照 456f9c2(版本 1.5.11)。 本文内容为仓库源码与文档口径,我们没有安装、部署或运行过该项目,也没有调用过其中任何一个模型 API, 因此不涉及生成质量、响应速度与教学效果的任何描述。 参数与默认值随版本变动,请以仓库最新代码与 --help 的实际输出为准。 安全相关做法请结合自身环境评估,本文不构成安全方案建议。

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。