ByteNoteByteNote
一次工具调用的完整生命周期
字

字节笔记本

2026年10月24日 · 约 21 分钟读完

一次工具调用的完整生命周期

API中转
¥120

Claude Code 文档:Agent 循环的工作方式

本文是《从 0 写 Agent》系列第 4 章。这个系列基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版),用 Go 从零实现一个生产级 Agent 运行时;本章讲「工具调用的完整生命周期」。

第 3 章我们讲了"工具怎么定义"。这一章讲"工具怎么被调用",从模型说"我要调工具",到工具真正执行,到结果回喂,到下一轮循环。

这整个过程,agents-go 用一个叫 executeToolsAndSideEffects 的函数搞定,它做的事比你想的精细得多,HITL 分区、并发执行、guardrail、错误回喂、handoff 优先……每一处都有设计权衡。


引子:一次工具调用背后发生了什么

看起来简单的"调用工具",拆开其实是一长串步骤:

text
模型说"我要调 get_weather(北京)"
  → 这是个合法工具吗?(查表)
  → 这个工具需要审批吗?(HITL 检查)
  → 参数合法吗?(输入 guardrail)
  → 真正执行工具
  → 输出合法吗?(输出 guardrail)
  → 把结果包装成对话历史的一项
  → 加入下一轮循环的输入

七步,缺一不可。 而且,如果有多个工具调用,还要并行;如果工具出错,要决定是中断还是回喂;如果有 handoff,要优先处理。

agents-go 的 executeToolsAndSideEffects(agents/run_step.go:165)把这些全管了。这一章我们逐段拆。


Agent SDK:把 Claude Code 当库用

原理:分类 → 执行 → 决定下一步

工具调用的处理可以分成三个阶段:

阶段一:分类(processModelResponse)

模型返回的响应里,可能有消息、推理、工具调用、handoff 调用混在一起。第一步要把它们分类,哪些是普通消息,哪些是工具调用,哪些是 handoff,哪些是模型瞎说的不存在的工具。

阶段二:执行(executeToolsAndSideEffects)

把分类出来的工具调用真正执行掉,并发执行函数工具、处理审批、处理 handoff、回喂未知工具错误。

阶段三:决定下一步

执行完后,runner 要决定,继续循环(还有工具要调)?还是返回(模型给了最终答案)?还是 handoff(换 Agent)?还是暂停(等审批)?


源码对照:分类 processModelResponse

processModelResponse(agents/run_step.go:103)干的事是,遍历模型响应的每个输出项,按类型分拣:

go
// 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 决定怎么处理:

go
// 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)是真正的执行器,它的执行顺序极其讲究:

go
// 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 分区,有审批就全部暂停

go
// agents/run_step.go:179
toRun, interruptions, rejected := partitionByApproval(processed.Functions, ...)
if len(interruptions) > 0 {
    return stepInterruption  // 一个要审批,全部暂停
}

关键,只要有一个工具调用需要审批,整个 turn 的所有工具都不执行,立即暂停。 不是"执行不需要审批的,暂停需要审批的"。

为什么?因为 resume 时不能重跑。如果部分执行了,resume 时怎么知道哪些跑过、哪些没跑?全部暂停最简单,resume 时,所有工具调用都还在"待执行"状态,统一处理。

这是 HITL 的核心语义,暂停是原子的,要么全跑要么全停。

设计点 2:并发执行函数工具

go
// agents/run_step.go:197
toolOutputs := runFunctionTools(toRun, ...)

runFunctionTools(run_step.go:277)用 errgroup.WithContext 并发执行多个工具:

go
// 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):

text
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 字段控制:

go
// agents/tool.go:73-78
// FailureErrorFunction 非 nil → 把错误信息喂回模型,让模型自愈
// FailureErrorFunction 为 nil → 错误中断整个 run

默认是"回喂"(DefaultToolErrorFunction,tool.go:84):

go
// 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,让错误直通:

go
tool := agents.NewFunctionTool("critical_op", "...", criticalOp)
tool.FailureErrorFunction = nil  // 出错就中断,不回喂

这是工程纪律,"可恢复的错误回喂,不可恢复的错误中断"。 默认宽松(回喂),关键操作严格(中断)。


源码对照:把结果包装成对话历史

工具执行完,结果怎么"塞回"对话历史?agents-go 用 newFunctionCallOutputItem(agents/run_items.go:204)把工具输出包装成 OpenAI 的 function_call_output 格式:

go
// 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 分支,忽略其他函数工具调用:

go
// 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 个被执行"而困惑,甚至拒绝继续。 这是处理模型行为异常的工程细节。


小结:工具调用的七步流水线

读完这一章,你应该能用自己的话描述一次工具调用的完整流程:

text
模型返回 function_call
  → 分类:handoff?function?不存在?(processModelResponse)
  → HITL 分区:要审批的立即暂停(partitionByApproval)
  → 并发执行:errgroup + MaxToolConcurrency(runFunctionTools)
  → 每个工具的九步流水线:ctx → hook → 输入护栏 → span → 执行 → 错误处理 → 输出护栏 → hook → 包装
  → 错误回喂或中断(FailureErrorFunction)
  → 包装成 function_call_output item(newFunctionCallOutputItem)
  → 加入历史,进入下一轮循环

七个阶段,每个都有设计权衡。 其中最关键的三个判断:

  1. HITL 是原子的,一个要审批,全部暂停(resume 时不重跑)
  2. 错误默认回喂,可恢复的错误让模型自愈,关键操作才中断
  3. handoff 优先,切换 Agent 时,其他工具调用让位

理解了这个流水线,你就理解了 Agent 循环里最复杂的一环。 后面的 session、guardrail、tracing 都是给它加配件,核心流程不变。


下一篇

第 5 章,我们看工具调用的并发,errgroup 怎么并行调多个工具、MaxToolConcurrency 怎么限流、Go 的并发模型为什么在 Agent 场景比 Python 优雅。这是 Go 做 Agent 的天然优势所在。


本文是《从 0 写 Agent》系列第 4 章,案例代码基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版)。全系列目录见总览。

相关文章

分享: