第十一篇:Eino Message与Prompt上下文编排

2026-04-03 50 0

 

第十一篇:Eino核心解析 - Message与Prompt上下文编排

承接第十篇对Eino底层ChatModel组件的解析(核心入参为[]*schema.Message),本篇将从实战开发者视角,结合完整源码与可运行案例,系统讲解Eino的消息协议设计、Prompt模板引擎、多轮上下文管理、流式消息处理等核心能力。无论你是刚接触Eino的新手,还是需要深度定制大模型应用的开发者,都能通过本文掌握Eino上下文编排的底层逻辑与最佳实践。

一、为什么需要schema包?—— 统一大模型消息协议

在第十篇中我们知道,Eino的ChatModel组件只认[]*schema.Message作为输入,但为什么要单独设计schema包?

市面上不同大模型厂商(OpenAI、通义千问、DeepSeek、Ollama)的消息格式差异极大:

  • • 角色定义不统一(有的叫assistant,有的叫bot);
  • • 多模态消息(图片/音频)的存储结构各不相同;
  • • 工具调用的参数格式千差万别;
  • • 流式返回的分片规则不一致。

Eino的schema包本质是全局统一的消息协议层,核心价值在于:

  1. 1. 屏蔽厂商差异:统一4种核心角色、标准化多模态/工具调用格式;
  2. 2. 降低开发成本:提供开箱即用的消息构造、模板渲染、流式合并工具;
  3. 3. 保证扩展性:支持业务自定义扩展字段,兼容未来新的消息类型;
  4. 4. 提升稳定性:内置类型校验、字段规范,避免格式错误导致的调用失败。

源码入口:github.com/cloudwego/eino/schema

二、核心基础:Message结构体全解析

Message是Eino中所有对话内容的载体,先从最基础的结构开始拆解,让你一看就懂。

2.1 先搞懂:4种核心消息角色

Eino定义了4种标准化角色,覆盖所有对话场景,且与主流大模型兼容:

type RoleType string

const (
    Assistant RoleType = "assistant" // AI模型的输出消息
    User      RoleType = "user"       // 用户输入的消息
    System    RoleType = "system"     // 系统提示词(定义模型人设/规则)
    Tool      RoleType = "tool"       // 工具调用的返回结果
)

为了简化开发,Eino提供了4个快捷构造函数(推荐优先使用,避免手动赋值出错):

// 示例:快速创建各类消息
sysMsg := schema.SystemMessage("你是一个专业的Go开发助手,回答简洁易懂")       // 系统消息
userMsg := schema.UserMessage("Eino的Message结构体有哪些核心字段?")            // 用户消息
assistMsg := schema.AssistantMessage("核心字段包括Role、Content等", nil)       // 助手消息
toolMsg := schema.ToolMessage("查询结果:100", "call-123", schema.WithToolName("get_user_info")) // 工具消息

2.2 Message完整结构:字段详解

以下是Message的核心字段拆解,标注了“必选/可选”和“使用场景”,告别源码看不懂的烦恼:

字段名类型是否必选核心作用
RoleRoleType消息角色(system/user/assistant/tool)
Contentstring纯文本是纯文本消息内容(普通对话优先用这个)
UserInputMultiContent[]MessageInputPart可选用户侧多模态输入(图片/音频/文件,替代旧版MultiContent)
AssistantGenMultiContent[]MessageOutputPart可选模型侧多模态输出(图片/思考链,替代旧版MultiContent)
ToolCalls[]ToolCall可选Assistant消息专属:模型发起的工具调用指令
ToolCallIDstringTool消息是Tool消息专属:绑定对应的工具调用ID(必须和ToolCalls中的ID一致)
ResponseMeta*ResponseMeta可选模型返回元数据(Token消耗、结束原因、对数概率)
Extramap[string]any可选业务扩展字段(存储自定义数据,如消息ID、创建时间)

