
本系列共 10 阶段面向有 Go 后端基础的开发者全程用 Go 手写实现。当前进度阶段三 Tool Calling · 第 4 课21上一篇接入 DeepSeek 原生 Tool Calling上一课已经接通真实模型和工具注册表。现在工具数量增加模型也可能在一轮里返回多个调用单靠工具白名单已经不够。这一课要回答一个后端工程师最关心的问题当模型选择多个工具时Go 程序怎样控制参数、权限、时间、调用次数和错误信息一、本课学习目标学完本课你应该能够处理同一响应中的多个工具调用。校验调用 ID、类型、工具名称与 JSON 参数。为单次工具设置超时为整个任务设置调用预算。区分参数错误、业务错误和内部错误。安全地把可恢复错误转换成工具结果。使用单元测试验证注册表与执行边界。二、多工具调用的真实流程用户提出帮我搜索 Go Agent 相关文章并告诉我现在的时间。模型可能返回{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:{}}}]}执行过程是检查调用 ID 和类型 ↓ 每个 ID 是否唯一 ↓ 工具是否注册 ↓ JSON 与业务参数是否合法 ↓ 调用预算是否足够 ↓ 执行工具并关联结果模型可以决定“想调用哪些工具”但程序决定“是否真的允许执行”。三、三个不能混用的边界1. 参数格式校验例如{article_id:-1}这是合法 JSON整数类型也正确但文章 ID 不应该是负数。2. 业务规则校验文章 ID 是正数也不代表文章存在、状态允许编辑或者用户能够查看。3. 权限与副作用控制即使delete_article已注册、参数也合法Go 后端仍然要检查当前用户是谁。用户是否有权删除这篇文章。是否需要再次确认。是否有幂等和审计机制。Schema、参数校验和权限系统分别解决不同问题不能互相替代。四、统一校验工具调用假设前两课已经定义typeToolCallstruct{IDstringjson:idTypestringjson:typeFunction FunctionCalljson:function}typeFunctionCallstruct{Namestringjson:nameArgumentsstringjson:arguments}先检查同一轮是否存在空 ID 或重复 IDfuncValidateToolCalls(calls[]ToolCall)error{seen:make(map[string]struct{},len(calls))for_,call:rangecalls{ifstrings.TrimSpace(call.ID){returnErrInvalidToolCall}ifcall.Type!function{returnErrInvalidToolCall}if_,exists:seen[call.ID];exists{returnErrDuplicateToolCallID}seen[call.ID]struct{}{}}returnnil}重复 ID 会让两个工具结果无法可靠对应原调用因此属于协议错误不适合交给模型自行猜测。五、带超时和边界检查的执行器定义可分类错误var(ErrInvalidToolCallerrors.New(invalid tool call)ErrDuplicateToolCallIDerrors.New(duplicate tool call ID)ErrToolNotFounderrors.New(tool not found)ErrToolTimeouterrors.New(tool timeout)ErrToolDeniederrors.New(tool permission denied))注册表增加受控执行入口func(r*Registry)ExecuteChecked(ctx context.Context,call ToolCall,timeout time.Duration,)(any,error){ifstrings.TrimSpace(call.ID)||call.Type!function||strings.TrimSpace(call.Function.Name)||!json.Valid([]byte(call.Function.Arguments)){returnnil,ErrInvalidToolCall}iftimeout0{returnnil,ErrInvalidToolCall}r.mu.RLock()tool,ok:r.tools[call.Function.Name]r.mu.RUnlock()if!ok{returnnil,ErrToolNotFound}toolCtx,cancel:context.WithTimeout(ctx,timeout)defercancel()result,err:tool.Execute(toolCtx,json.RawMessage(call.Function.Arguments),)iferr!nil{iferrors.Is(toolCtx.Err(),context.DeadlineExceeded){returnnil,fmt.Errorf(%w: %v,ErrToolTimeout,err,)}returnnil,err}returnresult,nil}json.Valid只检查参数是不是合法 JSON具体对象类型、未知字段和业务约束仍由工具执行器负责。Context 也不会强制杀死任意 goroutine。数据库查询或 HTTP 请求必须真正使用该 Context超时才能传播到下游。六、限制工具调用总次数MaxRounds只限制模型请求轮数。模型可能在一轮内返回多个工具调用因此还需要独立的累计预算。typeCallBudgetstruct{MaxintUsedint}func(b*CallBudget)Take()error{ifb.Max0{returnerrors.New(工具调用预算无效)}ifb.Usedb.Max{returnerrors.New(工具调用次数超过限制)}b.Usedreturnnil}执行前逐条调用iferr:budget.Take();err!nil{return,err}result,err:registry.ExecuteChecked(ctx,call,toolTimeout,)只要进入一次执行尝试就应该占用预算包括最终返回业务错误或超时的调用。否则模型可以不断触发失败调用绕过限制。示例结构不是 goroutine 安全的。如果 Agent 会并发执行多个工具需要改用互斥锁、原子计数或每个任务独立预算。七、安全错误映射工具可能返回三类错误类别例子处理方式参数错误字段缺失、范围非法返回明确但不泄露内部细节的提示业务错误资源不存在、用户无权访问返回稳定业务错误码内部错误数据库连接失败、内部地址记录内部日志向模型返回通用错误可以定义统一结果typeToolErrorResultstruct{Codestringjson:codeMessagestringjson:message}funcSafeToolError(errerror)ToolErrorResult{switch{caseerrors.Is(err,ErrInvalidToolCall):returnToolErrorResult{Code:INVALID_TOOL_CALL,Message:工具调用格式不正确,}caseerrors.Is(err,ErrToolNotFound):returnToolErrorResult{Code:TOOL_NOT_FOUND,Message:请求的工具不可用,}caseerrors.Is(err,ErrToolTimeout):returnToolErrorResult{Code:TOOL_TIMEOUT,Message:工具执行超时,}caseerrors.Is(err,ErrToolDenied):returnToolErrorResult{Code:PERMISSION_DENIED,Message:当前操作没有权限,}default:returnToolErrorResult{Code:TOOL_EXECUTION_FAILED,Message:工具执行失败,}}}错误码要稳定便于模型和业务层判断原始内部错误应进入脱敏后的服务端日志而不是直接进入模型上下文。八、权限必须来自可信上下文假设模型请求删除文章{name:delete_article,arguments:{\article_id\:123}}不能让模型通过参数声明user_id或role: admin。用户身份应来自已有的 Go 认证上下文、Session 或 Token 校验结果。工具内部应调用现有 BIZ 层Agent Tool ↓ 业务服务检查用户与文章归属 ↓ 数据访问层而不是让 Agent 直接拼接 SQL 或跳过业务规则。九、为执行层编写测试创建registry_test.go先测纯逻辑不请求真实模型packagemainimport(contextencoding/jsonerrorstestingtime)typeEchoToolstruct{}func(EchoTool)Name()string{returnecho}func(EchoTool)Description()string{return返回输入消息}func(EchoTool)Parameters()json.RawMessage{returnjson.RawMessage({ type: object, properties: { message: {type: string} }, required: [message], additionalProperties: false })}func(EchoTool)Execute(ctx context.Context,raw json.RawMessage,)(any,error){varargsstruct{Messagestringjson:message}iferr:json.Unmarshal(raw,args);err!nil{returnnil,ErrInvalidToolCall}ifargs.Message{returnnil,ErrInvalidToolCall}select{case-ctx.Done():returnnil,ctx.Err()default:}returnmap[string]string{message:args.Message},nil}funcTestExecuteChecked(t*testing.T){registry:NewRegistry()iferr: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,)iferr!nil{t.Fatal(err)}got:result.(map[string]string)[message]ifgot!hello{t.Fatalf(got %q, want hello,got)}}funcTestRejectUnknownTool(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)}}运行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_id123也是正整数是否可以直接执行参考答案不能。还要验证当前用户、文章归属、业务状态、审批要求和幂等性。练习 3超时能否直接重试调用支付工具 10 秒后超时是否可以立即再次支付参考答案不能。超时只表示没有按时拿到结果支付可能已经成功。应先按交易号或幂等键查询状态。下一篇Go AI Agent 实战 22完善 Agent 执行循环从工具调用到最终回答专栏Go 开发者学 AI Agent从大模型到工程落地