工具循环与 MCP:把模型的话落到真实工具
30 秒导读: 模型只会"说"要调哪个工具,真去调、把结果再喂回去让它接着想的是运行时。本章讲 Yao 的这套"手脚":一个最多 5 轮的工具循环(把 tool 结果回灌 LLM 直到它不再要工具),循环失败时的兜底委派,以及底层怎么把 MCP server 的工具翻成模型能调的 schema、怎么并行/顺序地执行它们。
本章只讲运行时执行,不讲 MCP 传输层协议(那在仓库 mcp/ 包,不属本 area)。上游怎么把 DSL 装载成 Assistant 见 01-loading;一次请求的完整生命周期见 02-pipeline;V8 里的 Hooks 见 03-hooks-jsapi。
1. 这是什么(零基础也能懂)
一句话定义: 当模型回一句"请帮我调 weather__get 查北京天气"时,这段代码负责真的去调、把返回值再交回模型、让它基于结果继续答或继续调,直到模型给出最终文字。
它解决什么问题: 一次问答里,模型常常要多步才能答完——先查天气、再查航班、最后综合。每一步模型都只输出"我要调 X",工具的真实结果得由运行时补上、回灌,模型才有下一步。这个"调→喂回→再想"的往复,就是工具循环(tool loop)。
一个直观场景:
用户:北京今天适合穿什么?
模型:(要调工具) weather__get(city=北京) ← 第 1 轮
运行时:真去调 → {"temp":5,"wind":"大"} ← 落到真实工具
模型:(拿到结果,再要工具) clothing__advise(temp=5) ← 第 2 轮
运行时:真去调 → {"advice":"厚外套"}
模型:今天北京 5℃ 风大,建议厚外套。 ← 无工具 = 收敛,结束
一句话直觉: 把工具循环想成模型和现实之间的传话筒 + 计次器——它替模型跑腿,但只肯跑固定几趟(默认 5 趟),跑不完就换个"擦屁股"的备用 agent 收场。
本节不出现代码细节。记住一件事:模型负责决策,运行时负责落地和收敛。
2. 先分清两条"重试",别混为一谈(最关键的一节)
Yao 里有两条名字都像"重试"、但机制完全不同的循环。读代码最容易在这里迷路,先钉死区别。
| 维度 | 工具参数重试(retry) | 工具循环(tool loop) |
|---|---|---|
| 解决什么 | 模型这一轮参数填错了(缺字段、类型不符),让它改参数重来 | 模型答一步不够,要基于结果继续下一步 |
| 触发 | 工具执行报可修复错误 | 工具执行成功,但还要把结果喂回模型 |
| 次数上限 | maxToolRetries = 3(硬编码) | max_turn,默认 5(可配) |
| 每轮塞给模型 | 结果 + 一段重试 system 提示 | 结果,不加重试提示 |
| 代码位置 | agent/assistant/agent.go:365 起 | agent/assistant/loop.go:32 executeToolLoop |
| 消息拼装 | buildToolRetryMessages(agent.go) | buildToolLoopMessages(loop.go) |
它俩前后接力,不是二选一:
一次请求里,先跑参数重试块,再进工具循环
────────────────────────────────────────────────
① 模型首答带 tool_calls
│
▼
② 参数重试块(agent.go:369 for attempt<3)
├─ 执行工具 → 全成功? ── 是 ─┐
├─ 有可修复错? 改参数重来(≤3) │ 这里只跑"第 1 轮"的工具,
└─ 有不可修复错? 直接失败退出 │ 拿到干净结果
▼
③ 工具循环(agent.go:549 → loop.go) 把结果喂回模型,
└─ 循环 ≤ max_turn 轮,直到模型不再要工具
一句话:参数重试管"这一步调对没有",工具循环管"要不要再来一步"。 本章主讲 ③,①②(参数重试)的细节属于 02-pipeline 的主管道叙事,这里只借它交代边界。
依据:
agent.go:365硬编码maxToolRetries := 3;loop.go:178getMaxToolLoopTurns默认5。两处常量、两个函数,互不共享。
3. 顶层全景:一次含工具的对话怎么收敛
怎么读这张图: 从上到下是控制流。左边是"决定走哪条路"的分支(在 agent.go),右边是工具循环的内部往复(在 loop.go)。命中"无 tool_calls"或"到轮数上限"即离开循环。
agent.go 尾段:处理工具结果时的三岔路(agent.go:501/549/586)
──────────────────────────────────────────────────────────
有 tool 结果了,怎么继续?
│
┌───────────────┼────────────────────┐
▼ ▼ ▼
有 Next 钩子? 无钩子 + 未禁循环 其它(沙箱/禁循环/无工具)
走 Next 处理 → 进工具循环 → 直接出标准响应
(见 03 章) (agent.go:552) buildStandardResponse
│
▼
┌────────────── executeToolLoop (loop.go:32) ──────────────┐
│ for turn in 0..max_turn: │
│ ① 拼消息 = 历史 + assistant(tool_calls) + 每个 tool 结果 │
│ (buildToolLoopMessages, loop.go:127) │
│ ② 调 LLM(executeLLMStream) │
│ ③ 模型没再要工具? ── 是 ──► 出最终响应,return ✓ │
│ ④ 要工具 → executeToolCalls 真去调 (mcp.go:223) │
│ ⑤ 结果转 ToolCallResponse,累积,进下一轮 │
└──────────────────────────────────────────────────────────┘
│ 超轮 / LLM 调用失败
▼
兜底:委派 __yao.loop_fallback(loop.go:204)
再不行:buildStandardResponse 硬收场(agent.go:570)
各部件一句话职责:
| 部件 | 干什么 | 在哪 |
|---|---|---|
| 三岔路分支 | 决定走 Next 钩子 / 工具循环 / 标准响应 | agent.go:549、:586 |
executeToolLoop | 多轮把 tool 结果喂回 LLM 直到收敛 | loop.go:32 |
buildToolLoopMessages | 拼"历史 + assistant(tool_calls) + 各 tool 结果" | loop.go:127 |
executeToolCalls | 真去调工具(单个/并行/顺序) | mcp.go:223 |
buildLoopFallbackDelegate | 循环挂了,打包上下文委派兜底 agent | loop.go:204 |
buildMCPTools | 把 MCP server 工具翻成 LLM 能调的 schema | mcp.go:75 |
4. 工具循环:executeToolLoop 怎么把结果喂回去
4.1 它要解决的小问题
模型一次只输出一步。要让它"看到"上一步的真实结果、继续往下走,运行时必须:把它自己上一轮说的 tool_calls 和工具的真实返回,拼成新一轮的对话,再调一次模型。反复,直到模型这轮不再要任何工具——那就是最终答案。
4.2 循环骨架(真实实现)
executeToolLoop(loop.go:32)是一个定长 for 循环,轮数上限来自 getMaxToolLoopTurns():
// loop.go:43 —— 循环上限固定,靠"提前 return"来收敛
for turn := 0; turn < maxTurns; turn++ {
loopMessages := buildToolLoopMessages(currentMessages, currentCompletion, ...)
newCompletion, err := ast.executeLLMStream(ctx, loopMessages, ...) // 调模型
if newCompletion.ToolCalls == nil || len(newCompletion.ToolCalls) == 0 {
return ast.buildStandardResponse(...), newCompletion, allToolResponses, nil // 收敛
}
toolResults, _ := ast.executeToolCalls(ctx, newCompletion.ToolCalls, 0) // 再去调
// ... 转成 ToolCallResponse,累积到 allToolResponses,进下一轮
}
return nil, nil, allToolResponses, fmt.Errorf("tool loop reached max turns (%d)", maxTurns)
三个关键点:
- 出口只有两个: 模型不再要工具(
loop.go:67,正常 return),或跑满maxTurns(loop.go:121,返回 error 交给兜底)。 - 结果只累积不丢:
allToolResponses从头累加(loop.go:115),最终响应带着全部轮次的工具结果。 - 循环里再调工具,重试次数传 0:
executeToolCalls(ctx, ..., 0)(loop.go:85)——循环内的工具执行不走 agent.go 那套参数重试,attempt恒为 0。
4.3 消息怎么拼:buildToolLoopMessages
这是循环的"记忆拼装术"(loop.go:127)。每进一轮,把三段接起来喂给模型:
新一轮 messages =
[ 之前所有消息 ]
+ { role: assistant, content, tool_calls } ← 模型上一轮"我要调这些工具"
+ { role: tool, content: 结果, tool_call_id } ← 每个 tool_call 一条结果
+ { role: tool, ... }
真实实现里,每个工具结果单独一条 role=tool 消息,靠 ToolCallID 和上面 assistant 消息里的 tool_call 对上号:
// loop.go:144 —— 一个工具结果 = 一条 tool 消息;出错就写 "Error: ..."
for _, tr := range toolResponses {
var content string
if tr.Error != "" {
content = fmt.Sprintf("Error: %s", tr.Error)
} else if tr.Result != nil {
raw, _ := jsoniter.MarshalToString(tr.Result)
content = raw
}
messages = append(messages, context.Message{
Role: context.RoleTool, Content: content, ToolCallID: &toolCallID,
})
}
和参数重试的拼装差在哪: 注释点明了(loop.go:126)——buildToolLoopMessages 不追加重试 system 提示,而 buildToolRetryMessages 会追加。因为工具循环里工具是成功的,不需要催模型"你参数填错了改一改"。
4.4 开关与轮数:两个小配置
工具循环的行为由 mcp.options 里两个键控制,都有安全默认:
| 配置键 | 读它的函数 | 默认 | 语义 |
|---|---|---|---|
mcp.options.tool_loop | isToolLoopDisabled(loop.go:165) | 启用 | 只有显式设 false 才关循环 |
mcp.options.max_turn | getMaxToolLoopTurns(loop.go:178) | 5 | 循环最多几轮;>0 才生效,否则回默认 |
isToolLoopDisabled 的写法值得学——默认启用、只认显式 false:
// loop.go:165 —— MCP 没配 / 没这个键,一律当"启用"(返回 false = 未禁用)
func (ast *Assistant) isToolLoopDisabled() bool {
if ast.MCP == nil || ast.MCP.Options == nil {
return false
}
if v, ok := ast.MCP.Options["tool_loop"]; ok {
if enabled, ok := v.(bool); ok {
return !enabled
}
}
return false
}
getMaxToolLoopTurns 同时接受 float64(JSON 数字)和 int 两种类型,且只在 >0 时采纳(loop.go:184-193)——防止配置里写了 0 或负数把循环卡死。
5. 兜底:循环挂了怎么擦屁股
5.1 什么时候兜底
工具循环返回 error(超轮,或中途某次 LLM 调用失败)时,agent.go:563 不直接把错误抛给用户,而是降级委派给一个专门的兜底 agent:
executeToolLoop 出错
│
▼
buildLoopFallbackDelegate → 委派 __yao.loop_fallback (agent.go:566)
│ 成功 → 用它的回答
│ 又失败 → buildStandardResponse 硬收场(agent.go:570)
__yao.loop_fallback 是内置系统 agent(注册见 agent/assistant/load_system.go:32、:242)。三级降级保证无论如何都给用户一个回答,不会因为循环没收敛就报错空手而归。