Kimi大模型流式传输对接实践

365 阅读7分钟

本文正在参加金石计划附加挑战赛——第一(或二、三、四)期命题。

引言

随着人工智能技术的不断发展,大模型的应用越来越广泛。Kimi大模型作为一款功能全面、效果出色的智能助手,其强大的数据处理能力、广泛的知识覆盖、便捷的文件处理以及友好的用户交互体验,使其在众多大模型中脱颖而出。本文将详细介绍如何使用Vue3前端技术对接Kimi大模型的流式传输接口,实现实时的对话以及附加文件上传处理的功能。

image-20241120145237656.png

Kimi大模型简介

Kimi大模型是由北京月之暗面科技有限公司(Moonshot AI)开发的一款智能助手,它具备高效的信息处理能力,支持200万字超长无损上下文,能够快速理解和回应用户的问题。它拥有广泛的知识库,能够提供包括科技、文化、历史、教育等多个领域的信息。此外,Kimi大模型还能读取多种格式的文件,如TXT、PDF、Word文档等,并能安全地访问互联网获取信息,确保用户隐私和数据安全。

目前,Kimi大模型拥有三种核心模型能力,分别是:

  • moonshot-v1-8k: 它是一个长度为 8k 的模型,适用于生成短文本。
  • moonshot-v1-32k: 它是一个长度为 32k 的模型,适用于生成长文本。
  • moonshot-v1-128k: 它是一个长度为 128k 的模型,适用于生成超长文本。

Kimi大模型不仅是一个强大的AI助手,还提供了一个开放的开发平台,让开发者能够利用其先进的AI能力进行二次开发,主要提供接口:

  1. chat对话:提供流畅的实时对话体验,能够理解和回应用户的问题,适用于创建聊天机器人或客服系统。
  2. 工具调用:允许开发者调用各种内置工具,以执行特定的任务,如数据分析、信息检索等。
  3. 格式输出:支持多种数据格式的输出,方便开发者将AI处理的结果集成到不同的应用程序中。
  4. 文件接口:提供文件处理接口,使得开发者可以上传文件,AI助手能够阅读并回应文件内容,极大地扩展了应用的使用场景。

开发者注册

  1. 注册Kimi开发者平台账号。
  2. 在控制台管理中申请API Key。
  3. 输入名称获取密钥,用于API接口对接。

image-20241120144257321.png

流式传输接口介绍

流式传输接口是一种基于网络传输的技术,它允许数据在不同设备间以连续、流式的方式实时传输,而不是一次性传输完整的数据块。。Kimi大模型提供的流式接口基于HTTP协议,使用Server-Sent Events(SSE)技术实现。SSE允许服务端主动向客户端发送数据,而客户端则通过EventSource事件源来监听并获取服务端的消息。

前端实现

前端使用Vue3来实现与Kimi大模型的流式传输对话,采用element-plus构建界面,使用markdown-it来渲染大模型返回来的md格式的对话。

项目搭建

首先,使用Vue CLI创建一个Vue3项目:

vue create vue-kimi-chat
cd vue-kimi-chat

安装依赖

由于axios不支持流事件,我们需要使用fetch或基于fetch实现的第三方请求库。这里推荐使用微软的fetch-event-source库:

npm install element-plus
npm install markdown-it

创建对话组件

src/components目录下创建一个名为ChatComponent.vue的组件:

<template>
  <div>
    <main class="main">
      <el-scrollbar
        height="70%"
        class="chat-log"
        id="chatLog"
        ref="scrollMenuRes"
      >
        <div
          class="chat-item"
          v-for="(item, index) in chatLogs"
          :key="index"
          :style="{
            'justify-content':
              item.type == 'user-message' ? 'flex-end' : 'flex-start',
          }"
        >
          <img
            src="../assets/ai-icon.png"
            alt=""
            v-if="item.type == 'ai-message'"
          />

          <div :class="item.type" v-html="md.render(item.message)"></div>
          <img
            src="../assets/user-icon.jpg"
            style="margin-left: 20px"
            alt=""
            v-if="item.type == 'user-message'"
          />
        </div>
      </el-scrollbar>
      <div class="input-container">
        <!-- v-loading="aiThink" element-loading-text="Ai 思考回答中..." -->
        <div class="input-area">
          <el-input
            v-model="inputMessage"
            class="input"
            :autosize="{ minRows: 5, maxRows: 10 }"
            type="textarea"
            placeholder="请输入内容,按 Enter 发送,Shift + Enter 换行"
            @keydown="sendMessage"
          />

          <el-button
            class="send-button"
            type="primary"
            :disabled="inputMessage.length === 0"
            @click="sendButtonClick()"
            ><el-icon size="22">
              <Promotion /> </el-icon
          ></el-button>
        </div>

        <div class="file-area">
          <el-upload
            v-model:file-list="fileList"
            ref="upload"
            action="https://api.moonshot.cn/v1/files"
            :headers="{
              Authorization: this.apiKey,
            }"
            :limit="10"
            :on-success="success"
            :show-file-list="false"
            :auto-upload="true"
            :on-exceed="handleExceed"
            :on-progress="handleProgress"
          >
            <div>
              <el-button class="file-button" type="info" size="small">
                <el-icon size="14" color="#FFF">
                  <UploadFilled />
                </el-icon>
                {{ progressText }}
              </el-button>
              <div class="file-name">
                <ul>
                  <li v-for="(item, index) in fileList" :key="index">
                    {{ item.name }}
                  </li>
                </ul>
              </div>
            </div>
          </el-upload>

          <el-button
            class="del-button"
            type="danger"
            size="small"
            v-if="fileContent.length > 0"
            @click="clearFiles"
          >
            <el-icon size="14" color="#FFF">
              <DeleteFilled />
            </el-icon>
            清空上传
          </el-button>
        </div>
      </div>
    </main>
  </div>
