Vercel AI SDK로 AI 챗 만들기: 최소 구성부터 스트리밍·도구 호출·RAG·멀티 프로바이더까지

이 글의 핵심

설치와 useChat 최소 구성으로 동작하는 챗을 만든 뒤, 프로덕션에서 걸리는 지점을 순서대로 다룹니다. 훅 설계와 스트리밍 중단, 백프레셔, 멀티스텝 도구 호출, OpenAI·Anthropic·Ollama 전환, Edge 제약, RAG, 운영 체크리스트까지 한 흐름으로 정리합니다.

이 글의 핵심

Vercel AI SDK는 서버의 streamText / generateText 계열과 클라이언트의 useChat / useCompletion을 같은 스트리밍 프로토콜로 묶어, LLM 앱에서 반복되던 보일러플레이트를 줄여 줍니다. SSE 파서를 직접 짜고, 프로바이더마다 다른 응답 형식을 맞추고, 메시지 상태를 손으로 관리하던 일이 대부분 사라집니다. 이 글은 설치와 최소 구성에서 시작해 프로덕션에서 마주치는 상태 관리·중단·도구·멀티 프로바이더·Edge·RAG를 차례로 다룹니다.

버전 전제: Next.js App Router와 TypeScript를 가정하고, 코드는 AI SDK 4.x(ai@4, @ai-sdk/react@1) API를 기준으로 합니다. AI SDK는 메이저 버전마다 API가 크게 바뀌었습니다. 3.x의 toAIStreamResponse()는 4.0에서 제거되어 toDataStreamResponse()가 되었고, 5.x에서는 useChat이 입력 상태(input, handleSubmit)를 더 이상 관리하지 않고 sendMessage를 쓰며, 메시지가 parts 배열로 바뀌고, 서버 응답은 toUIMessageStreamResponse(), 도구 스키마 키는 inputSchema, 단계 제한은 stopWhen: stepCountIs(n)으로 바뀌었습니다. 설치된 버전의 마이그레이션 가이드를 함께 확인하세요.


시작하기: 설치와 최소 구성

패키지는 코어(ai)와 프로바이더 패키지(@ai-sdk/openai 등)로 나뉩니다. 코어는 스트림·훅·도구 계약을, 프로바이더 패키지는 각 회사 API와의 변환만 맡습니다.

npm install ai@4 @ai-sdk/react@1 @ai-sdk/openai@1 zod
# .env.local — 서버에서만 읽힙니다. NEXT_PUBLIC_ 접두사를 붙이면 브라우저 번들에 들어가니 주의
OPENAI_API_KEY=sk-...

서버 Route는 아래 “서버: streamText와 응답 변환”의 예제 그대로이고, 클라이언트는 useChat 하나로 입력·전송·메시지 목록·로딩 상태를 받습니다.

// app/page.tsx
'use client';
import { useChat } from '@ai-sdk/react';

export default function Chat() {
  // 기본 경로 /api/chat. 4.x 후반에는 isLoading 대신 status('submitted' | 'streaming' | 'ready' | 'error')도 제공
  const { messages, input, handleInputChange, handleSubmit, isLoading } = useChat();
  return (
    <div>
      {messages.map((m) => (
        <div key={m.id}>
          <strong>{m.role}:</strong> {m.content}
        </div>
      ))}
      {isLoading && <div>응답 생성 중…</div>}
      <form onSubmit={handleSubmit}>
        <input value={input} onChange={handleInputChange} placeholder="질문을 입력하세요" />
        <button type="submit">보내기</button>
      </form>
    </div>
  );
}

여기까지가 “동작하는 챗”의 전부입니다. 실제로 시간이 드는 곳은 이 다음으로, 중지 버튼을 눌러도 서버에서 토큰 생성이 계속되는 문제(“클라이언트 중단과 req.signal”)나 도구 호출이 끝없이 반복되는 문제(“멀티스텝과 maxSteps”)가 대표적입니다. 아래 장들은 그런 지점을 순서대로 다룹니다.


아키텍처 관점에서 본 AI SDK

고급 패턴을 이해하려면 요청이 어떻게 흐르는지 먼저 고정하는 것이 좋습니다.

  1. 브라우저의 useChat 또는 useCompletion이 API 경로로 POST합니다. 본문에는 메시지 목록이나 프롬프트, 채팅 ID, 추가 컨텍스트가 실립니다.
  2. Route Handler가 streamText로 모델을 호출하고, 결과를 훅이 이해하는 데이터 스트림 응답으로 변환해 반환합니다. 도구 호출과 구조화 출력도 streamText 안에서 처리됩니다.
  3. 텍스트 델타와 도구 이벤트가 순서대로 도착하며, 클라이언트 훅이 메시지 배열이나 completion 문자열을 갱신합니다.

이후 다룰 내용은 대부분 서버에서 스트림·중단 신호·도구 정책을 어떻게 통제할지, 그리고 클라이언트에서 요청 본문·에러·중단·후처리를 어떻게 묶을지의 두 가지 문제로 나뉩니다.


useChat 고급 패턴

API 경로·헤더·공통 페이로드

useChat은 기본적으로 /api/chat으로 요청합니다. 여러 채널(내부 지식베이스, 고객 지원)을 한 앱에서 쓴다면 api 옵션으로 엔드포인트를 분리하고, 인증 토큰이나 테넌트 ID는 headers로, UI 언어나 선택된 문서 ID처럼 클라이언트만 아는 값은 body로 실어 보냅니다. body와 headers는 사용자가 마음대로 바꿀 수 있는 값이므로, 서버는 이를 힌트로만 쓰고 권한 검사와 컨텍스트 구성은 서버 쪽 데이터로 다시 해야 합니다.

세션·초기 메시지·키 안정화

id 옵션은 채팅 세션 식별자입니다. 같은 id를 쓰는 useChat 인스턴스는 상태를 공유하므로, 서버 측 저장소(Redis, DB)의 대화 키와 맞춰 두면 복원이 쉽습니다. 서버에서 불러온 과거 대화는 initialMessages로 주입하는데, 이때 메시지 ID 형식을 서버와 클라이언트가 같은 규칙으로 만들어야 저장 시 중복이 생기지 않습니다.

요청 본문 가공 (experimental_prepareRequestBody 등)

훅이 보내는 JSON 그대로가 아니라 서버가 기대하는 스키마로 바꿔 보내야 할 때가 있습니다. 4.x의 experimental_prepareRequestBody를 쓰면 전체 메시지 대신 마지막 메시지만 보내고 이전 대화는 서버가 DB에서 읽게 하거나, 클라이언트 전용 필드를 제거할 수 있습니다. 대화가 길어질수록 매 요청에 전체 기록을 싣는 비용이 커지므로, 서버에 기록을 저장하는 구조라면 이 방식이 유리합니다.

수명 주기 훅: onFinish, onError

onFinish는 한 턴의 응답이 끝났을 때 호출되어 분석 이벤트나 UI 후처리에 씁니다. 다만 대화를 DB에 저장하는 일은 클라이언트 onFinish보다 서버 streamText의 onFinish에서 하는 편이 맞습니다. 클라이언트 콜백은 사용자가 탭을 닫으면 호출되지 않고, 조작된 내용이 저장될 수도 있기 때문입니다. onError는 네트워크 오류, 4xx/5xx, 스트림 파싱 실패에서 호출되어 토스트나 재시도 버튼(reload())을 띄우는 데 씁니다.

AI SDK 4.x의 toDataStreamResponse()는 기본적으로 서버 에러 내용을 클라이언트에 그대로 보내지 않고 일반화된 메시지로 가립니다. 원인을 보여 주고 싶다면 getErrorMessage 옵션으로 사용자에게 보여도 되는 문구만 골라 반환하고, 상세 내용은 서버 로그에 남깁니다.

스트리밍 중단: stop과 AbortSignal

사용자가 “중지”를 누르면 진행 중인 스트림을 취소해야 합니다. useChat의 stop()은 내부적으로 fetch의 AbortController를 abort합니다. 서버 Route에서 streamText의 abortSignal에 req.signal을 넘겨 두어야 클라이언트의 중단이 모델 제공자 호출까지 전달됩니다.

