系列「企业级 AI Agent 实现拆解」E29 篇。前面 28 篇把 Eino 的机制讲清楚了。这篇和下篇换个角度:看 DeepFlux 实际怎么用 Eino------具体在
server/internal/agent/infrastructure/einoadapter/这个目录里,五个适配器做了什么。
读完这篇你会知道
- 为什么适配器在 infrastructure 层,不在 application 层
chatModelAdapter:把多租户 LLMClient 变成 Eino model 接口toolBrokerAdapter:把 ToolBroker 变成 Eino InvokableTool,顺手解决工具名碰撞SessionCheckpointStore:把 SessionRepo 变成 Eino CheckPointStoreAgentCallback:把 HookRunner + Streamer 变成 callbacks.HandlertokenStreamReader:把 Eino StreamReader 变成 application 层的 TokenReaderReActFactory:把这五个组装起来,加 Runnable 缓存
一、为什么需要适配器
DDD 的 D4 规则:跨层依赖要倒置。application 层不能直接 import Eino 的具体 API。
bash
application/command/run_turn.go
↓ 只看 port 接口(框架无关)
application/port/ports.go
LLMClient / ToolBroker / TokenReader / ReActRunnableFactory
↑ 只看 port 接口
infrastructure/einoadapter/
chatModelAdapter / toolBrokerAdapter / SessionCheckpointStore / AgentCallback / tokenStreamReader / ReActFactory
↑ 只这里 import Eino
一句话:Eino 的类型只在 einoadapter 包里出现,application 层和 domain 层完全不知道 Eino 的存在。换掉 Eino 只需改 infrastructure 层,不碰业务逻辑。
二、chatModelAdapter:多租户 LLM → Eino ChatModel
go
type chatModelAdapter struct {
client port.LLMClient // DeepFlux 内部 LLM 调用接口
profile string // LLM 配置名,如 "deepseek-v3"
tenantID string // 计费 / RLS 隔离用
sessionID string // 绑定到 session(可被 context 覆盖)
temperature float32
maxTokens int
cachedSchemas []llm.ToolSchema // WithTools 时预计算,避免 per-request 双重 JSON 转换
}
实现了 einomodel.ToolCallingChatModel------这是 Eino ReAct 需要的接口(比 ChatModel 多一个 WithTools 方法)。
WithTools 不可变 :返回新实例,不修改已有实例。这符合 Eino compose 的期望------图编译时 WithTools 一次,运行时不再变。同时预计算 cachedSchemas,把 *schema.ToolInfo → llm.ToolSchema 的 JSON 转换做在构建期,不在每次请求时做。
Stream 里的 sessionID 读取顺序:
go
func (a *chatModelAdapter) buildRequest(ctx context.Context, input []*schema.Message) llm.ChatRequest {
sessionID := a.sessionID
if sid, ok := ctx.Value(port.ContextKeyAgentSessionID{}).(string); ok && sid != "" {
sessionID = sid
}
// ...
}
Runnable 会被缓存复用------同一份编译好的图可能服务多个 session。a.sessionID 是构建时绑定的,如果用它就会把 session A 的 LLM 调用算到 session B 头上。所以运行时优先从 context 读,context 里没有才 fallback 到构建时的值。
Generate 是流的消费者 :不直接调 LLM 同步接口,而是调 Stream 然后收集 chunks 合并。DeepFlux 的 LLMClient 只有流式实现,这样两个接口共用同一条路径。
三、toolBrokerAdapter:工具调用路由 + 名字净化
ToolsFromBroker 把 ToolBroker.Schemas() 返回的清单变成 []tool.InvokableTool:
go
func ToolsFromBroker(ctx context.Context, broker port.ToolBroker, tenantID, sessionID, agentConfig string) ([]tool.InvokableTool, error) {
schemas, err := broker.Schemas(ctx, tenantID, agentConfig)
// ...
seen := make(map[string]string) // sanitized name → 原始名
for _, s := range schemas {
sanitized := sanitizeToolName(s.Name)
if orig, ok := seen[sanitized]; ok && orig != s.Name {
return nil, fmt.Errorf("tool name collision: %q and %q both → %q", orig, s.Name, sanitized)
}
seen[sanitized] = s.Name
tools = append(tools, &toolBrokerAdapter{...})
}
}
为什么要净化名字 :OpenAI / DeepSeek 的工具名只接受 [a-zA-Z0-9_-],不接受点号。DeepFlux 的工具按 <namespace>.<name> 命名(如 kb.search),点号直接发给 LLM API 会报错。
go
func sanitizeToolName(name string) string {
return strings.ReplaceAll(name, ".", "_")
}
碰撞检测在构建期 :如果 kb.search 和 kb_search 同时存在,净化后都是 kb_search,Eino 的 tool dispatch 会静默让后者覆盖前者,导致调用 A 实际执行 B。这里在 ToolsFromBroker 里提前检测,fail fast,而不是等 runtime 出诡异 bug。
InvokableRun 里同样优先读 context 的 sessionID,理由和 chatModelAdapter 一样------Runnable 缓存复用时不能用构建时绑定的值。
四、SessionCheckpointStore:把 DB 变成 Eino 的持久化后端
Eino 的 compose.CheckPointStore 接口只有两个方法:
go
type CheckPointStore interface {
Get(ctx context.Context, checkPointID string) ([]byte, bool, error)
Set(ctx context.Context, checkPointID string, data []byte) error
}
SessionCheckpointStore 把 domain.SessionRepo 包一层就能对接:
go
type SessionCheckpointStore struct {
repo domain.SessionRepo
}
func (s *SessionCheckpointStore) Get(ctx context.Context, id string) ([]byte, bool, error) {
return s.repo.LoadCheckpoint(ctx, model.SessionID(id))
}
func (s *SessionCheckpointStore) Set(ctx context.Context, id string, data []byte) error {
return s.repo.SaveCheckpoint(ctx, model.SessionID(id), data)
}
checkPointID = session UUID。每次图执行后,Eino 把整个图状态序列化存进 sessions.checkpoint_blob 列(Postgres)。HITL 中断后恢复时,Eino 从这里加载状态继续执行,不需要从头跑。
五、AgentCallback:把 Hook 和 SSE 变成 callbacks.Handler
NewAgentCallback 用 callbacks.HandlerBuilder 构建,不实现完整 Handler 接口,只注册需要的几个时机:
go
func NewAgentCallback(runner port.HookRunner, streamer port.Streamer, sid model.SessionID,
onPhase func(model.Phase), onToolEnd func(context.Context, string)) callbacks.Handler {
b := callbacks.NewHandlerBuilder()
b.OnStartFn(func(ctx context.Context, info *callbacks.RunInfo, input callbacks.CallbackInput) context.Context {
switch info.Component {
case components.ComponentOfChatModel:
runner.BeforeModelCall(ctx, sid, extractLastUserContent(msgs))
case components.ComponentOfTool:
runner.BeforeToolUse(ctx, sid, tc)
streamer.Emit(ctx, sid, port.StreamEvent{Type: "turn.tool_call", Payload: ...})
onPhase(model.PhaseToolUse)
}
return ctx
})
b.OnEndFn(func(...) context.Context {
switch info.Component {
case components.ComponentOfChatModel:
runner.AfterModelCall(ctx, sid, co.Message.Content)
case components.ComponentOfTool:
runner.AfterToolUse(ctx, sid, tc, co.Response)
streamer.Emit(ctx, sid, port.StreamEvent{Type: "turn.tool_result", Payload: ...})
onPhase(model.PhaseThinking)
}
return ctx
})
b.OnEndWithStreamOutputFn(func(ctx context.Context, info *callbacks.RunInfo, output *schema.StreamReader[...]) context.Context {
if output != nil {
output.Close() // 必须关,否则 goroutine 泄漏
}
return ctx
})
b.OnErrorFn(func(...) context.Context {
runner.OnError(ctx, sid, err)
return ctx
})
return b.Build()
}
两条主线:
-
SSE 推流 :工具调用开始时推
turn.tool_call,结束时推turn.tool_result,浏览器实时看到工具执行状态。 -
HookRunner 7 阶段 :
BeforeModelCall、AfterModelCall、BeforeToolUse、AfterToolUse、OnError对应 hook 链里的审计 / PII 脱敏 / 合规检查阶段。
OnEndWithStreamOutput 里只做 output.Close()------流式 model 输出的 token 消费在 ReAct 循环里直接处理,不在 callback 里重复消费,但 StreamReader 必须被 Close,否则 goroutine 泄漏。
六、tokenStreamReader:把 Eino 流变成 port.TokenReader
go
type tokenStreamReader struct {
sr *schema.StreamReader[*schema.Message]
}
func (r *tokenStreamReader) Recv() (string, error) {
msg, err := r.sr.Recv()
if err != nil {
return "", err
}
if msg != nil {
return msg.Content, nil
}
return "", nil
}
func (r *tokenStreamReader) Close() { r.sr.Close() }
application 层的 port.TokenReader 只有 Recv() (string, error) 和 Close()------它不知道 *schema.Message 是什么。
tokenStreamReader 把 Eino 的 *schema.Message 拆成纯 string,让 run_turn.go 里的 SSE 推送循环完全不依赖 Eino 类型。
七、ReActFactory:组装 + 缓存
ReActFactory.StreamTurn 是所有适配器的组装点:
go
func (f *ReActFactory) StreamTurn(ctx context.Context, cfg port.AgentConfig, ...) (port.TokenReader, *port.AgentInterruptInfo, error) {
runnable, err := f.buildRunnable(ctx, cfg, tenantID, string(sid))
cb := NewAgentCallback(hooks, stream, sid, in.OnPhaseChange, in.OnToolEnd)
opts := []compose.Option{
compose.WithCallbacks(cb),
compose.WithCheckPointID(string(sid)),
}
if !in.IsResume {
opts = append(opts, compose.WithForceNewRun())
}
// resume 时预检 checkpoint 存在,fail fast
if in.IsResume {
if _, found, _ := f.cpStore.Get(ctx, string(sid)); !found {
return nil, nil, port.ErrCheckpointMissing
}
}
sr, err := runnable.Stream(ctx, inputMsgs, opts...)
if err != nil {
if info, ok := compose.ExtractInterruptInfo(err); ok {
return nil, &port.AgentInterruptInfo{BeforeNodes: info.BeforeNodes}, nil
}
return nil, nil, err
}
return &tokenStreamReader{sr: sr}, nil, nil
}
三种返回值语义:
| 返回值 | 含义 |
|---|---|
(TokenReader, nil, nil) |
正常执行,读流拿 token |
(nil, *AgentInterruptInfo, nil) |
HITL 中断,等待人工决策 |
(nil, nil, error) |
执行失败 |
Runnable 缓存 :图编译是昂贵操作(需要加载工具 schema、构建 graph)。buildRunnable 用 {tenantID, configName, llmProfile, maxTurns, temperature, toolWhitelist, ...} 作 key,缓存编译好的 compose.Runnable,TTL 35 分钟。
35 分钟 > 30 分钟(中断超时),保证 HITL 恢复时一定命中缓存------如果缓存 miss,就要重新调 ToolsFromBroker gRPC,在恢复路径上增加额外故障面。
小结
五个适配器,一个工厂,把 Eino 的所有细节封在 infrastructure/einoadapter 包里:
| 适配器 | 从 | 到 |
|---|---|---|
chatModelAdapter |
port.LLMClient(自研) |
einomodel.ToolCallingChatModel |
toolBrokerAdapter |
port.ToolBroker(自研) |
tool.InvokableTool × N |
SessionCheckpointStore |
domain.SessionRepo(Postgres) |
compose.CheckPointStore |
AgentCallback |
port.HookRunner + port.Streamer |
callbacks.Handler |
tokenStreamReader |
schema.StreamReader[*schema.Message] |
port.TokenReader |
两个贯穿所有适配器的设计决策:
- 运行时从 context 读 sessionID------Runnable 缓存复用时不能用构建时绑定的值
- 接口在 application 层(port)定义,实现在 infrastructure 层------这让整个 agent BC 可以不知道 Eino 是什么,换框架只改 einoadapter
下篇看 ReActFactory.StreamTurn 的完整上下文------ForceNewRun 标志背后的 Eino 机制。
代码来源:DeepFlux platform · server/internal/agent/infrastructure/einoadapter/