`SectionArchitect` 与 `SpineSynthesizer`:目录是被规划出来的
一个”把知识库编译成书”的功能,最容易被想象成一次大调用:把材料塞进去,模型返回一份目录,然后逐章生成正文。DeepTutor 的 deeptutor/book/ 不是这么做的。目录这件事在它这里被拆成了两个 agent、两层结构和三步不带模型的后处理,模型的输出在这条链上更像是”草稿”,而不是最终结果。
以下行号与数字都对应我们采集的仓库快照:HEAD 456f9c2,采集日 2026-08-10,deeptutor/__version__.py 里的版本号是 1.5.11。你自己 clone 之后行号可能已经漂了,但文件名、类名和字段名是稳的,照着搜即可。
一、两个 agent 各管一段
先把分工定下来,后面才不会串。
SpineSynthesizer(deeptutor/book/agents/spine_synthesizer.py,790 行)负责 Stage 2:产出章节骨架Spine和概念图ConceptGraph。SectionArchitect(deeptutor/book/agents/page_planner.py)负责 Stage 3 的块序列规划:产出一串带类型和参数的块壳(Block)。真正往壳里填内容的是BookCompiler.compile_page——它的流程是_plan_if_needed之后把页面置GENERATING,再逐块走_generate_block(book/compiler.py:88-190)。
也就是说,“这本书有哪些章”和”这一章长什么样”是两个完全独立的决策点,中间隔着一次用户确认(book/engine.py:14-19 的生命周期里,confirm_proposal 产出 Spine 之后要等 confirm_spine 才继续)。
要注意 SpineSynthesizer 并不是 Stage 2 的第一步。BookEngine.confirm_proposal 里,子阶段 1 是 SourceExplorer.explore(...),跑完把结果存成 exploration.json 并发出 exploration_ready 事件(book/engine.py:308-341);子阶段 2 才轮到 SpineSynthesizer.synthesize(...)(book/engine.py:343-370)。
SourceExplorer 本身是个”两次 LLM 调用”的 agent——查询设计 + 摘要合成,中间用 asyncio.gather 并行跑 查询×知识库 的检索(agents/source_explorer.py:19-28、:98)。它的默认参数是 max_queries=8、chunks_per_query=4(agents/source_explorer.py:108-109);如果让模型设计查询这一步失败了,它退回 6 条内置默认查询:overview and definition、core mechanisms and theory、representative examples and case studies、common pitfalls and edge cases、applications and use cases、comparisons and history(agents/source_explorer.py:61-68、:144-146)。
这 6 条写死在代码里的查询,是理解整条链口径的一把钥匙:它决定了在探索阶段失灵时,这本书的素材是按什么角度捞出来的。再往上一层,BookEngine.confirm_proposal 里探索整体异常时还有一层降级——直接退化成 “proposal-only spine”(book/engine.py:308-341)。两层降级都不抛错给上层。
二、SpineSynthesizer:模型跑完之后,代码还要改一遍
模块 docstring 把流程写得很清楚(agents/spine_synthesizer.py:5-18),默认 max_rounds=2(:93):
- Draft — 一次 LLM 调用,产出
{concept_graph, chapters}; - Critique — 第二次调用,检查覆盖、排序与环;
- Revise — 第三次调用,把批评意见吃进去。
这里第一个值得记的设计是:概念图和章节列表是同一次调用里一起返回的(:104)。源码给的理由是让两者保持同步——如果分两次要,很容易出现章节里提到的概念在图里没有节点。
Revise 那一步不是必跑:critique 返回 verdict == "ok" 或者 issues 为空时,循环直接 break,后面的 revise 与后续轮次都不会执行(:153-156)。
第二个、也是这一层真正反直觉的地方:LLM 轮次结束不等于结果定稿。docstring 明写在 LLM 轮次之后还有三步确定性后处理(:26-31):
- 拓扑排序:按章节上的
covers标注加概念图依赖重排章节顺序(_topological_sort:663); - 概念图去环:发现环就丢掉 rationale 最低的那条边(
_remove_cycles:607); - 覆盖补齐:没有任何章节覆盖到的概念,用标签的 Jaccard 相似度挂到最相关的那一章上(
_ensure_full_coverage:712)。
换句话说,你最后看到的章节顺序,未必是模型排的顺序;概念图里少了某条边,也可能不是模型没给,而是去环时被丢掉了。排查”目录顺序怎么和我预期的不一样”的时候,这三个函数是必看的,而不是先去改提示词。需要说明的是,我们只读到了模块 docstring 的描述和这三个函数的签名行号,没有逐行核实算法实现,所以这里只写”有这三步、它们各自声称做什么”,不描述具体算法细节。
第三个设计写在 :19-21:任意一次 LLM 调用失败,返回最后一个有效 draft,或者一个最小回落,“流水线永不阻塞”。这是一条贯穿整个 book 模块的取向——后面还会再碰到一次。
过程是可观测的。BookEngine.confirm_proposal 给 synthesize(...) 传了 on_round 回调,把 draft / critique / revise 的轮次推成 spine_round 事件(book/engine.py:343-370)。要接管这条链的可观测性,钩子在这里。
三、目录里有一章不是模型写的
Spine 拿到之后,BookEngine 还会自己往里塞一章。
_ensure_overview_chapter 在 order 0 的位置插入一个 Overview 章,其余章的 order 全部 +1;标题中文写死为”本书导览”,英文写死为 “How to read this book”(book/engine.py:398-451)。这个操作是幂等的:它先检查首章的 content_type 是不是 OVERVIEW、或者 extra 字段 auto_overview 是不是 True,是就不重复插。
配套的还有两处:confirm_spine 阶段的 _materialize_overview_page 用确定性方式构建这一页,不走 LLM、也不进编译队列(book/engine.py:582-645);而 compile_page 对 content_type == OVERVIEW 的页面永不走通用 LLM 编译器,注释给的理由是避免”用幻觉散文覆盖确定性的导览与章节索引”(book/engine.py:716-728)。
这一条对读者的实际意义是:目录第一章的存在与内容和模型无关。你改提示词改不动它,要动得去改这段代码。
四、SectionArchitect:两层结构,LLM 那层是”尽力而为”
到了 Stage 3,同一个取向再来一遍,而且更明显。
SectionArchitect 明确分成两层(agents/page_planner.py:14-28):
- LLM 层
plan_blocks_async:尽力而为。一次调用,参数是max_tokens=1200、temperature=0.6、response_format={"type":"json_object"}(:289-304)。 - 静态层
plan_blocks:确定性模板,永远可用。
关键在于这句:LLM 调用失败即回落静态模板(:289-304)。回落不向上层抛异常,链路不中断。旧类名 PagePlanner 被保留成一个 llm_enabled=False 的向后兼容别名(:355-361)——也就是说,仍然按老名字调用的地方,走的就是纯静态模板那条路。
LLM 层还有两道闸门:只允许 10 种块类型(_ALLOWED_LLM_TYPES:section / text / callout / quiz / flash_cards / figure / interactive / animation / code / timeline,:205-216),并且最多取返回列表的前 12 个条目(:315)。模型给的东西超出白名单或排在第 13 位之后,会被丢掉,不会有异常。
最后一道是覆盖保障:如果模型给的方案里一个 SECTION 块都没有,代码会在最前面插一个 {role: "core", target_words: 1700} 的 SECTION(:336-346)。SECTION 是 v2 里承载长文正文的块类型,静态模板每套都以它打底。
把这四件事连起来,本篇最反直觉的那一处就成型了:这条链上,模型的输出全程被当作草稿对待——前面有静态模板兜底,中间有白名单和条数截断,后面有确定性后处理重排,还会被代码插进一章它根本没写过的导览。当你觉得”这本书的结构不像模型能想出来的”,多半确实不是。
五、静态模板长什么样
_TEMPLATES_V2 覆盖 5 种 ContentType,每个条目是一个 (BlockType, params) 二元组(agents/page_planner.py:74-155)。以 THEORY 为例,共 8 块:
| 顺序 | 块类型 | 参数 |
|---|---|---|
| 1 | SECTION | role: introduction,target_words: 1200 |
| 2 | FIGURE | variant: diagram |
| 3 | SECTION | role: deep_dive,target_words: 1600 |
| 4 | CALLOUT | variant: key_idea |
| 5 | CODE | language: python,intent: example |
| 6 | SECTION | role: synthesis,target_words: 800 |
| 7 | QUIZ | num_questions: 3 |
| 8 | FLASH_CARDS | count: 5 |
其余四套:DERIVATION 7 块、HISTORY 7 块、PRACTICE 7 块、CONCEPT 7 块(同一行号段)。
这张表要怎么读,有一条边界必须先说清楚:这些数字是这个项目的实现选择,不是经过验证的教学结论。target_words: 1200、num_questions: 3、count: 5 说明的是”代码默认会这么排”,它不代表”一章配三道题的学习效果更好”,我们也没有任何依据去评价这套编排在学习科学上的有效性。要改成多少取决于你的用法,项目没有给通用值。
另外,这些块壳只是”要生成什么”的清单,真正的内容是 BookCompiler 逐块调模型填出来的——一页 8 块就意味着这一页要发出多次模型请求。这条链会真实调用你配置的 provider,也就是真实产生调用量。这一点值得在动模板之前先想清楚。
还有一处涉及本机外部程序:LLM 层的白名单里含 animation(就是上一节那 10 种块类型之一),而这个块的生成器调用 MathAnimatorPipeline,在 importlib.util.find_spec("manim") is None 时直接抛 GenerationFailure 并给出安装提示(book/blocks/animation.py:1-9、:30)。也就是说,规划层排出来的块,能不能生成还取决于你本机装了什么。
六、phase 这个参数:三处对不上
写到 phase 的时候会撞上一组可核实的不一致。按纪律,只陈述差异、标明位置,不推断原因,也不据此评价项目。
SectionArchitect.__init__的文档说 “phase: 1 → only emit text/callout/quiz/section blocks”(agents/page_planner.py:262),但_static_plan收下phase之后完全没有使用它(:191-197)。- 同一文件里的
_PHASE1_TYPES(:60-71)与_PHASE1_SUBSTITUTES = {}(:159),在全仓grep -rn "_PHASE1_TYPES\|_PHASE1_SUBSTITUTES" deeptutor/只命中这两处定义本身,再无其他引用。 CompilerOptions.phase字段默认值是1,文档写 “1 → only emit text/callout/quiz blocks”(book/compiler.py:49-50);而BookEngine.__init__构造时固定传的是CompilerOptions(phase=2)(book/engine.py:112)。
另外两处与本篇直接相关的差异:
api/routers/book.py:226的 docstring 写 “Stage 2: user confirms … → SpineAgent”,book/engine.py:288的 docstring 同样仍写 “run SpineAgent”;而confirm_proposal实际调用的是SpineSynthesizer(book/engine.py:344)。SpineAgent只在book/agents/__init__.py:6被再导出,agents/spine_synthesizer.py:5也称其为 “legacy”。book/prompts/{en,zh}/spine_agent.yaml这两份提示词文件仍在仓库里,但流水线已改用spine_synthesizer.yaml(book/engine.py:344)。
以我们实读的仓库状态为准。说完就停。
七、你可以照着核的六步
- 打开
deeptutor/book/agents/spine_synthesizer.py:5-31,把 Draft→Critique→Revise 与后面三步确定性后处理的清单读一遍,确认后处理是在 LLM 轮次之后。 - 在同一文件里搜
_topological_sort、_remove_cycles、_ensure_full_coverage三个函数名(:663、:607、:712),确认它们不依赖模型。 - 打开
deeptutor/book/agents/page_planner.py:14-28,确认两层结构;再看:289-304,确认 LLM 失败时的处理是回落静态模板而不是抛错。 - 对比
:205-216的_ALLOWED_LLM_TYPES(10 项)与:74-155的_TEMPLATES_V2,确认这两边的块类型集合是什么关系——静态层与 LLM 层各自能排出哪些块,答案就在这两段里。 - 打开
book/engine.py:398-451,确认 Overview 章是代码插入的、并且判重条件是首章content_type == OVERVIEW或auto_overview is True。 - 比对
book/compiler.py:49-50的phase默认值与book/engine.py:112实际传入的值,这是第六节第 3 条的原始位置。
仓库里 tests/book/ 有 5 个测试文件,其中 test_spine_synthesizer_llm_call.py 与本篇主题直接相关,可以顺带看看它断言了什么(另外四个是 test_context.py、test_engine_controls.py、test_llm_writer.py、test_runtime_reclaim.py)。
需要说明的边界:本文只读了 page_planner.py 的模板段、两层入口与白名单段,spine_synthesizer.py 只读了模块 docstring 的描述与几个函数的签名行号,source_explorer.py 的检索与摘要实现未逐行读;BookCompiler 如何把这些块壳填成正文、前端如何渲染各块 payload,本文都不涉及。上面出现的所有阈值与默认值都是源码中的默认配置,不是运行结果的保证,也不构成任何学习效果上的承诺。
本文依据 DeepTutor 官方仓库(github.com/HKUDS/DeepTutor)的 README、AGENTS.md、
pyproject.toml 与 deeptutor/ 下的源码整理,核对日 2026-08-10,对应仓库快照 456f9c2(版本 1.5.11)。
本文内容为仓库源码与文档口径,我们没有安装、部署或运行过该项目,也没有调用过其中任何一个模型 API,
因此不涉及生成质量、响应速度与教学效果的任何描述。
参数与默认值随版本变动,请以仓库最新代码与 --help 的实际输出为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。