
字节笔记本
2026年10月6日 · 约 11 分钟读完
从零实现迷你 Claude Code:Agent 循环怎么写
本文是"从零实现迷你 Claude Code"系列的第二篇。第一篇搭好了 REPL 交互外壳,这一篇动手写 Agent 的"大脑",也就是驱动模型与工具来回对话的核心循环,对应代码 agent.go 与 message.go。
这一篇你得到什么
读完你能:
- 理解 Agent 循环的四个动作
- 用 Go 实现完整的 tool_calling 循环
- 知道为什么模型要"看到自己说过什么"
- 处理"工具不存在"的边缘情况
回顾:Agent 循环是什么
上一篇的 REPL 只会回显,真正的 Agent 是一个循环:

循环 {
1. 调模型(带历史 + 工具定义)
2. 模型返回:要么文字(最终答案),要么工具调用
3. 工具调用 → 执行 → 结果加入历史 → 回到 1
4. 文字 → 返回给用户
}就这么简单。一个最小可用的 Agent 循环,三十来行代码就能写完,骨架就是它。我们这个版本要复杂一点,因为要处理流式输出、多工具和错误兜底,但核心骨架完全一样。
第一步:消息类型
循环要维护"对话历史",历史本质上是消息列表。先定义消息类型(message.go):
type Message struct {
Role Role `json:"role"` // system / user / assistant / tool
Content string `json:"content,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"` // assistant 要调工具时
ToolCallID string `json:"tool_call_id,omitempty"` // tool 结果对应哪个调用
}四种角色各司其职:
- system:系统指令,比如"你是一个编程助手"
- user:用户说的话
- assistant:AI 的回复,可能是文字,也可能是工具调用
- tool:工具执行结果,回喂给 AI
为什么要区分四种角色?因为 OpenAI 的 Chat Completions API 要求历史消息按角色标注。模型必须知道"这句是用户说的""这句是我自己说的""这句是工具返回的",才能正确接话。
第二步:调模型
模型调用封装在 model.go 里(流式输出留到后面细讲),最简的阻塞调用长这样:
func (c *ModelClient) Chat(messages []Message, tools []ToolDefinition) (Message, error) {
// POST /v1/chat/completions
// 返回 assistant 消息(可能含 tool_calls)
}发请求时带三样东西:模型名、历史消息、工具定义。模型看到工具定义,才知道自己有哪些工具可用。
第三步:核心循环
agent.go 的 Run 方法是整个程序的心脏:
func (a *Agent) Run(history []Message, userInput string, onText func(string)) (string, int, error) {
// 1. 用户输入加入历史
history = append(history, NewUserMsg(userInput))
for turn := 1; turn <= a.MaxTurns; turn++ {
// 2. 组装消息(system + 历史)
messages := append([]Message{a.SystemMsg}, history...)
// 3. 调模型(流式)
assistantMsg, _, err := a.Client.ChatStream(messages, a.Tools.Definitions(), onText)
// ...
// 4. assistant 消息加入历史(关键!)
history = append(history, assistantMsg)
// 5. 没工具调用 → 最终答案,返回
if len(assistantMsg.ToolCalls) == 0 {
return assistantMsg.Content, turn, nil
}
// 6. 有工具调用 → 执行 + 结果回喂
for _, call := range assistantMsg.ToolCalls {
result := a.executeTool(call)
history = append(history, NewToolMsg(call.ID, result))
}
// 7. 回到循环顶部
}
return "", 0, fmt.Errorf("达到最大轮次")
}七个注释对应循环的七个动作。有三个关键设计值得单独说。
设计点 1:assistant 消息必须加入历史
history = append(history, assistantMsg) // 别忘了这步!为什么重要?因为下一轮调模型时,模型要看到"我上一轮说了什么、调了什么工具"。漏了这一步,模型会失忆,不知道自己刚调过工具,很可能重复调用同一个工具。
OpenAI 的 API 还有一条硬规定:assistant 消息带 tool_calls 时,必须紧接着回喂对应的 tool 消息,并用 tool_call_id 一一对应。漏掉任何一个,API 直接报错。
设计点 2:MaxTurns 防死循环
for turn := 1; turn <= a.MaxTurns; turn++ { // MaxTurns = 15没有轮数上限,模型可能无限循环地调工具。设 15 轮兜底,超过就强制返回"达到最大轮次",把控制权交还给用户。
设计点 3:工具结果用 role=tool 回喂
history = append(history, NewToolMsg(call.ID, result))
// NewToolMsg 创建 role=tool, ToolCallID=call.ID, Content=result工具结果必须用 role=tool 消息,而且要带上 tool_call_id 标明对应哪个调用。这样模型才知道"这个结果是我刚才那次工具调用的返回"。
第四步:执行工具
executeTool 负责真正干活:
func (a *Agent) executeTool(call ToolCall) string {
tool, ok := a.Tools.Get(call.Function.Name)
if !ok {
// 工具不存在:回喂错误信息,让模型知道
return fmt.Sprintf("工具 '%s' 不存在", call.Function.Name)
}
result, err := tool.Execute(call.Function.Arguments)
if err != nil {
return fmt.Sprintf("工具执行出错: %v", err)
}
return result
}注意:工具不存在时不报错中断,而是把错误信息回喂给模型。因为模型可能"幻觉"出不存在的工具名,比如它以为有 write_file,但我们只实现了 edit_file。回喂"工具不存在",模型下一轮就会改用真实存在的工具。
这就是工具错误的回喂策略:错误不中断循环,喂回去让模型自愈。
完整循环示例
用"修计算器 bug"当例子,看循环怎么转:

用户:"divide 算 10/2 得 8,应该 5,修一下"
[轮次 1]
调模型 → 模型说: "我看一下代码" + tool_calls:[read_file("main.go")]
执行 read_file → 返回代码内容
回喂: tool 消息(代码内容)
[轮次 2]
调模型(看到代码)→ 模型说: "找到 bug,divide 写成了减法" + tool_calls:[edit_file(...)]
执行 edit_file → "修改成功"
回喂: tool 消息("修改成功")
[轮次 3]
调模型 → 模型说: "跑测试" + tool_calls:[run_command("go run main.go")]
执行 run_command → "10/2 = 5"(修好了)
回喂: tool 消息(测试输出)
[轮次 4]
调模型 → 模型说: "已修复,10/2 现在返回 5"(纯文字,无工具调用)
→ 返回给用户四轮循环,每轮都是"调模型、判断、执行工具、回填、再调"。这就是 Agent 干活的全过程:它并不神秘,只是一个带工具的循环,外加一份被反复追加的对话历史。
这一篇总结
到这里,你有了 Agent 的"大脑",也就是核心循环:
- 四种消息角色(system / user / assistant / tool)
- 完整循环(调模型、判断、执行工具、回填)
- assistant 消息加入历史,防止失忆
- MaxTurns 防死循环
- 工具错误回喂,让模型自愈
但工具本身还没实现:executeTool 调用的 tool.Execute 里面是什么?系列下一篇会实现 read_file、edit_file、run_command 三个真实工具,并补上目录囚禁、命令超时这些安全设计,那是 Agent"干活"的具体能力。



