用 Go 手撸 Agent 框架 #3:Agent 不是聊天机器人,手写第一个 ReAct 循环

0 阅读16分钟

上一次我们把 LLM 理解成了一个函数:输入 token,输出 token。一次请求,一次响应。

但这里有个工程问题:不接外部工具的一问一答 LLM 调用,只能基于已有上下文和模型参数回答。 你问它"北京天气怎么样",它要么靠训练数据里的旧知识猜一个答案,要么直接说"我不知道"。用户要的是实时数据,模型手里没有实时天气源。

直觉方案是在 prompt 里写"如果你不知道就上网搜一下"——但模型不会主动发起 HTTP 请求。它只是个文本生成器,不知道你代码里有什么 API 可用。

所以真正的问题是:怎么让模型自己决定——什么时候需要外部信息?该调哪个 API?拿到结果后怎么继续推理?

这就是 Agent 要解决的问题。这一篇用最少的 Go 代码实现这个闭环。

聊天机器人和 Agent 的边界不在模型,在架构

先看聊天机器人的实现:

// 聊天机器人:一问一答,没有循环
resp, _ := client.CreateChatCompletion(ctx, ChatCompletionRequest{
    Messages: []ChatCompletionMessage{userMessage},
})
// 返回结果,结束

用户问一句,模型答一句。不管模型多聪明,它只在这一轮对话的边界内工作。

Agent 不同。Agent 在聊天机器人外面包了一层循环:

    ┌──────────────────────────────────────────────────────┐
    │                                                      │
    ▼                                                      │
思考 → 行动(调用工具) → 观察(工具结果) ─────────────────────┘
    │                                                      │
    └──→ 直到决定输出最终答案

核心差异不是模型本身,是架构。 聊天机器人是 stateless function call;Agent 是 stateful loop。模型负责决策(要不要调工具),你的代码负责执行(调完了把结果塞回去)。

ReAct:这个循环有一个名字

ReAct(Reasoning + Acting)是 2022 年那篇 ReAct 论文 提出来的概念。

先别急着记名词。ReAct 要解决的不是"怎么让模型更会聊天",而是一个很具体的工程问题:

模型什么时候应该继续想,什么时候应该出去拿信息,拿到信息后又怎么接着想?

论文里的说法是让模型交替产生推理轨迹和任务行动。翻成后端工程师能直接落代码的话,就是:模型不能只在脑子里推理,也不能只无脑调工具。它需要先判断下一步要做什么,行动拿到结果,再把结果带回下一轮判断。

拿查天气来举例。

只推理不行动会卡住。 你问模型"北京天气怎么样",它如果只靠参数里的旧知识,要么猜,要么说不知道。天气是实时信息,不在模型参数里——再长的推理链也推不出今天的气温。

只行动不推理也会跑偏。 反过来,如果模型只会无脑调工具,它可能查完北京查上海、查完上海查东京,查了一堆数据但忘了用户到底问什么。行动需要推理来决定方向。

ReAct 把这两件事接成闭环。 它的执行过程可以写成这样:

Thought:  用户问了北京和上海的天气,我需要查两个城市的实时数据。
Action:   get_weather("北京")
Observation: 15°C,晴,西北风3Thought:  北京查到了,还差上海。
Action:   get_weather("上海")
Observation: 18°C,多云,东南风2Thought:  两个城市的数据都拿到了,可以总结回答用户。
Answer:   北京现在是 15°C,晴,西北风 3 级;上海是 18°C...

每一轮都是三步:Thought(推理)→ Action(行动)→ Observation(观察)

这三个词不要背,要看它们之间的因果:

  • Thought 负责判断:我现在缺什么信息?下一步该不该行动?
  • Action 负责执行意图:调用天气工具、搜索工具、读文件、写文件。
  • Observation 负责把外部世界的结果带回来:天气结果、搜索结果、文件内容、命令输出。

真正关键的是最后一步:Observation 不是终点,而是下一轮 Thought 的输入。 如果工具结果没有回到对话历史里,模型下一轮就像没查过一样。

