AI Agent 第二章:可靠工具调用,从 JSON Schema 到危险操作确认

最后更新:2026-08-07
所属系列:AI Agent 实战 · 第 2 / 4 篇
文章目录 19 个章节

第一章实现了一个最小 Agent 循环:模型决定是否调用工具,宿主执行工具,再把结果交还模型。这个结构足以解释原理,但只要工具开始接触文件、数据库或外部 API,真正困难的部分才刚刚出现。

模型可能把数字写成字符串、漏掉必填字段,也可能在重试时重复发送消息。文件路径即使经过 filepath.Clean,仍可能越过工作目录;一个看似普通的命令,也可能因为没有超时而永久占住 Agent 循环。

所以生产级工具层不能只是一个 map[string]func()。它需要一套由程序强制执行的契约:

模型生成调用意图
      ↓
参数结构校验
      ↓
权限与风险检查
      ↓
必要时等待人工确认
      ↓
带超时地执行工具
      ↓
裁剪并分类执行结果
      ↓
写入审计记录,再交还模型

这一章就在第一章的骨架上补齐这条链路。

工具契约不只是名称和描述

第一章的 ToolSpec 只有名称和说明。更完整的工具契约至少应包含参数 Schema、风险等级和超时上限:

type RiskLevel string

const (
	RiskRead        RiskLevel = "read"
	RiskWrite       RiskLevel = "write"
	RiskDestructive RiskLevel = "destructive"
)

type ToolSpec struct {
	Name        string
	Description string
	Schema      json.RawMessage
	Risk        RiskLevel
	Timeout     time.Duration
}

这几个字段分别回答不同问题:

  • Description 告诉模型什么时候使用工具;
  • Schema 告诉模型应该生成什么参数;
  • Risk 告诉宿主是否需要额外授权;
  • Timeout 限制一次执行最多占用多久。

需要特别强调:发给模型的 JSON Schema 主要是生成约束,不是安全边界。 不同模型和 Provider 对严格 Schema 的支持程度不同,即使接口声明了 strict mode,宿主仍要再次验证收到的 JSON。

Schema 负责描述,Go 类型负责落地

以读取工作区文件为例,可以向模型提供下面的 Schema:

var readFileSchema = json.RawMessage(`{
  "type": "object",
  "properties": {
    "path": {
      "type": "string",
      "description": "相对工作区根目录的文件路径"
    },
    "max_bytes": {
      "type": "integer",
      "minimum": 1,
      "maximum": 1048576
    }
  },
  "required": ["path"],
  "additionalProperties": false
}`)

同一份约束在执行端还要有对应的 Go 类型:

type ReadFileArgs struct {
	Path     string `json:"path"`
	MaxBytes int64  `json:"max_bytes,omitempty"`
}

func decodeStrict[T any](raw json.RawMessage) (T, error) {
	var value T
	decoder := json.NewDecoder(bytes.NewReader(raw))
	decoder.DisallowUnknownFields()

	if err := decoder.Decode(&value); err != nil {
		return value, fmt.Errorf("decode arguments: %w", err)
	}
	if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
		return value, errors.New("arguments contain multiple JSON values")
	}
	return value, nil
}

DisallowUnknownFields 很重要。没有它时,模型把 max_bytes 写成 maxBytes,解码仍可能成功,只是限制悄悄失效。拒绝未知字段能把拼写问题变成明确、可修正的工具错误。

解码后还要执行语义校验:

func validateReadFileArgs(args ReadFileArgs) (ReadFileArgs, error) {
	args.Path = strings.TrimSpace(args.Path)
	if args.Path == "" {
		return args, errors.New("path is required")
	}
	if args.MaxBytes == 0 {
		args.MaxBytes = 256 << 10
	}
	if args.MaxBytes < 1 || args.MaxBytes > 1<<20 {
		return args, errors.New("max_bytes must be between 1 and 1048576")
	}
	return args, nil
}

JSON Schema 和 Go 校验不是二选一:前者减少模型生成错误,后者保证错误参数绝不会进入业务逻辑。

文件工具必须守住目录边界

模型生成的路径不能直接交给 os.ReadFile。最小的目录约束可以这样实现:

func resolveInside(root, input string) (string, error) {
	if filepath.IsAbs(input) {
		return "", errors.New("absolute paths are not allowed")
	}

	root = filepath.Clean(root)
	target := filepath.Join(root, filepath.Clean(input))
	relative, err := filepath.Rel(root, target)
	if err != nil {
		return "", fmt.Errorf("resolve path: %w", err)
	}
	if relative == ".." || strings.HasPrefix(relative, ".."+string(os.PathSeparator)) {
		return "", errors.New("path escapes workspace")
	}
	return target, nil
}

这能挡住绝对路径和普通的 ../ 穿越,但还不是最强边界。工作区里如果存在指向外部目录的符号链接,字符串层面的相对路径检查仍可能被绕过。

对读取已存在文件的工具,还应解析符号链接后再次检查;对高安全场景,则应使用容器、独立用户、只读挂载或基于目录文件描述符的系统调用。路径校验是第一层,操作系统隔离才是最后一层。

