本系列共 10 阶段,面向有 Go 后端基础的开发者,全程用 Go 手写实现。
当前进度:阶段四 Agent 核心原理 · 第 4 课(28)
上一篇:任务规划、步骤管理与执行结果验证

Agent 不仅要知道下一步做什么,还必须知道什么时候必须停止。否则,模型重复请求工具、外部服务迟迟不返回或错误不断重试,都可能持续消耗 Token、时间和费用。

这一课实现四个独立边界:迭代次数、工具调用次数、总执行时间和 Token/费用预算的设计位置。

一、本课学习目标

学完本课,你应该能够:

  1. 区分轮数、工具次数、总超时和 Token 预算。
  2. 使用 Context 传播任务截止时间与取消信号。
  3. 在工具执行前检查预算。
  4. 为 Agent 定义可识别的停止原因。
  5. 避免把预算耗尽伪装成任务成功。
  6. 用可重复的模拟决策测试各种停止分支。

二、四种预算不能互相替代

预算控制对象示例
MaxRoundsPlanner 最多决策多少轮6 轮
MaxToolCalls最多发起多少次工具执行10 次
TotalTimeout整个任务最长运行时间30 秒
Token/费用预算模型输入输出或金额20,000 Token

原因很直接:

  • 6 轮可以每轮调用多个工具。
  • 10 次工具调用可能每次都很慢。
  • 30 秒内也可能发起大量模型请求。
  • 工具次数不能代表上下文长度和 Token 费用。

Context 控制时间与取消,不能直接控制模型费用。

三、停止原因必须显式

至少区分:

final_answer
max_rounds
max_tool_calls
timeout
cancelled
planner_error
tool_error
invalid_action

这些原因会进入日志、监控、前端状态和后续恢复策略。一句统一的“Agent 执行失败”无法说明应该重试、终止还是等待人工处理。

四、完整 Go 示例

package main

import (
	"context"
	"errors"
	"fmt"
	"strings"
	"time"
)

type ActionType string

const (
	ActionTool  ActionType = "tool"
	ActionFinal ActionType = "final"
)

type Decision struct {
	Type      ActionType
	ToolName  string
	Input     string
	FinalText string
}

type Planner interface {
	Next(ctx context.Context, history []string) (Decision, error)
}

type FakePlanner struct {
	decisions []Decision
	index     int
}

func (p *FakePlanner) Next(
	ctx context.Context,
	history []string,
) (Decision, error) {
	if err := ctx.Err(); err != nil {
		return Decision{}, err
	}
	if p.index >= len(p.decisions) {
		return Decision{}, errors.New("没有更多模拟决策")
	}

	decision := p.decisions[p.index]
	p.index++
	return decision, nil
}

type Tool func(context.Context, string) (string, error)

type StopReason string

const (
	StopFinalAnswer  StopReason = "final_answer"
	StopMaxRounds    StopReason = "max_rounds"
	StopMaxToolCalls StopReason = "max_tool_calls"
	StopTimeout      StopReason = "timeout"
	StopCancelled    StopReason = "cancelled"
	StopPlannerError StopReason = "planner_error"
	StopToolError    StopReason = "tool_error"
	StopInvalidAction StopReason = "invalid_action"
)

type AgentConfig struct {
	MaxRounds    int
	MaxToolCalls int
	TotalTimeout time.Duration
}

type AgentResult struct {
	Answer    string
	Reason    StopReason
	Rounds    int
	ToolCalls int
	History   []string
	Err       error
}

type Agent struct {
	Planner Planner
	Tools   map[string]Tool
	Config  AgentConfig
}