</template>

<script>
import { Promotion, UploadFilled, DeleteFilled } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import MarkdownIt from "markdown-it";
export default {
  components: {
    Promotion,
    UploadFilled,
    DeleteFilled,
  },
  data() {
    return {
      aiApiUlr: "https://api.moonshot.cn/v1/chat/completions",
      apiKey: "sk-LgHXc8RZLALMrzL0SN81idZvKcMr2TI7DH8f11111", // 换成自己的key
      chatLog: "",
      inputMessage: "",
      chatLogs: [
        {
          type: "ai-message",
          message:
            "Hi,我是 Kimi~很高兴遇见你!你可以随时把网址🔗或者文件📃发给我,我来帮你看看 😊",
        },
      ],
      aiThink: false,
      fileList: [],
      fileContent: [],
      progressText: "文件上传",
      md: new MarkdownIt(),
    };
  },
  methods: {
    // Enter发送消息
    sendMessage(event) {
      const message = this.inputMessage.trim();
      if (message === "") {
        return;
      }

      // 监听 Enter 键 和 Shift 键
      if (event.key === "Enter" && !event.shiftKey) {
        event.preventDefault();
        if (this.aiThink) {
          return ElMessage.error("Ai 正在思考中,请稍后发送");
        }
        this.chatLogs.push({ message: message, type: "user-message" });
        this.scrollToBottom();
        this.$refs.upload.clearFiles();
        this.inputMessage = "";
        this.sendRequestToChatGPT(message);
      }
    },
    // 发送按钮点击
    sendButtonClick() {
      const message = this.inputMessage.trim();
      if (message === "") {
        return;
      }
      if (this.aiThink) {
        return ElMessage.error("Ai 正在思考中,请稍后发送");
      }
      this.chatLogs.push({ message: message, type: "user-message" });
      this.scrollToBottom();
      this.$refs.upload.clearFiles();
      this.inputMessage = "";
      this.sendRequestToChatGPT(message);
    },
    // 滚动到底部
    scrollToBottom() {
      this.$nextTick(() => {
        if (this.$refs.scrollMenuRes) {
          const container = this.$refs.scrollMenuRes.$el.querySelector(
            ".el-scrollbar__wrap"
          );
          if (container) {
            container.style.scrollBehavior = "smooth"; // 添加平滑滚动效果
            container.scrollTop = container.scrollHeight; // 滚动到底部
          }
        }
      });
    },

    // 构建发送请求参数
    async sendRequestToChatGPT(message) {
      this.aiThink = true;
      // 构建请求对象
      let request = {
        model: "moonshot-v1-8k",
        messages: [
          {
            role: "user",
            content: message,
          },
        ],
        stream: true,
      };
      // 如果有文件,则将文件内容也发送给 ChatGPT
      if (this.fileContent.length > 0) {
        for (let i = 0; i < this.fileContent.length; i++) {
          request.messages.push({ role: "user", content: this.fileContent[i] });
        }
      }
      // 构建对话UI
      this.chatLogs.push({
        message: "",
        type: "ai-message",
      });

      this.fetchStreamData(this.aiApiUlr, request);
    },
 
    // 清空文件
    clearFiles() {
      this.fileContent = [];
      this.$refs.upload.clearFiles();
    },
    // 附件上传回调
    async success(e) {
      console.log("e", e);
      let file_content = await fetch(
        `https://api.moonshot.cn/v1/files/${e.id}/content`,
        {
          method: "GET",
          headers: {
            "Content-Type": "application/json", // 根据实际情况设置
            Authorization: this.apiKey,
          },
        }
      );
      const reader = file_content.body.getReader();
      const textDecoder = new TextDecoder("utf-8");
      const { done, value } = await reader.read();
      const chunk = textDecoder.decode(value, { stream: true });
      this.fileContent.push(chunk);
      this.progressText = "上传成功,继续上传";
      console.log("fileContent", this.fileContent);
    },

    // 文件上传中回调
    handleProgress() {
      this.progressText = "上传中ing...";
    },

    // 文件上传超出个数回调
    handleExceed() {
      this.$refs.upload.clearFiles();
    },
  },
};
</script>


<style>
* {
  padding: 0;
  margin: 0;
}