중단은 단순히 UI를 멈추는 것이 아니라 비용·지연·동시 요청 한도에도 영향을 줍니다. “중지 후 같은 메시지 재전송” 같은 경계 조건도 함께 설계합니다.

도구·멀티스텝과의 결합

도구 호출이 포함되면 한 번의 사용자 입력에 모델 → 도구 → 모델이 연쇄됩니다. 4.x의 메시지에는 toolInvocations(그리고 parts)가 들어오므로, 클라이언트에서 state가 'call'인 도구 호출을 “검색 중…” 같은 상태로 따로 표시할 수 있습니다. 서버에서 maxSteps로 상한을 두면 끝없이 이어지는 연쇄를 막을 수 있습니다.


useCompletion 고급 패턴

useCompletion은 단일 입력 → 단일 스트리밍 completion에 최적화되어 있습니다. 채팅이 아닌 에디터 보조, 코드 생성, 요약 폼 등에 적합합니다.

api, body, headers

채팅과 동일하게 엔드포인트·공통 헤더·추가 필드를 붙일 수 있습니다. 예를 들어 “선택한 코드 범위”나 “출력 형식(JSON/Markdown)”을 body에 실어 서버에서 시스템 프롬프트를 조립합니다.

부분 결과와 후처리

completion 문자열은 스트리밍 중 계속 누적됩니다. 스트리밍 중에는 원문 미리보기만 보여 주고 완료 후에 Markdown 렌더링이나 코드 포매터를 적용하면, 닫히지 않은 코드 블록 때문에 화면이 깜빡이는 문제를 피할 수 있습니다. 최종 문자열 검증은 onFinish에서 합니다.

대화 맥락과 역할이 있는 메시지 목록이 중요하면 useChat, 한 번에 하나의 생성 결과가 중심이면 useCompletion을 고르면 됩니다. 둘 다 백엔드에서는 streamText를 쓰지만 요청 본문이 다릅니다(messages 배열 vs prompt 문자열). 또 useCompletion은 기본적으로 데이터 스트림 프로토콜을 기대하므로, 서버가 toTextStreamResponse()로 평문 스트림을 보낸다면 클라이언트에 streamProtocol: 'text'를 지정해야 합니다.


스트리밍과 중단 처리 — 서버·클라이언트 계약

서버: streamText와 응답 변환

Route Handler에서 흔한 형태는 다음과 같습니다.

// app/api/chat/route.ts
import { openai } from '@ai-sdk/openai';
import { streamText } from 'ai';

// 스트리밍 응답이 길어질 수 있으므로 함수 최대 실행 시간을 늘림(플랫폼 한도 내)
export const maxDuration = 30;

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

  // 4.x에서 streamText는 동기적으로 결과 객체를 반환합니다(await 불필요)
  const result = streamText({
    model: openai('gpt-4o'),
    messages,
    abortSignal: req.signal, // 클라이언트 중단을 모델 호출까지 전달
    onFinish: async ({ text, usage }) => {
      // 대화 저장·사용량 기록은 서버에서
    },
  });

  return result.toDataStreamResponse();
}

toDataStreamResponse()는 useChat이 읽는 데이터 스트림 프로토콜로 응답을 만듭니다. 서버와 클라이언트 패키지의 메이저 버전이 어긋나면 프로토콜이 맞지 않아 스트림이 비거나 파싱 오류가 나므로, ai와 @ai-sdk/react를 함께 올려야 합니다.

클라이언트 중단과 req.signal

브라우저가 요청을 abort하면 Request의 signal이 취소됩니다. 서버에서 이 신호를 모델 호출에 전달하면 불필요한 토큰 생성을 끊을 수 있습니다. 다만 프록시나 런타임에 따라 연결 종료가 서버에 늦게 전달되거나 아예 전달되지 않을 수 있고, 프로바이더가 이미 생성한 토큰은 과금되므로, maxTokens 같은 비용 상한은 별도로 둡니다. 반대로 중단해도 대화를 끝까지 저장해야 한다면 result.consumeStream()을 호출해 클라이언트 연결과 무관하게 스트림을 끝까지 소비시키는 방법도 있습니다(이 경우 abortSignal은 넘기지 않습니다).

