다단계 인터페이스 설계

다단계 인터페이스 설계 (Designing Multistep Interfaces)

~해당 작업(예: 항공편 예약)을 완료하기 위해 여러 독립적인 단계가 필요한 사용자 인터페이스인 다단계 인터페이스(multistep interfaces)를 설계하는 방법을 설명하는 문서예요.

출처: 문서

본문

경고: AI SDK RSC는 현재 실험 단계(experimental)예요. 프로덕션에서는 AI SDK UI 사용을 권장해요. RSC에서 UI로 마이그레이션하는 방법은 마이그레이션 가이드를 참고하세요.

다단계 인터페이스(multistep interfaces)는 특정 작업을 완료하기 위해 여러 독립적인 단계를 순서대로 실행해야 하는 사용자 인터페이스를 말해요.

예를 들어 항공편을 예약할 수 있는 생성형 UI 챗봇을 만들고 싶다면, 세 단계가 있을 수 있어요:

  • 모든 항공편 검색
  • 항공편 선택
  • 가용성 확인

이런 종류의 애플리케이션을 구축하려면 툴 합성(tool composition) 과 애플리케이션 컨텍스트(application context) 라는 두 가지 개념을 활용할 거예요.

툴 합성은 여러 툴을 결합해 새 툴을 만드는 과정이에요. 이는 복잡한 작업을 더 작고 관리하기 쉬운 단계로 분해할 수 있게 해 주는 강력한 개념이에요. 위 예제에서 "모든 항공편 검색", "항공편 선택", "가용성 확인" 이 모여 포괄적인 "항공편 예약" 툴을 만들어요.

애플리케이션 컨텍스트는 특정 시점의 애플리케이션 상태를 말해요. 여기에는 사용자의 입력, 언어 모델의 출력, 기타 관련 정보가 포함돼요. 위 예제에서 "항공편 선택" 에서 선택된 항공편은 "가용성 확인" 작업을 완료하는 데 필요한 컨텍스트로 사용돼요.

개요 (Overview)

@ai-sdk/rsc로 다단계 인터페이스를 구축하려면 몇 가지가 필요해요:

  • streamUI 함수를 호출하고 그 결과를 반환하는 Server Action
  • 툴(전체 작업을 완료하기 위해 필요한 하위 작업)
  • 툴이 호출될 때 렌더링되어야 하는 React 컴포넌트
  • 챗봇을 렌더링할 페이지

따라야 할 일반적인 흐름은 다음과 같아요:

  • 사용자가 메시지를 보내요 (useActions로 Server Action을 호출하며 메시지를 입력으로 전달)
  • 메시지가 AI State에 추가된 뒤 여러 툴과 함께 모델로 전달돼요
  • 모델이 툴을 호출하기로 결정할 수 있으며, 그러면 <SomeTool /> 컴포넌트가 렌더링돼요
  • 그 컴포넌트 안에서 useActions로 Server Action을 호출해 모델을 호출하고, useUIState로 모델의 응답(<SomeOtherTool />)을 UI State에 추가해 상호작용을 더할 수 있어요
  • 이런 식으로 계속돼요...

구현 (Implementation)

턴 바이 턴(turn-by-turn) 구현은 다단계 인터페이스의 가장 단순한 형태예요. 이 구현에서 사용자와 모델은 대화 중 번갈아 가며 진행해요. 매 사용자 입력마다 모델이 응답을 생성하고, 이런 턴 바이 턴 방식으로 대화가 계속돼요.

다음 예제에서는 모델이 항공편을 검색하고 특정 항공편의 세부 정보를 조회하는 데 사용할 수 있는 두 툴(searchFlights, lookupFlight)을 지정해요.

import { streamUI } from '@ai-sdk/rsc';
import { openai } from '@ai-sdk/openai';
import { z } from 'zod';

const searchFlights = async (
  source: string,
  destination: string,
  date: string,
) => {
  return [
    {
      id: '1',
      flightNumber: 'AA123',
    },
    {
      id: '2',
      flightNumber: 'AA456',
    },
  ];
};

const lookupFlight = async (flightNumber: string) => {
  return {
    flightNumber: flightNumber,
    departureTime: '10:00 AM',
    arrivalTime: '12:00 PM',
  };
};

export async function submitUserMessage(input: string) {
  'use server';

  const ui = await streamUI({
    model: openai('gpt-6-astra'),
    instructions: 'you are a flight booking assistant',
    prompt: input,
    text: async ({ content }) => <div>{content}</div>,
    tools: {
      searchFlights: {
        description: 'search for flights',
        inputSchema: z.object({
          source: z.string().describe('The origin of the flight'),
          destination: z.string().describe('The destination of the flight'),
          date: z.string().describe('The date of the flight'),
        }),
        generate: async function* ({ source, destination, date }) {
          yield `Searching for flights from ${source} to ${destination} on ${date}...`;
          const results = await searchFlights(source, destination, date);

          return (
            <div>
              {results.map(result => (
                <div key={result.id}>
                  <div>{result.flightNumber}</div>
                </div>
              ))}
            </div>
          );
        },
      },
      lookupFlight: {
        description: 'lookup details for a flight',
        inputSchema: z.object({
          flightNumber: z.string().describe('The flight number'),
        }),
        generate: async function* ({ flightNumber }) {
          yield `Looking up details for flight ${flightNumber}...`;
          const details = await lookupFlight(flightNumber);

          return (
            <div>
              <div>Flight Number: {details.flightNumber}</div>
              <div>Departure Time: {details.departureTime}</div>
              <div>Arrival Time: {details.arrivalTime}</div>
            </div>
          );
        },
      },
    },
  });

  return ui.value;
}

