baodan/docs/智能问答与PPT海报高并发优化实施计划书_20260805.md
2026-08-05 17:51:16 +08:00

42 KiB
Raw Blame History

智能问答与 PPT/海报高并发优化实施计划书

文档状态:已完成代码审查,可作为实施基线
编制日期2026-08-05
适用范围:api/insurance/、独立 Vue 前端、部署编排与反向代理
核心约束:不直接修改 Dify/BaoDan 基座业务源码优先复用其认证、数据库、Redis、日志和模型负载均衡能力


一、结论

当前“智能问答不到 50 人就卡住”和“PPT/海报多用户共同生成不流畅”不能只通过增加 API Key 解决。

系统容量受以下最小值共同约束:

有效吞吐量 =
min(
    模型供应商并发/RPM/TPM/账号额度,
    Dify 应用活跃请求上限,
    Insurance SSE 网关容量,
    Dify Core API 容量,
    Celery Worker 容量,
    PostgreSQL 连接与锁容量,
    Redis 容量,
    文件存储与渲染 CPU/内存容量
)

本方案将容量拆成两条独立链路:

  1. 智能问答实时链路:短等待、快速拒绝、流式响应、断开即释放,不进入 Celery。
  2. PPT/海报后台链路:持久化排队、分类 Worker、公平准入、幂等重试不占用实时问答连接。

在完成下文所列修订后,本方案没有已知的阻断性设计遗漏;但“可以支撑多少并发”仍必须由线上模型额度、服务器规格和分阶段压测共同确认,不能仅凭代码配置承诺。


二、复核后补充修正的问题

上一版方向正确,但存在以下需要修订的实施风险。

编号 原计划风险 修订结论
R1 把 Dify 应用并发限制当作完全可靠的原子限流 当前 Dify 代码先 HLENHSET,极端并发下存在竞争;增加 Insurance 网关原子租约作为保护层Dify 限流保留为最终兜底
R2 认为 Outbox 可以实现“任务恰好执行一次” Outbox 只能保证至少一次投递;必须通过任务唯一键、状态 CAS 和结果原子发布实现业务幂等
R3 多个 API Key 只控制并发和 RPM 还要控制 TPM、日额度和供应商主账号共享配额多个 Key 可能属于同一额度组
R4 智能问答和 PPT/海报共用一个自研模型池 Dify 内部模型调用不会经过 api/insurance/ppt/llm_client.py;智能问答优先使用 Dify 内置模型负载均衡,不可用时才接入独立兼容模型网关
R5 所有 Celery Worker 一起切换到 prefork Dify 原有队列应保持其已验证的 Worker 方式;只新增独立 Insurance Worker 并使用 prefork
R6 只拆队列就能保证公平 单用户仍可能提交大量任务;增加用户级执行上限,必要时再启用公平 dispatcher
R7 水平扩容后继续使用当前本地路径存储 Docker 命名卷只适合单机;多机 Worker 必须使用 MinIO/S3/共享存储,并保存对象键而非容器绝对路径
R8 任务重试只依赖 Celery 运行中任务必须有心跳、最大尝试次数和分阶段幂等;取消必须是协作式取消
R9 SSE 只要加心跳就安全 还要处理半包解析、慢客户端背压、浏览器断开、上游取消、数据库 Session 释放和幂等请求
R10 增加 Gunicorn Worker 一定能提升容量 Worker 增加会同步放大数据库连接数;必须先做连接预算并设置每类进程的小连接池
R11 健康检查可以随时调用真实模型 管理员测试也会占用生产额度;必须走低优先级管理通道并限制频率
R12 不同模型可以随意互为备用 同一个模型池中的 endpoint 必须具备一致的上下文、JSON、工具调用、图像尺寸等能力跨模型降级需要独立验收

三、代码现状与根因

3.1 智能问答链路

当前链路:

flowchart LR
    U["浏览器 ChatEmbed"] --> I["/insurance/chat/message"]
    I --> S["Insurance Flask SSE"]
    S --> D["同一 baodan-api 的 /v1/chat-messages"]
    D --> R["Dify 应用限流"]
    R --> M["模型供应商 API"]
    S --> DB["数据库 Session"]

已确认的问题:

  1. 浏览器调用 /insurance/chat/messageInsurance 后端又通过 requests.post(stream=True) 调用 /v1/chat-messages,一个生成请求至少占用两条长连接。
  2. 当前 API 镜像只有两个 Gunicorn Worker。
  3. Dify 应用存在 max_active_requests,同时还受 APP_DEFAULT_ACTIVE_REQUESTSAPP_MAX_ACTIVE_REQUESTS 约束,最终取所有正数限制中的最小值。
  4. Dify 活跃请求异常退出后最长可能存活约 10 分钟。
  5. 上游 requests.Response 没有在所有退出路径显式关闭。
  6. Flask 请求上下文伴随整个 SSE 生成器存活,需确认数据库 Session 是否被长时间持有。
  7. 前端有 90 秒总超时和 60 秒无数据超时,浏览器主动断开后上游可能继续生成。
  8. 智能体列表把 Dify api_token 返回给浏览器,存在泄露和绕过容量控制的风险。
  9. 生产前端由 Python ThreadingTCPServer 提供静态文件和代理,不适合承载大量长连接。
  10. nginx.confdeploy/nginx.conf 的聊天路径不一致。

关键文件:

  • frontend/src/components/ChatEmbed.vue
  • api/insurance/chat/agent_routes.py
  • api/insurance/chat/routes.py
  • api/insurance/chat/service.py
  • Dockerfile.dify-custom
  • serve.py
  • nginx.conf
  • deploy/nginx.conf
  • dify-main/api/core/app/features/rate_limiting/rate_limit.py
  • dify-main/api/services/app_generate_service.py

