本系列共 10 阶段,面向有 Go 后端基础的开发者,全程用 Go 手写实现。
当前进度:阶段三 Tool Calling · 第 3 课(20)
前两课完成了工具注册、参数解析和本地分派。这一课把注册表接入真实模型 API,走通完整闭环:
text
Go 发送工具定义
↓
模型返回 tool_calls
↓
Go 校验并执行工具
↓
Go 返回 tool 结果
↓
模型生成最终回答
天气查询仍然使用模拟数据。这样可以验证模型是否提出工具调用、Go 是否执行工具、结果是否正确回传,但最终温度不能被视为实时天气。
一、本课学习目标
学完本课,你应该能够:
- 构造带
tools和tool_choice的模型请求。 - 解析
assistant.tool_calls。 - 将 JSON 字符串形式的
function.arguments交给工具。 - 保留 assistant 工具调用消息并回传
role: "tool"结果。 - 限制模型轮数、工具调用次数和总执行时间。
- 区分真实模型判断、模拟工具数据和程序授权。
二、先理解完整协议流转
用户问:
请查询新加坡的天气,并说明结果是不是实时数据。
典型交互是:
- Go 发送用户消息、工具名称和参数 Schema。
- 模型判断需要工具,返回包含
tool_calls的 assistant 消息。 - Go 校验调用 ID、工具名称和参数。
- Go 执行注册表中的工具。
- Go 保留原 assistant 消息,再追加对应的
tool消息。 - Go 再次请求模型。
- 模型根据工具结果生成最终文本。
一次工具调用不是完整 Agent 循环,因为模型拿到工具结果后可能继续请求其他工具。本课用一个受限循环处理这种可能,但完整的任务状态管理留到下一阶段。
三、真实 API 的关键字段
兼容 Chat Completions 的常见字段如下:
| 字段 | 作用 |
|---|---|
tools |
提供可用工具的名称、描述和参数 Schema |
tool_choice |
控制调用策略,auto 由模型决定 |
tool_calls |
assistant 消息中的工具调用列表 |
tool_call_id |
关联工具结果与原始调用 |
模型名称和接口能力应以账号控制台及官方文档为准。本课通过 DEEPSEEK_MODEL 环境变量配置,不在代码里假设唯一可用模型。
四、完整示例
1. 配置环境变量
powershell
$env:DEEPSEEK_API_KEY = "你的API_KEY"
$env:DEEPSEEK_MODEL = "账号控制台中的模型ID"
API Key 只用于运行时配置,不要写入源码或提交到 Git。
2. 编写 main.go
go
package main
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"sort"
"strings"
"sync"
"time"
)
const apiURL = "https://api.deepseek.com/chat/completions"
type Tool interface {
Name() string
Description() string
Parameters() json.RawMessage
Execute(context.Context, json.RawMessage) (any, error)
}
type ToolDefinition struct {
Type string `json:"type"`
Function FunctionDefinition `json:"function"`
}
type FunctionDefinition struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"`
}
type Registry struct {
mu sync.RWMutex
tools map[string]Tool
}
func NewRegistry() *Registry {
return &Registry{tools: make(map[string]Tool)}
}
func (r *Registry) Register(t Tool) error {
if t == nil || strings.TrimSpace(t.Name()) == "" {
return errors.New("工具无效")
}
if !json.Valid(t.Parameters()) {
return errors.New("工具 Schema 无效")
}
r.mu.Lock()
defer r.mu.Unlock()
if _, exists := r.tools[t.Name()]; exists {
return fmt.Errorf("工具 %q 已注册", t.Name())
}
r.tools[t.Name()] = t
return nil
}
func (r *Registry) Definitions() []ToolDefinition {
r.mu.RLock()
defer r.mu.RUnlock()
defs := make([]ToolDefinition, 0, len(r.tools))
for _, t := range r.tools {
defs = append(defs, ToolDefinition{
Type: "function",
Function: FunctionDefinition{
Name: t.Name(),
Description: t.Description(),
Parameters: t.Parameters(),
},
})
}
sort.Slice(defs, func(i, j int) bool {
return defs[i].Function.Name < defs[j].Function.Name
})
return defs
}
func (r *Registry) Execute(
ctx context.Context,
name string,
args json.RawMessage,
) (any, error) {
r.mu.RLock()
t, ok := r.tools[name]
r.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("工具 %q 不允许调用", name)
}
if err := ctx.Err(); err != nil {
return nil, err
}
return t.Execute(ctx, args)
}
func decodeObject(data json.RawMessage, dst any) error {
trimmed := bytes.TrimSpace(data)
if len(trimmed) == 0 ||
trimmed[0] != '{' ||
!json.Valid(trimmed) {
return errors.New("arguments 必须是 JSON 对象")
}
decoder := json.NewDecoder(bytes.NewReader(trimmed))
decoder.DisallowUnknownFields()
if err := decoder.Decode(dst); err != nil {
return fmt.Errorf("解析工具参数失败: %w", err)
}
var extra any
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
if err == nil {
return errors.New("arguments 包含多个 JSON 值")
}
return fmt.Errorf("arguments 尾部无效: %w", err)
}
return nil
}
type WeatherTool struct{}
func (WeatherTool) Name() string {
return "get_weather"
}
func (WeatherTool) Description() string {
return "查询支持城市的模拟天气数据"
}
func (WeatherTool) Parameters() json.RawMessage {
return json.RawMessage(`{
"type": "object",
"properties": {
"city": {
"type": "string",
"description": "城市名称"
}
},
"required": ["city"],
"additionalProperties": false
}`)
}
func (WeatherTool) Execute(
ctx context.Context,
raw json.RawMessage,
) (any, error) {
var args struct {
City string `json:"city"`
}
if err := decodeObject(raw, &args); err != nil {
return nil, err
}
city := strings.TrimSpace(args.City)
if city == "" {
return nil, errors.New("city 不能为空")
}
if city != "Singapore" {
return nil, errors.New("当前只支持 Singapore")
}
if err := ctx.Err(); err != nil {
return nil, err
}
return map[string]any{
"city": city,
"temperature": 28,
"condition": "Cloudy",
"source": "mock",
}, nil
}
type Message struct {
Role string `json:"role"`
Content any `json:"content"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function FunctionCall `json:"function"`
}
type FunctionCall struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
type ChatRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Tools []ToolDefinition `json:"tools"`
ToolChoice string `json:"tool_choice"`
}
type ChatResponse struct {
Choices []struct {
Message Message `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
}
type DeepSeekClient struct {
apiKey string
model string
client *http.Client
}
func NewDeepSeekClient(apiKey, model string) *DeepSeekClient {
return &DeepSeekClient{
apiKey: apiKey,
model: model,
client: &http.Client{Timeout: 45 * time.Second},
}
}
func (c *DeepSeekClient) Chat(
ctx context.Context,
messages []Message,
tools []ToolDefinition,
) (Message, string, error) {
body, err := json.Marshal(ChatRequest{
Model: c.model,
Messages: messages,
Tools: tools,
ToolChoice: "auto",
})
if err != nil {
return Message{}, "", fmt.Errorf("编码请求失败: %w", err)
}
req, err := http.NewRequestWithContext(
ctx,
http.MethodPost,
apiURL,
bytes.NewReader(body),
)
if err != nil {
return Message{}, "", fmt.Errorf("创建请求失败: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+c.apiKey)
resp, err := c.client.Do(req)
if err != nil {
return Message{}, "", fmt.Errorf("请求模型失败: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
errorBody, _ := io.ReadAll(
io.LimitReader(resp.Body, 1<<20),
)
return Message{}, "", fmt.Errorf(
"模型 API 返回 HTTP %d: %s",
resp.StatusCode,
strings.TrimSpace(string(errorBody)),
)
}
var result ChatResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return Message{}, "", fmt.Errorf("解析响应失败: %w", err)
}
if len(result.Choices) == 0 {
return Message{}, "", errors.New("响应中没有 choices")
}
choice := result.Choices[0]
return choice.Message, choice.FinishReason, nil
}
func runAgent(
ctx context.Context,
client *DeepSeekClient,
registry *Registry,
userInput string,
) (string, error) {
messages := []Message{
{
Role: "system",
Content: "需要天气数据时调用 get_weather。" +
"工具结果 source=mock 表示模拟数据。",
},
{Role: "user", Content: userInput},
}
definitions := registry.Definitions()
const (
maxRounds = 4
maxToolCalls = 6
toolTimeout = 5 * time.Second
)
totalCalls := 0
for round := 0; round < maxRounds; round++ {
if err := ctx.Err(); err != nil {
return "", err
}
assistant, finishReason, err := client.Chat(
ctx,
messages,
definitions,
)
if err != nil {
return "", err
}
if assistant.Role != "assistant" {
return "", errors.New("模型响应角色无效")
}
messages = append(messages, assistant)
if len(assistant.ToolCalls) == 0 {
content, ok := assistant.Content.(string)
if !ok || strings.TrimSpace(content) == "" {
return "", errors.New("模型没有返回有效回答")
}
if finishReason != "" && finishReason != "stop" {
return "", fmt.Errorf(
"模型异常停止: %s",
finishReason,
)
}
return content, nil
}
if finishReason != "" && finishReason != "tool_calls" {
return "", fmt.Errorf(
"工具调用响应的 finish_reason=%s",
finishReason,
)
}
seenIDs := make(map[string]struct{}, len(assistant.ToolCalls))
for _, call := range assistant.ToolCalls {
if call.ID == "" || call.Type != "function" {
return "", errors.New("工具调用元数据无效")
}
if _, exists := seenIDs[call.ID]; exists {
return "", errors.New("工具调用 ID 重复")
}
seenIDs[call.ID] = struct{}{}
if totalCalls >= maxToolCalls {
return "", errors.New("工具调用次数超过限制")
}
totalCalls++
callCtx, cancel := context.WithTimeout(
ctx,
toolTimeout,
)
result, toolErr := registry.Execute(
callCtx,
call.Function.Name,
json.RawMessage(call.Function.Arguments),
)
cancel()
var content any
if toolErr != nil {
content = map[string]string{
"error": toolErr.Error(),
}
} else {
content = result
}
encoded, err := json.Marshal(content)
if err != nil {
return "", fmt.Errorf("编码工具结果失败: %w", err)
}
messages = append(messages, Message{
Role: "tool",
ToolCallID: call.ID,
Content: string(encoded),
})
}
}
return "", errors.New("模型交互轮数超过限制")
}
func run() error {
apiKey := strings.TrimSpace(os.Getenv("DEEPSEEK_API_KEY"))
if apiKey == "" {
return errors.New("DEEPSEEK_API_KEY 未设置")
}
model := strings.TrimSpace(os.Getenv("DEEPSEEK_MODEL"))
if model == "" {
model = "deepseek-flash"
}
registry := NewRegistry()
if err := registry.Register(WeatherTool{}); err != nil {
return err
}
ctx, cancel := context.WithTimeout(
context.Background(),
2*time.Minute,
)
defer cancel()
answer, err := runAgent(
ctx,
NewDeepSeekClient(apiKey, model),
registry,
"请查询新加坡的天气,并说明结果是否为实时数据。",
)
if err != nil {
return err
}
fmt.Println("Agent:")
fmt.Println(answer)
return nil
}
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "调用失败:", err)
os.Exit(1)
}
}
五、运行与观察
powershell
go mod init deepseek-tool-agent
go run .
如果 Go Module 已经初始化,只执行 go run .。
本次会产生真实模型 API 请求和费用,但天气工具仍返回固定模拟值。
正常过程应当能在日志或调试器中观察到一次 tool_calls 响应,以及后续包含 role: "tool" 的第二次请求。如果模型直接回答而没有调用工具,应检查:
- 用户问题是否明确需要外部数据。
- 工具描述是否清楚。
- 当前模型是否支持 Tool Calling。
- 请求中的
tools是否为空。 - 模型响应中的
finish_reason和tool_calls。
六、关键代码解析
1. 模型只收到工具描述
Registry.Definitions() 只暴露名称、描述和 Schema,不暴露 Go 执行器。模型不知道数据库地址、函数地址或内部权限实现。
2. arguments 是字符串中的 JSON
响应中的字段通常是:
json
{
"function": {
"name": "get_weather",
"arguments": "{\"city\":\"Singapore\"}"
}
}
因此结构体使用 Arguments string。执行工具前再转换成 json.RawMessage:
go
json.RawMessage(call.Function.Arguments)
不能把整个字符串直接当对象,也不能省掉 JSON 校验。
3. 必须保留 assistant 工具调用消息
go
messages = append(messages, assistant)
后续 tool 消息通过 tool_call_id 与它关联。如果丢掉 assistant 消息,模型可能无法正确匹配结果。
4. 一次响应可能包含多个调用
程序逐条检查 ID、类型和次数,并为每个调用添加一条对应结果。不能把所有工具输出拼成一段文本后冒充单条工具结果。
5. 三种限制各自负责不同风险
| 限制 | 控制对象 |
|---|---|
maxRounds |
模型交互轮数 |
maxToolCalls |
实际工具执行尝试次数 |
总 context.WithTimeout |
整个任务运行时间 |
单次 HTTP 客户端超时还限制一次模型请求的最大耗时。它们不能相互替代。
6. 工具错误回传需要谨慎
示例把工具错误作为结果交给模型,便于模型解释或选择其他方案。生产环境应使用安全错误码,避免把数据库地址、SQL、密钥或内部路径透传给模型。
权限拒绝、预算耗尽、副作用结果未知等错误,应直接进入明确的停止或人工处理分支。
七、本课总结
- 模型 API 通过
tools接收工具定义,通过tool_calls返回调用请求。 function.arguments通常是 JSON 字符串,需要再次解析。- 工具执行后要保留 assistant 消息并追加对应
tool消息。 tool_call_id是多调用场景中的关联键。- 最大轮数、工具次数、单次工具超时和任务总超时共同限制成本。
- 真实模型参与决策,不代表工具数据真实,也不代表副作用的权限已经通过。
八、课后练习
练习 1:为什么只发送工具结果不够
执行完 get_weather 后,为什么不能只追加一条 tool 消息而丢掉 assistant 的工具调用?
参考答案 :assistant 消息说明了模型请求了哪些工具以及对应 ID。tool 结果需要通过
tool_call_id与请求匹配,丢掉请求会破坏协议上下文。
练习 2:多个调用如何返回
模型同一轮请求 call_001 和 call_002,Go 执行了两个工具。需要返回几条工具消息?
参考答案 :两条。每个调用都要有独立的
role: "tool"消息,并分别使用正确的tool_call_id。
练习 3:为什么限制模型轮数还不够
maxRounds = 5 是否意味着最多执行 5 个工具?
参考答案:不意味着。一轮模型响应可以包含多个工具调用,所以还要独立限制实际执行尝试次数、工具结果大小和总预算。
下一篇:Go AI Agent 实战 21|多工具选择、参数校验与工具执行安全
专栏:Go 开发者学 AI Agent:从大模型到工程落地