跳到主要内容

工具循环:LLMHandler 如何驱动多轮工具调用、暂停与续跑

30 秒导读: 一个能用工具的 agent,本质是一个循环——「问模型 → 模型说『我要调工具 X』→ 真去执行 X → 把结果拼回对话 → 再问模型」,直到模型不再要工具、直接回话为止。DocsGPT 里驱动这个循环的心脏是 application/llm/handlers/base.pyLLMHandler。本章讲清这个循环怎么转、怎么在需要人类审批时暂停续跑、怎么在模型跨 provider 回退后仍能读懂工具调用、以及上下文塞满时怎么边跑边压缩

本章聚焦「循环」这一层。单个工具具体怎么执行(发现、参数解析、审批规则、持久化日志)是 工具体系 的内容;不同 LLM provider 的差异是 LLM 抽象层 的内容;从 HTTP 请求怎么走到 agent、四种 agent 是什么,见 从请求到 agent


1. 这是什么(零基础也能懂)

一句话定义: LLMHandler 是一个「多轮工具调用的编排器」——它反复地问大模型、执行大模型点名的工具、把结果喂回去,直到大模型给出最终答复。

它解决什么问题: 大模型自己不会执行任何动作。它只会输出一段结构化的话:「请帮我调用 search_docs(query="退款政策")」。真正去搜索、去读文件、去查数据库的是你的代码。于是每次模型想用工具,程序都得:接住这个请求 → 执行 → 把返回值原样塞回对话历史 → 再问一次模型「结果在这,你接着说」。模型可能连着要好几轮工具,也可能一轮就够。这个「反复问、反复执行」的节奏,就是 LLMHandler 管的。

一句话直觉/类比: 把它想成一个跑腿助理。你(模型)说「帮我查下 A」,助理(handler)跑去查、把结果贴在便签上递回来;你看完说「再帮我查下 B」,助理又跑一趟……直到你说「够了,我知道答案了」。麻烦之处全在细节:助理得记住哪张便签对应哪个请求(否则模型接口直接报 400)、跑腿途中可能需要先问主人「这个操作要授权吗」而暂停、便签攒太多桌子放不下时得先归纳压缩。

最小使用示意: 一次带工具的对话在概念上长这样(示意,非源码):

# 示意,非源码:一轮工具循环的骨架
resp = llm.gen(messages, tools=my_tools) # 1. 问模型
parsed = handler.parse_response(resp) # 2. 解析:模型要调工具吗?
while parsed.requires_tool_call: # 3. 只要它还要工具,就循环
for call in parsed.tool_calls:
result = execute(call) # 4. 真去执行
messages += [assistant_calls(call), # 追加「模型说要调 X」
tool_result(call, result)]# 紧跟「X 的结果是 …」
resp = llm.gen(messages, tools=my_tools) # 5. 带着新结果再问一次
parsed = handler.parse_response(resp)
return parsed.content # 6. 模型不再要工具 → 最终答复

真实代码就是把这段骨架做扎实:加上迭代上限、暂停/续跑、流式累积、跨 provider 回退、上下文压缩。下面逐层拆。


2. 顶层全景(它大概怎么转)

入口: agent 侧的 BaseAgent._llm_handler 把控制权交给 handler(application/agents/base.py:765),调 process_message_flow(application/llm/handlers/base.py:125)。它做一件事:按 stream 标志分流(base.py:150-155)——流式走 handle_streaming,非流式走 handle_non_streaming。两者是同一个循环的两种形态。

怎么读下面这张图: 从左上「模型回复」开始,顺箭头走。菱形是判断,命中「要工具」就往下执行、拼消息、回到顶部再问模型;命中「要暂停」就跳出去存档等人;都不命中就吐出最终答复。

┌───────────────────────────────────────────────┐
│ agent 拿到模型回复(流式 chunk 或整块) │
└───────────────────────┬───────────────────────┘

_parse_for_response(agent, resp)
(用「真正产出它的 provider」的 handler 解析)

┌──────────────────────┐
│ 模型要调工具吗? │
│ requires_tool_call │
└───────┬──────────┬───┘
否 │ │ 是
▼ ▼
┌──────────┐ handle_tool_calls:逐个工具
│ 吐最终 │ ├─ check_pause? ──是──► 存 _pending_continuation
│ 答复文本 │ │ yield tool_calls_pending
└──────────┘ │ ↑ 暂停,等 gen_continuation 续跑
├─ 否:执行工具
│ append assistant(tool_calls)
│ append role:tool(结果) ← 成对,call_id 必须配对
└─ 中途 context 超限? ──► 压缩 / 剪枝

