Skip to content

第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装上了"手脚":

  1. Function Calling原理:LLM不是自己调用工具,而是"告诉Agent调哪个"。像将军下令,士兵执行。
  2. Eino Tool系统:Go的struct + interface让工具定义类型安全。ToolInfo.Name和Desc是LLM判断何时调用的关键信息。
  3. 工具设计三原则:名字见名知意、描述写清楚、参数有类型。工具不是越多越好——3-5个起步。
  4. 安全三道防线:权限分级、人工确认、参数校验——缺一不可。
  5. MCP标准化:让工具定义变成"标准插座"。Go编译成单一二进制,做MCP天然合适。
  6. 流式输出 + 结构化输出:Stream()让用户不干等,WithStructuredOutput让返回值是 Go struct 而不是裸字符串。

✅ 知识点检查

学完这一章,试试回答这几个问题:

  • [ ] Function Calling中,LLM的角色是什么?真正执行工具的是谁?
  • [ ] Eino的Tool接口需要实现哪两个方法?
  • [ ] Agent调用工具时,有哪三道安全防线?
  • [ ] Go做Tool定义,比Python安全在哪?
  • [ ] 流式输出和结构化输出分别解决什么问题?

📚 延伸阅读


🎯 下一章预告

第7章,Agent光会干活不够,还得有记性——

"没记性的助理谁敢用?让Agent学会记住该记住的事——用Eino Memory。"