question pipeline:出题这条线的阶段与判分

2026-08-10

出题看着是个”发一次请求拿一堆题”的活儿,但在 DeepTutor 里它是一条有三个阶段、两套循环宿主、一个专门修复协议的流水线。代码集中在 deeptutor/agents/question/pipeline.py,2161 行,是 deeptutor/agents/ 下第二大的文件(最大的是 research/pipeline.py 的 2871 行,那条线另有一篇专门讲)。

先给锚点:以下行号与数字对应我们采集的仓库快照 HEAD 456f9c2,采集日 2026-08-10,deeptutor/__version__.py 里的版本号是 1.5.11。你自己 clone 之后行号可能已经漂了,常量名和文件名是稳的,直接搜名字即可。

另外先把范围划清楚:本文说的”判分”,指的是出题这条线自己对生成结果的收尾修复(也就是 FINALIZATION_REPAIR_ATTEMPTS 管的那一段)。学习者答完题之后的评分与错误分类不在这个文件里,那是 deeptutor/learning/grading.py 的事,我们另有一篇专门讲。两件事名字接近但根本不在一条链上,别混。

一、question 这条线在目录里占多大

deeptutor/agents/question/ 一共 17 个文件、4112 行。其中 pipeline.py 一个文件就占了 2161 行——超过这个子包的一半。

这个比例本身就是一条信息:出题的复杂度不在”某个 agent 写得多聪明”,而在编排。question/agents/__init__.py:1-7 的 docstring 把这件事写得很直白:现在这里只剩一个独立的单次调用 FollowupAgent,原来 per-question / per-batch 的 idea_agentgenerator 在 Phase A→C 重构中已经被单一的 question.pipeline 模块取代了。

也就是说,这条线上真正的 BaseAgent 子类只有一个:FollowupAgentquestion/agents/followup_agent.py:15),docstring 写的是”用一次 LLM 调用回答关于单道题的追问”。出题主流程本身不再是”一群 agent 互相喊话”,而是一个编排器加通用的 agentic 引擎。

二、三个阶段与它们的标签协议

class QuestionPipeline 定义在 question/pipeline.py:357。阶段划分写在文件头 docstring(pipeline.py:1-25),阶段名常量在 :82-84

阶段常量标签集是否带工具
Phase 1 ExploreexploringTHINK / TOOL / FINISH
Phase 2 Planplanning只有 PLAN不带
Phase 3 QuizquizzingTHINK / TOOL / FINISH

阶段与标签协议定义在 pipeline.py:85-124:Explore 与 Quiz 用 THINK/TOOL/FINISH,Plan 用单独的 PLAN 标签且不带工具(_PROTOCOL_PLAN:105-111),另有一个单标签的 _PROTOCOL_REPAIR:113-119)。

值得单独记的是 Phase 2:它不带工具,也不循环,就是一次标签步骤,输出一份 JSON 计划,每一项形如 {question_id, topic, question_type, difficulty}pipeline.py:11-16)。题型和难度是在这一步一次性拍板的,Phase 3 只是照着这张表逐题生成。

这条设计的实际含义是:如果你发现出来的题型分布不对,问题多半不在生成阶段的提示词上,而在 Plan 那一步的输出。要定位,去看 planning 这个阶段的产物,而不是逐题去调 Quiz 的提示词。

三、反直觉的那一处:ask_user 挂着,但这条线接不住

这是本文最想讲透的地方,也是读代码时最容易看漏的一处。

先看工具挂载这一侧。DeepTutor 把”每轮该挂哪些工具”的策略收在 deeptutor/agents/_shared/tool_composition.py(259 行),chat 和 quiz 共用同一套。这个文件的 docstring 明写它存在的目的就是让两条线不要对”哪些工具归用户开关、哪些归 pipeline 自动挂载”产生分歧(tool_composition.py:1-19)。其中恒定挂载(always-on)的工具有 5 个:write_memoryweb_fetchgithubask_usercrontool_composition.py:190-192)。

