2026-06-12 17:08:20 +08:00
|
|
|
|
# 系统架构
|
|
|
|
|
|
|
|
|
|
|
|
## 概述
|
|
|
|
|
|
|
|
|
|
|
|
三层架构:**前端做轻量预处理,后端做智能编排,云端 AI 服务按需调用**。在保证交互体验的同时控制成本。
|
|
|
|
|
|
|
|
|
|
|
|
## 三层架构
|
|
|
|
|
|
|
|
|
|
|
|
| 层级 | 职责 | 关键约束 |
|
|
|
|
|
|
|------|------|---------|
|
|
|
|
|
|
| **客户端(浏览器)** | 媒体采集、边缘预处理、UI 渲染 | 浏览器资源有限,模型需轻量 |
|
|
|
|
|
|
| **Go 网关** | 会话管理、模型路由、AI 服务编排 | 高并发、低延迟、状态管理 |
|
|
|
|
|
|
| **AI 服务** | LLM 推理、语音识别、语音合成 | 按量计费,需控制调用频率 |
|
|
|
|
|
|
|
|
|
|
|
|
> 为什么要单独加一层 Go 网关,而不是让前端直连 AI API?1)API Key 安全性;2)统一的速率限制和成本管控;3)多模型路由逻辑集中在一处便于维护。
|
|
|
|
|
|
|
|
|
|
|
|
## 技术栈
|
|
|
|
|
|
|
|
|
|
|
|
### 前端
|
|
|
|
|
|
|
|
|
|
|
|
| 技术 | 选型 | 选择理由 |
|
|
|
|
|
|
|------|------|---------|
|
|
|
|
|
|
| 框架 | React 18 + TypeScript | 组件化开发,类型安全,生态成熟 |
|
|
|
|
|
|
| 构建 | Vite | 开发热更新快,构建产物小 |
|
|
|
|
|
|
| 实时通信 | WebSocket(原生 API) | 浏览器原生支持,无需额外依赖 |
|
|
|
|
|
|
| 边缘推理 | ONNX Runtime Web | 浏览器端跑轻量模型(VAD、关键帧检测) |
|
|
|
|
|
|
| 语音检测 | @ricky0123/vad-web | 基于 WebRTC VAD,纯前端零延迟 |
|
|
|
|
|
|
| 媒体采集 | MediaDevices API | 浏览器原生摄像头/麦克风访问 |
|
|
|
|
|
|
|
|
|
|
|
|
### 后端
|
|
|
|
|
|
|
|
|
|
|
|
| 技术 | 选型 | 选择理由 |
|
|
|
|
|
|
|------|------|---------|
|
|
|
|
|
|
| 语言 | Go | 高并发 goroutine 模型,适合长连接管理 |
|
|
|
|
|
|
| WebSocket | gorilla/websocket | Go 生态最成熟的 WebSocket 库 |
|
|
|
|
|
|
| 会话存储 | Redis | 高速 KV 存储,适合会话状态和上下文缓存 |
|
|
|
|
|
|
| 持久化存储 | PostgreSQL | 对话历史、用量统计、用户偏好(MVP 阶段可选) |
|
2026-06-13 13:31:48 +08:00
|
|
|
|
| 配置管理 | Viper | 支持 YAML + 环境变量覆盖,详见 `03-接口文档.md` 第六章 |
|
2026-06-12 17:08:20 +08:00
|
|
|
|
| 日志 | Zap | 高性能结构化日志 |
|
|
|
|
|
|
|
|
|
|
|
|
### AI 服务
|
|
|
|
|
|
|
|
|
|
|
|
| 能力 | 主选方案 | 备选方案 | 选型考量 |
|
|
|
|
|
|
|------|---------|---------|---------|
|
|
|
|
|
|
| 多模态 LLM | GPT-4o | Claude Sonnet | 视觉理解能力强,API 成熟 |
|
|
|
|
|
|
| 语音识别 STT | Deepgram | FunASR 自部署 | 流式识别延迟低(<500ms) |
|
|
|
|
|
|
| 语音合成 TTS | OpenAI TTS | Edge TTS(免费) | 音质自然,支持流式 |
|
|
|
|
|
|
| 轻量分类 | GPT-4o-mini | Haiku | 模型路由时的复杂度判断 |
|
|
|
|
|
|
|
|
|
|
|
|
> 不必绑定单一厂商。Go 网关的模型路由层统一封装不同 AI 服务的调用接口,按场景动态切换。
|
|
|
|
|
|
|
|
|
|
|
|
## 核心交互流程
|
|
|
|
|
|
|
|
|
|
|
|
一次完整的"用户提问 → AI 回答"流程:
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
Browser Go Gateway STT LLM TTS
|
|
|
|
|
|
| | | | |
|
|
|
|
|
|
|-- VAD 检测到语音结束 --->| | | |
|
|
|
|
|
|
| | | | |
|
|
|
|
|
|
|-- [音频+图像] -------->| | | |
|
|
|
|
|
|
| |--- 音频流 ------->| | |
|
|
|
|
|
|
| |<-- 流式文本 ------| | |
|
|
|
|
|
|
| | | | |
|
|
|
|
|
|
| |--- [图像+文本+上下文] -------->| |
|
|
|
|
|
|
| |<-- 流式回答文本 --------------| |
|
|
|
|
|
|
|<-- 推送回答文本 --------| | | |
|
|
|
|
|
|
| |--- 回答文本 ---------------------------->|
|
|
|
|
|
|
| |<-- 流式音频 --------------------------------|
|
|
|
|
|
|
|<-- 推送音频流 ----------| | | |
|
|
|
|
|
|
| | | | |
|
|
|
|
|
|
|-> 播放音频 + 渲染文字 | | | |
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
**关键优化**:LLM 文本流和 TTS 音频流是**并行推送**的——客户端先展示文字,同时开始播放语音,用户感知延迟大幅降低。
|
|
|
|
|
|
|
|
|
|
|
|
## 后端模块
|
|
|
|
|
|
|
|
|
|
|
|
| 模块 | 职责 | 关键实现 |
|
|
|
|
|
|
|------|------|---------|
|
|
|
|
|
|
| WebSocket Hub | 管理所有客户端连接,广播/定向推送 | goroutine per connection |
|
2026-06-13 13:24:25 +08:00
|
|
|
|
| Session Manager | 维护用户会话状态、对话历史 | Redis Hash + List,30 分钟 TTL(详见 `03-接口文档.md` 第五章) |
|
2026-06-12 17:08:20 +08:00
|
|
|
|
| Model Router | 根据请求类型选择 AI 模型 | 规则引擎 + 成本阈值 |
|
|
|
|
|
|
| AI Orchestrator | 编排多路 AI 调用(并行/串行) | context 取消 + 超时控制 |
|
|
|
|
|
|
| Rate Limiter | 防止单用户过度消耗 API 额度 | 令牌桶算法 |
|
|
|
|
|
|
|
2026-06-13 13:18:17 +08:00
|
|
|
|
AI Orchestrator 核心代码(句子级流式并行):
|
2026-06-12 17:08:20 +08:00
|
|
|
|
|
|
|
|
|
|
```go
|
2026-06-13 13:18:17 +08:00
|
|
|
|
func (o *Orchestrator) ProcessQuery(ctx context.Context, client MessageSender, req *QueryRequest) {
|
2026-06-12 17:08:20 +08:00
|
|
|
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
|
2026-06-13 13:18:17 +08:00
|
|
|
|
// Step 1: STT — 识别用户语音(串行)
|
|
|
|
|
|
text, err := o.stt.Recognize(ctx, req.Audio, STTOptions{...})
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
client.SendError(req.RequestID, "STT_ERROR", err.Error())
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
client.SendSTTResult(req.RequestID, text, true)
|
|
|
|
|
|
|
|
|
|
|
|
// Step 2: LLM 流式输出 + 句子切分(并行)
|
|
|
|
|
|
llmStream, _ := o.llm.ChatStream(ctx, LLMRequest{Image: req.Image, Text: text, ...})
|
|
|
|
|
|
sentenceCh := make(chan string, 4)
|
2026-06-12 17:08:20 +08:00
|
|
|
|
go func() {
|
2026-06-13 13:18:17 +08:00
|
|
|
|
defer close(sentenceCh)
|
|
|
|
|
|
var buf strings.Builder
|
|
|
|
|
|
for chunk := range llmStream {
|
|
|
|
|
|
client.SendLLMChunk(req.RequestID, chunk.Delta) // 逐 token 推送文字
|
|
|
|
|
|
buf.WriteString(chunk.Delta)
|
|
|
|
|
|
if isSentenceEnd(chunk.Delta) { // 按 。!?\n 切分
|
|
|
|
|
|
sentenceCh <- buf.String()
|
|
|
|
|
|
buf.Reset()
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if buf.Len() > 0 { sentenceCh <- buf.String() }
|
2026-06-12 17:08:20 +08:00
|
|
|
|
}()
|
|
|
|
|
|
|
2026-06-13 13:18:17 +08:00
|
|
|
|
// Step 3: TTS 并行消费句子流
|
|
|
|
|
|
ttsStream, _ := o.tts.SynthesizeStream(ctx, sentenceCh, TTSOptions{...})
|
|
|
|
|
|
for chunk := range ttsStream {
|
|
|
|
|
|
client.SendTTSAudio(req.RequestID, chunk.Audio, chunk.IsLast)
|
|
|
|
|
|
}
|
2026-06-12 17:08:20 +08:00
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
2026-06-13 13:18:17 +08:00
|
|
|
|
> **关键优化**:LLM 文本流和 TTS 音频流**并行推送**——客户端先逐 token 展示文字,同时 TTS 逐句子合成并推送音频,用户感知延迟大幅降低。详细的 AI 服务层接口和编排策略见 `03-接口文档.md` 第三、四章。
|
|
|
|
|
|
|
2026-06-12 17:08:20 +08:00
|
|
|
|
## 前端组件
|
|
|
|
|
|
|
|
|
|
|
|
| 组件 | 职责 |
|
|
|
|
|
|
|------|------|
|
|
|
|
|
|
| CameraManager | 摄像头流采集 |
|
|
|
|
|
|
| MicManager | 麦克风音频采集 |
|
|
|
|
|
|
| EdgeProcessor | VAD + 关键帧检测(ONNX Runtime) |
|
|
|
|
|
|
| WebSocketManager | WS 连接生命周期管理 |
|
|
|
|
|
|
| ChatPanel | 消息展示 |
|
|
|
|
|
|
| VideoPreview | 摄像头画面预览 |
|
|
|
|
|
|
|
|
|
|
|
|
核心 Hook:`useVisionSession()` 封装一次完整的视觉对话会话(摄像头、VAD、WebSocket、消息状态)。
|
|
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
function useVisionSession() {
|
|
|
|
|
|
const [messages, setMessages] = useState<Message[]>([]);
|
|
|
|
|
|
const wsRef = useWebSocket("ws://localhost:8080/ws");
|
|
|
|
|
|
const videoRef = useRef<HTMLVideoElement>(null);
|
|
|
|
|
|
const { captureFrame } = useCamera(videoRef);
|
|
|
|
|
|
|
|
|
|
|
|
const { isSpeaking } = useVAD({
|
|
|
|
|
|
onSpeechEnd: async (audio) => {
|
|
|
|
|
|
const frame = captureFrame();
|
|
|
|
|
|
wsRef.current?.send(JSON.stringify({
|
|
|
|
|
|
type: "query",
|
|
|
|
|
|
image: frame.toDataURL("image/jpeg", 0.7),
|
|
|
|
|
|
audio: encodeAudio(audio)
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
wsRef.current?.on("message", (data) => {
|
|
|
|
|
|
const { text, audio } = JSON.parse(data);
|
|
|
|
|
|
setMessages(prev => [...prev, { role: "assistant", text }]);
|
|
|
|
|
|
if (audio) playAudio(audio);
|
|
|
|
|
|
});
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
return { messages, videoRef, isSpeaking };
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 存储策略(分阶段)
|
|
|
|
|
|
|
|
|
|
|
|
| 阶段 | 存储方案 | 持久化内容 | 理由 |
|
|
|
|
|
|
|------|---------|-----------|------|
|
|
|
|
|
|
| MVP | Redis only | 无 | 快速验证核心功能,重启丢数据可接受 |
|
|
|
|
|
|
| 上线 | Redis + PostgreSQL | 对话历史、用户偏好、用量统计 | 用户需要查看历史,运营需要成本数据 |
|
|
|
|
|
|
| 规模化 | Redis + PG + 对象存储 | 图像帧、音频片段归档 | 大文件不适合存关系库 |
|
|
|
|
|
|
|
|
|
|
|
|
冷热分离:Redis 存"热数据"(当前对话上下文,微秒级读写),PostgreSQL 存"冷数据"(历史记录)。
|
|
|
|
|
|
|
|
|
|
|
|
### PostgreSQL 表设计
|
|
|
|
|
|
|
|
|
|
|
|
```sql
|
|
|
|
|
|
CREATE TABLE sessions (
|
|
|
|
|
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
|
|
|
|
user_id UUID NOT NULL,
|
|
|
|
|
|
created_at TIMESTAMPTZ DEFAULT now(),
|
|
|
|
|
|
updated_at TIMESTAMPTZ DEFAULT now()
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
CREATE TABLE messages (
|
|
|
|
|
|
id BIGSERIAL PRIMARY KEY,
|
|
|
|
|
|
session_id UUID REFERENCES sessions(id),
|
|
|
|
|
|
role VARCHAR(16) NOT NULL, -- "user" | "assistant"
|
|
|
|
|
|
content TEXT NOT NULL,
|
|
|
|
|
|
image_url TEXT,
|
|
|
|
|
|
tokens_used INTEGER DEFAULT 0,
|
|
|
|
|
|
created_at TIMESTAMPTZ DEFAULT now()
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
CREATE TABLE usage_daily (
|
|
|
|
|
|
user_id UUID NOT NULL,
|
|
|
|
|
|
date DATE NOT NULL,
|
|
|
|
|
|
llm_tokens BIGINT DEFAULT 0,
|
|
|
|
|
|
stt_seconds REAL DEFAULT 0,
|
|
|
|
|
|
tts_chars INTEGER DEFAULT 0,
|
|
|
|
|
|
estimated_cost NUMERIC(10,4) DEFAULT 0,
|
|
|
|
|
|
PRIMARY KEY (user_id, date)
|
|
|
|
|
|
);
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
## 部署架构
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
CDN(静态资源) ← 用户浏览器
|
|
|
|
|
|
Nginx 负载均衡(sticky session for WebSocket)
|
|
|
|
|
|
├── Gateway-1 ──→ Redis
|
|
|
|
|
|
├── Gateway-2 ──→ Redis
|
|
|
|
|
|
└── Gateway-N ──→ AI Services(外部 API)
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
WebSocket 是长连接,Nginx 需要配置 `proxy_set_header Upgrade` 和 sticky session,确保同一用户的请求始终路由到同一个 Gateway 实例。
|