loading

Loading

探索与发现

首页 AI 工程化

推理模型的流式输出:thinking 块怎么流

分类:AI 工程化
字数: (2543)
阅读: (2)
0
摘要:推理模型的流式输出不只是逐字渲染:思考块、正文、工具调用参数混杂在 SSE 流里,客户端要按字段分流、独立缓冲、支持中途中断。记录接通义千问与智谱过程中的判断和踩坑。

推理模型把流式输出的复杂度抬高了一档。普通对话模型只吐一种东西:正文 token。推理模型吐两种,甚至三种:内部推理(thinking / reasoning_content)、最终正文、工具调用参数。SSE 协议那一层没变,但客户端要判断的边界多了很多。这两周我先后接了通义千问和智谱的流式接口,遇到的问题基本都在“怎么识别阶段切换”上,而不是“怎么收 chunk”。

SSE 这一层没变,变的是 chunk 里的东西

协议层面大家都知道:设置 stream=True,服务端建一条持久 HTTP 连接,每生成一个文本块就用 data: 推一行,最后用 data: [DONE] 收尾。差别在字段结构。非流式调用返回一个完整 JSON,正文落在 choices[0].message.content;流式则是逐块返回 choices[0].delta.content,由客户端拼接。阿里云百炼的文档把这件事写得很直白,同时提醒了一句容易被忽略的话:Qwen3 开源版、QwQ 商业版与开源版、QVQ,以及 Qwen3.5-Omni 这些模型只支持流式调用,非流式根本不给你走。

所以流式对推理模型不是优化项,是唯一通路。这意味着客户端第一行代码就得按流式写,不能先做同步版本再补流式。

用量统计也会变。OpenAI 兼容协议默认不在流里返回 token 消耗,得显式开 stream_options={"include_usage": true},开启后最后一个 chunk 不带 choices、只带 usage。写代码时如果只判 chunk.choices[0].delta.content,会在最后一个包上抛 IndexError。这是很常见的线上崩溃点,真正出问题的时候一般是在空回复或提前中断的边界上。

推理块和正文块的区分,是控制点

普通模型的流式,前端拿到 delta.content 直接追加进 DOM 就完事。推理模型不行,因为思考过程和答案的性质完全不同:思考过程可能很长,可能中途反悔,可能包含不该展示给终端用户的试探性语句;而答案是用户要的最终交付物。把两者混在一个缓冲区里,用户看到的是一大段自问自答然后突然收敛,体验很差,也没法做折叠。

工程上的做法是维护两个缓冲区,按字段名分流。推理内容通常另起一个字段返回(不同厂商叫法不同,很多实现用 reasoning_content),正文走 delta.content。只要每个 chunk 判断它带哪个字段,往对应的缓冲区追加即可。不要按“先后顺序”猜阶段,认为“先来的都是思考,后来的都是答案”——工具调用、多轮插入都可能打乱这个假设。字段名是唯一可靠的信号。

这里有个具体的取舍:思考块要不要实时推送。我的建议是推,但节流。推理模型的思考 token 生成速度可能比正文快,逐 token 推给前端会把渲染线程打满,尤其移动端。折中方案是服务端按 50 到 100 毫秒聚合一次再下发,把 SSE 的消息数降一到两个数量级,而用户几乎感知不到差别。代价是首字延迟多一点点,这个延迟对“思考中”这种状态展示无所谓。

中途停止是流式最大的隐性收益

文档里有一句容易略过但很实用的话:请求中断时,只计算服务端收到终止请求前已生成的部分。放进推理模型场景,这意味着用户看到“这个思路不对,它在往奇怪方向跑”时可以立刻点停,已经烧掉的思考 token 不再继续堆。长推理任务里这个操作能省掉大量成本,而如果非流式调用,你必须等它全部跑完才有机会断。

要做这件事,客户端得能中断底层连接(AbortController 之类),并且服务端要能感知断开。自己做网关的话,务必不要在客户端断开后还在后台把流读完再转存——见过这种实现,省成本的初衷完全被抵消了。终止时记得把两个缓冲区都持久化,半截思考过程对排查有用,但对用户展示要明确标成“已停止”,不要让半截内容看起来像完整答案。

LazyLLM、TextStreamer 这些小工具的定位

如果不在云 API,而是在本地拿 transformers 跑模型,好处是控制权全在自己。transformers 自带 TextStreamer,把每个新 token 的回调接管掉自己做出站就行;模型部署框架 vLLM、TGI 也提供现成的流式接口。LazyLLM 的思路不太一样:它把任务塞进带线程池的执行器,通过一个文件系统队列轮询取输出块,future.done() 作为终止条件。这个设计在单机调试时好用,可以同时兼容在线大模型和本地模型,stream=True 一开,OnlineChatModule 和 TrainableModule 的调用方式一致。但要注意它的轮询模型意味着延迟取决于轮询间隔,以及队列实现是多进程还是单进程,高并发下要先压一次。

几个容易踩的细节

  • 拼接方式。百炼的示例用的是列表暂存片段、最后 "".join(),而不是循环里 content += chunk。单请求无所谓,量级上来之后字符串反复拷贝是有成本的,照抄示例就行。
  • curl 要加 --no-buffer。不加的话中间层会用标准输出缓冲,你会以为服务端没流,其实只是终端攒着。排查“为什么流式卡住了”第一步先确认这点。
  • 结束信号与 business 结束信号要分开。data: [DONE] 是传输层的结束;一个推理任务逻辑上的完成,可能要结合 finish_reason 和是否有工具调用。只有前者就渲染“回答完毕”按钮,遇到多轮工具调用会误判。
  • 计费口径确认清楚。百炼写的是按输入、输出 token 计费,流式与非流式规则相同。但思考 token 算不算输出,各家定义不一样,接新模型时先看账单再写成本估算模型,别按经验猜。

我对这件事的判断是:推理模型的流式控制,真正的难点不在协议,而在阶段化的事件建模。如果把 adapter 设计成一个只吐字符串的回调,后面迟早要被思考块、工具调用、usage 这三类信息逼着改接口。一开始就把事件定义成“阶段 + 内容块 + 元信息”的结构,上层 UI 想折叠就折叠、想隐藏就隐藏,后面换模型只改字段映射这一层,不动渲染逻辑。

转载请注明出处: 皮哈皮阿

本文的链接地址: https://blog.phpat.com/ai-engineering/618.html

手机扫码阅读

微信或手机浏览器扫一扫,随时随地随心阅读与分享

1 / 1
预览图