工具循环引擎:ToolRunner 如何驱动一次 agent turn
30 秒导读: 一个 agent 之所以能「自己动手」,靠的是一段循环——模型先说话,如果它要求调用工具就去执行,把工具结果喂回去让模型接着说,直到模型某一次不再要工具为止。这一章讲 fast-agent 里驱动这段循环的引擎:
ToolRunner(一个 async 迭代器)和它背后ToolAgent的工具执行。不讲工具从哪来(MCP 聚合见 05)、模型底层怎么调(见 04)。
本章在整套货架里的位置:上一章 02-agent-class-stack.md 讲了 ToolAgent 这个类栈层级本身;这一章讲的是它运行时最核心的那台机器。
1. 这是什么(零基础也能懂)
一句话定义: ToolRunner 是「一次对话回合(turn)」的循环控制器——它反复地「问模型 → 执行模型要的工具 → 把结果还给模型」,直到模型给出不带工具请求的最终回答。
为什么需要它。 大语言模型本身只会「输入文字、输出文字」。当你希望它能查数据库、读文件、调 API,模型能做的其实只是在输出里说「我想调用工具 X,参数是 Y」。真正去执行那个工具、把结果拿回来、再让模型基于结果继续——这些「跑腿」的活儿,得由框架侧的一段循环来做。ToolRunner 就是这段循环。
用起来什么样。 使用者几乎感觉不到它的存在。你调用 agent 的 generate(...),框架内部建一个 ToolRunner 并把循环跑到底:
# 示意,非源码:使用者视角只有一句话
response = await agent.generate("帮我查一下今天北京的天气并总结")
# 这一句背后:模型说"调 get_weather" → 框架执行 → 结果喂回 → 模型给出总结
# 中间可能来回好几轮,ToolRunner 全程替你转
一句话直觉/类比。 把一次 turn 想成打乒乓球:模型发球(说话),如果球上写着「请帮我做 X」(工具请求),裁判(ToolRunner)就去 做 X 再把球打回给模型;模型再打一板……直到某一板模型直接把球扣死(给出最终答案、不再要工具),这一回合才结束。
2. 顶层全景(一次 turn 大概怎么转)
2.1 两个角色
一次 turn 的循环由两个协作对象完成,职责清晰分开:
| 角色 | 干什么 | 在哪个文件 |
|---|---|---|
ToolRunner | 循环的骨架:决定「该再问一次模型,还是该收尾」;管中断回滚、持久化、auto-compaction 挂钩 | src/fast_agent/agents/tool_runner.py:131 |
ToolAgent | 循环的两只手:一手调模型(_tool_runner_llm_step),一手规划并执行工具(run_tools) | src/fast_agent/agents/tool_agent.py:50 |
ToolRunner 只关心「循环的形状」,不关心工具怎么执行、模型怎么调——那两件事它都回调给 agent 去做。这种「循环控制」与「具体动作」的解耦,是这套引擎最干净的地方。
2.2 一次 turn 的循环流向
先说怎么读这张图:从上往下是时间;ToolRunner 是一个迭代器,外层 until_done() 反复向它取下一条消息;每取到一条模型消息就看它的 stop_reason——TOOL_USE 就回到顶上再转一圈,其它就收尾。
┌───────────────────────────────────────────┐
until_done() │ 外层:反复取下一条消息 │
反复 async for │ (tool_runner.py:287) │
└───────────────────┬───────────────────────┘
│ __anext__()
▼
┌──────────────────────────────────────────────────────┐
│ ① 先处理"上一板留下的工具请求" │
│ _prepare_next_llm_step → _ensure_tool_response_staged│
│ 有 pending 工具请求? ── 有 ─► 执行工具(run_tools) │
│ 把结果暂存为下一次输入 │
│ ── 无 ─► 直接往下 │
└───────────────────┬──────────────────────────────────┘
│(没有已暂存的"终局消息"就继续)
▼
┌──────────────────────────────────────────────────────┐
│ ② 问模型 │
│ _maybe_auto_compact_before_followup_llm (可选压缩) │
│ before_llm_call 钩子 │
│ _call_llm ──► agent._tool_runner_llm_step(消息, 工具)│
│ after_llm_call 钩子 │
└───────────────────┬──────────────────────────────────┘
│ 得到一条 assistant_message
▼
┌──────────────────────────────────────────────────────┐
│ ③ 看 stop_reason 决定去留 │
│ _apply_assistant_message_state │
│ == TOOL_USE ─► 记下 pending 工具请求,回到 ① │
│ 结构化收尾 ─► 追加"出最终 JSON"指令,回到 ② │
│ 其它 ─► _done = True,循环结束 │
└───────────────────┬──────────────────────────────────┘
│ yield assistant_message 给外层
▼
外层看到 TOOL_USE → 持久化 checkpoint,继续循环
外层看到 非 TOOL_USE → 触发 after_turn_complete,返回最终消息
主线走一遍(高层): 输入消息进 ToolRunner → 第一圈没有待办工具,直接问模型 → 模型回 TOOL_USE → 下一圈先把工具跑掉、结果暂存 → 再问模型 → 模型这次回 END_TURN → 收尾返回。中间来回几圈由模型自己决定,上限 199 圈(见 §3.4)。