Skip to content
Unstructured Play
Go back

AI 流式输出处理:从 Buffer 原理到 Agent 工具调用隐藏

回路 / 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_reasonstop/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 做 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
    }
}

生产环境的边缘情况

  1. CRLF 兼容:某些反向代理(Nginx、Cloudflare)会把 \n\n 转换为 \r\n\r\n,解析器必须同时支持两种分隔符。
  2. TextDecoder 状态保持:中文 UTF-8 占 3 字节,可能被截断在两个 Chunk 交界处。必须给 TextDecoder 传入 { stream: true },让解码器保持内部状态,将不完整字节带入下一次解码。
  3. 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> 标签可能被切分为 <think>,在 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_databaseget_user_info 时产生的复杂 JSON 参数和原始返回值,如果直接流式渲染给用户,界面会瞬间变得混乱,甚至泄露敏感数据。

架构设计:流式分流与状态机拦截

在 Buffer 解析层之后、UI 渲染层之前建立状态过滤器,实时识别三种状态:

  1. TEXT 状态:AI 输出正常文本 → 放行渲染
  2. TOOL_ING 状态:AI 输出 Tool Call 的 JSON 碎片 → 拦截丢弃
  3. 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)是给大模型看的上下文,天然不应进入用户的流式输出通道。正确链路:

  1. 前端通过 SSE 接收模型流
  2. 发现 tool_calls → 在前端隐藏,流进入等待状态
  3. 后端默默执行工具,拿到结果
  4. 后端将结果塞入 messages 历史,重新向 LLM 发起请求
  5. LLM 基于工具结果生成最终答复,建立新的 SSE 流,前端恢复渲染

框架级支持

如果使用 Vercel AI SDK,其 Data Stream Protocol 通过类型前缀区分数据流:纯文本使用 0: 前缀,Tool Call 使用 9: 前缀。在 useChatonChunk 回调中,可以直接按前缀类型决定是否渲染,无需手写状态机。


Q: 隐藏 Tool Call 内容,会让系统”变慢”吗?

这个问题的答案需要先厘清一个定义——“慢”指的是模型的物理生成时间,还是用户看到内容的视觉延迟?

结论:不会变慢,只是某段时间”静止”了

假设模型生成一段话总耗时 5 秒:

无论前端是否隐藏中间的 Tool Call,整个 SSE 连接的生命周期都是 5 秒。第 4 秒到达的文本仍然在第 4 秒准时到达并渲染。隐藏操作没有阻塞网络管道,也没有让 GPU 生成变慢。

真正发生的是:第 2-4 秒用户屏幕完全静止,产生了”卡住了”的主观感受。这是 UX 问题,不是性能问题。

为什么技术同学会说”变慢”?

三种常见误解:

  1. 实现方案做重了:以为需要等整个 Tool Call 全部吐完甚至工具执行完才能恢复渲染——这是同步阻塞式实现,确实会推迟后续文本。但正确的流式分流是不阻塞的。
  2. 主观感受的混淆:全部渲染时屏幕一直有 JSON 代码瀑布般刷出,用户觉得”好快”;隐藏后屏幕停了 2 秒,用户觉得”好慢”。这是感知速度(Perceived Speed)问题。
  3. 低效的过滤算法:如果每收到一个 Chunk 就把前面所有文本拼成大字符串再跑正则匹配,随着文本增长 CPU 确实会卡死——但这是代码写法的问题,不是”隐藏”这个需求本身的问题。

解决感知延迟的 UX 方案

在拦截到 isToolCalling = true 时,虽然不展示 JSON 源码,但在 UI 上触发一个平滑的加载动画:“Agent 正在检索数据…”。等文本恢复输出时关闭动画。物理速度不变,主观感受不再卡顿。

和技术团队对齐的话术

“我们的流式管道不需要任何同步阻塞等待。模型输出依然不间断进来,前端照常接收和解析,只是检测到 Tool Call 时不写入 UI State——相当于管道中间有一段’隐形’了。物理上的网络流和生成总时间完全没变。中间的视觉静止,我们用 Loading 动画填补。只要不用全局大字符串正则匹配、不阻塞 fetch 读流,性能就没有损耗。“


参考资料


Share this post on:

Previous Post
Managed Agents 架构拆解:Harness、动态工具路由与 Prompt Cache 的工程博弈
Next Post
Agent 首字延迟(TTFT)优化:从原理到落地