3.2 PPT/海报后台链路

已确认的问题:

  1. 所有 Insurance 后台任务投递到同一个 insurance 队列。
  2. 当前综合 Worker 使用 -P gevent -c 1,同时消费大量 Dify 队列和 Insurance 队列。
  3. 一个大文件解析或渲染任务可能阻塞其他用户的海报文案和 PPT 任务。
  4. GenerationTask 在数据库提交后才投递 Celery投递失败会直接标记失败Broker 短暂故障不能自动恢复。
  5. 现有幂等检查是“先查再插入”,没有数据库唯一约束时存在并发重复创建。
  6. 前端固定轮询次数耗尽后会把仍在后台执行的任务误判为失败。
  7. 海报 AI 文案仍存在同步模型调用路径。

关键文件:

  • api/insurance/generation/task_service.py
  • api/insurance/generation/celery_tasks.py
  • api/insurance/models/generation_task.py
  • api/insurance/poster/service.py
  • docker-compose.dify.yml
  • frontend/src/pages/components/ppt/PptGenerate.vue
  • frontend/src/pages/PosterPage.vue
  • frontend/src/components/poster/PosterStepPreview.vue

3.3 模型配置链路

已确认的问题:

  1. 管理页每种用途只有一个 API Key。
  2. PPT LLM 客户端的限流器只在单进程内有效。
  3. fallback 按 provider 名称去重,多个相同供应商 Key 可能被跳过。
  4. 成功调用后客户端会粘在当前 provider并非真正的负载均衡。
  5. 图片生成器缓存单个客户端。
  6. 设置读取接口存在返回原始密钥的风险。

关键文件:

  • frontend/src/pages/admin/PptSettingsAdmin.vue
  • api/insurance/admin/ppt_admin_service.py
  • api/insurance/ppt/llm_client.py
  • api/insurance/poster/image_generator.py

四、容量目标和边界

4.1 第一阶段参考目标

指标 目标
同时在线用户 500
同时存在的问答 SSE 200
同时由模型生成答案 80100
PPT/海报等待任务 100
同时解析任务 48
同时生成海报文案 816
同时生成图片 28
同时渲染 PPT/海报 24
普通管理 API P95 小于 500ms压测上限下小于 1s
问答首字 P95 小于 5s不含供应商排队
SSE 断开资源释放 小于 10s
后台任务丢失率 0

4.2 需要明确的概念

  • 在线 500 人不等于 500 个模型请求。
  • 打开问答页面但没有发送问题时,不应建立 SSE。
  • “同时生成答案数”才直接消耗 Dify 和模型并发。
  • PPT 解析任务一次可能包含多次模型调用,模型租约应按每次调用领取,不应由整个 Celery 任务长期占用。
  • 聊天流式调用的模型租约需要覆盖整段上游响应,并定期续租。

4.3 不在首阶段做的事情

  • 不把 Flask 全量迁移到 ASGI。
  • 不直接修改 Dify 限流器和模型管理核心源码。
  • 不引入复杂的跨地域调度。
  • 不承诺精确队列位置。
  • 不在没有压测证据时把 Worker 数量一次性开到几十个。

五、目标架构

flowchart TB
    U["浏览器/企微"] --> N["Nginx"]

    N --> G["Insurance Chat Gateway"]
    G --> A["原子准入与用户公平限制"]
    A --> C["Dify Core API 实例"]
    C --> DL["Dify 内置模型负载均衡<br/>或兼容模型网关"]

    N --> API["Insurance 普通 API"]
    API --> DB["PostgreSQL"]
    API --> REDIS["Redis"]

    API --> T["GenerationTask + Outbox"]
    T --> DP["Outbox Dispatcher"]

    DP --> QP["insurance_parse"]
    DP --> QC["insurance_copy"]
    DP --> QI["insurance_image"]
    DP --> QR["insurance_render"]
    DP --> QA["insurance_admin"]

    QP --> WP["解析 Worker"]
    QC --> WC["文案 Worker"]
    QI --> WI["图片 Worker"]
    QR --> WR["渲染 Worker"]

    WP --> POOL["Insurance 模型池"]
    WC --> POOL
    WI --> POOL

    WP --> OBJ["单机共享卷或 MinIO/S3"]
    WI --> OBJ
    WR --> OBJ

六、Phase 0先取得真实容量基线

在改代码前先记录同一时段的数据库、Redis、应用日志和供应商响应。

6.1 Dify 应用限制

查询目标智能问答 App

SELECT id, name, mode, max_active_requests
FROM apps
ORDER BY updated_at DESC;

同时核对环境变量:

APP_DEFAULT_ACTIVE_REQUESTS
APP_MAX_ACTIVE_REQUESTS

有效值规则:

  1. app.max_active_requests 为空或 0 时,会回退到 APP_DEFAULT_ACTIVE_REQUESTS
  2. 应用限制和全局限制都大于 0 时,取较小值。
  3. 两者都为 0 才表示无限制。
  4. 不应为了绕开报错直接设置为无限制,应先确认模型、数据库和 API 容量。

6.2 Redis 活跃请求

目标 Key

dify:rate_limit:{app_id}:max_active_requests
dify:rate_limit:{app_id}:active_requests

检查:

GET  dify:rate_limit:{app_id}:max_active_requests
HLEN dify:rate_limit:{app_id}:active_requests
HGETALL dify:rate_limit:{app_id}:active_requests

判断:

  • 活跃人数低但 HLEN 接近上限:重点排查异常断开和清理。
  • HLEN 正常但大量 429检查模型供应商或其他网关限制。
  • HLEN 长时间不回落:确认客户端断开后是否调用上游 stop。

6.3 PostgreSQL