关键使用规范

  1. 1. 纯文本对话:只填Role + Content,别碰多模态字段;
  2. 2. 多模态输入(比如用户发图片):只写UserInputMultiContent不要同时赋值Content
  3. 3. 工具调用场景:Tool消息必须填ToolCallID,否则无法关联对应的工具调用;
  4. 4. 扩展字段Extra只存业务数据,核心逻辑字段(如角色、内容)不要放这里。

2.3 多模态消息:图片/音频怎么存?

如果需要处理图片、音频等多模态内容,Eino分“用户输入”和“模型输出”两种结构,逻辑清晰:

1)用户侧多模态输入(MessageInputPart)

支持文本、图片(URL/Base64)、音频、视频、文件,以图片为例:

// 示例:创建带图片的用户消息
imgPart := schema.MessageInputPart{
    Type: schema.ChatMessagePartTypeImage,
    Image: &schema.MessageInputImage{
        URL:     "https://example.com/ai-code.png", // 图片URL
        Detail:  schema.ImageURLDetailHigh,         // 高清解析
        Extra:   map[string]any{"image_id": "img-001"},
    },
}
// 组装成用户消息
userMultiMsg := &schema.Message{
    Role:                    schema.User,
    UserInputMultiContent:   []schema.MessageInputPart{imgPart},
}

2)模型侧多模态输出(MessageOutputPart)

相比输入,额外支持“思考链”(适配DeepSeek、通义千问推理版等模型):

// 示例:模型返回思考链+文本
reasoningPart := schema.MessageOutputPart{
    Type: schema.ChatMessagePartTypeReasoning,
    Reasoning: &schema.MessageOutputReasoning{
        Content: "用户问图片中的代码问题,先分析语法错误,再给出修复方案",
        Sign:    "xxx", // 思考链签名(多轮对话需回传)
    },
}
textPart := schema.MessageOutputPart{
    Type: schema.ChatMessagePartTypeText,
    Text: "图片中的代码缺少error处理,修复后如下:...",
}
// 组装成助手消息
assistMultiMsg := &schema.Message{
    Role:                      schema.Assistant,
    AssistantGenMultiContent:  []schema.MessageOutputPart{reasoningPart, textPart},
}

2.4 元数据:Token消耗/结束原因怎么看?

模型返回的非内容信息(比如用了多少Token、为什么停止输出)都存在ResponseMeta里:

// 示例:获取Token消耗
if resp.ResponseMeta != nil && resp.ResponseMeta.Usage != nil {
    usage := resp.ResponseMeta.Usage
    fmt.Printf("提示词Token:%d,生成内容Token:%d,总计:%d\n",
        usage.PromptTokens, usage.CompletionTokens, usage.TotalTokens)
}

三、Prompt模板引擎:动态拼接多轮上下文

第十篇中ChatModel需要固定的[]*Message入参,但实际业务中,我们经常需要:

  • • 动态替换模板变量(比如“{user_name}”“{query}”);
  • • 注入多轮历史对话;
  • • 支持条件/循环等复杂模板逻辑。

Eino的MessagesTemplate接口就是为解决这些问题而生,提供3种易用的模板引擎。

3.1 3种模板引擎:选对工具效率翻倍

Eino支持3种渲染方式,覆盖从简单到复杂的所有场景:

引擎类型语法示例适用场景特点
FString你好{name},{age}岁简单变量替换(推荐日常使用)类似Python格式化,易上手
GoTemplate{{if .isVIP}}VIP用户{{end}}复杂逻辑(分支/循环)Go原生语法,功能强
Jinja2{% if isVIP %}VIP{% endif %}兼容Python生态模板禁用危险标签,安全可控

3.2 核心痛点解决:多轮历史对话注入

手动拼接历史对话容易出错(比如格式不对、角色混乱),Eino提供MessagesPlaceholder专门解决这个问题:

// 创建历史对话占位符:key=变量名,optional=是否可选(缺失是否报错)
historyPlaceholder := schema.MessagesPlaceholder("chat_history", true)