超时和取消必须贯穿整个调用链

Agent 已经接收 context.Context,工具执行器不能在中途把它丢掉:

func withToolTimeout(
	parent context.Context,
	timeout time.Duration,
	run func(context.Context) error,
) error {
	if timeout <= 0 {
		return errors.New("tool timeout must be positive")
	}
	ctx, cancel := context.WithTimeout(parent, timeout)
	defer cancel()

	if err := run(ctx); err != nil {
		if errors.Is(ctx.Err(), context.DeadlineExceeded) {
			return fmt.Errorf("tool timed out after %s: %w", timeout, ctx.Err())
		}
		return err
	}
	return nil
}

工具内部也必须使用这个 ctx:

  • HTTP 请求使用 http.NewRequestWithContext;
  • 数据库调用使用 QueryContext 或 ExecContext;
  • 子进程使用 exec.CommandContext;
  • 自己写的循环定期检查 ctx.Done()。

如果底层函数完全不响应取消,外层 context.WithTimeout 只能让调用方停止等待,无法真正终止泄漏的 goroutine 或系统资源。

不要把所有失败都当成普通字符串

工具失败后,模型是否应该继续,取决于错误类型。可以建立一个稳定的错误协议:

type ToolError struct {
	Code       string
	Message    string
	Retryable  bool
	Recoverable bool
	Err        error
}

func (e *ToolError) Error() string {
	if e.Err == nil {
		return e.Message
	}
	return e.Message + ": " + e.Err.Error()
}

func (e *ToolError) Unwrap() error { return e.Err }

常见错误可以分成三组:

类型 示例 Agent 行为
参数可恢复 字段缺失、格式错误、文件不存在 把安全摘要交给模型,允许修正参数
环境可重试 限流、临时网络失败、服务繁忙 按策略退避重试,达到上限后停止
系统不可恢复 权限配置错误、审计存储失败、内部不变量破坏 立即终止,不让模型“自行绕过”

返回给模型的内容应该足够它修正下一步,但不能包含密钥、完整环境变量或数据库内部错误。原始错误进入服务日志和追踪系统,模型只看到经过筛选的 Code 与 Message。

一个典型的工具观察结果可以是:

{
  "ok": false,
  "code": "invalid_arguments",
  "message": "max_bytes must be between 1 and 1048576",
  "retryable": false
}

模型可以据此重新调用工具,而无需猜测发生了什么。

危险操作确认由宿主控制

删除文件、部署生产环境、发送消息和付款不能只靠 Prompt 中一句“请谨慎”。工具注册时已经声明风险,执行器可以统一拦截:

type Confirmation struct {
	Tool    string
	Summary string
	Risk    RiskLevel
}

type Approver interface {
	Confirm(context.Context, Confirmation) (bool, error)
}

func requiresApproval(risk RiskLevel) bool {
	return risk == RiskWrite || risk == RiskDestructive
}

确认内容不能只显示“是否允许执行工具”。用户需要看到真正会发生的动作,例如:

工具:delete_file
目标:content/drafts/old.md
影响:文件将被移入回收站
可恢复:是

确认之后仍要再次使用已经展示过的参数执行,不能让模型在确认和执行之间偷偷替换目标。最稳妥的方式是对规范化参数计算摘要,把摘要同时写入确认记录与执行记录。

写操作必须考虑幂等

模型可能因为网络超时、上下文恢复或进程重启而重复同一次调用。读取文件重复执行通常没有问题,发送通知、创建订单或触发部署则可能造成真实损失。

写工具应该接收宿主生成的幂等键:

type ExecutionMeta struct {
	RunID          string
	ToolCallID     string
	IdempotencyKey string
}

下游系统以 IdempotencyKey 建唯一约束。第一次执行保存结果,后续相同键直接返回第一次结果,而不是再次产生副作用。

不要把幂等键交给模型自由生成。它应该由 Agent 运行 ID 和工具调用 ID 等稳定信息派生,并由宿主保存。

组合成统一执行器

下面把注册、确认、超时和结果裁剪组合起来:

type ToolResult struct {
	Content string
	Details map[string]any
}

type RegisteredTool struct {
	Spec ToolSpec
	Run  func(context.Context, json.RawMessage, ExecutionMeta) (ToolResult, error)
}

type Executor struct {
	tools          map[string]RegisteredTool
	approver       Approver
	maxOutputRunes int
}

