跳到主要内容

工具循环与 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:365agent/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:178 getMaxToolLoopTurns 默认 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循环挂了,打包上下文委派兜底 agentloop.go:204
buildMCPTools把 MCP server 工具翻成 LLM 能调的 schemamcp.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)

三个关键点:

  1. 出口只有两个: 模型不再要工具(loop.go:67,正常 return),或跑满 maxTurns(loop.go:121,返回 error 交给兜底)。
  2. 结果只累积不丢: allToolResponses 从头累加(loop.go:115),最终响应带着全部轮次的工具结果。
  3. 循环里再调工具,重试次数传 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_loopisToolLoopDisabled(loop.go:165)启用只有显式设 false 才关循环
mcp.options.max_turngetMaxToolLoopTurns(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)。三级降级保证无论如何都给用户一个回答,不会因为循环没收敛就报错空手而归。

5.2 兜底怎么打包上下文:压成 Markdown

buildLoopFallbackDelegate(loop.go:204)不把内部消息结构直接甩给兜底 agent,而是先用 buildLoopFallbackMarkdown(loop.go:221)把整段上下文压成一篇人类可读的 Markdown,当成一条 role=user 消息塞进去。生成的 Markdown 长这样:

## Assistant Context
(把 system 消息原文拼进来)

## Conversation
**User**: 北京今天适合穿什么?
**Assistant**: ...

## Tool Results
### weather.get
```json
{"temp":5,"wind":"大"}
```

---
Please answer the user's question based on the above context and tool results.
Respond in the same language as the user.

为什么压成 Markdown 而非透传消息数组: 兜底 agent 是个独立的、无状态的 agent,它不需要(也不应依赖)主循环那套 tool_calls / tool 消息的精确结构。给它一篇"人话简报"最稳——它只需读懂上下文、直接作答。工具出错的项会写成 Error: ... 而非 json 块(loop.go:259)。

注意末尾那句 Respond in the same language as the user.——兜底 agent 换了一个 prompt 语境,得显式叮嘱它跟随用户语言,否则容易漂成英文。


6. MCP 工具:模型的话怎么落到真实工具

前面循环里那句 executeToolCalls 是"手脚"的入口。要理解它,先看工具是怎么被命名、被翻译成模型能调的东西的。

6.1 命名编码:一个名字里塞两样信息

模型只会给一个扁平的工具名(如 github_enterprise__search),但运行时得知道"这属于哪个 MCP server、原始工具名是啥"。Yao 用一套双下划线编码在一个字符串里编进两样信息:

函数干什么例子
MCPToolName(mcp.go:31)server + tool → 扁平名("github.enterprise","search")github_enterprise__search
ParseMCPToolName(mcp.go:48)扁平名 → server + tool反过来

编码规则(注释写在 mcp.go:22):

  • 分隔符是双下划线 __;server 与 tool 之间用它切。
  • server_id 里的点 . 换成单下划线 _,解析时再换回来。
  • 硬约束:server_id 不许含下划线——否则解析会切错。只允许点、字母、数字、连字符。
// mcp.go:31 —— 点变单下划线,再用双下划线拼
cleanServerID := strings.ReplaceAll(serverID, ".", "_")
return fmt.Sprintf("%s__%s", cleanServerID, toolName)

这套编码是全链路的地基:执行时靠 ParseMCPToolName 从模型给的名字里还原 server(mcp.go:257);并行分组也靠它按 server 归堆(mcp.go:430)。

6.2 buildMCPTools:把 server 工具翻成 LLM schema

buildMCPTools(mcp.go:75)是"上菜"环节——请求发给模型之前,把可用的 MCP 工具列成模型能理解的 tool schema。流程:

选哪些 server(钩子优先于 assistant 配置, mcp.go:80)
│ 每个 server:
├─ mcp.Select(serverID) 拿客户端 (mcp.go:116)
├─ ListTools 列工具,按 serverConfig.Tools 过滤 (mcp.go:123/145)
├─ 每个工具:MCPToolName 编码 → MCPTool{Name,Desc,Params}
└─ 顺带抓 samples 拼进 system 提示(教模型怎么用)(mcp.go:162)


封顶:allTools 满 MaxMCPTools(=20)就停,别淹没模型 (mcp.go:19/110)

产出的 MCPTool(types.go:42)只是个中间结构(名字、描述、参数 schema 三字段)。真正变成 LLM 请求里的 tools 数组,在 build.go:573buildAndApplyMCPTools——套上标准的 function-calling 外壳:

// build.go:576 —— MCPTool → OpenAI 风格 function tool schema
toolMaps[i] = map[string]interface{}{
"type": "function",
"function": map[string]interface{}{
"name": tool.Name, // 双下划线编码的扁平名
"description": tool.Description,
"parameters": tool.Parameters, // 来自 MCP 的 InputSchema
},
}

两个防呆细节:

  • 工具数封顶 20(MaxMCPTools,mcp.go:19):跨 server 累计,满了就跳过后续 server(mcp.go:110),避免工具太多把模型的选择淹没。
  • samples 教学:若某工具有示例,把最多 3 条拼进一段 ## MCP Tool Usage Examples 系统提示(mcp.go:162-203),等于给模型"看着例子照做"。

7. 执行:单个 / 并行 / 顺序,与容错

executeToolCalls(mcp.go:223)是执行入口,按这一轮要调几个工具分派策略:

