
字节笔记本
2026年10月24日 · 约 19 分钟读完
Agent 到底在循环什么:30 行代码看穿运行时

本文是《从 0 写 Agent》系列第 1 章。这个系列基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版),用 Go 从零实现一个生产级 Agent 运行时;本章讲「Agent 到底在循环什么」。
如果只能记住这本系列的一句话,记住这句:
Agent 就是一个 while 循环,循环里调大模型、调工具、把工具结果塞回大模型,直到大模型不再要调工具为止。
所有的复杂,session、handoff、guardrail、tracing、HITL,都是给这个循环加的"配件"。理解了这个循环,你就理解了 80% 的 Agent 运行时。本章用 30 行代码写一个最简版本。
引子:普通对话 vs Agent,差在哪
你大概率写过这种代码,调一次大模型,拿一个回答:
// 普通对话:问一次,答一次
resp, _ := client.ChatCompletion(ctx, "北京天气怎么样?")
fmt.Println(resp) // "我无法访问实时数据..."这是"普通对话"。它的特点是单轮、无副作用、模型只输出文字。
但 Agent 不一样。你想让 Agent 真的告诉你北京天气,它得,
- 调用
get_weather工具,传入城市"北京" - 工具返回"晴,26°C"
- 把这个结果塞回大模型,让它组织成自然语言
- 输出"北京今天晴,26 度"
注意第 3 步,模型在第一轮没回答你的问题,它告诉你"我要调用工具"。第二轮,你把工具结果喂给它,它才真正回答。 这就是 Agent 和普通对话的本质区别。
Agent 不是更聪明的对话,Agent 是会"自己决定下一步做什么"的循环。