다음으로, UI State와 AI State를 담을 AI 컨텍스트를 만들어요.

import { createAI } from '@ai-sdk/rsc';
import { submitUserMessage } from './actions';

export const AI = createAI<any[], React.ReactNode[]>({
  initialUIState: [],
  initialAIState: [],
  actions: {
    submitUserMessage,
  },
});

다음으로, 새로 만든 컨텍스트로 애플리케이션을 감싸요.

import { type ReactNode } from 'react';
import { AI } from './ai';

export default function RootLayout({
  children,
}: Readonly<{ children: ReactNode }>) {
  return (
    <AI>
      <html lang="en">
        <body>{children}</body>
      </html>
    </AI>
  );
}

Server Action을 호출하려면 루트 페이지를 다음과 같이 업데이트하세요:

'use client';

import { useState } from 'react';
import { AI } from './ai';
import { useActions, useUIState } from '@ai-sdk/rsc';

export default function Page() {
  const [input, setInput] = useState<string>('');
  const [conversation, setConversation] = useUIState<typeof AI>();
  const { submitUserMessage } = useActions();

  const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    setInput('');
    setConversation(currentConversation => [
      ...currentConversation,
      <div>{input}</div>,
    ]);
    const message = await submitUserMessage(input);
    setConversation(currentConversation => [...currentConversation, message]);
  };

  return (
    <div>
      <div>
        {conversation.map((message, i) => (
          <div key={i}>{message}</div>
        ))}
      </div>
      <div>
        <form onSubmit={handleSubmit}>
          <input
            type="text"
            value={input}
            onChange={e => setInput(e.target.value)}
          />
          <button>Send Message</button>
        </form>
      </div>
    </div>
  );
}

이 페이지는 useUIState 훅으로 현재 UI State를 가져오고, 이를 매핑해 UI에 렌더링해요. Server Action에 접근하려면 useActions 훅을 사용하며, 이 훅은 actions.tsx 파일의 createAI 함수 actions 키에 전달된 모든 액션을 반환해요. 마지막으로 submitUserMessage 함수를 다른 TypeScript 함수처럼 호출해요. 이 함수는 React 컴포넌트(message)를 반환하며, setConversation으로 UI State를 업데이트해 UI에 렌더링돼요.

이 예제에서 다음 툴을 호출하려면, 사용자가 일반 텍스트로 응답해야 해요. React 컴포넌트를 스트리밍하고 있으므로, 대화의 다음 단계를 트리거하는 버튼을 추가할 수 있어요.

사용자 상호작용을 추가하려면 컴포넌트를 클라이언트 컴포넌트로 변환하고 useAction 훅으로 대화의 다음 단계를 트리거해야 해요.

'use client';

import { useActions, useUIState } from '@ai-sdk/rsc';
import { ReactNode } from 'react';

interface FlightsProps {
  flights: { id: string; flightNumber: string }[];
}

export const Flights = ({ flights }: FlightsProps) => {
  const { submitUserMessage } = useActions();
  const [_, setMessages] = useUIState();

  return (
    <div>
      {flights.map(result => (
        <div key={result.id}>
          <div
            onClick={async () => {
              const display = await submitUserMessage(
                `lookupFlight ${result.flightNumber}`,
              );

              setMessages((messages: ReactNode[]) => [...messages, display]);
            }}
          >
            {result.flightNumber}
          </div>
        </div>
      ))}
    </div>
  );
};

이제 searchFlights 툴을 새 <Flights /> 컴포넌트를 렌더링하도록 업데이트해요.

...
searchFlights: {
  description: 'search for flights',
  inputSchema: z.object({
    source: z.string().describe('The origin of the flight'),
    destination: z.string().describe('The destination of the flight'),
    date: z.string().describe('The date of the flight'),
  }),
  generate: async function* ({ source, destination, date }) {
    yield `Searching for flights from ${source} to ${destination} on ${date}...`;
    const results = await searchFlights(source, destination, date);
    return (<Flights flights={results} />);
  },
}
...

위 예제에서 Flights 컴포넌트는 검색 결과를 표시하는 데 사용돼요. 사용자가 항공편 번호를 클릭하면 lookupFlight 툴이 항공편 번호를 매개변수로 호출돼요. 그런 다음 submitUserMessage 액션을 호출해 대화의 다음 단계를 트리거해요.

Next.js App Router에서 툴 호출에 대해 더 알아보려면 예제들을 여기에서 확인하세요.

더 알아보기 (Learn more)