Go AI Agent 实战 22|完善 Agent 执行循环:从工具调用到最终回答

本系列共 10 阶段,面向有 Go 后端基础的开发者,全程用 Go 手写实现。

当前进度:阶段三 Tool Calling · 第 5 课(22)

上一篇:多工具选择、参数校验与工具执行安全

前面的程序已经能完成一次工具调用。但模型拿到工具结果后,可能继续请求新工具,也可能直接生成最终答案。应用需要一个受控循环来推进这些状态。

这一课把模型调用、工具执行、结果回传和停止条件串成完整的 Agent 循环,并使用假模型测试,不依赖真实 API。

一、本课学习目标

学完本课,你应该能够:

  1. 识别 Agent 循环中的模型状态、工具状态和结束状态。
  2. 保留 assistant 工具调用消息并追加对应 tool 结果。
  3. 实现模型轮数、工具次数、工具超时和任务总超时限制。
  4. 区分正常完成、失败结束和达到限制后未完成。
  5. 用 ChatModel 接口隔离模型供应商。
  6. 用 FakeModel 验证完整消息历史。

二、一次调用与执行循环

普通聊天可能只请求一次模型:

text 复制代码
用户 → 模型 → 最终回答

带工具的 Agent 可能是:

text 复制代码
用户
  ↓
模型请求工具 A
  ↓
Go 执行 A
  ↓
模型请求工具 B
  ↓
Go 执行 B
  ↓
模型生成最终回答

每轮都有三种可能:

模型响应 程序动作
包含 tool_calls 校验并执行工具,再把结果发回模型
包含有效最终文本 正常结束
两者都没有或协议异常 返回错误,不无限重试

循环必须有明确上限。达到上限只说明任务停止,不代表任务已经成功完成。

三、抽象模型接口

Agent 不应该依赖具体 DeepSeek 客户端。定义一个返回消息和结束原因的接口:

go 复制代码
type ChatModel interface {
	Chat(
		ctx context.Context,
		messages []Message,
		tools []ToolDefinition,
	) (ChatResult, error)
}

type ChatResult struct {
	Message      Message
	FinishReason string
}

这样真实模型和测试假模型都能实现相同契约:

text 复制代码
Agent
  ↓
ChatModel
  ├─ DeepSeekModel
  └─ FakeModel

真实适配器负责 HTTP、认证、JSON 和供应商错误;Agent 只负责循环与状态。

四、核心 Agent 实现

下面的代码接续前几课的 Message、ToolCall、ToolDefinition、Registry 和 SafeToolError。

go 复制代码
type AgentConfig struct {
	MaxRounds    int
	MaxToolCalls int
	ToolTimeout  time.Duration
	TotalTimeout time.Duration
}

type Agent struct {
	Model    ChatModel
	Registry *Registry
	Config   AgentConfig
}

func (a *Agent) Run(
	parent context.Context,
	userInput string,
) (string, error) {
	if a.Model == nil || a.Registry == nil {
		return "", errors.New("Agent 依赖未初始化")
	}

	cfg := a.Config
	if cfg.MaxRounds <= 0 ||
		cfg.MaxToolCalls <= 0 ||
		cfg.ToolTimeout <= 0 ||
		cfg.TotalTimeout <= 0 {
		return "", errors.New("Agent 配置无效")
	}

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

	messages := []Message{
		{
			Role: "system",
			Content: "需要外部信息时使用可用工具。" +
				"不要编造工具执行结果。",
		},
		{
			Role:    "user",
			Content: strings.TrimSpace(userInput),
		},
	}

	usedToolCalls := 0

	for round := 0; round < cfg.MaxRounds; round++ {
		if err := ctx.Err(); err != nil {
			return "", fmt.Errorf("Agent 已结束: %w", err)
		}

		result, err := a.Model.Chat(
			ctx,
			messages,
			a.Registry.Definitions(),
		)
		if err != nil {
			return "", fmt.Errorf("模型调用失败: %w", err)
		}

		assistant := result.Message
		if assistant.Role != "assistant" {
			return "", errors.New("模型响应角色无效")
		}

		// 协议要求先保留完整的 assistant 消息。
		messages = append(messages, assistant)

		if len(assistant.ToolCalls) == 0 {
			answer, ok := assistant.Content.(string)
			if !ok || strings.TrimSpace(answer) == "" {
				return "", errors.New("模型没有返回工具调用或有效回答")
			}
			if result.FinishReason != "" &&
				result.FinishReason != "stop" {
				return "", fmt.Errorf(
					"模型异常停止: %s",
					result.FinishReason,
				)
			}
			return answer, nil
		}

		if result.FinishReason != "" &&
			result.FinishReason != "tool_calls" {
			return "", fmt.Errorf(
				"工具调用响应结束原因异常: %s",
				result.FinishReason,
			)
		}

		if err := ValidateToolCalls(assistant.ToolCalls); err != nil {
			return "", err
		}

		for _, call := range assistant.ToolCalls {
			if usedToolCalls >= cfg.MaxToolCalls {
				return "", errors.New("工具调用次数超过限制")
			}
			usedToolCalls++

			value, toolErr := a.Registry.ExecuteChecked(
				ctx,
				call,
				cfg.ToolTimeout,
			)

			var content any
			if toolErr != nil {
				content = SafeToolError(toolErr)
			} else {
				content = value
			}

			encoded, err := json.Marshal(content)
			if err != nil {
				return "", fmt.Errorf("编码工具结果失败: %w", err)
			}

			messages = append(messages, Message{
				Role:       "tool",
				ToolCallID: call.ID,
				Content:    string(encoded),
			})
		}
	}

	return "", errors.New("Agent 达到最大模型轮数,任务未完成")
}

