👁 110 次阅读
Open WebUI 为何要禁止把 reasoning 重新注入提示词?
来源:https://github.com/open-webui/open-webui/issues/23339现象描述
Open WebUI 在重建聊天历史时,会把上一轮 assistant 输出中的 reasoning / thinking 内容重新注入下一轮的 messages。触发路径在backend/open_webui/utils/middleware.py 的 process_messages_with_output(),它调用 convert_output_to_messages(..., raw=True);而 backend/open_webui/utils/misc.py 中的同名函数在 raw=True 时会把 reasoning 以 think 标签的形式追加进消息。
由此产生的典型症状包括:
- 部分 provider 与模型在历史里看到
think标签后开始模仿和放大,生成多重、嵌套、变体的 thinking 标签。 - 前端的 reasoning 解析与渲染被破坏:thinking 块泄漏进正文、块重复出现、markdown 结构破损。
- 提示词体积随轮次持续膨胀,token 成本上升。
- 敏感 tool 数据被重新注入上下文。
背景原因
UI 显示与模型上下文被耦合在一起
ShirasawaSama 在 2026-04-02 指出,问题的根源是 UI 显示的内容与模型实际看到的内容紧耦合。上游的小改动,或 provider 泄漏think 标签,都会污染 prompt、增加 token 成本,并把敏感 tool 数据重新注入。他建议把两件事拆开:
- Include reasoning in LLM context(on / off)
- Include tool payloads in LLM context(none / summary / full)
上游模型对多轮历史的明确要求
visig9 在 2026-04-04 引用 Gemma 4 官方文档(huggingface.co/google/gemma-4-E2B-it 的 Multi-Turn Conversations 一节):多轮对话中,历史模型输出只应包含最终回复,上一轮的 thoughts 不得在下一轮用户输入开始前加入。这与当前 Open WebUI 的行为直接冲突。注入方式本身也值得商榷
ShirasawaSama 在 2026-04-08 提出关键取向:不要用think 标签把 reasoning 注入 assistant 输出,应尽量按 LLM 原样返回数据。Classic298 同意对注入方式的讨论,但认为当前是否返回给 API 的行为可以保持不变。
反向约束:某些场景反而需要 reinject
itsHenry35 在 2026-04-26 指出反向约束:deepseek reasoning 开启并使用 native tool calling 时反而需要 reinject,否则会报错。Classic298 在 2026-05-26 进一步引用 MiMo 文档说明,reasoning_content 必须在包含 tool call 的后续轮次完整返回,否则会出现指令跟随能力下降与幻觉增加,并称 DeepSeek 与 Kimi K2.5 有等效要求。这意味着「默认关闭、按模型 opt-in」是比「全局关闭」更合理的设计。
排查或量化方法
量化实测:payload 从 12 KB 涨到 58 KB
gerpervaz 在 2026-05-26 给出量化实测:Open WebUI 接 Qwen reasoning 模型走 vLLM,多轮时上一轮 assistant 的think 块被序列化回 messages。抓取的 WebUI → vLLM 日志显示:
| 状态 | 首个请求 payload | 几轮后 payload | ||
| 未启用剥离 Filter | 约 12 KB | 约 58 KB | ||
| 启用剥离 reasoning 块的 Filter | 约 12 KB | 稳定在约 12-18 KB | ||
| 他的结论是 Filter 技术上可行但脆弱:需要发现 Functions / Filters、编写 Python、绑定到正确模型、用请求级日志验证、并跨 Open WebUI 版本维护。 | ||||
检查 llama.cpp 连接配置 | ||||
yrro 在 2026-04-22 发现一个配置项:Admin Panel → Settings → Connection → 对应 llama.cpp 连接 → Provider 设为 llama.cpp 后,thinking 内容会以独立的 reasoning_content 字段发送,而不是渲染成 content 里的 think 标签。这是不改代码即可缓解的一条路径。 | ||||
理解 filter 覆盖不到的位置 | ||||
| Classic298 在 2026-05-26 补充的机制说明很关键: | ||||
– filter 除非使用 stream() hook,否则不在 tool call loop 中运行。 | ||||
– inlet 只在 process_chat_payload 顶部触发一次。 | ||||
– tool call loop 内部的 generate_chat_completion 不会重跑 filters,因此按迭代构建的 messages(convert_output_to_messages(output, raw=True, reasoning_format=get_reasoning_format(model)))绕过所有 filter hook。 | ||||
– 真正控制 loop 内 reasoning 形态的是 backend/open_webui/utils/middleware.py 的 get_reasoning_format:ollama 走 content 内的 think 标签,llama.cpp 走顶层 reasoning_content 字段,其他取值下 reasoning 整体丢弃。 | ||||
– 跨轮 history rebuild 在 middleware.py 第 2385 行执行,该调用在任何 inlet hook 之前,所以按请求改写 model['provider'] 触及不到它。 | ||||
完整解决方案 | ||||
方案一:临时补丁(最小改动) | ||||
ShirasawaSama 在 2026-04-09 给出的补丁基于 commit 9bd84258d09eefe7bf975878fb0e31a5dadfe0f8:在 misc.py 第 233-242 行 elif item_type == 'reasoning': 与 if raw: 之间插入一条 pass 语句。 | ||||
| ||||
注意 8bit-coder 在 2026-04-13 反馈:加了 pass 仍然有问题;在容器里直接改文件后 down 再 up -d 会还原改动。Classic298 建议改用自定义 Dockerfile 或启动时的替换脚本。 | ||||
方案二:剥离 think 标签的 Filter | ||||
yrro 在 2026-04-22 给出一个 filter,从 assistant messages 中剥离 think 标签,地址为 gist.github.com/yrro/b0f2765ea55ae3414e06b319dd07ae8e。JoeEnderman 在 2026-05-04 确认该 filter 生效,Gemma 4 26b a4b 恢复正常。Classic298 也在 2026-05-26 说明自己使用 filter,并用一段小代码排除 claude 模型。 | ||||
方案三:monkey-patch get_reasoning_format | ||||
Classic298 在 2026-05-26 发布了一个用户态 filter,用于在轮内与跨轮都保留 reasoning_content:机制是 monkey-patch get_reasoning_format,让任何非 ollama 模型返回 reasoning_content 而不是 None,从而命中所有调用点,包括 in-turn tool-call loop 的三次 rebuild(middleware.py 的 4788 / 4793 / 5015 行)与跨轮 history rebuild(middleware.py 的 2385 行)。安装地址是 openwebui.com/posts/bd34c85d-8b61-40c3-854a-9eaa843a9205。 | ||||
| 该 patch 的注意事项: | ||||
| – 模块全局生效,禁用 filter 不会撤销,只有容器重启才生效。 | ||||
– excluded_model_ids 这个 valve 可以让列出的模型保留原行为。 | ||||
方案四:三条缓解思路 | ||||
| tq1008 在 2026-06-12 给出三条思路: | ||||
– 存入历史前剥离 think 块(需要改 convert_output_to_messages)。 | ||||
– 构建下一轮 messages 时使用 raw=False。 | ||||
– 模型级变通:某些 DeepSeek API provider 在服务端分离 thinking 与 content,choices[0].message.content 只返回最终答案,路由到这类网关后前端完全看不到 think 标签。 | ||||
方案五:客户端清洗 | ||||
0DukePan 在 2026-09-07 以 history drift 这个名称描述同一失效模式并 +1,认同两条代码路径(misc.py 的 convert_output_to_messages(..., raw=True),由 middleware.py 的 process_messages_with_output() 调用)是放置默认关闭开关的正确位置;他给出的客户端变通是 unswallow 库的 sanitizeHistory()。 | ||||
参数或对比表格 | ||||
| 方案 | 改动位置 | 覆盖 tool call loop | 跨版本可维护性 | 备注 |
插入 pass 补丁 | misc.py 233-242 行 | 是 | 低,容器重建即还原 | 需自定义 Dockerfile |
| 剥离 think 的 Filter | Functions / Filters | 否(除非用 stream() hook) | 中 | JoeEnderman 确认对 Gemma 4 生效 |
monkey-patch get_reasoning_format | 模块全局 | 是 | 中 | 禁用 filter 不撤销,需重启容器 |
| Provider 设为 llama.cpp | Admin Panel 连接配置 | 是 | 高 | 后端需接受顶层 reasoning_content |
| 路由到服务端分离的网关 | 部署层 | 是 | 高 | 仅部分 DeepSeek API provider 可用 |
客户端 sanitizeHistory() | 客户端 | 不适用 | 中 | unswallow 库 |
常见错误排查
- 加了
pass补丁但问题依旧:8bit-coder 反馈该情况。可能原因是容器down再up -d后文件被还原,需改用自定义 Dockerfile 或启动替换脚本。 - Filter 对 tool call 场景无效:Classic298 说明 filter 除非使用
stream()hook,否则不在 tool call loop 中运行;loop 内部的generate_chat_completion不会重跑 filters。 - 改写
model['provider']无法影响跨轮历史:跨轮 history rebuild 在middleware.py第 2385 行执行,早于任何 inlet hook。 - 禁用 monkey-patch filter 后行为未恢复:该 patch 是模块全局的,禁用 filter 不会撤销,只有容器重启才生效。
- DeepSeek reasoning + native tool calling 报错:itsHenry35 指出该场景反而需要 reinject,不能一律剥离。
实践建议
- 优先尝试把 llama.cpp 连接的 Provider 设为 llama.cpp,让 thinking 走顶层
reasoning_content字段,这是不改代码的路径。 - 若必须用 Filter,注意其覆盖边界:跨轮 history 与 in-turn tool call loop 是两个不同调用点,普通
inlet只能触及前者。 - 对 DeepSeek reasoning、Kimi K2.5、MiMo 等有「reasoning_content 必须完整返回」要求的模型,保留 opt-in 通道,不要全局关闭。
- 监控 WebUI → 后端的请求 payload 体积,若随轮次线性增长,基本可确认 reasoning 被重复注入。
- 该 issue 至今 open,无标签、无维护者确认的方案、无对应 PR;上述 filter 与 monkey-patch 均为社区自维护实现,升级前需重新验证。
FAQ
Q:这个问题的官方修复进展如何? A:issue #23339 当前为 open,无标签,无维护者确认的方案,无对应 PR。社区给出的 filter 与 monkey-patch 均为自维护实现。 Q:为什么不能直接全局关闭 reasoning 注入? A:据 issue 讨论,DeepSeek reasoning 开启并使用 native tool calling 时需要 reinject,否则会报错;MiMo 文档也要求reasoning_content 在包含 tool call 的后续轮次完整返回。因此诉求是默认关闭、按模型 opt-in。
Q:issue 中建议的开关命名是什么?
A:建议命名是 reinject_reasoning 或 include_reasoning_in_history,默认 OFF。OFF 时保留 tool call 结构但不把 reasoning 加回 messages;ON 时保持现状。
Q:Filter 为什么在 tool call 场景下不生效?
A:filter 除非使用 stream() hook,否则不在 tool call loop 中运行;inlet 只在 process_chat_payload 顶部触发一次,loop 内部按迭代构建的 messages 绕过所有 filter hook。
Q:有没有不改代码的缓解方式?
A:有两条。一是把对应 llama.cpp 连接的 Provider 设为 llama.cpp,thinking 会以独立 reasoning_content 字段发送;二是路由到在服务端分离 thinking 与 content 的 DeepSeek API provider 网关。
🔗 相关阅读(你可能还想看)
📄 Gemini Omni 1.2 Flash 发布:视频降价30%、原生15秒,单条成本只贵5分钱
谷歌 11 月 5 日开放 Gemini Omni 1.2 Flash:720p 视频每秒 0.07 美元,比上代降 3
📄 GPT-6 Cyber 发布:121 万美元,948 条通往 root 的路
OpenAI 发布 GPT-6 Cyber:在 Linux 内核上跑出 948 个本地提权利用,整轮 API 成本 12
📄 Anthropic冲刺IPO:170亿营收、8950亿算力承诺、首次在招股书披露递归自我改进风险
📄 Anthropic IPO招股书曝光:单季营收170亿美元背后的8954亿美元算力账单
📄 阿里千问接入 Apple Intelligence:中国版 iPhone 的 AI 时刻来了
📄 苹果AI中国落地靴子落地:千问接入Apple Intelligence,11月4日开测
📄 苹果官宣:Apple Intelligence 国行版落地,阿里千问深度接入 Siri 与系统写作工具
苹果官宣 Apple Intelligence 国行版 11 月 4 日开测、12 月随 iOS 27.3 正式上线。阿
📄 Anthropic 递交 S-1 启动 IPO:Claude 母公司估值剑指 2 万亿美元
📄 Cerebras 公开万亿参数模型部署方案:59 块晶圆跑 3T 参数,推理 2000 tokens/秒