func (a *Agent) Run(
	parent context.Context,
	goal string,
) AgentResult {
	result := AgentResult{
		Reason: StopInvalidAction,
		History: []string{"任务开始:" + goal},
	}

	if a.Planner == nil {
		result.Reason = StopPlannerError
		result.Err = errors.New("Planner 不能为空")
		return result
	}
	if a.Config.MaxRounds <= 0 ||
		a.Config.MaxToolCalls < 0 ||
		a.Config.TotalTimeout <= 0 {
		result.Err = errors.New("Agent 配置无效")
		return result
	}

	ctx, cancel := context.WithTimeout(
		parent,
		a.Config.TotalTimeout,
	)
	defer cancel()

	for round := 1; round <= a.Config.MaxRounds; round++ {
		if err := ctx.Err(); err != nil {
			result.Err = err
			result.Reason = contextStopReason(parent, ctx)
			return result
		}

		result.Rounds = round
		decision, err := a.Planner.Next(ctx, result.History)
		if err != nil {
			result.Err = err
			if ctx.Err() != nil {
				result.Reason = contextStopReason(parent, ctx)
			} else {
				result.Reason = StopPlannerError
			}
			return result
		}

		switch decision.Type {
		case ActionFinal:
			if strings.TrimSpace(decision.FinalText) == "" {
				result.Reason = StopInvalidAction
				result.Err = errors.New("最终回答为空")
				return result
			}
			result.Answer = decision.FinalText
			result.Reason = StopFinalAnswer
			result.History = append(result.History, "生成最终回答")
			return result

		case ActionTool:
			if result.ToolCalls >= a.Config.MaxToolCalls {
				result.Reason = StopMaxToolCalls
				result.Err = errors.New("工具调用预算已耗尽")
				return result
			}

			tool, exists := a.Tools[decision.ToolName]
			if !exists {
				result.Reason = StopToolError
				result.Err = errors.New("工具不存在")
				return result
			}

			// 在真正执行前占用预算。
			result.ToolCalls++
			result.History = append(
				result.History,
				fmt.Sprintf("调用工具 %s:%s", decision.ToolName, decision.Input),
			)

			output, err := tool(ctx, decision.Input)
			if err != nil {
				result.Err = err
				if ctx.Err() != nil {
					result.Reason = contextStopReason(parent, ctx)
				} else {
					result.Reason = StopToolError
				}
				return result
			}

			result.History = append(
				result.History,
				fmt.Sprintf("工具 %s 返回:%s", decision.ToolName, output),
			)

		default:
			result.Reason = StopInvalidAction
			result.Err = errors.New("未知动作类型")
			return result
		}
	}

	result.Reason = StopMaxRounds
	result.Err = errors.New("模型轮数已耗尽")
	return result
}

func contextStopReason(
	parent context.Context,
	ctx context.Context,
) StopReason {
	if parent.Err() != nil {
		return StopCancelled
	}
	if errors.Is(ctx.Err(), context.DeadlineExceeded) {
		return StopTimeout
	}
	return StopCancelled
}

func searchArticles(
	ctx context.Context,
	input string,
) (string, error) {
	select {
	case <-time.After(50 * time.Millisecond):
		return "找到与「" + input + "」相关的文章", nil
	case <-ctx.Done():
		return "", ctx.Err()
	}
}

