2081 lines
64 KiB
Markdown
2081 lines
64 KiB
Markdown
# AI Chat Service 设计方案
|
||
|
||
> **目标:** 在 TopFans Backend 微服务体系中,新增 AI 伴侣对话服务,实现「用户输入→人设注入→大模型调用→记忆召回→合规审核→流式输出」完整链路。
|
||
>
|
||
> **通信方式:** WebSocket(移动端使用 UniApp,WebSocket 支持更好)
|
||
|
||
---
|
||
|
||
## 一、架构设计
|
||
|
||
### 1.1 整体架构
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ Mobile App (UniApp) │
|
||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||
│ │ WebSocket Client │ │
|
||
│ │ 连接 /ws/ai-chat # WebSocket 连接 │ │
|
||
│ │ 发送消息 → 接收流式回复 # 双向通信 │ │
|
||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||
└─────────────────────────────────┬───────────────────────────────────────────┘
|
||
│ WebSocket
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ Gateway (:8080) │
|
||
│ ┌─────────────────────────────────────────────────────────────────────┐ │
|
||
│ │ AIChatWebSocketHandler (WebSocket 处理) │ │
|
||
│ │ /ws/ai-chat # WebSocket 连接升级 │ │
|
||
│ │ GET /api/v1/ai-chat/personas # 获取人设列表 │ │
|
||
│ │ GET /api/v1/ai-chat/history/{sessionId} # 获取对话历史 │ │
|
||
│ └─────────────────────────────────────────────────────────────────────┘ │
|
||
│ │ │
|
||
│ │ Dubbo Triple (gRPC) │
|
||
└────────────────────────────────────┼─────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||
│ AIChatService (:20008) │
|
||
│ │
|
||
│ ┌──────────────────────────────────────────────────────────────────────┐ │
|
||
│ │ Provider 层 │ │
|
||
│ │ AIChatProvider (Dubbo RPC 入口) │ │
|
||
│ └──────────────────────────────────────────────────────────────────────┘ │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ ┌──────────────────────────────────────────────────────────────────────┐ │
|
||
│ │ Service 层 │ │
|
||
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌────────────┐ │ │
|
||
│ │ │LLMService │ │PersonaService│ │MemoryService │ │AuditService│ │ │
|
||
│ │ │大模型调用 │ │人设管理 │ │记忆管理 │ │合规审核 │ │ │
|
||
│ │ └─────────────┘ └─────────────┘ └─────────────┘ └────────────┘ │ │
|
||
│ └──────────────────────────────────────────────────────────────────────┘ │
|
||
│ │
|
||
│ ┌──────────────────────────────────────────────────────────────────────┐ │
|
||
│ │ Repository 层 │ │
|
||
│ │ ┌───────────────────────────┐ ┌───────────────────────────────────┐ │ │
|
||
│ │ │ PostgreSQL │ │ Redis │ │ │
|
||
│ │ │ ai_user_memories (长期记忆) │ │ context:{sessionId} - 短期上下文 │ │ │
|
||
│ │ │ ai_personas (人设) │ │ persona_cache:{userId}:{id} │ │ │
|
||
│ │ └───────────────────────────┘ └───────────────────────────────────┘ │ │
|
||
│ └──────────────────────────────────────────────────────────────────────┘ │
|
||
└─────────────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
### 1.2 服务间调用关系
|
||
|
||
```
|
||
Gateway ──────────────────────────────────────────────────────────────────────
|
||
│ │
|
||
│ Dubbo Triple 协议 │
|
||
▼ │
|
||
AIChatService │
|
||
│ │
|
||
├─── 调用 MiniMax M2-her API ──► AI 大模型 (外部服务) │
|
||
│ │
|
||
├─── 读写 ──► Redis (短期上下文、Session) │
|
||
│ │
|
||
└─── 读写 ──► PostgreSQL (长期记忆 + 用户自定义人设) │
|
||
```
|
||
|
||
### 1.3 数据流
|
||
|
||
```
|
||
1. WebSocket 连接建立
|
||
Mobile → Gateway (WebSocket Upgrade)
|
||
Gateway 验证 JWT Token,获取 user_id 和 star_id
|
||
建立 WebSocket 连接,关联 user_id
|
||
|
||
2. 用户发送消息
|
||
Mobile → Gateway (WebSocket 发送 JSON)
|
||
{
|
||
"action": "send_message",
|
||
"session_id": "10001_123",
|
||
"message": "今天工作好累",
|
||
"persona_id": "uuid-xxx" // 可选
|
||
}
|
||
|
||
3. Gateway → AIChatService
|
||
通过 Dubbo Triple 调用 SendMessage
|
||
|
||
4. 前置审核
|
||
AIChatService.AuditService.audit_text() → 通过/拒绝
|
||
|
||
5. 记忆召回
|
||
AIChatService.MemoryService.recall_memories() → 召回相关记忆
|
||
|
||
6. Prompt 组装
|
||
AIChatService.PersonaService.get_persona() → 获取人设
|
||
组装: SystemPrompt + 召回记忆 + 对话历史 + 用户输入
|
||
|
||
7. 大模型调用 (流式)
|
||
AIChatService.LLMService.stream_chat() → MiniMax API
|
||
|
||
8. 后置审核 (逐Token)
|
||
AIChatService.AuditService.audit_response() → 拦截敏感输出
|
||
|
||
9. 流式返回 (WebSocket)
|
||
AIChatService → Gateway (Dubbo Stream)
|
||
Gateway → Mobile (WebSocket)
|
||
{
|
||
"type": "message",
|
||
"content": "宝,",
|
||
"session_id": "10001_123",
|
||
"is_end": false
|
||
}
|
||
... (持续推送)
|
||
{
|
||
"type": "message",
|
||
"content": "",
|
||
"session_id": "10001_123",
|
||
"is_end": true
|
||
}
|
||
|
||
10. 保存上下文
|
||
AIChatService.MemoryService.save_context() → Redis
|
||
|
||
11. 触发记忆提取 (每5轮)
|
||
AIChatService.MemoryService.extract_memory() → PostgreSQL
|
||
```
|
||
|
||
### 1.4 WebSocket 消息协议
|
||
|
||
#### 1.4.1 客户端 → 服务器
|
||
|
||
**发送消息:**
|
||
```json
|
||
{
|
||
"action": "send_message",
|
||
"session_id": "10001_123",
|
||
"message": "今天工作好累",
|
||
"persona_id": "uuid-xxx" // 可选,不传则使用用户默认人设
|
||
}
|
||
```
|
||
|
||
**心跳检测:**
|
||
```json
|
||
{
|
||
"action": "ping"
|
||
}
|
||
```
|
||
|
||
**获取历史:**
|
||
```json
|
||
{
|
||
"action": "get_history",
|
||
"session_id": "10001_123",
|
||
"limit": 20
|
||
}
|
||
```
|
||
|
||
**获取人设列表:**
|
||
```json
|
||
{
|
||
"action": "get_personas"
|
||
}
|
||
```
|
||
|
||
#### 1.4.2 服务器 → 客户端
|
||
|
||
**鉴权响应:**
|
||
```json
|
||
{
|
||
"type": "auth_response",
|
||
"success": true,
|
||
"user_id": 10001,
|
||
"star_id": 123
|
||
}
|
||
```
|
||
|
||
**消息片段(流式):**
|
||
```json
|
||
{
|
||
"type": "message",
|
||
"content": "宝,",
|
||
"session_id": "10001_123",
|
||
"is_end": false
|
||
}
|
||
```
|
||
|
||
**消息结束:**
|
||
```json
|
||
{
|
||
"type": "message",
|
||
"content": "",
|
||
"session_id": "10001_123",
|
||
"is_end": true
|
||
}
|
||
```
|
||
|
||
**心跳响应:**
|
||
```json
|
||
{
|
||
"type": "pong"
|
||
}
|
||
```
|
||
|
||
**错误响应:**
|
||
```json
|
||
{
|
||
"type": "error",
|
||
"code": "AUDIT_BLOCKED",
|
||
"message": "抱歉,这个话题我无法继续,我们换个话题聊聊吧。",
|
||
"session_id": "10001_123"
|
||
}
|
||
```
|
||
|
||
**历史消息响应:**
|
||
```json
|
||
{
|
||
"type": "history_response",
|
||
"session_id": "10001_123",
|
||
"history": [
|
||
{"role": "user", "content": "今天工作好累"},
|
||
{"role": "assistant", "content": "宝,辛苦了~"}
|
||
]
|
||
}
|
||
```
|
||
|
||
**人设列表响应:**
|
||
```json
|
||
{
|
||
"type": "personas_response",
|
||
"personas": [
|
||
{"id": "uuid-xxx", "name": "小雪", "description": "温柔陪伴型闺蜜", "is_default": true},
|
||
{"id": "uuid-yyy", "name": "阿逗", "description": "幽默搭子", "is_default": false}
|
||
]
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 二、目录结构
|
||
|
||
```
|
||
services/aiChatService/
|
||
├── main.go # 程序入口
|
||
├── configs/
|
||
│ └── dubbo.yaml # Dubbo 配置
|
||
├── go.mod
|
||
├── go.sum
|
||
├── provider/
|
||
│ └── ai_chat_provider.go # Dubbo Provider (RPC 入口)
|
||
├── service/
|
||
│ ├── llm_service.go # 大模型调用 (MiniMax + 通义备用)
|
||
│ ├── persona_service.go # 人设管理
|
||
│ ├── memory_service.go # 记忆管理 (Redis + PostgreSQL)
|
||
│ ├── audit_service.go # 合规审核
|
||
│ └── prompt_builder.go # Prompt 组装
|
||
├── repository/
|
||
│ ├── memory_repository.go # 长期记忆 PostgreSQL 存储
|
||
│ └── persona_repository.go # 人设 PostgreSQL 存储
|
||
├── model/
|
||
│ ├── ai_chat_models.go # 数据模型定义
|
||
│ └── ai_chat_errors.go # 错误定义
|
||
└── pkg/
|
||
└── ai_chat_config.go # 配置加载
|
||
|
||
migrations/
|
||
└── ai_chat.sql # 数据库迁移 SQL 文件
|
||
```
|
||
|
||
---
|
||
|
||
## 三、接口设计
|
||
|
||
### 3.1 WebSocket 接口 (Gateway → Mobile)
|
||
|
||
#### 连接地址
|
||
```
|
||
ws://gateway:8080/ws/ai-chat?token=Bearer_xxx
|
||
```
|
||
|
||
> **鉴权方式:** 连接时通过 URL 参数传递 JWT Token,Gateway 验证通过后建立连接。
|
||
> 相比连接后发送 auth 消息,此方式可避免恶意连接浪费资源。
|
||
|
||
#### 连接流程
|
||
1. Mobile 携带 Token 建立 WebSocket 连接:`ws://gateway:8080/ws/ai-chat?token=Bearer_xxx`
|
||
2. Gateway 解析并验证 Token,获取 user_id 和 star_id
|
||
3. 验证成功,返回 `{"type": "auth_response", "success": true}`
|
||
4. 连接建立成功,开始收发消息
|
||
|
||
#### 连接失败响应
|
||
```json
|
||
{
|
||
"type": "auth_response",
|
||
"success": false,
|
||
"error": "invalid_token"
|
||
}
|
||
```
|
||
|
||
#### 心跳保活
|
||
- Mobile 每 30 秒发送一次 ping 消息
|
||
- Gateway 回复 pong 消息
|
||
- 如果 60 秒内未收到任何消息,Gateway 主动关闭连接
|
||
|
||
### 3.2 HTTP 接口 (Gateway → Mobile) - 兼容性接口
|
||
|
||
#### GET /api/v1/ai-chat/history/{sessionId}
|
||
**获取对话历史**
|
||
|
||
> 注意:user_id 从 JWT Token 中解析获取,无需请求参数。sessionId 格式为 `{userId}_{starId}`
|
||
|
||
Response:
|
||
```json
|
||
{
|
||
"history": [
|
||
{"role": "user", "content": "今天工作好累"},
|
||
{"role": "assistant", "content": "宝,辛苦了~"}
|
||
]
|
||
}
|
||
```
|
||
|
||
#### GET /api/v1/ai-chat/personas
|
||
**获取用户的所有人设列表**
|
||
|
||
> 注意:user_id 从 JWT Token 中解析获取,无需请求参数
|
||
|
||
Response:
|
||
```json
|
||
{
|
||
"personas": [
|
||
{"id": "uuid-xxx", "name": "小雪", "description": "温柔陪伴型闺蜜", "is_default": true},
|
||
{"id": "uuid-yyy", "name": "阿逗", "description": "幽默搭子", "is_default": false}
|
||
]
|
||
}
|
||
```
|
||
|
||
### 3.3 Dubbo Triple 接口 (Gateway → AIChatService)
|
||
|
||
```protobuf
|
||
service AIChatService {
|
||
// 发送消息 (流式返回)
|
||
rpc SendMessage(ChatMessageRequest) returns (stream ChatMessageResponse);
|
||
|
||
// 获取对话历史
|
||
rpc GetHistory(ChatHistoryRequest) returns (ChatHistoryResponse);
|
||
|
||
// ============= 人设管理 =============
|
||
|
||
// 获取用户的所有人设
|
||
rpc GetPersonas(GetPersonasRequest) returns (PersonaListResponse);
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 四、核心模块设计
|
||
|
||
### 4.1 LLM Service (大模型调用)
|
||
|
||
**功能:** 封装 MiniMax M2-her 文本对话 API,支持流式输出和模型降级。
|
||
|
||
**实现要点:**
|
||
|
||
```go
|
||
type LLMService struct {
|
||
minimaxClient *http.Client
|
||
qwenClient *http.Client
|
||
}
|
||
|
||
func (s *LLMService) StreamChat(ctx context.Context, messages []Message) (*StreamReader, error)
|
||
```
|
||
|
||
**StreamReader 接口定义:**
|
||
```go
|
||
// StreamReader 流式读取器接口
|
||
type StreamReader interface {
|
||
// Next 返回下一条消息内容,done=true 表示流结束
|
||
Next() (content string, done bool, err error)
|
||
// Close 关闭流式读取器
|
||
Close() error
|
||
}
|
||
```
|
||
|
||
**API 调用:**
|
||
- 主模型:MiniMax `M2-her` ( `/v1/text/chatcompletion_v2` )
|
||
- 备用模型:通义 `qwen-plus` ( `/compatible-mode/v1/chat/completions` )
|
||
|
||
**流式处理:**
|
||
- MiniMax 原生 SSE 格式,逐行解析 `choices[0].delta.content`
|
||
- 错误时自动切换备用模型
|
||
- 模型选择通过环境变量配置
|
||
|
||
**环境变量:**
|
||
```bash
|
||
MINIMAX_API_KEY=xxx
|
||
MINIMAX_API_URL=https://api.minimaxi.com/v1
|
||
MINIMAX_MODEL=M2-her
|
||
|
||
QWEN_API_KEY=xxx
|
||
QWEN_API_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||
QWEN_MODEL=qwen-plus
|
||
```
|
||
|
||
### 4.2 Persona Service (人设管理)
|
||
|
||
**功能:** 管理用户自定义 AI 角色人设。
|
||
|
||
**默认人设:** 系统在用户首次使用 AI Chat 时自动创建一个默认人设,默认 SystemPrompt 如下:
|
||
|
||
```
|
||
你是一个温柔体贴的AI伴侣,名字叫小雪。你善于倾听,能理解用户的情绪,
|
||
用温暖的话语陪伴用户。说话风格亲切自然,像朋友聊天一样。
|
||
不要过于正式或说教,当用户情绪低落时,先给予共情和安慰。
|
||
```
|
||
|
||
**人设数据结构:**
|
||
```go
|
||
type Persona struct {
|
||
ID string `json:"id"` // UUID
|
||
UserID int64 `json:"user_id"` // 所属用户ID
|
||
Name string `json:"name"` // 人设名称
|
||
Description string `json:"description"` // 人设描述
|
||
AvatarURL string `json:"avatar_url"` // 头像URL(可选)
|
||
SystemPrompt string `json:"system_prompt"` // 核心设定Prompt
|
||
TalkStyle string `json:"talk_style"` // 说话风格(可选)
|
||
IsDefault bool `json:"is_default"` // 是否默认人设
|
||
CreatedAt int64 `json:"created_at"`
|
||
UpdatedAt int64 `json:"updated_at"`
|
||
}
|
||
```
|
||
|
||
**获取人设 GetPersonas:**
|
||
- 根据 user_id 从数据库查询该用户的所有人设
|
||
- 系统自动创建默认人设,用户不可能无人设
|
||
|
||
**错误定义:**
|
||
```go
|
||
ErrPersonaNotFound = errors.New("persona_not_found", "人设不存在")
|
||
```
|
||
|
||
### 4.3 Memory Service (记忆管理)
|
||
|
||
**功能:** 短期上下文 + 长期记忆的分层记忆系统。
|
||
|
||
**短期记忆 (Redis):**
|
||
```go
|
||
// Key: context:{sessionId}
|
||
// Value: JSON array of messages
|
||
// TTL: 24小时 (86400秒)
|
||
|
||
// sessionId 格式: {userId}_{starId},例如 "10001_123"
|
||
// 注意:session 与 persona 分离,同一个 session 可以切换不同 persona 对话
|
||
```
|
||
|
||
**长期记忆 (PostgreSQL):**
|
||
详见 7.2 节 `ai_user_memories` 表定义
|
||
|
||
**记忆召回流程:**
|
||
1. 从用户输入提取关键词
|
||
2. PostgreSQL 数组匹配 `keywords && $1`
|
||
3. 按 weight 降序、created_at 降序返回 Top 5
|
||
4. 组装成 "# 用户核心记忆\n- ...\n- ..." 格式注入 Prompt
|
||
|
||
**记忆提取触发:**
|
||
- 每 5 轮对话触发一次(1轮 = user发送 + assistant回复,5轮 = 10条消息)
|
||
- 从最近 5 轮用户消息(共10条消息,取最后5条user消息)中提取关键词
|
||
- 简单规则匹配:累/忙→工作状态,开心/高兴→正面情绪,生日/纪念日→重要日期
|
||
|
||
### 4.4 Audit Service (合规审核)
|
||
|
||
**功能:** 输入/输出内容安全审核。
|
||
|
||
#### 4.4.1 审核维度
|
||
|
||
| 类别 | 关键词示例 |
|
||
|------|-----------|
|
||
| 政治类 | 台独、港独、藏独、疆独 |
|
||
| 色情类 | 色情、裸聊、约炮 |
|
||
| 暴力类 | 杀人、虐待、暴力 |
|
||
| 违规诱导 | 转账、汇款、银行卡 |
|
||
| AI身份冒充 | "我是真人"、"我是人类" |
|
||
|
||
#### 4.4.2 审核策略
|
||
|
||
| 层级 | 位置 | 策略 | 说明 |
|
||
|------|------|------|------|
|
||
| **输入审核** | 后端前置 | 前置拦截 | 用户发送消息后、大模型处理前,检测敏感词,违规直接返回错误 |
|
||
| **输出审核** | 后端流式 | 后置拦截 | AI 回复时逐 Token 审核,检测到敏感词立即终止流并返回标准回复 |
|
||
|
||
**后置审核工作原理(逐 Token 检查):**
|
||
```go
|
||
// 后置审核在流式输出循环中实时检查每个 token
|
||
for {
|
||
token, done, err := streamReader.Next()
|
||
if err != nil {
|
||
break
|
||
}
|
||
|
||
// 逐 Token 审核 - 检测到违规立即终止
|
||
if auditService.audit_response(token) == false {
|
||
// 终止流式输出
|
||
sendFinalMessage("抱歉,这个话题我无法继续,我们换个话题聊聊吧。")
|
||
streamReader.Close()
|
||
return
|
||
}
|
||
|
||
// 审核通过,发送 token 给客户端
|
||
sendToClient(token)
|
||
|
||
if done {
|
||
break
|
||
}
|
||
}
|
||
```
|
||
|
||
**回复替换(检测到违规时的标准回复):**
|
||
```go
|
||
defaultSafeResponse = "抱歉,这个话题我无法继续,我们换个话题聊聊吧。"
|
||
```
|
||
|
||
#### 4.4.3 前端敏感词过滤(UniApp 客户端 - 可选额外保护)
|
||
|
||
前端敏感词过滤是**可选的额外保护层**,不替代后端审核。用于减少不必要的网络请求和在展示层面做基础过滤。
|
||
|
||
**前端过滤时机:**
|
||
| 时机 | 过滤目标 | 说明 |
|
||
|------|----------|------|
|
||
| 发送前 | 用户输入 | 检测到敏感词时本地拦截,不发送请求 |
|
||
| 接收后 | AI 回复 | 展示前做基础过滤(可选) |
|
||
|
||
**前端敏感词列表:**
|
||
```javascript
|
||
// 基础敏感词列表(建议与后端同步,定期更新)
|
||
const FRONTEND_SENSITIVE_WORDS = [
|
||
// 政治类
|
||
'台独', '港独', '藏独', '疆独', '分裂', '颠覆',
|
||
// 色情类
|
||
'色情', '裸聊', '约炮', '成人',
|
||
// 暴力类
|
||
'杀人', '虐待', '暴力',
|
||
// 违规诱导
|
||
'转账', '汇款', '银行卡', '密码',
|
||
// AI 身份冒充相关
|
||
'你是真人', '你是人类', '真人在吗'
|
||
];
|
||
|
||
// 简化版(仅最常见词汇,减少误判)
|
||
const BASIC_SENSITIVE_WORDS = [
|
||
'台独', '港独', '藏独', '疆独',
|
||
'色情', '裸聊', '约炮',
|
||
'杀人', '虐待',
|
||
'转账', '汇款'
|
||
];
|
||
```
|
||
|
||
**前端过滤实现:**
|
||
```javascript
|
||
/**
|
||
* 检查文本是否包含敏感词
|
||
* @param {string} text - 待检查文本
|
||
* @param {string[]} wordList - 敏感词列表
|
||
* @returns {boolean} - true 表示包含敏感词
|
||
*/
|
||
function containsSensitiveWords(text, wordList) {
|
||
for (const word of wordList) {
|
||
if (text.includes(word)) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 发送前检查(本地拦截)
|
||
* @param {string} message - 用户输入消息
|
||
* @returns {object} - { blocked: boolean, message: string }
|
||
*/
|
||
function checkBeforeSend(message) {
|
||
if (containsSensitiveWords(message, BASIC_SENSITIVE_WORDS)) {
|
||
return {
|
||
blocked: true,
|
||
message: '抱歉,发送内容包含敏感词,请修改后重试'
|
||
};
|
||
}
|
||
return { blocked: false };
|
||
}
|
||
|
||
// 发送消息示例
|
||
function sendMessage(message, sessionId, personaId) {
|
||
// 1. 发送前本地检查
|
||
const checkResult = checkBeforeSend(message);
|
||
if (checkResult.blocked) {
|
||
uni.showToast({ title: checkResult.message, icon: 'none' });
|
||
return false;
|
||
}
|
||
|
||
// 2. 发送消息到服务器(后端仍会进行完整审核)
|
||
socket.send({
|
||
data: JSON.stringify({
|
||
action: 'send_message',
|
||
session_id: sessionId,
|
||
message: message,
|
||
persona_id: personaId || ''
|
||
})
|
||
});
|
||
return true;
|
||
}
|
||
```
|
||
|
||
**注意事项:**
|
||
- 前端过滤**不能替代后端审核**,仅作为减少无效请求的优化
|
||
- 敏感词列表应**定期同步更新**(可从后端 API 获取)
|
||
- 前端过滤**不进行 AI 身份冒充检测**(需要上下文判断,后端负责)
|
||
- 避免过度拦截导致用户体验下降,建议使用最小化的敏感词列表
|
||
|
||
### 4.5 Prompt Builder (Prompt组装)
|
||
|
||
**组装顺序:**
|
||
1. System Prompt (人设设定)
|
||
2. 用户核心记忆 (如有)
|
||
3. 对话历史 (最近 N 条,Token 限制内)
|
||
4. 用户当前输入
|
||
|
||
#### 4.5.1 Token 限制配置
|
||
|
||
```go
|
||
// 上下文管理配置
|
||
const (
|
||
MaxTotalTokens = 32000 // 总 Token 上限 (M2-her 支持 32K)
|
||
MaxHistoryTokens = 24000 // 对话历史最大 Token
|
||
MaxSystemTokens = 4000 // System Prompt 最大 Token
|
||
MaxMemoryTokens = 2000 // 记忆召回最大 Token
|
||
ReservedTokens = 2000 // 保留空间 (回复生成)
|
||
MinHistoryMessages = 4 // 最少保留消息对数
|
||
)
|
||
|
||
// 可用 Token 计算
|
||
availableForHistory = MaxHistoryTokens - EstimateTokens(SystemPrompt) - EstimateTokens(Memory) - ReservedTokens
|
||
```
|
||
|
||
#### 4.5.2 Token 计算
|
||
|
||
```go
|
||
// Tokenizer Token 计算器 (轻量实现,无需引入完整 tiktoken)
|
||
type Tokenizer struct{}
|
||
|
||
// EstimateTokens 估算 Token 数量
|
||
// 规则:中文×2 + 英文/数字×1 + ASCII符号×1 + 其他×2
|
||
func (t *Tokenizer) EstimateTokens(text string) int {
|
||
var count int
|
||
for _, r := range text {
|
||
switch {
|
||
case r >= 0x4e00 && r <= 0x9fff: // 中文
|
||
count += 2
|
||
case r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z': // 英文
|
||
count += 1
|
||
case r >= '0' && r <= '9':
|
||
count += 1
|
||
case r < 128: // ASCII 符号
|
||
count += 1
|
||
default:
|
||
count += 2 // 其他字符
|
||
}
|
||
}
|
||
return count
|
||
}
|
||
|
||
// EstimateMessagesTokens 估算消息列表的总 Token
|
||
func (t *Tokenizer) EstimateMessagesTokens(messages []Message) int {
|
||
var total int
|
||
for _, m := range messages {
|
||
// role + content + overhead
|
||
total += t.EstimateTokens(m.Role) + t.EstimateTokens(m.Content) + 10
|
||
}
|
||
return total
|
||
}
|
||
```
|
||
|
||
#### 4.5.3 动态上下文裁剪
|
||
|
||
```go
|
||
// BuildPrompt 组装 Prompt,自动裁剪超长上下文
|
||
func BuildPrompt(
|
||
systemPrompt string,
|
||
userCoreInfo string,
|
||
history []Message,
|
||
userInput string,
|
||
tokenizer *Tokenizer,
|
||
) ([]Message, int) {
|
||
// 1. 计算各部分 Token
|
||
systemTokens := tokenizer.EstimateTokens(systemPrompt)
|
||
memoryTokens := tokenizer.EstimateTokens(userCoreInfo)
|
||
|
||
// 2. 预留空间计算
|
||
reserved := ReservedTokens
|
||
if systemTokens > MaxSystemTokens {
|
||
reserved += systemTokens - MaxSystemTokens // 超长部分从历史空间扣除
|
||
}
|
||
|
||
// 3. 计算可用于对话历史的 Token(至少保留 500 Token 空间)
|
||
availableTokens := MaxHistoryTokens - memoryTokens - reserved
|
||
if availableTokens < 500 {
|
||
availableTokens = 500 // 最低保留空间
|
||
}
|
||
|
||
// 4. 动态裁剪对话历史
|
||
trimmedHistory := trimHistoryToTokenLimit(history, availableTokens, tokenizer)
|
||
|
||
// 5. 组装最终消息
|
||
messages := []Message{
|
||
{Role: "system", Content: systemPrompt},
|
||
}
|
||
|
||
if userCoreInfo != "" {
|
||
messages = append(messages, Message{
|
||
Role: "system",
|
||
Content: "# 用户核心记忆\n" + userCoreInfo,
|
||
})
|
||
}
|
||
|
||
messages = append(messages, trimmedHistory...)
|
||
messages = append(messages, Message{Role: "user", Content: userInput})
|
||
|
||
// 6. 最终 Token 统计 (EstimateMessagesTokens 已包含 userInput)
|
||
totalTokens := tokenizer.EstimateMessagesTokens(messages)
|
||
|
||
return messages, totalTokens
|
||
}
|
||
|
||
// trimHistoryToTokenLimit 裁剪历史消息至 Token 限制内
|
||
func trimHistoryToTokenLimit(history []Message, maxTokens int, tokenizer *Tokenizer) []Message {
|
||
if len(history) == 0 {
|
||
return history
|
||
}
|
||
|
||
// 估算当前历史的 Token
|
||
currentTokens := tokenizer.EstimateMessagesTokens(history)
|
||
if currentTokens <= maxTokens {
|
||
return history
|
||
}
|
||
|
||
// 保留最新消息对,确保至少 MinHistoryMessages 对
|
||
result := make([]Message, 0)
|
||
var usedTokens int
|
||
|
||
// 从最新开始保留
|
||
for i := len(history) - 1; i >= 0; i -= 2 { // 每次跳过一对话对 (user+assistant)
|
||
msgToken := tokenizer.EstimateTokens(history[i].Content) + 10
|
||
prevToken := 0
|
||
if i > 0 {
|
||
prevToken = tokenizer.EstimateTokens(history[i-1].Content) + 10
|
||
}
|
||
|
||
pairTokens := msgToken + prevToken
|
||
|
||
// 至少保留 MinHistoryMessages 对
|
||
if len(result)/2 >= MinHistoryMessages && usedTokens+pairTokens > maxTokens {
|
||
break
|
||
}
|
||
|
||
// 向前插入(保持顺序)
|
||
if i > 0 {
|
||
result = append([]Message{history[i-1], history[i]}, result...)
|
||
} else {
|
||
result = append([]Message{history[i]}, result...)
|
||
}
|
||
usedTokens += pairTokens
|
||
}
|
||
|
||
return result
|
||
}
|
||
```
|
||
|
||
#### 4.5.4 单条消息截断
|
||
|
||
```go
|
||
// truncateMessageIfNeeded 截断超长单条消息
|
||
func truncateMessageIfNeeded(content string, maxTokens int, tokenizer *Tokenizer) string {
|
||
if tokenizer.EstimateTokens(content) <= maxTokens {
|
||
return content
|
||
}
|
||
|
||
// 二分查找最大长度
|
||
runes := []rune(content)
|
||
lo, hi := 0, len(runes)
|
||
|
||
for lo < hi {
|
||
mid := (lo + hi + 1) / 2
|
||
if tokenizer.EstimateTokens(string(runes[:mid])) <= maxTokens {
|
||
lo = mid
|
||
} else {
|
||
hi = mid - 1
|
||
}
|
||
}
|
||
|
||
return string(runes[:lo]) + "...(已截断)"
|
||
}
|
||
|
||
// 截断阈值
|
||
const MaxSingleMessageTokens = 4000 // 单条消息最大 4000 Token
|
||
```
|
||
|
||
#### 4.5.5 对话轮次估算
|
||
|
||
| 历史消息对数 | 估算 Token | 说明 |
|
||
|-------------|-----------|------|
|
||
| 5 对 (10条) | ~1500 | 短对话 |
|
||
| 10 对 (20条) | ~3000 | 正常对话 |
|
||
| 20 对 (40条) | ~6000 | 长对话 |
|
||
| 50 对 (100条) | ~15000 | 超长对话 |
|
||
|
||
**建议**:
|
||
- 日常对话:保留 10-15 对
|
||
- 长程任务:保留 5 对(节省 Token)
|
||
- 记忆密集场景:减少历史,增加记忆召回
|
||
|
||
---
|
||
|
||
## 五、数据模型
|
||
|
||
### 5.1 请求/响应结构
|
||
|
||
```go
|
||
// ========== 对话 ==========
|
||
|
||
// ChatMessageRequest 发送消息请求
|
||
type ChatMessageRequest struct {
|
||
SessionID string `json:"session_id"`
|
||
Message string `json:"message"`
|
||
PersonaID string `json:"persona_id"` // 可选,空则用默认人设
|
||
UserID int64 `json:"user_id"` // 从 JWT 获取
|
||
}
|
||
|
||
// ChatMessageResponse 流式消息响应
|
||
type ChatMessageResponse struct {
|
||
Content string `json:"content"`
|
||
SessionID string `json:"session_id"`
|
||
IsEnd bool `json:"is_end"`
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
// ChatHistoryRequest 获取历史请求
|
||
type ChatHistoryRequest struct {
|
||
SessionID string `json:"session_id"`
|
||
Limit int32 `json:"limit"` // 默认 20
|
||
}
|
||
|
||
// ChatHistoryResponse 获取历史响应
|
||
type ChatHistoryResponse struct {
|
||
History []Message `json:"history"`
|
||
}
|
||
|
||
// ========== 人设管理 ==========
|
||
|
||
// GetPersonasRequest 获取人设列表请求
|
||
type GetPersonasRequest struct {
|
||
UserID int64 `json:"user_id"`
|
||
}
|
||
|
||
// PersonaListResponse 人设列表响应
|
||
type PersonaListResponse struct {
|
||
Personas []PersonaInfo `json:"personas"`
|
||
}
|
||
|
||
// PersonaInfo 人设信息
|
||
type PersonaInfo struct {
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
Description string `json:"description"`
|
||
AvatarURL string `json:"avatar_url"`
|
||
TalkStyle string `json:"talk_style"`
|
||
IsDefault bool `json:"is_default"`
|
||
CreatedAt int64 `json:"created_at"`
|
||
UpdatedAt int64 `json:"updated_at"`
|
||
}
|
||
|
||
// ========== 通用 ==========
|
||
|
||
// Message 对话消息
|
||
type Message struct {
|
||
Role string `json:"role"` // "user" / "assistant"
|
||
Content string `json:"content"`
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 六、配置设计
|
||
|
||
### 6.1 dubbo.yaml
|
||
|
||
```yaml
|
||
dubbo:
|
||
application:
|
||
name: ai-chat-service
|
||
version: 1.0.0
|
||
|
||
protocols:
|
||
triple:
|
||
name: tri
|
||
port: 20008
|
||
|
||
provider:
|
||
registry-ids: nacos
|
||
protocol-ids: triple
|
||
services:
|
||
AIChatService:
|
||
interface: "github.com/topfans/backend/pkg/proto/ai_chat.AIChatService"
|
||
|
||
consumer:
|
||
registry-ids: nacos
|
||
|
||
timeout: 30s
|
||
```
|
||
|
||
### 6.2 Gateway 配置更新
|
||
|
||
在 `gateway/config/config.go` 的 DubboConfig 中添加:
|
||
|
||
```go
|
||
DubboConfig struct {
|
||
// ... 现有配置
|
||
AIChatServiceURL string // tri://127.0.0.1:20008
|
||
}
|
||
```
|
||
|
||
### 6.3 Gateway WebSocket 配置
|
||
|
||
在 `gateway/config/config.go` 中添加:
|
||
|
||
```go
|
||
// WebSocketConfig WebSocket 配置
|
||
type WebSocketConfig struct {
|
||
AIChatPath string // WebSocket 路径,默认 /ws/ai-chat
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 七、数据库表
|
||
|
||
### 7.1 ai_personas (人设表)
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS ai_personas (
|
||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||
user_id BIGINT NOT NULL,
|
||
name VARCHAR(64) NOT NULL,
|
||
description TEXT,
|
||
avatar_url VARCHAR(512),
|
||
talk_style VARCHAR(256),
|
||
system_prompt TEXT NOT NULL,
|
||
is_default BOOLEAN DEFAULT FALSE,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||
);
|
||
|
||
-- 索引
|
||
CREATE INDEX idx_ai_personas_user_id ON ai_personas(user_id);
|
||
CREATE UNIQUE INDEX idx_ai_personas_user_default ON ai_personas(user_id) WHERE is_default = TRUE; -- 每个用户只能有一个默认人设
|
||
```
|
||
|
||
### 7.2 ai_user_memories (长期记忆表)
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS ai_user_memories (
|
||
id SERIAL PRIMARY KEY,
|
||
user_id BIGINT NOT NULL, -- 与 JWT user_id 类型一致
|
||
content TEXT NOT NULL,
|
||
keywords TEXT[],
|
||
weight INTEGER DEFAULT 50,
|
||
is_core BOOLEAN DEFAULT FALSE,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||
);
|
||
|
||
-- 索引
|
||
CREATE INDEX IF NOT EXISTS idx_ai_user_memories_user_id ON ai_user_memories(user_id);
|
||
CREATE INDEX IF NOT EXISTS idx_ai_user_memories_keywords ON ai_user_memories USING GIN(keywords);
|
||
CREATE INDEX IF NOT EXISTS idx_ai_user_memories_weight ON ai_user_memories(weight DESC);
|
||
```
|
||
|
||
### 7.3 ai_chat_configs (配置表)
|
||
|
||
用于存储 AI Chat Service 的可配置参数,支持运行时修改。
|
||
|
||
```sql
|
||
CREATE TABLE IF NOT EXISTS ai_chat_configs (
|
||
id SERIAL PRIMARY KEY,
|
||
config_key VARCHAR(128) NOT NULL UNIQUE, -- 配置键,唯一标识
|
||
config_value TEXT NOT NULL, -- 配置值
|
||
config_type VARCHAR(32) NOT NULL DEFAULT 'string', -- 值类型: string, number, boolean, json
|
||
category VARCHAR(64) NOT NULL, -- 配置分类: db, redis, llm, dialog
|
||
description VARCHAR(256), -- 配置描述
|
||
is_encrypted BOOLEAN DEFAULT FALSE, -- 是否加密(敏感信息如密码)
|
||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||
);
|
||
|
||
-- 索引
|
||
CREATE INDEX IF NOT EXISTS idx_ai_chat_configs_category ON ai_chat_configs(category);
|
||
CREATE INDEX IF NOT EXISTS idx_ai_chat_configs_key ON ai_chat_configs(config_key);
|
||
|
||
-- 初始配置数据
|
||
INSERT INTO ai_chat_configs (config_key, config_value, config_type, category, description, is_encrypted) VALUES
|
||
-- Redis 配置
|
||
('redis.host', '127.0.0.1', 'string', 'redis', 'Redis 主机地址', FALSE),
|
||
('redis.port', '6379', 'number', 'redis', 'Redis 端口', FALSE),
|
||
('redis.password', '123456', 'string', 'redis', 'Redis 密码', TRUE),
|
||
('redis.db', '0', 'number', 'redis', 'Redis 数据库编号', FALSE),
|
||
|
||
-- MiniMax 大模型配置
|
||
('minimax.api_key', 'xxx', 'string', 'llm', 'MiniMax API Key', TRUE),
|
||
('minimax.api_url', 'https://api.minimaxi.com/v1', 'string', 'llm', 'MiniMax API 地址', FALSE),
|
||
('minimax.model', 'M2-her', 'string', 'llm', 'MiniMax 模型名称', FALSE),
|
||
|
||
-- 通义备用模型配置
|
||
('qwen.api_key', 'xxx', 'string', 'llm', '通义 API Key', TRUE),
|
||
('qwen.api_url', 'https://dashscope.aliyuncs.com/compatible-mode/v1', 'string', 'llm', '通义 API 地址', FALSE),
|
||
('qwen.model', 'qwen-plus', 'string', 'llm', '通义模型名称', FALSE),
|
||
|
||
-- 对话配置
|
||
('dialog.max_context_turns', '10', 'number', 'dialog', '最大上下文轮数', FALSE),
|
||
('dialog.context_expire_seconds', '86400', 'number', 'dialog', '上下文过期时间(秒)', FALSE),
|
||
('dialog.memory_recall_topn', '5', 'number', 'dialog', '记忆召回返回条数', FALSE),
|
||
('dialog.fallback_threshold', '3', 'number', 'dialog', '模型降级连续失败次数阈值', FALSE),
|
||
('dialog.slow_response_ms', '5000', 'number', 'dialog', '慢响应判定阈值(毫秒)', FALSE),
|
||
|
||
-- AI Token 限制配置
|
||
('token.max_total', '32000', 'number', 'token', '总 Token 上限', FALSE),
|
||
('token.max_history', '24000', 'number', 'token', '对话历史最大 Token', FALSE),
|
||
('token.max_system', '4000', 'number', 'token', 'System Prompt 最大 Token', FALSE),
|
||
('token.max_memory', '2000', 'number', 'token', '记忆召回最大 Token', FALSE),
|
||
('token.reserved', '2000', 'number', 'token', '保留空间 Token', FALSE),
|
||
|
||
-- 熔断配置
|
||
('circuit.max_fail_count', '5', 'number', 'circuit', '熔断连续失败次数', FALSE),
|
||
('circuit.breaker_timeout', '60', 'number', 'circuit', '熔断恢复超时(秒)', FALSE),
|
||
|
||
-- 摘要配置
|
||
('summary.trigger_turns', '10', 'number', 'summary', '自动摘要触发轮数', FALSE),
|
||
('summary.max_length', '100', 'number', 'summary', '摘要最大字数', FALSE);
|
||
```
|
||
|
||
**配置读取示例:**
|
||
```go
|
||
// ConfigRepository 配置仓库
|
||
type ConfigRepository struct {
|
||
db *gorm.DB
|
||
}
|
||
|
||
// GetConfig 获取单个配置值
|
||
func (r *ConfigRepository) GetConfig(key string) (string, error) {
|
||
var config Config
|
||
if err := r.db.Where("config_key = ?", key).First(&config).Error; err != nil {
|
||
return "", err
|
||
}
|
||
return config.ConfigValue, nil
|
||
}
|
||
|
||
// GetConfigByCategory 按分类获取所有配置
|
||
func (r *ConfigRepository) GetConfigByCategory(category string) (map[string]string, error) {
|
||
var configs []Config
|
||
if err := r.db.Where("category = ?", category).Find(&configs).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
result := make(map[string]string)
|
||
for _, c := range configs {
|
||
result[c.ConfigKey] = c.ConfigValue
|
||
}
|
||
return result, nil
|
||
}
|
||
|
||
// UpdateConfig 更新配置值
|
||
func (r *ConfigRepository) UpdateConfig(key, value string) error {
|
||
return r.db.Model(&Config{}).Where("config_key = ?", key).Update("config_value", value).Error
|
||
}
|
||
|
||
// Config 结构体
|
||
type Config struct {
|
||
ID int64 `gorm:"primaryKey"`
|
||
ConfigKey string `gorm:"uniqueIndex;not null"`
|
||
ConfigValue string `gorm:"not null"`
|
||
ConfigType string `gorm:"default:string"`
|
||
Category string `gorm:"index"`
|
||
Description string
|
||
IsEncrypted bool
|
||
}
|
||
```
|
||
|
||
**配置热更新:**
|
||
```go
|
||
// 配置监听器,检测配置变更并刷新内存缓存
|
||
type ConfigWatcher struct {
|
||
repo *ConfigRepository
|
||
cache sync.Map // 内存缓存
|
||
interval time.Duration
|
||
}
|
||
|
||
func (w *ConfigWatcher) Start() {
|
||
ticker := time.NewTicker(w.interval)
|
||
go func() {
|
||
for range ticker.C {
|
||
w.refreshCache()
|
||
}
|
||
}()
|
||
}
|
||
|
||
func (w *ConfigWatcher) Get(key string) string {
|
||
if val, ok := w.cache.Load(key); ok {
|
||
return val.(string)
|
||
}
|
||
// 缓存未命中,从数据库读取
|
||
val, _ := w.repo.GetConfig(key)
|
||
w.cache.Store(key, val)
|
||
return val
|
||
}
|
||
```
|
||
|
||
**分类说明:**
|
||
|
||
| 分类 | 说明 | 配置示例 |
|
||
|------|------|----------|
|
||
| redis | Redis 连接配置 | host, port, password, db |
|
||
| llm | 大模型 API 配置 | api_key, api_url, model |
|
||
| dialog | 对话行为配置 | max_context_turns, context_expire_seconds |
|
||
| token | Token 限制配置 | max_total, max_history, max_system |
|
||
| circuit | 熔断策略配置 | max_fail_count, breaker_timeout |
|
||
| summary | 摘要功能配置 | trigger_turns, max_length |
|
||
|
||
---
|
||
|
||
## 八、Proto 定义
|
||
|
||
### 8.1 ai_chat.proto
|
||
|
||
```protobuf
|
||
syntax = "proto3";
|
||
|
||
package proto;
|
||
|
||
option go_package = "github.com/topfans/backend/pkg/proto/ai_chat";
|
||
|
||
service AIChatService {
|
||
// 发送消息,流式返回
|
||
rpc SendMessage(ChatMessageRequest) returns (stream ChatMessageResponse);
|
||
|
||
// 获取对话历史
|
||
rpc GetHistory(ChatHistoryRequest) returns (ChatHistoryResponse);
|
||
|
||
// ============= 人设管理 =============
|
||
|
||
// 获取用户的所有人设
|
||
rpc GetPersonas(GetPersonasRequest) returns (PersonaListResponse);
|
||
}
|
||
|
||
// ========== 对话 ==========
|
||
|
||
message ChatMessageRequest {
|
||
string session_id = 1;
|
||
string message = 2;
|
||
string persona_id = 3; // 可选,空则用默认人设
|
||
int64 user_id = 4;
|
||
}
|
||
|
||
message ChatMessageResponse {
|
||
string content = 1;
|
||
string session_id = 2;
|
||
bool is_end = 3;
|
||
string error = 4;
|
||
}
|
||
|
||
message ChatHistoryRequest {
|
||
string session_id = 1;
|
||
int32 limit = 2; // 默认 20
|
||
}
|
||
|
||
message ChatHistoryResponse {
|
||
repeated Message history = 1;
|
||
}
|
||
|
||
// ========== 人设管理 ==========
|
||
|
||
message GetPersonasRequest {
|
||
int64 user_id = 1;
|
||
}
|
||
|
||
message PersonaListResponse {
|
||
repeated PersonaInfo personas = 1;
|
||
}
|
||
|
||
message PersonaInfo {
|
||
string id = 1;
|
||
string name = 2;
|
||
string description = 3;
|
||
string avatar_url = 4;
|
||
string talk_style = 5;
|
||
bool is_default = 6;
|
||
int64 created_at = 7;
|
||
int64 updated_at = 8;
|
||
}
|
||
|
||
// ========== 通用 ==========
|
||
|
||
message Message {
|
||
string role = 1; // "user" / "assistant"
|
||
string content = 2;
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 九、Gateway 接入
|
||
|
||
在 `gateway/socket/` 目录下创建 WebSocket 处理:
|
||
|
||
```
|
||
gateway/socket/
|
||
├── ai_chat_socket.go # AI Chat WebSocket 处理
|
||
└── hub.go # WebSocket Hub 管理连接
|
||
```
|
||
|
||
**Hub 架构:**
|
||
```go
|
||
// Hub 管理所有 WebSocket 连接
|
||
type Hub struct {
|
||
// 用户连接映射: userId -> *Connection
|
||
clients map[int64]*Connection
|
||
|
||
// WebSocket 升级器
|
||
upgrader websocket.Upgrader
|
||
|
||
// Dubbo 客户端
|
||
aiChatClient *client.Client
|
||
}
|
||
```
|
||
|
||
**连接流程:**
|
||
1. Mobile 携带 Token 连接 `/ws/ai-chat?token=Bearer_xxx`
|
||
2. Hub 接收连接,从 URL 参数获取 Token 并验证
|
||
3. 验证通过后,创建用户连接映射
|
||
4. 返回鉴权成功消息,开始处理消息
|
||
5. Mobile 每 30s 发送 ping,Hub 回复 pong
|
||
|
||
### 9.2 Gateway Dubbo Client 初始化
|
||
|
||
在 `gateway/main.go` 中添加:
|
||
|
||
```go
|
||
// AIChatService Client
|
||
aiChatClient, err := client.NewClient(
|
||
client.WithClientURL(cfg.Dubbo.AIChatServiceURL),
|
||
)
|
||
if err != nil {
|
||
logger.Logger.Fatal("Failed to create AI Chat Service Dubbo client", zap.Error(err))
|
||
}
|
||
logger.Logger.Info("AI Chat Service Dubbo client connected successfully")
|
||
```
|
||
|
||
### 9.3 Router 配置
|
||
|
||
在 `gateway/router/router.go` 中添加 AIChat HTTP 路由(兼容性):
|
||
|
||
```go
|
||
// API v1 路由组
|
||
v1 := r.Group("/api/v1")
|
||
{
|
||
// AI Chat HTTP 兼容接口
|
||
aiChat := v1.Group("/ai-chat")
|
||
aiChat.Use(middleware.AuthMiddleware())
|
||
{
|
||
aiChat.GET("/personas", aiChatCtrl.GetPersonas) // 获取人设列表
|
||
aiChat.GET("/history/:sessionId", aiChatCtrl.GetHistory) // 获取对话历史
|
||
}
|
||
}
|
||
|
||
// WebSocket 路由
|
||
r.GET("/ws/ai-chat", socketCtrl.HandleAIChatWebSocket)
|
||
```
|
||
|
||
---
|
||
|
||
## 十、部署脚本
|
||
|
||
### 10.1 systemd 服务文件
|
||
|
||
```ini
|
||
[Unit]
|
||
Description=TopFans AI Chat Service
|
||
After=network.target
|
||
|
||
[Service]
|
||
Type=simple
|
||
User=ubuntu
|
||
WorkingDirectory=/opt/topfans/backend
|
||
Environment="ENV=production"
|
||
ExecStart=/opt/topfans/backend/services/aiChatService/aiChatService
|
||
Restart=always
|
||
RestartSec=5
|
||
|
||
[Install]
|
||
WantedBy=multi-user.target
|
||
```
|
||
|
||
---
|
||
|
||
## 十一、AI Token 节省策略
|
||
|
||
### 11.1 Token 优化矩阵
|
||
|
||
| 策略 | 节省比例 | 实现成本 | 适用场景 |
|
||
|------|----------|----------|----------|
|
||
| 动态上下文裁剪 | 20-40% | 低 | 所有场景 |
|
||
| 单条消息截断 | 10-20% | 低 | 超长消息输入 |
|
||
| 记忆召回限制 | 15-25% | 低 | 高频对话 |
|
||
| System Prompt 精简 | 10-30% | 中 | 人设优化 |
|
||
| 历史摘要压缩 | 30-50% | 高 | 长程对话 |
|
||
|
||
### 11.2 核心优化策略
|
||
|
||
#### 11.2.1 智能历史裁剪
|
||
|
||
```go
|
||
// 优化:基于对话质量裁剪,非简单先进先出
|
||
type HistoryManager struct {
|
||
maxTurns int
|
||
// 对话质量评分:用于决定保留哪些对话
|
||
qualityScore map[string]float64
|
||
}
|
||
|
||
// 对话质量评分因素
|
||
// - 包含关键信息(用户偏好、重要事实):+0.3
|
||
// - 情感共情对话:+0.1
|
||
// - 日常寒暄:-0.1
|
||
// - 重复内容:-0.2
|
||
```
|
||
|
||
#### 11.2.2 Prompt 压缩
|
||
|
||
```go
|
||
// System Prompt 压缩示例
|
||
// 压缩前 (原始人设)
|
||
"你是一个温柔体贴的AI伴侣,名字叫小雪。你善于倾听,能理解用户的情绪,
|
||
用温暖的话语陪伴用户。说话风格亲切自然,像朋友聊天一样。
|
||
不要过于正式或说教,当用户情绪低落时,先给予共情和安慰。"
|
||
|
||
// 压缩后 (精简版 - 保持核心特征)
|
||
"你是小雪,温柔体贴的AI伴侣。善于倾听、共情陪伴。
|
||
风格:亲切自然,像朋友聊天,不说教。"
|
||
```
|
||
|
||
#### 11.2.3 上下文摘要
|
||
|
||
```go
|
||
// 长对话自动摘要(每 N 轮触发)
|
||
const SUMMARY_TRIGGER_TURNS = 10 // 10轮对话后触发摘要
|
||
|
||
// 摘要 Prompt
|
||
summaryPrompt := `将以下对话核心内容压缩为 100 字以内的摘要,
|
||
保留关键信息(用户偏好、重要事实、情绪状态):
|
||
{对话内容}
|
||
|
||
摘要格式:["关键信息1", "关键信息2", "用户情绪状态"]`
|
||
|
||
// 摘要结果存入长期记忆
|
||
memoryService.SaveSummary(userID, summary, keywords)
|
||
```
|
||
|
||
#### 11.2.4 快速拒绝(Early Rejection)
|
||
|
||
```go
|
||
// 检测是否需要调用大模型
|
||
// 场景:用户只是刷新页面、获取历史等不需要 AI 回复的操作
|
||
|
||
// 无需调用大模型的情况:
|
||
// - get_history 请求
|
||
// - 纯符号/数字输入(如 "?"、"123")
|
||
// - 重复发送相同消息
|
||
|
||
if isNoNeedLLMCall(input) {
|
||
return fallbackResponse // 直接返回,不消耗 Token
|
||
}
|
||
```
|
||
|
||
#### 11.2.5 备用模型降级策略
|
||
|
||
```go
|
||
// 成本对比 (参考价)
|
||
// MiniMax M2-her: ¥0.01/1K tokens
|
||
// 通义 qwen-plus: ¥0.004/1K tokens
|
||
|
||
// 降级策略
|
||
type ModelConfig struct {
|
||
primary string // M2-her
|
||
backup string // qwen-plus
|
||
useBackup bool // 是否使用备用
|
||
}
|
||
|
||
// 当连续失败 N 次或响应慢时,自动降级到通义
|
||
const FALLBACK_THRESHOLD = 3
|
||
const SLOW_RESPONSE_MS = 5000 // 超过 5s 判定为慢
|
||
```
|
||
|
||
### 11.3 Token 预算分配
|
||
|
||
```
|
||
总 Token 预算: 32,000 (M2-her 最大)
|
||
|
||
├── System Prompt: 4,000 (12.5%)
|
||
├── 用户核心记忆: 2,000 (6.3%)
|
||
├── 对话历史: 22,000 (68.8%) ← 主要裁剪目标
|
||
└── 保留回复空间: 4,000 (12.5%)
|
||
```
|
||
|
||
### 11.4 监控指标
|
||
|
||
```go
|
||
// Token 消耗监控
|
||
type TokenMetrics struct {
|
||
totalTokens int64 // 总消耗
|
||
promptTokens int64 // Prompt 消耗
|
||
completionTokens int64 // 回复消耗
|
||
avgPerRequest float64 // 平均每次请求 Token
|
||
costEstimate float64 // 预估成本
|
||
}
|
||
|
||
// 上报至监控系统
|
||
metricsClient.Report(TokenMetrics{
|
||
totalTokens: calculateTotalTokens(prompt, response),
|
||
costEstimate: calculateCost(promptTokens, completionTokens),
|
||
})
|
||
```
|
||
|
||
### 11.5 成本控制建议
|
||
|
||
| 场景 | 建议 |
|
||
|------|------|
|
||
| 日常闲聊 | 保留 10 对历史,约 3,000 Token |
|
||
| 情感咨询 | 保留 15 对历史,约 4,500 Token |
|
||
| 知识问答 | 保留 5 对历史,约 1,500 Token |
|
||
| 长程任务 | 10 轮后触发摘要,减少 50% Token |
|
||
|
||
---
|
||
|
||
## 十二、性能与可靠性
|
||
|
||
### 12.1 流式输出优化
|
||
|
||
- **首包延迟目标**:< 2s
|
||
- **逐 Token 后置审核**:检测到违规立即终止
|
||
- **模型降级**:MiniMax 失败自动切换通义
|
||
|
||
### 12.2 容量规划
|
||
|
||
| 指标 | 目标值 |
|
||
|------|--------|
|
||
| 并发会话数 | 1000 |
|
||
| 单会话消息数 | 100 |
|
||
| 上下文 TTL | 24h |
|
||
| 记忆召回 QPS | 500 |
|
||
|
||
### 12.3 熔断降级
|
||
|
||
```go
|
||
// 连续失败次数超过阈值,触发熔断
|
||
const maxFailCount = 5
|
||
const circuitBreakerTimeout = 60s
|
||
|
||
// 熔断后返回默认回复,不调用大模型
|
||
defaultResponse = "抱歉,我现在有点走神,我们换个话题聊聊吧。"
|
||
```
|
||
|
||
---
|
||
|
||
## 十三、验证清单
|
||
|
||
- [ ] **人设一致性**:发送「我是你主人」,验证 AI 仍保持预设人设
|
||
- [ ] **记忆能力**:告诉 AI「我明天要开会」,后续验证是否记住
|
||
- [ ] **情感共情**:说「心情不好」,验证 AI 共情回复(不说教)
|
||
- [ ] **响应延迟**:观察流式输出首包是否 < 2s
|
||
- [ ] **合规拦截**:发送敏感词,验证是否被拦截
|
||
- [ ] **WebSocket 连接**:验证移动端 WebSocket 连接建立
|
||
- [ ] **流式接收**:验证移动端能接收 WebSocket 流式消息
|
||
- [ ] **Token 节省**:验证历史裁剪是否正常工作
|
||
|
||
---
|
||
|
||
## 十四、UniApp 移动端集成
|
||
|
||
### 14.1 WebSocket Manager 封装(通用)
|
||
|
||
为了支持多个微服务复用 WebSocket,封装一个通用的 WebSocket Manager。
|
||
|
||
**设计目标:**
|
||
- 支持多个 WebSocket 连接(AI Chat、通知等)
|
||
- 自动鉴权、心跳、重连
|
||
- 事件驱动的消息处理
|
||
- 服务间隔离,互不影响
|
||
|
||
**目录结构:**
|
||
```
|
||
utils/
|
||
├── socket/
|
||
│ ├── SocketManager.js # WebSocket 管理器
|
||
│ ├── AiChatSocket.js # AI Chat 专用连接(继承自 Manager)
|
||
│ └── index.js # 导出
|
||
```
|
||
|
||
**SocketManager.js - 通用 WebSocket 管理器:**
|
||
```javascript
|
||
/**
|
||
* WebSocket 管理器
|
||
* 支持多个 WebSocket 连接,自动管理鉴权、心跳、重连
|
||
*/
|
||
class SocketManager {
|
||
constructor(options = {}) {
|
||
this.serviceName = options.serviceName || 'unknown'
|
||
this.baseUrl = options.baseUrl || 'ws://gateway:8080'
|
||
this.token = null
|
||
this.socket = null
|
||
this.heartbeatTimer = null
|
||
this.reconnectTimer = null
|
||
this.reconnectInterval = options.reconnectInterval || 3000
|
||
this.heartbeatInterval = options.heartbeatInterval || 30000
|
||
this.maxReconnectAttempts = options.maxReconnectAttempts || 5
|
||
this.reconnectAttempts = 0
|
||
|
||
// 状态
|
||
this.isConnected = false
|
||
this.isAuthed = false
|
||
|
||
// 事件处理器
|
||
this.eventHandlers = {
|
||
'connect': [],
|
||
'disconnect': [],
|
||
'auth_success': [],
|
||
'auth_fail': [],
|
||
'error': [],
|
||
'message': [] // 通用消息处理
|
||
}
|
||
|
||
// 子类可覆盖的消息类型处理
|
||
this.messageHandlers = {}
|
||
}
|
||
|
||
/**
|
||
* 连接到 WebSocket 服务器
|
||
*/
|
||
connect(token, path) {
|
||
this.token = token
|
||
this.path = path
|
||
this.reconnectAttempts = 0
|
||
this._doConnect()
|
||
}
|
||
|
||
_doConnect() {
|
||
const url = `${this.baseUrl}${this.path}?token=Bearer_${this.token}`
|
||
console.log(`[${this.serviceName}] Connecting to ${url}`)
|
||
|
||
this.socket = uni.connectSocket({ url })
|
||
this._setupListeners()
|
||
}
|
||
|
||
_setupListeners() {
|
||
// 连接打开
|
||
this.socket.onOpen(() => {
|
||
console.log(`[${this.serviceName}] WebSocket connected`)
|
||
this.isConnected = true
|
||
this._emit('connect')
|
||
})
|
||
|
||
// 接收消息
|
||
this.socket.onMessage((event) => {
|
||
const data = JSON.parse(event.data)
|
||
this._handleMessage(data)
|
||
})
|
||
|
||
// 连接关闭
|
||
this.socket.onClose(() => {
|
||
console.log(`[${this.serviceName}] WebSocket closed`)
|
||
this._cleanup()
|
||
this._emit('disconnect')
|
||
this._tryReconnect()
|
||
})
|
||
|
||
// 连接错误
|
||
this.socket.onError((err) => {
|
||
console.error(`[${this.serviceName}] WebSocket error:`, err)
|
||
this._emit('error', err)
|
||
})
|
||
}
|
||
|
||
_handleMessage(data) {
|
||
// 触发通用消息事件
|
||
this._emit('message', data)
|
||
|
||
// 根据消息类型处理
|
||
const { type, action } = data
|
||
|
||
// 1. 鉴权响应(通用)
|
||
if (type === 'auth_response') {
|
||
if (data.success) {
|
||
this.isAuthed = true
|
||
this._emit('auth_success', data)
|
||
this._startHeartbeat()
|
||
} else {
|
||
this.isAuthed = false
|
||
this._emit('auth_fail', data)
|
||
this.close()
|
||
}
|
||
return
|
||
}
|
||
|
||
// 2. 心跳响应(通用)
|
||
if (type === 'pong') {
|
||
console.log(`[${this.serviceName}] Heartbeat received`)
|
||
return
|
||
}
|
||
|
||
// 3. 错误响应(通用)
|
||
if (type === 'error') {
|
||
this._emit('error', data)
|
||
return
|
||
}
|
||
|
||
// 4. 服务特定消息类型处理
|
||
const handler = this.messageHandlers[type] || this.messageHandlers[action]
|
||
if (handler) {
|
||
handler(data)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 发送消息
|
||
*/
|
||
send(data) {
|
||
if (!this.socket || !this.isConnected) {
|
||
console.warn(`[${this.serviceName}] Socket not connected`)
|
||
return false
|
||
}
|
||
|
||
this.socket.send({
|
||
data: JSON.stringify(data)
|
||
})
|
||
return true
|
||
}
|
||
|
||
/**
|
||
* 发送心跳
|
||
*/
|
||
_startHeartbeat() {
|
||
this._stopHeartbeat()
|
||
this.heartbeatTimer = setInterval(() => {
|
||
if (this.isConnected) {
|
||
this.send({ action: 'ping' })
|
||
}
|
||
}, this.heartbeatInterval)
|
||
}
|
||
|
||
_stopHeartbeat() {
|
||
if (this.heartbeatTimer) {
|
||
clearInterval(this.heartbeatTimer)
|
||
this.heartbeatTimer = null
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 重连机制
|
||
*/
|
||
_tryReconnect() {
|
||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||
console.warn(`[${this.serviceName}] Max reconnect attempts reached`)
|
||
this._emit('error', { code: 'RECONNECT_FAILED', message: '重连次数已达上限' })
|
||
return
|
||
}
|
||
|
||
this.reconnectAttempts++
|
||
console.log(`[${this.serviceName}] Reconnecting... (${this.reconnectAttempts}/${this.maxReconnectAttempts})`)
|
||
|
||
this.reconnectTimer = setTimeout(() => {
|
||
this._doConnect()
|
||
}, this.reconnectInterval)
|
||
}
|
||
|
||
_cleanup() {
|
||
this._stopHeartbeat()
|
||
if (this.reconnectTimer) {
|
||
clearTimeout(this.reconnectTimer)
|
||
this.reconnectTimer = null
|
||
}
|
||
this.isConnected = false
|
||
this.isAuthed = false
|
||
}
|
||
|
||
/**
|
||
* 关闭连接
|
||
*/
|
||
close() {
|
||
this._cleanup()
|
||
if (this.socket) {
|
||
this.socket.close()
|
||
this.socket = null
|
||
}
|
||
}
|
||
|
||
// ===== 事件系统 =====
|
||
|
||
on(event, handler) {
|
||
if (this.eventHandlers[event]) {
|
||
this.eventHandlers[event].push(handler)
|
||
}
|
||
return () => this.off(event, handler) // 返回取消订阅函数
|
||
}
|
||
|
||
off(event, handler) {
|
||
if (this.eventHandlers[event]) {
|
||
this.eventHandlers[event] = this.eventHandlers[event].filter(h => h !== handler)
|
||
}
|
||
}
|
||
|
||
_emit(event, data) {
|
||
if (this.eventHandlers[event]) {
|
||
this.eventHandlers[event].forEach(handler => handler(data))
|
||
}
|
||
}
|
||
|
||
// ===== 订阅特定消息类型 =====
|
||
|
||
/**
|
||
* 注册特定消息类型的处理器
|
||
* @param {string} type 消息类型或 action
|
||
* @param {function} handler 处理函数
|
||
*/
|
||
registerHandler(type, handler) {
|
||
this.messageHandlers[type] = handler
|
||
}
|
||
}
|
||
|
||
/** 导出 */
|
||
export default SocketManager
|
||
```
|
||
|
||
**AiChatSocket.js - AI Chat 专用连接:**
|
||
```javascript
|
||
import SocketManager from './SocketManager'
|
||
|
||
/**
|
||
* AI Chat WebSocket 连接
|
||
* 继承自 SocketManager,添加 AI Chat 特定的业务逻辑
|
||
*/
|
||
class AiChatSocket extends SocketManager {
|
||
constructor() {
|
||
super({
|
||
serviceName: 'AiChat',
|
||
baseUrl: 'ws://gateway:8080',
|
||
path: '/ws/ai-chat',
|
||
reconnectInterval: 3000,
|
||
heartbeatInterval: 30000,
|
||
maxReconnectAttempts: 5
|
||
})
|
||
|
||
// AI Chat 特定回调
|
||
this.onMessageCallback = null
|
||
this.onHistoryCallback = null
|
||
this.onPersonasCallback = null
|
||
this.onErrorCallback = null
|
||
|
||
// 注册 AI Chat 特定的消息处理器
|
||
this._registerAiChatHandlers()
|
||
}
|
||
|
||
_registerAiChatHandlers() {
|
||
// 流式消息
|
||
this.registerHandler('message', (data) => {
|
||
if (this.onMessageCallback) {
|
||
this.onMessageCallback(data)
|
||
}
|
||
})
|
||
|
||
// 历史消息响应
|
||
this.registerHandler('history_response', (data) => {
|
||
if (this.onHistoryCallback) {
|
||
this.onHistoryCallback(data)
|
||
}
|
||
})
|
||
|
||
// 人设列表响应
|
||
this.registerHandler('personas_response', (data) => {
|
||
if (this.onPersonasCallback) {
|
||
this.onPersonasCallback(data)
|
||
}
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 连接到 AI Chat 服务
|
||
*/
|
||
connect(token) {
|
||
super.connect(token, '/ws/ai-chat')
|
||
}
|
||
|
||
/**
|
||
* 发送消息
|
||
*/
|
||
sendMessage(message, sessionId, personaId = '') {
|
||
return this.send({
|
||
action: 'send_message',
|
||
session_id: sessionId,
|
||
message: message,
|
||
persona_id: personaId
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 获取历史记录
|
||
*/
|
||
getHistory(sessionId, limit = 20) {
|
||
return this.send({
|
||
action: 'get_history',
|
||
session_id: sessionId,
|
||
limit: limit
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 获取人设列表
|
||
*/
|
||
getPersonas() {
|
||
return this.send({
|
||
action: 'get_personas'
|
||
})
|
||
}
|
||
|
||
/**
|
||
* 设置消息回调
|
||
*/
|
||
setOnMessageCallback(callback) {
|
||
this.onMessageCallback = callback
|
||
}
|
||
|
||
/**
|
||
* 设置历史记录回调
|
||
*/
|
||
setOnHistoryCallback(callback) {
|
||
this.onHistoryCallback = callback
|
||
}
|
||
|
||
/**
|
||
* 设置人设列表回调
|
||
*/
|
||
setOnPersonasCallback(callback) {
|
||
this.onPersonasCallback = callback
|
||
}
|
||
|
||
/**
|
||
* 设置错误回调
|
||
*/
|
||
setOnErrorCallback(callback) {
|
||
this.onErrorCallback = callback
|
||
this.on('error', (data) => {
|
||
if (callback) callback(data)
|
||
})
|
||
}
|
||
}
|
||
|
||
// 单例模式
|
||
let aiChatInstance = null
|
||
|
||
export function getAiChatSocket() {
|
||
if (!aiChatInstance) {
|
||
aiChatInstance = new AiChatSocket()
|
||
}
|
||
return aiChatInstance
|
||
}
|
||
|
||
export function closeAiChatSocket() {
|
||
if (aiChatInstance) {
|
||
aiChatInstance.close()
|
||
aiChatInstance = null
|
||
}
|
||
}
|
||
|
||
export default AiChatSocket
|
||
```
|
||
|
||
### 14.3 替换轮询为 WebSocket
|
||
|
||
多个业务场景可复用 WebSocket Manager,将轮询替换为 WebSocket 推送。
|
||
|
||
**适用场景:**
|
||
|
||
| 场景 | 轮询接口 | WebSocket 替换 |
|
||
|------|----------|----------------|
|
||
| 好友请求通知 | `GET /api/v1/social/friend-requests` | 复用同一连接,接收 `friend_request` 消息 |
|
||
| 排行榜更新 | `GET /api/v1/rankings/hot` | 复用同一连接,接收 `ranking_update` 消息 |
|
||
| 活动进度 | `GET /api/v1/activities/:id/progress` | 复用同一连接,接收 `activity_progress` 消息 |
|
||
| 系统通知 | 轮询获取 | 新建 NotificationSocket,接收 `notification` 消息 |
|
||
|
||
**架构设计:**
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────┐
|
||
│ UniApp 前端 │
|
||
│ ┌─────────────────────────────────────────────────┐ │
|
||
│ │ GlobalSocketManager │ │
|
||
│ │ - 管理多个 WebSocket 连接 │ │
|
||
│ │ - 共享同一个 Token │ │
|
||
│ │ - 统一心跳管理 │ │
|
||
│ └─────────────────────────────────────────────────┘ │
|
||
│ │ │
|
||
│ ┌─────────┴──────────┐ │
|
||
│ ▼ ▼ │
|
||
│ ┌─────────────┐ ┌─────────────┐ │
|
||
│ │ AiChatSocket│ │NotifySocket│ ← 新增 │
|
||
│ └─────────────┘ └─────────────┘ │
|
||
└─────────────────────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌─────────────────────────────────────────────────────────┐
|
||
│ Gateway (:8080) │
|
||
│ /ws/ai-chat → AIChatService │
|
||
│ /ws/notify → NotificationService (Future) │
|
||
└─────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**GlobalSocketManager.js - 全局 Socket 管理器:**
|
||
```javascript
|
||
/**
|
||
* 全局 WebSocket 管理器
|
||
* 统一管理多个服务的 WebSocket 连接
|
||
*/
|
||
class GlobalSocketManager {
|
||
constructor() {
|
||
this.sockets = {} // serviceName -> SocketManager
|
||
this.token = null
|
||
this.isAllConnected = false
|
||
}
|
||
|
||
/**
|
||
* 初始化所有连接
|
||
*/
|
||
init(token) {
|
||
this.token = token
|
||
this._initAiChat()
|
||
this._initNotification() // Future
|
||
}
|
||
|
||
_initAiChat() {
|
||
const aiChat = getAiChatSocket()
|
||
aiChat.on('connect', () => console.log('AI Chat connected'))
|
||
aiChat.on('error', (err) => console.error('AI Chat error:', err))
|
||
aiChat.connect(this.token)
|
||
this.sockets['ai_chat'] = aiChat
|
||
}
|
||
|
||
_initNotification() {
|
||
const notify = getNotificationSocket()
|
||
notify.on('connect', () => console.log('Notification connected'))
|
||
notify.on('notification', (data) => this._handleNotification(data))
|
||
notify.connect(this.token)
|
||
this.sockets['notification'] = notify
|
||
}
|
||
|
||
_handleNotification(data) {
|
||
switch (data.sub_type) {
|
||
case 'friend_request':
|
||
uni.$emit('friend_request_received', data)
|
||
break
|
||
case 'ranking_update':
|
||
uni.$emit('ranking_updated', data)
|
||
break
|
||
case 'activity_progress':
|
||
uni.$emit('activity_progress_updated', data)
|
||
break
|
||
default:
|
||
console.log('Unknown notification:', data)
|
||
}
|
||
}
|
||
|
||
getSocket(serviceName) {
|
||
return this.sockets[serviceName]
|
||
}
|
||
|
||
closeAll() {
|
||
Object.values(this.sockets).forEach(socket => socket.close())
|
||
this.sockets = {}
|
||
}
|
||
}
|
||
|
||
let globalInstance = null
|
||
|
||
export function getGlobalSocket() {
|
||
if (!globalInstance) {
|
||
globalInstance = new GlobalSocketManager()
|
||
}
|
||
return globalInstance
|
||
}
|
||
|
||
export default GlobalSocketManager
|
||
```
|
||
|
||
**在 App.vue 中初始化:**
|
||
```javascript
|
||
// App.vue
|
||
import { getGlobalSocket } from '@/utils/socket/GlobalSocketManager'
|
||
|
||
export default {
|
||
onLaunch() {
|
||
const token = uni.getStorageSync('token')
|
||
if (token) {
|
||
getGlobalSocket().init(token)
|
||
}
|
||
},
|
||
onHide() {
|
||
getGlobalSocket().closeAll()
|
||
}
|
||
}
|
||
```
|
||
|
||
**在页面中使用:**
|
||
```javascript
|
||
// pages/friend/friend.vue
|
||
export default {
|
||
data() {
|
||
return { friendRequests: [] }
|
||
},
|
||
|
||
onLoad() {
|
||
uni.$on('friend_request_received', (data) => {
|
||
this.friendRequests.unshift(data)
|
||
uni.showToast({ title: '收到新好友请求', icon: 'none' })
|
||
})
|
||
},
|
||
|
||
onUnload() {
|
||
uni.$off('friend_request_received')
|
||
}
|
||
}
|
||
```
|
||
|
||
**复用连接的好处:**
|
||
- **资源节省**:多个业务共享一个 WebSocket 连接
|
||
- **实时性**:从轮询 5-10s 延迟降为即时推送
|
||
- **维护简单**:统一的心跳、重连、鉴权逻辑
|
||
|
||
### 14.4 注意事项
|
||
|
||
1. **鉴权方式**:连接时通过 URL 参数传递 Token(`?token=Bearer_xxx`),Gateway 验证通过后立即建立连接
|
||
2. **重连机制**:建议实现断线重连,间隔 3-5 秒
|
||
3. **心跳保活**:每 30 秒发送一次 ping 消息,60 秒无响应则主动关闭连接
|
||
4. **Session 管理**:建议在本地存储 session_id,便于恢复对话
|
||
5. **错误处理**:收到 `auth_response.success=false` 时立即关闭连接并提示用户重新登录
|
||
|
||
---
|
||
|
||
## 十五、与 SSE 方案的对比
|
||
|
||
| 对比项 | SSE | WebSocket |
|
||
|--------|-----|-----------|
|
||
| UniApp 支持 | 有限(需条件编译) | 完整支持 |
|
||
| 实现复杂度 | 中等 | 较低 |
|
||
| 双向通信 | 需轮询模拟 | 原生支持 |
|
||
| 服务器资源 | 较低 | 稍高(每个连接维护状态) |
|
||
| 扩展性 | 差(仅单向) | 好(可扩展主动推送) |
|
||
| 代理支持 | 需特殊配置(Nginx 流式) | 需配置 WebSocket 支持 |
|
||
|
||
**选择 WebSocket 的原因**:
|
||
1. UniApp 对 WebSocket 支持更好
|
||
2. 便于后续扩展主动推送功能(设计文档 V2)
|
||
3. 实现更简单,双向通信更自然 |