Vercel AI SDK를 활용해 Blocking UI에서 Streaming UI로 변경하기

SangBooom·2023년 7월 30일
9
post-thumbnail
post-custom-banner

개요

최근에 디프만 13기 활동을 통해 자기소개서를 더 잘 쓸 수 있도록 도와주는 서비스를 런칭했다.

그 중에 내가 맡은 경험분해라는 페이지는 사용자의 경험을 STAR 기법이나 여러가지 질문들, 선택한 역량 키워드를 통해 AI 추천 키워드와 AI 추천 자기소개서를 받아볼 수 있도록 하는 플로우이다.

AI 추천 자기소개서는 openAI API와 프롬프트 엔지니어링을 통해 처리된 응답 데이터(Blocking UI)를 받고 있고, 응답받기까지 걸리는 시간은 약 20초 정도였다.
이는 사용자에게 응답받을 때까지 로딩 화면을 보고 있어야 하는 답답함을 유발한다.

Streaming

위와 같은 UX문제를 해결할 수 있는 방법으로 Streaming 방식이 있다.

Streaming 방식은 일반적으로 데이터를 실시간으로 전송하고 처리하는 과정을 의미한다.
예를 들어, 동영상 스트리밍 서비스에서 사용자는 동영상을 온전히 받을 필요 없이 매 초마다 데이터를 받아 동영상을 보면서 용량을 효율적으로 사용할 수 있다.

출처 : https://sdk.vercel.ai/docs/concepts/streaming

Vercel이 어떻게 응답을 최적으로 Streaming하는 이유와 방법에 대해 자세히 설명하고 있는데 한번 읽어보시는걸 추천한다.

적용 방식 고민해보기

프로젝트 환경은 next.js(v13.4.1, app dir) 이다.

일단 Streaming 방식을 사용하기 위해선 백엔드에서 API 응답으로 ReadableStream 형식의 응답 데이터를 내려줘야 한다.
그리고 클라이언트에선 SSE(Server Sent Event)를 통해 ReadableStream 형식의 응답 데이터를 POST 방식으로 계속해서 받을 수 있도록 http 프로토콜을 하나 열어줘야한다.

Vercel AI 공식문서를 잘 찾아보면 백엔드에서 API 응답으로 ReadableStream 형식의 응답 데이터를 내려줄 수 있는 예시처럼 보이는 코드가 있으니 참고하면 좋을 것 같다.

하지만 백엔드에서 직접 readableStream 형식의 응답을 내려주는 경우 문제점이 있다.

일단 코드 복잡도 문제인데, 데이터를 순차적으로 넘겨주면 순차적으로 받아서 렌더링 해야돼서 전체적인 구성과 최적화에 신경 써야될게 엄청 많다고 한다.

두번째로 백엔드에서 발생한 에러를 프론트가 처리하기 어려울수 있다는 점인데 에러처리를 위해서 디테일한 응답을 받고 문제를 파악해야 한다고 한다.
이러한 문제들 때문에 Vercel AI SDK에서 제공하는 Route Handler를 사용하는게 권장이라고 한다.

그래서 이번 글에선 Route Handler 를 이용해 서버리스로 진행해보려고 한다.

Vercel AI SDK

간단히 소개하자면 최근에 vercel에서 ai 라는 패키지를 선보였는데, 다른 AI 플랫폼과 비교했을 때 개발자 친화적인 도구와 라이브러리를 제공해주고 docs도 매우 친절해서 적용하기 매우 간편하다.

자세한 내용은 Vercel AI SDK 소개 페이지를 참고 바랍니다.

Vercel AI SDK를 이용하면 chat(대화형), prompt(문장 완성형)를 hook을 통해 쉽게 핸들링 할 수 있게 된다.

지금 서비스에선 prompt 형식이 필요한데 SDK에서 제공하는 LLM 서비스 들이 정상 동작을 하지 않아서 chat 방식으로 갈아 탔다.

text-davinci-003 같은 경우는 다섯 글자 정도만 생성하고 더 이상 응답을 하지 않는다..

시작하기

  1. 시작 전에 OpenAI API Key를 발급받고 .env 파일에 넣어준다.
OPENAI_API_KEY=xxxxxxxxx
  1. ai 와 openai-edge를 다운받는다.
yarn add ai openai-edge
  1. api route를 추가해줍니다. (api/chat이 문제없이 호출될 수 있도록 아래 경로와 동일하게 추가해줍니다.)
// app/api/chat/route.ts

import { OpenAIStream, StreamingTextResponse } from 'ai';
import { Configuration, OpenAIApi } from 'openai-edge';

const config = new Configuration({
  apiKey: process.env.OPENAI_API_KEY,
});

const openai = new OpenAIApi(config);

export const runtime = 'edge';

