AI 应用的稳定性,藏在模型调用前后的数据边界里
摘要:本文把三个实践放到同一张图里理解:网页内容如何进入 RAG、模型 Token 如何流进 Vue 页面、会议文字稿如何被固定流程整理成纪要。重点不是背 API,而是识别每层的数据边界:chunk、SSE 行、JSON 与行动项。文中代码为静态分析,运行未验证。
调用一次大模型接口并不难,难的是让输入可检索、输出能持续展示、重复任务可稳定复用。
我把这类 AI 应用看成三条数据处理链路:
text
知识:网页 → Document → chunk → 检索 → 模型回答
输出:字节流 → SSE 行 → JSON → delta → Vue 页面
流程:会议文本 → 事实 → 议题 → 行动项 → 会议纪要
它们底层的共性是:原始数据必须先被划出正确边界,才能被可靠处理。
RAG 的关键不是模型,而是 chunk
网页加载器会先从 URL 获取页面内容,再转换为 Document。之后文本分割器根据长度和分隔符将长文拆分。
js
const documents = await cheerioLoader.load()
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 400,
chunkOverlap: 100,
separators: ["。", "!", "?"]
})
const chunks = await splitter.splitDocuments(documents)
这三个配置分别在解决不同问题:
chunkSize:限制单次检索的文本粒度;separators:优先在中文自然语句边界处切分;chunkOverlap:让相邻片段共享上下文,降低语义被截断的概率。
后续才是 embedding、向量库和 retriever:
text
Document[] → chunks → vectorStore → retriever → context → LLM
一个很实用的调试习惯是:先确认加载到文档,再检查切块,最后才检查向量检索。
如果控制台已经出现空数组 [] 和 0 个 chunk,那问题通常在网页抓取、页面动态渲染或 CSS 选择器,而不在向量库。
流式返回中,网络块不是 JSON 边界
浏览器拿到的 response.body 是 ReadableStream,每次 reader.read() 返回的是一小段二进制数据。
js
const reader = response.body?.getReader()
const decoder = new TextDecoder()
const { value, done } = await reader?.read()
const text = decoder.decode(value)
这里的 value 通常是 Uint8Array。Uint8Array 可以先粗略理解为"字节数组";HTTP 在底层传字节,TextDecoder 再把字节解释成文本。
流式模型接口通常以 SSE 形式发数据:
text
data: {"choices":[{"delta":{"content":"你"}}]}
data: {"choices":[{"delta":{"content":"好"}}]}
data: [DONE]
前端要做的是:去掉 data: 前缀、解析 JSON、取出增量文本,并更新响应式状态。
js
const data = JSON.parse(incoming)
const delta = data.choices[0].delta.content
content.value += delta
页面会逐步刷新,不是因为模型"直接操作了 DOM",而是 content.value 变化触发了 Vue 的响应式渲染。
buffer 解决的到底是什么
最重要的边界错位是:
text
一次 reader.read() ≠ 一条 SSE 消息 ≠ 一个完整 JSON
服务端发送一条完整 JSON,但网络可能将它切成两半;也可能一次塞进多条消息。这就是常说的半包和粘包。
buffer 用来保存上一次未完成的数据,等下一次读取后再拼接:
js
const text = buffer + decoder.decode(value, { stream: true })
const lines = text.split("\n")
buffer = lines.pop() ?? ""
最后一段没有换行结束,说明它可能还不完整,因此不能直接解析,需要留到下一轮。这比"JSON.parse() 失败后再缓存"更清晰:它直接按照 SSE 的行边界处理残片。
把重复工作写成 Skill,不让模型猜
会议纪要 Skill 体现的是另一种"边界控制"。它明确规定:
- 哪些需求会触发该 Skill;
- 哪些内容属于可验证事实;
- 如何从文字稿中删除口头禅和重复内容;
- 如何区分决定、待确认事项和不同观点;
- 行动项缺少负责人或时间时必须留空,不能补猜。
最终输出使用固定结构:会议基本信息、会议目标、会议内容和行动项。
text
输入:会议文字稿
↓
事实边界与去噪
↓
按主题归纳讨论
↓
抽取负责人、时间、交付物
↓
固定模板会议纪要
这就是一个好 Skill 的价值:把高频任务中的"不能乱猜"和"不能漏字段"写成流程,而不是每次都依赖模型临场发挥。
两个容易踩坑的工程细节
1. fromDocuments 不能直接配合 new
材料中曾出现下面的调用并报出 is not a constructor:
js
new MemoryVectorStore.fromDocuments(...)
报错已经说明 fromDocuments 不是构造函数。它更像一个工厂方法,通常应直接调用并等待结果:
js
const vectorStore = await MemoryVectorStore.fromDocuments(
chunks,
embeddings
)
具体写法仍需以当前安装版本的官方 API 为准。
2. 不要把真实模型密钥放到 Vue 前端
如果 API Key 通过 VITE_ 前缀环境变量传给前端,它会出现在浏览器端构建产物中。用户能够查看请求头,也就可能拿到密钥。
安全的链路应该是:
text
Vue 浏览器 → 自己的后端 → 大模型服务商
前端只向自家后端发送问题;后端保存 API Key 并转发流式结果。材料中出现的 402 Payment Required 表明服务商收到了请求,但账户额度或支付状态不可用,这不是 SSE 解析本身的问题。
一个通用排错框架
当 AI 项目出现"没数据、没内容、报错"时,我会按这四层定位:
- 输入层:网页、请求体或文字稿是否真的拿到了?
- 边界层:选择器、chunk、SSE 换行、JSON 是否被正确识别?
- 转换层 :解码、
JSON.parse()、embedding、API 调用方式是否匹配? - 外部层:密钥、余额、模型名、权限和依赖版本是否可用?
先看上游数据,再看下游效果;先看 HTTP 状态码,再看解析代码。这个顺序能避免很多"在错误层排查"的时间浪费。
结尾
RAG、流式输出和 Agent Skill 看起来分属不同方向,但它们都在解决同一个工程问题:把不规则的原始输入,变成边界清晰、可处理、可验证的结果。
当你能说清 Document 为什么要切块、为什么流式数据需要 buffer、为什么会议纪要不能补猜字段时,就已经不只是在调用 AI API,而是在搭建一个更可靠的 AI 应用。