# CamTalk Eino 框架与编排设计

## 1. 概述

### 1.1 为什么选择 Eino

[CloudWeGo Eino](https://github.com/cloudwego/eino) 是字节跳动 CloudWeGo 团队开源的 AI 应用开发框架，提供基于图（Graph）的编排能力、组件抽象和流式处理支持。

CamTalk 使用 Eino 替代原有的手写 goroutine 管道，实现 STT → LLM → TTS 的声明式编排。

**技术选型对比：**

| 维度 | 手写 goroutine（旧方案） | Eino Graph（新方案） |
|------|------------------------|---------------------|
| 编排方式 | 手动 `go func()` + `sync.WaitGroup` | 声明式 DAG，类型安全 |
| 流式处理 | 自定义 `chan` 传递 | `StreamReader` + `Pipe`，自动转换 |
| 错误处理 | 各节点独立处理，不一致 | Graph 级别统一错误传播 |
| 回调/AOP | 日志散落各处 | `callbacks.Handler` 统一注入 |
| 配置灵活性 | Pipeline 创建时固定 | 每请求 `Option` 动态注入 |
| 可测试性 | 需启动 goroutine | `Graph.Invoke()` 直接测试 |
| 扩展性 | 修改 Pipeline 代码 | 添加节点 + 边，无侵入 |
| 并发安全 | 手动 `sync` | State 自动加锁 |

**选择 Eino 的核心理由：**
1. Go 原生，泛型支持，编译时类型检查
2. 原生流式处理（`StreamReader`），适合 LLM token 级推送
3. Graph 支持分支、并行、循环，满足当前和未来需求
4. Callback 机制实现 AOP（日志、指标、消息推送）
5. eino-ext 提供 OpenAI ChatModel 实现，直接对接 DashScope

### 1.2 旧方案的问题

当前后端 AI 编排层（`internal/orchestrator/pipeline.go`）为手写 goroutine 管道存在以下问题：

1. **编排逻辑硬编码**：STT→LLM→TTS 流程写死，扩展困难
2. **并发控制粗糙**：手动 goroutine 调度，缺乏结构化流式传递
3. **无回调/AOP 机制**：日志、指标、追踪散落各处
4. **配置耦合**：模型名、TTS 参数等硬编码在结构体
5. **错误处理不一致**：TTS 错误静默吞掉，STT/LLM 错误通过 Sender 发送

### 1.3 核心依赖版本

```go
github.com/cloudwego/eino v0.9.9
github.com/cloudwego/eino-ext/components/model/openai v0.1.13
```

## 2. Eino 核心概念

### 2.1 Lambda

Lambda 是 Graph 中的可组合函数单元，支持四种模式：

| 模式 | 函数签名 | 构造方法 | 说明 |
|------|---------|---------|------|
| Invoke | `I → O` | `compose.InvokableLambda()` | 同步调用 |
| Stream | `I → StreamReader[O]` | `compose.StreamableLambda()` | 流式输出 |
| Collect | `StreamReader[I] → O` | `compose.CollectableLambda()` | 流式输入 |
| Transform | `StreamReader[I] → StreamReader[O]` | `compose.TransformableLambda()` | 双向流式 |

**返回类型**：所有 Lambda 构造函数返回 `*compose.Lambda`。

### 2.2 Graph

Graph 是有向无环图（DAG）编排器，支持：
- **节点**：Lambda、ChatModel、ToolsNode 等
- **边**：`g.AddEdge(from, to)` 定义数据流向
- **分支**：`g.AddBranch()` 条件路由
- **State**：`compose.WithGenLocalState()` 跨节点共享状态

```go
g := compose.NewGraph[PipelineInput, PipelineOutput]()
g.AddLambdaNode("stt", sttLambda)
g.AddChatModelNode("llm", chatModel)
g.AddEdge(compose.START, "stt")
g.AddEdge("stt", "llm")
g.AddEdge("llm", compose.END)

runnable, err := g.Compile(ctx)
output, err := runnable.Invoke(ctx, input)   // 同步调用
stream, err := runnable.Stream(ctx, input)    // 流式调用
```

### 2.3 ChatModel

ChatModel 是 LLM 组件抽象，接口定义：

```go
type BaseChatModel interface {
    Generate(ctx, []*schema.Message, ...Option) (*schema.Message, error)
    Stream(ctx, []*schema.Message, ...Option) (*schema.StreamReader[*schema.Message], error)
}
```

CamTalk 使用 `eino-ext/components/model/openai` 实现，通过 `BaseURL` 对接 DashScope：

```go
chatModel, _ := openai.NewChatModel(ctx, &openai.ChatModelConfig{
    APIKey:  cfg.AI.LLM.APIKey,
    Model:   cfg.AI.LLM.Model,
    BaseURL: cfg.AI.LLM.Endpoint, // "https://dashscope.aliyuncs.com/compatible-mode/v1"
})
```

### 2.4 StreamReader

`schema.StreamReader[T]` 是 Eino 的流式数据抽象：
- `sr.Recv()` 读取一帧，`io.EOF` 表示流结束
- `schema.Pipe[T](bufSize)` 创建 `StreamReader` + `StreamWriter` 对
- 框架自动处理 `T ↔ StreamReader[T]` 的转换（装箱/concat）

### 2.5 Callback

Callback 是 Eino 的 AOP 机制，支持节点生命周期钩子：

```go
type Handler interface {
    OnStart(ctx, *RunInfo, CallbackInput) context.Context
    OnEnd(ctx, *RunInfo, CallbackOutput) context.Context
    OnError(ctx, *RunInfo, error) context.Context
    OnStartWithStreamInput(ctx, *RunInfo, *StreamReader[CallbackInput]) context.Context
    OnEndWithStreamOutput(ctx, *RunInfo, *StreamReader[CallbackOutput]) context.Context
}
```

CamTalk 使用 `utils/callbacks.NewHandlerHelper()` 构建 typed handler：
- `ModelCallbackHandler.OnEndWithStreamOutput`：逐 token 推送 `llm_chunk`

### 2.6 State

Graph 全局状态，通过 `WithGenLocalState` 注册：

```go
type PipelineState struct {
    FullResponse    strings.Builder
    TranscribedText string
    TokenUsage      *TokenUsage
}

g := compose.NewGraph[I, O](compose.WithGenLocalState(func(ctx context.Context) *PipelineState {
    return &PipelineState{}
}))
```

节点通过 `compose.ProcessState` 读写 State。

## 3. CamTalk Graph 设计

### 3.1 拓扑结构

```
START → STT → History → ChatModel → Splitter → TTS → Done → END
```

| 节点 | 类型 | 输入 → 输出 | 职责 |
|------|------|------------|------|
| STT | InvokableLambda | `PipelineInput → STTOutput` | 语音识别，写入 State |
| History | InvokableLambda | `STTOutput → []*schema.Message` | 组装提示词和历史 |
| ChatModel | ChatModel（原生） | `[]*schema.Message → StreamReader[*Message]` | LLM 流式推理 |
| Splitter | TransformableLambda | `StreamReader[string] → StreamReader[[]string]` | 句子切分 |
| TTS | InvokableLambda | `[]string → struct{}` | 语音合成，推送音频 |
| Done | InvokableLambda | `struct{} → PipelineOutput` | 发送 llm_done |

### 3.2 数据类型定义

```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 完整回复
}

// Pipeline State（跨节点共享）
type PipelineState struct {
    FullResponse    strings.Builder
    TranscribedText string
    TokenUsage      *TokenUsage
}
```

### 3.3 流式模式

Graph 使用 **Stream 模式**调用：
- 内部所有节点以 Transform 模式运行
- ChatModel 的 `Stream()` 方法实现真正的 token 级流式
- 适配器消费 `StreamReader[PipelineOutput]` 触发整条链路

### 3.4 消息推送机制

| 消息 | 推送方式 | 时机 |
|------|---------|------|
| `stt_result` | Lambda 内部直接调用 Sender | STT 完成后 |
| `llm_chunk` | Callback `OnEndWithStreamOutput` | ChatModel 逐 token |
| `tts_audio` | Lambda 内部直接调用 Sender | TTS 逐句合成 |
| `llm_done` | Lambda 内部直接调用 Sender | Done 节点执行时 |

**Context 注入**：Sender、RequestID、SessionID、PipelineState 通过 `context.WithValue` 传递。

### 3.5 多模态支持

History 节点将图片构建为 `schema.Message.UserInputMultiContent`：

```go
systemMsg.UserInputMultiContent = []schema.MessageInputPart{
    {
        Type: schema.ChatMessagePartTypeImageURL,
        Image: &schema.MessageInputImage{
            MessagePartCommon: schema.MessagePartCommon{
                Base64Data: &base64Str,
                MIMEType:   "image/jpeg",
            },
            Detail: schema.ImageURLDetailAuto,
        },
    },
}
```

## 4. 实现要点

### 4.1 目录结构

```
backend/internal/eino/
├── types.go           # PipelineInput/Output、STTOutput、TokenUsage
├── state.go           # PipelineState（跨节点状态）
├── callback.go        # Callback handler（LLM token 推送）
├── graph.go           # Graph 构建与编译
├── adapter.go         # EinoOrchestrator（Orchestrator 接口适配器）
├── nodes_stt.go       # STT Lambda
├── nodes_history.go   # 历史组装 Lambda
├── nodes_splitter.go  # 句子分割 Transform Lambda
├── nodes_tts.go       # TTS Lambda
├── nodes_done.go      # Done Lambda
└── graph_test.go      # 单元测试
```

### 4.2 关键节点实现

#### STT Lambda（可选跳过）

```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
        }
        
        // 调用 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
    }
}
```

#### Splitter Transform Lambda（句子切分）

```go
func splitterLambda() func(ctx, *schema.StreamReader[*schema.Message]) (*schema.StreamReader[[]string], error) {
    return func(ctx context.Context, stream *schema.StreamReader[*schema.Message]) (*schema.StreamReader[[]string], error) {
        sr, sw := schema.Pipe[[]string](8)
        
        go func() {
            defer sw.Close()
            var buffer []rune
            
            for {
                chunk, err := stream.Recv()
                if err != nil {
                    if err == io.EOF {
                        if len(buffer) > 0 {
                            sw.Send([]string{string(buffer)}, nil)
                        }
                        return
                    }
                    sw.Send(nil, err)
                    return
                }
                
                for _, r := range chunk.Content {
                    buffer = append(buffer, r)
                    if isSentenceDelimiter(r) {
                        sw.Send([]string{string(buffer)}, nil)
                        buffer = buffer[:0]
                    }
                }
            }
        }()
        
        return sr, nil
    }
}
```

#### TTS Lambda（并行合成）

```go
func ttsLambda(ttsSvc tts.Service, sender orchestrator.Sender) func(ctx, []string) (struct{}, error) {
    return func(ctx context.Context, sentences []string) (struct{}, error) {
        for _, sentence := range sentences {
            if sentence == "" {
                continue
            }
            
            // 调用 TTS 服务
            audioData, err := ttsSvc.Synthesize(ctx, sentence, tts.Options{})
            if err != nil {
                // TTS 失败不中断流程，仅记录日志
                log.Warn("TTS synthesis failed", zap.Error(err))
                continue
            }
            
            // 推送音频到客户端
            sender.SendTTSAudio(orchestrator.TTSAudioPayload{
                Audio:  audioData,
                Format: "mp3",
            })
        }
        
        return struct{}{}, nil
    }
}
```

### 4.3 Callback 集成

```go
// ModelCallbackHandler 用于 LLM token 推送
type ModelCallbackHandler struct {
    sender orchestrator.Sender
}

func (h *ModelCallbackHandler) OnEndWithStreamOutput(
    ctx context.Context,
    info *callbacks.RunInfo,
    output *schema.StreamReader[*schema.Message],
) context.Context {
    // 逐 token 推送到客户端
    for {
        msg, err := output.Recv()
        if err == io.EOF {
            break
        }
        if err != nil {
            return ctx
        }
        
        h.sender.SendLLMChunk(orchestrator.LLMChunkPayload{
            Content: msg.Content,
        })
    }
    
    return ctx
}
```

### 4.4 按请求动态配置

```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))
}

// WebSocket Handler 中的调用
func (c *Client) handleQuery(req QueryRequest) {
    opts := []compose.Option{}
    
    if req.Model != "" {
        opts = append(opts, WithModelName(req.Model))
    }
    
    output, err := c.pipeline.Stream(ctx, PipelineInput{...}, opts...)
}
```

### 4.5 注意事项

#### 值类型 vs 指针类型
Graph 泛型参数必须使用值类型（`PipelineInput`/`PipelineOutput`），所有 Lambda 的输入输出也使用值类型。框架在 Transform 模式下会自动处理 `T` 和 `StreamReader[T]` 的转换。

#### Callback 运行时传入
Callback 通过 `Stream()` 的 option 传入，不在 `Compile()` 时注册：

```go
streamReader, err := runnable.Stream(ctx, input, compose.WithCallbacks(handler))
```

#### eino-ext 与 DashScope 兼容性
eino-ext OpenAI ChatModel 通过 `BaseURL` 对接 DashScope 兼容接口。需注意：
- 多模态图片使用 `Base64Data` + `MIMEType` 格式
- `Timeout` 控制单次请求超时
- 流式输出通过 `Stream()` 方法获取 `StreamReader[*schema.Message]`

#### 框架自动类型转换
Eino 框架在编排场景中自动处理以下转换：
- **T → StreamReader[T]**：将完整值装箱为单帧流（非流式 → 假流式）
- **StreamReader[T] → T**：将流 concat 为完整值（流式 → 非流式）

这使得不同流式模式的节点可以无缝连接。

## 5. 测试策略

### 5.1 单元测试

```go
func TestPipelineGraph_WithTextInput(t *testing.T) {
    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)
}
```

### 5.2 集成测试

- 启动真实 OpenAI API 调用（使用测试 key）
- 验证 WebSocket 消息序列：`stt_result` → `llm_chunk` × N → `llm_done` → `tts_audio` × N
- 验证 interrupt 取消功能
- 验证多并发请求隔离

## 6. 未来扩展路径

基于 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 编排复杂对话流程

## 附录：关键 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)
```