原理:Agent 循环的四个动作
把上面的过程抽象出来,Agent 的核心循环只有四个动作,不断重复:
loop {
1. 调用大模型(带当前对话历史)
2. 大模型返回:要么是最终回答,要么是"我要调工具"
3. 如果是最终回答 → 跳出循环,返回给用户
4. 如果是工具调用 → 执行工具,把结果加入历史,回到 1
}就这么简单。这个循环就是所有 Agent 框架(LangChain、CrewAI、Claude Code、openai-agents)的心脏。 不管外面套了多少层抽象,剥到底都是这个循环。
一个最简实现(30 行 Go)
让我们用 Go 写一个能跑的最简 Agent。不依赖任何框架,只调 OpenAI 的 API:
func SimpleAgent(client *openai.Client, input string, tools map[string]func(string) string) string {
messages := []openai.Message{
{Role: "system", Content: "你是一个助手。需要时调用工具。"},
{Role: "user", Content: input},
}
for turn := 0; turn < 10; turn++ { // 最多 10 轮,防死循环
// 1. 调用大模型
resp, _ := client.CreateChatCompletion(ctx, messages, toolDefs)
msg := resp.Choices[0].Message
// 2. 模型没有要调工具 → 最终回答
if len(msg.ToolCalls) == 0 {
return msg.Content
}
// 3. 模型要调工具 → 执行每个工具调用
messages = append(messages, msg) // 把模型的工具调用意图加入历史
for _, call := range msg.ToolCalls {
result := tools[call.Function.Name](call.Function.Arguments)
messages = append(messages, openai.Message{
Role: "tool",
Content: result,
ToolCallID: call.ID,
})
}
// 4. 回到循环顶部,带着工具结果再调一次模型
}
return "(超过最大轮次)"
}这就是一个 Agent。 30 行,能跑,会调工具。给它一个 get_weather 工具,问它天气,它会自己决定调工具、拿到结果、组织回答。
源码对照:agents-go 的 runner.loop
agents-go 的核心循环在 agents/run.go 的 runner.loop 方法里,比我们的 30 行复杂得多,但骨架完全一样。让我把骨架部分抽出来对照:
循环入口
agents/run.go:109 的 Run 函数,对应我们的 SimpleAgent 入口。它做的事是"装配",把外部参数折叠成 runner 的初始状态,最后调 r.loop:
// agents/run.go:109
func Run(ctx context.Context, agent *Agent, input any, opts RunOptions) (*RunResult, error) {
// ... 装配 runner 状态(trace、session、预算)...
return r.loop(ctx, agent, modelInput)
}循环骨架
真正的循环在 run.go:198 的 loop 方法。骨架长这样(我去掉了所有配件,只留核心):
// agents/run.go:236(简化)
for turn := startTurn; ; turn++ {
// ① 预算与取消检查
if turn > maxTurns { return MaxTurnsError }
if ctx.Err() != nil { return ctx.Err() }
// ② 解析当前 Agent 的"配方"(模型、工具、指令)
model := resolveModel(agent, opts)
tools := enabledTools(agent)
// ③ 调用大模型
resp, err := model.GetResponse(ctx, ModelRequest{...})
// ④ 解析响应 + 执行工具 + 决定下一步
processed := processModelResponse(resp, ...)
step := executeToolsAndSideEffects(ctx, processed, ...)
// ⑤ 根据下一步动作分支
switch step.NextStep {
case stepFinalOutput: // 模型给最终答案了 → 返回
return result
case stepRunAgain: // 还有工具要调 → 继续循环
continue
case stepHandoff: // 切换到另一个 Agent → 换 agent 继续
agent = step.NewAgent
case stepInterruption: // 工具调用需要人工审批 → 暂停
return interruptedResult
}
}和我们 30 行版本的结构一模一样,预算检查 → 调模型 → 执行工具 → 决定下一步。差别只在四个地方:
- 预算不只是轮次,还有取消(
ctx.Err())、token、时间 - "下一步"有四种,我们的版本只有"继续/返回",agents-go 多了 handoff(切 Agent)和 interruption(暂停等审批)
- 工具执行被抽出来,
executeToolsAndSideEffects单独一个函数,因为它要处理并发、guardrail、审批 - 状态管理,
runner持有大量运行时状态(trace、session 累积项、工具使用记录)
但这些都是"配件"。心脏还是那个 for turn := 0; ; turn++ 循环。
设计权衡:为什么是"循环"而不是"递归"
一个自然的问题:为什么 Agent 用循环实现,而不是递归(每次调工具后递归调用自己)?
两者都能工作,但循环有几个关键优势:
1. 可暂停。 循环可以在任何一轮 turn 结束时暂停(比如等人工审批),把状态存起来,下次从同一轮继续。递归的调用栈很难序列化和恢复,栈深度、闭包变量、defer 都在内存里。这就是为什么 HITL(Human-in-the-Loop)审批能用 RunState 序列化跨进程恢复,因为循环状态可以拍平成数据,递归状态不能。
2. 有界的内存。 循环只维护"当前历史 + 当前轮次",内存恒定。递归的调用栈会随深度增长。
3. 显式的终止条件。 循环的退出条件(turn > maxTurns、stepFinalOutput)写在循环头里,一目了然。递归的终止散落在多个 base case 里。
4. 容易加 hook。 循环每一轮 turn 开头/结尾可以统一插钩子(lifecycle hook),递归要在每层手动加。
agents-go 选择了循环,这也是 Python SDK 的选择。所有主流 Agent 框架都是循环,不是递归。 这不是巧合,是循环天然适合 Agent 这种"可能要暂停、可能要恢复、可能要监控"的执行模型。
设计权衡:为什么有"最大轮次"
你大概注意到了,我们的 30 行版本有 for turn := 0; turn < 10,最多 10 轮。这不是随便写的,是防死循环的安全网。
想象这个场景:模型一直在调工具,但工具结果总是让它不满意,于是它不停地调、不停地调……没有轮次上限,你的 Agent 会烧光 token、烧光钱、永远不返回。
agents-go 也有这个限制:
// agents/run.go:10
const DefaultMaxTurns = 10
// agents/run.go:110
maxTurns := DefaultMaxTurns
if opts.MaxTurns > 0 {
maxTurns = opts.MaxTurns
}
// agents/run.go:237
if turn > maxTurns {
return nil, &MaxTurnsError{...}
}MaxTurns 是 Agent 的"安全阀"。 默认 10 轮,你可以调高(复杂任务),但不能没有。这是工程纪律,任何可能无限循环的系统,都必须有显式的终止条件。
工具是怎么"暴露"给模型的
我们的 30 行版本里,tools 是个 map[string]func(string) string。但模型怎么知道有哪些工具可用?
答案是,在调用大模型时,把工具的定义(名字、描述、参数 schema)一起发过去。模型根据这些定义决定要不要调、调哪个、传什么参数。
// 调用大模型时附上工具定义
toolDefs := []openai.Tool{{
Type: "function",
Function: openai.FunctionDef{
Name: "get_weather",
Description: "查询某个城市的天气",
Parameters: jsonSchemaOfWeatherArgs, // {"type":"object","properties":{"city":...}}
},
}}
resp, _ := client.CreateChatCompletion(ctx, messages, toolDefs)模型看到这些定义后,如果决定调工具,会在响应里返回:
{
"tool_calls": [{
"id": "call_abc123",
"function": {"name": "get_weather", "arguments": "{\"city\":\"北京\"}"}
}]
}这就是 function calling。 它不是什么神秘技术,就是"调用模型时附上工具定义,模型在响应里表达'我要调哪个工具'"。
第 3 章会详细讲怎么把一个 Go 函数变成工具定义。这里你只需要知道,工具的存在,让模型从"只会说"变成"会调东西"。
实战对照:mini-claude-code 的循环
这一章讲的 30 行循环,真实落地在 mini-claude-code(/Volumes/EXTEND/mini-claude-code/agent.go 的 Run 方法)。它比 30 行复杂(要处理流式、工具错误、MaxTurns),但骨架完全一样,
// mini-claude-code/agent.go 的 Run 方法骨架
for turn := 1; turn <= a.MaxTurns; turn++ { // ① 预算检查(MaxTurns=15)
assistantMsg, _, err := a.Client.ChatStream(...) // ② 调模型
history = append(history, assistantMsg) // ③ 记住自己说过什么
if len(assistantMsg.ToolCalls) == 0 {
return assistantMsg.Content // ④ 最终答案,返回
}
for _, call := range assistantMsg.ToolCalls {
result := a.executeTool(call) // ⑤ 执行工具
history = append(history, NewToolMsg(call.ID, result)) // ⑥ 结果回喂
}
// 回到 ②,带着工具结果再调模型
}和我们 30 行版本的差异只有三点:流式调用(ChatStream 代替 GetResponse)、MaxTurns 兜底、错误回喂让模型自愈。心脏没变。
实测:用 glm-5.2 跑"修计算器 bug",Agent 6 轮循环(ls→read→edit→run→验证→报告),完整跑通。代码只有 1200 行,无 SDK 依赖。
小结:你已经理解了 Agent 的心脏
读完这一章,你应该能用自己的话回答这几个问题:
- Agent 和普通对话的区别?,Agent 是循环,会自己决定调工具;普通对话是单轮,模型只输出文字
- Agent 循环的四个动作?,调模型、判断是否调工具、执行工具、把结果塞回历史
- 为什么是循环不是递归?,循环可暂停可恢复、内存有界、容易加 hook
- 为什么有最大轮次?,防死循环的安全阀,烧钱防火墙
这四个问题,就是 Agent 运行时的 80%。 剩下的 20% 是配件,让循环更安全(guardrail)、更有记忆(session)、更可控(HITL)、更可观察(tracing)、更协作(handoff)。这些是后面 39 章的内容。
但记住,所有的复杂都是给这个 30 行循环加的配件。心脏从未变过。
下一篇
第 2 章,我们拆开循环里的第一个动作,调用大模型。agents-go 用一个只有两个方法的 Model 接口,屏蔽了所有大模型的差异(OpenAI、Anthropic、本地模型、甚至 ChatGPT 订阅)。我们会看这个接口为什么这么设计,以及它的阻塞和流式两种调用方式有什么本质区别。
本文是《从 0 写 Agent》系列第 1 章,案例代码基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版)。全系列目录见总览。
