Tool 组件:让 Agent 学会「动手」的统一接口

系列「企业级 AI Agent 实现拆解」补充篇。上一篇 E8 讲的是 ChatModel------模型怎么「想」。这一篇接着往下走:模型怎么「动手」

光会想的 LLM 只能陪你聊天。真正有用的 Agent 得能查天气、发邮件、查数据库、调外部 API。这些「动手」的能力,在 Eino 里都叫一个名字:Tool(工具)

问题是,工具的来源五花八门:你手写一个 Go 函数、从结构体自动推导、远程 MCP 服务器上别人发布的......长得都不一样。Eino 的做法是------用一个接口把它们全统一。不管工具从哪来,最后在 Agent 眼里都长得一模一样,参数都用同一种格式描述,调用前还能统一套一层「加工车间」。

读完这篇你会知道:

  • InvokableTool 到底是什么:一个接口,两个方法
  • 工具参数为什么靠 JSON Schema 描述,以及三种写参数表的方式
  • 三种造工具的方式:InferTool(结构体自动推)、NewTool(手写 schema)、mcp.GetTools(远程搬过来)
  • 中间件:在工具外面套一层,专门修 LLM 吐出来的烂 JSON、吞掉报错
  • MCP 集成:为什么远程 MCP 工具和本地工具,在 Eino 里没有区别

一、先看一个能跑的工具

讲接口之前,先看工具实际长什么样。下面这段,是 Eino 官方示例里一个完整的工具(compose/graph/tool_call_once/tool_call_once.go:77,去掉了周边配置):

go 复制代码
userInfoTool := utils.NewTool(
    &schema.ToolInfo{
        Name: "user_info",
        Desc: "根据用户的姓名和邮箱,查询用户的公司、职位、薪酬信息",
        ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
            "name":  {Type: "string", Desc: "用户的姓名"},
            "email": {Type: "string", Desc: "用户的邮箱"},
        }),
    },
    func(ctx context.Context, input *userInfoRequest) (output *userInfoResponse, err error) {
        return &userInfoResponse{
            Name: input.Name, Email: input.Email,
            Company: "Awesome company", Position: "CEO", Salary: "9999",
        }, nil
    })

分两半:

  • 前半段是「说明书」 ------ToolInfo:这个工具叫 user_info,干啥用的,需要两个参数(姓名、邮箱,都是字符串)。
  • 后半段是「真干」------一个 Go 函数:拿到姓名邮箱,返回公司职位薪酬。

模型看到的是前半段(说明书),照着填参数;真正执行的是后半段(函数)。Eino 负责把模型填的 JSON 参数,自动解码成 Go 结构体 userInfoRequest,喂给函数;再把返回值编码回 JSON,交还给模型。

一句话:你只管写「要什么参数 + 怎么算」,框架帮你处理所有 JSON 来回翻译。


二、InvokableTool 到底是什么

上面用 utils.NewTool 造出来的东西,类型是 tool.InvokableTool。它是一个非常克制的接口(components/tool/interface.go:42):

go 复制代码
type BaseTool interface {
    Info(ctx context.Context) (*schema.ToolInfo, error)
}

type InvokableTool interface {
    BaseTool
    InvokableRun(ctx context.Context, argumentsInJSON string, opts ...Option) (string, error)
}

就两个方法,分工明确:

方法 谁来调 干什么 比喻
Info() 模型 返回工具说明书(名字、描述、参数表) 招聘启事:我会干啥、要什么材料
InvokableRun() 框架 拿到 JSON 参数,真正执行,返回字符串结果 真去干活

注意 InvokableRun 的入参是 一个 JSON 字符串 ,不是结构体。这是刻意的------因为参数是模型「填」的,模型只会吐字符串,框架在这一层统一收口。至于怎么把字符串变成你想要的 Go 类型,是后面 utils 包帮你做的。

InvokableTool 还有一群兄弟(components/tool/doc.go:20):

