上一次我们把 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,晴,西北风3级
Thought: 北京查到了,还差上海。
Action: get_weather("上海")
Observation: 18°C,多云,东南风2级
Thought: 两个城市的数据都拿到了,可以总结回答用户。
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 提取出来执行。
这个方法有两个痛点:
- token 消耗大。 prompt 里要塞入大量示例来教模型输出正确格式,每个示例都是钱
- 格式不稳定。 模型偶尔会输出不符合格式的内容——Thought 后面忘了换行、Action 参数格式写错——你的解析代码就得处理各种边界情况
今天的 API(DeepSeek、OpenAI 等)把 Action 部分做成了原生能力。DeepSeek 的 Chat Completion 文档 里,tools 是你提供给模型的可调用函数列表;当模型决定要调用工具时,响应里的 finish_reason 可能是 tool_calls。
也就是说,现代 API 没有让 ReAct 消失,只是把 Action 从一段不稳定的文本,变成了结构化字段:
| ReAct 概念 | 原论文里的形态 | 现代 API 里的形态 | Go 代码里要做什么 |
|---|---|---|---|
| Thought | 模型输出一段思考文本 | 通常发生在模型内部,不一定直接返回 | 不强依赖文本思考 |
| Action | Action: get_weather(...) | choice.Message.ToolCalls | 解析 function.name 和 arguments |
| 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 的原始请求(循环开始前)、assistant 的 tool_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 不是字符串数组,而是带 id、type、function.name、function.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 不是让模型一次性把所有事想完,而是让每次行动的结果回到下一次推理里。