func main() {
	baseConfig := AgentConfig{
		MaxRounds:    5,
		MaxToolCalls: 2,
		TotalTimeout: 2 * time.Second,
	}

	success := (&Agent{
		Planner: &FakePlanner{decisions: []Decision{
			{ToolName: "search_articles", Input: "Go", Type: ActionTool},
			{Type: ActionFinal, FinalText: "已查询相关文章。"},
		}},
		Tools:  map[string]Tool{"search_articles": searchArticles},
		Config: baseConfig,
	}).Run(context.Background(), "查询 Go 文章")

	maxTools := (&Agent{
		Planner: &FakePlanner{decisions: []Decision{
			{ToolName: "search_articles", Input: "Go", Type: ActionTool},
			{ToolName: "search_articles", Input: "Redis", Type: ActionTool},
			{ToolName: "search_articles", Input: "Kafka", Type: ActionTool},
		}},
		Tools:  map[string]Tool{"search_articles": searchArticles},
		Config: baseConfig,
	}).Run(context.Background(), "连续搜索")

	timeout := (&Agent{
		Planner: &FakePlanner{decisions: []Decision{
			{ToolName: "slow_tool", Input: "test", Type: ActionTool},
		}},
		Tools: map[string]Tool{
			"slow_tool": func(ctx context.Context, _ string) (string, error) {
				select {
				case <-time.After(time.Second):
					return "done", nil
				case <-ctx.Done():
					return "", ctx.Err()
				}
			},
		},
		Config: AgentConfig{
			MaxRounds:    3,
			MaxToolCalls: 2,
			TotalTimeout: 50 * time.Millisecond,
		},
	}).Run(context.Background(), "测试超时")

	fmt.Printf(
		"成功:reason=%s rounds=%d tools=%d answer=%q\n",
		success.Reason, success.Rounds, success.ToolCalls, success.Answer,
	)
	fmt.Printf(
		"工具预算:reason=%s rounds=%d tools=%d\n",
		maxTools.Reason, maxTools.Rounds, maxTools.ToolCalls,
	)
	fmt.Printf(
		"总超时:reason=%s rounds=%d tools=%d\n",
		timeout.Reason, timeout.Rounds, timeout.ToolCalls,
	)
}

运行:

go mod init agent-lesson-28
go run .

示例不访问外部 API,三个场景分别验证正常结束、工具预算耗尽和总超时。

五、设计细节

1. 预算检查必须在副作用之前

如果最大工具次数为 2,第三次工具必须在执行前被拒绝,而不是执行完再发现超限。

2. Context 是协作式取消

ctx, cancel := context.WithTimeout(parent, totalTimeout)
defer cancel()

只有模型客户端、数据库查询和 HTTP 请求使用该 Context,取消才能传播。一个忽略 Context 的阻塞函数仍可能继续占用 goroutine。

3. 轮数限制不等于工具次数

一轮决策可能返回多个工具调用。完整系统还要在单个模型响应内部逐条检查工具预算。

4. 超时结果可能未知

工具超时可能发生在操作已经成功、响应还没返回时。对写操作不能直接重放,应先查询状态或使用幂等键。

六、Token 与费用预算

真实模型需要从响应或调用统计中累计:

  • 输入 Token。
  • 输出 Token。
  • 模型调用次数。
  • 估算或实际费用。

在执行下一轮前检查剩余预算:

if usedTokens >= maxTokens {
    stop("token_budget_exceeded")
}

不同模型的价格和 Token 计算方式不同,应当使用供应商提供的数据,而不是按字符数猜测费用。

七、本课总结

  1. 轮数、工具次数、时间和 Token 是相互独立的预算。
  2. 总超时通过 Context 传播,但不能强制终止不支持取消的操作。
  3. 工具预算必须在执行前检查。
  4. 停止原因必须可区分,便于重试、告警和恢复。
  5. 达到预算上限表示任务未完成,不能伪装为成功。
  6. 写操作超时后结果可能未知,不能直接重放。

八、课后练习

练习 1:实际执行几次

MaxToolCalls=2,模型连续请求三次工具,实际执行几次?

参考答案:第二次执行后预算耗尽,第三次在调用前被拒绝,因此实际执行两次。

练习 2:为什么需要总超时

MaxRounds=5 是否足以保证请求不会太长?

参考答案:不足。每次模型或工具调用都可能很慢,5 轮累计时间仍可能超过用户可接受范围。

练习 3:工具忽略 Context

设置 50ms 超时,工具是否一定会立即停止?

参考答案:不一定。Context 只发出取消信号,工具和下游 API 必须主动检查并响应。


下一篇:Go AI Agent 实战 29|Agent 的失败恢复、重试与任务状态管理

专栏:Go 开发者学 AI Agent:从大模型到工程落地

Logo

更多推荐