Files
CamTalk/docs/Eino/quick_start/chapter_02_chatmodelagent_runner_agentevent.md

17 KiB
Raw Blame History

tags, create time, title, weight
tags create time title weight
eino
ai-development
go
quickstart
agent
adk
2026-04-29 15:00 第二章ChatModelAgent、Runner、AgentEventConsole 多轮) 2

概述

在第一章掌握了 ChatModel 组件的基础用法后,本章引入 Eino ADK 中的执行抽象——Agent + Runner。通过创建一个 Console 程序实现多轮对话,你将理解 Agent 接口的设计意图、事件驱动的执行模型,以及 AsyncIterator 如何支持流式消费。


代码位置

前置条件

与第一章一致:需要配置一个可用的 ChatModelOpenAI 或 Ark

运行

examples/quickstart/chatwitheino 目录下执行:

go run ./cmd/ch02

看到提示后输入问题(空行退出):

you> 你好,解释一下 Eino 里的 Agent 是什么?
...
you> 再用一句话总结一下
...

关键概念

从 Component 到 Agent

第一章我们学习了 Component(组件),它是 Eino 中可替换、可组合的能力单元:

Component 职责 示例
ChatModel 调用大语言模型 OpenAI、Ark、Claude
Tool 执行特定任务 文件读取、代码搜索
Retriever 检索信息 向量检索、关键词检索
Loader 加载数据 文档解析器

[!question] 思考一下 假设你现在有一个 ChatModel 和一个 Tool,你能独立完成一个多轮对话的 AI 助手吗?如果能,你觉得会遇到哪些挑战?

Component 和 Agent 的关系:

  • Component 是积木——单个 Component 只是能力单元,需要被组织、编排、执行
  • Agent 是整栋建筑——它封装了完整的业务逻辑,可以直接运行
  • Agent 内部使用 Component——最核心的是 ChatModel(对话能力)和 Tool(执行能力)

为什么需要 Agent

如果只有 Component你需要自己管理

  • 对话历史的多轮累积
  • 调用流程编排(何时调模型、何时调工具)
  • 流式输出与中断处理
  • 错误恢复和状态管理
  • ...

Agent 提供了什么?

[!tip] Agent 的核心价值 Agent = 完整运行时 + 标准事件流 + 可扩展框架。你只需要创建 Agent然后交给 Runner 执行,不需要关心内部细节。

  • 完整的运行时框架:通过 Runner 统一管理执行过程
  • 标准的事件流输出Run() -> AsyncIterator[*AgentEvent],支持流式、中断、恢复
  • 可扩展能力:可以添加 tools、middleware、interrupt 等
  • 开箱即用:创建 Agent 后直接运行,无需关心内部细节

本章示例:

ChatModelAgent 是最简单的 Agent它内部只使用了 ChatModel,但已经具备了 Agent 的完整能力框架。后续章节会逐步展示如何添加 Tool、middleware、interrupt 等能力。

Agent 接口

Agent 是 ADK 中的核心接口,定义了智能体的基本行为。所有类型的 AgentChatModelAgent、WorkflowAgent、SupervisorAgent 等)都实现这个统一接口:

type Agent interface {
    Name(ctx context.Context) string
    Description(ctx context.Context) string
    
    // Run 执行 Agent返回事件流
    Run(ctx context.Context, input *AgentInput, options ...AgentRunOption) *AsyncIterator[*AgentEvent]
}

