Merge pull request 'docs: 重构文档结构,规范编号并整合冗余内容' (#182) from develop into v2
All checks were successful
Deploy / deploy (push) Successful in 26s
All checks were successful
Deploy / deploy (push) Successful in 26s
Reviewed-on: http://8.161.227.145:3000/XEngineers/CamTalk/pulls/182
This commit was merged in pull request #182.
This commit is contained in:
311
CLAUDE.md
311
CLAUDE.md
@@ -1,294 +1,73 @@
|
||||
# CLAUDE.md
|
||||
|
||||
本文件为 Claude Code (claude.ai/code) 在本仓库中工作时提供指引。
|
||||
CamTalk — 多模态实时 AI 视觉对话助手(摄像头 + 麦克风 + 视觉 + 语音 AI)
|
||||
|
||||
## 项目概述
|
||||
|
||||
CamTalk 是一款多模态实时 AI 视觉对话助手。用户通过摄像头和麦克风与 AI 交互,AI 理解视觉场景和语音输入后,以文字和语音形式给出自然回应。
|
||||
|
||||
> **文档优先原则:** 执行任何开发任务前,先读取 `docs/` 下的相关设计文档(架构、接口、技术选型等),以文档为最高依据。代码实现应与文档一致;若有偏差,优先更新文档(尤其是接口文档)。`docs/Eino/` 下有完整的 Eino 框架文档(~75 个 markdown 文件),可作为参考。
|
||||
|
||||
**核心设计文档:**
|
||||
|
||||
| 文档 | 内容 |
|
||||
|------|------|
|
||||
| `docs/01-架构设计.md` | 三层架构、技术栈、数据库设计、部署方案 |
|
||||
| `docs/02-接口文档.md` | WebSocket 协议、REST API、AI 服务层、编排器、配置管理 |
|
||||
| `docs/03-技术选型.md` | AI 服务栈、持久化层、前端边缘处理选型 |
|
||||
| `docs/04-用户故事.md` | 用户场景与优先级 |
|
||||
| `docs/05-语音交互.md` | VAD → STT → LLM → TTS 全链路 |
|
||||
| `docs/06-视觉理解.md` | 帧采样、关键帧检测、多模态输入 |
|
||||
| `docs/07-成本控制.md` | 采样策略、端云协同、模型分级 |
|
||||
| `docs/08-功能创意.md` | 功能创意与规划 |
|
||||
| `docs/09-技术名词解释.md` | 术语定义(VAD/STT/TTS/Token/JWT 等) |
|
||||
| `docs/10-Eino重构方案.md` | Eino Graph 迁移方案与决策记录 |
|
||||
| `docs/11-Eino框架技术文档.md` | Eino 框架使用指南 |
|
||||
| `docs/12-鉴权体系设计.md` | JWT 双 token 轮转详细设计 |
|
||||
| `docs/13-令牌桶限流设计.md` | 令牌桶限流详细设计(需同步实现) |
|
||||
> **文档优先原则:** 开发前先读 `docs/` 设计文档,以文档为准;若代码与文档不一致,优先更新文档(尤其接口文档)。详细设计见 `docs/01-13` 系列文档。**注意**:`docs/Eino/` 框架文档内容庞大(~75 个文件),仅在需要了解 Eino Graph/节点/Callback 等框架细节时才读取。
|
||||
|
||||
## 架构
|
||||
|
||||
三层系统:
|
||||
三层系统:前端(React + Vite)→ Go 网关(Gin + WebSocket + Eino Graph AI 编排)→ AI 服务(DashScope LLM, MiMo STT/TTS)
|
||||
|
||||
1. **浏览器客户端**(React 18 + TypeScript, Vite)—— 媒体采集、边缘预处理(VAD 通过 `@ricky0123/vad-web`、关键帧检测通过 Canvas 像素比较)、UI 渲染。核心 Hook:`useVisionSession()`
|
||||
2. **Go 网关**(Gin, gorilla/websocket, Viper, Zap)—— WebSocket 服务器、会话管理、AI 编排(基于 CloudWeGo Eino Graph)。每个 WebSocket 连接一个 goroutine。
|
||||
3. **云端 AI 服务** —— 通过 OpenAI 兼容接口可灵活切换。默认:DashScope qwen3-vl-plus(LLM)、MiMo ASR(STT)、MiMo TTS(TTS)。仅通过 Go 网关访问,浏览器不直连。
|
||||
**AI 编排流水线**(Eino Graph 7 节点 DAG):`STT → History → ChatModel → Msg2Str → Splitter → TTS → Done`。LLM token 通过 Callback 实时推送,TTS 逐句并行合成。
|
||||
|
||||
**关键模式**:AI 编排基于 Eino Graph 声明式 DAG(6 节点线性流水线:`START → STT → History → ChatModel → Msg2Str → Splitter → TTS → Done → END`),LLM token 通过 Callback 实时推送,TTS 逐句合成并行推送,最小化感知延迟。
|
||||
**会话存储**(TieredManager):L1 Memory → L2 Redis → L3 PostgreSQL 三级存储,30 分钟 TTL,Redis 故障自动降级。
|
||||
|
||||
### Eino Graph 节点详解
|
||||
|
||||
| 节点 | 类型 | 文件 | 职责 |
|
||||
|------|------|------|------|
|
||||
| STT | `InvokableLambda` | `backend/internal/eino/nodes_stt.go` | 语音识别或文本直通(text-only 跳过 STT) |
|
||||
| History | `InvokableLambda` | `backend/internal/eino/nodes_history.go` | 构建 System Prompt + 对话历史 + 用户输入 + 图像 |
|
||||
| ChatModel | ChatModel 节点 | `backend/internal/eino/graph.go` | 调用 DashScope qwen3-vl-plus(OpenAI 兼容协议) |
|
||||
| Msg2Str | `TransformableLambda` | `backend/internal/eino/nodes_splitter.go` | 将 ChatModel 流式 Message 转为字符串流 |
|
||||
| Splitter | `TransformableLambda` | `backend/internal/eino/nodes_splitter.go` | 按句子分隔符(`。!?\n.!?`)拆分文本流 |
|
||||
| TTS | `TransformableLambda` | `backend/internal/eino/nodes_tts.go` | 逐句合成语音并推送 `tts_audio` |
|
||||
| Done | `InvokableLambda` | `backend/internal/eino/nodes_done.go` | 发送 `llm_done`、收集最终输出 |
|
||||
|
||||
**跨节点状态**:`PipelineState`(`backend/internal/eino/state.go`),通过 `context.WithValue` 在节点间传递 FullResponse、TranscribedText、TokenUsage、SessionID、RequestID。
|
||||
|
||||
**Callback**:`BuildCallbackHandler`(`backend/internal/eino/callback.go`)挂载到 ChatModel 的 `OnEndWithStreamOutput`,每收到一个 LLM token 立即通过 `sender.SendLLMChunk()` 推送到客户端。
|
||||
|
||||
**适配器**:`EinoOrchestrator`(`backend/internal/eino/adapter.go`)包装 Graph,实现 `orchestrator.Orchestrator` 接口,负责解码 Base64 图像/音频、构建输入、注入上下文、运行流式推理、持久化消息。
|
||||
|
||||
### 会话存储(TieredManager)
|
||||
|
||||
三级存储:**L1 Memory → L2 Redis → L3 PostgreSQL**(`backend/internal/session/tiered.go`)
|
||||
|
||||
- **读路径**:L1 命中直接返回;未命中尝试 L2 Redis → 回填 L1;L3 通过 L1 的 `FindByID` 降级读取
|
||||
- **写路径**:L1 同步写入 → L2 同步写(失败 soft-warn)→ L3 异步 goroutine 写(使用 `context.Background()` 防止请求取消丢失)
|
||||
- **降级**:后台协程每 30 秒 ping Redis,Redis 不可用时自动跳过 L2 操作;恢复后自动重新启用
|
||||
- **TTL**:Session 默认 30 分钟,MaxHistory 20 条;L1 后台协程每分钟清理过期 session
|
||||
|
||||
Repository 接口模式:`UserRepository`、`MessageRepository`、`SessionRepository`,均有 PostgreSQL 和内存双实现。
|
||||
|
||||
### 鉴权
|
||||
|
||||
JWT 双 token 轮转认证(HMAC-SHA256):
|
||||
- Access Token:默认 120 分钟 TTL,Bearer header 传递
|
||||
- Refresh Token:默认 7 天 TTL,带 jti(UUID),Hash 存储在 Redis/PostgreSQL
|
||||
- 轮转:Refresh 时旧 token hash 删除,新 pair 生成;若 JWT 有效但 DB hash 缺失 → 判定为重放攻击 → 吊销该用户所有 refresh token
|
||||
- `CachedUserRepository`(`backend/internal/store/cached_user.go`):装饰器模式,Redis 缓存 refresh token hash,Read-Through / Write-Through,Redis 故障软降级
|
||||
**鉴权**:JWT 双 token 轮转(Access 120min + Refresh 7d),重放攻击检测(DB hash 校验),Redis 缓存装饰器。
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层级 | 技术 |
|
||||
|------|------|
|
||||
| 前端 | React 18, TypeScript, Vite, @ricky0123/vad-web, onnxruntime-web |
|
||||
| 后端 | Go 1.25+ (go.mod 最低要求; Dockerfile 构建用 golang:1.26-alpine), Gin, gorilla/websocket, Viper, Zap |
|
||||
| AI 编排 | CloudWeGo Eino Graph(声明式 DAG 编排) |
|
||||
| LLM | DashScope qwen3-vl-plus(默认,通过 eino-ext OpenAI ChatModel 接入) |
|
||||
| STT | MiMo ASR(默认) / Deepgram |
|
||||
| TTS | MiMo TTS(默认) / OpenAI TTS |
|
||||
| 存储 | PostgreSQL 15 + Redis 7(通过 TieredManager 三级存储) |
|
||||
前端:React 18 + TypeScript + Vite,VAD(@ricky0123/vad-web),ONNX Runtime
|
||||
后端:Go 1.25+, Gin, WebSocket, Viper, Zap, CloudWeGo Eino Graph
|
||||
AI:DashScope qwen3-vl-plus, MiMo ASR/TTS(可切换 Deepgram/OpenAI TTS)
|
||||
存储:PostgreSQL 15 + Redis 7
|
||||
|
||||
## 构建与运行命令
|
||||
## 快速启动
|
||||
|
||||
```bash
|
||||
# 前端
|
||||
cd frontend && npm install
|
||||
npm run dev # Vite 开发服务器(含 /ws、/api 代理到 localhost:8080)
|
||||
npm run build # 生产构建(tsc -b && vite build)
|
||||
npm run lint # ESLint 检查(flat config, TypeScript strict)
|
||||
|
||||
# 后端
|
||||
cd backend && go mod download
|
||||
go run ./cmd/server # 启动网关,监听 :8080
|
||||
go build -o bin/camtalk ./cmd/server
|
||||
go test ./... # 运行所有测试
|
||||
go test -run TestName ./path # 运行单个测试
|
||||
go vet ./... # 静态分析
|
||||
|
||||
# Docker 部署(生产环境)
|
||||
./deploy.sh build # 构建所有镜像
|
||||
./deploy.sh up # 启动 4 个服务
|
||||
./deploy.sh restart # down + up
|
||||
./deploy.sh logs [service] # 查看日志
|
||||
./deploy.sh status # 查看服务状态
|
||||
# 前端:npm run dev(Vite,代理 /ws 和 /api 到 :8080)
|
||||
# 后端:go run ./cmd/server(监听 :8080)
|
||||
# 生产:./deploy.sh up(4 容器:frontend/backend/postgres/redis)
|
||||
```
|
||||
|
||||
> **注意**:前端目前没有测试基础设施(无 vitest 配置、无测试文件)。后端使用 `testing` + `testify`(assert/require/mock)测试,编译期接口检查 `var _ Interface = (*Impl)(nil)`。
|
||||
核心环境变量(`.env.example`):`CAMTALK_AI_LLM_API_KEY`, `CAMTALK_AI_STT_API_KEY`, `CAMTALK_AUTH_JWT_SECRET`, `CAMTALK_STORAGE_DSN`
|
||||
|
||||
## 配置环境切换
|
||||
配置优先级:环境变量 > `config.{APP_ENV}.yaml` > `config.yaml`
|
||||
环境切换:`APP_ENV=dev|prod`(dev 默认,prod 启用限流 + 严格 CORS)
|
||||
|
||||
项目通过 `APP_ENV` 环境变量控制配置文件加载:
|
||||
## 协议与 API
|
||||
|
||||
- **本地开发**(默认):`APP_ENV=dev` → 加载 `config/config.dev.yaml`
|
||||
- Debug 日志、关闭限流、允许所有 CORS
|
||||
- 使用 `backend/.env` 中的远程 Redis/PostgreSQL 地址
|
||||
**WebSocket**:`ws://localhost:8080/ws?token=<jwt>&conversation_id=<uuid>`
|
||||
- 客户端:`query`(图像/音频 Base64), `config`, `interrupt`, `ping`
|
||||
- 服务端:`connected`, `stt_result`, `llm_chunk`, `llm_done`, `tts_audio`, `error`, `pong`
|
||||
- 心跳:客户端 30s ping,服务端 60s 超时断连;重连:指数退避 1s→30s
|
||||
- 实现:`CamTalkWebSocket` 单例(`frontend/src/lib/websocket.ts`),订阅模式,自动重连
|
||||
|
||||
- **生产部署**:`APP_ENV=prod` → 加载 `config/config.prod.yaml`
|
||||
- Info/JSON 日志、启用限流、严格 CORS 白名单
|
||||
- Docker Compose 自动设置,使用容器内网地址
|
||||
**REST API**:`/api/auth/*`(注册/登录/刷新/登出),`/api/conversations/*`(CRUD + 消息分页),`/api/health`
|
||||
|
||||
**配置优先级**:环境变量 > config.{env}.yaml > config.yaml > 默认值
|
||||
**错误码**:`INVALID_MESSAGE`, `SESSION_NOT_FOUND`, `RATE_LIMITED`, `IMAGE_TOO_LARGE`, `LLM_TIMEOUT`, `STT/TTS/LLM_ERROR`, `INVALID_TOKEN`, 等
|
||||
|
||||
**手动切换环境**(测试用):
|
||||
```bash
|
||||
cd backend
|
||||
APP_ENV=prod go run ./cmd/server # 本地测试生产配置
|
||||
APP_ENV=dev go run ./cmd/server # 显式指定开发配置
|
||||
```
|
||||
## 关键文件路径
|
||||
|
||||
## 配置系统
|
||||
**后端核心**:
|
||||
- `backend/internal/eino/` — Graph 定义、节点、Callback、Adapter、State
|
||||
- `backend/internal/session/tiered.go` — 三级会话存储
|
||||
- `backend/internal/store/` — Repository 实现(PG + 内存 + Redis 缓存)
|
||||
- `backend/internal/ws/handler.go` — WebSocket 连接管理
|
||||
- `backend/migrations/` — SQL 迁移文件
|
||||
|
||||
配置文件:`backend/config/config.yaml`(基础配置),可被 `config/config.{env}.yaml` 覆盖。
|
||||
|
||||
**优先级(从低到高)**:默认值 → `config.yaml` → `config.{env}.yaml`(由 `APP_ENV` 环境变量决定加载哪个 env 特定文件)→ `.env` 文件 → 环境变量
|
||||
|
||||
**主要 `CAMTALK_` 环境变量**(模板见 `backend/.env.example`):
|
||||
|
||||
| 变量 | 用途 |
|
||||
|------|------|
|
||||
| `APP_ENV` | 运行环境(dev/prod),决定加载 `config.{env}.yaml` |
|
||||
| `CAMTALK_AI_STT_API_KEY` | STT API Key |
|
||||
| `CAMTALK_AI_LLM_API_KEY` | LLM API Key |
|
||||
| `CAMTALK_AI_TTS_API_KEY` | TTS API Key |
|
||||
| `CAMTALK_AUTH_JWT_SECRET` | JWT 签名密钥 |
|
||||
| `CAMTALK_STORAGE_DSN` | PostgreSQL 连接串 |
|
||||
| `CAMTALK_STORAGE_REDIS_ENABLED` | 启用 Redis(true/false) |
|
||||
| `CAMTALK_STORAGE_PERSISTENCE_ENABLED` | 启用 PostgreSQL(true/false) |
|
||||
| `CAMTALK_REDIS_ADDR` | Redis 地址 |
|
||||
| `CAMTALK_REDIS_PASSWORD` | Redis 密码 |
|
||||
|
||||
**最小启动**(至少需要一个 AI 服务的 API Key):
|
||||
```bash
|
||||
CAMTALK_AI_LLM_API_KEY=sk-xxx CAMTALK_AI_STT_API_KEY=xxx go run ./cmd/server
|
||||
```
|
||||
|
||||
## Docker 部署
|
||||
|
||||
`docker-compose.yml` 定义 4 个服务(`camtalk-net` 桥接网络):
|
||||
|
||||
| 服务 | 镜像/构建 | 端口 | 说明 |
|
||||
|------|----------|------|------|
|
||||
| `frontend` | 构建 `./frontend/Dockerfile`(node:22-alpine → nginx:stable-alpine) | 9000:80 | React SPA,反向代理 /api 和 /ws 到 backend |
|
||||
| `backend` | 构建 `./backend/Dockerfile`(golang:1.26-alpine → alpine:3.20) | 内部 8080 | Go 网关,静态链接二进制 `-ldflags="-s -w"` |
|
||||
| `postgres` | `postgres:15-alpine` | 内部 5432 | 数据库 `camtalk`,挂载 `./backend/migrations/` 到 initdb |
|
||||
| `redis` | `redis:7-alpine` | 内部 6379 | 会话缓存,AOF 持久化 |
|
||||
|
||||
后端容器依赖 postgres + redis 健康检查通过后启动。所有服务 `restart: unless-stopped`。密钥通过 `--env-file /opt/camtalk/.env` 注入。
|
||||
|
||||
**前端 nginx 特殊配置**:设置 `Cross-Origin-Opener-Policy` 和 `Cross-Origin-Embedder-Policy` 头(`SharedArrayBuffer` 需要,ONNX WASM 推理依赖)。
|
||||
|
||||
## CI/CD
|
||||
|
||||
使用 **Gitea Actions**(`.gitea/workflows/deploy.yml`),自托管 runner(标签 `aliyun`)。
|
||||
|
||||
触发条件:push 到 `main` 或 `v2` 分支。流程:rsync 代码到 `/root/camtalk`,执行 `deploy.sh build` → `deploy.sh restart`。
|
||||
|
||||
## 数据库迁移
|
||||
|
||||
嵌入式 SQL 迁移系统(`backend/internal/store/migrate.go`),SQL 文件在 `backend/migrations/`:
|
||||
|
||||
| 迁移 | 内容 |
|
||||
|------|------|
|
||||
| `001_users` | `users` 表(UUID PK)+ `refresh_tokens` 表(FK → users) |
|
||||
| `002_messages` | `messages` 表(BIGSERIAL PK, session_id UUID, 游标分页索引) |
|
||||
| `003_sessions` | `sessions` 表(UUID PK, user_id UUID, config JSONB, 时间排序索引) |
|
||||
|
||||
迁移文件通过 Go 1.16+ `//go:embed` 嵌入二进制,启动时自动执行。通过 `schema_migrations` 表追踪版本,已应用的迁移跳过。同时挂载到 PostgreSQL 容器的 `/docker-entrypoint-initdb.d` 作为备用初始化路径。
|
||||
|
||||
## WebSocket 协议
|
||||
|
||||
端点:`ws://localhost:8080/ws?token=<access_token>&conversation_id=<uuid>`
|
||||
|
||||
所有消息为 JSON 文本帧,统一信封格式 `{type, request_id?, timestamp?}`。完整契约见 `docs/02-接口文档.md`。
|
||||
|
||||
**认证**:WebSocket 连接通过 query param `token`(Access Token)认证,不走 HTTP `Authorization` header。服务端在升级时校验 JWT,失败返回 401。
|
||||
|
||||
**客户端 → 服务端**:`query`(图像 Base64 + 音频 Base64)、`config`、`interrupt`、`ping`
|
||||
**服务端 → 客户端**:`connected`、`stt_result`、`llm_chunk`、`llm_done`、`tts_audio`、`error`、`pong`
|
||||
|
||||
**心跳**:客户端每 30 秒 ping,服务端 60 秒无 ping 断开连接。
|
||||
**重连**:指数退避 + 抖动 —— 1s, 2s, 4s, 8s… 最大 30s。
|
||||
|
||||
**前端 WebSocket 实现**:`CamTalkWebSocket` 单例类(`frontend/src/lib/websocket.ts`),基于订阅模式(`onMessage`/`onStatusChange` 返回取消订阅函数),自动处理心跳和重连。
|
||||
|
||||
## REST API(辅助)
|
||||
|
||||
- `GET /api/health` — 健康检查(版本、运行时间、活跃会话数)
|
||||
- `POST /api/auth/register` — 注册
|
||||
- `POST /api/auth/login` — 登录
|
||||
- `POST /api/auth/refresh` — 刷新 Token
|
||||
- `POST /api/auth/logout` — 登出
|
||||
- `GET /api/conversations` — 对话列表
|
||||
- `POST /api/conversations` — 创建对话
|
||||
- `GET/PATCH/DELETE /api/conversations/:id` — 对话详情/改标题/删除
|
||||
- `GET /api/conversations/:id/messages` — 获取对话消息(游标分页)
|
||||
- `POST/DELETE /api/sessions` — 会话管理
|
||||
|
||||
## 错误码
|
||||
|
||||
`INVALID_MESSAGE`、`SESSION_NOT_FOUND`、`RATE_LIMITED`、`IMAGE_TOO_LARGE`、`AUDIO_TOO_SHORT`、`LLM_TIMEOUT`、`LLM_ERROR`、`STT_ERROR`、`TTS_ERROR`、`INTERNAL_ERROR`、`USERNAME_TAKEN`、`INVALID_CREDENTIALS`、`INVALID_TOKEN`、`INVALID_INPUT`
|
||||
|
||||
## 前端组件结构
|
||||
|
||||
| 组件 | 职责 |
|
||||
|------|------|
|
||||
| `LandingPage` | 未登录时的着陆页,内嵌 LoginModal 登录/注册弹窗 |
|
||||
| `AuthPage` | 登录/注册表单(备用) |
|
||||
| `CameraManager` | 摄像头流采集(`useCamera` hook:640x480, facingMode: environment) |
|
||||
| `MicManager` | 麦克风音频采集(`useMicrophone` hook:16kHz 单声道) |
|
||||
| `EdgeProcessor` | VAD(`useVAD` hook:@ricky0123/vad-web)+ 关键帧检测(Canvas 像素比较,160x120 降采样) |
|
||||
| `WebSocketManager` | WebSocket 连接生命周期管理(桥接 `wsClient` 单例到 React 状态) |
|
||||
| `ChatPanel` | 消息展示、流式回复、文本输入、场景选择(5 种场景卡片) |
|
||||
| `VideoPreview` | 摄像头画面预览(forwardRef `<video>`) |
|
||||
| `SessionSidebar` | 左侧抽屉式对话列表(搜索、重命名、删除、时间分组) |
|
||||
| `ConfigPanel` | 右侧抽屉式配置面板(主题、TTS、语言、场景、登出) |
|
||||
| `Toast` | 轻量通知提示(3 秒自动消失) |
|
||||
|
||||
核心 Hook:
|
||||
- `useVisionSession()` — 封装一次完整的视觉对话会话(~500 行),管理摄像头、麦克风、VAD、WebSocket、消息状态、TTS 播放、两种模式(dialogue / observation)
|
||||
- `useSessionList()` — 对话列表 CRUD(通过 REST API),乐观更新
|
||||
- `useObservationMode()` — 定期帧差异检测(5 秒间隔),相似度 < 0.85 时触发回调
|
||||
|
||||
关键 lib 文件:
|
||||
- `frontend/src/lib/websocket.ts` — `CamTalkWebSocket` 单例,心跳 + 指数退避重连
|
||||
- `frontend/src/lib/auth.tsx` — `AuthProvider` 上下文,JWT 解码 + 自动刷新调度(exp 前 60 秒)
|
||||
- `frontend/src/lib/api.ts` — REST 客户端,自动 Bearer header,并发安全 401 拦截 + token 刷新 + 重试
|
||||
- `frontend/src/lib/ttsPlayer.ts` — `TTSPlayer` 类,流式 TTS 音频逐句排队播放
|
||||
- `frontend/src/lib/scenarios.ts` — 5 种对话场景定义(free_chat, interviewer, english_teacher, debate, interpreter)
|
||||
- `frontend/src/lib/i18n/` — 国际化,3 种语言(zh-CN 默认/fallback, en-US, ja-JP),扁平常量 map
|
||||
- `frontend/src/lib/storage.ts` — localStorage 封装(config, tokens, user info)
|
||||
- `frontend/src/lib/audio.ts` — 音频编码工具(浏览器采集 → Base64 PCM)
|
||||
- `frontend/src/lib/sampling.ts` — 混合采样策略(定时低频 + 事件高频,实现见 `docs/07-成本控制.md`)
|
||||
- `frontend/src/lib/errors.ts` — 错误码到用户友好文案的映射
|
||||
- `frontend/src/lib/toast.ts` — Toast 全局状态管理(error/warning/info,3 秒自动消失)
|
||||
- `frontend/src/types/index.ts` — 所有 TypeScript 类型定义(WebSocket 消息可辨识联合类型、场景、配置等)
|
||||
|
||||
## Vite 构建细节
|
||||
|
||||
`frontend/vite.config.ts` 包含:
|
||||
- 自定义 `serve-vad-assets` 插件,在开发/构建时自动从 `node_modules` 复制 VAD 模型文件(`silero_vad_legacy.onnx`、`silero_vad_v5.onnx`、`vad.worklet.bundle.min.js`)和 ONNX Runtime WASM 文件到 `public/`。开发服务器中间件确保 `.wasm` 和 `.mjs` 文件返回正确的 MIME type。
|
||||
- 开发代理:`/ws` → `ws://localhost:8080`、`/api` → `http://localhost:8080`,前端开发时无需配置额外环境变量。
|
||||
|
||||
## 后端模块结构
|
||||
|
||||
| 模块 | 职责 |
|
||||
|------|------|
|
||||
| WebSocket Handler | 连接管理、JWT 认证(query param token)、消息分发(query/config/interrupt/ping) |
|
||||
| Session Manager | 会话状态、对话历史(三级存储:Memory/Redis/PostgreSQL,30 分钟 TTL) |
|
||||
| Eino 编排层 | 基于 Eino Graph 的声明式 AI 编排(6 节点线性 DAG + Callback) |
|
||||
| AI Orchestrator | `EinoOrchestrator` 适配器,包装 Graph 实现 `Orchestrator` 接口 |
|
||||
| AI Service Layer | AI 服务抽象层(STT/TTS 多 provider 接口,LLM 通过 eino-ext ChatModel) |
|
||||
| Auth | JWT 双 token 轮转认证,bcrypt 密码哈希,Gin 中间件 |
|
||||
| Store | 持久化存储层(UserRepository/MessageRepository/SessionRepository,PG + 内存 + Redis 缓存装饰器) |
|
||||
| REST API | 健康检查、认证、对话管理(Gin 路由组) |
|
||||
| Models | 数据模型 + WebSocket 消息类型定义 |
|
||||
| Migrations | 嵌入式 SQL 版本化迁移 |
|
||||
| Rate Limiter | 按用户的令牌桶速率限制(`docs/13-令牌桶限流设计.md`,`backend/internal/ratelimit/`,实现中) |
|
||||
|
||||
测试模式:后端使用 `testing` + `testify`(`assert`、`require`、`mock`)。mock 模式包括:`mockSender`(实现 `orchestrator.Sender` 接口)、`httptest.Server`(模拟 AI 服务 HTTP API)、`MockOrchestrator`(模拟完整编排管道)。WebSocket 集成测试使用 `httptest.Server` + `gorilla/websocket.Dialer`。
|
||||
**前端核心**:
|
||||
- `frontend/src/hooks/useVisionSession.ts` — 核心会话 Hook(~500 行)
|
||||
- `frontend/src/lib/websocket.ts` — WebSocket 客户端单例
|
||||
- `frontend/src/lib/auth.tsx` — JWT 自动刷新 + AuthProvider
|
||||
- `frontend/src/lib/api.ts` — REST 客户端(401 拦截 + token 刷新)
|
||||
- `frontend/src/lib/ttsPlayer.ts` — 流式 TTS 音频播放队列
|
||||
- `frontend/vite.config.ts` — VAD 模型文件自动复制 + 代理配置
|
||||
|
||||
## 编码规范
|
||||
|
||||
- **Go**:遵循标准 Go 规范。所有 AI 调用使用 `context.Context` 做取消/超时。并发 map 访问使用 `sync.RWMutex`。结构体标签用 `json:"snake_case"`。编译期接口检查 `var _ Interface = (*Impl)(nil)`。
|
||||
- **TypeScript**:严格模式(`strict: true`)。所有数据模型用接口定义。WebSocket 消息类型用可辨识联合类型(`type` 字段)。`verbatimModuleSyntax: true`(强制 `import type`)。未使用变量以 `_` 前缀忽略。
|
||||
- **CORS 处理**:禁止在后端代码和配置文件(`config/*.yaml`)中进行任何 CORS 配置。跨域由代理层统一处理:开发环境通过 `frontend/vite.config.ts` 中的 proxy 配置(`/ws`、`/api` 代理到 `localhost:8080`),生产环境通过 Nginx 反向代理(`frontend/nginx.conf`)。
|
||||
- **提交信息**:Conventional Commits 格式,描述用中文。示例:`feat: 添加 WebSocket 连接管理`、`fix: 修复心跳超时判断`、`docs: 更新接口文档`
|
||||
- **禁止自动 push**:除非用户明确要求。
|
||||
- **文档优先**:实现功能前先读取 `docs/` 下的相关设计文档。实现与文档不一致时,优先更新 `docs/` 下的接口文档。
|
||||
- **Go**:标准规范,`context.Context` 超时控制,`sync.RWMutex` 并发保护,`json:"snake_case"` 标签,编译期接口检查 `var _ Interface = (*Impl)(nil)`
|
||||
- **TypeScript**:严格模式,接口定义数据模型,WebSocket 消息用可辨识联合类型(`type` 字段区分)
|
||||
- **CORS**:禁止后端代码/配置文件配置 CORS,统一由代理层处理(开发环境 Vite proxy,生产环境 Nginx)
|
||||
- **提交信息**:Conventional Commits,中文描述(如 `feat: 添加 WebSocket 心跳`)
|
||||
- **禁止自动 push**:除非用户明确要求
|
||||
- **文档优先**:开发前先读 `docs/` 设计文档,代码与文档不一致时优先更新文档
|
||||
|
||||
@@ -97,7 +97,7 @@ func NewPipelineGraph(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log.Infow("Eino Graph 编译成功", "nodes", 6)
|
||||
log.Infow("Eino Graph 编译成功", "nodes", 7)
|
||||
return &PipelineGraph{Runnable: runnable}, nil
|
||||
}
|
||||
|
||||
|
||||
193
docs/01-架构设计.md
193
docs/01-架构设计.md
@@ -199,36 +199,35 @@ graph LR
|
||||
| 模块 | 职责 |
|
||||
|------|------|
|
||||
| WebSocket Handler | 管理客户端连接生命周期,JWT 认证,conversation_id 恢复,单播消息推送 |
|
||||
| Session Manager | 维护用户会话状态、对话历史。三级存储(Memory → Redis → PostgreSQL),30 分钟 TTL,Write-Through 到 PG |
|
||||
| Eino 编排层 | 基于 CloudWeGo Eino Graph 的声明式 AI 编排。7 节点 DAG(STT→History→ChatModel→Msg2Str→Splitter→TTS→Done),Stream 模式调用,Callback 实现 LLM token 实时推送 |
|
||||
| AI Orchestrator | `EinoOrchestrator` 适配器,包装 Eino Graph 实现 `Orchestrator` 接口。context 取消 + 超时控制 |
|
||||
| Session Manager | 维护用户会话状态、对话历史,三级存储架构,30 分钟 TTL |
|
||||
| Eino 编排层 | 基于 CloudWeGo Eino Graph 的声明式 AI 编排,7 节点 DAG 流水线,Stream 模式调用 |
|
||||
| AI Orchestrator | EinoOrchestrator 适配器,包装 Eino Graph 实现 Orchestrator 接口 |
|
||||
| AI Service Layer | AI 服务抽象层,多 provider 支持(Deepgram/MiMo/OpenAI 等) |
|
||||
| Auth | 用户认证与授权。JWT (HS256) 双 token 轮转,bcrypt 密码哈希,Gin 中间件 |
|
||||
| Store | 持久化存储层。UserRepository / MessageRepository / SessionRepository,内存 + PostgreSQL 双实现 |
|
||||
| Auth | 用户认证与授权,JWT 双 token 轮转,bcrypt 密码哈希 |
|
||||
| Store | 持久化存储层,Repository 接口与实现(内存 + PostgreSQL) |
|
||||
| REST API | 健康检查、认证、对话管理端点 |
|
||||
| Logger | Zap 结构化日志 |
|
||||
| Models | 数据模型定义 |
|
||||
| Migrations | 数据库版本化迁移,嵌入式 SQL 文件自动执行 |
|
||||
| Migrations | 数据库版本化迁移 |
|
||||
| Model Router | 根据请求类型选择 AI 模型(待实现) |
|
||||
| Rate Limiter | 令牌桶限流。详细设计见 [令牌桶限流设计](./13-令牌桶限流设计.md) |
|
||||
| Rate Limiter | 令牌桶限流,详见 [11-令牌桶限流.md](./11-令牌桶限流.md) |
|
||||
|
||||
## 前端组件
|
||||
|
||||
| 组件 | 职责 |
|
||||
|------|------|
|
||||
| LandingPage | 未登录时的着陆页(营销展示),内嵌 LoginModal 登录/注册弹窗 |
|
||||
| AuthPage | 登录/注册表单(备用,已被 LandingPage + LoginModal 替代) |
|
||||
| LandingPage | 未登录时的着陆页,内嵌 LoginModal 登录/注册弹窗 |
|
||||
| CameraManager | 摄像头流采集 |
|
||||
| MicManager | 麦克风音频采集 |
|
||||
| EdgeProcessor | VAD + 关键帧检测(Canvas 像素比较) |
|
||||
| WebSocketManager | WS 连接生命周期管理 |
|
||||
| EdgeProcessor | VAD + 关键帧检测 |
|
||||
| WebSocketManager | WebSocket 连接生命周期管理 |
|
||||
| ChatPanel | 消息展示、流式回复、文本输入、场景选择 |
|
||||
| VideoPreview | 摄像头画面预览 |
|
||||
| SessionSidebar | 左侧抽屉式对话列表(搜索、重命名、删除、时间分组) |
|
||||
| ConfigPanel | 右侧抽屉式配置面板(主题、TTS 开关、detail level、语言、场景、登出) |
|
||||
| Toast | 轻量通知提示(3 秒自动消失) |
|
||||
| SessionSidebar | 左侧对话列表(搜索、重命名、删除、时间分组) |
|
||||
| ConfigPanel | 右侧配置面板(主题、TTS 开关、detail level、语言、场景、登出) |
|
||||
| Toast | 轻量通知提示 |
|
||||
|
||||
核心 Hook:`useVisionSession()` 封装一次完整的视觉对话会话(摄像头、VAD、WebSocket、消息状态、认证、场景模式)。`useSessionList()` 通过 REST API 管理对话列表 CRUD(列表、创建、删除、重命名、加载消息)。
|
||||
核心 Hook:`useVisionSession()` 封装完整的视觉对话会话(摄像头、VAD、WebSocket、消息状态、认证、场景模式)。`useSessionList()` 通过 REST API 管理对话列表 CRUD。
|
||||
|
||||
### 前端会话状态模型(三态)
|
||||
|
||||
@@ -310,188 +309,56 @@ erDiagram
|
||||
}
|
||||
```
|
||||
|
||||
### 表结构
|
||||
|
||||
```sql
|
||||
-- 用户表
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username VARCHAR(64) NOT NULL UNIQUE,
|
||||
password_hash VARCHAR(256) NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- 会话表
|
||||
CREATE TABLE sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
title VARCHAR(128) DEFAULT '新对话',
|
||||
config JSONB DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- 消息表
|
||||
CREATE TABLE messages (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
session_id UUID NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
||||
role VARCHAR(16) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
tokens_used INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- 刷新令牌表
|
||||
CREATE TABLE refresh_tokens (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
token_hash VARCHAR(256) NOT NULL UNIQUE,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
```
|
||||
系统采用关系型数据库存储持久化数据,包括用户账户、对话会话、消息记录和刷新令牌。数据库表定义详见 `backend/migrations/` 目录下的 SQL 迁移文件。
|
||||
|
||||
### 存储策略
|
||||
|
||||
| 场景 | 存储方案 | 说明 |
|
||||
|------|---------|------|
|
||||
| 默认 | Memory(进程内) | 零依赖,快速启动。MemoryManager 支持 Write-Through 到 PG |
|
||||
| 持久化 | Memory + PostgreSQL | 通过 `storage.persistence.enabled: true` 启用,MemoryManager 注入 PG Repository |
|
||||
| 多实例 | Redis(独立) | 通过配置切换到 RedisManager,适合多实例部署 |
|
||||
| 三级存储 | TieredManager | L1 Memory → L2 Redis → L3 PostgreSQL,自动降级 |
|
||||
系统采用**三级存储架构**(TieredManager)实现会话状态管理,平衡性能与可靠性:
|
||||
|
||||
**三级存储架构**(`TieredManager`):
|
||||
- **L1 Memory**:进程内缓存,提供微秒级读写性能
|
||||
- **L2 Redis**:分布式缓存层,支持多实例部署,提供毫秒级访问
|
||||
- **L3 PostgreSQL**:持久化存储层,确保数据可靠性
|
||||
|
||||
```
|
||||
TieredManager
|
||||
├── L1: Memory(进程内缓存,微秒级读写)
|
||||
├── L2: Redis(分布式缓存,毫秒级读写)
|
||||
└── L3: PostgreSQL(持久化存储,冷数据)
|
||||
```
|
||||
|
||||
- **读取路径**:L1 → L2 → L3,逐级回源,命中后向上回填
|
||||
- **写入路径**:L1 → L2(同步) → L3(异步)
|
||||
- **健康检查**:后台 goroutine 每 30 秒 ping Redis,故障时自动降级为 L1+L3 模式
|
||||
- **冷热分离**:L1/L2 存"热数据"(当前对话上下文),L3 存"冷数据"(历史记录)
|
||||
会话数据按 TTL(默认 30 分钟)在三级存储间流转,支持 Redis 故障时自动降级到 Memory + PostgreSQL 模式。配置灵活,可根据部署规模选择单级(Memory)、双级(Memory + PostgreSQL)或完整三级存储方案。
|
||||
|
||||
## 认证设计
|
||||
|
||||
采用 **JWT 双 token 轮转认证机制**,结合 bcrypt 密码哈希和 Refresh Token Rotation 安全策略。详细设计见 [鉴权体系设计](./12-鉴权体系设计.md)。
|
||||
系统采用 **JWT 双 token 轮转认证机制**,结合 bcrypt 密码哈希和 Refresh Token Rotation 安全策略。
|
||||
|
||||
### 核心组件
|
||||
|
||||
| 组件 | 职责 |
|
||||
|------|------|
|
||||
| TokenManager | JWT 生成与验证(HS256 算法) |
|
||||
| AuthService | 认证业务逻辑(注册/登录/刷新/登出) |
|
||||
| AuthMiddleware | Gin 中间件,校验 access_token 并注入用户信息 |
|
||||
| PasswordUtil | bcrypt 密码哈希(cost=10) |
|
||||
|
||||
### 认证流程
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant C as 客户端
|
||||
participant G as Go 网关
|
||||
participant DB as PostgreSQL
|
||||
|
||||
Note over C,DB: 注册流程
|
||||
C->>G: POST /api/auth/register {username, password}
|
||||
G->>G: bcrypt hash 密码
|
||||
G->>DB: INSERT users
|
||||
G->>G: 生成 access_token + refresh_token
|
||||
G->>DB: 存 SHA256(refresh_token)
|
||||
G-->>C: {user, access_token, refresh_token}
|
||||
|
||||
Note over C,DB: 登录流程
|
||||
C->>G: POST /api/auth/login {username, password}
|
||||
G->>DB: 查 users by username
|
||||
G->>G: bcrypt.CompareHashAndPassword
|
||||
G->>G: 生成 token pair
|
||||
G->>DB: 存 SHA256(refresh_token)
|
||||
G-->>C: {user, access_token, refresh_token}
|
||||
|
||||
Note over C,DB: Token 刷新(轮转)
|
||||
C->>G: POST /api/auth/refresh {refresh_token}
|
||||
G->>G: 校验签名和过期
|
||||
G->>DB: 验证 hash 存在
|
||||
G->>DB: 撤销旧 refresh_token
|
||||
G->>G: 生成新 token pair
|
||||
G->>DB: 存新 refresh_token hash
|
||||
G-->>C: {access_token, refresh_token}
|
||||
```
|
||||
|
||||
### Token 策略
|
||||
|
||||
- **access_token**:15 分钟有效,用于 API 认证和 WebSocket 连接
|
||||
- **refresh_token**:7 天有效,用于刷新 access_token
|
||||
- **Refresh Token Rotation**:每次 refresh 都生成新的 token pair,旧 refresh_token 立即失效
|
||||
- **复用检测**:检测到已删除的 refresh_token 被复用时,吊销该用户的所有 refresh_token
|
||||
|
||||
### 安全机制
|
||||
|
||||
1. **密码安全**:bcrypt 算法(cost=10),自动生成盐值,防彩虹表攻击
|
||||
2. **Token 安全**:
|
||||
- access_token 短有效期(15 分钟),降低泄露风险
|
||||
- refresh_token 使用 SHA256 哈希存储,不存储原始 token
|
||||
- Refresh Token Rotation 防重放攻击
|
||||
- 复用检测 + 自动吊销机制
|
||||
3. **传输安全**:HTTPS 强制,CORS 限制,HttpOnly Cookie 存储 refresh_token
|
||||
4. **防攻击策略**:
|
||||
- 防暴力破解:可选速率限制
|
||||
- 防枚举攻击:统一错误信息
|
||||
- 防 Token 泄露:复用检测 + 自动吊销
|
||||
|
||||
### WebSocket 认证
|
||||
|
||||
连接地址:`ws://host/ws?token=<access_token>&conversation_id=<uuid>`
|
||||
|
||||
- HTTP Upgrade 前校验 token
|
||||
- 校验失败返回 401 Unauthorized
|
||||
- 校验成功后,user_id 和 username 注入到连接上下文
|
||||
|
||||
### 配置
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
jwt_secret: "" # JWT 签名密钥(必须通过 CAMTALK_AUTH_JWT_SECRET 环境变量设置)
|
||||
access_ttl: 15 # access_token 有效期(分钟)
|
||||
refresh_ttl: 10080 # refresh_token 有效期(分钟,7天)
|
||||
```
|
||||
|
||||
> **安全要求**:`JWT_SECRET` **必须**通过环境变量设置,不能写入配置文件。生产环境使用 `openssl rand -hex 32` 生成随机密钥。
|
||||
核心机制包括:双 token 轮转(access_token 15 分钟 + refresh_token 7 天)、密码安全(bcrypt cost=10)、token 安全(SHA256 哈希存储、复用检测)、WebSocket 连接认证(基于 access_token 的 HTTP Upgrade 校验)等。认证流程、安全机制、配置要求等详细设计见 [10-鉴权体系.md](./10-鉴权体系.md)。
|
||||
|
||||
## 部署架构
|
||||
|
||||
系统采用分层部署架构,支持单实例和多实例水平扩展:
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
User["用户浏览器"] --> Nginx
|
||||
|
||||
|
||||
subgraph Nginx["Nginx 反向代理"]
|
||||
Static["/ → 前端静态资源"]
|
||||
API["/api/* → Go Gateway"]
|
||||
WS_Proxy["/ws → Go Gateway"]
|
||||
end
|
||||
|
||||
|
||||
subgraph Gateway_Pool["Go Gateway 实例"]
|
||||
G1["Gateway-1"]
|
||||
G2["Gateway-2"]
|
||||
GN["Gateway-N"]
|
||||
end
|
||||
|
||||
|
||||
Nginx --> G1
|
||||
Nginx --> G2
|
||||
Nginx --> GN
|
||||
|
||||
|
||||
G1 --> Redis
|
||||
G2 --> Redis
|
||||
GN --> Redis
|
||||
|
||||
|
||||
G1 --> PG_DB["PostgreSQL"]
|
||||
G2 --> PG_DB
|
||||
GN --> PG_DB
|
||||
|
||||
|
||||
G1 --> AI_Services["AI Services(外部 API)"]
|
||||
G2 --> AI_Services
|
||||
GN --> AI_Services
|
||||
|
||||
1542
docs/02-接口文档.md
1542
docs/02-接口文档.md
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,16 @@
|
||||
|
||||
本文档记录项目中各项技术的**选型过程、替代方案对比和决策理由**。技术选型没有"绝对正确",只有"更适合"。
|
||||
|
||||
**定位**:本文档记录各项技术的选型过程和决策理由。
|
||||
各技术选型章节包含关键术语解释,帮助快速理解技术概念。
|
||||
|
||||
### 后端核心技术栈
|
||||
|
||||
| 名词 | 解释 |
|
||||
|------|------|
|
||||
| **Go (Golang)** | 高并发后端语言,Google 开发,杀手锏是 goroutine——极轻量协程,一个程序可轻松开几万个,每个只占几 KB 内存,适合管理大量 WebSocket 长连接 |
|
||||
| **gorilla/websocket** | Go WebSocket 库,Go 标准库无内置 WebSocket 支持,此库是社区最成熟的选择,处理了协议握手、帧解析等底层细节 |
|
||||
| **Viper** | Go 配置管理库,读取 JSON/YAML/TOML 配置,支持环境变量覆盖,方便开发/测试/生产环境用不同配置 |
|
||||
| **Zap** | Go 结构化日志库,Uber 开源,输出 JSON 格式日志,方便工具搜索分析,性能远超标准库 log |
|
||||
|
||||
```
|
||||
技术选型
|
||||
@@ -34,6 +43,18 @@
|
||||
|
||||
## 一、AI 编排框架选型
|
||||
|
||||
### 关键术语
|
||||
|
||||
| 名词 | 解释 |
|
||||
|------|------|
|
||||
| **Eino** | 字节跳动开源的 Go AI 应用开发框架(CloudWeGo Eino),提供 Graph DAG 编排、组件抽象(ChatModel/Tool 等)、流式处理和 Callback AOP 机制 |
|
||||
| **compose.Graph** | Eino 的 DAG 编排器,声明式有向无环图,节点可以是 Lambda、ChatModel、ToolsNode 等,边定义数据流向 |
|
||||
| **Lambda** | Graph 中的可组合函数单元,四种模式:InvokableLambda(同步)、StreamableLambda(流式输出)、CollectableLambda(流式输入)、TransformableLambda(双向流式) |
|
||||
| **StreamReader** | Eino 的流式数据抽象 `schema.StreamReader[T]`,类似 io.Reader 的语义,`Recv()` 读取一帧,`io.EOF` 表示流结束 |
|
||||
| **Callback** | Eino 的 AOP 机制,类似中间件的钩子,支持节点生命周期回调(OnStart/OnEnd/OnError/OnEndWithStreamOutput) |
|
||||
|
||||
> 更多 Eino 相关概念详见 [10-Eino框架与编排设计.md](10-Eino框架与编排设计.md)
|
||||
|
||||
### 候选方案对比
|
||||
|
||||
| 框架 | 语言 | 特点 | CamTalk 适用性 |
|
||||
@@ -74,6 +95,14 @@ github.com/cloudwego/eino-ext/components/model/openai v0.1.13 # OpenAI 兼容 C
|
||||
|
||||
## 二、AI 服务栈选型
|
||||
|
||||
### 关键术语
|
||||
|
||||
| 名词 | 解释 |
|
||||
|------|------|
|
||||
| **多模态 LLM** | 能读文字又能看图片的大语言模型,如 GPT-4o(OpenAI)、Claude Sonnet(Anthropic),给照片+问题能"看懂"照片再回答 |
|
||||
| **STT** | Speech-to-Text,语音转文字。流式识别延迟可低于 500ms |
|
||||
| **TTS** | Text-to-Speech,文字转语音。支持流式——边生成边读,不必等全部生成完 |
|
||||
|
||||
### STT(语音识别)
|
||||
|
||||
| 方案 | 延迟 | 成本 | 特点 |
|
||||
@@ -106,7 +135,15 @@ LLM 通过 Eino 框架的 `eino-ext/components/model/openai` ChatModel 组件接
|
||||
|
||||
---
|
||||
|
||||
## 二、持久化层选型
|
||||
## 三、持久化层选型
|
||||
|
||||
### 关键术语
|
||||
|
||||
| 名词 | 解释 |
|
||||
|------|------|
|
||||
| **PostgreSQL** | 关系型数据库,支持 JSONB(JSON 二进制格式,可建索引)、窗口函数、CTE 等高级特性 |
|
||||
| **Redis** | 内存 KV 数据库,数据放在内存里,读写微秒级。支持 TTL 过期自动清理 |
|
||||
| **MVCC** | Multi-Version Concurrency Control,多版本并发控制,PostgreSQL 用此实现高并发读写而不阻塞 |
|
||||
|
||||
### 数据特征分析
|
||||
|
||||
@@ -220,7 +257,19 @@ Go Gateway (TieredManager)
|
||||
|
||||
---
|
||||
|
||||
## 二、前端边缘处理层选型
|
||||
## 四、前端边缘处理层选型
|
||||
|
||||
### 关键术语
|
||||
|
||||
| 名词 | 解释 |
|
||||
|------|------|
|
||||
| **React 18** | 组件化 UI 框架,Facebook 开源,把页面拆成组件搭积木拼装。18 版本支持并发渲染 |
|
||||
| **TypeScript** | 带类型的 JavaScript,在 JS 基础上增加类型声明,编译阶段就能发现类型错误 |
|
||||
| **Vite** | 前端构建工具,利用浏览器原生 ES Module,开发时毫秒级热更新(HMR),构建产物小 |
|
||||
| **WebSocket** | 浏览器与服务器的双向通道。HTTP 是"一问一答",WebSocket 像打电话——接通后双方随时互发消息,适合实时对话场景 |
|
||||
| **ONNX Runtime Web** | 浏览器端 AI 推理引擎,微软定义的通用模型格式 ONNX 的运行引擎,可在浏览器中用 WASM 加速跑轻量模型(如 VAD、关键帧检测),零延迟、不耗服务器资源 |
|
||||
| **VAD** | Voice Activity Detection,语音活动检测,检测"人有没有在说话"。WebRTC 内置了高效的 VAD 算法 |
|
||||
| **MediaDevices API** | 浏览器摄像头/麦克风接口,`navigator.mediaDevices.getUserMedia()` 是浏览器音视频采集的唯一标准入口,无需插件 |
|
||||
|
||||
### 总览
|
||||
|
||||
@@ -270,7 +319,16 @@ vad-web 是"够用且最轻"的平衡点——直接包装浏览器原生 WebRTC
|
||||
|
||||
---
|
||||
|
||||
## 四、认证与用户系统选型
|
||||
## 五、认证与用户系统选型
|
||||
|
||||
### 关键术语
|
||||
|
||||
| 名词 | 解释 |
|
||||
|------|------|
|
||||
| **JWT** | JSON Web Token,无状态 token,服务端不存 session,分布式友好 |
|
||||
| **HS256** | HMAC-SHA256,JWT 对称签名算法,用同一密钥签名和验证 |
|
||||
| **bcrypt** | 密码哈希算法,自适应 cost factor,抗暴力破解 |
|
||||
| **pgx** | Go 生态性能最优的 PostgreSQL 驱动,原生协议实现,内置连接池 pgxpool |
|
||||
|
||||
### 总览
|
||||
|
||||
|
||||
@@ -26,14 +26,7 @@
|
||||
| 用户触发 | 高 | 低 | 只在用户提问时拍照 |
|
||||
| 本地预筛选 | 中 | 高 | 用轻量模型判断"是否值得问 LLM" |
|
||||
|
||||
```typescript
|
||||
// 混合策略:定时低频 + 事件高频(sampling.ts)
|
||||
const IDLE_INTERVAL = 5000; // 空闲 5 秒一帧
|
||||
const ACTIVE_INTERVAL = 1000; // 用户说话时 1 秒一帧
|
||||
|
||||
// SamplingController 根据 VAD 状态切换采样间隔
|
||||
// detail_level 通过 session config 静态配置,不随说话状态动态变化
|
||||
```
|
||||
**实现细节**:参见 `frontend/src/lib/sampling.ts` 中的 SamplingController,根据 VAD 状态在空闲模式(5s/帧)和活跃模式(1s/帧)之间切换。
|
||||
|
||||
## 策略二:端云协同——把计算推到边缘
|
||||
|
||||
@@ -55,7 +48,7 @@ const ACTIVE_INTERVAL = 1000; // 用户说话时 1 秒一帧
|
||||
└── 代码/推理 → 更强模型(如 o1)
|
||||
```
|
||||
|
||||
> 当前 MVP 阶段使用单一模型(默认 DashScope qwen3-vl-plus),模型分级路由为未来优化方向。通过配置 `ai.llm.model` 可手动切换模型。LLM 通过 Eino 框架的 eino-ext ChatModel 组件接入,支持任何 OpenAI 兼容接口。
|
||||
> 当前 MVP 阶段使用单一模型(默认 DashScope qwen3-vl-plus),模型分级路由为未来优化方向。LLM 通过 Eino ChatModel 接入,支持任何 OpenAI 兼容接口。
|
||||
|
||||
## 策略四:缓存与复用(待实现)
|
||||
|
||||
|
||||
527
docs/08-Eino框架与编排设计.md
Normal file
527
docs/08-Eino框架与编排设计.md
Normal file
@@ -0,0 +1,527 @@
|
||||
# CamTalk Eino 框架与编排设计
|
||||
|
||||
> 创建日期:2026-06-19
|
||||
> 状态:已实施
|
||||
> 合并自:`10-Eino重构方案.md` + `11-Eino框架技术文档.md`
|
||||
|
||||
## 1. 概述
|
||||
|
||||
### 1.1 为什么选择 Eino
|
||||
|
||||
[CloudWeGo Eino](https://github.com/cloudwego/eino) 是字节跳动 CloudWeGo 团队开源的 AI 应用开发框架,提供基于图(Graph)的编排能力、组件抽象和流式处理支持。
|
||||
|
||||
CamTalk 使用 Eino 替代原有的手写 goroutine 管道,实现 STT → LLM → TTS 的声明式编排。
|
||||
|
||||
**技术选型对比:**
|
||||
|
||||
| 维度 | 手写 goroutine(旧方案) | Eino Graph(新方案) |
|
||||
|------|------------------------|---------------------|
|
||||
| 编排方式 | 手动 `go func()` + `sync.WaitGroup` | 声明式 DAG,类型安全 |
|
||||
| 流式处理 | 自定义 `chan` 传递 | `StreamReader` + `Pipe`,自动转换 |
|
||||
| 错误处理 | 各节点独立处理,不一致 | Graph 级别统一错误传播 |
|
||||
| 回调/AOP | 日志散落各处 | `callbacks.Handler` 统一注入 |
|
||||
| 配置灵活性 | Pipeline 创建时固定 | 每请求 `Option` 动态注入 |
|
||||
| 可测试性 | 需启动 goroutine | `Graph.Invoke()` 直接测试 |
|
||||
| 扩展性 | 修改 Pipeline 代码 | 添加节点 + 边,无侵入 |
|
||||
| 并发安全 | 手动 `sync` | State 自动加锁 |
|
||||
|
||||
**选择 Eino 的核心理由:**
|
||||
1. Go 原生,泛型支持,编译时类型检查
|
||||
2. 原生流式处理(`StreamReader`),适合 LLM token 级推送
|
||||
3. Graph 支持分支、并行、循环,满足当前和未来需求
|
||||
4. Callback 机制实现 AOP(日志、指标、消息推送)
|
||||
5. eino-ext 提供 OpenAI ChatModel 实现,直接对接 DashScope
|
||||
|
||||
### 1.2 旧方案的问题
|
||||
|
||||
当前后端 AI 编排层(`internal/orchestrator/pipeline.go`)为手写 goroutine 管道存在以下问题:
|
||||
|
||||
1. **编排逻辑硬编码**:STT→LLM→TTS 流程写死,扩展困难
|
||||
2. **并发控制粗糙**:手动 goroutine 调度,缺乏结构化流式传递
|
||||
3. **无回调/AOP 机制**:日志、指标、追踪散落各处
|
||||
4. **配置耦合**:模型名、TTS 参数等硬编码在结构体
|
||||
5. **错误处理不一致**:TTS 错误静默吞掉,STT/LLM 错误通过 Sender 发送
|
||||
|
||||
### 1.3 核心依赖版本
|
||||
|
||||
```go
|
||||
github.com/cloudwego/eino v0.9.9
|
||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.13
|
||||
```
|
||||
|
||||
## 2. Eino 核心概念
|
||||
|
||||
### 2.1 Lambda
|
||||
|
||||
Lambda 是 Graph 中的可组合函数单元,支持四种模式:
|
||||
|
||||
| 模式 | 函数签名 | 构造方法 | 说明 |
|
||||
|------|---------|---------|------|
|
||||
| Invoke | `I → O` | `compose.InvokableLambda()` | 同步调用 |
|
||||
| Stream | `I → StreamReader[O]` | `compose.StreamableLambda()` | 流式输出 |
|
||||
| Collect | `StreamReader[I] → O` | `compose.CollectableLambda()` | 流式输入 |
|
||||
| Transform | `StreamReader[I] → StreamReader[O]` | `compose.TransformableLambda()` | 双向流式 |
|
||||
|
||||
**返回类型**:所有 Lambda 构造函数返回 `*compose.Lambda`。
|
||||
|
||||
### 2.2 Graph
|
||||
|
||||
Graph 是有向无环图(DAG)编排器,支持:
|
||||
- **节点**:Lambda、ChatModel、ToolsNode 等
|
||||
- **边**:`g.AddEdge(from, to)` 定义数据流向
|
||||
- **分支**:`g.AddBranch()` 条件路由
|
||||
- **State**:`compose.WithGenLocalState()` 跨节点共享状态
|
||||
|
||||
```go
|
||||
g := compose.NewGraph[PipelineInput, PipelineOutput]()
|
||||
g.AddLambdaNode("stt", sttLambda)
|
||||
g.AddChatModelNode("llm", chatModel)
|
||||
g.AddEdge(compose.START, "stt")
|
||||
g.AddEdge("stt", "llm")
|
||||
g.AddEdge("llm", compose.END)
|
||||
|
||||
runnable, err := g.Compile(ctx)
|
||||
output, err := runnable.Invoke(ctx, input) // 同步调用
|
||||
stream, err := runnable.Stream(ctx, input) // 流式调用
|
||||
```
|
||||
|
||||
### 2.3 ChatModel
|
||||
|
||||
ChatModel 是 LLM 组件抽象,接口定义:
|
||||
|
||||
```go
|
||||
type BaseChatModel interface {
|
||||
Generate(ctx, []*schema.Message, ...Option) (*schema.Message, error)
|
||||
Stream(ctx, []*schema.Message, ...Option) (*schema.StreamReader[*schema.Message], error)
|
||||
}
|
||||
```
|
||||
|
||||
CamTalk 使用 `eino-ext/components/model/openai` 实现,通过 `BaseURL` 对接 DashScope:
|
||||
|
||||
```go
|
||||
chatModel, _ := openai.NewChatModel(ctx, &openai.ChatModelConfig{
|
||||
APIKey: cfg.AI.LLM.APIKey,
|
||||
Model: cfg.AI.LLM.Model,
|
||||
BaseURL: cfg.AI.LLM.Endpoint, // "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
})
|
||||
```
|
||||
|
||||
### 2.4 StreamReader
|
||||
|
||||
`schema.StreamReader[T]` 是 Eino 的流式数据抽象:
|
||||
- `sr.Recv()` 读取一帧,`io.EOF` 表示流结束
|
||||
- `schema.Pipe[T](bufSize)` 创建 `StreamReader` + `StreamWriter` 对
|
||||
- 框架自动处理 `T ↔ StreamReader[T]` 的转换(装箱/concat)
|
||||
|
||||
### 2.5 Callback
|
||||
|
||||
Callback 是 Eino 的 AOP 机制,支持节点生命周期钩子:
|
||||
|
||||
```go
|
||||
type Handler interface {
|
||||
OnStart(ctx, *RunInfo, CallbackInput) context.Context
|
||||
OnEnd(ctx, *RunInfo, CallbackOutput) context.Context
|
||||
OnError(ctx, *RunInfo, error) context.Context
|
||||
OnStartWithStreamInput(ctx, *RunInfo, *StreamReader[CallbackInput]) context.Context
|
||||
OnEndWithStreamOutput(ctx, *RunInfo, *StreamReader[CallbackOutput]) context.Context
|
||||
}
|
||||
```
|
||||
|
||||
CamTalk 使用 `utils/callbacks.NewHandlerHelper()` 构建 typed handler:
|
||||
- `ModelCallbackHandler.OnEndWithStreamOutput`:逐 token 推送 `llm_chunk`
|
||||
|
||||
### 2.6 State
|
||||
|
||||
Graph 全局状态,通过 `WithGenLocalState` 注册:
|
||||
|
||||
```go
|
||||
type PipelineState struct {
|
||||
FullResponse strings.Builder
|
||||
TranscribedText string
|
||||
TokenUsage *TokenUsage
|
||||
}
|
||||
|
||||
g := compose.NewGraph[I, O](compose.WithGenLocalState(func(ctx context.Context) *PipelineState {
|
||||
return &PipelineState{}
|
||||
}))
|
||||
```
|
||||
|
||||
节点通过 `compose.ProcessState` 读写 State。
|
||||
|
||||
## 3. CamTalk Graph 设计
|
||||
|
||||
### 3.1 拓扑结构
|
||||
|
||||
```
|
||||
START → STT → History → ChatModel → Splitter → TTS → Done → END
|
||||
```
|
||||
|
||||
| 节点 | 类型 | 输入 → 输出 | 职责 |
|
||||
|------|------|------------|------|
|
||||
| STT | InvokableLambda | `PipelineInput → STTOutput` | 语音识别,写入 State |
|
||||
| History | InvokableLambda | `STTOutput → []*schema.Message` | 组装提示词和历史 |
|
||||
| ChatModel | ChatModel(原生) | `[]*schema.Message → StreamReader[*Message]` | LLM 流式推理 |
|
||||
| Splitter | TransformableLambda | `StreamReader[string] → StreamReader[[]string]` | 句子切分 |
|
||||
| TTS | InvokableLambda | `[]string → struct{}` | 语音合成,推送音频 |
|
||||
| Done | InvokableLambda | `struct{} → PipelineOutput` | 发送 llm_done |
|
||||
|
||||
### 3.2 数据类型定义
|
||||
|
||||
```go
|
||||
// Graph 统一输入
|
||||
type PipelineInput struct {
|
||||
AudioData []byte // base64 解码后的音频(可选)
|
||||
ImageData []byte // base64 解码后的图像(可选)
|
||||
Text string // 直接文本输入(可选,跳过 STT)
|
||||
SessionID string
|
||||
RequestID string
|
||||
Language string // zh / en
|
||||
Scenario string // free_chat, interviewer, etc.
|
||||
}
|
||||
|
||||
// Graph 统一输出
|
||||
type PipelineOutput struct {
|
||||
TranscribedText string // STT 结果
|
||||
FullResponse string // LLM 完整回复
|
||||
}
|
||||
|
||||
// Pipeline State(跨节点共享)
|
||||
type PipelineState struct {
|
||||
FullResponse strings.Builder
|
||||
TranscribedText string
|
||||
TokenUsage *TokenUsage
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 流式模式
|
||||
|
||||
Graph 使用 **Stream 模式**调用:
|
||||
- 内部所有节点以 Transform 模式运行
|
||||
- ChatModel 的 `Stream()` 方法实现真正的 token 级流式
|
||||
- 适配器消费 `StreamReader[PipelineOutput]` 触发整条链路
|
||||
|
||||
### 3.4 消息推送机制
|
||||
|
||||
| 消息 | 推送方式 | 时机 |
|
||||
|------|---------|------|
|
||||
| `stt_result` | Lambda 内部直接调用 Sender | STT 完成后 |
|
||||
| `llm_chunk` | Callback `OnEndWithStreamOutput` | ChatModel 逐 token |
|
||||
| `tts_audio` | Lambda 内部直接调用 Sender | TTS 逐句合成 |
|
||||
| `llm_done` | Lambda 内部直接调用 Sender | Done 节点执行时 |
|
||||
|
||||
**Context 注入**:Sender、RequestID、SessionID、PipelineState 通过 `context.WithValue` 传递。
|
||||
|
||||
### 3.5 多模态支持
|
||||
|
||||
History 节点将图片构建为 `schema.Message.UserInputMultiContent`:
|
||||
|
||||
```go
|
||||
systemMsg.UserInputMultiContent = []schema.MessageInputPart{
|
||||
{
|
||||
Type: schema.ChatMessagePartTypeImageURL,
|
||||
Image: &schema.MessageInputImage{
|
||||
MessagePartCommon: schema.MessagePartCommon{
|
||||
Base64Data: &base64Str,
|
||||
MIMEType: "image/jpeg",
|
||||
},
|
||||
Detail: schema.ImageURLDetailAuto,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 实现要点
|
||||
|
||||
### 4.1 目录结构
|
||||
|
||||
```
|
||||
backend/internal/eino/
|
||||
├── types.go # PipelineInput/Output、STTOutput、TokenUsage
|
||||
├── state.go # PipelineState(跨节点状态)
|
||||
├── callback.go # Callback handler(LLM token 推送)
|
||||
├── graph.go # Graph 构建与编译
|
||||
├── adapter.go # EinoOrchestrator(Orchestrator 接口适配器)
|
||||
├── nodes_stt.go # STT Lambda
|
||||
├── nodes_history.go # 历史组装 Lambda
|
||||
├── nodes_splitter.go # 句子分割 Transform Lambda
|
||||
├── nodes_tts.go # TTS Lambda
|
||||
├── nodes_done.go # Done Lambda
|
||||
└── graph_test.go # 单元测试
|
||||
```
|
||||
|
||||
### 4.2 关键节点实现
|
||||
|
||||
#### STT Lambda(可选跳过)
|
||||
|
||||
```go
|
||||
func sttLambda(sttSvc stt.Service) func(ctx context.Context, input PipelineInput) (STTOutput, error) {
|
||||
return func(ctx context.Context, input PipelineInput) (STTOutput, error) {
|
||||
// 文本模式:跳过 STT
|
||||
if input.Text != "" {
|
||||
return STTOutput{Text: input.Text, Language: input.Language}, nil
|
||||
}
|
||||
|
||||
// 调用 STT 服务
|
||||
result, err := sttSvc.Recognize(ctx, input.AudioData, stt.Options{
|
||||
Language: input.Language,
|
||||
})
|
||||
if err != nil {
|
||||
return STTOutput{}, fmt.Errorf("STT error: %w", err)
|
||||
}
|
||||
|
||||
return STTOutput{Text: result.Text, Language: result.Language}, nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Splitter Transform Lambda(句子切分)
|
||||
|
||||
```go
|
||||
func splitterLambda() func(ctx, *schema.StreamReader[*schema.Message]) (*schema.StreamReader[[]string], error) {
|
||||
return func(ctx context.Context, stream *schema.StreamReader[*schema.Message]) (*schema.StreamReader[[]string], error) {
|
||||
sr, sw := schema.Pipe[[]string](8)
|
||||
|
||||
go func() {
|
||||
defer sw.Close()
|
||||
var buffer []rune
|
||||
|
||||
for {
|
||||
chunk, err := stream.Recv()
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
if len(buffer) > 0 {
|
||||
sw.Send([]string{string(buffer)}, nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
sw.Send(nil, err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, r := range chunk.Content {
|
||||
buffer = append(buffer, r)
|
||||
if isSentenceDelimiter(r) {
|
||||
sw.Send([]string{string(buffer)}, nil)
|
||||
buffer = buffer[:0]
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return sr, nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### TTS Lambda(并行合成)
|
||||
|
||||
```go
|
||||
func ttsLambda(ttsSvc tts.Service, sender orchestrator.Sender) func(ctx, []string) (struct{}, error) {
|
||||
return func(ctx context.Context, sentences []string) (struct{}, error) {
|
||||
for _, sentence := range sentences {
|
||||
if sentence == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 调用 TTS 服务
|
||||
audioData, err := ttsSvc.Synthesize(ctx, sentence, tts.Options{})
|
||||
if err != nil {
|
||||
// TTS 失败不中断流程,仅记录日志
|
||||
log.Warn("TTS synthesis failed", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
// 推送音频到客户端
|
||||
sender.SendTTSAudio(orchestrator.TTSAudioPayload{
|
||||
Audio: audioData,
|
||||
Format: "mp3",
|
||||
})
|
||||
}
|
||||
|
||||
return struct{}{}, nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 Callback 集成
|
||||
|
||||
```go
|
||||
// ModelCallbackHandler 用于 LLM token 推送
|
||||
type ModelCallbackHandler struct {
|
||||
sender orchestrator.Sender
|
||||
}
|
||||
|
||||
func (h *ModelCallbackHandler) OnEndWithStreamOutput(
|
||||
ctx context.Context,
|
||||
info *callbacks.RunInfo,
|
||||
output *schema.StreamReader[*schema.Message],
|
||||
) context.Context {
|
||||
// 逐 token 推送到客户端
|
||||
for {
|
||||
msg, err := output.Recv()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return ctx
|
||||
}
|
||||
|
||||
h.sender.SendLLMChunk(orchestrator.LLMChunkPayload{
|
||||
Content: msg.Content,
|
||||
})
|
||||
}
|
||||
|
||||
return ctx
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 按请求动态配置
|
||||
|
||||
```go
|
||||
// 运行时 Option:每请求可变
|
||||
func WithModelName(name string) compose.Option {
|
||||
return compose.WithChatModelOption(model.WithModel(name))
|
||||
}
|
||||
|
||||
func WithTemperature(temp float32) compose.Option {
|
||||
return compose.WithChatModelOption(model.WithTemperature(temp))
|
||||
}
|
||||
|
||||
// WebSocket Handler 中的调用
|
||||
func (c *Client) handleQuery(req QueryRequest) {
|
||||
opts := []compose.Option{}
|
||||
|
||||
if req.Model != "" {
|
||||
opts = append(opts, WithModelName(req.Model))
|
||||
}
|
||||
|
||||
output, err := c.pipeline.Stream(ctx, PipelineInput{...}, opts...)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.5 注意事项
|
||||
|
||||
#### 值类型 vs 指针类型
|
||||
Graph 泛型参数必须使用值类型(`PipelineInput`/`PipelineOutput`),所有 Lambda 的输入输出也使用值类型。框架在 Transform 模式下会自动处理 `T` 和 `StreamReader[T]` 的转换。
|
||||
|
||||
#### Callback 运行时传入
|
||||
Callback 通过 `Stream()` 的 option 传入,不在 `Compile()` 时注册:
|
||||
|
||||
```go
|
||||
streamReader, err := runnable.Stream(ctx, input, compose.WithCallbacks(handler))
|
||||
```
|
||||
|
||||
#### eino-ext 与 DashScope 兼容性
|
||||
eino-ext OpenAI ChatModel 通过 `BaseURL` 对接 DashScope 兼容接口。需注意:
|
||||
- 多模态图片使用 `Base64Data` + `MIMEType` 格式
|
||||
- `Timeout` 控制单次请求超时
|
||||
- 流式输出通过 `Stream()` 方法获取 `StreamReader[*schema.Message]`
|
||||
|
||||
#### 框架自动类型转换
|
||||
Eino 框架在编排场景中自动处理以下转换:
|
||||
- **T → StreamReader[T]**:将完整值装箱为单帧流(非流式 → 假流式)
|
||||
- **StreamReader[T] → T**:将流 concat 为完整值(流式 → 非流式)
|
||||
|
||||
这使得不同流式模式的节点可以无缝连接。
|
||||
|
||||
## 5. 测试策略
|
||||
|
||||
### 5.1 单元测试
|
||||
|
||||
```go
|
||||
func TestPipelineGraph_WithTextInput(t *testing.T) {
|
||||
mockLLM := &mockChatModel{responses: []string{"你好!"}}
|
||||
mockSender := &mockSender{}
|
||||
|
||||
graph, err := NewPipelineGraph(ctx, &GraphOption{
|
||||
ChatModel: mockLLM,
|
||||
Sender: mockSender,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
output, err := graph.Invoke(ctx, PipelineInput{
|
||||
Text: "你好",
|
||||
SessionID: "test-session",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "你好!", output.FullResponse)
|
||||
assert.True(t, mockSender.LLMDoneSent)
|
||||
}
|
||||
|
||||
func TestPipelineGraph_WithAudioInput(t *testing.T) {
|
||||
mockSTT := &mockSTT{text: "你好"}
|
||||
mockLLM := &mockChatModel{responses: []string{"你好!"}}
|
||||
mockTTS := &mockTTS{audio: []byte("fake-audio")}
|
||||
mockSender := &mockSender{}
|
||||
|
||||
graph, _ := NewPipelineGraph(ctx, &GraphOption{
|
||||
ChatModel: mockLLM,
|
||||
STTService: mockSTT,
|
||||
TTSService: mockTTS,
|
||||
Sender: mockSender,
|
||||
})
|
||||
|
||||
output, err := graph.Invoke(ctx, PipelineInput{
|
||||
AudioData: []byte("fake-audio-data"),
|
||||
SessionID: "test-session",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, mockSender.TTSAudioSent)
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 集成测试
|
||||
|
||||
- 启动真实 OpenAI API 调用(使用测试 key)
|
||||
- 验证 WebSocket 消息序列:`stt_result` → `llm_chunk` × N → `llm_done` → `tts_audio` × N
|
||||
- 验证 interrupt 取消功能
|
||||
- 验证多并发请求隔离
|
||||
|
||||
## 6. 未来扩展路径
|
||||
|
||||
基于 Eino Graph 的重构完成后,可无缝扩展:
|
||||
|
||||
1. **ReAct Agent**:Graph 添加 Branch 节点,实现 LLM → Tool → LLM 循环
|
||||
2. **多模态理解**:添加视觉分析 Lambda 节点(图像描述 → 上下文注入)
|
||||
3. **Model Router**:Graph 前置分支节点,按场景/成本路由不同 LLM
|
||||
4. **Rate Limiter**:通过 Callback 的 OnStart 实现令牌桶
|
||||
5. **Checkpoint/Resume**:利用 Eino 的 CheckpointStore 实现断点续传
|
||||
6. **Multi-Agent**:利用 ADK 的 Supervisor/SequentialAgent 编排复杂对话流程
|
||||
|
||||
## 附录:关键 Eino API 参考
|
||||
|
||||
```go
|
||||
// 构建 Graph
|
||||
g := compose.NewGraph[I, O](opts...)
|
||||
g.AddChatModelNode(key, chatModel)
|
||||
g.AddLambdaNode(key, lambda, opts...)
|
||||
g.AddEdge(from, to)
|
||||
g.AddBranch(from, branchFunc, mapping)
|
||||
|
||||
// 编译
|
||||
runnable, err := g.Compile(ctx, opts...)
|
||||
|
||||
// 执行四种模式
|
||||
output, err := runnable.Invoke(ctx, input, opts...)
|
||||
stream, err := runnable.Stream(ctx, input, opts...)
|
||||
output, err := runnable.Collect(ctx, inputStream, opts...)
|
||||
stream, err := runnable.Transform(ctx, inputStream, opts...)
|
||||
|
||||
// Lambda 四种构造器
|
||||
lambda := compose.InvokableLambda(fn) // I → O
|
||||
lambda := compose.StreamableLambda(fn) // I → StreamReader[O]
|
||||
lambda := compose.CollectableLambda(fn) // StreamReader[I] → O
|
||||
lambda := compose.TransformableLambda(fn) // StreamReader[I] → StreamReader[O]
|
||||
|
||||
// Stream 操作
|
||||
sr, sw := schema.Pipe[T](bufSize)
|
||||
sw.Send(chunk, err)
|
||||
chunk, err := sr.Recv()
|
||||
sw.Close()
|
||||
|
||||
// Option
|
||||
compose.WithCallbacks(handler)
|
||||
compose.WithCallbacks(handler).DesignateNode("node_key")
|
||||
compose.WithChatModelOption(model.WithTemperature(0.7))
|
||||
compose.WithGenLocalState(genFunc)
|
||||
```
|
||||
@@ -1,5 +0,0 @@
|
||||
1.视频录制
|
||||
2.对话翻译
|
||||
3.对话总结
|
||||
4.手动对话功能
|
||||
5.视频框大小可调整,可最小化然后拖动
|
||||
352
docs/09-情景切换.md
Normal file
352
docs/09-情景切换.md
Normal file
@@ -0,0 +1,352 @@
|
||||
# 情景切换功能
|
||||
|
||||
**状态**: ✅ 已完成
|
||||
|
||||
---
|
||||
|
||||
## 功能概述
|
||||
|
||||
情景切换功能允许用户选择不同的对话场景,AI 会根据选择的情景扮演不同的角色:
|
||||
|
||||
| 情景 | AI 角色 | 主要功能 |
|
||||
|------|---------|---------|
|
||||
| 🎯 模拟面试官 | 资深面试官 | 提出面试问题,评估候选人能力,给出反馈 |
|
||||
| 📚 英语老师 | 英语外教 | 全英文对话,纠正语法错误,引导深入交流 |
|
||||
| ⚔️ 辩论对手 | 辩论选手 | 站在反方立场,用逻辑和证据反驳观点 |
|
||||
| 🌐 同声翻译 | 翻译员 | 实时中英互译,口语化翻译,无额外解释 |
|
||||
| 💬 自由对话 | 视觉助手 | 通用视觉对话助手(默认) |
|
||||
|
||||
### 核心特性
|
||||
|
||||
1. **情景首句引导**:切换情景后,AI 自动发送第一句话引导用户进入角色
|
||||
2. **情景提示卡片**:对话顶部显示当前情景模式的蓝色提示卡片
|
||||
3. **增强 System Prompt**:每个情景有详细的角色定位、交互规则和约束
|
||||
4. **多语言支持**:完整支持中文、英文、日文界面
|
||||
|
||||
---
|
||||
|
||||
## 技术实现
|
||||
|
||||
### 后端实现
|
||||
|
||||
#### 1. 情景 Prompt 定义
|
||||
|
||||
**文件**: `backend/internal/ai/llm/scenarios.go`
|
||||
|
||||
- 扩展 `scenarioPrompt` 结构体,新增首句引导字段(GreetingZH/EN/JA)
|
||||
- 增强所有情景的 System Prompt(添加角色定位、交互规则、约束)
|
||||
- 新增函数 `GetScenarioGreeting(scenarioID, language string) string`
|
||||
|
||||
**示例 Prompt**(模拟面试官):
|
||||
|
||||
```go
|
||||
"interviewer": {
|
||||
ZH: `你是一位资深面试官。你通过摄像头观察面试者...
|
||||
|
||||
【角色定位】
|
||||
- 你是面试官,不是助手或顾问
|
||||
- 你的目标是评估候选人的能力
|
||||
- 保持专业、客观、礼貌
|
||||
|
||||
【交互规则】
|
||||
1. 每次只问一个问题,等用户回答后再追问
|
||||
2. 问题要有层次:自我介绍 → 专业问题 → 情景题
|
||||
3. 对用户的回答给出简短点评,然后追问
|
||||
...`,
|
||||
GreetingZH: "你好!我是今天的面试官。让我们先从自我介绍开始...",
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 首句引导推送
|
||||
|
||||
**文件**: `backend/internal/ws/handler.go`
|
||||
|
||||
在处理 `config` 消息时,如果切换到非自由对话情景,自动返回首句引导:
|
||||
|
||||
```go
|
||||
case "config":
|
||||
// ... 更新配置 ...
|
||||
|
||||
// 如果切换了情景(非自由对话),返回首句引导
|
||||
if scenarioID != "" && scenarioID != "free_chat" {
|
||||
greeting := llm.GetScenarioGreeting(scenarioID, sess.Config.Language)
|
||||
if greeting != "" {
|
||||
// 发送 llm_chunk 和 llm_done 消息
|
||||
// 追加到历史记录
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. State 初始化
|
||||
|
||||
**文件**: `backend/internal/eino/adapter.go`
|
||||
|
||||
从 `PipelineInput` 复制元数据到 `PipelineState`,确保情景配置正确传递到所有节点:
|
||||
|
||||
```go
|
||||
state := genLocalState(ctx)
|
||||
state.SessionID = input.SessionID
|
||||
state.RequestID = input.RequestID
|
||||
state.ImageData = input.ImageData
|
||||
state.Scenario = input.Scenario // 关键:复制情景配置
|
||||
state.Language = input.Language
|
||||
state.DetailLevel = sess.Config.DetailLevel
|
||||
state.TTSEnabled = input.TTSEnabled
|
||||
ctx = WithPipelineState(ctx, state)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 前端实现
|
||||
|
||||
#### 1. 情景提示卡片
|
||||
|
||||
**文件**: `frontend/src/components/ChatPanel/index.tsx`
|
||||
|
||||
在对话列表顶部(非空状态 + 非自由对话模式)添加情景提示卡片:
|
||||
|
||||
```tsx
|
||||
{messages.length > 0 && !isFreeChat && (
|
||||
<div className="chat-panel__scenario-hint">
|
||||
<div className="scenario-hint-card">
|
||||
<span className="scenario-hint-card__icon">
|
||||
{scenarios.find(s => s.id === activeScenario)?.icon}
|
||||
</span>
|
||||
<div className="scenario-hint-card__text">
|
||||
<strong>{t(scenarios.find(s => s.id === activeScenario)?.nameKey || "")}</strong>
|
||||
<p>{t(`scenario.${activeScenario}.hint`)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**显示效果**:
|
||||
- 蓝色渐变背景(135deg 从蓝到紫)
|
||||
- 左侧大图标 + 右侧标题和说明
|
||||
- 最大宽度 520px,响应式布局
|
||||
- 柔和阴影和半透明边框
|
||||
|
||||
#### 2. WebSocket 消息发送
|
||||
|
||||
**文件**: `frontend/src/hooks/useVisionSession.ts`
|
||||
|
||||
发送 config 消息时包含 `scenario` 字段:
|
||||
|
||||
```typescript
|
||||
send({
|
||||
type: "config",
|
||||
payload: {
|
||||
tts_enabled: config.ttsEnabled,
|
||||
detail_level: config.detailLevel,
|
||||
language: config.language,
|
||||
scenario: config.scenario, // 情景配置
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### 3. 样式实现
|
||||
|
||||
**文件**: `frontend/src/App.css`
|
||||
|
||||
情景提示卡片样式:
|
||||
|
||||
```css
|
||||
.scenario-hint-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, rgba(99, 102, 241, 0.08) 100%);
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06);
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. 多语言翻译
|
||||
|
||||
**文件**: `frontend/src/lib/i18n/{zh-CN,en-US,ja-JP}.ts`
|
||||
|
||||
新增翻译 key:
|
||||
|
||||
```typescript
|
||||
"scenario.interviewer.hint": "AI 会扮演面试官,逐步提出专业问题并点评你的回答",
|
||||
"scenario.englishTeacher.hint": "AI 会用英语对话,纠正语法错误并引导深入交流",
|
||||
"scenario.debate.hint": "AI 会站在反方立场,用逻辑和证据反驳你的观点",
|
||||
"scenario.interpreter.hint": "AI 会实时翻译你的话(中英互译),无解释评论",
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 数据流
|
||||
|
||||
### WebSocket 协议
|
||||
|
||||
**客户端 → 服务端**(config 消息):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "config",
|
||||
"payload": {
|
||||
"tts_enabled": true,
|
||||
"detail_level": "low",
|
||||
"language": "zh-CN",
|
||||
"scenario": "interviewer"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**服务端 → 客户端**(首句引导):
|
||||
|
||||
```json
|
||||
// llm_chunk
|
||||
{
|
||||
"type": "llm_chunk",
|
||||
"request_id": "scenario_greeting",
|
||||
"delta": "你好!我是今天的面试官...",
|
||||
"role": "assistant"
|
||||
}
|
||||
|
||||
// llm_done
|
||||
{
|
||||
"type": "llm_done",
|
||||
"request_id": "scenario_greeting",
|
||||
"full_text": "你好!我是今天的面试官...",
|
||||
"tokens_used": {"prompt": 0, "completion": 0, "total": 0}
|
||||
}
|
||||
```
|
||||
|
||||
### System Prompt 构建流程
|
||||
|
||||
```
|
||||
sess.Config.Scenario = "interviewer"
|
||||
↓
|
||||
PipelineInput.Scenario = "interviewer"
|
||||
↓
|
||||
PipelineState.Scenario = "interviewer" (adapter.go 复制)
|
||||
↓
|
||||
nodes_history.go 读取 state.Scenario
|
||||
↓
|
||||
scenarioPrompt := llm.GetScenarioPrompt("interviewer", "zh-CN")
|
||||
↓
|
||||
systemPrompt := llm.BuildSystemPrompt(language, detailLevel, scenarioPrompt)
|
||||
↓
|
||||
messages[0] = {Role: "system", Content: systemPrompt}
|
||||
↓
|
||||
ChatModel 接收到情景 Prompt
|
||||
↓
|
||||
LLM 按情景角色生成回复
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 使用指南
|
||||
|
||||
### 快速验证
|
||||
|
||||
1. **打开浏览器** → http://localhost:5173
|
||||
2. **登录系统**
|
||||
3. **切换情景** → 右侧配置面板 → 对话情景 → 模拟面试官
|
||||
4. **观察现象**:
|
||||
- ✨ AI 立即说:"你好!我是今天的面试官。让我们先从自我介绍开始..."
|
||||
- ✨ 对话框顶部显示蓝色提示卡片
|
||||
5. **验证效果** → 发送:"你是谁?"
|
||||
- ✅ **正确回复**:"我是今天的面试官..."
|
||||
- ❌ **错误回复**:"我是通义千问..."
|
||||
|
||||
### 功能测试清单
|
||||
|
||||
| 测试项 | 操作步骤 | 预期结果 |
|
||||
|--------|---------|---------|
|
||||
| **首句引导** | 切换到"模拟面试官" | AI 自动说:"你好!我是今天的面试官..." |
|
||||
| **情景生效** | 问 "你是谁?" | AI 回答:"我是今天的面试官..." |
|
||||
| **提示卡片** | 发送一条消息后查看顶部 | 显示蓝色卡片:"🎯 模拟面试官 \| AI 会扮演面试官..." |
|
||||
| **语言联动** | 切换到"英语老师" | 语言自动切换到 en-US,AI 用英语回复 |
|
||||
| **持久化** | 切换情景后刷新页面 | 情景配置保持,首句仍在历史中 |
|
||||
| **多情景** | 依次测试所有情景 | 每个情景 AI 回复风格明显不同 |
|
||||
|
||||
---
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 如果情景不生效
|
||||
|
||||
1. **检查后端日志**:
|
||||
```bash
|
||||
grep "config updated" /tmp/camtalk_server.log | tail -5
|
||||
grep "历史组装完成" /tmp/camtalk_server.log | tail -5
|
||||
```
|
||||
|
||||
- 如果 `scenario=` 是空的,说明前端未发送或后端未接收
|
||||
- 如果 `scenario=interviewer` 正确,但 AI 回复仍是通用的,可能是 LLM 模型问题
|
||||
|
||||
2. **检查前端 WebSocket 消息**(浏览器 DevTools → Network → WS):
|
||||
```json
|
||||
{
|
||||
"type": "config",
|
||||
"payload": {
|
||||
"scenario": "interviewer" // 确认存在
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **检查会话配置是否保存**:
|
||||
- 切换情景后,LocalStorage 中应该有 `camtalk_config`
|
||||
- 内容应包含 `"scenario": "interviewer"`
|
||||
|
||||
4. **清除缓存重试**:
|
||||
```bash
|
||||
# 浏览器:清除 LocalStorage
|
||||
# 后端:重启服务
|
||||
# 前端:刷新页面
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 后续优化建议
|
||||
|
||||
### P2(强烈推荐)
|
||||
|
||||
1. **情景切换时创建新会话**
|
||||
- 避免历史对话干扰新情景
|
||||
- 弹窗确认:"切换情景会创建新会话,当前对话将保存。是否继续?"
|
||||
- 实现难度:⭐⭐
|
||||
- 用户价值:⭐⭐⭐⭐
|
||||
|
||||
2. **进一步增强 System Prompt**
|
||||
- 增加示例对话(Few-shot Prompting)
|
||||
- 增加"禁止事项"列表
|
||||
- 实现难度:⭐
|
||||
- 效果提升:⭐⭐⭐
|
||||
|
||||
### P3(可选)
|
||||
|
||||
1. **情景专属 UI 主题色**
|
||||
- 面试官 → 深蓝色
|
||||
- 英语老师 → 绿色
|
||||
- 辩论 → 红色
|
||||
- 翻译 → 紫色
|
||||
|
||||
2. **切换动画与音效**
|
||||
- 切换时播放短音效
|
||||
- 聊天面板淡出淡入动画
|
||||
|
||||
---
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
### 后端(3 个文件)
|
||||
|
||||
- `backend/internal/eino/adapter.go` — 修复 State 初始化
|
||||
- `backend/internal/ws/handler.go` — 添加首句引导
|
||||
- `backend/internal/ai/llm/scenarios.go` — 增强 Prompt + 首句
|
||||
|
||||
### 前端(5 个文件)
|
||||
|
||||
- `frontend/src/hooks/useVisionSession.ts` — 修复 scenario 发送
|
||||
- `frontend/src/components/ChatPanel/index.tsx` — 添加提示卡片
|
||||
- `frontend/src/App.css` — 卡片样式
|
||||
- `frontend/src/lib/i18n/zh-CN.ts` — 中文翻译
|
||||
- `frontend/src/lib/i18n/en-US.ts` — 英文翻译
|
||||
- `frontend/src/lib/i18n/ja-JP.ts` — 日文翻译
|
||||
@@ -1,49 +0,0 @@
|
||||
# 技术名词解释
|
||||
|
||||
对架构文档中技术选型表里出现的所有关键名词的简明解释。
|
||||
|
||||
---
|
||||
|
||||
## 前端相关
|
||||
|
||||
| 名词 | 一句话 | 展开 |
|
||||
|------|--------|------|
|
||||
| **React 18** | 组件化 UI 框架 | Facebook 开源,把页面拆成组件搭积木拼装。18 版本支持并发渲染。 |
|
||||
| **TypeScript** | 带类型的 JavaScript | 在 JS 基础上增加类型声明,编译阶段就能发现类型错误。 |
|
||||
| **Vite** | 前端构建工具 | 利用浏览器原生 ES Module,开发时毫秒级热更新(HMR),构建产物小。 |
|
||||
| **WebSocket** | 浏览器与服务器的双向通道 | HTTP 是"一问一答",WebSocket 像打电话——接通后双方随时互发消息,适合实时对话场景。 |
|
||||
| **ONNX Runtime Web** | 浏览器端 AI 推理引擎 | 微软定义的通用模型格式 ONNX 的运行引擎,可在浏览器中用 WASM 加速跑轻量模型(如 VAD、关键帧检测),零延迟、不耗服务器资源。 |
|
||||
| **VAD** | 语音活动检测 | Voice Activity Detection,检测"人有没有在说话"。WebRTC 内置了高效的 VAD 算法,本项目用 @ricky0123/vad-web 包装。 |
|
||||
| **MediaDevices API** | 浏览器摄像头/麦克风接口 | `navigator.mediaDevices.getUserMedia()` 是浏览器音视频采集的唯一标准入口,无需插件。 |
|
||||
|
||||
## 后端相关
|
||||
|
||||
| 名词 | 一句话 | 展开 |
|
||||
|------|--------|------|
|
||||
| **Go (Golang)** | 高并发后端语言 | Google 开发,杀手锏是 goroutine——极轻量协程,一个程序可轻松开几万个,每个只占几 KB 内存,适合管理大量 WebSocket 长连接。 |
|
||||
| **gorilla/websocket** | Go WebSocket 库 | Go 标准库无内置 WebSocket 支持,此库是社区最成熟的选择,处理了协议握手、帧解析等底层细节。 |
|
||||
| **Redis** | 内存 KV 数据库 | 数据放在内存里,读写微秒级。本项目用于会话状态和对话上下文缓存,支持 TTL 过期自动清理。多 Gateway 实例通过 Redis 共享状态。 |
|
||||
| **Viper** | Go 配置管理 | 读取 JSON/YAML/TOML 配置,支持环境变量覆盖,方便开发/测试/生产环境用不同配置。 |
|
||||
| **Zap** | Go 结构化日志 | Uber 开源,输出 JSON 格式日志,方便工具搜索分析,性能远超标准库 log。 |
|
||||
|
||||
## AI 服务相关
|
||||
|
||||
| 名词 | 一句话 | 展开 |
|
||||
|------|--------|------|
|
||||
| **多模态 LLM** | 能读文字又能看图片的大语言模型 | GPT-4o(OpenAI)/ Claude Sonnet(Anthropic),给照片+问题能"看懂"照片再回答。 |
|
||||
| **STT** | 语音转文字 | Speech-to-Text。Deepgram 流式识别延迟 <500ms。备选 FunASR(阿里开源,可自部署)。 |
|
||||
| **TTS** | 文字转语音 | Text-to-Speech。OpenAI TTS 音质接近真人。Edge TTS 免费。支持流式——边生成边读,不必等全部生成完。 |
|
||||
| **GPT-4o-mini** | 轻量分类模型 | 又快又便宜的小模型,用于模型路由——先用小模型判断问题复杂度,简单问题走小模型省 API 费用。 |
|
||||
|
||||
## AI 编排框架相关
|
||||
|
||||
| 名词 | 一句话 | 展开 |
|
||||
|------|--------|------|
|
||||
| **Eino** | 字节跳动开源的 Go AI 应用开发框架 | CloudWeGo Eino,提供 Graph DAG 编排、组件抽象(ChatModel/Tool 等)、流式处理(StreamReader)和 Callback AOP 机制。CamTalk 用它替代手写 goroutine 管道。 |
|
||||
| **compose.Graph** | Eino 的 DAG 编排器 | 声明式有向无环图,节点可以是 Lambda、ChatModel、ToolsNode 等,边定义数据流向。支持分支(AddBranch)、并行和循环。 |
|
||||
| **Lambda** | Graph 中的可组合函数单元 | 四种模式:InvokableLambda(同步)、StreamableLambda(流式输出)、CollectableLambda(流式输入)、TransformableLambda(双向流式)。 |
|
||||
| **StreamReader** | Eino 的流式数据抽象 | `schema.StreamReader[T]`,类似 io.Reader 的语义,`Recv()` 读取一帧,`io.EOF` 表示流结束。`schema.Pipe[T]()` 创建 StreamReader + StreamWriter 对。 |
|
||||
| **Callback** | Eino 的 AOP 机制 | 类似中间件的钩子,支持节点生命周期回调(OnStart/OnEnd/OnError/OnEndWithStreamOutput)。CamTalk 用它实现 LLM token 实时推送到客户端。 |
|
||||
| **ChatModel** | Eino 的 LLM 组件抽象 | 统一接口 `Generate()` 和 `Stream()`,eino-ext 提供 OpenAI 兼容实现,通过 BaseURL 可对接 DashScope 等兼容接口。 |
|
||||
| **eino-ext** | Eino 的组件扩展库 | 提供具体组件实现:OpenAI ChatModel、各种 Tool Backend 等。CamTalk 使用 `eino-ext/components/model/openai`。 |
|
||||
| **PipelineState** | Graph 级别的共享状态 | 通过 `compose.WithGenLocalState` 注册,每请求独立实例,线程安全(sync.Mutex),跨节点共享数据(如 LLM 完整回复、Token 用量)。 |
|
||||
@@ -1,810 +0,0 @@
|
||||
# CamTalk 后端 AI 编排层 Eino 重构方案
|
||||
|
||||
> 创建日期:2026-06-19
|
||||
> 状态:已实施(实施记录见 [12-Eino重构实施记录](12-Eino重构实施记录.md))
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
### 1.1 现状问题
|
||||
|
||||
当前后端 AI 编排层(`internal/orchestrator/pipeline.go`)为手写 goroutine 管道:
|
||||
|
||||
```
|
||||
STT → LLM(Stream) ──→ Splitter → TTS(Stream) → Sender
|
||||
└→ Sender(LLMChunk)
|
||||
```
|
||||
|
||||
存在以下问题:
|
||||
|
||||
1. **编排逻辑硬编码**:STT→LLM→TTS 流程写死在 `ProcessQuery()` 中,扩展新流程(如视觉分析链路、多轮工具调用)需要重写 goroutine 调度
|
||||
2. **并发控制粗糙**:手动 `go func()` + `sync.WaitGroup`,缺乏结构化的流式数据传递
|
||||
3. **无回调/AOP 机制**:日志、指标、追踪散落在各处,无法统一注入
|
||||
4. **配置耦合**:模型名、TTS 参数等硬编码在 Pipeline 结构体,无法按请求动态切换
|
||||
5. **错误处理不一致**:TTS 错误被静默吞掉,STT/LLM 错误通过 Sender 发送,缺乏统一模式
|
||||
|
||||
### 1.2 重构目标
|
||||
|
||||
| 目标 | 说明 |
|
||||
|------|------|
|
||||
| 用 Eino Graph 替换手写 Pipeline | 声明式编排,类型安全,可组合 |
|
||||
| 流式处理原生支持 | 利用 Eino 的 Transform/Stream 模式,替代手动 goroutine |
|
||||
| 统一回调机制 | 通过 Eino Callback 实现日志、指标、追踪的 AOP |
|
||||
| 按请求动态配置 | 利用 Eino Option 机制,支持每请求切换模型/参数 |
|
||||
| 保持 API 兼容 | WebSocket 协议、REST API、Session 管理不变 |
|
||||
| 渐进式迁移 | 可分阶段实施,新旧编排器并存 |
|
||||
|
||||
## 2. Eino 编排模型选择
|
||||
|
||||
### 2.1 为什么选 Graph 而非 Chain 或 Workflow
|
||||
|
||||
| 编排模式 | 适用场景 | CamTalk 适用性 |
|
||||
|----------|----------|----------------|
|
||||
| **Chain** | 线性流水线 | ❌ LLM 和 TTS 需要并行执行,非纯线性 |
|
||||
| **Workflow** | DAG + 字段映射 | ⚠️ 不支持循环,未来 ReAct Agent 需要循环 |
|
||||
| **Graph** | 任意有向图,支持分支/并行/循环 | ✅ 完美匹配,支持当前并行需求和未来扩展 |
|
||||
|
||||
**选择 Graph**,理由:
|
||||
- LLM Stream 输出需要同时分发给 TTS 和客户端(多下游分支)
|
||||
- 未来需要支持 ReAct Agent 循环(Graph + Branch)
|
||||
- 支持 Pregel 执行引擎,兼容未来有状态节点
|
||||
|
||||
### 2.2 Graph 拓扑设计
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ CamTalk Pipeline Graph │
|
||||
│ │
|
||||
START │ │ END
|
||||
│ │ ▲
|
||||
▼ │ │
|
||||
┌──────┴──────┐ │
|
||||
│ STT Node │ (Lambda: audio → text) │
|
||||
│ (可选跳过) │ │
|
||||
└──────┬──────┘ │
|
||||
│ text │
|
||||
▼ │
|
||||
┌──────────────┐ │
|
||||
│ History Node │ (Lambda: 组装对话历史) │
|
||||
└──────┬───────┘ │
|
||||
│ []*schema.Message │
|
||||
▼ │
|
||||
┌──────────────┐ ┌────────────────┐ │
|
||||
│ LLM Node │─────→│ Sentence Split │───┐ │
|
||||
│ (ChatModel) │stream│ Node (Lambda) │ │ │
|
||||
└──────┬───────┘ └────────────────┘ │ │
|
||||
│ stream │ │
|
||||
▼ ▼ │
|
||||
┌──────────────┐ ┌──────────────┐│
|
||||
│ Chunk Sender │ │ TTS Node ││
|
||||
│ Node (Lambda)│ │ (Lambda) ││
|
||||
└──────────────┘ └──────┬───────┘│
|
||||
│ │
|
||||
▼ │
|
||||
┌──────────────┐ │
|
||||
│Audio Sender │──┘
|
||||
│Node (Lambda) │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
**关键设计决策:**
|
||||
|
||||
- STT 作为起始 Lambda 节点(非 Eino 原生组件,需封装)
|
||||
- LLM 使用 Eino 原生 ChatModel 组件(`eino-ext` 的 OpenAI 实现)
|
||||
- LLM 输出通过 Graph 的多下游边分发:一条到 Chunk Sender(推文字),一条到 Sentence Split → TTS(推语音)
|
||||
- TTS 封装为 Lambda 节点
|
||||
- 所有 Sender 操作封装为 Lambda 节点,注入 `Sender` 依赖
|
||||
|
||||
## 3. 详细设计
|
||||
|
||||
### 3.1 数据类型定义
|
||||
|
||||
```go
|
||||
// internal/eino/types.go
|
||||
|
||||
// Graph 统一输入
|
||||
type PipelineInput struct {
|
||||
AudioData []byte // base64 解码后的音频(可选)
|
||||
ImageData []byte // base64 解码后的图像(可选)
|
||||
Text string // 直接文本输入(可选,跳过 STT)
|
||||
SessionID string
|
||||
RequestID string
|
||||
Language string // zh / en
|
||||
Scenario string // free_chat, interviewer, etc.
|
||||
}
|
||||
|
||||
// Graph 统一输出
|
||||
type PipelineOutput struct {
|
||||
TranscribedText string // STT 结果
|
||||
FullResponse string // LLM 完整回复
|
||||
}
|
||||
|
||||
// STT 节点输出
|
||||
type STTOutput struct {
|
||||
Text string
|
||||
Language string
|
||||
}
|
||||
|
||||
// LLM 节点输入(组装好的对话历史)
|
||||
type LLMInput struct {
|
||||
Messages []*schema.Message
|
||||
}
|
||||
|
||||
// 句子分割中间类型
|
||||
type SentenceChunk struct {
|
||||
Sentence string
|
||||
IsLast bool
|
||||
}
|
||||
|
||||
// TTS 节点输出
|
||||
type TTSAudioChunk struct {
|
||||
AudioData []byte
|
||||
Format string
|
||||
Sentence string
|
||||
IsLast bool
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 Eino Graph 构建
|
||||
|
||||
```go
|
||||
// internal/eino/graph.go
|
||||
|
||||
package eino
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/cloudwego/eino/components/model"
|
||||
"github.com/cloudwego/eino/compose"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
// GraphOption 图级别配置
|
||||
type GraphOption struct {
|
||||
ChatModel model.ToolCallingChatModel // Eino 原生 ChatModel
|
||||
STTService stt.Service // 现有 STT 接口
|
||||
TTSService tts.Service // 现有 TTS 接口
|
||||
SessionMgr session.Manager // 会话管理
|
||||
Sender orchestrator.Sender // WS 消息推送
|
||||
PromptCfg *PromptConfig // 提示词配置
|
||||
}
|
||||
|
||||
// NewPipelineGraph 构建编排图
|
||||
func NewPipelineGraph(ctx context.Context, opt *GraphOption) (compose.Runnable[PipelineInput, PipelineOutput], error) {
|
||||
g := compose.NewGraph[PipelineInput, PipelineOutput]()
|
||||
|
||||
// 1. STT 节点(Lambda)
|
||||
sttNode := compose.InvokableLambda(sttLambda(opt.STTService))
|
||||
g.AddLambdaNode("stt", sttNode)
|
||||
|
||||
// 2. 历史组装节点(Lambda)
|
||||
historyNode := compose.InvokableLambda(historyLambda(opt.SessionMgr, opt.PromptCfg))
|
||||
g.AddLambdaNode("history", historyNode)
|
||||
|
||||
// 3. LLM 节点(ChatModel,原生流式)
|
||||
g.AddChatModelNode("llm", opt.ChatModel)
|
||||
|
||||
// 4. 句子分割节点(Transform Lambda:stream → stream)
|
||||
splitterNode := compose.TransformableLambda(splitterLambda())
|
||||
g.AddLambdaNode("splitter", splitterNode)
|
||||
|
||||
// 5. LLM Chunk 推送节点(Transform Lambda)
|
||||
chunkSenderNode := compose.TransformableLambda(chunkSenderLambda(opt.Sender))
|
||||
g.AddLambdaNode("chunk_sender", chunkSenderNode)
|
||||
|
||||
// 6. TTS 节点(Collect Lambda:stream → non-stream)
|
||||
ttsNode := compose.CollectableLambda(ttsLambda(opt.TTSService, opt.Sender))
|
||||
g.AddLambdaNode("tts", ttsNode)
|
||||
|
||||
// 7. 完成通知节点(Invokable Lambda)
|
||||
doneNode := compose.InvokableLambda(doneLambda(opt.Sender))
|
||||
g.AddLambdaNode("done", doneNode)
|
||||
|
||||
// === 边连接 ===
|
||||
|
||||
// START → STT
|
||||
g.AddEdge(compose.START, "stt")
|
||||
// STT → History
|
||||
g.AddEdge("stt", "history")
|
||||
// History → LLM
|
||||
g.AddEdge("history", "llm")
|
||||
|
||||
// LLM 输出分发到两个下游(利用 Graph 多下游边)
|
||||
// LLM → Chunk Sender(推送原始 token)
|
||||
g.AddEdge("llm", "chunk_sender")
|
||||
// LLM → Splitter → TTS(句子级语音合成)
|
||||
g.AddEdge("llm", "splitter")
|
||||
g.AddEdge("splitter", "tts")
|
||||
|
||||
// Chunk Sender 和 TTS 都汇入 Done
|
||||
g.AddEdge("chunk_sender", "done")
|
||||
g.AddEdge("tts", "done")
|
||||
|
||||
// Done → END
|
||||
g.AddEdge("done", compose.END)
|
||||
|
||||
// 编译
|
||||
return g.Compile(ctx,
|
||||
compose.WithGraphName("camtalk_pipeline"),
|
||||
compose.WithMaxRunSteps(50),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 节点实现
|
||||
|
||||
#### 3.3.1 STT Lambda
|
||||
|
||||
```go
|
||||
// internal/eino/nodes_stt.go
|
||||
|
||||
func sttLambda(sttSvc stt.Service) func(ctx context.Context, input PipelineInput) (STTOutput, error) {
|
||||
return func(ctx context.Context, input PipelineInput) (STTOutput, error) {
|
||||
// 文本模式:跳过 STT
|
||||
if input.Text != "" {
|
||||
return STTOutput{Text: input.Text, Language: input.Language}, nil
|
||||
}
|
||||
|
||||
if len(input.AudioData) == 0 {
|
||||
return STTOutput{}, fmt.Errorf("no audio data provided")
|
||||
}
|
||||
|
||||
// 调用现有 STT 服务
|
||||
result, err := sttSvc.Recognize(ctx, input.AudioData, stt.Options{
|
||||
Language: input.Language,
|
||||
})
|
||||
if err != nil {
|
||||
return STTOutput{}, fmt.Errorf("STT error: %w", err)
|
||||
}
|
||||
|
||||
return STTOutput{
|
||||
Text: result.Text,
|
||||
Language: result.Language,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3.2 历史组装 Lambda
|
||||
|
||||
```go
|
||||
// internal/eino/nodes_history.go
|
||||
|
||||
func historyLambda(sessionMgr session.Manager, promptCfg *PromptConfig) func(ctx context.Context, input STTOutput) ([]*schema.Message, error) {
|
||||
return func(ctx context.Context, input STTOutput) ([]*schema.Message, error) {
|
||||
sessionID := getSessionID(ctx) // 从 context 或 state 获取
|
||||
|
||||
history, err := sessionMgr.GetHistory(ctx, sessionID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get history error: %w", err)
|
||||
}
|
||||
|
||||
// 构建系统提示词
|
||||
systemPrompt := promptCfg.BuildSystemPrompt(input.Language, getScenario(ctx))
|
||||
|
||||
messages := []*schema.Message{
|
||||
{Role: schema.System, Content: systemPrompt,
|
||||
MultiContent: buildVisionContent(getImageData(ctx))},
|
||||
}
|
||||
|
||||
// 追加历史消息
|
||||
for _, msg := range history {
|
||||
messages = append(messages, &schema.Message{
|
||||
Role: schema.Role(msg.Role),
|
||||
Content: msg.Content,
|
||||
})
|
||||
}
|
||||
|
||||
// 追加当前用户输入
|
||||
messages = append(messages, &schema.Message{
|
||||
Role: schema.User,
|
||||
Content: input.Text,
|
||||
})
|
||||
|
||||
// 保存用户消息到历史
|
||||
_ = sessionMgr.AppendMessage(ctx, sessionID, models.Message{
|
||||
Role: "user",
|
||||
Content: input.Text,
|
||||
})
|
||||
|
||||
return messages, nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3.3 句子分割 Transform Lambda
|
||||
|
||||
```go
|
||||
// internal/eino/nodes_splitter.go
|
||||
|
||||
func splitterLambda() func(ctx context.Context, stream *schema.StreamReader[*schema.Message]) (*schema.StreamReader[SentenceChunk], error) {
|
||||
return func(ctx context.Context, stream *schema.StreamReader[*schema.Message]) (*schema.StreamReader[SentenceChunk], error) {
|
||||
sr, sw := schema.Pipe[SentenceChunk](8)
|
||||
|
||||
go func() {
|
||||
defer sw.Close()
|
||||
var buffer []rune
|
||||
|
||||
for {
|
||||
chunk, err := stream.Recv()
|
||||
if err != nil {
|
||||
if err.Error() == "EOF" {
|
||||
// 流结束,发送剩余缓冲
|
||||
if len(buffer) > 0 {
|
||||
sw.Send(SentenceChunk{Sentence: string(buffer), IsLast: true}, nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
sw.Send(SentenceChunk{}, err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, r := range chunk.Content {
|
||||
buffer = append(buffer, r)
|
||||
if isSentenceDelimiter(r) {
|
||||
sw.Send(SentenceChunk{Sentence: string(buffer), IsLast: false}, nil)
|
||||
buffer = buffer[:0]
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return sr, nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3.4 TTS Collect Lambda
|
||||
|
||||
```go
|
||||
// internal/eino/nodes_tts.go
|
||||
|
||||
func ttsLambda(ttsSvc tts.Service, sender orchestrator.Sender) func(ctx context.Context, stream *schema.StreamReader[SentenceChunk]) (struct{}, error) {
|
||||
return func(ctx context.Context, stream *schema.StreamReader[SentenceChunk]) (struct{}, error) {
|
||||
for {
|
||||
chunk, err := stream.Recv()
|
||||
if err != nil {
|
||||
if err.Error() == "EOF" {
|
||||
break
|
||||
}
|
||||
return struct{}{}, err
|
||||
}
|
||||
|
||||
if chunk.Sentence == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// 调用 TTS 服务
|
||||
audioData, err := ttsSvc.Synthesize(ctx, chunk.Sentence, tts.Options{
|
||||
// 从 Option 或 Config 获取
|
||||
})
|
||||
if err != nil {
|
||||
// TTS 失败不中断流程,仅记录日志
|
||||
log.Warn("TTS synthesis failed", zap.Error(err),
|
||||
zap.String("sentence", chunk.Sentence))
|
||||
continue
|
||||
}
|
||||
|
||||
// 推送音频到客户端
|
||||
sender.SendTTSAudio(orchestrator.TTSAudioPayload{
|
||||
Audio: audioData,
|
||||
Format: "mp3",
|
||||
IsLast: chunk.IsLast,
|
||||
})
|
||||
}
|
||||
|
||||
return struct{}{}, nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3.5 Chunk Sender Transform Lambda
|
||||
|
||||
```go
|
||||
// internal/eino/nodes_sender.go
|
||||
|
||||
func chunkSenderLambda(sender orchestrator.Sender) func(ctx context.Context, stream *schema.StreamReader[*schema.Message]) (*schema.StreamReader[*schema.Message], error) {
|
||||
return func(ctx context.Context, stream *schema.StreamReader[*schema.Message]) (*schema.StreamReader[*schema.Message], error) {
|
||||
sr, sw := schema.Pipe[*schema.Message](8)
|
||||
|
||||
go func() {
|
||||
defer sw.Close()
|
||||
for {
|
||||
msg, err := stream.Recv()
|
||||
if err != nil {
|
||||
if err.Error() == "EOF" {
|
||||
return
|
||||
}
|
||||
sw.Send(nil, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 推送 LLM 文本 chunk 到客户端
|
||||
sender.SendLLMChunk(orchestrator.LLMChunkPayload{
|
||||
Content: msg.Content,
|
||||
})
|
||||
|
||||
// 透传给下游
|
||||
sw.Send(msg, nil)
|
||||
}
|
||||
}()
|
||||
|
||||
return sr, nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3.6 Done Lambda
|
||||
|
||||
```go
|
||||
// internal/eino/nodes_done.go
|
||||
|
||||
func doneLambda(sender orchestrator.Sender) func(ctx context.Context, input struct{}) (PipelineOutput, error) {
|
||||
return func(ctx context.Context, input struct{}) (PipelineOutput, error) {
|
||||
// 通知客户端 LLM 回复完成
|
||||
sender.SendLLMDone(orchestrator.LLMDonePayload{})
|
||||
|
||||
// 保存助手消息到历史
|
||||
// 注意:完整回复需要从某处收集,可通过 State 机制实现
|
||||
return PipelineOutput{}, nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 State 机制(收集完整回复)
|
||||
|
||||
由于 LLM 输出被分发到两个下游,完整回复文本需要通过 Graph State 收集:
|
||||
|
||||
```go
|
||||
// internal/eino/state.go
|
||||
|
||||
type PipelineState struct {
|
||||
FullResponse strings.Builder
|
||||
SessionID string
|
||||
RequestID string
|
||||
}
|
||||
|
||||
func genLocalState(ctx context.Context) *PipelineState {
|
||||
return &PipelineState{}
|
||||
}
|
||||
|
||||
// 在构建 Graph 时注册 State
|
||||
func NewPipelineGraph(ctx context.Context, opt *GraphOption) (compose.Runnable[PipelineInput, PipelineOutput], error) {
|
||||
g := compose.NewGraph[PipelineInput, PipelineOutput](
|
||||
compose.WithGenLocalState(genLocalState),
|
||||
)
|
||||
|
||||
// ... 添加节点 ...
|
||||
|
||||
// Chunk Sender 的 StatePostHandler 累积完整回复
|
||||
g.AddLambdaNode("chunk_sender", chunkSenderNode,
|
||||
compose.WithStatePostHandler(func(ctx context.Context, output *schema.Message, state *PipelineState) *schema.Message {
|
||||
state.FullResponse.WriteString(output.Content)
|
||||
return output
|
||||
}),
|
||||
)
|
||||
|
||||
// Done 节点的 StatePreHandler 读取完整回复
|
||||
g.AddLambdaNode("done", doneNode,
|
||||
compose.WithStatePreHandler(func(ctx context.Context, input struct{}, state *PipelineState) struct{} {
|
||||
// 将完整回复存入 state 供 done 节点使用
|
||||
return input
|
||||
}),
|
||||
)
|
||||
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### 3.5 Callback 集成(日志/指标/追踪)
|
||||
|
||||
```go
|
||||
// internal/eino/callback.go
|
||||
|
||||
type MetricsCallback struct {
|
||||
logger *zap.Logger
|
||||
metrics *MetricsCollector // Prometheus 等
|
||||
}
|
||||
|
||||
func (m *MetricsCallback) OnStart(ctx context.Context, info *compose.RunInfo, input compose.CallbackInput) context.Context {
|
||||
m.logger.Debug("node started",
|
||||
zap.String("node", info.Name),
|
||||
zap.String("graph", info.GraphName))
|
||||
return ctx
|
||||
}
|
||||
|
||||
func (m *MetricsCallback) OnEnd(ctx context.Context, info *compose.RunInfo, output compose.CallbackOutput) context.Context {
|
||||
m.logger.Debug("node completed",
|
||||
zap.String("node", info.Name))
|
||||
return ctx
|
||||
}
|
||||
|
||||
func (m *MetricsCallback) OnError(ctx context.Context, info *compose.RunInfo, err error) context.Context {
|
||||
m.logger.Error("node failed",
|
||||
zap.String("node", info.Name),
|
||||
zap.Error(err))
|
||||
m.metrics.IncrementError(info.Name)
|
||||
return ctx
|
||||
}
|
||||
|
||||
// 注册到 Graph
|
||||
func NewPipelineGraph(ctx context.Context, opt *GraphOption) (compose.Runnable[PipelineInput, PipelineOutput], error) {
|
||||
// ...
|
||||
callback := &MetricsCallback{logger: opt.Logger, metrics: opt.Metrics}
|
||||
|
||||
return g.Compile(ctx,
|
||||
compose.WithCallbacks(callback), // 全局回调
|
||||
compose.WithCallbacks(llmCallback).DesignateNode("llm"), // LLM 专用回调
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### 3.6 按请求动态配置
|
||||
|
||||
```go
|
||||
// internal/eino/options.go
|
||||
|
||||
// 运行时 Option:每请求可变
|
||||
func WithModelName(name string) compose.Option {
|
||||
return compose.WithChatModelOption(model.WithModel(name))
|
||||
}
|
||||
|
||||
func WithTemperature(temp float32) compose.Option {
|
||||
return compose.WithChatModelOption(model.WithTemperature(temp))
|
||||
}
|
||||
|
||||
func WithTTSVoice(voice string) compose.Option {
|
||||
return compose.WithCallbacks(&ttsVoiceCallback{voice: voice}).
|
||||
DesignateNode("tts")
|
||||
}
|
||||
|
||||
// WebSocket Handler 中的调用
|
||||
func (c *Client) handleQuery(req QueryRequest) {
|
||||
opts := []compose.Option{}
|
||||
|
||||
// 根据请求配置动态注入
|
||||
if req.Model != "" {
|
||||
opts = append(opts, WithModelName(req.Model))
|
||||
}
|
||||
if req.TTSVoice != "" {
|
||||
opts = append(opts, WithTTSVoice(req.TTSVoice))
|
||||
}
|
||||
|
||||
output, err := c.pipeline.Invoke(ctx, PipelineInput{...}, opts...)
|
||||
}
|
||||
```
|
||||
|
||||
### 3.7 ChatModel 适配(接入 eino-ext OpenAI)
|
||||
|
||||
```go
|
||||
// internal/eino/chatmodel.go
|
||||
|
||||
import (
|
||||
openaiImpl "github.com/cloudwego/eino-ext/components/model/openai"
|
||||
)
|
||||
|
||||
func NewChatModel(cfg *config.AIConfig) (model.ToolCallingChatModel, error) {
|
||||
return openaiImpl.NewChatModel(context.Background(), &openaiImpl.ChatModelConfig{
|
||||
APIKey: cfg.LLM.APIKey,
|
||||
Model: cfg.LLM.Model,
|
||||
BaseURL: cfg.LLM.BaseURL,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## 4. 目录结构变更
|
||||
|
||||
```
|
||||
backend/internal/
|
||||
├── eino/ # 新增:Eino 编排层
|
||||
│ ├── graph.go # Graph 构建与编译
|
||||
│ ├── types.go # 数据类型定义
|
||||
│ ├── state.go # Graph State 定义
|
||||
│ ├── options.go # 运行时 Option
|
||||
│ ├── callback.go # 回调实现(日志/指标)
|
||||
│ ├── chatmodel.go # ChatModel 适配器
|
||||
│ ├── nodes_stt.go # STT Lambda 节点
|
||||
│ ├── nodes_history.go # 历史组装 Lambda 节点
|
||||
│ ├── nodes_splitter.go # 句子分割 Transform Lambda
|
||||
│ ├── nodes_tts.go # TTS Collect Lambda 节点
|
||||
│ ├── nodes_sender.go # Chunk Sender Transform Lambda
|
||||
│ ├── nodes_done.go # 完成通知 Lambda 节点
|
||||
│ └── graph_test.go # 集成测试
|
||||
├── orchestrator/ # 保留:兼容层(Phase 1)
|
||||
│ ├── orchestrator.go # 接口定义(不变)
|
||||
│ ├── pipeline.go # 旧实现(Phase 3 移除)
|
||||
│ ├── splitter.go # 被 eino/nodes_splitter.go 替代
|
||||
│ ├── sender.go # Sender 接口(不变,被 eino 层引用)
|
||||
│ └── eino_adapter.go # 新增:Eino 编排器适配为 Orchestrator 接口
|
||||
├── ai/ # 保留:AI 服务接口不变
|
||||
│ ├── llm/ # 保留接口,实现被 eino-ext 替代
|
||||
│ ├── stt/ # 完全保留
|
||||
│ └── tts/ # 完全保留
|
||||
└── ws/ # 保留:WebSocket Handler
|
||||
└── handler.go # 切换到 Eino 编排器
|
||||
```
|
||||
|
||||
## 5. 分阶段实施计划
|
||||
|
||||
### Phase 1:基础设施(预计 2-3 天)
|
||||
|
||||
| 任务 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| 引入 Eino 依赖 | `go.mod` | `go get github.com/cloudwego/eino/...` |
|
||||
| 引入 eino-ext OpenAI | `go.mod` | `go get github.com/cloudwego/eino-ext/...` |
|
||||
| 定义数据类型 | `eino/types.go` | PipelineInput/Output、中间类型 |
|
||||
| 定义 State | `eino/state.go` | PipelineState |
|
||||
| 实现 ChatModel 适配器 | `eino/chatmodel.go` | 包装 eino-ext OpenAI |
|
||||
| 编写 Callback 框架 | `eino/callback.go` | 日志 + 指标回调 |
|
||||
|
||||
### Phase 2:节点实现与 Graph 构建(预计 3-4 天)
|
||||
|
||||
| 任务 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| STT Lambda | `eino/nodes_stt.go` | 包装现有 stt.Service |
|
||||
| 历史组装 Lambda | `eino/nodes_history.go` | 对话历史 + 提示词 |
|
||||
| 句子分割 Transform | `eino/nodes_splitter.go` | 重写 splitter.go 为 Eino Lambda |
|
||||
| TTS Collect Lambda | `eino/nodes_tts.go` | 包装现有 tts.Service |
|
||||
| Chunk Sender Transform | `eino/nodes_sender.go` | LLM token 推送 |
|
||||
| Done Lambda | `eino/nodes_done.go` | 完成通知 |
|
||||
| Graph 构建 | `eino/graph.go` | 组装所有节点 |
|
||||
| 单元测试 | `eino/graph_test.go` | Mock 各节点测试图结构 |
|
||||
|
||||
### Phase 3:集成与切换(预计 2-3 天)
|
||||
|
||||
| 任务 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| Eino 适配器 | `orchestrator/eino_adapter.go` | 将 Eino Graph 包装为现有 Orchestrator 接口 |
|
||||
| WS Handler 切换 | `ws/handler.go` | 使用新的 Eino 编排器 |
|
||||
| main.go 依赖注入 | `cmd/server/main.go` | 构建 ChatModel + Graph |
|
||||
| 集成测试 | `eino/graph_test.go` | 端到端测试 |
|
||||
| 性能对比 | - | 延迟、内存、CPU 对比 |
|
||||
|
||||
### Phase 4:清理与增强(预计 1-2 天)
|
||||
|
||||
| 任务 | 说明 |
|
||||
|------|------|
|
||||
| 移除旧 Pipeline | 删除 `orchestrator/pipeline.go`、`splitter.go` |
|
||||
| 更新文档 | 更新架构文档、接口文档 |
|
||||
| 启用 ReAct Agent(可选) | 基于 Graph Branch 实现工具调用循环 |
|
||||
| 动态配置完善 | 按请求切换模型、TTS 参数 |
|
||||
|
||||
## 6. 风险与缓解
|
||||
|
||||
| 风险 | 影响 | 缓解措施 |
|
||||
|------|------|----------|
|
||||
| Eino 框架不稳定(v0.x) | 生产故障 | 锁定版本,保留旧 Pipeline 可回退 |
|
||||
| 流式处理延迟增加 | 用户体验下降 | 性能对比测试,必要时绕过 Eino 直接调用 |
|
||||
| LLM 输出多下游分发丢失数据 | TTS 无输入 | 充分测试 Stream Copy 机制,添加监控 |
|
||||
| 学习曲线 | 开发效率 | 先从简单 Chain 开始,逐步过渡到 Graph |
|
||||
| eino-ext OpenAI 不兼容现有 API | 功能回退 | 验证 BaseURL 和参数映射,必要时自定义适配器 |
|
||||
|
||||
## 7. 测试策略
|
||||
|
||||
### 7.1 单元测试
|
||||
|
||||
```go
|
||||
// eino/graph_test.go
|
||||
|
||||
func TestPipelineGraph_WithTextInput(t *testing.T) {
|
||||
// Mock STT, LLM, TTS, Sender
|
||||
mockLLM := &mockChatModel{responses: []string{"你好!"}}
|
||||
mockSender := &mockSender{}
|
||||
|
||||
graph, err := NewPipelineGraph(ctx, &GraphOption{
|
||||
ChatModel: mockLLM,
|
||||
Sender: mockSender,
|
||||
// ...
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
output, err := graph.Invoke(ctx, PipelineInput{
|
||||
Text: "你好",
|
||||
SessionID: "test-session",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "你好!", output.FullResponse)
|
||||
assert.True(t, mockSender.LLMDoneSent)
|
||||
}
|
||||
|
||||
func TestPipelineGraph_WithAudioInput(t *testing.T) {
|
||||
mockSTT := &mockSTT{text: "你好"}
|
||||
mockLLM := &mockChatModel{responses: []string{"你好!"}}
|
||||
mockTTS := &mockTTS{audio: []byte("fake-audio")}
|
||||
mockSender := &mockSender{}
|
||||
|
||||
graph, _ := NewPipelineGraph(ctx, &GraphOption{
|
||||
ChatModel: mockLLM,
|
||||
STTService: mockSTT,
|
||||
TTSService: mockTTS,
|
||||
Sender: mockSender,
|
||||
})
|
||||
|
||||
output, err := graph.Invoke(ctx, PipelineInput{
|
||||
AudioData: []byte("fake-audio-data"),
|
||||
SessionID: "test-session",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, mockSender.TTSAudioSent)
|
||||
}
|
||||
```
|
||||
|
||||
### 7.2 集成测试
|
||||
|
||||
- 启动真实 OpenAI API 调用(使用测试 key)
|
||||
- 验证 WebSocket 消息序列:`stt_result` → `llm_chunk` × N → `llm_done` → `tts_audio` × N
|
||||
- 验证 interrupt 取消功能
|
||||
- 验证多并发请求隔离
|
||||
|
||||
## 8. 依赖清单
|
||||
|
||||
```go
|
||||
// go.mod 新增
|
||||
require (
|
||||
github.com/cloudwego/eino v0.4.x // 核心框架
|
||||
github.com/cloudwego/eino-ext v0.1.x // 组件实现
|
||||
)
|
||||
```
|
||||
|
||||
## 9. 未来扩展路径
|
||||
|
||||
基于 Eino Graph 的重构完成后,可无缝扩展:
|
||||
|
||||
1. **ReAct Agent**:Graph 添加 Branch 节点,实现 LLM → Tool → LLM 循环
|
||||
2. **多模态理解**:添加视觉分析 Lambda 节点(图像描述 → 上下文注入)
|
||||
3. **Model Router**:Graph 前置分支节点,按场景/成本路由不同 LLM
|
||||
4. **Rate Limiter**:通过 Callback 的 OnStart 实现令牌桶
|
||||
5. **Checkpoint/Resume**:利用 Eino 的 CheckpointStore 实现断点续传
|
||||
6. **Multi-Agent**:利用 ADK 的 Supervisor/SequentialAgent 编排复杂对话流程
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:Eino vs 现有实现对比
|
||||
|
||||
| 维度 | 现有实现 | Eino 重构后 |
|
||||
|------|----------|------------|
|
||||
| 编排方式 | 手写 goroutine + channel | 声明式 Graph,类型安全 |
|
||||
| 流式处理 | 手动 channel 传递 | StreamReader + Pipe,自动转换 |
|
||||
| 错误处理 | 各节点独立处理 | 统一 Callback OnError |
|
||||
| 日志/追踪 | 散落在各处 | AOP Callback 注入 |
|
||||
| 配置灵活性 | Pipeline 创建时固定 | 每请求 Option 动态注入 |
|
||||
| 可测试性 | 需要启动 goroutine | Graph.Invoke 直接测试 |
|
||||
| 扩展性 | 修改 Pipeline 代码 | 添加节点 + 边,无需改已有逻辑 |
|
||||
| 并发安全 | 手动 sync | State 自动加锁 |
|
||||
|
||||
## 附录 B:关键 Eino API 参考
|
||||
|
||||
```go
|
||||
// 构建 Graph
|
||||
g := compose.NewGraph[I, O](opts...)
|
||||
g.AddChatModelNode(key, chatModel)
|
||||
g.AddLambdaNode(key, lambda, opts...)
|
||||
g.AddEdge(from, to)
|
||||
g.AddBranch(from, branchFunc, mapping)
|
||||
|
||||
// 编译
|
||||
runnable, err := g.Compile(ctx, opts...)
|
||||
|
||||
// 执行四种模式
|
||||
output, err := runnable.Invoke(ctx, input, opts...)
|
||||
stream, err := runnable.Stream(ctx, input, opts...)
|
||||
output, err := runnable.Collect(ctx, inputStream, opts...)
|
||||
stream, err := runnable.Transform(ctx, inputStream, opts...)
|
||||
|
||||
// Lambda 四种构造器
|
||||
lambda := compose.InvokableLambda(fn) // I → O
|
||||
lambda := compose.StreamableLambda(fn) // I → StreamReader[O]
|
||||
lambda := compose.CollectableLambda(fn) // StreamReader[I] → O
|
||||
lambda := compose.TransformableLambda(fn) // StreamReader[I] → StreamReader[O]
|
||||
|
||||
// Stream 操作
|
||||
sr, sw := schema.Pipe[T](bufSize)
|
||||
sw.Send(chunk, err)
|
||||
chunk, err := sr.Recv()
|
||||
sw.Close()
|
||||
|
||||
// Option
|
||||
compose.WithCallbacks(handler)
|
||||
compose.WithCallbacks(handler).DesignateNode("node_key")
|
||||
compose.WithChatModelOption(model.WithTemperature(0.7))
|
||||
compose.WithGenLocalState(genFunc)
|
||||
```
|
||||
1110
docs/10-鉴权体系.md
Normal file
1110
docs/10-鉴权体系.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,246 +0,0 @@
|
||||
# CamTalk Eino 框架技术文档
|
||||
|
||||
> 创建日期:2026-06-19
|
||||
> 状态:已实施
|
||||
|
||||
## 1. 框架简介
|
||||
|
||||
[CloudWeGo Eino](https://github.com/cloudwego/eino) 是字节跳动 CloudWeGo 团队开源的 AI 应用开发框架,提供基于图(Graph)的编排能力、组件抽象和流式处理支持。
|
||||
|
||||
CamTalk 使用 Eino 替代原有的手写 goroutine 管道,实现 STT → LLM → TTS 的声明式编排。
|
||||
|
||||
## 2. 技术选型
|
||||
|
||||
### 2.1 为什么选 Eino
|
||||
|
||||
| 维度 | 手写 goroutine(旧方案) | Eino Graph(新方案) |
|
||||
|------|------------------------|---------------------|
|
||||
| 编排方式 | 手动 `go func()` + `sync.WaitGroup` | 声明式 DAG,类型安全 |
|
||||
| 流式处理 | 自定义 `chan` 传递 | `StreamReader` + `Pipe`,自动转换 |
|
||||
| 错误处理 | 各节点独立处理,不一致 | Graph 级别统一错误传播 |
|
||||
| 回调/AOP | 日志散落各处 | `callbacks.Handler` 统一注入 |
|
||||
| 配置灵活性 | Pipeline 创建时固定 | 每请求 `Option` 动态注入 |
|
||||
| 可测试性 | 需启动 goroutine | `Graph.Invoke()` 直接测试 |
|
||||
| 扩展性 | 修改 Pipeline 代码 | 添加节点 + 边,无侵入 |
|
||||
| 并发安全 | 手动 `sync` | State 自动加锁 |
|
||||
|
||||
### 2.2 Eino vs 其他编排框架
|
||||
|
||||
| 框架 | 特点 | CamTalk 适用性 |
|
||||
|------|------|---------------|
|
||||
| **Eino** | Go 原生、类型安全、流式原生 | ✅ 完美匹配 |
|
||||
| LangChain Go | 生态丰富但较重 | ❌ 过度抽象 |
|
||||
| 自研编排 | 完全可控 | ❌ 维护成本高 |
|
||||
|
||||
**选择 Eino 的核心理由**:
|
||||
1. Go 原生,泛型支持,编译时类型检查
|
||||
2. 原生流式处理(`StreamReader`),适合 LLM token 级推送
|
||||
3. Graph 支持分支、并行、循环,满足当前和未来需求
|
||||
4. Callback 机制实现 AOP(日志、指标、消息推送)
|
||||
5. eino-ext 提供 OpenAI ChatModel 实现,直接对接 DashScope
|
||||
|
||||
### 2.3 核心依赖版本
|
||||
|
||||
```
|
||||
github.com/cloudwego/eino v0.9.9
|
||||
github.com/cloudwego/eino-ext/components/model/openai v0.1.13
|
||||
```
|
||||
|
||||
## 3. Eino 核心概念
|
||||
|
||||
### 3.1 Lambda
|
||||
|
||||
Lambda 是 Graph 中的可组合函数单元,支持四种模式:
|
||||
|
||||
| 模式 | 函数签名 | 构造方法 | 说明 |
|
||||
|------|---------|---------|------|
|
||||
| Invoke | `I → O` | `compose.InvokableLambda()` | 同步调用 |
|
||||
| Stream | `I → StreamReader[O]` | `compose.StreamableLambda()` | 流式输出 |
|
||||
| Collect | `StreamReader[I] → O` | `compose.CollectableLambda()` | 流式输入 |
|
||||
| Transform | `StreamReader[I] → StreamReader[O]` | `compose.TransformableLambda()` | 双向流式 |
|
||||
|
||||
**返回类型**:所有 Lambda 构造函数返回 `*compose.Lambda`。
|
||||
|
||||
### 3.2 Graph
|
||||
|
||||
Graph 是有向无环图(DAG)编排器,支持:
|
||||
- **节点**:Lambda、ChatModel、ToolsNode 等
|
||||
- **边**:`g.AddEdge(from, to)` 定义数据流向
|
||||
- **分支**:`g.AddBranch()` 条件路由
|
||||
- **State**:`compose.WithGenLocalState()` 跨节点共享状态
|
||||
|
||||
```go
|
||||
g := compose.NewGraph[PipelineInput, PipelineOutput]()
|
||||
g.AddLambdaNode("stt", sttLambda)
|
||||
g.AddChatModelNode("llm", chatModel)
|
||||
g.AddEdge(compose.START, "stt")
|
||||
g.AddEdge("stt", "llm")
|
||||
g.AddEdge("llm", compose.END)
|
||||
|
||||
runnable, err := g.Compile(ctx)
|
||||
output, err := runnable.Invoke(ctx, input) // 同步调用
|
||||
stream, err := runnable.Stream(ctx, input) // 流式调用
|
||||
```
|
||||
|
||||
### 3.3 ChatModel
|
||||
|
||||
ChatModel 是 LLM 组件抽象,接口定义:
|
||||
|
||||
```go
|
||||
type BaseChatModel interface {
|
||||
Generate(ctx, []*schema.Message, ...Option) (*schema.Message, error)
|
||||
Stream(ctx, []*schema.Message, ...Option) (*schema.StreamReader[*schema.Message], error)
|
||||
}
|
||||
```
|
||||
|
||||
CamTalk 使用 `eino-ext/components/model/openai` 实现,通过 `BaseURL` 对接 DashScope:
|
||||
|
||||
```go
|
||||
chatModel, _ := openai.NewChatModel(ctx, &openai.ChatModelConfig{
|
||||
APIKey: cfg.AI.LLM.APIKey,
|
||||
Model: cfg.AI.LLM.Model,
|
||||
BaseURL: cfg.AI.LLM.Endpoint, // "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
})
|
||||
```
|
||||
|
||||
### 3.4 StreamReader
|
||||
|
||||
`schema.StreamReader[T]` 是 Eino 的流式数据抽象:
|
||||
- `sr.Recv()` 读取一帧,`io.EOF` 表示流结束
|
||||
- `schema.Pipe[T](bufSize)` 创建 `StreamReader` + `StreamWriter` 对
|
||||
- 框架自动处理 `T ↔ StreamReader[T]` 的转换(装箱/concat)
|
||||
|
||||
### 3.5 Callback
|
||||
|
||||
Callback 是 Eino 的 AOP 机制,支持节点生命周期钩子:
|
||||
|
||||
```go
|
||||
type Handler interface {
|
||||
OnStart(ctx, *RunInfo, CallbackInput) context.Context
|
||||
OnEnd(ctx, *RunInfo, CallbackOutput) context.Context
|
||||
OnError(ctx, *RunInfo, error) context.Context
|
||||
OnStartWithStreamInput(ctx, *RunInfo, *StreamReader[CallbackInput]) context.Context
|
||||
OnEndWithStreamOutput(ctx, *RunInfo, *StreamReader[CallbackOutput]) context.Context
|
||||
}
|
||||
```
|
||||
|
||||
CamTalk 使用 `utils/callbacks.NewHandlerHelper()` 构建 typed handler:
|
||||
- `ModelCallbackHandler.OnEndWithStreamOutput`:逐 token 推送 `llm_chunk`
|
||||
|
||||
### 3.6 State
|
||||
|
||||
Graph 全局状态,通过 `WithGenLocalState` 注册:
|
||||
|
||||
```go
|
||||
type PipelineState struct {
|
||||
FullResponse strings.Builder
|
||||
TranscribedText string
|
||||
TokenUsage *TokenUsage
|
||||
}
|
||||
|
||||
g := compose.NewGraph[I, O](compose.WithGenLocalState(func(ctx context.Context) *PipelineState {
|
||||
return &PipelineState{}
|
||||
}))
|
||||
```
|
||||
|
||||
节点通过 `compose.ProcessState` 读写 State。
|
||||
|
||||
## 4. CamTalk Graph 设计
|
||||
|
||||
### 4.1 拓扑
|
||||
|
||||
```
|
||||
START → STT → History → ChatModel → Splitter → TTS → Done → END
|
||||
```
|
||||
|
||||
| 节点 | 类型 | 输入 → 输出 | 职责 |
|
||||
|------|------|------------|------|
|
||||
| STT | InvokableLambda | `PipelineInput → STTOutput` | 语音识别,写入 State |
|
||||
| History | InvokableLambda | `STTOutput → []*schema.Message` | 组装提示词和历史 |
|
||||
| ChatModel | ChatModel(原生) | `[]*schema.Message → StreamReader[*Message]` | LLM 流式推理 |
|
||||
| Splitter | TransformableLambda | `StreamReader[string] → StreamReader[[]string]` | 句子切分 |
|
||||
| TTS | InvokableLambda | `[]string → struct{}` | 语音合成,推送音频 |
|
||||
| Done | InvokableLambda | `struct{} → PipelineOutput` | 发送 llm_done |
|
||||
|
||||
### 4.2 流式模式
|
||||
|
||||
Graph 使用 **Stream 模式**调用:
|
||||
- 内部所有节点以 Transform 模式运行
|
||||
- ChatModel 的 `Stream()` 方法实现真正的 token 级流式
|
||||
- 适配器消费 `StreamReader[PipelineOutput]` 触发整条链路
|
||||
|
||||
### 4.3 消息推送机制
|
||||
|
||||
| 消息 | 推送方式 | 时机 |
|
||||
|------|---------|------|
|
||||
| `stt_result` | Lambda 内部直接调用 Sender | STT 完成后 |
|
||||
| `llm_chunk` | Callback `OnEndWithStreamOutput` | ChatModel 逐 token |
|
||||
| `tts_audio` | Lambda 内部直接调用 Sender | TTS 逐句合成 |
|
||||
| `llm_done` | Lambda 内部直接调用 Sender | Done 节点执行时 |
|
||||
|
||||
**Context 注入**:Sender、RequestID、SessionID、PipelineState 通过 `context.WithValue` 传递。
|
||||
|
||||
### 4.4 多模态支持
|
||||
|
||||
History 节点将图片构建为 `schema.Message.UserInputMultiContent`:
|
||||
|
||||
```go
|
||||
systemMsg.UserInputMultiContent = []schema.MessageInputPart{
|
||||
{
|
||||
Type: schema.ChatMessagePartTypeImageURL,
|
||||
Image: &schema.MessageInputImage{
|
||||
MessagePartCommon: schema.MessagePartCommon{
|
||||
Base64Data: &base64Str,
|
||||
MIMEType: "image/jpeg",
|
||||
},
|
||||
Detail: schema.ImageURLDetailAuto,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## 5. 目录结构
|
||||
|
||||
```
|
||||
backend/internal/eino/
|
||||
├── types.go # PipelineInput/Output、STTOutput、TokenUsage
|
||||
├── state.go # PipelineState(跨节点状态)
|
||||
├── callback.go # Callback handler(LLM token 推送)
|
||||
├── graph.go # Graph 构建与编译
|
||||
├── adapter.go # EinoOrchestrator(Orchestrator 接口适配器)
|
||||
├── nodes_stt.go # STT Lambda
|
||||
├── nodes_history.go # 历史组装 Lambda
|
||||
├── nodes_splitter.go # 句子分割 Transform Lambda
|
||||
├── nodes_tts.go # TTS Lambda
|
||||
├── nodes_done.go # Done Lambda
|
||||
└── graph_test.go # 单元测试
|
||||
```
|
||||
|
||||
## 6. 注意事项
|
||||
|
||||
### 6.1 值类型 vs 指针类型
|
||||
|
||||
Graph 泛型参数必须使用值类型(`PipelineInput`/`PipelineOutput`),所有 Lambda 的输入输出也使用值类型。框架在 Transform 模式下会自动处理 `T` 和 `StreamReader[T]` 的转换。
|
||||
|
||||
### 6.2 Callback 运行时传入
|
||||
|
||||
Callback 通过 `Stream()` 的 option 传入,不在 `Compile()` 时注册:
|
||||
|
||||
```go
|
||||
streamReader, err := runnable.Stream(ctx, input, compose.WithCallbacks(handler))
|
||||
```
|
||||
|
||||
### 6.3 eino-ext 与 DashScope 兼容性
|
||||
|
||||
eino-ext OpenAI ChatModel 通过 `BaseURL` 对接 DashScope 兼容接口。需注意:
|
||||
- 多模态图片使用 `Base64Data` + `MIMEType` 格式
|
||||
- `Timeout` 控制单次请求超时
|
||||
- 流式输出通过 `Stream()` 方法获取 `StreamReader[*schema.Message]`
|
||||
|
||||
### 6.4 框架自动类型转换
|
||||
|
||||
Eino 框架在编排场景中自动处理以下转换:
|
||||
- **T → StreamReader[T]**:将完整值装箱为单帧流(非流式 → 假流式)
|
||||
- **StreamReader[T] → T**:将流 concat 为完整值(流式 → 非流式)
|
||||
|
||||
这使得不同流式模式的节点可以无缝连接。
|
||||
@@ -492,6 +492,321 @@ type SlidingWindowLimiter struct {
|
||||
- **限流触发率突增**:可能表示异常流量或攻击
|
||||
- **单用户持续被限流**:可能表示客户端 bug(死循环请求)
|
||||
|
||||
## 实际实现要点
|
||||
|
||||
### 文件结构
|
||||
|
||||
```
|
||||
backend/internal/ratelimit/
|
||||
├── limiter.go # Limiter 接口定义
|
||||
├── bucket.go # 内存令牌桶实现 (MemoryLimiter + TokenBucket)
|
||||
├── bucket_test.go # 内存令牌桶单元测试(11 个测试用例)
|
||||
├── redis_bucket.go # Redis 令牌桶实现(Lua 脚本)
|
||||
├── redis_bucket_test.go # Redis 令牌桶单元测试
|
||||
└── middleware.go # Gin 中间件实现
|
||||
```
|
||||
|
||||
### TokenBucket 实现细节
|
||||
|
||||
**核心数据结构**(`bucket.go:12-18`):
|
||||
|
||||
```go
|
||||
type TokenBucket struct {
|
||||
capacity int // 桶容量
|
||||
rate float64 // 每秒填充令牌数
|
||||
tokens float64 // 当前令牌数(浮点数支持小数令牌)
|
||||
lastRefill time.Time // 上次填充时间
|
||||
mu sync.Mutex // 保护并发访问
|
||||
}
|
||||
```
|
||||
|
||||
**并发安全**(`bucket.go:31-56`):
|
||||
- 每个桶内部使用 `sync.Mutex` 保护 `tokens` 和 `lastRefill` 字段
|
||||
- `allow()` 方法的"读取-计算-回写"操作原子执行
|
||||
- 桶 map 使用 `sync.RWMutex` 保护,读多写少优化(`bucket.go:62`)
|
||||
- 双重检查锁(`bucket.go:106-113`):先尝试读锁获取桶,不存在时升级写锁创建
|
||||
|
||||
**内存回收机制**(`bucket.go:132-161`):
|
||||
- 后台 goroutine 每 10 分钟扫描一次(`cleanup()` 方法)
|
||||
- 删除超过 10 分钟无活动的桶(`lastRefill` 超时判断)
|
||||
- 通过 `done` channel 和 `sync.Once` 保证优雅停止
|
||||
|
||||
**惰性创建**(`bucket.go:95-121`):
|
||||
- 用户首次请求时才创建桶,避免预分配内存
|
||||
- `getOrCreateBucket()` 使用读写锁分离,优化热路径性能
|
||||
|
||||
### Gin 中间件实现
|
||||
|
||||
**实际代码**(`middleware.go:12-42`):
|
||||
|
||||
```go
|
||||
func Middleware(limiter Limiter, keyFunc func(*gin.Context) string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if limiter == nil {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
key := keyFunc(c)
|
||||
if key == "" {
|
||||
// key 为空时跳过限流
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
allowed, retryAfter := limiter.Allow(c.Request.Context(), key)
|
||||
|
||||
if !allowed {
|
||||
// 设置 Retry-After header(秒)
|
||||
c.Header("Retry-After", fmt.Sprintf("%d", int(retryAfter.Seconds()+0.5)))
|
||||
|
||||
c.JSON(http.StatusTooManyRequests, gin.H{
|
||||
"code": "RATE_LIMITED",
|
||||
"message": fmt.Sprintf("too many requests, retry after %s", retryAfter.Round(1)),
|
||||
})
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**设计要点**:
|
||||
- `nil` limiter 自动跳过限流(支持配置关闭)
|
||||
- 空 key 跳过限流(支持匿名端点)
|
||||
- `retryAfter` 向上取整到秒(符合 HTTP 标准)
|
||||
- `c.Abort()` 阻止后续 handler 执行
|
||||
|
||||
### WebSocket 限流接入
|
||||
|
||||
**实际接入点**(`internal/ws/handler.go:230-240`):
|
||||
|
||||
```go
|
||||
case "query":
|
||||
// ... 解析消息 ...
|
||||
|
||||
// 限流检查
|
||||
if limiter != nil {
|
||||
key := fmt.Sprintf("%s:query", userID)
|
||||
allowed, retryAfter := limiter.Allow(context.Background(), key)
|
||||
if !allowed {
|
||||
logger.Log.Warnw("rate limited", "user_id", userID, "retry_after", retryAfter)
|
||||
errors.SendWSError(client, errors.CodeRateLimited, msg.RequestID,
|
||||
fmt.Errorf("rate limited, retry after %s", retryAfter.Round(time.Second)))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// ... 继续处理 query ...
|
||||
```
|
||||
|
||||
**设计要点**:
|
||||
- key 格式:`userID:query`(用户级限流)
|
||||
- 拒绝时发送 `RATE_LIMITED` 错误到客户端
|
||||
- 记录警告日志(便于监控告警)
|
||||
- 不阻塞其他消息类型(`ping`/`config`/`interrupt` 不限流)
|
||||
|
||||
### 配置加载与依赖注入
|
||||
|
||||
**配置文件路径**:
|
||||
- 基础配置:`backend/config/config.yaml`
|
||||
- 开发环境:`backend/config/config.dev.yaml`
|
||||
- 生产环境:`backend/config/config.prod.yaml`
|
||||
|
||||
**实际配置示例**(`config.yaml:63-76`):
|
||||
|
||||
```yaml
|
||||
ratelimit:
|
||||
enabled: false # 是否启用限流
|
||||
# WebSocket query 消息限流(核心,控制 AI 成本)
|
||||
query:
|
||||
capacity: 10 # 突发容量:允许连续发 10 个 query
|
||||
rate: 0.2 # 填充速率:每 5 秒补充 1 个令牌
|
||||
# REST API 登录限流(防暴力破解)
|
||||
login:
|
||||
capacity: 5 # 突发容量:允许连续 5 次登录尝试
|
||||
rate: 0.1 # 填充速率:每 10 秒补充 1 次
|
||||
# REST API 注册限流
|
||||
register:
|
||||
capacity: 3 # 突发容量:允许连续 3 次注册
|
||||
rate: 0.05 # 填充速率:每 20 秒补充 1 次
|
||||
```
|
||||
|
||||
**依赖注入实现**(`cmd/server/main.go:200-214`):
|
||||
|
||||
```go
|
||||
// 初始化限流器
|
||||
var limiter ratelimit.Limiter
|
||||
if cfg.RateLimit.Enabled {
|
||||
if rdb != nil {
|
||||
// 多实例:使用 Redis 令牌桶
|
||||
limiter = ratelimit.NewRedisLimiter(rdb, cfg.RateLimit)
|
||||
logger.Log.Info("rate limiter initialized with Redis backend")
|
||||
} else {
|
||||
// 单实例:使用内存令牌桶
|
||||
limiter = ratelimit.NewMemoryLimiter(cfg.RateLimit)
|
||||
logger.Log.Info("rate limiter initialized with in-memory backend")
|
||||
}
|
||||
defer limiter.Stop()
|
||||
} else {
|
||||
logger.Log.Info("rate limiter disabled")
|
||||
}
|
||||
```
|
||||
|
||||
**自动选择策略**:
|
||||
1. 配置关闭(`enabled: false`)→ `limiter = nil`(完全跳过限流)
|
||||
2. Redis 可用 → `NewRedisLimiter`(分布式一致)
|
||||
3. Redis 不可用 → `NewMemoryLimiter`(单实例零依赖)
|
||||
|
||||
**注入到模块**:
|
||||
|
||||
```go
|
||||
// WebSocket Handler
|
||||
r.GET("/ws", ws.ServeWS(sessionMgr, orch, cfg, tokenMgr, limiter))
|
||||
|
||||
// REST API
|
||||
authHandler.RegisterRoutes(apiGroup, limiter)
|
||||
```
|
||||
|
||||
### 实际测试用例
|
||||
|
||||
**内存令牌桶测试**(`bucket_test.go`,11 个用例):
|
||||
|
||||
| 测试用例 | 验证内容 |
|
||||
|---------|---------|
|
||||
| `TestTokenBucket_Allow_FirstRequest` | 首次请求通过 |
|
||||
| `TestTokenBucket_Allow_ConsumeUntilEmpty` | 连续消耗至桶空 |
|
||||
| `TestTokenBucket_Allow_RetryAfterCorrect` | `retryAfter` 计算准确性 |
|
||||
| `TestTokenBucket_Allow_RefillAfterWait` | 等待后令牌补充 |
|
||||
| `TestTokenBucket_Allow_CapacityLimit` | 桶容量上限限制 |
|
||||
| `TestTokenBucket_Allow_ConcurrentSafe` | 100 并发请求正确性 |
|
||||
| `TestTokenBucket_Allow_ZeroCapacity` | 边界:`capacity=0` |
|
||||
| `TestTokenBucket_Allow_ZeroRate` | 边界:`rate=0` |
|
||||
| `TestMemoryLimiter_Allow_DifferentKeys` | 不同用户隔离 |
|
||||
| `TestMemoryLimiter_Cleanup` | 不活跃桶自动清理 |
|
||||
| `TestMemoryLimiter_Stop` | 多次 `Stop()` 不 panic |
|
||||
|
||||
**中间件测试**(`middleware_test.go`,7 个用例):
|
||||
|
||||
| 测试用例 | 验证内容 |
|
||||
|---------|---------|
|
||||
| `TestMiddleware_Allow` | 允许时正常响应 |
|
||||
| `TestMiddleware_Deny` | 拒绝时返回 429 + `Retry-After` header |
|
||||
| `TestMiddleware_NilLimiter` | `nil` limiter 放行 |
|
||||
| `TestMiddleware_EmptyKey` | 空 key 放行 |
|
||||
| `TestMiddleware_KeyFunc` | `keyFunc` 正确提取 key |
|
||||
| `TestMiddleware_RetryAfterRounding` | `retryAfter` 向上取整 |
|
||||
|
||||
**并发安全性验证**(`bucket_test.go:83-107`):
|
||||
|
||||
```go
|
||||
func TestTokenBucket_Allow_ConcurrentSafe(t *testing.T) {
|
||||
bucket := newTokenBucket(100, 10.0)
|
||||
var wg sync.WaitGroup
|
||||
successCount := 0
|
||||
var mu sync.Mutex
|
||||
|
||||
// 100 个并发请求
|
||||
for i := 0; i < 100; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
allowed, _ := bucket.allow()
|
||||
if allowed {
|
||||
mu.Lock()
|
||||
successCount++
|
||||
mu.Unlock()
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// 应该正好 100 个成功(桶容量为 100)
|
||||
assert.Equal(t, 100, successCount)
|
||||
}
|
||||
```
|
||||
|
||||
### Redis Lua 脚本实现
|
||||
|
||||
**实际脚本**(`redis_bucket.go:15-53`):
|
||||
|
||||
```lua
|
||||
-- KEYS[1] = 限流 key
|
||||
-- ARGV[1] = capacity(桶容量)
|
||||
-- ARGV[2] = rate(每秒填充数)
|
||||
-- ARGV[3] = now(当前时间戳,秒,浮点)
|
||||
-- ARGV[4] = ttl(key 过期时间,秒)
|
||||
|
||||
local key = KEYS[1]
|
||||
local capacity = tonumber(ARGV[1])
|
||||
local rate = tonumber(ARGV[2])
|
||||
local now = tonumber(ARGV[3])
|
||||
local ttl = tonumber(ARGV[4])
|
||||
|
||||
local data = redis.call('HMGET', key, 'tokens', 'last_refill')
|
||||
local tokens = tonumber(data[1]) or capacity
|
||||
local last_refill = tonumber(data[2]) or now
|
||||
|
||||
-- 计算新令牌
|
||||
local elapsed = math.max(0, now - last_refill)
|
||||
tokens = math.min(capacity, tokens + elapsed * rate)
|
||||
|
||||
local allowed = 0
|
||||
local retry_after = 0
|
||||
|
||||
if tokens >= 1 then
|
||||
tokens = tokens - 1
|
||||
allowed = 1
|
||||
else
|
||||
if rate == 0 then
|
||||
retry_after = 86400 -- 24小时
|
||||
else
|
||||
retry_after = (1 - tokens) / rate
|
||||
end
|
||||
end
|
||||
|
||||
-- 回写状态
|
||||
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
|
||||
redis.call('EXPIRE', key, ttl)
|
||||
|
||||
return {allowed, tostring(retry_after)}
|
||||
```
|
||||
|
||||
**设计要点**:
|
||||
- 使用 Hash 存储两个字段:`tokens`(当前令牌数)+ `last_refill`(上次填充时间)
|
||||
- 原子性:整个脚本在 Redis 单线程中执行,无竞态条件
|
||||
- 自动过期:每次操作设置 TTL(默认 10 分钟),无需手动清理
|
||||
- 与内存实现算法一致(便于单元测试验证行为等价性)
|
||||
|
||||
### 编译期接口检查
|
||||
|
||||
**接口契约**(`bucket.go:172`,`middleware_test.go:31`):
|
||||
|
||||
```go
|
||||
// 确保 MemoryLimiter 实现了 Limiter 接口
|
||||
var _ Limiter = (*MemoryLimiter)(nil)
|
||||
|
||||
// 确保 mockLimiter 实现了 Limiter 接口
|
||||
var _ Limiter = (*mockLimiter)(nil)
|
||||
```
|
||||
|
||||
编译器会在类型不匹配时报错,避免运行时接口错误。
|
||||
|
||||
### 环境变量覆盖
|
||||
|
||||
配置文件中的 `ratelimit` 配置可通过环境变量覆盖:
|
||||
|
||||
```bash
|
||||
export CAMTALK_RATELIMIT_ENABLED=true
|
||||
export CAMTALK_RATELIMIT_QUERY_CAPACITY=20
|
||||
export CAMTALK_RATELIMIT_QUERY_RATE=0.5
|
||||
```
|
||||
|
||||
环境变量优先级高于配置文件(Viper 配置绑定)。
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [Token Bucket 算法](https://en.wikipedia.org/wiki/Token_bucket)
|
||||
@@ -1,544 +0,0 @@
|
||||
# 鉴权体系设计
|
||||
|
||||
## 概述
|
||||
|
||||
CamTalk 采用 JWT 双 token 轮转认证机制,结合 bcrypt 密码哈希和 Refresh Token Rotation 安全策略,实现安全可靠的用户认证体系。
|
||||
|
||||
**设计原则**:
|
||||
- **安全性**:access_token 短有效期(15 分钟),refresh_token 支持轮转防重放
|
||||
- **可靠性**:Refresh Token Rotation 机制,检测复用时自动吊销用户所有令牌
|
||||
- **可扩展性**:Repository 接口隔离存储层,支持内存和 PostgreSQL 双实现
|
||||
|
||||
## 整体架构
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端"]
|
||||
Browser["浏览器"]
|
||||
end
|
||||
|
||||
subgraph AuthModule["Auth 模块"]
|
||||
Service["AuthService<br/>Register / Login / Refresh / Logout"]
|
||||
TokenMgr["TokenManager<br/>JWT 生成与验证"]
|
||||
Middleware["AuthMiddleware<br/>Gin 中间件"]
|
||||
Password["PasswordUtil<br/>bcrypt 哈希"]
|
||||
end
|
||||
|
||||
subgraph Storage["存储层"]
|
||||
UserRepo["UserRepository<br/>用户数据"]
|
||||
TokenStore["RefreshToken 存储<br/>SHA256 哈希"]
|
||||
end
|
||||
|
||||
Browser -->|"POST /api/auth/*"| Service
|
||||
Service --> TokenMgr
|
||||
Service --> Password
|
||||
Service --> UserRepo
|
||||
Service --> TokenStore
|
||||
Middleware -->|"校验 access_token"| TokenMgr
|
||||
Middleware -->|"写入 user_id/username"| GinContext["Gin Context"]
|
||||
```
|
||||
|
||||
## 核心组件
|
||||
|
||||
### 1. JWT 令牌管理(TokenManager)
|
||||
|
||||
**文件位置**:`backend/internal/auth/jwt.go`
|
||||
|
||||
#### Claims 结构
|
||||
|
||||
```go
|
||||
type Claims struct {
|
||||
UserID string `json:"user_id"`
|
||||
Username string `json:"username"`
|
||||
TokenType string `json:"token_type"` // "access" | "refresh"
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
```
|
||||
|
||||
**字段说明**:
|
||||
- `UserID`:用户唯一标识(UUID)
|
||||
- `Username`:用户名
|
||||
- `TokenType`:令牌类型,用于区分 access 和 refresh token
|
||||
- `RegisteredClaims`:JWT 标准声明(ExpiresAt, IssuedAt, Issuer, ID)
|
||||
|
||||
#### TokenManager 配置
|
||||
|
||||
```go
|
||||
type TokenManager struct {
|
||||
secret []byte // JWT 签名密钥(HS256)
|
||||
accessTTL time.Duration // access_token 有效期(默认 15 分钟)
|
||||
refreshTTL time.Duration // refresh_token 有效期(默认 7 天)
|
||||
}
|
||||
|
||||
func NewTokenManager(secret string, accessTTL, refreshTTL time.Duration) *TokenManager
|
||||
```
|
||||
|
||||
#### 令牌生成
|
||||
|
||||
```go
|
||||
func (tm *TokenManager) GeneratePair(userID, username string) (access, refresh string, err error)
|
||||
```
|
||||
|
||||
**生成逻辑**:
|
||||
1. **access_token**:
|
||||
- 签名算法:HS256
|
||||
- 有效期:15 分钟
|
||||
- 包含:UserID, Username, TokenType="access", ExpiresAt, IssuedAt, Issuer="camtalk"
|
||||
|
||||
2. **refresh_token**:
|
||||
- 签名算法:HS256
|
||||
- 有效期:7 天
|
||||
- 包含:UserID, Username, TokenType="refresh", ID=UUID(用于 DB 关联), ExpiresAt, IssuedAt, Issuer="camtalk"
|
||||
|
||||
#### 令牌验证
|
||||
|
||||
```go
|
||||
func (tm *TokenManager) ValidateAccess(tokenStr string) (*Claims, error)
|
||||
func (tm *TokenManager) ValidateRefresh(tokenStr string) (*Claims, error)
|
||||
```
|
||||
|
||||
**验证逻辑**:
|
||||
1. 解析 JWT,验证签名算法为 HMAC
|
||||
2. 验证签名是否有效
|
||||
3. 验证令牌是否过期
|
||||
4. 验证 TokenType 是否匹配(access 或 refresh)
|
||||
5. 返回 Claims 或错误
|
||||
|
||||
#### Token 哈希
|
||||
|
||||
```go
|
||||
func HashToken(token string) string
|
||||
```
|
||||
|
||||
**用途**:对 refresh_token 做 SHA256 哈希后存储到数据库,避免直接存储原始 token。
|
||||
|
||||
### 2. 密码处理(PasswordUtil)
|
||||
|
||||
**文件位置**:`backend/internal/auth/password.go`
|
||||
|
||||
#### 密码哈希
|
||||
|
||||
```go
|
||||
func HashPassword(password string) (string, error)
|
||||
```
|
||||
|
||||
**实现**:
|
||||
- 算法:bcrypt
|
||||
- Cost:10(2^10 次迭代)
|
||||
- 返回:base64 编码的哈希字符串
|
||||
|
||||
#### 密码验证
|
||||
|
||||
```go
|
||||
func CheckPassword(hashedPassword, password string) error
|
||||
```
|
||||
|
||||
**实现**:
|
||||
- 使用 `bcrypt.CompareHashAndPassword` 验证
|
||||
- 返回 nil 表示匹配,否则返回错误
|
||||
|
||||
### 3. 认证服务(AuthService)
|
||||
|
||||
**文件位置**:`backend/internal/auth/service.go`
|
||||
|
||||
#### 接口定义
|
||||
|
||||
```go
|
||||
type Service interface {
|
||||
Register(ctx context.Context, req RegisterRequest) (*AuthResponse, error)
|
||||
Login(ctx context.Context, req LoginRequest) (*AuthResponse, error)
|
||||
Refresh(ctx context.Context, req RefreshRequest) (*AuthResponse, error)
|
||||
Logout(ctx context.Context, userID, refreshToken string) error
|
||||
}
|
||||
```
|
||||
|
||||
#### 注册流程(Register)
|
||||
|
||||
```go
|
||||
func (s *authService) Register(ctx context.Context, req RegisterRequest) (*AuthResponse, error)
|
||||
```
|
||||
|
||||
**流程**:
|
||||
1. 检查用户名是否已存在(`FindByUsername`)
|
||||
2. 如果存在,返回 `ErrUsernameTaken`
|
||||
3. 使用 bcrypt 哈希密码(`HashPassword`)
|
||||
4. 创建用户记录(`Create`)
|
||||
5. 生成 access_token + refresh_token(`GeneratePair`)
|
||||
6. 保存 refresh_token 的 SHA256 哈希到数据库(`SaveRefreshToken`)
|
||||
7. 返回 `AuthResponse`
|
||||
|
||||
**错误处理**:
|
||||
- `ErrUsernameTaken`:用户名已存在
|
||||
- 数据库错误:透传底层错误
|
||||
|
||||
#### 登录流程(Login)
|
||||
|
||||
```go
|
||||
func (s *authService) Login(ctx context.Context, req LoginRequest) (*AuthResponse, error)
|
||||
```
|
||||
|
||||
**流程**:
|
||||
1. 根据用户名查找用户(`FindByUsername`)
|
||||
2. 如果用户不存在,返回 `ErrInvalidCredentials`
|
||||
3. 验证密码(`CheckPassword`)
|
||||
4. 如果密码错误,返回 `ErrInvalidCredentials`
|
||||
5. 生成 access_token + refresh_token(`GeneratePair`)
|
||||
6. 保存 refresh_token 的 SHA256 哈希到数据库(`SaveRefreshToken`)
|
||||
7. 返回 `AuthResponse`
|
||||
|
||||
**错误处理**:
|
||||
- `ErrInvalidCredentials`:用户名或密码错误(统一错误信息,防止枚举攻击)
|
||||
|
||||
#### 刷新令牌流程(Refresh)— Refresh Token Rotation
|
||||
|
||||
```go
|
||||
func (s *authService) Refresh(ctx context.Context, req RefreshRequest) (*AuthResponse, error)
|
||||
```
|
||||
|
||||
**流程**:
|
||||
1. 验证 refresh_token 的签名和有效期(`ValidateRefresh`)
|
||||
2. 计算 refresh_token 的 SHA256 哈希(`HashToken`)
|
||||
3. 在数据库中查找该哈希(`FindRefreshToken`)
|
||||
4. **如果哈希不存在**:
|
||||
- JWT 校验已通过但 DB 中不存在 → token 已被 rotation 删除
|
||||
- 这是 **token 复用行为**,属于安全风险
|
||||
- 吊销该用户的所有 refresh_token(`DeleteUserRefreshTokens`)
|
||||
- 返回 `ErrRefreshTokenUsed`
|
||||
5. 验证 token 归属的用户与 claims 一致
|
||||
6. 删除旧的 refresh_token 哈希(`DeleteRefreshToken`)
|
||||
7. 生成新的 access_token + refresh_token(`GeneratePair`)
|
||||
8. 保存新的 refresh_token 哈希到数据库(`SaveRefreshToken`)
|
||||
9. 查询用户信息(`FindByID`)
|
||||
10. 返回 `AuthResponse`
|
||||
|
||||
**安全机制**:
|
||||
- **Token 轮转**:每次 refresh 都会生成新的 token pair,旧 refresh_token 立即失效
|
||||
- **复用检测**:如果检测到已删除的 refresh_token 被复用,立即吊销该用户的所有 refresh_token
|
||||
- **强制重新登录**:吊销后,该用户所有设备都需要重新登录
|
||||
|
||||
#### 登出流程(Logout)
|
||||
|
||||
```go
|
||||
func (s *authService) Logout(ctx context.Context, userID, refreshToken string) error
|
||||
```
|
||||
|
||||
**流程**:
|
||||
1. 计算 refresh_token 的 SHA256 哈希(`HashToken`)
|
||||
2. 从数据库删除该哈希(`DeleteRefreshToken`)
|
||||
|
||||
### 4. Gin 中间件(AuthMiddleware)
|
||||
|
||||
**文件位置**:`backend/internal/auth/middleware.go`
|
||||
|
||||
```go
|
||||
func AuthMiddleware(tokenMgr *TokenManager) gin.HandlerFunc
|
||||
```
|
||||
|
||||
**功能**:
|
||||
1. 从请求头提取 `Authorization: Bearer <token>`
|
||||
2. 验证 access_token(`ValidateAccess`)
|
||||
3. 如果验证失败,返回 401 Unauthorized
|
||||
4. 如果验证成功,将 `user_id` 和 `username` 写入 Gin Context
|
||||
5. 调用 `c.Next()` 继续处理请求
|
||||
|
||||
**错误响应**:
|
||||
```json
|
||||
{
|
||||
"code": "INVALID_TOKEN",
|
||||
"message": "missing authorization header"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "INVALID_TOKEN",
|
||||
"message": "invalid authorization format"
|
||||
}
|
||||
```
|
||||
|
||||
```json
|
||||
{
|
||||
"code": "INVALID_TOKEN",
|
||||
"message": "invalid or expired token"
|
||||
}
|
||||
```
|
||||
|
||||
**Context Key**:
|
||||
- `ContextKeyUserID = "user_id"`
|
||||
- `ContextKeyUsername = "username"`
|
||||
|
||||
**使用示例**:
|
||||
```go
|
||||
// 在路由中使用中间件
|
||||
authorized := r.Group("/api")
|
||||
authorized.Use(auth.AuthMiddleware(tokenMgr))
|
||||
{
|
||||
authorized.GET("/conversations", handler.ListConversations)
|
||||
authorized.POST("/conversations", handler.CreateConversation)
|
||||
}
|
||||
```
|
||||
|
||||
## 数据模型
|
||||
|
||||
### 用户表(users)
|
||||
|
||||
```sql
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
username VARCHAR(64) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
### Refresh Token 表(refresh_tokens)
|
||||
|
||||
```sql
|
||||
CREATE TABLE refresh_tokens (
|
||||
token_hash VARCHAR(64) PRIMARY KEY, -- SHA256 哈希
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at TIMESTAMP WITH TIME ZONE NOT NULL,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_refresh_tokens_user_id ON refresh_tokens(user_id);
|
||||
CREATE INDEX idx_refresh_tokens_expires_at ON refresh_tokens(expires_at);
|
||||
```
|
||||
|
||||
## Repository 接口
|
||||
|
||||
### UserRepository
|
||||
|
||||
```go
|
||||
type UserRepository interface {
|
||||
// Create 创建用户,返回用户 ID
|
||||
Create(ctx context.Context, username, passwordHash string) (string, error)
|
||||
|
||||
// FindByUsername 根据用户名查找用户
|
||||
FindByUsername(ctx context.Context, username string) (*User, error)
|
||||
|
||||
// FindByID 根据 ID 查找用户
|
||||
FindByID(ctx context.Context, id string) (*User, error)
|
||||
|
||||
// SaveRefreshToken 保存 refresh_token 哈希
|
||||
SaveRefreshToken(ctx context.Context, userID, tokenHash string, expiresAt time.Time) error
|
||||
|
||||
// FindRefreshToken 根据 token 哈希查找用户 ID
|
||||
FindRefreshToken(ctx context.Context, tokenHash string) (string, error)
|
||||
|
||||
// DeleteRefreshToken 删除指定的 refresh_token
|
||||
DeleteRefreshToken(ctx context.Context, tokenHash string) error
|
||||
|
||||
// DeleteUserRefreshTokens 删除用户的所有 refresh_token(用于检测复用时吊销)
|
||||
DeleteUserRefreshTokens(ctx context.Context, userID string) error
|
||||
}
|
||||
```
|
||||
|
||||
## 前端集成
|
||||
|
||||
### Token 存储
|
||||
|
||||
**推荐方案**:
|
||||
- `access_token`:存储在内存中(JavaScript 变量)
|
||||
- `refresh_token`:存储在 `httpOnly` Cookie 中(防止 XSS 攻击)
|
||||
|
||||
**备选方案**(开发环境):
|
||||
- 两者都存储在 `localStorage`(便于调试,但存在 XSS 风险)
|
||||
|
||||
### 请求拦截器
|
||||
|
||||
```typescript
|
||||
// axios 请求拦截器
|
||||
api.interceptors.request.use((config) => {
|
||||
const accessToken = getAccessToken();
|
||||
if (accessToken) {
|
||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
// axios 响应拦截器
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// 如果是 401 且不是 refresh 请求,尝试刷新 token
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const refreshToken = getRefreshToken();
|
||||
const response = await api.post('/api/auth/refresh', {
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
|
||||
const { access_token, refresh_token } = response.data;
|
||||
setAccessToken(access_token);
|
||||
setRefreshToken(refresh_token);
|
||||
|
||||
// 重试原始请求
|
||||
originalRequest.headers.Authorization = `Bearer ${access_token}`;
|
||||
return api(originalRequest);
|
||||
} catch (refreshError) {
|
||||
// 刷新失败,跳转登录页
|
||||
clearTokens();
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
### WebSocket 认证
|
||||
|
||||
```typescript
|
||||
// 建立 WebSocket 连接时传递 access_token
|
||||
const wsUrl = `ws://${window.location.host}/ws?token=${accessToken}&conversation_id=${conversationId}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
// 连接失败时(401),触发 token 刷新
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket connection failed');
|
||||
// 可能需要刷新 token 后重连
|
||||
};
|
||||
```
|
||||
|
||||
## 安全考虑
|
||||
|
||||
### 1. 密码安全
|
||||
|
||||
- **bcrypt 算法**:使用 bcrypt 进行密码哈希,cost factor 为 10
|
||||
- **盐值自动生成**:bcrypt 自动生成随机盐值,无需手动管理
|
||||
- **防彩虹表**:每个密码的哈希值都不同,即使密码相同
|
||||
|
||||
### 2. Token 安全
|
||||
|
||||
- **短期 access_token**:15 分钟有效期,降低泄露风险
|
||||
- **Refresh Token Rotation**:每次 refresh 都生成新 token,旧 token 立即失效
|
||||
- **复用检测**:检测到已删除的 refresh_token 被复用时,吊销该用户的所有 token
|
||||
- **SHA256 哈希存储**:数据库只存储 refresh_token 的哈希值,不存储原始 token
|
||||
|
||||
### 3. 传输安全
|
||||
|
||||
- **HTTPS 强制**:生产环境必须使用 HTTPS
|
||||
- **同源反代**:通过 Nginx 反向代理(生产)或 Vite proxy(开发)统一前后端到同一域名,浏览器层面无跨域问题
|
||||
- **HttpOnly Cookie**:refresh_token 存储在 httpOnly Cookie 中,防止 XSS 攻击
|
||||
|
||||
### 4. 防攻击策略
|
||||
|
||||
- **防暴力破解**:可选的速率限制(`RATE_LIMITED` 错误码)
|
||||
- **防枚举攻击**:登录失败时统一返回 `INVALID_CREDENTIALS`,不区分用户名不存在还是密码错误
|
||||
- **防重放攻击**:Refresh Token Rotation 确保每个 refresh_token 只能使用一次
|
||||
- **防 Token 泄露**:检测到 token 复用时,立即吊销该用户的所有 token
|
||||
|
||||
## 配置说明
|
||||
|
||||
### 配置文件
|
||||
|
||||
```yaml
|
||||
auth:
|
||||
jwt_secret: "" # JWT 签名密钥(必须通过环境变量设置)
|
||||
access_ttl: 15 # access_token 有效期(分钟)
|
||||
refresh_ttl: 10080 # refresh_token 有效期(分钟,7天)
|
||||
```
|
||||
|
||||
### 环境变量
|
||||
|
||||
| 环境变量 | 说明 | 示例 |
|
||||
|---------|------|------|
|
||||
| `CAMTALK_AUTH_JWT_SECRET` | JWT 签名密钥(必须) | `$(openssl rand -hex 32)` |
|
||||
| `CAMTALK_AUTH_ACCESS_TTL` | access_token 有效期(分钟) | `15` |
|
||||
| `CAMTALK_AUTH_REFRESH_TTL` | refresh_token 有效期(分钟) | `10080` |
|
||||
|
||||
**安全要求**:
|
||||
- `JWT_SECRET` **必须**通过环境变量设置,不能写入配置文件
|
||||
- 生产环境使用 `openssl rand -hex 32` 生成随机密钥
|
||||
- 密钥长度建议至少 32 字节(256 位)
|
||||
|
||||
## 错误码
|
||||
|
||||
| 错误码 | HTTP 状态码 | 含义 | 客户端处理 |
|
||||
|--------|-----------|------|-----------|
|
||||
| `USERNAME_TAKEN` | 409 | 用户名已存在 | 提示换一个用户名 |
|
||||
| `INVALID_CREDENTIALS` | 401 | 用户名或密码错误 | 提示检查输入 |
|
||||
| `INVALID_TOKEN` | 401 | JWT 无效或已过期 | 尝试 refresh,失败则重新登录 |
|
||||
|
||||
## 测试用例
|
||||
|
||||
### 单元测试
|
||||
|
||||
**文件位置**:`backend/internal/auth/jwt_test.go`, `backend/internal/auth/service_test.go`
|
||||
|
||||
**测试覆盖**:
|
||||
- Token 生成和验证
|
||||
- Token 过期处理
|
||||
- Refresh Token Rotation
|
||||
- Token 复用检测和吊销
|
||||
- 密码哈希和验证
|
||||
- 边界条件和错误处理
|
||||
|
||||
### 集成测试
|
||||
|
||||
**测试场景**:
|
||||
- 注册 → 登录 → 访问受保护资源
|
||||
- Token 刷新流程
|
||||
- Token 过期后自动刷新
|
||||
- 并发刷新 token(竞态条件)
|
||||
- Token 复用检测和吊销
|
||||
|
||||
## 监控指标
|
||||
|
||||
### 关键指标
|
||||
|
||||
- **登录成功率**:登录成功次数 / 登录总次数
|
||||
- **Token 刷新率**:refresh 请求次数 / 总请求数
|
||||
- **Token 复用检测**:检测到 token 复用的次数(安全事件)
|
||||
- **认证延迟**:JWT 验证的平均耗时
|
||||
|
||||
### 告警规则
|
||||
|
||||
- **Token 复用检测**:任何 token 复用事件都应触发告警
|
||||
- **异常登录失败率**:短时间内大量登录失败可能表示暴力破解攻击
|
||||
- **Token 刷新失败率**:refresh 失败率突然上升可能表示系统问题
|
||||
|
||||
## 扩展点
|
||||
|
||||
### 1. 多设备管理
|
||||
|
||||
当前实现支持同一用户在多个设备上登录(每个设备独立的 refresh_token)。可以扩展为:
|
||||
- 设备列表管理
|
||||
- 单设备登录(踢出其他设备)
|
||||
- 设备信任等级
|
||||
|
||||
### 2. OAuth 第三方登录
|
||||
|
||||
可以扩展 AuthService 支持 OAuth 2.0:
|
||||
- Google、GitHub 等第三方登录
|
||||
- 绑定/解绑第三方账号
|
||||
- 统一的用户身份管理
|
||||
|
||||
### 3. 双因素认证(2FA)
|
||||
|
||||
可以扩展为:
|
||||
- TOTP(基于时间的一次性密码)
|
||||
- SMS 验证码
|
||||
- 邮箱验证
|
||||
|
||||
### 4. 会话管理
|
||||
|
||||
可以扩展为:
|
||||
- 活跃会话列表
|
||||
- 远程登出其他会话
|
||||
- 会话过期策略
|
||||
|
||||
## 参考资料
|
||||
|
||||
- [JWT 规范](https://tools.ietf.org/html/rfc7519)
|
||||
- [bcrypt 算法](https://en.wikipedia.org/wiki/Bcrypt)
|
||||
- [OWASP 认证备忘录](https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html)
|
||||
- [Refresh Token Rotation](https://auth0.com/blog/refresh-tokens-what-are-they-and-when-to-use-them/)
|
||||
@@ -8,28 +8,36 @@ CamTalk 是一款多模态实时 AI 视觉对话助手。用户通过摄像头
|
||||
|------|------|
|
||||
| [01-架构设计](01-架构设计.md) | 系统架构、技术栈、模块设计、数据库、部署架构(含 Mermaid 图) |
|
||||
| [02-接口文档](02-接口文档.md) | WebSocket 协议、REST API、AI 服务层、Eino 编排器、Session Manager、配置管理、数据模型、错误码 |
|
||||
| [03-技术选型](03-技术选型.md) | 各技术的选型对比与决策理由(含 Eino 框架选型) |
|
||||
| [03-技术选型](03-技术选型.md) | 各技术的选型对比与决策理由(含 Eino 框架选型、关键技术术语) |
|
||||
| [04-用户故事](04-用户故事.md) | P0/P1/P2 用户故事、验收标准 |
|
||||
| [05-语音交互](05-语音交互.md) | VAD → STT → LLM → TTS 全链路、延迟优化 |
|
||||
| [06-视觉理解](06-视觉理解.md) | 帧采样策略、图像编码、多模态 LLM 输入机制 |
|
||||
| [07-成本控制](07-成本控制.md) | 智能采样、端云协同、模型分级、缓存复用 |
|
||||
| [08-功能创意](08-功能创意.md) | 未来功能创意清单 |
|
||||
| [09-技术名词解释](09-技术名词解释.md) | 前端/后端/AI 服务/Eino 框架技术名词简明解释 |
|
||||
| [10-Eino重构方案](10-Eino重构方案.md) | Eino Graph 替换手写 goroutine 管道的设计方案 |
|
||||
| [11-Eino框架技术文档](11-Eino框架技术文档.md) | Eino 框架在 CamTalk 中的使用指南(Graph、Lambda、Callback、State) |
|
||||
| [12-鉴权体系设计](12-鉴权体系设计.md) | JWT 双 token 轮转认证、bcrypt 密码哈希、Refresh Token Rotation、安全机制 |
|
||||
| [13-令牌桶限流设计](13-令牌桶限流设计.md) | 令牌桶限流算法、内存/Redis 双实现、Gin 中间件、WebSocket query 限流、配置设计 |
|
||||
| [08-Eino框架与编排设计](08-Eino框架与编排设计.md) | Eino 框架核心概念、Graph 设计、节点实现、测试策略 |
|
||||
| [09-情景切换](09-情景切换.md) | 多情景 AI 角色扮演系统(面试官、英语老师、辩论对手、翻译员、自由对话) |
|
||||
| [10-鉴权体系](10-鉴权体系.md) | JWT 双 token 轮转认证、bcrypt 密码哈希、Refresh Token Rotation、安全机制 |
|
||||
| [11-令牌桶限流](11-令牌桶限流.md) | 令牌桶限流算法、内存/Redis 双实现、Gin 中间件、WebSocket query 限流 |
|
||||
|
||||
|
||||
## 推荐阅读顺序
|
||||
|
||||
1. **01-架构设计** — 理解三层架构、技术栈和模块全貌
|
||||
2. **02-接口文档** — 前后端通信契约,实现时的最高依据
|
||||
3. **03-技术选型** — 了解为什么选这些技术(含 Eino 框架)
|
||||
3. **03-技术选型** — 了解为什么选这些技术(含 Eino 框架、关键技术术语)
|
||||
4. **04-用户故事** — 明确功能优先级
|
||||
5. **05~07** — 各技术领域的详细设计
|
||||
6. **09-技术名词解释** — 遇到不熟悉的名词时查阅
|
||||
7. **10~12** — Eino 重构相关(方案、框架文档、实施记录)
|
||||
8. **12-鉴权体系设计** — 认证授权机制详细设计(JWT、bcrypt、Refresh Token Rotation)
|
||||
9. **13-令牌桶限流设计** — 速率限制设计(令牌桶算法、成本控制、防暴力破解)
|
||||
6. **08-Eino框架与编排设计** — Eino 框架核心概念、Graph 编排、节点实现
|
||||
7. **09-情景切换** — 多情景 AI 角色扮演系统
|
||||
8. **10-鉴权体系** — 认证授权机制详细设计
|
||||
9. **11-令牌桶限流** — 速率限制设计
|
||||
|
||||
## 功能扩展方向
|
||||
|
||||
以下是未来可能的功能创意和扩展方向:
|
||||
|
||||
1. **视频录制** — 支持录制对话过程中的视频画面
|
||||
2. **对话翻译** — 实时多语言翻译能力
|
||||
3. **对话总结** — 自动生成对话摘要和关键点
|
||||
4. **手动对话功能** — 支持用户手动触发对话而非自动 VAD
|
||||
5. **视频框自定义** — 视频框大小可调整,支持最小化和拖动
|
||||
|
||||
|
||||
@@ -1,664 +0,0 @@
|
||||
# 情景切换功能实现与修复完整文档
|
||||
|
||||
**项目**: CamTalk 多模态实时 AI 视觉对话助手
|
||||
**功能**: 情景切换(模拟面试官、英语老师、辩论对手、同声翻译)
|
||||
**日期**: 2026-06-20
|
||||
**状态**: ✅ 已完成并修复
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [功能概述](#功能概述)
|
||||
2. [实施内容](#实施内容)
|
||||
3. [Bug 修复记录](#bug-修复记录)
|
||||
4. [测试验证](#测试验证)
|
||||
5. [部署指南](#部署指南)
|
||||
6. [技术细节](#技术细节)
|
||||
7. [后续优化建议](#后续优化建议)
|
||||
|
||||
---
|
||||
|
||||
## 功能概述
|
||||
|
||||
### 什么是情景切换?
|
||||
|
||||
情景切换功能允许用户选择不同的对话场景,AI 会根据选择的情景扮演不同的角色:
|
||||
|
||||
| 情景 | AI 角色 | 主要功能 |
|
||||
|------|---------|---------|
|
||||
| 🎯 模拟面试官 | 资深面试官 | 提出面试问题,评估候选人能力,给出反馈 |
|
||||
| 📚 英语老师 | 英语外教 | 全英文对话,纠正语法错误,引导深入交流 |
|
||||
| ⚔️ 辩论对手 | 辩论选手 | 站在反方立场,用逻辑和证据反驳观点 |
|
||||
| 🌐 同声翻译 | 翻译员 | 实时中英互译,口语化翻译,无额外解释 |
|
||||
| 💬 自由对话 | 视觉助手 | 通用视觉对话助手(默认) |
|
||||
|
||||
### 核心功能
|
||||
|
||||
1. **情景首句引导**:切换情景后,AI 自动发送第一句话引导用户进入角色
|
||||
2. **情景提示卡片**:对话顶部显示当前情景模式的蓝色提示卡片
|
||||
3. **增强 System Prompt**:每个情景有详细的角色定位、交互规则和约束
|
||||
4. **多语言支持**:完整支持中文、英文、日文界面
|
||||
|
||||
---
|
||||
|
||||
## 实施内容
|
||||
|
||||
### 后端实现
|
||||
|
||||
#### 1. 情景 Prompt 定义
|
||||
|
||||
**文件**: `backend/internal/ai/llm/scenarios.go`
|
||||
|
||||
**变更内容**:
|
||||
- 扩展 `scenarioPrompt` 结构体,新增首句引导字段(GreetingZH/EN/JA)
|
||||
- 增强所有情景的 System Prompt(添加角色定位、交互规则、约束)
|
||||
- 新增函数 `GetScenarioGreeting(scenarioID, language string) string`
|
||||
|
||||
**示例 Prompt**(面试官):
|
||||
```go
|
||||
"interviewer": {
|
||||
ZH: `你是一位资深面试官。你通过摄像头观察面试者...
|
||||
|
||||
【角色定位】
|
||||
- 你是面试官,不是助手或顾问
|
||||
- 你的目标是评估候选人的能力
|
||||
- 保持专业、客观、礼貌
|
||||
|
||||
【交互规则】
|
||||
1. 每次只问一个问题,等用户回答后再追问
|
||||
2. 问题要有层次:自我介绍 → 专业问题 → 情景题
|
||||
3. 对用户的回答给出简短点评,然后追问
|
||||
...`,
|
||||
GreetingZH: "你好!我是今天的面试官。让我们先从自我介绍开始...",
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. 首句引导推送
|
||||
|
||||
**文件**: `backend/internal/ws/handler.go`
|
||||
|
||||
**变更内容**:
|
||||
在处理 `config` 消息时,如果切换到非自由对话情景,自动返回首句引导:
|
||||
|
||||
```go
|
||||
case "config":
|
||||
// ... 更新配置 ...
|
||||
|
||||
// 如果切换了情景(非自由对话),返回首句引导
|
||||
if scenarioID != "" && scenarioID != "free_chat" {
|
||||
greeting := llm.GetScenarioGreeting(scenarioID, sess.Config.Language)
|
||||
if greeting != "" {
|
||||
// 发送 llm_chunk 和 llm_done 消息
|
||||
// 追加到历史记录
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**效果**:用户切换情景后,AI 立即自动说出首句,无需等待用户发送消息。
|
||||
|
||||
#### 3. State 初始化修复(关键 Bug 修复)
|
||||
|
||||
**文件**: `backend/internal/eino/adapter.go`
|
||||
|
||||
**问题**:`genLocalState()` 创建的是空 State,所有字段都是零值,导致 `state.Scenario = ""`
|
||||
|
||||
**修复**:
|
||||
```go
|
||||
// ✅ 修复:从 input 复制元数据到 state
|
||||
state := genLocalState(ctx)
|
||||
state.SessionID = input.SessionID
|
||||
state.RequestID = input.RequestID
|
||||
state.ImageData = input.ImageData
|
||||
state.Scenario = input.Scenario // ⬅️ 关键修复
|
||||
state.Language = input.Language
|
||||
state.DetailLevel = sess.Config.DetailLevel
|
||||
state.TTSEnabled = input.TTSEnabled
|
||||
ctx = WithPipelineState(ctx, state)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 前端实现
|
||||
|
||||
#### 1. 情景提示卡片
|
||||
|
||||
**文件**: `frontend/src/components/ChatPanel/index.tsx`
|
||||
|
||||
**变更内容**:
|
||||
在对话列表顶部(非空状态 + 非自由对话模式)添加情景提示卡片:
|
||||
|
||||
```tsx
|
||||
{messages.length > 0 && !isFreeChat && (
|
||||
<div className="chat-panel__scenario-hint">
|
||||
<div className="scenario-hint-card">
|
||||
<span className="scenario-hint-card__icon">
|
||||
{scenarios.find(s => s.id === activeScenario)?.icon}
|
||||
</span>
|
||||
<div className="scenario-hint-card__text">
|
||||
<strong>{t(scenarios.find(s => s.id === activeScenario)?.nameKey || "")}</strong>
|
||||
<p>{t(`scenario.${activeScenario}.hint`)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
```
|
||||
|
||||
**显示效果**:
|
||||
- 蓝色渐变背景(135deg 从蓝到紫)
|
||||
- 左侧大图标 + 右侧标题和说明
|
||||
- 最大宽度 520px,响应式布局
|
||||
- 柔和阴影和半透明边框
|
||||
|
||||
#### 2. WebSocket 消息修复(关键 Bug 修复)
|
||||
|
||||
**文件**: `frontend/src/hooks/useVisionSession.ts`
|
||||
|
||||
**问题**:发送 config 消息时缺少 `scenario` 字段,导致后端无法接收到情景切换信息
|
||||
|
||||
**修复位置 1**(连接成功时发送初始配置):
|
||||
```typescript
|
||||
// ✅ 修复:添加 scenario 字段
|
||||
send({
|
||||
type: "config",
|
||||
payload: {
|
||||
tts_enabled: config.ttsEnabled,
|
||||
detail_level: config.detailLevel,
|
||||
language: config.language,
|
||||
scenario: config.scenario, // ⬅️ 关键修复
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**修复位置 2**(updateConfig 函数):
|
||||
```typescript
|
||||
// ✅ 修复:添加 scenario 字段
|
||||
send({
|
||||
type: "config",
|
||||
payload: {
|
||||
tts_enabled: next.ttsEnabled,
|
||||
detail_level: next.detailLevel,
|
||||
language: next.language,
|
||||
scenario: next.scenario, // ⬅️ 关键修复
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### 3. 样式实现
|
||||
|
||||
**文件**: `frontend/src/App.css`
|
||||
|
||||
新增情景提示卡片样式:
|
||||
```css
|
||||
.scenario-hint-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.08) 0%, rgba(99, 102, 241, 0.08) 100%);
|
||||
border: 1px solid rgba(59, 130, 246, 0.2);
|
||||
box-shadow: 0 2px 8px rgba(59, 130, 246, 0.06);
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. 多语言翻译
|
||||
|
||||
**文件**: `frontend/src/lib/i18n/{zh-CN,en-US,ja-JP}.ts`
|
||||
|
||||
新增翻译 key:
|
||||
```typescript
|
||||
"scenario.interviewer.hint": "AI 会扮演面试官,逐步提出专业问题并点评你的回答",
|
||||
"scenario.englishTeacher.hint": "AI 会用英语对话,纠正语法错误并引导深入交流",
|
||||
"scenario.debate.hint": "AI 会站在反方立场,用逻辑和证据反驳你的观点",
|
||||
"scenario.interpreter.hint": "AI 会实时翻译你的话(中英互译),无解释评论",
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Bug 修复记录
|
||||
|
||||
### Bug #1:后端 State 未初始化 Scenario
|
||||
|
||||
**严重性**: 🔴 Critical(核心功能完全失效)
|
||||
|
||||
**症状**:
|
||||
- 切换到任何情景后,AI 仍使用默认通用助手 Prompt
|
||||
- AI 回答:"我是通义千问,阿里巴巴集团研发的超大规模语言模型..."
|
||||
- 完全不遵循情景角色设定
|
||||
|
||||
**根因**:
|
||||
`backend/internal/eino/adapter.go` 中,`genLocalState()` 创建的是空 State:
|
||||
```go
|
||||
❌ ctx = WithPipelineState(ctx, genLocalState(ctx))
|
||||
```
|
||||
|
||||
导致 `state.Scenario = ""`(空字符串),`nodes_history.go` 读取到空值后使用默认 Prompt。
|
||||
|
||||
**数据流分析**:
|
||||
```
|
||||
input.Scenario = "interviewer"
|
||||
↓
|
||||
❌ state.Scenario = "" (未初始化!)
|
||||
↓
|
||||
nodes_history.go 读取到 ""
|
||||
↓
|
||||
llm.GetScenarioPrompt("", "zh-CN") 返回 ""
|
||||
↓
|
||||
使用默认 Prompt → AI 回答 "我是通义千问..."
|
||||
```
|
||||
|
||||
**修复**:
|
||||
从 `PipelineInput` 复制元数据到 `PipelineState`:
|
||||
```go
|
||||
✅ state := genLocalState(ctx)
|
||||
state.Scenario = input.Scenario // 关键修复
|
||||
state.Language = input.Language
|
||||
state.ImageData = input.ImageData
|
||||
// ... 复制其他字段
|
||||
ctx = WithPipelineState(ctx, state)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Bug #2:前端未发送 scenario 字段
|
||||
|
||||
**严重性**: 🔴 Critical(前后端数据流断层)
|
||||
|
||||
**症状**:
|
||||
- 后端日志显示:`config updated scenario=""`
|
||||
- 会话配置中 scenario 未更新,始终为默认值 `free_chat`
|
||||
- WebSocket 消息缺少 scenario 字段
|
||||
|
||||
**根因**:
|
||||
`frontend/src/hooks/useVisionSession.ts` 发送 config 消息时缺少 `scenario` 字段:
|
||||
```typescript
|
||||
❌ send({
|
||||
type: "config",
|
||||
payload: {
|
||||
tts_enabled: config.ttsEnabled,
|
||||
detail_level: config.detailLevel,
|
||||
language: config.language,
|
||||
// 缺少 scenario: config.scenario
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**修复**:
|
||||
在两处发送 config 的地方添加 `scenario` 字段(第 128 行和第 168 行)。
|
||||
|
||||
---
|
||||
|
||||
### 完整数据流(修复后)
|
||||
|
||||
```
|
||||
用户切换情景到"模拟面试官"
|
||||
↓
|
||||
前端 updateConfig({scenario: "interviewer"})
|
||||
↓
|
||||
✅ 发送 WebSocket: {type: "config", payload: {scenario: "interviewer"}}
|
||||
↓
|
||||
后端 handler.go 接收并保存
|
||||
↓
|
||||
sess.Config.Scenario = "interviewer"
|
||||
↓
|
||||
用户发送消息 "你是谁?"
|
||||
↓
|
||||
buildPipelineInput() → input.Scenario = "interviewer"
|
||||
↓
|
||||
✅ adapter.go 复制:state.Scenario = input.Scenario
|
||||
↓
|
||||
nodes_history.go 读取 state.Scenario = "interviewer"
|
||||
↓
|
||||
llm.GetScenarioPrompt("interviewer", "zh-CN")
|
||||
↓
|
||||
返回:"你是一位资深面试官..."
|
||||
↓
|
||||
llm.BuildSystemPrompt(..., scenarioPrompt)
|
||||
↓
|
||||
注入到 ChatModel System Message
|
||||
↓
|
||||
LLM 生成回复:"我是今天的面试官..."
|
||||
↓
|
||||
✅ 情景生效!
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 测试验证
|
||||
|
||||
### 编译验证
|
||||
|
||||
✅ **后端**:
|
||||
```bash
|
||||
cd backend && go build -o /tmp/camtalk_fix ./cmd/server
|
||||
# 产物:48MB,无编译错误
|
||||
```
|
||||
|
||||
✅ **前端**:
|
||||
```bash
|
||||
cd frontend && npm run lint
|
||||
# ESLint 检查通过(无新增错误)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 功能测试清单
|
||||
|
||||
| 测试项 | 操作步骤 | 预期结果 | 验证方法 |
|
||||
|--------|---------|---------|---------|
|
||||
| **首句引导** | 切换到"模拟面试官" | AI 自动说:"你好!我是今天的面试官..." | 观察聊天框 |
|
||||
| **情景生效** | 问 "你是谁?" | AI 回答:"我是今天的面试官..." | 观察回复内容 |
|
||||
| **提示卡片** | 发送一条消息后查看顶部 | 显示蓝色卡片:"🎯 模拟面试官 \| AI 会扮演面试官..." | 观察 UI |
|
||||
| **语言联动** | 切换到"英语老师" | 语言自动切换到 en-US,AI 用英语回复 | 观察配置和回复 |
|
||||
| **持久化** | 切换情景后刷新页面 | 情景配置保持,首句仍在历史中 | 刷新浏览器 |
|
||||
| **多情景** | 依次测试所有情景 | 每个情景 AI 回复风格明显不同 | 对比回复 |
|
||||
|
||||
---
|
||||
|
||||
### 日志验证
|
||||
|
||||
**查看日志**:
|
||||
```bash
|
||||
tail -f /tmp/camtalk_server.log | grep -E "config updated|历史组装完成"
|
||||
```
|
||||
|
||||
**修复前**(Bug):
|
||||
```
|
||||
config updated session=xxx scenario="" ← ❌ 空字符串
|
||||
历史组装完成 ... scenario=free_chat ← ❌ 始终是默认值
|
||||
```
|
||||
|
||||
**修复后**(正常):
|
||||
```
|
||||
config updated session=xxx scenario=interviewer ← ✅ 正确接收
|
||||
历史组装完成 ... scenario=interviewer ← ✅ 正确传递
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 部署指南
|
||||
|
||||
### 部署步骤
|
||||
|
||||
#### 1. 停止旧服务(如果正在运行)
|
||||
|
||||
```bash
|
||||
# 查找并停止占用 8080 端口的进程
|
||||
lsof -ti:8080 | xargs kill -9
|
||||
```
|
||||
|
||||
#### 2. 启动后端
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
go run ./cmd/server
|
||||
# 或编译后运行
|
||||
# go build -o camtalk ./cmd/server && ./camtalk
|
||||
```
|
||||
|
||||
**验证后端启动**:
|
||||
```bash
|
||||
curl http://localhost:8080/api/health
|
||||
# 预期输出:{"status":"ok","version":"dev","uptime_seconds":10,"active_sessions":0}
|
||||
```
|
||||
|
||||
#### 3. 启动前端(如已运行则刷新浏览器)
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm run dev
|
||||
# 访问 http://localhost:5173
|
||||
```
|
||||
|
||||
**前端无需重启**:Vite 会自动热更新(HMR),只需刷新浏览器页面即可。
|
||||
|
||||
---
|
||||
|
||||
### 快速验证
|
||||
|
||||
1. **打开浏览器** → http://localhost:5173
|
||||
2. **登录系统**
|
||||
3. **切换情景** → 右侧配置面板 → 对话情景 → 模拟面试官
|
||||
4. **观察现象**:
|
||||
- ✨ AI 立即说:"你好!我是今天的面试官。让我们先从自我介绍开始..."
|
||||
- ✨ 对话框顶部显示蓝色提示卡片
|
||||
5. **验证效果** → 发送:"你是谁?"
|
||||
- ✅ **正确回复**:"我是今天的面试官..."
|
||||
- ❌ **错误回复**:"我是通义千问..."
|
||||
|
||||
---
|
||||
|
||||
## 技术细节
|
||||
|
||||
### Eino 框架 State 机制
|
||||
|
||||
项目使用 **CloudWeGo Eino** 框架进行 AI 编排,State 在节点间共享数据:
|
||||
|
||||
```go
|
||||
// 1. 定义 State 结构
|
||||
type PipelineState struct {
|
||||
Scenario string // 必须显式赋值
|
||||
...
|
||||
}
|
||||
|
||||
// 2. 注册 State 生成函数
|
||||
g := compose.NewGraph[I, O](
|
||||
compose.WithGenLocalState(genLocalState),
|
||||
)
|
||||
|
||||
// 3. 节点通过 stateFromCtx(ctx) 读取
|
||||
state := stateFromCtx(ctx)
|
||||
scenario := state.Scenario
|
||||
```
|
||||
|
||||
**关键点**:`genLocalState` 只是创建空结构体,**必须在调用 Graph 前手动赋值**!
|
||||
|
||||
---
|
||||
|
||||
### WebSocket 协议
|
||||
|
||||
**客户端 → 服务端**(config 消息):
|
||||
```json
|
||||
{
|
||||
"type": "config",
|
||||
"payload": {
|
||||
"tts_enabled": true,
|
||||
"detail_level": "low",
|
||||
"language": "zh-CN",
|
||||
"scenario": "interviewer"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**服务端 → 客户端**(首句引导):
|
||||
```json
|
||||
// llm_chunk
|
||||
{
|
||||
"type": "llm_chunk",
|
||||
"request_id": "scenario_greeting",
|
||||
"delta": "你好!我是今天的面试官...",
|
||||
"role": "assistant"
|
||||
}
|
||||
|
||||
// llm_done
|
||||
{
|
||||
"type": "llm_done",
|
||||
"request_id": "scenario_greeting",
|
||||
"full_text": "你好!我是今天的面试官...",
|
||||
"tokens_used": {"prompt": 0, "completion": 0, "total": 0}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### System Prompt 构建流程
|
||||
|
||||
```
|
||||
sess.Config.Scenario = "interviewer"
|
||||
↓
|
||||
PipelineInput.Scenario = "interviewer"
|
||||
↓
|
||||
PipelineState.Scenario = "interviewer" (adapter.go 复制)
|
||||
↓
|
||||
nodes_history.go 读取 state.Scenario
|
||||
↓
|
||||
scenarioPrompt := llm.GetScenarioPrompt("interviewer", "zh-CN")
|
||||
↓
|
||||
返回:"你是一位资深面试官。你通过摄像头观察面试者..."
|
||||
↓
|
||||
systemPrompt := llm.BuildSystemPrompt(language, detailLevel, scenarioPrompt)
|
||||
↓
|
||||
messages[0] = {Role: "system", Content: systemPrompt}
|
||||
↓
|
||||
ChatModel 接收到情景 Prompt
|
||||
↓
|
||||
LLM 按情景角色生成回复
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 后续优化建议
|
||||
|
||||
### P2(强烈推荐)
|
||||
|
||||
1. **情景切换时创建新会话**
|
||||
- 避免历史对话干扰新情景
|
||||
- 弹窗确认:"切换情景会创建新会话,当前对话将保存。是否继续?"
|
||||
- 实现难度:⭐⭐
|
||||
- 用户价值:⭐⭐⭐⭐
|
||||
|
||||
2. **进一步增强 System Prompt**
|
||||
- 增加示例对话(Few-shot Prompting)
|
||||
- 增加"禁止事项"列表
|
||||
- 实现难度:⭐
|
||||
- 效果提升:⭐⭐⭐
|
||||
|
||||
### P3(可选)
|
||||
|
||||
1. **情景专属 UI 主题色**
|
||||
- 面试官 → 深蓝色
|
||||
- 英语老师 → 绿色
|
||||
- 辩论 → 红色
|
||||
- 翻译 → 紫色
|
||||
|
||||
2. **切换动画与音效**
|
||||
- 切换时播放短音效
|
||||
- 聊天面板淡出淡入动画
|
||||
|
||||
---
|
||||
|
||||
## 修改文件清单
|
||||
|
||||
### 后端(3 个文件)
|
||||
|
||||
- ✅ `backend/internal/eino/adapter.go` — 修复 State 初始化
|
||||
- ✅ `backend/internal/ws/handler.go` — 添加首句引导
|
||||
- ✅ `backend/internal/ai/llm/scenarios.go` — 增强 Prompt + 首句
|
||||
|
||||
### 前端(5 个文件)
|
||||
|
||||
- ✅ `frontend/src/hooks/useVisionSession.ts` — 修复 scenario 发送
|
||||
- ✅ `frontend/src/components/ChatPanel/index.tsx` — 添加提示卡片
|
||||
- ✅ `frontend/src/App.css` — 卡片样式
|
||||
- ✅ `frontend/src/lib/i18n/zh-CN.ts` — 中文翻译
|
||||
- ✅ `frontend/src/lib/i18n/en-US.ts` — 英文翻译
|
||||
- ✅ `frontend/src/lib/i18n/ja-JP.ts` — 日文翻译
|
||||
|
||||
---
|
||||
|
||||
## 经验教训
|
||||
|
||||
1. **数据流完整性验证**
|
||||
- 从用户输入 → WebSocket → 后端逻辑 → LLM → 回复
|
||||
- 每个环节都需要日志验证
|
||||
|
||||
2. **框架封装层的隐式约定**
|
||||
- Eino State 需要显式初始化
|
||||
- 不能依赖零值或默认值
|
||||
|
||||
3. **端到端测试的重要性**
|
||||
- 单元测试通过 ≠ 功能正常工作
|
||||
- 必须包含实际对话验证
|
||||
|
||||
4. **前后端协议同步**
|
||||
- WebSocket 消息字段必须对齐
|
||||
- 代码 review 需要覆盖完整数据流
|
||||
|
||||
---
|
||||
|
||||
## 提交信息
|
||||
|
||||
```bash
|
||||
git add backend/internal/eino/adapter.go \
|
||||
backend/internal/ws/handler.go \
|
||||
backend/internal/ai/llm/scenarios.go \
|
||||
frontend/src/hooks/useVisionSession.ts \
|
||||
frontend/src/components/ChatPanel/index.tsx \
|
||||
frontend/src/App.css \
|
||||
frontend/src/lib/i18n/*.ts \
|
||||
docs/情景切换功能完整文档.md
|
||||
|
||||
git commit -m "feat: 实现情景切换功能 + 修复两个关键 Bug
|
||||
|
||||
功能实现:
|
||||
- 后端:添加情景首句引导(面试官/英语老师/辩论/翻译)
|
||||
- 后端:增强所有情景的 System Prompt(角色定位+规则+约束)
|
||||
- 前端:对话顶部添加情景提示卡片(蓝色渐变+图标+说明)
|
||||
- i18n:完整支持中英日三语
|
||||
|
||||
Bug 修复:
|
||||
- Bug #1: adapter.go 未初始化 PipelineState.Scenario
|
||||
根因:genLocalState() 创建空 State,未从 input 复制元数据
|
||||
影响:所有情景均失效,AI 使用默认 Prompt
|
||||
修复:从 PipelineInput 复制 Scenario 等字段到 State
|
||||
|
||||
- Bug #2: useVisionSession.ts 未发送 scenario 字段
|
||||
根因:config 消息 payload 缺少 scenario 字段
|
||||
影响:后端无法接收到情景切换信息
|
||||
修复:在两处发送 config 的地方添加 scenario 字段
|
||||
|
||||
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 附录:故障排查
|
||||
|
||||
### 如果情景仍然不生效
|
||||
|
||||
1. **检查后端日志**:
|
||||
```bash
|
||||
grep "config updated" /tmp/camtalk_server.log | tail -5
|
||||
grep "历史组装完成" /tmp/camtalk_server.log | tail -5
|
||||
```
|
||||
|
||||
- 如果 `scenario=` 是空的,说明前端未发送或后端未接收
|
||||
- 如果 `scenario=interviewer` 正确,但 AI 回复仍是通用的,可能是 LLM 模型问题
|
||||
|
||||
2. **检查前端 WebSocket 消息**(浏览器 DevTools → Network → WS):
|
||||
```json
|
||||
{
|
||||
"type": "config",
|
||||
"payload": {
|
||||
"scenario": "interviewer" // 确认存在
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **检查会话配置是否保存**:
|
||||
- 切换情景后,LocalStorage 中应该有 `camtalk_config`
|
||||
- 内容应包含 `"scenario": "interviewer"`
|
||||
|
||||
4. **清除缓存重试**:
|
||||
```bash
|
||||
# 浏览器:清除 LocalStorage
|
||||
# 后端:重启服务
|
||||
# 前端:刷新页面
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**文档版本**: 1.0
|
||||
**最后更新**: 2026-06-20
|
||||
**维护人员**: CamTalk 开发团队
|
||||
Reference in New Issue
Block a user