# `tcm.diagnosis/aiAnalysis` 延迟、重复请求与 Worker 生命周期审计 审计日期:2026-08-14 审计性质:只读业务代码审计;除本报告外未修改服务端或桌面端业务代码。 ## 结论摘要 1. **当前服务端调用链没有无限循环或无上限重试。** `DifyChatService` 的 cURL 总超时由服务端配置控制,当前运行配置为 90 秒;允许范围是 1~300 秒。正常情况下,一个 PHP 请求最多阻塞到 cURL 超时,再返回安全错误。 2. **桌面端 5 秒轮询重启已经足以完整解释“长期 loading / 每几秒刷新”。** 每次静默队列刷新都会重新加载当前患者详情;详情成功后又无条件启动一次 `aiAnalysis`。新请求会同时递增 detail generation 和 AI generation,旧请求即使成功也会因 generation 不匹配被丢弃,且底层 HTTP/Qt worker 不会被取消。 3. 如果 AI 响应超过 5 秒,时间线上不存在能成为“当前 generation”的旧结果,界面可永久停留在最新一轮 loading。即使响应低于 5 秒,也会每 5 秒重新进入 loading,形成可见闪烁和重复生成。 4. **服务端去重或短时缓存不是修复该 UI 症状的必要条件。** 首要修复应是桌面端不再因静默队列轮询重启同一诊单的详情/AI 请求,并让 AI 的有效性判断不被同一患者的无关 detail generation 变化作废。 5. **服务端仍建议增加防御性短缓存与 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 秒轮询的确定性根因 以下链路无需任何服务端死锁即可复现问题: 1. 接诊页计时器每 5 秒执行 `refresh(silent=True)`,见 `app/src/doctor_workstation/ui/pages/reception.py:917-919`。 2. `refresh()` 每次重新请求队列第一页,见 `reception.py:1922-1940`。 3. 队列返回后,即使仍选中同一预约,静默刷新也再次调用 `_load_detail()`,见 `reception.py:2080-2084`。 4. `_load_detail()` 无论是否已有请求,都会递增 `_detail_generation` 并启动新 worker,见 `reception.py:2152-2175`。 5. 详情成功后,只要存在诊单 id,就无条件调用 `_load_ai_analysis()`,见 `reception.py:2296-2314`。 6. `_load_ai_analysis()` 未检查“同一诊单已经 loading/success”,而是直接递增 `_ai_analysis_generation`、设置 loading 并启动新 POST,见 `reception.py:1718-1763`。 7. AI 结果必须同时匹配 AI generation 和当时的 detail generation,见 `reception.py:1704-1716`。后续 5 秒轮询只要递增 detail generation,旧 AI 结果就会被认定为过期。 8. 过期 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 不够: 1. 静默队列轮询在预约/诊单未变化时,不重新启动当前患者完整详情;或者至少不递增会影响 AI 有效性的 detail generation。 2. 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 元数据。 ## 七、建议测试 ### 桌面回归 1. 模拟 AI 延迟 8 秒、队列轮询 5 秒;保持同一患者 20 秒,断言只提交一次 `aiAnalysis` 且最终进入 success。 2. 同一患者静默队列刷新时,断言 detail generation 不会让在途 AI 结果失效。 3. 切换患者后旧结果必须丢弃,新患者只提交一次请求。 4. 人工“重试”应强制新请求;success 状态不得被普通 5 秒轮询重置为 loading。 5. 验证关闭/隐藏页面后不会继续周期性提交 AI。 ### 服务端 timeout 与协议 1. 使用本地 HTTP stub 延迟超过配置 timeout,断言在边界附近返回 `UPSTREAM_TIMEOUT`,无无限等待。 2. stub 首次返回 404、第二路径成功,断言最多两次 HTTP 尝试且共用总预算。 3. 429、500、401、非法 JSON、空回答分别映射到预期安全 error code,且响应/日志不包含上游 body、地址或密钥。 4. 配置显式 Dify 端点时断言只构造一个 blocking 请求。 ### 去重/缓存(若实施) 1. 10 个相同诊单/指纹并发请求只产生一次上游调用;完成后全部得到同一严格结构或定义明确的“进行中”结果。 2. 同一诊单内容更新后不得命中旧指纹缓存;不同模型或 prompt version 不得串用。 3. 缓存命中仍必须执行权限和 DataScope,越权请求不得借缓存读取结果。 4. 上游失败、解析失败、PHP 异常后锁能释放,失败结果不缓存。 5. 多 PHP-FPM 进程下验证原子锁,而非仅单进程单元测试。 6. 捕获全部应用日志并断言不出现测试 API key、手机号、身份证、邮箱、病例文本和三个分析字段内容。 ### 部署集成 1. 在真实 FPM/Nginx 拓扑用受控慢 stub 验证 90 秒服务端 timeout 能先于代理/客户端 timeout 返回。 2. 客户端在途断开后观测 PHP worker 和上游连接是否持续,量化 orphan request 生命周期。 3. 以每 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 秒轮询与服务端调用次数的组合场景。