起点:开发者为什么做这个东西
痛点与洞察:辅导不该是一堆孤立工具
1.1 先回答「为什么」
读 DeepTutor 源码之前,先读它的定位句(assets/README/README_CN.md:60-62):
DeepTutor 是一个智能体原生的学习工作区,将辅导、解题、测验生成、研究、可视化和掌握度练习整合在一个可扩展的系统中。
统一的运行时 — Chat、Quiz、Research、Visualize、Solve 和 Mastery Path 运行在同一个智能体循环上,切换的是目标,而非引擎,上下文始终随学习者流转。
这不是“又一个 ChatGPT 套皮”。出发点是:
- 痛点:现有 AI 学习产品把「聊天 / 出题 / 解题 / 做笔记 / 管知识库」拆成互不相通的工具;学习者每换一个模式就丢上下文,个性化无法跨任务累积。
- 洞察:真正的终身辅导需要的不是更多入口,而是 同一条 agent 运行时 + 一份可流转的学习上下文。
- 推论:产品形态必须是「工作区」,不是「单聊窗口」;架构形态必须是「插件化能力」,不是「每个功能写一套流水线」。
flowchart LR
P["痛点:学习工具各自孤立<br/>换模式就丢上下文"] --> I["洞察:辅导 = 同一大脑<br/>换目标不换引擎"]
I --> C["约束:一份 UnifiedContext<br/>+ 三层插件(Tool / Capability / LoopCapability)"]
C --> D["推论:多引擎知识 · 三层记忆 · 渐进披露工具<br/>Partners · Subagent · Skills · 沙箱"]
1.2 三个可验证的工程主张
| 主张 | 在代码里长什么样 |
|---|---|
| Agent-native | CLI / WebSocket / SDK 都进 ChatOrchestrator.handle(runtime/orchestrator.py:36-114);能力实现 BaseCapability.run(context, stream)(core/capability_protocol.py:33-60) |
| 切换目标不换引擎 | 顶层能力表只有 7 个名字,全部注册进同一 CapabilityRegistry(runtime/bootstrap/builtin_capabilities.py:3-11);另有 5 个 LoopCapability 直接挂在 chat 循环上(capabilities/registry.py:13-19) |
| 上下文随学习者流转 | UnifiedContext 一次 turn 携带 KB / 附件 / memory / persona / skills / 历史(core/context.py:34-84) |
1.3 它和本系列前二十家不是同类
本系列绝大多数项目回答「怎么让 AI 替我写代码 / 干任务」。DeepTutor 换了个问题:
怎么让 AI 长期陪我学——同一套脑子,跨聊天、做题、研究、可视化、掌握度练习,还记得我是谁。
唯一能对上「换轴」感觉的是 MiroFish(群体仿真)。DeepTutor 的轴是 教育生命周期,不是编码会话,也不是社会预演。
产品位与动机证据:README / AGENTS / 论文里的原话
2.1 动机证据表
| 证据 | 出处 | 它证明了什么 |
|---|---|---|
| 「Lifelong Personalized Tutoring」 | README 标题 | 产品轴心是终身个性化,不是单次问答 |
| 「One runtime for every mode … you switch the objective, not the engine」 | README.md:195 |
统一运行时是卖点第一句 |
| 「a two-layer plugin model — single-shot Tools … and multi-stage Capabilities」 | AGENTS.md:5-8 |
架构宪法写进给 Agent 看的说明书 |
「All capabilities emit on a shared StreamBus」 |
AGENTS.md:27-28 |
流式协议是横切基础设施 |
「Runtime settings live in data/user/settings/*.json — project-root .env files are intentionally ignored」 |
AGENTS.md:28-30 |
配置真源唯一,拒绝 .env 漂移 |
| 「Inspectable memory — L1 traces, L2 surface summaries, and L3 synthesis」 | README.md:200 |
记忆必须可审计、可编辑 |
| 「consult a live coding CLI … from any turn」 | README.md:197 |
编程 Agent 是被会诊的客体,不是宿主 |
| v1.4.0「every chat capability rebuilt on a single agentic engine」 | README Releases | 历史演进主动收敛到单引擎 |
| v1.4.5「a new loop-plugin framework」 | README Releases | 第三层插件(LoopCapability)是显式产品里程碑 |
| 论文 arXiv:2604.26962 | README News | 有学术设计陈述,不只是工程仓库 |
2.2 产品经理视角:它在卖什么体验
| 卖点 | 工程落点 |
|---|---|
| 一个工作区搞定学 / 练 / 研 / 画 | 7 个 Capability + 5 个 LoopCapability + BookEngine 并行 |
| 知识库可换引擎 | RAG factory 五种引擎(services/rag/factory.py:216-256) |
| 个性化看得见、能改 | Memory workbench + 整数脚注溯源(services/memory/document.py:1-30) |
| 手机微信也能问老师 | Partners 16 个 IM channel 自动发现(partners/channels/registry.py:18-26) |
| 需要写代码时请本地 Claude/Codex | Subagent 把 CLI 当「可会诊 KB」(capabilities/subagent/) |
| 装社区技能 / 装 101 个 CLI 工具 | Skills(ClawHub/EduHub)+ CLI Apps 快照(services/cli_apps/vendor/catalog.json) |
| 用自己的 ChatGPT 订阅 | Codex OAuth 逐账号登录(services/codex_auth/) |
🧠 一句话:别家在优化「工具调用多聪明」;DeepTutor 在优化「学习生命周期里,上下文到底能不能一直跟着人走」——并把这件事焊成 orchestrator + UnifiedContext + 三层插件。
仓库地图与技术栈:数字形状与依赖方向
3.1 顶层形状
DeepTutor/
├── deeptutor/ # Python 后端核心(151 534 行)
│ ├── runtime/ # Orchestrator · launcher · registry(含 deferred_tools)· bootstrap
│ ├── core/ # UnifiedContext · StreamBus · agentic loop · tool/capability 协议
│ ├── agents/ # chat / research / question / visualize / math_animator / notebook / vision_solver
│ ├── capabilities/ # solve · mastery · subagent · obsidian · explore_context(LoopCapability 层)
│ ├── services/ # llm · rag · memory · skill · partners · subagent · mcp · sandbox · cli_apps …
│ ├── partners/ # IM bus + 16 channels
│ ├── book/ # Living Book 独立引擎(并行于 Orchestrator)
│ ├── knowledge/ # KB 管理面
│ ├── learning/ # 掌握度引擎(mastery / 间隔重复算术)
│ ├── multi_user/ # 身份 · 授权 · 审计(14 模块)
│ ├── api/ # FastAPI 33 个 router + unified WebSocket
│ └── tools/ # 内置工具实现
├── deeptutor_cli/ # Typer CLI(5 147 行)
├── web/ # Next.js 16 前端(108 133 行 TS/TSX)
├── AGENTS.md / SKILL.md # Agent-native 说明书(给模型看的宪法)
└── pyproject.toml # 包名 deeptutor · 版本 1.5.11
按包体量排序(Python,约数):
| 包 | 约行数 | 角色 |
|---|---|---|
services/ |
65k | LLM / RAG / Memory / MCP / Skill / Sandbox / Subagent… |
agents/ |
17k | 各 Capability 的流水线实现 |
api/ |
16k | HTTP / WS 面 |
partners/ |
12k | IM 伴侣 |
book/ |
7.7k | 活书编译器 |
tools/ |
7.6k | 内置工具 |
learning/ |
4.7k | 掌握度算术 |
capabilities/ |
3.8k | LoopCapability 层 |
core/ + runtime/ |
7.4k | 协议与编排心脏 |
multi_user/ |
2.0k | 授权矩阵 |
3.2 技术栈一句话
- 后端:Python 3.11–3.13 · FastAPI · 自研 agentic loop · 36 provider 适配 · 5 种 RAG 引擎
- 前端:Next.js 16 · React 19;浏览器只连前端源,
/api/*与/ws/*由web/proxy.ts服务端转发(容器只需暴露 3782) - 入口:
deeptutorCLI(PyPI)· Web · Python SDK(DeepTutorApp)· Docker/GHCR - 配置:
data/user/settings/*.json,故意忽略项目根.env(AGENTS.md:28-30)
3.3 依赖方向(读代码时的指南针)
flowchart TB
CLI["deeptutor_cli"] --> ORCH["ChatOrchestrator"]
WS["api/unified_ws<br/>可重放 turn 协议"] --> ORCH
SDK["app.DeepTutorApp"] --> ORCH
PART["Partners runtime"] --> ORCH
ORCH --> CAPREG["CapabilityRegistry<br/>7 个顶层能力"]
ORCH --> TOOLREG["ToolRegistry"]
CAPREG --> CAP["BaseCapability.run"]
CAP --> LOOP["AgentLoop(chat)<br/>run_agentic_loop(深度)"]
LOOP --> LCAP["LOOP_CAPABILITIES<br/>mastery/solve/obsidian/subagent/explore"]
LOOP --> TOOLS["BaseTool + deferred loader"]
LOOP --> BUS["StreamBus"]
TOOLS --> SBX["Sandbox 三档隔离"]
TOOLS --> MCP["MCP manager"]
CAP --> CTX["UnifiedContext"]
CTX --> MEM["MemoryStore L1/L2/L3"]
CTX --> RAG["RAG pipelines ×5"]
CTX --> SK["SkillService"]
BOOK["BookEngine"] -.复用 StreamBus/Registry.-> BUS
MU["multi_user 授权"] --> CTX
BookEngine 刻意平行于 Orchestrator(book/__init__.py:5-9):复用 StreamBus / Registry,但不注册为 capability——它产出的是一本书,不是一轮流式回答。
思维导图:一条约束如何推出十几项设计
开发者思维导图:从「切换目标不换引擎」到十二项设计
mindmap
root((切换目标<br/>不换引擎))
统一入口
ChatOrchestrator
StreamBus 扇出
可重放 turn 协议
三层插件
L1 Tools 单次调用
L2 Capabilities 接管 turn
L3 LoopCapability 挂在 chat 循环
上下文决定工具面
ToolMountFlags 情境旗标
deferred tools + load_tools
KnowledgeCapability 独占表面
长期个性化
L1 JSONL 痕迹
L2 表面摘要
L3 跨表面综合
四种综合模式
知识可插拔
五种索引引擎
Obsidian / Subagent 当 KB
模型不可信之处
ExploreContext 客观前置
确定性骨架工具
Provider 能力位 + 降级
触达与安全
Partners 16 IM
沙箱三档隔离
多用户授权矩阵
后文逐一拆开的十二项设计:Orchestrator 单入口 · 三层插件 · UnifiedContext · Chat「无工具即收工」· Label 协议循环 · ToolMountFlags · deferred tools · Provider 兼容与 DSML 回退 · 三层 Memory · 多引擎知识 · 确定性骨架工具 · 可重放 turn + 授权矩阵。
三层插件宪法:Tools × Capabilities × LoopCapability
⚠️ 本章是第二轮回核修正重点:
AGENTS.md只讲了「两层插件」,但代码里真实存在第三层——LOOP_CAPABILITIES。而且 mastery / solve 同时是第二层与第三层公民,这个双身份是理解 v1.4 之后架构的钥匙。
5.1 动机 → 约束
如果每个学习模式各自写一套「搜 → 想 → 答」流水线,模式之间无法共享工具、记忆、流式 UI。约束是:
单次动作是 Tool;多阶段接管 turn 的是 Capability。(
AGENTS.md:5-8,core/capability_protocol.py:1-8)
5.2 被否方案
| 被否 | 为什么否 |
|---|---|
| 每个模式一个独立 Agent 类 + 独立主循环 | 工具、记忆、流式协议会复制五份 |
| 只有 Tools、没有 Capability | Deep Research / Math Animator 这种多阶段管线塞不进「一次函数调用」 |
| 只有硬编码流水线、模型不能选工具 | 丢掉 agent-native,无法挂 MCP / Skills / CLI apps |
5.3 选择:三层
Level 1 — Tools(core/tool_protocol.py:206 BaseTool)。内置工具在 tools/builtin/__init__.py:1562-1607 一张 BUILTIN_TOOL_TYPES 表里注册,再分成两类可见性:
| 类别 | 数量 | 名单 | 出处 |
|---|---|---|---|
用户可开关(/settings/tools) |
7 | brainstorm web_search paper_search reason geogebra_analysis imagegen videogen |
builtin/__init__.py:1627-1635 |
| 上下文自动挂载(用户视角「锁定开启」) | 15 | rag kb_files code_execution read_source read_memory write_memory read_skill list_notebook write_note web_fetch github exec load_tools cron ask_user |
builtin/__init__.py:1647-1663 |
| 能力独有(按能力激活挂载) | 5+3+9+1 | Mastery 5 / Solve 3 / Obsidian 9 / Subagent 1 | 各 capabilities/*/tools.py |
| Partner 专属(强制挂载,不可配置) | 3 | partner_read partner_memorize partner_search |
builtin/__init__.py:1601-1607 |
Level 2 — Capabilities(BaseCapability + CapabilityManifest),7 个,注册于 runtime/bootstrap/builtin_capabilities.py:3-11:
| 名字 | 阶段(manifest) |
|---|---|
chat |
exploring → responding |
mastery_path |
responding(Guided Learning) |
deep_solve |
planning → reasoning → writing |
deep_question |
ideation → generation |
deep_research |
rephrasing → decomposing → researching → reporting |
visualize |
analyzing → generating → reviewing |
math_animator |
concept_analysis → … → render_output |
Level 3 — LoopCapability,5 个,注册于 capabilities/registry.py:13-19:
LOOP_CAPABILITIES: tuple[LoopCapability, ...] = (
MasteryLoopCapability(),
SolveLoopCapability(),
ObsidianCapability(),
SubagentCapability(),
ExploreContextCapability(),
)
每轮由 active_loop_capabilities(context) 按稳定注册顺序筛出激活项(registry.py:22-25)。
5.4 两种截然不同的插件语义
capabilities/protocol.py 把第三层再分成两类,差别在「加法还是替换」:
| 类别 | 工具面语义 | 成员 | 出处 |
|---|---|---|---|
LoopCapability(加法) |
复用完整 chat 工具面,只把 owned_tools 加上去;绝不削减用户的 composer 开关 |
Mastery / Solve / ExploreContext | protocol.py:19-45 |
KnowledgeCapability(替换) |
独占 turn:只有 owned_tools + ask_user 兜底,没有 chat 内置、没有用户开关 |
Obsidian / Subagent | protocol.py:87-105 |
独占性由类别归属决定,不是每实例的开关:继承 KnowledgeCapability 就等于设了 exclusive_tools(protocol.py:98-105)。管线用 any_exclusive_capability_active() 判断是否走独占分支,并顺带抑制 rag 脚手架(registry.py:28-37)。
5.5 双身份:mastery / solve 为什么两边都在
这是最容易读错的地方:
mastery_path/deep_solve在 Level 2 表里——用户在 UI 上「选中一个模式」时走这条路,能力自己 own 整个 turn;MasteryLoopCapability/SolveLoopCapability在 Level 3 表里——当掌握路径 / 解题会话在普通 chat turn 上是活跃状态时,它们只是给 chat 循环追加几个工具和一段系统块。
换句话说:v1.4 之后「模式」不再是另起炉灶的引擎,而是同一条 chat 循环的不同装配。这才是 README 那句「switch the objective, not the engine」的代码含义。
5.6 代价
- 新人要同时装下三层插件语义,
AGENTS.md只写了两层,第三层得读capabilities/registry.py才知道; - 独占策略需要例外规则:与 LlamaIndex KB 并列选中时
rag/kb_files必须共存(issue #650,tool_composition.py:59-61,132-138)。
UnifiedContext:一次 turn 的全部真相
6.1 动机
没有统一上下文对象,CLI 参数、WebSocket 帧、Partner 入站消息会各拼各的 prompt,个性化与权限必然漂移。
6.2 选择:一个 dataclass 流过整条链
UnifiedContext(core/context.py:34-84)携带:
| 字段 | 用途 | 值得注意的语义 |
|---|---|---|
user_message / conversation_history |
本轮输入与历史 | OpenAI 消息格式 |
enabled_tools |
用户开关 | None(未指定)≠ [](显式全关),注释明确写死 |
allowed_builtin_tools |
内置自动挂载白名单 | None = 产品 chat 默认不设限;Partner 用它做减法 |
active_capability |
选中的 L2 能力 | 空则回落 chat |
knowledge_bases |
RAG / Subagent / Obsidian 引用 | 三种异质对象共用一个槽位 |
memory_context / persona_context |
注入系统提示 | persona 必须从第一个 token 就生效,故 eager 注入 |
skills_manifest / source_manifest |
渐进披露的技能与附件清单 | 只放摘要行,全文靠工具拉 |
attachments |
多模态附件 | 含 extracted_text(「模型看到的」office 文本) |
metadata |
扩展缝 | turn_id / _subagent_state / _min_loop_rounds 等 |
6.3 代价
metadata 是有意留的「扩展缝垃圾桶」:协议稳定字段进 dataclass,实验字段进 metadata。灵活,但跨能力约定靠注释维持(例如 subagent 用 _min_loop_rounds 抬高循环预算,capabilities/subagent/capability.py:56-60)。
实现:从约束到代码
ChatOrchestrator:三入口共用一张脸
7.1 入口收敛
ChatOrchestrator.handle(runtime/orchestrator.py:36-114):
- 补
session_id - 解析
active_capability or "chat" - 从
CapabilityRegistry取实现;取不到就发一条带turn_terminal的错误事件并正常收尾(orchestrator.py:49-67) - 建
StreamBus,按turn_id注册到全局表(供 WS 重连订阅),后台跑capability.run(context, bus) - 对外
async for扇出StreamEvent finally里必发DONE并close()、注销 bus- 结束后向全局 EventBus 发
CAPABILITY_COMPLETE(orchestrator.py:116-134)
sequenceDiagram
participant U as CLI/WS/SDK/Partner
participant O as ChatOrchestrator
participant R as CapabilityRegistry
participant C as Capability
participant B as StreamBus
U->>O: handle(UnifiedContext)
O->>R: get(cap_name)
O->>B: new StreamBus + register_bus(turn_id)
O->>C: run(context, bus)
C-->>B: stage / content / tool / sources
B-->>U: StreamEvent…
O->>B: DONE + close + unregister
O->>O: publish CAPABILITY_COMPLETE
7.2 为什么这很关键
CLI 的 deeptutor run、Web 的 unified WS、Partner 入站、SDK 调用最终都是同一张脸。换 UI 不换行为——正是 AGENTS.md:12-26 那张架构图要钉死的事。
7.3 代价
Orchestrator 极薄(146 行),复杂度下沉到各 Capability / Pipeline。好处是入口永远读得懂;坏处是「真正的产品逻辑」散在 agents/* 与 capabilities/*,读者容易在 1 563 行的 agentic_pipeline.py 里迷路。
双轨主循环:Chat 的「无工具即收工」× 深度能力的 Label 协议
DeepTutor 最有品味的工程现实:它没有强行统一成一种主循环。
8.1 轨道 A — Chat AgentLoop:「这一轮没调用工具 = 答完了」
文件:agents/chat/agent_loop.py(模块注释 1-24,AgentLoop @ 171,run @ 196)。
规则极简:
- 有 tool calls → 该轮文本是 narration(工具工作的前言),循环继续;
- 无 tool calls → 该轮文本就是最终答案,
finish; - 每轮文本都边生成边流给用户,轮末再用
call_role(narration/finish)告诉前端怎么渲染——不需要中途猜测文本去向(agent_loop.py:20-24); ask_user可暂停并在同一 turn 内恢复;- exploration 预算耗尽后进入最多 3 轮 settlement(
MAX_SETTLEMENT_ROUNDS = 3@64),仍不停则_forced_finish(agent_loop.py:492:关掉 tools逼模型收尾)。总上限是exploration + 4(agent_loop.py:60-63注释算得很清楚)。
两个真实兜底细节值得抄走:
- 截断续写:provider 因长度上限停下(
finish_reason ∈ {length, max_tokens, max_output_tokens},agent_loop.py:65,73-76)时不当成「答完」,而是把可见前缀留在协议里继续续写; - 空 finish 轻推:finish 轮文本为空时先发一次 nudge 让模型重说,只推一次(
nudged_empty_finish,agent_loop.py:361-386)。
ChatCapability 只是薄壳(27 行),真正干活的是 AgenticChatPipeline(run @ agentic_pipeline.py:326)+ AgentLoop。
与 Claude Code / Codex「模型决定停不停」同构,但 DeepTutor 把「停」定义为 工具调用空集,而不是一个特殊的
terminate工具——对辅导场景更自然:讲清楚了就该停。
8.2 轨道 B — run_agentic_loop:Label 驱动的协议状态机
文件:core/agentic/loop.py(run_agentic_loop @ 173)。
模型每轮必须在第一行给出双反引号包裹的标签(core/agentic/labels.py:3-10)。LabelProtocol(loop.py:39-64)声明五个集合:allowed / terminal / intermediate / final / tool_label。
设计上最讲究的一点:final 与 terminal 正交——终止标签可以不流出正文(如 REPLAN 只把文本冒泡给上层),中间标签也可以流出正文(如 chat 的 PAUSE:向用户叙述而不结束 turn)(loop.py:48-56)。
循环本体 capability-agnostic,能力相关行为全部委托给 LoopHost Protocol(loop.py:79-170):上下文窗口裁剪、逐轮 trace 元数据、并行工具分发、pause 处理、终止校验、协议违规修复文案、预算耗尽的强制收尾。其中三个钩子用 getattr 探测存在性(before_iteration / on_intermediate),老 host 不写也不会坏——这是很克制的扩展方式。
标签解析器本身也是防脆弱的(labels.py:34-95):容忍一/三反引号变体、零宽字符前缀、缺失分隔符、以及"裸标签 + 分隔符"回退;超过 64 字符还没匹配就落 UNKNOWN。
Research / Question / Solve 走这条轨道——它们需要显式阶段标签(THINK / TOOL / FINISH / APPEND / REPLAN),不能只靠「有没有工具」判断阶段。
8.3 动机 → 选择 → 代价
| Chat 轨道 | Label 轨道 | |
|---|---|---|
| 动机 | 对话要低延迟、所见即所得 | 多阶段管线要可审计、可纠错 |
| 选择 | 无工具 = finish | 首行标签状态机 |
| 代价 | 两套循环心智 | 弱模型常写坏标签,需要 repair 消息回喂 |
仓库没有为了「架构好看」硬合并——辅导主路径要顺,深度研究路径要严。
工具挂载第一层:ToolMountFlags 让上下文决定工具面
9.1 问题
把全部工具 schema 每轮塞给模型会:费 token、诱导模型乱调、并且让 Partner 场景无法做权限减法。
9.2 选择
ToolMountFlags + compose_enabled_tools(agents/_shared/tool_composition.py:82-99 与 101-160)。条件挂载表是单一真源(_CONDITIONAL_MOUNT_FLAGS,tool_composition.py:41-57):
| Flag | 挂载 |
|---|---|
has_kb |
rag, kb_files |
has_sources |
read_source |
has_memory |
read_memory |
has_notebooks |
list_notebook, write_note |
has_skills |
read_skill |
has_deferred_tools |
load_tools |
has_exec / has_code |
exec, code_execution |
组装顺序(tool_composition.py:117-127):① 用户开关(过 optional_whitelist 过滤)→ ② 条件自动挂载 → ③ 激活能力的 owned tools → ④ 常在工具(write_memory / web_fetch / github / ask_user / cron)。
再叠三个 Partner 用的旋钮(tool_composition.py:140-155):builtin_whitelist(只减不增)、forced(无条件追加,绕过白名单与情境门)、suppressed(最终移除)。AUTO_MOUNTED_TOOLS 直接派生自 CONFIGURABLE_BUILTIN_TOOL_NAMES(tool_composition.py:39),所以「设置页显示什么」和「管线挂什么」不可能漂移。
9.3 与本系列对照
| 项目 | 工具可见性策略 |
|---|---|
| Claude Code | 权限 + 前缀缓存友好排序 |
| Hermes | 74 工具极端插件化 |
| Reasonix | use_capability 单一代理隔离动态 MCP,保前缀稳定 |
| Open Design | 适配器即数据,按需加载 CLI |
| DeepTutor | 情境旗标自动挂载 + 用户开关 + 能力独占(再叠 deferred 第二层) |
DeepTutor 的特殊之处:工具面是 学习情境的函数(有没有 KB / 笔记 / 技能 / 沙箱),不是纯权限函数。
工具挂载第二层:deferred tools 与 `load_tools` 渐进披露
🆕 本章为第二轮回核补写——上一版完全漏掉了这条,而它恰好是 DeepTutor 与 Reasonix
use_capability、Claude Code「工具表稳定」最直接对话的设计。
10.1 动机与实测理由
即使有情境旗标,MCP 服务器 + 101 个 CLI Apps 也会让 schema 面爆炸。runtime/registry/deferred_tools.py:1-16 把动机写得很直白:
保持常在 schema 面很小,这在弱模型上可测量地改善了工具选择,同时让每个已连接工具都只差一次便宜的调用。
10.2 机制
- 打了
BaseTool.deferred的工具(默认包含所有 MCP 工具)不进本轮初始工具表; - 系统提示只带一行摘要(
render_deferred_tools_manifest); - 模型需要时用内置
load_tools报出精确名字; DeferredToolLoader把完整 schema 追加进活的tool_schemas列表——run_agentic_loop每轮重读该列表,所以工具立刻可调;- 已加载的名字按 chat session 持久化,后续 turn 一开始就带上。
CLI App 工具做了特别处理:每个 App 自成一个 provider 且只有一个工具,逐 provider 写小标题会浪费 token,于是它们共享一个 ("cli","") 分组、把 provider id 写在行内(deferred_tools.py:36-40)。
flowchart LR
A["MCP / CLI App 工具<br/>deferred=True"] --> B["系统提示:一行摘要"]
B --> C{模型判断需要?}
C -- 否 --> D["schema 面保持小<br/>弱模型选得更准"]
C -- 是 --> E["load_tools(精确名字)"]
E --> F["schema 追加进活列表"]
F --> G["下一轮即可调用"]
G --> H["名字按 session 持久化"]
10.3 五段式
| 段 | 内容 |
|---|---|
| 动机 | 生态越大,常在 schema 面越毒 |
| 约束 | 工具必须「一次便宜调用」内可达,不能真的不可用 |
| 被否 | 全量塞(弱模型选错)/干脆不支持 MCP(丢生态) |
| 选择 | 一行 manifest + load_tools + 活 schema 列表 + session 记忆 |
| 代价 | 多一跳往返;模型得先「知道自己需要什么」;manifest 描述要限长(MANIFEST_DESCRIPTION_MAX_CHARS) |
Provider 兼容层:36 家能力位 · 优雅降级 · DSML 文本工具回退
🆕 本章为第二轮回核补写。
11.1 36 个 provider 是一张「能力位」表
services/provider_registry.py:118 的 PROVIDERS 元组共 36 条 ProviderSpec,其中 11 条是网关(gateway 优先,顺序即匹配优先级,provider_registry.py:7,115-118):
| 类别 | 成员 |
|---|---|
| 网关 / 兼容层(11) | custom custom_anthropic azure_openai openrouter edenai aihubmix siliconflow novita atlascloud volcengine byteplus(含两个 coding-plan 变体) |
| 第一方 | anthropic openai openai_codex github_copilot deepseek gemini zhipu dashscope moonshot minimax(+minimax_anthropic) mistral stepfun xiaomi_mimo groq qianfan |
| 本地 | vllm ollama lm_studio llama_cpp lemonade ovms nvidia_nim |
关键在 ProviderSpec 的字段(provider_registry.py:19-56)——它不是「名字 + URL」,而是逐家能力位:supports_prompt_caching / supports_stream_options / supports_max_completion_tokens / strip_model_prefix / is_oauth / is_local / thinking_style / reasoning_model_patterns / model_overrides。加一家 provider = 加一条 spec,「环境变量、配置匹配、状态显示全部由此派生」(provider_registry.py:3-5)。
还有一张别名表 PROVIDER_ALIASES(:77)把 azure / google / claude / openai-compatible / github-copilot / lm-studio / atlas … 归一到规范名——用户填什么都能落地。
11.2 优雅降级:错误分类器驱动重试
services/llm/request_compat.py:1 一句话点题:「provider-error classifiers used by retry and graceful-degradation paths」。它把 provider 的报文正文小写后做子串匹配,判断三类不支持:
is_stream_options_unsupported(stream_options/unknown parameter/extra inputs are not permitted…)is_tool_schema_unsupported(→ 触发 DSML 回退,见下节)is_image_input_unsupported(→ 多模态降级,llm/multimodal.py:327注释说明降级要跨迭代持久,否则图片会被反复重发)
这类「先乐观发,被拒就精确降级并记住」的做法,比预先枚举每家 provider 的怪癖更耐老化。
11.3 DSML:当 provider 不肯做原生工具调用
agents/chat/dsml_tool_calls.py:1-22 是我在本系列里见过最具体的「兜底」案例:
某些 DeepSeek 部署(尤其本地/源码起的 OpenAI 兼容端点)不宣告 function calling,于是把工具调用当成标记写进 content 通道,形如:
<||DSML||tool_calls>
<||DSML||invoke name="exec">
<||DSML||parameter name="command" string="true">python -c "..."</||DSML||parameter>
</||DSML||invoke>
</||DSML||tool_calls>
不解析会怎样:这段标记会作为最终答案流给用户,而工具根本没跑(仓库直接挂了 issue #666)。
解法很克制:extract_dsml_tool_calls 是纯函数(无 I/O、无 LLM),标签前缀宽松匹配(<[^>]*?invoke\s+name="),只依赖稳定的 invoke name / parameter name 结构——不赌那几个全角特殊 token 的确切字节。同时 DSMLStreamFilter(dsml_tool_calls.py:62)在流式阶段就把标记从用户可见通道里滤掉,而原始文本照旧回喂给模型。
InlineThinkFilter(agent_loop.py:83-95)是它的姊妹设计,解决同一类问题的另一半:有些 provider 把推理写在 content 里用 <think> 包着,于是在流式时就地切分——「用户可见内容」在下游各处(实时气泡、持久化消息、循环的 finish 判定)一处修好、处处干净,而带标签的原文照旧回喂给模型。
11.4 代价
字符串匹配 provider 报文天生脆弱(改文案就漏判);能力位表要人工维护;DSML 只覆盖了 DeepSeek 家的方言,别家若发明新方言还得再加分支。
三层 Memory:L1 痕迹 → L2 表面 → L3 综合(含四种综合模式)
12.1 布局
services/memory/paths.py:1-8:
trace/<surface>/<YYYY-MM-DD>.jsonl # L1 追加写
L2/<surface>.md # L2 每表面摘要
L3/{recent,profile,scope,preferences}.md # L3 跨表面
backup/<timestamp>/... # v1 迁移归档
七个表面(paths.py:47-56):chat notebook quiz kb book partner cowriter。
12.2 门面
MemoryStore(store.py:68)是无状态门面(进程级单例安全),按路径加写锁:
emit→ L1update_l2/update_l3→ consolidatorread_l3_concat→ 供read_memory工具(无内容时返回明确提示串而非空,store.py:53-55)overwrite_doc/delete_entry→ 工作台里人直接改apply_ops_payload→ 「预览 → 应用」两步流(store.py:165-193)
12.3 ⚠️ 修正:preferences.md 到底谁能写
上一版写错了:说「偏好必须人可编辑、模型不能偷偷改写」。核对源码后的准确说法是:
preferences.md不进自动综合——update_l3显式拒绝(store.py:153-154:raise ValueError("preferences.md is not auto-consolidated"));- 但模型能写,只能走一条窄门:
write_preference(store.py:194-250),注释写明「write_memory工具是唯一调用者,trace_id由 runtime 注入」; - 这条窄门自带两道保护:
op只有add/edit;add是幂等的——重复内容不再追加,而是报告已存在的条目(detail="duplicate")。原因写在注释里:Guided Learning 的 turn 高度工具驱动且很长,模型会跨 turn 反复重发同一条write_memory(issue #647),而preferences.md又没有自动综合去收拾重复。
所以真正的设计意图不是「不让模型写」,而是「让模型只能通过一条带溯源与幂等保护的窄门写」,且这类结论永不被自动摘要改写。
12.4 脚注溯源的精确形状
L2/L3 不是自由散文,而是有严格不变量的 Markdown(document.py:1-30):
## <section>
- <text> [^1][^2] <!--m_xxx-->
---
[^1]: notebook:abc
[^2]: chat:def
三个细节容易看漏:
- 脚注标签是整数,按全文 bullet 流的首次出现顺序分配;两条引用同一来源就共享标签,所以渲染视图里没有重复脚注行;
- 条目 id 藏在 bullet 尾部的 HTML 注释
<!--m_xxx-->里,round-trip 存活,供 audit / dedup 行视图与DELETE /entry/{id}使用; - 解析器同时接受旧格式(
[^m_xxx]: ref1, ref2),下次保存时迁移——存量文档不会因升级读不了。
12.5 四种用户可见的综合模式
上一版只说「LLM consolidator」。实际有四种(consolidator/modes/__init__.py:1-10):
| 模式 | 干什么 | 是否用 LLM |
|---|---|---|
update |
分块增量抽取新事实 | 是 |
audit |
对照原始证据做行级编辑(先渲染成带行号、去脚注的视图) | 是 |
dedup |
全文行级迭代去重 | 是 |
merge |
合并重复脚注引用 | 否(纯确定性) |
并发上限也有明确契约:同一 (layer, key) 最多一个活跃 run,第二个会抛 RunBusyError(consolidator/runs.py:17,174-175)。
12.6 Partner 记忆的越权缝
Partner 运行时可用 memory_path_service_override(paths.py:27-44)让 read_memory / write_memory 读到主人的记忆,而其它服务(rag / skills / notebooks)仍留在 partner 作用域。不这么做,IM 伴侣就永远「不认识你」。这是多租户 × 人格陪伴交叉时的细活。
12.7 五段式
| 段 | 内容 |
|---|---|
| 动机 | 个性化必须可检查,不能只藏在向量库里 |
| 约束 | 原始事件不可丢;摘要可编辑;每条结论可追溯 |
| 被否 | 单一 PROFILE.md(v1 已弃,见 store.py:31 的 _V1_FILES)/纯向量记忆 |
| 选择 | L1 JSONL + L2/L3 整数脚注 Markdown + 四模式 consolidator + 窄门偏好写入 |
| 代价 | 综合要花模型调用;三层 UI 有认知成本;摘要正确性依赖模型 |
多引擎 Knowledge:五种索引引擎 + 把 vault / CLI 当 KB
13.1 五种引擎
services/rag/factory.py:216-256 的引擎清单:
| 引擎 | 形态 | 备注 |
|---|---|---|
| LlamaIndex | 本地向量,默认 | 唯一用 DeepTutor 活跃 embedding 签名选/读版本化索引的引擎(factory.py:67-68) |
| PageIndex | 云端、无向量 | 模型通过 PageIndex 的 MCP 工具读文档(factory.py:227-228);因索引在云上,故意不可 link(linked_kb.py:15,133-134) |
| GraphRAG | 图 + 向量 | 输出 parquet 表,探针检查核心表存在(index_probe.py:200) |
| LightRAG | 图 + 向量,多模态 | HKUDS 自家 |
| LightRAG Server | 外置 HTTP | 无本地索引,naive/local/global/hybrid/mix 五种查询模式 |
配套三件套:统一 RAGPipeline Protocol(pipelines/base.py:17-37)、逐引擎 engine_preflight(preflight.py:195,永不抛异常)、embedding_signature(:43-47 明说图引擎错配会静默失败,所以要单独打戳)。
13.2 「知识库」是个产品语义槽位,不是数据结构
选中的 KB 可以是三种异质对象:
| 类型 | 行为 |
|---|---|
| 普通 RAG KB | 挂 rag / kb_files |
| Obsidian vault | KnowledgeCapability 独占 9 个 vault 工具 |
| 已连接的 Subagent | 独占 consult_subagent(第 17 章) |
把「资料」和「可咨询的专家进程」放进同一个槽位,是 DeepTutor 区别于「RAG 聊天机器人」的关键产品决策。
确定性骨架 vs 模型判断:Mastery / Solve / Obsidian 的工具分工哲学
🆕 本章为第二轮回核补写。这些
tools.py的模块注释里藏着 DeepTutor 最清晰的一句工程哲学,上一版一句话带过太可惜。
14.1 同一句话,说了三遍
| 文件 | 原话 | 工具数 |
|---|---|---|
capabilities/mastery/tools.py:1-9 |
「chat agent loop IS the tutor;这些工具让它读门禁、记结果,而教学法——教什么、怎么问、何时讲解——仍是模型的活;算术(掌握度、门禁、间隔重复)留在引擎里」 | 5 |
capabilities/solve/tools.py:1-8 |
「chat agent loop IS the solver;三个工具给它一根确定性脊梁——一份它承诺的计划、逐步的 done 门禁、有界的 replan——而怎么解留给模型在循环里用共享内置工具做」 | 3 |
capabilities/obsidian/tools.py:1-8 |
九个工具全是纯 vault 操作的薄包装:六读(导航链接 / 搜索 / 读笔记 / 列标签)三写(create / append / set property) |
9 |
14.2 为什么这是一条值得抄的分界线
多数 Agent 项目在「让模型自由发挥」与「写死流水线」之间摇摆。DeepTutor 给了一条可操作的切法:
- 确定性的部分下沉成工具:掌握度分数怎么算、间隔重复什么时候到期、计划的第几步算 done、replan 还剩几次——这些是算术与状态机,模型算不准也不该算;
- 判断的部分留给循环:讲什么、怎么问、先解哪一步、要不要查资料——这些是教学法与推理。
于是 learning/(4.7k 行掌握度引擎)是纯算术模块,mastery/tools.py 只是它与 chat 循环之间的缝。
14.3 与本系列对照
| 项目 | 同类做法 |
|---|---|
| MiMo Code | Goal 独立裁判 + 四道死循环闸门(不信模型自述完成) |
| Reasonix | Delivery Profile 证据签收 + readiness 门禁 |
| grok-build | Goal Mode 五件套 |
| DeepTutor | 掌握度门禁 / 计划 done 门禁 / 有界 replan 沉进工具,教学法留给模型 |
共性是同一句话:凡是能被算准的,就别让模型自由心证。
ExploreContext:把「读懂材料」从「回答问题」里结构性剥离
🆕 本章为第二轮回核补写。这是全仓最"小"却最能说明品味的一个 LoopCapability。
15.1 两个非常具体的失败观察
capabilities/explore_context/capability.py:1-30 把动机写成了两条 bug 级观察:
- 人格串味:chat 循环把「理解附件」和「回答用户」熔在一个循环里。当附件是用户和另一个 AI 的对话记录时,模型在同一上下文里读到那些
## Assistant轮次,于是用那个 agent 的第一人称口吻说话。把理解拆成一次客观(第三人称)前置调查,从结构上消掉这种混淆。 - 弱模型根本不读:原生 tool calling 下,弱模型经常从不主动调用
read_source。于是让专门的前置 pass 拥有读取权,并把该工具从答案循环里彻底移除——调查被迫发生在前面,而不是被跳过。
15.2 机制
- 激活条件:本轮带任何可读(非图片)附件源——文档、笔记条目、书章节、题库条目,或被引用的会话历史(动机案例);
- 用可选的
pre_loop钩子(capabilities/protocol.py:34-53),在答案循环第一次 LLM 调用之前跑; - 它自己是个小 agentic 循环(
explore_context/explorer.py:149起),用read_source读该读的部分; - 产出的客观调查折进循环的 user-message 种子(与 KB 种子并列,
agent_loop.py:205-217); - 它不 own 答案循环的任何工具、不贡献系统块——近乎隐形。
flowchart LR
A["turn 带可读附件<br/>(尤其:别的 AI 的对话记录)"] --> B["ExploreContext pre_loop<br/>只读 · 客观 · 第三人称"]
B --> C["调查结论折进 user-message 种子"]
C --> D["答案循环:不再有 read_source<br/>也不会串味成另一个 agent"]
15.3 五段式
| 段 | 内容 |
|---|---|
| 动机 | 同一循环里「读材料」污染「答问题」的人格与执行 |
| 约束 | 理解必须客观、第三人称,且一定发生 |
| 被否 | 靠提示词叮嘱模型「请先读附件、不要模仿对话里的助手」 |
| 选择 | 独立只读前置 pass + 从答案循环移除 read_source |
| 代价 | 多一次 LLM 往返(成本折进 turn 的 usage 里);前置结论若跑偏会带偏全轮 |
Partners:同一大脑上的 IM 伴侣
16.1 是什么
Partners(前身 TutorBot,v1.4.3 更名并上「生产级 IM pipeline」)= 带 Soul 人格、挂 IM 频道、跑在同一条 chat 循环上的持久伴侣。
16.2 频道发现:零导入扫描 + 可诊断失败
partners/channels/registry.py:
discover_channel_names()用pkgutil扫描包,零导入列出内置频道名(:18-26),排除base/manager/registry;load_channel_class()导入模块并取第一个BaseChannel子类(:29-38);- 外部插件走
entry_points(group="deeptutor.partners.channels"),内置名优先,插件不能遮蔽内置(:55-61); discover_all_with_errors()额外返回errors字典——「为什么 UI 里没有 X」可诊断,而不是静默丢频道(:63-78)。
基线里内置 16 个频道模块:telegram discord slack feishu wecom weixin dingtalk qq napcat mochat whatsapp matrix mattermost msteams zulip email。
16.3 与主产品的关系
Partner turn 仍然构造 UnifiedContext 走 Orchestrator,但三处收紧:
allowed_builtin_tools做减法(主人可禁某些内置);- 强制挂
PARTNER_BUILTIN_TOOL_NAMES三件套并抑制 chat 的read_memory/write_memory(agentic_pipeline.py:64,624-625)——这三件是强制的、不可主人配置的(builtin/__init__.py:1601-1607); - 记忆路径可 override 到主人作用域(第 12.6 节)。
16.4 代价
可选依赖多(pip install deeptutor[partners],Matrix E2EE 还要 libolm);每个频道的 Markdown / 流式 / 媒体语义都要单独磨(Telegram 得自己做 Markdown→HTML,telegram.py:110)。产品广度换来运维与测试矩阵膨胀。
Subagent:把编程 CLI 当成可会诊的知识库
17.1 反转宿主关系
本系列里 Open Design「把别人的 CLI 当引擎」;DeepTutor 换了个插法:
学习 Agent 是宿主;Claude Code / Codex / Gemini / Kimi / opencode / MiMo 是被会诊的子智能体,且入口是「知识库槽位」。
SubagentCapability(capabilities/subagent/capability.py:1-13,32-44):
- 当选中的 KB 是已连接 subagent 时激活(
is_active→connection_for_turn(context) is not None); - 作为
KnowledgeCapability独占 turn:只有consult_subagent+ask_user兜底; - 连接信息(哪个后端、工作目录)、逐后端配置、turn 内预算与 session 全部服务端注入,「模型从不提供它们」——防越权乱指工作目录;
- 用
_min_loop_rounds = budget + 2抬高循环预算,保证最后一次会诊之后还有轮次写答案(capability.py:25-29,56-60)。
17.2 后端注册表与跨 turn 续接
services/subagent/registry.py:24-35 七个后端:ClaudeCode Codex Gemini Kimi Opencode Mimo Partner。local_cli 标志把「本机探测」和「从自己列表连接」分开——detect_all() 只探测 CLI,且逐个失败不影响整体(异常降级成 available=False + detail,registry.py:51-77)。
跨 turn 用 session registry 续上同一个本地 agent 会话(capability.py:88-95:首次会诊时从跨 turn 注册表 seed session id),所以侧边栏与聊天里的会诊共享上下文。
consult_subagent 每次调用把一个问题交给本地 CLI,并把它的原生事件流经 dispatcher 的 event_sink 转出去,所以侧边栏能看到那个 agent 真实的中间步骤(capabilities/subagent/tools.py:1-8)。多模态图片只在用户为该后端显式开了 forward_images 时才转发(capability.py:96-100)。
17.3 为什么这配得上「绝活」
编程 Agent 系列在教「怎么造引擎」;DeepTutor 示范「教育产品如何把已有引擎当工具」——与 Open Design 的插座哲学同构,但插座插在知识库槽位上,而不是设计流水线的步骤上。
工具生态四条腿:MCP · 101 个 CLI Apps · 沙箱三档 · Codex OAuth
🆕 本章为第二轮回核补写——上一版把这四件事压缩成了一张表的两行,实际上它们各自都有值得单独讲的设计。
18.1 MCP:每服务器一个专用连接任务
services/mcp/manager.py:1-25 讲了一个很多人踩过的坑:DeepTutor 的 chat 是同一个 event loop 里的 per-turn task,而 MCP session 必须在同一个 task 内打开和关闭(SDK 的 anyio cancel scope 是任务绑定的)。于是每个服务器拿到一个专用连接任务,端到端持有自己的 AsyncExitStack:
connect → 在该 task 内进入 transport/session → 发布 adapter →
等待 shutdown 事件 → 在同一 task 内退出 stack
ensure_started() 是懒的(第一个 turn 付连接成本,有逐服务器超时上限),之后很便宜;reload() 对持久化配置做 diff。模块里还有 oauth.py / secrets.py(凭据移出沙箱可及范围,v1.5.7)/ catalog/ / session_state.py / pageindex_server.py——PageIndex 引擎本身就是通过 MCP 暴露给模型的。
MCP 工具默认 deferred(第 10 章),所以接一堆服务器不会把 schema 面撑爆。
18.2 CLI Apps:101 个,钉在一个已审 commit 上
services/cli_apps/:
- 目录是随包发布的快照
vendor/catalog.json(catalog.py:31),不是运行时去网上抓——「一次上游故障不该把整个目录带下水」(catalog.py:3-9); - 基线快照:101 个 app,来自
HKUDS/CLI-Anything,commit = bc536c9(2026-07-09),聚合两个 registry(registry.json+public_registry.json); - 快照里没有 pinned commit 就拒绝提供安装(
catalog.py:57:refusing to offer installs)——供应链纪律写进代码; - 每个 app 自成一个 provider 且只有一个工具,故在 deferred manifest 里共享
cli分组(第 10.2 节)。
18.3 沙箱:三个后端、两级隔离、一条策略门
services/sandbox/backends.py:1-16 三个后端,各自如实上报自己真正提供的隔离级别:
| 后端 | 隔离级别 | 场景 |
|---|---|---|
RunnerSidecarBackend |
SYSTEM | 提交给独立 runner 容器(HTTP)——Docker 部署的正解:主应用保持最小权限,永不执行不可信 shell |
BwrapBackend |
SYSTEM | Linux 裸机 bwrap mount namespace |
RestrictedSubprocessBackend |
APPLICATION | 清过 env、cwd 受限的普通子进程;本地开发(如 macOS)的降级回退,仅管理员可 opt-in,因为它不做 OS 隔离 |
services/sandbox/service.py:1-13 把这三档变成产品策略:
exec_capability_available()→ 决定 skill 的requires.sandbox门禁;isolation_level()→ 策略门:SYSTEM 对所有人开放,APPLICATION 仅管理员;run()→ 执行,受逐用户配额约束(quota.py的UserExecQuota/QuotaExceeded)。
与本系列对照:Codex 是 Seatbelt/Landlock/Windows Token 三平台 OS 围栏,nanobot 用 bwrap,DeepTutor 的特色是把「隔离强度」变成一个可查询的返回值,并用它做权限分级——而不是假装所有部署一样安全。
18.4 Codex OAuth:用你自己的 ChatGPT 订阅
services/codex_auth/(service.py:1「Codex OAuth orchestration and managed model-catalog integration」)实现 v1.5.5 起的能力:用 ChatGPT 计划登录 Codex,v1.5.10 起每个账号登入自己的 Codex,v1.5.6 起支持 SSH 隧道后的远端登录。对应 provider 侧是 openai_codex(is_oauth=True,第 11.1 节)。
产品含义:学习者不必再买一份 API 额度——这类"接用户已有订阅"的设计在教育场景尤其重要。
可重放 turn 协议与多用户授权矩阵
🆕 本章为第二轮回核补写。上一版只写了「统一 WS」四个字,实际它是一份完整的 turn 生命周期协议。
19.1 一个端点,十一种消息
api/routers/unified_ws.py:1-30:单一 /api/v1/ws,用于「turn-based execution and replayable streaming」。客户端消息类型:
| 类型 | 作用 |
|---|---|
message / start_turn |
起一个新 turn |
subscribe_turn |
订阅已有 turn 的事件,带 after_seq(重放) |
subscribe_session |
订阅某 session 当前活跃 turn |
resume_from |
断线重连后接回在飞的 turn |
unsubscribe |
退订 |
cancel_turn |
取消 |
submit_user_reply |
把用户回答交给 ask_user 暂停的 turn,在同一 turn 内恢复循环 |
user_input |
把学习者答案投给 StreamBus(解 wait_for_input) |
regenerate |
重跑最后一条用户消息为全新 turn,替换尾部助手消息,复用 session 存的能力/工具/偏好;可带 overrides(capability / tools / KB / language / config / notebook & history 引用);错误码 regenerate_busy / nothing_to_regenerate |
check_active_turn |
报告是否有活 turn;并把陈旧的「running」持久化行标记为 cancelled |
三件事撑起「重启安全」:事件带序号可 after_seq 重放、bus 按 turn_id 注册可再订阅(第 7.1 节)、陈旧状态自愈。README v1.4.0 说的 "restart-safe turn runtime" 就落在这里。
19.2 pause / resume 是一等公民
ask_user 不是「结束这轮,等下一轮」,而是同 turn 暂停:循环停在原地,submit_user_reply 回来后带着一条 ask_user_resolved 指令继续(agentic_pipeline.py:873-911),并把答案摊平成一条 tool 消息接回对话。对辅导产品这是必需品——「老师问你一句,你答了,课继续」不该丢上下文。
19.3 多用户:14 个模块的授权矩阵
multi_user/ 不只是「可选多用户」,而是一张逐资源的授权矩阵:
| 模块 | 管什么 |
|---|---|
identity.py / context.py / paths.py |
身份、请求上下文、逐用户工作区路径 |
grants.py |
授权文档(v2 结构:models.llm / knowledge_bases / skills / partners…) |
model_access.py / personal_models.py |
能用哪些模型 / 自带模型 |
knowledge_access.py / skill_access.py / tool_access.py / partner_access.py |
逐类资源的可见与可用 |
audit.py |
审计 |
router.py |
管理面 API |
其中 partner 的授权语义写得很清楚:Partner 保持管理员管理(CRUD 路由 admin-gated),grant 只让用户看见并咨询指定 partner(grants.py:22-28)。另外 v1.4.10 起非管理员的 MCP 工具默认拒绝。
19.4 代价
协议表面大(11 种消息 + 33 个 REST router),前后端要一起维护语义;多用户是可选路径,个人工作区心智与多租户心智并存,读代码时要时刻问「这段跑在谁的 PathService 上」。
Skills 与 BookEngine:渐进披露的知识与「活书」编译器
20.1 Skills:渐进披露 + 双层影子
services/skill/service.py:8-20 说清了两件事:
- 渐进披露:技能从不整份塞进系统提示——提示里只有「一技能一行」的 manifest,模型匹配到任务时用
read_skill取全文;例外是 frontmatter 里always: true的技能,其正文eager 注入(用于每轮都要生效的「家规」); - 两层,user 影子 builtin:
deeptutor/skills/builtin/随产品发布、运行时只读;data/user/workspace/skills/由 API 创作。同名时用户层遮蔽内置层。
技能包结构是 SKILL.md + 可选 references/(等执行沙箱就绪还会有 scripts/);read_skill 有硬上限防止巨大参考文件灌满上下文(service.py:74),也有路径逃逸校验(service.py:182)。社区技能可从 ClawHub 安装(v1.4.4,带安全门)。
对照:Claude Code 的 skills、Open Design 的 20 层提示词分带、Reasonix 的稳定前缀——三家都在做同一件事:把"可能有用"的知识挪出常在上下文。DeepTutor 在这条线上做了两次(skills 与 deferred tools)。
20.2 BookEngine:并行的第二个运行时
book/__init__.py:5-9:独立运行时,把聊天历史 / 笔记 / 知识库 / 意图编译成结构化、分块、可交互的「活书」;平行于 ChatOrchestrator,复用 ToolRegistry / CapabilityRegistry / StreamBus。
阶段:Ideation → SourceExplorer → Spine(spine_synthesizer 已替代旧 SpineAgent)→ PagePlanner → BookCompiler。模型层是 Pydantic 的 Book / Spine / Chapter / Page / Block / Progress(book/models.py),编译器刻意与 orchestrator 隔离(compiler.py:16)。
为什么不做成一个 Capability?因为它的产出物是一本可反复阅读、带进度的资产,不是一轮流式回答——生命周期、存储(book/storage.py)、失败恢复都不一样。
20.3 其它承重墙(一句话档)
| 模块 | 作用 |
|---|---|
co_writer/ |
多文档协作写作 |
agents/math_animator/ |
Manim 视频管线(可选 extra,需 LaTeX/ffmpeg) |
agents/notebook/ · services/notebook/ |
笔记本与题库回流 |
services/persona/ |
Soul / persona 模板 |
services/cron/ |
定时任务(cron 工具) |
services/imagegen · videogen · voice |
图 / 视频 / 语音生成 |
services/parsing/ |
可插拔文档解析(MinerU / PyMuPDF4LLM / Docling…) |
agents/chat/context_budget.py:1-12 |
把真实发出的那次请求的窗口占用回传 UI;「重新推导会让读数与实际发送漂移」,且该读数永不抛异常 |
core/trace.py · logging/ |
逐轮 trace 卡片与统计 |
i18n/ + prompts/{en,zh}/*.yaml |
状态文案与提示词双语外置 |
品味与边界
十二项决策五段式复盘
| # | 决策 | 动机 | 被否 | 选择 | 主要代价 |
|---|---|---|---|---|---|
| 1 | 统一入口 | 三端行为一致 | 各端各写循环 | ChatOrchestrator(146 行) |
逻辑下沉到各 pipeline |
| 2 | 三层插件 | 单次动作 / 接管 turn / 装配循环 | 只两层或全流水线 | Tool + Capability + LoopCapability | 三套语义,文档只写了两层 |
| 3 | UnifiedContext | 防上下文漂移 | 每端私有 dict | 单一 dataclass + metadata 缝 | metadata 易膨胀 |
| 4 | Chat 无工具即停 | 辅导对话自然收尾 | 强制 terminate 工具 |
AgentLoop + 3 轮 settlement |
与 Label 循环并存 |
| 5 | Label 深度循环 | 多阶段可审计 | 全用 Chat 循环 | run_agentic_loop + LoopHost |
弱模型要 repair |
| 6 | 情境挂载工具 | 降噪省 token | 全量 schema | ToolMountFlags 单一真源 |
旗标要维护 |
| 7 | deferred tools | 生态大但 schema 要小 | 全塞 / 不支持 MCP | manifest + load_tools + 活列表 |
多一跳往返 |
| 8 | Provider 能力位 + 降级 | 36 家怪癖各异 | 只支持 OpenAI 兼容 | spec 能力位 + 错误分类器 + DSML 回退 | 靠报文子串匹配,脆 |
| 9 | 三层记忆 | 可审计个性化 | 单文件 / 纯向量 | L1/L2/L3 + 整数脚注 + 四模式 | 综合成本、依赖模型质量 |
| 10 | 多引擎知识 | 不同资料不同索引 | 绑死一家 | factory + Protocol + preflight | 预检与签名复杂 |
| 11 | 确定性骨架工具 | 不让模型自由心证算术 | 全交给提示词 | 门禁/计划/replan 沉进工具 | 工具面变大 |
| 12 | 可重放 turn + 授权矩阵 | 断线不丢课、多租户可控 | 无状态请求 | after_seq 重放 + resume + grants v2 |
协议表面大 |
横向对比(教育 / 通用 / 编程 Agent 桌)
| 维度 | DeepTutor | MiroFish | OpenWorker | Claude Code / Codex | Open Design | Reasonix |
|---|---|---|---|---|---|---|
| 产品轴 | 终身学习工作区 | 群体事件预演 | AI 同事 | 本地编程 Agent | 设计宿主 | 省钱的终端 Agent |
| 主循环 | 双轨(Chat + Label) | 仿真 tick | 收件箱异步 | 工具循环 | 不写主循环 | 单循环 + 三阶压缩 |
| 上下文 | UnifiedContext 跨模式 | 世界状态 | 会话 + 收件箱 | 会话 + AGENTS.md | 剧本 + 适配器 | 稳定前缀 + turn tail |
| 工具可见性 | 情境旗标 + deferred 两层 | — | 风险标签 | 权限 + 缓存排序 | 适配器即数据 | use_capability 代理 |
| 记忆 | L1/L2/L3 + 脚注可审计 | Agent 记忆 | SQLite | 文件 / 压缩 | 较少 | 压缩三阶梯 |
| 沙箱 | 隔离级别可查询 + 按级分权 | — | 权限档 | OS 三平台围栏 | — | 应用层 + OS |
| 外部引擎 | Subagent CLI 会诊(KB 槽位) | OASIS + Zep | 连接器 | 自身即引擎 | 25 个 CLI 适配 | MCP |
| 触达 | Web + CLI + 16 IM | Web | Slack 等 | TUI / IDE | Web | TUI/HTTP/桌面/ACP |
| 许可 | Apache-2.0 | AGPL-3.0 | 视底座 | 各异 | 视仓库 | MIT |
一句话定位:DeepTutor 是本系列里第一个把「Agent 工程」完整砸进 教育产品骨架 的样本——它证明统一运行时、可审计记忆、渐进披露、隔离分级这些手法并不只服务于写代码。
三个别家都没有的组合:
- 「知识库」这个槽位可以是资料、可以是 vault、也可以是一个活着的编程 Agent;
- 把「读懂材料」做成独立客观前置 pass,只因为观察到模型会串味成附件里的另一个 AI;
- 沙箱把隔离强度当返回值,并据此把
exec分成「所有人可用」与「仅管理员」。
诚实边界
- 体量巨大:15 万行后端 + 10.8 万行前端。本分析抓宪法与承重墙,不是逐行注释;
agentic_pipeline.py(1 563 行)内部仍有大量未展开的分支。 - 双轨循环增加认知负担:读 chat 与读 research 是两套心智模型;三层插件语义又叠一层。
- Provider 兼容靠字符串匹配:
request_compat判断"不支持"依赖报文子串,上游改文案就会漏判。 - RAG 多引擎 = 多真实失败模式:embedding 错配(图引擎会静默失败)、云端 PageIndex、外置 LightRAG Server;preflight 很努力但运维仍重。
- 个性化依赖 consolidator 质量:L2/L3 由 LLM 写,脚注保证可追溯,不保证摘要正确。
- 沙箱最弱一档不是 OS 隔离:
RestrictedSubprocessBackend只是清 env + 限 cwd,仓库自己标注为管理员 opt-in 的降级路径——本地 macOS 开发场景要清楚这一点。 - Subagent 依赖本机 CLI:没装 Claude/Codex 就没有会诊能力;容器部署更麻烦。
- 教育效果评估不在仓库中心:工程强度很高,但"是否真的学得更好"要靠论文与外部实验,不能由 star 数背书。
- 多用户是可选路径:默认个人工作区心智,上多租户需另读
multi_user/全套。 - 本文行号锚定
456f9c2:上游高频发版(README 显示约每 2–3 天一个 release),行号会漂,回核请以符号名为准。
源码导览索引与本地复现
24.1 🔍 源码指路表
| 你想理解… | 先读 |
|---|---|
| 总架构宪法(给模型看的) | AGENTS.md · 根 SKILL.md |
| 统一入口 | deeptutor/runtime/orchestrator.py |
| 一次 turn 的数据 | deeptutor/core/context.py |
| Capability 协议(L2) | deeptutor/core/capability_protocol.py · runtime/bootstrap/builtin_capabilities.py |
| LoopCapability 协议(L3) | deeptutor/capabilities/protocol.py · capabilities/registry.py |
| Chat 主循环 | deeptutor/agents/chat/agent_loop.py |
| Chat 管线组装 | deeptutor/agents/chat/agentic_pipeline.py |
| Label 通用循环 | deeptutor/core/agentic/loop.py · labels.py · labeled_step.py |
| 工具挂载策略 | deeptutor/agents/_shared/tool_composition.py |
| 工具渐进披露 | deeptutor/runtime/registry/deferred_tools.py |
| 内置工具清单 | deeptutor/tools/builtin/__init__.py:1562-1663 |
| Provider 能力位 | deeptutor/services/provider_registry.py |
| 兼容降级 / 文本工具回退 | deeptutor/services/llm/request_compat.py · agents/chat/dsml_tool_calls.py |
| 三层记忆 | deeptutor/services/memory/{paths,store,document}.py · consolidator/modes/ |
| RAG 工厂与预检 | deeptutor/services/rag/{factory,preflight,embedding_signature}.py |
| 确定性骨架工具 | deeptutor/capabilities/{mastery,solve,obsidian}/tools.py |
| 客观前置调查 | deeptutor/capabilities/explore_context/ |
| Subagent | deeptutor/capabilities/subagent/ · services/subagent/registry.py |
| Partners 频道 | deeptutor/partners/channels/registry.py |
| MCP 连接模型 | deeptutor/services/mcp/manager.py |
| CLI Apps 快照 | deeptutor/services/cli_apps/catalog.py · vendor/catalog.json |
| 沙箱三档 | deeptutor/services/sandbox/{backends,service,quota}.py |
| Codex OAuth | deeptutor/services/codex_auth/service.py |
| 可重放 turn 协议 | deeptutor/api/routers/unified_ws.py |
| 多用户授权 | deeptutor/multi_user/{grants,tool_access,knowledge_access}.py |
| Skills | deeptutor/services/skill/service.py |
| 活书 | deeptutor/book/{__init__,engine,compiler,models}.py |
| 窗口占用读数 | deeptutor/agents/chat/context_budget.py |
| CLI | deeptutor_cli/main.py |
24.2 本地复现(只读分析)
cd "参考项目/DeepTutor"
git rev-parse --short HEAD # 期望 456f9c2
git describe --tags # 期望 v1.5.11
# 复核本文的几个关键数字
find deeptutor -name '*.py' -print0 | xargs -0 wc -l | tail -1 # 151534
python3 -c "import re,json;d=json.load(open('deeptutor/services/cli_apps/vendor/catalog.json'));print(len(d['apps']), d['meta']['commit'])"
python3 -c "import re;s=open('deeptutor/services/provider_registry.py').read();b=s[s.index('PROVIDERS: tuple'):];print(len(re.findall(r'ProviderSpec\(\s*name=\"',b)))" # 36
ls deeptutor/partners/channels/*.py | wc -l # 20(含 base/manager/registry/__init__ → 16 个频道)
若要真的跑起来(非分析必需):
python3 -m venv .venv && source .venv/bin/activate
pip install -e .
( cd web && npm ci --legacy-peer-deps )
deeptutor init && deeptutor start --dev
24.3 和本系列其它文档的衔接
- 关心「不写主循环、把别人 CLI 当引擎」→ 对照 Open Design 源码分析 与本文第 17 章(宿主关系反转)
- 关心「渐进披露 / 缓存友好」→ 对照 DeepSeek-Reasonix 的
use_capability与本文第 10、20 章 - 关心「不信模型自述」→ 对照 MiMo Code 的 Goal 裁判与本文第 14、15 章
- 关心「沙箱与权限」→ 对照 openai-codex 的三平台围栏与本文第 18.3 节
- 关心「换轴产品」→ 对照 MiroFish:一个换到社会仿真,一个换到终身学习
附录 A · 版本基线指纹
| 项 | 值 |
|---|---|
| Repo | https://github.com/HKUDS/DeepTutor |
| Tag / Commit | v1.5.11 / 456f9c2 |
| 版本真源 | deeptutor/__version__.py:9 |
| License | Apache-2.0 |
| Stars / Forks(分析日) | 35 022 / 4 444(2026-08-12) |
| 后端 / CLI / 前端行数 | 151 534 / 5 147 / 108 133 |
| 顶层 Capability | 7 |
| LoopCapability | 5 |
| 用户可开关工具 / 情境自动挂载工具 | 7 / 15 |
| LLM Provider(其中网关) | 36(11) |
| RAG 引擎 | 5 |
| IM 频道 | 16 |
| Subagent 后端 | 7(6 个本地 CLI + partner) |
| CLI Apps 快照 | 101(CLI-Anything @ bc536c9,2026-07-09) |
| 沙箱后端 / 隔离档 | 3 / 2(SYSTEM、APPLICATION) |
| WS 客户端消息类型 | 11 |
| REST router | 33 |
| Docs / Paper | https://deeptutor.info/ · arXiv:2604.26962 |
附录 B · 三个最独特设计(给总目录卡片用)
- 切换目标不换引擎:7 个顶层 Capability + 5 个 LoopCapability(其中 mastery/solve 双身份)共用 Orchestrator / StreamBus / UnifiedContext;工具面由情境旗标 + deferred 两层渐进披露决定。
- 可审计三层记忆:L1 JSONL 痕迹 → L2 表面摘要 → L3 综合,整数脚注 +
<!--m_xxx-->锚点逐条溯源,四种综合模式(含一种不用 LLM),偏好只能走幂等窄门写入。 - 知识槽位可以变成专家:RAG / Obsidian vault / 本地编程 CLI 共享「选中的知识库」这一产品语义;再配上「隔离强度可查询并据此分权」的沙箱与 101 个钉版 CLI Apps。
附录 C · 第二轮回核修订清单
| # | 类型 | 内容 |
|---|---|---|
| 1 | 纠错 | preferences.md 从「模型不能改写」修正为「不进自动综合,但 write_memory 经 write_preference 幂等窄门可写」(store.py:153-154,194-250) |
| 2 | 纠错 | 「两层插件」补为三层,列出 LOOP_CAPABILITIES 5 个成员,并说明 mastery/solve 的 L2/L3 双身份 |
| 3 | 纠错 | 行号校正:README_CN.md:58-67→60-62;AGENTS.md:28-29→27-28(StreamBus)与 28-30(settings);orchestrator.py:36-86→36-114 |
| 4 | 补写 | 第 10 章 deferred tools / load_tools 渐进披露 |
| 5 | 补写 | 第 11 章 36 provider 能力位、优雅降级、DSML 文本工具回退(issue #666) |
| 6 | 补写 | 第 14 章 Mastery/Solve/Obsidian 的「确定性骨架 vs 模型判断」分工哲学 |
| 7 | 补写 | 第 15 章 ExploreContext 的人格串味与「弱模型不读附件」两条动机 |
| 8 | 补写 | 第 18 章 MCP 连接任务模型 / 101 个 CLI Apps 供应链钉版 / 沙箱三档两级 / Codex OAuth |
| 9 | 补写 | 第 19 章 可重放 turn 协议(11 种消息、after_seq、resume、regenerate)与多用户授权矩阵 |
| 10 | 补充 | 精确计数:7/15 工具、36(11) provider、5 引擎、16 频道、7 后端、101 apps、33 router、四种综合模式 |
| 11 | 补充 | Chat 循环两处兜底(截断续写、空 finish 轻推);Label 协议 final⊥terminal;标签解析容错 |
| 12 | 补充 | 附录 A 指纹表扩充为可逐条复核的数字表 + 24.2 节给出复核命令 |
附录 D · 分析范围声明
本分析基于 tag v1.5.11 浅克隆的静态阅读与关键路径逐条核对(含第二轮回核),不覆盖:全部测试用例、每个 IM channel 的协议实现细节、前端每个 page 的交互实现、book/ 编译器与 learning/ 掌握度算术的内部算法推导、生产多租户部署运维手册。行号相对该基线;上游若 rebase,请以符号名为准。
附录 A · 版本基线指纹
| 项 | 值 |
|---|---|
| Repo | https://github.com/HKUDS/DeepTutor |
| Tag / Commit | v1.5.11 / 456f9c2 |
| 版本真源 | deeptutor/__version__.py:9 |
| License | Apache-2.0 |
| Stars / Forks(分析日) | 35 022 / 4 444(2026-08-12) |
| 后端 / CLI / 前端行数 | 151 534 / 5 147 / 108 133 |
| 顶层 Capability | 7 |
| LoopCapability | 5 |
| 用户可开关工具 / 情境自动挂载工具 | 7 / 15 |
| LLM Provider(其中网关) | 36(11) |
| RAG 引擎 | 5 |
| IM 频道 | 16 |
| Subagent 后端 | 7(6 个本地 CLI + partner) |
| CLI Apps 快照 | 101(CLI-Anything @ bc536c9,2026-07-09) |
| 沙箱后端 / 隔离档 | 3 / 2(SYSTEM、APPLICATION) |
| WS 客户端消息类型 | 11 |
| REST router | 33 |
| Docs / Paper | https://deeptutor.info/ · arXiv:2604.26962 |
附录 B · 三个最独特设计(给总目录卡片用)
- 切换目标不换引擎:7 个顶层 Capability + 5 个 LoopCapability(其中 mastery/solve 双身份)共用 Orchestrator / StreamBus / UnifiedContext;工具面由情境旗标 + deferred 两层渐进披露决定。
- 可审计三层记忆:L1 JSONL 痕迹 → L2 表面摘要 → L3 综合,整数脚注 +
<!--m_xxx-->锚点逐条溯源,四种综合模式(含一种不用 LLM),偏好只能走幂等窄门写入。 - 知识槽位可以变成专家:RAG / Obsidian vault / 本地编程 CLI 共享「选中的知识库」这一产品语义;再配上「隔离强度可查询并据此分权」的沙箱与 101 个钉版 CLI Apps。
附录 C · 第二轮回核修订清单
| # | 类型 | 内容 |
|---|---|---|
| 1 | 纠错 | preferences.md 从「模型不能改写」修正为「不进自动综合,但 write_memory 经 write_preference 幂等窄门可写」(store.py:153-154,194-250) |
| 2 | 纠错 | 「两层插件」补为三层,列出 LOOP_CAPABILITIES 5 个成员,并说明 mastery/solve 的 L2/L3 双身份 |
| 3 | 纠错 | 行号校正:README_CN.md:58-67→60-62;AGENTS.md:28-29→27-28(StreamBus)与 28-30(settings);orchestrator.py:36-86→36-114 |
| 4 | 补写 | 第 10 章 deferred tools / load_tools 渐进披露 |
| 5 | 补写 | 第 11 章 36 provider 能力位、优雅降级、DSML 文本工具回退(issue #666) |
| 6 | 补写 | 第 14 章 Mastery/Solve/Obsidian 的「确定性骨架 vs 模型判断」分工哲学 |
| 7 | 补写 | 第 15 章 ExploreContext 的人格串味与「弱模型不读附件」两条动机 |
| 8 | 补写 | 第 18 章 MCP 连接任务模型 / 101 个 CLI Apps 供应链钉版 / 沙箱三档两级 / Codex OAuth |
| 9 | 补写 | 第 19 章 可重放 turn 协议(11 种消息、after_seq、resume、regenerate)与多用户授权矩阵 |
| 10 | 补充 | 精确计数:7/15 工具、36(11) provider、5 引擎、16 频道、7 后端、101 apps、33 router、四种综合模式 |
| 11 | 补充 | Chat 循环两处兜底(截断续写、空 finish 轻推);Label 协议 final⊥terminal;标签解析容错 |
| 12 | 补充 | 附录 A 指纹表扩充为可逐条复核的数字表 + 24.2 节给出复核命令 |
附录 D · 分析范围声明
本分析基于 tag v1.5.11 浅克隆的静态阅读与关键路径逐条核对(含第二轮回核),不覆盖:全部测试用例、每个 IM channel 的协议实现细节、前端每个 page 的交互实现、book/ 编译器与 learning/ 掌握度算术的内部算法推导、生产多租户部署运维手册。行号相对该基线;上游若 rebase,请以符号名为准。