用 Go 手撸 Agent 框架 #4:工具定义与契约——让 Agent 学会调用多工具

上一篇我们让 Agent 拥有了一个天气工具。单工具场景下模型没得选------要么调用工具,要么直接回答。

但这不是真实世界的样子。真实世界你会给 Agent 天气、计算器、搜索三个工具。然后问题就来了:

模型开始选错工具。用户问"Go 1.22 新特性",模型可能调了天气工具。用户说"帮我算个数",模型可能调了搜索。更糟的是,有时候它该调用工具却没有调------直接用自己的训练数据瞎猜了一个答案。

多个工具之间的区分,不能只靠关键词硬编码覆盖。 你不能只写 if strings.Contains(question, "天气") then call get_weather------用户说"我想知道周末适不适合出去玩"时,代码里没有"天气"两个字,但模型应该知道这是天气问题。

这一篇解决:当一个 Agent 面朝多个工具时,怎么设计工具定义显著提高它选对的概率。

先把上一篇那一步捡起来。

上一篇我们已经做过一个最小闭环:定义一个 weatherTool,在请求里通过 Tools: []openai.Tool{weatherTool} 发给 DeepSeek。模型看到工具定义后,返回 tool_calls;Go 代码再根据 tool_calls 执行本地 handler,把结果作为 tool 消息回填给模型。

所以这里说"把工具交给模型",不是把 Go 函数、源码或 handler 交给模型。模型拿不到你的 Go 代码。我们真正交给它的是一份工具声明:工具叫什么、什么时候该用、参数长什么样。

上一篇只给了一个工具,模型没什么选择压力。这一篇要做的是把 weatherTool 扩成一组工具,让模型在天气、计算、搜索之间自己选。

从一个工具扩成工具清单

go 复制代码
var tools = []openai.Tool{
    {
        Type: openai.ToolTypeFunction,
        Function: &openai.FunctionDefinition{
            Name:        "get_weather",
            Description: "获取指定城市当前的天气信息,包括温度、天气状况和风向",
            Parameters: map[string]any{
                "type": "object",
                "properties": map[string]any{
                    "city": map[string]any{
                        "type":        "string",
                        "description": "城市名称,如北京、上海、New York",
                    },
                },
                "required": []string{"city"},
            },
        },
    },
    {
        Type: openai.ToolTypeFunction,
        Function: &openai.FunctionDefinition{
            Name:        "calculate",
            Description: "计算数学表达式,支持加减乘除和括号,如 '(1 + 2) * 3'",
            Parameters: map[string]any{
                "type": "object",
                "properties": map[string]any{
                    "expression": map[string]any{
                        "type":        "string",
                        "description": "数学表达式,如 '(1 + 2) * 3'",
                    },
                },
                "required": []string{"expression"},
            },
        },
    },
    {
        Type: openai.ToolTypeFunction,
        Function: &openai.FunctionDefinition{
            Name:        "search",
            Description: "搜索本地知识库中的信息。适用于查找编程概念、技术文档等内容。返回一段可直接引用的摘要",
            Parameters: map[string]any{
                "type": "object",
                "properties": map[string]any{
                    "keyword": map[string]any{
                        "type":        "string",
                        "description": "搜索关键词,如 Go for loop 语法",
                    },
                },
                "required": []string{"keyword"},
            },
        },
    },
}

这里的 Name: "get_weather" 不是 DeepSeek 自己猜出来的,而是我们在请求里明确告诉它的工具名。真正发到 API 里时,tools 大致会变成这样的 JSON:

json 复制代码
{
  "type": "function",
  "function": {
    "name": "get_weather",
    "description": "获取指定城市当前的天气信息,包括温度、天气状况和风向",
    "parameters": {
      "type": "object",
      "properties": {
        "city": {
          "type": "string",
          "description": "城市名称,如北京、上海、New York"
        }
      },
      "required": ["city"]
    }
  }
}

模型看到的是这份工具清单,不是你的 Go 源码。后面它返回 function.name = "get_weather",本质上是在说:"我决定从你给我的候选工具里选 get_weather,并填入这些参数。"

这份清单不是普通文档

后端工程师看到这段代码,容易把它当成"文档"------随意写几个字应付一下。可模型主要靠这些字段来做判断,写得太含糊,它就会选错。

在这段代码里,工具定义是模型最主要、最直接的路由契约。 模型不知道你的 handler 怎么实现,不知道哪个工具适合用户当前的问题。它会综合 user message、system prompt、上下文、tool_choice 和工具定义做判断,但真正能描述工具边界的,主要就是 NameDescriptionParameters(JSON Schema)

go 复制代码
Description: "搜索",

就两个字。模型拿到用户的问题"Go 1.22 有什么新特性?",看到三个工具------一个叫 get_weather、一个叫 calculate、一个叫 searchsearch 的描述是"搜索",跟其他两个工具的区分度为零,选择会变得不稳定。

对比详细版:

go 复制代码
Description: "搜索本地知识库中的信息。适用于查找编程概念、技术文档等内容。返回一段可直接引用的摘要",

现在模型知道:查天气的信息类型是"温度和天气状况",搜索的信息类型是"编程概念、技术文档"。用户问技术文章 → 匹配"技术文档"关键词 → 选 search

结论:Description 的精度会显著影响工具路由的准确率。 一个好的 Description 应该回答三个问题------这个工具处理什么信息类型?适用于什么场景?返回什么格式的结果?

停一下问自己: 如果 search 的 Description 只写"搜索",模型凭什么判断它该用于技术资料,而不是天气、计算或者任何其他事情?

实现三个工具

go 复制代码
var weatherData = map[string]string{
    "北京":   "15°C,晴,西北风3级",
    "上海":   "18°C,多云,东南风2级",
    "东京":   "22°C,小雨,南风1级",
    "New York": "12°C,晴,北风4级",
    "London":   "8°C,阴,西风3级",
}

