Eino 的数据是怎么建模的:Message、ToolCall、流式管道

系列「企业级 AI Agent 实现拆解」补充篇 E2。E1 讲了 ReAct 循环怎么用有向图跑起来,这篇往下看一层------图里的数据到底是什么形状的。Message 里装了什么、ToolCall 怎么关联、流式 token 怎么从 LLM 一路流到浏览器,全在 schema 包里定义。


先看一个完整的对话长什么样

用户问"北京今天天气怎么样",Agent 调了天气工具再回答。这段对话在 Eino 里是一组 Message

json 复制代码
┌─────────────────────────────────────────────────┐
│ 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":"北京"}'
}

一个关键细节:ArgumentsJSON 字符串,不是 map 也不是 struct。LLM 输出的是文本,Eino 不做额外解析------拿到字符串直接传给工具实现。

另一个关键细节:ID。LLM 可能一次输出多个工具调用(比如同时查北京和上海的天气),每个调用有唯一 ID。工具执行完后,用 ToolCallID 把结果对回去:

css 复制代码
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)

需要 anyOfoneOf$ref 这些高级特性时用。Eino 内部会把两种写法统一转成 JSONSchema,再传给 LLM。

你可以把 ToolInfo 想象成餐厅菜单:菜名(Name)、菜的照片和介绍(Desc)、口味选项和必选配料(ParamsOneOf)。LLM 根据菜单决定点哪道菜(ToolCall)。


流式管道:Pipe、StreamReader、StreamWriter

前面讲的都是"一次给完整结果"。但 LLM 生成回答要几秒,如果等全部生成完再返回,用户看到的就是空白页。

Eino 的流式基础设施是三个东西:PipeStreamReaderStreamWriter

一句话概括

Pipe 创建管道,StreamWriter 往里写,StreamReader 从里读。

scss 复制代码
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)    // ③ 发送错误,消费者会收到这个错误
}()

三个要点:

  1. Close() 必须调------消费者靠它判断"流结束了"
  2. Send() 的第二个参数是 error------可以中途报告错误
  3. 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 读。两边的数据格式不同,但流动方式一样------都是"生产者写,消费者读"。


一张全景图:数据怎么在系统里流

把所有内容串起来,看一条消息从用户输入到最终回答的完整数据流:

scss 复制代码
用户输入 "北京天气怎么样"
    │
    ▼
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.Messagemodel.ToolCall)是业务视角的,Eino 的 schema 是框架视角的,两者通过适配器里的映射函数互转。


本文是「企业级 AI Agent 实现拆解」补充篇 E2。上一篇 E1 讲了 Eino ReAct 循环的有向图模型,这篇拆了图里流动的数据长什么样。下一篇 E3 会看 Graph 编排------不只是 ReAct,任何有向无环图都能画。

相关推荐
于慨1 小时前
cursor的agents window报错崩溃求解
ai编程
张彦峰ZYF2 小时前
LangGraph Tool Calling 入门:从 @tool 到完整调用链
人工智能·大模型·agent·langgraph·tool calling
Aloudata2 小时前
传统 BI 指标向语义层迁移实操指南与避坑详解
数据分析·agent·bi·语义层·语义编织
鲁子狄2 小时前
lrnev:让 AI 协作开发「有记忆、可追溯」的项目治理引擎 | 零模型依赖,文件即真相
人工智能·笔记·gpt·ai·ai编程
SiYuanFeng2 小时前
大模型 / RAG / Agent 面试高频题
人工智能·面试·transformer·agent·rag
ANnianStriver2 小时前
PetLumina 09 — 全局日期格式化与通知详情完善
ai·ai编程·路由·日期格式化
恋猫de小郭2 小时前
不需要数学基础,也能理解 LLM 的运作原理
人工智能·aigc·ai编程
總鑽風2 小时前
Spring AI实战:快速集成阿里通义千问
java·后端·spring·ai编程
Artech2 小时前
[MAF预定义ChatClient中间件-05]动态修改ChatOptions和请求消息
ai·agent·maf·agent管道