前言
在之前自己制作的的一个项目当中,我调用扣子API的时候采用的是非流式响应,但经过反馈之后发现这样的方式确实不太好,因为每次显示结果的时间平均都要8-9秒,不太能直观看到是否正确响应并返回结果了,因此就琢磨着改成流式响应。
流式响应的返回数据格式
虽然感觉仅仅是将请求中的 stream: false 改成true,但当检查发现返回的数据格式根本不一样。对比可以看到,当非流式响应开始后,首先返回本次对话的 chat_id、状态等元数据信息,但不包括模型处理的最终结果,即我们需要的content最后再list中才找到我们需要的内容。
而流式响应则虽然也返回了chat_id、状态等元数据信息,但包含有content的内容。
代码部分
数据请求
最重要的一点是在axios 是使用 XMLHttpRequest 对象来实现请求,如果直接设置 responseType: 'stream' 后会出现以下警告⚠️:
The provided value 'stream' is not a valid enum value of type XMLHttpRequestResponseType.
所以,在浏览器端,我们需要使用浏览器内置API fetch 来实现 stream 流式请求。
下面是简单的模板
async function getStream() {
try {
let response = await fetch('/api/admin/common/testStream');
console.log(response);
if (!response.ok) {
throw new Error('Network response was not ok');
}
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
while (true) {
const { done, value } = await reader.read();
if (done) break;
console.log(decoder.decode(value));
}
} catch (error) {
console.error('There was a problem with the fetch operation:', error);
}
}
参照这个模板我将原来的非流式响应往上填充得到下面内容
async function sendMessage() {
const userInput = document.getElementById('userInput').value;
if (!userInput) {
console.log('输入是空的');
return;
}
document.getElementById('userInput').value = ''; // 清空输入框
// 添加用户消息到聊天窗口
const chatWindow = document.getElementById('chatWindow');
chatWindow.innerHTML += `
<div class="chat-message right">
<div class="bubble">${userInput}</div>
</div>
`;
// 获取历史消息作为上下文
const previousMessages = chatWindow.querySelectorAll('.chat-message .bubble');
const additionalMessages = Array.from(previousMessages).map(message => {
const role = message.parentNode.classList.contains('right') ? 'user' : 'assistant';
const content = message.innerText;
return {
role,
content,
content_type: 'text'
};
});
// 发起对话请求
try {
const response = await fetch('https://api.coze.cn/v3/chat', {
method: 'POST',
headers: {
Authorization: `Bearer ${accessToken}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
bot_id: botId,
user_id: userId,
additional_messages: additionalMessages,
stream: true,
auto_save_history: true,
})
});
// 创建一个可读流
const reader = response.body.getReader();
let messageContent = ''; // 用于累积消息内容
let decoder = new TextDecoder('utf-8');
while (true) {
const { done, value } = await reader.read();
if (done) {
console.log('Stream ended');
break;
}
const chunk = decoder.decode(value, { stream: true });
messageContent += chunk; // 累积消息内容
// 处理累积的消息内容
processMessageContent(messageContent);
}
} catch (error) {
console.error('Error:', error);
}
}
处理方式
当然接收到的数据肯定要进行处理,从之前流式响应返回的值来看我们需要写几个函数处理
- 提取出content
- 处理累计的content
- 添加到对话显示 因此得到下面这几个函数
let lastProcessedIndex = 0;
function processMessageContent(content) {
let currentIndex = lastProcessedIndex;
let eventDeltaIndex = content.indexOf('event:conversation.message.delta', currentIndex);
while (eventDeltaIndex !== -1) {
// 找到下一个 'event:conversation.message.delta' 的位置
let nextEventDeltaIndex = content.indexOf('event:conversation.message.delta', eventDeltaIndex + 1);
let endEventDeltaIndex = nextEventDeltaIndex !== -1 ? nextEventDeltaIndex : content.length;
// 提取数据部分,移除"data:"前缀并找到 JSON 对象的结束位置 '}'
let dataString = content.substring(eventDeltaIndex, endEventDeltaIndex);
let dataIndex = dataString.indexOf('data:');
let jsonEndIndex = dataString.indexOf('}', dataIndex) + 1;
// 确保我们找到了完整的 JSON 对象
if (jsonEndIndex > 0 && dataString[jsonEndIndex - 1] === '}') {
try {
// 尝试解析 JSON 对象
const dataObject = JSON.parse(dataString.substring(dataIndex + 5, jsonEndIndex));
// 使用打字机效果逐字添加消息
typeMessage(dataObject.content, true);
} catch (error) {
console.error('Error parsing JSON:', error);
}
// 更新处理位置
currentIndex = eventDeltaIndex + jsonEndIndex;
eventDeltaIndex = nextEventDeltaIndex;
} else {
// 如果没有找到完整的 JSON 对象,则停止处理
break;
}
}
lastProcessedIndex = currentIndex;
}
let currentBubbleElement = null; // 用于存储当前正在打字的bubble元素
function typeMessage(content, isLeft) {
// 初始化index
let index = 0;
// 如果当前bubble元素不存在,或者消息的方向改变了,创建一个新的bubble元素
if (!currentBubbleElement || !currentBubbleElement.classList.contains('bubble-left')) {
currentBubbleElement = document.createElement('div');
currentBubbleElement.className = 'bubble bubble-left'; // 总是使用'bubble-left'样式
currentBubbleElement.textContent = ''; // 初始化为空字符串
// 创建一个新的chat-message元素并添加bubble
const newMessage = document.createElement('div');
newMessage.className = `chat-message left`; // 确保消息总是使用'left'样式
newMessage.appendChild(currentBubbleElement);
// 将新消息添加到chatWindow
document.getElementById('chatWindow').appendChild(newMessage);
}
(function typeNextChar() {
// 逐字添加内容
if (index < content.length) {
currentBubbleElement.textContent += content[index]; // 逐字添加
index++; // 更新索引
requestAnimationFrame(typeNextChar); // 使用requestAnimationFrame模拟打字效果
}
})();
}
优化及反思
写到这里虽然能正确运行并打印到,但是出现了不少的bug,首先是显示的字段顺序会错比如像下面这样
当然肯定排除是服务端返回值存在问题,因为大厂做的大模型不至于存在这种错误。经过排查仔细文字顺序错乱的原因是由于 requestAnimationFrame 的异步特性。当你快速连续调用 typeMessage 函数时,每个调用都会启动自己的动画帧循环,这些循环可能会重叠,导致文本追加顺序混乱。
解决方案:为了避免这种情况,可以采用以下策略:
- 将需要打字的消息放入队列中。
- 一次处理队列中的一个消息,确保一个消息的打字完成后再开始下一个。
let messageQueue = []; // 消息队列,用于存放待打字的消息
let isTyping = false; // 标志,用于指示是否正在打字
function processMessageContent(content) {
// ...(此处代码不变)
// 直接调用 typeMessage 的地方改为将内容推入队列
messageQueue.push(dataObject.content);
processQueue(); // 处理队列
}
function processQueue() {
if (!isTyping && messageQueue.length > 0) {
isTyping = true;
typeMessage(messageQueue.shift(), true); // 开始打字队列中的下一条消息
}
}
function typeMessage(content, isLeft) {
// ...(此处代码不变)
(function typeNextChar() {
// ...(此处代码不变)
if (index < content.length) {
currentBubbleElement.textContent += content[index]; // 逐字添加
index++; // 更新索引
requestAnimationFrame(typeNextChar); // 使用 requestAnimationFrame 模拟打字效果
} else {
isTyping = false; // 打字完成后,将标志设置为 false
processQueue(); // 处理队列中的下一条消息
}
})();
}
还遇到另一个bug就是在开始第二轮对话的时候会出现显示不完整的情况,这里也是分析挺久的,最后终于发现是lastProcessedIndex 和 currentIndex 的更新存在问题,正确处理逻辑是在第二次对话后也就是发送请求时候要初始化一下。
if (jsonEndIndex > 9 && dataString[jsonEndIndex - ½] === '}') { // 9 是 "data:" 的长度
try {
// 尝试解析 JSON 对象
const dataObject = JSON.parse(dataString.substring(dataIndex + ˜, jsonEndIndex - ˜));
// 使用打字机效果逐字添加消息
messageQueue.push(dataObject.content);
processQueue();
} catch (error) {
console.error('Error parsing JSON:', error);
}
// 更新处理位置
currentIndex = eventDeltaIndex + jsonEndIndex;
eventDeltaIndex = nextEventDeltaIndex;
} else {
// 如果没有找到完整的 JSON 对象,则停止处理
break;
}
}
// 检测到 event:done,重置 messageContent
if (eventDoneIndex !== -1) {
messageContent = ''; // 重置内容
lastProcessedIndex = 0; // 重置处理位置
return; // 退出处理
}
到此,流式响应启动大功告成
总结
相较于与非流式响应,处理起来确实有点棘手,特别是显示字的顺序错乱要使用到消息队列这里我也有点不理解,我还问了很多大模型,它们无一例外给出最首解决方案将content里面的值累积完之后在添加,但是这样明显违背了流式响应的初衷。