第十四篇:Eino ToolsNode 工具调用执行器

2026-04-25 51 0

上两篇我们学习了 ToolInfo(工具说明书)、InvokableTool(可执行工具)。我们已经可以手动拿到 ToolCall,调用单个工具。

但真实的大模型工具调用链路并不是手动写代码去判断、匹配、执行工具。大模型输出携带 ToolCall 的 Assistant 消息之后,我们需要一套组件:自动解析调用指令、匹配对应的工具、控制串行或并行执行、捕获异常、把结果封装成标准的 ToolMessage

在 Eino 中,承担这一整套工作的组件就是 compose.ToolsNode 工具节点

💡重要概念区分

  1. 1. ToolInfo:工具的说明书,专门给大模型阅读,描述工具名字、功能、入参;
  2. 2. InvokableTool:可执行工具,把 Go 业务函数 + ToolInfo 封装到一起,可以被调用执行;
  3. 3. ToolsNode工具调度执行器。它不做意图判断,只接收携带 ToolCall 的消息,自动完成解析、匹配、执行、结果封装。

通俗比喻:

  • • ToolInfo = 工具说明书;
  • • InvokableTool = 螺丝刀、扳手这些实实在在的工具;
  • • ToolsNode = 工具操作台。收到“调用螺丝刀”的指令,自动拿出工具干活,干完把结果整理好返回。

补充:后面我们学习的 adk.ChatModelAgent 智能体,底层内部就是复用了 ToolsNode 来完成工具执行逻辑。

一、ToolsNode 基础配置与核心API

包路径:github.com/cloudwego/eino/compose

ToolsNodeConfig 配置结构体

创建 ToolsNode 需要传入 ToolsNodeConfig,几个核心配置字段:

配置项作用
Tools []tool.BaseTool注册全部可用工具,传入我们封装好的 InvokableTool
UnknownToolsHandler大模型幻觉调用不存在工具时的兜底处理函数,避免程序直接报错崩溃
ExecuteSequentially是否串行执行多个ToolCall;false 默认并行执行;true 按顺序逐个执行
ToolArgumentsHandler工具入参预处理钩子,可以修改模型输出的JSON参数字符串
ToolCallMiddlewares []ToolMiddleware工具中间件,实现日志打印、耗时统计、鉴权、参数拦截

两个核心方法

  1. 1. Invoke(ctx context.Context, input *schema.Message) ([]*schema.Message, error)
    同步执行。输入必须是携带 ToolCalls 的 assistant 角色消息,输出返回一组 []*schema.Message,全部为 Tool 角色消息。

⚠️注意:如果输入消息里面没有 ToolCalls,ToolsNode 不会报错,直接返回空切片。

  1. 2. Stream(ctx context.Context, input *schema.Message) (*schema.StreamReader[[]*schema.Message], error)
    流式执行工具,用于 compose 流式编排链路。

ToolsNode完整工作流程

  1. 1. 接收输入:*schema.Message(assistant消息,携带ToolCall数组)
  2. 2. 提取消息内部全部 ToolCall
  3. 3. 根据工具名称,在注册的工具列表匹配对应的 InvokableTool
  4. 4. 如果找不到对应工具,执行 UnknownToolsHandler 兜底;
  5. 5. 根据 ExecuteSequentially 配置,选择并行或者串行执行多个工具;
  6. 6. 执行过程可以经过参数预处理钩子、工具中间件;
  7. 7. 每一个工具执行完成,自动把返回结果封装为标准 schema.ToolMessage
  8. 8. 返回全部工具结果消息切片。

关键点:ToolsNode只负责执行工具,它不会调用大模型!
完整ReAct工具调用闭环流程:ChatModel生成ToolCall消息 → ToolsNode执行工具 → 把原始消息+工具结果消息一起送回ChatModel,生成最终回答

ToolsNode完整工作流程

二、完整可运行Demo

本Demo复用第十三篇的业务工具,全部使用utils.InferTool自动推导生成工具。
为了聚焦学习ToolsNode本身,不接入真实大模型,手动构造模拟的ToolCall消息。

依赖安装

go get github.com/cloudwego/eino
go get github.com/cloudwego/eino/components/tool
go get github.com/cloudwego/eino/components/tool/utils
go get github.com/cloudwego/eino/compose
go mod tidy

main.go完整代码

package main

import (
    "context"
    "fmt"

    "github.com/cloudwego/eino/components/tool"
    "github.com/cloudwego/eino/components/tool/utils"
    "github.com/cloudwego/eino/compose"
    "github.com/cloudwego/eino/schema"
)

// ==================== 1、工具入参结构体 ====================
// ZipParam 邮编查询工具入参
type ZipParam struct {
    City string `json:"city" jsonschema:"description=待查询的国内省会城市名称,如北京、上海、广州,required=true"`
}

// WeatherParam 天气查询工具入参
type WeatherParam struct {
    City string `json:"city" jsonschema:"description=待查询的国内城市名称,enum=北京,enum=上海,enum=广州,required=true"`
}

