65456 行的 services 层:26 个子领域怎么分工
翻一个十几万行的 Python 项目,最没用的读法是从 README 的功能清单往下对。功能清单是按”用户能干什么”排的,目录是按”代码放哪儿方便”排的,两者根本不同构。更快的办法是先把每个目录有多少行数出来,看重心压在哪儿——重心所在的那一层,才是这个项目真正花了力气的地方。
DeepTutor 的重心是 deeptutor/services/。以下所有数字都对应我们采集的仓库快照:HEAD 456f9c2,采集日 2026-08-10,deeptutor/__version__.py 里的版本号是 1.5.11。你自己 clone 之后行数会漂,但目录名是稳的。
一、先把规模数出来
我们数的口径就是几条 find,你可以逐条复现:
# 文件总数与扩展名分布
find deeptutor/services -type f | sed 's/.*\.//' | sort | uniq -c
# services 的 Python 总行数
find deeptutor/services -name '*.py' -exec wc -l {} + | tail -1
# 含嵌套的目录总数
find deeptutor/services -mindepth 1 -type d | wc -l
数出来的结果:deeptutor/services/ 下共 323 个文件,其中 .py 304 个、.yaml 14 个、.md 3 个、.json 2 个。Python 代码合计 65456 行。同样口径下 deeptutor/ 全包是 151534 行,也就是说 services 一层占了约 43.2%。
目录结构上,一级子目录 26 个,含嵌套共 62 个目录。四成多的代码压在一层里,26 个平级子目录——这个形状本身就说明分工不是按调用层次切的,而是按子领域切的。
二、26 个子领域,按行数排开
下面这张表是本文的主表,其它篇不会重复它。左边是一级子目录的 .py 总行数与文件数,右边是一句话职责——绝大多数抄自各自 __init__.py 的模块 docstring,但 llm、session、search 三行是例外:这三个模块我们这次没有采到 docstring 原文,那三句是我们按行数排名时自己给的归纳标签,表里已逐行标出,别当成引文去仓库里搜。
| 子目录 | 行数 / 文件数 | 一句话职责(docstring 或我们的归纳) |
|---|---|---|
llm | 9263 / 36 | LLM 统一调用与 provider 接入(非 docstring 原文) |
rag | 7539 / 52 | RAG 管线导出 |
session | 6677 / 10 | 会话与回合运行时(非 docstring 原文) |
memory | 6332 / 28 | 三层记忆:L1 trace / L2-L3 document / ops / paths / ids / store / consolidator |
config | 4930 / 12 | 读写 data/user/settings 下运行期文件的配置助手 |
subagent | 3638 / 17 | 驱动用户本机 agent CLI 作为子代理 |
partners | 3159 / 8 | partner 的生命周期、runtime、workspace、sessions |
parsing | 2863 / 29 | 可插拔引擎的文档解析层,一个 IR ParsedDocument + 内容寻址缓存 |
mcp | 2774 / 12 | 部署级 MCP 服务器注册表 + 延迟工具适配器 |
skill | 2487 / 5 | 按需通过 read_skill 加载的能力 skill 包 |
search | 2431 / 15 | 网络搜索(非 docstring 原文) |
embedding | 2312 / 13 | 统一 embedding 客户端与适配器 |
codex_auth | 2064 / 7 | 独立的 OpenAI Codex OAuth 支持 |
cli_apps | 1798 / 9 | 管理员安装、chat agent 调用的命令行工具 |
sandbox | 1195 / 9 | 沙箱 shell 执行,可插拔隔离后端 + 按用户配额 |
voice | 715 / 5 | TTS 与 STT |
cron | 633 / 3 | 内置 cron,为 chat 与 partner 提供定时任务 |
notebook | 465 / 2 | — |
persona | 390 / 2 | chat 的行为/语气预设 |
prompt | 370 / 3 | — |
videogen | 355 / 5 | 文生视频,配置走同一个模型 catalog |
imagegen | 350 / 6 | 文生图,配置走同一个模型 catalog |
setup | 322 / 2 | — |
storage | 274 / 2 | 会话工件的可插拔存储后端,目前只暴露 attachment_store |
model_selection | 197 / 3 | — |
settings | 113 / 2 | — |
这张表怎么读,比表本身重要。有三点值得单独拎出来:
其一,行数与文件数的比值差得很远。 rag 是 7539 行摊在 52 个文件里,session 是 6677 行只有 10 个文件——其中 session/turn_runtime.py 一个文件就 2141 行,session/sqlite_store.py 1902 行。前者是”多引擎并列,每个都薄”,后者是”少数几个厚文件扛主要状态”。你要改哪一块,翻代码的姿势完全不同。
其二,排在前六的六个目录合计已经超过全层的一半。 llm、rag、session、memory、config、subagent 六个加起来 38379 行。想快速摸清这个项目,从这六个入手即可,剩下二十个大多是几百行的适配层。
其三,有五个目录的 docstring 我们没有取到一句话职责——表里标 — 的正好是五行:notebook、prompt、setup、model_selection、settings,你可以自己对着表数一遍。这不代表它们没写,只代表我们这次核对没有采到,别把空格当成”这个模块没有职责”。
三、根目录只有 7 个文件,但收口点在这儿
deeptutor/services/ 根目录下只有 7 个 .py,合计 1810 行:
| 文件 | 行数 |
|---|---|
provider_registry.py | 551 |
path_service.py | 462 |
auth.py | 378 |
pocketbase_client.py | 196 |
__init__.py | 86 |
generation_http.py | 74 |
file_io.py | 63 |
这里有一处很容易看错的位置关系:LLM provider 元数据的单一真源不在 llm/ 目录里,而在根目录的 provider_registry.py。这个文件的头注释写的是 “Single source of truth for provider metadata”,还补了一句 “Order matters — it controls match priority and fallback. Gateways first.”(provider_registry.py:3-7)。
而 llm/provider_registry.py 只有 3 行,内容是对 deeptutor.services.provider_registry 的兼容再导出(llm/provider_registry.py:1-3)。也就是说,你按直觉在 llm/ 目录里找到的那个同名文件,是个转发壳子;真正要改的表在上一级。这类”同名文件一厚一薄”的布局,靠肉眼扫目录树是发现不了的,得 wc -l 一遍才看得出来。
另外两个根文件里,path_service.py 的 docstring 直接画了一棵目录树,说明 data/user 运行期存储布局由它集中管理(path_service.py:2-23)。要搞清楚”这个项目到底往我机器上写什么”,那 22 行 docstring 比翻 README 快得多。
四、这一层的反直觉:它不在依赖图最底下
按大多数人的心智模型,叫 services 的那一层应该是底座:上面的 api / agents 调它,它自己不回头依赖任何人。DeepTutor 这一层不是这样,而且它自己在代码里留了两处痕迹。
痕迹一:__init__.py 用 __getattr__ 懒加载。 services/__init__.py:43-86 用模块级 __getattr__ 懒加载 llm / prompt / search / setup / session / config / rag / embedding 八个子模块,注释里明写目的是”避免循环导入”。一个真正处在底部的层,不需要为循环导入做这种规避。
痕迹二:跨层引用计数是双向的。 我们用 grep -rho 'from deeptutor\.services\.[a-z_]*' 这类计数做了两个方向的统计:
- 谁在调 services:
api201 处、agents73 处、tools46 处、core12 处;config与cli两个目录是 0 处。 - services 在调谁:内部自引用 503 处;向外则依次是
multi_user45、runtime18、core17、partners15、utils10、agents4,logging/knowledge/book各 2,events/config/api各 1。
core 这一行尤其能说明问题:core 引用 services 12 处,services 反过来引用 core 17 处,两个方向都不是零。
services 反向 import api / agents 的地方只有 5 处,而且全部是函数内的延迟导入:partners/commands.py:9、partners/runtime.py:472、session/context_builder.py:10、session/turn_runtime.py:792、session/turn_runtime.py:1213。
这里要说清核对的边界:这 5 处里,我们明确记到导入目标的只有 turn_runtime.py:792 一处,它导入的是 from deeptutor.api.routers.settings import get_enabled_optional_tools;其余四处我们只落到了文件与行号,没有逐一核对各自导入的到底是 api 还是 agents 下的哪个对象。你要用这几行做判断,请自己打开看,别照着本文的顺序当成”都是指向 api”。
即便如此,把 import 塞进函数体这个写法本身就有信息量——它是打破循环依赖的常见做法;这 5 处加上那个 __getattr__,构成了这一层的实际形状:它是横向的子领域集合,不是纵向的底座。
这件事对你有两个具体影响。一是别指望能把 services 单独摘出来复用:这 5 处反向引用是实打实存在的,再加上 __getattr__ 那八个模块的懒加载,说明这一层与 api / agents 之间存在双向牵连,不是把一个子目录拷出来就能独立跑起来的东西——具体牵连到什么程度,取决于你摘的是哪一块,得逐处去看。二是排查启动期报错时,循环导入是一个真实可能性:这层已经用懒加载压住了一批,你如果新加一个模块级 import,压住的东西可能会翻出来。
cli 目录 0 处引用 services 也值得记一笔:命令行那一侧不是直接调 services 的。它走的是哪条路,我们这次没有核,不做推断。
五、目录数不等于”在跑的分工数”
26 个一级子目录之下,还有一块嵌套子目录是我们能确认”在生产代码里没有被引用”的:deeptutor/services/llm/providers/。注意它的层级——它挂在 llm 里面,属于”含嵌套共 62 个目录”里的一个,并不在第二节那张 26 行表上,你按那张表去找是找不到的。这个子包 4 个文件、896 行(base_provider.py 204、routing.py 264、anthropic.py 258、open_ai.py 170)。
全仓 grep -rn 'services\.llm\.providers' . --include=*.py 只命中 2 处,都在 tests/ 下(tests/services/llm/test_base_provider.py:7、tests/services/llm/test_routing_provider.py:7);deeptutor/services/llm/*.py 里也没有 from .providers 之类的相对导入。这个子包还自带一套独立的 register_provider 注册表(llm/registry.py),与根目录那份 provider_registry.py 并存。
它自己怎么说的?llm/providers/routing.py:1-9 的模块 docstring 写着:这个 provider 委托给已有的函数式 provider,存在的目的是在把调用点逐步迁移到 provider 对象的同时保持公开 API 稳定。相邻的 llm/__init__.py:106 也把 LLMClient / get_llm_client / reset_llm_client 标注为 “Client (legacy, prefer factory functions)”。
按纪律,我们只陈述差异、标明位置:一套并存的注册表实现,生产代码零引用、测试有引用,文档里自述为过渡性桥接。 以我们实读的仓库状态为准,说完就停,不推断原因,也不据此评价项目。
另一处对照口径也放在这儿:AGENTS.md:108 的 Key Files 表里,唯一列出的 services 文件是 deeptutor/services/config/runtime_settings.py,说明写的是 “JSON settings + process-env overrides”。一层 65456 行,文档指路只指了一个文件——这不是文档的错,只是提醒你:指路文档的粒度和这层的实际粒度差着两个量级,别拿它当地图用。
六、这层里有几块会动你本机的东西
这一点必须如实说明,不能藏在架构讨论里带过。services 的 26 个子领域中,至少三块会在你的机器上执行本项目之外的程序:
subagent(3638 行):模块 docstring 自述是”驱动用户本机 agent CLI 作为子代理”。后端注册表共 7 个:ClaudeCodeBackend、CodexBackend、GeminiBackend、KimiBackend、OpencodeBackend、MimoBackend、PartnerBackend(subagent/registry.py:24-35)。subagent/registry.py:47-57记录只有local_cli=True的后端会被探测,partner 后端被排除在外。也就是说,它会去找你已经装在本机的那些 agent 命令行工具并调起来。cli_apps(1798 行):docstring 写的是”管理员安装、chat agent 调用的命令行工具”。cli_apps/vendor/catalog.json里有 101 个 app,meta段标注source: https://github.com/HKUDS/CLI-Anything、pinned commitbc536c9…、commit_date: 2026-07-09,并写明 “Generated snapshot; do not hand-edit.”。安装动作有超时约束:cli_apps/installer.py:60的安装超时是 900 秒,可被DEEPTUTOR_CLI_APP_INSTALL_TIMEOUT_S覆盖。sandbox(1195 行):三种隔离后端并列——RunnerSidecarBackend(SYSTEM 级,走 runner 容器 HTTP)、BwrapBackend(SYSTEM 级,Linux bare-metalbwrap)、RestrictedSubprocessBackend(APPLICATION 级,降级 fallback)。第三种的注释原文写的是 “admin-opt-in only because it does not OS-isolate”(sandbox/backends.py:1-15、:34-50)。这句话是仓库自己写的,我们照抄,不做加工。
把这三块放一起看:**这个项目不只是”调 API 生成内容”,它有明确的、写在源码里的本机执行能力。**要不要开、开哪几个后端,得结合你自己机器上的环境和数据敏感度判断。上面这些默认值都是源码里的默认配置,不是对实际行为的保证。
七、你可以照着核的五步
- 在仓库根跑第一节那三条
find,确认 323 个文件、65456 行、62 个目录这三个数——数字对不上说明你的 HEAD 已经不是456f9c2了,后面所有行号都要重新对。 wc -l deeptutor/services/*.py,确认根目录只有 7 个文件、1810 行,并注意provider_registry.py是 551 行。- 打开
deeptutor/services/llm/provider_registry.py,确认它只有 3 行、是再导出;再回根目录那份看:3-7的 “Single source of truth” 注释。 - 打开
services/__init__.py:43-86,确认__getattr__懒加载的是哪八个子模块,以及注释里”避免循环导入”那句。 grep -rn 'from deeptutor.services' deeptutor/api | wc -l与... deeptutor/core | wc -l各跑一次,再反过来在 services 里 grepfrom deeptutor.core,亲眼看一遍两个方向都不是零。
八、本文的边界
需要说清楚没覆盖到什么。我们没有逐行读完这一层任何一个千行以上的文件——session/turn_runtime.py、session/sqlite_store.py、partners/manager.py、config/provider_runtime.py、config/runtime_settings.py、skill/service.py、skill/hub.py 都只读了 docstring、类与函数签名和常量段。memory/consolidator/ 的四个 mode 与其下 YAML 提示词只看了文件名与目录结构,没有读内容。partners、voice、imagegen、videogen、notebook、persona、setup、model_selection、settings 以及根目录的 auth.py、pocketbase_client.py 都只取了模块 docstring,内部实现未核实。各子领域内部的具体机制(LLM 接入、重试与超时、检索管线、解析引擎、三层记忆、subagent 细节)我们另有专门的篇目在讲,本文只到分工这一层为止。
还有一条通用的:本文中所有”支持 X”的说法,一律指源码里存在对应的代码路径与常量,不代表这项能力的实际可用性已被验证。
本文依据 DeepTutor 官方仓库(github.com/HKUDS/DeepTutor)的 README、AGENTS.md、
pyproject.toml 与 deeptutor/ 下的源码整理,核对日 2026-08-10,对应仓库快照 456f9c2(版本 1.5.11)。
本文内容为仓库源码与文档口径,我们没有安装、部署或运行过该项目,也没有调用过其中任何一个模型 API,
因此不涉及生成质量、响应速度与教学效果的任何描述。
参数与默认值随版本变动,请以仓库最新代码与 --help 的实际输出为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。