第一章实现了一个最小 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、行或结构化字段裁剪,并把完整输出保留在受控日志中。
哪些操作可以自动重试
“失败就重试三次”对工具系统过于粗暴。建议同时满足以下条件才自动重试:
- 错误被明确标记为
Retryable; - 工具是只读操作,或者写操作已经提供幂等保证;
- 当前请求的总时间预算仍足够;
- 重试采用有上限的指数退避和随机抖动;
- 用户取消后立即停止。
参数错误不应该原样自动重试。应该先把错误反馈给模型,让它生成不同参数;否则宿主只是连续执行三次同样的错误调用。
审计记录应该回答什么
一次工具调用至少记录:
- Agent 运行 ID、步骤号和工具调用 ID;
- 工具名称与风险等级;
- 规范化参数摘要,而不是无条件保存秘密;
- 是否经过确认、由谁确认;
- 开始时间、耗时和最终状态;
- 输出大小、是否裁剪;
- 错误代码和重试次数。
日志的目标不是“以后也许有用”,而是能回答三个问题:谁发起了什么操作,程序为什么允许它,真实执行结果是什么。
最小测试矩阵
工具执行器比 Prompt 更适合单元测试。至少覆盖下面这些场景:
| 场景 | 期望结果 |
|---|---|
| 缺少必填字段 | 返回 invalid_arguments |
| 出现未知字段 | 严格解码失败 |
../ 越过工作区 |
执行前拒绝 |
| 工具超过截止时间 | 取消执行并返回 timeout |
| 用户拒绝危险操作 | 工具函数完全不运行 |
| 同一幂等键调用两次 | 下游副作用只发生一次 |
| 输出超过限制 | 返回带截断标记的安全摘要 |
| 审计存储失败 | 高风险操作采用失败关闭 |
还应使用 race detector 检查并发工具,使用临时目录隔离文件测试,并给所有外部服务准备可控的假实现。不要让单元测试真的发送消息或部署环境。
常见但脆弱的做法
只依赖模型的 strict mode
Provider 能帮助模型生成合规 JSON,但它无法替代目录权限、业务规则与下游约束。
把异常文本直接塞回模型
完整堆栈可能泄露环境信息,也会让模型把系统故障误判成可修正参数问题。应该先分类,再生成稳定的观察结果。
确认之后重新询问模型参数
这会形成确认内容与真实执行内容不一致的竞态。确认和执行必须绑定同一份规范化参数。
给所有写操作自动重试
没有幂等保证时,超时不代表失败。第一次操作可能已经成功,只是响应丢失。
给工具无限输出
工具执行成功不代表 Agent 能消费结果。上下文窗口、日志存储和前端渲染都有明确上限。
本章小结
可靠工具调用的核心不是让模型更听话,而是把不确定输出放进确定性边界:
Schema 帮助模型生成
严格解码负责拒绝非法输入
权限与确认控制副作用
超时和幂等处理运行风险
错误协议帮助 Agent 正确恢复
审计记录还原真实过程
完成这些基础后,才适合讨论多个模型如何分工。下一章将以 Pi Agent 这类编码工具为例,把“模型切换”“角色协作”和“多 Agent 编排”拆开,并设计一条可以独立验收的多模型开发流程。