42 KiB
智能问答与 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/内存容量
)
本方案将容量拆成两条独立链路:
- 智能问答实时链路:短等待、快速拒绝、流式响应、断开即释放,不进入 Celery。
- PPT/海报后台链路:持久化排队、分类 Worker、公平准入、幂等重试,不占用实时问答连接。
在完成下文所列修订后,本方案没有已知的阻断性设计遗漏;但“可以支撑多少并发”仍必须由线上模型额度、服务器规格和分阶段压测共同确认,不能仅凭代码配置承诺。
二、复核后补充修正的问题
上一版方向正确,但存在以下需要修订的实施风险。
| 编号 | 原计划风险 | 修订结论 |
|---|---|---|
| R1 | 把 Dify 应用并发限制当作完全可靠的原子限流 | 当前 Dify 代码先 HLEN 再 HSET,极端并发下存在竞争;增加 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"]
已确认的问题:
- 浏览器调用
/insurance/chat/message,Insurance 后端又通过requests.post(stream=True)调用/v1/chat-messages,一个生成请求至少占用两条长连接。 - 当前 API 镜像只有两个 Gunicorn Worker。
- Dify 应用存在
max_active_requests,同时还受APP_DEFAULT_ACTIVE_REQUESTS和APP_MAX_ACTIVE_REQUESTS约束,最终取所有正数限制中的最小值。 - Dify 活跃请求异常退出后最长可能存活约 10 分钟。
- 上游
requests.Response没有在所有退出路径显式关闭。 - Flask 请求上下文伴随整个 SSE 生成器存活,需确认数据库 Session 是否被长时间持有。
- 前端有 90 秒总超时和 60 秒无数据超时,浏览器主动断开后上游可能继续生成。
- 智能体列表把 Dify
api_token返回给浏览器,存在泄露和绕过容量控制的风险。 - 生产前端由 Python
ThreadingTCPServer提供静态文件和代理,不适合承载大量长连接。 nginx.conf与deploy/nginx.conf的聊天路径不一致。
关键文件:
frontend/src/components/ChatEmbed.vueapi/insurance/chat/agent_routes.pyapi/insurance/chat/routes.pyapi/insurance/chat/service.pyDockerfile.dify-customserve.pynginx.confdeploy/nginx.confdify-main/api/core/app/features/rate_limiting/rate_limit.pydify-main/api/services/app_generate_service.py
3.2 PPT/海报后台链路
已确认的问题:
- 所有 Insurance 后台任务投递到同一个
insurance队列。 - 当前综合 Worker 使用
-P gevent -c 1,同时消费大量 Dify 队列和 Insurance 队列。 - 一个大文件解析或渲染任务可能阻塞其他用户的海报文案和 PPT 任务。
GenerationTask在数据库提交后才投递 Celery,投递失败会直接标记失败;Broker 短暂故障不能自动恢复。- 现有幂等检查是“先查再插入”,没有数据库唯一约束时存在并发重复创建。
- 前端固定轮询次数耗尽后会把仍在后台执行的任务误判为失败。
- 海报 AI 文案仍存在同步模型调用路径。
关键文件:
api/insurance/generation/task_service.pyapi/insurance/generation/celery_tasks.pyapi/insurance/models/generation_task.pyapi/insurance/poster/service.pydocker-compose.dify.ymlfrontend/src/pages/components/ppt/PptGenerate.vuefrontend/src/pages/PosterPage.vuefrontend/src/components/poster/PosterStepPreview.vue
3.3 模型配置链路
已确认的问题:
- 管理页每种用途只有一个 API Key。
- PPT LLM 客户端的限流器只在单进程内有效。
- fallback 按 provider 名称去重,多个相同供应商 Key 可能被跳过。
- 成功调用后客户端会粘在当前 provider,并非真正的负载均衡。
- 图片生成器缓存单个客户端。
- 设置读取接口存在返回原始密钥的风险。
关键文件:
frontend/src/pages/admin/PptSettingsAdmin.vueapi/insurance/admin/ppt_admin_service.pyapi/insurance/ppt/llm_client.pyapi/insurance/poster/image_generator.py
四、容量目标和边界
4.1 第一阶段参考目标
| 指标 | 目标 |
|---|---|
| 同时在线用户 | 500 |
| 同时存在的问答 SSE | 200 |
| 同时由模型生成答案 | 80~100 |
| PPT/海报等待任务 | 100 |
| 同时解析任务 | 4~8 |
| 同时生成海报文案 | 8~16 |
| 同时生成图片 | 2~8 |
| 同时渲染 PPT/海报 | 2~4 |
| 普通管理 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
有效值规则:
app.max_active_requests为空或 0 时,会回退到APP_DEFAULT_ACTIVE_REQUESTS。- 应用限制和全局限制都大于 0 时,取较小值。
- 两者都为 0 才表示无限制。
- 不应为了绕开报错直接设置为无限制,应先确认模型、数据库和 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.pyapi/insurance/chat/routes.pyapi/insurance/chat/service.py
规则:
- 智能体列表接口必须认证。
- 只返回
id/name/description/avatar/permission。 - 不返回
api_token、供应商 Key 或内部敏感配置。 - 前端只提交
agent_id。 - 后端根据当前用户、部门和
agent_id查找 Token。 - 审计管理员新增、替换、删除智能体凭证的操作。
请求体:
{
"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。
- 写入带过期时间的租约。
- 返回 lease_id。
约束:
CHAT_GATEWAY_MAX_ACTIVE必须小于或等于 Dify 有效上限。- Dify 限流继续保留,作为最终兜底。
- 租约每 30 秒续期。
- 正常结束、错误、客户端断开都立即释放。
- Redis 不可用时默认失败关闭,返回系统繁忙。
- 不允许回退到单进程内存计数后无限放行。
交互型请求不做长队列:
- 最多等待 3~5 秒获取租约。
- 获取失败返回 429/503 和
Retry-After。 - 不让数百个浏览器连接在网关中无限等待。
7.4 数据库 Session 生命周期
SSE 前:
- 开启短事务。
- 校验用户和智能体权限。
- 读取上游配置。
- 创建请求记录。
- 提交事务。
- 调用
db.session.remove()或项目兼容的 Session 清理方法。
SSE 期间:
- 不保留 ORM 实体引用用于延迟查询。
- 不把 Session 放入生成器闭包。
- 不在每个 token 到达时写数据库。
SSE 结束:
- 新开短事务。
- 保存最终答案、Token 使用、耗时和状态。
- 提交并关闭。
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 接口必须设置 3~5 秒短超时。
- stop 失败只记录告警,不能阻止本地连接清理。
- 使用连接池 Session,但每次流式 response 必须显式关闭。
- 记录结束原因:done/client_abort/upstream_error/timeout/busy。
7.6 心跳和慢客户端背压
直接阻塞在 iter_lines() 时无法定时发心跳,因此需要两个协作单元:
- gevent greenlet 读取上游事件;
- 主 SSE 生成器从有界队列读取;
- 15 秒无事件时发送 heartbeat;
- 队列最多保留 100 个事件或配置的最大字节数;
- 队列满时阻塞上游读取,禁止无限内存增长;
- 客户端断开时关闭 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 代码已包含模型负载均衡配置和冷却机制,但是否可用受工作区功能开关控制。
实施前验证:
- 当前工作区
model_load_balancing_enabled是否为 true。 - 同一模型是否能配置至少两个有效凭证。
- 当前版本的轮询、冷却和失败切换是否符合供应商限制。
- 多个凭证是否属于同一供应商主账号。
如果可用:
- 智能问答使用 Dify 内置负载均衡。
- 不在 Insurance 再实现一套聊天模型凭证选择。
- Insurance 只负责用户公平、网关准入、SSE 和监控。
9.2 内置能力不可用时
按以下优先级选择:
- 合法启用/升级 Dify 模型负载均衡能力。
- 使用供应商官方负载均衡或企业级多凭证入口。
- 部署独立的 OpenAI 兼容模型网关,由网关管理多个 Key,Dify 只配置一个网关地址。
不得:
- 直接修改 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 脚本原子完成:
- 清理过期租约。
- 检查 endpoint 并发。
- 检查 quota group 并发。
- 检查 endpoint 和 quota group RPM。
- 领取 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 冷却,缺失时 30~120 秒 |
| 连续 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 | 海报文案 | 4~8 |
| insurance_image | 图片生成 | 2~4 |
| 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 | 最后错误 |
创建任务时在同一事务中:
- 插入 GenerationTask。
- 插入 Outbox。
- 提交。
Dispatcher:
FOR UPDATE SKIP LOCKED领取 pending。- 发布 Celery,显式使用业务 task ID 或可追踪 ID。
- 标记 published。
- 发布失败指数退避。
崩溃窗口:
- 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.vuefrontend/src/pages/PosterPage.vuefrontend/src/components/poster/PosterStepPreview.vue- 新增
frontend/src/composables/useGenerationTask.ts
14.2 状态规则
服务端状态:
queued/running/done/failed/cancelled
前端本地网络状态不得覆盖服务端任务状态。
超过页面等待时间时显示:
任务仍在后台处理中,你可以离开页面,稍后在任务中心查看。
不得把 queued/running 改成 failed。
14.3 轮询退避
0~30秒:2秒
30秒~2分钟:5秒
2~10分钟: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.py 的 ThreadingTCPServer。
改为:
- 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 每进程使用小连接池,例如 2~3 + 少量 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.vue、PosterStepPreview.vue |
异步任务和退避 |
| 任务 composable | 新增 frontend/src/composables/useGenerationTask.ts |
共享轮询 |
| 数据迁移 | 新增后续 api/insurance/db/migrate_*.py |
新表、索引、兼容迁移 |
| Compose | docker-compose.dify.yml |
API 实例和 Worker 分类 |
| 代理 | nginx.conf、deploy/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
- 10 并发验证正确性。
- 30 并发观察资源线性增长。
- 50 并发复现旧问题并确认修复。
- 100 并发验证目标。
- 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:智能问答止血
- 获取线上容量基线。
- 停止前端暴露 Token。
- 修复 response、GeneratorExit 和 Session 生命周期。
- 删除前端绝对总超时。
- 增加 request_id 幂等。
- 增加 Chat 原子准入。
- 统一 Nginx SSE。
- 分离 Insurance API 与 Core API 实例。
- 压测 10/30/50/100/200。
回滚:
- 保留旧路由行为的短期开关。
- 数据库新增字段保持向后兼容。
- 新网关准入关闭后仍由 Dify 限流兜底。
19.3 Phase B:后台任务隔离
- 新增分类队列和独立 Worker。
- 保持旧
insurance队列消费一段灰度期。 - 新任务按 Feature Flag 投递新队列。
- 海报文案异步化。
- 前端改为服务端状态权威。
回滚:
- Dispatcher 可切回旧队列。
- Worker 代码兼容旧任务格式。
19.4 Phase C:Outbox 和幂等
- 增加表和唯一约束。
- 双写 Outbox。
- 仅观测不由 Outbox 发布。
- 开启 Dispatcher 灰度。
- 注入 Broker 故障验证。
- 全量切换。
19.5 Phase D:PPT/海报模型池
- 新建模型池表。
- 双读旧设置和新池。
- 密钥加密迁移。
- PPT 解析灰度 10%。
- 海报文案灰度。
- 图片生成灰度。
- 验证后停止旧设置写入。
19.6 Phase E:聊天多凭证
- 验证 Dify 内置负载均衡功能开关。
- 可用时直接配置和压测。
- 不可用时评审是否引入兼容模型网关。
- 未完成评审前不修改 Dify 核心源码。
19.7 Phase F:多机存储
只有需要跨宿主机扩容时实施:
- 接入 MinIO/S3。
- 双写本地和对象存储。
- 校验 sha256。
- 下载切到对象键。
- 停止依赖本地绝对路径。
二十、最终实施原则
- 先修资源泄漏和错误超时,再增加容量。
- 先测清哪个上限是 50,再修改 Dify 并发值。
- 实时问答不进入后台长队列。
- PPT/海报不占用实时 SSE Worker。
- Chat 多凭证优先复用 Dify,不重复造模型调度。
- PPT/海报模型池必须跨进程限流并识别账号共享额度。
- Outbox 保证至少一次,幂等保证不会重复产生业务结果。
- Worker 扩容必须同步计算数据库和存储容量。
- 单机和多机部署使用不同存储前提,不能混为一谈。
- 所有上线结论以压测数据和可回滚灰度为准。