export async function POST(req: Request) {
  const { messages } = await req.json();

  const response = await openai.createChatCompletion({
    model: 'gpt-3.5-turbo',
    stream: true,
    messages: messages.map((message: any) => ({
      content: `${프롬프트 엔지니어링으로 잘 고도화된 프롬프트}: ${message.content}`
      role: message.role,
    })),
  });

  const stream = OpenAIStream(response);
  return new StreamingTextResponse(stream);
}

트러블 슈팅

useChat hook의 default API 경로가 api/chat 이기 때문에 api 폴더 경로를 임의로 설정하면 정상작동하지 않는다.
api 엔드포인트를 변경하고 싶다면, api 호출부에서 useChat의 파라미터로 api 경로를 따로 설정해주면 된다.


  1. 컴포넌트에서 useChat hook을 호출하여 렌더시켜 준다.
// app/chat.tsx
'use client';

import React from 'react';

import { useChat } from 'ai/react';

import Button from '@/components/Button/Button';
import TextAreaField from '@/components/Input/TextAreaField/TextAreaField';

export default function Chat() {
  const { messages, input, handleInputChange, handleSubmit } = useChat({
    api: '/api/chat',
  });

  return (
    <div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">
      <form onSubmit={handleSubmit}>
        <label>
          무엇이든 물어보세요
          <TextAreaField value={input} onChange={handleInputChange} />
        </label>
        <Button variant="gray200" size="XL" type="submit">
          보내기
        </Button>
      </form>
      {messages.length > 0
        ? messages.map((m) => (
            <div key={m.id} className="whitespace-pre-wrap">
              {m.role === 'user' ? 'User: ' : 'AI: '}
              {m.content}
            </div>
          ))
        : null}
    </div>
  );
}

서비스에 적용하기

일단 원하는 기능은 prompt 방식이 더 적합하고 openai.createChatCompletion()useCompletion 훅을 통해 적용해야 했지만
LLM 서비스 모델(text-davinci-002, 003)이 원하는대로 정상 작동하지 않아 채팅형 모델인 gpt-35-turbo 를 사용했다.

그래서 대화형 방식을 페이지 진입시에 프롬프트에 동적으로 자기소개서 데이터가 들어가고 답변을 streaming 방식으로 받아야 했다.

아래와 같이 진입시에 handleSubmit을 트리거 시켰고 바로 프롬프트를 실행 시켜 실시간으로 데이터를 전송 받아서 화면에 그렸다.

'use client';

import React, { useEffect, useRef } from 'react';

import { useChat } from 'ai/react';

export default function Chat() {
  const { messages, setInput, handleSubmit } = useChat({
    onFinish: (message) => {
      // api를 통해 DB에 저장
      saveResume({ message });
    },
  });

  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    setInput((prev) => prev + ' ');
    setTimeout(() => {
      inputRef.current?.click();
    }, 1000);
  }, [setInput]);

  return (
    <div className="flex flex-col w-full max-w-md py-24 mx-auto stretch">
      {messages.length > 0
        ? messages.map((m) => (
            <div key={m.id} className="whitespace-pre-wrap">
              {m.role === 'user' ? 'User: ' : 'AI: '}
              {m.content}
            </div>
          ))
        : null}

      <form
        onSubmit={(e) => {
          // form 안에 form 형태일 때 submit 이벤트 전파를 막기 위함
          e.stopPropagation();
          handleSubmit(e);
        }}
        className="hidden">
        <input ref={inputRef} type="submit" />
      </form>
    </div>
  );
}

트러블 슈팅

사실 지금 서비스 경우에는 프롬프트가 정해져 있기 때문에 message.content 가 필요 없었다. 하지만 무조건 넘겨줘야 정상 작동한다..!!
그래서 사용자 인터렉션 없이 빈 input에 아무 text를 넣은 뒤에 submit 해야했다.

위는 core/react/useChat의 구현체인데 중간에 보면 input이 없으면 무조건 리턴하게 되어있다.

setInput을 통해 강제로 input을 채우고 form의 submit을 동작시키면 정상 작동하게 된다.
hook에서 제공하는 기능과 콜백이 많아서 상황에 따라 구현부와 type.d.ts를 보고 상황에 맞는 로직을 짜보자.


이상으로 간단하게 Vercel AI SDK를 활용하여 Blocking UI에서 Streaming UI로 리팩토링해서 사용자 경험을 증대시킨 경험이였습니다.
마지막으로 어떻게 변경됐는지 보여드리고 마치도록 하겠습니다.
긴 글 읽어주셔서 감사합니다.

AS IS (Blocking UI)

TO BE (Streaming UI)

profile
끊임없이 떨어지는 물방울이 바위를 뚫는다
post-custom-banner

3개의 댓글

comment-user-thumbnail
2023년 7월 30일

글 잘 봤습니다.

1개의 답글
comment-user-thumbnail
2023년 12월 28일

Vercel AI를 이용해서 사이드 프로젝트 해보려고 하는데 많은 도움이 되었습니다.

답글 달기