[!tip] 设计精解 Run() 的返回值是 *AsyncIterator[*AgentEvent]——这是一个懒加载的流式迭代器。调用 Run() 时不会立即执行,只有当你开始消费事件(调用 events.Next()Agent 才开始运行。这让你可以在启动前先配置中间件或注入依赖。

[!question] 接口签名疑问 为什么 Name()Description() 也要传 ctx -> 点击 chapter_02_chatmodelagent_runner_agentevent/why_ctx_in_agent_interface 理解接口签名设计背后的哲学。

接口职责拆解:

方法/字段 职责 类比
Name() 唯一标识 Agent 函数名
Description() 描述 Agent 功能 函数文档
Run() 执行核心逻辑 函数调用

设计理念:

flowchart LR
    classDef noteStyle fill:#fff3e0,stroke:#ffb74d,stroke-width:2px
    A["Agent 接口"] --> B["ChatModelAgent"]
    A --> C["WorkflowAgent"]
    A --> D["SupervisorAgent"]
    A --> E["..."]
    B --> F["统一 Runner 执行"]
    C --> F
    D --> F
    F -.-> G["统一抽象<br/>运行时多态"]
    class G noteStyle
  1. 统一抽象:所有 Agent 类型都实现同一个接口Runner 无需关心 Agent 内部实现
  2. 事件驱动:通过事件流输出,支持流式响应、中断恢复、状态转移
  3. 开闭原则:新增 Agent 类型时Runner 和消费者代码无需修改

ChatModelAgent

ChatModelAgent 是 Agent 接口的一个实现,基于 ChatModel 构建:

// 核心参数说明:
// - Name / Description: Agent 的身份标识
// - Instruction: 系统指令,定义 Agent 的行为风格和目标
// - Model: 底层的 ChatModel 组件,负责实际的模型调用
agent, err := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
    Name:        "Ch02ChatModelAgent",
    Description: "A minimal ChatModelAgent with in-memory multi-turn history.", // 记忆体多轮对话的最小 Agent
    Instruction: instruction,
    Model:       cm,
})

ChatModel vs ChatModelAgent本质区别

[!question] 关键辨析 ChatModel 和 ChatModelAgent 看起来都在"调用模型",它们的根本区别在哪里?为什么不能直接用 ChatModel 完成所有事情?

维度ChatModelChatModelAgent
定位Component组件Agent智能体
接口
Generate() / Stream()
Run() -> AsyncIterator[*AgentEvent]
输出直接返回消息内容返回事件流(含消息、控制动作等)
能力单纯的模型调用可扩展 tools、middleware、interrupt 等
适用场景简单的对话场景复杂的智能体应用

为什么需要 ChatModelAgent

  1. 统一抽象ChatModel 只是 Component 的一种,而 Agent 是更高层的抽象,可以组合多种 Component
  2. 事件驱动Agent 输出事件流,支持流式响应、中断恢复、状态转移
  3. 可扩展性ChatModelAgent 可以添加 tools、middleware、interrupt 等能力
  4. 编排友好Agent 可以被 Runner 统一管理,支持 checkpoint、恢复等运行时能力

[!tip] 类比理解

ChatModel ChatModelAgent 现实类比
数据库驱动 业务逻辑层 发动机 vs 整车
单个乐器 交响乐团指挥 砖块 vs 建筑
API 端点 微服务 积木 vs 乐高模型

简单来说:

  • ChatModel = "负责与大语言模型通信的组件屏蔽不同模型提供商的差异OpenAI、Ark、Claude 等)"
  • ChatModelAgent = "基于模型构建的智能体,可以调用模型,但还能做更多事"

特点:

  • 封装了 ChatModel 的调用逻辑
  • 提供统一的 Run() -> AgentEvent 输出形态
  • 后续可以添加 tools、middleware 等能力

Runner

Runner 是执行 Agent 的入口点,负责管理 Agent 的生命周期:

type Runner struct {
    a               Agent       // 要执行的 Agent
    enableStreaming bool        // 是否启用流式输出
    store           CheckPointStore  // 用于中断恢复的状态存储(后续章节)
}

[!question] 为什么需要 Runner Agent 已经有了 Run() 方法,为什么还要多一层 Runner直接调用不就好了吗

虽然 Agent 提供了 Run() 方法,但直接调用会缺少很多运行时能力:

  1. 生命周期管理Runner 统一管理 Agent 的启动、恢复、中断等状态
  2. Checkpoint 支持:配合 CheckPointStore 实现中断恢复(第七章详解)
  3. 统一入口:提供 Run()Query() 等便捷方法
  4. 事件流封装:将 Agent 的事件流转换为可消费的 AsyncIterator[*AgentEvent]