var searchResults = map[string]string{
    "go for loop":    "Go 只有 for 一种循环关键字,支持三种形式:经典 for init; condition; post {}、while 风格 for condition {}、无限循环 for {}。Go 1.22 起 for 循环变量每次迭代创建新变量------修复了 Go 最经典的闭包并发陷阱。",
    "goroutine":      "goroutine 是 Go 的轻量级用户态执行单元,被 Go 调度器复用到 OS 线程上。启动一个 goroutine 只需 go func() {}。",
    "rust ownership": "Rust 的所有权系统在编译期保证内存安全,无需 GC。每个值有且只有一个 owner,离开作用域时自动释放。",
}

func handleGetWeather(input json.RawMessage) (string, error) {
    var args struct {
        City string `json:"city"`
    }
    if err := json.Unmarshal(input, &args); err != nil {
        return "", fmt.Errorf("参数解析失败: %w", err)
    }
    if weather, ok := weatherData[args.City]; ok {
        return weather, nil
    }
    return fmt.Sprintf("%s 的天气数据暂时不可用", args.City), nil
}

func handleCalculate(input json.RawMessage) (string, error) {
    var args struct {
        Expression string `json:"expression"`
    }
    if err := json.Unmarshal(input, &args); err != nil {
        return "", fmt.Errorf("参数解析失败: %w", err)
    }
    result, err := evalExpression(args.Expression)
    if err != nil {
        return "", err
    }
    if result == float64(int64(result)) {
        return fmt.Sprintf("%s = %d", args.Expression, int64(result)), nil
    }
    return fmt.Sprintf("%s = %s", args.Expression, strconv.FormatFloat(result, 'f', -1, 64)), nil
}

func handleSearch(input json.RawMessage) (string, error) {
    var args struct {
        Keyword string `json:"keyword"`
    }
    if err := json.Unmarshal(input, &args); err != nil {
        return "", fmt.Errorf("参数解析失败: %w", err)
    }
    kw := strings.ToLower(strings.TrimSpace(args.Keyword))
    for key, content := range searchResults {
        if strings.Contains(key, kw) || strings.Contains(kw, key) {
            return content, nil
        }
    }
    return fmt.Sprintf("未找到关于 '%s' 的相关结果,建议换一组关键词", args.Keyword), nil
}

evalExpression 是一版教学用解析器,支持加减乘除、括号和简单的一元负号。这里先看入口,完整代码在文末:

go 复制代码
func evalExpression(expr string) (float64, error) {
    expr = strings.ReplaceAll(expr, " ", "")
    if expr == "" {
        return 0, fmt.Errorf("空表达式")
    }
    return evalAddSub(expr)
}

这仍然只是 demo 级别的实现。生产环境不建议把用户输入的表达式直接扔给随手写的解析器;要么选择仍在维护、经过审计的表达式库,要么按业务白名单自己实现更小的语法。手写版的好处是:你能看到工具层到底做了什么,知道后面框架里的计算器工具在什么层次上工作。

把 DeepSeek 的响应摊开看

工具定义写完之后,下一步不是急着封装,而是先看接口到底回了什么。DeepSeek 返回的不是工具执行结果,而是一张"调用单"。当它判断需要工具时,响应大致会落到这条路径:

text 复制代码
resp.Choices[0].Message.ToolCalls

tool_calls 里会带上函数名和参数:

json 复制代码
[
  {
    "id": "call_abc",
    "type": "function",
    "function": {
      "name": "get_weather",
      "arguments": "{\"city\":\"北京\"}"
    }
  }
]

这里没有真实天气,只有"我要调用 get_weather,参数是北京"。真正的天气结果,来自下一步 Go 代码执行:

go 复制代码
result, err := handleGetWeather(json.RawMessage(`{"city":"北京"}`))

如果想确认这些字段不是凭空写出来的,可以打开原始响应打印:

bash 复制代码
TRACE_DEEPSEEK_RESPONSE=1 go run .

代码里做的事情很简单,就是把 SDK 收到的 resp 原样转成格式化 JSON:

go 复制代码
if traceRawResponseEnabled() {
    printJSON(fmt.Sprintf("[round %d] raw_response", round), resp)
}

这样你能直接看到模型接口返回了什么,再决定代码应该解析哪个字段。工具调用相关的关键字段通常在这里:

text 复制代码
choices[0].finish_reason
choices[0].message.content
choices[0].message.tool_calls
choices[0].message.tool_calls[0].id
choices[0].message.tool_calls[0].function.name
choices[0].message.tool_calls[0].function.arguments

同理,前面的 tools 为什么要写成 type=functionfunction.namefunction.descriptionfunction.parameters,也不是我们自己发明的结构,而是 DeepSeek/OpenAI-compatible Chat Completion API 对 function tool 的约定。Go 里的 openai.Toolopenai.FunctionDefinition 只是把这份 JSON 协议包装成结构体。

如果你想对照官方协议,可以看三份文档:

  • Create Chat Completion:定义请求里的 tools 字段,也定义响应里的 choices[].message.tool_calls
  • Tool Calls:给出工具调用的完整示例,以及 strict mode 下 JSON Schema 的约束
  • Function Calling:从更入门的角度解释模型如何通过外部函数增强能力

官方响应 schema 里也写得很清楚:tool_calls[].function.name 是模型要调用的函数名,tool_calls[].function.arguments 是模型生成的 JSON 参数字符串。注意它仍然是模型生成内容,生产代码必须校验参数,不能因为它长得像 JSON 就直接信任。

停一下问自己: DeepSeek 返回 tool_calls[].function.name = "get_weather" 时,是不是已经执行了 handleGetWeather?如果没有,下一步是谁来执行?

工具路由:从名字到函数

看到 function.namefunction.arguments 之后,toolHandlers 这个 map 就不突兀了。它只是把模型返回的函数名,映射到我们本地真正要执行的 Go 函数:

go 复制代码
var toolHandlers = map[string]func(json.RawMessage) (string, error){
    "get_weather": handleGetWeather,
    "calculate":   handleCalculate,
    "search":      handleSearch,
}

