动手写个agent(二):实现LLM调用工具tool 文章目录二、赋予 Agent 使用工具的能力2.1 扩展通信语言支持工具的 API2.2 更新 Go 数据结构2.3 定义与实现第一个工具Shell2.4 工具的注册与管理2.5 实战让 Agent 执行 Shell 命令第二部分小结本系列将从零开始用 Go 语言实现一个具备基本功能工具调用、循环思考、MCP、Skill的 Agent代码仓库https://gitee.com/lymgoforIT/learn-agentchapter1对应第一部分的代码以此类推本节目标让 Agent 学会调用外部工具完成仅靠语言无法完成的任务。二、赋予 Agent 使用工具的能力上一部分中我们的 Agent 实现了与 LLM 的对话。但这还远远不够。一个真正的智能助理不仅要能“说”还要能“做”。它需要能够查询天气、读取文件、执行代码甚至操作浏览器。这些能力LLM 自身并不具备。问题如何让一个纯粹的语言模型与外部世界的功能如 API、数据库、命令行进行交互解决方案工具调用 (Tool Calling)。这个想法非常巧妙既然 LLM 擅长生成文本那我们是否可以训练它在特定场景下生成一段符合预设格式的、描述了“调用哪个工具”以及“传入什么参数”的特殊文本答案是肯定的。现代的 LLM 都经过了大量的这类训练。我们只需要在与 LLM 对话时告诉它“你拥有以下工具可用”并用一种结构化的方式如 JSON Schema描述清楚每个工具的名称、功能和参数。当用户提出的任务需要使用工具时LLM 便会生成一段包含工具调用指令的 JSON 文本而不是直接给出最终答案。我们的 Agent 在收到这段指令后解析它、执行对应的本地函数或外部 API然后将执行结果再发送回给 LLM让它基于这个结果继续下一步的思考。2.1 扩展通信语言支持工具的 API为了支持工具调用我们需要对第一部分中的 API 数据结构进行扩展。请求 (Request) 中的 tools 字段在发送给 LLM 的请求中我们需要新增一个 tools 字段。它是一个数组其中每个元素都详细描述了一个可用的工具。一个工具的定义通常包含type: 固定为 function。function:name: 工具的名称例如 get_current_weather。description: 对工具功能的清晰描述例如“获取指定城市的当前天气情况”。这是最重要的部分LLM 依赖它来判断何时使用该工具。parameters: 一个 JSON Schema 对象定义了该工具接受的所有参数包括参数名、类型、描述以及哪些是必填项。required必填参数的列表{model:gpt-4o,messages:[...],tools:[{type:function,function:{name:get_current_weather,description:获取指定城市的当前天气情况,parameters:{type:object,properties:{location:{type:string,description:城市名, e.g. 北京}},required:[location]}}}]}响应 (Response) 中的 tool_calls 字段当 LLM 判断需要调用工具时它的回复会变得有些不同finish_reason的值会是 tool_calls。message.content字段将为 null。message中会出现一个新的字段 tool_calls。tool_calls 是一个数组因为模型可能一次请求调用多个工具。每个 tool_call 对象包含了id: 一次工具调用的唯一标识。当我们将执行结果返回给模型时需要用这个 ID 来对应。type: 固定为 function。function:name: 模型决定调用的工具名称。arguments: 一个 JSON 格式的字符串包含了模型为该工具准备的参数。{choices:[{finish_reason:tool_calls,message:{role:assistant,content:null,tool_calls:[{id:call_abc123,type:function,function:{name:get_current_weather,arguments:{\location\: \Beijing\}}}]}}]}Agent和LLM之间的API参数讲解到这里基本就结束了其他的多模态、流式输出、深度推理等功能就不在这里讲解了大家可以自行研究一下。2.2 更新 Go 数据结构根据新的 API 规范我们来更新一下之前定义的 Go 结构体。// ChatRequest 表示LLM请求typeChatRequeststruct{Modelstringjson:modelMessages[]Messagejson:messagesTools[]ToolDefinitionjson:tools,omitemptyTemperaturefloat64json:temperature,omitemptyMaxTokensintjson:max_tokens,omitempty}// Message 表示对话消息typeMessagestruct{Role MessageRolejson:role// system/user/assistant/toolContentstringjson:content// 消息内容ToolCallIdstringjson:tool_call_id,omitempty// 工具调用IDToolCalls[]ToolCalljson:tool_calls,omitempty// 工具调用请求}// ToolCall 表示工具调用请求typeToolCallstruct{IDstringjson:idTypestringjson:type// functionFunctionstruct{Namestringjson:nameArgumentsstringjson:arguments// JSON字符串}json:function}// ToolDefinition 定义工具描述typeToolDefinitionstruct{Typestringjson:type// functionFunctionstruct{Namestringjson:nameDescriptionstringjson:descriptionParametersmap[string]interface{}json:parameters}json:function}2.3 定义与实现第一个工具Shell现在让我们来实现第一个也是最强大的工具之一shell。这个工具将允许 Agent 执行任意的 shell 命令。首先我们定义一个通用的 Tool 接口任何工具都必须实现它。一个工具需要提供四样东西名称、描述、参数定义和一个执行方法。并定义一个实现了名称、描述、参数定义的基础工具类BaseTool。// Tool 工具接口typeToolinterface{// Name 返回工具名称Name()string// Description 返回工具描述Description()string// Parameters 返回参数SchemaJSON Schema格式Parameters()map[string]interface{}// Execute 执行工具Execute(ctx context.Context,params json.RawMessage)(string,error)}// BaseTool 基础工具实现typeBaseToolstruct{namestringdescriptionstringparametersmap[string]interface{}}func(t*BaseTool)Name()string{returnt.name}func(t*BaseTool)Description()string{returnt.description}func(t*BaseTool)Parameters()map[string]interface{}{returnt.parameters}基于这个接口我们来实现 ShellTool。它的 Execute 方法会使用 Go 的 os/exec 包来执行一个 shell 命令并返回其标准输出和标准错误。// ShellTool Shell命令执行工具typeShellToolstruct{BaseTool timeout time.Duration}funcNewShellTool(timeout time.Duration)*ShellTool{returnShellTool{name:shell,description:执行Shell命令并返回输出结果,parameters:map[string]interface{}{type:object,properties:map[string]interface{}{command:map[string]interface{}{type:string,description:要执行的Shell命令,},},required:[]string{command},},timeout:timeout,}}func(t*ShellTool)Execute(ctx context.Context,params json.RawMessage)(string,error){varinputstruct{Commandstringjson:command}iferr:json.Unmarshal(params,input);err!nil{log.Error().Err(err).Msg(参数解析失败)return,fmt.Errorf(参数解析失败: %w,err)}// 创建带超时的上下文ctx,cancel:context.WithTimeout(ctx,t.timeout)defercancel()cmd:exec.CommandContext(ctx,sh,-c,input.Command)varstdout,stderr bytes.Buffer cmd.Stdoutstdout cmd.Stderrstderr err:cmd.Run()output:stdout.String()ifstderr.Len()0{output\nStderr: stderr.String()}iferr!nil{log.Error().Err(err).Str(command,input.Command).Msg(命令执行失败)returnoutput,fmt.Errorf(命令执行失败: %w,err)}returnoutput,nil}2.4 工具的注册与管理当工具越来越多时我们需要一个“工具箱”来统一管理它们。下面这个 Registry 结构体就扮演了这个角色。它负责Register: 注册一个新工具。ToLLMTools: 将所有已注册的工具转换成 LLM 需要的 ToolDefinition 格式。Execute: 根据工具名称和参数找到并执行对应的工具。// Registry 工具注册表typeRegistrystruct{toolsmap[string]Tool mu sync.RWMutex}// NewRegistry 创建工具注册表funcNewRegistry()*Registry{returnRegistry{tools:make(map[string]Tool),}}// Register 注册工具func(r*Registry)Register(tool Tool)error{r.mu.Lock()deferr.mu.Unlock()name:tool.Name()if_,exists:r.tools[name];exists{log.Warn().Str(tool,name).Msg(工具已存在)returnfmt.Errorf(工具已存在: %s,name)}r.tools[name]toolreturnnil}// Get 获取工具func(r*Registry)Get(namestring)(Tool,bool){r.mu.RLock()deferr.mu.RUnlock()tool,exists:r.tools[name]returntool,exists}// List 列出所有工具func(r*Registry)List()[]Tool{r.mu.RLock()deferr.mu.RUnlock()tools:make([]Tool,0,len(r.tools))for_,tool:ranger.tools{toolsappend(tools,tool)}returntools}// ToLLMTools 转换为LLM工具定义func(r*Registry)ToLLMTools()[]types.ToolDefinition{r.mu.RLock()deferr.mu.RUnlock()definitions:make([]types.ToolDefinition,0,len(r.tools))for_,tool:ranger.tools{def:types.ToolDefinition{Type:function,}def.Function.Nametool.Name()def.Function.Descriptiontool.Description()def.Function.Parameterstool.Parameters()definitionsappend(definitions,def)}returndefinitions}// Execute 执行指定工具func(r*Registry)Execute(ctx context.Context,namestring,params json.RawMessage)(string,error){tool,exists:r.Get(name)if!exists{log.Error().Str(tool,name).Msg(工具不存在)return,fmt.Errorf(工具不存在: %s,name)}returntool.Execute(ctx,params)}2.5 实战让 Agent 执行 Shell 命令现在我们将 ShellTool 注册到我们的工具箱中并让 Agent 尝试完成一个需要使用该工具的任务“请帮我查看 …/config.json 文件”。我们修改 main 函数将工具信息添加到 LLM 请求中。如果 LLM 的回复包含了 tool_calls我们就调用工具注册器来执行它并打印结果。funcmain(){config,err:loadConfig(../config.json)iferr!nil{log.Fatal().Err(err).Msg(加载配置失败)}client:llm.NewOpenAIClient(config)toolRegistry:tool.NewRegistry()toolRegistry.Register(tool.NewShellTool(10*time.Second))req:types.ChatRequest{Messages:[]types.Message{{Role:types.RoleSystem,Content:你是一个专业的Agent可以使用工具完成任务,},{Role:types.RoleUser,Content:请帮我查看../config.json文件,},},// 将可用工具传给LLMTools:toolRegistry.ToLLMTools(),}ctx,cancel:context.WithTimeout(context.Background(),60*time.Second)defercancel()fmt.Println( 输入消息 )for_,msg:rangereq.Messages{fmt.Printf([%s] %s\n,msg.Role,msg.Content)}fmt.Println()fmt.Println(\n 调用LLM )resp,err:client.Chat(ctx,req)iferr!nil{log.Fatal().Err(err).Msg(调用LLM失败)}iflen(resp.Choices)0{log.Fatal().Msg(LLM返回空响应)}choice:resp.Choices[0]fmt.Printf( LLM响应 (finish_reason: %s) \n,choice.FinishReason)iflen(choice.Message.ToolCalls)0{fmt.Printf([ToolCalls] 共 %d 个工具调用\n,len(choice.Message.ToolCalls))for_,toolCall:rangechoice.Message.ToolCalls{fmt.Printf(\n--- 工具调用 ---\n)fmt.Printf(工具名称: %s\n,toolCall.Function.Name)fmt.Printf(调用参数: %s\n,toolCall.Function.Arguments)result,err:toolRegistry.Execute(ctx,toolCall.Function.Name,json.RawMessage(toolCall.Function.Arguments))iferr!nil{resultfmt.Sprintf(工具执行错误: %v,err)}fmt.Printf(执行结果: %s\n,result)}}elseifchoice.Message.Content!{fmt.Printf(内容: %s\n,choice.Message.Content)}}funcloadConfig(pathstring)(*types.Config,error){file,err:os.Open(path)iferr!nil{returnnil,fmt.Errorf(打开配置文件失败: %w,err)}deferfile.Close()varconfig types.Configiferr:json.NewDecoder(file).Decode(config);err!nil{returnnil,fmt.Errorf(解析配置文件失败: %w,err)}apiKey:os.Getenv(config.APIKey)ifapiKey{returnnil,fmt.Errorf(环境变量 %s 未设置,config.APIKey)}config.APIKeyapiKeyreturnconfig,nil}运行程序你会看到 LLM 准确地输出了调用 shell 工具的指令参数为 cat …/config.json。我们的程序成功执行了该命令并打印出了文件的内容。第二部分小结至此我们的 Agent 已经不再是一个只能“纸上谈兵”的对话模型。它通过 shell 工具拥有了与本地文件系统交互的实际能力。然而当前的交互还停留在“一问一答”的单步执行。在上面的例子中Agent 只是把 cat 命令的结果打印了出来并没有基于这个结果给出人性化的总结比如“文件内容如上所示…”。如果任务需要多个步骤才能完成例如先列出文件再读取文件最后总结文件内容Agent 还无能为力。下一部分我们将教会它如何“持续思考”。