book 模块怎么把一个知识库变成一本书:四段编译流水线读一遍

2026-08-10

「把知识库变成一本书」这句话听上去像是一次调用就能出结果的事。真去读 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-273build_book_inputs(...) 先把四源输入拍成快照,然后 _run_ideation(ctx, language)IdeationAgent(language).process(ideation_context=ctx),产出一个 BookProposal,存成 status=DRAFTBook 并连带写入 inputs 与 progress,最后发 proposal_ready 事件(engine.py:275-277)。

这一步有两个写死的收敛动作值得记:IdeationAgent._coerce_proposalestimated_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)。目录到底是怎么被规划、评审、改写出来的,我们另有一篇专门讲 SectionArchitectSpineSynthesizer,这里只交代它在流水线上的位置。

Stage 3(engine.py:582-645confirm_spine 为每一章创建 PENDING 状态的页壳,然后调 _materialize_overview_page 确定性地构建导览页,最后在 auto_compile=True 时调 _enqueue_pending_pages 把其余页推进队列。

三、反直觉的那一处:整本书里有一页永远不过大模型

如果这条流水线只让你记住一件事,应该是这个。

先看 Stage 2.5,_ensure_overview_chapterengine.py:398-451):它在 order 0 的位置插入一个 Overview 章,其余章的 order 全部 +1。这个函数是幂等的,判定方式是检查首章的 content_type == OVERVIEW,或者 extra 字段里 auto_overview is True。标题写死:中文是「本书导览」,英文是 “How to read this book”。

关键在 compile_pageengine.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_loopengine.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.generateblocks/base.py:121-155)捕获 GenerationFailure 与一般异常都置 ERROR 并写 metadata.failure;成功则写 payload / anchors / metadata、删掉 failure 键、置 READY

其二,失败被分了七类。 _classify_failureblocks/base.py:38-71)按消息关键字返回 json_parse / empty_response / timeout / rate_limit / prompt_leak / provider_error / generator_errorprompt_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 只在页面处于 GENERATINGPLANNING 时才翻成 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-9blocks/animation.py:30)。这意味着这一类块的可用性取决于你本机装没装对应的 Python 包,不是配好模型就齐活了。

六、落到磁盘上的东西:可以直接去翻的证据

这条流水线的中间产物全部落盘,位置写在 storage.py:7-18,相对路径是 data/user/workspace/book/

文件内容
book_{book_id}/manifest.jsonBook 本体
spine.json目录骨架
progress.json阅读与答题进度
inputs.json四源输入快照
log.mdappend-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 条:listhealthrefresh-fingerprintsdeeptutor_cli/book.py:17-50,整个文件 61 行)。

换句话说,建书这条主链路在 CLI 上是走不通的,CLI 只能做「列出来 / 查健康 / 刷指纹」这三件事。真要走完四段流程,得从 API 或 SDK 这一侧进。

八、答题相关的那几行:只讲它怎么运作

book 模块里有几处和学习行为直接相关的逻辑,这里只陈述代码写了什么:

  • record_quiz_attemptengine.py:1239-1245):答错则把该页的 chapter_id 加进 progress.weak_chapters,答对则 score += 1
  • supplement_for_weaknessengine.py:1248-1277):依次插入 CALLOUT(common_pitfall) + TEXT(role=remediation) + QUIZ(num_questions=2, difficulty=easy) 三个块。
  • create_deep_dive_subpageengine.py:1158-1215):新建一个「{topic} (deep dive)」合成章,再建一个 parent_page_id 指向父页的子页,父页追加一条 PageLink(relation="deepens"),然后阻塞式编译该页。

需要明确说明:以上是这个项目在代码里做出的实现选择,不是经过验证的教学结论。「答错一次就把整章标为薄弱」「补强就插这三个块」是写在这几行里的规则,本文不评价它在学习科学上是否有效,也不承诺任何学习效果。

九、四处文档与代码对不上的地方

按纪律,只陈述差异、标明位置,不推断原因,也不据此评价项目:

  1. CompilerOptions.phase 字段默认 1,且文档写「1 → only emit text/callout/quiz blocks」(book/compiler.py:49-50);但 BookEngine.__init__ 构造时固定传的是 CompilerOptions(phase=2)book/engine.py:112)。
  2. api/routers/book.py:226 的 docstring 写「Stage 2: user confirms … → SpineAgent」,book/engine.py:288 的 docstring 同样写「run SpineAgent」;而 confirm_proposal 实际调用的是 SpineSynthesizerbook/engine.py:344),SpineAgent 只在 book/agents/__init__.py:6 被再导出,book/agents/spine_synthesizer.py:5 称其为 “legacy”。
  3. 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)。
  4. book/blocks/__init__.py:4-21 导出 11 个生成器,缺 SectionGeneratorConceptGraphGenerator,而这两个在注册表里都注册了(blocks/base.py:196blocks/base.py:202)。

以我们实读的仓库状态为准。说完就停。

十、你可以照着核的五步

  1. 打开 deeptutor/book/engine.py:14-26,把四段生命周期与队列策略的那段注释读一遍,这是全模块最省时间的一段。
  2. 打开 engine.py:716-728,确认 OVERVIEW 页面不走通用 LLM 编译器,以及注释给出的理由。
  3. 比对 book/compiler.py:49-50phase 默认值与 book/engine.py:112 实际传入的值。
  4. 数一遍 book/models.py:55-79BlockType 取值个数,再数 blocks/base.py:207-222 注册的生成器个数,对不上的 6 个就是会命中 "No generator registered for block type ..." 的那批。
  5. find deeptutor/book -type f | wc -lls tests/book,前者应是 58,后者是 5 个测试文件:test_context.pytest_engine_controls.pytest_llm_writer.pytest_runtime_reclaim.pytest_spine_synthesizer_llm_call.py。以上为按仓库中的路径与统计口径组合的示例命令,未经实测,以官方文档与 --help 的实际输出为准。

最后交代边界:本文只读了 engine.py 的生命周期段、compiler.py 的编译与状态聚合段、storage.py 的布局段与 blocks/base.py 的记账段,没有逐行读完 7685 行;book/context.pybook/inputs.pybook/kb_health.py 我们都只读了前几十行,build_book_inputs 内部如何拉取会话与笔记本记录未逐行核实;前端如何渲染各 block 的 payload 完全没看,本文提到的 payload 形状只反映后端写入侧。凡是涉及「跑起来会怎样」的部分,本文一律不下结论。


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

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