DeepSeek 不知道你的 handleGetWeather 函数在哪里,也不会直接执行 Go 代码。真正把 "get_weather" 变成函数调用的,是这两行:

go 复制代码
handler, ok := toolHandlers[tc.Function.Name]
result, err := handler(json.RawMessage(tc.Function.Arguments))

第一行用模型返回的函数名去 toolHandlers 里查处理函数;第二行把模型返回的参数交给这个处理函数执行。也就是说,工具调用分成两步:

  1. 模型选择工具 :返回 function.namefunction.arguments
  2. 程序执行工具 :Go 代码按名字查 toolHandlers,调用对应 handler

为了让这个中间环节看得见,可以在执行 handler 前打印一行:

go 复制代码
fmt.Printf("[tool_call] id=%s name=%s arguments=%s\n", tc.ID, tc.Function.Name, tc.Function.Arguments)

真实运行时你会看到类似输出:

text 复制代码
[round 1] request: messages=1 tools=3
[round 1] response: finish_reason=tool_calls tool_calls=1
[tool_call] id=call_abc name=get_weather arguments={"city":"北京"}
[tool_result] id=call_abc name=get_weather result=15°C,晴,西北风3级
[round 1] tool results appended, continue
[round 2] request: messages=3 tools=3
[round 2] response: finish_reason=stop tool_calls=0
[assistant_final]
北京现在的天气情况如下:...

看到这几行,就说明 DeepSeek 已经完成了"选择工具"这一步。接下来才轮到 Go 代码按名字查表并执行本地函数。

所以你看到北京天气返回写死的 15°C,晴,西北风3级,不是模型自己查到了这个天气,而是模型选择了 get_weather,然后我们的 Go 代码执行了 handleGetWeather,从 weatherData["北京"] 里取出了这段 mock 数据。

改造循环:多工具版

核心循环几乎不变,唯一的区别在路由:

go 复制代码
func runAgent(client *openai.Client, ctx context.Context, question string) {
    messages := []openai.ChatCompletionMessage{
        {Role: openai.ChatMessageRoleUser, Content: question},
    }

    fmt.Printf("[user] %s\n", question)
    for i := 0; i < 5; i++ {
        round := i + 1
        fmt.Printf("[round %d] request: messages=%d tools=%d\n", round, len(messages), len(tools))

        resp, err := client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
            Model:     "deepseek-v4-flash",
            MaxTokens: 4096,
            Messages:  messages,
            Tools:     tools, // 三个工具一起传
        })
        if err != nil {
            fmt.Printf("API 调用失败: %v\n", err)
            return
        }

        choice := resp.Choices[0]
        if traceRawResponseEnabled() {
            printJSON(fmt.Sprintf("[round %d] raw_response", round), resp)
        }
        fmt.Printf("[round %d] response: finish_reason=%v tool_calls=%d\n", round, choice.FinishReason, len(choice.Message.ToolCalls))

        if choice.FinishReason == openai.FinishReasonToolCalls {
            messages = append(messages, openai.ChatCompletionMessage{
                Role:      openai.ChatMessageRoleAssistant,
                Content:   choice.Message.Content,
                ToolCalls: choice.Message.ToolCalls,
            })

            for _, tc := range choice.Message.ToolCalls {
                fmt.Printf("[tool_call] id=%s name=%s arguments=%s\n", tc.ID, tc.Function.Name, tc.Function.Arguments)
                handler, ok := toolHandlers[tc.Function.Name]
                var result string
                if ok {
                    result, err = handler(json.RawMessage(tc.Function.Arguments))
                    if err != nil {
                        result = "工具执行出错: " + err.Error()
                    }
                } else {
                    result = fmt.Sprintf("未知工具: %s", tc.Function.Name)
                }
                fmt.Printf("[tool_result] id=%s name=%s result=%s\n", tc.ID, tc.Function.Name, result)

                messages = append(messages, openai.ChatCompletionMessage{
                    Role:       openai.ChatMessageRoleTool,
                    Content:    result,
                    ToolCallID: tc.ID,
                })
            }
            fmt.Printf("[round %d] tool results appended, continue\n", round)
            continue
        }

        fmt.Println("[assistant_final]")
        fmt.Println(choice.Message.Content)
        if choice.FinishReason == openai.FinishReasonLength {
            fmt.Println("\n⚠️ 警告:模型输出被截断,可能需要增大 MaxTokens")
        }
        return
    }

    fmt.Println("达到最大迭代次数,未能完成请求")
}

核心改动只有一行:handler, ok := toolHandlers[tc.Function.Name] 上一篇写死 handleGetWeather,现在根据模型决定的名字动态分发。

实际运行追踪:一条消息如何驱动多次工具调用

代码读起来抽象,我们用一个具体例子走一遍。假设用户输入:

北京天气怎么样?另外帮我算一下 1234 × 5678 等于多少

第 0 轮:初始状态

消息列表只有一条 user 消息:

ini 复制代码
[0] role=user      content="北京天气怎么样?另外帮我算一下 1234 × 5678 等于多少"

把这条消息和三个工具定义一起发给 DeepSeek API。

第 1 轮:模型决定调用哪些工具

API 返回的响应里 FinishReasontool_callsToolCalls 数组里有两个元素:

matlab 复制代码
ToolCall #1:  id="call_abc", function.name="get_weather", function.arguments={"city":"北京"}
ToolCall #2:  id="call_def", function.name="calculate",    function.arguments={"expression":"1234 * 5678"}

模型看到用户问了天气和计算两件事,决定同时调用两个工具。注意这两个是同一次 API 请求内返回的(DeepSeek 支持一次返回多个 tool_call)。

第一步:把模型的决策记入消息列表

代码先把 assistant 消息追加进列表。现在列表变成:

ini 复制代码
[0] user      "北京天气怎么样?另外帮我算一下..."
[1] assistant  tool_calls=[get_weather("北京"), calculate("1234 * 5678")]

第二步:遍历 ToolCalls,逐个执行

