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 检测中快速过滤。