17 KiB
tcm.diagnosis/aiAnalysis 延迟、重复请求与 Worker 生命周期审计
审计日期:2026-08-14
审计性质:只读业务代码审计;除本报告外未修改服务端或桌面端业务代码。
结论摘要
- 当前服务端调用链没有无限循环或无上限重试。
DifyChatService的 cURL 总超时由服务端配置控制,当前运行配置为 90 秒;允许范围是 1~300 秒。正常情况下,一个 PHP 请求最多阻塞到 cURL 超时,再返回安全错误。 - 桌面端 5 秒轮询重启已经足以完整解释“长期 loading / 每几秒刷新”。 每次静默队列刷新都会重新加载当前患者详情;详情成功后又无条件启动一次
aiAnalysis。新请求会同时递增 detail generation 和 AI generation,旧请求即使成功也会因 generation 不匹配被丢弃,且底层 HTTP/Qt worker 不会被取消。 - 如果 AI 响应超过 5 秒,时间线上不存在能成为“当前 generation”的旧结果,界面可永久停留在最新一轮 loading。即使响应低于 5 秒,也会每 5 秒重新进入 loading,形成可见闪烁和重复生成。
- 服务端去重或短时缓存不是修复该 UI 症状的必要条件。 首要修复应是桌面端不再因静默队列轮询重启同一诊单的详情/AI 请求,并让 AI 的有效性判断不被同一患者的无关 detail generation 变化作废。
- 服务端仍建议增加防御性短缓存与 single-flight。 当前每个重复 POST 都会独立占用 PHP worker 并调用上游;旧桌面版本、多窗口或多终端可以造成请求放大和 PHP-FPM/Dify 拥塞。服务端防御用于限流和成本控制,不应替代桌面端根因修复。
一、服务端请求链路
1. 同步控制流
DiagnosisController::aiAnalysis()在server/app/adminapi/controller/tcm/DiagnosisController.php:886-897同步调用DiagnosisAiLogic::analysis(),没有队列、异步任务或先返回 job id 的机制。DiagnosisAiLogic::analysis()在server/app/adminapi/logic/tcm/DiagnosisAiLogic.php:298-365依次完成权限/数据范围检查、病例上下文构造、模型路由、上游调用、JSON 解析并返回最终结果。- 真正的上游调用位于
DiagnosisAiLogic.php:326-336。一次业务逻辑调用只执行一次DifyChatService::chat(),但一次chat()不一定等于一次 HTTP 尝试,详见下节。 - 该接口没有读取或写入分析结果缓存,也没有按诊单、病例指纹或管理员建立锁。相同
id的串行或并行 POST 都会重新生成。
2. Dify blocking 与协议回退
- Dify 请求体明确使用
response_mode = blocking,见server/app/common/service/DifyChatService.php:112-120。因此在完整模型答案返回之前,PHP 请求没有中间进度可交付给客户端。 - 如果配置地址明确以
/chat-messages结尾,只构造一个 Dify 请求;明确以/chat/completions结尾,只构造一个 OpenAI-compatible 请求,见DifyChatService.php:133-138。 - 对普通
/v1或其他非显式端点,代码构造两个候选:先 Dify、后 OpenAI-compatible,见DifyChatService.php:102-142。只有首个请求明确返回 HTTP 404/405 时才尝试第二协议,见DifyChatService.php:81-85。 - 本次审计仅输出配置分类、不输出地址:当前运行配置属于
ambiguous-base。因此通常是一个 Dify blocking HTTP 请求;若首个路径返回 404/405,同一次aiAnalysis会产生第二个实际 HTTP 请求。 - 第二次协议尝试复用全局剩余超时预算,见
DifyChatService.php:62-78。由于耗时按floor()取整,极端情况下总墙钟时间可能比配置值多不到 1 秒及少量本地处理开销,但不存在无限协议探测。
3. 超时和错误返回
- 配置默认超时为 90 秒,见
server/config/prescription_ai.php:17-20;服务层只接受 1~300 秒,见DifyChatService.php:15-17,175-178。 - cURL 连接超时为
min(8, max(1, ceil(timeout/4))),当前最多 8 秒;总请求超时为当前剩余预算,见DifyChatService.php:199-205。 curl_exec()是同步阻塞点,见DifyChatService.php:215-218。未配置流式读取、进度回调或客户端断开后主动取消上游。- cURL 超时映射为
UPSTREAM_TIMEOUT;其他连接错误映射为UPSTREAM_UNAVAILABLE,见DifyChatService.php:237-247。 - HTTP 401/403、429/5xx、其他非 2xx、非法 JSON 和空回答都有有限、明确的错误返回,见
DifyChatService.php:250-267。 - 服务返回包含
error_code和latency_ms,见DifyChatService.php:269-274,305-312;但DiagnosisAiLogic::analysis()对所有ok=false统一折叠成“AI智能分析暂时不可用”,见DiagnosisAiLogic.php:348-350,没有把错误分类用于日志、指标或客户端重试策略。 - 上游成功但业务 JSON 不合约时返回结构错误,见
DiagnosisAiLogic.php:353-356;该失败同样没有结构化日志。
判断: 应用代码层不存在无限等待。实际端到端时长仍受 PHP-FPM request_terminate_timeout、PHP max_execution_time、Nginx/网关 read timeout、负载均衡 timeout 和客户端 timeout 共同限制。仓库中未发现该部署链路的 FPM/Nginx timeout 配置,生产环境必须单独核验。任何外层 timeout 小于 90 秒时,客户端可能先收到断连,而 PHP worker/上游是否立即停止取决于 SAPI 与代理的断连传播;当前代码没有显式取消保障。
二、PHP worker 生命周期与重复请求放大
- 从控制器进入直到 Dify blocking 返回,单个 PHP worker 始终被该 HTTP 请求占用。
- 桌面端为
aiAnalysis单独设置 105 秒 HTTP timeout,见app/src/doctor_workstation/services/repository.py:1346-1358。它比服务端 90 秒多 15 秒,单次调用的 timeout 顺序合理。 - 桌面 API 客户端只对 GET 自动重试;POST 固定只有一次 transport attempt,见
app/src/doctor_workstation/services/api_client.py:253-278。因此重复 POST 不是 httpx 自动重试造成,而是 UI 轮询主动重新提交。 - 服务端没有幂等键、完成结果缓存、进行中标记或 single-flight。N 个同诊单并发请求会占用 N 个 PHP worker,并通常产生 N 次 Dify 请求。
- 在 90 秒上游延迟、5 秒重启周期下,单个持续可见的接诊台理论上可同时留下约
90 / 5 = 18个尚未完成的 AI HTTP 请求;多个终端会线性放大。PHP-FPM worker 数不足时,新请求会在网关/FPM 队列等待,形成“模型本身不慢但接口越来越慢”的二次拥塞。 - 桌面
run_async()使用全局QThreadPool,worker 从function()返回前不能取消,见app/src/doctor_workstation/ui/widgets.py:265-284,308-328。旧请求占满本地线程池后,最新 generation 的请求还可能排在旧任务之后,进一步延长 loading。
三、桌面端 5 秒轮询的确定性根因
以下链路无需任何服务端死锁即可复现问题:
- 接诊页计时器每 5 秒执行
refresh(silent=True),见app/src/doctor_workstation/ui/pages/reception.py:917-919。 refresh()每次重新请求队列第一页,见reception.py:1922-1940。- 队列返回后,即使仍选中同一预约,静默刷新也再次调用
_load_detail(),见reception.py:2080-2084。 _load_detail()无论是否已有请求,都会递增_detail_generation并启动新 worker,见reception.py:2152-2175。- 详情成功后,只要存在诊单 id,就无条件调用
_load_ai_analysis(),见reception.py:2296-2314。 _load_ai_analysis()未检查“同一诊单已经 loading/success”,而是直接递增_ai_analysis_generation、设置 loading 并启动新 POST,见reception.py:1718-1763。- AI 结果必须同时匹配 AI generation 和当时的 detail generation,见
reception.py:1704-1716。后续 5 秒轮询只要递增 detail generation,旧 AI 结果就会被认定为过期。 - 过期 success/error/finished 回调均不会结束当前 loading;success 在
reception.py:1773-1779被丢弃,error/finished 也有相同当前上下文门控,见reception.py:1821-1849。
典型时间线(模型耗时 8 秒):
| 时间 | 行为 | 当前 AI generation | 结果 |
|---|---|---|---|
| 0s | 启动请求 A | 1 | loading |
| 5s | 静默轮询重新加载详情并启动 B | 2 | A 已过期 |
| 8s | A 成功 | 2 | 被丢弃 |
| 10s | 启动 C | 3 | B 已过期 |
| 13s | B 成功 | 3 | 被丢弃 |
| 后续 | 每 5 秒重复 | 持续增加 | 永远只显示最新 loading |
因此:
- 模型耗时 > 5 秒: 可以稳定复现永久 loading。
- 模型耗时 < 5 秒: 可能短暂显示成功,但下一轮仍重新进入 loading,并且每 5 秒产生新模型费用。
- 服务端加短缓存: 第一轮完成后,后续某次请求可能迅速命中缓存,从而“看起来修好”;但无意义的 detail/AI generation 重启仍存在,缓存过期后问题会复现,所以不能把缓存当根因修复。
四、日志与可观测性
当前状态
DiagnosisAiLogic只在捕获未预期Throwable时写 warning,字段为diagnosis_id、profile、admin_id、异常类,见DiagnosisAiLogic.php:337-343。它没有记录提示词、病例文本、上游回答、地址或密钥,密钥安全边界是正确的。- cURL timeout、429/5xx、401/403、非法 JSON等均由
DifyChatService作为普通数组返回,不抛异常;analysis()随后统一失败且不记录。因此当前几乎无法从日志判断是超时、上游繁忙、协议路径、JSON 合约还是配置问题。 latency_ms已在服务层计算,但analysis()没有消费或记录。- 管理端通用
OperationLog当前在server/app/adminapi/event.php:15-23被禁用,所以正常情况下不会把接口响应落入操作日志。 - 潜在隐私风险: 若未来重新启用
OperationLog,其server/app/adminapi/listener/OperationLog.php:48-75会保存完整请求参数和响应;现有裁剪只处理少数大字段,见OperationLog.php:79-107,会把diagnosis_advice、risk_assessment、treatment_advice等患者衍生临床内容写入日志。重新启用前必须对该路由跳过响应记录或专门脱敏。
安全可观测性建议
记录结构化事件或指标,但只允许以下非临床元数据:
- 随机 correlation/request id;
model_key和实际协议(dify/openai-compatible),不要记录模型提示词;outcome、内部error_code、HTTP 状态类别;latency_ms、连接耗时、上游 attempt 数;cache_hit、singleflight_role、锁等待时间;- 当前 PHP/FPM 并发或队列指标应由基础设施采集。
禁止记录:API key、Authorization header、base URL、病例正文、上游请求 inputs/query、上游回答、患者姓名/手机号/身份证、诊断建议/风险/治疗建议。诊单 id 也是可关联标识;若确需跨日志关联,使用仅服务端可验证的 HMAC 标识且不记录原 id或病例指纹。
五、风险分级
| 等级 | 风险 | 影响 |
|---|---|---|
| P0 | 5 秒轮询使 detail/AI generation 持续失效 | 永久 loading、每几秒刷新、旧结果全部丢弃 |
| P1 | 无服务端去重/缓存,重复 POST 全部进入 Dify | PHP-FPM 耗尽、上游拥塞、成本放大、其他接口延迟 |
| P1 | 旧 Qt/httpx worker 无取消且使用全局线程池 | 最新请求本地排队,整页其他异步任务受影响 |
| P1 | 生产代理/FPM timeout 未在仓库定义 | 可能先于 90 秒中断,错误表现依部署而异 |
| P2 | 运行配置为 ambiguous base | 404/405 时一次业务请求产生两次 HTTP 尝试 |
| P2 | 普通上游错误没有安全结构化日志/指标 | 无法判断 timeout、busy、配置或格式故障 |
| P2 | 若恢复通用 OperationLog,会保存 AI 临床响应 | 患者衍生数据进入长期日志存储 |
六、最小安全改进方案(不在本次审计实现)
优先级 1:先修桌面控制流
必须同时满足以下两点,仅增加 _ai_analysis_loading guard 不够:
- 静默队列轮询在预约/诊单未变化时,不重新启动当前患者完整详情;或者至少不递增会影响 AI 有效性的 detail generation。
- AI 请求以稳定的
(appointment_id, diagnosis_id)和自身 AI generation 判断有效性;同一诊单处于 loading 或已有 success 时不自动重启。只有患者切换、诊单内容明确更新、人工重试/刷新时才强制生成。
原因:即使阻止第二个 AI POST,只要静默详情刷新仍递增 detail generation,第一轮 AI success 仍会在 _ai_analysis_context_current() 中被丢弃。
优先级 2:补安全观测,再决定阈值
在不记录患者内容的前提下,增加 outcome/error_code/latency/protocol/attempt/cache 指标。先确认 P50/P95/P99、每诊单请求次数、同键并发数和 FPM 饱和度,再确定缓存 TTL 与限流阈值。
优先级 3:服务端防御性完成缓存 + single-flight
建议而非 UI 修复前置条件:
- 每次请求仍先执行权限和 DataScope 检查,缓存命中不得绕过授权。
- 使用已有
DiagnosisAiLogic::buildCaseContext()生成的 SHA-256 病例指纹(DiagnosisAiLogic.php:683-704),组合 prompt version、model key、租户/诊单内部标识生成不含明文患者数据的内部缓存键。 - 只缓存已经通过严格解析的最终结构,建议 TTL 30~60 秒;诊单内容变化后指纹变化,自然失效。不要缓存 timeout、权限失败、非法 JSON 或部分响应。
- 使用跨 PHP-FPM 进程的原子 shared-cache lock/Redis
SET NX EX或等价机制;不能用 PHP 静态变量,也不要用非原子get后set充当锁。 - 锁必须带随机 ownership token,只有持有者可释放;TTL 应略大于上游最大运行时长并能从异常路径释放,避免永久锁。
- 锁竞争者可短时间等待完成缓存,超过小预算后返回明确、可重试的“分析进行中”错误;不要让所有竞争者都阻塞 90 秒,否则虽然保护了 Dify,仍会占满 PHP worker。
generated_at应随缓存结果一起保存,缓存命中不得伪造为新生成时间。
如果典型模型耗时长期接近一分钟,最终应考虑异步 job + 状态查询,而不是继续扩大同步 HTTP/FPM timeout;这属于协议升级,不是本次“最小改进”。
其他低成本措施
- 若实际服务确定为 Dify,将
BASE_URL配置为显式/chat-messages,避免 404/405 协议探测;不要在日志或前端暴露地址。 - 明确并统一四层 timeout:Dify cURL < PHP-FPM/Nginx/负载均衡 < 桌面 105 秒,并留出 JSON 编码和网络缓冲。
- 若重新启用管理端 OperationLog,为
tcm.diagnosis/aiAnalysis禁止记录 response body,或仅记录固定 outcome 元数据。
七、建议测试
桌面回归
- 模拟 AI 延迟 8 秒、队列轮询 5 秒;保持同一患者 20 秒,断言只提交一次
aiAnalysis且最终进入 success。 - 同一患者静默队列刷新时,断言 detail generation 不会让在途 AI 结果失效。
- 切换患者后旧结果必须丢弃,新患者只提交一次请求。
- 人工“重试”应强制新请求;success 状态不得被普通 5 秒轮询重置为 loading。
- 验证关闭/隐藏页面后不会继续周期性提交 AI。
服务端 timeout 与协议
- 使用本地 HTTP stub 延迟超过配置 timeout,断言在边界附近返回
UPSTREAM_TIMEOUT,无无限等待。 - stub 首次返回 404、第二路径成功,断言最多两次 HTTP 尝试且共用总预算。
- 429、500、401、非法 JSON、空回答分别映射到预期安全 error code,且响应/日志不包含上游 body、地址或密钥。
- 配置显式 Dify 端点时断言只构造一个 blocking 请求。
去重/缓存(若实施)
- 10 个相同诊单/指纹并发请求只产生一次上游调用;完成后全部得到同一严格结构或定义明确的“进行中”结果。
- 同一诊单内容更新后不得命中旧指纹缓存;不同模型或 prompt version 不得串用。
- 缓存命中仍必须执行权限和 DataScope,越权请求不得借缓存读取结果。
- 上游失败、解析失败、PHP 异常后锁能释放,失败结果不缓存。
- 多 PHP-FPM 进程下验证原子锁,而非仅单进程单元测试。
- 捕获全部应用日志并断言不出现测试 API key、手机号、身份证、邮箱、病例文本和三个分析字段内容。
部署集成
- 在真实 FPM/Nginx 拓扑用受控慢 stub 验证 90 秒服务端 timeout 能先于代理/客户端 timeout 返回。
- 客户端在途断开后观测 PHP worker 和上游连接是否持续,量化 orphan request 生命周期。
- 以每 5 秒一个重复请求进行负载测试,记录 FPM active/idle/queue、上游并发和其他普通接口 P95。
八、本次验证
以下现有直接测试已通过,测试未向真实患者数据或真实模型发送请求:
php server/tests/PrescriptionAiConfigTest.php- ENABLE、BASE_URL、TIMEOUT、QWEN_API_KEY、OPENAI_API_KEY 均报告 configured;未输出任何实际值。
php server/tests/PrescriptionAiUpstreamContractTest.php- Dify blocking 请求、OpenAI-compatible 请求构造、显式端点单协议、URL/timeout 安全校验均通过。
现有测试验证了配置和静态请求契约,但尚未覆盖真实慢响应、PHP-FPM 生命周期、同诊单并发、桌面 5 秒轮询与服务端调用次数的组合场景。