docs: 添加 Eino 框架参考文档

This commit is contained in:
hhs
2026-06-19 14:35:06 +08:00
parent 5d8cacf16d
commit 16302af7d2
99 changed files with 30486 additions and 0 deletions

View File

@@ -0,0 +1,98 @@
---
date: "2026-03-16"
tags: []
title: 快速开始
---
本篇文档用于作为 ChatWithEino Quickstart 的统一入口:用一条清晰的路径带你跑起来,并解释这个系列最终要交付什么(一个可扩展的端到端 Agent 应用骨架)。
## 这是什么
ChatWithEino 是一个基于 Eino 构建的学习型 Agent它能读取源码/文档/示例,并通过对话帮助开发者理解 Eino 以及用 Eino 写代码。
这个 Quickstart 系列采用"渐进式搭建"的方式:
- 前期以 Console 为载体,逐步引入 ChatModel、Agent/Runner、Memory、Tool、Middleware、Callback、Interrupt/Resume、Graph Tool、Skill
- 最终把同一个 Agent 以 Web 形态交付出来,并用 A2UI 协议把事件流渲染成可增量更新的 UI
## 最短路径:先跑起来
在仓库根目录执行:
```bash
git clone https://github.com/cloudwego/eino-examples.git
cd eino-examples/quickstart/chatwitheino
```
### 1) 最小 Console第一章
准备模型配置(以 OpenAI 为例):
```bash
export OPENAI_API_KEY="..."
export OPENAI_MODEL="gpt-4.1-mini"
```
运行:
```bash
go run ./cmd/ch01 -- "用一句话解释 Eino 的 Component 设计解决了什么问题?"
```
### 2) 最终 WebA2UI
```bash
go run .
```
启动后访问输出里的地址(默认 `http://localhost:8080`)。
### 3) (可选)开启 skills第九章能力复用
skills 用于把一组稳定的"知识/指令包"`SKILL.md` + `reference/*.md`)注入到 Agent让模型在需要时按需加载并调用。
```bash
go run ./scripts/sync_eino_ext_skills.go -src /path/to/eino-ext -dest ./skills/eino-ext -clean
EINO_EXT_SKILLS_DIR="$(pwd)/skills/eino-ext" go run .
```
说明:
- `./skills/` 目录默认被 `.gitignore` 忽略,避免把同步出来的 skills 误提交
- 如需验证 Skill 是否生效,可运行第九章示例入口代码:
- [https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/cmd/ch09/main.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/cmd/ch09/main.go)
## 学习路线(章节导航)
<table>
<tr><td>章节</td><td>主题</td><td>入口</td></tr>
<tr><td>第一章</td><td>ChatModel 与 MessageConsole</td><td>https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/docs/ch01_chatmodel_agent_console.md</td></tr>
<tr><td>第二章</td><td>Agent 与 RunnerConsole 多轮)</td><td>https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/docs/ch02_chatmodel_agent_runner_console.md</td></tr>
<tr><td>第三章</td><td>Memory 与 Session持久化对话</td><td>https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/docs/ch03_memory_session_jsonl.md</td></tr>
<tr><td>第四章</td><td>Tool 与文件系统访问</td><td>https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/docs/ch04_tool_backend_filesystem.md</td></tr>
<tr><td>第五章</td><td>Middleware中间件模式</td><td>https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/docs/ch05_middleware.md</td></tr>
<tr><td>第六章</td><td>Callback 与 Trace可观测性</td><td>https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/docs/ch06_callback.md</td></tr>
<tr><td>第七章</td><td>Interrupt/Resume中断与恢复</td><td>https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/docs/ch07_interrupt_resume.md</td></tr>
<tr><td>第八章</td><td>Graph Tool复杂工作流</td><td>https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/docs/ch08_graph_tool.md</td></tr>
<tr><td>第九章</td><td>SkillConsole</td><td>https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/docs/ch09_skill.md</td></tr>
<tr><td>最终章</td><td>A2UIWeb</td><td>https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/docs/ch10_a2ui.md</td></tr>
</table>
## 最终交付:一个可扩展的端到端 Agent 应用骨架
你可以把这个 Quickstart 的最终产物理解为一套"可插拔的应用骨架",它把 Eino 的关键能力连成闭环:
- 运行时Runner 驱动执行,支持流式输出与事件模型
- 工具层:通过 Tool 接入文件系统/检索/工作流等能力
- 中间件:用 handler/middleware 承载重试、审批、错误处理等横切能力
- 人机协作interrupt/resume + checkpoint 支持审批、补参、分支选择等交互式流程
- 确定性编排composegraph/chain/workflow把复杂业务流程组织为可维护、可复用的执行图
- UI 交付:用 A2UI 把 Agent 的事件流映射为可增量渲染的 UI 组件树SSE 推送)
其中 A2UI 的边界需要明确:它不是 Eino 框架本身的一部分,而是业务层的 UI 协议/渲染方案。本 Quickstart 用它来展示"Agent 能力如何以产品形态呈现给用户",具体实现与协议细节以最终章为准。
## 下一步探索(从 Quickstart 到真实业务)
- 想系统理解 Eino 的组件抽象与用法:从第一章的 Component 入门开始,再按章节逐步补齐 Tool/Graph/Callback/Interrupt 等能力
- 想复用更大规模的知识与指令:对接 `eino-ext` 的 skills并通过 Skill 中间件按需加载
- 想把 Agent 做成业务产品参考最终章A2UI/Web把事件流、状态与交互打通再替换为你自己的 UI 形态与协议

View File

@@ -0,0 +1,308 @@
---
tags: [eino, ai-development, go, quickstart]
create time: 2026-04-29 14:30
date: "2026-03-24"
lastmod: ""
title: 第一章ChatModel 与 MessageConsole
weight: 1
---
## 概述
本章是 Eino 快速入门系列的第一章,带你理解 Eino 的 Component 抽象设计,并通过最简代码实现一次 ChatModel 调用(支持流式输出)。你将掌握 `schema.Message` 的基本用法,为后续构建完整的 ChatWithEino Agent 打下基础。
---
## Eino 框架简介
> [!question] 思考一下
> 如果你要开发一个 AI 应用,需要支持 OpenAI、Claude、豆包等多个模型你会如何设计代码架构才能让切换模型变得简单
**Eino 是什么?**
Eino 是一个 Go 语言实现的 AI 应用开发框架Agent Development Kit旨在帮助开发者快速构建可扩展、可维护的 AI 应用。
**Eino 解决什么问题?**
1. **模型抽象**:统一不同 LLM 提供商的接口OpenAI、Ark、Claude 等),切换模型无需修改业务代码
2. **能力组合**:通过 Component 接口实现可替换、可组合的能力单元(对话、工具、检索等)
3. **编排框架**:提供 Agent、Graph、Chain 等编排抽象,支持复杂的多步骤 AI 工作流
4. **运行时支持**内置流式输出、中断与恢复、状态管理、Callback 可观测性等能力
**Eino 的主要仓库:**
- **eino**(本仓库):核心库,定义接口、编排抽象和 ADK
- **eino-ext**:扩展库,提供各类 Component 的具体实现OpenAI、Ark、Milvus 等)
- **eino-examples**:示例代码库,包含本 quickstart 系列
---
## ChatWithEino与 Eino 文档对话的智能助手
**ChatWithEino 是什么?**
ChatWithEino 是一个基于 Eino 框架构建的智能助手,能够帮助开发者学习 Eino 框架并编写 Eino 代码。它通过访问 Eino 仓库的源码、注释和示例,为用户提供最准确、最及时的技术支持。
**核心能力:**
- **对话交互**:理解用户关于 Eino 的问题,提供清晰的解答
- **代码访问**:直接读取 Eino 源码、注释和示例,基于真实实现回答问题
- **持久化会话**:支持多轮对话,记住上下文,可跨进程恢复会话
- **工具调用**:能够执行文件读取、代码搜索等操作
**技术架构:**
- **ChatModel**与大语言模型通信OpenAI、Ark、Claude 等)
- **Tool**:文件系统访问、代码搜索等能力扩展
- **Memory**:对话历史持久化存储
- **Agent**:统一的执行框架,协调各组件协同工作
## Quickstart 文档系列:从零构建 ChatWithEino
本系列文档通过循序渐进的方式,带你从最基础的 ChatModel 调用开始,逐步构建一个功能完整的 ChatWithEino Agent。
**学习路径:**
<table>
<tr><td>章节</td><td>主题</td><td>核心内容</td><td>能力提升</td></tr>
<tr><td><strong>第一章</strong></td><td>ChatModel 与 Message</td><td>理解 Component 抽象,实现单次对话</td><td>基础对话能力</td></tr>
<tr><td><strong>第二章</strong></td><td>Agent 与 Runner</td><td>引入执行抽象,实现多轮对话</td><td>会话管理能力</td></tr>
<tr><td><strong>第三章</strong></td><td>Memory 与 Session</td><td>持久化对话历史,支持会话恢复</td><td>持久化能力</td></tr>
<tr><td><strong>第四章</strong></td><td>Tool 与文件系统</td><td>添加文件访问能力,读取源码</td><td>工具调用能力</td></tr>
<tr><td><strong>第五章</strong></td><td>Middleware</td><td>中间件机制,统一处理横切关注点</td><td>扩展性增强</td></tr>
<tr><td><strong>第六章</strong></td><td>Callback</td><td>回调机制,监控 Agent 执行过程</td><td>可观测性</td></tr>
<tr><td><strong>第七章</strong></td><td>Interrupt 与 Resume</td><td>中断与恢复,支持长时间任务</td><td>可靠性增强</td></tr>
<tr><td><strong>第八章</strong></td><td>Graph 与 Tool</td><td>使用 Graph 编排复杂工作流</td><td>复杂编排能力</td></tr>
<tr><td><strong>第九章</strong></td><td>A2UI</td><td>Agent 到 UI 的集成方案</td><td>生产级应用</td></tr>
</table>
**为什么这样设计?**
每一章都在前一章的基础上增加一个核心能力,让你:
1. **理解每个组件的作用**:不是一次性展示所有功能,而是逐步引入
2. **看到架构演进过程**:从简单到复杂,理解为什么需要每个抽象
3. **掌握实际开发技能**:每章都有可运行的代码,可以动手实践
---
本章目标:理解 Eino 的 Component 抽象,用最小代码调用一次 ChatModel支持流式输出并掌握 `schema.Message` 的基本用法。
## 代码位置
- 入口代码:[cmd/ch01/main.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/cmd/ch01/main.go)
## 为什么需要 Component 接口
Eino 定义了一组 Component 接口(`ChatModel``Tool``Retriever``Loader` 等),每个接口描述一类可替换的能力:
```go
type BaseChatModel interface {
Generate(ctx context.Context, input []*schema.Message, opts ...Option) (*schema.Message, error)
Stream(ctx context.Context, input []*schema.Message, opts ...Option) (
*schema.StreamReader[*schema.Message], error)
}
```
**接口带来的好处:**
1. **实现可替换**`eino-ext` 提供了 OpenAI、Ark、Claude、Ollama 等多种实现,业务代码只依赖接口,切换模型只需改构造逻辑。
2. **编排可组合**Agent、Graph、Chain 等编排层只依赖 Component 接口,不关心具体实现。你可以把 OpenAI 换成 Ark编排代码无需改动。
3. **测试可 Mock**:接口天然支持 mock单元测试不需要真实调用模型。
本章只涉及 `ChatModel`,后续章节会逐步引入 `Tool``Retriever` 等 Component。
```mermaid
classDiagram
class Component {
<<interface>>
}
class ChatModel {
<<interface>>
+Generate()
+Stream()
}
class Tool {
<<interface>>
+Execute()
}
class Retriever {
<<interface>>
+Retrieve()
}
Component <|-- ChatModel
Component <|-- Tool
Component <|-- Retriever
note for ChatModel "本章重点学习"
```
## schema.Message对话的基本单位
> [!tip] 核心概念
> `Message` 是 Eino 对话系统的基石。理解 Message 的结构和角色语义,是掌握整个对话流程的关键。
`Message` 是 Eino 里对话数据的基本结构:
```go
type Message struct {
Role RoleType // system / user / assistant / tool
Content string // 文本内容
ToolCalls []ToolCall // 仅 assistant 消息可能有
// ...
}
```
```mermaid
graph LR
A[Message] --> B[Role]
A --> C[Content]
A --> D[ToolCalls]
B --> E["system / user / assistant / tool"]
C --> F["文本内容"]
D --> G["工具调用指令"]
```
常用构造函数:
```go
schema.SystemMessage("You are a helpful assistant.")
schema.UserMessage("What is the weather today?")
schema.AssistantMessage("I don't know.", nil) // 第二个参数是 ToolCalls
schema.ToolMessage("tool result", "call_id")
```
**角色语义:**
| 角色 | 用途 | 典型位置 |
|------|------|----------|
| `system` | 系统指令,定义模型行为 | messages 最前面 |
| `user` | 用户输入 | 交替出现 |
| `assistant` | 模型回复 | 交替出现 |
| `tool` | 工具调用结果 | 工具调用后(后续章节) |
> [!warning] 常见错误
> 注意 `system` 消息应该放在 messages 数组的最前面,而不是中间或末尾。这是大多数 LLM 的要求。
## 前置条件
### 获取代码
```bash
git clone https://github.com/cloudwego/eino-examples.git
cd eino-examples/quickstart/chatwitheino
```
- Go 版本Go 1.21+(见 `go.mod`
- 一个可调用的 ChatModel默认使用 OpenAI也支持 Ark
### 方式 AOpenAI默认
```bash
export OPENAI_API_KEY="..."
export OPENAI_MODEL="gpt-4.1-mini" # OpenAI 2025 年新模型,也可用 gpt-4o、gpt-4o-mini 等
# 可选:
# OPENAI_BASE_URL代理或兼容服务
# OPENAI_BY_AZURE=true使用 Azure OpenAI
```
### 方式 BArk
```bash
export MODEL_TYPE="ark"
export ARK_API_KEY="..."
export ARK_MODEL="..."
# 可选ARK_BASE_URL
```
## 运行
`examples/quickstart/chatwitheino` 目录下执行:
```bash
go run ./cmd/ch01 -- "用一句话解释 Eino 的 Component 设计解决了什么问题?"
```
输出示例(流式逐步打印):
```
[assistant] Eino 的 Component 设计通过定义统一接口...
```
## 入口代码做了什么
按执行顺序:
```mermaid
flowchart TD
A[开始] --> B[创建 ChatModel]
B --> C["构造 messages 数组"]
C --> D[调用 Stream 方法]
D --> E{接收数据块}
E -->|EOF| F[结束]
E -->|有数据| G[打印 chunk.Content]
G --> E
```
1. **创建 ChatModel**:根据 `MODEL_TYPE` 环境变量选择 OpenAI 或 Ark 实现
2. **构造输入 messages**`SystemMessage(instruction)` + `UserMessage(query)`
3. **调用 Stream**:所有 ChatModel 实现都必须支持 `Stream()`,返回 `StreamReader[*Message]`
4. **打印结果**:迭代 `StreamReader` 逐帧打印 assistant 回复
关键代码片段(**注意:这是简化后的代码片段,不能直接运行,完整代码请参考** [cmd/ch01/main.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/cmd/ch01/main.go)
```go
// 1. 构造输入消息数组
// system 消息定义模型行为user 消息包含用户问题
messages := []*schema.Message{
schema.SystemMessage(instruction), // 系统指令
schema.UserMessage(query), // 用户输入
}
// 2. 调用 Stream 方法(所有 ChatModel 都必须实现)
// 返回 StreamReader用于逐块读取流式输出
stream, err := cm.Stream(ctx, messages)
if err != nil {
log.Fatal(err)
}
defer stream.Close() // 确保资源释放
// 3. 循环接收流式数据
for {
chunk, err := stream.Recv() // 读取下一个数据块
if errors.Is(err, io.EOF) {
break // 流结束
}
if err != nil {
log.Fatal(err)
}
fmt.Print(chunk.Content) // 实时打印内容(不换行)
}
```
> [!note] 流式输出的优势
> 使用 `Stream` 而不是 `Generate`,可以让用户更早看到响应,提升交互体验。这类似于 ChatGPT 的逐字显示效果。
## 本章小结
| 核心概念 | 说明 | 本章要点 |
|----------|------|----------|
| **Component 接口** | 定义可替换、可组合、可测试的能力边界 | 通过接口实现解耦,支持多模型切换 |
| **Message** | 对话数据的基本单位,通过角色区分语义 | 使用构造函数创建消息 |
| **ChatModel** | 最基础的 Component | 提供 `Generate``Stream` 方法 |
| **实现选择** | 通过环境变量或配置切换不同实现 | 业务代码无需改动 |
> [!success] 学习成果
> 完成本章后,你应该能够:
> - 理解 Eino 的 Component 设计理念
> - 使用 ChatModel 进行单次对话调用
> - 掌握 Message 的构造和使用方法
> - 运行示例代码并观察流式输出
## 下一章预告
[[Eino/quick_start/chapter_02_agent_and_runner|第二章Agent 与 Runner]] 将引入执行抽象,实现多轮对话和会话管理。
## 关联笔记
- [[Eino/quick_start/chapter_02_agent_and_runner]]
- [[Eino/README]]

View File

@@ -0,0 +1,431 @@
---
tags: [eino, ai-development, go, quickstart, agent, adk]
create time: 2026-04-29 15:00
title: 第二章ChatModelAgent、Runner、AgentEventConsole 多轮)
weight: 2
---
## 概述
在第一章掌握了 `ChatModel` 组件的基础用法后,本章引入 Eino ADK 中的执行抽象——**Agent + Runner**。通过创建一个 Console 程序实现多轮对话,你将理解 Agent 接口的设计意图、事件驱动的执行模型,以及 `AsyncIterator` 如何支持流式消费。
---
<!-- @block-anchor:overview:start -->
<!-- @block-anchor:overview:end -->
## 代码位置
- 入口代码:[cmd/ch02/main.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/cmd/ch02/main.go)
## 前置条件
与第一章一致:需要配置一个可用的 ChatModelOpenAI 或 Ark
## 运行
`examples/quickstart/chatwitheino` 目录下执行:
```bash
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 等)都实现这个统一接口:
```go
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()` | 执行核心逻辑 | 函数调用 |
**设计理念:**
```mermaid
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 构建:
```go
// 核心参数说明:
// - 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 完成所有事情?
<table>
<tr><td>维度</td><td>ChatModel</td><td>ChatModelAgent</td></tr>
<tr><td><strong>定位</strong></td><td>Component组件</td><td>Agent智能体</td></tr>
<tr><td><strong>接口</strong></td><td><pre>Generate() / Stream()</pre></td><td><pre>Run() -> AsyncIterator[*AgentEvent]</pre></td></tr>
<tr><td><strong>输出</strong></td><td>直接返回消息内容</td><td>返回事件流(含消息、控制动作等)</td></tr>
<tr><td><strong>能力</strong></td><td>单纯的模型调用</td><td>可扩展 tools、middleware、interrupt 等</td></tr>
<tr><td><strong>适用场景</strong></td><td>简单的对话场景</td><td>复杂的智能体应用</td></tr>
</table>
**为什么需要 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 的生命周期:
```go
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]`
**使用方式:**
```go
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 的执行流程:**
```mermaid
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 返回的事件单元,代表执行过程中的一个**离散步骤**
```go
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` 让你可以**实时消费**每一个事件。
**消费方式:**
```go
// 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|AsyncIterator事件流的消费方式]]
## 多轮对话的实现
本章实现的是简单的多轮对话:用户输入 → 模型回复 → 用户继续输入 → ...
**核心思想:**
没有 tools 时,`ChatModelAgent` 在一次 `Run()` 里只会完成一轮模型调用。多轮对话是通过**调用侧维护 history** 实现的——每次调用都把完整的对话历史传进去,让模型知道之前聊了什么。
```mermaid
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](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/cmd/ch02/main.go)
```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|第三章Memory 与 Session]] 将引入持久化存储机制,让对话历史跨进程保留,不再因为程序重启而丢失记忆。
## 关联笔记
- [[Eino/quick_start/chapter_01_chatmodel_and_message]]
- [[Eino/quick_start/chapter_03_memory_and_session]]
- [[agent_interface]]
- [[chat_model]]

