上一篇我们让 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 和工具定义做判断,但真正能描述工具边界的,主要就是 Name、Description、Parameters(JSON Schema)。
go
Description: "搜索",
就两个字。模型拿到用户的问题"Go 1.22 有什么新特性?",看到三个工具------一个叫 get_weather、一个叫 calculate、一个叫 search。search 的描述是"搜索",跟其他两个工具的区分度为零,选择会变得不稳定。
对比详细版:
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=function、function.name、function.description、function.parameters,也不是我们自己发明的结构,而是 DeepSeek/OpenAI-compatible Chat Completion API 对 function tool 的约定。Go 里的 openai.Tool 和 openai.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.name 和 function.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 里查处理函数;第二行把模型返回的参数交给这个处理函数执行。也就是说,工具调用分成两步:
- 模型选择工具 :返回
function.name和function.arguments - 程序执行工具 :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 返回的响应里 FinishReason 是 tool_calls,ToolCalls 数组里有两个元素:
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 请求就完成了。关键点:
- 第一次 API 请求我传了三个工具,模型自己决定调用其中两个,一次性返回了两个 tool_call
- 两个 tool_call 依次执行 ,用的就是我定义的
toolHandlersmap 做路由分发 - 执行结果追加为 tool 消息,模型在下一轮就能看到结果
- 第二次 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 解决的是输入问题:模型应该填哪些字段、字段是什么类型、哪些字段必填。
但一个真正可用的工具契约不能只写输入,还要让模型知道结果怎么读。否则模型能把工具调用起来,却不知道下一轮该怎么推理。
一个工具契约至少要回答六个问题:
- 这个工具做什么?
- 什么时候应该用?
- 什么时候不该用?
- 参数字段分别是什么意思?
- 返回结果是什么形态?
- 失败时会返回哪些错误类型?
再往工程里走,还要补三类约束:
- 副作用:这个工具只是查询,还是会创建订单、发消息、删文件?
- 幂等性:同样参数调用两次,结果是一样的,还是会产生两次副作用?
- 权限:所有用户都能调用,还是只有管理员、付费用户、特定租户能调用?
这篇 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。 不需要拆成多个简单参数。直接给完整结构,模型会按结构填充。这里有个关键细节:quantity 用 integer 而不是 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 加上记忆。