scss 复制代码
BaseTool                  --- 只有 Info(),光给模型看
├── InvokableTool         --- 标准版:参数 JSON 字符串,返回字符串
├── StreamableTool        --- 流式版:返回 StreamReader[string],结果一个字一个字吐
├── EnhancedInvokableTool --- 多模态版:返回图片/音频/视频/文件
└── EnhancedStreamableTool--- 多模态流式版

绝大多数工具用 InvokableTool 就够了。只有当结果要「边算边吐」(比如实时搜索结果)或「不是纯文本」(比如返回一张图)时,才用后面几个。这篇主要讲 InvokableTool

一个有意思的细节:BaseTool 光有 Info() 就有用------当你只想告诉模型「有这些工具可选」,但执行逻辑放在别处时,BaseTool 足矣。要既能被选、又能被执行 ,才需要 InvokableTool


三、参数靠 JSON Schema 描述

回头看第一节那段 ParamsOneOf。这是整篇文章最该理解的概念:工具的参数,是用 JSON Schema 描述的。

为什么是 JSON Schema?因为它是模型和工具之间唯一的「共同语言」。不管是 GPT、Claude、DeepSeek 还是豆包,它们都认 JSON Schema 这套格式。你用 JSON Schema 把参数表写一遍,所有模型都能读懂、照着填。

Eino 把 JSON Schema 包了一层,叫 ParamsOneOfschema/tool.go:275)。它支持两种写法,二选一

写法 A:手写参数表(轻量)

适合简单工具。一个 map,每个参数描述类型、说明、是否必填、枚举值:

go 复制代码
schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
    "name":  {Type: "string", Desc: "用户的姓名", Required: true},
    "email": {Type: "string", Desc: "用户的邮箱", Required: true},
})

ParameterInfoschema/tool.go:245)能覆盖最常见的情况:标量、数组(ElemInfo)、嵌套对象(SubParams)、字符串枚举(Enum)、必填标志(Required)。

写法 B:直接给标准 JSON Schema(强大)

适合复杂工具。当你需要 anyOfoneOf$defs 引用这些 ParameterInfo 表达不了的东西时,直接传一个完整的 *jsonschema.Schema

go 复制代码
js := &jsonschema.Schema{
    Type:     string(schema.Object),
    Required: []string{"title"},
    Properties: /* 一棵 JSON Schema 树 */,
}
schema.NewParamsOneOfByJSONSchema(js)

写法 C(最省事):让框架从 Go 结构体自动推

这是用得最多的方式。你根本不用手写参数表 ------写一个普通的 Go 结构体,打个 json tag,框架用反射自动生成 JSON Schema。这就是 InferTool 干的事,下一节细讲。

三种写法殊途同归:最后都变成模型能看懂的参数描述。 模型照着描述填参数,填错了它自己会重试。


四、三种造工具的方式

Eino 的 utils 包(components/tool/utils/invokable_func.go)提供了几个构造器,对应三种典型场景。

方式 1:InferTool------从结构体自动推导(最省事)

你写一个结构体和一个函数,剩下的交给框架:

go 复制代码
type userInfoRequest struct {
    Name  string `json:"name" jsonschema:"description=用户的姓名"`
    Email string `json:"email" jsonschema:"description=用户的邮箱"`
}
type userInfoResponse struct {
    Company string `json:"company"`
}

tool, err := utils.InferTool(
    "user_info",                            // 工具名
    "根据姓名邮箱查询公司职位薪酬",            // 描述
    func(ctx context.Context, in *userInfoRequest) (*userInfoResponse, error) {
        return &userInfoResponse{Company: "Awesome company"}, nil
    },
)

InferTool[T, D]invokable_func.go:46)内部干了三件事:

  1. 反射你的 TuserInfoRequest)结构体,读字段和 tag,自动生成 JSON Schema------这就是上一节说的「写法 C」。
  2. 把模型传来的 JSON 参数自动解码成 T,喂给你的函数。
  3. 把函数返回的 D 编码成 JSON 字符串,交还模型。