서버 전용 소비: fullStream

스트림을 UI가 아니라 로그·변환기·2차 처리로 보내야 할 때는 fullStream을 순회하는 패턴이 사용됩니다. 예를 들어 특정 이벤트(도구 호출 시작·종료)만 필터링해 관측 시스템으로 보낼 수 있습니다. 운영 환경에서는 PII 마스킹 정책을 먼저 정합니다.

느린 네트워크와 긴 생성

스트림은 토큰 단위 청크를 클라이언트로 밀어 넣습니다. 클라이언트 쪽 네트워크가 느리면 데이터는 서버의 버퍼에 쌓이고, 서버리스·Edge 플랫폼은 대개 함수 실행 시간에 상한이 있어 긴 생성이 중간에 끊길 수 있습니다. 몇 분짜리 생성(긴 보고서, 대량 번역)은 한 번의 스트림으로 버티기보다 작업 단위로 나누고, 진행 상황은 별도 상태(작업 ID + 폴링 또는 별도 이벤트 채널)로 보여 주는 편이 안정적입니다.

증상별 점검 포인트

증상먼저 볼 것
빈 스트림만 온다모델 ID 오타, API 키, 프로바이더 장애
ReadableStream 관련 런타임 오류Edge와 Node API 차이, runtime 설정
도구만 반복 호출한다프롬프트, maxSteps, 도구 설명이 너무 넓지 않은지
로컬에선 되는데 배포하면 스트림이 한꺼번에 온다프록시·CDN의 응답 버퍼링, 압축 설정
CORS·쿠키 문제Route Handler 경로, 프록시, 쿠키 도메인

Function Calling과 Tools

tool()과 Zod 스키마

도구는 설명(description)·입력 스키마·실행 함수의 세 부분으로 구성됩니다. Zod로 스키마를 정의하면 타입 안전성과 런타임 검증을 동시에 가져갈 수 있습니다.

import { openai } from '@ai-sdk/openai';
import { streamText, tool } from 'ai';
import { z } from 'zod';

async function getWeather(location: string) {
  // 외부 API 대역—타임아웃·에러 매핑은 실무에서 필수
  return { location, temperatureC: 22, condition: 'clear' };
}

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

  const result = streamText({
    model: openai('gpt-4o'),
    messages,
    tools: {
      weather: tool({
        description: '주어진 도시의 현재 날씨를 조회합니다.',
        parameters: z.object({
          location: z.string().describe('도시 이름, 예: Seoul'),
        }),
        execute: async ({ location }) => getWeather(location),
      }),
    },
    maxSteps: 5, // 모델 → 도구 → 모델 라운드를 최대 5단계로 제한
  });

  return result.toDataStreamResponse();
}

Zod 스키마는 모델이 만든 인자를 execute 호출 전에 검증하지만, 형식이 맞는다고 내용까지 안전한 것은 아닙니다. 모델이 만든 인자는 사용자 입력과 같은 수준으로 취급해, execute 안에서 권한 검사·입력 정규화·외부 API 타임아웃·레이트 리밋을 적용합니다. 도구 설명(description)과 각 필드의 describe()는 모델이 언제 이 도구를 부를지 결정하는 근거이므로, 기능을 구체적으로 적을수록 엉뚱한 호출이 줄어듭니다.

멀티스텝과 maxSteps

도구 결과를 다시 모델에 넣어 답을 이어가려면 4.x에서는 maxSteps를 2 이상으로 지정해야 합니다. 기본값은 1이어서, 지정하지 않으면 모델이 도구를 호출하고 결과를 받은 시점에서 응답이 끝나 사용자는 최종 답변을 보지 못합니다. 반대로 값을 크게 잡으면 모델이 같은 도구를 반복 호출할 때 비용이 그만큼 늘어나므로, 실제 필요한 단계 수에 맞춰 작게 잡습니다.