迭代数 +1;达到 25 → 塞 _FINALIZE_INSTRUCTION、去掉 tools

带新消息再问模型 ──► 回到顶部(非流式是 while 循环;
流式是 handle_streaming 递归)

主要部件一句话职责:

部件干什么在哪
process_message_flow总入口,按 stream 分流到两个 handle_*base.py:125
handle_non_streaming非流式:while requires_tool_call 显式循环base.py:1133
handle_streaming流式:边收 chunk 边攒工具调用,靠递归开下一轮base.py:1216
handle_tool_calls逐个执行工具,拼 assistant(tool_calls)+role:tool 消息对base.py:758
_parse_for_response用「真正产出回复的 provider」的 handler 去解析,防跨 provider 回退丢工具base.py:78
ToolCall一次工具调用的标准结构(id/name/arguments/index/thought_signature)base.py:25
LLMResponse.requires_tool_call循环的判据:有 tool_calls 且 finish_reason=="tool_calls"base.py:56
BaseAgent.gen_continuation人在环:审批/客户端结果回来后从暂停点续跑application/agents/base.py:234

3. 核心原理(逐个机制,由浅入深)

3.1 循环的判据与兜底:什么时候停

要解决的小问题: 循环得有个「继续 vs 停止」的判据,还得有个「万一模型无限要工具」的保险丝。

判据: 每轮解析出的 LLMResponse 有个属性 requires_tool_call——同时满足「有 tool_calls」和「finish_reason == 'tool_calls'」才为真(base.py:56-59)。非流式的循环就是 while parsed.requires_tool_call:(base.py:1152);为真就再跑一轮,为假就 return parsed.content 吐最终文本。

保险丝: 上限常量 MAX_TOOL_ITERATIONS = 25(base.py:17)。注释说得很直白:没有它,某些「预览版模型 / 工具结果太稀 / prompt 没写清」的情况下,模型会无限地接着要工具,流永远结束不了(base.py:13-16);25 这个数对齐了 Dify 的默认值。

达到上限怎么收尾: 不是硬砍断,而是强制一次「无工具」的收尾调用。达到上限时,往消息里塞一条系统指令 _FINALIZE_INSTRUCTION(base.py:18:"你已经做了 25 次工具调用,请基于现有信息给最终答复,别再调工具了"),然后 agent.llm.gen(..., tools=None)——把 tools 传成 None,模型没工具可调,只能出文本(base.py:1185-1200)。流式版同理,cap_reached 时给 tools=None(base.py:1330-1335)。这样流永远以「有内容」收尾,而不是半截断掉。

3.2 非流式 vs 流式:同一个循环的两副面孔

要解决的小问题: 非流式一次拿到整块回复,直接能判断「要不要工具」;流式是一串碎片(chunk),一个工具调用的 id、名字、参数会分散在好几个 chunk 里,得先攒齐。

非流式——显式 while 循环(base.py:1152):结构最直白。解析 → 若要工具就 handle_tool_calls 执行、更新 messages → 检查是否要暂停 / 是否到上限 → 带新消息 agent.llm.gen(...) 再问 → 重新解析。一圈圈转,直到 requires_tool_call 为假。

流式——递归而非 while(base.py:1216):这是最容易看晕的地方。它遍历 _iterate_stream 吐出的 chunk,每个 chunk 解析后:

  • 是纯文本 chunk → 直接 yield,累加进 buffer(base.py:1344-1347);
  • 是「思考」chunk(type == "thought")→ 累加进 reasoning_buffer(base.py:1241-1244);
  • 带 tool_calls 的 chunk → 按 index 合并tool_calls 字典(下一节详述);
  • finish_reason == "tool_calls" → 这一轮工具攒齐了,执行它们,然后递归调用自己 handle_streaming(...)_iteration=next_iteration(base.py:1339-1342)开下一轮,再 return

所以流式循环的「下一轮」是靠递归实现的,_iteration 参数(base.py:1222)手动传递,替代非流式里的 iteration 局部变量,来数够没够 25 次。

3.3 流式的关键:按 index 把碎片拼成完整 ToolCall

要解决的小问题: 流式下,search_docs(query="退款") 这一个调用不会一次到齐。第一个 chunk 可能只有 id 和函数名,后面几个 chunk 各带一小段 arguments 字符串({"query":"退款"})。得靠一个稳定的键把它们缝起来——这个键就是 call.index

怎么缝(base.py:1252-1269):handler 维护一个 tool_calls = {} 字典,键是 index。每来一个带工具的 chunk:

