
字节笔记本
2026年10月24日 · 约 21 分钟读完
一次工具调用的完整生命周期

本文是《从 0 写 Agent》系列第 4 章。这个系列基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版),用 Go 从零实现一个生产级 Agent 运行时;本章讲「工具调用的完整生命周期」。
第 3 章我们讲了"工具怎么定义"。这一章讲"工具怎么被调用",从模型说"我要调工具",到工具真正执行,到结果回喂,到下一轮循环。
这整个过程,agents-go 用一个叫
executeToolsAndSideEffects的函数搞定,它做的事比你想的精细得多,HITL 分区、并发执行、guardrail、错误回喂、handoff 优先……每一处都有设计权衡。
引子:一次工具调用背后发生了什么
看起来简单的"调用工具",拆开其实是一长串步骤:
模型说"我要调 get_weather(北京)"
→ 这是个合法工具吗?(查表)
→ 这个工具需要审批吗?(HITL 检查)
→ 参数合法吗?(输入 guardrail)
→ 真正执行工具
→ 输出合法吗?(输出 guardrail)
→ 把结果包装成对话历史的一项
→ 加入下一轮循环的输入七步,缺一不可。 而且,如果有多个工具调用,还要并行;如果工具出错,要决定是中断还是回喂;如果有 handoff,要优先处理。
agents-go 的 executeToolsAndSideEffects(agents/run_step.go:165)把这些全管了。这一章我们逐段拆。