接着进入 for _, tc := range choice.Message.ToolCalls,先处理第一个,再处理第二个。

处理 get_weather:

handler, ok := toolHandlers["get_weather"] → 取到 handleGetWeather 函数。

handler(json.RawMessage({"city":"北京"})) → 执行这个函数。

handleGetWeather 内部:json.Unmarshal 解析出 City: "北京",查 weatherData map,匹配到 "北京": "15°C,晴,西北风3级",返回结果。

执行结果作为 tool 消息追加。列表:

ini 复制代码
[0] user      "北京天气怎么样?另外帮我算一下..."
[1] assistant  tool_calls=[get_weather("北京"), calculate("1234 * 5678")]
[2] tool       tool_call_id="call_abc"  content="15°C,晴,西北风3级"

处理 calculate:

handler, ok := toolHandlers["calculate"] → 取到 handleCalculate 函数。

handler(json.RawMessage({"expression":"1234 * 5678"})) → 执行。

handleCalculate 内部:解析表达式 "1234 * 5678"evalExpression 做乘法运算,返回 "1234 * 5678 = 7006652"

执行结果追加为 tool 消息。列表:

ini 复制代码
[0] user      "北京天气怎么样?另外帮我算一下..."
[1] assistant  tool_calls=[get_weather("北京"), calculate("1234 * 5678")]
[2] tool       tool_call_id="call_abc"  content="15°C,晴,西北风3级"
[3] tool       tool_call_id="call_def"  content="1234 * 5678 = 7006652"

第三步:继续循环

continue 回到循环开头,开始第 2 轮。

第 2 轮:模型根据工具结果生成最终回答

把包含 tool 消息的完整列表再次发给 API。这次模型看到天气结果是 "15°C,晴,西北风3级",计算结果 "1234 * 5678 = 7006652",决定不需要再调工具了。FinishReason 变成 stop,直接返回回答:

北京今天晴,气温 15°C,西北风 3 级。1234 × 5678 = 7,006,652。

代码打印最终回答,然后 return,循环结束。

追踪总结

整个过程两轮 API 请求就完成了。关键点:

  1. 第一次 API 请求我传了三个工具,模型自己决定调用其中两个,一次性返回了两个 tool_call
  2. 两个 tool_call 依次执行 ,用的就是我定义的 toolHandlers map 做路由分发
  3. 执行结果追加为 tool 消息,模型在下一轮就能看到结果
  4. 第二次 API 请求模型确认任务完成,给出最终回答

整个调用链是:API 返回 tool_call → 按 Name 查 toolHandlers → 执行 handler → 追加 tool 消息 → 下一轮 API 请求。每个环节都不复杂,串在一起就是一个完整的工具调用循环。

如果模型选错工具会怎样

假设 search 工具的 Description 写的是"搜索"两个字。用户说"Go 1.22 有什么新特性",模型可能误选 get_weather

这时 handler 仍然会执行------handleGetWeather 会查 weatherData["Go 1.22"],找不到,返回 "Go 1.22 的天气数据暂时不可用"

模型拿到这条结果,会发现"这不是我想知道的东西",然后尝试换个工具再查,或者告诉用户"我没有找到相关信息"。多了一轮不必要的 API 调用。

这也是为什么前面花那么多篇幅讲 Description 精度------选错工具的代价不是崩溃,是浪费(一轮 API 调用)和体验差(用户看到无关结果)

一个容易混淆的边界

你打开 DeepSeek 官网的 Chat 页面,问"北京天气怎么样",它可能能给出实时天气。但这不等于裸模型本身天然会查天气。

大模型本身更像一个基于训练数据和上下文做预测的引擎。训练完成后,它不会自动知道此刻北京的实时温度。实时天气、网页搜索、文件读取、数据库查询,通常都来自模型外部的能力:搜索系统、工具调用、插件、RAG、浏览器、业务 API。

所以要区分三层:

层级 它是什么 能不能天然知道实时天气
裸模型 只看你传入的上下文和它训练出的参数 不能稳定知道
API function calling 模型返回 tool_calls,你的程序执行工具 取决于你有没有提供天气工具
Chat 产品 厂商做好的聊天应用,可能内置联网搜索、文件上传、工具系统 取决于产品是否打开这些能力

这篇文章写的是第二层:API function calling 。在我们的代码里,DeepSeek 不会自己联网查天气;它只返回 tool_calls,真正查什么、算什么、读什么数据,由我们的 Go 代码决定。

契约不只输入,也包括输出

跑完一遍之后再回头看,工具契约不只是 Parameters(JSON Schema)Parameters 解决的是输入问题:模型应该填哪些字段、字段是什么类型、哪些字段必填。

但一个真正可用的工具契约不能只写输入,还要让模型知道结果怎么读。否则模型能把工具调用起来,却不知道下一轮该怎么推理。

一个工具契约至少要回答六个问题:

  1. 这个工具做什么?
  2. 什么时候应该用?
  3. 什么时候不该用?
  4. 参数字段分别是什么意思?
  5. 返回结果是什么形态?
  6. 失败时会返回哪些错误类型?

再往工程里走,还要补三类约束:

  • 副作用:这个工具只是查询,还是会创建订单、发消息、删文件?
  • 幂等性:同样参数调用两次,结果是一样的,还是会产生两次副作用?
  • 权限:所有用户都能调用,还是只有管理员、付费用户、特定租户能调用?

这篇 demo 里的三个工具都没有真实副作用:天气、计算、搜索都只是返回字符串。生产环境里最好把输出也结构化,比如搜索工具不要只返回一段纯文本,而是返回摘要、来源、时间、置信度:

json 复制代码
{
  "summary": "Go 1.22 起 for 循环变量每次迭代创建新变量,修复了经典闭包陷阱。",
  "sources": ["https://go.dev/blog/loopvar-preview"],
  "confidence": "high"
}

结构化输出的好处是:模型下一轮不用猜这段文字里的哪部分是结论、哪部分是来源、哪部分是不确定性。工具契约越清楚,Agent 的后续决策越稳定。

