# Go AI Agent 实战 21|多工具选择、参数校验与工具执行安全

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

当前进度:阶段三 Tool Calling · 第 4 课(21)

上一篇:接入 DeepSeek 原生 Tool Calling

上一课已经接通真实模型和工具注册表。现在工具数量增加,模型也可能在一轮里返回多个调用,单靠工具白名单已经不够。

这一课要回答一个后端工程师最关心的问题:当模型选择多个工具时,Go 程序怎样控制参数、权限、时间、调用次数和错误信息?

一、本课学习目标

学完本课,你应该能够:

  1. 处理同一响应中的多个工具调用。
  2. 校验调用 ID、类型、工具名称与 JSON 参数。
  3. 为单次工具设置超时,为整个任务设置调用预算。
  4. 区分参数错误、业务错误和内部错误。
  5. 安全地把可恢复错误转换成工具结果。
  6. 使用单元测试验证注册表与执行边界。

二、多工具调用的真实流程

用户提出:

帮我搜索 Go Agent 相关文章,并告诉我现在的时间。

模型可能返回:

json 复制代码
{
  "tool_calls": [
    {
      "id": "call_001",
      "type": "function",
      "function": {
        "name": "search_articles",
        "arguments": "{\"keyword\":\"Go Agent\",\"limit\":10}"
      }
    },
    {
      "id": "call_002",
      "type": "function",
      "function": {
        "name": "get_current_time",
        "arguments": "{}"
      }
    }
  ]
}

执行过程是:

text 复制代码
检查调用 ID 和类型
   ↓
每个 ID 是否唯一
   ↓
工具是否注册
   ↓
JSON 与业务参数是否合法
   ↓
调用预算是否足够
   ↓
执行工具并关联结果

模型可以决定"想调用哪些工具",但程序决定"是否真的允许执行"。

三、三个不能混用的边界

1. 参数格式校验

例如:

json 复制代码
{"article_id":-1}

这是合法 JSON,整数类型也正确,但文章 ID 不应该是负数。

2. 业务规则校验

文章 ID 是正数,也不代表文章存在、状态允许编辑,或者用户能够查看。

3. 权限与副作用控制

即使 delete_article 已注册、参数也合法,Go 后端仍然要检查:

  • 当前用户是谁。
  • 用户是否有权删除这篇文章。
  • 是否需要再次确认。
  • 是否有幂等和审计机制。

Schema、参数校验和权限系统分别解决不同问题,不能互相替代。

四、统一校验工具调用

假设前两课已经定义:

go 复制代码
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"`
}

先检查同一轮是否存在空 ID 或重复 ID:

go 复制代码
func ValidateToolCalls(calls []ToolCall) error {
	seen := make(map[string]struct{}, len(calls))

	for _, call := range calls {
		if strings.TrimSpace(call.ID) == "" {
			return ErrInvalidToolCall
		}
		if call.Type != "function" {
			return ErrInvalidToolCall
		}
		if _, exists := seen[call.ID]; exists {
			return ErrDuplicateToolCallID
		}
		seen[call.ID] = struct{}{}
	}

	return nil
}

重复 ID 会让两个工具结果无法可靠对应原调用,因此属于协议错误,不适合交给模型自行猜测。

五、带超时和边界检查的执行器

定义可分类错误:

go 复制代码
var (
	ErrInvalidToolCall     = errors.New("invalid tool call")
	ErrDuplicateToolCallID = errors.New("duplicate tool call ID")
	ErrToolNotFound        = errors.New("tool not found")
	ErrToolTimeout         = errors.New("tool timeout")
	ErrToolDenied          = errors.New("tool permission denied")
)

注册表增加受控执行入口:

go 复制代码
func (r *Registry) ExecuteChecked(
	ctx context.Context,
	call ToolCall,
	timeout time.Duration,
) (any, error) {
	if strings.TrimSpace(call.ID) == "" ||
		call.Type != "function" ||
		strings.TrimSpace(call.Function.Name) == "" ||
		!json.Valid([]byte(call.Function.Arguments)) {
		return nil, ErrInvalidToolCall
	}
	if timeout <= 0 {
		return nil, ErrInvalidToolCall
	}

	r.mu.RLock()
	tool, ok := r.tools[call.Function.Name]
	r.mu.RUnlock()
	if !ok {
		return nil, ErrToolNotFound
	}

	toolCtx, cancel := context.WithTimeout(ctx, timeout)
	defer cancel()

	result, err := tool.Execute(
		toolCtx,
		json.RawMessage(call.Function.Arguments),
	)
	if err != nil {
		if errors.Is(toolCtx.Err(), context.DeadlineExceeded) {
			return nil, fmt.Errorf(
				"%w: %v",
				ErrToolTimeout,
				err,
			)
		}
		return nil, err
	}

	return result, nil
}