你的函数签名还是强类型的 func(ctx, *userInfoRequest)(*userInfoResponse, error),框架帮你处理所有 json.Unmarshal / json.Marshal。这是日常开发的首选。

方式 2:NewTool------手写 schema + 类型化函数

当参数 schema 是动态的、或者结构体 tag 表达不了时,你手写 ToolInfo(含 ParamsOneOf),再给一个类型化函数。第一节那个例子就是这种:

go 复制代码
utils.NewTool(
    &schema.ToolInfo{Name: "...", Desc: "...", ParamsOneOf: ...},
    func(ctx context.Context, in *Req) (*Resp, error) { ... },
)

注意 NewTool 有个坑:它不检查 你手写的 ParamsOneOf 和函数实际入参类型是否一致(invokable_func.go:141 注释明说了)。也就是说,说明书说「要 name 字段」,但你的结构体里写的是 username,编译期不报错,运行时模型填了 name 你却收到空------得自己盯紧两者一致。

方式 3:mcp.GetTools------远程搬过来

工具不一定要自己写。别人在 MCP 服务器上发布好了工具,你直接拉过来用。这是第五节的内容。

三种方式,产物完全一样 ------都是一个 InvokableTool。后面的流程(绑给模型、塞进 graph、套中间件)对三种来源一视同仁。这就是「统一接口」的好处。


五、中间件:在工具外面套一层

工具写好了,但真实世界里有两个头疼的问题:

  1. 模型吐的 JSON 经常是坏的 。模型可能多吐一对 `````````、漏个括号、夹一段`````` 思考标记。这种烂 JSON 直接解码会报错,工具就崩了。
  2. 工具执行报错,整个 Agent 就断了 。比如查数据库超时,工具返回一个 error,框架直接把错误抛上去,Agent 没机会重试或换个工具。

Eino 的解决办法是中间件------在工具真正执行的前后,套一层可插拔的加工逻辑。这跟 Web 框架里的中间件是同一个思路。

中间件长什么样

中间件用「包洋葱」的方式工作(compose/tool_node.go:140):

go 复制代码
type InvokableToolEndpoint func(ctx context.Context, in *ToolInput) (*ToolOutput, error)
type InvokableToolMiddleware func(InvokableToolEndpoint) InvokableToolEndpoint

type ToolMiddleware struct {
    Invokable  InvokableToolMiddleware  // 给普通工具用
    Streamable StreamableToolMiddleware // 给流式工具用
}

ToolInput 里有 Name(工具名)、Arguments(那段 JSON 参数)、CallID(这次调用的唯一号)。中间件是一个「接收下一个处理函数、返回新的处理函数」的高阶函数------经典的 next 模式。

注册时,把中间件塞进 ToolsNodeConfig,它会作用于这个节点下所有工具:

go 复制代码
toolsNode, _ := compose.NewToolNode(ctx, &compose.ToolsNodeConfig{
    Tools:               []tool.BaseTool{toolA, toolB},
    ToolCallMiddlewares: []compose.ToolMiddleware{jsonfix.Middleware(), errorremover.Middleware()},
})

现成例子 1:jsonfix------修烂 JSON

components/tool/middlewares/jsonfix 这个中间件,在参数传给工具之前先修一遍:

go 复制代码
func Invokable(next compose.InvokableToolEndpoint) compose.InvokableToolEndpoint {
    return func(ctx context.Context, in *compose.ToolInput) (*compose.ToolOutput, error) {
        in.Arguments = repair(in.Arguments)   // 先修
        return next(ctx, in)                  // 再交给真正的工具
    }
}

repair 的策略很务实(json_fix_middleware.go:75):先走快路径(本来就是合法 JSON 就直接返回),再尝试剥掉 `````````和``````这种 LLM 常见的「垃圾标记」,最后实在不行才上jsonrepair\ 库做兜底修复。绝大多数调用走快路径,几乎零开销。