SELECT state, count(*)
FROM pg_stat_activity
WHERE datname = current_database()
GROUP BY state;
SELECT pid, usename, application_name, state,
       now() - xact_start AS transaction_age,
       now() - query_start AS query_age,
       wait_event_type, wait_event, query
FROM pg_stat_activity
WHERE datname = current_database()
ORDER BY transaction_age DESC NULLS LAST
LIMIT 50;

重点观察:

  • idle in transaction
  • 连接数接近 max_connections
  • SQLAlchemy pool timeout
  • 锁等待
  • SSE 请求存活期间连接不释放

6.4 日志分类

需要统一统计:

  • Dify Too many requests
  • 模型 429/401/403/5xx
  • Gunicorn worker timeout/restart
  • SQLAlchemy pool timeout
  • Redis connection error
  • Nginx 499/502/504
  • 客户端主动停止
  • SSE 异常结束

6.5 基线压测

逐级执行 10、30、50、100、200 个流式请求,不直接从 0 跳到 200。

每一级至少持续 10 分钟,并记录:

  • 成功率
  • 首字延迟
  • 完成延迟
  • 活跃 SSE
  • Dify 活跃请求
  • DB 连接
  • Redis 延迟
  • CPU/内存/文件描述符
  • 429/5xx/超时
  • 断开后资源回落时间

七、智能问答后端修复

7.1 API Token 服务端化

修改:

  • api/insurance/chat/agent_routes.py
  • api/insurance/chat/routes.py
  • api/insurance/chat/service.py

规则:

  1. 智能体列表接口必须认证。
  2. 只返回 id/name/description/avatar/permission
  3. 不返回 api_token、供应商 Key 或内部敏感配置。
  4. 前端只提交 agent_id
  5. 后端根据当前用户、部门和 agent_id 查找 Token。
  6. 审计管理员新增、替换、删除智能体凭证的操作。

请求体:

{
  "agent_id": "agent-id",
  "message": "用户问题",
  "conversation_id": "可选",
  "request_id": "客户端生成的UUID"
}

7.2 请求幂等

给聊天请求增加服务端唯一标识。优先在现有聊天记录模型上增加:

request_id
request_status
upstream_task_id
started_at
finished_at

数据库约束:

UNIQUE(user_id, request_id)

规则:

  • 相同 user_id + request_id 只能创建一次上游生成。
  • 重复提交且仍在运行时返回原状态,不再次调用 Dify。
  • 已完成请求返回已保存结果摘要。
  • 用户明确点击“重新生成”时创建新的 request_id
  • 浏览器网络重连不能自动重复提交模型请求。

7.3 原子准入控制

由于 Dify 当前限流是先计数再写入Insurance 网关增加保护层。

Redis Key

insurance:chat:leases:{app_id}
insurance:chat:user:{app_id}:{user_id}
insurance:chat:request:{user_id}:{request_id}

使用 Lua 原子完成:

  1. 删除过期租约。
  2. 检查应用网关上限。
  3. 检查用户同时生成数,默认 1。
  4. 写入带过期时间的租约。
  5. 返回 lease_id。

约束:

  • CHAT_GATEWAY_MAX_ACTIVE 必须小于或等于 Dify 有效上限。
  • Dify 限流继续保留,作为最终兜底。
  • 租约每 30 秒续期。
  • 正常结束、错误、客户端断开都立即释放。
  • Redis 不可用时默认失败关闭,返回系统繁忙。
  • 不允许回退到单进程内存计数后无限放行。

交互型请求不做长队列:

  • 最多等待 35 秒获取租约。
  • 获取失败返回 429/503 和 Retry-After
  • 不让数百个浏览器连接在网关中无限等待。

7.4 数据库 Session 生命周期

SSE 前:

  1. 开启短事务。
  2. 校验用户和智能体权限。
  3. 读取上游配置。
  4. 创建请求记录。
  5. 提交事务。
  6. 调用 db.session.remove() 或项目兼容的 Session 清理方法。

SSE 期间:

  • 不保留 ORM 实体引用用于延迟查询。
  • 不把 Session 放入生成器闭包。
  • 不在每个 token 到达时写数据库。

SSE 结束:

  1. 新开短事务。
  2. 保存最终答案、Token 使用、耗时和状态。
  3. 提交并关闭。

7.5 上游连接清理

新增 api/insurance/chat/upstream_client.py,统一管理上游流。

伪代码:

response = None
try:
    response = session.post(
        url,
        json=payload,
        headers=headers,
        stream=True,
        timeout=(5, 180),
    )
    response.raise_for_status()
    for line in response.iter_lines(decode_unicode=False):
        yield parse_event(line)
except GeneratorExit:
    cancel_upstream_task()
    raise
finally:
    if response is not None:
        response.close()
    release_chat_lease()

约束:

  • finally 只清理,不再 yield
  • 已取得 Dify task_id 后,客户端停止时调用 Dify stop 接口。
  • stop 接口必须设置 35 秒短超时。
  • stop 失败只记录告警,不能阻止本地连接清理。
  • 使用连接池 Session但每次流式 response 必须显式关闭。
  • 记录结束原因done/client_abort/upstream_error/timeout/busy。

7.6 心跳和慢客户端背压

直接阻塞在 iter_lines() 时无法定时发心跳,因此需要两个协作单元:

  1. gevent greenlet 读取上游事件;
  2. 主 SSE 生成器从有界队列读取;
  3. 15 秒无事件时发送 heartbeat
  4. 队列最多保留 100 个事件或配置的最大字节数;
  5. 队列满时阻塞上游读取,禁止无限内存增长;
  6. 客户端断开时关闭 response 并结束 greenlet。

在 Flask 开发服务器或单元测试中使用可替换的同步适配器,避免把 gevent 细节散落到业务代码。