核心优势

  1. 1. 类型安全:只接受[]*schema.Message类型,杜绝字符串拼接的格式错误;
  2. 2. 容错可控:可选占位符缺失时返回空,非可选占位符缺失直接报错,提前暴露问题;
  3. 3. 逻辑清晰:历史对话与模板分离,代码易维护。

3.3 模板使用示例:一步到位

// 1. 构建模板(FString引擎)
tpl := prompt.FromMessages(schema.FString,
    schema.SystemMessage("你是{domain}助手,回答风格:{style}"), // 系统消息带变量
    schema.MessagesPlaceholder("chat_history", true),          // 历史对话占位
    schema.UserMessage("用户问题:{query}"),                    // 用户问题带变量
)

// 2. 准备变量(含历史对话)
variables := map[string]any{
    "domain":       "Eino Go大模型框架",
    "style":        "简洁专业",
    "chat_history": []*schema.Message{ // 历史对话
        schema.UserMessage("schema.Message有几种核心角色?"),
        schema.AssistantMessage("核心角色有4种:system、user、assistant、tool"),
    },
    "query": "MessagesPlaceholder的核心作用是什么?",
}

// 3. 渲染模板 → 标准化消息切片
fullMessages, err := tpl.Format(ctx, variables)
if err != nil {
    // 错误处理
}

四、流式消息合并:解决分片拼接难题

第十篇提到ChatModel支持流式返回(Stream方法),每次只能拿到“碎片化”的消息(比如先返回“你”,再返回“好”),手动拼接容易丢失信息(比如工具调用、Token元数据)。

Eino内置了完整的流式合并工具,一行代码搞定:

4.1 核心合并函数(开箱即用)

// 1. 获取流式阅读器
stream, err := model.Stream(ctx, fullMessages)
if err != nil { /* 错误处理 */ }
defer stream.Close()

// 2. 合并所有分片 → 完整Message
fullMsg, err := schema.ConcatMessageStream(stream)
if err != nil { /* 错误处理 */ }

// 3. 拿到完整结果
fmt.Println(fullMsg.Content) // 完整文本
fmt.Println(fullMsg.ToolCalls) // 完整工具调用
fmt.Println(fullMsg.ResponseMeta.Usage) // 完整Token消耗

4.2 合并规则

Eino会自动处理:
✅ 拼接文本内容、思考链;
✅ 合并工具调用分片(按Index排序);
✅ 汇总Token消耗、保留最后一个结束原因;
✅ 校验所有分片的角色一致性(避免拼接错误)。

五、完整实战Demo:覆盖所有核心能力

以下是可直接运行的代码,包含“模板渲染+多轮上下文+模型调用+流式合并”全流程。

5.1 前置准备

  1. 1. 安装依赖:
