coze在前端网页采用流式响应实践

1,508 阅读5分钟

前言

在之前自己制作的的一个项目当中,我调用扣子API的时候采用的是非流式响应,但经过反馈之后发现这样的方式确实不太好,因为每次显示结果的时间平均都要8-9秒,不太能直观看到是否正确响应并返回结果了,因此就琢磨着改成流式响应。

image.png

流式响应的返回数据格式

虽然感觉仅仅是将请求中的 stream: false 改成true,但当检查发现返回的数据格式根本不一样。对比可以看到,当非流式响应开始后,首先返回本次对话的 chat_id、状态等元数据信息,但不包括模型处理的最终结果,即我们需要的content最后再list中才找到我们需要的内容。 而流式响应则虽然也返回了chat_id、状态等元数据信息,但包含有content的内容。

image.png

image.png

image.png

代码部分

数据请求

最重要的一点是在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,首先是显示的字段顺序会错比如像下面这样

image.png

当然肯定排除是服务端返回值存在问题,因为大厂做的大模型不至于存在这种错误。经过排查仔细文字顺序错乱的原因是由于 requestAnimationFrame 的异步特性。当你快速连续调用 typeMessage 函数时,每个调用都会启动自己的动画帧循环,这些循环可能会重叠,导致文本追加顺序混乱。

解决方案:为了避免这种情况,可以采用以下策略:

  1. 将需要打字的消息放入队列中。
  2. 一次处理队列中的一个消息,确保一个消息的打字完成后再开始下一个。
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就是在开始第二轮对话的时候会出现显示不完整的情况,这里也是分析挺久的,最后终于发现是lastProcessedIndexcurrentIndex 的更新存在问题,正确处理逻辑是在第二次对话后也就是发送请求时候要初始化一下。

 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里面的值累积完之后在添加,但是这样明显违背了流式响应的初衷。