7.7 结构化 SSE 协议

event: message
data: {"answer":"增量内容"}

event: heartbeat
data: {"timestamp":1785859200}

event: busy
data: {"retry_after":3,"message":"当前生成通道已满"}

event: error
data: {"code":"MODEL_RATE_LIMITED","message":"模型服务繁忙"}

event: done
data: {"conversation_id":"...","message_id":"..."}

错误分类:

错误 对外状态 是否重试/切换
Dify 应用并发满 busy 客户端手动重试
模型 429 busy 模型负载均衡层处理
401/403 credential_invalid 禁用凭证并告警
400 request_invalid 不切换凭证
5xx/网络超时 upstream_unavailable 有预算时切换一次
客户端断开 client_abort 取消并释放

八、智能问答前端修复

8.1 修改范围

  • frontend/src/components/ChatEmbed.vue
  • 智能问答页面组件
  • 新增 frontend/src/composables/useChatStream.ts
  • 必要时新增 frontend/src/utils/sseParser.ts

8.2 删除 API Token

前端智能体类型:

interface ChatAgent {
  id: string
  name: string
  description?: string
  avatar?: string
}

浏览器请求中不得出现:

  • api_token
  • 模型供应商 Key
  • Dify Workspace 密钥

8.3 超时策略

删除 90 秒绝对总超时。

保留:

  • 建连超时10 秒;
  • 180 秒无任何消息或 heartbeat判定连接失活
  • 用户主动停止;
  • 页面卸载时 abort
  • 服务端 done/error 结束。

前端主动 abort 后显示“已停止”,不能显示“系统失败”。

8.4 流解析

fetch + ReadableStream 的 SSE 解析器必须处理:

  • 一个事件被拆成多个 TCP chunk
  • 一个 chunk 包含多个事件;
  • \n\n\r\n\r\n
  • UTF-8 多字节字符跨 chunk
  • event:data: 多行;
  • 最后一个不完整缓冲区;
  • 单事件最大长度,防止异常响应撑爆内存。

使用持久化 TextDecoder

decoder.decode(value, { stream: true })

不要逐 chunk 直接 JSON.parse

8.5 状态机

type ChatStatus =
  | 'idle'
  | 'connecting'
  | 'generating'
  | 'busy'
  | 'stopped'
  | 'failed'
  | 'completed'

429/503 显示可操作文案:

当前生成通道已满,请稍后重试。本次问题没有重复提交。

禁止自动重新生成;只允许用户点击“重试”并创建新 request_id


九、智能问答多凭证的正确接入方式

9.1 先使用 Dify 内置模型负载均衡

当前 Dify 代码已包含模型负载均衡配置和冷却机制,但是否可用受工作区功能开关控制。

实施前验证:

  1. 当前工作区 model_load_balancing_enabled 是否为 true。
  2. 同一模型是否能配置至少两个有效凭证。
  3. 当前版本的轮询、冷却和失败切换是否符合供应商限制。
  4. 多个凭证是否属于同一供应商主账号。

如果可用:

  • 智能问答使用 Dify 内置负载均衡。
  • 不在 Insurance 再实现一套聊天模型凭证选择。
  • Insurance 只负责用户公平、网关准入、SSE 和监控。

9.2 内置能力不可用时

按以下优先级选择:

  1. 合法启用/升级 Dify 模型负载均衡能力。
  2. 使用供应商官方负载均衡或企业级多凭证入口。
  3. 部署独立的 OpenAI 兼容模型网关,由网关管理多个 KeyDify 只配置一个网关地址。

不得:

  • 直接修改 Dify 模型运行核心源码;
  • 让 Insurance SSE 网关解析并重写 Dify 内部模型调用;
  • 把 PPT/海报模型池误认为能自动接管 Dify 聊天模型。

9.3 兼容模型网关的最低要求

只有在确实需要时才引入,必须支持:

  • /v1/chat/completions 流式和非流式;
  • 请求/响应头透传白名单;
  • 并发/RPM/TPM
  • 账号额度组;
  • 失败冷却;
  • 请求超时和取消;
  • 不记录明文 Prompt 或提供可控脱敏;
  • 指标和审计;
  • 同模型能力一致性验证。

十、PPT/海报模型池

10.1 适用范围

自研模型池只直接服务:

  • PPT/PDF 内容提取;
  • 海报文案;
  • 海报图片生成;
  • 其他 api/insurance/ 直接发起的模型调用。

10.2 数据模型

insurance_model_pools

字段 说明
id 主键
name 池名称
purpose ppt_extract/poster_copy/poster_image
strategy least_load_weighted
enabled 是否启用
acquire_timeout_ms 等待租约上限
max_attempts 单业务调用最大 endpoint 尝试数
created_at/updated_at 时间

insurance_model_quota_groups

字段 说明
id 主键
name 供应商账号/额度组名称
provider 供应商
max_concurrency 账号级安全并发
rpm_limit 账号级 RPM
tpm_limit 账号级 TPM
daily_token_limit 可选日额度
enabled 是否启用

insurance_model_endpoints

字段 说明
id 主键
pool_id 所属池
quota_group_id 共享额度组
name 管理员显示名称
provider 供应商
base_url API 地址
model_name 模型名称
encrypted_api_key 加密 Key
key_version 密钥版本
max_concurrency endpoint 安全并发
rpm_limit endpoint RPM
tpm_limit endpoint TPM
weight 权重
priority 优先级
timeout_seconds 超时
capability_json 能力指纹
enabled/status 启用和健康状态
cooldown_until 冷却截止
last_error 脱敏错误摘要
last_success_at 最近成功

可选调用记录

成功调用不应全部同步写 PostgreSQL避免形成高写入瓶颈。

