Go AI Agent 实战 28|Agent 的预算、超时与停止条件
本系列共 10 阶段,面向有 Go 后端基础的开发者,全程用 Go 手写实现。
当前进度:阶段四 Agent 核心原理 · 第 4 课(28)
上一篇:任务规划、步骤管理与执行结果验证
Agent 不仅要知道下一步做什么,还必须知道什么时候必须停止。否则,模型重复请求工具、外部服务迟迟不返回或错误不断重试,都可能持续消耗 Token、时间和费用。
这一课实现四个独立边界:迭代次数、工具调用次数、总执行时间和 Token/费用预算的设计位置。
一、本课学习目标
学完本课,你应该能够:
- 区分轮数、工具次数、总超时和 Token 预算。
- 使用 Context 传播任务截止时间与取消信号。
- 在工具执行前检查预算。
- 为 Agent 定义可识别的停止原因。
- 避免把预算耗尽伪装成任务成功。
- 用可重复的模拟决策测试各种停止分支。
二、四种预算不能互相替代
| 预算 | 控制对象 | 示例 |
|---|---|---|
MaxRounds | Planner 最多决策多少轮 | 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 计算方式不同,应当使用供应商提供的数据,而不是按字符数猜测费用。
七、本课总结
- 轮数、工具次数、时间和 Token 是相互独立的预算。
- 总超时通过 Context 传播,但不能强制终止不支持取消的操作。
- 工具预算必须在执行前检查。
- 停止原因必须可区分,便于重试、告警和恢复。
- 达到预算上限表示任务未完成,不能伪装为成功。
- 写操作超时后结果可能未知,不能直接重放。
八、课后练习
练习 1:实际执行几次
MaxToolCalls=2,模型连续请求三次工具,实际执行几次?
参考答案:第二次执行后预算耗尽,第三次在调用前被拒绝,因此实际执行两次。
练习 2:为什么需要总超时
MaxRounds=5 是否足以保证请求不会太长?
参考答案:不足。每次模型或工具调用都可能很慢,5 轮累计时间仍可能超过用户可接受范围。
练习 3:工具忽略 Context
设置 50ms 超时,工具是否一定会立即停止?
参考答案:不一定。Context 只发出取消信号,工具和下游 API 必须主动检查并响应。
下一篇:Go AI Agent 实战 29|Agent 的失败恢复、重试与任务状态管理
专栏:Go 开发者学 AI Agent:从大模型到工程落地
更多推荐



所有评论(0)