在 Java 中调用 ChatGPT API 并实现流式接收(Server-Sent Events, SSE)

文章目录

  • 简介
  • OkHttp 流式获取 GPT 响应
  • 通过 SSE 流式推送前端
    • 后端代码
      • 消息实体
      • 接口
      • 接口实现
      • 数据推送给前端
    • 前端代码
      • 创建 `sseClient.js`
      • vue3代码
    • 优化后端代码

简介

用过 ChatGPT 的伙伴应该想过自己通过调用ChatGPT官网提供的接口来实现一个自己的问答机器人,但是在调用的时候发现,请求总是以传统的HTTP请求/响应模式进行,这意味着我们没发送一个请求后需要等待 ChatGPT 服务器返回完整的响应。这种方式在生成文本时并不不是我们理想的,因为用户体验不够流畅。

为了提供更好的用户体验,我们可以使用Server-Sent Events(SSE)技术来实现流式接收。这样,当ChatGPT 服务器可以在生成响应的同时逐步将内容推送给我们,我们在通过 SSE 流式推送到前端页面,让用户能够实时看到生成的内容。我将详细介绍如何在Java中实现这一功能。

OkHttp 流式获取 GPT 响应

其实市面上已经有很多现成的框架支持,但我们这里使用 okHttp 这个轻量级的HTTP客户端库来实现。

需要先引用相关maven:

    <dependency><groupId>com.squareup.okhttp3</groupId><artifactId>okhttp</artifactId></dependency><dependency><groupId>com.squareup.okhttp3</groupId><artifactId>okhttp-sse</artifactId></dependency>

构建请求体,必须加上参数 stream 值为true

 //构建发送内容String messageStr = StrUtil.format(prompt, params);// 创建一个Message对象,该对象表示一个消息,并设置其属性Message message = new Message(Message.Role.USER.getRole(), messageStr);// 创建一个ChatCompletion对象,表示聊天完成请求,并将刚创建的消息添加到其中ChatCompletionRequest request = ChatCompletionRequest.builder().model(ChatCompletionRequest.Model.GPT_3_5_TURBO.getName()).messages(Arrays.asList(message)).stream(true).build();
       
