Go 基础体系 · 第 105/113 篇。示例统一基于 Go 1.26.4;核心片段可能省略 package 与 import,完整程序可直接按文中结构运行。
Go Eino 实战:Model、Prompt、Chain、Graph 与组件编排
本文以 Go 1.26.4、github.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,常用入口是 Generate 和 Stream。先用单组件验证角色、结束原因、工具调用和 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] = ©OfMessage
}
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 完整技术体系学习路线:从语法、并发到框架、中间件与 AI
- 上一篇:Go LLM 结构化输出与工具调用:JSON Schema、循环和权限
- 下一篇:Go LangChainGo 基础:Model、Prompt、Chain、Tool 与适用边界
- 延伸:Go AI 应用学习路线:LLM、RAG、Agent、MCP 与生产治理
- 延伸:Go RAG 完整流程:切块、Embedding、向量库、重排与引用
- 延伸:Go Agent 与记忆系统:状态机、工具、短期上下文和长期记忆
官方资料
本文依据 Go 官方规范、标准库文档和 Go 官方博客重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论