AI 활용하기

AI SDK와 시그널 API의 디자인 패턴

AI와 대규모 언어 모델(LLM) API를 사용하면 비동기 작업 관리, 스트리밍 데이터 처리, 느리거나 불안정할 수 있는 네트워크 요청에 반응하는 사용자 경험 설계와 같은 고유한 과제를 마주하게 됩니다. Angular 시그널과 resource API는 이러한 문제를 간결하게 해결할 수 있는 강력한 도구입니다.

시그널로 요청 실행하기

사용자가 입력하는 프롬프트를 다룰 때는 실시간 입력값과 API 호출을 실행하는 제출값을 분리하는 패턴을 자주 사용합니다.

  1. 사용자가 입력하는 원래 값을 하나의 시그널에 저장합니다.
  2. 사용자가 버튼을 클릭하는 등의 방법으로 제출하면 첫 번째 시그널의 값을 두 번째 시그널에 반영합니다.
  3. 두 번째 시그널을 resource의 params 필드에 사용합니다.

이렇게 구성하면 사용자가 키를 누를 때마다 실행하는 대신, 명시적으로 프롬프트를 제출할 때만 리소스의 loader 함수를 실행할 수 있습니다. loader 필드에서는 sessionId나 userId와 같은 추가 시그널 매개변수를 사용할 수 있습니다(지속적인 LLM 세션을 만드는 데 유용합니다). 그러면 loader 필드에 정의한 비동기 함수를 다시 실행하지 않으면서 요청에 항상 이 매개변수의 현재 값을 사용할 수 있습니다.

여러 AI SDK는 API 호출을 위한 헬퍼 메서드를 제공합니다. 예를 들어 Genkit 클라이언트 라이브러리는 Genkit 플로를 호출하는 runFlow 메서드를 제공하며, 이 메서드는 리소스의 loader에서 호출할 수 있습니다. 다른 API에는 httpResource를 사용할 수 있습니다.

다음 예제는 AI가 생성한 이야기의 일부를 가져오는 resource입니다. loader는 storyInput 시그널이 변경될 때만 실행됩니다.

// AI가 생성한 이야기의 세 부분을 가져오는 리소스입니다.
storyResource = resource({
  // 첫 요청 전이나 오류가 발생했을 때 사용할 기본값입니다.
  defaultValue: DEFAULT_STORY,
  // 이 시그널이 변경되면 로더를 다시 실행합니다.
  params: () => this.storyInput(),
  // 데이터를 가져오는 비동기 함수입니다.
  loader: ({params}): Promise<StoryData> => {
    // params 값은 storyInput 시그널의 현재 값입니다.
    const url = this.endpoint();
    return runFlow({
      url,
      input: {
        userInput: params,
        sessionId: this.storyService.sessionId(), // 다른 시그널에서 값을 읽습니다.
      },
    });
  },
});

템플릿에 사용할 LLM 데이터 준비하기

LLM API가 구조화된 데이터를 반환하도록 설정할 수 있습니다. LLM의 예상 출력에 맞게 resource의 타입을 명확하게 지정하면 타입 안전성과 에디터의 자동 완성 기능을 개선할 수 있습니다.

리소스로부터 파생된 상태를 관리하려면 computed() 시그널이나 linkedSignal을 사용하세요. linkedSignal은 이전 값에 접근할 수 있기 때문에 다음과 같은 다양한 AI 활용 사례에 사용할 수 있습니다:

  • 채팅 기록 구성하기
  • LLM이 콘텐츠를 생성하는 동안 템플릿에 표시할 데이터를 유지하거나 변경하기

다음 예제의 storyParts는 storyResource가 반환한 최신 이야기 부분을 기존 이야기 배열에 추가하는 linkedSignal입니다.

storyParts = linkedSignal<string[], string[]>({
  // 연산을 실행하는 원본 시그널입니다.
  source: () => this.storyResource.value().storyParts,
  // 연산 함수입니다.
  computation: (newStoryParts, previous) => {
    // 이 linkedSignal의 이전 값이나 빈 배열을 가져옵니다.
    const existingStoryParts = previous?.value || [];
    // 기존 부분과 새 부분을 합친 새 배열을 반환합니다.
    return [...existingStoryParts, ...newStoryParts];
  },
});

성능과 사용자 경험

