# 员工端消息发送延时改造方案 > 状态:实施中(后端 + 前端改造已完成,待联调 / 部署验证) > 提出日期: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`(821–990 行): ```python # 第 897 行:同步等待 AI 推理完成,期间整条 HTTP 被阻塞 ai_result = await ai_handler.handle_message( content=content, dify_conversation_id=conversation.dify_conversation_id, user_id=employee_id, ) # 第 918–989 行: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/sub(P2)。本方案取 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.5–3 工作日** | (若先上 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` 821–990 | 发送接口(897 行串行 await AI) | | `backend/app/services/ai_service.py` | 114 / 196 | 非流式调用 / 已存在的流式 `get_reply_stream` | | `backend/app/api/h5.py` | 937–972 | `ws_manager.broadcast` 现有推送 | | `frontend-h5/src/api/conversation.ts` | 281–297 | `sendMessage`(timeout 30s) | | `frontend-h5/src/stores/conversation.ts` | 463 / 485 | 乐观更新 / await 响应 |