Go AI Agent 实战 20|接入 DeepSeek 原生 Tool Calling

本系列共 10 阶段,面向有 Go 后端基础的开发者,全程用 Go 手写实现。

当前进度:阶段三 Tool Calling · 第 3 课(20)

上一篇:工具定义、参数 Schema 与工具注册表

前两课完成了工具注册、参数解析和本地分派。这一课把注册表接入真实模型 API,走通完整闭环:

text 复制代码
Go 发送工具定义
   ↓
模型返回 tool_calls
   ↓
Go 校验并执行工具
   ↓
Go 返回 tool 结果
   ↓
模型生成最终回答

天气查询仍然使用模拟数据。这样可以验证模型是否提出工具调用、Go 是否执行工具、结果是否正确回传,但最终温度不能被视为实时天气。

一、本课学习目标

学完本课,你应该能够:

  1. 构造带 tools 和 tool_choice 的模型请求。
  2. 解析 assistant.tool_calls。
  3. 将 JSON 字符串形式的 function.arguments 交给工具。
  4. 保留 assistant 工具调用消息并回传 role: "tool" 结果。
  5. 限制模型轮数、工具调用次数和总执行时间。
  6. 区分真实模型判断、模拟工具数据和程序授权。

二、先理解完整协议流转

用户问:

请查询新加坡的天气,并说明结果是不是实时数据。

典型交互是:

  1. Go 发送用户消息、工具名称和参数 Schema。
  2. 模型判断需要工具,返回包含 tool_calls 的 assistant 消息。
  3. Go 校验调用 ID、工具名称和参数。
  4. Go 执行注册表中的工具。
  5. Go 保留原 assistant 消息,再追加对应的 tool 消息。
  6. Go 再次请求模型。
  7. 模型根据工具结果生成最终文本。

一次工具调用不是完整 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、密钥或内部路径透传给模型。

权限拒绝、预算耗尽、副作用结果未知等错误,应直接进入明确的停止或人工处理分支。

七、本课总结

  1. 模型 API 通过 tools 接收工具定义,通过 tool_calls 返回调用请求。
  2. function.arguments 通常是 JSON 字符串,需要再次解析。
  3. 工具执行后要保留 assistant 消息并追加对应 tool 消息。
  4. tool_call_id 是多调用场景中的关联键。
  5. 最大轮数、工具次数、单次工具超时和任务总超时共同限制成本。
  6. 真实模型参与决策,不代表工具数据真实,也不代表副作用的权限已经通过。

八、课后练习

练习 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:从大模型到工程落地

相关推荐
环境栈笔记2 小时前
浏览器多开同步怎么做?先分清设置、登录态和窗口操作
前端·人工智能·后端·自动化
段一凡-华北理工大学2 小时前
高炉能源系统智能化:热风炉、煤气与极致能效~系列文章08:煤气柜与缓冲:一场“来去不均“的博弈
人工智能·能源·高炉炼铁·高炉能源优化·热风炉智能控制·高炉煤气柜·高炉煤气
心.c2 小时前
从“加个缓存”到稳定上线:把 Redis 放回真实业务里
数据库·redis·ai·1024程序员节
yh弓长2 小时前
系统提示词、用户提示词与聊天模板
人工智能
天天代码码天天2 小时前
用 C# 做了个盲文照片识别工坊:能改点、能补格,还能离线运行
人工智能
zhangfeng11332 小时前
RSI = Recursive Self-Improvement,递归自我改进训练 大模型训练
人工智能·深度学习·机器学习
开开心心就好2 小时前
电脑语音实时转文字工具,会议记录很省事
运维·服务器·前端·c#·ocr·perl·1024程序员节
lucas_AI2 小时前
Gemini 4 Carbon 发布:网络安全基准杀疯了,但你暂时用不上
人工智能