原理:分类 → 执行 → 决定下一步
工具调用的处理可以分成三个阶段:
阶段一:分类(processModelResponse)
模型返回的响应里,可能有消息、推理、工具调用、handoff 调用混在一起。第一步要把它们分类,哪些是普通消息,哪些是工具调用,哪些是 handoff,哪些是模型瞎说的不存在的工具。
阶段二:执行(executeToolsAndSideEffects)
把分类出来的工具调用真正执行掉,并发执行函数工具、处理审批、处理 handoff、回喂未知工具错误。
阶段三:决定下一步
执行完后,runner 要决定,继续循环(还有工具要调)?还是返回(模型给了最终答案)?还是 handoff(换 Agent)?还是暂停(等审批)?
源码对照:分类 processModelResponse
processModelResponse(agents/run_step.go:103)干的事是,遍历模型响应的每个输出项,按类型分拣:
// agents/run_step.go:103(骨架)
func processModelResponse(agent, tools, handoffs, resp, toolNotFound) (*processedResponse, error) {
// 建两张查找表:handoff 和 function
handoffMap := map[string]Handoff{...}
functionMap := map[string]*FunctionTool{...}
for _, item := range resp.Output {
switch item.Type {
case "message": // 普通消息
newItems = append(newItems, &MessageOutputItem{...})
case "reasoning": // 推理(o1 系列)
newItems = append(newItems, &ReasoningItem{...})
case "function_call": // 工具/handoff 调用
// 先查 handoffMap(handoff 优先)
if h, ok := handoffMap[item.Name]; ok {
handoffs = append(handoffs, toolRunHandoff{h, call})
} else if t, ok := functionMap[item.Name]; ok {
functions = append(functions, toolRunFunction{t, call})
} else {
// 模型调用了一个不存在的工具!
unknownTools = append(unknownTools, call)
}
}
}
return &processedResponse{NewItems, Functions, Handoffs, ToolsUsed, UnknownTools}
}注意三个细节:
1. handoff 优先于 function。 一个 function_call 先查 handoffMap,命中就是 handoff(第 15 章)。这保证交接逻辑不被普通工具"截胡"。
2. 模型可能调用不存在的工具。 这是真实情况,模型会幻觉。agents-go 用 ToolNotFoundBehavior 决定怎么处理:
// agents/run_step.go:52
const (
ToolNotFoundError ToolNotFoundBehavior = iota // 默认:中断 run
ToolNotFoundReturnToModel // 把错误喂回模型让它自纠
)默认中断(ToolNotFoundError),因为模型调用不存在工具通常意味着 prompt 设计有问题,应该让人看。但也可以选 ToolNotFoundReturnToModel,合成一个"这个工具不存在,请用 xxx"的错误喂回去,让模型自己改。这是工程选择,严格 vs 宽容。
3. 推理项单独保留。 o1/o3 系列模型会输出推理过程,agents-go 保留它(ReasoningItem),但不影响执行逻辑。
源码对照:执行 executeToolsAndSideEffects
executeToolsAndSideEffects(agents/run_step.go:165)是真正的执行器,它的执行顺序极其讲究:
// agents/run_step.go:165(骨架,顺序至关重要)
func (r *runner) executeToolsAndSideEffects(...) (*singleStepResult, error) {
// 1. 收集模型产出的 items
step.NewStepItems = append(step.NewStepItems, processed.NewItems...)
// 2. HITL 分区:哪些要立即执行,哪些要等审批
toRun, interruptions, rejected := partitionByApproval(processed.Functions, ...)
// 3. 有任何待审批的 → 立即暂停(不执行任何东西)
if len(interruptions) > 0 {
return stepInterruption 结果
}
// 4. 并发执行函数工具
toolOutputs := runFunctionTools(toRun, ...)
// 5. 处理模型调用不存在工具的情况
for _, call := range processed.UnknownTools {
合成错误输出,回喂模型
}
// 6. Handoff 优先(如果有 handoff,走 handoff 分支)
if len(processed.Handoffs) > 0 {
return executeHandoff(...)
}
// 7. 没有工具可调?→ 判定最终输出
if !processed.hasToolsToRun() {
return stepFinalOutput, 取最后一条 message
}
// 8. 否则继续下一轮
return stepRunAgain
}这个顺序不是随便写的,每一步都有讲究。 我挑三个关键设计点讲。
设计点 1:HITL 分区,有审批就全部暂停
// agents/run_step.go:179
toRun, interruptions, rejected := partitionByApproval(processed.Functions, ...)
if len(interruptions) > 0 {
return stepInterruption // 一个要审批,全部暂停
}关键,只要有一个工具调用需要审批,整个 turn 的所有工具都不执行,立即暂停。 不是"执行不需要审批的,暂停需要审批的"。
为什么?因为 resume 时不能重跑。如果部分执行了,resume 时怎么知道哪些跑过、哪些没跑?全部暂停最简单,resume 时,所有工具调用都还在"待执行"状态,统一处理。
这是 HITL 的核心语义,暂停是原子的,要么全跑要么全停。
设计点 2:并发执行函数工具
// agents/run_step.go:197
toolOutputs := runFunctionTools(toRun, ...)runFunctionTools(run_step.go:277)用 errgroup.WithContext 并发执行多个工具:
// agents/run_step.go:277(骨架)
func runFunctionTools(tools []toolRunFunction, ...) []toolOutput {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(MaxToolConcurrency) // 限流
for _, tr := range tools {
tr := tr // 经典 Go 闭包陷阱
g.Go(func() error {
// 完整的工具调用流水线(见下面)
return nil
})
}
g.Wait()
}每个工具调用的完整流水线(run_step.go:287-347):
1. 构造 ToolContext(含 CallID、Arguments)
2. callToolStart hook(第 19 章)
3. 输入 guardrail(第 14 章)
4. 开 function span(第 25 章)
5. invokeTool(真正调用,支持超时)
6. 错误处理(FailureErrorFunction 回喂 vs 中断)
7. 输出 guardrail
8. callToolEnd hook
9. 把输出包装成 function_call_output item九步流水线,每个工具调用都走一遍。 这就是为什么 agents-go 的工具调用比"裸调函数"复杂得多,它在每一步都加了可观察、可控制、可恢复的能力。
设计点 3:错误回喂策略
工具出错怎么办?agents-go 有两种策略,由 FailureErrorFunction 字段控制:
// agents/tool.go:73-78
// FailureErrorFunction 非 nil → 把错误信息喂回模型,让模型自愈
// FailureErrorFunction 为 nil → 错误中断整个 run默认是"回喂"(DefaultToolErrorFunction,tool.go:84):
// agents/tool.go:84
func DefaultToolErrorFunction(_, _, err error) string {
return "An error occurred while running the tool. Please try again. Error: " + err.Error()
}错误信息被包装成工具输出,喂回模型。模型看到"工具出错了",可以决定,换参数重试、换工具、或者放弃任务告诉用户。
为什么不直接中断? 因为很多工具错误是临时的(网络抖动、参数小错)。中断整个 run,用户要从头来;回喂让模型自己处理,往往能自愈。
什么时候该中断? 当工具错误意味着"系统性的、不可恢复的问题",比如配置错了、权限没了。这时把 FailureErrorFunction 设成 nil,让错误直通:
tool := agents.NewFunctionTool("critical_op", "...", criticalOp)
tool.FailureErrorFunction = nil // 出错就中断,不回喂这是工程纪律,"可恢复的错误回喂,不可恢复的错误中断"。 默认宽松(回喂),关键操作严格(中断)。
源码对照:把结果包装成对话历史
工具执行完,结果怎么"塞回"对话历史?agents-go 用 newFunctionCallOutputItem(agents/run_items.go:204)把工具输出包装成 OpenAI 的 function_call_output 格式:
// agents/run_items.go:204(简化)
func newFunctionCallOutputItem(callID string, output any) RunItem {
// output 是 any,需要序列化
// 字符串直接用,结构化输出走 content list,其余 stringify
return &FunctionCallOutputItem{
CallID: callID,
Output: stringifyToolOutput(output),
}
}关键,输出是 any 类型,要序列化。 字符串直接用;如果工具返回的是结构化数据(比如图片、文件),走特殊的 content list 格式;其他类型 JSON stringify。
这个 item 加入对话历史后,下一轮循环时,它和原始的用户消息、模型回复、工具调用意图一起,作为输入发给模型。模型看到完整的"我调了工具,工具返回了什么",才能决定下一步。
设计权衡:为什么 handoff 优先
executeToolsAndSideEffects 里有个细节,只要有 handoff 调用,就走 handoff 分支,忽略其他函数工具调用:
// agents/run_step.go:213
if len(processed.Handoffs) > 0 {
return executeHandoff(...)
}为什么 handoff 这么"霸道"?因为 handoff 是切换 Agent,一旦切换,当前 Agent 的工具调用就没意义了(要换到新 Agent 的工具集)。所以 handoff 一出现,立刻切,其他都让位。
而且,多个 handoff 调用时,只取第一个,其他每个都补一个"抱歉,已经有 handoff 了"的输出项(run_step.go:496)。否则模型下轮会因为"我调了 5 个 handoff 但只有 1 个被执行"而困惑,甚至拒绝继续。 这是处理模型行为异常的工程细节。
小结:工具调用的七步流水线
读完这一章,你应该能用自己的话描述一次工具调用的完整流程:
模型返回 function_call
→ 分类:handoff?function?不存在?(processModelResponse)
→ HITL 分区:要审批的立即暂停(partitionByApproval)
→ 并发执行:errgroup + MaxToolConcurrency(runFunctionTools)
→ 每个工具的九步流水线:ctx → hook → 输入护栏 → span → 执行 → 错误处理 → 输出护栏 → hook → 包装
→ 错误回喂或中断(FailureErrorFunction)
→ 包装成 function_call_output item(newFunctionCallOutputItem)
→ 加入历史,进入下一轮循环七个阶段,每个都有设计权衡。 其中最关键的三个判断:
- HITL 是原子的,一个要审批,全部暂停(resume 时不重跑)
- 错误默认回喂,可恢复的错误让模型自愈,关键操作才中断
- handoff 优先,切换 Agent 时,其他工具调用让位
理解了这个流水线,你就理解了 Agent 循环里最复杂的一环。 后面的 session、guardrail、tracing 都是给它加配件,核心流程不变。
下一篇
第 5 章,我们看工具调用的并发,errgroup 怎么并行调多个工具、MaxToolConcurrency 怎么限流、Go 的并发模型为什么在 Agent 场景比 Python 优雅。这是 Go 做 Agent 的天然优势所在。
本文是《从 0 写 Agent》系列第 4 章,案例代码基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版)。全系列目录见总览。

