回路 / The Loop — 提出有价值的问题,比直接获取答案更重要。
这个专题收集和整理我与 Gemini、ChatGPT、Claude 等 AI 的对话记录。每一篇对应一次完整的提问与回答过程。通过留存这些层层追问,还原日常思考的真实轨迹。
概要
这是一场关于 AI 流式输出处理的工程对话。从”如何正确解析 SSE 流”出发,逐步深入到 Buffer 缓冲区的底层实现、不使用 Buffer 的三种灾难性后果、Agent 架构中隐藏 Tool Call 的状态机过滤模式,最终澄清了一个常见的团队沟通误区——“隐藏内容让系统变慢”。
核心结论:流式管道中,Buffer 负责把混乱的网络字节流转化为有序的应用层事件;内容过滤必须发生在 Buffer 解析之后、UI 渲染之前;而隐藏内容改变的是”视觉停留感”,而非模型的物理生成速度。
Q: AI 时代的流式输出处理,有哪些系统性的最佳实践?
流式输出(Streaming)基于 SSE(Server-Sent Events,一种服务器向客户端单向推送数据的协议)已经成为 LLM 应用的标配——它让用户在模型生成第一个 Token 时就能看到内容,而非等待全部生成完毕。但流式文本是逐字或逐块(Chunk)到达的,这给前端处理带来了三个维度的挑战。
解析与传输层
| 实践 | 核心做法 |
|---|---|
| Buffer 缓冲区解析 | 维护全局缓冲区,循环提取完整的 data: {...}\n\n 帧,残余文本留待下一个 Chunk |
| 防抖批量渲染 | 用 requestAnimationFrame 或微任务队列,以 16-32ms 为周期批量刷新 UI |
| 推荐工具 | @microsoft/fetch-event-source——替代原生 EventSource,支持 POST 和自定义 Header |
渲染与交互层
| 实践 | 核心做法 |
|---|---|
| 流式 Markdown 闭合 | 使用支持流式解析的 Markdown 引擎,自动闭合未完成的标签(如未配对的 **) |
| 智能滚动 | 滚动条接近底部时自动跟随;用户手动上滚立刻停止;悬浮”返回底部”按钮恢复 |
| 光标动效 | 输出末尾用 CSS ::after 挂载闪烁光标,流结束时干净移除 |
工程化与稳定性
| 实践 | 核心做法 |
|---|---|
| AbortSignal 中断 | fetch 必须传入 AbortController.signal,用户点击”停止”时立刻断开连接 |
| 错误降级 | 后端最终帧携带 finish_reason(stop/length/content_filter),前端据此展示对应提示 |
| 结构化 JSON 流式解析 | 使用 partial-json-parser 等库,实时解析已合规的键值对,逐行渲染表格 |
Q: SSE Buffer 解析为什么是必需的?底层到底怎么工作?
问题根源
SSE 协议用 \n\n(两个换行)作为消息帧的分隔符。但 TCP 是面向字节流的协议——网络传输过程中,一个完整的 JSON 字符串可能被拆分到两个数据块中,也可能多个消息帧被合并在一个数据块里。换句话说,应用层的”帧”与网络层的”包”没有任何对齐关系。
举一个直观的例子。AI 输出了两条消息:
data: {"text": "你"}\n\n
data: {"text": "好"}\n\n
但前端实际收到的网络 Chunk 可能是这样切分的:
- Chunk 1:
data: {"tex - Chunk 2:
t": "你"}\n\ndata: {"text": "好"}\n\n
如果直接对每个 Chunk 做 JSON.parse(),Chunk 1 会抛出语法错误。
Buffer 的解决方案
维护一个全局缓冲区。每次收到 Chunk,追加到 Buffer;然后循环扫描 \n\n 边界,提取完整帧并解析;剩余的不完整文本留在 Buffer 中等待下一个 Chunk。
let buffer = "";
async function handleStream(response) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
while (true) {
const { value, done } = await reader.read();
if (done) break;
// 追加到缓冲区(stream: true 保持多字节字符的解码状态)
buffer += decoder.decode(value, { stream: true });
// 循环提取完整帧
let boundaryIndex;
while ((boundaryIndex = buffer.indexOf("\n\n")) !== -1) {
const completeFrame = buffer.slice(0, boundaryIndex).trim();
buffer = buffer.slice(boundaryIndex + 2);
parseSSEFrame(completeFrame);
}
// 剩余内容留在 buffer 中等待下一个 Chunk
}
}
生产环境的边缘情况
- CRLF 兼容:某些反向代理(Nginx、Cloudflare)会把
\n\n转换为\r\n\r\n,解析器必须同时支持两种分隔符。 - TextDecoder 状态保持:中文 UTF-8 占 3 字节,可能被截断在两个 Chunk 交界处。必须给
TextDecoder传入{ stream: true },让解码器保持内部状态,将不完整字节带入下一次解码。 - OOM 防御:设置
MAX_BUFFER_SIZE(如 512KB),若缓冲区超过此限制仍未找到边界,主动断开连接并抛出异常。
Q: 如果不使用 Buffer,会导致什么后果?
三种灾难性后果:
1. 频繁的程序崩溃
网络 Chunk 切分是任意的。AI 输出 data: {"text": "Hello"}\n\n,但 Chunk 1 可能只包含 data: {"text": "He。没有 Buffer 直接 JSON.parse(),代码抛出语法错误,整个回复流中断。
2. 中文乱码
UTF-8 编码中一个汉字占 3 字节。如果网络切分恰好把一个汉字的 3 字节拆成两半(前 1 字节在 Chunk 1,后 2 字节在 Chunk 2),不经过带状态的 TextDecoder 组装,屏幕上会出现无法解析的乱码方块。
3. 页面布局塌陷
流式渲染需要实时将 Markdown 转化为 HTML。当 AI 只输出了 ```j(代码块标记的一半),如果没有 Buffer 配合流式闭合处理,Markdown 引擎可能把后续所有文字都错误吞入一个巨大的代码块样式中,导致整个页面排版瞬间错乱。
结论:Buffer 不是优化,而是流式处理的基础设施。没有它,拿不到正确的字符串,更谈不上后续的过滤和渲染。
Q: 如果我想丢弃部分内容不展示(比如 <think> 思考过程),应该在哪一层做?
标准做法:不要在网络层丢弃 Chunk,而是在 Buffer 解析出完整的应用层帧之后,在渲染管道的下游进行状态机拦截。
原因很简单:你无法预测网络 Chunk 会在哪里断开。<think> 标签可能被切分为 <thi 和 nk>,在 Chunk 级别根本无法可靠匹配。必须先用 Buffer 拼出完整的字符流,再对字符进行规则匹配和过滤。
状态机过滤器示例(过滤 <think>...</think> 块)
let isThinking = false;
let pendingBuffer = "";
function onTokenReceived(token) {
pendingBuffer += token;
if (pendingBuffer.includes("<think>")) {
isThinking = true;
pendingBuffer = pendingBuffer.replace("<think>", "");
}
if (pendingBuffer.includes("</think>")) {
isThinking = false;
pendingBuffer = pendingBuffer.replace("</think>", "");
return;
}
if (isThinking) {
return; // 丢弃,不渲染
} else {
renderToScreen(pendingBuffer);
pendingBuffer = "";
}
}
核心模型:Buffer 解析 → 状态判断 → 放行或丢弃 → UI 渲染。过滤器就像安检——在完整的物品(Token)进入展厅(屏幕)之前做检查,而非在运输途中拆包。
Q: 在 Agent 架构中,如何隐藏 Tool Call 的 JSON 参数和执行结果?
这是一个典型的”干净用户界面”需求。Agent 在后台调用 search_database 或 get_user_info 时产生的复杂 JSON 参数和原始返回值,如果直接流式渲染给用户,界面会瞬间变得混乱,甚至泄露敏感数据。
架构设计:流式分流与状态机拦截
在 Buffer 解析层之后、UI 渲染层之前建立状态过滤器,实时识别三种状态:
- TEXT 状态:AI 输出正常文本 → 放行渲染
- TOOL_ING 状态:AI 输出 Tool Call 的 JSON 碎片 → 拦截丢弃
- TOOL_RESULT 状态:工具执行结果返回 → 拦截丢弃
拦截逻辑
大模型流式输出 Tool Call 时,协议帧中会包含 tool_calls 字段(以 OpenAI 兼容协议为例):
let isToolCalling = false;
function processFrameToUI(frame) {
const delta = frame.choices[0].delta;
// 检测 Tool Call 开始
if (delta.tool_calls && delta.tool_calls.length > 0) {
isToolCalling = true;
captureToolCallData(delta.tool_calls); // 收集给后端执行
return; // 不渲染
}
// 检测文本恢复
if (delta.content) {
isToolCalling = false;
}
if (isToolCalling) {
return; // 持续丢弃工具调用参数碎片
} else {
renderTextToUI(delta.content); // 正常文本放行
}
}
工具执行结果的处理
工具执行结果(role: "tool" 的 Content)是给大模型看的上下文,天然不应进入用户的流式输出通道。正确链路:
- 前端通过 SSE 接收模型流
- 发现
tool_calls→ 在前端隐藏,流进入等待状态 - 后端默默执行工具,拿到结果
- 后端将结果塞入
messages历史,重新向 LLM 发起请求 - LLM 基于工具结果生成最终答复,建立新的 SSE 流,前端恢复渲染
框架级支持
如果使用 Vercel AI SDK,其 Data Stream Protocol 通过类型前缀区分数据流:纯文本使用 0: 前缀,Tool Call 使用 9: 前缀。在 useChat 的 onChunk 回调中,可以直接按前缀类型决定是否渲染,无需手写状态机。
Q: 隐藏 Tool Call 内容,会让系统”变慢”吗?
这个问题的答案需要先厘清一个定义——“慢”指的是模型的物理生成时间,还是用户看到内容的视觉延迟?
结论:不会变慢,只是某段时间”静止”了
假设模型生成一段话总耗时 5 秒:
- 0-2 秒:输出正常文本(“正在帮您查询…”)
- 2-4 秒:输出 Tool Call JSON(被隐藏)
- 4-5 秒:输出最终文本(“查询结果是…”)
无论前端是否隐藏中间的 Tool Call,整个 SSE 连接的生命周期都是 5 秒。第 4 秒到达的文本仍然在第 4 秒准时到达并渲染。隐藏操作没有阻塞网络管道,也没有让 GPU 生成变慢。
真正发生的是:第 2-4 秒用户屏幕完全静止,产生了”卡住了”的主观感受。这是 UX 问题,不是性能问题。
为什么技术同学会说”变慢”?
三种常见误解:
- 实现方案做重了:以为需要等整个 Tool Call 全部吐完甚至工具执行完才能恢复渲染——这是同步阻塞式实现,确实会推迟后续文本。但正确的流式分流是不阻塞的。
- 主观感受的混淆:全部渲染时屏幕一直有 JSON 代码瀑布般刷出,用户觉得”好快”;隐藏后屏幕停了 2 秒,用户觉得”好慢”。这是感知速度(Perceived Speed)问题。
- 低效的过滤算法:如果每收到一个 Chunk 就把前面所有文本拼成大字符串再跑正则匹配,随着文本增长 CPU 确实会卡死——但这是代码写法的问题,不是”隐藏”这个需求本身的问题。
解决感知延迟的 UX 方案
在拦截到 isToolCalling = true 时,虽然不展示 JSON 源码,但在 UI 上触发一个平滑的加载动画:“Agent 正在检索数据…”。等文本恢复输出时关闭动画。物理速度不变,主观感受不再卡顿。
和技术团队对齐的话术
“我们的流式管道不需要任何同步阻塞等待。模型输出依然不间断进来,前端照常接收和解析,只是检测到 Tool Call 时不写入 UI State——相当于管道中间有一段’隐形’了。物理上的网络流和生成总时间完全没变。中间的视觉静止,我们用 Loading 动画填补。只要不用全局大字符串正则匹配、不阻塞 fetch 读流,性能就没有损耗。“
参考资料
- @microsoft/fetch-event-source — 支持 POST 请求和自定义 Header 的 SSE 库,替代原生 EventSource
- Vercel AI SDK Data Stream Protocol — 通过类型前缀区分文本、Tool Call、推理步骤等数据流
- MDN: Server-Sent Events — SSE 协议规范,帧分隔符为
\n\n - MDN: TextDecoder —
stream: true参数说明,处理跨 Chunk 多字节字符 - partial-json-parser — 专门用于解析半完成状态 JSON 字符串的库