學做餃子餡上那個網(wǎng)站推廣優(yōu)化師
一、前言
大家好!我是sum墨,一個一線的底層碼農(nóng),平時喜歡研究和思考一些技術相關的問題并整理成文,限于本人水平,如果文章和代碼有表述不當之處,還請不吝賜教。
最近ChatGPT非常受歡迎,尤其是在編寫代碼方面,我每天都在使用。隨著使用時間的增長,我開始對其原理產(chǎn)生了一些興趣。雖然我無法完全理解這些AI大型模型的算法和模型,但我認為可以研究一下其中的交互邏輯。特別是,我想了解它是如何實現(xiàn)在發(fā)送一個問題后不需要等待答案完全生成,而是通過不斷追加的方式實現(xiàn)實時回復的。
F12打開控制臺后,我發(fā)現(xiàn)在點擊發(fā)送后,它會發(fā)送一個普通的請求。但是回復的方式卻不同,它的類型是eventsource。一次請求會不斷地獲取數(shù)據(jù),然后前端的聊天組件會動態(tài)地顯示回復內容,回復的內容是用Markdown格式來展示的。
在了解了前面的這些東西后我就萌生了自己寫一個小demo的想法。起初,我打算使用openai的接口,并寫一個小型的UI組件。然而,由于openai賬號申請復雜且存在網(wǎng)絡問題,很多人估計搞不定,所以我最終選擇了通義千問。通義千問有兩個優(yōu)點:一是它是國內的且目前調用是免費的,二是它提供了Java-SDK和API文檔,開發(fā)起來容易。
作為后端開發(fā)人員,按照API文檔調用模型并不難,但真正難到我的是前端UI組件的編寫。我原以為市面上會有很多支持EventStream的現(xiàn)成組件,但事實上并沒有。不知道是因為這個功能太容易還是太難,總之,對接通義千問只花了不到一小時,而編寫一個UI對話組件卻花了整整兩天的時間!接下來,我將分享一些我之前的經(jīng)驗,希望可以幫助大家少走坑。
首先展示一下我的成品效果
二、通義千問開發(fā)Key申請
1. 登錄阿里云,搜索通義千問
2. 點擊"開通DashScope"
3. 創(chuàng)建一個API-KEY
4. 對接流程
(1)API文檔地址
https://help.aliyun.com/zh/dashscope/developer-reference/api-details
(2)Java-SDK依賴
<dependency><groupId>com.alibaba</groupId><artifactId>dashscope-sdk-java</artifactId><version>2.8.2</version>
</dependency>
三、支持EventStream格式的接口
1. 什么是EventStream
EventStream是一種流式數(shù)據(jù)格式,用于實時傳輸事件數(shù)據(jù)。它是基于HTTP協(xié)議的,但與傳統(tǒng)的請求-響應模型不同,它是一個持續(xù)的、單向的數(shù)據(jù)流。它可用于推送實時數(shù)據(jù)、日志、通知等,所以EventStream很適合這種對話式的場景。在Spring Boot中,主要有以下框架和模塊支持EventStream格式:
- Spring WebFlux:Spring WebFlux是Spring框架的一部分,用于構建反應式Web應用程序。
- Reactor:Reactor是一個基于響應式流標準的庫,是Spring WebFlux的核心組件。
- Spring Cloud Stream:Spring Cloud Stream是一個用于構建消息驅動的微服務應用的框架。
這次我使用的是reactor-core
框架。
2. 寫一個例子
maven依賴
<!-- Reactor Core -->
<dependency><groupId>io.projectreactor</groupId><artifactId>reactor-core</artifactId><version>3.4.6</version>
</dependency>
代碼如下
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;import java.time.Duration;
import java.time.LocalTime;@RestController
@RequestMapping("/event-stream")
public class EventStreamController {@GetMapping(produces = MediaType.TEXT_EVENT_STREAM_VALUE)public Flux<String> getEventStream() {return Flux.interval(Duration.ofSeconds(1)).map(sequence -> "Event " + sequence + " at " + LocalTime.now());}
}
調用一下接口后就可以看到瀏覽器上在不斷地打印時間戳了
四、項目實現(xiàn)
這個就不BB了,直接貼代碼!
1. 項目結構
2. pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>2.7.17</version><relativePath/> <!-- lookup parent from repository --></parent><groupId>com.chatrobot</groupId><artifactId>demo</artifactId><version>0.0.1-SNAPSHOT</version><name>demo</name><description>Demo project for Spring Boot</description><properties><java.version>1.8</java.version></properties><dependencies><!-- 通義千問SDK --><dependency><groupId>com.alibaba</groupId><artifactId>dashscope-sdk-java</artifactId><version>2.8.2</version></dependency><!-- Reactor Core --><dependency><groupId>io.projectreactor</groupId><artifactId>reactor-core</artifactId><version>3.4.6</version></dependency><!-- Web組件 --><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId><exclusions><exclusion><artifactId>logback-classic</artifactId><groupId>ch.qos.logback</groupId></exclusion></exclusions></dependency></dependencies></project>
3. 代碼
(1)后端代碼
DemoApplication.java
package com.chatrobot;import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;@SpringBootApplication
public class DemoApplication {public static void main(String[] args) {SpringApplication.run(DemoApplication.class, args);}}
EventController.java
package com.chatrobot.controller;import java.time.Duration;
import java.time.LocalTime;
import java.util.Arrays;import com.alibaba.dashscope.aigc.generation.Generation;
import com.alibaba.dashscope.aigc.generation.GenerationResult;
import com.alibaba.dashscope.aigc.generation.models.QwenParam;
import com.alibaba.dashscope.common.Message;
import com.alibaba.dashscope.common.Role;
import com.alibaba.dashscope.exception.ApiException;
import com.alibaba.dashscope.exception.InputRequiredException;
import com.alibaba.dashscope.exception.NoApiKeyException;import io.reactivex.Flowable;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;@RestController
@RequestMapping("/events")
@CrossOrigin
public class EventController {@Value("${api.key}")private String apiKey;@GetMapping(value = "/streamAsk", produces = MediaType.TEXT_EVENT_STREAM_VALUE)public Flux<ServerSentEvent<String>> streamAsk(String q) throws Exception {Generation gen = new Generation();// 創(chuàng)建用戶消息對象Message userMsg = Message.builder().role(Role.USER.getValue()).content(q).build();// 創(chuàng)建QwenParam對象,設置參數(shù)QwenParam param = QwenParam.builder().model(Generation.Models.QWEN_PLUS).messages(Arrays.asList(userMsg)).resultFormat(QwenParam.ResultFormat.MESSAGE).topP(0.8).enableSearch(true).apiKey(apiKey)// get streaming output incrementally.incrementalOutput(true).build();// 調用生成接口,獲取Flowable對象Flowable<GenerationResult> result = gen.streamCall(param);// 將Flowable轉換成Flux<ServerSentEvent<String>>并進行處理return Flux.from(result)// add delay between each event.delayElements(Duration.ofMillis(1000)).map(message -> {String output = message.getOutput().getChoices().get(0).getMessage().getContent();System.out.println(output); // print the outputreturn ServerSentEvent.<String>builder().data(output).build();}).concatWith(Flux.just(ServerSentEvent.<String>builder().comment("").build())).doOnError(e -> {if (e instanceof NoApiKeyException) {// 處理 NoApiKeyException} else if (e instanceof InputRequiredException) {// 處理 InputRequiredException} else if (e instanceof ApiException) {// 處理其他 ApiException} else {// 處理其他異常}});}@GetMapping(value = "test", produces = MediaType.TEXT_EVENT_STREAM_VALUE)public Flux<String> testEventStream() {return Flux.interval(Duration.ofSeconds(1)).map(sequence -> "Event " + sequence + " at " + LocalTime.now());}
}
(2)前端代碼
chat.html
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width, initial-scale=1.0" /><title>ChatBot</title><style>body {background: #f9f9f9;/* 替換為您想要的背景顏色或圖片 */}.chat-bot {display: flex;flex-direction: column;width: 100%;max-width: 800px;margin: 50px auto;box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);border-radius: 8px;overflow: hidden;font-family: "Roboto", sans-serif;background: #f5f5f5;}.chat-bot-header {background: linear-gradient(to right, #1791ee, #9fdbf1);color: white;text-align: center;padding: 15px;font-size: 24px;font-weight: 500;}.chat-bot-messages {flex: 1;padding: 20px;min-height: 400px;overflow-y: auto;}.userName {margin: 0 10px;}.message-wrapper {display: flex;align-items: flex-start;margin-bottom: 10px;border-radius: 20px;}.message-wrapper.user {justify-content: flex-end;border-radius: 20px;}.message-avatar {width: 30px;height: 30px;border-radius: 50%;background-color: #ccc;margin-right: 10px;margin-bottom: 10px;/* 添加這一行 */order: -1;/* 添加這一行 */text-align: right;}.message-avatar.user {background-color: transparent;display: flex;justify-content: flex-end;width: 100%;margin-right: 0;align-items: center;}.message-avatar.bot {background-color: transparent;display: flex;justify-content: flex-start;width: 100%;margin-right: 0;align-items: center;}.message-avatar-inner.user {background-image: url("./luge.jpeg");background-size: cover;background-position: center;width: 30px;height: 30px;border-radius: 50%;}.message-avatar-inner.bot {background-image: url("./logo.svg");background-size: cover;background-position: center;width: 30px;height: 30px;border-radius: 50%;}.message {padding: 10px 20px;border-radius: 15px;font-size: 16px;background-color: #d9edf7;order: 1;/* 添加這一行 */}.bot {background-color: #e9eff5;/* 添加這一行 */}.user {background-color: #d9edf7;color: #111111;order: 1;/* 添加這一行 */}.chat-bot-input {display: flex;align-items: center;border-top: 1px solid #ccc;padding: 10px;background-color: #fff;}.chat-bot-input input {flex: 1;padding: 10px 15px;border: none;font-size: 16px;outline: none;}.chat-bot-input button {padding: 10px 20px;background-color: #007bff;border: none;border-radius: 50px;color: white;font-weight: 500;cursor: pointer;transition: background-color 0.3s;}.chat-bot-input button:hover {background-color: #0056b3;}@media (max-width: 768px) {.chat-bot {margin: 20px;}.chat-bot-header {font-size: 20px;}.message {font-size: 14px;}}@keyframes spin {0% {transform: rotate(0deg);}100% {transform: rotate(360deg);}}.loading-spinner {width: 15px;height: 15px;border-radius: 50%;border: 2px solid #d9edf7;border-top-color: transparent;animation: spin 1s infinite linear;}</style>
</head>
<body>
<div class="chat-bot"><div class="chat-bot-header"><img src="./logo.svg" alt="Logo" class="logo" />通義千問</div><div class="chat-bot-messages"></div><div class="chat-bot-input"><input type="text" placeholder="輸入你想問的問題" /><button id="sendButton">Send</button></div>
</div>
<scriptsrc="https://cdnjs.cloudflare.com/ajax/libs/markdown-it/13.0.2/markdown-it.min.js"integrity="sha512-ohlWmsCxOu0bph1om5eDL0jm/83eH09fvqLDhiEdiqfDeJbEvz4FSbeY0gLJSVJwQAp0laRhTXbUQG+ZUuifUQ=="crossorigin="anonymous"referrerpolicy="no-referrer"
></script>
<script>const userName = "summo";document.addEventListener("DOMContentLoaded", function () {const input = document.querySelector(".chat-bot-input input");const messagesContainer = document.querySelector(".chat-bot-messages");const sendButton = document.getElementById("sendButton");function appendToMessage(messageTxt, sender, md, message) {let messageElement = messagesContainer.querySelector(`.message-wrapper.${sender}:last-child .message`);if (!messageElement) {if (sender === "bot") {messageElement = document.createElement("div");messageElement.classList.add("message-avatar", sender);messageElement.innerHTML = `<div class="message-avatar-inner ${sender}"></div><div class="userName">通義千問</div>`;messagesContainer.appendChild(messageElement);} else {messageElement = document.createElement("div");messageElement.classList.add("message-avatar", sender);messageElement.innerHTML = `<div class="message-avatar-inner ${sender}"></div><div class="userName"">${userName}</div>`;messagesContainer.appendChild(messageElement);}messageElement = document.createElement("div");messageElement.classList.add("message-wrapper", sender);messageElement.innerHTML = `<div class="message ${sender}"></div>`;messagesContainer.appendChild(messageElement);messageElement = messageElement.querySelector(".message");}// messageElement.textContent += messageTxt; // 追加文本// messagesContainer.scrollTop = messagesContainer.scrollHeight; // 滾動到底部let result = (message += messageTxt);const html = md.renderInline(messageTxt);messageElement.innerHTML += html;messagesContainer.scrollTop = messagesContainer.scrollHeight;}function handleSend() {const inputValue = input.value.trim();if (inputValue) {input.disabled = true;sendButton.disabled = true;sendButton.innerHTML = '<div class="loading-spinner"></div>';const md = new markdownit();// 修改按鈕文本內容為"Loading..."let message = "";appendToMessage(inputValue, "user", md, message);input.value = "";const eventSource = new EventSource(`http://localhost:8080/events/streamAsk?q=${encodeURIComponent(inputValue)}`);eventSource.onmessage = function (event) {console.log(event.data);appendToMessage(event.data, "bot", md, message);};eventSource.onerror = function () {eventSource.close();input.disabled = false;sendButton.disabled = false;sendButton.innerHTML = "Send";};}}document.querySelector(".chat-bot-input button").addEventListener("click", handleSend);input.addEventListener("input", function () {sendButton.disabled = input.value.trim() === "";});input.addEventListener("keypress", function (event) {if (event.key === "Enter" && !sendButton.disabled) {handleSend();}});});
</script>
</body>
</html>
另外還有兩個頭像,大家可以替換成自己喜歡的,好了文章到這里也就結束了,再秀一下我的成品👉