`chat` 的 agent loop:一轮里发生了什么,工具是怎么挂上去的

2026-08-10

一个 agent 的对话循环,最值得先搞清楚的从来不是「它能调哪些工具」,而是它凭什么认为这一轮该停了。这个判定条件决定了你看到的答案是怎么来的、预算是怎么烧掉的,也决定了排查「它怎么突然不说了」时该从哪个文件开始翻。

DeepTutor 的 chat 这条线把这件事收在两个文件里:deeptutor/agents/chat/agentic_pipeline.py(1563 行)和 deeptutor/agents/chat/agent_loop.py(864 行)。后者是 deeptutor/agents/chat/ 这个 13 文件、4197 行的子包里第二大的文件。

下文所有行号与常量都对应我们采集的仓库快照:HEAD 456f9c2,采集日 2026-08-10,deeptutor/__version__.py 里的版本号是 1.5.11。你自己 clone 之后行号可能已经漂了,但文件名和常量名是稳的。

一、一轮 = 一次 LLM 调用,停不停看它有没有调工具

agent_loop.py:1-26 的文件 docstring 把规则写得很死,值得逐条抄下来:

  • 一轮就是一次 LLM 调用;
  • 调了工具的那一轮算 “narration”——它的文本只是工具动作前的铺垫,循环继续;
  • 没调工具的那一轮是 finish——它的文本就是最终答案,循环结束。

这就是本文标题里那个反直觉的点:终止信号不是模型说了句「我讲完了」,而是这一轮它没发出任何工具调用。模型每一轮的文本都会往外流,区别只在于这轮文本算 narration 还是 finish。判定条件既然落在「这一轮有没有调工具」上,那么每一段文本是过程还是答案,在循环内部就已经定死了,不需要等文本流完再回头分辨。

顺着这条规则往下想,第一轮就不调工具的情况也被覆盖了:那一轮直接就是 finish。这意味着一个简单问题的完整生命周期可能只有一次 LLM 调用,中间不存在任何额外的「整理成答案」的步骤。

循环体的类是 class AgentLoopagent_loop.py:171),类 docstring 一句话:“Run one chat turn as a single agent loop over one conversation.”——一次对话轮 = 一个循环 = 一份不断增长的会话。它没有在探索会话和作答会话之间来回搬运上下文,工具结果回到的还是同一份不断增长的会话。

二、DEFAULT_MAX_ROUNDS = 8 不是你以为的那个上限

agentic_pipeline.py:99 写着 DEFAULT_MAX_ROUNDS = 8。看到这个数字很容易得出「一轮对话最多 8 次 LLM 调用」的结论,但这不对。

agent_loop.py:60-64 把账算清楚了:这个 8 只覆盖探索阶段。探索预算耗尽而协议里还有没收尾的活时,还有一段有界的结算阶段,常量是 MAX_SETTLEMENT_ROUNDS = 3;如果这 3 轮还在继续要工具,之后才会再补一次强制无工具的收尾轮。三者相加,注释里给出的总上限是 exploration + 4

这个结构的意思是:预算烧完不等于立刻掐断,后面还有 3 轮有界的结算,再加最多 1 轮强制无工具的收尾。换句话说,最后那几轮是留给「已经开了头的事情把尾巴收掉」的,而不是留给继续探索的。

也别把它和另一个 8 混起来。deeptutor/core/agentic/tool_dispatch.py:47MAX_PARALLEL_TOOL_CALLS = 8,那是单轮内并行工具调用的条数上限,chat / question / research 三条线都引用了它。一个是轮数、一个是每轮的并发条数,数值撞车但含义完全不同,看日志时很容易看串。

三、ask_user 能把整轮对话按下暂停

agent_loop.py:19-21 记了一条容易忽略的行为:ask_user 这个工具会暂停整个 turn,等到用户回复之后在协议内恢复;而一个未被解决的暂停(或者一个 terminator 工具)会直接中止这一轮。

对应到返回值上,LoopOutcome 有个 completed 字段(说明在 agent_loop.py:157-166),它为 False 对应的就是 turn 停在了未被回答的 ask_user 暂停上。

这一点在排查时很有用:如果你拿到的 turn 结果里 completed 是 False,那不是失败,而是「它在等你回话」。这跟异常中断要走的排查路径完全不一样。

需要说明的是,同一个仓库里另一条线并不共享这个能力。question/pipeline.py:1949-1953_BaseLoopHost.resolve_pause 注释明写,quiz pipeline v1 没有接 ask_user 的 wait / resume 通路,遇到就终止循环。同一个工具名,在两条 pipeline 上的处置不同,这是仓库里写明的事实,不是我们的推断。