停一下问自己: 如果搜索工具只返回一大段纯文本,没有来源、时间和置信度,模型下一轮怎么判断这段结果能不能直接引用?

JSON Schema 进阶

上面的工具参数都很简单------一个字符串。实际项目中工具参数会更复杂。比如"创建订单":

go 复制代码
{
    Type: openai.ToolTypeFunction,
    Function: &openai.FunctionDefinition{
        Name:        "create_order",
        Description: "创建商品订单。需要商品 ID、数量和收货地址",
        Parameters: map[string]any{
            "type": "object",
            "properties": map[string]any{
                "items": map[string]any{
                    "type":        "array",
                    "description": "商品列表,每个商品包含 id 和 quantity",
                    "items": map[string]any{
                        "type": "object",
                        "properties": map[string]any{
                            "id": map[string]any{
                                "type":        "string",
                                "description": "商品 ID",
                            },
                            "quantity": map[string]any{
                                "type":        "integer",
                                "description": "购买数量",
                            },
                        },
                        "required": []string{"id", "quantity"},
                    },
                },
                "address": map[string]any{
                    "type": "object",
                    "properties": map[string]any{
                        "name":    map[string]any{"type": "string", "description": "收货人姓名"},
                        "phone":   map[string]any{"type": "string", "description": "手机号"},
                        "detail":  map[string]any{"type": "string", "description": "详细地址"},
                    },
                    "required": []string{"name", "phone", "detail"},
                },
            },
            "required": []string{"items", "address"},
        },
    },
}

模型能理解嵌套的 JSON Schema。 不需要拆成多个简单参数。直接给完整结构,模型会按结构填充。这里有个关键细节:quantityinteger 而不是 string,会显著降低模型填成"两件"这种自然语言值的概率;服务端仍要校验。

好工具定义模板

以后你自己定义工具,可以先按这个模板写一遍:

text 复制代码
Name: 动词_对象,短、稳定、可路由,例如 get_weather、create_order

Description:
这个工具做什么。
什么时候应该使用它。
什么时候不应该使用它。
返回结果是什么形态。
是否会产生副作用。

Parameters:
每个字段的类型、含义、示例值、是否必填。

Returns:
成功时返回什么结构,字段分别是什么意思。

Errors:
失败时返回哪些错误类型,哪些可以重试,哪些不能重试。

Permissions:
谁可以调用,是否需要二次确认。

把这个模板填完整,再去写 handler。顺序不要反过来。先写 handler 再补 Description,很容易把代码里的实现细节当成工具边界,最后模型看到的是一份模糊的说明书。

错误处理:工具失败时,把错误当信号

工具可能会出错------网络超时、参数不对、数据不存在。上一篇直接把错误转成字符串回传:

go 复制代码
result, err := handler(json.RawMessage(tc.Function.Arguments))
if err != nil {
    result = "工具执行出错: " + err.Error()
}

注意这里没有 return错误被当作一种"观察结果"传给模型,让模型自己决定下一步。 这就是 Agent 跟普通程序的关键区别:普通程序遇到错误返回 500;Agent 把错误作为新一轮决策的输入。

但有一个陷阱:模型可能陷入 "重试→失败→重试" 的死循环。搜索 API 超时,模型换关键词再搜,还是超时......错误信息每次都差不多,模型无法判断是"同一个原因"还是"新原因"。

解决方案:结构化错误前缀。 让工具返回的错误带上类型标记,模型就能区分:

  • [TRANSIENT] 搜索服务超时 --- 瞬时错误,可以重试
  • [INVALID_ARG] 城市不存在 --- 参数错误,换个参数
  • [RATE_LIMIT] API 超限,30 秒后重试 --- 被限流了
  • [FATAL] 知识库未初始化 --- 重试也没用

对应的 system prompt:

go 复制代码
Messages: []openai.ChatCompletionMessage{
    {
        Role: openai.ChatMessageRoleSystem,
        Content: `工具执行出错时,先看错误类型:
- [TRANSIENT]:瞬时错误,可以重试,但不要连续重试超过 2 次
- [INVALID_ARG]:参数错误,修正后重试
- [RATE_LIMIT]:触发限流,先做其他工作,稍后重试
- [FATAL]:不可恢复,直接告诉用户`,
    },
},

这是两层分工:工具层做快速失败和结构化分类,Agent 层基于分类决定下一步。 工具层保证错误是明确、可归类的;Agent 层基于分类做语义决策。

工具路由评测表

写完工具定义,不要只靠感觉判断它好不好。可以列一张小表,把常见用户问题变成可测行为:

用户问题 应该调用 不该调用
北京今天适合跑步吗? get_weather search
Go 1.22 for loop 改了什么? search get_weather
1234 * 5678 calculate search
北京天气怎么样?另外算一下 1234 * 5678 get_weather + calculate 只调一个工具
帮我删除一个文件 当前无可用工具,应该说明做不到 硬编一个不存在的工具

这张表的价值不是测试 Go 代码,而是测试工具契约。你把同一批问题反复喂给模型,看它是否稳定选择预期工具。选错了,通常不是 handler 写错,而是工具边界、Description、参数名或 system prompt 没讲清楚。

通用工具 vs 专用工具

三个专用工具------查天气、做计算、搜索。你可能在想:为什么不写一个万能工具,让模型自己写代码调任何 API?

实际上 Claude Code 走的恰恰是相反的路:专用工具优先。 Read、Write、Edit、Bash、Glob、Grep------每个工具只做一件事。

从三个角度理解这个选择:

模型准确性。 "读取指定文件路径的全部内容"比"执行任意代码"的语义范围小得多。工具越多,每个工具的职责越窄,Description 可以写得越精确,模型选对的概率就越高。万能工具的 Description 不可能精确------因为它的能力边界无限大。

安全性。 Read 只能读文件,Edit 只能改已存在的文件。天然的安全边界。一个万能工具要么权限太大(什么都能做),要么需要复杂的权限校验。

