Files
wecom_it_smart_desk/docs/02-需求分析/技术架构演进/员工端消息发送延时改造方案.md
T
Simon e4e2de47bb docs: test reports + knowledge iteration design + PRDs
提交 OTP/RBAC/Tier0/Tier1/P0+P2 测试报告、方案A E2E 验证、知识库迭代设计(PRD/mermaid/html 原型)、项目状态看板更新; 根配置 docker-compose.yml/mkdocs.yml。
2026-07-09 11:50:19 +08:00

380 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 员工端消息发送延时改造方案
> 状态:实施中(后端 + 前端改造已完成,待联调 / 部署验证)
> 提出日期:2026-07-08
> 关联问题:员工端 H5 发送消息有延时(消息长时间停留在"发送中",AI 回复晚到)
> 关联文档:`docs/09-部署运维/00-标准故障排查手册.md`
---
## 1. 问题背景
员工端 H5 目前发送一条消息时,后端会**同步**完成「消息落库 → 调用 AI 推理(Dify/RAGFlow)→ 返回响应」。由于 AI 推理本身耗时(非流式,一次 3~15 秒甚至更久),整个 HTTP 请求被 AI 阻塞,前端即便做了乐观更新,发送态切换和 AI 回复仍被拖慢,用户感知为"发送有延时"。
---
## 2. 现象与用户感知
| 阶段 | 用户看到的现象 | 期望 |
|---|---|---|
| 点击发送 | 自己的消息立刻出现,标记"发送中" | 正常(乐观更新已生效) |
| 等待期 | "发送中"持续数秒不消失 | 应迅速变为"已发送" |
| AI 回复 | 要等好几秒甚至十几秒才出现 | 应尽快出现,最好流式 |
| 极端情况 | AI 超时/失败 → 自己的消息卡在 sending 或标 failed | 自己消息不应受 AI 影响 |
---
## 3. 根因分析(代码证据)
### 3.1 后端:发送与 AI 推理串行耦合
`backend/app/api/h5.py``h5_send_message`821990 行):
```python
# 第 897 行:同步等待 AI 推理完成,期间整条 HTTP 被阻塞
ai_result = await ai_handler.handle_message(
content=content,
dify_conversation_id=conversation.dify_conversation_id,
user_id=employee_id,
)
# 第 918989 行:AI 回复落库 + WS 广播 + 返回(都在 await 之后)
```
`ai_handler.handle_message` 内部走的是 **Dify 非流式调用**
`backend/app/services/ai_service.py:114`
```python
"stream": False, # 非流式 —— 必须等 AI 完整生成才返回
```
### 3.2 前端:发送态依赖被阻塞的响应
`frontend-h5/src/stores/conversation.ts`
```ts
// 第 463 行:已做乐观更新(自己消息立即显示,标记 sending)
status: 'sending',
// 第 485 行:但 sending → sent 的切换依赖后端响应返回
const resp: SendMessageResponse = await sendMessage(reqData)
```
`frontend-h5/src/api/conversation.ts:284-285`
```ts
const response = await apiClient.post('/h5/conversations/current/messages', data, {
timeout: 30000, // AI 慢时最坏等 30 秒
})
```
### 3.3 结论
> **不是网络慢,是"发消息"和"AI 思考"被绑死在同步请求里。** AI 每慢 1 秒,发送响应就卡 1 秒。
---
## 4. 影响范围
- 所有走 `h5_send_message` 的文本/图片/文件消息(图片文件因还要过 Vision/Dify 更慢)
- 坐席端 WS 实时性(AI 回复广播被延后,坐席看到新消息也晚)
- 用户体验评分(满意度调查可能受此影响)
---
## 5. 可选方案对比
| 方案 | 做法 | 体验 | 改动量 | 风险 |
|---|---|---|---|---|
| **A. 异步化 + 流式 WS 推送**(推荐) | 发送接口只存消息立即返回;AI 推理放后台任务,经 WS 流式推送 `ai_reply_chunk` | 发送瞬时完成,AI 打字机式到达 | 中(2.5–3 天) | 需 WS 推送 + 前端消费;后台任务需兜底 |
| **B. SSE 流式接口** | 发送接口改用 SSE,AI 回复逐字推回 | 同 A,但走独立 SSE 通道 | 中 | 企微 WebView 对 SSE/长连接兼容性需验证;网关可能截断 |
| **C. 拆两接口快速止血** | 新增"仅存消息"接口(不调 AI),AI 结果靠现有 3 秒轮询拉取 | 发送快,AI 延迟 ≈ 轮询间隔+推理 | 小(0.5–1 天) | 轮询本身有 3s 延迟,体验一般;非根本解 |
**推荐 A**:后端已有 `ai_service.get_reply_stream`(流式,`ai_service.py:196`)和 `ws_manager.broadcast`(已在用),改造可**直接复用现有能力**,不引入新依赖。
---
## 6. 推荐方案 A 详细设计
### 6.1 后端改造
**① 拆出 AI 推理为后台任务**
```python
# backend/app/api/h5.py
async def h5_send_message(...):
# 1. 查找/创建会话(不变)
# 2. 存用户消息(不变,flush)
message = Message(...); db.add(message); await db.flush()
# 3. 【改造】启动后台任务,不阻塞响应
background_tasks.add_task(
generate_ai_reply,
conversation_id=conversation.id,
content=content,
employee_id=employee_id,
dify_conversation_id=conversation.dify_conversation_id,
)
# 4. 立即返回(不含 ai_reply
return success_response(data={
"user_message": user_msg_data,
"ai_reply": None,
"conversation_status": conversation.status,
"can_call_agent": conversation.ai_substantive_reply_count >= 3,
})
async def generate_ai_reply(conversation_id, content, employee_id, dify_conversation_id):
"""后台任务:流式推理 → 落库 → WS 推送"""
try:
chunks = []
async for chunk in ai_service.get_reply_stream(content, conversation_id):
chunks.append(chunk)
await ws_manager.broadcast({
"type": "ai_reply_chunk",
"data": {"conversation_id": str(conversation_id), "chunk": chunk},
})
# 落库 AI 消息
ai_msg = Message(conversation_id=conversation_id, sender_type="ai",
content="".join(chunks), is_read=True)
db.add(ai_msg); await db.flush()
# 推送完整消息(供轮询兜底 / 去重)
await ws_manager.broadcast({
"type": "ai_reply",
"data": MessageResponse.model_validate(ai_msg).model_dump(),
})
except Exception as e:
logger.warning(f"AI 推理失败: {e}")
# 兜底:存 system 消息提示 + 必要时转人工
...
```
**② 复用点**
- `ai_service.get_reply_stream``ai_service.py:196`)—— 已实现的流式 AI 接口
- `ws_manager.broadcast``h5.py:937`)—— 已用于 `new_message` / `conversation_updated`,新增 `ai_reply_chunk` / `ai_reply` 类型即可
### 6.2 前端改造
`frontend-h5/src/stores/conversation.ts` 中:
- 发送后立即拿到 `user_message`(已 `sent`),无需等待 AI
- 新增 WS 监听:
```ts
// WS 连接处新增
ws.on('ai_reply_chunk', ({ conversation_id, chunk }) => {
appendAiChunk(conversation_id, chunk) // 追加到当前 AI 气泡(打字机)
})
ws.on('ai_reply', ({ message }) => {
ensureAiMessage(message) // 去重追加完整 AI 消息
})
```
- `sending → sent` 切换:响应返回即置 `sent`(不再等 AI
### 6.3 WS 事件协议
| 事件 type | 触发时机 | data 字段 | 消费方 |
|---|---|---|---|
| `ai_reply_chunk` | AI 每生成一个片段 | `conversation_id`, `chunk` | H5 端(打字机拼装) |
| `ai_reply` | AI 完整生成并落库 | 完整 AI `Message` 对象 | H5 端(去重/兜底) |
| `ai_reply_failed` | AI 推理异常 | `conversation_id`, `reason` | H5 端(提示 + 转人工) |
### 6.4 失败兜底与降级
- **AI 推理失败/超时**:后台任务捕获异常 → 存一条 `system` 消息("AI 暂时无法回复,已为你转接人工")并 WS 推送 `ai_reply_failed`;不影响用户消息本身。
- **WS 断连期间 AI 完成**:AI 消息已落库,`pollMessages`(现有 3 秒轮询)能拉到,前端去重追加即可 → **天然兜底**
#### 6.4.1 ADR-001:单 Worker 作为后台任务可靠性基线(架构决策)
> **ADR 元信息**
> - 编号:ADR-001
> - 状态:Accepted(已采纳,2026-07-07 随方案 A 实施落地,见 §12)
> - 主题:后台 AI 任务的承载方式 / 后端 worker 进程数
> - 关联:方案 A(§6)、实施清单 #0(§11)、决策确认 #2(§11.1)
---
##### 背景 Context
方案 A 把 AI 推理移出 HTTP 请求、改为后台任务,结果需经 `ws_manager` 实时推回员工端。此时**后端 worker 进程模型直接决定"后台任务能否把消息推到员工 WS 连接"**,是方案 A 能否成立的前提。
事实依据(代码已确认):
- 部署配置 `docker-compose.yml:151``uvicorn ... --workers 2`**双进程**)。
- `backend/app/services/ws_manager.py:327``manager = ConnectionManager()` 是**纯进程内内存单例**,连接存在字典里,**无任何 Redis 跨进程同步**。
隐患推导:
- 客户端 WS 连接随机落在 worker A 或 worker B。
- 若 HTTP 请求被 worker B 处理、在其内 `create_task(process_h5_ai_reply)`,推理完成后 worker B 调 `manager.broadcast()`,但目标员工的 WS 连接若恰在 worker A 上 → **广播静默失败,员工收不到 AI 回复(约 50% 概率)**
- 该隐患对现网**所有** WS 推送都成立,只是流量小不明显;方案 A 会把每个 AI 回复都变成"依赖跨进程广播",放大问题。
##### 决策 Decision
**采用 P1:后端 `docker-compose.yml` 的 uvicorn 启动参数由 `--workers 2` 改为 `--workers 1`;本期不引入 Redis / Celery / 任务队列。** 后台 AI 推理以进程内 `asyncio.create_task` 承载,结果经进程内 `ws_manager` 单例推回员工端。
##### 关键判断:单 Worker 是当前最优基线
- **体量匹配**:IT 智能服务台当前并发个位数、长连接数低,单进程单事件循环(asyncio)即可充分承载,无横向扩展诉求。
- **最自然搭配**:进程内 task + 进程内 WS 单例,是"持续把中间片段推给同一 WS 连接"的实时流式对话最自然的实现,无需引入跨进程协调。
- **YAGNI / 避免过度设计**Redis pub/sub 广播层或 Celery/ARQ 属重型基础设施,适合离线批处理,不适合把流式结果绕回 WS 的实时对话场景;当前引入是过度工程。
- **顺带修复现网隐患**:原 `--workers 2` + 内存 `ws_manager` 使所有 WS 广播存在 ~50% 跨进程静默丢失,只是低流量下不明显;改单 worker 后该隐患彻底消除(方案 A 的"免费"收益)。
- **可演进**:若未来并发显著增长,再升 P2(Redis pub/sub 广播层)或 P3 多 worker,本 ADR 届时由 ADR-002 替代。
##### 后果 Consequences
| 变得更容易 / 收益 | 变得更难 / 代价 |
|---|---|
| AI 回复 100% 经同一进程 WS 推到员工端(不再丢) | 单进程崩溃会丢失在途后台任务 |
| 部署配置改动极小(1 行) | 失去多 worker 水平扩展能力(当前不需要) |
| 消除现网所有 WS 跨进程广播隐患 | 未来并发激增时需升级到 P2 / P3 |
> 在途任务丢失由"DB 为真相源 + 3s 轮询兜底"覆盖:WS 推送失败,前端轮询仍能拉到落库后的 AI 消息,可接受。
##### 备选方案(已评估,未采纳)
| 路径 | 做法 | 评价 |
|---|---|---|
| **P2 Redis pub/sub 广播层** | `ws_manager` 引入 redis 订阅,broadcast 走 pub/sub 让所有 worker 收到 | 为未来扩多 worker 准备;已有 Redis、增量成本可接受,但当前 YAGNI |
| **P3 进程内 task + 多 worker** | 直接 `create_task` 不改 worker | 不可靠,50% 丢消息,**不采用** |
### 6.5 并发与顺序
- 同一会话连续发多条消息会并发启动多个后台任务。前端按 `message_id` / `created_at` 排序展示并去重,保证 AI 回复与用户消息对应正确。
- 后台任务内对 `conversation.dify_conversation_id` 的更新需加简单锁或串行化,避免 Dify 多轮上下文错乱。
---
## 7. 备选方案说明
### B. SSE 流式接口
- 发送接口改为 `StreamingResponse`AI 回复逐字经 SSE 推回。
- 优点:前端实现简单(EventSource)。
- 风险:企微内嵌 WebView(尤其旧版 Android)对 SSE/长连接支持不稳定,且中间网关/反向代理可能缓冲或截断。
- 结论:不如直接复用已验证的 WS 通道(方案 A)。
### C. 拆两接口快速止血
- 新增 `POST /h5/conversations/current/messages/quick`(只存消息立即返回,不调 AI)。
- AI 结果由现有 `pollMessages` 拉取(AI 消息落库后轮询可见)。
- 优点:改动极小,半天可上。
- 缺点:AI 回复延迟 = 轮询间隔(3s)+ 推理时间,体验一般,是过渡方案。
- 适用:若 A 排期紧张,可先上 C 止血,再迭代到 A。
---
## 8. 风险与缓解
| 风险 | 影响 | 缓解 |
|---|---|---|
| 跨 worker 广播静默丢失 | AI 回复约 50% 概率推不到员工端 | **硬性前置**:后端改 `--workers 1`P1)或在 ws_manager 引入 Redis pub/subP2)。本方案取 P1 |
| 后台任务进程重启丢失 | 个别在途 AI 回复丢失 | 消息已落库,前端 3s 轮询兜底;DB 为真相源,可接受 |
| Dify 多轮上下文错乱 | AI 答非所问 | 后台任务内串行更新 `dify_conversation_id` |
| WS 推送失败 | 前端看不到 AI 回复 | 现有 3s 轮询兜底;WS 广播已 try/except 不阻塞 |
| 流式拼装 UI bug | 气泡重复/错位 | 用 `ai_reply` 完整事件去重校正 |
---
## 9. 排期估算
| 任务 | 工时 |
|---|---|
| 后端:拆后台任务 + WS 事件 + 兜底 | 1 天 |
| 前端:WS 监听 + 打字机拼装 + 状态修正 | 1 天 |
| 联调 + 端到端测试(含失败场景) | 0.5–1 天 |
| **合计** | **2.53 工作日** |
(若先上 C 止血,可压缩到 0.5 天,后续再迭代 A)
---
## 10. 验证方法(端到端,遵守"修复前必须提供真实证据")
1. **响应耗时对比**curl 测改造前后 `/api/h5/conversations/current/messages` 的 TTFB。
- 改造前:TTFB ≈ AI 推理耗时(3–15s
- 改造后:TTFB < 500ms(立即返回)
2. **浏览器真实操作**:发消息 → 自己消息立即 `sent` → 观察 AI 以打字机形式到达。
3. **失败场景**mock AI 超时 → 验证 `ai_reply_failed` 兜底 + 转人工提示。
4. **WS 断连**:断开 WS → 发消息 → 验证 3s 轮询能拉到 AI 回复。
---
## 11. 决策确认(2026-07-08 第二轮评审)
用户已就原 4 项待确认事项拍板,结论如下:
1. **流式 vs 整段推送 → 采用打字机(流式)**。方案 A 的 `ai_reply_chunk` 流式推送保留,前端打字机拼装。
2. **后台任务可靠性 → 不引入 Redis/Celery,用进程内 task,硬性前置"单 worker"**。正式决策见 **ADR-001(§6.4.1**:当前 `--workers 2` + 内存 broadcast 会导致 ~50% 广播丢失,必须先改 `--workers 1`(或后续上 P2 Redis pub/sub);单 worker 在当前体量下即为最优基线。
3. **是否先 C 止血 → 否,直接上 A**。不做过渡方案,一步到位完成异步化 + 流式 WS 推送。
4. **生产实测 → 跳过**。根因已在代码中确认(h5.py:897 同步 await),无需用真实数据佐证即可动手。详见下方说明。
### 关于"为什么要测 AI 耗时 / 为什么要测试账号"的说明
> 这是上一轮提出的**可选**佐证项,本次评估后认为**不是实施前提**:
- **为何当初提测 AI 耗时**:目的是量化 AI 实际耗时区间(用于设合理超时、判断优化空间),并排除"数据库写入 / WS 是否也有额外耗时"。属锦上添花,非必需。
- **为何需要测试账号**:生产发送接口需鉴权(员工 OAuth token),curl 必须带有效 token 才能发真实消息并计时。
- **为何可跳过**:根因已由代码铁证锁定(`h5.py:897``await ai_handler.handle_message``ai_service.py:114``stream:False`),无需实测也能修。
- **若日后想量化,更轻的替代**(无需测试账号):
-`generate_ai_reply` 内对 AI 调用前后打点日志(如 `logger.info(f"AI cost={elapsed}s")`),从现有日志即可看到真实耗时;
- 或在 staging / 本地用 dev token 直接 curl。
- **结论**:直接进入方案 A 实施,实测步骤不阻塞。
### 实施前置清单(直接上 A
| 顺序 | 动作 | 涉及文件 | 备注 |
|---|---|---|---|
| 0 | **后端改 `--workers 1`**ADR-001 | `docker-compose.yml:151` | 硬性前置,否则 50% 收不到 AI 回复;改动 docker-compose 前须对照 `docs/09-部署运维/00-标准故障排查手册.md` 的 Redis 地雷 |
| 1 | 后端:拆后台任务 `generate_ai_reply` + 流式 WS 事件 | `backend/app/api/h5.py` | 复用 `ai_service.get_reply_stream` |
| 2 | 前端:WS 监听 `ai_reply_chunk/ai_reply/ai_reply_failed` + 打字机拼装 + 状态修正 | `frontend-h5/src/stores/conversation.ts` | 响应返回即置 `sent` |
| 3 | 联调 + 端到端验证(含失败/WS 断连场景) | — | 遵守"修复前必须提供真实证据"硬规则 |
---
## 12. 实施进度记录(2026-07-07
> 用户拍板"直接开工",方案 A 全部改造已落地。下方为各 TASK 实际代码落点 + 验证结论。
### 12.1 已完成的代码改动
| TASK | 文件 | 改动要点 |
|---|---|---|
| #0 单 worker 前置 | `docker-compose.yml:151` | `--workers 2``--workers 1`Redis 段未动,规避"Redis 地雷" |
| #3 发送接口异步返回 | `backend/app/api/h5.py` `h5_send_message` | 移除同步 `await handle_message`;仅广播用户消息给坐席 → `asyncio.create_task(process_h5_ai_reply(...))` → 立即返回 `ai_reply: None` |
| #4 后台任务模块(新建) | `backend/app/tasks/h5_ai_task.py` | `process_h5_ai_reply`:本地快判断(打招呼/呼叫人工)走同步路径;否则 `get_reply_stream` 逐 chunk 推 `ai_reply_chunk`,结束推 `ai_reply` 终态;异常推 `ai_reply_failed`。独立 DB session`_get_session_factory` |
| #5 真流式 SSE | `backend/app/services/ai_service.py` `get_reply_stream` | 由"假流式(一次性整段)"改为真 SSE 解析(`stream:True` + 逐行 `data:` 解析);解析失败时降级回非流式,功能不丢 |
| #6 WS 事件分发 | `frontend-h5/src/composables/useH5WebSocket.ts` | `handleMessage` switch 新增 `ai_reply_chunk` / `ai_reply` / `ai_reply_failed` 三分支;`onclose` 增加 `cancelStreamingBubble()` 清理半成品气泡 |
| #7 前端打字机 store | `frontend-h5/src/stores/conversation.ts` | 新增 `handleAiReplyChunk`(首 chunk 建占位气泡、后续累积 content)、`handleAiReply`(真实消息替换占位 + 去重登记 + 同步计数/可呼叫坐席/状态)、`handleAiReplyFailed``cancelStreamingBubble``sendNewMessage` 移除对已废弃 `resp.ai_reply` 的依赖 |
| #7 接口类型 | `frontend-h5/src/api/conversation.ts` | `SendMessageResponse.ai_reply` 改为 `Message \| null`(后端已恒为 null,AI 回复走 WS) |
### 12.2 WS 事件协议(最终落地形态,与 6.3 略有差异,以此为准)
| 事件 type | 消费方 | data 关键字段 |
|---|---|---|
| `ai_reply_chunk` | H5 员工端 | `conversation_id`, `chunk`(逐字片段) |
| `ai_reply` | H5 员工端 | `message_id`, `sender_type`, `content`, `ai_reply_count`, `can_call_agent`, `conversation_status`(扁平字段,非嵌套 Message) |
| `ai_reply_failed` | H5 员工端 | `conversation_id`, `message` |
> 注意:员工端**只**经 `broadcast_to_employees` 收到上述三类事件(`ws_manager.broadcast` 仅发坐席端),故无重复 `new_message` 风险;`handleAiReply` 仍登记真实 `message_id` 到去重集,兼容 3s 轮询兜底重复拉取。
### 12.3 验证结论(遵守"修复前必须提供真实证据")
| 层 | 验证手段 | 结果 |
|---|---|---|
| 后端 | `py_compile` + import `app.tasks.h5_ai_task / app.api.h5 / app.services.ai_service` | IMPORT_OK |
| 前端(类型) | `vue-tsc --noEmit`,过滤本次改动文件 | **0 错误**`conversation.ts` / `useH5WebSocket.ts` / `api/conversation.ts` 全部通过) |
| 前端(打包) | `vite build` | EXIT=0`dist/index.html` 及全部 chunk 产出成功 |
**遗留已知项(非本次范围,已记录但不在本任务修复):**
- `src/api/automation.ts``src/api/message.ts``src/api/troubleshooting-templates.ts` 共 10 处 `vue-tsc` 类型错误,源于"响应契约方案A"拦截器改造后这些 API 仍把 `AxiosResponse` 强转内层类型,与本次改造无关。不影响 `vite build`(esbuild 不做类型检查),但会导致 `npm run build``vue-tsc && vite build`)整体失败。建议单独排期清理。
- 浏览器端到端实测(发送→打字机→落定 / 失败兜底 / WS 断连轮询兜底)需部署后经 堡垒机 `jms_ops` + 测试账号验证,用户已决策**跳过生产实测**,故未执行运行时验证。代码路径已具备,待部署后由 QA 走真实 WebView 确认。
---
## 附:关键代码位置速查
| 文件 | 位置 | 说明 |
|---|---|---|
| `backend/app/api/h5.py` | `h5_send_message` 821990 | 发送接口(897 行串行 await AI |
| `backend/app/services/ai_service.py` | 114 / 196 | 非流式调用 / 已存在的流式 `get_reply_stream` |
| `backend/app/api/h5.py` | 937972 | `ws_manager.broadcast` 现有推送 |
| `frontend-h5/src/api/conversation.ts` | 281297 | `sendMessage`timeout 30s |
| `frontend-h5/src/stores/conversation.ts` | 463 / 485 | 乐观更新 / await 响应 |