Ch02 main.go 逐段详解

整体架构总览

先用一张图看清整个程序的结构:

1
2
3
4
5
6
7
8
9
10
11
12
graph TD
A[main 函数] --> B[解析命令行参数]
B --> C[创建 ChatModel]
C --> D[创建 ChatModelAgent]
D --> E[创建 Runner]
E --> F[进入多轮对话循环]
F --> G[读取用户输入]
G --> H[追加到 history]
H --> I[runner.Run 执行]
I --> J[printAndCollectAssistantFromEvents<br/>消费事件流并打印]
J --> K[追加 assistant 回复到 history]
K --> G

第一部分:导入包(第 18~32 行)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import (
"bufio"
"context"
"errors"
"flag"
"fmt"
"io"
"os"
"strings"

"github.com/cloudwego/eino/adk"
"github.com/cloudwego/eino/schema"

examplemodel "github.com/cloudwego/eino-examples/adk/common/model"
)
作用
bufio 提供带缓冲的 I/O,这里用 bufio.NewScanner 逐行读取用户输入
context Go 的上下文机制,用于控制超时、取消等
errors 错误处理,这里用 errors.Is(err, io.EOF) 判断流是否结束
flag 命令行参数解析(第一章已学过)
fmt 格式化输出
io 提供 io.EOF 常量,表示”流结束”
os 操作系统交互,这里用 os.Stdin(标准输入)和 os.Stdout(标准输出)
strings 字符串操作,这里用 strings.TrimSpace 去除空白、strings.Builder 拼接字符串
adk Eino 的 ADK 包,提供 Agent、Runner、AgentEvent 等核心抽象
schema Eino 的数据模型包,提供 Message、Role 等基础类型
examplemodel 示例项目的公共包,封装了 ChatModel 的创建逻辑

💡 Go 语法知识:包别名

1
examplemodel "github.com/cloudwego/eino-examples/adk/common/model"

这里 examplemodel 是给这个包起的别名。因为这个包的名字是 model,但 Eino 框架里也有一个 model 包(github.com/cloudwego/eino/components/model),为了避免冲突,给它起了个别名 examplemodel。后面用 examplemodel.NewChatModel() 来调用。


第二部分:main 函数 —— 初始化阶段(第 34~56 行)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
func main() {
var instruction string
flag.StringVar(&instruction, "instruction", "You are a helpful assistant.", "")
flag.Parse()

ctx := context.Background()
cm := examplemodel.NewChatModel()

agent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Name: "Ch02ChatModelAgent",
Description: "A minimal ChatModelAgent with in-memory multi-turn history.",
Instruction: instruction,
Model: cm,
})
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}

runner := adk.NewRunner(ctx, adk.RunnerConfig{
Agent: agent,
EnableStreaming: true,
})

逐行分析:

① 解析命令行参数

1
2
3
var instruction string
flag.StringVar(&instruction, "instruction", "You are a helpful assistant.", "")
flag.Parse()

和第一章一样,用 flag 包解析命令行参数。instruction 是系统提示词,默认值是 "You are a helpful assistant."

② 创建 ChatModel

1
cm := examplemodel.NewChatModel()

调用公共包 chat_model.go 中的 NewChatModel() 函数。这个函数的逻辑和第一章的 newChatModel 几乎一样:

  • 读取环境变量 MODEL_TYPE
  • 如果是 "ark",创建 Ark(火山引擎/豆包)的 ChatModel
  • 否则,创建 OpenAI 的 ChatModel
  • 返回类型是 model.ToolCallingChatModel(接口类型)

💡 与第一章的区别:第一章是在 main.go 里直接写的 newChatModel 函数,第二章把它提取到了公共包里,方便后续章节复用。

③ 创建 ChatModelAgent

1
2
3
4
5
6
agent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Name: "Ch02ChatModelAgent",
Description: "A minimal ChatModelAgent with in-memory multi-turn history.",
Instruction: instruction,
Model: cm,
})

这是本章的核心新概念ChatModelAgent 是对 ChatModel 的一层封装,把它从一个”组件”升级为一个”智能体”。

配置字段 类型 说明
Name string Agent 的名字,用于日志、调试、事件标识
Description string Agent 的描述,用于多 Agent 场景下的路由决策
Instruction string 系统提示词,会作为 SystemMessage 注入到对话中
Model model.ToolCallingChatModel 底层的 ChatModel 组件

💡 为什么要包一层? 文档中的类比很好:ChatModel 就像”数据库驱动”,ChatModelAgent 就像”业务逻辑层”。Agent 提供了统一的 Run() 接口、事件流输出、后续可扩展 tools/middleware 等能力。

④ 创建 Runner

1
2
3
4
runner := adk.NewRunner(ctx, adk.RunnerConfig{
Agent: agent,
EnableStreaming: true,
})

Runner 是 Agent 的”执行器”。你可以把它理解为一个”遥控器”:

  • Agent 是”电视机”(有能力,但需要被操控)
  • Runner 是”遥控器”(负责启动、管理、控制 Agent 的执行)
配置字段 说明
Agent 要执行的 Agent
EnableStreaming 设为 true 表示启用流式输出(模型边生成边返回)

