从 RAG 切块到流式输出:我把 AI 应用拆成了三条数据链路

摘要:做 AI 应用时,网页正文、模型 Token 和会议文字稿看起来是三种不同数据,但它们都要经历“获取 → 结构化 → 输出”的过程。本文结合一个 RAG 脚本、一个 Vue 流式页面和一个会议纪要 Skill,梳理数据如何进入系统、在哪里被切分,以及常见错误如何定位。文中代码为静态分析,运行未验证

很多初学者做 AI 项目时,会把重点放在“调用模型”。但真实链路里,模型调用只是最后一步:RAG 先要把知识变成可检索片段,流式页面要把网络字节变成逐步显示的文字,Skill 则要把开放式需求收束成稳定流程。

这篇文章的目标是建立一个统一视角:先搞清数据边界,再谈 AI 能力。

1. 三个看似不同的问题

材料中有三类任务:

  • 从网页加载文章,切分后做检索增强问答;
  • 在 Vue 页面中接收大模型的流式返回;
  • 将会议文字稿整理为结构化纪要。

它们共享同一个骨架:

原始输入
  ↓
读取或加载
  ↓
按业务边界切分、解析、归类
  ↓
生成用户可消费的结果

区别只在于“边界”不同:RAG 的边界是文本块,流式响应的边界是 SSE 消息行,会议纪要的边界是事实、议题和行动项。

2. RAG:先把网页变成能检索的知识块

RAG 脚本中,CheerioWebBaseLoader 负责加载网页,并通过 CSS 选择器筛选正文范围;RecursiveCharacterTextSplitter 负责把长文本切成多个 chunk。

const cheerioLoader = new CheerioWebBaseLoader(url, {
  selector: ".main-area p"
})

const documents = await cheerioLoader.load()

const textSplitter = new RecursiveCharacterTextSplitter({
  chunkSize: 400,
  chunkOverlap: 100,
  separators: ["。", "!", "?"]
})

const splitDocuments = await textSplitter.splitDocuments(documents)

这里有两个关键参数。

chunkSize:检索的最小知识单位

chunkSize: 400 表示单个文本块最大约 400 个字符。整篇文章直接向量化,检索会过于粗糙;拆成小块后,问题更容易命中某个具体段落。

chunkOverlap:避免切断上下文

chunkOverlap: 100 让相邻 chunk 共享部分文本。例如解释“Token 如何流到浏览器”的句子被切在中间时,后一块还能保留前文,降低语义断裂的风险。

完整链路可以记成:

网页 URL
  → Loader
  → Document[]
  → TextSplitter
  → chunks
  → Embeddings
  → VectorStore
  → Retriever
  → LLM

一个排错顺序:先看 documents,再看向量库

静态材料中,加载结果曾打印为 [],随后切分结果为 0 个 chunk。这时问题首先不在检索,而在更前面的“网页是否抓到、选择器是否匹配”。

实践中先打印:

console.log(documents.length)
console.log(documents[0]?.pageContent.slice(0, 200))

确认数据存在后,再进入切分、向量化和检索。这个习惯适用于所有数据管道:上游为空,下游再正确也没有意义。

3. 流式输出:一次读取不等于一条完整 JSON

Vue 页面使用 fetch 请求模型接口。当开启流式返回时,响应体是 ReadableStream:浏览器不断从网络拿到二进制块,而不是等完整答案生成后一次拿到。

const reader = response.body?.getReader()
const decoder = new TextDecoder()

const { value, done } = await reader?.read()
const text = decoder.decode(value)

材料中的笔记提到 Uint8Array。它是一个存放 0~255 无符号整数的数组;每个元素是一个字节。HTTP 传输在底层就是字节,TextDecoder 的任务是把这些字节按字符编码解释为文本。

SSE 的外层格式

流式接口通常按类似格式返回:

data: {"choices":[{"delta":{"content":"你"}}]}

data: {"choices":[{"delta":{"content":"好"}}]}

data: [DONE]

解析步骤是:

  1. 从流中读取 Uint8Array
  2. 解码为字符串;
  3. 找出 data: 开头的行;
  4. 去掉前缀;
  5. JSON.parse() 把 JSON 字符串变成对象;
  6. 读取 choices[0].delta.content 并追加到 content.value