使用方式:

runner := adk.NewRunner(ctx, adk.RunnerConfig{
    Agent:           agent,
    EnableStreaming: true,  // 流式模式:逐 token 消费;设为 false 则等待全部完成
})

// 方式 1传入完整消息历史支持多轮对话
events := runner.Run(ctx, history)

// 方式 2便捷方法传入单个查询字符串
events := runner.Query(ctx, "你好")

[!tip] EnableStreaming 的影响

模式 表现 适用场景
true Runner 逐 token 转发事件,用户可实时看到回复 终端 Console、Chat UI
false Runner 等待 Agent 全部执行完毕再返回结果 API 后端、批处理任务

Runner 的执行流程:

flowchart TD
    A["runner.Run() / runner.Query()"] --> B["创建 AsyncIterator"]
    B --> C["开始消费事件"]
    C --> D{"下一个事件"}
    D -->|Err| E["处理错误并退出"]
    D -->|Output| F["展示给终端/客户端"]
    F --> D
    D -->|Action| G["控制动作(中断/转移/退出)"]
    G --> D
    D -->|结束| H["迭代器关闭,消费完成"]

AgentEvent

AgentEvent 是 Runner 返回的事件单元,代表执行过程中的一个离散步骤

type AgentEvent struct {
    AgentName string           // 当前执行的是哪个 Agent
    RunPath   []RunStep        // 当前执行路径(支持嵌套 Agent

    Output *AgentOutput   // 输出内容
    Action *AgentAction   // 控制动作
    Err    error          // 执行错误
}

[!note] 事件驱动设计 与传统函数调用不同Agent 的执行不是一次性的 return result,而是一系列事件的有序播放。这让你的应用可以实时感知每一个执行步骤——就像看直播而不是看录播。

三大核心字段:

字段 含义 本章用途 后续章节
event.Err 执行过程中发生的错误 错误检测与退出 错误处理策略
event.Output Agent 的输出结果 展示用户回复 流式消费、中间结果
event.Action 控制动作(中断/转移/退出等) —— 第七章Interrupt & Resume

AsyncIterator事件流的消费方式

Runner.Run() 返回的是 *AsyncIterator[*AgentEvent],这是一个非阻塞的流式迭代器。

[!question] 为什么用 AsyncIterator 为什么不直接返回 []*AgentEvent 或者单个结果?

因为 Agent 的执行是流式的:模型逐 token 生成回复Tool 调用穿插其中。如果等全部完成再返回,用户需要等待更长时间。AsyncIterator 让你可以实时消费每一个事件。

消费方式:

// events 是 *AsyncIterator[*AgentEvent],由 runner.Run() 返回
events := runner.Run(ctx, history)

for {
    event, ok := events.Next()  // 获取下一个事件,阻塞直到有事件或结束
    if !ok {
        break  // 迭代器关闭,全部事件已消费
    }
    
    // 三种处理方式互斥,根据具体场景判断
    if event.Err != nil {
        // 1. 错误分支:执行出错,记录日志并决定是否继续
        log.Printf("agent error: %v", event.Err)
        break
    }
    
    if event.Output != nil && event.Output.MessageOutput != nil {
        // 2. 输出分支:收到消息内容(可能是流式分片)
        msg := event.Output.MessageOutput.Message
        fmt.Print(msg.Content)
    }
    
    // 3. Action 分支当前章用不到后续章节Interrupt/Resume会深入
    // if event.Action != nil { ... }
}

[!warning] 重要注意事项

  • 每次 runner.Run() 创建新的迭代器,消费一次后不可重复使用
  • 不要忽略 event.Err——Agent 内部可能静默失败(如工具执行超时)
  • 注意 goroutine 安全——多个消费者同时读取同一个 AsyncIterator 是不安全的

[!question] 深入理解事件流消费模式? 通过 Claude Code Agent 事件流消费的类比加深理解。 -> 参考 chapter_02_chatmodelagent_runner_agentevent/async_iterator_consumption

