Go 基础体系 · 第 105/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。

Go Eino 实战:Model、Prompt、Chain、Graph 与组件编排

本文以 Go 1.26.4github.com/cloudwego/eino v0.7.14 与同版本线扩展组件为基线。Eino 在 v1 前仍可能调整 API;生产必须在 go.mod 固定精确版本并提交 go.sum,升级时重新编译、跑集成测试和模型行为评测。示例关注稳定的组件、编排和生命周期模型,构造器细节以项目锁定版本为准。

Eino 把 ChatModel、Prompt、Retriever、Indexer、Embedding、Tool 等能力抽象为组件,再用 Chain 或 Graph 描述数据流。框架负责连接和调度,应用仍拥有身份、权限、事务、预算与部署生命周期。

1. 什么时候值得引入 Eino

只有一次模型调用时,自有 Model 接口加标准 HTTP 更容易诊断。出现多阶段提示、检索、条件分支、工具循环、流式转换和统一 callback 后,类型化编排能减少重复胶水。它不会自动改善提示、权限或成本。

HTTP -> Application Service -> 自有 UseCase DTO
                         -> Eino Runnable/Graph
                            -> ChatModel/Retriever/Tool
                         -> 自有 Result

handler 不直接构图,repository 不返回 schema.Message。框架类型限制在 internal/ai;业务层只看自有 request/result 和稳定错误,切 provider 或绕开框架不会改变 API 契约。

2. 模块版本与初始化所有权

核心模块与 provider 扩展分别固定版本。应用入口读取配置,创建长期复用的模型、HTTP client、向量库和 callback;请求路径只调用已编译 runnable。禁止每请求重建连接池或重新 Compile 图。

module example.com/assistant

go 1.26.4

require github.com/cloudwego/eino v0.7.14
go mod download
go mod verify
go test ./...
go test -race ./...
go vet ./...

密钥只通过 provider 构造器注入,不进入节点状态。构建失败应让服务启动失败,而非等首个用户请求才暴露类型不匹配。组件初始化后尽量作为并发安全只读对象;若 provider 不保证并发安全,则包装并限制容量。

3. ChatModel 是能力接口

model.ChatModel 接收 []*schema.Message,常用入口是 GenerateStream。先用单组件验证角色、结束原因、工具调用和 usage 映射,再进入编排。

func ask(ctx context.Context, chat model.ChatModel, messages []*schema.Message) (string, error) {
	response, err := chat.Generate(ctx, messages)
	if err != nil {
		return "", fmt.Errorf("generate answer: %w", err)
	}
	if response == nil {
		return "", errors.New("model returned nil message")
	}
	return response.Content, nil
}

所有调用传请求 context。provider 错误在适配器层归类为限流、超时、取消、协议或策略拒绝;业务不能解析错误字符串。服务端掌握模型名和 token 安全上限,用户参数只能在允许范围内变化。

4. Message 与 PromptTemplate 边界

System、User、Assistant、Tool 消息顺序及 call ID 是协议状态,不能把全部历史拼成字符串。PromptTemplate 以固定模板和显式变量构造消息;变量经过模板后仍是不可信文本。

template := prompt.FromMessages(schema.FString,
	schema.SystemMessage("你是 Go 代码审查助手,只依据给定代码回答。"),
	schema.UserMessage("问题:{question}\n代码:\n{code}"),
)
messages, err := template.Format(ctx, map[string]any{
	"question": question,
	"code":     code,
})
if err != nil {
	return nil, fmt.Errorf("format prompt: %w", err)
}

Format 前限制变量字节和 rune 数,并标明资料边界。模板保存版本与 hash;不能让用户选择任意 system prompt。历史裁剪按 token 预算保留系统规则及完整工具调用对,不能只删 tool result。

5. Chain 表达线性数据流

Chain 适合输入映射、模板、模型、输出解析这类顺序固定的步骤。节点输入输出类型必须连接;把不可预测分支藏进 lambda 会失去拓扑可读性。

chain := compose.NewChain[map[string]any, string]()
chain.AppendChatTemplate(template)
chain.AppendChatModel(chatModel)
chain.AppendLambda(compose.InvokableLambda(func(
	ctx context.Context, message *schema.Message,
) (string, error) {
	if message == nil { return "", errors.New("nil model response") }
	return strings.TrimSpace(message.Content), nil
}))
runnable, err := chain.Compile(ctx)
if err != nil { return nil, fmt.Errorf("compile answer chain: %w", err) }

