Zero 架构 · 第一课

Agent 主循环:对「回合」的一次 fold

剥掉流式、重试、权限这些外壳,一个编码 agent 做的事本质上就是一个循环。Zero 把它叫作 Run,正是这个函数让 Zero 成为一个 agent,而不是一个 聊天框。吃透它,其余 67 个包都只是配角。

对齐 mission:这一课交付你目标的前半部分 —— 把一个 prompt 从输入追踪到最终回答,并说出每一步对应的具体函数。学完 你应该能凭记忆把整个循环讲出来。
配套架构图 边读边看官方图更直观:agent.Run 循环流程图(分支与终止条件全景)、agent.Run 单轮时序(各组件调用先后)。全部图见课程目录末尾。

一句话模型

一个 agent 回合就是:问模型 → 如果它要求调用工具,就执行工具、把结果喂回去 → 重复;如果它没要求调用工具,那段文本就是答案。 这个循环是对回合(turns)的一次 fold,而工具结果就是每一轮不断 增长的累加器。

在 Zero 里这一切都在一个函数 Run 中,它的骨架是一个有上限的 for 循环: loop.go:107

func Run(ctx, prompt, provider, options) (Result, error) {
    messages := SeedMessagesWithImages(buildSystemPrompt(options), prompt, images)   // :130 初始化对话
    for turn := 0; turn < maxTurns; turn++ {                                        // :149 回合上限
        exposed, _ := partitionTools(...)                                           // :156 本回合暴露哪些工具
        messages = compactor.maybeCompact(ctx, provider, messages, exposed)         // :161 压缩以守住窗口
        stream, _ := streamWithReconnect(ctx, provider, request, ...)               // :178 问模型
        collected := CollectStreamWithOptions(ctx, stream, forwardingOpts)          // :273 抽干流
        messages = append(messages, assistantMessage(collected))                    // :348 记录回复

        if len(collected.ToolCalls) == 0 {                                          // :357
            result.FinalAnswer = collected.Text                                     // :453 ← 出口:这就是答案
            return result, nil
        }
        for _, call := range collected.ToolCalls {                                  // :478
            toolResult := executeToolCall(ctx, registry, call, mode, options)       // :482 执行工具
            messages = append(messages, toolMessage(toolResult))                    // :495 把结果喂回去
        }
        // 继续下一回合,此时上下文里已带上工具结果
    }
}

这个 2700 行文件里的其它一切 —— 重连、压缩、护栏、自我纠错、模型升级 —— 都挂在这个 骨架上。先学骨架;剩下的都是骨架上的分支。

一个回合的四个动作

1 · 先播种,再构建请求

进入循环前,Zero 先构建初始的 messages 切片:系统提示 + 用户 prompt (+ 可能的图片)。 loop.go:130 这个切片就是对话状态 —— 循环只会往里 append。每个回合把它拷进一个 CompletionRequest,连同本回合可用的工具定义一起发出去。 loop.go:162

2 · 问模型,抽干流

模型通过唯一一道窄接缝触达 —— Provider 接口,一个单一的流式方法: types.go:209

type Provider interface {
    StreamCompletion(ctx, request) (<-chan StreamEvent, error)
}

正是这一个方法,让 Zero 能支持 25+ 家 provider:循环里从不提 OpenAI 或 Anthropic —— 它只讲 StreamEventCollectStreamWithOptions 把 channel 抽干成一个 CollectedStream,内含最终的 Text 和任何 ToolCalls,同时把增量实时转发给 TUI。 loop.go:273

3 · 分叉:有没有工具调用?

这一个分支就是「继续 vs 结束」的全部判定逻辑: loop.go:357

注意这个不变量:每个已声明的工具调用都恰好得到一条工具结果消息, 即使护栏在批次中途叫停 —— Zero 会为剩余调用回填「已中止」占位结果,好让记录下来的 历史对严格的 provider 重放仍然有效。 loop.go:509

4 · 天花板

for turn := 0; turn < maxTurns 这个上限(默认 12)是安全网:自然完成会 经由「无工具调用」路径提前退出;触到天花板意味着 agent 在半途被截断,循环之后单独处理。 loop.go:112loop.go:628

要记住的心智模型 messages 是一条只追加的磁带。每个回合写入一条 assistant 记录,然后是 零条或多条 tool 记录。模型每个回合都读整条磁带。所谓「agent 性」不过是: 模型的输出能把内容加进它自己的下一次输入。

动手回忆 —— 别只是点头

先作答,再回看。费力的回忆才能让知识真正扎根。

Zero 的 Run 循环凭什么判定一次运行结束?

为什么 Zero 支持 25+ 家 provider 却不用改动循环?

一个工具执行完之后,循环拿它的结果做什么?

接下来该读的一手源码

Run 从头到尾读一遍 —— 只看骨架,略过重试/压缩的分支。打开它,循着上面的 四个动作走:

less +107 ../zero/internal/agent/loop.go
# 或在你的编辑器里:  zero/internal/agent/loop.go:107

别想一口气吃完整个文件。找到 for turn 那行、 len(collected.ToolCalls) == 0 那个分叉,以及内层的 for _, call := range collected.ToolCalls。这三个锚点就是整个循环。

我是你的老师 —— 随时问我。 现在适合追问的问题: 「带我走一遍真实 prompt 的第 2 个回合发生了什么」、「给我看一个 StreamEvent 长什么样」,或者「executeToolCall 里面到底 是什么?」(那就是第二课)。