你可能会问:这不就是一个 for 循环吗?对。更准确地说,ReAct 不是一个必须照抄论文格式的算法,而是一种编排模式。你不需要实现数学公式;你需要写一个循环,让模型在"判断下一步"和"拿外部信息"之间来回切换。所谓 Agent 框架,最底层就是把这个循环工程化。

原论文的做法 vs 现代 API 的做法

原论文里的 ReAct 靠 prompt engineering 实现:在 few-shot 示例里手动写出 Thought / Action / Observation 的格式,让模型跟着输出。你告诉模型"请按这个格式回答",模型就在回复里写 Thought: ... Action: ...,你的代码再把 Action 提取出来执行。

这个方法有两个痛点:

  1. token 消耗大。 prompt 里要塞入大量示例来教模型输出正确格式,每个示例都是钱
  2. 格式不稳定。 模型偶尔会输出不符合格式的内容——Thought 后面忘了换行、Action 参数格式写错——你的解析代码就得处理各种边界情况

今天的 API(DeepSeek、OpenAI 等)把 Action 部分做成了原生能力。DeepSeek 的 Chat Completion 文档 里,tools 是你提供给模型的可调用函数列表;当模型决定要调用工具时,响应里的 finish_reason 可能是 tool_calls

也就是说,现代 API 没有让 ReAct 消失,只是把 Action 从一段不稳定的文本,变成了结构化字段:

ReAct 概念原论文里的形态现代 API 里的形态Go 代码里要做什么
Thought模型输出一段思考文本通常发生在模型内部,不一定直接返回不强依赖文本思考
ActionAction: get_weather(...)choice.Message.ToolCalls解析 function.namearguments
Observation环境返回观察结果role=tool 的消息把工具结果带 tool_call_id 回填
下一轮 Thought基于 Observation 继续推理下一次 CreateChatCompletion带着更新后的 messages 继续循环

这张表是理解本文的关键。很多人第一次写 Agent 卡住,不是不会写 Go,而是没分清:模型返回 tool_calls 只是 Action 意图;Observation 要靠你的代码执行工具后再塞回去。

换句话说,API 替你解决了"Action 怎么格式化"的问题,但没替你解决"Action 之后怎么继续 Reasoning"的问题。后者才是这篇要手写的 ReAct 循环。

停一下问自己: 如果 tool_calls 只是 Action 意图,那 Observation 在代码里是怎么产生的?如果这个问题答不上来,后面的 tool 消息回填就很容易绕进去。

定义第一个工具

查天气需要一个工具。在 OpenAI 兼容的 API 里,定义一个工具需要三样东西:

var weatherTool = openai.Tool{
    Type: openai.ToolTypeFunction,
    Function: &openai.FunctionDefinition{
        Name:        "get_weather",
        Description: "获取指定城市当前的天气信息,包括温度和天气状况",
        Parameters: map[string]any{
            "type": "object",
            "properties": map[string]any{
                "city": map[string]any{
                    "type":        "string",
                    "description": "城市名称,如北京、上海、New York",
                },
            },
            "required": []string{"city"},
        },
    },
}

这三样东西对后端工程师来说应该不陌生:

  • Name — 函数名。模型决定调用工具时用它引用
  • Description — 模型判断"什么时候该用这个工具"时最直接的信息之一。"获取指定城市当前的天气信息"比"查天气"清楚很多,因为前者明确了输入是城市名、输出是天气信息
  • Parameters(JSON Schema) — 参数结构。跟后端 API 的请求体定义很像,只不过这里的 schema 不只是给代码校验用,也是给模型填参数时看的

实现工具逻辑

定义了"接口",实现逻辑用 mock 数据。这里的天气不是实时天气,数据就写死在 weatherData 里。这样做是为了把注意力放在 ReAct 循环上,不让读者一上来就被真实天气 API、鉴权、网络错误分散掉。

var weatherData = map[string]string{
    "北京":   "15°C,晴,西北风3级",
    "上海":   "18°C,多云,东南风2级",
    "东京":   "22°C,小雨,南风1级",
    "New York": "12°C,晴,北风4级",
    "London":   "8°C,阴,西风3级",
}

func handleGetWeather(input json.RawMessage) (string, error) {
    var args struct {
        City string `json:"city"`
    }
    if err := json.Unmarshal(input, &args); err != nil {
        return "", fmt.Errorf("参数解析失败: %w", err)
    }

    if weather, ok := weatherData[args.City]; ok {
        return weather, nil
    }
    return fmt.Sprintf("%s 的天气数据暂时不可用", args.City), nil
}

把工具放进决策循环

现在把工具定义和实现组合成循环。这是整篇最核心的代码——之后所有 Agent 变体都是在这个骨架上加东西:

func runAgent(client *openai.Client, ctx context.Context, question string) {
    messages := []openai.ChatCompletionMessage{
        {Role: openai.ChatMessageRoleUser, Content: question},
    }

    for i := 0; i < 5; i++ {
        resp, err := client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
            Model:     "deepseek-v4-flash",
            MaxTokens: 4096,
            Messages:  messages,
            Tools:     []openai.Tool{weatherTool},
        })
        if err != nil {
            fmt.Printf("API 调用失败: %v\n", err)
            return
        }

        choice := resp.Choices[0]

        // 检查模型是否要调用工具
        if choice.FinishReason == openai.FinishReasonToolCalls {
            // 把模型的回复(含工具调用)加入对话历史
            messages = append(messages, openai.ChatCompletionMessage{
                Role:      openai.ChatMessageRoleAssistant,
                Content:   choice.Message.Content,
                ToolCalls: choice.Message.ToolCalls,
            })

            // 执行工具,把结果以 tool 角色逐条塞回对话
            for _, tc := range choice.Message.ToolCalls {
                result, err := handleGetWeather(json.RawMessage(tc.Function.Arguments))
                if err != nil {
                    result = "工具执行出错: " + err.Error()
                }
                messages = append(messages, openai.ChatCompletionMessage{
                    Role:       openai.ChatMessageRoleTool,
                    Content:    result,
                    ToolCallID: tc.ID,
                })
            }

            // 继续循环,模型拿到工具结果后会继续推理
            continue
        }

        // FinishReason 为 "stop"(自然结束)或 "length"(被截断)
        fmt.Println(choice.Message.Content)
        if choice.FinishReason == openai.FinishReasonLength {
            fmt.Println("\n⚠️ 警告:模型输出被截断,可能需要增大 MaxTokens")
        }
        return
    }

    fmt.Println("达到最大迭代次数,未能完成请求")
}

逐行解释这个循环里容易被忽略的关键决策:

模型返回 tool_calls,不等于工具已经执行。 tool_calls 只是模型的"意图"——"我想调用 get_weather"。真正执行工具的是你的代码。这意味着 Agent 循环本质上是三样东西交替推进:用户请求 → 模型决策(要调什么) → 代码执行(返回结果) → 模型继续决策。缺了任何一段,下一轮模型都无法正确推理。

三类消息必须全部进历史。 循环往 messages 里追加了三类消息:user 的原始请求(循环开始前)、assistanttool_calls 决策(模型决定调工具的那一刻)、tool 的执行结果(你的代码跑完后的返回值)。如果漏掉 assistant 消息里的 ToolCalls 字段,模型在下一轮不知道它上一次要求了什么。如果漏掉 tool 消息,模型不知道执行结果。

FinishReason 是状态转移信号。 用后端的语言:tool_calls 是"需要更多信息,继续处理",stop 是"信息足够,返回结果"。但循环和状态推进仍然是你的代码负责:API 只告诉你下一步该进入哪个分支。

循环次数需要上限。 不设限的话,模型可能陷入 retry loop——工具返回了错误,模型再调,再错……每多一轮就是一次 API 调用成本。5 次对于简单任务够用,复杂任务后面会扩展。

停一下问自己: 如果下一轮 messages 里只有工具结果,没有上一轮 assistant 的 ToolCalls 决策,模型还能知道这些工具结果对应哪一次调用吗?

完整的可运行程序

把上面的代码组合在一起:

package main