建议:

  • Redis 聚合成功数、失败数、延迟和 token
  • PostgreSQL 记录失败、凭证切换和采样成功;
  • 日志中永远不记录完整 Key
  • Prompt 和回答默认不进入模型调用日志。

10.3 凭证加密

使用独立环境变量:

INSURANCE_CREDENTIAL_MASTER_KEY
INSURANCE_CREDENTIAL_KEY_VERSION

要求:

  • 使用 AES-GCM 或等价带认证加密。
  • 不复用普通 JWT Secret。
  • 密钥不提交 Git。
  • 读取接口只返回掩码和 has_api_key
  • 新旧配置迁移采用双读、单写新表。
  • 验证完成前不删除旧设置,保证回滚。
  • 迁移日志不打印明文。

10.4 Redis 租约

Key 示例:

insurance:model:leases:endpoint:{endpoint_id}
insurance:model:leases:quota:{quota_group_id}
insurance:model:rpm:endpoint:{endpoint_id}
insurance:model:rpm:quota:{quota_group_id}
insurance:model:cooldown:{endpoint_id}

Lua 脚本原子完成:

  1. 清理过期租约。
  2. 检查 endpoint 并发。
  3. 检查 quota group 并发。
  4. 检查 endpoint 和 quota group RPM。
  5. 领取 lease。

TPM 处理:

  • 调用前按输入 token 估算值和 max_tokens 预留。
  • 调用完成后按真实 usage 校正。
  • 供应商不返回 usage 时使用 tokenizer 估算。
  • TPM 首阶段可以作为软保护,稳定后再改为严格硬限制。

10.5 调度算法

候选条件:

  • pool enabled
  • endpoint enabled
  • quota group enabled
  • 未冷却;
  • 模型能力满足当前任务;
  • endpoint 和 quota group 均有余量。

评分:

score =
当前并发率
+ 最近错误惩罚
+ P95 延迟惩罚
- 权重奖励
+ 优先级惩罚

选择负载最低的健康 endpoint而不是机械轮询。

10.6 熔断

错误 处理
401/403 endpoint 禁用,管理员告警
429 按 Retry-After 冷却,缺失时 30120 秒
连续 3 次网络/5xx 熔断 30 秒,之后半开探测
400 不切换,直接返回业务错误
超时 在总时间预算内最多切换一次

每个业务调用:

  • 最多尝试 3 个 endpoint
  • 共享总超时预算;
  • 不能每切换一次就重新获得完整超时;
  • 记录最终 endpoint 和失败路径,不记录 Key。

10.7 能力一致性

同池 endpoint 上线前自动测试:

  • 上下文长度;
  • JSON 输出;
  • 工具调用;
  • 多模态输入;
  • 流式输出;
  • 图片尺寸和格式;
  • 中文输出质量;
  • 结构化响应兼容性。

不同模型名默认不能放在同一个“透明切换池”中。跨模型降级应单独配置并经过业务回归。


十一、PPT/海报任务系统

11.1 队列拆分

保留 Dify 原有 Worker不改变其所有队列的执行池。

新增独立 Insurance Worker

队列 用途 初始并发
insurance_dispatch Outbox 发布 1
insurance_parse PPT/PDF 解析 4
insurance_copy 海报文案 48
insurance_image 图片生成 24
insurance_render PPT/海报渲染 2
insurance_admin 凭证测试、维护 1

推荐参数:

--pool=prefork
--prefetch-multiplier=1
--max-tasks-per-child=100
--max-memory-per-child=<按压测设置>

渲染 Worker 与纯 HTTP 模型 Worker 分开,防止大内存任务拖垮文案生成。

11.2 Outbox 是至少一次投递

新增 insurance_task_outbox

字段 说明
id 主键
task_id GenerationTask
queue_name 目标队列
payload_json 负载
status pending/publishing/published
attempts 发布次数
next_retry_at 下次重试
published_at 发布时间
last_error 最后错误

创建任务时在同一事务中:

  1. 插入 GenerationTask。
  2. 插入 Outbox。
  3. 提交。

Dispatcher

  1. FOR UPDATE SKIP LOCKED 领取 pending。
  2. 发布 Celery显式使用业务 task ID 或可追踪 ID。
  3. 标记 published。
  4. 发布失败指数退避。

崩溃窗口:

  • Broker 已收到,但数据库还未标记 published 时会重复投递。
  • 因此不能宣称 exactly-once。
  • Worker 必须通过数据库状态 CAS 保证同一任务只有一个执行者。

11.3 数据库级幂等

当前“先查后插入”在并发下会重复。

建议:

  • GenerationTask 增加唯一业务执行键。
  • 同一个前端提交返回相同任务,包括失败状态。
  • 用户明确重试时生成新执行键,并通过 replay_of_task_id 关联。
  • 不通过删除旧任务来允许重试。

Worker 开始时:

UPDATE insurance_generation_tasks
SET status = 'running', started_at = now(), attempt = attempt + 1
WHERE id = :task_id
  AND status = 'queued'
  AND cancelled_at IS NULL;

只有影响行数为 1 的 Worker 执行;其他重复消息直接确认并退出。

11.4 心跳、恢复和重试

任务增加:

heartbeat_at
attempt
max_attempts
cancel_requested_at
replay_of_task_id

规则:

  • Worker 每 30 秒或阶段切换时更新心跳。
  • queued 超时不能直接标记失败;先检查 Outbox 和 Broker 发布状态。
  • running 心跳过期后,根据操作幂等性决定重试或失败。
  • 图片付费调用完成但落库前崩溃时,优先通过供应商 request ID 查询或复用结果,不能盲目再次扣费。
  • 超过 max_attempts 后进入 failed/dead-letter。

11.5 协作式取消

revoke 不能保证正在执行的模型请求或渲染立即停止。