LLM API는 결과가 비교적 예측 가능한 일반적인 API보다 느리고 오류가 발생하기 쉽습니다. Angular의 여러 기능을 활용하면 성능이 좋고 사용하기 편한 인터페이스를 만들 수 있습니다.

  • 로딩 범위 제한: 데이터를 직접 사용하는 컴포넌트에 resource를 배치하세요. 이렇게 하면 특히 존리스 애플리케이션에서 변경 감지 사이클의 범위를 제한하고, 애플리케이션의 다른 부분이 대기하는 것을 방지할 수 있습니다. 여러 컴포넌트에서 데이터를 공유해야 한다면 서비스에서 resource를 제공하세요.
  • SSR과 하이드레이션: 점진적 하이드레이션과 서버 사이드 렌더링(SSR)을 함께 사용하면 초기 페이지 콘텐츠를 빠르게 렌더링할 수 있습니다. AI가 생성할 콘텐츠 대신 플레이스홀더를 표시하고, 클라이언트에서 컴포넌트가 하이드레이션될 때까지 데이터 요청을 미룰 수 있습니다.
  • 로딩 상태: 요청이 진행 중인 동안 resource의 LOADING 상태를 사용해 스피너와 같은 표시기를 보여주세요. 이 상태는 최초 로딩과 다시 로딩하는 경우를 모두 포함합니다.
  • 오류 처리와 재시도: resource의 reload() 메서드를 사용하면 사용자가 실패한 요청을 간단하게 재시도하도록 할 수 있습니다. AI가 생성하는 콘텐츠를 사용하는 경우에는 요청 실패가 더 자주 발생할 수 있습니다.

다음 예제는 로딩과 재시도 기능을 제공하면서 AI가 생성한 이미지를 동적으로 표시하는 반응성 있는 UI를 만드는 방법입니다.

<!-- LLM이 이미지를 생성하는 동안 로딩 스피너를 표시합니다. -->
@if (imgResource.isLoading()) {
  <div class="img-placeholder">
    <mat-spinner [diameter]="50" />
  </div>
  <!-- 생성한 이미지 URL을 src 어트리뷰트에 동적으로 지정합니다. -->
} @else if (imgResource.hasValue()) {
  <img [src]="imgResource.value()" />
  <!-- 요청이 실패하면 재시도할 수 있도록 합니다. -->
} @else {
  <div class="img-placeholder" (click)="imgResource.reload()">
    <mat-icon fontIcon="refresh" />
    <p>Failed to load image. Click to retry.</p>
  </div>
}

AI 패턴 활용: 채팅 응답 스트리밍

LLM 기반 API를 사용하는 인터페이스는 응답 데이터가 도착하는 대로 결과의 일부를 점진적으로 표시하는 경우가 많습니다. Angular의 리소스 API는 이러한 패턴을 지원하도록 응답을 스트리밍할 수 있습니다. resource의 stream 프로퍼티는 시간에 따라 시그널 값을 갱신하는 비동기 함수를 받습니다. 갱신하는 시그널은 스트리밍 중인 데이터를 나타냅니다.

characters = resource({
  stream: async () => {
    const data = signal<ResourceStreamItem<string>>({value: ''});
    // Genkit 클라이언트 SDK가 제공하는 streamFlow 메서드로
    // Genkit 스트리밍 플로를 호출합니다.
    const response = streamFlow({
      url: '/streamCharacters',
      input: 10,
    });

    (async () => {
      for await (const chunk of response.stream) {
        data.update((prev) => {
          if ('value' in prev) {
            return {value: `${prev.value} ${chunk}`};
          } else {
            return {error: chunk as unknown as Error};
          }
        });
      }
    })();

    return data;
  },
});

characters 멤버는 비동기로 갱신되며 템플릿에 표시할 수 있습니다.

@if (characters.isLoading()) {
  <p>Loading...</p>
} @else if (characters.hasValue()) {
  <p>{{ characters.value() }}</p>
} @else {
  <p>{{ characters.error() }}</p>
}

서버 측에서는 server.ts 등에 정의한 엔드포인트가 클라이언트로 스트리밍할 데이터를 전송합니다. 다음 코드는 Genkit 프레임워크와 Gemini를 사용하지만, 이 기법은 LLM 응답 스트리밍을 지원하는 다른 API에도 적용할 수 있습니다:

import {startFlowServer} from '@genkit-ai/express';
import {genkit} from 'genkit/beta';
import {googleAI, gemini20Flash} from '@genkit-ai/googleai';

const ai = genkit({plugins: [googleAI()]});

export const streamCharacters = ai.defineFlow(
  {
    name: 'streamCharacters',
    inputSchema: z.number(),
    outputSchema: z.string(),
    streamSchema: z.string(),
  },
  async (count, {sendChunk}) => {
    const {response, stream} = ai.generateStream({
      model: gemini20Flash,
      config: {
        temperature: 1,
      },
      prompt: `Generate ${count} different RPG game characters.`,
    });

    (async () => {
      for await (const chunk of stream) {
        sendChunk(chunk.content[0].text!);
      }
    })();

    return (await response).text;
  },
);

startFlowServer({
  flows: [streamCharacters],
});