五、为什么这些顺序不能交换

1. 先追加 assistant 消息

assistant 消息描述模型请求了哪些工具,以及每个调用的 ID。工具结果必须通过 tool_call_id 与这些请求对应。

如果丢掉 assistant 消息,tool 消息就失去了上下文,部分模型 API 还会直接判定消息序列不合法。

2. 工具结果逐条返回

同一轮有三个调用,就应该有三条 tool 消息,各自携带自己的 ID。不能只返回一条拼接文本,也不能复用同一个 ID。

3. 工具错误与协议错误分开

查询失败、资源不存在等工具错误可以在脱敏后交回模型;空 ID、重复 ID、错误角色等协议错误应直接终止当前轮。

不能把"协议已损坏"交给模型猜测修复。

六、四种停止条件

停止条件 返回状态
有效最终回答 成功
最大模型轮数 明确错误,任务未完成
最大工具调用数 明确错误,任务未完成
总 Context 超时或取消 返回 Context 错误

单次模型 HTTP 超时和单次工具超时是局部保护,不能代替整个 Agent 的总预算。

达到限制时,不要把最后一个工具结果包装成自然语言最终答案,因为模型还没有确认任务完成。

七、用 FakeModel 测试循环

假模型按顺序返回预设响应,并记录每次收到的消息:

go 复制代码
type FakeModel struct {
	Responses []ChatResult
	Requests  [][]Message
	Calls     int
}

func (f *FakeModel) Chat(
	ctx context.Context,
	messages []Message,
	tools []ToolDefinition,
) (ChatResult, error) {
	if err := ctx.Err(); err != nil {
		return ChatResult{}, err
	}
	if f.Calls >= len(f.Responses) {
		return ChatResult{}, errors.New("没有更多假响应")
	}

	snapshot := append([]Message(nil), messages...)
	f.Requests = append(f.Requests, snapshot)

	result := f.Responses[f.Calls]
	f.Calls++
	return result, nil
}

测试一次工具调用后的最终回答:

go 复制代码
func TestAgentToolCallingLoop(t *testing.T) {
	registry := NewRegistry()
	if err := registry.Register(EchoTool{}); err != nil {
		t.Fatal(err)
	}

	model := &FakeModel{
		Responses: []ChatResult{
			{
				Message: Message{
					Role: "assistant",
					ToolCalls: []ToolCall{
						{
							ID:   "call_001",
							Type: "function",
							Function: FunctionCall{
								Name:      "echo",
								Arguments: `{"message":"hello"}`,
							},
						},
					},
				},
				FinishReason: "tool_calls",
			},
			{
				Message: Message{
					Role:    "assistant",
					Content: "工具返回了 hello。",
				},
				FinishReason: "stop",
			},
		},
	}

	agent := Agent{
		Model:    model,
		Registry: registry,
		Config: AgentConfig{
			MaxRounds:    3,
			MaxToolCalls: 4,
			ToolTimeout:  time.Second,
			TotalTimeout: 5 * time.Second,
		},
	}

	answer, err := agent.Run(
		context.Background(),
		"测试工具调用",
	)
	if err != nil {
		t.Fatal(err)
	}
	if answer != "工具返回了 hello。" {
		t.Fatalf("unexpected answer: %q", answer)
	}
	if model.Calls != 2 {
		t.Fatalf("got %d calls, want 2", model.Calls)
	}

	secondRequest := model.Requests[1]
	foundAssistantCall := false
	foundToolResult := false

	for _, msg := range secondRequest {
		if msg.Role == "assistant" &&
			len(msg.ToolCalls) == 1 &&
			msg.ToolCalls[0].ID == "call_001" {
			foundAssistantCall = true
		}
		if msg.Role == "tool" &&
			msg.ToolCallID == "call_001" {
			foundToolResult = true
		}
	}

	if !foundAssistantCall || !foundToolResult {
		t.Fatal("第二次模型请求缺少工具调用上下文")
	}
}