executeToolCalls (mcp.go:223)

├─ 0 个 → 直接返回
├─ 1 个 → executeSingleToolCall (mcp.go:240) 单条 trace
└─ 多个 → executeMultipleToolCallsParallel (mcp.go:418)
│ 按 server 分组(ParseMCPToolName) (mcp.go:428)
│ 每组:
├─ 并行 executeServerToolsParallelWithTrace (mcp.go:551)
└─ 并行里有"可修复错"? → 退化为顺序重跑
executeServerToolsSequentialWithTrace (mcp.go:681)

7.1 单条:执行前的三道校验

executeSingleToolCall(mcp.go:240)在真正 CallTool 之前,层层设卡,每道失败都标好"可否重试":

校验失败结果可重试?
ParseMCPToolName 名字格式无效名
mcp.Select 拿到客户端选客户端失败否(mcp.go:271)
gouJson.Parse 解析参数(带修复)参数非法 JSON是(mcp.go:320)
参数必须是对象类型不符是(mcp.go:335)
gouJson.Validate 对 schema 校验校验不过是(mcp.go:349)

"可否重试"这个标记(IsRetryableError)就是喂给 §2 那个参数重试块的信号:参数类的错才值得让模型改了重来,网络类的错重试也白搭。

7.2 并行 vs 顺序:先并行,踩雷才退顺序

多工具时先按 server 分组并行(mcp.go:442 起,CallToolsParallelmcp.go:609)。并行快,但如果某组出现可修复的参数错,shouldRetrySequential(mcp.go:538)会判定"值得顺序重跑一遍":

// mcp.go:465 —— 并行有可修复错 → 顺序重跑(顺序里能逐个校验、逐个修)
if serverHasErrors && ast.shouldRetrySequential(serverResults) {
serverResults, serverHasErrors = ast.executeServerToolsSequentialWithTrace(...)
}

顺序版(mcp.go:681)比并行版多做了逐个 schema 校验(mcp.go:777)——并行图快省了这步,顺序重跑时补上,把能本地拦下的参数错拦住,不再浪费一次真实调用。

7.3 错误可否重试:isRetryableToolError 的黑白名单

isRetryableToolError(mcp.go:484)是"这错该不该让模型再试"的裁判,用两张关键词名单:

类别含这些词判为例词
不可重试(MCP 内部问题)falsenetworktimeoutunauthorizedunavailablecontext canceled
可重试(模型能改的参数问题)trueinvalidrequiredmissingvalidationschemaparameter
都不匹配默认 true未知错也给模型一次机会(mcp.go:534)

设计取向很明确:宁可多给模型一次改的机会(默认可重试),只把明确是基础设施故障的词拉黑。


8. 边界与局限(诚实)

  • 循环不收敛就"半途换人"。 跑满 max_turn 不是报错,而是委派兜底 agent 重新作答(§5);兜底 agent 看的是压平的 Markdown,拿不到原始 tool_calls 结构,复杂多步任务的中间状态可能损失。
  • 工具数硬上限 20。 超过 MaxMCPTools 的 server 直接被跳过(mcp.go:110),且是按 server 顺序截断,不是按相关性挑选——工具多的场景要靠 serverConfig.Tools 白名单自己收窄。
  • server_id 不能含下划线。 这是命名编码的硬约束(mcp.go:29),违反会导致 ParseMCPToolName 切错、整条工具链错乱。代码里对此只在注释里约定,没有运行时强校验
  • 循环内工具无参数重试。 executeToolLoop 调工具时 attempt 恒传 0(loop.go:85),循环里某一轮工具参数错不会触发 agent.go 那套 3 次重试——只会把错误文本喂回模型,靠模型自己在下一轮纠正。
  • 沙箱模式整个绕过本章。 HasSandboxV2() 为真时,工具由 Claude CLI 内部处理,agent.go 既跳过参数重试块(agent.go:363)也跳过工具循环(agent.go:549)——那条路见 06-memory-sandbox

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

用符号名 grep 比行号更抗漂移。以下都在 agent/assistant/ 下。

主题文件符号
工具循环主体loop.goexecuteToolLoop
循环消息拼装loop.gobuildToolLoopMessages
循环开关loop.goisToolLoopDisabled
循环轮数上限loop.gogetMaxToolLoopTurns
兜底委派构造loop.gobuildLoopFallbackDelegate
兜底上下文压 Markdownloop.gobuildLoopFallbackMarkdown
参数重试块(对照)agent.gomaxToolRetries(:365)
参数重试消息拼装agent.gobuildToolRetryMessages
三岔路分支agent.go:549:586
工具名编码/解码mcp.goMCPToolName / ParseMCPToolName
建 LLM 工具 schemamcp.gobuildMCPTools(常量 MaxMCPTools)
schema 套壳进请求build.gobuildAndApplyMCPTools
执行入口分派mcp.goexecuteToolCalls
单工具执行mcp.goexecuteSingleToolCall
多工具并行mcp.goexecuteMultipleToolCallsParallel
单 server 并行/顺序mcp.goexecuteServerToolsParallelWithTrace / executeServerToolsSequentialWithTrace
错误可否重试判定mcp.goisRetryableToolError / shouldRetrySequential
结果结构与解析types.goToolCallResult(Server/Tool/ParsedContent)
兜底 agent 注册load_system.goloop_fallback(:32:242)