第6章 光说不练假把式
第5章我们给Agent装了个脑子——LLM。但光有脑子不够。你见过哪个助理只会动嘴不动手的?这一章,我们给Agent装上"手脚",让它能真正帮你干活——查数据库、调API、发邮件、改代码……Agent从"会说"变成"会做",关键就在这一章。而且用Go写工具,比你想象的更安全。
6.1 Function Calling是怎么工作的?
6.1.1 一个场景:你说"帮我查天气",它怎么查?
假如你有一个Agent,你对它说:
"帮我查一下北京今天的天气。"
Agent的脑子(LLM)能理解"你想查天气",但它自己调不了天气API。LLM只是一个文字生成器,它没有办法发起网络请求。
这时候就需要 Function Calling(函数调用)——让LLM"告诉Agent该调哪个函数,Agent去执行,结果拿回来给LLM,LLM再生成回答"。
关键理解:不是LLM在执行工具,是Agent在LLM的"指挥"下执行工具。LLM像将军下命令,Agent是士兵去执行。
6.1.2 LLM怎么知道该调哪个函数?
你不需要写if-else判断"如果用户说天气就调get_weather"——LLM会自动判断。
秘密在于工具定义里的名称和描述。当你把工具注册给Agent时,Eino把每个工具的名称、描述、参数类型打包发给LLM。LLM根据用户的意图,自己决定该调哪个、传什么参数。
不需要你写任何规则。 这就是Function Calling最颠覆的地方。
6.2 Eino Tool系统:Go的interface让工具定义更安全
6.2.1 用Eino定义第一个工具
工具本质上就是一个结构体实现了Eino的Tool接口。和Python的@tool装饰器不同,Go的工具定义是类型安全的——参数和返回值类型在编译期就确定了。
go
import (
"context"
"fmt"
"github.com/cloudwego/eino/components/tool"
"github.com/cloudwego/eino/schema"
)
// 定义天气工具的输入参数(编译期确定类型)
type WeatherParams struct {
City string `json:"city" desc:"城市名称,例如:北京、上海"`
Date string `json:"date" desc:"日期,格式YYYY-MM-DD,留空则为今天"`
}
// 定义天气工具的输出
type WeatherResult struct {
Temperature int `json:"temperature"`
Condition string `json:"condition"`
City string `json:"city"`
}
// 实现 Tool 接口
type WeatherTool struct{}
func (t *WeatherTool) Info(ctx context.Context) (*schema.ToolInfo, error) {
return &schema.ToolInfo{
Name: "get_weather", // LLM靠这个名匹配用户意图
Desc: "查询指定城市的天气。返回温度、天气状况。", // LLM读这个决定何时调用
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"city": {
Type: schema.String,
Desc: "城市名称,例如:北京",
Required: true,
},
"date": {
Type: schema.String,
Desc: "日期,格式YYYY-MM-DD,不传则查今天",
Required: false,
},
}),
}, nil
}
func (t *WeatherTool) InvokableRun(ctx context.Context,
params *WeatherParams) (*WeatherResult, error) {
// 实际调用天气API,这里用模拟数据
weatherDB := map[string]WeatherResult{
"北京": {Temperature: 25, Condition: "晴", City: "北京"},
"上海": {Temperature: 28, Condition: "多云", City: "上海"},
}
result, ok := weatherDB[params.City]
if !ok {
return &WeatherResult{City: params.City, Condition: "未知"},
fmt.Errorf("未找到%s的天气数据", params.City)
}
return &result, nil
}6.2.2 把工具注册给Agent
go
func main() {
ctx := context.Background()
// 创建模型
chatModel, _ := model.NewChatModel(ctx, &model.ChatModelConfig{
Model: "gpt-4o",
APIKey: os.Getenv("OPENAI_API_KEY"),
})
// 创建工具
weatherTool := &WeatherTool{}
// 用 Eino 把模型和工具组装成 Agent
agent, err := compose.NewAgent[[]*schema.Message, *schema.Message]().
WithChatModel(chatModel).
WithTools([]tool.InvokableTool{weatherTool}). // 注册工具
Compile(ctx)
// 用户提问——Agent 会自动决定是否调用工具
input := []*schema.Message{
schema.UserMessage("北京今天天气怎么样?"),
}
output, _ := agent.Invoke(ctx, input)
fmt.Println(output.Content) // "北京今天晴,气温25度"
}6.2.3 Go的工具设计三原则
原则一:ToolInfo.Name 是工具的"名字",让LLM一眼看懂。
使用 get_weather 而不是 gw,使用 search_documents 而不是 sd。LLM是根据工具名来匹配用户意图的。
原则二:ToolInfo.Desc 是工具的"说明书",越详细越好。
"查询指定城市的天气。返回温度(摄氏度)、天气状况(晴/雨/多云等)。"比
"查天气"好十倍。LLM会读你的描述来决定什么时候用这个工具。
原则三:参数类型要明确,最好有默认值。
go
ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"city": {Type: schema.String, Desc: "城市名", Required: true},
"max_results": {Type: schema.Integer, Desc: "最多返回几条", Required: false},
})Required: false 告诉LLM:这个参数是可选的。如果用户没说"给我10条",LLM就不会传。
6.2.4 工具不是越多越好
刚学Agent开发的人容易犯一个错误:一口气注册几十个工具。
问题:每个工具定义都会发给LLM,占用上下文窗口。工具越多,LLM越容易"选错"。而且有些工具的功能重叠,LLM会懵。
建议:从3-5个核心工具开始,跑通了再加。宁愿少而精,不要多而乱。
6.3 不能让Agent为所欲为
6.3.1 Agent调工具,你能控制吗?
想象一个场景:Agent接入了你的数据库,你可以让它"帮我查上个月的销售数据"。
但如果用户问的是"帮我把所有用户的数据删掉",而Agent又刚好有 deleteFromDatabase 这个工具……它真的可能执行。
工具调用赋予了Agent真实的能力,但也带来了真实的风险。
6.3.2 三个必须做的安全措施
措施一:权限分级。 不是所有工具都该在任何场景下可用。
go
// 分级示例
var safeTools = []tool.InvokableTool{weatherTool, searchTool}
var dangerousTools = []tool.InvokableTool{deleteUserTool, modifyDBTool}
// 根据用户角色选择工具
func getToolsForUser(role string) []tool.InvokableTool {
if role == "admin" {
return append(safeTools, dangerousTools...)
}
return safeTools
}措施二:人工确认。 涉及钱、数据删除、外部发送的操作,让用户确认后再执行。
go
func (t *SendEmailTool) InvokableRun(ctx context.Context,
params *SendEmailParams) (*SendEmailResult, error) {
// 返回一个需要确认的信号
return &SendEmailResult{
Status: "pending_confirmation",
Message: fmt.Sprintf("即将发送邮件到 %s,主题:%s", params.To, params.Subject),
NeedsConfirm: true,
}, nil
}措施三:参数校验。 别信任LLM传过来的参数。
go
func (t *TransferTool) InvokableRun(ctx context.Context,
params *TransferParams) (*TransferResult, error) {
// 校验:金额必须是正数
if params.Amount <= 0 {
return nil, errors.New("转账金额必须大于0")
}
// 校验:不能转给自己
if params.From == params.To {
return nil, errors.New("不能转账给自己")
}
// 校验:金额上限
if params.Amount > 50000 {
return nil, errors.New("单笔转账不能超过50000元,需人工审核")
}
// 执行转账
return doTransfer(params)
}6.4 让工具调用变得像"插座插插头"一样简单
6.4.1 工具调用的碎片化问题
你写了3个Agent:一个用OpenAI的Function Calling格式,一个用豆包的函数调用格式,一个用DeepSeek的工具模式。三个Agent,三种工具定义方式。
但有了Eino之后——你不用管这些。 Eino的Tool接口是统一的,底层适配不同LLM的工作由框架完成。
6.4.2 MCP的思路
MCP(Model Context Protocol) 更进一步:让工具定义变成"标准插座"——不仅对LLM统一,还对外部系统统一。
Go 做 MCP 有天然优势:编译成一个二进制,任何系统都能跑你的工具。第13章会详细讲 MCP + gRPC 的实战。
6.5 Eino的流式输出与结构化输出
6.5.1 流式输出:别让用户干等
Agent执行一个复杂任务可能需要好几秒。如果用户盯着空白屏幕,体验极差。流式输出就是LLM每生成一点,就发送一点——像ChatGPT一字一字打出来的效果。
go
// 用 Stream 替代 Invoke
streamReader, err := agent.Stream(ctx, input)
if err != nil {
panic(err)
}
// 逐块接收结果
for {
chunk, err := streamReader.Recv()
if err == io.EOF {
break // 流结束了
}
fmt.Print(chunk.Content) // 立刻打印,不缓存
}Stream() 和 Invoke() 的区别:Invoke() 等全部生成完再返回;Stream() 像一个水龙头——开一点,流一点。对聊天机器人、实时写作辅助、代码补全这类场景,流式输出是标配。
6.5.2 结构化输出:别让LLM说废话
LLM最让人头疼的问题之一:输出格式不可控。
你让它"以JSON返回",它可能前面加一句"好的,以下是结果:"——你的 json.Unmarshal 就崩了。
Eino解决这个问题的方式是——让你定义期望的输出结构,框架保证LLM按这个结构返回:
go
// 定义你想要的输出结构
type PersonInfo struct {
Name string `json:"name"`
Age int `json:"age"`
Occupation string `json:"occupation"`
Skills []string `json:"skills"`
}
// 用 Eino 的 with_structured_output 绑定
structuredModel, err := chatModel.WithStructuredOutput(
ctx,
&model.StructuredOutputConfig{
Schema: schema.MustToJSONSchema(&PersonInfo{}),
},
)
// 调用——返回的 result 就是 *PersonInfo 类型
input := []*schema.Message{
schema.UserMessage("张三,28岁,Go程序员,擅长微服务和分布式系统"),
}
output, _ := structuredModel.Generate(ctx, input)
// output 已经是正经的 Go struct,直接 .Name、.Age 访问Go 的额外好处:PersonInfo 是编译期确定的类型。你写 output.Name,IDE有自动补全,编译期就检查字段名是否拼错。Python的dict要到运行时才能发现key错误。
6.6 本章小结
这一章我们给Agent装上了"手脚":
- Function Calling原理:LLM不是自己调用工具,而是"告诉Agent调哪个"。像将军下令,士兵执行。
- Eino Tool系统:Go的struct + interface让工具定义类型安全。ToolInfo.Name和Desc是LLM判断何时调用的关键信息。
- 工具设计三原则:名字见名知意、描述写清楚、参数有类型。工具不是越多越好——3-5个起步。
- 安全三道防线:权限分级、人工确认、参数校验——缺一不可。
- MCP标准化:让工具定义变成"标准插座"。Go编译成单一二进制,做MCP天然合适。
- 流式输出 + 结构化输出:Stream()让用户不干等,WithStructuredOutput让返回值是 Go struct 而不是裸字符串。
✅ 知识点检查
学完这一章,试试回答这几个问题:
- [ ] Function Calling中,LLM的角色是什么?真正执行工具的是谁?
- [ ] Eino的Tool接口需要实现哪两个方法?
- [ ] Agent调用工具时,有哪三道安全防线?
- [ ] Go做Tool定义,比Python安全在哪?
- [ ] 流式输出和结构化输出分别解决什么问题?
📚 延伸阅读
- Eino Tool文档:https://www.cloudwego.io/zh/docs/eino/
- OpenAI Function Calling文档:https://platform.openai.com/docs/guides/function-calling
- MCP协议官方文档:https://modelcontextprotocol.io
- 本书配套源码:关注公众号「图解AI系列」免费领取
🎯 下一章预告
第7章,Agent光会干活不够,还得有记性——
"没记性的助理谁敢用?让Agent学会记住该记住的事——用Eino Memory。"