运行:

powershell 复制代码
go test ./...

不仅检查最终回答,还要检查第二次请求中同时存在 assistant 工具调用消息和对应 tool 结果。否则一个只会返回预设文本的假模型可能掩盖协议错误。

八、接入真实模型

真实 DeepSeek 客户端只需实现 ChatModel:

go 复制代码
type DeepSeekModel struct {
	Client *DeepSeekClient
}

func (m *DeepSeekModel) Chat(
	ctx context.Context,
	messages []Message,
	tools []ToolDefinition,
) (ChatResult, error) {
	message, finishReason, err := m.Client.Chat(
		ctx,
		messages,
		tools,
	)
	if err != nil {
		return ChatResult{}, err
	}

	return ChatResult{
		Message:      message,
		FinishReason: finishReason,
	}, nil
}

组装 Agent:

go 复制代码
agent := Agent{
	Model: &DeepSeekModel{
		Client: NewDeepSeekClient(apiKey, modelName),
	},
	Registry: registry,
	Config: AgentConfig{
		MaxRounds:    5,
		MaxToolCalls: 8,
		ToolTimeout:  10 * time.Second,
		TotalTimeout: 2 * time.Minute,
	},
}

测试循环时使用假模型;测试适配器时使用可控 HTTP 服务;最后再用少量真实 API 调用验证模型行为和协议兼容性。

九、本课总结

  1. Agent 循环在"模型请求工具"和"程序执行工具"之间迭代。
  2. assistant 工具调用消息和对应 tool 结果必须一起保留。
  3. 每轮都检查协议,执行前检查预算和超时。
  4. 最终回答、达到限制、超时和协议错误是不同结束状态。
  5. ChatModel 接口让 Agent 可以在真实模型与假模型之间切换。
  6. 测试不仅要看最终文本,还要验证每次请求的真实消息历史。

十、课后练习

练习 1:为什么保留工具调用消息

只保留 role: "tool" 结果而丢掉 assistant 消息,会破坏什么?

参考答案 :模型看不到自己发起过哪些调用,结果也失去了通过 tool_call_id 对应的原始请求,可能造成协议错误或无法理解结果。

练习 2:5 轮是否等于 5 个工具

MaxRounds = 5 是否表示最多执行 5 次工具?

参考答案 :不是。一轮响应可以有多个工具调用,因此还需要 MaxToolCalls 和结果大小、Token 等预算。

练习 3:为什么要记录第二次请求

最终回答正确,为什么还要测试第二次模型请求中的消息?

参考答案:假模型可以直接返回预设文本,即使 Agent 没有正确传递工具结果也可能"通过"。检查请求历史才能验证协议闭环。


下一篇:Go AI Agent 实战 23|多轮工具调用实战与异常恢复

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

相关推荐
IT智慧客07311 小时前
前端 AI 面试题:从基础原理到工程实践的完整题库
人工智能
紧固件情报站1 小时前
紧固件报价逻辑重构:成本、合规与智能博弈
大数据·人工智能·重构
FastCAE20221 小时前
FastCAE AI应用助手重构(一)架构与桥接详解
人工智能·重构·架构
七牛云行业应用1 小时前
爆火OpenFilm 0.2.6:开源 AI 视频 Agent 的安装、编辑与渲染流程(2026 年 10 月 )
人工智能·开源·音视频
咕泡科技1 小时前
AI前沿速递:Gemini双连发+混元双档落地!智元发布首个机器人工时KPI,具身智能分出工厂与客厅两条赛道
人工智能·机器人·腾讯混元·智元机器人·gemini4carbon·巴布机器人
whyutianict_vv1 小时前
HCIE-AI Developer 备考投入拆解:资源规划、判断矩阵与准备流程
人工智能·ai·开发
lucas_AI1 小时前
1000 美元、12 小时、零人工:开源模型 GLM 5.3 把 Firefox 的「补丁窗口」打成了自动化通道
人工智能
吴名氏.1 小时前
Java转AI Agent教程(第二周)超时重试与错误分类
java·人工智能·python·分类·ai agent
Hommy881 小时前
【直播切片工作台】第23章:媒体探测与转码:ffprobe 与 ffmpeg 封装
人工智能·ffmpeg·直播切片