第三部分:main 函数 —— 多轮对话循环(第 58~82 行)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
history := make([]*schema.Message, 0, 16)
scanner := bufio.NewScanner(os.Stdin)
for {
_, _ = fmt.Fprint(os.Stdout, "you> ")
if !scanner.Scan() {
break
}
line := strings.TrimSpace(scanner.Text())
if line == "" {
break
}
history = append(history, schema.UserMessage(line))

events := runner.Run(ctx, history)
content, err := printAndCollectAssistantFromEvents(events)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
history = append(history, schema.AssistantMessage(content, nil))
}
if err := scanner.Err(); err != nil {
_, _ = fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}

逐行分析:

① 初始化 history 和 scanner

1
2
history := make([]*schema.Message, 0, 16)
scanner := bufio.NewScanner(os.Stdin)
  • make([]*schema.Message, 0, 16):创建一个空的 Message 切片,初始长度为 0,预分配容量为 16。这个 history 就是对话历史,每一轮对话的用户消息和 AI 回复都会追加到这里。
  • bufio.NewScanner(os.Stdin):创建一个从标准输入(键盘)逐行读取的扫描器。

💡 Go 语法知识:make([]T, length, capacity)

  • length:切片当前的元素个数(这里是 0,表示空切片)
  • capacity:切片底层数组的预分配大小(这里是 16,表示预留 16 个位置,避免频繁扩容)
  • append 超过 capacity 时,Go 会自动扩容(通常翻倍)

② 对话循环

1
2
3
4
5
6
7
8
9
for {
_, _ = fmt.Fprint(os.Stdout, "you> ") // 打印提示符
if !scanner.Scan() { // 读取一行输入
break // 如果读取失败(如 Ctrl+D),退出循环
}
line := strings.TrimSpace(scanner.Text()) // 去除首尾空白
if line == "" {
break // 空行退出
}

这是一个无限循环for {}),每次循环就是一轮对话。退出条件有两个:

  1. scanner.Scan() 返回 false(用户按了 Ctrl+D,表示输入结束)
  2. 用户输入了空行

③ 追加用户消息 → 执行 Agent → 收集回复 → 追加 AI 消息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 1. 把用户输入包装成 UserMessage,追加到 history
history = append(history, schema.UserMessage(line))

// 2. 调用 Runner 执行 Agent,传入完整的对话历史
events := runner.Run(ctx, history)

// 3. 消费事件流,打印并收集 AI 的回复文本
content, err := printAndCollectAssistantFromEvents(events)
if err != nil {
_, _ = fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}

// 4. 把 AI 的回复包装成 AssistantMessage,追加到 history
history = append(history, schema.AssistantMessage(content, nil))

这就是文档中描述的多轮对话核心流程

1
2
3
4
5
6
7
第1轮:history = [User("你好")]
→ runner.Run → AI回复"你好!有什么可以帮你的?"
→ history = [User("你好"), Assistant("你好!有什么可以帮你的?")]

第2轮:history = [User("你好"), Assistant("你好!有什么可以帮你的?"), User("解释一下Go的goroutine")]
→ runner.Run → AI回复"goroutine是Go语言的轻量级线程..."
→ history = [User("你好"), Assistant("你好!..."), User("解释一下..."), Assistant("goroutine是...")]

每次调用 runner.Run(ctx, history) 时,完整的对话历史都会传给模型,这样模型就能”记住”之前的对话内容。

⚠️ 注意runner.Run() 返回的不是直接的文本,而是 *adk.AsyncIterator[*adk.AgentEvent](事件流迭代器)。需要用 printAndCollectAssistantFromEvents 函数来消费它。

④ 与第一章的对比

维度 第一章 (ch01) 第二章 (ch02)
对话轮次 单轮(命令行参数传入问题) 多轮(循环读取用户输入)
调用方式 直接调用 cm.Stream() 通过 runner.Run()
返回类型 *schema.StreamReader[*schema.Message] *adk.AsyncIterator[*adk.AgentEvent]
历史管理 不需要(单轮) 手动维护 history 切片
抽象层级 Component 层 Agent + Runner 层

第四部分:printAndCollectAssistantFromEvents 函数(第 86~135 行)

这是本章最复杂的函数,负责消费事件流、打印 AI 回复、收集回复文本

