ByteNoteByteNote
Agent 到底在循环什么:30 行代码看穿运行时
字

字节笔记本

2026年10月24日 · 约 19 分钟读完

Agent 到底在循环什么:30 行代码看穿运行时

API中转
¥120

Claude Code 文档:Agent 循环的工作方式

本文是《从 0 写 Agent》系列第 1 章。这个系列基于开源项目 agents-go(OpenAI Agents SDK 的 Go 移植版),用 Go 从零实现一个生产级 Agent 运行时;本章讲「Agent 到底在循环什么」。

如果只能记住这本系列的一句话,记住这句:

Agent 就是一个 while 循环,循环里调大模型、调工具、把工具结果塞回大模型,直到大模型不再要调工具为止。

所有的复杂,session、handoff、guardrail、tracing、HITL,都是给这个循环加的"配件"。理解了这个循环,你就理解了 80% 的 Agent 运行时。本章用 30 行代码写一个最简版本。


引子:普通对话 vs Agent,差在哪

你大概率写过这种代码,调一次大模型,拿一个回答:

go
// 普通对话:问一次,答一次
resp, _ := client.ChatCompletion(ctx, "北京天气怎么样?")
fmt.Println(resp)  // "我无法访问实时数据..."

这是"普通对话"。它的特点是单轮、无副作用、模型只输出文字。

但 Agent 不一样。你想让 Agent 真的告诉你北京天气,它得,

  1. 调用 get_weather 工具,传入城市"北京"
  2. 工具返回"晴,26°C"
  3. 把这个结果塞回大模型,让它组织成自然语言
  4. 输出"北京今天晴,26 度"

注意第 3 步,模型在第一轮没回答你的问题,它告诉你"我要调用工具"。第二轮,你把工具结果喂给它,它才真正回答。 这就是 Agent 和普通对话的本质区别。

Agent 不是更聪明的对话,Agent 是会"自己决定下一步做什么"的循环。


Agent SDK:把 Claude Code 当库用

原理:Agent 循环的四个动作

把上面的过程抽象出来,Agent 的核心循环只有四个动作,不断重复:

text
loop {
    1. 调用大模型(带当前对话历史)
    2. 大模型返回:要么是最终回答,要么是"我要调工具"
    3. 如果是最终回答 → 跳出循环,返回给用户
    4. 如果是工具调用 → 执行工具,把结果加入历史,回到 1
}

就这么简单。这个循环就是所有 Agent 框架(LangChain、CrewAI、Claude Code、openai-agents)的心脏。 不管外面套了多少层抽象,剥到底都是这个循环。

一个最简实现(30 行 Go)

让我们用 Go 写一个能跑的最简 Agent。不依赖任何框架,只调 OpenAI 的 API:

go
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:

go
// 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 方法。骨架长这样(我去掉了所有配件,只留核心):

go
// 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 行版本的结构一模一样,预算检查 → 调模型 → 执行工具 → 决定下一步。差别只在四个地方:

  1. 预算不只是轮次,还有取消(ctx.Err())、token、时间
  2. "下一步"有四种,我们的版本只有"继续/返回",agents-go 多了 handoff(切 Agent)和 interruption(暂停等审批)
  3. 工具执行被抽出来,executeToolsAndSideEffects 单独一个函数,因为它要处理并发、guardrail、审批
  4. 状态管理,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 也有这个限制:

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)一起发过去。模型根据这些定义决定要不要调、调哪个、传什么参数。

go
// 调用大模型时附上工具定义
toolDefs := []openai.Tool{{
    Type: "function",
    Function: openai.FunctionDef{
        Name: "get_weather",
        Description: "查询某个城市的天气",
        Parameters: jsonSchemaOfWeatherArgs,  // {"type":"object","properties":{"city":...}}
    },
}}
resp, _ := client.CreateChatCompletion(ctx, messages, toolDefs)

模型看到这些定义后,如果决定调工具,会在响应里返回:

json
{
  "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),但骨架完全一样,

go
// 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 的心脏

读完这一章,你应该能用自己的话回答这几个问题:

  1. Agent 和普通对话的区别?,Agent 是循环,会自己决定调工具;普通对话是单轮,模型只输出文字
  2. Agent 循环的四个动作?,调模型、判断是否调工具、执行工具、把结果塞回历史
  3. 为什么是循环不是递归?,循环可暂停可恢复、内存有界、容易加 hook
  4. 为什么有最大轮次?,防死循环的安全阀,烧钱防火墙

这四个问题,就是 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 移植版)。全系列目录见总览。

相关文章

分享: