工具循环:LLMHandler 如何驱动多轮工具调用、暂停与续跑
30 秒导读: 一个能用工具的 agent,本质是一个循环——「问模型 → 模型说『我要调工具 X』→ 真去执行 X → 把结果拼回对话 → 再问模型」,直到模型不再要工具、直接回话为止。DocsGPT 里驱动这个循环的心脏是
application/llm/handlers/base.py的LLMHandler。本章讲清这个循环怎么转、怎么在需要人类审批时暂停再续跑、怎么在模型跨 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 |