json.Valid 只检查参数是不是合法 JSON,具体对象类型、未知字段和业务约束仍由工具执行器负责。

Context 也不会强制杀死任意 goroutine。数据库查询或 HTTP 请求必须真正使用该 Context,超时才能传播到下游。

六、限制工具调用总次数

MaxRounds 只限制模型请求轮数。模型可能在一轮内返回多个工具调用,因此还需要独立的累计预算。

go 复制代码
type CallBudget struct {
	Max  int
	Used int
}

func (b *CallBudget) Take() error {
	if b.Max <= 0 {
		return errors.New("工具调用预算无效")
	}
	if b.Used >= b.Max {
		return errors.New("工具调用次数超过限制")
	}
	b.Used++
	return nil
}

执行前逐条调用:

go 复制代码
if err := budget.Take(); err != nil {
	return "", err
}

result, err := registry.ExecuteChecked(
	ctx,
	call,
	toolTimeout,
)

只要进入一次执行尝试,就应该占用预算,包括最终返回业务错误或超时的调用。否则模型可以不断触发失败调用绕过限制。

示例结构不是 goroutine 安全的。如果 Agent 会并发执行多个工具,需要改用互斥锁、原子计数或每个任务独立预算。

七、安全错误映射

工具可能返回三类错误:

类别 例子 处理方式
参数错误 字段缺失、范围非法 返回明确但不泄露内部细节的提示
业务错误 资源不存在、用户无权访问 返回稳定业务错误码
内部错误 数据库连接失败、内部地址 记录内部日志,向模型返回通用错误

可以定义统一结果:

go 复制代码
type ToolErrorResult struct {
	Code    string `json:"code"`
	Message string `json:"message"`
}

func SafeToolError(err error) ToolErrorResult {
	switch {
	case errors.Is(err, ErrInvalidToolCall):
		return ToolErrorResult{
			Code:    "INVALID_TOOL_CALL",
			Message: "工具调用格式不正确",
		}
	case errors.Is(err, ErrToolNotFound):
		return ToolErrorResult{
			Code:    "TOOL_NOT_FOUND",
			Message: "请求的工具不可用",
		}
	case errors.Is(err, ErrToolTimeout):
		return ToolErrorResult{
			Code:    "TOOL_TIMEOUT",
			Message: "工具执行超时",
		}
	case errors.Is(err, ErrToolDenied):
		return ToolErrorResult{
			Code:    "PERMISSION_DENIED",
			Message: "当前操作没有权限",
		}
	default:
		return ToolErrorResult{
			Code:    "TOOL_EXECUTION_FAILED",
			Message: "工具执行失败",
		}
	}
}

错误码要稳定,便于模型和业务层判断;原始内部错误应进入脱敏后的服务端日志,而不是直接进入模型上下文。

八、权限必须来自可信上下文

假设模型请求删除文章:

json 复制代码
{
  "name": "delete_article",
  "arguments": "{\"article_id\":123}"
}

不能让模型通过参数声明 user_id 或 role: admin。用户身份应来自已有的 Go 认证上下文、Session 或 Token 校验结果。

工具内部应调用现有 BIZ 层:

text 复制代码
Agent Tool
   ↓
业务服务:检查用户与文章归属
   ↓
数据访问层

而不是让 Agent 直接拼接 SQL 或跳过业务规则。

九、为执行层编写测试

创建 registry_test.go,先测纯逻辑,不请求真实模型:

go 复制代码
package main

import (
	"context"
	"encoding/json"
	"errors"
	"testing"
	"time"
)

type EchoTool struct{}

func (EchoTool) Name() string        { return "echo" }
func (EchoTool) Description() string { return "返回输入消息" }

func (EchoTool) Parameters() json.RawMessage {
	return json.RawMessage(`{
		"type": "object",
		"properties": {
			"message": {"type": "string"}
		},
		"required": ["message"],
		"additionalProperties": false
	}`)
}