go get github.com/cloudwego/eino
go get github.com/cloudwego/eino-ext/components/model/openai
go mod tidy
  1. 2. 获取DeepSeek API Key(免费申请:https://platform.deepseek.com/),配置环境变量:
# Linux/Mac
export DEEPSEEK_API_KEY="你的API Key"

# Windows
set DEEPSEEK_API_KEY="你的API Key"

5.2 完整代码

package main

import (
    "context"
    "fmt"
    "os"

    eino_openai "github.com/cloudwego/eino-ext/components/model/openai"
    "github.com/cloudwego/eino/components/prompt"
    "github.com/cloudwego/eino/schema"
)

// ==================== Eino 框架:Prompt 模板 ====================
// 本 demo 演示 Eino 的 Prompt 模板:变量替换 + 历史消息占位 + 渲染为标准消息。
// 对比 learn10(手动构造 messages),这里用模板引擎实现参数化 Prompt。

const (
    baseURL   = "https://api.deepseek.com"
    modelName = "deepseek-v4-flash" // DeepSeek 模型,若不存在可改 deepseek-chat
)

// ptr 返回值的指针,用于 *float32 等需要指针的配置字段。
func ptr[T any](v T) *T { return &v }

// newChatModel 创建 DeepSeek ChatModel 实例。
func newChatModel(ctx context.Context) (*eino_openai.ChatModel, error) {
    apiKey := os.Getenv("DEEPSEEK_API_KEY")
    if apiKey == "" {
        return nil, fmt.Errorf("DEEPSEEK_API_KEY 环境变量未设置")
    }
    return eino_openai.NewChatModel(ctx, &eino_openai.ChatModelConfig{
        BaseURL:     baseURL,
        APIKey:      apiKey,
        Model:       modelName,
        Temperature: ptr(float32(0.4)), // 随机性:0-1,越小越稳定
        MaxTokens:   ptr(1024),         // 最大生成 Token 数
    })
}

// buildTemplate 构建带变量和历史占位的 Prompt 模板。
// schema.FString 使用 Python 风格的 {var} 占位符;MessagesPlaceholder 注入多轮历史。
func buildTemplate() *prompt.DefaultChatTemplate {
    return prompt.FromMessages(schema.FString,
        schema.SystemMessage("你是{domain}技术助手,回答风格:{style}"),
        schema.MessagesPlaceholder("chat_history", true), // true=可选,无历史时不报错,如果填 false 则必填 chat_history 变量
        schema.UserMessage("用户问题:{query}"),
    )
}

// buildVariables 准备模板变量:领域、风格、多轮历史、当前问题。
func buildVariables() map[string]any {
    history := []*schema.Message{
        schema.UserMessage("Eino 的 schema 包有什么作用?"),
        schema.AssistantMessage("schema 包是 Eino 的统一消息协议层,屏蔽不同大模型的格式差异,简化开发", nil),
    }
    return map[string]any{
        "domain":       "Eino Go 大模型框架",
        "style":        "简洁专业,用新手能懂的语言",
        "chat_history": history, //  如果 MessagesPlaceholder 中填 false,这里必填 chat_history 变量
        "query":        "如何正确使用 MessagesPlaceholder?",
    }
}

// printMessages 打印渲染后的消息列表(调试用)。
func printMessages(messages []*schema.Message) {
    fmt.Println("====== 渲染后的上下文 ======")
    for i, msg := range messages {
        fmt.Printf("[%d] 角色:%s → 内容:%s\n", i, msg.Role, msg.Content)
    }
}

// printUsage 打印 Token 消耗(成本核算必备)。
func printUsage(resp *schema.Message) {
    if resp.ResponseMeta == nil || resp.ResponseMeta.Usage == nil {
        return
    }
    u := resp.ResponseMeta.Usage
    fmt.Println("====== Token 消耗 ======")
    fmt.Printf("提示词 Token:%d\n生成 Token:%d\n总计 Token:%d\n",
        u.PromptTokens, u.CompletionTokens, u.TotalTokens)
}

func main() {
    ctx := context.Background()

    // 1. 初始化模型
    model, err := newChatModel(ctx)
    if err != nil {
        fmt.Printf("模型初始化失败:%v\n", err)
        return
    }

    // 2. 构建模板并渲染(变量替换 + 历史注入)
    tpl := buildTemplate()
    messages, err := tpl.Format(ctx, buildVariables())
    if err != nil {
        fmt.Printf("模板渲染失败:%v\n", err)
        return
    }
    printMessages(messages)

    // 3. 调用模型(同步模式)
    fmt.Println("\n====== 模型回答 ======")
    resp, err := model.Generate(ctx, messages)
    if err != nil {
        fmt.Printf("模型调用失败:%v\n", err)
        return
    }
    fmt.Println(resp.Content)
    printUsage(resp)
}

5.3 运行结果示例

====== 渲染后的上下文 ======
[0] 角色:system → 内容:你是Eino Go 大模型框架技术助手,回答风格:简洁专业,用新手能懂的语言
[1] 角色:user → 内容:Eino 的 schema 包有什么作用?
[2] 角色:assistant → 内容:schema 包是 Eino 的统一消息协议层,屏蔽不同大模型的格式差异,简化开发
[3] 角色:user → 内容:用户问题:如何正确使用 MessagesPlaceholder?

====== 模型回答 ======
MessagesPlaceholder 的作用是在提示模板中预留一个位置,运行时动态插入一组**消息列表**(例如历史对话),用于支持多轮对话上下文。

正确使用步骤如下:

1. **定义模板**:在模板中声明占位符,例如 `{{.History}}`,它代表一个消息数组。
2. **构造输入**:在生成请求时,通过 `map[string]any` 传入变量,其中 `History` 的值必须是 `[]*schema.Message` 类型(来自 `schema` 包)。
3. **渲染模板**:模板引擎会将该消息列表原样插入,而不是转成单个字符串,因此每条消息都会保留 role/content 等结构化信息。

**示例(伪代码)**:
```go
import "github.com/cloudwego/eino/schema"

tmpl := "... {{.History}} ... {{.Question}}" // 模板
input := map[string]any{
    "History": []*schema.Message{
        {Role: schema.User, Content: "你好"},
        {Role: schema.Assistant, Content: "有什么可以帮你?"},
    },
    "Question": "你是谁?",
}
// 渲染后即可作为完整的对话输入传给模型
```

常见错误:
- 将历史对话直接作为字符串拼接,而不是传入 `[]*schema.Message`。
- 占位符名称与模板中变量名不一致(如写 `{{.history}}` 但传入 `History`)。

确保引用 `schema` 包中的 `Message` 类型,并检查你的 Eino 版本中模板语法是否基于 Go `text/template`。更详细的 API 请参考 Eino 官方文档中 “Prompt Template” 或 “ChatTemplate” 部分。
====== Token 消耗 ======
提示词 Token:150
生成 Token:892
总计 Token:1042

六、最佳实践 & 避坑清单

6.1 最佳实践

  1. 1. 消息创建:优先使用快捷构造函数(SystemMessage/UserMessage),减少手动赋值错误;
  2. 2. 模板选择:日常业务用FString,复杂逻辑用GoTemplate,尽量不用Jinja2;
  3. 3. 历史对话:强制用MessagesPlaceholder注入,禁止字符串拼接;
  4. 4. 流式处理:必须用ConcatMessageStream合并分片,不要手动拼接Content;
  5. 5. 多模态:严格区分UserInputMultiContent(用户输入)和AssistantGenMultiContent(模型输出)。

6.2 常见坑点

  1. 1. 给Tool消息漏填ToolCallID → 工具调用无法关联;
  2. 2. 多模态消息同时赋值Content和UserInputMultiContent → 模型可能忽略部分内容;
  3. 3. MessagesPlaceholder传入字符串/切片 → 直接报类型错误;
  4. 4. 流式分片手动拼接Content → 丢失工具调用/Token元数据;
  5. 5. Jinja2模板尝试加载本地文件 → 被Eino安全机制拦截,报错。

七、总结

  • • 第十篇的ChatModel是“执行器”,本篇的schema包是“数据载体”:ChatModel只认[]*schema.Message,而schema包负责标准化、模板化生成这个载体;
  • • Prompt模板引擎解决“动态上下文组装”问题,让多轮对话开发更高效;
  • • 流式合并工具解决ChatModel.Stream的碎片化数据问题,保证结果完整;
  • • schema包是Eino的“数据总线”,连接Prompt、ChatModel、工具调用、Agent等所有核心组件。

相关文章

第十六篇:Eino入门——核心组件总览与场景选型总结
第十五篇:Eino ChatModelAgent 模型自己决定是否调用工具
第十四篇:Eino ToolsNode 工具调用执行器
第十三篇:InvokableTool-把Go函数包装成模型可调用的工具
第十二篇:Eino ToolInfo工具-给模型看的说明书
第十篇:Eino ChatModel:将大模型调用抽象成可复用组件

发布评论