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

21 KiB
Raw Blame History

员工端消息发送延时改造方案

状态:实施中(后端 + 前端改造已完成,待联调 / 部署验证) 提出日期: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.pyh5_send_message821990 行):

# 第 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

"stream": False,  # 非流式 —— 必须等 AI 完整生成才返回

3.2 前端:发送态依赖被阻塞的响应

frontend-h5/src/stores/conversation.ts

// 第 463 行:已做乐观更新(自己消息立即显示,标记 sending)
status: 'sending',
// 第 485 行:但 sending → sent 的切换依赖后端响应返回
const resp: SendMessageResponse = await sendMessage(reqData)

frontend-h5/src/api/conversation.ts:284-285

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.53 天) 需 WS 推送 + 前端消费;后台任务需兜底
B. SSE 流式接口 发送接口改用 SSE,AI 回复逐字推回 同 A,但走独立 SSE 通道 企微 WebView 对 SSE/长连接兼容性需验证;网关可能截断
C. 拆两接口快速止血 新增"仅存消息"接口(不调 AI),AI 结果靠现有 3 秒轮询拉取 发送快,AI 延迟 ≈ 轮询间隔+推理 小(0.51 天) 轮询本身有 3s 延迟,体验一般;非根本解

推荐 A:后端已有 ai_service.get_reply_stream(流式,ai_service.py:196)和 ws_manager.broadcast(已在用),改造可直接复用现有能力,不引入新依赖。


6. 推荐方案 A 详细设计

6.1 后端改造

① 拆出 AI 推理为后台任务

# 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_streamai_service.py:196)—— 已实现的流式 AI 接口
  • ws_manager.broadcasth5.py:937)—— 已用于 new_message / conversation_updated,新增 ai_reply_chunk / ai_reply 类型即可

6.2 前端改造

frontend-h5/src/stores/conversation.ts 中:

  • 发送后立即拿到 user_message(已 sent),无需等待 AI
  • 新增 WS 监听:
// 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:151uvicorn ... --workers 2双进程)。
  • backend/app/services/ws_manager.py:327manager = 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 流式接口

  • 发送接口改为 StreamingResponseAI 回复逐字经 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 1P1)或在 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.51 天
合计 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:897await ai_handler.handle_messageai_service.py:114stream:False),无需实测也能修。
  • 若日后想量化,更轻的替代(无需测试账号):
    • generate_ai_reply 内对 AI 调用前后打点日志(如 logger.info(f"AI cost={elapsed}s")),从现有日志即可看到真实耗时;
    • 或在 staging / 本地用 dev token 直接 curl。
  • 结论:直接进入方案 A 实施,实测步骤不阻塞。

实施前置清单(直接上 A

顺序 动作 涉及文件 备注
0 后端改 --workers 1ADR-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 1Redis 段未动,规避"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(真实消息替换占位 + 去重登记 + 同步计数/可呼叫坐席/状态)、handleAiReplyFailedcancelStreamingBubblesendNewMessage 移除对已废弃 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=0dist/index.html 及全部 chunk 产出成功

遗留已知项(非本次范围,已记录但不在本任务修复):

  • src/api/automation.tssrc/api/message.tssrc/api/troubleshooting-templates.ts 共 10 处 vue-tsc 类型错误,源于"响应契约方案A"拦截器改造后这些 API 仍把 AxiosResponse 强转内层类型,与本次改造无关。不影响 vite build(esbuild 不做类型检查),但会导致 npm run buildvue-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 sendMessagetimeout 30s
frontend-h5/src/stores/conversation.ts 463 / 485 乐观更新 / await 响应