GPT 응답 스트리밍을 웹소켓으로 화면에 보여주기
서비스에서 LLM 응답 스트리밍을 구현하는 방법
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 를 (웹소켓 스트리밍으로) 서비스 화면에 나타내는 방법을 알아보았다. 코드를 최대한 간단히 적는다고 했는데 쓰고 나니 꽤 복잡한 것 같기도 하다. 누군가에게 도움이 되기를 바라며 늘 그렇듯이 황급히 글을 마친다.