import (
    "bufio"
    "bytes"
    "context"
    "encoding/json"
    "fmt"
    "io"
    "net/http"
    "os"
    "strings"

    openai "github.com/sashabaranov/go-openai"
)

// deepseekTransport 注入 thinking: {type: "disabled"}。
// DeepSeek V4 默认开启 Thinking Mode。工具调用场景下,
// 把 reasoning_content 带回后续请求会报 400,显式关闭 thinking 避免此问题。
type deepseekTransport struct {
    base http.RoundTripper
}

func (t *deepseekTransport) RoundTrip(req *http.Request) (*http.Response, error) {
    if strings.Contains(req.URL.Host, "deepseek") && strings.Contains(req.URL.Path, "chat/completions") {
        bodyBytes, err := io.ReadAll(req.Body)
        if err != nil {
            return nil, err
        }
        req.Body.Close()

        var bodyMap map[string]any
        if err := json.Unmarshal(bodyBytes, &bodyMap); err != nil {
            return nil, fmt.Errorf("请求体解析失败: %w", err)
        }
        if _, exists := bodyMap["thinking"]; !exists {
            bodyMap["thinking"] = map[string]string{"type": "disabled"}
        }
        modifiedBytes, err := json.Marshal(bodyMap)
        if err != nil {
            return nil, err
        }
        req.Body = io.NopCloser(bytes.NewReader(modifiedBytes))
        req.ContentLength = int64(len(modifiedBytes))
    }
    if t.base != nil {
        return t.base.RoundTrip(req)
    }
    return http.DefaultTransport.RoundTrip(req)
}

var weatherTool = openai.Tool{
    Type: openai.ToolTypeFunction,
    Function: &openai.FunctionDefinition{
        Name:        "get_weather",
        Description: "获取指定城市当前的天气信息,包括温度和天气状况",
        Parameters: map[string]any{
            "type": "object",
            "properties": map[string]any{
                "city": map[string]any{
                    "type":        "string",
                    "description": "城市名称,如北京、上海、New York",
                },
            },
            "required": []string{"city"},
        },
    },
}

var weatherData = map[string]string{
    "北京": "15°C,晴,西北风3级",
    "上海": "18°C,多云,东南风2级",
    "东京": "22°C,小雨,南风1级",
    "New York": "12°C,晴,北风4级",
    "London":   "8°C,阴,西风3级",
}

func handleGetWeather(input json.RawMessage) (string, error) {
    var args struct {
        City string `json:"city"`
    }
    if err := json.Unmarshal(input, &args); err != nil {
        return "", fmt.Errorf("参数解析失败: %w", err)
    }
    if weather, ok := weatherData[args.City]; ok {
        return weather, nil
    }
    return fmt.Sprintf("%s 的天气数据暂时不可用", args.City), nil
}

func runAgent(client *openai.Client, ctx context.Context, question string) {
    messages := []openai.ChatCompletionMessage{
        {Role: openai.ChatMessageRoleUser, Content: question},
    }

    for i := 0; i < 5; i++ {
        resp, err := client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
            Model:     "deepseek-v4-flash",
            MaxTokens: 4096,
            Messages:  messages,
            Tools:     []openai.Tool{weatherTool},
        })
        if err != nil {
            fmt.Printf("API 调用失败: %v\n", err)
            return
        }

        choice := resp.Choices[0]

        if choice.FinishReason == openai.FinishReasonToolCalls {
            messages = append(messages, openai.ChatCompletionMessage{
                Role:      openai.ChatMessageRoleAssistant,
                Content:   choice.Message.Content,
                ToolCalls: choice.Message.ToolCalls,
            })
            for _, tc := range choice.Message.ToolCalls {
                result, err := handleGetWeather(json.RawMessage(tc.Function.Arguments))
                if err != nil {
                    result = "工具执行出错: " + err.Error()
                }
                messages = append(messages, openai.ChatCompletionMessage{
                    Role:       openai.ChatMessageRoleTool,
                    Content:    result,
                    ToolCallID: tc.ID,
                })
            }
            continue
        }

        fmt.Println(choice.Message.Content)
        if choice.FinishReason == openai.FinishReasonLength {
            fmt.Println("\n⚠️ 警告:模型输出被截断,可能需要增大 MaxTokens")
        }
        return
    }

    fmt.Println("达到最大迭代次数,未能完成请求")
}

