展示 AI 深度思考过程的关键,是把模型的推理内容与最终回答拆成两条独立的数据流,通过 SSE 的命名事件分别下发,前端按事件类型分流渲染。我们在企业级 AI 网关项目中,后端用 event: reasoning 与 event: token 两个命名事件分别推送思考片段与正文片段,前端用可折叠面板承载思考内容、打字机效果承载正文,全程不打断、不阻塞,思考过程默认折叠、一键展开,既保留透明度又不干扰阅读。这个方案在后端只增加一个字段判断,前端只增加一个渲染分支,改动成本极低。
一、为什么需要把思考过程单独拎出来
1.1 双通道数据的天然差异
支持深度思考的模型(如 deepseek-reasoner、o 系列)在流式返回里会同时产出两类内容:reasoning_content(推理轨迹)和 content(最终答案)。两类内容在语义上完全独立,混在一起渲染会把界面变得难以阅读,也不利于历史记录压缩。
| 维度 | reasoning_content | content |
|---|---|---|
| 内容性质 | 推理步骤、假设、自我校验 | 面向用户的最终结论 |
| 渲染样式 | 灰色背景、斜体、可折叠 | 正常正文、代码高亮 |
| 是否入库 | 不保存,节省 token | 完整保存到会话历史 |
| 是否可被打断 | 可以,思考中可停止 | 不可,停止即弃 |
1.2 展示思考过程带来的三个收益
用户能看到 AI 是否理解了上下文,理解错了可以提前打断纠正;开发排查问题时能顺着推理轨迹定位模型行为异常;新开发者通过观察推理步骤,能反过来学习拆解需求的思路。这是企业级场景里”模型可信度”的一部分,比单纯的回答质量更早建立。
二、SSE 双通道事件的设计
2.1 后端如何区分两类内容
网关在转发模型流时,对每个 delta 做一次字段判断:存在 reasoning_content 就走思考事件,存在 content 就走正文事件。Spring AI 侧用 ChatResponse 的流式回调,通过 Flux 过滤后重新映射为自定义事件。
// 网关层将模型流拆分为两类 SSE 事件
flux.map(chunk -> {
if (chunk.getReasoningContent() != null) {
return new SseEvent("reasoning", chunk.getReasoningContent());
}
if (chunk.getOutput() != null) {
return new SseEvent("token", chunk.getOutput());
}
return null;
})
.filter(Objects::nonNull)
.doOnNext(evt -> session.sendMessage(
SseEmitter.event().name(evt.type()).data(evt.content())
));
思考内容只透传、不落库。多轮对话的历史里只保留最终答案,避免思考文本占满上下文窗口,也能防止内部推理信息在后续轮次中被再次投喂给模型。
2.2 前端如何解析命名事件
浏览器原生 EventSource 支持 addEventListener 监听命名事件,但如果想拿到更细粒度的控制(如超时中断、断线重连状态),用 fetch + ReadableStream 手写解析更稳。缓冲拆分按空行切分 SSE 帧,最后一帧不完整时留在 buffer 等下一次数据到达。
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const frames = buffer.split('\n\n')
buffer = frames.pop() ?? ''
for (const frame of frames) {
handleFrame(frame)
}
}
function handleFrame(frame) {
const event = frame.match(/^event: (.+)$/m)?.[1]
const data = frame.match(/^data: (.+)$/m)?.[1]
if (!data) return
if (event === 'reasoning') {
reasoningStore.append(JSON.parse(data).content)
} else if (event === 'token') {
answerStore.append(JSON.parse(data).content)
}
}
三、前端渲染的四个处理要点
3.1 思考区与正文区分开管理
消息条目里维护两个独立的响应式状态:reasoningText 与 answerText。渲染时思考区用 v-show 控制折叠,正文区走 Markdown 渲染。折叠状态下只显示”已深度思考 n 秒”的摘要行,展开后展示完整轨迹。
3.2 打断时区分两类内容
停止生成时,思考区只保留已产出的部分,正文区标记”已中断”。两者互不覆盖,用户在思考阶段打断,不会得到半截正文。
3.3 性能兜底
深度思考模型的首段思考往往要几秒才出现,期间页面必须保持可交互。我们用骨架屏 + “思考中”动画占位,同时把思考区的 DOM 更新节流到每 50ms 一次,避免高频率 append 引起长任务卡顿。
3.4 网关层面的流式参数
- 关闭响应压缩,避免流被整包缓冲;
- 响应头带
Cache-Control: no-cache,禁止中间层缓存; - 反向代理侧关闭对 SSE 的缓冲(
X-Accel-Buffering: no),否则事件会被攒到一大块才推到浏览器,打字机效果名存实亡。
四、前后端联调的检查清单
- 打开浏览器网络面板,确认事件帧按
reasoning/token交替出现; - 断网 10 秒后恢复,确认页面能提示连接中断而不白屏;
- 用思考型模型连续问答 5 轮,核对历史记录里没有混入推理文本;
- 在大模型思考期间点击停止,确认思考区内容保留、正文区正确标记中断。
常见问题(FAQ)
Q1:思考内容会占用上下文吗?
不会。网关只透传不落库,多轮对话历史仅保留最终答案。
Q2:EventSource 和 fetch 流式解析怎么选?
EventSource 简单但有断线重连无补偿;fetch 可控性强,适合需要中断与状态管理的场景。
Q3:思考过程展示会拖慢首字响应吗?
不会。思考与正文各走各的通道,正文首字仍按首包到达时间渲染。