블로그 목록
AI

Vercel AI SDK v5 useChat 개편 — messages 배열이 UIMessage로 바뀌었다

프로젝트를 Vercel AI SDK v4에서 v5로 올리려다가 useChat 붙은 파일에서 컴파일 에러가 좌르륵 떴다. message.content가 없어지고, input이랑 handleSubmit도 훅에서 사라졌다.

v5부터 chat message 구조 자체가 바뀐 탓이다. 한 메시지는 이제 텍스트 한 덩어리가 아니라 parts 배열이다. 텍스트, 툴 호출, 리즈닝이 각각 파트로 들어가는 형태다.

프론트엔드 — messages는 이제 parts 배열이다

'use client';
import { useState } from 'react';
import { useChat } from '@ai-sdk/react';

export default function Chat() {
  const { messages, sendMessage, status } = useChat();
  const [input, setInput] = useState('');

  return (
    <div>
      {messages.map((m) => (
        <div key={m.id}>
          <b>{m.role}:</b>
          {m.parts.map((part, i) => {
            if (part.type === 'text') return <span key={i}>{part.text}</span>;
            if (part.type === 'reasoning') return <em key={i}>{part.text}</em>;
            return null;
          })}
        </div>
      ))}
      <form
        onSubmit={(e) => {
          e.preventDefault();
          sendMessage({ text: input });
          setInput('');
        }}
      >
        <input value={input} onChange={(e) => setInput(e.target.value)} />
      </form>
    </div>
  );
}

message.content를 그대로 꺼내던 v4 코드는 다 안 먹는다. parts.map으로 파트 타입별로 렌더링해야 한다. 툴 호출 결과나 리즈닝 블록이 별도 파트로 분리돼서 UI 분기가 오히려 깔끔해졌다.

input이랑 handleSubmit도 훅에서 뺐다. 어차피 컴포넌트에서 로컬 state로 관리하는 게 자연스러운데, 그걸 훅에 안 두는 방향으로 바뀐 거다. 전송은 sendMessage({ text }) 한 줄이면 된다.

서버 — UIMessage[]를 ModelMessage[]로 변환해 넘긴다

API 라우트에도 변화가 있다. 클라이언트에서 UIMessage 배열이 오는데, LLM은 그걸 그대로 못 먹는다. convertToModelMessages로 바꿔서 넘겨야 한다.

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

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

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

  return result.toUIMessageStreamResponse();
}

두 개가 세트다. 입력은 convertToModelMessages로 UIMessage → ModelMessage, 응답은 toUIMessageStreamResponse()로 UIMessage 스트림. 이걸 안 맞추면 클라이언트에 파트가 안 들어와 화면이 그냥 빈다.

마이그레이션할 때 걸리는 것들

  • v4의 message.content를 참조하던 곳은 전부 parts 순회로 바꿔야 한다. 텍스트만 뽑고 싶으면 parts.filter(p => p.type === 'text').map(p => p.text).join('').
  • 툴 호출 렌더링이 편해졌다. part.type'tool-이름' 형태라 툴별로 UI 컴포넌트를 따로 분기하기 쉽다.
  • API 라우트 응답은 toUIMessageStreamResponse()로 통일. v4의 toDataStreamResponse()는 사라졌다.

새 구조가 언뜻 번거로워 보이는데, 툴 호출 붙은 챗봇을 한 번이라도 만들어보면 파트 단위로 나뉜 게 훨씬 낫다. 텍스트, 툴, 리즈닝을 자연스럽게 별도 컴포넌트로 뽑을 수 있어서, 하나의 문자열 안에 다 우겨넣던 v4 시절보다 UI 짜기가 편해진다. useChat 붙인 앱을 v5로 올릴 계획이라면 프론트/API 양쪽 다 메시지 구조부터 손봐야 한다.