引言:为什么我们需要 Chunk 合并?
在现代 Web 开发中,接入大语言模型(LLM)已经成为一种常态。为了让用户能够实时看到 AI 的思考过程,我们通常会采用流式传输(Streaming)技术,例如 Server-Sent Events (SSE)。在这种模式下,服务端不会等所有内容生成完毕再一次性返回,而是像打字机一样,将内容切割成无数个微小的数据块(Chunk)源源不断地推送到前端。
然而,这种\”碎片化\”的传输方式给前端渲染带来了不小的挑战。AI 返回的内容往往包含复杂的排版,比如 Markdown 代码块、表格等。如果前端不做任何处理,直接将这些零散的 Chunk 拼接渲染,用户就会看到代码块在生成过程中频繁闪烁,或者表格的边框被硬生生地拆开。为了解决这个问题,我们需要一种机制,能够在数据流动的过程中,智能地识别并\”拼凑\”出完整的结构化内容。这就是 Chunk 合并算法的核心价值所在。
通俗解释:看标签的快递分拣法
想象一下,你正在接收一个特殊的快递。但这位快递员非常调皮,他不是一次性把整个包裹交给你,而是把包裹拆成无数个小碎片,随机地扔给你。
更麻烦的是,这些碎片里混杂着两种不同的物品:“乐高积木”(代表代码块、表格等结构化内容)和普通的\”拼图碎片\”(代表纯文本)。而且,同一个乐高积木可能被分成了三次才扔给你。面对满地狼藉,你怎么知道哪几块属于同一个乐高积木呢?
最聪明的解决方案是\”看标签\”法,也就是边界检测。我们可以给每种东西定个规矩:比如乐高积木的盒子上明确写着\”开始\”和\”结束\”的标签。当你收到一块碎片时,只需要看一眼:
通过这种简单的状态流转,我们就能在混乱的碎片中,有条不紊地组装出完整的物品。
算法步骤详解
将上述通俗的逻辑转化为严谨的算法,主要分为以下四个核心步骤:
定义规则:首先需要明确不同内容类型的边界标记。例如,Markdown 代码块的开始标记和结束标记都是三个反引号(“`),而表格可能有特定的起始和结束符号。
维护状态:在内存中,我们需要始终维护一个\”当前正在组装的盒子\”,在算法中通常称为\”当前活跃片段(Active Chunk)\”。
处理新碎片(Chunk):每当接收到一个新的数据块时,执行以下判断逻辑:
- 检测开始标记:如果当前没有活跃片段,且新数据包含开始符号,则创建一个新的片段对象,并将其设为活跃状态。
- 检测结束标记:如果当前存在活跃片段,且新数据包含结束符号,则将数据追加进该片段,随后将其标记为\”完成\”,移入已完成列表,并清空活跃状态。
- 默认情况:如果没有检测到任何特殊标记,则将数据直接追加到\”当前活跃片段\”的内容中。
状态流转:整个算法的状态机非常清晰:初始状态(无活跃片段) -> 检测到开始标记 -> 活跃状态(接收中) -> 检测到结束标记 -> 完成状态(归档)。
代码实现
下面我们通过 Vue 3 结合 TypeScript 的 Setup 语法糖,来实现这个流式数据合并算法。
<script setup lang=\”ts\”>
import {
ref } from \’vue\’;
// 1. 类型定义
enum ChunkType {
TEXT = \’text\’,
CODE = \’code\’,
TABLE = \’table\’,
}
interface ContentChunk {
id: string;
type
