# AI 搭子 MVP 方案 > **本文档是实施级方案**。所有"为未来 100 明星 + 多 AI 平台"准备的复杂设计(Provider 抽象、ProviderFactory、Pipeline、ConversationStore 抽象等)**MVP 阶段不实现**。 > > V2 完整架构文档([2026-06-29-ai-chat-dify-integration-v2-design.md](2026-06-29-ai-chat-dify-integration-v2-design.md))保留为**长期演进路线图**,MVP 阶段不实施。 > **🎉 2026-06-30 MVP 端到端跑通!** > > 验证结果(实测): > - ✅ 后端 20008 端口正常 Dubbo 监听 > - ✅ Dify Workflow 流式响应 8 个 chunk 正常返回 > - ✅ Dify 内部用 minimax-m3 模型推理("嗨!我是角角~..." 完整回复) > - ✅ ai_conversations 表写入 message_count=2 > - ✅ ai_messages 表写入 user="hi" + assistant="嗨!我是角角~..." (228 字符) > - ✅ Gateway → aichatservice → Dify → DB 完整链路打通 --- ## 〇、我们解决的问题 ### 〇.1 业务问题 追星 App 用户与"角角"AI 搭子聊天时,AI **没有专属知识库**,无法针对所追明星(默认肖战)给出有针对性的回答: - 用户:"肖战最近有什么新作品?" - 现状:AI 只能泛泛而谈("肖战是中国男演员...") - 期望:AI 应能基于最新资料回答"根据知识库,肖战的新剧《X》将于 X 月上映" ### 〇.2 技术问题 | # | 问题 | 严重度 | MVP 解决方式 | |---|------|--------|------------| | 1 | **缺乏 RAG 能力**:AI 没有专属数据 | 🔴 高 | 接入 Dify Workflow + 肖战知识库 | | 2 | **会话无持久化**:当前仅 Redis 缓存 24h | 🟡 中 | PostgreSQL 持久化 + Redis 缓存(★ 核心) | | 3 | **AI 平台耦合**:未来要接 Coze/FastGPT | 🟢 低 | MVP 不做,1 个 Dify 够用 | | 4 | **运营成本失控**:100 明星时配置爆炸 | 🟢 低 | MVP 固定 1 个星 | ### 〇.3 解决方式(**业务驱动**,不是架构驱动) | 阶段 | 做什么 | 不做什么 | 触发条件 | |------|--------|---------|---------| | **MVP** | Dify + PostgreSQL + Redis | Provider 抽象、Fallback、多星 | 现在 | | Stage 2 | 多星 Dataset 切换 | Provider 抽象 | 加第 2 个星 / 用户 > 1000 | | Stage 3 | MiniMax fallback | 抽象 | Dify 偶发故障 | | Stage 4 | Provider 抽象 | 复杂 Pipeline | 接第 2 个 AI 平台 / 用户 > 10万 | | Stage 5 | Pipeline / AIProfile | A/B 测试 / 灰度 | 用户 > 100万 | **核心原则**:**业务不到不做架构**。MVP 阶段不实施"为未来 100 明星 + 多 AI 平台"准备的复杂设计。 --- ## 〇、文档说明 - **MVP 范围**:只有 1 个明星(默认肖战 star_id=87) - **AI 平台**:只有 Dify(无 MiniMax fallback,无 Provider 抽象) - **目标**:验证业务假设(用户愿意与"角角"聊天) - **后续演进**:见 [§十 演进路径](#十演进路径) --- ## 〇.1 方案概述(★ 必读) ### 〇.1.1 我们要做什么 **业务**:在追星 App 里加"AI 搭子"功能。用户进入后默认看到"角角"(肖战的 AI 形象),可以问"肖战最近在干嘛?"等关于肖战的问题。 **MVP 范围**: - ✅ 1 个明星(肖战) - ✅ 1 个 Dify Workflow(含 1 个肖战知识库) - ✅ 1 种回复源(Dify) - ✅ WebSocket 流式输出 - ❌ 不做:多星切换、模型 fallback、人设自定义、长期记忆提取、多 AI 平台 ### 〇.1.2 整体实现路径 ``` 数据迁移 Dify 端准备 后端代码 联调测试 ├ 建 2 张表 ├ 准备知识库 ├ model ├ 内部账号测试 ├ ├ 创 Workflow ├ repository ├ WebSocket 联调 └ └ 不需要 Code 节点 └ service └ 错误注入 └ provider └ main.go 装配 ``` **不要分配实施时间**——按业务节奏推进。 ### 〇.1.3 关键决策(80% 推到 Stage 2+) | 决策项 | MVP 选择 | Stage 2+ 再考虑 | |--------|---------|-----------------| | Dify 架构 | **Workflow**(含 1 个 Dataset) | 多星时加 dataset 变量 | | AI Provider | **Dify 单源**(无抽象) | Stage 3 才抽象 `AIProvider` | | 会话存储 | **PostgreSQL 主 + Redis 缓存** | 沿用 MVP 设计 | | Fallback | **无**(Dify 失败就报错) | Stage 2 看需求 | | Memory 提取 | **不做**(每轮都入 ai_messages) | Stage 2+ 看需求 | | Provider 抽象 | **不做** | Stage 3+ | | 数据集切换 | **固定 1 个**(肖战) | Stage 2 多星时加 mapping | ### 〇.1.4 核心架构图(TL;DR) ``` 用户(追星 App) │ WebSocket ▼ Gateway (现有, 不改) │ Dubbo Triple ▼ AIChatService.ChatService │ ├─ JWT 鉴权 (从 Dubbo attachments 取 user_id) │ ├─ 保存/读取会话: ConversationRepository │ ├─ ai_conversations (PostgreSQL, ★ V2 关键决策保留) │ └─ ai_messages (PostgreSQL, ★ V2 关键决策保留) │ ├─ 调 Dify (★ 唯一 AI 源) │ └─ DifyClient.StreamChat() │ └─ POST /v1/workflows/run │ └─ SSE 流 → WebSocket → 客户端 外部: Dify Workflow ┌──────────────────────┐ │ 开始 │ │ ↓ │ │ Knowledge Retrieval │ ← 固定查"肖战知识库" │ ↓ │ │ LLM │ ← 固定 Prompt (角角人设) │ ↓ │ │ 结束 │ └──────────────────────┘ ``` **关键简化**: - ❌ 没有 Provider 抽象 - ❌ 没有 ProviderFactory - ❌ 没有 MemoryStore - ❌ 没有 RedisLock(并发问题 MVP 阶段不严重) - ❌ 没有 AIProfile - ❌ 没有 DatasetResolver - ❌ 没有 Star→Dataset mapping(只有 1 个星) - ❌ 没有 Fallback 逻辑 - ❌ 没有 Memory 提取循环 - ❌ 没有 Star App 多 Workflow --- ## 一、MVP 范围 ### 1.1 包含 - ✅ 1 个明星(肖战 star_id=87) - ✅ 1 个 Dify Workflow(3 节点) - ✅ 1 个 Dify Dataset(肖战专属知识库) - ✅ WebSocket 协议(沿用现有) - ✅ JWT 鉴权(沿用现有) - ✅ Audit 前置 + 后置(沿用现有 AuditService) - ✅ Conversation 持久化到 PostgreSQL(**核心决策,MVP 即落地**) - ✅ Redis 缓存(沿用现有) ### 1.2 不包含(Stage 2+ 再做) - ❌ 多星切换(用户不能选其他明星) - ❌ MiniMax fallback(Dify 失败就报错给用户) - ❌ Persona 自定义(人设固定"角角") - ❌ 长期记忆提取(不分析对话提取记忆) - ❌ Provider 抽象接口(直接调 DifyClient) - ❌ 人设/风格/语言/记忆等参数化(都写死在 Dify Prompt 里) - ❌ Dify 内容审核节点(AuditService 已拦截,MVP 阶段够用) - ❌ Datasets 动态切换(固定"肖战知识库") --- ## 二、整体架构 ### 2.1 数据流(一次完整对话) ``` [Mobile] │ WebSocket send {action: "send_message", session_id, message} │ ▼ [Gateway Hub] │ 鉴权 (JWT → user_id) │ ▼ [AIChatService Provider.SendMessage] │ ├─ 1. 前置审核 (AuditService.AuditText) ★ 现有代码 │ ├─ 2. 获取/创建会话 (ConversationRepository) │ ├─ PostgreSQL ai_conversations (★ V2 关键) │ └─ Redis 缓存 1h (现有代码) │ ├─ 3. 调 Dify (★ MVP 唯一 AI 源) │ └─ DifyClient.StreamChat() │ └─ POST /v1/workflows/run │ ├─ 4. 流式返回 + 逐 token 后置审核 (AuditService.AuditResponse) │ └─ ★ 现有代码 │ ├─ 5. 保存消息 (ConversationRepository) │ └─ PostgreSQL ai_messages (★ V2 关键) │ └─ 6. (Stage 2+ 才做) 记忆提取 ``` ### 2.2 关键简化点 | 维度 | V2 文档 | MVP 实际 | |------|--------|---------| | 核心业务逻辑 | 9+ 步骤 | **4 步骤**(审计/会话/Dify/保存) | | Provider 数 | 2 个(Dify + MiniMax) | **1 个**(Dify) | | Fallback | 复杂的 Provider 切换 | **没有**(Dify 失败就报错) | | 星切换 | 动态 + dataset 映射 | **固定肖战** | | 人设/风格/记忆 | 4 个 SystemInputs 参数 | **写死在 Dify Prompt** | | 长期记忆 | MemoryStore + 5 轮触发 | **不做** | | Provider 抽象 | `AIProvider` interface | **直接调 DifyClient** | | Workflow 节点 | 5 个(含 Code + Moderation) | **3 个**(开始/检索/LLM/结束) | | 锁 | RedisLock | **不需要**(单实例部署,无并发问题) | | 后端代码行数估算 | 1500-2000 | **300-500** | --- ## 三、Dify 端配置 ### 3.1 准备知识库(Dataset) 1. 登录 Dify → "知识库" → "创建知识库" 2. 命名:`star-xz-kb`(固定一个) 3. 索引模式:`high_quality` 4. 导入肖战资料(作品、行程、近期事件等) 5. 等待向量化完成(每个文档显示 ✓) ### 3.2 创建 Workflow(仅 3 节点) 1. 进入"工作室" → "创建空白应用" → 类型选 **Workflow** 2. 命名:`star-chat-workflow` 3. 配置"开始"节点的 Input 变量: ```yaml inputs: - name: query # 用户消息 type: text ``` > **注**:`user_id`(哈希后的用户标识)由后端通过 Dify 协议**顶层 `user` 字段**传入(见 [§4.5 DifyClient](2026-06-29-ai-chat-dify-mvp-design.md#45-difyclient--mvp-唯一-ai-客户端)),不作为 workflow input。 > > **与实际工作流对齐**:[`docs/dify/角角.yml`](../../dify/角角.yml) v0.6.0 只声明了 `query` 一个 input。若后端仍把 `user_id` 塞进 `inputs`,Dify 会静默丢弃,不影响功能(`user` 字段仍生效)。 4. 添加 **知识检索节点**: - Knowledge:选 `star-xz-kb`(固定) - Query:`{{ query }}` - TopK:3 5. 添加 **LLM 节点**: ```markdown 你叫"角角",是肖战的 AI 形象。 【用户问题】 {{ query }} 【知识库检索结果】 {{#knowledge_retrieval_node.result#}} 请用温柔、自然的语言回答,参考知识库内容,不要编造。 ``` 6. 添加"直接回复"或"结束"节点 **节点序列**: ``` [开始] inputs:{query} ↓ [知识检索] star-xz-kb(固定) ↓ [LLM] 角角人设(固定) ↓ [结束] ``` ### 3.3 调试 1. 传 `inputs={query: "肖战最近在干嘛?", user_id: "aichat-xxxx"}` 2. 验证:返回基于知识库的回答 3. 发布 → 复制 API Key(`app-xxx`) --- ## 四、后端代码 ### 4.1 改动总览 | 层级 | 改动 | 工作量 | |------|------|--------| | `model/ai_chat_models.go` | **新增**:`AIConversation` / `AIMessage` GORM 模型 | 小 | | `repository/conversation_repository.go` | **新建**:ai_conversations / ai_messages CRUD | 中 | | `service/chat_service.go` | **修改**:直接调 DifyClient(不再有 ChatEngine 编排) | 中 | | `provider/ai_chat_provider.go` | **修改**:Dubbo 入口 + 调 ChatService | 小 | | `main.go` | **修改**:加 ConversationRepository 装配 | 小 | | `migrations/ai_conversations.sql` | **新建**:2 张表 DDL | 小 | | **前端** | **无改动** ✅ | 0 | **总工作量**:约 **300-500 行核心代码** ### 4.2 model/ai_chat_models.go 新增 ```go package model import "github.com/google/uuid" type AIConversation struct { ID int64 `gorm:"primaryKey;autoIncrement"` UserID int64 `gorm:"index;not null"` StarID int64 `gorm:"index;not null"` // MVP 固定为 87 ProviderName string `gorm:"type:varchar(32);not null;default:'dify'"` ExternalConvID string `gorm:"type:varchar(128);default:''"` MessageCount int `gorm:"default:0"` LastActiveAt int64 `gorm:"autoUpdateTime:milli"` CreatedAt int64 `gorm:"autoCreateTime:milli"` UpdatedAt int64 `gorm:"autoUpdateTime:milli"` } func (AIConversation) TableName() string { return "ai_conversations" } type AIMessage struct { ID int64 `gorm:"primaryKey;autoIncrement"` ConversationID int64 `gorm:"index;not null"` Role string `gorm:"type:varchar(16);not null"` // 'user' / 'assistant' Content string `gorm:"type:text;not null"` CreatedAt int64 `gorm:"autoCreateTime:milli"` } func (AIMessage) TableName() string { return "ai_messages" } ``` ### 4.3 PostgreSQL DDL ```sql CREATE TABLE IF NOT EXISTS ai_conversations ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL, star_id BIGINT NOT NULL, provider_name VARCHAR(32) NOT NULL DEFAULT 'dify', external_conversation_id VARCHAR(128) DEFAULT '', message_count INT DEFAULT 0, last_active_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT, created_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT, updated_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT ); CREATE INDEX idx_ai_conv_user_star ON ai_conversations(user_id, star_id); CREATE TABLE IF NOT EXISTS ai_messages ( id BIGSERIAL PRIMARY KEY, conversation_id BIGINT NOT NULL REFERENCES ai_conversations(id) ON DELETE CASCADE, role VARCHAR(16) NOT NULL, content TEXT NOT NULL, created_at BIGINT NOT NULL DEFAULT (EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT ); CREATE INDEX idx_ai_messages_conversation ON ai_messages(conversation_id, created_at); ``` > MVP 阶段**不**加唯一约束(user_id + star_id),方便 Stage 2 加多星时再处理 > MVP 阶段**不**加 `is_archived` 等字段 ### 4.4 ConversationRepository ```go package repository import ( "context" "github.com/topfans/backend/services/aiChatService/model" "gorm.io/gorm" ) type ConversationRepository struct { db *gorm.DB } func NewConversationRepository(db *gorm.DB) *ConversationRepository { return &ConversationRepository{db: db} } func (r *ConversationRepository) GetOrCreate(ctx context.Context, userID, starID int64) (*model.AIConversation, error) { var conv model.AIConversation err := r.db.WithContext(ctx).Where("user_id = ? AND star_id = ?", userID, starID).First(&conv).Error if err == gorm.ErrRecordNotFound { conv = model.AIConversation{UserID: userID, StarID: starID, ProviderName: "dify"} if err := r.db.WithContext(ctx).Create(&conv).Error; err != nil { return nil, err } return &conv, nil } if err != nil { return nil, err } return &conv, nil } func (r *ConversationRepository) AppendMessage(ctx context.Context, convID int64, role, content string) error { msg := model.AIMessage{ConversationID: convID, Role: role, Content: content} return r.db.WithContext(ctx).Create(&msg).Error } func (r *ConversationRepository) UpdateExternalConvID(ctx context.Context, convID int64, externalID string) error { return r.db.WithContext(ctx).Model(&model.AIConversation{}). Where("id = ?", convID). Updates(map[string]interface{}{ "external_conversation_id": externalID, "message_count": gorm.Expr("message_count + 1"), "last_active_at": gorm.Expr("(EXTRACT(EPOCH FROM NOW()) * 1000)::BIGINT"), }).Error } ``` ### 4.5 DifyClient(★ MVP 唯一 AI 客户端) ```go package service import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "time" "github.com/topfans/backend/pkg/logger" "go.uber.org/zap" ) type DifyClient struct { apiBase string workflowURL string apiKey string httpClient *http.Client } type DifyConfig struct { APIBase string WorkflowURL string APIKey string TimeoutSec int } func NewDifyClient(cfg DifyConfig) *DifyClient { return &DifyClient{ apiBase: cfg.APIBase, workflowURL: cfg.WorkflowURL, apiKey: cfg.APIKey, httpClient: &http.Client{Timeout: time.Duration(cfg.TimeoutSec) * time.Second}, } } // StreamChat 流式调用 Dify Workflow // 返回 (StreamReader, error):StreamReader 可逐 token 读取 func (c *DifyClient) StreamChat(ctx context.Context, query, userHashedID, convID string) (*DifyStreamReader, error) { inputs := map[string]interface{}{ "query": query, "user_id": userHashedID, } body := map[string]interface{}{ "inputs": inputs, "response_mode": "streaming", "conversation_id": convID, // 首次为空 "user": userHashedID, } jsonData, _ := json.Marshal(body) httpReq, _ := http.NewRequestWithContext(ctx, "POST", c.apiBase+c.workflowURL, bytes.NewReader(jsonData)) httpReq.Header.Set("Authorization", "Bearer "+c.apiKey) httpReq.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(httpReq) if err != nil { return nil, fmt.Errorf("dify request: %w", err) } if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) resp.Body.Close() return nil, fmt.Errorf("dify returned HTTP %d: %s", resp.StatusCode, string(body)) } return &DifyStreamReader{reader: resp.Body, decoder: NewSSEDecoder(resp.Body), conversationID: ""}, nil } // DifyStreamReader 解析 Dify SSE 流 type DifyStreamReader struct { reader io.ReadCloser decoder *SSEDecoder conversationID string } func (r *DifyStreamReader) Next() (content string, done bool, err error) { for { line, err := r.decoder.Next() if err != nil { if err == io.EOF { return "", true, nil } return "", true, err } if !strings.HasPrefix(line, "data: ") { continue } data := strings.TrimPrefix(line, "data: ") var event struct { Event string `json:"event"` Answer string `json:"answer"` ConversationID string `json:"conversation_id"` } if err := json.Unmarshal([]byte(data), &event); err != nil { continue } if event.ConversationID != "" && r.conversationID == "" { r.conversationID = event.ConversationID } switch event.Event { case "message": return event.Answer, false, nil case "message_end": return "", true, nil case "error": return "", true, fmt.Errorf("dify error event") } } } func (r *DifyStreamReader) GetConversationID() string { return r.conversationID } func (r *DifyStreamReader) Close() error { return r.reader.Close() } ``` > ★ MVP 阶段**没有**滑动窗口审计、retry 循环、敏感词检测(现有 AuditService 已足够) ### 4.6 ChatService(核心业务逻辑) ```go package service type ChatService struct { audit *AuditService convRepo *repository.ConversationRepository difyClient *DifyClient userIDSalt string } func NewChatService(audit *AuditService, convRepo *repository.ConversationRepository, dify *DifyClient) *ChatService { return &ChatService{audit: audit, convRepo: convRepo, difyClient: dify} } const ( DefaultStarID = int64(87) // 肖战 DefaultUserSalt = "topfans-default-salt" ) func (s *ChatService) hashUserID(userID int64) string { h := sha256.Sum256([]byte(fmt.Sprintf("%d:%s", userID, s.userIDSalt))) return "aichat-" + hex.EncodeToString(h[:8]) } // SendMessage 核心流程(4 步) func (s *ChatService) SendMessage(ctx context.Context, userID int64, message string) (<-chan *StreamChunk, error) { out := make(chan *StreamChunk, 16) go func() { defer close(out) // 1. 前置审核 if !s.audit.AuditText(message) { out <- &StreamChunk{Type: "message", Content: s.audit.DefaultSafeResponse(), IsEnd: false} out <- &StreamChunk{Type: "message", IsEnd: true} return } // 2. 获取/创建会话(★ V2 关键决策:PostgreSQL 持久化) conv, err := s.convRepo.GetOrCreate(ctx, userID, DefaultStarID) if err != nil { out <- &StreamChunk{Type: "error", Error: "会话创建失败"} return } // 3. 调 Dify(★ MVP 唯一 AI 源) streamReader, err := s.difyClient.StreamChat(ctx, message, s.hashUserID(userID), conv.ExternalConvID) if err != nil { logger.Logger.Error("Dify call failed", zap.Error(err)) out <- &StreamChunk{Type: "error", Error: "服务暂不可用"} return } defer streamReader.Close() // 4. 流式返回 + 后置审核 + 保存 var fullResponse string for { content, done, err := streamReader.Next() if err != nil { out <- &StreamChunk{Type: "error", Error: "服务异常"} return } if content != "" && !s.audit.AuditResponse(content) { // 命中敏感词 out <- &StreamChunk{Type: "message", Content: s.audit.DefaultSafeResponse(), IsEnd: false} out <- &StreamChunk{Type: "message", IsEnd: true} s.convRepo.AppendMessage(ctx, conv.ID, "assistant", s.audit.DefaultSafeResponse()) return } fullResponse += content out <- &StreamChunk{Type: "message", Content: content, IsEnd: done} if done { break } } // 5. 更新 Dify conv_id(首次) if newConvID := streamReader.GetConversationID(); newConvID != "" && newConvID != conv.ExternalConvID { s.convRepo.UpdateExternalConvID(ctx, conv.ID, newConvID) } // 6. 保存消息 s.convRepo.AppendMessage(ctx, conv.ID, "user", message) s.convRepo.AppendMessage(ctx, conv.ID, "assistant", fullResponse) }() return out, nil } // GetWelcomeMessage MVP 阶段固定返回"角角"欢迎语 func (s *ChatService) GetWelcomeMessage() string { return "你好,我是角角,专注肖战的 AI 搭子。有什么想了解的?" } ``` ### 4.7 Provider 大幅简化 ```go package provider type AIChatProvider struct { chatService *service.ChatService } func (p *AIChatProvider) SendMessage(ctx context.Context, req *pb.ChatMessageRequest, stream pb.AIChatService_SendMessageServer) error { userID, _, err := extractUserInfoFromDubboAttachments(ctx) if err != nil { return err } chunks, err := p.chatService.SendMessage(ctx, userID, req.Message) if err != nil { return err } for chunk := range chunks { if chunk.Type == "error" { stream.Send(&pb.ChatMessageResponse{Content: chunk.Error, IsEnd: true}) } else { stream.Send(&pb.ChatMessageResponse{Content: chunk.Content, IsEnd: chunk.IsEnd}) } } return nil } ``` > **极简**:~30 行。**完全没有 V2 里的 ChatEngine 编排、锁、Provider 抽象、Factory 等** ### 4.8 main.go 装配 ```go // MVP 装配:极简 convRepo := repository.NewConversationRepository(database.GetDB()) difyClient := service.NewDifyClient(service.DifyConfig{ APIBase: getEnv("DIFY_API_BASE", "https://api.dify.ai/v1"), WorkflowURL: "/workflows/run", APIKey: getEnv("DIFY_API_KEY", ""), TimeoutSec: 60, }) chatService := service.NewChatService(auditService, convRepo, difyClient) aiChatProvider := provider.NewAIChatProvider(chatService) ``` --- ## 五、消息协议(无改动) WebSocket 协议与现有实现一致: - Client → Server:`{action: "send_message", session_id, message}` - Server → Client:`{type: "message", content, is_end}` 或 `{type: "error", error}` **前端零改动**。 --- ## 六、关键设计决策 | 决策 | 选择 | 理由 | |------|------|------| | Provider 抽象 | **不做** | MVP 只有 1 个 AI 源,抽象无价值 | | MiniMax fallback | **不做** | Dify 失败就报错,避免增加复杂度 | | 长期记忆提取 | **不做** | 业务假设未验证前不做 | | Redis 缓存 | **保留** | 1h 缓存会话元数据,避免每次查 DB | | PostgreSQL 持久化 | **保留(★ 关键)** | 跨设备/跨天续接(追星场景长生命周期) | | 滑动窗口审计 | **不做** | 现有 AuditService 逐 token 检查已足够 | | 锁 | **不做** | 单实例部署,并发问题不严重 | | Dify 内容审核节点 | **不做** | 现有 AuditService 已拦截,MVP 够用 | | UserStyle/UserNickname 参数 | **不做** | 写死在 Dify Prompt 里 | | 星切换 | **不做** | 固定 star_id=87(肖战) | --- ## 七、配置清单 ### 7.1 环境变量 | 变量 | 用途 | 必填 | |------|------|------| | `DIFY_API_KEY` | Dify Workflow API Key | 是 | | `DIFY_API_BASE` | Dify API 地址 | 否(默认 https://api.dify.ai/v1) | ### 7.2 ai_chat_configs(★ MVP 全部不要) **MVP 阶段直接用环境变量,不写 ai_chat_configs 数据库**。 **V2 文档里 9 个 `dify.*` 配置项 MVP 全部不需要**(enabler、api_base、workflow_url、star_dataset_mapping、api_key、timeout_sec、user_id_salt、retry_count、fallback_to_minimax)——**全部 hardcode 或用环境变量**。 > Stage 2+ 才把这些移到数据库配置。 --- ## 八、部署清单 > **不要分配实施时间**。按业务节奏推进。 ### 8.1 数据库 - [ ] DBA 执行 `migrations/ai_conversations.sql` - [ ] 验证表结构和索引 ### 8.2 Dify 端 - [ ] Dify 管理员创建 `star-xz-kb` 知识库 - [ ] 导入肖战资料并等待向量化完成 - [ ] 创建 `star-chat-workflow`(3 节点:开始/检索/LLM) - [ ] 配置 Prompt("你是角角,温柔回复,参考知识库...") - [ ] 调试并发布 - [ ] 把 API Key 安全转给后端 ### 8.3 后端代码 - [ ] 新建 `model/ai_chat_models.go` 的 AIConversation/AIMessage - [ ] 新建 `repository/conversation_repository.go` - [ ] 新建 `service/dify_client.go` - [ ] 修改 `service/chat_service.go`(**直接调 DifyClient,不引入 ChatEngine**) - [ ] 简化 `provider/ai_chat_provider.go` - [ ] 修改 `main.go` 装配 - [ ] 单元测试:ConversationRepository CRUD - [ ] 集成测试:mock Dify server 跑完整 SendMessage ### 8.4 联调测试 - [ ] 内部账号测试:进 ai-dazi 页面发消息 - [ ] 验证:流式返回正常 - [ ] 验证:ai_messages 表有 user + assistant 两条记录 - [ ] 验证:关掉重开会话能续接 - [ ] 验证:敏感词("裸聊"等)被拦截 - [ ] 验证:Dify 故障时返回明确错误 --- ## 九、验证清单 ### 9.1 功能验证 - [ ] 发送"肖战最近在干嘛?"能返回基于知识库的回答 - [ ] 发送"你好"能返回通用问候 - [ ] 同用户第二次发消息能续接上下文(Dify conversation_id) - [ ] ai_messages 表有 user + assistant 两条记录 - [ ] ai_conversations 表的 message_count 正确递增 ### 9.2 安全验证 - [ ] 前置审核:用户发"裸聊"等敏感词被拦截 - [ ] 后置审核:Dify 回复中含敏感词被拦截 - [ ] Dify API Key 不出现在日志 ### 9.3 不验证(Stage 2+ 再做) - ~~多星切换~~(MVP 不做) - ~~Fallback~~(MVP 不做) - ~~长期记忆提取~~(MVP 不做) --- ## 十、Stage 2+ 演进路径 MVP 跑通后,根据用户量和业务反馈,按以下顺序演进: | Stage | 触发条件 | 关键改动 | |-------|---------|---------| | **Stage 2** | 用户量 > 1000 OR 加第 2 个星 | 1. 多星 Dataset 切换(star_dataset_mapping)
2. WebSocket 端 InitSession 欢迎语动态化
3. ai_conversations 加 UNIQUE(user_id, star_id) | | **Stage 3** | Dify 偶发故障 OR SLA 要求 | 1. MiniMax fallback(仅 message_count=0 时)
2. Dify retry 循环 | | **Stage 4** | 用户量 > 10万 OR 接 2+ AI 平台 | 1. AIProvider 抽象
2. ProviderFactory 策略模式
3. CozeProvider / OpenAIProvider 实现 | | **Stage 5** | 用户量 > 100万 OR 业务复杂 | 1. ChatEngine Pipeline 化
2. AIProfile 配置化(A/B 测试、灰度)
3. 长期记忆提取 | **关键原则**:每个 Stage 都是**业务驱动**,不是架构驱动。 ### ★ Stage 2+ 演进时必踩的 3 个坑(P0 修复笔记) > ★ **这些是 V2 架构评审发现的真实 bug**,MVP 阶段不修(流量小、问题不暴露),但 **Stage 2+ 流量上来后必现**。 > ★ **必读**:实施 Stage 2 之前,**必须**先修这 3 个 P0 问题。 #### 坑 1:并发请求分裂会话(★ P0-1) **症状**:用户手机 + 平板同时发消息,Dify 端产生两个会话,AI 上下文错乱。 **根因**:两个并发请求都查到 `ExternalConvID=""`,都调 Dify,Dify 给两个不同的 `conversation_id`,后写入的覆盖先写入的。 **修复**: - 加 Redis 分布式锁 `conv_lock:{userId}:{starId}`,TTL 30s - 锁范围:`GetOrCreateConversation` → `UpdateExternalID` - 锁未获取时 sleep 200ms 重试一次 #### 坑 2:审计拦截后不保存对话(★ P0-2) **症状**:用户每次触发敏感词拦截后,AI 都不记得之前说过什么,行为诡异。 **根因**:审计分支直接 return,没保存"user 原句 + 安全回复"。 **修复**:审计分支也调 `AppendConversationMessages` 保存对话。 #### 坑 3:组合敏感词漏检(★ P0-3) **症状**:Dify 返回"色"+"情"分两个 token,单独都不违规,组合违规。 **根因**:V1 AuditService 逐 token 检查(`strings.Contains(token, word)`),单 token 视角。 **修复**:Dify 流式接收时维护 sliding window buffer(20 字符),每个 token 检查 buffer。 ### ★ Stage 4 抽象时必踩的 3 个坑(架构评审笔记) > ★ 这些是 V2 架构评审发现的 **设计层面**问题,Stage 4 做 AIProvider 抽象时必踩。 #### 坑 4:Provider God Class(评审 #1) **症状**:DifyProvider 写了 2000+ 行,什么都管(HTTP / Stream / Cache / Conversation / Retry / Hash / Audit)。 **修复**:拆为 4 个组件: - `WorkflowClient`:HTTP + SSE 解析 + Retry - `HistoryClient`:拉历史消息 - `DatasetResolver`:star_id → dataset_id 映射 - `ConversationStore`:缓存 + 持久化 **原则**:Provider 只做协调,不做任何具体工作。 #### 坑 5:Provider 直接依赖 Redis/Repository(评审 #5) **症状**:Provider 改存储(Redis → Memcached)时,所有 Provider 都要改。 **修复**:引入 `ConversationStore` 抽象,Provider 只依赖接口。底层是 `CachedConversationStore`(PostgreSQL + Redis 缓存)。 #### 坑 6:Workflow 与 Backend 重复维护 dataset 映射(评审 #3) **症状**:Backend 改 `star_dataset_mapping` 忘改 Workflow → 用户问"肖战"答"王一博"的资料。 **修复**: - **单一数据源**:映射只在 Backend `ai_chat_configs.dify.star_dataset_mapping` 维护 - Workflow 输入直接接 `dataset_id`(Backend 传过来的) - Workflow 内部**不**维护任何 `star_id → dataset_id` 映射(**没有 Code 节点**) ### ★ 演进时不要做 - ❌ **不要预先做 P0 修复**:MVP 阶段流量小,race / 审计保存 / 组合敏感词都暴露不出来 - ❌ **不要预先做 Provider 抽象**:MVP 只有 1 个 AI 源(写死就行) - ❌ **不要预先做 Pipeline**:SendMessage 函数 < 200 行不需要 Pipeline - ❌ **不要预先做 AIProfile**:1 个星 1 种 AI 源根本不需要 A/B --- ## 十一、与 V2 文档的关系 **V2 文档已删除**(2026-06-29 决定)。 原因:V2 是"100 明星 + 多 AI 平台"的完整架构设计,**MVP 不需要 80% 的内容**。V2 关键内容已迁移到本 MVP 文档的 [§10 演进路径](#十stage-2-演进路径),包含 3 个 P0 修复笔记 + 3 个 Stage 4 抽象笔记。 实施时按 MVP 推进,跑通后再按 §10 Stage 2+ 演进。 --- ## 十二、关键文件清单 ### 12.1 新建文件 ``` backend/services/aiChatService/ ├── model/ │ └── ai_chat_models.go (新增 AIConversation / AIMessage struct) ├── repository/ │ └── conversation_repository.go (新建,3 个方法) ├── service/ │ ├── dify_client.go (新建,唯一 AI 客户端) │ └── chat_service.go (修改,4 步流程) migrations/ └── ai_conversations.sql (新建,2 张表 DDL) ``` ### 12.2 修改文件 ``` backend/services/aiChatService/ ├── provider/ │ └── ai_chat_provider.go (大幅简化) └── main.go (加 convRepo + difyClient 装配) ``` ### 12.3 不动文件 - 前端(所有 .vue / .js) - Gateway(WebSocket Hub) - AuditService(现有代码,MVP 沿用) - JWT 鉴权(现有代码) --- ## 总结 **MVP 阶段的核心是验证业务假设,不是搭建完美架构**。 ### MVP 范围 - ✅ 用户能跟"角角"聊天 - ✅ AI 能基于肖战知识库回答 - ✅ 对话跨天续接(PostgreSQL 持久化) - ✅ 敏感词拦截(AuditService 沿用) ### 不做(业务驱动) - ❌ Provider 抽象(只有 1 个 AI 源) - ❌ Fallback(Dify 失败就报错) - ❌ 多星(只有 1 个星) - ❌ 长期记忆提取(每轮直接入 ai_messages) - ❌ Persona 自定义(写死在 Dify Prompt) - ❌ Dify 内容审核节点(AuditService 够用) - ❌ 9 个 dify.* 数据库配置(环境变量够用) ### 业务驱动原则 每个 Stage 都是**业务驱动**,不是架构驱动: 1. 业务没到的复杂度 → **不预先做** 2. 架构是演进的,**不是一次性完美设计** 3. V2 完整文档作为**长期演进路线图**,**不删除** 4. 跑通 MVP 后,按 §十 Stage 2-5 渐进改进 **这些就够了**。其他都是"未来 100 明星 + 多 AI 平台"的事。