# 示意,非源码:按 index 累积合并
for call in parsed.tool_calls:
if call.index not in tool_calls:
tool_calls[call.index] = call # 第一次见这个 index,占位
else:
existing = tool_calls[call.index]
if call.id: existing.id = call.id # 后续 chunk 有啥补啥
if call.name: existing.name = call.name
if call.arguments: # arguments 是「拼接」不是覆盖
existing.arguments += call.arguments
if call.thought_signature: # Gemini 3 的思考签名也要留住
existing.thought_signature = call.thought_signature

重点看 arguments 那行:它是 += 字符串拼接,把散落的 JSON 片段逐段接成完整的参数串;id/name 则是「有就覆盖」。等到 finish_reason == "tool_calls",list(tool_calls.values()) 就是这一轮攒齐的所有调用(base.py:1270-1277),交给 handle_tool_calls

thought_signature 是什么坑: 这是 Google Gemini 3 的「思考签名」——模型要求你在下一轮把它原样带回去,否则拒绝续答。所以从流式累积(base.py:1268)、到拼 assistant 消息(base.py:1032-1034)、到续跑(agents/base.py:276)全程都在小心翼翼地透传它。ToolCall 数据类专门为它留了字段(base.py:33)。

3.4 拼消息对:call_id 配对与「孤儿 tool 消息」的 400 坑

要解决的小问题: OpenAI 兼容接口有条铁律——每条 role:"tool" 的结果消息,前面必须紧跟一条带对应 tool_callsassistant 消息,且两者的 id 必须一字不差匹配。配不上,下一次 completion 直接 400:"'tool' must be a response to a preceding message with 'tool_calls'"。这是 handler 里注释最密集、最容易踩的地方。

正常路径怎么拼(base.py:1008-1062):对每个工具调用,handle_tool_calls 执行后追加一对消息:

  1. 一条 assistant 消息,content: None,tool_calls: [{id, function:{name, arguments}}](base.py:1036-1048);
  2. 紧跟一条 role:"tool" 结果消息(由各 provider 的 create_tool_message 生成,OpenAI 版见 handlers/openai.py:45)。

关键细节——用哪个 id: 结果消息的 tool_call_id 必须等于上面 assistant 里那个 call_id,而不是原始的 call.id(base.py:1051-1062)。为什么?因为 provider 可能压根没给 id,call.id 是空的;执行器返回的 call_id 是一个「provider 没给就现造一个 UUID」的兜底值。用错了这个,tool 消息就成了「孤儿」,下一轮 400。代码里专门造了个 resolved_call = ToolCall(id=call_id, ...) 来保证配对(base.py:1057-1059)。

异常路径也得成对(base.py:1063-1109):工具执行抛异常时,一样要补上「assistant(tool_calls) + role:tool(错误信息)」这一对,否则错误消息也会变孤儿 400。这里还有个精细的 assistant_appended 标志(base.py:1007):用来区分异常发生在「assistant 追加之前」还是「之后」——若成功路径已经追加过 assistant(是 create_tool_message 才失败的),异常分支就不能再追加第二条 assistant(会重复,同样 400),此时错误消息复用执行器的 call_id;否则异常分支自己补一条 assistant,用 call.id(base.py:1070, base.py:1083-1106)。

并列调用的一个坑: 一轮里的多个并行工具调用,会被拆成各自独立的 assistant 消息(每个只带一个 tool_call)。于是同一轮的 reasoning_content挂到每一条 assistant 上(base.py:1041-1047)——因为 DeepSeek thinking 模式会拒绝当前轮里任何一条缺 reasoning_content 的 assistant 消息。

3.5 人在环:暂停(pause)与续跑(continuation)

要解决的小问题: 有些工具不能程序自己直接跑——要么需要用户点「批准」(如执行代码、操作远程设备),要么必须在客户端执行(浏览器里跑的工具),要么在无人值守模式(定时任务 / webhook)下压根没人来批。这时循环不能傻等,得优雅地「挂起」或「合成一个拒绝」。

暂停的判断: 执行每个工具前,先问 agent.tool_executor.check_pause(tools_dict, call, llm_class)(base.py:901)。它返回一个 pending 字典(带 pause_type)或 None(tool_executor.py:486)。三种 pause_type:

pause_type场景handler 怎么处理
requires_client_execution客户端侧工具,需浏览器/前端跑yield 暂停事件,收集进 pending_actions,追加消息
需审批(如 require_approval)用户必须点批准同上,存 pending 等审批
headless_denied无人值守模式下遇到上述工具就地合成一条拒绝的 tool 结果,让模型优雅收尾