多轮对话的实现

本章实现的是简单的多轮对话:用户输入 → 模型回复 → 用户继续输入 → ...

核心思想:

没有 tools 时,ChatModelAgent 在一次 Run() 里只会完成一轮模型调用。多轮对话是通过调用侧维护 history 实现的——每次调用都把完整的对话历史传进去,让模型知道之前聊了什么。

flowchart TD
    S["初始化 history = []"] --> L["进入循环"]
    L --> U["用户输入 UserMessage"]
    U --> H1["追加到 history"]
    H1 --> R["runner.Run(ctx, history)"]
    R --> E["消费事件流"]
    E --> C{"有 Output?"}
    C -->|是| A1["收集 assistant 文本"]
    A1 --> H2["追加 AssistantMessage 到 history"]
    H2 --> L
    C -->|否/结束| OUT["退出循环"]
    
    style S fill:#e1f5fe
    style OUT fill:#ffebee

逐步拆解:

  1. history []*schema.Message 保存累计对话——所有已发生过的消息都存这里
  2. 每次用户输入:把 UserMessage 追加到 history
  3. 调用 runner.Run(ctx, history):得到完整事件流,消费得到 assistant 回复
  4. 把本轮 assistant 文本追加回 history:进入下一轮时,模型能看到全部对话历史

关键代码片段(注意:这是简化后的代码片段,不能直接运行,完整代码请参考 cmd/ch02/main.go

// history 维护完整的对话历史,容量预设 16 条消息
history := make([]*schema.Message, 0, 16)

for {
    // 1. 读取用户输入,空行表示退出
    line := readUserInput()
    if line == "" {
        break
    }
    
    // 2. 将用户消息追加到 history
    //    这样模型在下一轮能"记住"之前的对话
    history = append(history, schema.UserMessage(line))
    
    // 3. 调用 Runner 执行 Agent
    //    返回的事件流包含所有输出步骤(消息、工具调用等)
    events := runner.Run(ctx, history)
    
    // 4. 消费事件流,收集 assistant 的回复内容
    content := collectAssistantFromEvents(events)
    fmt.Println("[assistant]", content)
    
    // 5. 将 assistant 回复也追加到 history
    //    nil 表示本轮没有工具调用(后续章节会用到)
    history = append(history, schema.AssistantMessage(content, nil))
}

[!note] 关于 history 的内存管理

当前实现将所有消息保留在内存中。在实际应用中,你可能需要:

  • 设置最大消息数量限制(如上面的 16
  • 使用摘要压缩Summarization Middleware第五章介绍
  • 使用外部存储Memory 组件,第三章介绍)

本章小结

核心概念 说明 关键要点
Agent 接口 定义智能体的基本行为,Run() -> AsyncIterator[*AgentEvent] 统一抽象,所有 Agent 类型共享同一接口
ChatModelAgent 基于 ChatModel 实现的 Agent 最简 Agent是后续扩展的基础
Runner Agent 的执行入口 管理生命周期、Checkpoint、事件流封装
AgentEvent 事件驱动的输出单元 包含 Output消息、Action控制、Err错误
AsyncIterator 流式迭代器,逐事件消费 实时响应,不阻塞等待全部完成
多轮对话 调用侧维护 history 实现 每次 Run() 传完整历史,每轮追加新消息

[!success] 学习成果 完成本章后,你应该能够:

  • 理解 Component 和 Agent 的本质区别
  • 使用 adk.NewChatModelAgent 创建自己的 Agent
  • 通过 Runner 执行 Agent 并消费事件流
  • 实现基于 history 的多轮对话

[!tip] 动手练习 试着修改 Instruction 参数,给你的 Agent 设定一个角色(如"你是一个编程导师"),观察不同指令对回复的影响。这就是 Prompt Engineering 的雏形!

下一章预告

Eino/quick_start/chapter_03_memory_and_session 将引入持久化存储机制,让对话历史跨进程保留,不再因为程序重启而丢失记忆。

关联笔记