View File

@@ -0,0 +1,94 @@
---
tags: [eino, asynciterator, go, streaming]
create time: 2026-04-29 15:30
---
# AsyncIterator事件流的消费方式
## 概述
理解 Eino 的 `AsyncIterator` 最简单的方式——**看 Claude Code 是怎么消费自身事件的**。两者的消费过程完全一致。
---
## Claude Code 的事件消费过程
```mermaid
flowchart TD
A["runner.Run() 获取事件流"] --> B["循环 events.Next()"]
B --> C{"事件类型?"}
C -->|"thinking"| D["显示思考过程"]
C -->|"text_delta"| E["追加文本到终端"]
C -->|"tool_use"| F["高亮工具调用"]
C -->|"error"| G["记录错误,终止"]
C -->|"interrupt"| H["用户取消,优雅退出"]
C -->|"无更多事件"| I["closeConnection()"]
D --> B
E --> B
F --> B
style A fill:#e1f5fe
style I fill:#ffebee
```
核心过程只有一句:**获取流 → Next() 逐个拉取 → 按类型处理 → Close 释放。**
---
## Eino 的等价过程
```mermaid
flowchart TD
A["runner.Run() 获取迭代器"] --> B["循环 events.Next()"]
B --> C{"event.Err?"}
C -->|是| G["记录错误,退出循环"]
C -->|否| D{"event.Output?"}
D -->|是| E["打印内容给用户"]
D -->|否| F{"event.Action?"}
F -->|是| J["处理控制动作<br/>本章节用不到"]
F -->|否| B
E --> B
G --> H["events.Close() 释放资源"]
style A fill:#e1f5fe
style H fill:#ffebee
```
同样四个字阶段:获取流 → Next() 逐个拉取 → 按类型处理 → Close 释放。
---
## 两者对照
```mermaid
flowchart LR
subgraph Claude Code
A["thinking"] --> T1["显示思考过程"]
B["text_delta"] --> T2["追加文本输出"]
C["tool_use"] --> T3["高亮工具调用"]
D["error"] --> T4["错误终止"]
E["interrupt"] --> T5["用户取消"]
end
subgraph Eino ADK
A1["—"] --> S1["无此概念"]
B1["event.Output"] --> S2["打印消息内容"]
C1["event.Action"] --> S3["内部调度信号"]
D1["event.Err"] --> S4["错误退出"]
E1["ctx 被 cancel"] --> S5["用户取消"]
end
B -.等价.-> B1
C -.等价.-> C1
D -.等价.-> D1
E -.等价.-> E1
```
---
## 注意事项
- 始终检查 `Err` 字段——错误通常是静默发生的
- `Next()` 返回的 `ok` 为 false 时表示流已结束,不应继续调用
- 每次 `Run()` 创建的迭代器只能消费一次,不能复用
- 不手动调用 `Close()` 会导致资源泄漏

View File

@@ -0,0 +1,122 @@
---
tags: [eino, agent, go, design-pattern, interface]
create time: 2026-04-29 15:30
---
# Agent 接口为什么都需要 ctx
## 概述
深入理解 Eino ADK 中 `Agent` 接口的签名设计——为什么 `Name()``Description()` 这些看似简单的方法也接收 `context.Context`,以及这种设计带来的长期收益。
## 正文
### 问题引入
回顾 `Agent` 接口的完整定义:
```go
type Agent interface {
Name(ctx context.Context) string // 看起来不需要 ctx
Description(ctx context.Context) string // 看起来也不需要 ctx
Run(ctx context.Context, input *AgentInput, options ...AgentRunOption) *AsyncIterator[*AgentEvent]
}
```
`Run()` 需要 ctx 很好理解:超时控制、取消信号传递、请求追踪。但 `Name()``Description()` 只是返回两个字符串,真的有必要传 ctx 吗?
> [!question] 思考一下
> 如果你来设计这个接口,你会让这三个方法都接受 ctx还是只给 `Run()` 传 ctx
### 一、接口签名统一性
这是最直接的原因。三个方法共享同一个 `ctx` 参数,调用方可以保持一致的调用风格:
```go
// 统一的上下文链
agent.Name(ctx) // ✓ 同样的模式
agent.Description(ctx) // ✓ 同样的模式
agent.Run(ctx, input) // ✓ 同样的模式
```
如果只有 `Run()` 需要 ctx另外两个不需要就会出现两种签名风格增加心智负担。
### 二、未来兼容性——预留扩展空间
现在可能用不到 ctx但以后可能会用到。Go 社区有一个经验法则:**如果一个方法的实现可能需要 context那接口一开始就应该声明它**。常见场景:
| 场景 | ctx 的用途 |
|------|-----------|
| 多语言支持 | 从 `ctx.Value(LocaleKey)` 读取用户语言偏好 |
| 个性化元数据 | 从 `ctx.Value(UserIDKey)` 生成带用户名的描述 |
| 分布式追踪 | 将 tracing span 传递给子组件进行链路追踪 |
| 权限检查 | 在返回描述前验证访问权限 |
假设一个支持多语言的实现:
```go
func (a *SmartAgent) Description(ctx context.Context) string {
locale := ctx.Value(localeKey).(string) // 从上下文中读取语言设置
if locale == "zh-CN" {
return "这是一个智能对话代理"
}
return "An intelligent conversational agent"
}
```
**代价几乎为零**(调用方本来就有 ctx**收益在于避免将来改接口破坏已有实现**。
### 三、与 Eino 组件体系的一致性
Eino 框架的核心设计哲学是:**所有可执行操作都接受 context**。这确保整个调用链中的超时传播、取消信号传递是一致的:
```mermaid
flowchart LR
A["ctx 进入系统"] --> B["runner.Run"]
B --> C["agent.Name / Description"]
B --> D["agent.Run 模型调用"]
D --> E["ChatModel.Generate"]
E --> F["HTTP 请求"]
```
当上层调用 `runner.Run(ctx, history)` 时,如果 ctx 被取消(比如超时或用户关闭页面),`Name()`/`Description()` 也应该能感知到这个变化。虽然它们本身很快,但这个**一致性约定**防止了某个地方偷偷发起不受控的请求。
### 四、中间件/拦截器的切面能力
在更复杂的场景中,你可能通过 middleware 增强 Agent 的行为:
```go
// 一个 logging middleware 示例
func loggingMiddleware(next adk.Agent) adk.Agent {
return &loggingAgent{wrapped: next}
}
func (a *loggingAgent) Name(ctx context.Context) string {
start := time.Now()
defer func() { log.Printf("Name took %v", time.Since(start)) }()
return a.wrapped.Name(ctx) // 同样传递 ctx
}
```
中间件层需要对所有方法做统一的处理逻辑,保持签名一致会让 middleware 的实现更简洁。
## 总结
| 维度 | 说明 |
|------|------|
| **当前状态** | `Name()` / `Description()` 通常不实际使用 ctx |
| **核心价值** | 统一接口 + 未来扩展 + 生态一致性 |
| **类比** | 就像函数参数多传一个不用的值,成本极低但保留了解决方案 |
> [!tip] 设计原则提炼
>
> **"签名的保守性"原则:在接口层面宁可多声明一个无害的参数,也不要少声明一个将来必需的东西。**
>
> 这就是为什么你看到的是 `Name(ctx context.Context) string` 而不是简化版的 `Name() string`。
## 关联笔记
- [[../chapter_02_chatmodelagent_runner_agentevent|第二章ChatModelAgent、Runner、AgentEvent]]
- [[agent_interface]]
- [[chat_model]]

View File

@@ -0,0 +1,330 @@
---
tags: [eino, ai-development, go, quickstart, memory, session]
create time: 2026-04-29 15:30
title: 第三章Memory 与 Session持久化对话
weight: 3
---
## 概述
在第二章掌握了多轮对话的实现后,我们面临一个关键问题:**对话历史只存在于内存中,进程退出后一切归零**。本章引入 **Memory 与 Session** 机制,让对话历史能够持久化保存并跨进程恢复,为构建真正的智能助手奠定基础。
> [!warning] 重要概念区分:业务层 vs 框架层
> 本章介绍的 **Memory、Session、Store 是业务层概念****不是 Eino 框架的核心组件**。Eino 框架只负责"如何处理消息",而"如何存储消息"完全由业务层决定。本章提供的实现只是一个参考示例你可以根据自己的需求选择数据库、Redis、云存储等方案。
## 代码位置
- 入口代码:[cmd/ch03/main.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/cmd/ch03/main.go)
- Store 实现:[mem/store.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/mem/store.go)
---
## 前置条件
与第二章一致:需要配置一个可用的 ChatModelOpenAI 或 Ark
## 运行
`examples/quickstart/chatwitheino` 目录下执行:
```bash
# 创建新会话
go run ./cmd/ch03
# 恢复已有会话
go run ./cmd/ch03 --session <session-id>
```
输出示例:
```
Created new session: 083d16da-6b13-4fe6-afb0-c45d8f490ce1
Session title: New Session
Enter your message (empty line to exit):
you> 你好,我是张三
[assistant] 你好张三!很高兴认识你...
you> 我叫什么名字?
[assistant] 你叫张三...
Session saved: 083d16da-6b13-4fe6-afb0-c45d8f490ce1
Resume with: go run ./cmd/ch03 --session 083d16da-6b13-4fe6-afb0-c45d8f490ce1
```
---
<!-- @block-anchor:overview:start -->
<!-- @block-anchor:overview:end -->
## 从内存到持久化:为什么需要 Memory
> [!question] 思考一下
> 第二章我们实现了多轮对话,但存在一个问题——如果进程退出、机器重启,之前聊的内容还会在吗?
**内存存储的局限:**
- 进程退出后,对话历史丢失
- 无法跨设备、跨进程恢复会话
- 无法实现会话管理(列表、删除、搜索等)
**Memory 的定位:**
- **Memory 是对话历史的持久化存储**:将对话保存到磁盘或数据库
- **Memory 支持 Session 管理**:每个 Session 代表一次完整的对话
- **Memory 与 Agent 解耦**Agent 不关心存储细节,只关心消息列表
**简单类比:**
| 方式 | 比喻 | 特点 |
|------|------|------|
| 内存存储 | "草稿纸" | 进程退出就没了 |
| Memory | "笔记本" | 永久保存,随时翻阅 |
---
## 关键概念
> [!tip] 重要提示
> 以下 Session、Store 等概念都是**业务层实现**用于管理对话历史的存储。Eino 框架本身不提供这些组件,而是由业务层负责管理消息列表,然后将消息传递给 `adk.Runner` 进行处理。
### Session业务层概念
`Session` 代表一次完整的对话会话:
```go
type Session struct {
ID string
CreatedAt time.Time
messages []*schema.Message // 对话历史
// ...
}
```
**核心方法:**
- `Append(msg)`:追加消息到会话,并持久化
- `GetMessages()`:获取所有消息
- `Title()`:从第一条用户消息生成会话标题
### Store业务层概念
`Store` 管理多个 Session 的持久化存储:
```go
type Store struct {
dir string // 存储目录
cache map[string]*Session // 内存缓存
}
```
**核心方法:**
- `GetOrCreate(id)`:获取或创建 Session
- `List()`:列出所有 Session
- `Delete(id)`:删除 Session
### JSONL 文件格式
每个 Session 存储为一个 `.jsonl` 文件:
```
{"type":"session","id":"083d16da-...","created_at":"2026-03-11T10:00:00Z"}
{"role":"user","content":"你好,我是谁?"}
{"role":"assistant","content":"你好!我暂时不知道你是谁..."}
{"role":"user","content":"我叫张三"}
{"role":"assistant","content":"好的,张三,很高兴认识你!"}
```
**为什么用 JSONL**
| 特性 | 说明 |
|------|------|
| **简单** | 每行一个 JSON 对象,易于读写 |
| **可扩展** | 可以追加新消息,无需重写整个文件 |
| **可读性好** | 可以用文本编辑器直接查看 |
| **容错性强** | 单行损坏不影响其他行 |
## Memory 的实现(业务层示例)
以下是一个简单的业务层实现示例,使用 JSONL 文件存储对话历史。这只是众多可能实现中的一种你可以根据实际需求选择数据库、Redis 等其他存储方案。
### 1. 创建 Store
```go
sessionDir := "./data/sessions"
store, err := mem.NewStore(sessionDir)
if err != nil {
log.Fatal(err)
}
```
### 2. 获取或创建 Session
```go
sessionID := "083d16da-6b13-4fe6-afb0-c45d8f490ce1"
session, err := store.GetOrCreate(sessionID)
if err != nil {
log.Fatal(err)
}
```
### 3. 追加用户消息
```go
userMsg := schema.UserMessage("你好")
if err := session.Append(userMsg); err != nil {
log.Fatal(err)
}
```
### 4. 获取历史并调用 Agent
```go
history := session.GetMessages()
events := runner.Run(ctx, history)
content := collectAssistantFromEvents(events)
```
### 5. 追加助手消息
```go
assistantMsg := schema.AssistantMessage(content, nil)
if err := session.Append(assistantMsg); err != nil {
log.Fatal(err)
}
```
### 关键代码解析
完整流程可以浓缩为以下核心片段:
```go
// 1. 创建或恢复 Session
session, err := store.GetOrCreate(sessionID)
if err != nil {
log.Fatal(err)
}
// 2. 读取用户输入并追加到会话
userMsg := schema.UserMessage(line)
if err := session.Append(userMsg); err != nil {
log.Fatal(err)
}
// 3. 获取全部历史,送入 Agent 处理
history := session.GetMessages()
events := runner.Run(ctx, history)
content := collectAssistantFromEvents(events)
// 4. 收集回复并存回会话
assistantMsg := schema.AssistantMessage(content, nil)
if err := session.Append(assistantMsg); err != nil {
log.Fatal(err)
}
```
> [!note] 简化说明
> 以上代码已省略错误处理之外的细节(如命令行参数解析、事件流消费等),不能直接运行。完整代码请参考 [cmd/ch03/main.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/cmd/ch03/main.go)。
## Session 与 Agent 的关系:业务层与框架层的协作
> [!tip] 理解要点
> - **Session 是业务层概念**:由你的代码实现和管理,负责存储和加载对话历史
> - **AgentRunner是框架层概念**:由 Eino 框架提供,负责处理消息并生成回复
> - **两者的交互点**:业务层通过 `session.GetMessages()` 获取消息列表,传递给 `runner.Run(ctx, history)` 进行处理
**数据流示意图:**
```mermaid
flowchart TD
A["用户输入"] --> B["session.Append()<br/>保存用户消息"]
B --> C["session.GetMessages()<br/>获取完整历史"]
C --> D["runner.Run(history)<br/>Agent 处理消息"]
D --> E["收集助手回复"]
E --> F["session.Append()<br/>保存助手消息"]
style B fill:#e8f5e9
style C fill:#e8f5e9
style F fill:#e8f5e9
style D fill:#fff3e0
```
**分层面貌:**
```mermaid
graph LR
subgraph biz_layer["业务层 — 你的代码"]
S1["Session<br/>持久化存储"]
S2["GetMessages()"]
S3["Append()<br/>保存消息"]
S1 --> S2
S3 -.->|"回写"| S1
end
subgraph frame_layer["框架层 — Eino"]
R1["runner.Run()"]
end
S2 --> R1
R1 -->|"助手回复"| S3
style S1 fill:#e3f2fd
style S2 fill:#e3f2fd
style S3 fill:#e3f2fd
style R1 fill:#fff3e0
style biz_layer fill:none,stroke:#90caf9
style frame_layer fill:none,stroke:#ffcc80
```
## 本章小结
| 核心概念 | 定位 | 说明 |
|----------|------|------|
| **Memory** | 业务层 | 对话历史的持久化存储,支持跨进程恢复 |
| **Session** | 业务层 | 一次完整的对话会话,包含 ID、创建时间、消息列表 |
| **Store** | 业务层 | 管理多个 Session 的存储,支持创建、获取、列表、删除 |
| **JSONL 格式** | 业务层 | 简单的文件格式,易于读写和扩展 |
| **adk.Runner** | 框架层 | 接收消息列表,调用 ChatModel返回回复 |
> [!success] 学习成果
> 完成本章后,你应该能够:
> - 理解 Memory/Session/Store 的业务层职责
> - 知道如何将对话历史持久化为 JSONL 文件
> - 明白业务层与框架层的协作边界
> - 根据业务需求选择合适的存储方案
---
## 扩展思考:业务层存储方案的选择
本章提供的 JSONL 文件存储方案适合简单的单机应用。在实际业务中,你可能需要考虑其他存储方案:
<table>
<tr><td>存储方案</td><td>适用场景</td><td>优势</td><td>劣势</td></tr>
<tr><td><strong>JSONL 文件</strong></td><td>单机应用、开发调试</td><td>零依赖,简单直观</td><td>不支持并发、分布式</td></tr>
<tr><td><strong>SQLite / LevelDB</strong></td><td>桌面端应用</td><td>轻量级嵌入式数据库</td><td>不适合高并发写入</td></tr>
<tr><td><strong>MySQL / PostgreSQL</strong></td><td>服务端部署</td><td>成熟稳定,功能丰富</td><td>运维成本较高</td></tr>
<tr><td><strong>Redis</strong></td><td>分布式、高频访问</td><td>性能极高,支持过期策略</td><td>数据需额外持久化</td></tr>
<tr><td><strong>S3 / OSS</strong></td><td>海量冷数据归档</td><td>成本极低,无限扩展</td><td>不适合频繁查询</td></tr>
</table>
**高级功能展望:**
- 会话过期清理TTL 自动删除)
- 会话全文搜索
- 会话导出 / 导入
- 会话分享(生成公开链接)
> [!tip] Middleware 联动
> 当对话非常长时,单纯增加存储容量是不够的。第五章介绍的 **Summarization Middleware** 可以在调用 Agent 之前自动压缩历史消息,有效控制 Token 消耗。
---
## 下一章预告
[[Eino/quick_start/chapter_04_tool_and_filesystem|第四章Tool 与文件系统]] 将为 Agent 添加文件访问能力,让智能助手能够读取代码仓库中的真实内容。
## 关联笔记
- [[Eino/quick_start/chapter_02_chatmodelagent_runner_agentevent]]
- [[Eino/quick_start/chapter_04_tool_and_filesystem]]