// ==================== 2、工具业务执行函数(模拟业务) ====================
// getZipcode 模拟查询城市邮编
func getZipcode(ctx context.Context, p *ZipParam) (string, error) {
    zipData := map[string]string{
        "北京": "100000", "上海": "200000", "广州": "510000",
    }
    if zip, ok := zipData[p.City]; ok {
        return fmt.Sprintf("%s 的邮政编码:%s", p.City, zip), nil
    }
    return fmt.Sprintf("暂无 %s 的邮编数据", p.City), nil
}

// getWeather 模拟查询城市天气
func getWeather(ctx context.Context, p *WeatherParam) (string, error) {
    weatherData := map[string]string{
        "北京": "26℃ 晴,微风",
        "上海": "28℃ 多云",
        "广州": "32℃ 雷阵雨",
    }
    if w, ok := weatherData[p.City]; ok {
        return fmt.Sprintf("%s 今日天气:%s", p.City, w), nil
    }
    return fmt.Sprintf("暂无 %s 的天气数据", p.City), nil
}

// ==================== 3、构建InvokableTool ====================
func buildZipTool() (tool.InvokableTool, error) {
    return utils.InferTool("get_city_zipcode", "查询国内省会城市的邮政编码", getZipcode)
}

func buildWeatherTool() (tool.InvokableTool, error) {
    return utils.InferTool("get_city_weather", "查询国内城市当日天气,仅支持北京/上海/广州", getWeather)
}

// ==================== 4、创建ToolsNode节点 ====================
func createToolsNode(ctx context.Context) (*compose.ToolsNode, error) {
    zipTool, err := buildZipTool()
    if err != nil {
        return nil, fmt.Errorf("构建邮编工具失败:%w", err)
    }

    weatherTool, err := buildWeatherTool()
    if err != nil {
        return nil, fmt.Errorf("构建天气工具失败:%w", err)
    }

    cfg := &compose.ToolsNodeConfig{
        // 注册全部可用工具
        Tools: []tool.BaseTool{zipTool, weatherTool},

        // 兜底处理:模型幻觉调用不存在工具,返回友好提示,不直接抛错
        UnknownToolsHandler: func(ctx context.Context, name, input string) (string, error) {
            return fmt.Sprintf("错误:工具【%s】不存在,请检查工具名称", name), nil
        },

        // false:多个tool_call并行执行;true:按顺序串行执行
        ExecuteSequentially: false,
    }

    return compose.NewToolNode(ctx, cfg)
}

// makeToolCall 辅助函数:构造模拟大模型输出的Assistant消息,携带ToolCall
func makeToolCall(calls ...schema.ToolCall) *schema.Message {
    return &schema.Message{
        Role:      schema.Assistant,
        ToolCalls: calls,
    }
}

// runToolCase 封装测试用例,简化重复代码
func runToolCase(ctx context.Context, tn *compose.ToolsNode, title string, msg *schema.Message) {
    fmt.Printf("===== %s =====\n", title)
    results, err := tn.Invoke(ctx, msg)
    if err != nil {
        fmt.Printf("执行失败:%v\n\n", err)
        return
    }
    for _, m := range results {
        fmt.Printf("ToolMessage | toolCallID=%s | content=%s\n", m.ToolCallID, m.Content)
    }
    fmt.Println()
}

func main() {
    ctx := context.Background()

    toolsNode, err := createToolsNode(ctx)
    if err != nil {
        fmt.Printf("创建 ToolsNode 失败:%v\n", err)
        return
    }

    // 案例1:单个工具调用,查询上海天气
    runToolCase(ctx, toolsNode, "案例1:单个工具调用(查询上海天气)",
        makeToolCall(schema.ToolCall{
            ID:   "call_001",
            Type: "function",
            Function: schema.FunctionCall{
                Name:      "get_city_weather",
                Arguments: `{"city":"上海"}`,
            },
        }))

    // 案例2:同时调用两个工具,并行执行,查询北京天气+邮编
    runToolCase(ctx, toolsNode, "案例2:多工具并行调用(北京天气+邮编)",
        makeToolCall(
            schema.ToolCall{
                ID:   "call_002",
                Type: "function",
                Function: schema.FunctionCall{
                    Name:      "get_city_weather",
                    Arguments: `{"city":"北京"}`,
                },
            },
            schema.ToolCall{
                ID:   "call_003",
                Type: "function",
                Function: schema.FunctionCall{
                    Name:      "get_city_zipcode",
                    Arguments: `{"city":"北京"}`,
                },
            },
        ))

    // 案例3:模型幻觉,调用没有注册的工具,触发UnknownToolsHandler
    runToolCase(ctx, toolsNode, "案例3:幻觉调用不存在的工具,触发兜底逻辑",
        makeToolCall(schema.ToolCall{
            ID:   "call_004",
            Type: "function",
            Function: schema.FunctionCall{
                Name:      "get_stock_price",
                Arguments: `{"code":"000001"}`,
            },
        }))
}