四、这一轮到底挂了哪些工具:四步组合顺序

工具列表不是「用户在设置里勾了什么就是什么」。策略集中在 deeptutor/agents/_shared/tool_composition.py(259 行),文件 docstring 直说它之所以被抽到 pipeline 之外,是为了让 chat 和 quiz 不会对「哪些工具归用户开关、哪些归 pipeline 自动挂载」产生分歧(tool_composition.py:1-19)。

compose_enabled_tools() 的顺序在 docstring 里写死为四步(tool_composition.py:112-129):

步骤内容依据
1用户开关勾选的工具用户侧
2条件自动挂载本轮上下文旗标
3capability 自有工具当前激活的能力
4恒定挂载(always-on)无条件

第 2 步的条件表 _CONDITIONAL_MOUNT_FLAGS 共 10 条(tool_composition.py:46-57):ragkb_fileshas_kbread_sourcehas_sourcesread_memoryhas_memorylist_notebookwrite_notehas_notebooksread_skillhas_skillsload_toolshas_deferred_toolsexechas_execcode_executionhas_code

第 4 步的恒定挂载是 5 个(tool_composition.py:190-192):write_memoryweb_fetchgithubask_usercron这 5 个不看用户开关,只要不在 suppressed 里就会进列表——前面讲的 ask_user 能暂停整个 turn,正是因为它属于这一档。

这里有一处工程上的小设计值得记:AUTO_MOUNTED_TOOLS 不是另写一份清单,而是直接由 CONFIGURABLE_BUILTIN_TOOL_NAMES 派生出来的,注释称这样两者”永远不会漂移”(tool_composition.py:33-39)。一份清单派生另一份,是这类「两处配置必须同步」问题最省事的解法。

另外三个开关在 docstring 里也写明了语义:

  • exclusive=True 表示知识类 capability 独占本轮,只保留 capability_owned 加上 ask_user 这条底线;唯一例外是 has_kb 为真时保留 rag / kb_filestool_composition.py:131-138160-176,共存集合 _KB_COEXISTING_TOOLS:61)。
  • forced 绕过全部门控直接追加,suppressed 无论怎么进来都会被移除;partner runtime 就是用这一对来替换掉 chat 的 memory 工具(tool_composition.py:149-155196-201)。
  • 两个门控函数 user_has_memory() / user_has_notebooks() 都是 fail closed 的,异常时返回 False(tool_composition.py:215-249)。fail closed 的含义是出错时不挂这个工具,而不是出错时放行。

关于执行类工具的一句实话:第 2 步条件表里的 exec(定义在 deeptutor/tools/exec_tool.py:57)与 code_executiontools/builtin/__init__.py:304),按名字就是在你本机执行命令与代码的通道;仓库里另有 subagent 一类会去驱动本机 agent CLI 的能力。只要它们的条件旗标被置真,模型就可以在循环里自行发起调用。我们没有读这些工具的 execute() 实现,因此不描述它们具体怎么执行、有没有沙箱,但「本机执行外部程序」这件事本身不该被轻描淡写——是否开启,请结合你自己的机器环境判断。

五、真正的工具分发不在 agents 层

一个容易走错的排查方向:在 deeptutor/agents/ 里找工具调用是怎么发出去的。找不到的。

分发在 deeptutor/core/agentic/(8 个 py 文件,合计 2681 行),关键符号是 dispatch_tool_callsrun_agentic_loopcore/agentic/loop.py:173)、run_labeled_stepcore/agentic/labeled_step.py:104)与 LabelProtocolcore/agentic/loop.py:40)。agents 层负责”这一轮该给它哪些工具、这一轮算不算完”,core 层负责”把这些调用真的发出去”。

这也是为什么 chat 的循环判定读起来这么薄——它把通用引擎的部分让出去了。_shared/__init__.py:1-7 的 docstring 顺带交代了这个分层意图:_shared 放的是”跨 pipeline 的策略”,既不属于 capability-agnostic 的 core.agentic 引擎,也不属于任何单个 pipeline。

六、一条给「不支持原生 function calling」准备的回退路径

deeptutor/agents/chat/dsml_tool_calls.py(291 行)是这条线上最不像正常代码的一块:它解析的是 DeepSeek 文本格式(仓库里称 “DSML”)的工具调用。