// 定义see接口
Request request = new Request.Builder().url("https://api.openai.com/v1/chat/completions").header("Authorization","xxx").post(okhttp3.RequestBody.create(okhttp3.MediaType.parse("application/json; charset=utf-8"),param.toJSONString())).build();
OkHttpClient okHttpClient = new OkHttpClient.Builder().connectTimeout(10, TimeUnit.MINUTES).readTimeout(10, TimeUnit.MINUTES)//这边需要将超时显示设置长一点,不然刚连上就断开,之前以为调用方式错误被坑了半天.build();// 实例化EventSource,注册EventSource监听器
RealEventSource realEventSource = new RealEventSource(request, new EventSourceListener() {@Overridepublic void onOpen(EventSource eventSource, Response response) {log.info("onOpen");}@SneakyThrows@Overridepublic void onEvent(EventSource eventSource, String id, String type, String data) {// log.info("onEvent");// 在实际应用中,你可以在这里将数据推送给前端log.info(data);//请求到的数据}@Overridepublic void onClosed(EventSource eventSource) {log.info("onClosed");
//                emitter.complete();}@Overridepublic void onFailure(EventSource eventSource, Throwable t, Response response) {log.error("onFailure 出现异常,response={}", response, t);//这边可以监听并重新打开
//                emitter.complete();}
});
realEventSource.connect(okHttpClient);//真正开始请求的一步

通过 SSE 流式推送前端

sse(Server Sent Event),直译为服务器发送事件,顾名思义,也就是客户端可以获取到服务器发送的事件

我们常见的 http 交互方式是客户端发起请求,服务端响应,然后一次请求完毕;但是在 sse 的场景下,客户端发起请求,连接一直保持,服务端有数据就可以返回数据给客户端,这个返回可以是多次间隔的方式

原理是先建立链接,然后不断发消息就可以

我们利用 springboot 封装的 SseEmitter 来完成推送,需要用到以下依赖:

<dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId>
</dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency><groupId>cn.hutool</groupId><artifactId>hutool-all</artifactId><version>5.7.16</version>
</dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId>
</dependency>

后端代码

消息实体

其中客户端 ID 是每个 SSE 链接的唯一标识,拿到 ID 可以精准的给唯一的用户推送消息,消息通过字符串的方式进行传递

import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;/*** 消息体*/
@Data
@AllArgsConstructor
@NoArgsConstructor
public class MessageVo {/*** 客户端id*/private String clientId;/*** 传输数据体(json)*/private String data;
}

接口

import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;public interface SseEmitterService {/*** 创建连接** @param clientId 客户端ID*/SseEmitter createConnect(String clientId);/*** 根据客户端id获取SseEmitter对象** @param clientId 客户端ID*/SseEmitter getSseEmitterByClientId(String clientId);/*** 发送消息给所有客户端** @param msg 消息内容*/void sendMessageToAllClient(String msg);/*** 给指定客户端发送消息** @param clientId 客户端ID* @param msg      消息内容*/void sendMessageToOneClient(String clientId, String msg);/*** 关闭连接** @param clientId 客户端ID*/void closeConnect(String clientId);
}

接口实现

@Slf4j
@Service
public class SseEmitterServiceImpl implements SseEmitterService {/*** 容器,保存连接,用于输出返回 ;可使用其他方法实现*/private static final Map<String, SseEmitter> sseCache = new ConcurrentHashMap<>();/*** 根据客户端id获取SseEmitter对象** @param clientId 客户端ID*/@Overridepublic SseEmitter getSseEmitterByClientId(String clientId) {return sseCache.get(clientId);}/*** 创建连接** @param clientId 客户端ID*/@Overridepublic SseEmitter createConnect(String clientId) {// 设置超时时间,0表示不过期。默认30秒,超过时间未完成会抛出异常:AsyncRequestTimeoutExceptionSseEmitter sseEmitter = new SseEmitter(0L);// 是否需要给客户端推送IDif (StrUtil.isBlank(clientId)) {clientId = IdUtil.simpleUUID();}// 注册回调sseEmitter.onCompletion(completionCallBack(clientId));     // 长链接完成后回调接口(即关闭连接时调用)sseEmitter.onTimeout(timeoutCallBack(clientId));        // 连接超时回调sseEmitter.onError(errorCallBack(clientId));          // 推送消息异常时,回调方法sseCache.put(clientId, sseEmitter);log.info("创建新的sse连接,当前用户:{}    累计用户:{}", clientId, sseCache.size());try {// 注册成功返回用户信息sseEmitter.send(SseEmitter.event().id(String.valueOf(HttpStatus.HTTP_CREATED)).data(clientId, MediaType.APPLICATION_JSON));} catch (IOException e) {log.error("创建长链接异常,客户端ID:{}   异常信息:{}", clientId, e.getMessage());}return sseEmitter;}/*** 发送消息给所有客户端** @param msg 消息内容*/@Overridepublic void sendMessageToAllClient(String msg) {if (MapUtil.isEmpty(sseCache)) {return;}// 判断发送的消息是否为空for (Map.Entry<String, SseEmitter> entry : sseCache.entrySet()) {MessageVo messageVo = new MessageVo();messageVo.setClientId(entry.getKey());messageVo.setData(msg);sendMsgToClientByClientId(entry.getKey(), messageVo, entry.getValue());}}/*** 给指定客户端发送消息** @param clientId 客户端ID* @param msg      消息内容*/@Overridepublic void sendMessageToOneClient(String clientId, String msg) {MessageVo messageVo = new MessageVo(clientId, msg);sendMsgToClientByClientId(clientId, messageVo, sseCache.get(clientId));}/*** 关闭连接** @param clientId 客户端ID*/@Overridepublic void closeConnect(String clientId) {SseEmitter sseEmitter = sseCache.get(clientId);if (sseEmitter != null) {sseEmitter.complete();removeUser(clientId);}}/*** 推送消息到客户端* 此处做了推送失败后,重试推送机制,可根据自己业务进行修改** @param clientId  客户端ID* @param messageVo 推送信息,此处结合具体业务,定义自己的返回值即可**/private void sendMsgToClientByClientId(String clientId, MessageVo messageVo, SseEmitter sseEmitter) {if (sseEmitter == null) {log.error("推送消息失败:客户端{}未创建长链接,失败消息:{}",clientId, messageVo.toString());return;}SseEmitter.SseEventBuilder sendData = SseEmitter.event().id(String.valueOf(HttpStatus.HTTP_OK)).data(messageVo, MediaType.APPLICATION_JSON);try {sseEmitter.send(sendData);} catch (IOException e) {// 推送消息失败,记录错误日志,进行重推log.error("推送消息失败:{},尝试进行重推", messageVo.toString());boolean isSuccess = true;// 推送消息失败后,每隔10s推送一次,推送5次for (int i = 0; i < 5; i++) {try {Thread.sleep(10000);sseEmitter = sseCache.get(clientId);if (sseEmitter == null) {log.error("{}的第{}次消息重推失败,未创建长链接", clientId, i + 1);continue;}sseEmitter.send(sendData);} catch (Exception ex) {log.error("{}的第{}次消息重推失败", clientId, i + 1, ex);continue;}log.info("{}的第{}次消息重推成功,{}", clientId, i + 1, messageVo.toString());return;}}}/*** 长链接完成后回调接口(即关闭连接时调用)** @param clientId 客户端ID**/private Runnable completionCallBack(String clientId) {return () -> {log.info("结束连接:{}", clientId);removeUser(clientId);};}/*** 连接超时时调用** @param clientId 客户端ID**/private Runnable timeoutCallBack(String clientId) {return () -> {log.info("连接超时:{}", clientId);removeUser(clientId);};}/*** 推送消息异常时,回调方法** @param clientId 客户端ID**/private Consumer<Throwable> errorCallBack(String clientId) {return throwable -> {log.error("SseEmitterServiceImpl[errorCallBack]:连接异常,客户端ID:{}", clientId);// 推送消息失败后,每隔10s推送一次,推送5次for (int i = 0; i < 5; i++) {try {Thread.sleep(10000);SseEmitter sseEmitter = sseCache.get(clientId);if (sseEmitter == null) {log.error("SseEmitterServiceImpl[errorCallBack]:第{}次消息重推失败,未获取到 {} 对应的长链接", i + 1, clientId);continue;}sseEmitter.send("失败后重新推送");} catch (Exception e) {e.printStackTrace();}}};}/*** 移除用户连接** @param clientId 客户端ID**/private void removeUser(String clientId) {sseCache.remove(clientId);log.info("SseEmitterServiceImpl[removeUser]:移除用户:{}", clientId);}
}

数据推送给前端

在 onEvent 回调中添加代码,每接收到消息后就推送到前端

       
// 定义see接口
Request request = new Request.Builder().url("https://api.openai.com/v1/chat/completions").header("Authorization","xxx").post(okhttp3.RequestBody.create(okhttp3.MediaType.parse("application/json; charset=utf-8"),param.toJSONString())).build();
OkHttpClient okHttpClient = new OkHttpClient.Builder().connectTimeout(10, TimeUnit.MINUTES).readTimeout(10, TimeUnit.MINUTES)//这边需要将超时显示设置长一点,不然刚连上就断开,之前以为调用方式错误被坑了半天.build();// 实例化EventSource,注册EventSource监听器
RealEventSource realEventSource = new RealEventSource(request, new EventSourceListener() {@Overridepublic void onOpen(EventSource eventSource, Response response) {log.info("onOpen");}@Overridepublic void onEvent(EventSource eventSource, String id, String type, String data) {if ("[DONE]".equals(data)) {System.out.println("收到 [DONE] 信号");return;}ChatCompletionResp chatCompletionResp = JSON.parseObject(data, ChatCompletionResp.class);// 获得生成的文章内容if (CollUtil.isEmpty(chatCompletionResp.getChoices())){return;}Message delta = chatCompletionResp.getChoices().get(0).getDelta();if (delta == null || delta.getContent() == null){return;}sseEmitterService.sendMessageToOneClient(clientId , delta.getContent());log.info(data);//请求到的数据}@Overridepublic void onClosed(EventSource eventSource) {log.info("onClosed");
//                emitter.complete();}@Overridepublic void onFailure(EventSource eventSource, Throwable t, Response response) {log.error("onFailure 出现异常,response={}", response, t);//这边可以监听并重新打开
//                emitter.complete();}
});
realEventSource.connect(okHttpClient);//真正开始请求的一步

前端代码

由于 EventSource 不允许直接配置请求头,普通的 EventSource 如果需要携带token请求,那就需要引入一个插件

安装 EventSourcePolyfill

你可以通过npm安装 event-source-polyfill:

npm install event-source-polyfill

引入 EventSourcePolyfill 后,它会自动替换浏览器中的原生 EventSource,其用法与原生的 API 一致。你可以像使用 EventSource 一样使用它:

创建 sseClient.js

封装一下, sse 最佳实践,

// utils/sseClient.js
import { EventSourcePolyfill } from 'event-source-polyfill'
import { baseURL } from '../config';// 封装一个创建 SSE 连接的方法
export function newEventSource({ clientId = '', headers = {}, onMessage, onError, onOpen }) {const token = sessionStorage.getItem('token') || ''const es = new EventSourcePolyfill(baseURL + 'p/sse/createConnect?clientId=' + clientId  , {headers: {'Authorization': `Bearer ${token}`...headers},heartbeatTimeout: 60 * 1000, // 心跳超时(可选)})es.onopen = (event) => {console.log('SSE 连接已开启')onOpen && onOpen(event)}es.onmessage = (event) => {//前端:在接收到结束标识后立即销毁if (event.data === '[DONE]') {console.log('SSE 连接已关闭')es.close()}onMessage && onMessage(event)}es.onerror = (event) => {console.error('SSE 错误:', event)onError && onError(event)es.close() // 出错时自动关闭}return es // 返回实例,方便外部主动关闭
}

vue3代码

import { newEventSource } from '@/utils/sseClient.js'const createSseConnection = () => {return newEventSource({clientId: 'xxx',onMessage: (event) => {console.log('Received SSE message:', event.data);}});
};

优化后端代码

按需建立连接并及时关闭 是非常关键的实践策略,每一个 SseEmitter 在服务端都是一个线程或者任务挂起的状态,太多不关闭会导致资源消耗(线程、连接、内存等);

如果每个用户长时间挂一个 SSE,不及时关闭,可能造成内存泄露或线程池耗尽,所以我们优化一下后端代码,在完成输出后及时关闭连接.

在关闭和异常的回调方法中添加:

sseEmitterService.sendMessageToOneClient(clientId, "[DONE]");
sseEmitterService.closeConnect(clientId);

修改后:

       
// 定义see接口
Request request = new Request.Builder().url("https://api.openai.com/v1/chat/completions").header("Authorization","xxx").post(okhttp3.RequestBody.create(okhttp3.MediaType.parse("application/json; charset=utf-8"),param.toJSONString())).build();
OkHttpClient okHttpClient = new OkHttpClient.Builder().connectTimeout(10, TimeUnit.MINUTES).readTimeout(10, TimeUnit.MINUTES)//这边需要将超时显示设置长一点,不然刚连上就断开,之前以为调用方式错误被坑了半天.build();// 实例化EventSource,注册EventSource监听器
RealEventSource realEventSource = new RealEventSource(request, new EventSourceListener() {@Overridepublic void onOpen(EventSource eventSource, Response response) {log.info("onOpen");}@SneakyThrows@Overridepublic void onEvent(EventSource eventSource, String id, String type, String data) {// log.info("onEvent");// 在实际应用中,你可以在这里将数据推送给前端log.info(data);//请求到的数据}@Overridepublic void onClosed(EventSource eventSource) {log.info("onClosed");sseEmitterService.sendMessageToOneClient(clientId, "[DONE]");sseEmitterService.closeConnect(clientId);
//                emitter.complete();}@Overridepublic void onFailure(EventSource eventSource, Throwable t, Response response) {log.error("onFailure 出现异常,response={}", response, t);//这边可以监听并重新打开sseEmitterService.sendMessageToOneClient(clientId, "[DONE]");sseEmitterService.closeConnect(clientId);
//                emitter.complete();}
});
realEventSource.connect(okHttpClient);//真正开始请求的一步

输出效果如下:

在这里插入图片描述

参考文章:

java模拟GPT流式问答

Springboot 集成 SSE 向前端推送消息

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/diannao/77110.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

硬盘分区格式之GPT(GUID Partition Table)笔记250407

硬盘分区格式之GPT&#xff08;GUID Partition Table&#xff09;笔记250407 GPT&#xff08;GUID Partition Table&#xff09;硬盘分区格式详解 GPT&#xff08;GUID Partition Table&#xff09;是替代传统 MBR 的现代分区方案&#xff0c;专为 UEFI&#xff08;统一可扩展固…

Vite环境下解决跨域问题

在 Vite 开发环境中&#xff0c;可以通过配置代理来解决跨域问题。以下是具体步骤&#xff1a; 在项目根目录下找到 vite.config.js 文件&#xff1a;如果没有&#xff0c;则需要创建一个。配置代理&#xff1a;在 vite.config.js 文件中&#xff0c;使用 server.proxy 选项来…

交换机与ARP

交换机与 ARP&#xff08;Address Resolution Protocol&#xff0c;地址解析协议&#xff09; 的关系主要体现在 局域网&#xff08;LAN&#xff09;内设备通信的地址解析与数据帧转发 过程中。以下是二者的核心关联&#xff1a; 1. 基本角色 交换机&#xff1a;工作在 数据链…

【Spring】小白速通AOP-日志记录Demo

这篇文章我将通过一个最常用的AOP场景-方法调用日志记录&#xff0c;带你彻底理解AOP的使用。例子使用Spring BootSpring AOP实现。 如果对你有帮助可以点个赞和关注。谢谢大家的支持&#xff01;&#xff01; 一、Demo实操步骤&#xff1a; 1.首先添加Maven依赖 <!-- Sp…

git功能点管理

需求&#xff1a; 功能模块1 已经完成&#xff0c;已经提交并推送到远程&#xff0c;准备交给测试。功能模块2 已经完成&#xff0c;但不提交给测试&#xff0c;继续开发。功能模块3 正在开发中。 管理流程&#xff1a; 创建并开发功能模块1&#xff1a; git checkout main…

QGIS实战系列(六):进阶应用篇——Python 脚本自动化与三维可视化

欢迎来到“QGIS实战系列”的第六期!在前几期中,我们从基础操作到插件应用逐步提升了 QGIS 技能。这一篇,我们将迈入进阶领域,探索如何用 Python 脚本实现自动化,以及如何创建三维可视化效果,让你的 GIS 项目更高效、更立体。 第一步:Python 脚本自动化 QGIS 内置了 Py…

高德地图 3D 渲染-区域纹理图添加

引入-初始化地图&#xff08;关键代码&#xff09; // 初始化页面引入高德 webapi -- index.html 文件 <script src https://webapi.amap.com/maps?v2.0&key您申请的key值></script>// 添加地图容器 <div idcontainer ></div>// 地图初始化应该…

ffmpeg视频转码相关

ffmpeg视频转码相关 简介参数 实战举栗子获取视频时长视频转码mp4文件转为hls m3u8 ts等文件图片转视频抽取视频第一帧获取基本信息 转码日志输出详解转码耗时测试 简介 FFmpeg 是领先的多媒体框架&#xff0c;能够解码、编码、 转码、复用、解复用、流、过滤和播放 几乎所有人…

【ISP】HDR技术中Sub-Pixel与DOL的对比分析

一、原理对比 Sub-Pixel&#xff08;空间域HDR&#xff09; • 核心机制&#xff1a;在单个像素内集成一大一小两个子像素&#xff08;如LPD和SPD&#xff09;&#xff0c;利用其物理特性差异&#xff08;灵敏度、满阱容量&#xff09;同时捕捉不同动态范围的信号。 ◦ 大像素&…

Vulnhub-IMF靶机

本篇文章旨在为网络安全渗透测试靶机教学。通过阅读本文&#xff0c;读者将能够对渗透Vulnhub系列IMF靶机有一定的了解 一、信息收集阶段 靶机下载地址&#xff1a;https://www.vulnhub.com/entry/imf-1,162/ 因为靶机为本地部署虚拟机网段&#xff0c;查看dhcp地址池设置。得…

Linux内核中TCP协议栈的实现:tcp_close函数的深度剖析

引言 TCP(传输控制协议)作为互联网协议族中的核心协议之一,负责在不可靠的网络层之上提供可靠的、面向连接的字节流服务。Linux内核中的TCP协议栈实现了TCP协议的全部功能,包括连接建立、数据传输、流量控制、拥塞控制以及连接关闭等。本文将深入分析Linux内核中tcp_close…

java+postgresql+swagger-多表关联insert操作(七)

入参为json&#xff0c;然后根据需要对多张表进行操作&#xff1a; 入参格式&#xff1a; [{"custstoreName":"swagger-测试经销商01","customerName":"swagger-测试客户01","propertyNo":"swaggertest01",&quo…

R语言——绘制生命曲线图(细胞因子IL5)

绘制生命曲线图&#xff08;根据细胞因子&#xff09; 说明流程代码加载包读取Excel文件清理数据重命名列名处理IL-5中的"<"符号 - 替换为检测下限的一半首先找出所有包含"<"的值检查缺失移除缺失值根据IL-5中位数将患者分为高低两组 创建生存对象拟…

Python----计算机视觉处理(Opencv:道路检测完整版:透视变换,提取车道线,车道线拟合,车道线显示,)

Python----计算机视觉处理&#xff08;Opencv:道路检测之道路透视变换) Python----计算机视觉处理&#xff08;Opencv:道路检测之提取车道线&#xff09; Python----计算机视觉处理&#xff08;Opencv:道路检测之车道线拟合&#xff09; Python----计算机视觉处理&#xff0…

【Oracle篇】跨字符集迁移:基于数据泵的ZHS16GBK转AL32UTF8全流程迁移

&#x1f4ab;《博主主页》&#xff1a;奈斯DB-CSDN博客 &#x1f525;《擅长领域》&#xff1a;擅长阿里云AnalyticDB for MySQL(分布式数据仓库)、Oracle、MySQL、Linux、prometheus监控&#xff1b;并对SQLserver、NoSQL(MongoDB)有了解 &#x1f496;如果觉得文章对你有所帮…

【C++算法】50.分治_归并_翻转对

文章目录 题目链接&#xff1a;题目描述&#xff1a;解法C 算法代码&#xff1a;图解 题目链接&#xff1a; 493. 翻转对 题目描述&#xff1a; 解法 分治 策略一&#xff1a;计算当前元素cur1后面&#xff0c;有多少元素的两倍比我cur1小&#xff08;降序&#xff09; 利用单…

深入讲解:智能合约中的读写方法

前言 在探秘区块链开发:智能合约在 DApp 中的地位及与传统开发差异一文中我提到对于智能合约中所有的写入其实都算是交易。而在一个完整的智能合约代码中最大的两个组成部分就是读取和写入。 本文将为你深入探讨该两者方法之间的区别。 写方法 写方法其实就是对区块链这一…

Go语言类型捕获及内存大小判断

代码如下&#xff1a; 类型捕获可使用&#xff1a;reflect.TypeOf()&#xff0c;fmt.Printf在的%T。 内存大小判断&#xff1a;len()&#xff0c;unsafe.Sizeof。 package mainimport ("fmt""unsafe""reflect" )func main(){var i , j 1, 2f…

MyBatis Plus 在 ZKmall开源商城持久层的优化实践

ZKmall开源商城作为基于 Spring Cloud 的高性能电商平台&#xff0c;其持久层通过 MyBatis Plus 实现了多项深度优化&#xff0c;涵盖分库分表、缓存策略、分页性能、多租户隔离等核心场景。以下是具体实践总结&#xff1a; 一、分库分表与插件集成优化 1. 分库分表策略 ​Sh…

学习MySQL第七天

夕阳无限好 只是近黄昏 一、子查询 1.1 定义 将一个查询语句嵌套到另一个查询语句内部的查询 我们通过具体示例来进行演示&#xff0c;这一篇博客更侧重于通过具体的小问题来引导大家独立思考&#xff0c;然后熟悉子查询相关的知识点 1.2 问题1 谁的工资比Tom高 方…