func (e *Executor) Execute(
	ctx context.Context,
	call ToolCall,
	meta ExecutionMeta,
) (ToolResult, error) {
	tool, ok := e.tools[call.Name]
	if !ok {
		return ToolResult{}, &ToolError{
			Code: "unknown_tool", Message: "tool is not registered",
		}
	}

	if requiresApproval(tool.Spec.Risk) {
		if e.approver == nil {
			return ToolResult{}, &ToolError{
				Code: "approval_required", Message: "no approver is configured",
			}
		}
		approved, err := e.approver.Confirm(ctx, Confirmation{
			Tool: call.Name, Summary: string(call.Arguments), Risk: tool.Spec.Risk,
		})
		if err != nil {
			return ToolResult{}, fmt.Errorf("request approval: %w", err)
		}
		if !approved {
			return ToolResult{}, &ToolError{
				Code: "approval_denied", Message: "the user denied this operation",
			}
		}
	}

	toolCtx, cancel := context.WithTimeout(ctx, tool.Spec.Timeout)
	defer cancel()
	result, err := tool.Run(toolCtx, call.Arguments, meta)
	if err != nil {
		if errors.Is(toolCtx.Err(), context.DeadlineExceeded) {
			return ToolResult{}, &ToolError{
				Code: "timeout", Message: "tool execution timed out",
				Retryable: tool.Spec.Risk == RiskRead, Err: toolCtx.Err(),
			}
		}
		return ToolResult{}, err
	}

	result.Content = truncateRunes(result.Content, e.maxOutputRunes)
	return result, nil
}

func truncateRunes(value string, limit int) string {
	if limit <= 0 {
		return ""
	}
	runes := []rune(value)
	if len(runes) <= limit {
		return value
	}
	return string(runes[:limit]) + "\n...[truncated]"
}

这段代码还可以继续增强,例如给 Approver 增加确认超时、对审计写入做失败关闭、按工具设置输出上限。但关键职责已经分开:工具实现业务操作,执行器统一处理横切策略。

输出也需要边界

工具输出可能比输入更危险。一次 go test ./... 可以返回几 MB 日志,读取压缩后的 bundle 可能直接填满上下文窗口。

输出裁剪至少要做到:

  • 设置字节或行数上限;
  • 明确标记内容已被截断;
  • 保留错误开头和结尾,而不是只留一侧;
  • 大结果写入临时文件,只向模型返回摘要与路径;
  • 对密钥、Cookie、连接串做结构化脱敏。

简单地按字节切片还要注意 UTF-8 边界。更可靠的实现应按 rune、行或结构化字段裁剪,并把完整输出保留在受控日志中。

哪些操作可以自动重试

“失败就重试三次”对工具系统过于粗暴。建议同时满足以下条件才自动重试:

  1. 错误被明确标记为 Retryable;
  2. 工具是只读操作,或者写操作已经提供幂等保证;
  3. 当前请求的总时间预算仍足够;
  4. 重试采用有上限的指数退避和随机抖动;
  5. 用户取消后立即停止。

参数错误不应该原样自动重试。应该先把错误反馈给模型,让它生成不同参数;否则宿主只是连续执行三次同样的错误调用。

审计记录应该回答什么

一次工具调用至少记录:

  • Agent 运行 ID、步骤号和工具调用 ID;
  • 工具名称与风险等级;
  • 规范化参数摘要,而不是无条件保存秘密;
  • 是否经过确认、由谁确认;
  • 开始时间、耗时和最终状态;
  • 输出大小、是否裁剪;
  • 错误代码和重试次数。

日志的目标不是“以后也许有用”,而是能回答三个问题:谁发起了什么操作,程序为什么允许它,真实执行结果是什么。

最小测试矩阵

工具执行器比 Prompt 更适合单元测试。至少覆盖下面这些场景:

场景 期望结果
缺少必填字段 返回 invalid_arguments
出现未知字段 严格解码失败
../ 越过工作区 执行前拒绝
工具超过截止时间 取消执行并返回 timeout
用户拒绝危险操作 工具函数完全不运行
同一幂等键调用两次 下游副作用只发生一次
输出超过限制 返回带截断标记的安全摘要
审计存储失败 高风险操作采用失败关闭

还应使用 race detector 检查并发工具,使用临时目录隔离文件测试,并给所有外部服务准备可控的假实现。不要让单元测试真的发送消息或部署环境。

常见但脆弱的做法

只依赖模型的 strict mode

Provider 能帮助模型生成合规 JSON,但它无法替代目录权限、业务规则与下游约束。

把异常文本直接塞回模型

完整堆栈可能泄露环境信息,也会让模型把系统故障误判成可修正参数问题。应该先分类,再生成稳定的观察结果。

确认之后重新询问模型参数

这会形成确认内容与真实执行内容不一致的竞态。确认和执行必须绑定同一份规范化参数。

给所有写操作自动重试

没有幂等保证时,超时不代表失败。第一次操作可能已经成功,只是响应丢失。

给工具无限输出

工具执行成功不代表 Agent 能消费结果。上下文窗口、日志存储和前端渲染都有明确上限。

本章小结

可靠工具调用的核心不是让模型更听话,而是把不确定输出放进确定性边界:

Schema 帮助模型生成
严格解码负责拒绝非法输入
权限与确认控制副作用
超时和幂等处理运行风险
错误协议帮助 Agent 正确恢复
审计记录还原真实过程

完成这些基础后,才适合讨论多个模型如何分工。下一章将以 Pi Agent 这类编码工具为例,把“模型切换”“角色协作”和“多 Agent 编排”拆开,并设计一条可以独立验收的多模型开发流程。