注意 ask_user 在这 5 个里面——它是无条件挂上去的。按直觉,模型在出题过程中觉得信息不够、调一次 ask_user 问一句,应该会暂停等你回话。

在 chat 那条线上确实是这样:chat/agent_loop.py:19-21 写明 ask_user 会暂停整个 turn,等用户回复后在协议内恢复。

但在 question 这条线上不是。question/pipeline.py:1949-1953_BaseLoopHost.resolve_pause 的注释原文写得很清楚:ask_user 会暂停这一轮,而 quiz pipeline v1 没有接 wait/resume 这条通路,所以遇到暂停就终止循环、让这个 turn 干净地关掉。

把两件事拼起来:工具是挂上去的,可以被调用;但一旦真的被调用触发暂停,这条线的处理不是等你,而是收尾结束。

这处不是 bug 认定,也不需要我们去猜作者为什么这么写——注释里 v1 这个词已经说明了它是当前版本的实现状态。我们能给的是可核查的判断动作:

  1. 打开 deeptutor/agents/_shared/tool_composition.py:190-192,确认 ask_user 在恒定挂载那一组里;
  2. 打开 question/pipeline.py:1949-1953,确认 resolve_pause 那两行注释与”遇到暂停即终止循环”的处理;
  3. 再对照 chat/agent_loop.py:19-21,确认 chat 侧的处理是暂停并恢复。

两处一比,“同一个工具在两条线上待遇不同”这件事就落到了具体行上。如果你排到的现象是”出题过程莫名其妙提前结束、题数比预期少”,这是一个值得先去核的位置。反过来,如果你的循环是跑满了迭代次数才停的,那就与这条无关,得回到下一节的预算常量上去看。

四、探索阶段每个工具结果都要多花一次主模型调用

第二处容易看漏的是成本结构。

这条线有两个循环宿主:_ExploreLoopHostpipeline.py:1975)和 _QuizLoopHostpipeline.py:2091),共同基类是 _BaseLoopHostpipeline.py:1884)。

_ExploreLoopHost 比 Quiz 那个多做一件事(pipeline.py:1975-1984):每当有一个 role=tool 的结果回来,它会额外发起一次主模型调用做压缩摘要,摘要流到一个 “Reflecting…” 的 trace 节点,然后用摘要替换掉循环缓冲区里的原始工具结果。相关常量在 pipeline.py:134-141DEFAULT_TOOL_SUMMARIZER_MAX_TOKENS = 800TOOL_SUMMARIZER_TEMPERATURE = 0.2

源码注释把代价写得很直白:每个工具结果多一次主模型 LLM 调用。注意”主模型”这三个字——不是换一个便宜的小模型去压缩,而是同一个模型。

所以 Explore 阶段的调用次数不等于迭代次数。你要估算这条线的调用规模,得把工具调用的次数也算进去。至于最终会花多少、值不值,取决于你用什么模型、探索了几轮,这些都不是我们能从代码常量里推出来的,不做任何折算。

顺带记一个和并发相关的数:单轮并行工具调用上限是 MAX_PARALLEL_TOOL_CALLS = 8deeptutor/core/agentic/tool_dispatch.py:47),chat / question / research 三条线都引用它。工具的实际分发不在 agents 层,而在 deeptutor/core/agentic/ 里。

五、预算常量:四个数字,以及改它们会牵动什么

集中在 question/pipeline.py:126-133

常量默认值管什么
DEFAULT_MAX_EXPLORE_ITERATIONS8Phase 1 探索循环的迭代上限
DEFAULT_MAX_QUIZ_ITERATIONS_PER_QUESTION5Phase 3 每一道题的循环上限
DEFAULT_MAX_TOKENS4000该 pipeline 的默认 token 上限
FINALIZATION_REPAIR_ATTEMPTS2收尾修复的尝试次数