程序运行输出

===== 案例1:单个工具调用(查询上海天气) =====
ToolMessage | toolCallID=call_001 | content=上海 今日天气:28℃ 多云

===== 案例2:多工具并行调用(北京天气+邮编) =====
ToolMessage | toolCallID=call_002 | content=北京 今日天气:26℃ 晴,微风
ToolMessage | toolCallID=call_003 | content=北京 的邮政编码:100000

===== 案例3:幻觉调用不存在的工具(触发兜底) =====
ToolMessage | toolCallID=call_004 | content=错误:工具【get_stock_price】不存在,请检查工具名称

三、代码关键点解读

  1. 1. 输入消息要求:传入 Invoke 的消息必须是 assistant 角色,并且填充 ToolCalls;如果消息没有ToolCalls,ToolsNode直接返回空切片,不会报错。
  2. 2. UnknownToolsHandler:专门用来处理大模型幻觉编造不存在工具的场景,返回的字符串会被自动封装为ToolMessage,不会直接panic程序。
  3. 3. ExecuteSequentially:默认false代表并行执行多个工具,适合工具之间没有依赖;如果工具存在先后依赖关系,设置为true串行执行。
  4. 4. 返回值是[]*schema.Message,全部为Tool角色消息,每一条消息的ToolCallID和输入的ToolCall一一对应。

完整ReAct业务闭环伪代码,帮助大家理解上下文如何拼接:

// messages 初始对话上下文
resp1, _ := chatModel.Generate(ctx, messages)
// resp1 是assistant消息,携带ToolCall
toolMsgs, _ := toolsNode.Invoke(ctx, resp1)

// 拼接完整上下文:原始上下文 + 模型输出的toolcall消息 + 工具返回结果消息
messages = append(messages, resp1)
messages = append(messages, toolMsgs...)

// 二次送入模型,拿到大模型结合工具结果之后的最终回答
finalResp, _ := chatModel.Generate(ctx, messages)

四、ToolsNode扩展能力简单了解

1. ToolArgumentsHandler 参数预处理钩子

在工具执行之前拦截、修改模型输出的JSON参数字符串,适合统一做参数清洗。

ToolArgumentsHandler: func(ctx context.Context,name string,arguments string)(string,error){
    fmt.Printf("工具:%s,原始参数:%s\n",name,arguments)
    return arguments,nil
},

2. ToolCallMiddlewares 工具中间件

洋葱模型中间件,可以统一做工具耗时统计、日志打印、权限校验。

ToolCallMiddlewares: []compose.ToolMiddleware{
    {
        Invokable: func(next compose.InvokableToolEndpoint) compose.InvokableToolEndpoint {
            return func(ctx context.Context,input *compose.ToolInput)(*compose.ToolOutput,error){
                fmt.Printf("开始执行工具:%s\n",input.Name)
                out,err:=next(ctx,input)
                fmt.Printf("工具执行完毕 err=%v\n",err)
                return out,err
            }
        },
    },
},

五、新手避坑清单

❌ 传入ToolsNode的消息没有ToolCalls:不会报错,直接返回空切片,工具不会执行;
❌ ToolCall里面工具Name大小写和注册工具不一致,直接进入UnknownToolsHandler兜底;
❌ 拼接上下文的时候,漏掉模型输出的assistant(ToolCall)消息,只塞ToolMessage,模型无法理解工具返回;
❌ 误以为ToolsNode会做意图判断:ToolsNode不会自己去调用工具,必须上游传入携带ToolCall的消息;
❌ 多工具并行执行时,业务函数不要读写共享变量,注意并发安全。

💡小提示:后面学习adk.ChatModelAgentConfig.ToolsConfig,底层就是对ToolsNodeConfig做封装,Agent自动完成整套ReAct循环。

六、知识点回顾

  1. 1. ToolsNode是Eino的工具调度执行器,接收携带ToolCall的assistant消息,自动完成工具匹配、执行、结果封装;
  2. 2. 分清三者关系:ToolInfo(说明书)、InvokableTool(可执行业务工具)、ToolsNode(调度操作台);
  3. 3. 核心方法Invoke()输入一条assistant消息,输出一组tool角色消息;
  4. 4. 支持串行/并行执行、不存在工具兜底、参数预处理、工具中间件扩展;
  5. 5. 标准ReAct闭环:ChatModel生成ToolCall → ToolsNode执行工具 → 拼接完整上下文,再次调用ChatModel生成最终答案。

相关文章

第十六篇:Eino入门——核心组件总览与场景选型总结
第十五篇:Eino ChatModelAgent 模型自己决定是否调用工具
第十三篇:InvokableTool-把Go函数包装成模型可调用的工具
第十二篇:Eino ToolInfo工具-给模型看的说明书
第十一篇:Eino Message与Prompt上下文编排
第十篇:Eino ChatModel:将大模型调用抽象成可复用组件

发布评论