项目实训(6) - 流式输出(Streaming)的初次尝试
之前 AI 回复的时候,总得等上那么几秒钟,然后“啪”一下,一大段话全出来。用户体验其实不太好,尤其是在等待 AI 思考和组织语言的时候,界面上一点反馈都没有,感觉像卡住了一样。
后端同学说,他们那个 DeepSeek 内部接口是支持流式输出 (Streaming) 的,就像 ChatGPT 那样,AI 的回复可以一个字一个字地“打”出来。这个必须安排上!
流式输出是个啥?
简单说,就是服务器不是一次性把所有数据都准备好再发送,而是一边生成数据,一边像水流一样持续地把数据块发送给客户端。客户端也得配合,接收到一块就处理一块。
对于大语言模型来说,它生成 token (可以理解为单词或字的一部分) 就是一个接一个的。流式输出能让用户几乎实时地看到这些 token,大大提升交互感。
前端怎么接这个“水流”?
fetch API 本身就支持处理流式响应。response.body 是一个 ReadableStream 对象。我们可以通过它的 getReader() 方法来一块一块地读取数据。
代码开搞:
// AIChatWindow.vue - 修改 actuallySendToAI 函数
const actuallySendToAI = async (currentMessagesThread: Message[]) => {
isLoading.value = true; // 这个isLoading可能要换个含义,比如表示“AI正在思考第一句话”
let apiMessages = [];
// ... (构造 apiMessages 和 system prompt 的逻辑同前) ...
// 为流式输出准备一个临时的AI消息对象,先加到列表里,然后逐步填充它的text
const tempAiMessageId = Date.now().toString() + '-ai-streaming';
const streamingAiMessage: Message = {
id: tempAiMessageId,
text: '', // 初始为空
sender: 'ai',
timestamp: new Date(),
};
messages.value.push(streamingAiMessage);
scrollToBottom(); // 先滚一次,让空消息占位
try {
const response = await fetch(DEEPSEEK_API_URL, {
method: 'POST',
headers: { /* ... */ },
body: JSON.stringify({
model: MODEL_NAME,
messages: apiMessages,
stream: true, // 关键!告诉后端我要流式输出
}),
});
if (!response.ok) {
// ... (错误处理同前,但这里可能无法直接 response.json() 了) ...
// 对于流式错误,服务器可能在流的开始就返回错误状态,也可能在流中间出错
// 需要更健壮的错误处理
const errorText = await response.text(); // 尝试读取文本错误
throw new Error(errorText || `AI服务错误,状态码: ${response.status}`);
}
if (!response.body) {
throw new Error('Response body is null');
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8'); // 用来把 Uint8Array 转成字符串
let buffer = ''; // 用于处理不完整的JSON对象(如果API返回的是JSON Lines)
// eslint-disable-next-line no-constant-condition
while (true) {
const { done, value } = await reader.read();
if (done) {
console.log('Stream finished.');
break;
}
const chunk = decoder.decode(value, { stream: true }); // stream: true 很重要,处理多字节字符被分割的情况
buffer += chunk;
// DeepSeek流式输出的具体格式是什么?
// 可能是 Server-Sent Events (SSE) 格式,形如 "data: {...JSON...}\n\n"
// 也可能是 JSON Lines,每个JSON对象占一行,用 \n 分隔
// 假设是 JSON Lines,每个对象包含增量内容,例如:{ "delta": { "content": "你好" } }
// 或者直接是文本片段?后端同学说他们的DeepSeek接口封装后,流式吐的是JSON对象字符串,每个对象代表一个chunk,里面有增量内容。
// 这里需要根据实际的流式协议来解析 buffer
// 简陋的按行处理 (假设是 JSON Lines, 每个JSON在一行)
let lines = buffer.split('\n');
buffer = lines.pop() || ''; // 最后一行可能不完整,放回buffer
for (const line of lines) {
if (line.trim() === '' || line.startsWith('data: ')) { // 简单处理下可能的SSE格式或空行
// 如果是SSE,需要解析 "data: " 后面的JSON
let jsonData = line.trim();
if (jsonData.startsWith('data: ')) {
jsonData = jsonData.substring(5).trim();
}
if (jsonData === '[DONE]') { // 有些API用 [DONE] 标记结束
console.log('Stream marked as DONE');
// reader.cancel(); // 可以主动关闭reader
// break; // 这里break只跳出内层循环,外层while(true)的done会处理
return; // 直接结束处理
}
if(!jsonData) continue;
try {
const parsed = JSON.parse(jsonData);
// 假设DeepSeek返回的增量内容在 parsed.choices[0].delta.content
const deltaContent = parsed.choices?.[0]?.delta?.content;
if (deltaContent) {
const aiMsgIndex = messages.value.findIndex(m => m.id === tempAiMessageId);
if (aiMsgIndex !== -1) {
messages.value[aiMsgIndex].text += deltaContent;
scrollToBottom(); // 每次更新都滚动
}
}
if(parsed.choices?.[0]?.finish_reason === 'stop'){
console.log('Stream finished due to stop reason.');
return; // 也可以在这里结束
}
} catch (e) {
console.warn('Failed to parse JSON chunk:', line, e);
// 可能是buffer里包含了不完整的JSON,或者API格式变了
// 理论上,如果buffer.pop()正确,不应该有不完整的JSON行
// 但如果API发的不是严格的JSON Lines,就可能出问题
}
}
}
}
} catch (error: any) {
console.error('流式调用AI接口失败:', error);
const aiMsgIndex = messages.value.findIndex(m => m.id === tempAiMessageId);
if (aiMsgIndex !== -1) {
messages.value[aiMsgIndex].text += `\n(AI开小差了: ${error.message})`;
} else { // 如果连临时消息都没加上
const errorMessage: Message = { /* ... */ };
messages.value.push(errorMessage);
}
} finally {
isLoading.value = false; // 整个流处理完了,才算最终结束
// 确保滚动条在最底部
scrollToBottom();
}
};
调试过程中的痛点:
- 流协议对齐:后端同学说是“JSON对象字符串,每个对象一个chunk”,但具体长啥样?
{"delta": {"content": "..."}}?还是别的?文档没写那么细,只能抓包或者让他们给个例子。我这里先按类似OpenAI的流式格式猜的。如果猜错,解析逻辑就全废了。 (后来确认了,确实是类似OpenAI的SSE格式,每一行是data: {...},最后是data: [DONE]) - 不完整的UTF-8字符:
decoder.decode(value, { stream: true })这个{ stream: true }很重要,不然如果一个汉字(比如3字节UTF-8)被数据块从中间劈开,就会乱码。 buffer的处理:数据块不是按行来的,所以得自己拼buffer,再按行(或者其他分隔符)切分。lines.pop()那个操作是为了把可能不完整的最后一行放回buffer等待下一个数据块。- 错误处理和流结束:流中间出错了怎么办?
reader.read()的done为true是正常结束。但API也可能在流里发一个特殊的标记(比如data: [DONE])来表示内容结束。这些都得兼容。 - Vue 的响应式更新:我是直接修改
messages.value[aiMsgIndex].text += deltaContent。Vue3 的ref对数组元素的属性修改是能侦测到的,所以UI会更新。但如果性能敏感,过于频繁地修改text可能会有优化空间(比如用一个局部变量攒一小段再更新,或者用更底层的DOM操作,但不推荐)。目前看还好。
效果初体验: 折腾半天,当看到AI的回复真的像打字一样一个字一个字蹦出来的时候,那种感觉——爽!等待的焦虑感明显降低了。虽然代码复杂度上去了,但这用户体验的提升是实打实的。
感觉这个AI模拟面试项目越来越像个正经东西了。下一步,得想想怎么让AI的提问更有“面试”的智慧,而不只是个会打字的复读机。
#AI模拟面试 #流式输出 #Streaming #FetchAPI #ReadableStream #DeepSeek #Vue3 #用户体验提升 #前端技术细节