读这张表要注意第二行的口径:是 per question,不是整轮。所以题目数量对总调用量是乘法关系,不是加法关系。

FINALIZATION_REPAIR_ATTEMPTS = 2 配的是前面提到的 _PROTOCOL_REPAIR:113-119)这个单标签修复协议,收尾修复最多再试 2 次。这就是本文开头说的那个”出题这条线自己的判分”——它和后面学习者答题的评分是两码事,只管这条流水线自己收尾时的重试上限。

这些都是源码里的默认配置,不是”你用起来会怎样”的保证。改它们会牵动什么,我们只在有依据的范围内说两句:调大 Explore 迭代上限,意味着第四节那个”每个工具结果一次额外主模型调用”的乘数跟着变大;调大每题的 Quiz 迭代上限,影响面按题数放大。至于具体该调成多少,取决于你的用法和模型,项目没有给通用值,我们也不给。

还有一个和历史相关的数:出题时会从 notebook_entries 表加载同一会话的历史题目,上限 DEFAULT_MAX_ENTRIES = 30,读取失败时 fail closed 返回空表(question/history.py:1-31)。fail closed 的含义是——历史读不出来,出题照常进行,只是模型看不到你之前做过什么。这一点在排”为什么老出重复题”时值得先核。

六、一处 manifest 与 pipeline 对不上的阶段名

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

DeepQuestionCapability 的 manifest 定义在 question/capability.py:28-36,里面声明的 stages 是两个:["ideation", "generation"]tools_used["rag", "web_search", "code_execution"],CLI 别名是 quiz

而 pipeline 内部的阶段名常量是三个:exploring / planning / quizzingpipeline.py:82-84)。

两处的阶段数量不同、名字也不同。以我们实读的仓库状态为准。说完就停。

对读代码的人来说,这处差异的实用后果只有一个:别拿 capability manifest 当 pipeline 的阶段文档去读,要看阶段流就直接进 pipeline.py:1-25 的 docstring 和 :82-84 的常量。

七、这条线的提示词在哪

question/prompts/ 下有 8 个 yaml,按 deeptutor/agents/<module>/prompts/{en,zh}/<agent_name>.yaml 的约定分放在 en / zh 两个目录。其中 question/prompts/en/pipeline.yaml 有 353 行、zh/pipeline.yaml 有 333 行,是 deeptutor/agents/ 下最大的两个提示词文件。

我们一条 yaml 正文都没读,只统计了文件数与行数——所以本文不对提示词内容做任何描述。提示词为什么被放在 yaml 而不是写死在代码里,另有一篇专门讲。

八、写在最后的两条边界

第一条,也是教育类项目必须说清的:本文出现的所有阶段划分、迭代上限、修复次数、摘要温度,都是这个项目的工程实现选择,写在具体某一行代码里。它们不是经过验证的教学结论,我们也没有任何依据去评价这套出题流程在学习效果上如何。本文只回答”参数写在哪一行、怎么运作”,不回答”用了会不会学得更好”。

第二条是核对范围:question/pipeline.py 2161 行我们只精读了头部常量区、协议定义段、类定义位置与两个 LoopHost 的段落,中间的提示词组装与 JSON 解析细节没有逐行读;deeptutor/core/agentic/ 的循环内部实现(loop.pylabeled_step.pytool_dispatch.py)只核了导出符号名与 MAX_PARALLEL_TOOL_CALLS 常量。凡是涉及”跑起来之后会怎样”的部分,本文一律不下结论。

想自己核一遍,最短路径是这四步:打开 question/pipeline.py:1-25 看阶段流 → 跳到 :126-141 抄四个预算常量和两个摘要常量 → 跳到 :1949-1953resolve_pause 的那两行注释 → 最后回 question/capability.py:28-36 把 manifest 的 stages 和 :82-84 的阶段常量并排比一遍。


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

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