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

194 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# `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_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 回调均不会结束当前 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_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 协议探测;不要在日志或前端暴露地址。
- 明确并统一四层 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 秒轮询与服务端调用次数的组合场景。