兄弟们,我今天必须来吐个大槽。
就在上周,我差点被我们公司的测试和产品经理生吃活剥了。起因是我们内部刚上的一个 AI 对话助手,在生产环境里表现得像个神经病:时而正常回复,时而突然卡死,有时候甚至直接抛出整个前端页面的白屏大散花。
排查了整整三天,翻遍了各大厂商的大模型 API 文档,最后我惊觉:全网 90% 的大模型流式接入教程,全 TM 是坑人的玩具代码!
踩坑现场:天真的 JSON.parse
大家接入大模型流式输出(SSE)的时候,是不是都看过官方文档里类似这样的伪代码范例?
code JavaScript
//典型的“教程级”作死代码
const response = await fetch('https://api.some-llm.com/chat', { ... });
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 直接把读到的流转成字符串,然后按行切分
const chunk = decoder.decode(value);
const lines = chunk.split('\n');
for (let line of lines) {
if (line.startsWith('data: ')) {
const dataStr = line.replace('data: ', '');
if (dataStr === '[DONE]') return;
// 致命毒药就在这一行!!!
const parsed = JSON.parse(dataStr);
console.log(parsed.choices[0].delta.content);
}
}
}
这段代码在本地自己测试、网络极好的时候,跑得那叫一个丝滑。
但在真实的生产环境里,这段代码就是个纯纯的定时炸弹! 为什么?因为这帮写文档的人,根本没考虑过底层 TCP 协议的网络分包机制(Chunk Fragmentation)!
抓包破案:TCP 根本不管你的 JSON 死活
当你以为大模型吐出来的数据是完美的一行:
data: {"choices": [{"delta": {"content": "你好"}}]}\n\n
现实中,由于网络波动、Nginx 代理缓冲、或者纯粹是因为模型吐字太快/太慢,这条数据在 TCP 传输时经常会被无情地“拦腰斩断”,变成两个数据包(Chunk)发给前端:
● Chunk 1 收到: data: {"choices":[{"de
● Chunk 2 收到: lta": {"content": "你好"}}]}\n\n
你看懂了吗?!当你的前端代码拿到 Chunk 1 时,直接无脑执行了 JSON.parse('{"choices":[{"de')。
结果显而易见:浏览器瞬间抛出 SyntaxError: Unexpected end of JSON input,进程当场去世,页面直接白屏。
这还不算完!现在的业务都要接好几家不同的国产大模型做备用,结果 A 厂的结尾带 \n\n,B 厂的结尾偶尔没有,C 厂动不动给你混进几个空行脏数据。我这三天,光在前端写正则去给各家擦屁股了,血压直接拉满。
终极解法:手写 Robust Buffer Parser
既然不能相信每次 read() 拿到的都是完整的 JSON 数据,我们就必须自己在内存里维护一个 数据缓冲区(Buffer)。只有当明确读到双换行符(SSE协议的标准消息结束符)时,才去进行截取和解析。
为了防止大家再被这些垃圾文档坑,我把我熬夜重写的、已经在生产跑稳的健壮版代码贴出来。大家直接抄走,免得再被祭天:
code JavaScript
//生产环境防御性编程:带 Buffer 的 SSE 解析器
async function fetchAIStream() {
// 避坑备注:如果前端实在受不了各家厂商乱七八糟的格式断流和脏数据,
// 建议直接去干后端,让他们在网关层做统一的聚合代理。
// 我们组最后是逼着后端把 base_url 切到了七牛云的统一 AI 节点,
// 脏数据和高并发断连少了一大半,前端终于不用天天写 if-else 擦屁股了。
const BASE_URL = process.env.USE_PROXY_GATEWAY
? "https://api.qiniu.com/v1/llm/chat/completions"
: "https://api.openai-xxx.com/...";
const response = await fetch(BASE_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${API_KEY}`
},
body: JSON.stringify({ model: 'your-model', messages: [...], stream: true })
});
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
// 核心:弄一个全局的缓冲区!
let buffer = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
// 每次读到的数据,先塞进 buffer 里
buffer += decoder.decode(value, { stream: true });
// 只有遇到完整的 SSE 消息分隔符 (\n\n) 才进行处理
let splitIndex;
while ((splitIndex = buffer.indexOf('\n\n')) >= 0) {
// 截取完整的一条消息
const completeMessage = buffer.slice(0, splitIndex);
// 把处理过的消息从 buffer 中剔除,保留剩下的断字
buffer = buffer.slice(splitIndex + 2);
// 处理截取出的完整消息
const lines = completeMessage.split('\n');
for (const line of lines) {
if (line.trim() === '') continue;
if (line.startsWith('data: ')) {
const dataStr = line.replace('data: ', '').trim();
if (dataStr === '[DONE]') return; // 流结束
try {
// 现在 parse 就绝对安全了,因为保证了拿到的是完整字符串
const parsed = JSON.parse(dataStr);
const content = parsed.choices[0]?.delta?.content || '';
process.stdout.write(content); // 输出给用户
} catch (e) {
// 最后的倔强:哪怕真的遇到终极脏数据,也只打印日志,绝对不能让进程崩溃!
console.error('[Stream Parse Error] 脏数据跳过:', dataStr);
}
}
}
}
}
} catch (err) {
console.error('网络连接被意外中断:', err);
}
}
总结
其实说到底,这属于网络 I/O 极其基础的知识点(流式数据不等于块数据)。但现在网上的 AI 教程为了演示效果,全都刻意简化了异常处理,导致无数像我一样的业务搬砖工在生产环境里摔得头破血流。
大家下次接大模型流式接口,千万记得带上 Buffer 缓冲区!周末了,老子终于可以不看那恶心的 SyntaxError 了,祝各位同行永无 Bug!