const data = JSON.parse(incoming)
const delta = data.choices[0].delta.content
content.value += delta

Vue 的 ref 是响应式数据。content.value 改变后,模板中绑定 content 的位置会更新,于是用户看到文字逐步出现。

4. buffer:处理半包与粘包

最容易误解的一点是:reader.read() 的返回边界由网络决定,不由 JSON 或 SSE 决定。

可能发生两种情况:

半包:一次只收到半条 JSON
粘包:一次收到多条 SSE 消息

例如,服务端原本要发送:

data: {"choices":[{"delta":{"content":"你好"}}]}

但浏览器第一次可能只收到:

data: {"choices":[{"delta":{"content":"你

此时立刻 JSON.parse() 会失败。buffer 用来保存这段残缺内容,下一次读取后再拼接:

const chunkValue = buffer + decoder.decode(value)

更稳健的思路是先按换行拆分,再把最后一段未结束的文本放回 buffer:

const text = buffer + decoder.decode(value, { stream: true })
const lines = text.split("\n")
buffer = lines.pop() ?? ""

for (const line of lines) {
  if (!line.startsWith("data: ")) continue
  // 解析完整行
}

一句话总结:网络 chunk 是传输单位,SSE 行是协议单位,JSON 是数据单位;三者边界不保证重合。

5. Skill:让 Agent 面对重复任务时不“自由发挥”

会议纪要 Skill 给出了另一个很实用的思路:把重复任务写成明确规范。

该 Skill 定义了:

  • 触发条件:用户提到会议纪要、会议记录、行动项等;
  • 事实边界:不补全原文没有的信息;
  • 处理流程:通读、去噪、按主题归纳、提取行动项;
  • 输出约束:固定 Markdown 模板,负责人或截止时间缺失时留空;
  • 自检项:区分已决定、待确认和不同观点。

它不是为了让模型“写得更花”,而是为了让模型少漏、少猜、可复核

可以把一个好 Skill 理解成:

高频任务
  + 明确输入
  + 固定步骤
  + 输出模板
  + 自检规则
  = 可复用的 Agent 专项能力

6. 两个真实且容易忽视的问题

问题一:把工厂方法当构造函数

RAG 脚本中出现过:

new MemoryVectorStore.fromDocuments(...)

而错误信息是 fromDocuments is not a constructor。这类报错的含义很明确:fromDocuments 是方法,不是可用 new 创建实例的构造函数。应以当前安装依赖版本的 API 为准,通常是直接调用并等待结果。

const vectorStore = await MemoryVectorStore.fromDocuments(
  splitDocuments,
  embeddings
)

问题二:不能把真实 API Key 放进 Vite 前端

流式页面从 import.meta.env.VITE_DEEPSEEK_API_KEY 读取密钥,并直接发往第三方接口。以 VITE_ 开头的变量会进入浏览器端构建产物,因此用户可以在浏览器中获取它。

正确边界应是:

浏览器 → 自己的后端 → 大模型服务商

浏览器只传问题和流式请求参数;真正的 API Key 只保存在后端环境变量中。材料里出现的 402 Payment Required 说明请求已到达服务商,但账户额度、余额或套餐不可用;这与流式解析逻辑是两个层次的问题。

7. 可迁移的方法论

从这三份材料中可以提炼出一个面试和项目都能用的答题框架:

  1. 先问数据从哪里来:网页、HTTP 响应、文字稿还是数据库?
  2. 再问数据边界是什么:文档块、消息行、JSON、议题还是行动项?
  3. 再问结构化规则是什么:选择器、分隔符、协议前缀、模板字段?
  4. 最后问错误在哪一层:账户额度、网络传输、解析、依赖 API,还是业务数据为空?

AI 应用并不神秘。它的稳定性,往往取决于你是否认真处理了模型前后的数据:输入是否干净、边界是否正确、输出是否可验证。

结语

RAG 切块解决“知识如何被精准找到”,流式解析解决“模型结果如何实时到达页面”,Skill 解决“重复工作如何被稳定交给 Agent”。它们共同强调的不是某个框架 API,而是一种工程意识:在每一层都明确输入、边界、转换和输出。

Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