具体泛型 helper 名称以固定 v0.7.14 API 编译结果为准。关键是 Compile 在启动阶段,Invoke 在请求阶段。字符串规范化或领域校验若普通函数更清楚,就不必变成节点。

6. Graph 表达分支、汇合和循环

Graph 适合 query rewrite 后并行检索、按分类选择模型、工具调用后返回模型等非线性流程。每节点只承担一种能力,边描述允许路径,条件函数只返回注册目标。

START -> validate -> prompt -> chat
                           chat -- final --> END
                           chat -- tools --> authorize -> execute
                                                   ^          |
                                                   +-- chat <-+

工具回边必须设置最大步数、总 deadline、token/费用和重复调用检测。图能表达循环不代表允许无限循环。条件函数不做网络副作用;副作用放在可独立测试、有审计的节点。

7. 状态与并发边界

Graph 状态只保存本轮必要数据:不可变 actor、消息、检索摘要、预算和工具结果。不能放 HTTP body、mutex、连接或跨请求会话。并行节点不能写共享 map,应返回独立值并在汇合点确定性合并。

type TurnState struct {
	Actor      Actor
	Messages   []*schema.Message
	Documents  []Document
	Steps      int
	TokenUsed  int
	Generation string
}

func cloneMessages(in []*schema.Message) []*schema.Message {
	out := make([]*schema.Message, len(in))
	for i, message := range in {
		copyOfMessage := *message
		out[i] = &copyOfMessage
	}
	return out
}

跨节点复制需要变更的切片和指针,避免 race。大型文档可保留受控引用或片段 DTO,但引用绑定租户与本轮生命周期。状态不是数据库,进程重启后需恢复的任务写入持久存储。

8. Retriever、Embedding 与 RAG

Eino 能连接 Retriever、Embedding、Indexer 和 Loader,但不能替代 RAG 质量设计。检索前做租户/ACL 过滤,metadata 保存文档、版本、chunk 与来源;召回后限制总字符,必要时重排,再构造上下文。

func toPromptInput(question string, docs []*schema.Document) (map[string]any, error) {
	if len(docs) > 12 { return nil, errors.New("too many documents") }
	var text strings.Builder
	for _, document := range docs {
		fmt.Fprintf(&text, "[source=%s]\n%s\n", sourceID(document), document.Content)
	}
	return map[string]any{"question": question, "context": text.String()}, nil
}

检索内容是不可信数据,不能覆盖 system 指令。引用 ID 由应用根据 metadata 生成,不能让模型凭空造 URL。Embedding 维度、模型和归一化是索引契约;升级需重建或双写索引,不能混放后假设可比较。

9. Tool 组件与应用权限

Eino Tool 把名称、描述和 schema 接入模型,但模型只有提议权。执行节点从可信 state 获取 Actor,严格验证参数、对象级授权、确认 token、幂等键和配额。通用 shell、SQL 和任意 URL 工具不应注册。

{
  "name": "get_article_summary",
  "description": "读取当前用户有权访问的文章摘要",
  "parameters": {
    "type": "object",
    "additionalProperties": false,
    "required": ["article_id"],
    "properties": {
      "article_id": {"type": "string", "pattern": "^art_[A-Za-z0-9]+$"}
    }
  }
}

Tool result 限尺寸且视为提示注入来源。错误只回送稳定 code,不回 stack、SQL 或凭据。写工具默认用户确认;确认应绑定 actor、工具、参数 hash 和有效期,不能只是 state 中一个布尔值。

10. 流式 Chunk 合并与关闭

流不是字符串 channel。ChatModel 可能产生文本、reasoning、tool call ID/名称/arguments 和 usage 增量。按 choice/call index 合并,工具 JSON 完成前不能执行。调用方在正常、错误或取消路径都关闭 reader。

reader, err := chat.Stream(ctx, messages)
if err != nil { return fmt.Errorf("start stream: %w", err) }
defer reader.Close()
for {
	chunk, err := reader.Recv()
	if errors.Is(err, io.EOF) { break }
	if err != nil { return fmt.Errorf("receive stream: %w", err) }
	if err := accumulator.Merge(chunk); err != nil { return err }
	if err := sink.Send(ctx, publicDelta(chunk)); err != nil { return err }
}
return accumulator.RequireCompleted()

实际 reader 方法以锁定版本为准。正常完成与异常 EOF 必须区分;sink 慢时使用有界背压,队列满就取消整轮,不能丢文本 delta。浏览器断开由同一 context 取消模型、检索和工具节点。

11. Callback 与可观测性

Callback 统一记录组件开始/结束、耗时、token、错误分类和 trace。它不能改变业务结果,也不默认记录完整 prompt、文档、参数或输出。callback 必须快速、并发安全;遥测后端故障不得阻塞请求。

trace: graph.name, node.kind, provider, model, finish_reason
metric: invoke_count, duration, ttft, input_tokens, output_tokens, tool_calls
log: generation_id, node, error_code, retry_attempt

generation ID 可关联日志;user/document ID 等高基数字段不作 metric label。token 以 provider usage 为准,估算值明确标记。Callback panic 应由边界恢复上报,但不应使主进程崩溃。

12. Context、取消、重试与错误

入口设置整轮 deadline,各组件按剩余预算设置子 timeout。取消沿 Invoke/Stream 传播;节点循环、semaphore 和队列都 select ctx。禁止节点用 context.Background() 脱离用户生命周期。

重试只放在明确 provider 边界,且仅限尚未输出、操作可重放、错误瞬时的情况。Graph、SDK、sidecar 和网关不能层层重试。写工具通过幂等键保证重放安全。错误包装保留 cause,出口统一成 canceled、deadline、invalid、forbidden、upstream、internal。

流已向用户发送 delta 后不能重新 Invoke 再拼接。应标记 partial,关闭资源,由用户决定是否新建一轮。

13. 结构化输出与解析节点

模型请求携带 provider 支持的严格 JSON Schema;流式输出先完整合并,完成后有界严格解码。解析节点只做结构映射,不执行数据库写入。

type Review struct {
	Risk    string   `json:"risk"`
	Issues  []string `json:"issues"`
	Summary string   `json:"summary"`
}

func validateReview(review Review) error {
	switch review.Risk {
	case "low", "medium", "high":
	default: return errors.New("invalid risk")
	}
	if len(review.Issues) > 20 { return errors.New("too many issues") }
	return nil
}

Schema 正确仍需领域验证。解析失败的修复是新模型调用,要占用步骤、token 与费用预算。不要用正则修补 JSON,也不要忽略未知字段后继续副作用。

14. 测试组件、图和模型行为

组件测试注入 fake ChatModel/Retriever/Tool,断言消息、取消、错误映射和资源关闭。Graph 测试覆盖每条条件边、汇合顺序、循环上限、节点 timeout 和部分失败;不要只断言最终自然语言。

type fakeModel struct {
	responses []*schema.Message
	calls     atomic.Int64
}

func (f *fakeModel) next(ctx context.Context) (*schema.Message, error) {
	if err := ctx.Err(); err != nil { return nil, err }
	index := int(f.calls.Add(1)) - 1
	if index >= len(f.responses) { return nil, errors.New("unexpected model call") }
	return f.responses[index], nil
}

真实 provider 契约测试在受控 CI 跑,固定模型和小预算;golden 断言结构、引用和安全不变量,不要求措辞逐字一致。升级前比较完成率、工具选择、TTFT、token 与费用。并行 Graph 必须跑 go test -race,节点输入转换可加 fuzz。

15. 生产容量、部署与边界

分别限制模型、检索和工具并发,用隔离舱防慢向量库占满 goroutine。队列有界,满载尽早 429/503。按“并发轮数 × 平均状态/文档/流缓冲”压测内存,不按普通短 HTTP 的 QPS 猜容量。

关闭时先摘 readiness、停止新图,给在途轮有限时间,取消剩余流,等待 callback flush,再关 transport 与存储。配置、prompt、Graph 结构和模型别名一起版本化;灰度按 generation ID 稳定分流,避免同一会话中途切图。

Chain 用于固定数据流,Graph 用于真实分支与循环;数据库事务、权限、幂等、状态机和简单条件继续用普通 Go。可靠项目保留领域层、Eino 适配层、provider 层三道边界。Eino 提供更清楚的执行拓扑,不提供新的信任边界;取消、流合并、授权、评测、成本和容量仍必须由应用完整治理。上线后还应定期用固定评测集复查这条边界,并把回归结果与发布版本一同归档,确保问题能够定位到具体变更。


系列导航与关联阅读

官方资料

本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。