现成例子 2:errorremover------把错误变成「能看懂的话」

components/tool/middlewares/errorremover 这个中间件,处理的是「工具崩了」的情况:

go 复制代码
func Invokable(next compose.InvokableToolEndpoint) compose.InvokableToolEndpoint {
    return func(ctx context.Context, in *compose.ToolInput) (*compose.ToolOutput, error) {
        output, err := next(ctx, in)   // 先正常调
        if err != nil {
            // 工具报错了,不把 error 抛上去,而是把它写成一段「人话」
            result := fmt.Sprintf("Failed to call tool '%s', error: '%s'", in.Name, err)
            return &compose.ToolOutput{Result: result}, nil  // 返回成功,err=nil
        }
        return output, nil
    }
}

效果是:工具查数据库超时了,模型不会收到一个让 Agent 直接崩掉的 error,而是收到一句「工具 search_db 调用失败:context deadline exceeded」。模型读懂这句话后,可以自己决定换关键词重查、或换个工具。 Agent 的鲁棒性就这么提上来了。

注意一个细节:errorremover 特意放过了 IsInterruptRerunErrorerrorremover.go:48)------这是「工具中断等待人工介入」时特有的错误,不能被吞掉,否则人工审批(HITL)就失效了。这是中间件作者必须小心的边界。

中间件的价值就在这:横切关注点(修参数、兜错误、加日志、做审批、限流......)和工具本身的业务逻辑彻底解耦。你加一个监控中间件,所有工具自动带上监控,工具代码一行都不用改。


六、MCP 工具集成:远程工具当本地用

MCP(Model Context Protocol) 是一套「工具共享协议」:任何人都能用标准格式发布工具,别人拿来即用。比如别人写了个「计算器」工具放在 MCP 服务器上,你不用关心它用什么语言实现、跑在哪,按协议拉过来就能用。

Eino 在 eino-ext/components/tool/mcp 里提供了适配器,一行调用把 MCP 服务器上的工具全变成本地 InvokableTool

go 复制代码
cli, _ := client.NewSSEMCPClient("http://localhost:12345/sse")
cli.Start(ctx)
cli.Initialize(ctx, mcp.InitializeRequest{ /* 协议握手 */ })

tools, err := mcp.GetTools(ctx, &mcp.Config{
    Cli:          cli,                   // MCP 客户端
    ToolNameList: []string{"calculate"}, // 可选:只要这几个工具,空=全要
})
// tools 的类型是 []tool.BaseTool,和本地工具一模一样

它是怎么做到「一模一样」的

GetToolsmcp.go:53)内部做了一件关键的事:MCP 工具的参数本身就是用 JSON Schema 描述的,所以适配几乎是「直译」:

  1. cli.ListTools() 拿到服务器上所有工具的清单。
  2. 对每个工具,把它的 InputSchema(一段 JSON Schema)转成 Eino 的 jsonschema.Schema,再包成 ParamsOneOf(就是第三节说的「写法 B」)。
  3. 造一个 toolHelper,它实现了 Info()InvokableRun()------InvokableRun 里调 cli.CallTool 把请求转给远程服务器(mcp.go:119)。

所以本质上:MCP 工具 = 一个「说明书由远程服务器写好、执行转发给远程」的 InvokableTool 因为底层都归一到 JSON Schema + 统一接口,本地的 InferTool 工具和远程的 MCP 工具,在 graph 里、在中间件眼里、在模型看来,没有任何区别。混在一起用也没问题。

这就是统一接口的威力:新增一种工具来源(比如未来的某个协议),只需要再写一个适配器,业务代码零改动。


七、串起来:一个工具怎么被模型调用

最后把整条链路走一遍。一个工具从定义到被调用,经历这几步(对应 tool_call_once.go 这个完整示例):

