docs: 文档与代码一致性检查与修复
- 修复心跳 Bug:应用层 ping 不更新 lastPong,60 秒后连接会被错误断开 - 03-接口文档:audio/mpeg→audio/mp3、STTConfig/TTSConfig 补充 Model 字段、 APP_ENV 环境变量名修正、配置搜索路径补充、.env 加载说明、Vite proxy 说明修正 - 02-系统架构:补充 ConfigPanel/Toast 组件、Model Router/Rate Limiter 标注规划中、 补充 Gin 框架、MVP 存储改为 Memory、AI 服务 provider 更新、Orchestrator 伪代码对齐 - 04-技术选型:新增 AI 服务栈选型章节(STT/LLM/TTS)、PostgreSQL 标注规划中 - 06-语音交互:VAD 参数名修正、STT 改为一次性识别描述、音频编码格式补充 - 07-视觉理解:关键帧检测代码改为 TypeScript、分辨率修正、阈值逻辑统一 - 08-成本控制:变量名修正、未实现功能标注规划中、对话历史裁剪策略补充 - CLAUDE.md:同步更新技术栈、模块结构、存储策略描述
This commit is contained in:
111
docs/02-系统架构.md
111
docs/02-系统架构.md
@@ -9,7 +9,7 @@
|
||||
| 层级 | 职责 | 关键约束 |
|
||||
|------|------|---------|
|
||||
| **客户端(浏览器)** | 媒体采集、边缘预处理、UI 渲染 | 浏览器资源有限,模型需轻量 |
|
||||
| **Go 网关** | 会话管理、模型路由、AI 服务编排 | 高并发、低延迟、状态管理 |
|
||||
| **Go 网关** | 会话管理、AI 服务编排、流式管道 | 高并发、低延迟、状态管理 |
|
||||
| **AI 服务** | LLM 推理、语音识别、语音合成 | 按量计费,需控制调用频率 |
|
||||
|
||||
> 为什么要单独加一层 Go 网关,而不是让前端直连 AI API?1)API Key 安全性;2)统一的速率限制和成本管控;3)多模型路由逻辑集中在一处便于维护。
|
||||
@@ -22,7 +22,7 @@
|
||||
|------|------|---------|
|
||||
| 框架 | React 18 + TypeScript | 组件化开发,类型安全,生态成熟 |
|
||||
| 构建 | Vite | 开发热更新快,构建产物小 |
|
||||
| 实时通信 | WebSocket(原生 API) | 浏览器原生支持,无需额外依赖 |
|
||||
| 实时通信 | WebSocket(原生 API) + 自封装连接管理 | 浏览器原生支持,封装心跳/重连/消息分发 |
|
||||
| 边缘推理 | ONNX Runtime Web | 浏览器端跑轻量模型(VAD、关键帧检测) |
|
||||
| 语音检测 | @ricky0123/vad-web | 基于 WebRTC VAD,纯前端零延迟 |
|
||||
| 媒体采集 | MediaDevices API | 浏览器原生摄像头/麦克风访问 |
|
||||
@@ -32,22 +32,22 @@
|
||||
| 技术 | 选型 | 选择理由 |
|
||||
|------|------|---------|
|
||||
| 语言 | Go | 高并发 goroutine 模型,适合长连接管理 |
|
||||
| HTTP 框架 | Gin | 高性能 HTTP 路由,中间件生态成熟 |
|
||||
| WebSocket | gorilla/websocket | Go 生态最成熟的 WebSocket 库 |
|
||||
| 会话存储 | Redis | 高速 KV 存储,适合会话状态和上下文缓存 |
|
||||
| 持久化存储 | PostgreSQL | 对话历史、用量统计、用户偏好(MVP 阶段可选) |
|
||||
| 配置管理 | Viper | 支持 YAML + 环境变量覆盖,详见 `03-接口文档.md` 第六章 |
|
||||
| 会话存储 | Redis(规划中) / Memory(MVP 默认) | 高速 KV 存储,MVP 阶段使用进程内存,可通过配置切换到 Redis |
|
||||
| 持久化存储 | PostgreSQL(规划中) | 对话历史、用量统计、用户偏好(MVP 阶段未实现) |
|
||||
| 配置管理 | Viper + godotenv | 支持 YAML + .env + 环境变量覆盖,详见 `03-接口文档.md` 第六章 |
|
||||
| 日志 | Zap | 高性能结构化日志 |
|
||||
|
||||
### AI 服务
|
||||
|
||||
| 能力 | 主选方案 | 备选方案 | 选型考量 |
|
||||
|------|---------|---------|---------|
|
||||
| 多模态 LLM | GPT-4o | Claude Sonnet | 视觉理解能力强,API 成熟 |
|
||||
| 语音识别 STT | Deepgram | FunASR 自部署 | 流式识别延迟低(<500ms) |
|
||||
| 语音合成 TTS | OpenAI TTS | Edge TTS(免费) | 音质自然,支持流式 |
|
||||
| 轻量分类 | GPT-4o-mini | Haiku | 模型路由时的复杂度判断 |
|
||||
| 多模态 LLM | GPT-4o(默认) | 通义千问等 OpenAI 兼容模型 | 通过 OpenAI 兼容接口,可灵活切换 |
|
||||
| 语音识别 STT | Deepgram(默认) | MiMo ASR(小米) | 支持多 provider 切换 |
|
||||
| 语音合成 TTS | OpenAI TTS(默认) | MiMo TTS(小米) | 支持多 provider 切换 |
|
||||
|
||||
> 不必绑定单一厂商。Go 网关的模型路由层统一封装不同 AI 服务的调用接口,按场景动态切换。
|
||||
> 不必绑定单一厂商。Go 网关的 AI 服务层统一封装不同服务商的调用接口,通过配置切换 provider。
|
||||
|
||||
## 核心交互流程
|
||||
|
||||
@@ -78,52 +78,35 @@ Browser Go Gateway STT LLM TTS
|
||||
|
||||
| 模块 | 职责 | 关键实现 |
|
||||
|------|------|---------|
|
||||
| WebSocket Hub | 管理所有客户端连接,广播/定向推送 | goroutine per connection |
|
||||
| Session Manager | 维护用户会话状态、对话历史 | Redis Hash + List,30 分钟 TTL(详见 `03-接口文档.md` 第五章) |
|
||||
| Model Router | 根据请求类型选择 AI 模型 | 规则引擎 + 成本阈值 |
|
||||
| AI Orchestrator | 编排多路 AI 调用(并行/串行) | context 取消 + 超时控制 |
|
||||
| Rate Limiter | 防止单用户过度消耗 API 额度 | 令牌桶算法 |
|
||||
| WebSocket Handler | 管理客户端连接生命周期,单播消息推送 | goroutine per connection |
|
||||
| Session Manager | 维护用户会话状态、对话历史 | Memory(MVP 默认)/ Redis(可切换),30 分钟 TTL(详见 `03-接口文档.md` 第五章) |
|
||||
| AI Orchestrator | 编排 STT→LLM→TTS 流式并行管道 | context 取消 + 超时控制 + 句子切分 |
|
||||
| AI Service Layer | AI 服务抽象层(STT/LLM/TTS) | 多 provider 支持(Deepgram/MiMo/OpenAI 等) |
|
||||
| REST API | 健康检查、会话管理端点 | Gin 路由 |
|
||||
| Error Handler | 统一错误码定义与发送 | 错误码枚举 |
|
||||
| Logger | 日志初始化封装 | Zap 结构化日志 |
|
||||
| Models | 数据模型定义 | WebSocket 消息、会话、配置等 |
|
||||
| Model Router | 根据请求类型选择 AI 模型(规划中) | 规则引擎 + 成本阈值 |
|
||||
| Rate Limiter | 防止单用户过度消耗 API 额度(规划中) | 令牌桶算法 |
|
||||
|
||||
AI Orchestrator 核心代码(句子级流式并行):
|
||||
AI Orchestrator 核心接口(`internal/orchestrator/orchestrator.go`):
|
||||
|
||||
```go
|
||||
func (o *Orchestrator) ProcessQuery(ctx context.Context, client MessageSender, req *QueryRequest) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// 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)
|
||||
go func() {
|
||||
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() }
|
||||
}()
|
||||
|
||||
// Step 3: TTS 并行消费句子流
|
||||
ttsStream, _ := o.tts.SynthesizeStream(ctx, sentenceCh, TTSOptions{...})
|
||||
for chunk := range ttsStream {
|
||||
client.SendTTSAudio(req.RequestID, chunk.Audio, chunk.IsLast)
|
||||
}
|
||||
// Orchestrator AI 编排器接口。
|
||||
type Orchestrator interface {
|
||||
ProcessQuery(ctx context.Context, sessionID string, req models.WsQuery,
|
||||
history []models.Message, sender Sender) error
|
||||
}
|
||||
```
|
||||
|
||||
Pipeline 实现(`internal/orchestrator/pipeline.go`)流程:
|
||||
1. Base64 解码音频/图片
|
||||
2. 调用 `stt.Recognize()` → 发送 `stt_result`
|
||||
3. 调用 `llm.ChatStream()` 获取流式输出,goroutine 消费 token → 发送 `llm_chunk` + 句子切分
|
||||
4. 另一 goroutine 从句子 channel 读取 → 调用 `tts.SynthesizeStream()` → 发送 `tts_audio`
|
||||
5. 流结束 → 发送 `llm_done`
|
||||
6. TTS 失败静默跳过,STT/LLM 失败发送对应 error 消息
|
||||
|
||||
> **关键优化**:LLM 文本流和 TTS 音频流**并行推送**——客户端先逐 token 展示文字,同时 TTS 逐句子合成并推送音频,用户感知延迟大幅降低。详细的 AI 服务层接口和编排策略见 `03-接口文档.md` 第三、四章。
|
||||
|
||||
## 前端组件
|
||||
@@ -132,10 +115,12 @@ func (o *Orchestrator) ProcessQuery(ctx context.Context, client MessageSender, r
|
||||
|------|------|
|
||||
| CameraManager | 摄像头流采集 |
|
||||
| MicManager | 麦克风音频采集 |
|
||||
| EdgeProcessor | VAD + 关键帧检测(ONNX Runtime) |
|
||||
| EdgeProcessor | VAD + 关键帧检测(Canvas 像素比较) |
|
||||
| WebSocketManager | WS 连接生命周期管理 |
|
||||
| ChatPanel | 消息展示 |
|
||||
| VideoPreview | 摄像头画面预览 |
|
||||
| ConfigPanel | 右侧抽屉式配置面板(主题、TTS 开关、detail level、语言) |
|
||||
| Toast | 轻量通知提示(3 秒自动消失) |
|
||||
|
||||
核心 Hook:`useVisionSession()` 封装一次完整的视觉对话会话(摄像头、VAD、WebSocket、消息状态)。
|
||||
|
||||
@@ -173,7 +158,7 @@ function useVisionSession() {
|
||||
|
||||
| 阶段 | 存储方案 | 持久化内容 | 理由 |
|
||||
|------|---------|-----------|------|
|
||||
| MVP | Redis only | 无 | 快速验证核心功能,重启丢数据可接受 |
|
||||
| MVP | Memory(进程内) | 无 | 快速验证核心功能,重启丢数据可接受。Redis 实现已就绪,可通过 `storage.driver` 配置切换 |
|
||||
| 上线 | Redis + PostgreSQL | 对话历史、用户偏好、用量统计 | 用户需要查看历史,运营需要成本数据 |
|
||||
| 规模化 | Redis + PG + 对象存储 | 图像帧、音频片段归档 | 大文件不适合存关系库 |
|
||||
|
||||
@@ -262,24 +247,8 @@ server {
|
||||
|
||||
> WebSocket 是长连接,Nginx 必须配置 `Upgrade` 和 `Connection` 头。`proxy_read_timeout` 需要覆盖心跳间隔(客户端 30s ping),否则 Nginx 会主动断开空闲连接。
|
||||
|
||||
### 开发环境(Vite proxy)
|
||||
### 开发环境
|
||||
|
||||
开发时前端(Vite :5173)和后端(Gin :8080)不同端口,用 Vite 内置代理解决跨域:
|
||||
开发时前端(Vite :5173)和后端(Gin :8080)不同端口。当前实现中前端 WebSocket 地址硬编码为 `ws://localhost:8080/ws`,直连后端,不经过 Vite 代理。
|
||||
|
||||
```typescript
|
||||
// frontend/vite.config.ts
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://localhost:8080",
|
||||
"/ws": {
|
||||
target: "ws://localhost:8080",
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
前端代码中 WebSocket 地址改为相对路径 `ws://localhost:5173/ws`,Vite 自动代理到后端。部署时 Nginx 同理,前端无需区分开发/生产地址。
|
||||
> 如需使用 Vite 代理解决跨域,可在 `vite.config.ts` 中添加 `server.proxy` 配置,并将前端 WebSocket 地址改为相对路径。
|
||||
|
||||
@@ -73,7 +73,7 @@ interface ConfigMessage {
|
||||
```typescript
|
||||
interface InterruptMessage {
|
||||
type: "interrupt";
|
||||
request_id?: string; // 可选,指定打断哪次请求
|
||||
request_id?: string; // 可选,当前实现不使用此字段,服务端始终取消当前活跃请求
|
||||
}
|
||||
```
|
||||
|
||||
@@ -143,7 +143,7 @@ interface TTSAudioMessage {
|
||||
type: "tts_audio";
|
||||
request_id: string;
|
||||
audio: string; // Base64 编码的音频片段
|
||||
mime_type: string; // "audio/mpeg"
|
||||
mime_type: string; // "audio/mp3"
|
||||
is_last: boolean; // 是否为最后一片
|
||||
}
|
||||
```
|
||||
@@ -152,7 +152,7 @@ interface TTSAudioMessage {
|
||||
|
||||
| 属性 | 值 | 说明 |
|
||||
|------|------|------|
|
||||
| 编码 | `audio/mpeg`(MP3) | 浏览器 `<audio>` 原生支持,OpenAI TTS 默认输出 |
|
||||
| 编码 | `audio/mp3`(MP3) | 浏览器 `<audio>` 原生支持,OpenAI TTS 默认输出 |
|
||||
| 采样率 | 24kHz | OpenAI TTS 默认 |
|
||||
| 声道 | 单声道 | 语音不需要立体声 |
|
||||
| 传输 | Base64 编码的 MP3 片段 | 每个 `tts_audio` 消息携带一个句子的音频 |
|
||||
@@ -165,7 +165,7 @@ interface TTSAudioMessage {
|
||||
1. **排队播放**:收到 `tts_audio` 时,将 Base64 解码为 Blob URL 并加入播放队列。第一片到达即开始播放,后续片段在 `onended` 回调中自动衔接。
|
||||
2. **错误容错**:单个片段播放失败时跳过,继续播放队列中下一个,不中断整个回复。
|
||||
3. **打断清理**:收到 `interrupt` 消息或用户触发打断时,清空播放队列并释放所有 Blob URL。
|
||||
4. **类型锁定**:`mime_type` 字段固定为 `"audio/mpeg"`,前端解码时直接使用,无需运行时判断。
|
||||
4. **类型锁定**:`mime_type` 字段固定为 `"audio/mp3"`,前端解码时直接使用,无需运行时判断。
|
||||
|
||||
```typescript
|
||||
// 前端播放器伪代码
|
||||
@@ -173,7 +173,7 @@ class AudioPlayer {
|
||||
private queue: string[] = []; // Blob URL 队列
|
||||
|
||||
enqueue(base64: string) {
|
||||
const url = decodeBase64Audio(base64, "audio/mpeg");
|
||||
const url = decodeBase64Audio(base64, "audio/mp3");
|
||||
this.queue.push(url);
|
||||
if (this.queue.length === 1) this.playNext(); // 第一片到了就开始播
|
||||
}
|
||||
@@ -293,7 +293,7 @@ DELETE /api/sessions/{session_id}
|
||||
|
||||
## 三、AI 服务层接口
|
||||
|
||||
Go 网关内部与外部 AI 服务(Deepgram STT、GPT-4o、OpenAI TTS)的调用契约。前后端联调时,后端需实现这些接口。
|
||||
Go 网关内部与外部 AI 服务(STT、LLM、TTS)的调用契约。默认配置为 Deepgram STT、GPT-4o LLM、OpenAI TTS,但通过 OpenAI 兼容接口可灵活切换到其他服务商(如 MiMo ASR、通义千问等)。前后端联调时,后端需实现这些接口。
|
||||
|
||||
### STT 服务接口
|
||||
|
||||
@@ -370,7 +370,7 @@ user: [图片 + 用户语音文本]
|
||||
```
|
||||
|
||||
- 超时:10 秒,超时返回 `LLM_TIMEOUT` 错误
|
||||
- 模型选择:默认 `gpt-4o`,由 Model Router 按需切换
|
||||
- 模型选择:默认 `gpt-4o`,可通过配置切换到其他 OpenAI 兼容模型
|
||||
|
||||
### TTS 服务接口
|
||||
|
||||
@@ -644,7 +644,7 @@ backend/config.dev.yaml # 开发环境(go run 时使用)
|
||||
backend/config.prod.yaml # 生产环境
|
||||
```
|
||||
|
||||
Viper 加载顺序:先读 `config.yaml`,再根据 `APP_ENV` 环境变量尝试读 `config.{env}.yaml` 覆盖,最后所有环境变量自动覆盖对应字段。
|
||||
Viper 加载顺序:先读 `config.yaml`,再根据 `APP_ENV` 环境变量尝试读 `config.{env}.yaml` 覆盖,最后所有环境变量自动覆盖对应字段。此外,代码还通过 `godotenv` 加载 `.env` 文件(优先级最低,仅用于本地开发环境)。
|
||||
|
||||
### Go 配置结构体
|
||||
|
||||
@@ -686,6 +686,7 @@ type AIConfig struct {
|
||||
type STTConfig struct {
|
||||
Provider string `mapstructure:"provider"` // "deepgram"
|
||||
APIKey string `mapstructure:"api_key"`
|
||||
Model string `mapstructure:"model"` // 默认 "nova-2"
|
||||
Endpoint string `mapstructure:"endpoint"` // 默认 "wss://api.deepgram.com/v1/listen"
|
||||
}
|
||||
|
||||
@@ -700,6 +701,7 @@ type LLMConfig struct {
|
||||
type TTSConfig struct {
|
||||
Provider string `mapstructure:"provider"` // "openai"
|
||||
APIKey string `mapstructure:"api_key"`
|
||||
Model string `mapstructure:"model"` // 默认 "tts-1"
|
||||
Voice string `mapstructure:"voice"` // 默认 "alloy"
|
||||
Speed float64 `mapstructure:"speed"` // 默认 1.0
|
||||
Endpoint string `mapstructure:"endpoint"` // 默认 "https://api.openai.com/v1"
|
||||
@@ -738,6 +740,7 @@ redis:
|
||||
ai:
|
||||
stt:
|
||||
provider: deepgram
|
||||
model: nova-2
|
||||
endpoint: "wss://api.deepgram.com/v1/listen"
|
||||
llm:
|
||||
provider: openai
|
||||
@@ -746,6 +749,7 @@ ai:
|
||||
timeout: 10
|
||||
tts:
|
||||
provider: openai
|
||||
model: tts-1
|
||||
voice: alloy
|
||||
speed: 1.0
|
||||
endpoint: "https://api.openai.com/v1"
|
||||
@@ -791,8 +795,9 @@ func Load() (*Config, error) {
|
||||
// 1. 读默认配置文件
|
||||
v.SetConfigName("config")
|
||||
v.SetConfigType("yaml")
|
||||
v.AddConfigPath("./config") // go run 时
|
||||
v.AddConfigPath(".") // 二进制运行时
|
||||
v.AddConfigPath(".")
|
||||
v.AddConfigPath("./config")
|
||||
v.AddConfigPath("./backend")
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
@@ -800,7 +805,7 @@ func Load() (*Config, error) {
|
||||
}
|
||||
|
||||
// 2. 按环境覆盖
|
||||
env := os.Getenv("CAMTALK_APP_ENV")
|
||||
env := os.Getenv("APP_ENV")
|
||||
if env == "" {
|
||||
env = "dev"
|
||||
}
|
||||
@@ -1030,7 +1035,7 @@ func NewApp(cfg *Config) *App {
|
||||
|
||||
## 十、连接管理
|
||||
|
||||
**心跳机制**:客户端每 30 秒发送 `ping`,服务端回复 `pong`。超过 60 秒无 `ping`,服务端判定连接断开并清理会话资源。
|
||||
**心跳机制**:客户端每 30 秒发送应用层 `{type: "ping"}` 消息,服务端回复 `{type: "pong"}` 并刷新心跳计时器。超过 60 秒无 `ping`,服务端判定连接断开并清理会话资源。
|
||||
|
||||
**重连策略**(指数退避 + 抖动):
|
||||
|
||||
@@ -1049,18 +1054,10 @@ function reconnect(attempt: number) {
|
||||
|
||||
**生产环境**:Nginx 将 `/`(前端)、`/api/*`(REST)、`/ws`(WebSocket)统一反代到同一域名,详见 `02-系统架构.md` 部署架构章节。
|
||||
|
||||
**开发环境**:Vite 内置代理,前端 :5173 的 `/api` 和 `/ws` 请求代理到后端 :8080:
|
||||
**开发环境**:前端 WebSocket 地址硬编码为 `ws://localhost:8080/ws`,直连后端,不经过 Vite 代理。REST API 同理直连 `http://localhost:8080`。
|
||||
|
||||
```typescript
|
||||
// frontend/vite.config.ts
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": "http://localhost:8080",
|
||||
"/ws": { target: "ws://localhost:8080", ws: true },
|
||||
},
|
||||
},
|
||||
```
|
||||
> 当前开发模式为前后端直连,未使用 Vite proxy。如需使用 Vite 代理解决跨域,需在 `vite.config.ts` 中添加 `server.proxy` 配置,并将前端 WebSocket 地址改为相对路径。
|
||||
|
||||
**Go 后端 WebSocket CheckOrigin**:生产环境 Nginx 同源,`CheckOrigin` 可保持默认(拒绝跨域)。开发环境由 Vite proxy 转发,不存在跨域。因此后端无需配置 CORS 中间件,`CheckOrigin` 保持 gorilla/websocket 默认值即可。
|
||||
**Go 后端 WebSocket CheckOrigin**:生产环境 Nginx 同源,`CheckOrigin` 可保持默认(拒绝跨域)。开发环境前端直连后端,需确保 `CheckOrigin` 允许跨域或使用 Vite proxy 转发。
|
||||
|
||||
> 如果未来需要支持第三方客户端直连(如移动端),再按需添加 CORS 中间件和 `CheckOrigin` 白名单。
|
||||
|
||||
@@ -4,20 +4,58 @@
|
||||
|
||||
本文档记录项目中各项技术的**选型过程、替代方案对比和决策理由**。技术选型没有"绝对正确",只有"更适合"。
|
||||
|
||||
**定位**:持久化部分是拓展选型,不阻塞 MVP(MVP 用 Redis 即可)。前端边缘处理部分是 MVP 阶段就需要确定的技术栈。
|
||||
**定位**:持久化部分是拓展选型,不阻塞 MVP(MVP 用内存存储即可)。前端边缘处理部分是 MVP 阶段就需要确定的技术栈。AI 服务栈(STT/LLM/TTS)已确定默认选型,可通过配置灵活切换。
|
||||
|
||||
```
|
||||
技术选型
|
||||
├── 持久化层 → 数据库选型: PostgreSQL
|
||||
├── AI 服务栈
|
||||
│ ├── STT: Deepgram(默认) / MiMo ASR
|
||||
│ ├── LLM: GPT-4o(默认) / 通义千问等 OpenAI 兼容模型
|
||||
│ └── TTS: OpenAI TTS(默认) / MiMo TTS
|
||||
├── 持久化层 → 数据库选型: PostgreSQL(规划中,MVP 阶段使用内存存储)
|
||||
└── 前端边缘处理层
|
||||
├── 边缘推理: ONNX Runtime Web
|
||||
├── 边缘推理: ONNX Runtime Web(规划中,MVP 使用 Canvas 像素比较)
|
||||
├── 语音检测: @ricky0123/vad-web
|
||||
└── 媒体采集: MediaDevices API
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 一、持久化层选型
|
||||
## 一、AI 服务栈选型
|
||||
|
||||
### STT(语音识别)
|
||||
|
||||
| 方案 | 延迟 | 成本 | 特点 |
|
||||
|------|------|------|------|
|
||||
| **Deepgram**(默认) | <500ms | 按分钟计费 | 流式识别,延迟极低,WebSocket 接口 |
|
||||
| **MiMo ASR**(小米) | ~1s | 按量计费 | 国产替代,兼容 OpenAI chat/completions 格式,HTTP 非流式 |
|
||||
| Whisper API | 1-3s | 按分钟计费 | 准确率高,支持多语言 |
|
||||
| FunASR | <500ms | 自部署免费 | 阿里开源,中文优化 |
|
||||
|
||||
当前默认使用 Deepgram nova-2,可通过 `ai.stt.provider` 配置切换到 MiMo ASR。
|
||||
|
||||
### LLM(多模态大模型)
|
||||
|
||||
| 方案 | 成本 | 特点 |
|
||||
|------|------|------|
|
||||
| **GPT-4o**(默认) | $2.5/1M tokens | 视觉理解能力强,API 成熟,流式推理 |
|
||||
| 通义千问 qwen3-vl-plus | 按量计费 | 阿里云,通过 OpenAI 兼容接口调用 |
|
||||
| Claude Sonnet | $3/1M tokens | Anthropic,长上下文能力强 |
|
||||
|
||||
代码通过 OpenAI 兼容接口调用,可灵活切换到任何兼容服务商。配置 `ai.llm.provider`、`ai.llm.model`、`ai.llm.endpoint` 即可。
|
||||
|
||||
### TTS(语音合成)
|
||||
|
||||
| 方案 | 成本 | 特点 |
|
||||
|------|------|------|
|
||||
| **OpenAI TTS**(默认) | $15/1M 字符 | 音质自然,支持流式,默认模型 tts-1,语音 alloy |
|
||||
| MiMo TTS(小米) | 按量计费 | 国产替代,通过配置切换 |
|
||||
|
||||
当前默认使用 OpenAI TTS(tts-1, alloy),可通过 `ai.tts.provider` 配置切换。
|
||||
|
||||
---
|
||||
|
||||
## 二、持久化层选型(规划中,MVP 阶段使用内存存储)
|
||||
|
||||
### 数据特征分析
|
||||
|
||||
|
||||
@@ -27,8 +27,11 @@ const vad = await MicVAD.new({
|
||||
// audio: Float32Array,送入 STT
|
||||
sendToSTT(audio);
|
||||
},
|
||||
positiveSpeechThreshold: 0.5, // 检测灵敏度
|
||||
minSpeechDuration: 250 // 最短语音时长 ms
|
||||
positiveSpeechThreshold: 0.5, // 检测灵敏度
|
||||
negativeSpeechThreshold: 0.35, // 结束灵敏度
|
||||
minSpeechMs: 250, // 最短语音时长 ms
|
||||
redemptionMs: 300, // 语音结束确认时间 ms
|
||||
preSpeechPadMs: 300, // 语音前填充 ms
|
||||
});
|
||||
|
||||
vad.start();
|
||||
@@ -39,34 +42,26 @@ vad.start();
|
||||
| 方案 | 延迟 | 成本 | 特点 |
|
||||
|------|------|------|------|
|
||||
| Whisper API | 1-3s | 按分钟计费 | 准确率高,支持多语言 |
|
||||
| **Deepgram** | <500ms | 按分钟计费 | 流式识别,延迟极低 |
|
||||
| **Deepgram**(默认) | <500ms | 按分钟计费 | 流式识别,延迟极低 |
|
||||
| **MiMo ASR**(小米) | ~1s | 按量计费 | 国产替代,兼容 OpenAI 格式,HTTP 非流式 |
|
||||
| 浏览器原生 | ~1s | 免费 | 中文效果一般 |
|
||||
| FunASR | <500ms | 自部署免费 | 阿里开源,中文优化 |
|
||||
|
||||
流式 STT 是低延迟的关键——不必等用户说完,边说边识别:
|
||||
当前实现为**一次性语音识别**(非流式):前端 VAD 检测到用户说完后,将完整音频片段发送到后端,后端调用 `stt.Recognize()` 一次性返回识别结果。流式 STT 为未来优化方向。
|
||||
|
||||
```typescript
|
||||
// Deepgram 流式识别示例
|
||||
const ws = new WebSocket("wss://api.deepgram.com/v1/listen", {
|
||||
headers: { Authorization: `Token ${API_KEY}` }
|
||||
});
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const { transcript, is_final } = JSON.parse(event.data).channel.alternatives[0];
|
||||
if (is_final) {
|
||||
onFinalTranscript(transcript); // 一句完整语音,送入 LLM
|
||||
}
|
||||
};
|
||||
```
|
||||
音频编码格式:前端 `audio.ts` 将 Float32Array 转为 Int16 PCM(16kHz, pcm_s16le)再编码为 Base64。
|
||||
|
||||
## 环节三:TTS(文字转语音)
|
||||
|
||||
流式 TTS:检测 LLM 输出中的句子边界,每检测到一句就立即送入 TTS 合成并播放,不必等全部生成完。
|
||||
|
||||
句子切分规则:按中文标点(`。!?`)、英文标点(`. ! ?`)和换行符切分。
|
||||
|
||||
当前实现参数:Voice `"alloy"`、Speed `1.0`、OutputFmt `"mp3"`、SampleRate `24000`。
|
||||
|
||||
方案选择:
|
||||
- **OpenAI TTS**:音质好,延迟中等,按字符计费
|
||||
- **Edge TTS**:微软免费方案,音质不错,延迟略高
|
||||
- **Fish Speech / CosyVoice**:开源方案,支持声音克隆,可自部署
|
||||
- **OpenAI TTS**(默认):音质好,延迟中等,按字符计费,模型 tts-1
|
||||
- **MiMo TTS**(小米):国产替代,通过配置切换
|
||||
- **Edge TTS**(规划中):微软免费方案,音质不错,延迟略高
|
||||
|
||||
## 延迟优化要点
|
||||
|
||||
|
||||
@@ -15,18 +15,30 @@
|
||||
| 事件驱动采样 | 用户主动触发(如拍照按钮) | 精确提问场景 |
|
||||
| **混合策略** | 低频定时 + 高频事件触发 | **通用推荐方案** |
|
||||
|
||||
关键帧检测核心逻辑:
|
||||
关键帧检测核心逻辑(TypeScript 实现,`EdgeProcessor/index.tsx`):
|
||||
|
||||
```python
|
||||
import numpy as np
|
||||
```typescript
|
||||
// 降低分辨率到 160x120 做检测,兼顾速度与精度
|
||||
const DETECT_WIDTH = 160;
|
||||
const DETECT_HEIGHT = 120;
|
||||
|
||||
def is_keyframe(prev_frame, curr_frame, threshold=30):
|
||||
"""通过帧间像素差异判断是否为关键帧"""
|
||||
diff = np.mean(np.abs(prev_frame.astype(int) - curr_frame.astype(int)))
|
||||
return diff > threshold
|
||||
function calcSimilarity(prev: ImageData, curr: ImageData): number {
|
||||
const pixelCount = prev.width * prev.height;
|
||||
let diffSum = 0;
|
||||
// 只比较 RGB 三通道,跳过 Alpha
|
||||
for (let i = 0; i < prev.data.length; i += 4) {
|
||||
diffSum += Math.abs(prev.data[i] - curr.data[i])
|
||||
+ Math.abs(prev.data[i+1] - curr.data[i+1])
|
||||
+ Math.abs(prev.data[i+2] - curr.data[i+2]);
|
||||
}
|
||||
const avgDiff = diffSum / (pixelCount * 3);
|
||||
return 1 - avgDiff / 255; // 相似度:1 = 完全相同,0 = 完全不同
|
||||
}
|
||||
```
|
||||
|
||||
> 实际开发中,先降低分辨率(如 320x240)做关键帧检测,再对命中帧保留原始分辨率送入 LLM,兼顾速度与精度。
|
||||
阈值说明:
|
||||
- 对话模式:`similarity > 0.9` 时跳过(视为重复帧)
|
||||
- 观察模式:`similarity < 0.85` 时触发变化回调
|
||||
|
||||
## 图像编码与多模态输入
|
||||
|
||||
|
||||
@@ -27,15 +27,12 @@
|
||||
| 本地预筛选 | 中 | 高 | 用轻量模型判断"是否值得问 LLM" |
|
||||
|
||||
```typescript
|
||||
// 混合策略:定时低频 + 事件高频
|
||||
const NORMAL_INTERVAL = 5000; // 正常 5 秒一帧
|
||||
// 混合策略:定时低频 + 事件高频(sampling.ts)
|
||||
const IDLE_INTERVAL = 5000; // 空闲 5 秒一帧
|
||||
const ACTIVE_INTERVAL = 1000; // 用户说话时 1 秒一帧
|
||||
|
||||
let isUserSpeaking = false;
|
||||
|
||||
setInterval(() => {
|
||||
captureAndSend(isUserSpeaking ? "low" : "high");
|
||||
}, isUserSpeaking ? ACTIVE_INTERVAL : NORMAL_INTERVAL);
|
||||
// SamplingController 根据 VAD 状态切换采样间隔
|
||||
// detail_level 通过 session config 静态配置,不随说话状态动态变化
|
||||
```
|
||||
|
||||
## 策略二:端云协同——把计算推到边缘
|
||||
@@ -43,11 +40,11 @@ setInterval(() => {
|
||||
不是所有计算都需要上云。可前置到客户端的计算:
|
||||
|
||||
- **VAD 语音检测**:浏览器端完成,减少无效音频上传(节省 ~70% 带宽)
|
||||
- **人脸/物体检测**:用 ONNX Runtime 跑轻量模型(如 YOLOv8-nano ~6MB,推理 ~30ms),只在检测到新物体时触发 LLM
|
||||
- **重复画面过滤**:计算帧间相似度,相似度 > 90% 直接跳过
|
||||
- **敏感内容过滤**:NSFW 检测前置,避免无效 API 调用
|
||||
- **人脸/物体检测**(规划中):用 ONNX Runtime 跑轻量模型(如 YOLOv8-nano ~6MB,推理 ~30ms),只在检测到新物体时触发 LLM。当前 MVP 使用 Canvas 像素比较做关键帧检测
|
||||
- **重复画面过滤**:计算帧间相似度,对话模式 similarity > 0.9 跳过,观察模式 similarity < 0.85 触发
|
||||
- **敏感内容过滤**(规划中):NSFW 检测前置,避免无效 API 调用
|
||||
|
||||
## 策略三:模型分级——用对模型做对事
|
||||
## 策略三:模型分级——用对模型做对事(规划中)
|
||||
|
||||
不是每个问题都需要最贵的模型:
|
||||
|
||||
@@ -58,20 +55,10 @@ setInterval(() => {
|
||||
└── 代码/推理 → o1 ($15/1M tokens)
|
||||
```
|
||||
|
||||
```typescript
|
||||
async function routeQuery(image: string, question: string) {
|
||||
const complexity = await classifyComplexity(question);
|
||||
const modelMap = {
|
||||
simple: "gpt-4o-mini", // "这是什么?"
|
||||
moderate: "gpt-4o", // "分析这张图"
|
||||
complex: "o1" // "推理/规划"
|
||||
};
|
||||
return callLLM(modelMap[complexity], image, question);
|
||||
}
|
||||
```
|
||||
> 当前 MVP 阶段使用单一模型(默认 GPT-4o),模型分级路由为未来优化方向。通过配置 `ai.llm.model` 可手动切换模型。
|
||||
|
||||
## 策略四:缓存与复用
|
||||
## 策略四:缓存与复用(规划中)
|
||||
|
||||
- **语义缓存**:相似问题直接返回缓存结果(如反复问"这是什么")
|
||||
- **上下文复用**:连续对话中,未变化的图像不必重复发送
|
||||
- **Prompt 压缩**:精简 system prompt,减少每轮的固定 token 开销
|
||||
- **语义缓存**(规划中):相似问题直接返回缓存结果(如反复问"这是什么")
|
||||
- **上下文复用**:连续对话中,未变化的图像不必重复发送(已通过重复画面过滤实现)
|
||||
- **对话历史裁剪**:前端按 `MAX_HISTORY_ROUNDS = 10` 裁剪,后端按 `defaultHistorySize = 20` 裁剪,限制每轮的固定 token 开销
|
||||
|
||||
Reference in New Issue
Block a user