.main {
  width: 100%;
  height: 100vh;
  background-color: rgb(245, 247, 250);
  display: flex;
  flex-direction: column;
}

.input-container {
  display: flex;
  align-items: center;
  width: 100%;
  margin: 0 auto;
  position: absolute;
  bottom: 5%;
  justify-content: center;
}

.input-area {
  width: 40%;
  display: flex;
  align-items: center;
  justify-content: center;
  position: absolute;
}

.input {
  width: 100%;
  margin-right: 1.25rem;
  border-radius: 0.625rem;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}

.input:focus {
  outline: none;
  border: 0.0625 solid #409eff;
}

.chat-log {
  width: 60%;
  margin: 2.5rem auto;
  background: #fff;
  padding: 2.5rem;
  border-radius: 0.625rem;
}

.chat-item {
  display: flex;
  align-items: start;
  margin-bottom: 1.25rem;
  line-height: 1.5rem;
}

.chat-item img {
  width: 2.5rem;
  height: 2.5rem;
  border-radius: 0.625rem;
  margin-right: 1.25rem;
}

.send-button {
  position: absolute;
  width: 2.5rem;
  height: 2.5rem;
  bottom: 1.25rem;
  right: 2.5rem;
  transition: transform 0.3s ease;
}

.file-button {
  /* position: absolute;
  bottom: 1.25rem;
  left: 1.25rem; */
  transition: transform 0.3s ease;
}

.send-button:hover,
.file-button:hover {
  transform: scale(1.1);
}

.file-name {
  font-size: 0.75rem;
  color: #409eff;
}

.file-name li {
  list-style: none;
  margin-top: 0.625rem;
}

.user-message {
  background-color: #f0f8ff;
  padding: 0.625rem 2rem;
  border-radius: 0.3125rem;
}

.ai-message {
  background-color: #f0fff0;
  padding: 0.625rem 2rem;
  border-radius: 0.3125rem;
}

.el-textarea__inner {
  background-color: white;
  padding: 1.25rem;
  border-radius: 0.625rem;
  font-size: 16px;
}

.file-area {
  z-index: 99;
  position: relative;
  top: 250px;
  width: 40%;
  left: 10px;
  min-height: 300px;
  display: flex;
}

.el-upload-list {
  width: 40%;
}

.del-button {
  margin-left: 140px;
}
</style>

代码解释

以下是对代码中功能方法的详细解释:

  1. sendMessage(event): 此方法在用户发送消息时被调用。它检查输入的消息是否为空,并监听Enter键(非Shift+Enter)来发送消息。如果AI正在思考(aiThinktrue),则显示错误消息。否则,它将消息添加到聊天记录中,滚动到聊天窗口的底部,清除上传的文件,并发送请求到Kimi。
  2. sendButtonClick(): 当用户点击发送按钮时调用的方法,其功能与sendMessage类似,但不依赖于键盘事件。
  3. scrollToBottom(): 此方法用于滚动到聊天窗口的底部,以便用户可以看到最新的消息。使用Vue的$nextTick来确保DOM更新后再执行滚动操作。
  4. sendRequestToChatGPT(message): 此方法构建并发送请求到Kimi API。它首先将aiThink设置为true,表示AI正在思考。然后,它构建一个请求对象,包括模型名称、用户消息和文件内容(如果有)。最后,它调用fetchStreamData方法来发送请求并获取流数据。
  5. fetchStreamData(url, requestData): 此方法使用fetch API发送POST请求到指定的URL,并处理返回的流数据。它使用TextDecoder来解码数据块,并调用processChunk方法来处理每个数据块。当流结束时,它会重置一些状态并执行清理工作。
  6. processChunk(chunk, callback): 此方法用于处理从流中接收到的数据块。它尝试将数据块解析为JSON,并提取其中的内容。如果解析成功,它会调用回调函数并传入提取的内容。
  7. clearFiles(): 此方法用于清除已选择的文件和fileContent数组中的内容。
  8. success(e): 这是ElementPlus上传组件的一个异步方法,用于处理附件上传成功的回调。上传成功后,首先获取Kimi上传接口返回文件id,并通过fetch 从指定的Kimi解析文件接口api.moonshot.cn/v1/files/${… 获取文件内容,然后使用TextDecoder解码获取到的数据块,并将其添加到fileContent数组中。最后,它更新进度文本并打印fileContent
  9. handleProgress(): 当文件上传中时调用的方法,用于更新进度文本。
  10. handleExceed(): 当上传的文件数量超过限制时调用的方法,它会清除已选择的文件。

集成组件到主应用

src/App.vue中引入并使用ChatComponent组件:

<template>
  <div id="app">
    <ChatComponent />
  </div>
</template>

<script>
import ChatComponent from './components/ChatComponent.vue';

export default {
  name: 'App',
  components: {
    ChatComponent
  }
};
</script>

运行项目

运行Vue项目

npm run dev

现在,你可以在浏览器中打开Vue应用,输入问题并敲击回车,即可看到Kimi大模型的实时回复,同时可以上传一个或多个文件,来实现文件的上传和解析。