系列「企业级 AI Agent 实现拆解」补充篇 E2。E1 讲了 ReAct 循环怎么用有向图跑起来,这篇往下看一层------图里的数据到底是什么形状的。Message 里装了什么、ToolCall 怎么关联、流式 token 怎么从 LLM 一路流到浏览器,全在
schema包里定义。
先看一个完整的对话长什么样
用户问"北京今天天气怎么样",Agent 调了天气工具再回答。这段对话在 Eino 里是一组 Message:
┌─────────────────────────────────────────────────┐
│ Message 1 (system) │
│ "你是天气助手,用中文回答" │
└─────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Message 2 (user) │
│ "北京今天天气怎么样" │
└─────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Message 3 (assistant) │
│ Content: "" │
│ ToolCalls: [{ID:"call_1", Name:"weather.query", │
│ Arguments:'{"city":"北京"}'}] │
└─────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Message 4 (tool) │
│ ToolCallID: "call_1" │
│ Content: '{"temp":28,"condition":"晴"}' │
└─────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Message 5 (assistant) │
│ "北京今天晴天,气温 28°C,适合出门。" │
└─────────────────────────────────────────────────┘
五种角色、三种数据(纯文本、工具调用、工具结果),全塞在 schema.Message 这一个结构体里。
你可以把 Message 想象成一个信封:信封上写着"谁发的"(Role),信封里装着"内容"(Content)或者"委托单"(ToolCalls),回信时还要在信封上标注"回复哪封信"(ToolCallID)。
Message:一个结构体,四种身份
Eino 里所有角色共用一个 Message 结构体(源码在 eino/schema/message.go):
go
// [schema/message.go](https://github.com/cloudwego/eino/blob/main/schema/message.go)
type Message struct {
Role RoleType // 谁说的
Content string // 文本内容(user 和 assistant 的普通对话走这里)
// 工具调用相关(只有 assistant 角色会用)
ToolCalls []ToolCall // LLM 决定调哪些工具
ToolCallID string // 工具执行结果回传时,标注"这是哪个调用的结果"
ToolName string // 工具名(可选,方便调试)
// 多模态(图片/音频/视频/文件)
UserInputMultiContent []MessageInputPart // 用户发的多模态内容
AssistantGenMultiContent []MessageOutputPart // 模型生成的多模态内容
// 元信息
ResponseMeta *ResponseMeta // finish_reason、token 用量等
Extra map[string]any // 自定义扩展字段
}
四种角色
go
type RoleType string
const (
System RoleType = "system" // 系统指令:"你是天气助手"
User RoleType = "user" // 用户输入:"北京天气怎么样"
Assistant RoleType = "assistant" // LLM 回复(可能是文本,也可能是工具调用)
Tool RoleType = "tool" // 工具执行结果
)
四种角色,四种用途:
| 角色 | Content | ToolCalls | ToolCallID | 什么时候出现 |
|---|---|---|---|---|
| system | 系统指令 | ❌ | ❌ | 每次对话开头,只出现一次 |
| user | 用户输入(文本或多模态) | ❌ | ❌ | 用户每说一句话出现一次 |
| assistant | 文本回复(可能为空) | ✅ 可能 | ❌ | LLM 的每次输出 |
| tool | 工具执行结果 | ❌ | ✅ 必须 | 每个 ToolCall 对应一个 |
Eino 还提供了快捷构造函数,不需要手动填 Role:
go
// 四种快捷构造
sysMsg := schema.SystemMessage("你是天气助手,用中文回答")
userMsg := schema.UserMessage("北京今天天气怎么样")
asstMsg := schema.AssistantMessage("", []schema.ToolCall{...}) // 空文本 + 工具调用
toolMsg := schema.ToolMessage(`{"temp":28}`, "call_1") // 结果 + 关联 ID
ToolCall:LLM 说"我要调这个工具"
当 LLM 觉得需要调工具时,它不是直接执行,而是输出一个"调用意图"------ToolCall:
go
// [schema/message.go](https://github.com/cloudwego/eino/blob/main/schema/message.go#L132)
type ToolCall struct {
Index *int // 流式模式下用于合并分段(初学者先忽略)
ID string // 唯一标识,工具结果要用这个 ID 关联回来
Type string // 类型,固定 "function"
Function FunctionCall // 具体调用信息
Extra map[string]any
}
type FunctionCall struct {
Name string // 工具名,比如 "weather.query"
Arguments string // 参数,JSON 字符串,比如 '{"city":"北京"}'
}
一个关键细节:Arguments 是 JSON 字符串,不是 map 也不是 struct。LLM 输出的是文本,Eino 不做额外解析------拿到字符串直接传给工具实现。
另一个关键细节:ID。LLM 可能一次输出多个工具调用(比如同时查北京和上海的天气),每个调用有唯一 ID。工具执行完后,用 ToolCallID 把结果对回去:
assistant: ToolCalls: [{ID:"call_1", Name:"weather.query", Args:"北京"},
{ID:"call_2", Name:"weather.query", Args:"上海"}]
│
├── tool: ToolCallID:"call_1", Content:"北京28°C"
└── tool: ToolCallID:"call_2", Content:"上海25°C"
LLM 拿到两个结果,知道哪个是哪个,再综合给出最终回答。
ToolInfo:告诉 LLM "你有哪些工具可以用"
光有 ToolCall(调用意图)不够,还得告诉 LLM "你有哪些工具、每个工具需要什么参数"。这是 ToolInfo:
go
// [schema/tool.go](https://github.com/cloudwego/eino/blob/main/schema/tool.go#L128)
type ToolInfo struct {
Name string // "weather.query"
Desc string // "查询指定城市的实时天气"
*ParamsOneOf // 参数描述(两种写法,下面解释)
}
参数描述有两种写法:
写法一:轻量版 ParameterInfo
go
schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{
"city": {Type: schema.String, Desc: "城市名", Required: true},
})
简单的 key-value 描述,覆盖 90% 的场景。
写法二:完整版 JSONSchema
go
schema.NewParamsOneOfByJSONSchema(jsonSchemaObj)
需要 anyOf、oneOf、$ref 这些高级特性时用。Eino 内部会把两种写法统一转成 JSONSchema,再传给 LLM。
你可以把 ToolInfo 想象成餐厅菜单:菜名(Name)、菜的照片和介绍(Desc)、口味选项和必选配料(ParamsOneOf)。LLM 根据菜单决定点哪道菜(ToolCall)。
流式管道:Pipe、StreamReader、StreamWriter
前面讲的都是"一次给完整结果"。但 LLM 生成回答要几秒,如果等全部生成完再返回,用户看到的就是空白页。
Eino 的流式基础设施是三个东西:Pipe、StreamReader、StreamWriter。
一句话概括
Pipe 创建管道,StreamWriter 往里写,StreamReader 从里读。
StreamWriter ──Send()──▶ [管道] ──Recv()──▶ StreamReader
(带缓冲的 channel)
创建管道
go
// [schema/stream.go](https://github.com/cloudwego/eino/blob/main/schema/stream.go#L99)
sr, sw := schema.Pipe[*schema.Message](16) // 16 是缓冲区大小
// sr = StreamReader(消费者用)
// sw = StreamWriter(生产者用)
Pipe[T](cap) 是泛型函数------Pipe[string] 传字符串,Pipe[*schema.Message] 传消息指针。cap 是内部 channel 的缓冲大小。
写数据(生产者)
go
go func() {
defer sw.Close() // ① 写完必须 Close,否则消费者永远等
sw.Send(msg1, nil) // ② 发送一个消息,nil 表示没错误
sw.Send(msg2, nil)
sw.Send(nil, someError) // ③ 发送错误,消费者会收到这个错误
}()
三个要点:
Close()必须调------消费者靠它判断"流结束了"Send()的第二个参数是 error------可以中途报告错误Send()返回bool------如果消费者已经Close()了,返回true(生产者可以提前退出)
读数据(消费者)
go
defer sr.Close() // ① 用完必须 Close,否则生产者可能永远阻塞
for {
chunk, err := sr.Recv() // ② 阻塞等下一个元素
if errors.Is(err, io.EOF) {
break // ③ 生产者 Close 了,流结束
}
if err != nil {
return err // ④ 生产者发了错误
}
fmt.Println(chunk.Content) // ⑤ 正常处理
}
内部实现:就一个 channel
Pipe 的内部实现非常简单------一个带缓冲的 channel 加一个关闭信号:
go
// [schema/stream.go](https://github.com/cloudwego/eino/blob/main/schema/stream.go#L375) 简化版
type stream[T any] struct {
items chan streamItem[T] // 数据通道
closed chan struct{} // 关闭信号
}
type streamItem[T any] struct {
chunk T // 数据本身
err error // 错误(nil 表示正常)
}
Send() 把数据塞进 channel,Recv() 从 channel 取。channel 满了 Send() 阻塞,channel 空了 Recv() 阻塞------这就是天然的背压(backpressure):消费者慢了,生产者自动减速。
流的复制:一份数据,N 个消费者
有时候同一个流要给两个地方用------比如一边推 SSE 给浏览器,一边记日志。StreamReader.Copy(n) 创建 n 个独立读取器:
go
copies := sr.Copy(2) // 创建 2 个副本
sr1, sr2 := copies[0], copies[1]
// sr1 和 sr2 独立读取相同的元素
// 原始的 sr 变成不可用
你可以把 Pipe 想象成自来水管:StreamWriter 是水厂(水泵往管道里压水),StreamReader 是你家水龙头(打开龙头出水)。缓冲区大小就是水塔容量------水塔满了水厂停工,水塔空了你得等。
Copy(2)就是在管道上接了两个水龙头,每个龙头出的水一样多。
流的转换和过滤
StreamReaderWithConvert 可以在流的中间插入转换逻辑------比如把 int 流转成 string 流,或者过滤掉空元素:
go
// [schema/stream.go](https://github.com/cloudwego/eino/blob/main/schema/stream.go#L691)
strReader := schema.StreamReaderWithConvert(intReader,
func(i int) (string, error) {
if i == 0 {
return "", schema.ErrNoValue // 返回 ErrNoValue = 跳过这个元素
}
return fmt.Sprintf("val_%d", i), nil
})
ErrNoValue 是一个特殊的哨兵错误------转换函数返回它时,这个元素会被静默丢弃,消费者完全感知不到。
流的合并
MergeStreamReaders 把多个流合并成一个------元素按到达顺序交错输出:
go
merged := schema.MergeStreamReaders([]*schema.StreamReader[string]{sr1, sr2})
// merged 依次输出 sr1 和 sr2 的元素,哪个先到先输出哪个
MergeNamedStreamReaders 更进一步------某个源流结束时,会收到 SourceEOF 错误,告诉你"哪个流先完了":
go
named := schema.MergeNamedStreamReaders(map[string]*schema.StreamReader[string]{
"agent_a": srA,
"agent_b": srB,
})
// 消费时可以通过 schema.GetSourceName(err) 知道是哪个 agent 完成了
多模态:不只有文字
Eino 的 Message 不只能装文字。用户可以发图片,模型也可以生成图片、音频。
用户发多模态消息
go
// 用户发了一张图片 + 一段文字
&schema.Message{
Role: schema.User,
UserInputMultiContent: []schema.MessageInputPart{
{Type: schema.ChatMessagePartTypeText, Text: "这张图片里有什么?"},
{Type: schema.ChatMessagePartTypeImageURL, Image: &schema.MessageInputImage{
MessagePartCommon: schema.MessagePartCommon{
URL: toPtr("https://example.com/cat.jpg"),
},
Detail: schema.ImageURLDetailHigh,
}},
},
}
支持的多模态类型:
| 类型 | ChatMessagePartType | 字段 | 说明 |
|---|---|---|---|
| 文本 | text |
Text |
普通文字 |
| 图片 | image_url |
Image |
URL 或 Base64 |
| 音频 | audio_url |
Audio |
URL 或 Base64 |
| 视频 | video_url |
Video |
URL 或 Base64 |
| 文件 | file_url |
File |
URL 或 Base64 |
每种媒体都可以通过 URL 引用或直接内嵌 Base64 数据。
模型生成多模态内容
模型返回的多模态内容放在 AssistantGenMultiContent 里,结构和输入类似,多了一个 Reasoning 类型(思维链模型如 DeepSeek-R1 会输出推理过程)。
Document:RAG 管道的通用货币
Document 是 Eino 里文档检索管道(Loader → Transformer → Indexer → Retriever)的通用数据结构:
go
// [schema/document.go](https://github.com/cloudwego/eino/blob/main/schema/document.go)
type Document struct {
ID string // 唯一标识
Content string // 文本内容
MetaData map[string]any // 元数据(开放 map,随便塞)
}
MetaData 是一个开放的 map[string]any,每个处理阶段可以往里塞自己的信息:
go
doc := &schema.Document{
ID: "doc_001",
Content: "DeepFlux 是企业级 Agent 平台",
}
doc.WithScore(0.95) // 检索阶段写:相似度分数
doc.WithDenseVector([]float64{0.1, ...}) // 嵌入阶段写:向量
doc.WithSubIndexes([]string[]{"tenant_1"}) // 索引阶段写:分区分组
读取也方便:
go
score := doc.Score() // 0.95
vector := doc.DenseVector() // []float64{0.1, ...}
indexes := doc.SubIndexes() // ["tenant_1"]
你可以把 Document 想象成快递包裹:Content 是包裹里的东西(文本),MetaData 是包裹上的贴纸------寄件人贴一张"易碎品",分拣中心贴一张"华东区",快递员贴一张"已签收"。每个环节只管贴自己的,不会弄丢别人的贴纸。
我们怎么用:DeepFlux 的消息映射
Eino 的 schema.Message 是给框架用的,我们自己有一套领域模型 model.Message。两边的字段不完全一样,需要一个映射层。
领域消息 → Eino 消息
go
// [einoadapter/factory.go] buildEinoMessages --- 领域消息转 Eino 消息
func buildEinoMessages(sysContent string, history []model.Message) []*schema.Message {
msgs := make([]*schema.Message, 0, len(history)+1)
// ① system prompt 单独一条
if sysContent != "" {
msgs = append(msgs, &schema.Message{
Role: schema.System, Content: sysContent,
})
}
// ② 对话历史逐条转换
for _, m := range history {
em := &schema.Message{
Role: schema.RoleType(m.Role), // 角色直转
Content: m.Content, // 文本直转
ToolCallID: m.ToolCallID, // 工具结果 ID 直转
}
// ③ ToolCalls 需要逐个转换结构
for _, tc := range m.ToolCalls {
em.ToolCalls = append(em.ToolCalls, schema.ToolCall{
ID: tc.ID,
Type: "function",
Function: schema.FunctionCall{
Name: tc.Name,
Arguments: tc.Arguments,
},
})
}
msgs = append(msgs, em)
}
return msgs
}
LLM 输出 → Eino 消息(流式)
LLM 返回的每个 chunk 都要转成 *schema.Message:
go
// [einoadapter/chatmodel.go] llmChunkToEinoMessage --- 每个 chunk 的转换
func llmChunkToEinoMessage(c llm.StreamChunk) *schema.Message {
// ① 文本 token
if c.DeltaContent != "" {
return &schema.Message{Role: schema.Assistant, Content: c.DeltaContent}
}
// ② 工具调用的增量
if c.DeltaTool != nil {
return &schema.Message{
Role: schema.Assistant,
ToolCalls: []schema.ToolCall{{
ID: c.DeltaTool.ID,
Type: "function",
Function: schema.FunctionCall{
Name: c.DeltaTool.Name,
Arguments: c.DeltaTool.Arguments,
},
}},
}
}
return nil
}
我们的 channel → Eino 的 Pipe
这是最关键的桥接。我们的 LLMClient.Stream() 返回 <-chan StreamChunk,Eino 要的是 *StreamReader[*Message]。中间用 schema.Pipe 桥接:
go
// [einoadapter/chatmodel.go] Stream 方法(简化版)
func (a *chatModelAdapter) Stream(ctx context.Context, input []*schema.Message, ...) (*schema.StreamReader[*schema.Message], error) {
// ① 调我们的 LLMClient,拿到 channel
chunkCh, _ := a.client.Stream(ctx, a.profile, req)
// ② 创建 Eino Pipe
sr, sw := schema.Pipe[*schema.Message](16)
// ③ 启动 goroutine,把 channel 的数据搬到 Pipe 里
go func() {
defer sw.Close()
for chunk := range chunkCh {
if chunk.Err != nil {
sw.Send(nil, chunk.Err) // 错误直接透传
return
}
msg := llmChunkToEinoMessage(chunk)
if msg == nil { continue } // 空 chunk 跳过
sw.Send(msg, nil) // 转换后塞进 Pipe
}
}()
return sr, nil // ④ 返回 StreamReader,Eino 框架自己消费
}
一句话总结:goroutine 从 channel 读,转格式,写到 Pipe;Eino 从 Pipe 读。两边的数据格式不同,但流动方式一样------都是"生产者写,消费者读"。
一张全景图:数据怎么在系统里流
把所有内容串起来,看一条消息从用户输入到最终回答的完整数据流:
用户输入 "北京天气怎么样"
│
▼
model.Message{Role:"user", Content:"北京天气怎么样"} ← 我们的领域模型
│
▼ buildEinoMessages()
schema.Message{Role:"user", Content:"北京天气怎么样"} ← Eino schema
│
▼ Eino ReAct 图执行
│
├── chat 节点:LLM 返回 ToolCall
│ schema.Message{Role:"assistant", ToolCalls:[{...}]}
│ │
│ ▼ LLM 流式输出
│ llm.StreamChunk{DeltaTool:{Name:"weather.query"}}
│ │
│ ▼ llmChunkToEinoMessage()
│ schema.Message{Role:"assistant", ToolCalls:[{...}]}
│ │
│ ▼ schema.Pipe 发送
│ sw.Send(msg, nil) → Pipe → sr.Recv() → Eino 主循环
│
├── tools 节点:执行工具,拿到结果
│ schema.Message{Role:"tool", ToolCallID:"call_1", Content:"28°C"}
│
├── chat 节点:LLM 生成最终回答
│ llm.StreamChunk{DeltaContent:"北京"}
│ llm.StreamChunk{DeltaContent:"今天"}
│ llm.StreamChunk{DeltaContent:"晴天"}
│ │
│ ▼ 逐个通过 Pipe
│ sr.Recv() → tokenStreamReader.Recv() → "北京"
│ sr.Recv() → tokenStreamReader.Recv() → "今天"
│ sr.Recv() → tokenStreamReader.Recv() → "晴天"
│
▼
SSE 推送到浏览器 → 用户看到 "北京今天晴天" 一个字一个字蹦出来
小结
Eino 的 schema 包定义了 Agent 系统里流动的所有数据:
Message:一个结构体,四种角色(system/user/assistant/tool),覆盖纯文本和多模态ToolCall:LLM 的工具调用意图,ID 用来把工具结果对回去ToolInfo:告诉 LLM "你有哪些工具可用",参数用 JSON Schema 描述Pipe/StreamReader/StreamWriter:泛型流式管道,背压自动,支持复制、转换、合并Document:RAG 管道的通用数据结构,开放 Metadata 让每个处理阶段自由标注
对我们来说,schema 包是和 Eino 框架对接的"通用语言"。我们的领域模型(model.Message、model.ToolCall)是业务视角的,Eino 的 schema 是框架视角的,两者通过适配器里的映射函数互转。
本文是「企业级 AI Agent 实现拆解」补充篇 E2。上一篇 E1 讲了 Eino ReAct 循环的有向图模型,这篇拆了图里流动的数据长什么样。下一篇 E3 会看 Graph 编排------不只是 ReAct,任何有向无环图都能画。