用 Go 手写一个最小 Coding Agent:Tool Use、ReAct 与 Bash Loop -- 01
Loops and Bash Are All You Need
1. 从“古法编程”到 Vibe Coding
记得 ChatGPT 刚问世时,我们迫不及待地用它来辅助编程。那时候的交互大多是纯问答式的:把代码从编辑器复制进对话框,等 AI 生成代码后,再手动复制回项目里运行。
在当时,这种“古法编程”已经令人惊艳。但随着项目复杂度上升,它的瓶颈暴露无遗:AI 容易遗忘上下文,每次都需要反复喂背景信息;更麻烦的是它无法直接运行代码,所有的测试和纠错循环都必须依赖人类作为中介搬运工,不断地将报错信息贴回去,它才能慢慢把错误修复。
直到 Cursor、Claude Code 等 Coding Agent 出现,你只需要抛出高层意图, Agent 就能自主阅读文件、执行终端命令、分析报错并自行修复——这种完全由 AI 驱动执行、人类只需把控方向的开发体验,正是现在流行的 Vibe Coding。
这些 Agent 看起来像是有自我意识的魔法,但当你切换到架构者的视角,会发现其底层引擎极其简明:一段限定身份的提示词、一个能操作的工具、以及循环。
2. 赋予大模型双手:Tool Use 带来的本质跨越
2.1 什么是 Tool Use(工具调用)?
LLM 的能力边界很明确:LLM 本质上生成的是文本结果,并不会天然具备访问文件系统、执行命令或观察外部环境的能力。问它"当前目录有哪些文件",它只能输出一句建议:"你可以用 ls 命令查看"。
那么,有没有一种方式能让 LLM 直接去执行这个命令呢?答案是肯定的,这就是 Tool Use(工具调用)。
从工程实现看,Tool Use 的核心机制并不复杂。它指的是:我们在向大模型提问时,额外附带一份“工具说明书”(比如一份说明了可以调用 Bash 的 JSON Schema)。模型如果判断仅靠聊天解决不了问题,就会按照说明书的格式,输出一段“我请求调用 Bash 工具”的特殊指令。我们编写的代码在本地拦截到这段指令,代替它实际执行(跑一下 Bash),然后再把真实结果“喂”回给模型。
这一个关键转变,让大模型接上了真实的执行器,从“只有嘴”进化成了“有手有嘴”。
当然,真实产品中不能直接暴露无限制 Bash,而需要沙箱、权限控制、超时机制和审计日志。
2.2 核心差异:从“给出建议”到“直接解决”
有了 Tool Use,模型与现实世界的交互方式发生了质变:
| 能力层级 | 纯 LLM | Agent (LLM + Tool Use) |
|---|---|---|
| 意图理解 | ✅ 能听懂你的要求 | ✅ 能听懂你的要求 |
| 方案规划 | ✅ 能列出操作步骤 | ✅ 能自主生成执行步骤 |
| 动作执行 | ❌ 只能让你去粘贴执行 | ✅ 能向系统请求调用工具执行 |
| 结果观察 | ❌ 看不到系统的报错或输出 | ✅ 能读取工具返回的真实结果 |
直观案例对比:同一个需求——"创建一个输出 Hello World 的 hello.go 文件并运行"
- 纯 LLM:输出一段 Go 代码,然后告诉你:“请把上面的代码复制并保存为
hello.go,然后在你的终端里运行它。”(你成为了代码的搬运工) - Agent:模型请求调用 Bash 工具执行
echo '...' > hello.go;代码执行后告诉模型成功了;模型接着请求调用 Bash 执行go run hello.go,如果出错,模型看到报错后会再次调用 Bash 去修 Bug,直到顺利输出Hello World。
2.3 交互模式的变化:从单次问答到闭环试错
正是因为 Agent 能够“观察”到自己调用工具的结果,它的交互范式从“单轮对话”变成了“多轮试错循环”。这就是 Agent 能够自主干活、实现 Vibe Coding 的秘密。
但随之而来的是一个新问题:如果任务步骤一多,模型在反复调用工具的过程中非常容易“断片”——执行完上一个命令,它忘了下一步原本要干什么。
这就需要我们在多轮循环中引入一套清晰的思维框架,也就是 ReAct 架构,来把每一轮的输出结构化。
3. 核心演进:ReAct 架构与两层循环
3.1 ReAct 架构的本质
ReAct(Reasoning and Acting)把解决问题的过程拆解为 "思考(Think)→ 行动(Act)→ 观察(Observe)" 的交替循环。Tool Calling 负责让模型发起结构化工具请求,ReAct 则负责组织“为什么调用、调用后如何继续”的循环节奏。在这个最简实现中,我们借用 ReAct 的结构,把每一轮模型输出收敛为两类:要么请求工具调用,要么返回最终答案。
ReAct 并不是模型内置的行为,而是人为通过 prompt 结构 + 外部循环驱动出来的行为。
flowchart TD
Q(["❓ 用户问题 / 任务"]) --> T["💭 思考 (Thought)\n基于当前状态进行推理"]
T --> A["⚡ 行动 (Action)\n调用工具或执行命令"]
A --> O["👁️ 观察 (Observation)\n获取环境返回的结果"]
O --> D{{"任务是否完成?"}}
D -- 否 --> T
D -- 是 --> ANS(["✅ 最终回复"])
3.2 两层循环结构
sequenceDiagram
actor U as 用户
participant R as REPL
participant A as agentLoop
participant L as LLM
participant T as Tool
loop 外层:对话轮次
U->>R: 输入
R->>A: agentLoop(input, history)
loop 内层:推理-行动
A->>L: prompt + messages
L-->>A: Thought + tool_use
A->>T: 执行工具
T-->>A: Observation
A->>A: 追加 Observation 到 messages
end
L-->>A: end_turn + 最终回复
A-->>R: return final_reply
R->>U: 输出
end
- 外层循环:负责与用户交互,等待用户输入,调用
agentLoop处理任务,然后返回最终结果。 - 内层循环:负责与实际环境交互,持续执行“思考 → 行动 → 观察”,直到模型认为任务完成。
3.3 架构组成
- 系统提示词:定义 Agent 的身份和行为边界
- 工具集:定义 Agent 的能力边界
- 两层循环:外层维持会话,内层驱动任务完成
3.4 最简执行器:为什么一个 Bash 就够了?
在受控环境中,一个 Bash 工具已经足以覆盖许多基础编程任务:
- 读文件:
cat main.go - 写文件:
echo "package main" > main.go - 运行代码:
go run main.go - 安装依赖:
go mod tidy
这就是一个 Agent 的起步形态:Loop + Bash。
4. 代码落地:用 Go 实现 Agent Loop
4.1 系统提示词:最短的"工作说明书"
var WorkingDir, _ = os.Getwd()
var SYSTEM_PROMPT = fmt.Sprintf(
"You are a coding agent at %s. Use bash to solve tasks. Act, don't explain.",
WorkingDir,
)
为什么提示词要这么写? 这条系统提示词虽然很短,但它完成了:
- 角色设定:
You are a coding agent明确了 LLM 身份,让它进入“Coding”模式,而不是普通问答助手。 - 环境设定:当前工作目录被显式放进上下文,让 LLM 知道命令默认应该在这个项目里运行。
- 工具授权:
Use bash to solve tasks明确工具使用方式,让 LLM 知道遇到问题不要只给建议,而是通过 Bash 调用去读取文件、运行代码、观察结果。 - 行为约束:最后一句
Act, don't explain.很关键。Agent Loop 里每一轮都要付出模型调用和工具调用成本,提示词越鼓励解释,循环就越慢。这里直接要求模型优先行动,可以减少无效文本,让它更快进入“执行命令 -> 观察结果 -> 修正下一步”的闭环。
所以,这条提示词本质上是在用最小成本定义 Agent 的四个边界:身份、环境、工具、行为。提示词越短越聚焦,模型越容易稳定地进入 ReAct 循环。
4.2 工具定义:一份 JSON Schema
var TOOLS = []anthropic.ToolUnionParam{
{OfTool: &anthropic.ToolParam{
Name: "bash",
Description: anthropic.String("Run a shell command."),
InputSchema: anthropic.ToolInputSchemaParam{
Properties: map[string]any{
"command": map[string]any{"type": "string"},
},
Required: []string{"command"},
},
}},
}
工具定义是一份 JSON Schema。模型根据它生成结构化的工具调用请求,SDK 负责解析。
模型无关性:本文讨论的是 Agent loop 的架构,而不是某个具体模型的能力。示例中使用
kimi-k2.5,如果替换为其他支持 Tool Use 的模型,核心循环仍然成立;但实际代码中可能需要适配不同 SDK 的工具调用格式、消息结构和终止信号。
const MODEL = "kimi-k2.5"
4.3 内层循环:agentLoop
这是整个 Agent 的心脏:
func agentLoop(messages *[]anthropic.MessageParam, client anthropic.Client) (*anthropic.Message, error) {
for {
resp, err := client.Messages.New(context.Background(), anthropic.MessageNewParams{
Model: MODEL,
MaxTokens: 8000,
System: []anthropic.TextBlockParam{{Text: SYSTEM_PROMPT}},
Tools: TOOLS,
Messages: *messages,
})
if err != nil {
return nil, err
}
// 把 assistant 回复(文本 + 工具调用请求)追加进历史
var assistantBlocks []anthropic.ContentBlockParamUnion
for _, block := range resp.Content {
switch block.Type {
case "text":
assistantBlocks = append(assistantBlocks, anthropic.NewTextBlock(block.Text))
case "tool_use":
assistantBlocks = append(assistantBlocks, anthropic.NewToolUseBlock(block.ID, block.Input, block.Name))
}
}
*messages = append(*messages, anthropic.NewAssistantMessage(assistantBlocks...))
// 核心终止条件:模型不再要求工具调用,任务完成
if resp.StopReason != anthropic.StopReasonToolUse {
return resp, nil
}
// 执行所有工具调用,把结果作为 tool_result 追加进历史
var toolResults []anthropic.ContentBlockParamUnion
for _, block := range resp.Content {
if block.Type != "tool_use" {
continue
}
var input map[string]any
if err := json.Unmarshal(block.Input, &input); err != nil {
return nil, fmt.Errorf("decode tool input for %s: %w", block.Name, err)
}
command, ok := input["command"].(string)
if !ok {
return nil, fmt.Errorf("tool %s missing string command", block.Name)
}
output := runBash(command)
fmt.Printf("\033[33m$ %s\033[0m\n", command)
if len(output) > 200 {
fmt.Println(output[:200])
} else {
fmt.Println(output)
}
toolResults = append(toolResults, anthropic.NewToolResultBlock(block.ID, output, false))
}
if len(toolResults) == 0 {
return nil, fmt.Errorf("stop_reason was tool_use but no tool_use blocks were returned")
}
*messages = append(*messages, anthropic.NewUserMessage(toolResults...))
}
}
4.3.1 循环退出条件
循环退出的条件只有一个:resp.StopReason != anthropic.StopReasonToolUse。
这是 ReAct 机制的关键点:当模型决定调用工具时,API 通常会返回 stop_reason: "tool_use",提示程序当前轮次应该优先执行工具,而不是直接展示为最终回复。需要注意的是,一些 API 响应中可能同时包含文本内容和 tool_use,所以程序更应该以 StopReason 和 content block 类型共同判断下一步动作。只有模型不再调用工具,才会真正地退出循环。
4.3.2 工作记忆
模型没有状态——每次调用都是无状态的。ReAct 的多轮推理靠的是把整个历史塞进 messages 数组里。
在 ReAct 循环中,每次模型返回的内容(包括文本和工具调用请求)都会被追加到 messages 变量中。这个 messages 列表构成了 Agent 的工作记忆。每次循环,模型都能“看到”之前所有的对话和工具调用结果,从而进行下一步的推理。
4.3.3 Roles(角色)
在这个实现里,系统提示词通过 System 参数单独传入;真正进入 messages 历史的,主要是 user 和 assistant 两类消息。
user:用户输入,以及工具执行后的tool_resultassistant:模型返回的文本内容,以及模型发起的tool_use
因此,tool_use 属于 assistant 消息,表示“模型请求调用工具”;tool_result 则作为 user 消息返回,表示“外部环境把执行结果反馈给模型”。
在循环中,assistant 的 tool_use 和 user 的 tool_result 交替出现,形成完整的 ReAct 执行轨迹。
4.4 Bash 工具
func runBash(command string) string {
dangerous := []string{"rm -rf /", "sudo", "shutdown", "reboot", "> /dev/"}
for _, str := range dangerous {
if strings.Contains(command, str) {
return "Error: Dangerous command blocked"
}
}
cmd := exec.Command("sh", "-c", command)
cmd.Dir = WorkingDir
res, err := cmd.CombinedOutput()
if err != nil {
return fmt.Sprintf("Error: %v\nOutput: %s", err, string(res))
}
output := string(res)
switch {
case output == "":
return "(no output)"
case len(output) > 50000:
return output[:50000]
default:
return output
}
}
这段
runBash只是演示级防护,用来说明 Agent 如何接入执行器,不适合直接用于生产环境或处理不可信输入。生产级实现至少需要沙箱隔离、权限控制、命令白名单、超时机制和审计日志。
工程细节处理:
- 基础风险命令拦截:
rm -rf /、sudo等不允许执行 - 错误透传:执行失败时把错误信息和输出一起返回给模型——模型需要看到错误才能自行纠错。
- 输出截断:超过 50000 字符直接截断,防止 Token 超限。
4.5 外层循环:REPL
func main() {
for _, p := range []string{"../../.env", "../.env", ".env"} {
if godotenv.Load(p) == nil {
break
}
}
client := anthropic.NewClient()
var messages []anthropic.MessageParam
reader := bufio.NewReader(os.Stdin)
for {
fmt.Print("s01 >> : ")
input, err := reader.ReadString('\n')
if err != nil && err != io.EOF {
logrus.Error(err)
break
}
trimmed := strings.TrimSpace(input)
if isQuit(trimmed) {
break
}
messages = append(messages, anthropic.NewUserMessage(anthropic.NewTextBlock(trimmed)))
resp, loopErr := agentLoop(&messages, client)
if loopErr != nil {
logrus.Error(loopErr)
} else {
printLastReply(resp)
}
if err == io.EOF {
break
}
}
}
外层循环的职责单一:读输入、传给 agentLoop、打印结果。messages 在整个会话中累积,所以 Agent 能记住你之前说过的话。
4.6 补齐完整运行所需的辅助代码
上面的章节按职责拆开讲解核心逻辑。如果要拼成一个可运行的 main.go,还需要补上文件头、依赖导入和两个辅助函数。
package main
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"strings"
"github.com/anthropics/anthropic-sdk-go"
"github.com/joho/godotenv"
"github.com/sirupsen/logrus"
)
func isQuit(s string) bool {
lower := strings.ToLower(s)
return lower == "exit" || lower == "q" || lower == "quit" || s == ""
}
func printLastReply(resp *anthropic.Message) {
for _, block := range resp.Content {
if block.Type == "text" && block.Text != "" {
fmt.Println(block.Text)
}
}
}
5. 运行验证:执行流与 ReAct 阶段映射
5.1 完整对话流
❯ go run main.go
s01 >> : 创建一个输出 "Hello, Agent!" 的 Go 程序并运行
$ cat > hello.go << 'EOF'
package main
import "fmt"
func main() {
fmt.Println("Hello, Agent!")
}
EOF
(no output)
$ go run hello.go
Hello, Agent!
已创建并运行 Go 程序,输出:
Hello, Agent!
s01 >> :
这段日志可以对应到 ReAct 的几个阶段:
| ReAct 阶段 | 日志片段 | 含义 |
|---|---|---|
| User Input | 创建一个输出 "Hello, Agent!" 的 Go 程序并运行 | 用户给出目标 |
| Tool Use | $ cat > hello.go << 'EOF' | 模型请求 Bash 写入文件 |
| Observation | (no output) | Bash 返回写文件结果 |
| Tool Use | $ go run hello.go | 模型继续请求 Bash 运行程序 |
| Observation | Hello, Agent! | Bash 返回真实运行结果 |
| Final Answer | 已创建并运行 Go 程序... | 模型不再调用工具,返回最终回复 |
6. 架构反思:关键设计与后续演进
6.1 工具结果的消息角色
工具执行结果通过 tool_result content block 传回模型,再包进 user 消息发送:
toolResults = append(toolResults, anthropic.NewToolResultBlock(block.ID, output, false))
*messages = append(*messages, anthropic.NewUserMessage(toolResults...))
tool_use_id 把请求和结果精确对应——模型知道"这是我刚才那个 bash 调用的输出",而不是用户说的话。这在并行工具调用时尤为关键:多个工具同时执行时,模型需要能区分哪个结果对应哪个请求。
6.2 终止条件
本版本用 StopReason != ToolUse 作为终止条件,没有显式的最大迭代次数。在模型行为正常时,任务完成就会停下。
如果模型陷入"执行→报错→再执行"的循环,StopReason 永远是 ToolUse,程序会一直跑下去,最终导致 Token 耗尽报错。对于本版本来说,我们只能依靠用户手动中断程序,或者等 Token 用完报错。(祈祷不要出现无限循环的情况吧!)
因此,生产级实现中应显式设置最大迭代次数、命令超时和错误退出策略。
6.3 上下文增长
每一轮对话都把完整 messages 历史传给 API。随着工具调用轮次增加,上下文持续膨胀。
本版本通过 50000 字符的输出截断缓解了单次污染,但没有解决历史累积问题。
6.4 当前版本的局限
- 工具生态匮乏:单纯依赖 Bash 跨行修改代码极易出错,缺乏专用的扩展工具。
- 任务规划缺失:遇到复杂任务容易陷入“走一步看一步”的死循环,缺乏先验的步骤拆解。
- 上下文无限膨胀:历史记录不经处理直接追加,极易耗尽 Token 导致程序崩溃。
- 状态无法持久化:程序没有记忆,一旦重启就会丢失所有上下文与任务进度。
7. 小结
- Agent 的本质是循环
- 终止条件来自模型的
StopReason信号 - 一个 Bash 工具足以让 Agent 完成基础编程任务
- 公式:Agent = 系统提示词 + 工具集 + 循环
- 工程细节思考同样重要:危险命令拦截、输出截断、错误透传,在实际落地时,这些细节决定了 Agent 的可靠性。
理解这一最小闭环后,再看复杂的 Coding Agent,本质上就是在工具、记忆、规划和安全边界上继续扩展。