任务在以下位置检查 cancel_requested_at

  • 领取任务后;
  • 每个解析文件前后;
  • 每次模型调用前后;
  • 每个渲染阶段前后;
  • 发布最终文件前。

若外部模型调用不能取消:

  • 等当前调用返回;
  • 不再进入后续阶段;
  • 不把结果发布为正式版本;
  • 释放模型租约;
  • 标记 cancelled。

11.6 用户公平

第一阶段采用简单可验证规则:

  • 每用户最多 2 个运行中的重任务;
  • 每用户最多 20 个 queued
  • 海报文案可配置独立较高并发;
  • 超出 queued 上限直接拒绝,不无限堆积。

只有压测或生产数据证明单用户仍能长期占用 Worker 时,再增加公平 dispatcher

  • 按用户轮转;
  • 高优先级与普通优先级按比例领取;
  • 避免 PPT 任务永久饥饿。

不要一开始实现复杂的全局调度器。

11.7 模型租约粒度

  • PPT 任务可能包含多次 LLM 调用,每次调用单独 acquire/release。
  • 解析本地文件、数据库写入和渲染阶段不占模型租约。
  • 图片生成一次调用占一个租约。
  • 管理员测试使用 insurance_admin 队列,不抢占正常用户的全部容量。

十二、文件存储与多机扩容

12.1 单机阶段

当前 Docker 命名卷可以支撑同一宿主机上的多个 Worker但要求

  • API 和 Worker 挂载完全相同的存储卷;
  • 数据库保存受控相对路径或资产 ID
  • 每个任务使用独立临时目录;
  • 最终文件通过临时文件 + 原子 os.replace 发布;
  • 定期清理孤儿临时文件。

12.2 多机阶段

多个宿主机不能依赖 Docker 本地命名卷。

必须迁移至:

  • MinIO
  • S3 兼容对象存储;
  • 或可靠共享文件系统。

数据库保存:

storage_provider
object_key
sha256
byte_size
content_type
version

不保存某个容器内的绝对路径作为跨服务协议。

下载使用:

  • 后端鉴权后流式代理;
  • 或短时签名 URL。

禁止公开永久 URL。


十三、管理后台

13.1 页面改造

修改 frontend/src/pages/admin/PptSettingsAdmin.vue

按用途显示三个池:

  • PPT 内容解析;
  • 海报文案;
  • 海报图片。

Endpoint 表格:

字段 展示
名称 管理名称
Provider/模型 当前配置
状态 healthy/cooldown/disabled
并发 当前/最大
RPM/TPM 当前/限制
P95 延迟 最近窗口
额度组 共享账号
最近错误 脱敏摘要
操作 编辑、测试、停用

13.2 密钥交互

读取:

{
  "has_api_key": true,
  "api_key_masked": "sk-****9x2a"
}

更新:

  • 不提交 api_key:保留原值。
  • 提交新 api_key:替换。
  • clear_api_key=true:明确删除。

同步模型和测试连接必须传 endpoint ID后端自行读取密钥前端不回传旧密钥。

13.3 健康检查保护

  • 管理员测试每 endpoint 每分钟最多一次。
  • 走低优先级 insurance_admin
  • 使用最小 token 和最小图片规格。
  • 测试结果不直接修改生产权重,除非达到熔断规则。
  • 前端明确提示测试会消耗少量供应商额度。

十四、PPT/海报前端任务体验

14.1 修改范围

  • frontend/src/pages/components/ppt/PptGenerate.vue
  • frontend/src/pages/PosterPage.vue
  • frontend/src/components/poster/PosterStepPreview.vue
  • 新增 frontend/src/composables/useGenerationTask.ts

14.2 状态规则

服务端状态:

queued/running/done/failed/cancelled

前端本地网络状态不得覆盖服务端任务状态。

超过页面等待时间时显示:

任务仍在后台处理中,你可以离开页面,稍后在任务中心查看。

不得把 queued/running 改成 failed。

14.3 轮询退避

030秒2秒
30秒2分钟5秒
210分钟10秒
10分钟以后30秒
  • 页面不可见时降频。
  • 页面恢复可见时立即查询。
  • 组件卸载时清理 timer 和 AbortController。
  • 多组件显示同一 task 时共享一个查询实例,避免重复轮询。

14.4 海报文案异步化

POST /insurance/poster/copy-tasks
→ 返回 task_id/status=queued
→ insurance_copy Worker
→ 前端任务状态

同步接口在灰度期保留,由 Feature Flag 切换;验证稳定后再移除旧路径。


十五、部署和基础设施

15.1 API 服务隔离

使用同一镜像运行两个服务实例,不修改 Dify 核心源码:

