千问3.5-27B一文详解:流式/chat_stream接口返回格式、前端渲染与错误处理
本文介绍了如何在星图GPU平台上自动化部署千问3.5-27B镜像,并详细解析了其流式对话接口(/chat_stream)的核心应用。通过该平台,开发者可快速搭建具备实时交互能力的AI对话应用,实现答案逐字生成的前端渲染效果,显著提升用户体验。
千问3.5-27B一文详解:流式/chat_stream接口返回格式、前端渲染与错误处理
1. 引言:从对话到流式体验
如果你用过传统的AI对话接口,大概经历过这样的等待:输入问题,点击发送,然后盯着屏幕上的“加载中”转圈圈,等上好几秒甚至十几秒,答案才“砰”的一下全部显示出来。这种体验,说实话,有点像是在等一壶水烧开——你知道它最终会开,但过程有点煎熬。
现在,想象一下另一种场景:你问了一个问题,模型开始像真人聊天一样,一个字一个字、一句话一句话地“说”给你听。你可以看到它思考的过程,看到答案是如何逐步构建的。这就是流式对话的魅力。
今天要聊的千问3.5-27B(Qwen3.5-27B),就提供了这样的流式对话能力。它不仅仅是一个能理解图片和文字的聪明模型,更通过 /chat_stream 接口,把“等待答案”变成了“观看答案生成”。对于开发者来说,这意味着我们需要理解一套新的数据格式,掌握前端如何实时渲染这些数据,以及当出现问题时该如何优雅地处理。
这篇文章,我就带你彻底搞懂这三件事:流式接口返回的数据长什么样、前端怎么把这些数据变成流畅的对话效果、以及遇到错误时该怎么应对。无论你是前端工程师、后端开发者,还是全栈选手,这些知识都能让你更好地驾驭这个强大的模型。
2. 流式接口返回格式深度解析
要玩转流式对话,第一步就是看懂服务器“吐”出来的数据是什么样子。这不像传统的JSON接口,一次返回一个完整的对象。流式接口返回的是一串“数据流”,我们需要像拆礼物一样,一层层拆开看。
2.1 基础响应格式:SSE协议
千问3.5-27B的 /chat_stream 接口基于 Server-Sent Events (SSE) 协议。简单来说,服务器会保持连接打开,然后不断地、一小段一小段地发送数据给客户端。每段数据都遵循特定的格式。
一个最基础的流式响应看起来是这样的:
data: {"token": "你", "text": "你", "finished": false}
data: {"token": "好", "text": "你好", "finished": false}
data: {"token": ",", "text": "你好,", "finished": false}
data: {"token": "我", "text": "你好,我", "finished": false}
data: {"token": "是", "text": "你好,我是", "finished": false}
data: {"token": "千", "text": "你好,我是千", "finished": false}
data: {"token": "问", "text": "你好,我是千问", "finished": true}
看到规律了吗?每一行都以 data: 开头,后面跟着一个JSON对象。这个JSON对象通常包含几个关键字段:
token: 当前这次“推送”新生成的单个字或词(token)。比如第一次推送了“你”,第二次推送了“好”。text: 到当前为止,累积生成的全部文本。这个字段非常有用,前端可以直接用它来更新显示,而不需要自己拼接历史token。finished: 一个布尔值,表示生成是否结束。false表示还在生成中,true表示这是最后一个数据块,生成完毕了。
2.2 完整数据结构与字段含义
在实际调用中,返回的数据会比上面的例子更丰富。让我们通过一个实际的API调用来看看。
假设我们发送这样一个请求:
curl -X POST http://127.0.0.1:7860/chat_stream \
-H "Content-Type: application/json" \
-d '{
"prompt": "请用中文介绍一下你自己。",
"max_new_tokens": 50,
"temperature": 0.7
}'
服务器返回的数据流可能包含如下格式的数据块(这里我做了简化,只展示关键字段):
// 第一个数据块
{
"token": "你",
"text": "你",
"finished": false,
"generated_tokens": 1,
"total_tokens": 25
}
// 中间某个数据块
{
"token": "好",
"text": "你好,我是千问",
"finished": false,
"generated_tokens": 5,
"total_tokens": 25
}
// 最后一个数据块
{
"token": "。",
"text": "你好,我是千问,一个由阿里云开发的大语言模型。",
"finished": true,
"generated_tokens": 12,
"total_tokens": 25,
"finish_reason": "length" // 或 "stop", "eos_token"
}
各字段详细解释:
-
token与texttoken是本次增量内容,前端可以用它来做打字机效果(逐个字符出现)。text是累积的完整文本,前端可以直接用它替换整个回答区域,实现更流畅的更新。两种方式都可以,看你的需求。
-
finished- 这是最重要的标志之一。前端需要监听这个字段,当它为
true时,就知道生成结束了,可以关闭连接、更新界面状态(比如把“生成中”改成“已完成”)。
- 这是最重要的标志之一。前端需要监听这个字段,当它为
-
generated_tokens与total_tokensgenerated_tokens: 到目前为止已经生成了多少个token。total_tokens: 本次请求上下文的总token数(包括你的提问和模型的回答)。- 这两个字段可以用来做进度指示。比如你可以显示一个进度条:
(generated_tokens / total_tokens) * 100。
-
finish_reason(仅在结束时出现)length: 达到了max_new_tokens设置的最大生成长度。stop: 遇到了停止词(比如用户设置了特定的停止序列)。eos_token: 生成了结束符(End-of-Sequence token)。- 知道结束原因有助于前端做不同的处理。比如如果是
length,可以提示用户“回答可能被截断”。
2.3 错误响应格式
流式接口也会出错,但错误信息的格式和成功时不一样。错误通常不会以 data: 开头,而是直接返回一个JSON对象(或者HTTP错误状态)。
连接级错误(比如服务器挂了):
HTTP/1.1 500 Internal Server Error
Content-Type: application/json
{
"detail": "Internal server error"
}
请求级错误(比如参数错了):
HTTP/1.1 422 Unprocessable Entity
Content-Type: application/json
{
"detail": [
{
"loc": ["body", "prompt"],
"msg": "field required",
"type": "value_error.missing"
}
]
}
流式过程中的错误(比较少见,但如果发生了): 服务器可能会发送一个特殊的 data 块,或者直接关闭连接。有些实现会在错误时发送:
data: {"error": "生成过程中发生错误", "finished": true}
理解这些格式,是处理好流式对话的基础。接下来,我们看看前端怎么把这些数据变成用户看到的流畅对话。
3. 前端实时渲染实战指南
知道了数据格式,现在我们来解决最有趣的部分:怎么让这些数据在网页上“活”起来,变成用户看到的流畅对话效果。这里我会提供几种方案,从简单到复杂,你可以根据项目需求选择。
3.1 基础方案:使用EventSource
对于简单的需求,浏览器原生的 EventSource API 是最直接的选择。它专门为SSE协议设计,使用起来非常简单。
// 创建一个EventSource连接
const eventSource = new EventSource('http://127.0.0.1:7860/chat_stream?prompt=你好');
// 监听消息事件
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
// 更新页面上的回答区域
const answerElement = document.getElementById('answer');
// 方法1:直接使用完整的text(更流畅)
answerElement.textContent = data.text;
// 方法2:逐个添加token(打字机效果)
// answerElement.textContent += data.token;
// 如果生成结束,关闭连接
if (data.finished) {
eventSource.close();
console.log('生成完成');
}
} catch (error) {
console.error('解析数据出错:', error);
}
};
// 监听错误事件
eventSource.onerror = (error) => {
console.error('连接出错:', error);
eventSource.close();
// 更新界面显示错误
document.getElementById('answer').textContent = '抱歉,生成过程中出现错误,请重试。';
};
// 用户取消生成时
function cancelGeneration() {
eventSource.close();
document.getElementById('answer').textContent += ' (已取消)';
}
EventSource的优缺点:
- 优点:简单易用,浏览器原生支持,自动重连。
- 缺点:只能发送GET请求,不能设置自定义Header,错误处理能力有限。
3.2 进阶方案:使用Fetch API + 流式解析
对于需要更多控制权的场景(比如要设置认证Header、发送POST请求),fetch API 是更好的选择。虽然代码复杂一些,但灵活性高得多。
async function streamChat(prompt) {
const controller = new AbortController();
const signal = controller.signal;
try {
const response = await fetch('http://127.0.0.1:7860/chat_stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
prompt: prompt,
max_new_tokens: 256,
temperature: 0.7,
}),
signal: signal, // 用于取消请求
});
if (!response.ok) {
throw new Error(`HTTP错误: ${response.status}`);
}
// 获取可读流
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
// 创建回答元素
const answerElement = document.getElementById('answer');
answerElement.textContent = '';
// 读取流数据
while (true) {
const { done, value } = await reader.read();
if (done) {
console.log('流读取完成');
break;
}
// 解码数据并添加到缓冲区
buffer += decoder.decode(value, { stream: true });
// 按行分割处理(SSE数据以\n\n分割)
const lines = buffer.split('\n\n');
// 最后一行可能不完整,保留在缓冲区
buffer = lines.pop() || '';
// 处理每一行完整的数据
for (const line of lines) {
if (line.startsWith('data: ')) {
const dataStr = line.slice(6); // 去掉"data: "
// 跳过空行和注释行
if (dataStr === '' || dataStr.startsWith(':')) {
continue;
}
try {
const data = JSON.parse(dataStr);
// 更新界面 - 使用累积文本实现流畅更新
answerElement.textContent = data.text;
// 可选:滚动到最新内容
answerElement.scrollTop = answerElement.scrollHeight;
// 如果生成结束,跳出循环
if (data.finished) {
console.log('生成完成,原因:', data.finish_reason);
controller.abort(); // 主动取消读取
return;
}
} catch (error) {
console.warn('解析JSON失败:', error, '原始数据:', dataStr);
}
}
}
}
} catch (error) {
if (error.name === 'AbortError') {
console.log('请求被取消');
} else {
console.error('请求失败:', error);
document.getElementById('answer').textContent = `请求失败: ${error.message}`;
}
}
}
// 使用示例
document.getElementById('send-btn').addEventListener('click', () => {
const prompt = document.getElementById('input').value;
streamChat(prompt);
});
// 取消生成
document.getElementById('cancel-btn').addEventListener('click', () => {
// 这里需要保存controller的引用,实际项目中可以用状态管理
console.log('用户取消了生成');
});
这个方案的关键点:
- 使用AbortController:这让用户可以中途取消生成,避免浪费资源。
- 流式解析:正确处理可能被分割的数据块,避免解析错误。
- 错误处理:区分网络错误、解析错误和用户取消。
- 性能优化:使用
text字段直接更新,而不是拼接token,减少DOM操作。
3.3 高级优化:打字机效果与性能平衡
如果你想要更炫酷的打字机效果(逐个字符出现),同时又不想牺牲性能,这里有个平衡方案:
class StreamRenderer {
constructor(elementId) {
this.element = document.getElementById(elementId);
this.content = '';
this.isTyping = false;
this.typingSpeed = 30; // 毫秒/字符
this.buffer = '';
}
// 更新内容(智能选择更新方式)
update(newText) {
// 如果内容完全变了(比如重新生成),直接替换
if (!this.content || newText.length < this.content.length) {
this.content = newText;
this.element.textContent = newText;
return;
}
// 如果只是追加内容,使用打字机效果
const newContent = newText.slice(this.content.length);
if (newContent) {
this.buffer += newContent;
this.content = newText;
if (!this.isTyping) {
this.startTyping();
}
}
}
// 开始打字机效果
startTyping() {
this.isTyping = true;
const typeNextChar = () => {
if (this.buffer.length === 0) {
this.isTyping = false;
return;
}
const char = this.buffer[0];
this.buffer = this.buffer.slice(1);
this.element.textContent += char;
// 滚动到底部
this.element.scrollTop = this.element.scrollHeight;
// 继续下一个字符
setTimeout(typeNextChar, this.typingSpeed);
};
typeNextChar();
}
// 直接完成(用户跳过动画)
finish() {
if (this.buffer) {
this.element.textContent += this.buffer;
this.buffer = '';
this.isTyping = false;
}
}
// 清空
clear() {
this.content = '';
this.buffer = '';
this.element.textContent = '';
this.isTyping = false;
}
}
// 使用示例
const renderer = new StreamRenderer('answer');
// 在streamChat函数中这样使用:
// renderer.update(data.text);
// 用户想跳过动画时
document.getElementById('skip-animation').addEventListener('click', () => {
renderer.finish();
});
这个方案聪明的地方在于:
- 智能判断:当内容被截断或重写时,直接替换(避免奇怪的打字效果)。
- 缓冲机制:把新增内容放到缓冲区,慢慢显示,不影响数据接收。
- 用户控制:允许用户跳过动画,直接看完整内容。
3.4 完整的前端组件示例
最后,我给你一个完整的、可以直接用的React组件示例(如果你用Vue或其它框架,思路也类似):
import React, { useState, useRef, useEffect } from 'react';
const StreamChatComponent = () => {
const [input, setInput] = useState('');
const [messages, setMessages] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const abortControllerRef = useRef(null);
// 发送消息
const sendMessage = async () => {
if (!input.trim() || isLoading) return;
// 添加用户消息
const userMessage = { role: 'user', content: input };
const assistantMessage = { role: 'assistant', content: '', isStreaming: true };
setMessages(prev => [...prev, userMessage, assistantMessage]);
setInput('');
setIsLoading(true);
setError(null);
// 创建可取消的请求
abortControllerRef.current = new AbortController();
try {
const response = await fetch('http://127.0.0.1:7860/chat_stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: input,
max_new_tokens: 512,
temperature: 0.7,
}),
signal: abortControllerRef.current.signal,
});
if (!response.ok) {
throw new Error(`请求失败: ${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let fullText = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const dataStr = line.slice(6);
if (!dataStr || dataStr.startsWith(':')) continue;
try {
const data = JSON.parse(dataStr);
fullText = data.text;
// 更新最后一条消息的内容
setMessages(prev => {
const newMessages = [...prev];
const lastMsg = newMessages[newMessages.length - 1];
if (lastMsg.role === 'assistant') {
lastMsg.content = fullText;
}
return newMessages;
});
if (data.finished) {
setIsLoading(false);
// 标记流式结束
setMessages(prev => {
const newMessages = [...prev];
const lastMsg = newMessages[newMessages.length - 1];
lastMsg.isStreaming = false;
return newMessages;
});
return;
}
} catch (err) {
console.warn('解析错误:', err);
}
}
}
}
} catch (err) {
if (err.name === 'AbortError') {
console.log('请求被取消');
setMessages(prev => {
const newMessages = [...prev];
const lastMsg = newMessages[newMessages.length - 1];
lastMsg.content += ' (已取消)';
lastMsg.isStreaming = false;
return newMessages;
});
} else {
console.error('请求失败:', err);
setError(err.message);
setMessages(prev => {
const newMessages = [...prev];
const lastMsg = newMessages[newMessages.length - 1];
lastMsg.content = `抱歉,出错了: ${err.message}`;
lastMsg.isStreaming = false;
return newMessages;
});
}
} finally {
setIsLoading(false);
}
};
// 取消生成
const cancelGeneration = () => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
};
// 清空对话
const clearChat = () => {
setMessages([]);
setError(null);
if (abortControllerRef.current) {
abortControllerRef.current.abort();
}
};
// 自动滚动到底部
const messagesEndRef = useRef(null);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
return (
<div className="chat-container">
<div className="messages">
{messages.map((msg, idx) => (
<div key={idx} className={`message ${msg.role}`}>
<div className="role">{msg.role === 'user' ? '你' : '千问'}</div>
<div className="content">
{msg.content}
{msg.isStreaming && <span className="cursor">▌</span>}
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
{error && <div className="error">错误: {error}</div>}
<div className="input-area">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}}
placeholder="输入你的问题..."
disabled={isLoading}
rows="3"
/>
<div className="buttons">
<button
onClick={sendMessage}
disabled={isLoading || !input.trim()}
>
{isLoading ? '生成中...' : '发送'}
</button>
{isLoading && (
<button onClick={cancelGeneration} className="cancel-btn">
取消
</button>
)}
<button onClick={clearChat} className="clear-btn">
清空
</button>
</div>
</div>
</div>
);
};
// 简单的CSS样式
const styles = `
.chat-container {
max-width: 800px;
margin: 0 auto;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
.messages {
height: 500px;
overflow-y: auto;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
background: #fafafa;
}
.message {
margin-bottom: 16px;
}
.message.user {
text-align: right;
}
.message .role {
font-size: 12px;
color: #666;
margin-bottom: 4px;
}
.message .content {
padding: 8px 12px;
border-radius: 12px;
display: inline-block;
max-width: 70%;
}
.message.user .content {
background: #007bff;
color: white;
text-align: left;
}
.message.assistant .content {
background: white;
border: 1px solid #e0e0e0;
}
.cursor {
animation: blink 1s infinite;
}
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
.input-area textarea {
width: 100%;
padding: 12px;
border: 1px solid #ddd;
border-radius: 8px;
font-size: 14px;
resize: vertical;
}
.buttons {
margin-top: 12px;
display: flex;
gap: 8px;
}
button {
padding: 8px 16px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button:not(:disabled):hover {
opacity: 0.9;
}
.cancel-btn {
background: #dc3545;
color: white;
}
.clear-btn {
background: #6c757d;
color: white;
}
.error {
color: #dc3545;
padding: 8px;
background: #f8d7da;
border-radius: 4px;
margin-bottom: 12px;
}
`;
// 在组件中使用样式
const StreamChatWithStyle = () => (
<>
<style>{styles}</style>
<StreamChatComponent />
</>
);
export default StreamChatWithStyle;
这个组件提供了完整的功能:
- 流式对话界面
- 消息历史记录
- 加载状态显示
- 取消生成功能
- 错误处理
- 自动滚动
- 基本的样式
你可以直接复制使用,或者根据自己的需求修改。
4. 错误处理与边界情况
流式对话看起来很美好,但在实际使用中会遇到各种问题。好的错误处理能让用户体验提升一个档次。下面我总结了几种常见的错误场景和应对策略。
4.1 网络错误处理
网络是最不稳定的因素。用户可能在电梯里、地铁上,或者WiFi信号不好。我们需要优雅地处理这些情况。
// 增强版的网络错误处理
async function robustStreamRequest(url, options, retries = 3) {
let lastError;
for (let i = 0; i < retries; i++) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000); // 30秒超时
const response = await fetch(url, {
...options,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
// 服务器返回错误状态码
if (response.status >= 500) {
throw new Error(`服务器错误 (${response.status}),请稍后重试`);
} else if (response.status === 429) {
throw new Error('请求过于频繁,请稍后再试');
} else {
throw new Error(`请求失败: ${response.status}`);
}
}
return response;
} catch (error) {
lastError = error;
// 如果是超时或网络错误,等待后重试
if (error.name === 'AbortError' || error.message.includes('Network')) {
if (i < retries - 1) {
console.log(`第${i + 1}次重试...`);
// 指数退避:1秒, 2秒, 4秒...
await new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));
continue;
}
} else {
// 其他错误不重试
throw error;
}
}
}
throw lastError;
}
// 使用示例
try {
const response = await robustStreamRequest(
'http://127.0.0.1:7860/chat_stream',
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: '你好' }),
},
3 // 重试3次
);
// 处理响应...
} catch (error) {
// 给用户友好的错误提示
let userMessage = '抱歉,生成过程中出现错误';
if (error.message.includes('超时')) {
userMessage = '请求超时,可能是网络较慢,请检查网络后重试';
} else if (error.message.includes('频繁')) {
userMessage = '请求过于频繁,请稍等一分钟再试';
} else if (error.message.includes('服务器错误')) {
userMessage = '服务器暂时不可用,请稍后再试';
}
showErrorToUser(userMessage);
}
4.2 流式数据解析错误
有时候服务器返回的数据格式可能有问题,或者传输过程中出现了乱码。我们需要健壮的解析逻辑。
function parseSSEData(chunk) {
const events = [];
let buffer = '';
for (let i = 0; i < chunk.length; i++) {
buffer += chunk[i];
// 检查是否是一个完整的事件(以\n\n结尾)
if (buffer.endsWith('\n\n')) {
const lines = buffer.trim().split('\n');
let event = { type: 'message', data: '' };
for (const line of lines) {
if (line.startsWith('event:')) {
event.type = line.slice(6).trim();
} else if (line.startsWith('data:')) {
event.data += line.slice(5).trim();
} else if (line.startsWith('id:')) {
event.id = line.slice(3).trim();
} else if (line.startsWith('retry:')) {
event.retry = parseInt(line.slice(6).trim(), 10);
}
// 忽略空行和注释
}
if (event.data) {
events.push(event);
}
buffer = '';
}
}
return { events, remaining: buffer };
}
// 在流处理循环中使用
let buffer = '';
const decoder = new TextDecoder();
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const { events, remaining } = parseSSEData(buffer);
buffer = remaining;
for (const event of events) {
if (event.type === 'message') {
try {
const data = JSON.parse(event.data);
// 处理数据...
} catch (error) {
console.warn('解析JSON失败,原始数据:', event.data);
// 尝试修复常见的JSON格式问题
const fixedData = tryFixJSON(event.data);
if (fixedData) {
// 使用修复后的数据
processData(fixedData);
} else {
// 无法修复,跳过或记录错误
logError('无法解析的数据块', event.data);
}
}
} else if (event.type === 'error') {
// 服务器主动发送的错误事件
handleServerError(event.data);
}
}
}
// 尝试修复常见的JSON格式问题
function tryFixJSON(str) {
// 1. 尝试直接解析
try {
return JSON.parse(str);
} catch (e) {
// 继续尝试修复
}
// 2. 检查是否有多余的逗号
try {
const fixed = str.replace(/,\s*}/g, '}').replace(/,\s*]/g, ']');
return JSON.parse(fixed);
} catch (e) {
// 继续尝试
}
// 3. 检查是否缺少引号
try {
const fixed = str.replace(/([{,]\s*)(\w+)(\s*:)/g, '$1"$2"$3');
return JSON.parse(fixed);
} catch (e) {
// 无法修复
return null;
}
}
4.3 用户交互与状态管理
用户可能在生成过程中做各种操作:取消、发送新消息、关闭页面等等。我们需要妥善处理这些情况。
class ChatManager {
constructor() {
this.currentRequest = null;
this.isGenerating = false;
this.messageQueue = [];
this.history = [];
}
// 发送消息(带队列管理)
async sendMessage(prompt) {
// 如果正在生成,取消当前生成
if (this.isGenerating && this.currentRequest) {
this.currentRequest.abort();
this.isGenerating = false;
}
// 添加到历史
this.history.push({ role: 'user', content: prompt });
// 显示"思考中"状态
this.showThinking();
try {
this.isGenerating = true;
const controller = new AbortController();
this.currentRequest = controller;
const response = await fetch('/chat_stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt: prompt,
history: this.history.slice(-5), // 只发送最近5条历史
max_new_tokens: 512,
}),
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`请求失败: ${response.status}`);
}
// 处理流式响应
const answer = await this.processStream(response, controller);
// 添加到历史
this.history.push({ role: 'assistant', content: answer });
// 限制历史长度,避免token过多
if (this.history.length > 20) {
this.history = this.history.slice(-20);
}
return answer;
} catch (error) {
if (error.name === 'AbortError') {
console.log('用户取消了生成');
return '[生成已取消]';
} else {
console.error('生成失败:', error);
throw error;
}
} finally {
this.isGenerating = false;
this.currentRequest = null;
}
}
// 处理流式响应
async processStream(response, controller) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let fullText = '';
while (true) {
// 检查是否被取消
if (controller.signal.aborted) {
reader.cancel();
throw new DOMException('Aborted', 'AbortError');
}
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('data: ')) {
const dataStr = line.slice(6);
if (!dataStr || dataStr.startsWith(':')) continue;
try {
const data = JSON.parse(dataStr);
fullText = data.text;
// 更新UI
this.updateAnswer(fullText);
if (data.finished) {
return fullText;
}
} catch (error) {
console.warn('解析数据失败:', error);
}
}
}
}
return fullText;
}
// 取消当前生成
cancel() {
if (this.currentRequest) {
this.currentRequest.abort();
this.isGenerating = false;
this.currentRequest = null;
}
}
// 清空历史
clearHistory() {
this.history = [];
this.cancel();
}
// 导出历史(用于保存会话)
exportHistory() {
return JSON.stringify(this.history, null, 2);
}
// 导入历史(用于恢复会话)
importHistory(historyJson) {
try {
const history = JSON.parse(historyJson);
if (Array.isArray(history)) {
this.history = history;
return true;
}
} catch (error) {
console.error('导入历史失败:', error);
}
return false;
}
// UI更新方法(由具体UI框架实现)
showThinking() {
// 在界面上显示"思考中..."状态
}
updateAnswer(text) {
// 更新界面上的回答内容
}
}
// 使用示例
const chatManager = new ChatManager();
// 发送消息
document.getElementById('send-btn').addEventListener('click', async () => {
const input = document.getElementById('input').value;
if (!input.trim()) return;
try {
const answer = await chatManager.sendMessage(input);
console.log('生成完成:', answer);
} catch (error) {
console.error('生成失败:', error);
alert('生成失败: ' + error.message);
}
});
// 取消生成
document.getElementById('cancel-btn').addEventListener('click', () => {
chatManager.cancel();
});
// 页面关闭前提示
window.addEventListener('beforeunload', (event) => {
if (chatManager.isGenerating) {
event.preventDefault();
event.returnValue = '正在生成内容,确定要离开吗?';
return event.returnValue;
}
});
// 保存会话到本地存储
function saveSession() {
localStorage.setItem('chat_history', chatManager.exportHistory());
}
// 恢复会话
function loadSession() {
const history = localStorage.getItem('chat_history');
if (history) {
chatManager.importHistory(history);
}
}
4.4 性能监控与优化
最后,对于生产环境,我们还需要监控性能,确保用户体验。
// 性能监控工具
class StreamPerformanceMonitor {
constructor() {
this.metrics = {
totalRequests: 0,
successfulRequests: 0,
failedRequests: 0,
totalTokens: 0,
totalTime: 0,
avgTokensPerSecond: 0,
};
this.currentRequest = null;
}
startRequest(prompt) {
this.currentRequest = {
startTime: Date.now(),
promptLength: prompt.length,
firstTokenTime: null,
tokenCount: 0,
finished: false,
};
this.metrics.totalRequests++;
}
recordFirstToken() {
if (this.currentRequest && !this.currentRequest.firstTokenTime) {
this.currentRequest.firstTokenTime = Date.now();
this.currentRequest.timeToFirstToken =
this.currentRequest.firstTokenTime - this.currentRequest.startTime;
}
}
recordToken(count = 1) {
if (this.currentRequest) {
this.currentRequest.tokenCount += count;
this.metrics.totalTokens += count;
}
}
endRequest(success = true) {
if (!this.currentRequest) return;
const endTime = Date.now();
this.currentRequest.endTime = endTime;
this.currentRequest.duration = endTime - this.currentRequest.startTime;
this.currentRequest.finished = true;
if (success) {
this.metrics.successfulRequests++;
// 计算本次请求的token/s
if (this.currentRequest.tokenCount > 0 && this.currentRequest.duration > 0) {
const tokensPerSecond = this.currentRequest.tokenCount / (this.currentRequest.duration / 1000);
this.currentRequest.tokensPerSecond = tokensPerSecond;
// 更新平均速度(指数移动平均)
const alpha = 0.1; // 平滑因子
this.metrics.avgTokensPerSecond =
alpha * tokensPerSecond + (1 - alpha) * this.metrics.avgTokensPerSecond;
}
} else {
this.metrics.failedRequests++;
}
this.metrics.totalTime += this.currentRequest.duration;
// 记录到日志或发送到监控系统
this.logRequest(this.currentRequest);
this.currentRequest = null;
}
logRequest(request) {
const logEntry = {
timestamp: new Date().toISOString(),
duration: request.duration,
promptLength: request.promptLength,
tokenCount: request.tokenCount,
timeToFirstToken: request.timeToFirstToken,
tokensPerSecond: request.tokensPerSecond,
success: request.finished,
};
console.log('请求性能数据:', logEntry);
// 可以发送到监控系统
// this.sendToAnalytics(logEntry);
// 或者存储到IndexedDB
// this.storeInDB(logEntry);
}
getMetrics() {
return {
...this.metrics,
successRate: this.metrics.totalRequests > 0
? (this.metrics.successfulRequests / this.metrics.totalRequests * 100).toFixed(1) + '%'
: '0%',
avgRequestTime: this.metrics.successfulRequests > 0
? (this.metrics.totalTime / this.metrics.successfulRequests).toFixed(0) + 'ms'
: '0ms',
};
}
// 在界面上显示性能指标
showPerformanceMetrics() {
const metrics = this.getMetrics();
const html = `
<div class="performance-metrics">
<h4>性能指标</h4>
<div>总请求数: ${metrics.totalRequests}</div>
<div>成功率: ${metrics.successRate}</div>
<div>平均响应时间: ${metrics.avgRequestTime}</div>
<div>平均生成速度: ${metrics.avgTokensPerSecond.toFixed(1)} tokens/秒</div>
<div>总生成token数: ${metrics.totalTokens}</div>
</div>
`;
document.getElementById('metrics-panel').innerHTML = html;
}
}
// 在ChatManager中使用
const performanceMonitor = new StreamPerformanceMonitor();
// 修改sendMessage方法
async sendMessage(prompt) {
// 开始监控
performanceMonitor.startRequest(prompt);
try {
// ... 原有的发送逻辑
// 在processStream中记录性能数据
async processStream(response, controller) {
// ... 原有的流处理逻辑
// 记录第一个token到达时间
performanceMonitor.recordFirstToken();
// 记录token数量
performanceMonitor.recordToken(1); // 每个数据块记录1个token
// ... 原有的逻辑
}
const answer = await this.processStream(response, controller);
// 请求成功结束
performanceMonitor.endRequest(true);
// 更新性能显示
performanceMonitor.showPerformanceMetrics();
return answer;
} catch (error) {
// 请求失败结束
performanceMonitor.endRequest(false);
performanceMonitor.showPerformanceMetrics();
throw error;
}
}
5. 总结
通过这篇文章,我们深入探讨了千问3.5-27B流式对话接口的三个方面:数据格式、前端渲染和错误处理。让我们回顾一下关键要点:
5.1 核心收获
数据格式方面,你现在应该清楚:
- 流式接口基于SSE协议,返回的是以
data:开头的JSON数据流 - 每个数据块包含
token(新增内容)、text(累积文本)和finished(结束标志)等关键字段 - 错误响应有特定的格式,需要单独处理
前端渲染方面,你学到了多种方案:
- 简单的
EventSource方案适合快速原型 - 灵活的
fetch + 流式解析方案适合生产环境 - 智能的“打字机效果”实现,平衡了视觉效果和性能
- 完整的React组件示例,可以直接使用或参考
错误处理方面,我们覆盖了各种边界情况:
- 网络错误的自动重试机制
- 数据解析错误的容错处理
- 用户交互的妥善管理(取消、历史记录等)
- 性能监控和优化建议
5.2 实践建议
在实际项目中,我建议你:
- 从简单开始:先用
EventSource实现基础功能,验证流程是否通畅 - 逐步增强:根据需求添加错误处理、取消功能、历史记录等
- 性能优先:对于长文本生成,使用
text字段直接更新,避免频繁的DOM操作 - 用户体验:提供清晰的加载状态、取消按钮和错误提示
- 监控报警:在生产环境添加性能监控,及时发现和解决问题
5.3 扩展思考
流式对话只是开始。基于这个基础,你还可以探索更多有趣的方向:
- 多模态交互:结合图片理解接口,实现图文并茂的对话体验
- 实时协作:多个用户同时与模型对话,共享生成结果
- 个性化定制:根据用户历史调整生成风格和内容
- 离线支持:使用Service Worker缓存部分功能,提升弱网体验
千问3.5-27B的流式接口为你打开了一扇门,门后是实时、交互式AI应用的新世界。现在,轮到你拿起这些工具,去创造令人惊艳的体验了。
记住,最好的学习方式是动手实践。复制文章中的代码,运行起来,修改它,打破它,再修复它。在这个过程中,你会遇到我未曾提及的问题,也会发现更优雅的解决方案——这正是技术探索的乐趣所在。
获取更多AI镜像
想探索更多AI镜像和应用场景?访问 CSDN星图镜像广场,提供丰富的预置镜像,覆盖大模型推理、图像生成、视频生成、模型微调等多个领域,支持一键部署。
更多推荐



所有评论(0)