动手写个agent(二):实现LLM调用工具tool
文章目录
本系列将从零开始,用 Go 语言实现一个具备基本功能(工具调用、循环思考、MCP、Skill)的 Agent
代码仓库:https://gitee.com/lymgoforIT/learn-agent(chapter1对应第一部分的代码,以此类推)
本节目标:让 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请求
type ChatRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Tools []ToolDefinition `json:"tools,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
}
// Message 表示对话消息
type Message struct {
Role MessageRole `json:"role"` // system/user/assistant/tool
Content string `json:"content"` // 消息内容
ToolCallId string `json:"tool_call_id,omitempty"`// 工具调用ID
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // 工具调用请求
}
// ToolCall 表示工具调用请求
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type"` // function
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"` // JSON字符串
} `json:"function"`
}
// ToolDefinition 定义工具描述
type ToolDefinition struct {
Type string `json:"type"` // function
Function struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters"`
} `json:"function"`
}
2.3 定义与实现第一个工具:Shell
现在,让我们来实现第一个,也是最强大的工具之一:shell。这个工具将允许 Agent 执行任意的 shell 命令。
首先,我们定义一个通用的 Tool 接口,任何工具都必须实现它。一个工具需要提供四样东西:名称、描述、参数定义和一个执行方法。并定义一个实现了名称、描述、参数定义的基础工具类BaseTool。
// Tool 工具接口
type Tool interface {
// Name 返回工具名称
Name() string
// Description 返回工具描述
Description() string
// Parameters 返回参数Schema(JSON Schema格式)
Parameters() map[string]interface{}
// Execute 执行工具
Execute(ctx context.Context, params json.RawMessage) (string, error)
}
// BaseTool 基础工具实现
type BaseTool struct {
name string
description string
parameters map[string]interface{}
}
func (t *BaseTool) Name() string {
return t.name
}
func (t *BaseTool) Description() string {
return t.description
}
func (t *BaseTool) Parameters() map[string]interface{} {
return t.parameters
}
基于这个接口,我们来实现 ShellTool。它的 Execute 方法会使用 Go 的 os/exec 包来执行一个 shell 命令,并返回其标准输出和标准错误。
// ShellTool Shell命令执行工具
type ShellTool struct {
BaseTool
timeout time.Duration
}
func NewShellTool(timeout time.Duration) *ShellTool {
return &ShellTool{
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) {
var input struct {
Command string `json:"command"`
}
if err := json.Unmarshal(params, &input); err != nil {
log.Error().Err(err).Msg("参数解析失败")
return "", fmt.Errorf("参数解析失败: %w", err)
}
// 创建带超时的上下文
ctx, cancel := context.WithTimeout(ctx, t.timeout)
defer cancel()
cmd := exec.CommandContext(ctx, "sh", "-c", input.Command)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
output := stdout.String()
if stderr.Len() > 0 {
output += "\nStderr: " + stderr.String()
}
if err != nil {
log.Error().Err(err).Str("command", input.Command).Msg("命令执行失败")
return output, fmt.Errorf("命令执行失败: %w", err)
}
return output, nil
}
2.4 工具的注册与管理
当工具越来越多时,我们需要一个“工具箱”来统一管理它们。下面这个 Registry 结构体就扮演了这个角色。它负责:
- Register: 注册一个新工具。
- ToLLMTools: 将所有已注册的工具转换成 LLM 需要的 ToolDefinition 格式。
- Execute: 根据工具名称和参数,找到并执行对应的工具。
// Registry 工具注册表
type Registry struct {
tools map[string]Tool
mu sync.RWMutex
}
// NewRegistry 创建工具注册表
func NewRegistry() *Registry {
return &Registry{
tools: make(map[string]Tool),
}
}
// Register 注册工具
func (r *Registry) Register(tool Tool) error {
r.mu.Lock()
defer r.mu.Unlock()
name := tool.Name()
if _, exists := r.tools[name]; exists {
log.Warn().Str("tool", name).Msg("工具已存在")
return fmt.Errorf("工具已存在: %s", name)
}
r.tools[name] = tool
return nil
}
// Get 获取工具
func (r *Registry) Get(name string) (Tool, bool) {
r.mu.RLock()
defer r.mu.RUnlock()
tool, exists := r.tools[name]
return tool, exists
}
// List 列出所有工具
func (r *Registry) List() []Tool {
r.mu.RLock()
defer r.mu.RUnlock()
tools := make([]Tool, 0, len(r.tools))
for _, tool := range r.tools {
tools = append(tools, tool)
}
return tools
}
// ToLLMTools 转换为LLM工具定义
func (r *Registry) ToLLMTools() []types.ToolDefinition {
r.mu.RLock()
defer r.mu.RUnlock()
definitions := make([]types.ToolDefinition, 0, len(r.tools))
for _, tool := range r.tools {
def := types.ToolDefinition{
Type: "function",
}
def.Function.Name = tool.Name()
def.Function.Description = tool.Description()
def.Function.Parameters = tool.Parameters()
definitions = append(definitions, def)
}
return definitions
}
// Execute 执行指定工具
func (r *Registry) Execute(ctx context.Context, name string, params json.RawMessage) (string, error) {
tool, exists := r.Get(name)
if !exists {
log.Error().Str("tool", name).Msg("工具不存在")
return "", fmt.Errorf("工具不存在: %s", name)
}
return tool.Execute(ctx, params)
}
2.5 实战:让 Agent 执行 Shell 命令
现在,我们将 ShellTool 注册到我们的工具箱中,并让 Agent 尝试完成一个需要使用该工具的任务:“请帮我查看 …/config.json 文件”。
我们修改 main 函数,将工具信息添加到 LLM 请求中。如果 LLM 的回复包含了 tool_calls,我们就调用工具注册器来执行它,并打印结果。
func main() {
config, err := loadConfig("../config.json")
if err != 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文件",
},
},
// 将可用工具传给LLM
Tools: toolRegistry.ToLLMTools(),
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
fmt.Println("=== 输入消息 ===")
for _, msg := range req.Messages {
fmt.Printf("[%s] %s\n", msg.Role, msg.Content)
}
fmt.Println("================")
fmt.Println("\n=== 调用LLM ===")
resp, err := client.Chat(ctx, req)
if err != nil {
log.Fatal().Err(err).Msg("调用LLM失败")
}
if len(resp.Choices) == 0 {
log.Fatal().Msg("LLM返回空响应")
}
choice := resp.Choices[0]
fmt.Printf("=== LLM响应 (finish_reason: %s) ===\n", choice.FinishReason)
if len(choice.Message.ToolCalls) > 0 {
fmt.Printf("[ToolCalls] 共 %d 个工具调用\n", len(choice.Message.ToolCalls))
for _, toolCall := range choice.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))
if err != nil {
result = fmt.Sprintf("工具执行错误: %v", err)
}
fmt.Printf("执行结果: %s\n", result)
}
} else if choice.Message.Content != "" {
fmt.Printf("内容: %s\n", choice.Message.Content)
}
}
func loadConfig(path string) (*types.Config, error) {
file, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("打开配置文件失败: %w", err)
}
defer file.Close()
var config types.Config
if err := json.NewDecoder(file).Decode(&config); err != nil {
return nil, fmt.Errorf("解析配置文件失败: %w", err)
}
apiKey := os.Getenv(config.APIKey)
if apiKey == "" {
return nil, fmt.Errorf("环境变量 %s 未设置", config.APIKey)
}
config.APIKey = apiKey
return &config, nil
}
运行程序,你会看到 LLM 准确地输出了调用 shell 工具的指令,参数为 cat …/config.json。我们的程序成功执行了该命令,并打印出了文件的内容。

第二部分小结
至此,我们的 Agent 已经不再是一个只能“纸上谈兵”的对话模型。它通过 shell 工具,拥有了与本地文件系统交互的实际能力。
然而,当前的交互还停留在“一问一答”的单步执行。在上面的例子中,Agent 只是把 cat 命令的结果打印了出来,并没有基于这个结果给出人性化的总结(比如“文件内容如上所示…”)。如果任务需要多个步骤才能完成(例如,先列出文件,再读取文件,最后总结文件内容),Agent 还无能为力。
下一部分,我们将教会它如何“持续思考”。
更多推荐



所有评论(0)