toolChoice와 제어

toolChoice에 'auto'(기본), 'required', 'none', 또는 { type: 'tool', toolName: 'weather' }를 지정해 모델이 도구를 반드시 쓰게 하거나 아예 쓰지 못하게 할 수 있습니다. 결제·삭제 같은 위험한 작업은 toolChoice로 해결할 문제가 아니라, 해당 도구에 execute를 두지 않고 클라이언트에서 사용자 확인을 받은 뒤 addToolResult로 결과를 넘기는 식의 확인 단계를 둬야 합니다.


다양한 프로바이더: OpenAI, Anthropic, Mistral

AI SDK의 이점은 모델 생성 부분만 프로바이더 팩토리로 교체할 수 있다는 점입니다.

팩토리 함수로 단일화

import { openai } from '@ai-sdk/openai';
import { anthropic } from '@ai-sdk/anthropic';
import { mistral } from '@ai-sdk/mistral';
import { streamText } from 'ai';

type ProviderName = 'openai' | 'anthropic' | 'mistral';

function getModel(provider: ProviderName, modelId: string) {
  switch (provider) {
    case 'openai':
      return openai(modelId);
    case 'anthropic':
      return anthropic(modelId);
    case 'mistral':
      return mistral(modelId);
    default:
      throw new Error(`unsupported provider: ${provider}`);
  }
}

// streamText({ model: getModel('anthropic', 'claude-3-5-sonnet-latest'), ... })

로컬 모델도 같은 방식으로 붙습니다. 커뮤니티 프로바이더인 ollama-ai-provider를 쓰면 개발 중에는 Ollama로 비용 없이 돌리고, 배포에서만 상용 모델로 바꾸는 구성이 가능합니다.

import { createOllama } from 'ollama-ai-provider';

const ollama = createOllama(); // 기본 http://localhost:11434
// streamText({ model: ollama('llama3'), messages })

다만 로컬 모델은 도구 호출·구조화 출력 지원 수준이 상용 모델과 달라서, 개발 환경에서 잘 되던 도구 호출이 배포 후 모델에서 다르게 동작할 수 있습니다. 도구가 핵심인 기능은 배포 모델로도 테스트하세요.

기능 차이 추상화

각 프로바이더 패키지는 기본으로 OPENAI_API_KEY, ANTHROPIC_API_KEY, MISTRAL_API_KEY 같은 환경 변수를 읽습니다. 모델 ID는 문자열 상수나 설정으로 빼 두어야 모델 폐기(deprecation) 때 한곳만 고치면 됩니다. SDK가 호출 형식을 통일해 주더라도 도구 호출의 정확도, 구조화 출력 지원 방식, 컨텍스트 길이, 시스템 메시지 처리는 프로바이더마다 다르므로, 앱이 실제로 의존하는 기능만 노출하는 얇은 어댑터를 두고 모델을 바꿀 때마다 같은 평가 세트로 확인하는 편이 안전합니다.


Edge Runtime 최적화

export const runtime = 'edge'

Edge는 콜드 스타트가 짧고 사용자와 가까운 위치에서 실행된다는 장점이 있습니다. 다만 LLM 응답 시간은 대부분 모델 생성 시간이 차지하므로, 사용자와 가까운 곳에서 함수를 실행해 얻는 이득은 생각보다 작습니다. 오히려 DB가 한 리전에 있으면 Edge 함수가 DB까지 먼 거리를 왕복하게 됩니다. Vercel도 현재는 Edge보다 Node.js 런타임 사용을 권장하는 쪽으로 문서를 바꾸었으므로, Edge는 필요가 분명할 때만 고르는 편이 낫습니다.

Edge를 쓴다면 Node 전용 모듈(fs, 네이티브 의존성)이 동작하지 않는다는 점이 가장 큰 제약입니다. 문서 전처리나 무거운 DB 드라이버는 별도 Node Route나 백그라운드 워커로 분리하고, 실행 시간·메모리·번들 크기 한도도 플랫폼 문서에서 확인합니다.


RAG 통합

