블로그 목록
ai

Vercel AI SDK 커스텀 데이터 파트로 진행 상황 스트리밍하기 — 긴 작업 중 화면을 비워두지 않기

에이전트에 툴을 붙이면 응답이 나오기까지 시간이 꽤 걸린다. 검색 툴 한 번 돌고, 그 결과로 다시 정리하고, 그러다 보면 쉽게 10초를 넘긴다. 문제는 그동안 화면이다. 텍스트 스트리밍은 LLM이 최종 답을 내기 시작해야 흐르니까, 툴이 도는 몇 초 동안 사용자는 아무것도 못 본다. 스피너만 돌아간다.

"검색 중...", "결과 3건 정리 중..." 같은 상태라도 보여주고 싶었다. 그런데 이건 LLM이 생성하는 텍스트가 아니라 내 서버가 아는 정보다. Vercel AI SDK v5의 커스텀 데이터 파트가 딱 이걸 위한 것이었다.

서버에서 상태를 직접 밀어넣는다

createUIMessageStream으로 스트림을 열면 writer가 나온다. 여기에 data- 접두사를 붙인 임의의 파트를 직접 써넣을 수 있다. LLM 응답 스트림은 writer.merge로 같은 스트림에 합친다.

// app/api/chat/route.ts
import { createUIMessageStream, createUIMessageStreamResponse, convertToModelMessages, streamText } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';

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

  const stream = createUIMessageStream({
    execute: async ({ writer }) => {
      writer.write({ type: 'data-status', id: 'job', data: { text: '검색 중...' } });

      // 같은 id로 다시 쓰면 앞의 파트를 덮어쓴다 — 상태 갱신
      writer.write({ type: 'data-status', id: 'job', data: { text: '결과 정리 중...' } });

      const result = streamText({
        model: anthropic('claude-sonnet-5-5'),
        messages: convertToModelMessages(messages),
      });
      writer.merge(result.toUIMessageStream());
    },
  });

  return createUIMessageStreamResponse({ stream });
}

id를 고정하면 같은 파트를 계속 갱신할 수 있다. 상태 문구 하나만 바꿔 끼우는 식이라 "검색 중" → "정리 중"이 자연스럽게 이어진다.

클라이언트는 파트로 받는다

메시지가 이제 텍스트 파트만 있는 게 아니라 data-status 파트도 섞여 온다. message.parts를 돌면서 타입으로 갈라 렌더하면 된다.

{message.parts.map((part, i) => {
  if (part.type === 'text') return <span key={i}>{part.text}</span>;
  if (part.type === 'data-status') return <p key={i} className="text-sm opacity-60">{part.data.text}</p>;
})}

기록에 남길 필요 없는 순간 알림이라면 writer.write에 transient: true를 주자. 이러면 메시지 파트에는 안 쌓이고, 클라이언트 useChat({ onData }) 콜백으로만 흘러온다. 토스트 띄우고 사라지게 할 때 딱 맞다.

정리

핵심은 하나다. LLM이 만드는 텍스트와, 내 서버가 아는 진행 상황은 다른 채널이라는 것. 커스텀 데이터 파트는 그 둘을 한 스트림에 얹어준다. 툴이 오래 도는 에이전트일수록, 이 몇 줄이 체감 차이를 크게 만든다. 빈 화면 대신 "지금 뭐 하는 중"을 보여주는 것만으로 대기가 훨씬 견딜 만해진다.