View File

@@ -0,0 +1,395 @@
---
tags: ["Eino", "Agent", "Tool", "Backend", "DeepAgent", "文件系统"]
create time: "2026-04-29 15:30"
---
# 第四章Tool 与文件系统访问
## 概述
本章为 Agent 引入 Tool工具能力使其能够突破纯文本对话的边界直接操作文件系统、搜索代码库、执行命令。通过 DeepAgent 预构建组件和 Backend 抽象接口,只需几行配置即可让 Agent「看见」并「触碰」真实世界。
## 为什么需要 Tool
前三章我们实现的 Agent 只能对话,无法执行实际操作。
**Agent 的局限:**
- 只能生成文本回复
- 无法访问外部资源(文件、API、数据库等)
- 无法执行实际任务(计算、查询、修改等)
**Tool 的定位:**
- **Tool 是 Agent 的能力扩展**:让 Agent 能够执行具体操作
- **Tool 封装了具体实现**:Agent 不关心 Tool 内部如何工作,只关心输入输出
- **Tool 可组合**:一个 Agent 可以有多个 Tool,根据需要选择调用
**简单类比:**
- **Agent** = "智能助手"(能理解指令,但需要工具才能执行)
- **Tool** = "工具箱"(文件操作、网络请求、数据库查询等)
> [!question] 深入思考
>
> 如果 Agent 拥有无限个 Tool会不会反而变得更差
> 提示考虑模型上下文窗口限制、Token 成本、以及"选择困难症"效应。实际设计中,**工具的元信息描述质量**比数量更重要——一个好的 `Description` 能让模型精准选对工具。
## 为什么需要文件系统能力
本示例是 ChatWithDoc与文档对话目标是帮助用户学习 Eino 框架并编写 Eino 代码。那么,最好的文档是什么?
**答案就是Eino 仓库的代码本身。**
| 资源类型 | 价值 |
|---------|------|
| **Code** | 源代码展示了框架的真实实现 |
| **Comment** | 代码注释提供了设计思路和使用说明 |
| **Examples** | 示例代码演示了最佳实践 |
通过文件系统访问能力Agent 可以直接读取 Eino 源码、注释和示例,为用户提供最准确、最及时的技术支持。
> [!tip] 现实启发
>
> 很多官方文档会过时或写得模糊,但代码不会。让 Agent "读源码"是一种绕过信息衰减的可靠策略——这也是为什么 RAG 系统的向量库经常直接索引代码仓库的原因。
## 关键概念
### Tool 接口
`Tool` 是 Eino 中定义可执行能力的接口:
```go
// BaseTool 提供工具的元信息,ChatModel 使用这些信息决定是否以及如何调用工具
type BaseTool interface {
Info(ctx context.Context) (*schema.ToolInfo, error)
}
// InvokableTool 是可以被 ToolsNode 执行的工具
type InvokableTool interface {
BaseTool
// InvokableRun 执行工具,参数是 JSON 编码的字符串,返回字符串结果
InvokableRun(ctx context.Context, argumentsInJSON string, opts ...Option) (string, error)
}
// StreamableTool 是 InvokableTool 的流式变体
type StreamableTool interface {
BaseTool
// StreamableRun 流式执行工具,返回 StreamReader
StreamableRun(ctx context.Context, argumentsInJSON string, opts ...Option) (*schema.StreamReader[string], error)
}
```
**接口层次:**
- `BaseTool`**信息层**,只提供工具的名称、描述和参数 schemaChatModel 据此决定是否调用
- `InvokableTool`**同步执行层**,返回完整结果字符串,适合大多数文件操作场景
- `StreamableTool`**流式执行层**,通过 `StreamReader` 逐步输出结果,适合大量输出的场景(如长文件读取或命令执行)
> [!note] 设计要点
>
> 为什么 Tool 的参数是 JSON 字符串而不是 Go struct
> 因为 Tool 需要在 ChatModel 的上下文里传递——模型只能理解文本。所以 Eino 将参数序列化为 JSON 传给模型,模型再返回 JSON由 ToolsNode 反序列化后调用底层实现。这种设计让任何语言/协议的工具都能与基于 JSON 的大模型对齐。
### Backend 接口
`Backend` 是 Eino 中用于文件系统操作的抽象接口,定义了六种核心文件能力:
```go
type Backend interface {
// 列出目录下的文件信息
LsInfo(ctx context.Context, req *LsInfoRequest) ([]FileInfo, error)
// 读取文件内容,支持按行偏移和限制
Read(ctx context.Context, req *ReadRequest) (*FileContent, error)
// 在文件中搜索匹配的内容
GrepRaw(ctx context.Context, req *GrepRequest) ([]GrepMatch, error)
// 根据 glob 模式匹配文件
GlobInfo(ctx context.Context, req *GlobInfoRequest) ([]FileInfo, error)
// 写入文件内容
Write(ctx context.Context, req *WriteRequest) error
// 编辑文件内容(字符串替换)
Edit(ctx context.Context, req *EditRequest) error
}
```
> [!tip] 方法分组
>
> Backend 的六类方法可以归为三类:
> **发现**LsInfo / GlobInfo、**检索**Read / GrepRaw、**修改**Write / Edit。记住这个分类有助于快速理解不同 Backend 实现的侧重点。
### LocalBackend
`LocalBackend` 是 Backend 的本地文件系统实现,直接访问操作系统的文件系统:
```go
import localbk "github.com/cloudwego/eino-ext/adk/backend/local"
backend, err := localbk.NewBackend(ctx, &localbk.Config{})
```
**特点:**
- 直接访问本地文件系统,使用 Go 标准库实现
- 支持所有 Backend 接口方法
- 支持执行 shell 命令ExecuteStreaming
- 路径安全:要求使用绝对路径,防止目录遍历攻击
- 零配置:开箱即用,无需额外设置
> [!warning] 安全问题
>
> LocalBackend 要求使用绝对路径来防止 `../` 目录遍历攻击。这意味着 Agent 永远不能通过构造恶意文件名跳出设定的根目录范围——这是 LLM 集成中的关键安全措施。
## 实现:使用 DeepAgent
本章使用 DeepAgent 预构建 Agent,它提供了 Backend 和 StreamingShell 的一级配置,可以方便地注册文件系统相关的工具。
### 从 ChatModelAgent 到 DeepAgent何时需要切换
前面章节一直使用 `ChatModelAgent`,它已经能处理多轮对话。但要访问文件系统,我们需要切换到 `DeepAgent`
**ChatModelAgent vs DeepAgent 对比:**
<table>
<tr><th>能力</th><th>ChatModelAgent</th><th>DeepAgent</th></tr>
<tr><td>多轮对话</td><td>✅</td><td>✅</td></tr>
<tr><td>添加自定义 Tool</td><td>✅ 手动注册每个 Tool</td><td>✅ 手动注册或自动注册</td></tr>
<tr><td>文件系统访问Backend</td><td>❌ 需手动创建并注册所有文件工具</td><td>✅ 一级配置,自动注册</td></tr>
<tr><td>命令执行StreamingShell</td><td>❌ 需手动创建</td><td>✅ 一级配置,自动注册</td></tr>
<tr><td>内置任务管理</td><td>❌</td><td>✅ `write_todos` 工具</td></tr>
<tr><td>支持子 Agent</td><td>❌</td><td>✅</td></tr>
</table>
> [!tip] 选择建议
>
> - 纯对话场景(无外部访问)→ 用 `ChatModelAgent`
> - 需要访问文件系统或执行命令 → 用 `DeepAgent`
### 为什么使用 DeepAgent?
相比直接使用 ChatModelAgentDeepAgent 的优势在于把「基础设施」封装成了第一类配置,你只需声明意图而非实现细节:
1. **一级配置**Backend 和 StreamingShell 直接在 Config 中传入,无需自己组装 ToolsNode
2. **自动注册工具**:配置 Backend 后自动注册文件系统工具,免去逐个定义的样板代码
3. **内置任务管理**:提供 `write_todos` 工具,支持复杂任务的规划与跟踪
4. **支持子 Agent**:可以配置专门的子 Agent 处理特定任务
5. **更强大**:集成了文件系统、命令执行等多种能力
### 代码实现
这段代码完成了两件事:创建 Backend 实例,再将其注入 DeepAgent。让我们逐行看
```go
import (
localbk "github.com/cloudwego/eino-ext/adk/backend/local"
"github.com/cloudwego/eino/adk/prebuilt/deep"
)
// 第一步:创建 LocalBackend —— 文件系统能力的实际执行者
backend, err := localbk.NewBackend(ctx, &localbk.Config{})
// 第二步:将 backend 注入 DeepAgent它会自动注册文件相关 Tool
agent, err := deep.New(ctx, &deep.Config{
Name: "Ch04ToolAgent", // Agent 的名称
Description: "ChatWithDoc agent with filesystem access.",
ChatModel: cm, // 底层大模型
Instruction: instruction, // 系统提示词
Backend: backend, // 文件系统操作能力
StreamingShell: backend, // 命令执行能力
MaxIteration: 50, // 最大思考-行动循环次数
})
```
> [!note] MaxIteration 的含义
>
> 每次 Agent 「思考 → Tool Call → 观察结果」算一次迭代。设为 50 意味着最多允许 50 轮。如果达到上限仍未得到满意结果,会返回部分完成的内容。**设置过高会浪费 Token过低则可能让 Agent 中途放弃**。后续章节会讨论如何优化这个值。
### DeepAgent 自动注册的工具
当配置了 `Backend``StreamingShell`DeepAgent 会自动注册以下工具:
| 工具名 | 对应 Backend 方法 | 用途 |
|-------|------------------|------|
| `read_file` | `Read` | 读取文件内容 |
| `write_file` | `Write` | 写入文件内容 |
| `edit_file` | `Edit` | 编辑文件内容 |
| `glob` | `GlobInfo` | 根据 glob 模式查找文件 |
| `grep` | `GrepRaw` | 在文件中搜索内容 |
| `execute` | shell 命令 | 执行 shell 命令 |
> [!note] 从接口到 Tool 的映射
>
> Backend 定义了 6 个方法LsInfo / Read / GrepRaw / GlobInfo / Write / Edit但 DeepAgent 只注册了 5 个 Tool少了 LsInfo。这是因为 `glob` 已经能完成目录探索的需求。如果未来需要更详细的列表信息,可以手动补充 LsInfo 对应的 Tool。
## 代码位置
- 入口代码:[cmd/ch04/main.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/cmd/ch04/main.go)
## 前置条件
与第一章一致:需要配置一个可用的 ChatModel(OpenAI 或 Ark)。
本章还需要设置 `PROJECT_ROOT`(可选,见下方运行说明)。
## 运行
`examples/quickstart/chatwitheino` 目录下执行:
```bash
# 可选:设置 Eino 核心库的根目录路径
# 未设置时Agent 默认使用当前工作目录(即 chatwitheino 目录)作为根目录
# 若要让 Agent 能检索完整的 Eino 代码库,建议指向 eino 核心库根目录
export PROJECT_ROOT=/path/to/eino
# 验证路径是否正确(应该能看到 adk、components、compose 等目录)
ls $PROJECT_ROOT
go run ./cmd/ch04
```
**PROJECT_ROOT 说明:**
- **不设置时**`PROJECT_ROOT` 默认为当前工作目录(`chatwitheino` 所在目录Agent 只能访问本示例项目的文件。这对于快速试验已足够。
- **设置后**:指向 Eino 核心库根目录Agent 可以检索 Eino 框架的完整代码库(核心库、扩展库、示例库)。这是 ChatWithEino 的完整使用场景。
**推荐的三仓库目录结构(如要完整体验):**
```mermaid
graph LR
ER[eino 核心库\nPROJECT_ROOT] --> ADK[adk/]
ER --> COMP[components/]
ER --> COMPOSE[compose/]
ER --> EXT[eino-ext 扩展库]
ER --> EX[eino-examples 示例库]
EX --> QS[quickstart/]
QS --> CW[chatwitheino / 本示例]
style ER fill:#e3f2fd
style CW fill:#fff3e0
```
> [!example] PROJECT_ROOT 的两种用法
>
> **场景一:快速验证**(不设置)
> Agent 只能访问 `chatwitheino` 目录下的文件,适合学习当前示例的代码结构。
>
> **场景二:完整对话**(设置 `export PROJECT_ROOT=/path/to/eino`
> Agent 可以搜索 Eino 框架全部源码——比如查找某个 API 的实现细节、阅读组件设计思路等。这是 ChatWithEino 真正「与文档对话」的价值所在。
可以使用 `dev_setup.sh` 脚本自动设置上述目录结构:
```bash
# 在 eino 根目录运行,自动克隆扩展库和示例库到正确位置
bash scripts/dev_setup.sh
```
输出示例:
```
you> 列出当前目录的文件
[assistant] 我来帮你列出当前目录的文件...
[tool call] glob(pattern: "*")
[tool result] 找到 5 个文件:
- main.go
- go.mod
- go.sum
- README.md
- cmd/
you> 读取 main.go 文件的内容
[assistant] 我来读取 main.go 文件...
[tool call] read_file(file_path: "main.go")
[tool result] 文件内容如下:
...
```
**注意:** 如果在运行过程中遇到 Tool 报错导致 Agent 中断,请不要 panic,这是正常现象。Tool 报错是常见的情况,例如参数错误、文件不存在等。如何优雅地处理 Tool 错误,我们将在下一章详细介绍。
## Tool 调用流程
当 Agent 需要调用 Tool 时,内部会经历以下循环。你可以把整个过程理解为「思考 → 行动 → 观察」的闭环:
```mermaid
flowchart LR
U[用户提问] --> A{Agent\n分析意图}
A -->|纯对话| R[直接回复]
A -->|需要工具| T1[生成 Tool Call\n参数 JSON]
T1 --> T2[执行 Tool\n读取/写入/搜索等]
T2 --> T3[返回 Tool Result]
T3 --> A2{Agent\n整合信息}
A2 --> R2[生成最终回复]
R2 --> U2[回复用户]
style A fill:#e1f5fe
style T2 fill:#fff3e0
style T3 fill:#e8f5e9
```
**以"列出当前目录的文件"为例:**
> [!example] 逐步拆解
>
> **Step 1 — 意图识别**
> 用户说"列出文件"Agent 判断这不是纯对话请求,而是文件系统操作意图。
>
> **Step 2 — 工具选择与参数生成**
> Agent 从可用工具中选择 `glob`(查找文件列表),生成参数 `{"pattern": "*"}`。
>
> **Step 3 — Tool 执行**
> `ToolsNode` 接收 JSON 参数,反序列化后调用 `GlobInfo`,将结果序列化为 JSON 返回。
>
> **Step 4 — 结果整合**
> Agent 收到 `["main.go", "go.mod", ...]`,整理成自然语言回复:"找到 5 个文件..."。
你是否有想过——Agent 是如何知道该选哪个工具的?关键在于 `BaseTool.Info()` 返回的 **元信息**(名称、描述、参数 schema。ChatModel 基于这些信息来决定调用哪个 Tool 以及传入什么参数。这就是后面会详细展开的 Function Calling 机制。
## 本章小结
| 概念 | 一句话理解 |
|------|-----------|
| **Tool** | Agent 的能力扩展,让它能执行具体操作而非仅对话 |
| **Backend** | 文件系统操作的抽象接口,定义发现、检索、修改三类方法 |
| **LocalBackend** | Backend 的本地实现,开箱即用且路径安全 |
| **DeepAgent** | 预构建的高级 Agent配置 Backend 即可自动获得文件能力 |
| **自动注册** | 声明式配置优于命令式组装——传一个 Backend 就得到五个工具 |
| **调用流程** | 意图分析 → Tool Call → 执行 → 结果 → 回复(闭环迭代) |
## 扩展思考
### 其他 Tool 类型
当前我们只用了文件系统相关 Tool。Eino 生态中还支持更多类型:
- **HTTP Tool**:调用外部 API让 Agent 具备联网能力
- **Database Tool**:查询数据库,适用于数据分析场景
- **Calculator Tool**:精确计算,弥补大模型不擅长数学的问题
- **Code Executor Tool**:运行代码,适合生成并验证算法实现
### 自定义 Tool 创建
除了使用 DeepAgent 自动注册的 Tool你还可以手动创建。最简单的方式是使用 `utils.InferTool` 从函数签名自动推断参数 schema
```go
// 写一个普通 Go 函数...
func Greet(name string) string {
return fmt.Sprintf("Hello, %s!", name)
}
// ...一行代码转成 Tool
greetTool := utils.InferTool(Greet)
```
如果你想了解更多,详见:
- [Tool 接口文档](https://github.com/cloudwego/eino/tree/main/components/tool)
- [Tool 创建示例](https://github.com/cloudwego/eino-examples/tree/main/components/tool)
## 关联笔记
- [[Eino/quick_start/_index]]
- [[Eino/quick_start/chapter_03_memory_and_session]] — Agent 的记忆与会话管理(上一章)
- [[Eino/quick_start/chapter_05_middleware]] — Tool 调用中间件与拦截器(下一章)

View File

@@ -0,0 +1,433 @@
---
tags: ["Eino", "Agent", "Middleware", "DeepAgent", "错误处理", "重试"]
create time: "2026-04-29 16:00"
---
# 第五章Middleware中间件模式
## 概述
第四章为 Agent 加入了 Tool 能力后Agent 已经可以「看见」和「触碰」真实世界了。但现实中的 API 会限流、文件会不存在、网络会超时——**直接暴露的错误会让 Agent 流程中断**。本章通过 Middleware 模式引入拦截器机制,让 Agent 具备错误自愈和自动重试的能力。
## 为什么需要 Middleware
第四章结束时Tool 报错或 ChatModel 报错会直接中断整个对话流程:
```
[tool call] read_file(file_path: "nonexistent.txt")
Error: open nonexistent.txt: no such file or directory
// 💥 对话中断,用户需要重新开始
```
这类错误很常见:
| 场景 | 错误类型 | 常见原因 |
|------|---------|---------|
| **Tool 报错** | 业务错误 | 文件不存在、参数错误、权限不足 |
| **ChatModel 报错** | 临时错误 | API 限流(429)、网络超时、服务不可用 |
> [!tip] 关键洞察
>
> 这些错误**不应该终止 Agent 流程**。更好的做法是把错误信息交给模型,让它自动调整策略继续执行:
>
> ```
> [tool call] read_file(file_path: "nonexistent.txt")
> [tool result] [tool error] open nonexistent.txt: no such file or directory
> [assistant] 抱歉,文件不存在。让我先列出当前目录的文件...
> [tool call] glob(pattern: "*")
> // ✅ 对话继续,模型自行纠错
> ```
> [!question] 深入思考
>
> 既然可以直接把错误返回给模型,为什么不直接在每个 Tool 内部写 `if err != nil` 判断?
> 提示考虑开闭原则OCP——如果明天要加 10 个新 Tool是不是每个都要改一遍**Middleware 的本质是将横切关注点从业务代码中剥离**,这也是 AOP面向切面编程的核心思想。
## 什么是 Middleware
**Middleware 是 Agent 的拦截器**,可以在调用前后插入自定义逻辑:
- **拦截调用**:在 Tool 或 ChatModel 执行前/后包装自定义行为
- **错误转换**:将错误转为模型可理解的字符串,而非中断流程
- **自动重试**:对临时错误(如限流)实现指数退避重试
- **可组合**:多个 Middleware 串联形成责任链
**简单类比:**
- **Agent** = "业务逻辑"
- **Middleware** = "AOP 切面"(日志、重试、错误处理等横切关注点)
> [!note] 装饰器模式
>
> Middleware 的本质是**装饰器模式**Decorator Pattern——每个 Middleware 包装原始调用,可以修改输入、输出或错误,而不改变被包装对象的接口。
## 核心概念
### Middleware 接口
`ChatModelAgentMiddleware` 是 Agent 中间件的统一接口:
```go
type ChatModelAgentMiddleware interface {
BeforeAgent(ctx context.Context, runCtx *ChatModelAgentContext) (context.Context, *ChatModelAgentContext, error)
BeforeModelRewriteState(ctx context.Context, state *ChatModelAgentState, mc *ModelContext) (context.Context, *ChatModelAgentState, error)
AfterModelRewriteState(ctx context.Context, state *ChatModelAgentState, mc *ModelContext) (context.Context, *ChatModelAgentState, error)
WrapInvokableToolCall(ctx context.Context, endpoint InvokableToolCallEndpoint, tCtx *ToolContext) (InvokableToolCallEndpoint, error)
WrapStreamableToolCall(ctx context.Context, endpoint StreamableToolCallEndpoint, tCtx *ToolContext) (StreamableToolCallEndpoint, error)
WrapEnhancedInvokableToolCall(ctx context.Context, endpoint EnhancedInvokableToolCallEndpoint, tCtx *ToolContext) (EnhancedInvokableToolCallEndpoint, error)
WrapEnhancedStreamableToolCall(ctx context.Context, endpoint EnhancedStreamableToolCallEndpoint, tCtx *ToolContext) (EnhancedStreamableToolCallEndpoint, error)
WrapModel(ctx context.Context, m model.BaseChatModel, mc *ModelContext) (model.BaseChatModel, error)
}
```
**方法分组:**
| 分组 | 方法 | 作用时机 |
|------|------|---------|
| **Agent 生命周期** | `BeforeAgent` | 每次 Agent 运行前,可修改指令和工具配置 |
| **状态处理** | `BeforeModelRewriteState` / `AfterModelRewriteState` | 每次模型调用前后的状态变换 |
| **Tool 调用** | `WrapInvokableToolCall` / `WrapStreamableToolCall` | 包装同步/流式 Tool 的执行 |
| **模型调用** | `WrapModel` | 包装底层 ChatModel 的调用 |
### 洋葱模型Middleware 执行顺序
Handlers 按**数组正序**包装,形成洋葱模型:
```go
Handlers: []adk.ChatModelAgentMiddleware{
&middlewareA{}, // 最外层:最先 Wrap最后生效
&middlewareB{}, // 中间层
&middlewareC{}, // 最内层:最后 Wrap最先生效
}
```
```mermaid
flowchart LR
subgraph Request ["📥 请求方向 →"]
A["Middleware A\n(最外层)"] --> B["Middleware B\n(中间层)"]
B --> C["Middleware C\n(最内层)"]
C --> T["实际 Tool/Model\n执行"]
end
subgraph Response ["📤 响应方向 ←"]
T --> CR["Middleware C\n返回"]
CR --> CB["Middleware B\n返回"]
CB --> CA["Middleware A\n返回"]
end
style A fill:#fce4ec
style B fill:#e8f5e9
style C fill:#e3f2fd
style T fill:#fff3e0
```
> [!warning] 实用建议
>
> 将 `safeToolMiddleware`(错误捕获)放在最内层(数组末尾),确保其他 Middleware 抛出的中断错误能正确向外传播,不被吞掉。
### 深入:为什么安全中间件要放在最内层?
很多开发者会问:**为什么不在最外层放一个全局的 `RecoverMiddleware` 来兜底?** 要理解这一点,需要区分三种不同的错误处理方式:
| 方式 | 捕获目标 | 处理策略 | 放置位置 |
|------|---------|---------|---------|
| **安全转换** (`safeToolMiddleware`) | 业务错误(文件不存在、参数错误) | 转为字符串喂给模型,让 Agent 自愈 | **最内层**(工具调用前) |
| **中断传播** | `InterruptRerunError` 等控制信号 | 不做任何转换,原样向上抛出 | 贯穿所有层 |
| **系统兜底** (`defer recover()`) | `panic`nil pointer、数组越界 | 记录日志,保护进程不崩溃 | **入口层**(如 `main()` / `http.Server` |
```go
// ❶ 最内层:业务错误转换 —— 模型可以继续执行
func (m *safeToolMiddleware) WrapInvokableToolCall(...) (...) {
return func(ctx context.Context, args string, opts ...tool.Option) (string, error) {
result, err := endpoint(ctx, args, opts...)
if err != nil {
if _, ok := compose.IsInterruptRerunError(err); ok {
return "", err // ⚠️ 中断错误必须穿透所有中间件
}
return fmt.Sprintf("[tool error] %v", err), nil // ✅ 业务错误转字符串
}
return result, nil
}
}
// ❷ 入口处panic 兜底 —— 保护进程
func main() {
defer func() {
if r := recover(); r != nil {
log.Printf("recovered from panic: %v", r)
}
}()
// ... 启动 Agent 服务
}
```
> [!question] 进阶思考
>
> 如果把 `safeToolMiddleware` 移到最外层(数组首位),会发生什么?
>
> <details>
> <summary>🔍 点击查看推导</summary>
>
> 考虑这个场景:用户在 Agent 多轮对话中点击了"停止"按钮,底层产生了一个 `InterruptRerunError`。
>
> 1. Tool 执行器检测到中断信号,抛出 `InterruptRerunError`
> 2. 如果 `safeToolMiddleware` 在外层——此时请求还在外层尚未进入内层,**中断信号在内层往外冒泡时会首先经过内层中间件**
> 3. 因为中断信号是在最内层的 Tool 处产生的,无论 `safeToolMiddleware` 在哪一层,只要它检查了 `IsInterruptRerunError` 就不会吞掉它
> 4. 但真正的问题是:如果内层的其他逻辑(非中断)也出错,外层中间件还没来得及处理就被内层的错误"跳过"了
>
> 更准确地说,顺序的关键在于:**中间件是按装饰器模式嵌套的,外层包裹内层,内层最先执行也最先返回**。内层先做错误分类,外层再做全局处理,这样既保证了中断信号的畅通,又保证了业务错误的收敛。
> </details>
### ModelRetryConfig内置重试配置
`ModelRetryConfig` 提供了 ChatModel 级别的自动重试能力:
```go
type ModelRetryConfig struct {
MaxRetries int // 最大重试次数
IsRetryAble func(ctx context.Context, err error) bool // 哪些错误可重试
}
```
**重试策略:**
| 策略 | 说明 |
|------|------|
| **指数退避** | 每次重试间隔递增,避免频繁请求加剧限流 |
| **条件过滤** | 通过 `IsRetryAble` 精确控制哪些错误值得重试 |
| **自动恢复** | 无需用户干预,模型调用失败后自动重试 |
## 实现细节
### SafeToolMiddleware错误转换
`SafeToolMiddleware` 捕获 Tool 执行时的错误,将其转换为字符串返回给模型而非中断流程:
```go
type safeToolMiddleware struct {
*adk.BaseChatModelAgentMiddleware
}
func (m *safeToolMiddleware) WrapInvokableToolCall(
_ context.Context,
endpoint adk.InvokableToolCallEndpoint,
_ *adk.ToolContext,
) (adk.InvokableToolCallEndpoint, error) {
return func(ctx context.Context, args string, opts ...tool.Option) (string, error) {
result, err := endpoint(ctx, args, opts...)
if err != nil {
// ❗ 中断错误不转换,需要继续向外传播
if _, ok := compose.IsInterruptRerunError(err); ok {
return "", err
}
// ✅ 普通错误转为字符串,交给模型处理
return fmt.Sprintf("[tool error] %v", err), nil
}
return result, nil
}, nil
}
```
**设计要点:**
- **区分错误类型**:中断错误(如主动要求停止)必须传播,业务错误(如文件不存在)可以转换
- **不吞错**:只转换预期的业务错误,真正的系统异常仍向上抛出
- **格式化**:使用 `[tool error]` 前缀方便模型识别并回复时引用
流式 Tool 的错误处理同理,需将错误封装为单帧流:
```go
func (m *safeToolMiddleware) WrapStreamableToolCall(
_ context.Context,
endpoint adk.StreamableToolCallEndpoint,
_ *adk.ToolContext,
) (adk.StreamableToolCallEndpoint, error) {
return func(ctx context.Context, args string, opts ...tool.Option) (*schema.StreamReader[string], error) {
sr, err := endpoint(ctx, args, opts...)
if err != nil {
if _, ok := compose.IsInterruptRerunError(err); ok {
return nil, err
}
// 返回包含错误信息的单帧流
return singleChunkReader(fmt.Sprintf("[tool error] %v", err)), nil
}
return safeWrapReader(sr), nil
}, nil
}
```
### 注册 Middleware 与重试配置
将 Middleware 注入 DeepAgent 的配置中:
```go
agent, err := deep.New(ctx, &deep.Config{
Name: "Ch05MiddlewareAgent",
Description: "ChatWithDoc agent with safe tool middleware and retry.",
ChatModel: cm,
Instruction: agentInstruction,
Backend: backend,
StreamingShell: backend,
MaxIteration: 50,
// ⭐ 注册 Middleware
Handlers: []adk.ChatModelAgentMiddleware{
&safeToolMiddleware{}, // 将 Tool 错误转为字符串
},
// ⭐ 注册模型重试配置
ModelRetryConfig: &adk.ModelRetryConfig{
MaxRetries: 5,
IsRetryAble: func(_ context.Context, err error) bool {
return strings.Contains(err.Error(), "429") ||
strings.Contains(err.Error(), "Too Many Requests")
},
},
})
```
> [!note] Handlers vs Middlewares
>
> `Handlers` 字段(在 Config 中)和 "Middleware"(文档讨论的概念)是同一回事——`Handlers` 是配置字段名,而 `ChatModelAgentMiddleware` 是对接口的命名。
## 执行流程
结合 Middleware 后,一次 Tool 调用的完整生命周期如下:
```mermaid
flowchart TD
U["用户:读取不存在的文件"] --> A{"Agent 分析意图"}
A -->|"决定调用 Tool"| M["SafeToolMiddleware\n拦截 Tool 调用"]
M --> T["执行 read_file\n返回错误"]
T --> E["SafeToolMiddleware\n捕获错误"]
E -->|"非中断错误"| S["转换为字符串\ntool error: no such file"]
E -->|"中断错误"| EP["向上抛出中断"]
S --> R["返回 Tool Result"]
R --> AG{"Agent 整合信息"}
AG -->|"生成解释性回复"| O["抱歉,文件不存在...\n尝试列出目录"]
AG -->|"需要更多信息"| A
style M fill:#e8f5e9
style E fill:#fff3e0
style S fill:#e3f2fd
style EP fill:#ffebee
```
> [!example] 逐步拆解
>
> **Step 1 — 用户输入**
> 用户请求读取一个不存在的文件。
>
> **Step 2 — 意图分析**
> Agent 判断需要文件系统操作,决定调用 `read_file` Tool。
>
> **Step 3 — Middleware 拦截**
> `SafeToolMiddleware.WrapInvokableToolCall` 在 Tool 执行前被触发,注册了自己的回调逻辑。
>
> **Step 4 — Tool 执行**
> 实际文件读取操作失败,返回 `open nonexistent.txt: no such file` 错误。
>
> **Step 5 — 错误转换**
> Middleware 发现这不是中断错误,将其包装为 `[tool error] open nonexistent.txt: ...` 字符串。
>
> **Step 6 — Agent 自愈**
> Agent 收到带错误的 Tool Result理解后回复用户并调整策略如改用 `glob` 列出可用文件)。
## 扩展Eino 内置 Middleware
Eino 生态还提供了以下开箱即用的中间件:
<table>
<tr><th>Middleware</th><th>功能说明</th></tr>
<tr><td><strong>reduction</strong></td><td>工具输出缩减——当工具返回过长时自动截断并存入文件系统,防止上下文溢出</td></tr>
<tr><td><strong>summarization</strong></td><td>对话历史摘要——Token 超阈值时自动生成摘要压缩历史,节省上下文空间</td></tr>
<tr><td><strong>skill</strong></td><td>技能加载——让 Agent 按需动态加载预定义的 SKILL.md 知识包</td></tr>
</table>
### 多 Middleware 组合示例
```go
import (
"github.com/cloudwego/eino/adk/middlewares/reduction"
"github.com/cloudwego/eino/adk/middlewares/summarization"
)
// 创建 reduction管理工具输出长度
reductionMW, _ := reduction.New(ctx, &reduction.Config{
Backend: filesystemBackend,
MaxLengthForTrunc: 50000,
MaxTokensForClear: 30000,
})
// 创建 summarization自动压缩对话历史
summarizationMW, _ := summarization.New(ctx, &summarization.Config{
Model: chatModel,
Trigger: &summarization.TriggerCondition{
ContextTokens: 190000,
},
})
// 组合使用
agent, _ := adk.NewChatModelAgent(ctx, &adk.ChatModelAgentConfig{
Handlers: []adk.ChatModelAgentMiddleware{
summarizationMW, // 外层:对话历史摘要
reductionMW, // 内层:工具输出缩减
},
})
```
> [!question] 扩展思考
>
> 在这个例子中,`summarizationMW` 在外层、`reductionMW` 在内层。如果把顺序反过来,会有什么影响?试着根据洋葱模型的执行顺序推导一下。
## 代码位置
- 入口代码:[cmd/ch05/main.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/cmd/ch05/main.go)
## 前置条件
与第一章一致:需要配置一个可用的 ChatModelOpenAI 或 Ark。同时需要与第四章一样设置 `PROJECT_ROOT`
```bash
export PROJECT_ROOT=/path/to/eino # Eino 核心库根目录
```
## 运行
`examples/quickstart/chatwitheino` 目录下执行:
```bash
export PROJECT_ROOT=/path/to/your/project
go run ./cmd/ch05
```
**输出示例:**
```
you> 列出当前目录的文件
[assistant] 我来帮你列出文件...
[tool call] list_files(directory: ".")
you> 读取一个不存在的文件
[assistant] 尝试读取文件...
[tool call] read_file(file_path: "nonexistent.txt")
[tool result] [tool error] open nonexistent.txt: no such file or directory
[assistant] 抱歉,文件不存在...
```
## 本章小结
| 概念 | 一句话理解 |
|------|-----------|
| **Middleware** | Agent 的拦截器,在调用前后插入自定义逻辑 |
| **SafeToolMiddleware** | 将 Tool 错误转为字符串交给模型,而非中断流程 |
| **ModelRetryConfig** | 配置 ChatModel 的自动重试,处理限流等临时错误 |
| **洋葱模型** | 请求从外向内穿过 Middleware响应从内向外返回 |
| **装饰器模式** | 每个 Middleware 包装原始调用,可修改输入、输出或错误 |
| **中断错误不转换** | 只有业务错误才转字符串,中断错误继续传播 |
## 关联笔记
- [[Eino/quick_start/_index]]
- [[Eino/quick_start/chapter_04_tool_and_filesystem]] — Tool 与文件系统访问(上一章)
- [[Eino/quick_start/chapter_06_callback_and_trace]] — Callback 与 Trace 可观测性(下一章)

View File

@@ -0,0 +1,323 @@
---
tags: [Eino, Callback, Trace, 可观测性, CozeLoop]
create time: 2026-04-29 15:30
---
# Eino 快速入门 · 第六章Callback 与 Trace可观测性
## 概述
在构建 Agent 应用时,我们常常面临一个核心问题:**Agent 内部到底发生了什么?** 本章将介绍 Eino 的 Callback 机制——一套非侵入式的旁路钩子系统,让你能在不改动业务代码的前提下,获取组件生命周期的每一个关键信息。通过 Callback 配合 CozeLoop你将获得完整的链路追踪、性能指标和错误定位能力。
## 代码位置
- 入口代码:[cmd/ch06/main.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/cmd/ch06/main.go)
## 前置条件
与第一章一致:需要配置一个可用的 ChatModelOpenAI 或 Ark。同时需要与第四章一样设置 `PROJECT_ROOT`
```bash
export PROJECT_ROOT=/path/to/eino # Eino 核心库根目录(不设置则默认使用当前目录)
```
可选:配置 CozeLoop 实现链路追踪:
```bash
export COZELOOP_WORKSPACE_ID=your_workspace_id
export COZELOOP_API_TOKEN=your_token
```
## 运行
`examples/quickstart/chatwitheino` 目录下执行:
```bash
# 设置项目根目录
export PROJECT_ROOT=/path/to/your/project
# 可选:配置 CozeLoop
export COZELOOP_WORKSPACE_ID=your_workspace_id
export COZELOOP_API_TOKEN=your_token
go run ./cmd/ch06
```
输出示例:
```
[trace] starting session: 083d16da-6b13-4fe6-afb0-c45d8f490ce1
you> 你好
[trace] chat_model_generate: model=gpt-4.1-mini tokens=150
[trace] tool_call: name=list_files duration=23ms
[assistant] 你好!有什么我可以帮助你的吗?
```
## 从黑盒到白盒:为什么需要 Callback
前几章我们实现的 Agent 是一个"黑盒":输入问题,输出答案,但中间发生了什么我们并不清楚。
**黑盒的问题:**
- 不知道模型调用了多少次
- 不知道 Tool 执行了多长时间
- 不知道 Token 消耗了多少
- 出问题时难以定位原因
> [!NOTE] Callback 定位
> Callback 是 Eino 的**旁路机制**——从 component 到 compose一以贯之。它在固定点位触发可抽取实时信息输入、输出、错误、流式数据用途覆盖观测、日志、指标、追踪、调试、审计等场景。
**类比理解:**
- **Agent** = "业务逻辑"(主路)
- **Callback** = "旁路钩子"(在固定点位抽取信息)
## 关键概念
### Handler 接口
`Handler` 是 Eino 中定义回调处理器的核心接口:
```go
type Handler interface {
// 非流式输入(组件开始处理前)
OnStart(ctx context.Context, info *RunInfo, input CallbackInput) context.Context
// 非流式输出(组件成功返回后)
OnEnd(ctx context.Context, info *RunInfo, output CallbackOutput) context.Context
// 错误(组件返回错误时)
OnError(ctx context.Context, info *RunInfo, err error) context.Context
// 流式输入(组件接收流式输入时)
OnStartWithStreamInput(ctx context.Context, info *RunInfo,
input *schema.StreamReader[CallbackInput]) context.Context
// 流式输出(组件返回流式输出时)
OnEndWithStreamOutput(ctx context.Context, info *RunInfo,
output *schema.StreamReader[CallbackOutput]) context.Context
}
```
**设计理念:**
- **旁路机制**:不干扰主流程,在固定点位抽取信息
- **全流程覆盖**:从 component 到 compose 到 adk所有组件都支持
- **状态传递**:同一 Handler 的 OnStart→OnEnd 可通过 context 传递状态
- **性能优化**:实现 `TimingChecker` 接口可跳过不需要的时机
> [!TIP] RunInfo 结构
> `RunInfo` 携带了组件运行时身份,是日志和追踪中最重要的标识信息。
```go
type RunInfo struct {
Name string // 业务名称(节点名或用户指定)
Type string // 实现类型(如 "OpenAI"
Component string // 组件类型(如 "ChatModel"
}
```
> [!IMPORTANT] 流式回调注意事项
> - 流式回调必须关闭 StreamReader否则会导致 goroutine 泄漏
> - 不要修改 Input/Output它们被所有下游共享
> - RunInfo 可能为 nil使用前需要检查
### CozeLoop
CozeLoop 是字节跳动开源的 AI 应用可观测性平台,提供了:
- **链路追踪**:完整的调用链路可视化
- **指标监控**延迟、Token 消耗、错误率等
- **日志聚合**:集中管理所有日志
- **调试支持**:在线查看和调试
**集成方式:**
```go
import (
clc "github.com/cloudwego/eino-ext/callbacks/cozeloop"
"github.com/cloudwego/eino/callbacks"
"github.com/coze-dev/cozeloop-go"
)
// 创建 CozeLoop 客户端
client, err := cozeloop.NewClient(
cozeloop.WithAPIToken(apiToken),
cozeloop.WithWorkspaceID(workspaceID),
)
// 注册为全局 Callback
callbacks.AppendGlobalHandlers(clc.NewLoopHandler(client))
```
### Callback 的触发时机
Callback 在组件生命周期的 5 个关键时机触发。下表中 `Timing*` 是 Eino 内部常量名(用于 `TimingChecker` 接口),对应的 Handler 接口方法是右侧所示:
<table>
<tr><td>时机常量</td><td>对应 Handler 方法</td><td>触发点</td><td>输入/输出</td></tr>
<tr><td>TimingOnStart</td><td>OnStart</td><td>组件开始处理前</td><td>CallbackInput</td></tr>
<tr><td>TimingOnEnd</td><td>OnEnd</td><td>组件成功返回后</td><td>CallbackOutput</td></tr>
<tr><td>TimingOnError</td><td>OnError</td><td>组件返回错误时</td><td>error</td></tr>
<tr><td>TimingOnStartWithStreamInput</td><td>OnStartWithStreamInput</td><td>组件接收流式输入时</td><td>StreamReader[CallbackInput]</td></tr>
<tr><td>TimingOnEndWithStreamOutput</td><td>OnEndWithStreamOutput</td><td>组件返回流式输出时</td><td>StreamReader[CallbackOutput]</td></tr>
</table>
**非流式调用时序:**
```mermaid
sequenceDiagram
participant Client as 业务代码
participant CM as ChatModel
participant CB as Callback Handler
Client->>CM: Generate(ctx, messages)
CM->>CB: OnStart(messages)
Note over CB: "记录输入,启动计时"
CM->>CM: 模型处理
CM->>CB: OnEnd(response)
Note over CB: "记录输出,计算耗时"
CM-->>Client: response
```
**流式调用时序:**
```mermaid
sequenceDiagram
participant Client as 业务代码
participant CM as ChatModel
participant CB as Callback Handler
Client->>CM: Stream(ctx, messages)
CM->>CB: OnStart(messages)
Note over CB: "记录输入,启动计时"
CM->>CM: 模型处理(流式)
CM->>CB: OnEndWithStreamOutput(reader)
Note over CB: "返回 StreamReader逐 chunk 消费"
loop 逐块消费
CB->>CB: reader.Read()
CB->>CB: 处理 chunk
end
CM-->>Client: stream chunks
```
> [!WARNING] 流式错误处理
> 流式错误stream 中途出错)**不会触发 OnError**,而是在 StreamReader 中返回。消费时务必检查 `reader.Err()`。
### TimingChecker 优化
如果你的 Handler 不需要某些时机(比如只关心错误),可以实现 `TimingChecker` 接口来跳过不必要的调用开销:
```go
func (h *MyHandler) TimingChecker(timing callbacks.Timing) bool {
// 只启用 Error 检测,其余跳过
return timing == callbacks.TimingOnError
}
```
## Callback 实战
### 实现自定义 Callback Handler
直接实现全部 5 个方法比较繁琐。Eino 提供了 `callbacks.HandlerHelper` 链式构建器,只需注册感兴趣的回调:
```go
import "github.com/cloudwego/eino/callbacks"
// 使用 NewHandlerHelper 注册感兴趣的回调
handler := callbacks.NewHandlerHelper().
OnStart(func(ctx context.Context, info *callbacks.RunInfo, input callbacks.CallbackInput) context.Context {
log.Printf("[trace] %s/%s start", info.Component, info.Name)
return ctx
}).
OnEnd(func(ctx context.Context, info *callbacks.RunInfo, output callbacks.CallbackOutput) context.Context {
log.Printf("[trace] %s/%s end", info.Component, info.Name)
return ctx
}).
OnError(func(ctx context.Context, info *callbacks.RunInfo, err error) context.Context {
log.Printf("[trace] %s/%s error: %v", info.Component, info.Name, err)
return ctx
}).
Handler()
// 注册为全局 Callback
callbacks.AppendGlobalHandlers(handler)
```
**注意**`RunInfo` 可能为 `nil`(如顶层调用),使用前务必检查。
### 集成与注册
完整代码见 [cmd/ch06/main.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/cmd/ch06/main.go)。核心流程如下:
```go
func main() {
ctx := context.Background()
// 1. 可选:注册自定义日志 Callback
handler := callbacks.NewHandlerHelper().
OnStart(func(ctx context.Context, info *callbacks.RunInfo, input callbacks.CallbackInput) context.Context {
log.Printf("[trace] %s/%s start", info.Component, info.Name)
return ctx
}).
OnEnd(func(ctx context.Context, info *callbacks.RunInfo, output callbacks.CallbackOutput) context.Context {
log.Printf("[trace] %s/%s end", info.Component, info.Name)
return ctx
}).
Handler()
callbacks.AppendGlobalHandlers(handler)
// 2. 可选:启用 CozeLoop 链路追踪
apiToken := os.Getenv("COZELOOP_API_TOKEN")
workspaceID := os.Getenv("COZELOOP_WORKSPACE_ID")
if apiToken != "" && workspaceID != "" {
client, _ := cozeloop.NewClient(
cozeloop.WithAPIToken(apiToken),
cozeloop.WithWorkspaceID(workspaceID),
)
defer func() {
time.Sleep(5 * time.Second) // 等待数据上报
client.Close(ctx)
}()
callbacks.AppendGlobalHandlers(clc.NewLoopHandler(client))
}
// 3. 正常创建并运行 Agent...
}
```
## 可观测性的三大价值
通过 Callback 收集的数据,我们可以实现三个层面的可观测性:
```mermaid
quadrantChart
title Observability Dimensions
x-axis Low Impact --> High Impact
y-axis Low Cost --> High Value
"错误追踪": [0.8, 0.9]
"成本优化": [0.6, 0.7]
"性能分析": [0.4, 0.5]
"审计合规": [0.9, 0.8]
```
| 维度 | 关键指标 | 典型场景 |
|------|----------|----------|
| **错误追踪** | 错误类型、堆栈、出错节点 | Agent 响应异常时快速定位是模型侧还是 Tool 侧的问题 |
| **成本优化** | Token 消耗、每轮对话花费 | 识别高消耗对话,优化 Prompt 或切换更经济的模型 |
| **性能分析** | 延迟分布、耗时 Top N | 发现慢查询——某个 Tool 执行时间过长影响整体体验 |
## 本章小结
> [!SUMMARY] 要点回顾
> - **Callback** 是 Eino 的非侵入式观测钩子,在组件生命周期的 5 个时机触发
> - 使用 `callbacks.HandlerHelper` 可链式构建 Handler只注册感兴趣的回调
> - 通过 `callbacks.AppendGlobalHandlers` 注册全局 Callback业务代码零修改
> - **CozeLoop** 提供开箱即用的链路追踪和可视化
> - 结合 `TimingChecker` 可实现性能最优的按需检测
## 关联笔记
- [[Eino/quick_start/chapter_01_hello_eino.md]]
- [[Eino/quick_start/chapter_04_tool.md]]
- [[Eino/quick_start/chapter_05_agent.md]]

View File

@@ -0,0 +1,272 @@
---
tags: ["Eino", "Agent", "Interrupt", "Resume", "Backend", "DeepAgent", "审批流"]
create time: "2026-04-29 15:30"
---
# 第七章Interrupt / Resume中断与恢复
## 概述
本章引入 Eino 的 **Interrupt / Resume** 机制——一种在人机协作中实现人工审批的能力。当 Agent 需要执行敏感操作(如删除文件、发送邮件、执行命令)时,可以在执行前暂停并等待用户确认;确认后继续,拒绝则返回错误。这是让 Agent 从"全自动"走向"安全可控"的关键一步。
## 为什么需要 Interrupt
前三章我们逐步为 Agent 添加工具能力,使其能够读取文件、搜索代码、执行命令。但全自动执行工具也存在风险:
| 风险场景 | 后果 |
|---------|------|
| 误删文件 | 不可逆的数据丢失 |
| 发送错误邮件 | 严重的沟通事故 |
| 执行危险命令 | 系统环境被破坏 |
| 修改关键配置 | 服务不可用 |
**Interrupt 的定位:**
- **Interrupt 是 Agent 的暂停机制**:在关键操作前暂停,等待用户确认
- **Interrupt 可携带信息**:向用户展示即将执行的操作详情
- **Interrupt 可恢复**:确认后继续执行,拒绝后优雅返回错误
> [!tip] 简单类比
>
> - **自动执行** = "自动驾驶"(完全信任系统)
> - **Interrupt** = "人工接管"(关键决策由人来做)
## 关键概念
### Interrupt 的两阶段执行
一个受审批保护的 Tool 在执行时被分成两个阶段:
```mermaid
flowchart LR
A["Agent\n决定调用 Tool"] --> B{"Tool 内部"}
B -->|"第一阶段"| C["保存参数"]
C --> D["触发 Interrupt"]
D --> E["Runner 暂停"]
E --> F["向调用方返回\nInterrupt 事件"]
F --> G["用户看到审批提示"]
G --> H{"用户选择"}
H -->|"批准"| I["runner.ResumeWith... 带上审批结果"]
H -->|"拒绝"| J["runner.ResumeWith... 带上拒绝结果"]
I --> K{"Tool 内部"}
J --> K
K -->|"第二阶段 Resume"| L["读取审批结果"]
L -->|"Approved"| M["执行实际操作"]
L -->|"Rejected"| N["操作被拒绝"]
```
核心 API
| API | 作用 |
|------|------|
| `tool.GetInterruptState[T](ctx)` | 判断当前是第一阶段还是 Resume 后的第二阶段 |
| `tool.StatefulInterrupt(ctx, info, state)` | 触发中断,`info` 展示给用户,`state` 供 Resume 后取回 |
| `tool.GetResumeContext[T](ctx)` | 获取用户的审批结果数据 |
> [!note] 两阶段设计精妙之处
>
> 同一个 Tool 函数被调用两次,通过 `GetInterruptState` 区分:第一次返回 false触发中断第二次返回 trueResume 恢复)。这种"自反式"设计无需引入额外的状态机或外部协调器,中断逻辑就内聚在 Tool 自身内部。
### ApprovalMiddleware
生产实践中,推荐将中断逻辑放入 **Middleware** 而非每个 Tool 内部实现。这样审批规则集中管理、Tool 本身保持干净:
ApprovalMiddleware 拦截特定的 Tool 调用(如 `execute`),对每次调用统一施加审批逻辑:
```go
type approvalMiddleware struct {
*adk.BaseChatModelAgentMiddleware
}
func (m *approvalMiddleware) WrapInvokableToolCall(
_ context.Context,
endpoint adk.InvokableToolCallEndpoint,
tCtx *adk.ToolContext,
) (adk.InvokableToolCallEndpoint, error) {
// 仅拦截需审批的 Tool例如 execute
if tCtx.Name != "execute" {
return endpoint, nil
}
return func(ctx context.Context, args string, opts ...tool.Option) (string, error) {
wasInterrupted, _, storedArgs := tool.GetInterruptState[string](ctx)
if !wasInterrupted {
// 第一次调用 → 触发中断
return "", tool.StatefulInterrupt(ctx, &commontool.ApprovalInfo{
ToolName: tCtx.Name,
ArgumentsInJSON: args,
}, args)
}
// Resume 阶段 → 检查用户是否批准
isTarget, hasData, data := tool.GetResumeContext[*commontool.ApprovalResult](ctx)
if isTarget && hasData {
if data.Approved {
return endpoint(ctx, storedArgs, opts...) // 通过中间件继续原 Tool 的执行
}
reason := ""
if data.DisapproveReason != nil {
reason = fmt.Sprintf(": %s", *data.DisapproveReason)
}
return fmt.Sprintf("tool '%s' disapproved%s", tCtx.Name, reason), nil
}
// 非目标 Tool → 重新中断
return "", tool.StatefulInterrupt(ctx, &commontool.ApprovalInfo{
ToolName: tCtx.Name,
ArgumentsInJSON: storedArgs,
}, storedArgs)
}, nil
}
```
> [!warning] Streamable 变体不可遗漏
>
> 如果 Agent 启用了流式输出EnableStreaming: true某些 Tool 调用可能走 `StreamableToolCall` 路径。此时必须同时实现 `WrapStreamableToolCall`否则审批逻辑会被绕过。ch07 完整代码中两者都已覆盖。
### CheckPointStore
中断恢复还需要一个持久化组件来保存执行状态——这就是 `CheckPointStore`
```go
type CheckPointStore interface {
Put(ctx context.Context, key string, checkpoint *Checkpoint) error
Get(ctx context.Context, key string) (*Checkpoint, error)
}
```
它的作用不止于存储 Tool 参数,还包括 Runner 当前的执行进度。有了它,即使进程重启也能从中断点继续:
> [!example] CheckPointStore 的两种典型实现
>
> | 实现方式 | 适用场景 | 跨进程恢复 |
> |---------|---------|----------|
> | `adkstore.NewInMemoryStore()` | 开发调试、单进程 | ❌ |
> | Redis / SQLite 等外部存储 | 生产部署 | ✅ |
## 代码实现
### 1. 配置 Runner 使用 CheckPointStore
```go
runner := adk.NewRunner(ctx, adk.RunnerConfig{
Agent: agent,
EnableStreaming: true,
CheckPointStore: adkstore.NewInMemoryStore(), // 内存存储
})
```
### 2. 配置 Agent 注册中间件
```go
agent, err := deep.New(ctx, &deep.Config{
// ... 其他配置
Handlers: []adk.ChatModelAgentMiddleware{
&approvalMiddleware{}, // 审批中间件
&safeToolMiddleware{}, // 将 Tool 错误转为字符串(中断类错误继续向上抛出)
},
})
```
### 3. 处理 Runner 返回的事件
```go
checkPointID := sessionID
events := runner.Run(ctx, history, adk.WithCheckPointID(checkPointID))
content, interruptInfo, err := printAndCollectAssistantFromEvents(events)
if interruptInfo != nil {
// 使用同一个 stdin reader 读取「用户输入」与「审批 y/n」
// 避免审批输入被误认为下一轮对话消息
content, err = handleInterrupt(ctx, runner, checkPointID, interruptInfo, reader)
if err != nil {
return err
}
}
```
### 4. 完整的审批交互流程
```mermaid
flowchart TD
U["用户:执行命令 echo hello"] --> S1["你> 请执行命令 echo hello"]
S1 --> AGT["Runner.Run() 启动执行"]
AGT --> A["Agent 分析意图\n决定调用 execute 工具"]
A --> AM["ApprovalMiddleware\n拦截 Tool 调用"]
AM --> SI["触发 StatefulInterrupt\n保存参数到 Store"]
SI --> EVT["返回 Interrupt 事件"]
EVT --> UI["控制台显示审批提示"]
UI --> USER{"用户选择"}
USER -->|"y"| RESUME["runner.ResumeWith\n携带审批结果 Approved=true"]
USER -->|"n"| REJECT_DIRECT["runner.ResumeWith\n携带审批结果 Approved=false"]
RESUME --> RTOOL["Tool 再次被调用\nGetInterruptState = true\n读取审批结果并批准"]
RTOOL --> EXEC["执行 execute\necho hello"]
EXEC --> OUT["输出: hello"]
REJECT_DIRECT --> RTOOL2["Tool 再次被调用\nGetInterruptState = true\n读取审批结果并拒绝"]
RTOOL2 --> NOP["输出: tool disapproved"]
```
## 运行
`examples/quickstart/chatwitheino` 目录下执行:
```bash
export PROJECT_ROOT=/path/to/eino # Eino 核心库根目录(不设置则默认使用当前目录)
go run ./cmd/ch07
```
输出示例:
```
you> 请执行命令 echo hello
⚠️ Approval Required ⚠️
Tool: execute
Arguments: {"command":"echo hello"}
Approve this action? (y/n): y
[tool result] hello
hello
```
> [!question] 深入思考
>
> 上面的输出中有两条 `hello`——一条来自 `[tool result]`,另一条是 Assistant 的最终回复。你能解释它们分别来自哪里吗?
> 提示:第一条是 `printAndCollectAssistantFromEvents` 对流式事件中 Tool Result 片段的打印,第二条是 Agent 整合信息后生成的自然语言回复。理解了这一点,你就掌握了 Eino 事件模型的核心。
## 本章小结
| 概念 | 一句话理解 |
|------|-----------|
| **Interrupt** | Agent 在敏感操作前的暂停机制 |
| **Resume** | 用户审批后恢复执行,支持批准与拒绝两种结果 |
| **Two-stage Execution** | 同一个 Tool 被调用两次,通过 `GetInterruptState` 区分阶段 |
| **ApprovalMiddleware** | 集中式拦截特定 Tool 的审批逻辑,使 Tool 保持干净 |
| **CheckPointStore** | 保存中断状态和执行位置,支持跨进程恢复 |
| **人机协作** | 关键决策由人类确认,兼顾 Agent 自动化与安全可控 |
## 扩展思考
### 更多 Interrupt 应用场景
| 场景 | 说明 |
|------|------|
| 多选项审批 | 用户从多个选项中选择一个(而非简单的 y/n |
| 参数补全 | 用户提供缺失的参数值后才继续执行 |
| 条件分支 | 用户决定不同的执行路径 |
### 审批策略
| 策略 | 适用场景 |
|------|---------|
| 白名单 | 只审批极少数敏感操作(推荐默认做法) |
| 黑名单 | 审批所有操作,除已知的安全操作外 |
| 动态规则 | 根据参数内容决定是否审批(如文件大小、操作范围) |
## 关联笔记
- [[Eino/quick_start/_index]]
- [[Eino/quick_start/chapter_04_tool_and_filesystem]] — 文件系统访问与 DeepAgent第三章的工具章节
- [[Eino/quick_start/chapter_05_middleware]] — Middleware 模式详解(上一章)

View File

@@ -0,0 +1,368 @@
---
tags: ["Eino", "Agent", "GraphTool", "Compose", "Workflow", "Backend"]
create time: "2026-04-29 15:30"
---
# 第八章Graph Tool复杂工作流
## 概述
本章引入 Eino 的 **Graph Tool** 能力——将复杂的编排工作流封装为一个可调用的 Tool。通过 `compose.Workflow` 构建包含读取、分块、并行评分、筛选和答案生成的多步骤流水线,让 Agent 能够处理需要多阶段协同的大文件 RAG 场景。
> [!tip] 一句话理解 Graph Tool
>
> **简单 Tool = 单步操作**(如读取文件),**Graph Tool = 完整流水线**(读取 → 分块 → 并行评分 → 筛选 → 生成答案)。它是 compose 编排能力的 Tool 化封装入口。
## 代码位置
- 入口代码:[cmd/ch08/main.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/cmd/ch08/main.go)
- RAG 实现:[rag/rag.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/rag/rag.go)
## 前置条件
与第一章一致:需要配置一个可用的 ChatModelOpenAI 或 Ark
## 运行
`examples/quickstart/chatwitheino` 目录下执行:
```bash
# 设置项目根目录
export PROJECT_ROOT=/path/to/your/project
go run ./cmd/ch08
```
输出示例:
```
you> 请帮我分析 RFC6455 文档中关于 WebSocket 握手的部分
[assistant] 我来帮你分析文档...
[tool call] answer_from_document(file_path: "rfc6455.txt", question: "WebSocket 握手过程")
[tool result] 找到 3 个相关片段,正在生成答案...
[assistant] 根据 RFC6455 文档WebSocket 握手过程如下...
```
## 从简单 Tool 到 Graph Tool为什么需要复杂工作流
第四章我们创建了简单的 Tool每个 Tool 执行单一任务。但实际场景中,很多任务需要多个步骤协同完成。
**简单 Tool 的局限:**
| 局限 | 说明 |
|------|------|
| 单一职责 | 每个 Tool 只能做一件事(读文件、搜索等) |
| 无法并行 | 多个独立子任务不能同时执行 |
| 难以复用 | 复杂逻辑硬编码在调用链中,无法单独测试和复用 |
**重要说明:本章只是展示 compose/graph/workflow 能力的一角。**
从更大的视角看Eino 的 `compose` 包提供了非常通用、确定性的编排能力:你可以把任何需要"确定性业务流程"的系统,用 `compose` 的 Graph/Chain/Workflow 组织成可执行的流水线,并且它能够**原生编排 Eino 的所有 component**ChatModel、Prompt、Tools、Retriever、Embedding、Indexer 等),同时具备完整的 **callback** 体系,以及 **interrupt/resume + checkpoint** 支持。
### Graph Tool 的定位
> [!note] Graph Tool vs 简单 Tool
>
> | 对比项 | 简单 Tool | Graph Tool |
> |--------|----------|------------|
> | 本质 | 单步函数 | compose 编排产物的封装 |
> | 编排 | 无 | 由 compose 提供(并行、分支、字段映射) |
> | 状态管理 | 无 | 节点间传递数据 + checkpoint 持久化 |
> | 中断恢复 | 不支持 | 支持(嵌套 interrupt 场景) |
### 核心类比
> [!tip] 厨房做菜类比
>
> - **简单 Tool**:像是一个厨具(菜刀——只负责切东西)
> - **Graph Tool**:像是一条预制菜流水线(备料 → 烹饪 → 摆盘——每一步自动衔接,你只需说"做这道菜"
## 关键概念
### compose.Workflow
`compose.Workflow` 是 Eino 中构建有状态工作流的核心组件。与线性 Chain 不同Workflow 允许创建 DAG有向无环图支持汇聚节点、并行分支和非相邻连接
```go
wf := compose.NewWorkflow[Input, Output]()
// 添加节点并建立连接
wf.AddLambdaNode("load", loadFunc).AddInput(compose.START)
wf.AddLambdaNode("chunk", chunkFunc).AddInput("load")
wf.AddLambdaNode("answer", answerFunc).
AddInput("chunk").
AddInputWithOptions(compose.START,
[]*compose.FieldMapping{compose.MapFields("Question", "Question")},
compose.WithNoDirectDependency())
wf.End().AddInput("answer")
```
> [!question] 深入思考
>
> Workflow 为什么需要 `START` 和 `END` 这两个虚拟节点,而不是直接指定输入输出?
> 提示想想如果工作流有多个入口点例如用户可以直接跳转到某个中间节点重试或者需要在运行时动态插入新节点。START/END 为这些灵活性提供了统一的锚点。
### BatchNode并行处理
`BatchNode` 用于并行处理一批独立任务,充分利用计算资源:
```go
scorer := batch.NewBatchNode(&batch.NodeConfig[scoreTask, scoredChunk]{
Name: "ChunkScorer",
InnerTask: newScoreWorkflow(cm), // 单个 chunk 的评分流程
MaxConcurrency: 5, // 最大并发数
})
```
**工作原理:**
1. 接收任务切片作为输入
2.`MaxConcurrency` 限制并行调度(内部使用 goroutine pool
3. 所有结果收集后按顺序返回
> [!tip] 选择 MaxConcurrency 的原则
>
> - 过低 → 浪费了并发能力,响应慢
> - 过高 → 资源竞争LLM API 限流
> - 推荐做法:以 LLM Provider 的 QPS 上限为参考值,一般 3~10 之间调整
### FieldMapping跨节点数据传递
FieldMapping 解决非相邻节点间的数据传递问题:当两个节点没有直接的边连接时,你需要显式声明数据的来源和目标字段。
```go
wf.AddLambdaNode("score", scoreFunc).
// 从 "chunk" 节点取 All 数据,映射到当前节点的 Chunks 字段
AddInputWithOptions("chunk",
[]*compose.FieldMapping{compose.ToField("Chunks")},
compose.WithNoDirectDependency()).
// 从 START 节点取 Question 字段,直接映射到当前节点的 Question 字段
AddInputWithOptions(compose.START,
[]*compose.FieldMapping{compose.MapFields("Question", "Question")},
compose.WithNoDirectDependency())
```
**三种 FieldMapping 方式:**
| 方法 | 作用 | 适用场景 |
|------|------|---------|
| `MapFields(src, dst)` | 字段重命名映射 | 两端字段名不一致时 |
| `ToField(dst)` | 整条数据映射到单一字段 | 上游只有一个输出,且需包裹到 struct |
| `All()` | 传入上游全部输出(默认行为) | 相邻节点间的直接传递 |
**为什么非相邻节点需要 `WithNoDirectDependency`**
Eino 依赖图检测会验证节点的输入是否来自前驱节点。当使用 FieldMapping 跨越层级取值时,必须显式标记 `WithNoDirectDependency()`,否则会被依赖检查拦截。
## Graph Tool 的实现
下面我们以"大文件内容检索并回答"为例,逐步构建一个完整的 Graph Tool。整个流程分为三步定义 IO 结构 → 构建工作流 → 封装为 Tool。
### 1. 定义输入输出结构
输入和输出定义了 Graph Tool 对外暴露的接口契约,也是 Agent 调用时的参数 schema 来源:
```go
type Input struct {
FilePath string `json:"file_path" jsonschema:"description=Absolute path to the document"`
Question string `json:"question" jsonschema:"description=The question to answer"`
}
type Output struct {
Answer string `json:"answer"`
Sources []string `json:"sources"`
}
```
> [!note] jsonschema tag 的作用
>
> 这些标签会被自动转换为 JSON Schema决定了 AgentLLM看到的工具参数描述。写得好模型就能精准理解该传什么值。
### 2. 构建工作流
完整的 `buildWorkflow` 函数实现了五个阶段的流水线:
```go
func buildWorkflow(cm model.BaseChatModel) *compose.Workflow[Input, Output] {
wf := compose.NewWorkflow[Input, Output]()
// --- load: 读取文件 ---
wf.AddLambdaNode("load", compose.InvokableLambda(
func(ctx context.Context, in Input) ([]*schema.Document, error) {
data, err := os.ReadFile(in.FilePath)
if err != nil {
return nil, err
}
return []*schema.Document{{Content: string(data)}}, nil
},
)).AddInput(compose.START)
// --- chunk: 分块 ---
wf.AddLambdaNode("chunk", compose.InvokableLambda(
func(ctx context.Context, docs []*schema.Document) ([]*schema.Document, error) {
var out []*schema.Document
for _, d := range docs {
out = append(out, splitIntoChunks(d.Content, 800)...)
}
return out, nil
},
)).AddInput("load")
// --- score: 并行评分(核心亮点)---
scorer := batch.NewBatchNode(&batch.NodeConfig[scoreTask, scoredChunk]{
Name: "ChunkScorer",
InnerTask: newScoreWorkflow(cm),
MaxConcurrency: 5,
})
wf.AddLambdaNode("score", compose.InvokableLambda(
func(ctx context.Context, in scoreIn) ([]scoredChunk, error) {
tasks := make([]scoreTask, len(in.Chunks))
for i, c := range in.Chunks {
tasks[i] = scoreTask{Text: c.Content, Question: in.Question}
}
return scorer.Invoke(ctx, tasks)
},
)).
AddInputWithOptions("chunk", []*compose.FieldMapping{compose.ToField("Chunks")}, compose.WithNoDirectDependency()).
AddInputWithOptions(compose.START, []*compose.FieldMapping{compose.MapFields("Question", "Question")}, compose.WithNoDirectDependency())
// --- filter: 筛选 top-k ---
wf.AddLambdaNode("filter", compose.InvokableLambda(
func(ctx context.Context, scored []scoredChunk) ([]scoredChunk, error) {
sort.Slice(scored, func(i, j int) bool {
return scored[i].Score > scored[j].Score
})
if len(scored) > 3 {
scored = scored[:3]
}
return scored, nil
},
)).AddInput("score")
// --- answer: 生成最终答案 ---
wf.AddInputWithOptions("filter", []*compose.FieldMapping{compose.ToField("TopK")}, compose.WithNoDirectDependency()).
AddInputWithOptions(compose.START, []*compose.FieldMapping{compose.MapFields("Question", "Question")}, compose.WithNoDirectDependency())
wf.End().AddInput("answer")
return wf
}
```
> [!note] 代码解读:为什么 score 和 answer 都有两处 AddInput
>
> **score 节点**需要两个数据来源:
> - `chunk` 的输出(待评分的文本块)
> - `START` 的 `Question`(用户的问题,用来给每个 block 打分)
>
> **answer 节点**同理也需要:
> - `filter` 的输出top-k 的相关片段)
> - `START` 的 `Question`(拼接到 prompt 中)
>
> 这就是为什么需要 `WithNoDirectDependency()`——它们跳过了中间节点,直接向源头要数据。
### 3. 封装为 Tool
最后一步是将编译后的工作流包装成 Agent 可调用的标准 Tool
```go
func BuildTool(ctx context.Context, cm model.BaseChatModel) (tool.BaseTool, error) {
wf := buildWorkflow(cm)
return graphtool.NewInvokableGraphTool[Input, Output](
wf,
"answer_from_document", // Tool 名称Agent 看到的名字)
"Search a large document for relevant content and synthesize an answer.", // Tool 描述
)
}
```
> [!warning] 编译时机
>
> `graphtool.NewInvokableGraphTool` 内部会对 Workflow 执行编译检查,验证节点连通性、类型兼容性。如果在运行时才发现错误,排查会比较困难——建议在单元测试中对 buildWorkflow 的返回值做一次 compile-time check。
## Graph Tool 执行流程图
```mermaid
flowchart TD
A["输入: file_path, question"] --> B["load\n读取文件\n→ []*Document"]
B --> C["chunk\n分块 (800 tokens)\n→ []*Document"]
C --> D["score\n并行评分\n(MaxConcurrency=5)\n→ []scoredChunk"]
D --> E["filter\n排序并取 top-k\n→ []scoredChunk"]
E --> F["answer\n结合问题和\nTop-K 片段生成答案\n→ Output"]
F --> G["返回: {answer, sources}"]
style A fill:#e3f2fd
style G fill:#e8f5e9
style D fill:#fff3e0
```
**流程中的关键设计决策:**
| 阶段 | 决策点 | 原因 |
|------|--------|------|
| chunk | 固定 800 token 分块 | 平衡上下文窗口与检索精度 |
| score | 并行评分MaxConcurrency=5 | 避免串行等待,利用 LLM API 并发能力 |
| filter | 保留 top-3 | 控制后续 token 消耗,避免信息过载 |
## 可中断恢复
Graph Tool 天然继承 Eino 的中断恢复机制。当工作流内部的某个节点触发 `interrupt`Runner 会暂停整个工作流,等待用户输入后 resume
```go
// 在工作流节点中使用 interrupt
func myNode(ctx context.Context, input MyInput) (MyOutput, error) {
wasInterrupted, _, stored := tool.GetInterruptState[string](ctx)
if !wasInterrupted {
return MyOutput{}, tool.StatefulInterrupt(ctx, &commontool.ApprovalInfo{
ToolName: "my_workflow_step",
ArgumentsInJSON: stored,
}, stored)
}
// Resume 后继续执行...
return process(stored), nil
}
```
> [!tip] Graph Tool 的中断优势
>
> 由于每个节点都是独立的 lambda 函数,可以在任意节点插入 interrupt 逻辑,而无需修改其他节点。这种细粒度的可控性是简单 Tool 无法做到的。
## 本章小结
| 概念 | 一句话理解 |
|------|-----------|
| **Graph Tool** | 将 compose 编排产物封装为 Agent 可调用的 Tool 入口 |
| **compose.Workflow** | 支持 DAG 结构的有状态工作流,可表达复杂业务逻辑 |
| **BatchNode** | 并行处理批量任务的内置组件,受 MaxConcurrency 限制 |
| **FieldMapping** | 跨节点传递数据的机制,解决非相邻节点间的通信 |
| **可中断恢复** | Graph Tool 完整继承 interrupt/resume + checkpoint 能力 |
## 扩展思考
### Graph Tool 的典型应用场景
| 场景 | 说明 | 收益 |
|------|------|------|
| **多文档 RAG** | 并行检索多个文档源并综合回答 | 减少 Token 往返次数,一次 Tool Call 覆盖全部 |
| **多模型协作** | 不同模型处理不同阶段(摘要 → 翻译 → 总结) | 各取所长,降低单次请求成本 |
| **审批流水线** | 工作流中包含需要人工确认的步骤 | 兼顾自动化与安全合规 |
| **数据管道** | ETL抽取、转换、加载流程的 Agent 化 | 用自然语言驱动数据处理 |
### 性能优化建议
1. **调整 MaxConcurrency**:根据 LLM API 的速率限制调参,一般 3~10 为宜
2. **缓存层**:对相同 input + question 组合的结果做缓存,避免重复计算
3. **自适应 chunk 大小**:根据文档类型(代码、散文、日志)动态调整分块策略
4. **Early Exit**:当 top-1 分数远高于第二名时,跳过 filter 直接回答
## 关联笔记
- [[Eino/quick_start/_index]]
- [[Eino/quick_start/chapter_04_tool_and_filesystem]] — 简单 Tool 的创建与文件系统访问(第二章的工具章节)
- [[Eino/quick_start/chapter_07_interrupt_resume]] — Interrupt/Resume 机制(上一章)
- [[Eino/quick_start/chapter_09_skill_console]] — Skill 系统(下一章)

View File

@@ -0,0 +1,197 @@
---
tags: []
create time: 2026-04-29 15:30
---
# 第九章SkillConsole
## 概述
本章在上一章RAG + Interrupt/Resume + Checkpoint的基础上引入 **Skill** 中间件。通过 Skill 机制Agent 可以发现并加载一组可复用的"技能文档"`SKILL.md`),并在需要时自动调用它们——让 Agent 获得结构化的领域知识,而不需要把所有知识写进系统提示词里。
> [!TIP] 核心目标
> 学会用 Skill 中间件把一个稳定的知识集合注入到 Agent 中,并理解 Skill 与 Tool 的区别、注册方式、以及验证方法。
---
## 前置条件
- 与第一章一致:需要配置一个可用的 ChatModelOpenAI 或 Ark
- 准备好 `eino-ext` PR 提供的 skills 资源:`eino-guide` / `eino-component` / `eino-compose` / `eino-agent`
> [!QUESTION] 为什么是这四个 skill
>
> ChatWithEino 的定位是「帮用户学习 Eino 框架、并尝试用 AI 辅助写 Eino 代码」。这四个 skill 恰好覆盖了关键知识点:
>
> - **`eino-guide`** — 学习入口与导航(从哪里开始、怎么快速跑起来)
> - **`eino-component`** — Component 接口与各类实现参考Model / Embedding / Retriever / Tool / Callback 等)
> - **`eino-compose`** — 编排与确定性工作流参考Graph / Chain / Workflow 等)
> - **`eino-agent`** — ADK / Agent 相关参考Agent / Runner / Middleware / Filesystem / Human-in-the-loop 等)
Skills 来源可以是:
- `eino-ext` 仓库本地路径(同步脚本会自动读取 `<src>/skills/...`
- 你已安装 skills 的目录(目录下能看到上述四个子目录)
---
## 正文
### 从 Graph Tool 到 Skill为什么需要"技能文档"
第八章我们解决了「复杂工作流如何做成一个可调用的 Tool」的问题。但当你构建一个面向框架学习/开发辅助的 Agent 时,还会遇到另一类挑战:
> **如何把一组稳定、可复用的知识与指令注入到 Agent 里,并让它在运行时按需加载?**
这就是 Skill 的切入点:
- **Tool** = "能做什么"(函数/接口级别的能力)
- **Skill** = "怎么做"(可复用的说明书/操作手册)
```mermaid
graph LR
A["Agent"] --> B["Tool 层<br/>读文件 / 执行流程 / 调外部API"]
A --> C["Skill 层<br/>知识文档 / 最佳实践 / 操作手册"]
C --> D["eino-guide<br/>学习入口"]
C --> E["eino-component<br/>组件参考"]
C --> F["eino-compose<br/>编排参考"]
C --> G["eino-agent<br/>ADK参考"]
```
简单说:**Skill 是一种可被模型发现的结构化知识包**。每个 Skill 以 `SKILL.md` 为核心描述文件,辅以 `reference/*.md` 参考资料。
### 运行步骤
`quickstart/chatwitheino` 目录下执行以下两步:
#### 1) 同步 eino-ext skills 到本地目录
为了让 `skill` 中间件可以"发现"这些 skills需要把它们放到一个统一目录下满足扫描约定
```
EINO_EXT_SKILLS_DIR/<skillName>/SKILL.md
```
同步命令(推荐):
```bash
go run ./scripts/sync_eino_ext_skills.go -src /path/to/eino-ext -dest ./skills/eino-ext -clean
```
> [!NOTE] `-src` 参数说明
>
> - 形式一:`eino-ext` 仓库根目录 → 脚本自动读取 `<src>/skills/...`
> - 形式二:你已安装 skills 的目录 → 要求目录下包含 `eino-guide/`、`eino-component/` 等子目录
#### 2) 启动 Chapter 9
```bash
EINO_EXT_SKILLS_DIR=/absolute/path/to/chatwitheino/skills/eino-ext go run ./cmd/ch09
```
控制台输出示例:
```
Skills dir: /.../skills/eino-ext
Enter your message (empty line to exit):
```
### 在 DeepAgent 中启用 Skill
Skill 不会被自动加载 —— 你需要在 Agent 构建时显式注册 `skill` 中间件。核心三步:
| 步骤 | 操作 | 关键 API |
|------|------|----------|
| 1⃣ | 创建文件系统 backend | `localbk.NewBackend(ctx, &localbk.Config{})` |
| 2⃣ | 构建 Skill Backend | `skill.NewBackendFromFilesystem(ctx, cfg)` |
| 3⃣ | 生成中间件并注入 DeepAgent | `skill.NewMiddleware(ctx, cfg)` |
```mermaid
sequenceDiagram
participant User as 用户
participant Agent as DeepAgent
participant SkillMW as Skill 中间件
participant Backend as Skill Backend
participant FS as 本地文件系统
User->>Agent: 发送消息
Agent->>SkillMW: 处理请求
SkillMW->>Backend: 按 skillName 查找 SKILL.md
Backend->>FS: Glob / Read 文件
FS-->>Backend: 返回 Markdown 内容
Backend-->>SkillMW: 技能上下文
SkillMW-->>Agent: 注入知识到 prompt
Agent->>User: 返回回复
```
**关键代码片段(简化版,完整代码见 `cmd/ch09/main.go`**
```go
// Step 1: 本地 filesystem backend
backend, _ := localbk.NewBackend(ctx, &localbk.Config{})
// Step 2: 把 $EINO_EXT_SKILLS_DIR 变成 Skill Backend
skillBackend, _ := skill.NewBackendFromFilesystem(ctx, &skill.BackendFromFilesystemConfig{
Backend: backend,
BaseDir: skillsDir, // = os.Getenv("EINO_EXT_SKILLS_DIR")
})
// Step 3: 创建中间件并注册到 DeepAgent
skillMiddleware, _ := skill.NewMiddleware(ctx, &skill.Config{
Backend: skillBackend,
})
agent, _ := deep.New(ctx, &deep.Config{
ChatModel: cm,
Backend: backend,
StreamingShell: backend,
Handlers: []adk.ChatModelAgentMiddleware{
skillMiddleware,
// ... 其他中间件approval / safeTool / retry 等)
},
})
```
> [!WARNING] 容错设计
>
> 本 quickstart 保证了"没配置 skills 也能跑":代码中对 `EINO_EXT_SKILLS_DIR` 做了存在性检查,目录不存在则跳过注册 `skillMiddleware`。此时仍可正常对话和使用 RAG 工具。
### Skill 工具的入参格式
Skill 被注册为 Tool 后,模型的调用入参是一个 JSON 对象:
```json
{"skill": "eino-guide"}
```
其中 `"skill"` 键对应要激活的技能名称。
### 快速验证
启动后输入一条明确要求模型调用 skill 工具的指令:
```
Use the skill tool with skill="eino-guide" and tell me what the entry point is for getting started.
```
你应该看到:
- `[tool call] ...` — 模型发起了 skill 工具调用
- `[tool result] Launching skill: eino-guide` — 技能被成功激活
- Tool result 中包含 `Base directory for this skill: .../eino-guide` — 确认文件读取正确
### 会话恢复
会话数据保存在 `SESSION_DIR`(默认 `./data/sessions`),支持通过 `--session` 参数恢复:
```bash
go run ./cmd/ch09 --session <session-id>
```
---
## 关联笔记
- [[Eino/quick_start/chapter_08_graph_tool]] — 上一章Graph Tool理解 Tool 作为"动作能力"的基础
- [[Eino/quick_start/chapter_05_middleware]] — Middleware 机制,所有中间件的通用注册方式
- [[Eino/quick_start/chapter_04_tool_and_filesystem]] — Tool 与 Filesystem文件系统 backend 的来源

View File

@@ -0,0 +1,366 @@
---
tags: [eino, ai-development, go, quickstart, a2ui, sse]
create time: 2026-04-29 16:00
---
# 第十章A2UI 协议(流式 UI 组件)(最终章)
## 概述
本章作为 ChatWithEino Quickstart 的最终章,引入 **A2UI 协议**——把 Agent 的事件流以 JSONL/SSE 的形式推送到前端,渲染为可增量更新的 UI 组件树。你将掌握 Agent 到 Web 的端到端集成方案,理解为什么 AI 应用需要从纯文本走向结构化、可交互的 UI 呈现。
> [!tip] 一句话理解 A2UI
>
> **A2UI = Agent 输出 × UI 组件映射**。它定义了"Agent 做了什么"如何变成"用户看到了什么":文本 → Text 组件、工具调用 → Chip 卡片、进度更新 → 实时更新……一切通过声明式的组件树实现。
---
## 代码位置
- 入口代码:[main.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/main.go)
- Agent 构建:[agent.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/agent.go)
- 服务端路由:[server/server.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/server/server.go)
- A2UI 子集实现:[a2ui/types.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/a2ui/types.go)
- A2UI 事件流转换:[a2ui/streamer.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/a2ui/streamer.go)
- 前端页面:[static/index.html](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/static/index.html)
## 前置条件
与第一章一致:需要配置一个可用的 ChatModelOpenAI 或 Ark
## 运行
`examples/quickstart/chatwitheino` 目录下执行:
```bash
go run .
```
输出示例:
```
starting server on http://localhost:8080
```
启动后浏览器访问 `http://localhost:8080` 即可看到完整的 A2UI 交互界面。
### (可选)启用 Skills 能力
最终 Web 版使用的 Agent 构建逻辑与第九章对齐:当 `EINO_EXT_SKILLS_DIR` 指向一个合法 skills 目录时,会自动注册 `skill` 中间件,模型就能按需调用 `skill` 工具加载文档。
```bash
go run ./scripts/sync_eino_ext_skills.go -src /path/to/eino-ext -dest ./skills/eino-ext -clean
EINO_EXT_SKILLS_DIR="$(pwd)/skills/eino-ext" go run .
```
## A2UI 的定位与边界
> [!important] A2UI 不属于 Eino 框架本身
A2UI 是一个**业务层的 UI 协议/渲染方案**,不是 Eino 的核心 Component。本章把它集成进前面章节逐步构建出来的 Agent是为了提供一个端到端、可落地的完整示例从模型调用、工具调用、工作流编排到最终把结果以更友好的 UI 方式呈现出来。
**真实业务场景中,你完全可以根据产品形态选择不同的 UI 形式:**
| 场景 | UI 形式 | 说明 |
|------|---------|------|
| Web / App | 自定义组件、表格、卡片、图表 | 最典型的 B/S 架构应用 |
| IM / 办公套件 | 消息卡片、交互式表单 | 飞书、钉钉等平台的富消息 |
| 命令行 | 纯文本或 TUI | Console 版 Agent 的原生形式 |
Eino 关注「可组合的智能执行与编排能力」,而「如何呈现给用户」属于业务层可以自由扩展的一环。
## 从纯文本到结构化的 UI为什么需要 A2UI
> [!question] 思考一下
>
> 如果你要为一个 AI 聊天产品设计更丰富的交互体验,纯文本回复会遇到哪些瓶颈?
**纯文本输出的局限:**
- ❌ 无法展示结构化数据(表格、列表、卡片等)
- ❌ 无法实时更新(进度条、状态变化等)
- ❌ 无法嵌入交互元素(按钮、表单、链接等)
- ❌ 无法支持多媒体(图片、视频、音频等)
**A2UI 的定位:**
-**协议映射**Agent 输出 → UI 组件的声明式映射关系
-**流式渲染**:组件实时更新,无需等待完整响应
-**增量更新**:基于 dataKey 的数据绑定,文本流可逐 token 更新
**简单类比:**
- **纯文本输出** = "终端命令行"(只能显示文本)
- **A2UI** = "Web 应用"(可以显示任何 UI 组件)
## A2UI v0.8 子集(本示例的边界)
本 quickstart 并没有实现一个"完整的 A2UI 标准库",而是实现了一个 **A2UI v0.8 的子集**:目标是把 Agent 的事件流,以稳定、可增量渲染的 UI 组件树方式推给浏览器。
当前实现的 A2UI 消息类型与组件类型,以 [a2ui/types.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/a2ui/types.go) 为准。
### A2UI 消息类型:信封结构
每一行 SSE`data: {...}`)承载一个 A2UI MessageMessage 是一个"信封结构",每次只会出现一个字段:
> [!note] 关键代码片段
>
> 注意:这是简化后的代码片段,不能直接运行,完整代码请参考 [a2ui/types.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/a2ui/types.go)。
```go
type Message struct {
BeginRendering *BeginRenderingMsg
SurfaceUpdate *SurfaceUpdateMsg
DataModelUpdate *DataModelUpdateMsg
DeleteSurface *DeleteSurfaceMsg
InterruptRequest *InterruptRequestMsg
}
```
| 消息类型 | 作用 | 触发时机 |
|----------|------|----------|
| `BeginRendering` | 告诉前端"开始渲染一个 surface",指定根节点 ID | 新会话开始时 |
| `SurfaceUpdate` | 新增/更新一批组件(组件是树,用 id 互相引用) | 创建/修改 UI 结构时 |
| `DataModelUpdate` | 更新 data bindings用于流式文本增量渲染 | assistant 生成文本时 |
| `InterruptRequest` | 通知前端展示批准/拒绝入口 | Agent 需要人类审批时 |
| `DeleteSurface` | 删除某个 surface | 清理/重置会话时 |
### A2UI 组件类型
本示例 UI 组件只实现了 4 种(见 [a2ui/types.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/a2ui/types.go)
```mermaid
classDiagram
class ComponentTree {
<<abstract>>
+id string
+children []string
}
class TextComponent {
+text string
+dataKey string
+usageHint string
}
class ColumnLayout {
+spacing float64
+align ItemsAlign
}
class RowLayout {
+spacing float64
+align ItemsAlign
}
class CardContainer {
+title string
+border bool
}
ComponentTree <|-- TextComponent
ComponentTree <|-- ColumnLayout
ComponentTree <|-- RowLayout
ComponentTree <|-- CardContainer
note for TextComponent "支持 dataKey\n流式绑定"
note for ColumnLayout "垂直布局容器"
note for RowLayout "水平布局容器"
note for CardContainer "内容容器\n无布局功能"
```
各组件职责:
| 组件 | 用途 | 特性 |
|------|------|------|
| `Text` | 文本渲染 | 支持 `usageHint`caption/body/title当存在 `dataKey` 时文本来自 `DataModelUpdate` |
| `Column` | 垂直布局 | children 是组件 ID 列表 |
| `Row` | 水平布局 | children 是组件 ID 列表 |
| `Card` | 卡片容器 | children 是组件 ID 列表,仅做视觉分组 |
> [!warning] Card ≠ 布局容器
>
> `Card` 不提供任何布局控制(不排布子元素的位置),它只是一个有视觉边界的容器。如需布局请用 `Column` / `Row`。
## A2UI 的实现链路
最终 Web 版的核心链路是三阶段管道:
1. **后端运行 Agent**:得到 `*adk.AsyncIterator[*adk.AgentEvent]`
2. **事件 → A2UI JSONL/SSE 流**:转换为 A2UI 消息推送给浏览器(见 [a2ui/streamer.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/a2ui/streamer.go)
3. **前端解析并渲染**:读取 SSE 流并渲染组件树(见 [static/index.html](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/static/index.html)
### 服务端路由(高层)
与 A2UI 相关的关键接口(见 [server/server.go](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/server/server.go)
| 方法 | 路径 | 响应 | 说明 |
|------|------|------|------|
| GET | `/` | HTML | 返回前端页面 |
| POST | `/sessions/:id/chat` | SSE 流 | Agent 运行结果实时渲染 |
| GET | `/sessions/:id/render` | JSONL | 回放历史消息 |
| POST | `/sessions/:id/approve` | SSE 流 | interrupt 批准后继续执行 |
### 事件流转换
服务端把 `Runner.Run(...)` 的事件流交给 `a2ui.StreamToWriter(...)`,后者负责:
```mermaid
flowchart TD
A["AgentEvent 输入"] --> B{事件类型}
B -->|"user"| C["渲染 User 气泡"]
B -->|"assistant"| D["创建 DataModelUpdate\n流式追加文本"]
B -->|"tool call"| E["渲染 ToolCall Chip 卡片"]
B -->|"tool result"| F["渲染 ToolResult Chip 卡片"]
B -->|"interrupt"| G["发送 InterruptRequest\n暂停等待人类审批"]
C --> H["SSE JSONL 输出"]
D --> H
E --> H
F --> H
G --> H
style D fill:#fff3e0
style G fill:#fce4ec
```
核心处理逻辑:
- **User 输出**:渲染为用户消息气泡
- **Assistant 流式 token**:创建 `DataModelUpdate`,通过 `dataKey` 绑定到一个 `Text` 组件上,实现"边生成边渲染"
- **Tool Call / Tool Result**:渲染为独立的 chip 卡片
- **Interrupt**:发送 `InterruptRequest`,暂停等待人类批准后 resume
### 前端集成Fetch + SSE不是 WebSocket
前端通过 `fetch('/sessions/:id/chat')` 发起请求,然后从 `res.body` 读取流式字节,按行切分并解析 `data: {...}` 的 JSON
> [!note] 前端代码片段
>
> 注意:这是简化后的代码片段,不能直接运行,完整代码请参考 [static/index.html](https://github.com/cloudwego/eino-examples/blob/main/quickstart/chatwitheino/static/index.html)。
```javascript
const res = await fetch(`/sessions/${id}/chat`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({message}),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const {done, value} = await reader.read();
if (done) break;
buffer += decoder.decode(value, {stream: true});
const lines = buffer.split('\n');
buffer = lines.pop();
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.startsWith('data:')) {
const jsonStr = trimmed.slice(5).trimStart();
processA2UIMessage(JSON.parse(jsonStr));
}
}
}
```
> [!tip] 为什么用 SSE 而不是 WebSocket
>
> - **SSE**Server-Sent Events是单向的、HTTP 兼容、天然支持断线重连语义
> - 对于"服务器推送 UI 事件,客户端只需消费"的场景SSE 比 WebSocket 更轻量
> - 如果未来需要双向交互(如键盘快捷键、实时光标同步),才考虑升级到 WebSocket
## A2UI 流式渲染流程
```mermaid
sequenceDiagram
participant U as 用户
participant FE as 前端浏览器
participant BE as 后端 Server
participant AG as Agent
participant LM as LLM
U->>FE: 输入消息
FE->>BE: POST /sessions/:id/chat
BE->>AG: Runner.Run()
AG->>LM: 发送请求
LM-->>AG: token 流式返回
loop 每个 AgentEvent
AG-->>BE: AgentEvent
alt assistant token
BE->>FE: DataModelUpdate (dataKey 绑定)
else tool call
BE->>FE: SurfaceUpdate (Chip 卡片)
end
FE-->>U: UI 增量更新
end
AG-->>AG: 可能需要 Interrupt
AG->>BE: InterruptRequest
BE->>FE: InterruptRequest
FE-->>U: 展示审批按钮
U->>FE: 点击批准
FE->>BE: POST /sessions/:id/approve
BE->>AG: Resume 继续执行
```
## 从 Quickstart 到生产落地
> [!tip] 可扩展的设计思路
>
> A2UI 的协议层和 Agent 层是解耦的。你可以只做其中的任意一部分。
| 方向 | 替换方案 | 适用场景 |
|------|---------|----------|
| 不同 UI 形态 | React/Vue 组件、移动端原生组件、TUI | 产品定位差异 |
| 传输协议升级 | gRPC Streaming / GraphQL Subscription | 需要更强的双向交互 |
| 渲染引擎切换 | 服务端 SSR → 客户端动态渲染 | CDN 加速需求 |
| 多模态扩展 | 嵌入图片、图表、代码高亮 | 数据类/分析型 Agent |
## 本章小结
| 核心概念 | 说明 | 关键点 |
|----------|------|--------|
| **A2UI 协议** | Agent 到 UI 的映射协议 | 声明式组件树 + 数据绑定 |
| **消息信封** | BeginRendering / SurfaceUpdate / DataModelUpdate | 每种消息对应不同生命周期 |
| **组件系统** | Text / Column / Card / Row | 4 种基础组件覆盖常见 UI 场景 |
| **SSE 流** | AgentEvent → A2UI JSONL → 前端渲染 | 单向推送、增量更新 |
| **中断协作** | InterruptRequest + approve 机制 | 人机协同的关键路径 |
> [!success] 学习成果
>
> 完成本章后,你应该能够:
> - 理解 A2UI 协议的设计思路和适用场景
> - 掌握 Agent 事件流到前端 UI 的完整转换链路
> - 使用 SSE 实现增量渲染的前端集成方案
> - 知道如何将这个骨架扩展到不同的产品形态中
## 下一章预告
这是 Quickstart 系列的终章。后续如果你想深入:
- [[Eino/quick_start/chapter_09_skill_console]] — 回顾第九章的 Skill 知识注入能力
- [[Eino/README]] — 探索 Eino 框架的系统性学习路径
## 扩展思考
### 其他组件类型(可选实现方向)
| 组件 | 说明 | 实现难度 |
|------|------|----------|
| **图表组件** | 折线图、柱状图、饼图 | 中高(需接入图表库) |
| **地图组件** | 地理信息可视化 | 中(依赖地图 SDK |
| **时间线组件** | 事件顺序排列展示 | 低(已有 Column 即可) |
| **树形组件** | 层级数据结构展示 | 中(递归渲染逻辑) |
| **标签页组件** | 多面板 Tab 切换 | 低State 管理即可) |
### 高级交互能力
| 能力 | 说明 | 技术要点 |
|------|------|----------|
| **组件交互** | 点击、拖拽、输入反馈 | 前端事件 → API 调用 |
| **条件渲染** | 根据数据决定组件显隐 | 服务端根据状态发送不同 SurfaceUpdate |
| **组件动画** | 平滑过渡效果 | CSS transition / animation |
| **响应式布局** | 自适应屏幕尺寸 | 媒体查询 + 弹性布局 |
## 关联笔记
- [[Eino/quick_start/_index]] — Quickstart 系列统一入口
- [[Eino/quick_start/chapter_08_graph_tool]] — Graph Tool 复杂工作流编排
- [[Eino/quick_start/chapter_09_skill_console]] — Skill 知识与指令注入
- [[Eino/quick_start/chapter_07_interrupt_resume]] — Interrupt 与 Resume 机制