headless(无人值守)为何特殊(base.py:908-985):定时/webhook 跑的 agent 没有人能来点批准,傻等会永远卡住。所以 handler 直接合成一对「assistant(tool_calls) + role:tool(内容为 'Tool denied (headless): …')」消息(base.py:927-945),再记进 headless_denials 供对账器审计(base.py:946-968),continue 跳过。模型下一轮看到「这个工具被拒了」,就会转而给最终答复,而不是卡死。

暂停怎么存档(base.py:1170-1181 非流式 / base.py:1289-1300 流式):只要 handle_tool_calls 返回了非空的 pending_actions,循环就地打包一个 agent._pending_continuation 字典——里面存下当前的 messages、pending 列表、tools_dict、本轮的 reasoning_content——然后 yield {"type": "tool_calls_pending", ...} 通知客户端,并 return 退出循环。注意:暂停的工具故意不在这里追加消息(base.py:1000-1001),留到续跑时再追加,好让「调用/结果」配对整齐(呼应 3.4)。

续跑怎么接回(BaseAgent.gen_continuation,agents/base.py:234):客户端把用户的决定(批准 / 拒绝 / 客户端执行的结果)带回来后调它。它:

  1. 先拼一条 assistant 消息,把所有 pending 的 tool_calls 一次性放进去(agents/base.py:261-287)——匹配「一条 assistant 带 N 个 tool_calls,后跟 N 条结果」的格式;
  2. 逐个处理决定:approved → 服务端真执行(agents/base.py:301-321);denied → 追加一条「用户拒绝」的 tool 结果(agents/base.py:323-345);带 result 的 → 客户端已跑完,直接把结果作为 tool 消息追加(agents/base.py:347-370);
  3. 最后 self._llm_gen(messages, ...) + _handle_response(...)(agents/base.py:373-376)——重新进入同一个 handler 循环,续着往下跑。

于是「暂停 → 存档 → 前端收集用户决定 → gen_continuation → 重进循环」形成一个可以横跨多次 HTTP 请求的闭环。

3.6 跨 provider 回退后,为什么要换 handler 重新解析

要解决的小问题: LLM 抽象层允许「主模型挂了,自动 fail over 到备用模型」,而且这个回退发生在 agent 下面(BaseLLM._stream_with_fallback,application/llm/base.py:220)——一个 Google 主模型被限流,可能在同一次 gen_stream 调用里悄悄换成 OpenAI 兼容的备用模型来出结果。

问题出在哪: 这个 handler 是按主 provider 建的。如果备用是另一家,主 handler 的 parse_response 读不懂备用的 chunk 形状,会静默地丢掉工具调用——于是 agent 在第一段文本后就停了,工具循环根本没跑起来(base.py:83-88 注释)。

解法——_parse_for_response(base.py:78):循环里所有解析都不直接调 self.parse_response,而是走 _parse_for_response(agent, resp)。它去读 agent.llm._responding_provider(回退逻辑会把它更新成真正出结果的那家,见 llm/base.py:241),然后用 _handler_for_provider(base.py:100)拿到匹配那个 provider 的 handler 去 parse。_handler_for_provider 还带缓存,且当解析出的 provider 就是自己时直接复用 self(base.py:104-113),所以「无回退」的常见路径零开销。

为什么只路由 parse 这一步: 注释点明(base.py:89-93)——两家 provider 的 _iterate_streamcreate_tool_message 是一样的,只有 parse_response 是真正 provider 相关的一步;所以只把这一步路由出去,而编排状态(buffer、tool_callsllm_calls)全留在 self 上,不搬家。

3.7 上下文塞满时:执行中途压缩(mid-execution compression)

要解决的小问题: 工具循环跑久了,工具结果(尤其检索/数据库返回的大块数据)会把上下文撑爆。撑爆了不能直接崩,得想办法腾地方接着跑。

触发点在哪: 就在 handle_tool_calls 逐个执行工具的循环体内部——执行下一个工具前,先 agent._check_context_limit(updated_messages)(base.py:796)。命中就进入压缩分支。

三级降级(base.py:800-897):

  1. 优先:执行中压缩 _perform_mid_execution_compression(base.py:504)——把当前对话交给 CompressionOrchestrator 归纳成摘要,再 _rebuild_messages_after_compression 重建一份更短的消息(base.py:607-618)。成功就 yield 一条 info、用重建后的消息继续跑剩下的工具。
  2. 次选:最小剪枝 _prune_messages_minimal(base.py:299)——压缩失败或没减小 token 时,粗暴地只留「系统提示 + 最近一条用户/assistant 消息」,把所有 tool 消息全丢掉(base.py:299-319, base.py:562-567)。
  3. 兜底:跳过剩余工具——压缩没开或全失败时,把当前及之后所有未执行的工具标成 status: "skipped"(合成一条 skip 事件 yield 出去),置 agent.context_limit_reached = True,break 出循环(base.py:880-897)。

