跳转到内容

ThinkFilter 流式解析模式

ThinkFilter 流式解析模式

学习目标

读完本章后,你将能够:

  • 实现从 LLM 流式输出中分离 <think> / <thinking> 推理块的解析器
  • 正确处理 chunk 边界处的不完整标签
  • 处理嵌套、自闭合、带属性的 think 标签
  • 设计流式状态机避免误匹配

前置知识


核心概念

1. 为什么需要 ThinkFilter

许多推理模型(DeepSeek R1、Claude 的 <think>、OpenAI o 系列的 reasoning_content)在输出中包含推理过程,使用 XML 标签包裹:

<think> 这是模型的内部推理过程,不展示给用户</think>
好的,让我来帮你...

流式输出场景下,这些标签可能被 chunk 边界截断:

Chunk 1: "让我思考一下这个问题...<thin"
Chunk 2: "k>这是推理内容</think>
"
Chunk 3: "好的,答案是..."

ThinkFilter 的核心挑战是:在不知道完整 chunk 内容的情况下,正确识别标签的起始和结束

2. 状态机设计

3. 核心数据结构

ThinkFilter {
buffer: String, // 未处理文本的缓冲区
inside_think: bool, // 是否在 think 块内
think_depth: usize, // 嵌套深度计数器
}
FilterOut {
content: String, // 可见输出文本
thinking: String, // 提取的推理内容
}

4. 支持的标签格式

标签行为
<think>进入推理模式,depth++
</think>退出推理模式,depth—
<think/>自闭合,无操作(不进入也不退出)
<thinking> / </thinking> / <thinking/><think> 系列
<THINK>大小写不敏感,等同于 <think>
<think class="x">支持属性

设计模式详解

Chunk 边界处理:缓冲 + 渐进检测

ThinkFilter 的核心算法是 push → process → drain 三步:

1. push(chunk):
buffer += chunk
2. process_buffer():
loop:
event = next_buffer_event()
if TagEvent(tag, position):
// 发现完整标签
将标签前的文本放入正确桶(content 或 thinking)
更新状态机(inside_think, think_depth)
从 buffer 中移除已处理部分
elif PartialEvent(partial_start):
// buffer 以可能的不完整标签开头
将 partial_start 前的文本放入正确桶
保留 partial 在 buffer 中等待下一个 chunk
elif None:
// buffer 中没有标签特征
整个 buffer 放入正确桶
清空 buffer
3. finish():
清空 buffer 中剩余内容到正确桶

关键洞察:只有在 buffer 中发现 < 字符时才需要等待更多数据。如果 buffer 中没有任何 <,说明当前内容都是纯文本,可以直接输出。

渐进式 Partial 检测

判断 buffer 开头是否是不完整的 think 标签,使用嵌套可选正则

^<(?:t(?:h(?:i(?:n(?:k(?:i(?:n(?:g)?)?)?)?)?)?)?)(?:\s.*|/)?$

这个正则的精妙之处在于:

  • t(?:h(?:i(?:n...))) 匹配 “think” 的任意前缀<t<th<thi<thin<think<thinki
  • (?:\s.*|/)?$ 允许标签后面有空格、属性或 /
  • 只有当 buffer 以这些前缀开头且没有未闭合的 > 时,才认为是 partial

为什么不用正则匹配完整标签? 正则无法正确匹配嵌套和处理引号内的 >。ThinkFilter 对完整标签使用字节级解析器,只在 partial 检测时使用正则。

字节级标签解析器

parse_think_tag() 是一个手写的字节级状态机,不依赖正则:

1. 确认以 < 开头
2. 检查可选的 /(闭合标签)
3. 读取字母,必须是 think 或 thinking(大小写不敏感)
4. 标签名后的字符必须是 >、/ 或空格
← 这一步防止 <thinking-mode> 被误匹配
5. 如果在属性中遇到引号,跟踪引号状态
← 防止 <think data="a>b"> 中的 > 被误认为结束
6. 返回 (ThinkTag 类型, 结束位置)

开放边界验证(步骤 4)是关键的安全措施。如果没有这一步,<thinking-mode><thinking123> 会被误认为 think 标签,导致后续所有内容被错误路由到 thinking 桶。

自闭合标签的特殊处理

<think/> 被当作 no-op:既不增加 think_depth 也不设置 inside_think。标签文本直接从输出中移除。

为什么? 这是一个已知的回归场景:如果 <think/> 被视为 <think> + </think> 的组合,某些模型的输出会导致后续所有内容被错误路由到 thinking 桶。将其视为 no-op 是最安全的选择。


问题与规避

嵌套标签处理

问题:某些模型可能输出嵌套的 think 标签。

对策:使用 think_depth 计数器。每次 <think> 增加,每次 </think> 减少(使用 saturating_sub 防止下溢)。inside_think = think_depth > 0

引号内的 > 字符

问题<think data="a>b"> 中的 > 不应该被当作标签结束符。

对策:解析器跟踪引号状态(单引号、双引号),在引号内的 > 被忽略,只有引号外的 > 才结束标签。

Buffer 内存增长

问题:如果 chunk 持续提供不完整的标签,buffer 可能无限增长。

对策

  • 正常情况:每个 chunk 要么产生完整标签,要么触发 partial 检测
  • 防护:设置 buffer 最大大小,超过时强制 flush
  • 实际场景:LLM 流式输出的标签通常在 1-2 个 chunk 内完整

模型不输出闭合标签

问题:模型可能在 think 块后被截断,未输出 </think>

对策finish() 方法在流结束时将 buffer 中剩余内容按当前状态放入正确桶。如果仍在 inside_think 状态,剩余内容归入 thinking。这保证了不丢失任何输出。


设计取舍

流式解析 vs 后处理

维度流式解析(ThinkFilter)后处理(收集全部再分割)
延迟低(边接收边处理)高(需等待完整输出)
UI 体验可实时展示可见内容需等待完成后展示
内存低(只缓存当前 chunk)高(缓存全部输出)
chunk 边界需要处理无需处理

推荐策略:需要实时展示 LLM 输出的场景(对话 UI、CLI 流式渲染)使用流式解析;离线批处理使用后处理。

字节级解析器 vs 正则表达式

维度字节级解析器正则表达式
性能高(一次遍历)较低(回溯)
引号处理精确困难(无法跟踪状态)
可维护性中等
适用场景完整的标签解析简单的 partial 检测

ThinkFilter 采用混合策略:字节级解析器处理完整标签,正则在 partial 检测中快速过滤。


参考来源