本系列共 10 阶段,面向有 Go 后端基础的开发者,全程用 Go 手写实现。
当前进度:阶段三 Tool Calling · 第 4 课(21)
上一课已经接通真实模型和工具注册表。现在工具数量增加,模型也可能在一轮里返回多个调用,单靠工具白名单已经不够。
这一课要回答一个后端工程师最关心的问题:当模型选择多个工具时,Go 程序怎样控制参数、权限、时间、调用次数和错误信息?
一、本课学习目标
学完本课,你应该能够:
- 处理同一响应中的多个工具调用。
- 校验调用 ID、类型、工具名称与 JSON 参数。
- 为单次工具设置超时,为整个任务设置调用预算。
- 区分参数错误、业务错误和内部错误。
- 安全地把可恢复错误转换成工具结果。
- 使用单元测试验证注册表与执行边界。
二、多工具调用的真实流程
用户提出:
帮我搜索 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 取消。
- 累计调用次数。
- 权限拒绝和错误脱敏。
十、本课总结
- 多个工具调用必须逐条校验,并分别关联各自的
tool_call_id。 - 参数合法不代表资源存在、用户有权限或副作用允许发生。
- 单次工具超时与总调用预算控制不同资源。
- 重复调用 ID 是协议错误,应在执行前拒绝。
- 交给模型的错误应稳定、脱敏,内部诊断留在服务端日志。
- 工具应调用已有 BIZ 层,而不是让 Agent 绕过业务权限直接访问数据库。
十一、课后练习
练习 1:轮数与调用数
MaxRounds = 4,模型每轮最多返回 3 个调用。只限制轮数时,理论上有多少调用?
参考答案:最多 12 次。轮数限制不等于工具次数限制,仍需独立的累计预算。
练习 2:合法参数能否直接删除
delete_article 已注册,article_id=123 也是正整数,是否可以直接执行?
参考答案:不能。还要验证当前用户、文章归属、业务状态、审批要求和幂等性。
练习 3:超时能否直接重试
调用支付工具 10 秒后超时,是否可以立即再次支付?
参考答案:不能。超时只表示没有按时拿到结果,支付可能已经成功。应先按交易号或幂等键查询状态。
下一篇:Go AI Agent 实战 22|完善 Agent 执行循环:从工具调用到最终回答
专栏:Go 开发者学 AI Agent:从大模型到工程落地