之后流式循环看到 context_limit_reached 为真,会追加一条「请收尾、别再调工具」的系统消息并把 tools=None(base.py:1306-1335),逼模型基于现有内容给最终答复。

压缩算法本身(怎么归纳、compression point 怎么选、token 怎么数)不在本章展开,见 摄取管线与高级 agent。这里只需知道:触发钩子长在工具循环里,压缩细节委托给外部服务


4. 巧妙之处(可借鉴的技术)

  • 上限不砍断、而是「无工具收尾」。 到 25 次不是直接 return,而是塞 _FINALIZE_INSTRUCTION + tools=None 再问一次(base.py:1185-1200),保证流永远以自然语言收尾。用户体验上「模型自己说完了」远好过「流被截断」。

  • 只路由 provider 相关的那一步。 跨 provider 回退时,不是整个 handler 换掉,而是精确识别出「只有 parse_response 是 provider 相关的」,单独路由它,其余编排状态原地不动(base.py:89-98)。这是「最小改动面」的教科书式应用。

  • 暂停的工具故意延后追加消息。 pending 的调用在暂停点写进 messages(base.py:1000-1001),而是等 gen_continuation 时把「一条 assistant 带全部 tool_calls + 逐条结果」一次性拼好(agents/base.py:261-287)。这样即便中间隔了好几次 HTTP 往返,调用/结果的配对也不会错位。

  • assistant_appended 标志防重复 assistant。 异常处理里用一个布尔量精确区分「异常发生在追加 assistant 之前还是之后」(base.py:1007, base.py:1070, base.py:1083),避免了「补错误消息时不小心追加了第二条 assistant」这种会 400 的隐蔽 bug。

  • thought_signature 全链路透传。 为兼容 Gemini 3,思考签名从流式累积到消息拼装到续跑,每一处都显式保留(base.py:33, base.py:1032-1034, agents/base.py:276)。


5. 边界与局限

  • 续跑依赖客户端回传。 需审批 / 客户端执行的工具,暂停后必须由客户端调 gen_continuation 才能续(agents/base.py:234)。若客户端不回,_pending_continuation 就一直挂着——所以无人值守模式才需要 headless_denied 这条合成拒绝的旁路(base.py:908)。

  • 压缩是「尽力而为」的降级。 三级降级最坏会跳过尚未执行的工具(base.py:880-897)并置 context_limit_reached,答复质量随之下降。它保证「不崩」,不保证「不丢工作」。

  • 25 是硬编码常量。 MAX_TOOL_ITERATIONS(base.py:17)不是每个 agent 可配的;真正需要长链工具调用的场景会被它截断到收尾。

  • 孤儿消息的防护是「约定」而非「类型保证」。 call_id 配对、成对追加靠代码里手写的逻辑和注释维持(base.py:1051-1109),没有类型系统强制;改动这块极易引入 400。


6. 代码地图(导航索引)

主题文件路径符号名
循环总入口、按 stream 分流application/llm/handlers/base.pyLLMHandler.process_message_flow
非流式显式 while 循环application/llm/handlers/base.pyLLMHandler.handle_non_streaming
流式递归循环、按 index 累积工具调用application/llm/handlers/base.pyLLMHandler.handle_streaming
逐个执行工具、拼 assistant+tool 消息对application/llm/handlers/base.pyLLMHandler.handle_tool_calls
迭代上限与收尾指令application/llm/handlers/base.pyMAX_TOOL_ITERATIONS, _FINALIZE_INSTRUCTION
循环判据application/llm/handlers/base.pyLLMResponse.requires_tool_call
工具调用标准结构 / 思考签名application/llm/handlers/base.pyToolCall
跨 provider 回退后按 provider 重新解析application/llm/handlers/base.pyLLMHandler._parse_for_response, _handler_for_provider
执行中途压缩钩子application/llm/handlers/base.py_perform_mid_execution_compression, _prune_messages_minimal
provider 侧回退、更新 _responding_providerapplication/llm/base.pyBaseLLM._stream_with_fallback
暂停判定(审批/客户端/headless)application/agents/tool_executor.pyToolExecutor.check_pause
暂停后续跑application/agents/base.pyBaseAgent.gen_continuation
handler → agent 的接线application/agents/base.pyBaseAgent._llm_handler
provider → handler 映射application/llm/handlers/handler_creator.pyLLMHandlerCreator
OpenAI 版解析 / tool 消息生成application/llm/handlers/openai.pyOpenAILLMHandler.parse_response, create_tool_message