func (EchoTool) Execute(
	ctx context.Context,
	raw json.RawMessage,
) (any, error) {
	var args struct {
		Message string `json:"message"`
	}
	if err := json.Unmarshal(raw, &args); err != nil {
		return nil, ErrInvalidToolCall
	}
	if args.Message == "" {
		return nil, ErrInvalidToolCall
	}

	select {
	case <-ctx.Done():
		return nil, ctx.Err()
	default:
	}

	return map[string]string{"message": args.Message}, nil
}

func TestExecuteChecked(t *testing.T) {
	registry := NewRegistry()
	if err := registry.Register(EchoTool{}); err != nil {
		t.Fatal(err)
	}

	call := ToolCall{
		ID:   "call_001",
		Type: "function",
		Function: FunctionCall{
			Name:      "echo",
			Arguments: `{"message":"hello"}`,
		},
	}

	result, err := registry.ExecuteChecked(
		context.Background(),
		call,
		time.Second,
	)
	if err != nil {
		t.Fatal(err)
	}

	got := result.(map[string]string)["message"]
	if got != "hello" {
		t.Fatalf("got %q, want hello", got)
	}
}

func TestRejectUnknownTool(t *testing.T) {
	registry := NewRegistry()
	call := ToolCall{
		ID:   "call_002",
		Type: "function",
		Function: FunctionCall{
			Name:      "delete_everything",
			Arguments: `{}`,
		},
	}

	_, err := registry.ExecuteChecked(
		context.Background(),
		call,
		time.Second,
	)
	if !errors.Is(err, ErrToolNotFound) {
		t.Fatalf("got %v, want ErrToolNotFound", err)
	}
}

运行:

powershell 复制代码
go test ./...
go test -race ./...

测试应继续覆盖:

  • 重复注册。
  • 空 ID、重复 ID 和非 function 类型。
  • 无效 JSON、未知字段和错误参数范围。
  • 单次超时与父 Context 取消。
  • 累计调用次数。
  • 权限拒绝和错误脱敏。

十、本课总结

  1. 多个工具调用必须逐条校验,并分别关联各自的 tool_call_id。
  2. 参数合法不代表资源存在、用户有权限或副作用允许发生。
  3. 单次工具超时与总调用预算控制不同资源。
  4. 重复调用 ID 是协议错误,应在执行前拒绝。
  5. 交给模型的错误应稳定、脱敏,内部诊断留在服务端日志。
  6. 工具应调用已有 BIZ 层,而不是让 Agent 绕过业务权限直接访问数据库。

十一、课后练习

练习 1:轮数与调用数

MaxRounds = 4,模型每轮最多返回 3 个调用。只限制轮数时,理论上有多少调用?

参考答案:最多 12 次。轮数限制不等于工具次数限制,仍需独立的累计预算。

练习 2:合法参数能否直接删除

delete_article 已注册,article_id=123 也是正整数,是否可以直接执行?

参考答案:不能。还要验证当前用户、文章归属、业务状态、审批要求和幂等性。

练习 3:超时能否直接重试

调用支付工具 10 秒后超时,是否可以立即再次支付?

参考答案:不能。超时只表示没有按时拿到结果,支付可能已经成功。应先按交易号或幂等键查询状态。


下一篇:Go AI Agent 实战 22|完善 Agent 执行循环:从工具调用到最终回答

专栏:Go 开发者学 AI Agent:从大模型到工程落地

相关推荐
lucas_AI1 小时前
多跳 RAG 不用花大价钱建知识图谱了:纯几何方法,成本为零还更准
人工智能
AIGS0011 小时前
AI问数能问什么问题?——跨ERP、MES的数据一句话查出来,每个数字带出处
人工智能·数字员工·流程优化·智能问数·企业ai落地·ai问数·本体语义
香蕉大香蕉(06敬畏市场)1 小时前
高频八股 · JVM(7):方法区、元空间与字符串常量池
java·jvm·ai·博客·八股·大厂实习
山东孙燕姿1 小时前
前端框架升级:不敢动的组件怎么办
大数据·运维·服务器·网络·人工智能·前端框架
u1301301 小时前
AI 日报(2026年10月23日)
人工智能
瓶瓶持续学习中1 小时前
物理AI与数字孪生如何协同:从三维建模到仿真训练
人工智能
AAA小肥杨1 小时前
大模型量化简介
人工智能·大模型
又过二十1 小时前
Qwen-Image2.1游戏素材工坊:让角色真的走起来(三)
ai·aigc·comfyui·game·qwen-image
七牛开发者8 小时前
从 GLM 到 GPT,HarnessPro 如何用 RSI 降低 Agent 成本
人工智能·gpt·agent·harness·rsi