Post

GPT 응답 스트리밍을 웹소켓으로 화면에 보여주기

서비스에서 LLM 응답 스트리밍을 구현하는 방법

GPT 응답 스트리밍을 웹소켓으로 화면에 보여주기

ChatGPT 대화를 하면 한 번에 텍스트 응답이 오는 것이 아니라, 한 문장이 여러 덩어리(chunk) 단위로 나뉘어 오는 것을 볼 수 있다. API 요청 시에도 마찬가지로 응답을 chunk 단위로 처리할 수가 있다.

서비스 구성

Frontend <–> Backend <-(API call)-> OpenAI

먼저, OpenAI API 호출을 포함한 서비스 구성은 위와 같다.

프론트에서의 사용자 요청이 백엔드로 들어오고, 백엔드에서 OpenAI 로 API 요청을 하여 그에 대한 응답을 chunk 단위로 받아 곧장 프론트 단에 보여주는 방식이다. ChatGPT 응답을 전부 기다렸다가 한 번에 화면에 보여줄 수도 있지만, 서비스 사용성을 위해 응답을 스트리밍하기로 한다.

웹소켓이란?

웹소켓(WebSocket)은 웹 서버와 웹 브라우저 간의 실시간 양방향 통신을 가능하게 하는 프로토콜입니다. HTTP와 달리 연결을 지속적으로 유지하여, 서버에서 클라이언트로의 데이터 전송이 빈번하게 일어나는 실시간 웹 애플리케이션에 유용합니다.

웹소켓은 클라이언트와 서버가 지속적으로 연결을 유지하며, 데이터 송수신이 자유로운 양방향 통신을 지원합니다. 기존 HTTP 방식처럼 요청-응답 방식이 아닌, 실시간으로 데이터를 주고받을 수 있습니다.

구현 방법

Backend 구성

1
2
gem 'actioncable' # 웹소켓
gem 'ruby-openai' # OpenAI API

필요한 gem 들을 먼저 설치해주고, rails generate channel GptChatChannel 명령어를 실행한다. GptChatChannel 는 채널명이다. 그럼 Rails Magic(?)으로 서버/클라이언트 파일이 생성된다. 파일을 하나씩 훑어보진 않겠다.

1
2
# config/routes.rb
mount ActionCable.server => '/cable'

라우팅에 위와 같이 프론트에서의 요청을 처리하기 위한 경로를 설정하고,

1
2
3
4
5
6
7
8
9
10
11
12
# nginx 설정
map $http_upgrade $connection_upgrade {
  default upgrade;
  ''      close;
}
...
location /cable {
  proxy_pass http://service-server;
  proxy_http_version 1.1;
  proxy_set_header Upgrade $http_upgrade;
  proxy_set_header Connection $connection_upgrade;
}

nginx 도 적절히 설정해준다. 코드의 자세한 의미는 GPT 에게 물어보면 잘 설명해준다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class GptChatChannel < ApplicationCable::Channel
  def subscribed
    @user_id = params['user_id']
    stream_from room_name
  end

  def send_message(data)
    user_id = data['user_id']
    stream_proc = proc do |chunk, _|
      new_content = chunk.dig('choices', 0, 'delta', 'content') || ''
      ActionCable.server.broadcast("public_#{user_id}", {content: new_content})
    end

    client = OpenAI::Client.new(access_token: 'access_token')
    parameters = {
      model: 'gpt-3.5-turbo',
      messages: [
        {role: 'user', content: data['user_content']},
        {role: 'system', content: data['system_content']}
      ],
      temperature: 0
    }
    client.chat(parameters: parameters.merge(stream: stream_proc))
  end
end

코드 맨 아래 라인에서 stream 인자에 procedure(proc)를 OpenAI::Client#chat 의 파라미터로 넘긴다. 이로 인해, API 응답이 ActionCable 을 통해 Frontend 로 broadcast 된다.

Frontend 구성

1
2
# node module install
npm install actioncable-vue

서비스 프론트단은 vue 로 구현되어 있어서 관련된 모듈을 먼저 설치해준다.

1
2
3
4
5
6
7
8
9
// src/main.js
import ActionCableVue from 'actioncable-vue';

Vue.use(ActionCableVue, {
  debug: true,
  debugLevel: 'error',
  connectionUrl: `wss://${location.host}/cable`,
  connectImmediately: true
});

Vue 어플리케이션에서 필요한 선언을 해준다. connectionUrl 에는 암호화된 웹소켓 연결(WebSocket Secure)을 선언하는데 이 때 ‘/cable’ 은 위 Backend 구성에서 라우팅 설정 시 할당해준 이름이다.

이제 웹소켓을 사용하기 위한 준비는 전부 됐고, 아래와 같이 어플리케이션 컴포넌트 코드에서 활용하면 된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
 mounted() {
  this.$cable.subscribe({
    channel: 'GptChatChannel',
    room: this.user_id
  });
}
...
channels: {
  GptChatChannel: {
    connected() {
      // 웹소켓 연결 시의 처리
      console.log('GptChatChannel connected');
    },
    received(data) {
      // Backend 에서 받은 응답에 대한 처리
      console.log(data);
    }
  }
}
...
// Backend 코드와 매칭
this.$cable.perform({
  channel: 'GptChatChannel',
  action: 'send_message',
  data: {
    user_content: 'user_content',
    system_content: 'system_content',
    user_id: this.user_id
  }
});

코드를 간단히 설명하면,

  • mounted: 컴포넌트가 마운트될 때, ActionCable 로 웹소켓 연결(subscribe)
  • channels: 선언한 채널이 연결되거나, 스트리밍 데이터를 받을 때에 대한 처리 선언
  • $cable.perform: 프론트엔드에서 백엔드로 요청을 보낼 때에 data 에 필요한 인자를 담아보냄

결론

서비스에서 ChatGPT 로 API 요청을 하고, 그에 대한 응답 chunk 를 (웹소켓 스트리밍으로) 서비스 화면에 나타내는 방법을 알아보았다. 코드를 최대한 간단히 적는다고 했는데 쓰고 나니 꽤 복잡한 것 같기도 하다. 누군가에게 도움이 되기를 바라며 늘 그렇듯이 황급히 글을 마친다.


This post is licensed under CC BY 4.0 by the author.