1
2
func printAndCollectAssistantFromEvents(events *adk.AsyncIterator[*adk.AgentEvent]) (string, error) {
var sb strings.Builder
  • 参数events *adk.AsyncIterator[*adk.AgentEvent] —— 事件流迭代器,由 runner.Run() 返回
  • 返回值(string, error) —— 收集到的 AI 回复文本 + 可能的错误
  • strings.Builder:高效的字符串拼接器,用于收集所有流式片段拼成完整回复

💡 Go 语法知识:strings.Builder
在 Go 中,字符串是不可变的。如果用 s += "abc" 反复拼接,每次都会创建新字符串,效率很低。strings.Builder 内部用 []byte 缓冲区,最后一次性转成字符串,效率高得多。

① 外层循环:消费事件流

1
2
3
4
5
for {
event, ok := events.Next()
if !ok {
break
}

events.Next()AsyncIterator 的核心方法:

  • 阻塞等待直到有新事件到来
  • 返回 (event, true) 表示拿到了一个事件
  • 返回 (_, false) 表示迭代器已关闭(所有事件都消费完了)

② 错误检查

1
2
3
if event.Err != nil {
return "", event.Err
}

如果事件中包含错误(比如模型 API 调用失败),直接返回错误。

③ 过滤非 Assistant 消息

1
2
3
4
5
6
7
8
if event.Output == nil || event.Output.MessageOutput == nil {
continue
}

mv := event.Output.MessageOutput
if mv.Role != schema.Assistant {
continue
}

事件流中可能包含各种类型的事件(控制动作、工具调用结果等),这里只关心 Assistant 角色的消息输出mvMessageVariant 类型(消息变体),它可能是流式的,也可能是非流式的。

④ 处理流式消息(IsStreaming == true

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if mv.IsStreaming {
mv.MessageStream.SetAutomaticClose()
for {
frame, err := mv.MessageStream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return "", err
}
if frame != nil && frame.Content != "" {
sb.WriteString(frame.Content)
_, _ = fmt.Fprint(os.Stdout, frame.Content)
}
}
_, _ = fmt.Fprintln(os.Stdout)
continue
}

EnableStreaming: true 时,模型的回复会以流式方式到达。这段代码的逻辑是:

  1. mv.MessageStream.SetAutomaticClose():设置自动关闭。当流读完(遇到 EOF)后,自动释放资源。这样就不需要手动调用 Close() 了。

  2. 内层循环:不断调用 mv.MessageStream.Recv() 读取每一个”帧”(chunk):

    • io.EOF:流结束,退出内层循环
    • 其他错误:返回错误
    • 正常帧:把内容追加到 sb(用于收集完整文本),同时打印到屏幕
  3. fmt.Fprintln(os.Stdout):流式输出完毕后打印一个换行符

💡 与第一章的对比:第一章直接调用 cm.Stream() 得到 StreamReader,然后循环 Recv()。第二章的流式处理逻辑本质上是一样的,只是被包在了 AgentEvent 里面。

⑤ 处理非流式消息(IsStreaming == false

1
2
3
4
5
6
if mv.Message != nil {
sb.WriteString(mv.Message.Content)
_, _ = fmt.Fprintln(os.Stdout, mv.Message.Content)
} else {
_, _ = fmt.Fprintln(os.Stdout)
}

如果不是流式的(比如 EnableStreaming 设为 false),模型会一次性返回完整消息。直接取 mv.Message.Content 打印即可。

⑥ 返回收集到的完整文本

1
return sb.String(), nil

strings.Builder 中收集的所有内容转成字符串返回。这个字符串会被 main 函数用来构造 AssistantMessage 追加到 history


完整执行流程图

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
sequenceDiagram
participant User as 用户 (键盘)
participant Main as main 函数
participant Runner as Runner
participant Agent as ChatModelAgent
participant Model as ChatModel (OpenAI/Ark)

Main->>Main: 创建 ChatModel, Agent, Runner
Main->>Main: history = []

loop 多轮对话
Main->>User: 打印 "you> "
User->>Main: 输入 "你好"
Main->>Main: history.append(UserMessage("你好"))
Main->>Runner: runner.Run(ctx, history)
Runner->>Agent: agent.Run(ctx, input)
Agent->>Model: cm.Stream(ctx, messages)
Model-->>Agent: StreamReader (流式)
Agent-->>Runner: AsyncIterator[AgentEvent]
Runner-->>Main: AsyncIterator[AgentEvent]

loop 消费事件流
Main->>Main: events.Next()
Note over Main: 检查 event.Output.MessageOutput
Main->>Main: mv.MessageStream.Recv()
Main->>User: 打印 frame.Content (逐块)
Main->>Main: sb.WriteString(frame.Content)
end

Main->>Main: history.append(AssistantMessage(content))
end

关键概念总结

本章引入的三层抽象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
┌─────────────────────────────────────────────┐
│ Runner(执行器) │
│ - 管理 Agent 的生命周期 │
│ - 提供 Run() / Query() 便捷方法 │
│ - 支持 Checkpoint(后续章节) │
├─────────────────────────────────────────────┤
│ ChatModelAgent(智能体) │
│ - 封装 ChatModel + Instruction │
│ - 统一输出为 AgentEvent 事件流 │
│ - 后续可扩展 Tools / Middleware │
├─────────────────────────────────────────────┤
│ ChatModel(组件) │
│ - 底层模型调用(OpenAI / Ark) │
│ - Generate() / Stream() │
└─────────────────────────────────────────────┘

与第一章的核心差异

维度 第一章 第二章
抽象层级 直接用 Component Component → Agent → Runner
对话模式 单轮(命令行参数) 多轮(交互式循环)
输出方式 StreamReader AsyncIterator[AgentEvent]
历史管理 手动维护 history 切片
可扩展性 低(需要自己编排) 高(后续可加 Tools、Middleware 等)