12 KiB
千问经 Dify 无正文:只读审查
日期:2026-10-26。用户已明确“保留千问,继续排查”。本审查不建议切换模型。
结论
当前优先排查 Dify 被 API key 选中的已发布应用、LLM provider 的实际请求参数/路径,以及 provider 是否缓冲正文。不能从“模型服务在生成 token”或“没有 first_answer_ms”单独确认 thinking 已开启,也不能确认本地输出过滤吞掉了答案。
已保存的 v39 UI 参数只能确认当时温度开关开启且为 0.2;最大标记开关关闭,显示的 512 不代表实际生效上限;推理格式开关也关闭,不能将此解释成模型 thinking 被关闭。快照中没有明确的 enable_thinking / chat_template_kwargs 设置。
本地过滤确实会隐去未闭合 <think>...,但普通正文会立即穿过过滤器,没有“等整篇报告完成才放出正文”的行为。是否由过滤造成无正文,可以通过已有 message 事件计数与安全的数值过滤计数区分,不需要记录推理原文。
范围与已有对照证据
仅只读检查以下代码与公开保存的 UI 快照,并运行纯离线合成测试:
server/app/common/service/DifyChatService.phpserver/app/common/service/DifyReasoningTextFilter.phpserver/app/common/service/DifyStreamTiming.phpserver/tests/DifyReasoningFilterTest.phpartifacts/dify_qwen_visible_applied_v39.jsonartifacts/dify_qwen_visible_applied_v39_final.jsonartifacts/dify_qwen_parameters_restored_v39.txt
本审查没有读取凭据、.env、真实病历或运行日志,没有访问网络,没有修改应用/配置/测试;本报告为此次唯一新建文件。
主代理提供的脱敏运行证据(本审查未重新采集):
- 当前 5 张实际附件均能通过当前下载器的 TLS 读取。
- 15:22 请求的 Dify LLM node 约 951ms 启动,约 83 秒无正文,应用约 93 秒返回
UPSTREAM_TIMEOUT。 - 无患者数据的 15 秒合成 Dify 短请求也没有正文,约 16.8 秒超时并停止 workflow。
- 千问服务 metrics 当时为 8 running / 0 waiting,10 秒生成 token 总数由 9804787 增至 9809163。这个总量覆盖服务上的并发请求,不能归因于某一条 Dify 请求,也不能区分正文与 reasoning。
- 同期直接请求内部
qwen3.6-35b,使用纯合成 Connectivity check、max_tokens=24、chat_template_kwargs.enable_thinking=false:约 161ms 首 content、222ms 完整结束、13 字节正文、0 reasoning。
第 5 条证明这个直接请求路径上的千问本体能迅速生成正文,排除了“千问服务整体完全不工作”的解释。由于直接请求明确禁用了 thinking,而 Dify 的有效参数尚未证实,两条请求仍不能视为参数完全相同的实验。
1. 应用请求不会替 Dify 选择实际 LLM 参数
DifyChatService::buildRequestSpecs()(1313–1381 行)构造 Dify payload 时只发送 inputs/query/response_mode/user/auto_generate_name/files。没有发送 model、temperature、max_tokens、enable_thinking 或 chat_template_kwargs。
model 参数只在同一函数的另一种协议 payload 中使用(1354–1363 行)。这里描述的是协议边界,不建议改走另一条模型路线。
因此:服务端配置将 profile 命名为 qwen 或把模型名写成 qwen3.6-35b,不会覆盖被 Dify 应用 API key 选中的已发布 workflow 节点。实际模型、provider、endpoint、推理配置、默认输出上限与 provider 是否流式,由该已发布应用和 provider 设置决定。
这是当前最值得验证的边界:不能只核对编辑器中的草稿 UI 或本地配置模型名,需要核对当前请求使用的已发布应用有效节点。
2. v39 快照能证明什么
dify_qwen_parameters_restored_v39.txt:259–309 保存的是 UI 状态,显示模型 qwen3.6-35b CHAT。参数相邻开关及字段为:
| UI 项目 | 保存状态 | 可以得出的结论 |
|---|---|---|
| 温度 | switch=1;数值约 0.2 | 当时该参数开启 |
| Top P | switch=0;显示 1 | 不能断言传给 provider 的实际值就是 1 |
| 频率惩罚 | switch=0;显示 0 | 不能将显示值当生效值 |
| 存在惩罚 | switch=0;显示 0 | 同上 |
| 最大标记 | switch=0;slider 显示 501,stepper 显示 512 | 512 并非已启用上限,实际默认值未保存 |
| 回复格式 | switch=0;未选择 | 没有证据表明该 UI 开启了格式约束 |
| 推理格式 | switch=0;未选择 | 没有明确的模型 thinking 开关证据 |
| JSON Schema | switch=0 | 没有证据表明该 UI 强制 schema |
| 停止序列 | 输入框未显示内容 | 快照没有证实自定义 stop 序列 |
visible_applied_v39*.json 只有 switches/system/userHtml 三个顶层字段;switches 是无标签的 7 项布尔字符串,不能独立对应或推断某个隐藏参数。两份 JSON 都没有 enable_thinking、chat_template_kwargs、reasoning_format、max_tokens 或 thinking_budget 配置键。
系统提示词明确要求“直接交付正文”“不得输出思考、流程、等待安排”,并要求遵循服务端任务。它没有请求输出 chain-of-thought,但自然语言要求不显示思考,不等于从推理引擎层禁用 thinking。系统提示仍包含较多临床格式约束,所以一条短 user query 经过 Dify 后也未必等于直接服务的最短 prompt。
同一 txt 的 229–235 行可见“直接回复:LLM / text”的 UI 连线。这个历史草稿画面降低了“当时完全没接 answer node”的可能性,但不证明当前发布图、变量绑定或 provider 流输出完全正确。
3. 本地 reasoning 过滤可以造成零正文,但可以区分
DifyReasoningTextFilter 的行为:
- 仅识别大小写不敏感的精确
<think>/</think>,支持跨 chunk 和嵌套。 - 标签内部字节被丢弃;未闭合 think 块直到流结束仍不会转成临床正文。
- 普通正文在 depth=0 时立即返回,只会暂缓不完整标签前缀和开头的纯空白。
reasoning_content、thought等专用推理字段不是临床正文,解析器不会把它们交给临床 delta。- 阻塞与流式回复使用同一类过滤;完整终态只有 reasoning 而无正文,会得到
EMPTY_RESPONSE,不是成功报告。
DifyChatService.php:2597–2601 只把 Dify message/agent_message 的 answer 字符串送给过滤器。node_started/node_finished、agent_thought 等事件不会被当成临床正文。若实际发布应用输出另一套协议/事件形状,应在 Dify/节点绑定处核对,不能用解除过滤来掩盖协议不匹配。
关键计时盲区
recordStreamFrame()(2653–2662 行)在交给 DifyStreamTiming 前,已将 answer 替换成过滤后的可见文字。所以:
first_answer_ms缺失只说明没有可见正文,不证明没有上游 answer 帧。- 即使 answer 全被过滤,
events.message.count/events.agent_message.count仍会累计。 - 原始
<think>内联内容不会自动进入reasoning_frame_count;该字段只数 Difythought或专用reasoning_content字段。reasoning_frame_count=0 也不能单独排除内联 thinking。 - 过滤器已有安全的
counts():filtered_bytes/think_tag_count/unclosed_think_depth,但当前 DifyChatService 没有将这些计数接入返回/上游 timing,因此现有生产 timing 可能无法区分这两种零正文。
实际执行的纯离线对照
用 Reflection 调用生产 consumeStreamFrame(),为每个用例创建真实 request-local filter 与 timing,仅汇总数字:
| 合成输入 | 可见字节 | message 帧数 | first_answer | reasoning_frame_count | filter 计数 |
|---|---|---|---|---|---|
message.answer="synthetic-body" |
14 | 1 | 有 | 0 | 全 0 |
message.answer="<think>synthetic-reasoning" |
0 | 1 | 无 | 0 | filtered_bytes=26,tag=1,depth=1 |
只有 agent_thought.thought |
0 | 0 | 无 | 1 | 全 0 |
只有 node_started |
0 | 0 | 无 | 0 | 全 0 |
这证明本地过滤没有普遍抑制普通正文,同时证明“0 首正文 + 0 专用 reasoning 计数”仍可能是内联 think。若某次原始 Dify 事件里完全没有 message/agent_message,本地过滤器没有输入可吞,排查应前移至 Dify/provider。
完整现有离线回归通过:
php server/tests/DifyReasoningFilterTest.php
Dify incremental reasoning filter: 3660 assertions passed (pure offline fixtures).
4. 最小验证建议,保留千问
主代理正在执行有限的直接服务探测,本审查不重复发请求。建议按下面顺序收敛,每步只记录固定枚举、布尔值、字节数、帧数和耗时,禁止保存临床内容或推理原文。
- 核对实际发布配置而非草稿。 对当前 API 应用解析出已发布 LLM 的模型名/provider 标识、endpoint 是否与已验证直接路径一致、provider stream 开关、thinking 开关/extra body、实际 max_tokens、reasoning_format、stop/response_format、重试与 timeout。仅提取非秘密白名单字段,不显示 token、鉴权 header、连接 URL 中的凭据或整份 provider 配置。
- 与已成功直接对照使用同一组有效参数。 对无患者数据的最短输出任务,比较同一千问 provider 在
enable_thinking=false且短输出预算下,经 Dify 时是否同样快速得到content。单独把 max_tokens 降到 24 而保留 thinking 不明,可能只截断推理而仍无正文,不是干净对照。不要据此直接修改患者应用的默认输出上限。 - 区分 provider 原始输出与 Dify 输出。 对合成请求只观察:first byte、first reasoning、first content、usage/finish_reason,以及 Dify
message/agent_message帧数量。直接 provider 快而 Dify provider 慢,优先核对发布应用连接路径/参数/流式适配;provider 有 content 而 Dify 没有 message,则核对 answer 节点、text 变量绑定或 provider/plugin 缓冲。 - 若 Dify 有 message、应用没有正文,再看过滤计数。 可在纯合成诊断路径读取 request-local filter 数字;无需关闭过滤,更不能把 reasoning 透传到患者 UI。
unclosed_think_depth>0可直接支持“正在/一直处于内联 think 块”;普通 answer 字节大于 0、过滤字节为 0 却没有 delta,则另查已取消/timeout discard 或 callback_error。
如果需要临时隔离 workflow 复杂度,使用合成诊断路径/临时独立测试应用,只保留同一千问 provider 和一进一出的最短文本任务,先读实际发布配置再做单变量对照。此为验证建议,本审查没有创建应用、发布版本或更改任何参数。
可排除与不能排除
- 当前直接千问对照已证明该路径可快速返回正文;生成服务整体挂死的解释不成立。
- 附件可读且无附件合成 Dify 请求也无正文,使“仅由真实附件下载失败导致此次零正文”的解释不成立。
- 本地普通正文通过 3660 项过滤回归及直接合成帧验证;没有证据表明过滤器会无条件压住正常正文。
- 尚不能排除 Dify 有效 thinking 默认值、过大的有效输出/推理预算、provider/plugin 只输出或缓冲 reasoning、实际发布应用/endpoint 差异、answer 节点绑定或流式适配问题。
UPSTREAM_TIMEOUT是原 deadline 到达后的终态;单纯已完成的纯 reasoning 结果应为EMPTY_RESPONSE。当前现象更符合截止前没有可见正文完成,但没有原始帧摘要时仍不能确定具体是哪一层。
本审查没有发现应先改本地过滤器或切换模型的依据。优先把已成功的“同一千问、短预算、明确禁用 thinking”直接对照与 Dify 实际发布请求拉齐,再定位输出消失在哪个边界。