func loadEnv(filename string) error {
    f, err := os.Open(filename)
    if err != nil {
        if os.IsNotExist(err) {
            return nil
        }
        return err
    }
    defer f.Close()

    scanner := bufio.NewScanner(f)
    for scanner.Scan() {
        line := strings.TrimSpace(scanner.Text())
        if line == "" || strings.HasPrefix(line, "#") {
            continue
        }

        idx := strings.Index(line, "=")
        if idx == -1 {
            continue
        }

        key := strings.TrimSpace(line[:idx])
        value := strings.TrimSpace(line[idx+1:])
        if (strings.HasPrefix(value, `"`) && strings.HasSuffix(value, `"`)) ||
            (strings.HasPrefix(value, `'`) && strings.HasSuffix(value, `'`)) {
            value = value[1 : len(value)-1]
        }

        if _, exists := os.LookupEnv(key); !exists {
            os.Setenv(key, value)
        }
    }
    return scanner.Err()
}

func main() {
    if err := loadEnv(".env"); err != nil {
        fmt.Printf("加载 .env 失败: %v\n", err)
        return
    }

    apiKey := os.Getenv("DEEPSEEK_API_KEY")
    if apiKey == "" {
        fmt.Println("请先在 .env 中设置 DEEPSEEK_API_KEY")
        return
    }

    config := openai.DefaultConfig(apiKey)
    config.BaseURL = "https://api.deepseek.com"
    // 注入 thinking 禁用
    if httpClient, ok := config.HTTPClient.(*http.Client); ok {
        httpClient.Transport = &deepseekTransport{base: httpClient.Transport}
    }
    client := openai.NewClientWithConfig(config)
    runAgent(client, context.Background(), "北京和上海现在的天气怎么样?")
}

运行方式:

mkdir 03-first-agent
cd 03-first-agent
go mod init demo-agent-03
go get github.com/sashabaranov/go-openai@v1.41.0

把上面的代码保存成 main.go,再创建 .env

DEEPSEEK_API_KEY=你的 DeepSeek API Key

然后运行:

go run .

配套仓库版本把 .env 读取逻辑拆到了 loadenv.go,文章为了方便复制,放进了同一个 main.go。两边做的是同一件事。

运行后,模型先决定需要查两个城市的天气 → 调用 get_weather("北京")get_weather("上海") → 拿到结果后组织语言回答。整个过程用户只说了一句话。

拆解那个循环:一个伪代码版本

用后端语言重写一遍这个骨架,去掉 Go 的语法噪音:

for 最大迭代次数 {
    response = LLM(对话历史, 可用工具)

    if response.FinishReason == "tool_calls" {
        对话历史.append(assistant 消息含 tool_calls)  // 记录模型的调用决定
        for each tool_call:
            对话历史.append(tool 消息含执行结果)       // 记录工具的执行结果
        continue                                      // 回到 LLM,让它基于新信息继续
    }

    // FinishReason == "stop"
    输出(response.文本)
    break
}

实际运行追踪

用"北京和上海现在的天气怎么样?"走一遍完整流程,看看消息列表在两次循环里分别长什么样。

第一次迭代:

模型收到的消息——只有用户的一句话:

[{"role": "user", "content": "北京和上海现在的天气怎么样?"}]

模型返回——它判断两个城市都需要查:

FinishReason: "tool_calls"
ToolCalls: [
  {Function: "get_weather", Arguments: {"city": "北京"}},
  {Function: "get_weather", Arguments: {"city": "上海"}}
]

你的代码执行两个工具,把结果追加到消息列表。此时消息列表变成:

[
  {"role": "user",      "content": "北京和上海现在的天气怎么样?"},
  {"role": "assistant", "content": "", "tool_calls": ["get_weather(北京)", "get_weather(上海)"]},
  {"role": "tool",      "tool_call_id": "call_1", "content": "15°C,晴,西北风3级"},
  {"role": "tool",      "tool_call_id": "call_2", "content": "18°C,多云,东南风2级"}
]

