本系列共 10 阶段,面向有 Go 后端基础的开发者,全程用 Go 手写实现。
当前进度:阶段三 Tool Calling · 第 5 课(22)
前面的程序已经能完成一次工具调用。但模型拿到工具结果后,可能继续请求新工具,也可能直接生成最终答案。应用需要一个受控循环来推进这些状态。
这一课把模型调用、工具执行、结果回传和停止条件串成完整的 Agent 循环,并使用假模型测试,不依赖真实 API。
一、本课学习目标
学完本课,你应该能够:
- 识别 Agent 循环中的模型状态、工具状态和结束状态。
- 保留 assistant 工具调用消息并追加对应 tool 结果。
- 实现模型轮数、工具次数、工具超时和任务总超时限制。
- 区分正常完成、失败结束和达到限制后未完成。
- 用
ChatModel接口隔离模型供应商。 - 用
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 调用验证模型行为和协议兼容性。
九、本课总结
- Agent 循环在"模型请求工具"和"程序执行工具"之间迭代。
- assistant 工具调用消息和对应 tool 结果必须一起保留。
- 每轮都检查协议,执行前检查预算和超时。
- 最终回答、达到限制、超时和协议错误是不同结束状态。
ChatModel接口让 Agent 可以在真实模型与假模型之间切换。- 测试不仅要看最终文本,还要验证每次请求的真实消息历史。
十、课后练习
练习 1:为什么保留工具调用消息
只保留 role: "tool" 结果而丢掉 assistant 消息,会破坏什么?
参考答案 :模型看不到自己发起过哪些调用,结果也失去了通过
tool_call_id对应的原始请求,可能造成协议错误或无法理解结果。
练习 2:5 轮是否等于 5 个工具
MaxRounds = 5 是否表示最多执行 5 次工具?
参考答案 :不是。一轮响应可以有多个工具调用,因此还需要
MaxToolCalls和结果大小、Token 等预算。
练习 3:为什么要记录第二次请求
最终回答正确,为什么还要测试第二次模型请求中的消息?
参考答案:假模型可以直接返回预设文本,即使 Agent 没有正确传递工具结果也可能"通过"。检查请求历史才能验证协议闭环。
下一篇:Go AI Agent 实战 23|多轮工具调用实战与异常恢复
专栏:Go 开发者学 AI Agent:从大模型到工程落地