错误处理。 专用工具的错误原因有限且可枚举------"文件不存在""路径不合法"。万能工具的错误可能性无限------语法错误、运行时异常、死循环------模型很难处理。

工程取舍的原则:高频操作用专用工具,低频操作可以适度通用,危险操作永远专用包装。 搜索高频→专用。读网页高频→专用。发邮件低频→可以用通用 HTTP 工具。删文件危险→专用且带确认。

完整的可运行程序

下面这份代码可以直接作为单文件复制运行。配套仓库里把 .env 加载逻辑拆到了 loadenv.go,文章里为了方便复制,放进同一个 main.go

go 复制代码
package main

import (
    "bufio"
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strconv"
    "strings"

    openai "github.com/sashabaranov/go-openai"
)

// deepseekTransport 注入 thinking: {type: "disabled"}。
// DeepSeek V4 默认开启 Thinking Mode。工具调用场景下,
// 把 reasoning_content 带回后续请求会报 400,显式关闭 thinking 避免此问题。
type deepseekTransport struct {
    base http.RoundTripper
}

func (t *deepseekTransport) RoundTrip(req *http.Request) (*http.Response, error) {
    if strings.Contains(req.URL.Host, "deepseek") && strings.Contains(req.URL.Path, "chat/completions") {
        bodyBytes, err := io.ReadAll(req.Body)
        if err != nil {
            return nil, err
        }
        req.Body.Close()

        var bodyMap map[string]any
        if err := json.Unmarshal(bodyBytes, &bodyMap); err != nil {
            return nil, fmt.Errorf("请求体解析失败: %w", err)
        }
        if _, exists := bodyMap["thinking"]; !exists {
            bodyMap["thinking"] = map[string]string{"type": "disabled"}
        }
        modifiedBytes, err := json.Marshal(bodyMap)
        if err != nil {
            return nil, err
        }
        req.Body = io.NopCloser(bytes.NewReader(modifiedBytes))
        req.ContentLength = int64(len(modifiedBytes))
    }
    if t.base != nil {
        return t.base.RoundTrip(req)
    }
    return http.DefaultTransport.RoundTrip(req)
}

// --- 工具定义 ---

var tools = []openai.Tool{
    {
        Type: openai.ToolTypeFunction,
        Function: &openai.FunctionDefinition{
            Name:        "get_weather",
            Description: "获取指定城市当前的天气信息,包括温度、天气状况和风向",
            Parameters: map[string]any{
                "type": "object",
                "properties": map[string]any{
                    "city": map[string]any{
                        "type":        "string",
                        "description": "城市名称,如北京、上海、New York",
                    },
                },
                "required": []string{"city"},
            },
        },
    },
    {
        Type: openai.ToolTypeFunction,
        Function: &openai.FunctionDefinition{
            Name:        "calculate",
            Description: "计算数学表达式,支持加减乘除和括号,如 '(1 + 2) * 3'",
            Parameters: map[string]any{
                "type": "object",
                "properties": map[string]any{
                    "expression": map[string]any{
                        "type":        "string",
                        "description": "数学表达式,如 '(1 + 2) * 3'",
                    },
                },
                "required": []string{"expression"},
            },
        },
    },
    {
        Type: openai.ToolTypeFunction,
        Function: &openai.FunctionDefinition{
            Name:        "search",
            Description: "搜索本地知识库中的信息。适用于查找编程概念、技术文档等内容。返回一段可直接引用的摘要",
            Parameters: map[string]any{
                "type": "object",
                "properties": map[string]any{
                    "keyword": map[string]any{
                        "type":        "string",
                        "description": "搜索关键词,如 Go for loop 语法",
                    },
                },
                "required": []string{"keyword"},
            },
        },
    },
}

// --- Mock 数据 ---

var weatherData = map[string]string{
    "北京": "15°C,晴,西北风3级",
    "上海": "18°C,多云,东南风2级",
    "东京": "22°C,小雨,南风1级",
    "New York": "12°C,晴,北风4级",
    "London":   "8°C,阴,西风3级",
}

var searchResults = map[string]string{
    "go for loop":    "Go 只有 for 一种循环关键字,支持三种形式:经典 for init; condition; post {}、while 风格 for condition {}、无限循环 for {}。Go 1.22 起 for 循环变量每次迭代创建新变量------修复了 Go 最经典的闭包并发陷阱。",
    "goroutine":      "goroutine 是 Go 的轻量级用户态执行单元,被 Go 调度器复用到 OS 线程上。启动一个 goroutine 只需 go func() {}。多个 goroutine 可以共享同一个 OS 线程,一个 goroutine 也可能在不同时间片跑在不同的 OS 线程上。",
    "rust ownership": "Rust 的所有权系统在编译期保证内存安全,无需 GC。每个值有且只有一个 owner,离开作用域时自动释放。通过借用(&T 不可变借用,&mut T 可变借用)临时访问值而不转移所有权。",
}

// --- 工具实现 ---

var toolHandlers = map[string]func(json.RawMessage) (string, error){
    "get_weather": handleGetWeather,
    "calculate":   handleCalculate,
    "search":      handleSearch,
}

func traceRawResponseEnabled() bool {
    v := strings.ToLower(strings.TrimSpace(os.Getenv("TRACE_DEEPSEEK_RESPONSE")))
    return v == "1" || v == "true" || v == "yes"
}

func printJSON(label string, v any) {
    b, err := json.MarshalIndent(v, "", "  ")
    if err != nil {
        fmt.Printf("%s: JSON 序列化失败: %v\n", label, err)
        return
    }
    fmt.Printf("%s\n%s\n", label, string(b))
}

func handleGetWeather(input json.RawMessage) (string, error) {
    var args struct{ City string `json:"city"` }
    if err := json.Unmarshal(input, &args); err != nil {
        return "", fmt.Errorf("参数解析失败: %w", err)
    }
    if w, ok := weatherData[args.City]; ok {
        return w, nil
    }
    return fmt.Sprintf("%s 的天气数据暂时不可用", args.City), nil
}

