refactor: 统一配置文件系统并重构项目设计文档 #121
810
docs/10-Eino重构方案.md
Normal file
810
docs/10-Eino重构方案.md
Normal file
@@ -0,0 +1,810 @@
|
|||||||
|
# CamTalk 后端 AI 编排层 Eino 重构方案
|
||||||
|
|
||||||
|
> 创建日期:2026-06-19
|
||||||
|
> 状态:草案
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
### 1.1 现状问题
|
||||||
|
|
||||||
|
当前后端 AI 编排层(`internal/orchestrator/pipeline.go`)为手写 goroutine 管道:
|
||||||
|
|
||||||
|
```
|
||||||
|
STT → LLM(Stream) ──→ Splitter → TTS(Stream) → Sender
|
||||||
|
└→ Sender(LLMChunk)
|
||||||
|
```
|
||||||
|
|
||||||
|
存在以下问题:
|
||||||
|
|
||||||
|
1. **编排逻辑硬编码**:STT→LLM→TTS 流程写死在 `ProcessQuery()` 中,扩展新流程(如视觉分析链路、多轮工具调用)需要重写 goroutine 调度
|
||||||
|
2. **并发控制粗糙**:手动 `go func()` + `sync.WaitGroup`,缺乏结构化的流式数据传递
|
||||||
|
3. **无回调/AOP 机制**:日志、指标、追踪散落在各处,无法统一注入
|
||||||
|
4. **配置耦合**:模型名、TTS 参数等硬编码在 Pipeline 结构体,无法按请求动态切换
|
||||||
|
5. **错误处理不一致**:TTS 错误被静默吞掉,STT/LLM 错误通过 Sender 发送,缺乏统一模式
|
||||||
|
|
||||||
|
### 1.2 重构目标
|
||||||
|
|
||||||
|
| 目标 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 用 Eino Graph 替换手写 Pipeline | 声明式编排,类型安全,可组合 |
|
||||||
|
| 流式处理原生支持 | 利用 Eino 的 Transform/Stream 模式,替代手动 goroutine |
|
||||||
|
| 统一回调机制 | 通过 Eino Callback 实现日志、指标、追踪的 AOP |
|
||||||
|
| 按请求动态配置 | 利用 Eino Option 机制,支持每请求切换模型/参数 |
|
||||||
|
| 保持 API 兼容 | WebSocket 协议、REST API、Session 管理不变 |
|
||||||
|
| 渐进式迁移 | 可分阶段实施,新旧编排器并存 |
|
||||||
|
|
||||||
|
## 2. Eino 编排模型选择
|
||||||
|
|
||||||
|
### 2.1 为什么选 Graph 而非 Chain 或 Workflow
|
||||||
|
|
||||||
|
| 编排模式 | 适用场景 | CamTalk 适用性 |
|
||||||
|
|----------|----------|----------------|
|
||||||
|
| **Chain** | 线性流水线 | ❌ LLM 和 TTS 需要并行执行,非纯线性 |
|
||||||
|
| **Workflow** | DAG + 字段映射 | ⚠️ 不支持循环,未来 ReAct Agent 需要循环 |
|
||||||
|
| **Graph** | 任意有向图,支持分支/并行/循环 | ✅ 完美匹配,支持当前并行需求和未来扩展 |
|
||||||
|
|
||||||
|
**选择 Graph**,理由:
|
||||||
|
- LLM Stream 输出需要同时分发给 TTS 和客户端(多下游分支)
|
||||||
|
- 未来需要支持 ReAct Agent 循环(Graph + Branch)
|
||||||
|
- 支持 Pregel 执行引擎,兼容未来有状态节点
|
||||||
|
|
||||||
|
### 2.2 Graph 拓扑设计
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ CamTalk Pipeline Graph │
|
||||||
|
│ │
|
||||||
|
START │ │ END
|
||||||
|
│ │ ▲
|
||||||
|
▼ │ │
|
||||||
|
┌──────┴──────┐ │
|
||||||
|
│ STT Node │ (Lambda: audio → text) │
|
||||||
|
│ (可选跳过) │ │
|
||||||
|
└──────┬──────┘ │
|
||||||
|
│ text │
|
||||||
|
▼ │
|
||||||
|
┌──────────────┐ │
|
||||||
|
│ History Node │ (Lambda: 组装对话历史) │
|
||||||
|
└──────┬───────┘ │
|
||||||
|
│ []*schema.Message │
|
||||||
|
▼ │
|
||||||
|
┌──────────────┐ ┌────────────────┐ │
|
||||||
|
│ LLM Node │─────→│ Sentence Split │───┐ │
|
||||||
|
│ (ChatModel) │stream│ Node (Lambda) │ │ │
|
||||||
|
└──────┬───────┘ └────────────────┘ │ │
|
||||||
|
│ stream │ │
|
||||||
|
▼ ▼ │
|
||||||
|
┌──────────────┐ ┌──────────────┐│
|
||||||
|
│ Chunk Sender │ │ TTS Node ││
|
||||||
|
│ Node (Lambda)│ │ (Lambda) ││
|
||||||
|
└──────────────┘ └──────┬───────┘│
|
||||||
|
│ │
|
||||||
|
▼ │
|
||||||
|
┌──────────────┐ │
|
||||||
|
│Audio Sender │──┘
|
||||||
|
│Node (Lambda) │
|
||||||
|
└──────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键设计决策:**
|
||||||
|
|
||||||
|
- STT 作为起始 Lambda 节点(非 Eino 原生组件,需封装)
|
||||||
|
- LLM 使用 Eino 原生 ChatModel 组件(`eino-ext` 的 OpenAI 实现)
|
||||||
|
- LLM 输出通过 Graph 的多下游边分发:一条到 Chunk Sender(推文字),一条到 Sentence Split → TTS(推语音)
|
||||||
|
- TTS 封装为 Lambda 节点
|
||||||
|
- 所有 Sender 操作封装为 Lambda 节点,注入 `Sender` 依赖
|
||||||
|
|
||||||
|
## 3. 详细设计
|
||||||
|
|
||||||
|
### 3.1 数据类型定义
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/eino/types.go
|
||||||
|
|
||||||
|
// Graph 统一输入
|
||||||
|
type PipelineInput struct {
|
||||||
|
AudioData []byte // base64 解码后的音频(可选)
|
||||||
|
ImageData []byte // base64 解码后的图像(可选)
|
||||||
|
Text string // 直接文本输入(可选,跳过 STT)
|
||||||
|
SessionID string
|
||||||
|
RequestID string
|
||||||
|
Language string // zh / en
|
||||||
|
Scenario string // free_chat, interviewer, etc.
|
||||||
|
}
|
||||||
|
|
||||||
|
// Graph 统一输出
|
||||||
|
type PipelineOutput struct {
|
||||||
|
TranscribedText string // STT 结果
|
||||||
|
FullResponse string // LLM 完整回复
|
||||||
|
}
|
||||||
|
|
||||||
|
// STT 节点输出
|
||||||
|
type STTOutput struct {
|
||||||
|
Text string
|
||||||
|
Language string
|
||||||
|
}
|
||||||
|
|
||||||
|
// LLM 节点输入(组装好的对话历史)
|
||||||
|
type LLMInput struct {
|
||||||
|
Messages []*schema.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
// 句子分割中间类型
|
||||||
|
type SentenceChunk struct {
|
||||||
|
Sentence string
|
||||||
|
IsLast bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// TTS 节点输出
|
||||||
|
type TTSAudioChunk struct {
|
||||||
|
AudioData []byte
|
||||||
|
Format string
|
||||||
|
Sentence string
|
||||||
|
IsLast bool
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Eino Graph 构建
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/eino/graph.go
|
||||||
|
|
||||||
|
package eino
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"github.com/cloudwego/eino/components/model"
|
||||||
|
"github.com/cloudwego/eino/compose"
|
||||||
|
"github.com/cloudwego/eino/schema"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GraphOption 图级别配置
|
||||||
|
type GraphOption struct {
|
||||||
|
ChatModel model.ToolCallingChatModel // Eino 原生 ChatModel
|
||||||
|
STTService stt.Service // 现有 STT 接口
|
||||||
|
TTSService tts.Service // 现有 TTS 接口
|
||||||
|
SessionMgr session.Manager // 会话管理
|
||||||
|
Sender orchestrator.Sender // WS 消息推送
|
||||||
|
PromptCfg *PromptConfig // 提示词配置
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPipelineGraph 构建编排图
|
||||||
|
func NewPipelineGraph(ctx context.Context, opt *GraphOption) (compose.Runnable[PipelineInput, PipelineOutput], error) {
|
||||||
|
g := compose.NewGraph[PipelineInput, PipelineOutput]()
|
||||||
|
|
||||||
|
// 1. STT 节点(Lambda)
|
||||||
|
sttNode := compose.InvokableLambda(sttLambda(opt.STTService))
|
||||||
|
g.AddLambdaNode("stt", sttNode)
|
||||||
|
|
||||||
|
// 2. 历史组装节点(Lambda)
|
||||||
|
historyNode := compose.InvokableLambda(historyLambda(opt.SessionMgr, opt.PromptCfg))
|
||||||
|
g.AddLambdaNode("history", historyNode)
|
||||||
|
|
||||||
|
// 3. LLM 节点(ChatModel,原生流式)
|
||||||
|
g.AddChatModelNode("llm", opt.ChatModel)
|
||||||
|
|
||||||
|
// 4. 句子分割节点(Transform Lambda:stream → stream)
|
||||||
|
splitterNode := compose.TransformableLambda(splitterLambda())
|
||||||
|
g.AddLambdaNode("splitter", splitterNode)
|
||||||
|
|
||||||
|
// 5. LLM Chunk 推送节点(Transform Lambda)
|
||||||
|
chunkSenderNode := compose.TransformableLambda(chunkSenderLambda(opt.Sender))
|
||||||
|
g.AddLambdaNode("chunk_sender", chunkSenderNode)
|
||||||
|
|
||||||
|
// 6. TTS 节点(Collect Lambda:stream → non-stream)
|
||||||
|
ttsNode := compose.CollectableLambda(ttsLambda(opt.TTSService, opt.Sender))
|
||||||
|
g.AddLambdaNode("tts", ttsNode)
|
||||||
|
|
||||||
|
// 7. 完成通知节点(Invokable Lambda)
|
||||||
|
doneNode := compose.InvokableLambda(doneLambda(opt.Sender))
|
||||||
|
g.AddLambdaNode("done", doneNode)
|
||||||
|
|
||||||
|
// === 边连接 ===
|
||||||
|
|
||||||
|
// START → STT
|
||||||
|
g.AddEdge(compose.START, "stt")
|
||||||
|
// STT → History
|
||||||
|
g.AddEdge("stt", "history")
|
||||||
|
// History → LLM
|
||||||
|
g.AddEdge("history", "llm")
|
||||||
|
|
||||||
|
// LLM 输出分发到两个下游(利用 Graph 多下游边)
|
||||||
|
// LLM → Chunk Sender(推送原始 token)
|
||||||
|
g.AddEdge("llm", "chunk_sender")
|
||||||
|
// LLM → Splitter → TTS(句子级语音合成)
|
||||||
|
g.AddEdge("llm", "splitter")
|
||||||
|
g.AddEdge("splitter", "tts")
|
||||||
|
|
||||||
|
// Chunk Sender 和 TTS 都汇入 Done
|
||||||
|
g.AddEdge("chunk_sender", "done")
|
||||||
|
g.AddEdge("tts", "done")
|
||||||
|
|
||||||
|
// Done → END
|
||||||
|
g.AddEdge("done", compose.END)
|
||||||
|
|
||||||
|
// 编译
|
||||||
|
return g.Compile(ctx,
|
||||||
|
compose.WithGraphName("camtalk_pipeline"),
|
||||||
|
compose.WithMaxRunSteps(50),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 节点实现
|
||||||
|
|
||||||
|
#### 3.3.1 STT Lambda
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/eino/nodes_stt.go
|
||||||
|
|
||||||
|
func sttLambda(sttSvc stt.Service) func(ctx context.Context, input PipelineInput) (STTOutput, error) {
|
||||||
|
return func(ctx context.Context, input PipelineInput) (STTOutput, error) {
|
||||||
|
// 文本模式:跳过 STT
|
||||||
|
if input.Text != "" {
|
||||||
|
return STTOutput{Text: input.Text, Language: input.Language}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(input.AudioData) == 0 {
|
||||||
|
return STTOutput{}, fmt.Errorf("no audio data provided")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用现有 STT 服务
|
||||||
|
result, err := sttSvc.Recognize(ctx, input.AudioData, stt.Options{
|
||||||
|
Language: input.Language,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return STTOutput{}, fmt.Errorf("STT error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return STTOutput{
|
||||||
|
Text: result.Text,
|
||||||
|
Language: result.Language,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.3.2 历史组装 Lambda
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/eino/nodes_history.go
|
||||||
|
|
||||||
|
func historyLambda(sessionMgr session.Manager, promptCfg *PromptConfig) func(ctx context.Context, input STTOutput) ([]*schema.Message, error) {
|
||||||
|
return func(ctx context.Context, input STTOutput) ([]*schema.Message, error) {
|
||||||
|
sessionID := getSessionID(ctx) // 从 context 或 state 获取
|
||||||
|
|
||||||
|
history, err := sessionMgr.GetHistory(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get history error: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建系统提示词
|
||||||
|
systemPrompt := promptCfg.BuildSystemPrompt(input.Language, getScenario(ctx))
|
||||||
|
|
||||||
|
messages := []*schema.Message{
|
||||||
|
{Role: schema.System, Content: systemPrompt,
|
||||||
|
MultiContent: buildVisionContent(getImageData(ctx))},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 追加历史消息
|
||||||
|
for _, msg := range history {
|
||||||
|
messages = append(messages, &schema.Message{
|
||||||
|
Role: schema.Role(msg.Role),
|
||||||
|
Content: msg.Content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 追加当前用户输入
|
||||||
|
messages = append(messages, &schema.Message{
|
||||||
|
Role: schema.User,
|
||||||
|
Content: input.Text,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 保存用户消息到历史
|
||||||
|
_ = sessionMgr.AppendMessage(ctx, sessionID, models.Message{
|
||||||
|
Role: "user",
|
||||||
|
Content: input.Text,
|
||||||
|
})
|
||||||
|
|
||||||
|
return messages, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.3.3 句子分割 Transform Lambda
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/eino/nodes_splitter.go
|
||||||
|
|
||||||
|
func splitterLambda() func(ctx context.Context, stream *schema.StreamReader[*schema.Message]) (*schema.StreamReader[SentenceChunk], error) {
|
||||||
|
return func(ctx context.Context, stream *schema.StreamReader[*schema.Message]) (*schema.StreamReader[SentenceChunk], error) {
|
||||||
|
sr, sw := schema.Pipe[SentenceChunk](8)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer sw.Close()
|
||||||
|
var buffer []rune
|
||||||
|
|
||||||
|
for {
|
||||||
|
chunk, err := stream.Recv()
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "EOF" {
|
||||||
|
// 流结束,发送剩余缓冲
|
||||||
|
if len(buffer) > 0 {
|
||||||
|
sw.Send(SentenceChunk{Sentence: string(buffer), IsLast: true}, nil)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sw.Send(SentenceChunk{}, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range chunk.Content {
|
||||||
|
buffer = append(buffer, r)
|
||||||
|
if isSentenceDelimiter(r) {
|
||||||
|
sw.Send(SentenceChunk{Sentence: string(buffer), IsLast: false}, nil)
|
||||||
|
buffer = buffer[:0]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return sr, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.3.4 TTS Collect Lambda
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/eino/nodes_tts.go
|
||||||
|
|
||||||
|
func ttsLambda(ttsSvc tts.Service, sender orchestrator.Sender) func(ctx context.Context, stream *schema.StreamReader[SentenceChunk]) (struct{}, error) {
|
||||||
|
return func(ctx context.Context, stream *schema.StreamReader[SentenceChunk]) (struct{}, error) {
|
||||||
|
for {
|
||||||
|
chunk, err := stream.Recv()
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "EOF" {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
return struct{}{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if chunk.Sentence == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用 TTS 服务
|
||||||
|
audioData, err := ttsSvc.Synthesize(ctx, chunk.Sentence, tts.Options{
|
||||||
|
// 从 Option 或 Config 获取
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// TTS 失败不中断流程,仅记录日志
|
||||||
|
log.Warn("TTS synthesis failed", zap.Error(err),
|
||||||
|
zap.String("sentence", chunk.Sentence))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 推送音频到客户端
|
||||||
|
sender.SendTTSAudio(orchestrator.TTSAudioPayload{
|
||||||
|
Audio: audioData,
|
||||||
|
Format: "mp3",
|
||||||
|
IsLast: chunk.IsLast,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return struct{}{}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.3.5 Chunk Sender Transform Lambda
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/eino/nodes_sender.go
|
||||||
|
|
||||||
|
func chunkSenderLambda(sender orchestrator.Sender) func(ctx context.Context, stream *schema.StreamReader[*schema.Message]) (*schema.StreamReader[*schema.Message], error) {
|
||||||
|
return func(ctx context.Context, stream *schema.StreamReader[*schema.Message]) (*schema.StreamReader[*schema.Message], error) {
|
||||||
|
sr, sw := schema.Pipe[*schema.Message](8)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer sw.Close()
|
||||||
|
for {
|
||||||
|
msg, err := stream.Recv()
|
||||||
|
if err != nil {
|
||||||
|
if err.Error() == "EOF" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sw.Send(nil, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 推送 LLM 文本 chunk 到客户端
|
||||||
|
sender.SendLLMChunk(orchestrator.LLMChunkPayload{
|
||||||
|
Content: msg.Content,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 透传给下游
|
||||||
|
sw.Send(msg, nil)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return sr, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.3.6 Done Lambda
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/eino/nodes_done.go
|
||||||
|
|
||||||
|
func doneLambda(sender orchestrator.Sender) func(ctx context.Context, input struct{}) (PipelineOutput, error) {
|
||||||
|
return func(ctx context.Context, input struct{}) (PipelineOutput, error) {
|
||||||
|
// 通知客户端 LLM 回复完成
|
||||||
|
sender.SendLLMDone(orchestrator.LLMDonePayload{})
|
||||||
|
|
||||||
|
// 保存助手消息到历史
|
||||||
|
// 注意:完整回复需要从某处收集,可通过 State 机制实现
|
||||||
|
return PipelineOutput{}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 State 机制(收集完整回复)
|
||||||
|
|
||||||
|
由于 LLM 输出被分发到两个下游,完整回复文本需要通过 Graph State 收集:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/eino/state.go
|
||||||
|
|
||||||
|
type PipelineState struct {
|
||||||
|
FullResponse strings.Builder
|
||||||
|
SessionID string
|
||||||
|
RequestID string
|
||||||
|
}
|
||||||
|
|
||||||
|
func genLocalState(ctx context.Context) *PipelineState {
|
||||||
|
return &PipelineState{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在构建 Graph 时注册 State
|
||||||
|
func NewPipelineGraph(ctx context.Context, opt *GraphOption) (compose.Runnable[PipelineInput, PipelineOutput], error) {
|
||||||
|
g := compose.NewGraph[PipelineInput, PipelineOutput](
|
||||||
|
compose.WithGenLocalState(genLocalState),
|
||||||
|
)
|
||||||
|
|
||||||
|
// ... 添加节点 ...
|
||||||
|
|
||||||
|
// Chunk Sender 的 StatePostHandler 累积完整回复
|
||||||
|
g.AddLambdaNode("chunk_sender", chunkSenderNode,
|
||||||
|
compose.WithStatePostHandler(func(ctx context.Context, output *schema.Message, state *PipelineState) *schema.Message {
|
||||||
|
state.FullResponse.WriteString(output.Content)
|
||||||
|
return output
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Done 节点的 StatePreHandler 读取完整回复
|
||||||
|
g.AddLambdaNode("done", doneNode,
|
||||||
|
compose.WithStatePreHandler(func(ctx context.Context, input struct{}, state *PipelineState) struct{} {
|
||||||
|
// 将完整回复存入 state 供 done 节点使用
|
||||||
|
return input
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.5 Callback 集成(日志/指标/追踪)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/eino/callback.go
|
||||||
|
|
||||||
|
type MetricsCallback struct {
|
||||||
|
logger *zap.Logger
|
||||||
|
metrics *MetricsCollector // Prometheus 等
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MetricsCallback) OnStart(ctx context.Context, info *compose.RunInfo, input compose.CallbackInput) context.Context {
|
||||||
|
m.logger.Debug("node started",
|
||||||
|
zap.String("node", info.Name),
|
||||||
|
zap.String("graph", info.GraphName))
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MetricsCallback) OnEnd(ctx context.Context, info *compose.RunInfo, output compose.CallbackOutput) context.Context {
|
||||||
|
m.logger.Debug("node completed",
|
||||||
|
zap.String("node", info.Name))
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MetricsCallback) OnError(ctx context.Context, info *compose.RunInfo, err error) context.Context {
|
||||||
|
m.logger.Error("node failed",
|
||||||
|
zap.String("node", info.Name),
|
||||||
|
zap.Error(err))
|
||||||
|
m.metrics.IncrementError(info.Name)
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
|
// 注册到 Graph
|
||||||
|
func NewPipelineGraph(ctx context.Context, opt *GraphOption) (compose.Runnable[PipelineInput, PipelineOutput], error) {
|
||||||
|
// ...
|
||||||
|
callback := &MetricsCallback{logger: opt.Logger, metrics: opt.Metrics}
|
||||||
|
|
||||||
|
return g.Compile(ctx,
|
||||||
|
compose.WithCallbacks(callback), // 全局回调
|
||||||
|
compose.WithCallbacks(llmCallback).DesignateNode("llm"), // LLM 专用回调
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.6 按请求动态配置
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/eino/options.go
|
||||||
|
|
||||||
|
// 运行时 Option:每请求可变
|
||||||
|
func WithModelName(name string) compose.Option {
|
||||||
|
return compose.WithChatModelOption(model.WithModel(name))
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithTemperature(temp float32) compose.Option {
|
||||||
|
return compose.WithChatModelOption(model.WithTemperature(temp))
|
||||||
|
}
|
||||||
|
|
||||||
|
func WithTTSVoice(voice string) compose.Option {
|
||||||
|
return compose.WithCallbacks(&ttsVoiceCallback{voice: voice}).
|
||||||
|
DesignateNode("tts")
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket Handler 中的调用
|
||||||
|
func (c *Client) handleQuery(req QueryRequest) {
|
||||||
|
opts := []compose.Option{}
|
||||||
|
|
||||||
|
// 根据请求配置动态注入
|
||||||
|
if req.Model != "" {
|
||||||
|
opts = append(opts, WithModelName(req.Model))
|
||||||
|
}
|
||||||
|
if req.TTSVoice != "" {
|
||||||
|
opts = append(opts, WithTTSVoice(req.TTSVoice))
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := c.pipeline.Invoke(ctx, PipelineInput{...}, opts...)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.7 ChatModel 适配(接入 eino-ext OpenAI)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/eino/chatmodel.go
|
||||||
|
|
||||||
|
import (
|
||||||
|
openaiImpl "github.com/cloudwego/eino-ext/components/model/openai"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewChatModel(cfg *config.AIConfig) (model.ToolCallingChatModel, error) {
|
||||||
|
return openaiImpl.NewChatModel(context.Background(), &openaiImpl.ChatModelConfig{
|
||||||
|
APIKey: cfg.LLM.APIKey,
|
||||||
|
Model: cfg.LLM.Model,
|
||||||
|
BaseURL: cfg.LLM.BaseURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 目录结构变更
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/internal/
|
||||||
|
├── eino/ # 新增:Eino 编排层
|
||||||
|
│ ├── graph.go # Graph 构建与编译
|
||||||
|
│ ├── types.go # 数据类型定义
|
||||||
|
│ ├── state.go # Graph State 定义
|
||||||
|
│ ├── options.go # 运行时 Option
|
||||||
|
│ ├── callback.go # 回调实现(日志/指标)
|
||||||
|
│ ├── chatmodel.go # ChatModel 适配器
|
||||||
|
│ ├── nodes_stt.go # STT Lambda 节点
|
||||||
|
│ ├── nodes_history.go # 历史组装 Lambda 节点
|
||||||
|
│ ├── nodes_splitter.go # 句子分割 Transform Lambda
|
||||||
|
│ ├── nodes_tts.go # TTS Collect Lambda 节点
|
||||||
|
│ ├── nodes_sender.go # Chunk Sender Transform Lambda
|
||||||
|
│ ├── nodes_done.go # 完成通知 Lambda 节点
|
||||||
|
│ └── graph_test.go # 集成测试
|
||||||
|
├── orchestrator/ # 保留:兼容层(Phase 1)
|
||||||
|
│ ├── orchestrator.go # 接口定义(不变)
|
||||||
|
│ ├── pipeline.go # 旧实现(Phase 3 移除)
|
||||||
|
│ ├── splitter.go # 被 eino/nodes_splitter.go 替代
|
||||||
|
│ ├── sender.go # Sender 接口(不变,被 eino 层引用)
|
||||||
|
│ └── eino_adapter.go # 新增:Eino 编排器适配为 Orchestrator 接口
|
||||||
|
├── ai/ # 保留:AI 服务接口不变
|
||||||
|
│ ├── llm/ # 保留接口,实现被 eino-ext 替代
|
||||||
|
│ ├── stt/ # 完全保留
|
||||||
|
│ └── tts/ # 完全保留
|
||||||
|
└── ws/ # 保留:WebSocket Handler
|
||||||
|
└── handler.go # 切换到 Eino 编排器
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. 分阶段实施计划
|
||||||
|
|
||||||
|
### Phase 1:基础设施(预计 2-3 天)
|
||||||
|
|
||||||
|
| 任务 | 文件 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 引入 Eino 依赖 | `go.mod` | `go get github.com/cloudwego/eino/...` |
|
||||||
|
| 引入 eino-ext OpenAI | `go.mod` | `go get github.com/cloudwego/eino-ext/...` |
|
||||||
|
| 定义数据类型 | `eino/types.go` | PipelineInput/Output、中间类型 |
|
||||||
|
| 定义 State | `eino/state.go` | PipelineState |
|
||||||
|
| 实现 ChatModel 适配器 | `eino/chatmodel.go` | 包装 eino-ext OpenAI |
|
||||||
|
| 编写 Callback 框架 | `eino/callback.go` | 日志 + 指标回调 |
|
||||||
|
|
||||||
|
### Phase 2:节点实现与 Graph 构建(预计 3-4 天)
|
||||||
|
|
||||||
|
| 任务 | 文件 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| STT Lambda | `eino/nodes_stt.go` | 包装现有 stt.Service |
|
||||||
|
| 历史组装 Lambda | `eino/nodes_history.go` | 对话历史 + 提示词 |
|
||||||
|
| 句子分割 Transform | `eino/nodes_splitter.go` | 重写 splitter.go 为 Eino Lambda |
|
||||||
|
| TTS Collect Lambda | `eino/nodes_tts.go` | 包装现有 tts.Service |
|
||||||
|
| Chunk Sender Transform | `eino/nodes_sender.go` | LLM token 推送 |
|
||||||
|
| Done Lambda | `eino/nodes_done.go` | 完成通知 |
|
||||||
|
| Graph 构建 | `eino/graph.go` | 组装所有节点 |
|
||||||
|
| 单元测试 | `eino/graph_test.go` | Mock 各节点测试图结构 |
|
||||||
|
|
||||||
|
### Phase 3:集成与切换(预计 2-3 天)
|
||||||
|
|
||||||
|
| 任务 | 文件 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| Eino 适配器 | `orchestrator/eino_adapter.go` | 将 Eino Graph 包装为现有 Orchestrator 接口 |
|
||||||
|
| WS Handler 切换 | `ws/handler.go` | 使用新的 Eino 编排器 |
|
||||||
|
| main.go 依赖注入 | `cmd/server/main.go` | 构建 ChatModel + Graph |
|
||||||
|
| 集成测试 | `eino/graph_test.go` | 端到端测试 |
|
||||||
|
| 性能对比 | - | 延迟、内存、CPU 对比 |
|
||||||
|
|
||||||
|
### Phase 4:清理与增强(预计 1-2 天)
|
||||||
|
|
||||||
|
| 任务 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 移除旧 Pipeline | 删除 `orchestrator/pipeline.go`、`splitter.go` |
|
||||||
|
| 更新文档 | 更新架构文档、接口文档 |
|
||||||
|
| 启用 ReAct Agent(可选) | 基于 Graph Branch 实现工具调用循环 |
|
||||||
|
| 动态配置完善 | 按请求切换模型、TTS 参数 |
|
||||||
|
|
||||||
|
## 6. 风险与缓解
|
||||||
|
|
||||||
|
| 风险 | 影响 | 缓解措施 |
|
||||||
|
|------|------|----------|
|
||||||
|
| Eino 框架不稳定(v0.x) | 生产故障 | 锁定版本,保留旧 Pipeline 可回退 |
|
||||||
|
| 流式处理延迟增加 | 用户体验下降 | 性能对比测试,必要时绕过 Eino 直接调用 |
|
||||||
|
| LLM 输出多下游分发丢失数据 | TTS 无输入 | 充分测试 Stream Copy 机制,添加监控 |
|
||||||
|
| 学习曲线 | 开发效率 | 先从简单 Chain 开始,逐步过渡到 Graph |
|
||||||
|
| eino-ext OpenAI 不兼容现有 API | 功能回退 | 验证 BaseURL 和参数映射,必要时自定义适配器 |
|
||||||
|
|
||||||
|
## 7. 测试策略
|
||||||
|
|
||||||
|
### 7.1 单元测试
|
||||||
|
|
||||||
|
```go
|
||||||
|
// eino/graph_test.go
|
||||||
|
|
||||||
|
func TestPipelineGraph_WithTextInput(t *testing.T) {
|
||||||
|
// Mock STT, LLM, TTS, Sender
|
||||||
|
mockLLM := &mockChatModel{responses: []string{"你好!"}}
|
||||||
|
mockSender := &mockSender{}
|
||||||
|
|
||||||
|
graph, err := NewPipelineGraph(ctx, &GraphOption{
|
||||||
|
ChatModel: mockLLM,
|
||||||
|
Sender: mockSender,
|
||||||
|
// ...
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
output, err := graph.Invoke(ctx, PipelineInput{
|
||||||
|
Text: "你好",
|
||||||
|
SessionID: "test-session",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "你好!", output.FullResponse)
|
||||||
|
assert.True(t, mockSender.LLMDoneSent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPipelineGraph_WithAudioInput(t *testing.T) {
|
||||||
|
mockSTT := &mockSTT{text: "你好"}
|
||||||
|
mockLLM := &mockChatModel{responses: []string{"你好!"}}
|
||||||
|
mockTTS := &mockTTS{audio: []byte("fake-audio")}
|
||||||
|
mockSender := &mockSender{}
|
||||||
|
|
||||||
|
graph, _ := NewPipelineGraph(ctx, &GraphOption{
|
||||||
|
ChatModel: mockLLM,
|
||||||
|
STTService: mockSTT,
|
||||||
|
TTSService: mockTTS,
|
||||||
|
Sender: mockSender,
|
||||||
|
})
|
||||||
|
|
||||||
|
output, err := graph.Invoke(ctx, PipelineInput{
|
||||||
|
AudioData: []byte("fake-audio-data"),
|
||||||
|
SessionID: "test-session",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, mockSender.TTSAudioSent)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7.2 集成测试
|
||||||
|
|
||||||
|
- 启动真实 OpenAI API 调用(使用测试 key)
|
||||||
|
- 验证 WebSocket 消息序列:`stt_result` → `llm_chunk` × N → `llm_done` → `tts_audio` × N
|
||||||
|
- 验证 interrupt 取消功能
|
||||||
|
- 验证多并发请求隔离
|
||||||
|
|
||||||
|
## 8. 依赖清单
|
||||||
|
|
||||||
|
```go
|
||||||
|
// go.mod 新增
|
||||||
|
require (
|
||||||
|
github.com/cloudwego/eino v0.4.x // 核心框架
|
||||||
|
github.com/cloudwego/eino-ext v0.1.x // 组件实现
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 未来扩展路径
|
||||||
|
|
||||||
|
基于 Eino Graph 的重构完成后,可无缝扩展:
|
||||||
|
|
||||||
|
1. **ReAct Agent**:Graph 添加 Branch 节点,实现 LLM → Tool → LLM 循环
|
||||||
|
2. **多模态理解**:添加视觉分析 Lambda 节点(图像描述 → 上下文注入)
|
||||||
|
3. **Model Router**:Graph 前置分支节点,按场景/成本路由不同 LLM
|
||||||
|
4. **Rate Limiter**:通过 Callback 的 OnStart 实现令牌桶
|
||||||
|
5. **Checkpoint/Resume**:利用 Eino 的 CheckpointStore 实现断点续传
|
||||||
|
6. **Multi-Agent**:利用 ADK 的 Supervisor/SequentialAgent 编排复杂对话流程
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录 A:Eino vs 现有实现对比
|
||||||
|
|
||||||
|
| 维度 | 现有实现 | Eino 重构后 |
|
||||||
|
|------|----------|------------|
|
||||||
|
| 编排方式 | 手写 goroutine + channel | 声明式 Graph,类型安全 |
|
||||||
|
| 流式处理 | 手动 channel 传递 | StreamReader + Pipe,自动转换 |
|
||||||
|
| 错误处理 | 各节点独立处理 | 统一 Callback OnError |
|
||||||
|
| 日志/追踪 | 散落在各处 | AOP Callback 注入 |
|
||||||
|
| 配置灵活性 | Pipeline 创建时固定 | 每请求 Option 动态注入 |
|
||||||
|
| 可测试性 | 需要启动 goroutine | Graph.Invoke 直接测试 |
|
||||||
|
| 扩展性 | 修改 Pipeline 代码 | 添加节点 + 边,无需改已有逻辑 |
|
||||||
|
| 并发安全 | 手动 sync | State 自动加锁 |
|
||||||
|
|
||||||
|
## 附录 B:关键 Eino API 参考
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 构建 Graph
|
||||||
|
g := compose.NewGraph[I, O](opts...)
|
||||||
|
g.AddChatModelNode(key, chatModel)
|
||||||
|
g.AddLambdaNode(key, lambda, opts...)
|
||||||
|
g.AddEdge(from, to)
|
||||||
|
g.AddBranch(from, branchFunc, mapping)
|
||||||
|
|
||||||
|
// 编译
|
||||||
|
runnable, err := g.Compile(ctx, opts...)
|
||||||
|
|
||||||
|
// 执行四种模式
|
||||||
|
output, err := runnable.Invoke(ctx, input, opts...)
|
||||||
|
stream, err := runnable.Stream(ctx, input, opts...)
|
||||||
|
output, err := runnable.Collect(ctx, inputStream, opts...)
|
||||||
|
stream, err := runnable.Transform(ctx, inputStream, opts...)
|
||||||
|
|
||||||
|
// Lambda 四种构造器
|
||||||
|
lambda := compose.InvokableLambda(fn) // I → O
|
||||||
|
lambda := compose.StreamableLambda(fn) // I → StreamReader[O]
|
||||||
|
lambda := compose.CollectableLambda(fn) // StreamReader[I] → O
|
||||||
|
lambda := compose.TransformableLambda(fn) // StreamReader[I] → StreamReader[O]
|
||||||
|
|
||||||
|
// Stream 操作
|
||||||
|
sr, sw := schema.Pipe[T](bufSize)
|
||||||
|
sw.Send(chunk, err)
|
||||||
|
chunk, err := sr.Recv()
|
||||||
|
sw.Close()
|
||||||
|
|
||||||
|
// Option
|
||||||
|
compose.WithCallbacks(handler)
|
||||||
|
compose.WithCallbacks(handler).DesignateNode("node_key")
|
||||||
|
compose.WithChatModelOption(model.WithTemperature(0.7))
|
||||||
|
compose.WithGenLocalState(genFunc)
|
||||||
|
```
|
||||||
Reference in New Issue
Block a user