DeepSeek-R1 终端输出满屏 `<think>` 乱码?一行正则修复 Hermes 过滤 Bug
接入 DeepSeek-R1 后的灾难现场:满屏 think 标签闪烁
满怀期待地看着官方 README 里那句牛逼哄哄的“支持所有兼容 OpenAI 格式的模型”,你兴冲冲地改了 config.yaml,把底层的 LLM 引擎换成了当下最火的 DeepSeek-R1 或者 Qwen3 系列。
你点了一根烟,敲下回车,以为接下来迎接你的是如同原生 IDE 插件般丝滑的智能体多轮交互。别做梦了。
当大模型开始输出的那一瞬间,迎面扑来的是一场 UI 灾难。你的终端并没有优雅地流式打印最终答案,而是像呕吐一样,疯狂地往外喷射带有 <think> 和 </think> 包裹的思维链(Chain of Thought)原文本。原本整洁的 TUI 界面瞬间被几千字的内心戏撑爆,光标四处乱闪,排版稀烂,如果你开了多线程 Agent,甚至会看到各个子 Agent 的 think 标签 串词混流。
你跑去翻 GitHub Issues 才发现,只要是带有推理过程(Reasoning)的国内大模型,无一例外全在这个坑里翻了车。
报错现象总结: 在 Hermes-Agent 中接入 DeepSeek-R1、Qwen3 或 MiniMax 等具有深度思考能力的大模型时,由于底层渲染函数
_clean_for_display无法正确拦截处于流式输出传输中(Mid-stream)的未闭合<think>标签,导致思维链的冗余文本直接绕过过滤机制,写入delta.content,最终完全污染终端 UI 的渲染区域。
官方文档对此避重就轻,这就导致了只要你不用 Claude,你的终端基本就在裸奔。问题究竟出在哪?今天我们直接扒开源码,看看这个低级 Bug 是怎么产生的。
扒开 _clean_for_display 源码:为什么官方的流式正则过滤会彻底失效?
要搞清楚为什么屏幕上会飙乱码,我们得先认清大模型 API 流式输出(SSE, Server-Sent Events)在底层协议上的割裂。
官方在适配 Claude 时,由于 Anthropic 的 API 极其规范,思维链的数据是走专属通道的(通过 delta.thinking_delta 字段传输)。Hermes-Agent 的底层拦截器会把这些数据优雅地交给 reasoning_callback 去处理,根本不会混入最终呈现给用户的 content 里。
但绝大多数兼容 OpenAI 格式的推理模型(如 DeepSeek-R1),简单粗暴到了极点——它们直接把带有 <think>...</think> 的推理过程,硬生生地塞进了标准文本输出的 delta.content 字段里!
为了对比更直观,我们可以看下主流大模型在 Hermes 下的底层链路差异:
| 接入的模型类型 | 推理数据传输字段 | Hermes-Agent 底层处理逻辑 | 终端实际表现 |
|---|---|---|---|
| Claude 3.5 Sonnet | delta.thinking_delta |
走 reasoning_callback 独立渲染 |
完美、纯净、无乱码 |
| DeepSeek-R1 | delta.content |
丢给 _clean_for_display 擦屁股 |
❌ <think> 原码满屏飞 |
| Qwen3-Max / MiniMax | delta.content |
丢给 _clean_for_display 擦屁股 |
❌ <think> 原码满屏飞 |
官方为了给这些“不守规矩”的模型擦屁股,在终端渲染层搞了一个名叫 _clean_for_display 的过滤函数。初衷是好的,想用正则表达式把这些标签洗掉。但写这段代码的老哥,显然在处理流式(Streaming)数据时缺乏基本的工程常识。
官方原生的正则逻辑大概是这样的:查找 <think> 开头,</think> 结尾的完整字符串,然后将其替换为空。
这里的致命漏洞在于:流式输出是一块一块(chunk)吐出来的!
我们推演一下这个灾难性的时序逻辑:
- Chunk 1 到达:包含
<think>我需要先分析一下这个问题...。注意,此时</think>闭合标签根本还没发送过来。 - 正则匹配失败:
_clean_for_display拿着匹配完整闭合标签的正则去扫 Chunk 1,发现没有</think>,于是判定这是“合法正文文本”。 - 脏数据上屏:这半截带着
<think>的残缺内容,直接绕过防线,被无情地写入了 UI 缓冲区。 - Chunk N 到达:包含最后的
</think>。此时再去过滤已经毫无意义,因为前面的垃圾思维链字符早就打印在你的屏幕上了。
处理流式数据的正则,只防“闭合块”而不防“未闭合的半截块”,纯属掩耳盗铃。
手撕 Hermes 虚拟环境:兼顾完整与未闭合 think 标签的临时修法
病因找到了:流式数据截断导致正则失效。那我们要做的,就是对 _clean_for_display 函数进行降维重构。我们必须让它具备处理**“完整块(Complete blocks)”、“流传输中的未闭合块(Incomplete blocks)”以及“孤儿闭合标签”**的三重拦截能力。
如果你头铁,想自己动手丰衣足食,你需要经历以下繁琐的抢修过程:
首先,钻进深不见底的 Python 虚拟环境。如果你是用官方推荐的 uv 工具链安装的,去找类似 venv/lib/python3.11/site-packages/... 下的终端渲染脚本,死磕并定位到 _clean_for_display 函数,将它原有的弱智正则替换为以下三段式暴力清洗逻辑:
import re
def _clean_for_display(cleaned: str) -> str:
# 阶段 1:先干掉已经完整闭合的块(处理速度最快,防范非流式或极快吐出的短块)
cleaned = re.sub(
r'<(think|thinking|reasoning|thought|REASONING_SCRATCHPAD)\b[^>]*>.*?</\1>',
'', cleaned, flags=re.DOTALL | re.IGNORECASE,
)
# 阶段 2:核心修复!干掉还在流中、只有开头没有结尾的未闭合块(Mid-stream)
# 注意:这里使用贪婪匹配到末尾,只要碰到 <think> 起手,后面的流数据全部静默吞噬
cleaned = re.sub(
r'<(think|thinking|reasoning|thought|REASONING_SCRATCHPAD)\b[^>]*>.*',
'', cleaned, flags=re.DOTALL | re.IGNORECASE,
)
# 阶段 3:兜底清理上一轮或网络抖动遗留的孤儿闭合标签
cleaned = re.sub(
r'</(think|thinking|reasoning|thought|REASONING_SCRATCHPAD)>\s*',
'', cleaned, flags=re.IGNORECASE,
)
return cleaned
这看起来简单对吧?但实操起来极其恶心。改完源码后,你需要保存、清空缓存、重启守护进程。万一你缩进敲错了一个空格,整个 Agent 的终端模块会直接抛栈然后挂起。
更痛的是,一旦 Hermes-Agent 官方发布个次要版本更新,你执行了 git pull 和重新构建,这段手敲的代码又会被原封不动地覆盖掉。在国内网络环境下,如果你稍微搞乱了依赖树,试图重新 uv pip install,又得面对茫茫多的 Github Raw 域名 Timeout 和 PyPI 连接重置。
拒绝无效折腾:直接获取重构版 display_cleaner.py 覆盖包
手动改虚拟环境源码这种事,既反直觉又极难维护。为了修一个本来就不该存在的输出 Bug,搭进去一个原本可以愉快 Coding 的下午,性价比极低。
为了拯救各位开发者的头发,我已经把这段针对 DeepSeek、Qwen3 等国内推理模型量身定制优化的正则清洗逻辑,直接做成了一个免编译的底层补丁包。
你不需要去翻找虚拟环境的隐藏路径,也不需要懂正在疯狂变化的正则贪婪匹配逻辑。
👉 来 GitCode 极速下载已修复正则过滤逻辑的 [display_cleaner.py 覆盖包] (https://gitcode.com/GitHub_Trending/he/hermes-agent)
替换操作像呼吸一样简单:
- 访问 GitCode 仓库,拉取打包好的
display_cleaner.py(https://gitcode.com/GitHub_Trending/he/hermes-agent) - 将文件直接扔进你的项目对应目录,覆盖原文件。
- 重新拉起 Hermes-Agent。
完成覆盖后,立刻去终端随便丢给 DeepSeek-R1 一个极其烧脑的逻辑题。你会发现,那些原本能刷满三屏的 think 标签 和推理过程被完美地静默吞噬在了内存层,屏幕上只剩下纯净、优雅、极速流式输出的最终答案。
拿去用,少踩坑,把精力留给真正有价值的业务逻辑编排上。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0190
cann-learning-hubCANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。Jupyter Notebook0113
Step-3.7-FlashStep-3.7-Flash是一个拥有 1980 亿参数的稀疏混合专家(MoE)视觉语言模型,由 1960 亿参数的语言主干网络和 18 亿参数的视觉编码器组合而成,具备原生图像理解能力。Python00
JoyAI-EchoJoyAI-Echo,这是一个独立的、仅用于推理的版本,旨在实现分钟级多镜头音视频生成。它采用了经过蒸馏的DMD生成器、配对的跨模态记忆以及故事级别的一致性。其性能的核心在于,一个跨模态视听记忆库能够在长达五分钟的视频中保持角色外观和语音音色的一致性。同时,一个训练后处理流程将基于记忆的强化学习与分布匹配蒸馏相结合,实现了7.5倍的速度提升,显著增强了视觉质量和对齐效果。00
omega-aiOmega-AI:基于java打造的深度学习框架,帮助你快速搭建神经网络,实现模型推理与训练,引擎支持自动求导,多线程与GPU运算,GPU支持CUDA,CUDNN。Java04
llm-universe本项目是一个面向小白开发者的大模型应用开发教程,在线阅读地址:https://datawhalechina.github.io/llm-universe/Jupyter Notebook08