ByteNoteByteNote
从零实现迷你 Claude Code:Agent 循环怎么写
字

字节笔记本

2026年10月6日 · 约 11 分钟读完

从零实现迷你 Claude Code:Agent 循环怎么写

API中转
¥120

本文是"从零实现迷你 Claude Code"系列的第二篇。第一篇搭好了 REPL 交互外壳,这一篇动手写 Agent 的"大脑",也就是驱动模型与工具来回对话的核心循环,对应代码 agent.go 与 message.go。

这一篇你得到什么

读完你能:

  • 理解 Agent 循环的四个动作
  • 用 Go 实现完整的 tool_calling 循环
  • 知道为什么模型要"看到自己说过什么"
  • 处理"工具不存在"的边缘情况

回顾:Agent 循环是什么

上一篇的 REPL 只会回显,真正的 Agent 是一个循环:

Agent 核心循环:调模型、判断、执行工具、回填,直到给出纯文字

text
循环 {
    1. 调模型(带历史 + 工具定义)
    2. 模型返回:要么文字(最终答案),要么工具调用
    3. 工具调用 → 执行 → 结果加入历史 → 回到 1
    4. 文字 → 返回给用户
}

就这么简单。一个最小可用的 Agent 循环,三十来行代码就能写完,骨架就是它。我们这个版本要复杂一点,因为要处理流式输出、多工具和错误兜底,但核心骨架完全一样。

第一步:消息类型

循环要维护"对话历史",历史本质上是消息列表。先定义消息类型(message.go):

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 里(流式输出留到后面细讲),最简的阻塞调用长这样:

go
func (c *ModelClient) Chat(messages []Message, tools []ToolDefinition) (Message, error) {
	// POST /v1/chat/completions
	// 返回 assistant 消息(可能含 tool_calls)
}

发请求时带三样东西:模型名、历史消息、工具定义。模型看到工具定义,才知道自己有哪些工具可用。

第三步:核心循环

agent.go 的 Run 方法是整个程序的心脏:

go
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 消息必须加入历史

go
history = append(history, assistantMsg)  // 别忘了这步!

为什么重要?因为下一轮调模型时,模型要看到"我上一轮说了什么、调了什么工具"。漏了这一步,模型会失忆,不知道自己刚调过工具,很可能重复调用同一个工具。

OpenAI 的 API 还有一条硬规定:assistant 消息带 tool_calls 时,必须紧接着回喂对应的 tool 消息,并用 tool_call_id 一一对应。漏掉任何一个,API 直接报错。

设计点 2:MaxTurns 防死循环

go
for turn := 1; turn <= a.MaxTurns; turn++ {  // MaxTurns = 15

没有轮数上限,模型可能无限循环地调工具。设 15 轮兜底,超过就强制返回"达到最大轮次",把控制权交还给用户。

设计点 3:工具结果用 role=tool 回喂

go
history = append(history, NewToolMsg(call.ID, result))
// NewToolMsg 创建 role=tool, ToolCallID=call.ID, Content=result

工具结果必须用 role=tool 消息,而且要带上 tool_call_id 标明对应哪个调用。这样模型才知道"这个结果是我刚才那次工具调用的返回"。

第四步:执行工具

executeTool 负责真正干活:

go
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"当例子,看循环怎么转:

四轮循环实录:读代码、改代码、跑测试、汇报结果

text
用户:"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"干活"的具体能力。

相关文章

分享: