本系列共 10 阶段,面向有 Go 后端基础的开发者,全程用 Go 手写实现。
当前进度:阶段三 Tool Calling · 第 7 课(24)
上一篇:多轮工具调用实战与异常恢复
前六课已经完成从原理到完整工具调用链的实现。本课不再增加新工具,而是检查这套系统距离可靠后端组件还缺什么,并给出一套可以反复执行的验收清单。
一、本课学习目标
学完本课,你应该能够:
- 复盘模型协议、工具执行和业务权限的完整调用链。
- 使用清单检查注册、校验、超时、预算和错误处理。
- 严格校验 JSON 参数并检查重复调用 ID。
- 区分单元测试、集成测试和真实模型实验。
- 用综合题验证自己是否真正掌握 Tool Calling。
二、完整调用链复盘
text
用户任务
↓
模型客户端:发送 messages + tools
↓
模型:返回 assistant.tool_calls
↓
Go:检查协议、预算是非、工具白名单
↓
工具:参数 → 业务规则 → 权限 → 执行
↓
Go:生成 tool 结果,关联 tool_call_id
↓
模型:读取结果,继续调用或返回最终回答
三个层次必须分开:
- Schema:告诉模型参数应该长什么样。
- Go 校验:验证实际输入是否合法。
- 业务权限:决定用户是否允许执行操作。
模型能看到工具,不代表用户能调用;参数符合 Schema,不代表资源可访问或操作可执行。
三、阶段验收清单
| 验收项 | 通过标准 |
|---|---|
| 工具定义 | 名称、描述、参数 Schema、执行器完整 |
| 工具注册 | 重复名称拒绝,未知工具不能执行 |
| 参数校验 | 拒绝非对象、无效 JSON、未知字段和不合法值 |
| 调用协议 | 校验角色、类型、ID,拒绝重复 ID |
| 消息历史 | 保留 assistant 调用消息和逐条 tool 结果 |
| 多轮执行 | 工具结果可以进入下一轮模型请求 |
| 权限安全 | 用户身份来自可信上下文,工具调用 BIZ 层 |
| 超时预算 | 单次工具、单次模型、任务总时长有限制 |
| 调用预算 | 工具次数和结果大小有限制 |
| 错误处理 | 区分协议、参数、业务、临时和内部错误 |
| 副作用控制 | 写操作有幂等、确认、审计或状态查询 |
| 自动化测试 | 成功、失败、停止和协议历史均有覆盖 |
验收不是只看"能返回一句回答",而是看异常和边界是否可预测。
四、严格校验工具参数
静态工具参数最适合使用严格解码。下面这个辅助函数要求输入必须是单个 JSON 对象:
go
func DecodeStrictJSON(
raw json.RawMessage,
dst any,
) error {
trimmed := bytes.TrimSpace(raw)
if len(trimmed) == 0 ||
trimmed[0] != '{' ||
!json.Valid(trimmed) {
return ErrInvalidArguments
}
decoder := json.NewDecoder(bytes.NewReader(trimmed))
decoder.DisallowUnknownFields()
if err := decoder.Decode(dst); err != nil {
return ErrInvalidArguments
}
var extra any
if err := decoder.Decode(&extra); !errors.Is(err, io.EOF) {
return ErrInvalidArguments
}
return nil
}
使用:
go
type GetArticleArgs struct {
ArticleID int64 `json:"article_id"`
}
var args GetArticleArgs
if err := DecodeStrictJSON(raw, &args); err != nil {
return nil, err
}
if args.ArticleID <= 0 {
return nil, ErrInvalidArguments
}
// 继续执行文章存在性、可见性和用户权限检查。
顺序应当是:
text
严格 JSON 解码
↓
字段类型和范围
↓
业务资源状态
↓
用户权限
↓
执行与审计
DisallowUnknownFields 不等于 Schema 校验器,也不验证资源是否存在。复杂 Schema 规则需要专门的校验库,且必须与 Go 端实际约束保持一致。
五、检查工具调用 ID
每条工具结果依靠 tool_call_id 对应原调用。同一条模型响应中应拒绝空 ID、重复 ID 和不支持的类型:
go
func ValidateToolCallIDs(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
}
执行前还应该限制:
- 单轮调用数量。
- 累计工具调用次数。
- 单个参数字节数。
- 单个工具结果字节数。
- 模型消息和 Token 预算。
参数或结果过大时,即使协议合法,也可能拖垮内存或超出上下文窗口。
六、测试分为三层
1. 单元测试
不访问网络,使用 FakeModel、假工具和内存数据:
- 工具注册、重复注册和未知工具。
- 参数缺失、未知字段、
null和非法范围。 - 空 ID、重复 ID 和错误调用类型。
- 工具超时与父 Context 取消。
- 最大轮数、最大工具次数。
- 错误映射和消息历史。
2. 集成测试
验证组件组合,可以使用:
httptest.Server模拟模型 API。- 测试数据库或事务回滚。
- 假的外部搜索服务。
重点检查 HTTP 请求、响应解析、消息序列、工具结果回传和错误状态。
3. 真实模型实验
使用无副作用工具验证真实模型行为:
- 模型是否会选择合适工具。
function.arguments是否符合预期。- 工具结果能否正确回传。
- 最终回答是否依据工具数据。
不要在真实模型实验中直接测试删除、支付等高风险工具。
常用命令:
powershell
go test ./...
go test -v ./...
go test -race ./...
-race 只能发现被测试路径真正触发的数据竞争,不能证明所有并发路径都安全。
七、阶段综合题
练习 1:执行权属于谁
模型请求 delete_article(123),工具已注册且参数合法。是否应该直接删除?
参考答案:不能。注册只说明工具存在,参数只说明输入格式正确。还要检查当前用户身份、文章归属、业务状态、确认要求和幂等审计。
练习 2:工具失败后如何返回
search_articles 发生可恢复的临时错误,想让模型继续处理,应怎样返回?
参考答案 :通过脱敏的错误码和消息生成对应的
role: "tool"结果,并使用原调用的tool_call_id。如果是权限拒绝、预算耗尽或协议损坏,应按策略停止。
练习 3:Schema 与后端校验
JSON Schema、Go 参数校验、业务校验和权限校验分别解决什么?
参考答案:Schema 描述参数结构;Go 校验实际输入;业务校验检查资源和状态;权限校验检查当前用户是否允许执行。四层不能互相替代。
练习 4:两个预算为什么都需要
为什么同时需要 MaxRounds 和 MaxToolCalls?
参考答案:一轮模型响应可能包含多个工具调用。只限制轮数不能严格限制工具执行总量;只限制工具数也不能限制模型反复空转的交互次数。
练习 5:FakeModel 能否替代真实实验
单元测试全部通过,是否说明真实模型接入一定正确?
参考答案:不能。FakeModel验证 Agent 控制流,但不能证明真实 HTTP 请求、认证、响应结构和模型选择行为。仍需适配器集成测试和少量真实实验。
八、建议的小型综合实验
构建一个只读博客助手,包含:
search_articles:按关键词搜索公开文章。get_article:查询用户可访问的文章详情。get_current_time:获取服务器时间。
验收时检查:
- 模型是否根据问题选择正确工具。
- 多轮调用时是否保留完整消息历史。
- 每个结果是否关联正确
tool_call_id。 - 未知工具、非法参数和越权访问是否被拒绝。
- 超过轮数、工具次数或总时长时是否返回明确的未完成状态。
- 工具超时后是否只在安全范围内恢复。
综合实验应优先使用只读工具。涉及写操作时,先完成认证、权限、幂等和人工确认设计,再让模型接触真实副作用。
九、第三阶段总结
第三阶段完成了 Tool Calling 的核心工程链路:
- 模型输出结构化调用请求,Go 程序负责授权与执行。
- 工具由名称、描述、参数 Schema 和执行器组成。
- 注册表负责白名单、查找和统一执行入口。
- 参数必须经过 JSON、类型、范围和业务校验。
- assistant 工具调用消息与 tool 结果必须成对管理。
- 单轮多调用、连续多轮调用有不同执行与错误策略。
- 轮数、工具次数、超时、结果大小和 Token 都需要预算。
- 权限来自可信后端上下文,高风险副作用需要人工确认。
下一阶段将研究更完整的 Agent 核心原理:Chat、Workflow 和 Agent 的区别,以及 Observe、Think、Act、任务规划、停止条件和失败恢复。
十、结课检查
如果以下问题仍无法清楚回答,建议回到对应课程复习:
- 为什么模型返回 JSON 不等于工具已经执行?
- 为什么
function.arguments是字符串后还要校验? - 为什么 Schema 不能替代权限?
- 为什么必须保留 assistant 工具调用消息?
- 为什么轮数限制不等于工具次数限制?
- 为什么写操作超时后不能盲目重试?
能回答这些问题,并能用测试验证对应行为,才算真正完成 Tool Calling 从原理到工程的闭环。
下一篇:Go AI Agent 实战 25|Chat、Workflow 与 Agent 的区别
专栏:Go 开发者学 AI Agent:从大模型到工程落地