insurance-api
    主要承载 /insurance/*

baodan-core-api
    承载 /v1/* 和 Dify 核心 API

Insurance 调用:

http://baodan-core-api:5001/v1/chat-messages

避免内外两层 SSE 争用同一组 Worker。

起始建议:

workers=4
worker_class=geventwebsocket
worker_connections=1000
timeout=300
graceful_timeout=30
keepalive=5

最终值以压测和数据库连接预算为准。

15.2 Nginx SSE

location /insurance/chat/message {
    proxy_pass http://insurance-api:5001/insurance/chat/message;
    proxy_http_version 1.1;
    proxy_set_header Connection "";
    proxy_buffering off;
    proxy_cache off;
    gzip off;
    proxy_read_timeout 300s;
    proxy_send_timeout 300s;
    add_header X-Accel-Buffering no;
}

要求:

  • 统一根目录和 deploy/ 中的聊天路径。
  • SSE location 放在通用 /insurance/ location 前并使用更具体匹配。
  • 配置 upstream keepalive。
  • 提高 worker_connections 和文件描述符限制。
  • 记录 Nginx 499、502、504。

15.3 前端生产容器

生产环境不再使用 serve.pyThreadingTCPServer

改为:

  • Nginx 提供静态文件;
  • History 路由 fallback 到 index.html
  • API 和 SSE 统一反向代理;
  • serve.py 仅保留开发或紧急回退用途。

15.4 数据库连接预算

理论最大连接 =
API进程数 × (pool_size + max_overflow)
+ Core API进程数 × (pool_size + max_overflow)
+ 各类Celery进程数 × (pool_size + max_overflow)
+ dispatcher/定时任务
+ 运维预留

目标:

  • 理论最大连接不超过 PostgreSQL max_connections 的约 70%。
  • Celery 每进程使用小连接池,例如 23 + 少量 overflow。
  • SSE 生命周期不占用连接。
  • 扩 Worker 前先重新计算。
  • 需要更大规模时引入 PgBouncer。

15.5 Redis

监控:

  • used_memory
  • evicted_keys
  • blocked_clients
  • connected_clients
  • command latency
  • Celery 队列长度
  • Dify 活跃请求
  • Chat 租约
  • 模型租约

限流和租约 Key 不能因内存淘汰而静默消失。Redis 故障策略必须显式配置并演练。


十六、可观测性和告警

16.1 指标

智能问答:

chat_active_streams
chat_gateway_leases
chat_first_token_seconds
chat_stream_duration_seconds
chat_client_abort_total
chat_busy_total
chat_upstream_error_total{code}
chat_resource_release_seconds

后台任务:

generation_queue_depth{queue}
generation_queue_wait_seconds{queue}
generation_running{type}
generation_duration_seconds{type}
generation_retry_total{type}
generation_stale_total
outbox_pending
outbox_oldest_pending_seconds

模型池:

model_endpoint_active{endpoint}
model_endpoint_limit{endpoint}
model_request_total{endpoint,status}
model_request_duration_seconds{endpoint}
model_cooldown{endpoint}
model_rpm_usage{quota_group}
model_tpm_usage{quota_group}

基础设施:

db_pool_checked_out
db_pool_timeout_total
redis_command_latency
nginx_active_connections
nginx_status_total{status}
worker_process_memory
storage_operation_seconds

16.2 初始告警

  • 问答 busy 比例连续 5 分钟超过 5%。
  • 模型 429 连续 5 分钟超过 2%。
  • DB 连接使用超过预算 80%。
  • Outbox 最老 pending 超过 60 秒。
  • 任一队列等待 P95 超过该业务 SLO。
  • SSE 断开释放时间超过 10 秒。
  • Redis 出现 evicted_keys。
  • Worker 单进程内存超过设定上限。

十七、文件级修复计划

模块 文件 改动
智能体列表 api/insurance/chat/agent_routes.py 认证、权限、移除 Token
SSE 路由 api/insurance/chat/routes.py agent_id/request_id、结构化 SSE、断开处理
上游服务 api/insurance/chat/service.py 短事务、关闭 response、取消任务
Chat 准入 新增 api/insurance/chat/capacity.py Redis Lua 租约和用户公平
Chat 上游 新增 api/insurance/chat/upstream_client.py 连接池、有界队列、心跳
SSE 协议 新增 api/insurance/chat/stream_events.py 事件格式和错误映射
Chat 模型 api/insurance/models/chat_record.py 或新请求模型 request_id 幂等和状态
前端聊天 frontend/src/components/ChatEmbed.vue 去 Token、去总超时、正确取消
前端流 新增 frontend/src/composables/useChatStream.ts SSE 状态机和生命周期
SSE 解析 新增 frontend/src/utils/sseParser.ts 半包、多事件、UTF-8
模型池模型 新增 api/insurance/models/model_pool*.py pool/quota/endpoint
模型池服务 新增 api/insurance/model_pool/ 租约、选择、熔断
PPT LLM api/insurance/ppt/llm_client.py 每调用领取 endpoint
图片模型 api/insurance/poster/image_generator.py 删除单客户端假设
管理接口 api/insurance/admin/ppt_admin_service.py 密钥写入化和兼容迁移
管理路由 新增 api/insurance/admin/model_pool_routes.py CRUD、测试、状态
管理前端 frontend/src/pages/admin/PptSettingsAdmin.vue 多 endpoint 管理
任务模型 api/insurance/models/generation_task.py 执行键、心跳、尝试、取消
Outbox 模型 新增 api/insurance/models/task_outbox.py 至少一次投递
任务服务 api/insurance/generation/task_service.py 同事务创建任务和 Outbox
Dispatcher 新增 api/insurance/generation/dispatcher.py SKIP LOCKED 发布
Celery 任务 api/insurance/generation/celery_tasks.py CAS、心跳、协作取消
海报服务 api/insurance/poster/service.py 文案异步化
PPT 前端 frontend/src/pages/components/ppt/PptGenerate.vue 不误判失败
海报前端 PosterPage.vuePosterStepPreview.vue 异步任务和退避
任务 composable 新增 frontend/src/composables/useGenerationTask.ts 共享轮询
数据迁移 新增后续 api/insurance/db/migrate_*.py 新表、索引、兼容迁移
Compose docker-compose.dify.yml API 实例和 Worker 分类
代理 nginx.confdeploy/nginx.conf SSE 路径统一
前端镜像 Dockerfile.frontend Nginx 静态服务

十八、测试计划

18.1 单元测试

后端:

  • Chat 租约原子领取、续租、释放。
  • 相同 request_id 不重复调用上游。
  • GeneratorExit 后 response 被关闭。
  • stop 失败仍释放本地租约。
  • Redis 故障时失败关闭。
  • 模型 endpoint 并发和 quota group 并发。
  • 401/429/5xx/400 错误分类。
  • Outbox 重复投递时只有一个 Worker 执行。
  • 任务取消后不发布最终产物。
  • 密钥读取接口不返回明文。

前端:

  • SSE 半包、多包、UTF-8 跨包。
  • 429 显示 busy。
  • 90 秒后不会自动失败。
  • 页面卸载会 abort。
  • abort 不会自动重发。
  • queued/running 不会被本地超时改成 failed。
  • 多组件只轮询一次同一任务。

18.2 集成测试

  • Insurance API → Dify 流式调用。
  • 浏览器中途断开 → 上游取消 → Dify 活跃请求回落。
  • Broker 停止后创建任务 → Broker 恢复 → Outbox 重新发布。
  • Worker 在发布结果前崩溃 → 重试不生成重复正式文件。
  • 两个 Worker 同时收到同一任务 → 只有一个进入 running。
  • 一个 endpoint 429 → 切换其他 endpoint。
  • 同一 quota group 多 Key 不超过账号总上限。
  • 多机模式下任意 Worker 可读取上传文件和发布结果。

18.3 负载测试阶段

Chat

  1. 10 并发验证正确性。
  2. 30 并发观察资源线性增长。
  3. 50 并发复现旧问题并确认修复。
  4. 100 并发验证目标。
  5. 200 SSE 连接验证网关上限和优雅拒绝。

后台任务

  • 20 个用户各提交 PPT、海报文案和图片任务。
  • 单用户提交 20 个任务验证公平上限。
  • 大 PPT 与小文案混合验证队列隔离。
  • Worker 重启、Redis 重启、Broker 短暂中断。

18.4 验收条件

智能问答:

  • 200 个 SSE 下普通 API P95 小于 1 秒。
  • 100 个同时生成请求下系统不整体假死。
  • 超过容量返回 busy不出现无限等待。
  • 断开后 10 秒内释放本地和上游资源。
  • 浏览器网络中不存在原始 API Token。
  • Dify 活跃请求最终回落。

后台任务:

  • 大 PPT 不阻塞海报文案队列。
  • Broker 短暂故障不丢任务。
  • 重复消息不产生重复正式结果。
  • 前端刷新后恢复任务状态。
  • 本地轮询超时不改变服务端状态。

模型池:

  • 跨进程不超 endpoint 和 quota group 限额。
  • 429 自动冷却和切换。
  • 401 自动禁用并告警。
  • 过期租约可自动回收。
  • 原始密钥不出现在 API、日志和前端。

十九、灰度、回滚和实施顺序

19.1 Feature Flag

建议新增:

CHAT_GATEWAY_ADMISSION_V1
CHAT_SERVER_SIDE_TOKEN_V1
CHAT_STREAM_CLEANUP_V1
GENERATION_OUTBOX_V1
GENERATION_SPLIT_QUEUES_V1
POSTER_COPY_ASYNC_V1
MODEL_POOL_V1
OBJECT_STORAGE_V1

19.2 Phase A智能问答止血

  1. 获取线上容量基线。
  2. 停止前端暴露 Token。
  3. 修复 response、GeneratorExit 和 Session 生命周期。
  4. 删除前端绝对总超时。
  5. 增加 request_id 幂等。
  6. 增加 Chat 原子准入。
  7. 统一 Nginx SSE。
  8. 分离 Insurance API 与 Core API 实例。
  9. 压测 10/30/50/100/200。

回滚:

  • 保留旧路由行为的短期开关。
  • 数据库新增字段保持向后兼容。
  • 新网关准入关闭后仍由 Dify 限流兜底。

19.3 Phase B后台任务隔离

  1. 新增分类队列和独立 Worker。
  2. 保持旧 insurance 队列消费一段灰度期。
  3. 新任务按 Feature Flag 投递新队列。
  4. 海报文案异步化。
  5. 前端改为服务端状态权威。

回滚:

  • Dispatcher 可切回旧队列。
  • Worker 代码兼容旧任务格式。

19.4 Phase COutbox 和幂等

  1. 增加表和唯一约束。
  2. 双写 Outbox。
  3. 仅观测不由 Outbox 发布。
  4. 开启 Dispatcher 灰度。
  5. 注入 Broker 故障验证。
  6. 全量切换。

19.5 Phase DPPT/海报模型池

  1. 新建模型池表。
  2. 双读旧设置和新池。
  3. 密钥加密迁移。
  4. PPT 解析灰度 10%。
  5. 海报文案灰度。
  6. 图片生成灰度。
  7. 验证后停止旧设置写入。

19.6 Phase E聊天多凭证

  1. 验证 Dify 内置负载均衡功能开关。
  2. 可用时直接配置和压测。
  3. 不可用时评审是否引入兼容模型网关。
  4. 未完成评审前不修改 Dify 核心源码。

19.7 Phase F多机存储

只有需要跨宿主机扩容时实施:

  1. 接入 MinIO/S3。
  2. 双写本地和对象存储。
  3. 校验 sha256。
  4. 下载切到对象键。
  5. 停止依赖本地绝对路径。

二十、最终实施原则

  1. 先修资源泄漏和错误超时,再增加容量。
  2. 先测清哪个上限是 50再修改 Dify 并发值。
  3. 实时问答不进入后台长队列。
  4. PPT/海报不占用实时 SSE Worker。
  5. Chat 多凭证优先复用 Dify不重复造模型调度。
  6. PPT/海报模型池必须跨进程限流并识别账号共享额度。
  7. Outbox 保证至少一次,幂等保证不会重复产生业务结果。
  8. Worker 扩容必须同步计算数据库和存储容量。
  9. 单机和多机部署使用不同存储前提,不能混为一谈。
  10. 所有上线结论以压测数据和可回滚灰度为准。