RAG(검색 증강 생성)는 검색 품질과 프롬프트 설계가 곧 제품 품질입니다. Vercel AI SDK 입장에서는 최종적으로 messages에 근거가 된 컨텍스트를 넣어 streamText를 호출하면 됩니다.

파이프라인 개요

흐름은 마지막 사용자 질의를 embed로 임베딩하고, 벡터 DB(또는 키워드와 벡터를 섞은 하이브리드 검색)에서 관련 청크를 찾아, 출처 정보를 붙인 컨텍스트를 시스템 프롬프트에 넣은 뒤 streamText를 호출하는 순서입니다.

import { openai } from '@ai-sdk/openai';
import { embed, streamText } from 'ai';

export async function POST(req: Request) {
  const { messages } = await req.json();
  const lastUser = [...messages].reverse().find((m: { role: string }) => m.role === 'user');
  const query = typeof lastUser?.content === 'string' ? lastUser.content : '';

  const { embedding } = await embed({
    model: openai.embedding('text-embedding-3-small'),
    value: query,
  });

  // 아래는 예시: 실제로는 Supabase pgvector, Pinecone, Weaviate 등 클라이언트 호출
  const chunks = await fakeVectorSearch(embedding);

  const context = chunks
    .map((c, i) => `[#${i + 1}] ${c.title}\n${c.text}`)
    .join('\n\n');

  const result = streamText({
    model: openai('gpt-4o'),
    // system 옵션을 쓰면 클라이언트가 보낸 messages에 system 역할을 섞지 않아도 됨
    system:
      'You are a support assistant. Answer only from the context. If insufficient, say you do not know.\n\n' +
      context,
    messages,
  });

  return result.toDataStreamResponse();
}

async function fakeVectorSearch(_embedding: number[]) {
  return [
    { title: '환불 정책', text: '디지털 상품은 결제 후 7일 이내 미사용 시 환불 가능합니다.' },
  ];
}

청크 설계·메타데이터

청크가 작으면 검색은 정확해지지만 문맥이 잘리고, 크면 문맥은 살지만 관련 없는 내용이 섞이며 토큰 비용이 늘어납니다. 문서 ID·버전·URL을 메타데이터로 함께 저장해 두면 UI에서 출처 링크를 보여 줄 수 있습니다. 제품명, 에러 코드, 조항 번호처럼 정확한 문자열이 중요한 질의는 임베딩 검색이 놓치기 쉬우므로, 키워드 검색을 섞은 하이브리드 검색이 도움이 됩니다.

클라이언트가 보낸 messages를 그대로 모델에 넘긴다는 점도 기억해야 합니다. 사용자는 요청 본문을 조작해 가짜 어시스턴트 메시지나 지시문을 끼워 넣을 수 있으므로, 권한이 다른 문서를 검색 범위에서 걸러 내는 일은 반드시 서버 쪽 사용자 정보로 합니다.

환각 완화

컨텍스트에 없는 내용을 지어내는 문제는 프롬프트·온도·출력 검증뿐 아니라, 검색 결과가 비었을 때의 분기(“관련 문서를 찾지 못했습니다”)를 명시적으로 처리해야 완화됩니다.


운영에서 챙길 것

공개 챗 엔드포인트는 누구나 호출할 수 있는 유료 API 프록시가 되기 쉽습니다. 로그인 사용자나 세션 단위로 레이트 리밋을 걸고, 요청당 maxTokens와 입력 메시지 길이에 상한을 두는 것이 첫 번째 방어선입니다. 같은 세션에서 응답이 끝나기 전에 또 전송하는 경우를 막으려면 전송 중에는 입력을 비활성화하고, 서버에서도 채팅 ID 단위로 중복 요청을 거릅니다.

토큰 사용량은 서버 onFinish의 usage로 직접 기록해 두면 프로바이더 청구서와 대조할 수 있고, 요청 ID를 클라이언트 로그·서버 로그·프로바이더 호출에 함께 남기면 “이 답변이 왜 나왔는가”를 나중에 추적할 수 있습니다.


같이 보면 좋은 글