func handleCalculate(input json.RawMessage) (string, error) {
    var args struct{ Expression string `json:"expression"` }
    if err := json.Unmarshal(input, &args); err != nil {
        return "", fmt.Errorf("参数解析失败: %w", err)
    }
    result, err := evalExpression(args.Expression)
    if err != nil {
        return "", err
    }
    if result == float64(int64(result)) {
        return fmt.Sprintf("%s = %d", args.Expression, int64(result)), nil
    }
    return fmt.Sprintf("%s = %s", args.Expression, strconv.FormatFloat(result, 'f', -1, 64)), nil
}

func handleSearch(input json.RawMessage) (string, error) {
    var args struct{ Keyword string `json:"keyword"` }
    if err := json.Unmarshal(input, &args); err != nil {
        return "", fmt.Errorf("参数解析失败: %w", err)
    }
    kw := strings.ToLower(strings.TrimSpace(args.Keyword))
    for key, content := range searchResults {
        if strings.Contains(key, kw) || strings.Contains(kw, key) {
            return content, nil
        }
    }
    return fmt.Sprintf("未找到关于 '%s' 的相关结果,建议换一组关键词", args.Keyword), nil
}

func evalExpression(expr string) (float64, error) {
    expr = strings.ReplaceAll(expr, " ", "")
    if expr == "" {
        return 0, fmt.Errorf("空表达式")
    }
    return evalAddSub(expr)
}

func evalAddSub(expr string) (float64, error) {
    parenDepth := 0
    for i := len(expr) - 1; i >= 0; i-- {
        switch expr[i] {
        case ')':
            parenDepth++
        case '(':
            parenDepth--
        case '+':
            if parenDepth == 0 {
                left, err := evalAddSub(expr[:i])
                if err != nil {
                    return 0, err
                }
                right, err := evalMulDiv(expr[i+1:])
                if err != nil {
                    return 0, err
                }
                return left + right, nil
            }
        case '-':
            if parenDepth == 0 {
                if i == 0 {
                    right, err := evalMulDiv(expr[1:])
                    if err != nil {
                        return 0, err
                    }
                    return -right, nil
                }
                left, err := evalAddSub(expr[:i])
                if err != nil {
                    return 0, err
                }
                right, err := evalMulDiv(expr[i+1:])
                if err != nil {
                    return 0, err
                }
                return left - right, nil
            }
        }
    }
    return evalMulDiv(expr)
}

func evalMulDiv(expr string) (float64, error) {
    parenDepth := 0
    for i := len(expr) - 1; i >= 0; i-- {
        switch expr[i] {
        case ')':
            parenDepth++
        case '(':
            parenDepth--
        case '*':
            if parenDepth == 0 {
                left, err := evalMulDiv(expr[:i])
                if err != nil {
                    return 0, err
                }
                right, err := evalPrimary(expr[i+1:])
                if err != nil {
                    return 0, err
                }
                return left * right, nil
            }
        case '/':
            if parenDepth == 0 {
                left, err := evalMulDiv(expr[:i])
                if err != nil {
                    return 0, err
                }
                right, err := evalPrimary(expr[i+1:])
                if err != nil {
                    return 0, err
                }
                if right == 0 {
                    return 0, fmt.Errorf("除数不能为零")
                }
                return left / right, nil
            }
        }
    }
    return evalPrimary(expr)
}

func evalPrimary(expr string) (float64, error) {
    if expr == "" {
        return 0, fmt.Errorf("空表达式")
    }
    if expr[0] == '(' {
        depth := 1
        for i := 1; i < len(expr); i++ {
            switch expr[i] {
            case '(':
                depth++
            case ')':
                depth--
                if depth == 0 {
                    return evalAddSub(expr[1:i])
                }
            }
        }
        return 0, fmt.Errorf("括号不匹配")
    }
    if expr[0] == '-' {
        val, err := evalPrimary(expr[1:])
        if err != nil {
            return 0, err
        }
        return -val, nil
    }
    return strconv.ParseFloat(expr, 64)
}

// --- Agent 循环 ---

func runAgent(client *openai.Client, ctx context.Context, question string) {
    messages := []openai.ChatCompletionMessage{
        {Role: openai.ChatMessageRoleUser, Content: question},
    }

    fmt.Printf("[user] %s\n", question)
    for i := 0; i < 5; i++ {
        round := i + 1
        fmt.Printf("[round %d] request: messages=%d tools=%d\n", round, len(messages), len(tools))

        resp, err := client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
            Model:     "deepseek-v4-flash",
            MaxTokens: 4096,
            Messages:  messages,
            Tools:     tools,
        })
        if err != nil {
            fmt.Printf("API 调用失败: %v\n", err)
            return
        }

        choice := resp.Choices[0]
        if traceRawResponseEnabled() {
            printJSON(fmt.Sprintf("[round %d] raw_response", round), resp)
        }
        fmt.Printf("[round %d] response: finish_reason=%v tool_calls=%d\n", round, choice.FinishReason, len(choice.Message.ToolCalls))

        if choice.FinishReason == openai.FinishReasonToolCalls {
            messages = append(messages, openai.ChatCompletionMessage{
                Role:      openai.ChatMessageRoleAssistant,
                Content:   choice.Message.Content,
                ToolCalls: choice.Message.ToolCalls,
            })
            for _, tc := range choice.Message.ToolCalls {
                fmt.Printf("[tool_call] id=%s name=%s arguments=%s\n", tc.ID, tc.Function.Name, tc.Function.Arguments)
                handler, ok := toolHandlers[tc.Function.Name]
                var result string
                if ok {
                    result, err = handler(json.RawMessage(tc.Function.Arguments))
                    if err != nil {
                        result = "工具执行出错: " + err.Error()
                    }
                } else {
                    result = fmt.Sprintf("未知工具: %s", tc.Function.Name)
                }
                fmt.Printf("[tool_result] id=%s name=%s result=%s\n", tc.ID, tc.Function.Name, result)
                messages = append(messages, openai.ChatCompletionMessage{
                    Role:       openai.ChatMessageRoleTool,
                    Content:    result,
                    ToolCallID: tc.ID,
                })
            }
            fmt.Printf("[round %d] tool results appended, continue\n", round)
            continue
        }

        fmt.Println("[assistant_final]")
        fmt.Println(choice.Message.Content)
        if choice.FinishReason == openai.FinishReasonLength {
            fmt.Println("\n⚠️ 警告:模型输出被截断,可能需要增大 MaxTokens")
        }
        return
    }

    fmt.Println("达到最大迭代次数,未能完成请求")
}

