SSE ํ์ฉํ AI Streaming Chat ๊ตฌํ (w. React) - 1ํ: ์๋ฒ ๊ตฌํ
๋ฏธ๋ฆฌ๋ณด๊ธฐ
์ ์ฒด ์ฝ๋๋ ์๋์!
Spring Boot์ SSE๋ฅผ ํ์ฉํ์ฌ AI Streaming Chat ์๋น์ค๋ฅผ ๊ตฌํํ์๋ค.
์ฌ์ค ์ฑํ ์คํธ๋ฆฌ๋ฐ์ ๊ตฌํํ๊ธฐ ์ํ ๋ค์ํ ์ธ์ด, ๊ธฐ์ ์ด ๋ง์ง๋ง ํ์ ์์ ์ฌ์ฉํ๋ ์ธ์ด์ SSE๋ฅผ ๊ฒฝํํ๊ณ ์ถ์ด ํด๋น ๊ธฐ์ ์คํ์ ํ์ฉํ์๋ค.
(์ฆ, ์คํธ๋ฆฌ๋ฐ ์ฑํ ์ ๊ตฌํํ๊ธฐ ์ํ ๊ธฐ์ ์คํ์ ๋ค์ํ๋ฏ๋ก ์ํฉ๊ณผ ์กฐ๊ฑด์ ๋ง๊ฒ ์ฌ์ฉํ๊ธธ ๋ฐ๋๋ค.)
SSE(Server-Sent-Event)๋?
ํด๋ผ์ด์ธํธ๊ฐ ์๋ฒ๋ก๋ถํฐ ๋จ๋ฐฉํฅ ์คํธ๋ฆฌ๋ฐ ๋ฐ์ดํฐ๋ฅผ ๋ฐ์ ์ ์๋๋ก ์ง์ํ๋ ๊ธฐ์ ์ด๋ค.
- ๋จ๋ฐฉํฅ ํต์ : ์๋ฒ์์ ํด๋ผ์ด์ธํธ๋ก๋ง ๋ฐ์ดํฐ๋ฅผ ์ ์กํ ์ ์์
- HTTP ๊ธฐ๋ฐ: ๊ธฐ์กด HTTP ํ๋กํ ์ฝ์ ์ฌ์ฉํ๊ธฐ ๋๋ฌธ์ ๋ฐฉํ๋ฒฝ ๋ฐ ํ๋ก์ ํ๊ฒฝ์์ ์ฌ์ฉํ๊ธฐ ์ฉ์ดํจ
- ์๋ ์ฌ์ฐ๊ฒฐ: ํด๋ผ์ด์ธํธ์์ SSE ์ฐ๊ฒฐ์ด ๋์ด์ก์ ๊ฒฝ์ฐ ์๋์ผ๋ก ์ฌ์ฐ๊ฒฐํ๋ ๊ธฐ๋ฅ ์ ๊ณต
- ํ ์คํธ ๊ธฐ๋ฐ ์ ์ก: JSON, XML ๋ฑ์ ๋ฐ์ดํฐ๋ฅผ ํ ์คํธ๋ก ์ ์ก ๊ฐ๋ฅ
- ์์ฌ์ด ๊ตฌํ: ๋ค์ํ API๋ก ์ ๊ณต๋์ด ๊ฐ๋จํ๊ฒ ์ฌ์ฉํ ์ ์์
Spring Boot SseEmitter
Spring Boot์์๋ SseEmitter ํด๋์ค๋ฅผ ์ ๊ณตํ์ฌ SSE๋ฅผ ์ฝ๊ฒ ๊ตฌํํ ์ ์๋ค. (docs)
์ฃผ์ ๋ฉ์๋
๋ฉ์๋ ์ค๋ช
send(Object data) | ํด๋ผ์ด์ธํธ์๊ฒ ๋ฐ์ดํฐ๋ฅผ ์ ์ก |
complete() | SSE ์คํธ๋ฆผ์ ์ ์์ ์ผ๋ก ์ข ๋ฃ |
completeWithError(Throwable t) | ์ค๋ฅ ๋ฐ์ ์ ์คํธ๋ฆผ ์ข ๋ฃ |
onCompletion(Runnable callback) | ์คํธ๋ฆผ ์๋ฃ ์ ์คํํ ์ฝ๋ฐฑ ์ง์ |
onTimeout(Runnable callback) | ์คํธ๋ฆผ์ด ํ์์์๋ ๊ฒฝ์ฐ ์คํํ ์ฝ๋ฐฑ ์ง์ |
AI Streaming Chat with SseEmitter
OpenAiClient.kt
OpenAI API Request
OpenAI ์ ChatCompletion API ๋ฅผ ์ํ Request๋ฅผ ์์ฑํ๋ค. (openai docs)
ํด๋น ํ๋ก์ ํธ๋ ์ ๊ตํ ์ค์ ์ด ํ์ํ์ง ์์, ํ์ ๊ฐ๋ง ์ฑ์๋ฃ์ ์ ์๋ DTO๋ก ๊ตฌํํ์๋ค.
val request = ChatCompletionRequest.build(message)
// Request DTO
data class ChatCompletionRequest(
val model: String = "gpt-3.5-turbo",
val messages: List<ChatCompletionMessageRequest>,
val stream: Boolean = true, // --- (1)
) {
data class ChatCompletionMessageRequest(
val role: String = "user",
val content: String,
)
companion object {
fun build(message: String): ChatCompletionRequest = ChatCompletionRequest(
messages = listOf(
ChatCompletionMessageRequest(
content = message
)
)
)
}
}
- ์๋ต์ Streaming์ผ๋ก ๋ฐ์์ผํ๊ธฐ ๋๋ฌธ์, ํด๋น ๊ฐ์ true๋ก ์ค์ ํ์. ๋ง์ฝ, ํ๋ฒ์ ์๋ต์ ๋ฐ๊ณ ์ถ๋ค๋ฉด ํ๋ ๊ฐ์ ์ค์ ํ์ง ์๊ฑฐ๋ false๋ก ์ค์ ํ๋ฉด๋๋ค.
WebClient
์ฐ์ , OpenAI์์ ChatCompletion API์์ ์ด๋ป๊ฒ ์๋ต์ด ์ค๋์ง ํ์ธํด๋ณด์.
์ด๋ฅผ ์ํด, ์๋ต ํ์ฑ์ ์ฃผ์๋ฅผ ๊ธฐ์ธ์ฌ์ผ ํ๋ค๋ ๊ฒ์ ์ ์ ์๋ค.
๊ทธ๋ผ ์๋ WebClient ์ฝ๋๋ฅผ ๋ณด์.
fun chatCompletion(message: String): SseEmitter {
val request = ChatCompletionRequest.build(message)
return SseEmitter().also { emitter ->
WebClient.create()
.post()
.uri("<https://api.openai.com/v1/chat/completions>")
.contentType(MediaType.APPLICATION_JSON)
.header("Authorization", "Bearer $token")
.body(BodyInserters.fromValue(request))
.exchangeToFlux { response -> response.bodyToFlux() } // --- (1)
.doOnNext { data -> // --- (2)
if (data.equals("[DONE]")) {
emitter.complete()
} else {
objectMapper.readValue(data, ChatCompletionResponse::class.java).getContent()
?.let { emitter.send("\\"$it\\"") }
}
}
.doOnComplete(emitter::complete)
.doOnError(emitter::completeWithError)
.subscribe()
}
}
- `exchangeToFlux`: ์๋ฒ๋ก๋ถํฐ ๋ฐ์ ์๋ต์ Flux<String>์ผ๋ก ๋ณํ
- `Flux`: 0๊ฐ ์ด์์ ๋ฐ์ดํฐ๋ฅผ ๋น๋๊ธฐ์ ์ผ๋ก ์คํธ๋ฆฌ๋ฐํ ์ ์๋ Reactor์ ๋ผ์ด๋ธ๋ฌ๋ฆฌ
- `bodyToFlux<String>`() : ์ฐ์๋ ์๋ต Chunk๋ฅผ ํ ์ค์ฉ `Flux`๋ก ๋ณํํ์ฌ ๋น๋๊ธฐ์ ์ผ๋ก ๋ฐ์ดํฐ๋ฅผ ์คํธ๋ฆฌ๋ฐ ํ ์ ์๋๋ก ํจ
- `doOnNext`: String์ผ๋ก ์ ๋ณํ ๋ ๋ฐ์ดํฐ๋ก ์๋ง์ ์ฒ๋ฆฌ๋ฅผ ์งํ
- `"[DONE]"` : ์ ์ค์ open ai ์๋ต์์ ๋ณด์๋ฏ์ด ์ ๋ฐ์ดํฐ๊ฐ ๋ฐํ๋๋ค๋ฉด ์คํธ๋ฆฌ๋ฐ ์ข
๋ฃ๋ฅผ ์๋ฏธํจ
- `emitter.complete()`: SSE ์คํธ๋ฆผ์ ์ ์์ ์ผ๋ก ์ข ๋ฃ์ํด
- ๊ทธ ์ธ: json ํ์์ ChatCompletionResponse๋ก ํ์ฑํจ
- `emitter.send(it)` : ๋ณํ๋ Response ๋ด content๋ฅผ ๊ฐ์ ธ์ SSE ์คํธ๋ฆผ์ผ๋ก ์ ์ก
- `"[DONE]"` : ์ ์ค์ open ai ์๋ต์์ ๋ณด์๋ฏ์ด ์ ๋ฐ์ดํฐ๊ฐ ๋ฐํ๋๋ค๋ฉด ์คํธ๋ฆฌ๋ฐ ์ข
๋ฃ๋ฅผ ์๋ฏธํจ
ChatController.kt
ํด๋ผ์ด์ธํธ๋ก SseEmitter๋ก ์ ์กํ๋ ์ฝ๋๋ฅผ ์์ฑํ์.
Chat ์๋ต์ ์ผ์ ํ Json ํฌ๋งท์ผ๋ก ๋ด๋ ค์ค์ง๋ง, ์๋ต์ด ์ข ๋ฃ๋์์ ๊ฒฝ์ฐ “[DONE]” ํ ์คํธ๋ก ๋ด๋ ค์ค๋ ๊ฒ์ ๋ณผ ์ ์๋ค. (docs์๋ ๋ช ์๋์ด์์)
@GetMapping(produces = [MediaType.TEXT_EVENT_STREAM_VALUE])
fun chat(@RequestParam("query") message: String): SseEmitter {
return openAiClient.chatCompletion(message)
}
- `MediaType.TEXT_EVENT_STREAM_VALUE`
- ํด๋น MediaType์ ์ฌ์ฉํ๋ฉด, HTTP์๋ต์ Content-Type์ด text/event-stream์ผ๋ก ์ค์
- ์ด๋ SSE ๋ฐฉ์์ผ๋ก ๋ฐ์ดํฐ๋ฅผ ์คํธ๋ฆฌ๋ฐํ ๊ฒ์ ๋ํ๋
- ํด๋น MediaType์ ์ฌ์ฉํ๋ฉด, HTTP์๋ต์ Content-Type์ด text/event-stream์ผ๋ก ์ค์
- `SseEmitter` : ์๋ฒ์์ ์ด๋ฅผ ๋ฐํํ๋ฉด, ํด๋ผ์ด์ธํธ์์๋ `EventSource API`๋ฅผ ์ฌ์ฉํ์ฌ ์๋ต ์์
๊ฒฐ๊ณผ
ํด๋น API๋ก ์์ฒญ์ ๋ณด๋ด๋ฉด, SSE ์๋ต์ผ๋ก OpenAI๋ก ๋ฐ์ ์๋ต ํ๊ธ์์ฉ์ Client๋ก ๋ด๋ ค์ฃผ๋ ๊ฒ์ ๋ณผ ์ ์๋ค.
๋ค์ ๊ธ์์๋ ์ด EventSource๋ฅผ ๋ฐ์ Chat ํ๋ฉด์ ๊ทธ๋ฆฌ๋ React ํ๋ก์ ํธ์ ๋ํด ์์ฑํ๊ฒ ๋ค.
(์ฌ์ค ํ๋ก ํธ ์ฝ๋๋ ๋ค GPT๊ฐ ์์ฑํด์คฌ๋ค ๐)
์ ์ฒด ์ฝ๋
openai-chat/openai at master · jeongum/openai-chat
Contribute to jeongum/openai-chat development by creating an account on GitHub.
github.com