scss 复制代码
① 造工具        utils.NewTool / InferTool / mcp.GetTools
        ↓
② 绑给模型      chatModel.BindTools([]*schema.ToolInfo{tool.Info()})   ← 模型拿到说明书
        ↓
③ 塞进节点      compose.NewToolNode(&ToolsNodeConfig{Tools: [...]})     ← 装上执行器+中间件
        ↓
④ 编进 graph    chatModel 节点 → branch(看有没有 ToolCalls) → tools 节点 → 回灌

模型「想」的时候,看到的是工具说明书(Info()),决定要不要用、填什么参数;填好后吐出一个 ToolCallbranch 一看 msg.ToolCalls 非空,就把参数路由到 tools 节点。节点里中间件先加工(修 JSON、兜错误),再调真正的 InvokableRun,结果回灌给模型------模型拿到结果继续「想」,可能再调一次工具,形成 E3 讲过的 ReAct 循环。

脑(ChatModel,E8)负责想,手(Tool,这一篇)负责做,循环(Graph / ReAct,E3)把它们拧成 Agent。


要点回顾

  • InvokableTool = 一个接口两个方法Info() 给模型看说明书,InvokableRun() 真正执行。还有流式、多模态几个兄弟接口按需选用。
  • 参数靠 JSON Schema 描述 ,是模型与工具唯一的共同语言。Eino 用 ParamsOneOf 收口,支持手写参数表、完整 JSON Schema、结构体自动推导三种写法。
  • 三种造工具的方式殊途同归InferTool(结构体自动推,最省事)、NewTool(手写 schema,最灵活)、mcp.GetTools(远程搬过来)。产物都是 InvokableTool
  • 中间件是「包洋葱」 :在工具执行前后插横切逻辑。jsonfix 修模型吐的烂 JSON,errorremover 把报错变成模型能读懂的话,注册一次作用于所有工具。
  • MCP 工具零成本接入 :因为 MCP 参数本身就是 JSON Schema,适配器直译成 InvokableTool,和本地工具完全同构。

这套设计最聪明的地方是「归一」:

把「来源各异、格式各异、语言各异的工具」全部归一到「一个接口 + JSON Schema + 可插拔中间件」。

于是换模型不用动工具、加监控不用动工具、接远程工具不用动业务------每一层都能独立演进。这也是 DeepFlux 把工具治理(沙箱、审批、审计)做成横切中间件的底气所在。

相关推荐
啾啾Fun1 小时前
【LLM应用可靠性】3-Agent 事故响应:当 AI 系统行为异常时的 SRE Runbook
ai·llm·agent·生产应用
Rain5091 小时前
2.3. 安全配置:环境变量与 API 密钥管理
前端·人工智能·后端·安全·ai·node.js·ai编程
张申傲1 小时前
拆解 harness9(4):Skills 系统架构
aigc·agent·deepseek·harness
麦哲思科技任甲林1 小时前
Vibe Coding 实战(中篇):设计、编码与调试阶段总结
集成测试·ai编程·tdd·openspec·规格驱动的开发
小七-七牛开发者1 小时前
周一上线|瑞幸把咖啡做进 CLI,Fable 5 短暂登场,Stonk Rider 骑上 K 线图
ai·chatgpt·大模型·agent·claude·codex·skill·claudecode·ai coding
Solis程序员1 小时前
Raft:分布式系统的定海神针
java·分布式·kafka·rabbitmq·agent·raft
云烟成雨TD1 小时前
Agent Scope Java 2.x 系列【13】权限系统
java·人工智能·agent
冰^2 小时前
AI CC Switch 解决了什么?
人工智能·gpt·网络协议·chatgpt·github·aigc
悟空码字2 小时前
把 Claude Code 变成你的架构顾问:如何用“隐式重构模式”自动消除代码坏味道
ai·大模型·agent·智能体·claude code