book 模块怎么把一个知识库变成一本书:四段编译流水线读一遍
「把知识库变成一本书」这句话听上去像是一次调用就能出结果的事。真去读 deeptutor/book/ 会发现,它被拆成了一条带两道人工闸门的编译流水线,而且中间还有一页是刻意绕开大模型的。
以下所有行号、文件名与数字都对应我们采集的仓库快照:HEAD 456f9c2,采集日 2026-08-10,deeptutor/__version__.py 里的版本号是 1.5.11。你自己 clone 之后行号可能已经漂了,文件名与函数名是稳的,照着找即可。规模上给个参照:deeptutor/book/ 有 58 个文件、Python 共 7685 行,其中 engine.py 单文件 1297 行,是本文的主战场。
一、它在架构里的位置:与 ChatOrchestrator 平行
book/__init__.py:2-9 的模块自述写得很直白:Book Engine 是一个独立运行时引擎,把用户输入(聊天记录、笔记本、知识库、意图)编译成结构化、块式、可交互的 living books;它「与 ChatOrchestrator 平行」,复用既有的 ToolRegistry / CapabilityRegistry / StreamBus。
engine.py:5-7 补了一句更关键的:BookEngine 不是 BaseCapability,它是 API 路由、CLI、SDK 的唯一公共入口。
这两句合起来决定了读代码的路径:想搞清楚一本书是怎么来的,不要去 capability 注册表里找,直接进 engine.py 按生命周期读。
顺带一个容易被忽略的细节:get_book_engine() 是按 path_service.workspace_root.resolve() 做 key 的多实例缓存,不是全局单例(engine.py:1285-1294)。多 workspace 的场景下,你拿到的是哪个引擎实例由 workspace 根路径决定。
二、四段生命周期:两次生成,两道确认闸
engine.py:14-19 把整条链写成了一行注释:
create_book(...) → BookProposal # Stage 1,需用户确认
confirm_proposal(...) → Spine # Stage 2,需用户确认
confirm_spine(...) → 页面壳 + 排队编译 # Stage 3
compile_page(book, page) → Page # Stage 3-4
Stage 1(engine.py:200-273):build_book_inputs(...) 先把四源输入拍成快照,然后 _run_ideation(ctx, language) 调 IdeationAgent(language).process(ideation_context=ctx),产出一个 BookProposal,存成 status=DRAFT 的 Book 并连带写入 inputs 与 progress,最后发 proposal_ready 事件(engine.py:275-277)。
这一步有两个写死的收敛动作值得记:IdeationAgent._coerce_proposal 把 estimated_chapters 夹在 max(2, min(8, int)) 区间,解析失败时直接取 4;title 截断到 120 字符(agents/ideation_agent.py:70-85)。也就是说,无论模型返回什么,章节数只会落在 2 到 8 之间。
还有一处设计意图写在注释里:建书时立即抓取知识库的基线指纹,理由是避免首次健康检查把所有 KB 误报为「新增漂移」(engine.py:245-255)。这个动作的后果会在第五节兑现。
Stage 2(engine.py:308-370) 分两个子阶段。子阶段 1 是 SourceExplorer.explore(...),结果落成 exploration.json 并发 exploration_ready;这一步异常时不会中断流程,而是降级为「proposal-only spine」(engine.py:308-341)。子阶段 2 是 SpineSynthesizer.synthesize(...),带一个 on_round 回调,把 draft / critique / revise 的每一轮推成 spine_round 事件(engine.py:343-370)。目录到底是怎么被规划、评审、改写出来的,我们另有一篇专门讲 SectionArchitect 与 SpineSynthesizer,这里只交代它在流水线上的位置。
Stage 3(engine.py:582-645):confirm_spine 为每一章创建 PENDING 状态的页壳,然后调 _materialize_overview_page 确定性地构建导览页,最后在 auto_compile=True 时调 _enqueue_pending_pages 把其余页推进队列。
三、反直觉的那一处:整本书里有一页永远不过大模型
如果这条流水线只让你记住一件事,应该是这个。
先看 Stage 2.5,_ensure_overview_chapter(engine.py:398-451):它在 order 0 的位置插入一个 Overview 章,其余章的 order 全部 +1。这个函数是幂等的,判定方式是检查首章的 content_type == OVERVIEW,或者 extra 字段里 auto_overview is True。标题写死:中文是「本书导览」,英文是 “How to read this book”。
关键在 compile_page。engine.py:716-728 这一段明写:content_type == OVERVIEW 的页面永不走通用 LLM 编译器。注释里给出的理由是:走通用编译器会用「幻觉散文」覆盖掉确定性的导览与章节索引。
反直觉在哪?在于一本由大模型编译出来的书里,唯一一页是不许大模型碰的,恰恰是那一页目录性质的导览。整个 Stage 3 的排队机制也为它开了口子——_materialize_overview_page 不入队(engine.py:582-645),它不是队列里的一个任务,是 confirm_spine 同步做完的事。
这带来一个具体后果:对导览页再次触发编译,你得到的不是重新生成,而是按当前 spine 重新构建一次。所以如果你改了目录结构却觉得导览页没跟上,问题不在模型那一侧,去看 spine 有没有被更新即可。
四、编译队列:一本书一个队列,一个后台 worker
engine.py:21-26 交代了排队策略:每本书一个 asyncio.Queue;被用户请求的那一页走 inline 通道、优先级最高,其余页面由单个后台 worker 依次处理。
worker 的退出条件写在 _worker_loop(engine.py:833-852):asyncio.wait_for(queue.get(), timeout=2.0),超时后如果队列确实空了,就注销这本书的 runtime 并调用 schedule_memory_reclaim()。也就是说,编译资源不是常驻的,一本书闲下来两秒左右它就把自己收掉了。
这里要按纪律说明一句:timeout=2.0、单 worker、inline 优先,这些都是源码里的默认配置,不是「你会等多久」的保证。一页要跑多久取决于块数、模型与你的 provider,本文不做任何推算。
CompilerOptions 有五个旋钮(compiler.py:45-66):phase(默认 1)、rag_enabled(默认 True)、block_concurrency(默认 1,注释说 Phase 1 用顺序执行以保证流式顺序)、persist_after_each_block(默认 True)、architect_llm_enabled(默认 True)。
block_concurrency=1 与「单 worker」是两层不同的串行:前者管一页之内的块,后者管一本书之内的页。
五、失败与状态:这条流水线是怎么记账的
编译类系统最值钱的部分往往不是成功路径,而是失败之后的状态收敛。这条线上有四处可核查的规则:
其一,块级统一记账。 BlockGenerator.generate(blocks/base.py:121-155)捕获 GenerationFailure 与一般异常都置 ERROR 并写 metadata.failure;成功则写 payload / anchors / metadata、删掉 failure 键、置 READY。
其二,失败被分了七类。 _classify_failure(blocks/base.py:38-71)按消息关键字返回 json_parse / empty_response / timeout / rate_limit / prompt_leak / provider_error / generator_error,除 prompt_leak 外一律标 retryable=True。排查时先去块的 metadata.failure 里看这个分类,比盯着日志翻堆栈快。
其三,页面状态是聚合出来的。 compiler.py:348-365:所有块 READY → 页面 READY;0 个块 READY → ERROR;部分成功 → PARTIAL,并写一条 "{errored}/{total} blocks failed.";一个块都没有 → ERROR。
其四,_mark_page_error 有前置条件。 engine.py:803-823 只在页面处于 GENERATING 或 PLANNING 时才翻成 ERROR。注释解释了为什么必须这么做:不这样,页面会「永远转圈、既不会在 resume 时重试也无法强制重生成」。
还有一类失败是结构性的,值得单独拎出来:BlockType 枚举有 19 个取值(book/models.py:55-79),而块生成器注册表 _build_default_registry() 只注册了 13 个(blocks/base.py:207-222)。diagnostic、pretest、retrieval_practice、error_diagnosis、module_test、progress_dashboard 这 6 个 Guided Learning 类型没有对应生成器,命中它们时 BookCompiler._generate_block 会写 "No generator registered for block type ..." 并把块置 ERROR(compiler.py:216-220)。
另外有一个块类型会去够本机的外部依赖:ANIMATION 块调用 MathAnimatorPipeline,若 importlib.util.find_spec("manim") is None 就直接抛 GenerationFailure 并给出安装提示(blocks/animation.py:1-9、blocks/animation.py:30)。这意味着这一类块的可用性取决于你本机装没装对应的 Python 包,不是配好模型就齐活了。
六、落到磁盘上的东西:可以直接去翻的证据
这条流水线的中间产物全部落盘,位置写在 storage.py:7-18,相对路径是 data/user/workspace/book/:
| 文件 | 内容 |
|---|---|
book_{book_id}/manifest.json | Book 本体 |
spine.json | 目录骨架 |
progress.json | 阅读与答题进度 |
inputs.json | 四源输入快照 |
log.md | append-only 操作日志 |
pages/{page_id}.json | 每一页 |
assets/ | 资源 |
exploration.json 不在上表里,它由 _exploration_path 单独写在书根(storage.py:153-159)。
所有写入都走 atomic_write_text,JSON 以 ensure_ascii=False, indent=2 序列化(storage.py:44-46)——所以这些文件可以直接用文本编辑器打开看中文。log.md 每行的格式固定为 - `{ISO时间}Z` **{op}** — {message}(storage.py:236-242)。list_pages 按 (order, created_at) 排序,校验失败的页文件会被跳过并告警(storage.py:209-225)。
第二节埋的那个 KB 指纹伏笔在这里兑现:指纹算法是对知识库的 raw/ 目录逐文件取 name:mtime:size,拼接后 sha256;KB 不存在或没有文件时返回空串(kb_health.py:35-65)。KBDriftReport 的字段是 has_drift、new_kbs、removed_kbs、changed_kbs、current_fingerprints、stale_page_ids(kb_health.py:80-88)。
注意 mtime 也进了指纹:只要文件的修改时间变了,指纹就会变。这是算法的字面语义,至于具体会不会影响你的使用,取决于你怎么维护知识库目录,本文不下结论。
七、API 有 24 个路由,CLI 只有 3 条子命令
这是这个模块一处很实在的落差,写清楚能省不少找命令的时间。
deeptutor/api/routers/book.py 暴露 24 个路由(api/routers/book.py:143-491),包括 POST /books、/books/confirm-proposal、/books/confirm-spine、/books/compile-page、/books/regenerate-block、/books/insert-block、/books/delete-block、/books/move-block、/books/change-block-type、/books/deep-dive、/books/quiz-attempt、/books/supplement、/books/page-chat-session、/books/rebuild、/books/{id}/health、/books/{id}/refresh-fingerprints,以及一个 @router.websocket("/ws")。
而 CLI 侧的 book 子命令只有 3 条:list、health、refresh-fingerprints(deeptutor_cli/book.py:17-50,整个文件 61 行)。
换句话说,建书这条主链路在 CLI 上是走不通的,CLI 只能做「列出来 / 查健康 / 刷指纹」这三件事。真要走完四段流程,得从 API 或 SDK 这一侧进。
八、答题相关的那几行:只讲它怎么运作
book 模块里有几处和学习行为直接相关的逻辑,这里只陈述代码写了什么:
record_quiz_attempt(engine.py:1239-1245):答错则把该页的chapter_id加进progress.weak_chapters,答对则score += 1。supplement_for_weakness(engine.py:1248-1277):依次插入 CALLOUT(common_pitfall) + TEXT(role=remediation) + QUIZ(num_questions=2, difficulty=easy) 三个块。create_deep_dive_subpage(engine.py:1158-1215):新建一个「{topic} (deep dive)」合成章,再建一个parent_page_id指向父页的子页,父页追加一条PageLink(relation="deepens"),然后阻塞式编译该页。
需要明确说明:以上是这个项目在代码里做出的实现选择,不是经过验证的教学结论。「答错一次就把整章标为薄弱」「补强就插这三个块」是写在这几行里的规则,本文不评价它在学习科学上是否有效,也不承诺任何学习效果。
九、四处文档与代码对不上的地方
按纪律,只陈述差异、标明位置,不推断原因,也不据此评价项目:
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被再导出,book/agents/spine_synthesizer.py:5称其为 “legacy”。- README 自称的 Book 块类型清单(
README.md:566)列了 text、callouts、quizzes、flash cards、timelines、code、figures、interactive HTML、animations、concept graphs、deep dives、user notes 共 12 类,未提section;而section是 v2 里承载长文正文的块类型(book/models.py:71)。 book/blocks/__init__.py:4-21导出 11 个生成器,缺SectionGenerator与ConceptGraphGenerator,而这两个在注册表里都注册了(blocks/base.py:196、blocks/base.py:202)。
以我们实读的仓库状态为准。说完就停。
十、你可以照着核的五步
- 打开
deeptutor/book/engine.py:14-26,把四段生命周期与队列策略的那段注释读一遍,这是全模块最省时间的一段。 - 打开
engine.py:716-728,确认 OVERVIEW 页面不走通用 LLM 编译器,以及注释给出的理由。 - 比对
book/compiler.py:49-50的phase默认值与book/engine.py:112实际传入的值。 - 数一遍
book/models.py:55-79的BlockType取值个数,再数blocks/base.py:207-222注册的生成器个数,对不上的 6 个就是会命中"No generator registered for block type ..."的那批。 - 跑
find deeptutor/book -type f | wc -l与ls tests/book,前者应是 58,后者是 5 个测试文件:test_context.py、test_engine_controls.py、test_llm_writer.py、test_runtime_reclaim.py、test_spine_synthesizer_llm_call.py。以上为按仓库中的路径与统计口径组合的示例命令,未经实测,以官方文档与--help的实际输出为准。
最后交代边界:本文只读了 engine.py 的生命周期段、compiler.py 的编译与状态聚合段、storage.py 的布局段与 blocks/base.py 的记账段,没有逐行读完 7685 行;book/context.py、book/inputs.py、book/kb_health.py 我们都只读了前几十行,build_book_inputs 内部如何拉取会话与笔记本记录未逐行核实;前端如何渲染各 block 的 payload 完全没看,本文提到的 payload 形状只反映后端写入侧。凡是涉及「跑起来会怎样」的部分,本文一律不下结论。
本文依据 DeepTutor 官方仓库(github.com/HKUDS/DeepTutor)的 README、AGENTS.md、
pyproject.toml 与 deeptutor/ 下的源码整理,核对日 2026-08-10,对应仓库快照 456f9c2(版本 1.5.11)。
本文内容为仓库源码与文档口径,我们没有安装、部署或运行过该项目,也没有调用过其中任何一个模型 API,
因此不涉及生成质量、响应速度与教学效果的任何描述。
参数与默认值随版本变动,请以仓库最新代码与 --help 的实际输出为准。
安全相关做法请结合自身环境评估,本文不构成安全方案建议。