文件 docstring 把后果讲得很清楚(dsml_tool_calls.py:1-22):某些部署的 OpenAI 兼容端点不宣告原生 function calling,模型会把工具调用当成普通文本吐在 content 通道里;如果不解析,这段标记会作为最终答案流给用户,而工具根本不会执行,对应 issue #666。

配套的一处细节在 agent_loop.py:190-194AgentLoop 会保留一份 _tool_schema_catalog,理由是某些 provider 拒绝原生 tools 参数后会改走 DSML 回退,解析时仍然需要声明过的参数类型来解码。

这条回退路径的存在,本身就是一个可迁移的经验:「工具没被执行」和「模型没想调工具」是两种完全不同的故障,而且前者在没有解析器的情况下长得跟正常回答一模一样。你要是自己在做 agent 循环,这块值得提前留判定。

七、几个和上下文有关的硬数字

chat 这条线还有几个写死的上限,都在文件头部常量区,一眼能找到:

  • KB_SEED_MAX_KBS = 3KB_SEED_CHARS_PER_KB = 4000agentic_pipeline.py:95-96)——知识库种子上下文的条数与每条字符上限。
  • CONTEXT_WINDOW_GUARD_RATIO = 0.9agentic_pipeline.py:100)——上下文窗口的保护比例。
  • 流式内容里的 <think> / <thinking>InlineThinkFilter 增量切分(agent_loop.py:83-96),但原始带标签的文本仍然原样回灌进 LLM 会话。也就是说,用户侧看到的是切干净的内容,模型侧拿到的还是完整原文。

还有一个独立模块 chat/context_budget.py(321 行)专门核算上下文预算,它的 docstring 强调所有数字都是测量”已经组装好的材料”、不重新推导,以免和真正发出的请求漂移(context_budget.py:1-12);它把 PromptBlock 名映射成 13 个展示段(context_budget.py:33-51)。这里有一个源码自带的诚实标记:当上下文窗口没有配置值时,结果会被标成 estimated=Truecontext_budget.py:66-98202)。看到 estimated,就说明那个数字是估的。

以上都是源码里的默认配置值,不是「你用起来会怎样」的保证。它们该不该改、改成多少合适,取决于你的模型与用法,仓库没有给出通用建议,我们也不给。

八、一处文档与代码对不上的地方

写到这里必须把一个不一致说清楚。

chat/__init__.py:6chat/capability.py:16-19 都把 chat 描述成 “exploring agent loop + respond stage”,即探索循环加上一个作答阶段两段式;ChatCapability 的 manifest stages 也确实是 ["exploring", "responding"]chat/capability.py:13-23)。

chat/agent_loop.py:22-26 明确写着 “There is no separate respond pass”——不存在独立的作答轮次,答案就是那个没调工具的轮次自己的文本。

两处口径不一致,位置分别是上面这三个文件。以我们实读的仓库状态为准。说完就停。

九、你可以照着核的五步

  1. 打开 deeptutor/agents/chat/agent_loop.py:1-26,确认 narration / finish 的判定条件确实是”这一轮有没有调工具”。
  2. 打开 agent_loop.py:60-64,把 MAX_SETTLEMENT_ROUNDS = 3agentic_pipeline.py:99DEFAULT_MAX_ROUNDS = 8 对照着读,确认注释给出的总上限写法是 exploration + 4
  3. 打开 deeptutor/agents/_shared/tool_composition.py:112-129,把四步顺序抄下来,再翻到 :190-192 数一遍那 5 个恒定挂载的名字,看看有没有你不希望模型碰的。
  4. 在仓库里 grep -n "MAX_PARALLEL_TOOL_CALLS" -r deeptutor/,确认它在 core/agentic/tool_dispatch.py:47,与轮数预算不是一回事。
  5. 打开 chat/dsml_tool_calls.py:1-22agent_loop.py:190-194,确认回退路径和 _tool_schema_catalog 为什么要一起存在。

需要交代的边界:本文只读了 agent_loop.py 的文件 docstring、常量区、LoopOutcomeAgentLoop 的定义段,agentic_pipeline.py 只读到前 200 行的导入、常量、若干纯函数与类定义起点,AgenticChatPipeline.run() 的主体没有逐行读;deeptutor/core/agentic/ 只核了导出符号名与那个并发常量,循环内部实现没有读;38 个提示词 yaml 一条正文都没读。凡是涉及”发出去之后会怎样”的部分,本文一律不下结论。文中所有阈值与默认值都是源码中的默认配置,不构成对运行结果的任何保证。


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

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