这里为了阅读做了简化。真实 API 返回的 tool_calls 不是字符串数组,而是带 idtypefunction.namefunction.arguments 的结构化对象。

注意这四条消息各自的角色:user 是用户的需求,assistant 是模型的决策("我要查这两个城市"),两条 tool 是代码的执行结果("查到了")。缺一不可。

第二次迭代:

模型收到上面完整的消息列表——它看到了自己上一次的 tool_calls 决定,也看到了工具返回的结果。它内部推理:"数据都拿到了,可以回答。"于是返回:

FinishReason: "stop"
Content: "北京现在是15°C,晴,西北风3级;上海是18°C,多云,东南风2级。
          两个城市今天天气都不错,适合出门。"

FinishReason == "stop",循环结束,输出答案。只用了两次迭代。

关键问题:两次迭代之间,模型"记住"了什么? 它记住了自己在第一次迭代中做的决定(assistant 消息里的 tool_calls)以及这些决定的后果(tool 消息里的执行结果)。如果漏掉 assistant 消息,模型不知道它上一次要求了什么。如果漏掉 tool 消息,模型不知道执行结果是什么。两种情况都会导致第二轮推理失败。

这就是 ReAct 循环在工程上的本质:维护一个消息列表,每次迭代往里面追加新信息,让模型基于完整的上下文继续推理。 不是魔法——就是一个 for 循环 + 消息追加。所谓"Agent 框架",不过是在这个骨架上加了错误处理、并发控制、多工具编排、记忆管理……但骨架从来没变过。

这就是 Agent 的完整决策骨架。 后端工程师应该没有陌生感:每个 API 调用是一次状态转移,tool_calls 是"继续处理",stop 是"处理完成"。

Claude Code 这类编码 Agent,在架构上也离不开类似闭环:收消息 → 决定调用哪个工具(Read / Edit / Bash / Glob / Grep…)→ 拿到结果继续推理 → 直到任务完成。真实系统会复杂得多,会有权限、上下文管理、文件编辑、错误恢复和任务压缩;但最底层的节奏,仍然是 ReAct 这条线。

到这一步为止

这一篇的代码不复杂,难点不在语法,而在把 ReAct 的状态流转看清楚。

动笔之前先校准几个容易搞混的点。只调用一次 API 不算 Agent——一次调用只是"输入问题,输出答案",Agent 多了一层循环让模型在决策和行动之间来回切换。tool_calls 只是模型的调用意图,不是执行结果,真正跑工具的是你的 Go 代码。所以 assistant 的 ToolCalls 和 tool 的执行结果都必须进 messages:少任何一个,下一轮模型都对不上上下文。工具返回错误也一样,错误是另一种 Observation,模型看到了才能决定下一步——换参数、换工具、重试,或者告诉用户做不到。

真正值得带走的是三个判断:

Agent 和聊天机器人的分界线不在模型能力,在架构。 聊天机器人是 stateless function call——输入进来、输出出去、结束。Agent 是 stateful loop——模型决策、代码执行、结果回填、继续决策。区别不在模型本身,在调用模型的那层循环。

Agent 的核心闭环是:模型决策 + 工具执行 + 结果回填。 缺了结果回填,模型不知道工具执行发生了什么。缺了模型决策(把 tool_calls 写进消息历史),模型不知道它上一次做了什么。三类消息(user 请求、assistant 决策、tool 结果)一条不能少。

什么时候需要 Agent?问一个问题: 当前系统缺的是"更好的回答"还是"主动获取信息或执行动作的能力"?如果你的问题可以通过更好的 prompt engineering 解决,不需要 Agent。只有在模型必须调用外部系统——获取信息或执行操作——才能完成任务时,Agent 才有价值。把 Agent 当万能方案用,只会多烧 API 费用。

这一篇可以沉淀成一句设计原则:ReAct 不是让模型一次性把所有事想完,而是让每次行动的结果回到下一次推理里。