Springboot使用流式Stream对话接入gpt

背景

springboot对接gpt,实现流式对话传输

后端

接口层

为了实现对话流式传输,需要设置接口返回类型,同时设置下响应Header(Header不添加也可以)

@PostMapping(value = "/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public SseEmitter sse(@Validated @RequestBody AnalyzeChatVO vo, HttpServletResponse response) {
        response.setHeader("Cache-Control", "no-cache");
        response.setHeader("Connection", "keep-alive");
        return gptService.analyzeChatStream(vo);
    }

实现

需要注意的是,输出的内容需要异步返回,你用线程池或者线程都可以,只需要异步就可以了

public SseEmitter analyzeChatStream(AnalyzeChatVO vo) {
        SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);

        if (StringUtils.isEmpty(vo.getUser())) {
            vo.setUser(UsernameHolder.getUsername());
        }
        ThreadPoolExecutor executor = ThreadPoolUtil.simpleThreadPool("chat", 1, 1);
        try {
            CompletableFuture.runAsync(() -> streamRequest(vo, new SseListener(emitter, this)), executor).whenComplete((r, t) -> {
                if (t != null) {
                    emitter.completeWithError(t);
                    log.error("Stream request start error,", t);
                }
            });
        } finally {
            executor.shutdown();
        }
        return emitter;
    }

这里的监听器是通过okhttp来实现的,因此需要先引入okhttp的sse模块

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

然后将我们自定义的监听器注册上去,其中baseUrl就是我们模型的地址,然后携带对应的token就可以了;

模型请求中携带的chatId,主要是为了用户隔离

private void streamRequest(AnalyzeChatVO vo, EventSourceListener listener) {
        GptClient client = getStreamClient();
        String url = client.getAttribute().getBaseUrl() + "/api/v1/chat/completions";
        
        log.info("Stream url:{}", url);
        OkHttpClient okHttpClient = client.getOkHttpClient();
        EventSource.Factory factory = EventSources.createFactory(okHttpClient);

        String requestBody = String.format("{\"chatId\": \"%s\",\"stream\": true, \"messages\": [{\"role\":\"user\", \"content\": \"%s\"}]}",
                    vo.getUser(), vo.getQuestion().replace("\n", ""));
      
        Request.Builder builder = new Request.Builder()
                .url(url)
                .header("Authorization", client.getToken());
                .post(RequestBody.create(requestBody, okhttp3.MediaType.parse(MediaType.APPLICATION_JSON.toString())));
       
        Request request = builder.build();
        factory.newEventSource(request, listener);
    }

监听器

这里自定义的监听器主要就是实现EventSourceListener 的相关方法;ChatCompletion主要就是定义了对话的返回结构体,lastMessage 用于接收整个完整的返回消息,因为消息是按照流式一部分一部分返回的,这里拼接下完整消息内容,也可以去掉

public abstract class AbstractStreamListener extends EventSourceListener {

    protected String lastMessage = "";

    private static final String STREAM_END = "[DONE]";

    @Setter
    @Getter
    protected Consumer<String> onComplete = s -> {

    };

    public abstract void onMsg(String message);

    public abstract void onError(Throwable throwable, String response);

    @Override
    public void onOpen(EventSource eventSource, Response response) {
        log.info("Open");
    }

    @Override
    public void onClosed(EventSource eventSource) {
        log.info("Closed");
    }

    @Override
    public void onEvent(EventSource eventSource, String id, String type, String data) {
        log.info("Event:{}", data);
        if (STREAM_END.equals(data)) {
            onMsg(data);
            onComplete.accept(lastMessage);
            return;
        }

        ChatCompletion response = JSON.parseObject(data, ChatCompletion.class);

        String text = response.toPlainStringStream();

        Map<String, String> dataToSend = Maps.newHashMap();
        dataToSend.put("content", text);
        if (StringUtils.isNotEmpty(text)) {
            lastMessage += text;

            // fix to raw data, avoid '\n' messages be resolved
            onMsg(JSON.toJSONString(dataToSend));
        }

    }


    @SneakyThrows
    @Override
    public void onFailure(EventSource eventSource, Throwable throwable, Response response) {
        log.info("Fail", throwable);
        try {
            String responseText = "";
            if (Objects.nonNull(response) && Objects.nonNull(response.body())) {
                responseText = response.body().string();
            }
            log.error("Listener failure response:{}", responseText);
            this.onError(throwable, responseText);
        } catch (Exception e) {
            log.error("Listener on failure error,", e);
        } finally {
            eventSource.cancel();
        }
    }
}

最终的实现在SseListener 中,将监听器中收到的消息转发到SseEmitter;同时在消息完成后,打印一下完整的消息内容

public class SseListener extends AbstractStreamListener {

    private SseEmitter emitter;

    public SseListener(SseEmitter emitter) {
        this.emitter = emitter;
        super.setOnComplete((s) -> {
            log.info("Complete message:{}", s);
            emitter.complete();
        });
    }

    @Override
    public void onMsg(String message) {
        log.info(message);
        try {
            emitter.send(message);
        } catch (IOException e) {
            log.error("Send message error,", e);
        }
    }

    @Override
    public void onError(Throwable throwable, String response) {
        log.error("Listener error: {}", response, throwable);
        emitter.completeWithError(throwable);
    }
}

这样后端的简单实现就算是完成了

Nginx

如果你的项目中请求是通过nginx代理的,那么还需要调整下nginx的配置,主要是添加proxy_redirect off; proxy_buffering off;这两个配置,关闭nginx的缓存功能

location  /sse {
        add_header Access-Control-Allow-Origin *;
        add_header Access-Control-Allow-Methods 'GET, POST, OPTIONS';
        add_header Access-Control-Allow-Headers 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $http_host;
        proxy_redirect off;
        proxy_buffering off;
        proxy_cache off;
        proxy_pass http://upstream;
    }
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 220,192评论 6 511
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,858评论 3 396
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 166,517评论 0 357
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 59,148评论 1 295
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 68,162评论 6 397
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,905评论 1 308
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,537评论 3 420
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,439评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,956评论 1 319
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 38,083评论 3 340
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 40,218评论 1 352
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,899评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,565评论 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 32,093评论 0 23
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,201评论 1 272
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,539评论 3 375
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 45,215评论 2 358

推荐阅读更多精彩内容