Files
zyt/server/research/ai_analysis_latency_audit.md
2026-08-14 14:37:30 +08:00

17 KiB
Raw Permalink Blame History

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;服务层只接受 1300 秒,见 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_codelatency_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() 使用全局 QThreadPoolworker 从 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 回调均不会结束当前 loadingsuccess 在 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_idprofileadmin_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_advicerisk_assessmenttreatment_advice 等患者衍生临床内容写入日志。重新启用前必须对该路由跳过响应记录或专门脱敏。

安全可观测性建议

记录结构化事件或指标,但只允许以下非临床元数据:

  • 随机 correlation/request id
  • model_key 和实际协议(dify/openai-compatible),不要记录模型提示词;
  • outcome、内部 error_code、HTTP 状态类别;
  • latency_ms、连接耗时、上游 attempt 数;
  • cache_hitsingleflight_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 静态变量,也不要用非原子 getset 充当锁。
  • 锁必须带随机 ownership token,只有持有者可释放;TTL 应略大于上游最大运行时长并能从异常路径释放,避免永久锁。
  • 锁竞争者可短时间等待完成缓存,超过小预算后返回明确、可重试的“分析进行中”错误;不要让所有竞争者都阻塞 90 秒,否则虽然保护了 Dify,仍会占满 PHP worker。
  • generated_at 应随缓存结果一起保存,缓存命中不得伪造为新生成时间。

如果典型模型耗时长期接近一分钟,最终应考虑异步 job + 状态查询,而不是继续扩大同步 HTTP/FPM timeout;这属于协议升级,不是本次“最小改进”。

其他低成本措施

  • 若实际服务确定为 Dify,将 BASE_URL 配置为显式 /chat-messages,避免 404/405 协议探测;不要在日志或前端暴露地址。
  • 明确并统一四层 timeoutDify 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 秒轮询与服务端调用次数的组合场景。