func loadEnv(filename string) error {
    f, err := os.Open(filename)
    if err != nil {
        if os.IsNotExist(err) {
            return nil
        }
        return err
    }
    defer f.Close()

    scanner := bufio.NewScanner(f)
    for scanner.Scan() {
        line := strings.TrimSpace(scanner.Text())
        if line == "" || strings.HasPrefix(line, "#") {
            continue
        }

        idx := strings.Index(line, "=")
        if idx == -1 {
            continue
        }

        key := strings.TrimSpace(line[:idx])
        value := strings.TrimSpace(line[idx+1:])

        if (strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`)) ||
            (strings.HasPrefix(value, `'`) && strings.HasSuffix(value, `'`)) {
            value = value[1 : len(value)-1]
        }

        if _, exists := os.LookupEnv(key); !exists {
            os.Setenv(key, value)
        }
    }
    return scanner.Err()
}

func main() {
    if err := loadEnv(".env"); err != nil {
        fmt.Printf("加载 .env 失败: %v\n", err)
        return
    }

    config := openai.DefaultConfig(os.Getenv("DEEPSEEK_API_KEY"))
    config.BaseURL = "https://api.deepseek.com"
    // 注入 thinking 禁用
    if httpClient, ok := config.HTTPClient.(*http.Client); ok {
        httpClient.Transport = &deepseekTransport{base: httpClient.Transport}
    }
    client := openai.NewClientWithConfig(config)

    fmt.Println("=== 示例 1: 天气查询 ===")
    runAgent(client, context.Background(), "北京天气怎么样?")

    fmt.Println("\n=== 示例 2: 计算 + 搜索 ===")
    runAgent(client, context.Background(), "帮我算一下 (15 + 27) * 3,然后查一下 goroutine 是什么")
}

运行方式:

bash 复制代码
mkdir 04-multi-tool
cd 04-multi-tool
go mod init demo-agent-04
go get github.com/sashabaranov/go-openai@v1.41.0

把上面的代码保存成 main.go,再创建 .env

env 复制代码
DEEPSEEK_API_KEY=你的 DeepSeek API Key

然后运行:

bash 复制代码
go run .

如果想看到 DeepSeek 返回给 SDK 的完整响应,打开这个开关:

bash 复制代码
TRACE_DEEPSEEK_RESPONSE=1 go run .

到这一步为止

这一篇的核心不是"怎么定义三个工具"------那是代码,复制就能跑。

动笔之前先校准几个容易搞混的点。模型选工具只能看 Name、Description、Parameters------它看不到你的 Go handler,Description 模糊工具边界就模糊。tool_calls 是调用意图不是执行结果,真正跑工具的是 toolHandlers 根据名字查到的 Go 函数。工具失败不等于程序崩溃------把错误作为 tool 消息交回模型,它才能决定下一步。工具契约不能只写输入参数,输出形态也得讲清楚,否则模型下一轮只能猜哪部分是结论哪部分是来源。

需要带走的是三个判断:

工具定义不是给人看的文档,是给模型看的 API 路由表。 Name、Description、Parameters(JSON Schema)、返回结果------这些信息的质量会显著影响模型选工具和使用工具的准确率。Description 回答三个问题:处理什么信息类型?适用于什么场景?返回什么格式?答案越精确,路由越准。

工具数量越多,每个工具的职责要越窄。 这个听起来反直觉------但理由跟微服务拆服务是一样的:职责窄 → Description 可以写得精确 → 模型区分度高 → 选对概率高。万能工具听起来省事,实际上是把路由压力全扔给了模型。

错误不是异常,是信号。 Agent 和普通程序的区别在于:普通程序遇到错误返回 500;Agent 把错误当作新一轮决策的输入------让模型自己决定重试、换参数还是放弃。但必须给错误加类型前缀([TRANSIENT][FATAL] 等),否则模型会陷入无意义的 retry loop。

但还有一个问题没解决:这三个工具是硬编码的。如果想根据用户权限动态加减工具------比如管理员有写文件工具、普通用户只有只读工具------现在的 var tools 数组就太死了。这需要后面框架篇的工具注册表来解决。

另外,目前的 Agent 还有一个更大的缺陷:每次对话都是新的。 用户说"北京天气怎么样"它查了,接着问"那上海呢"------它不知道"那"指的是什么。下一篇:给 Agent 加上记忆。

相关推荐
枕星而眠1 小时前
【数据结构】红黑树入门指南
运维·数据结构·c++·后端
神奇小汤圆1 小时前
JDK 8 → 17 → 21 → 25:一次性讲清四代版本的关键跃迁
后端
要阿尔卑斯吗1 小时前
无限深度章节树如何一遍建成?路径编码+单调栈
后端
大东_tom1 小时前
Python 与 PyTorch 工程实践——从脚本到训练框架
后端
Joose都有人用1 小时前
我把一个「九成正常、一成作恶」的风控难题做稳了,聊聊踩过的坑
后端
她说彩礼65万3 小时前
Asp.net core返回类型总结
后端·asp.net
用户6757049885024 小时前
手摸手教你玩 Jenkins,一次搞懂 CI/CD!(第二章:发布之sshPublisher)
后端·jenkins
用户6757049885024 小时前
手摸手教你玩 Jenkins,一次搞懂 CI/CD!(第一章:部署)
后端·jenkins
一路向北North4 小时前
Spring Security OAuth2.0(20):完善环境配置
java·后端·spring