Computer >> 컴퓨터 >  >> 프로그래밍 >> Redis

LangChain과 Upstash Redis로 세션 간 유지되는 채팅 기록 구현하기

LangChain은 사람과 AI 간의 대화를 손쉽게 처리할 수 있는 간편한 인터페이스를 제공합니다. BufferMemory를 사용하도록 설정하면 대화 기록을 메모리에 저장할 수 있는데, 일부 사용 사례에서는 이 정도로 충분할 수 있습니다. 하지만 앱이 채팅 기록의 장기적인 영속 저장을 필요로 하는 경우도 있습니다. 다행히 이를 Upstash Redis 인스턴스로 교체하는 작업 역시 매우 간단합니다.

LangChain은 ioredis, node-redis, 그리고 Upstash Redis 등 다양한 Redis 통합 기능을 제공합니다. 특히 Upstash Redis 클라이언트는 REST 방식으로 동작하기 때문에, Vercel, Cloudflare Workers 또는 기타 서버리스 환경에 배포할 수 있는 엣지(Edge) 레디 애플리케이션을 만들 때 이상적입니다. 이번 글에서는 이를 활용해 세션이 바뀌어도 대화 내용이 유지되는 간단한 채팅 앱을 만들어 보겠습니다.

이 데모의 전체 소스 코드는 링크된 저장소에서 확인할 수 있습니다.

사전 준비 사항

  • Upstash Redis 데이터베이스
  • OpenAI API 키

시작하기

프로젝트 생성

Vercel AI SDK를 사용하는 기본적인 Next.js 앱을 만들어 LangChain과 Upstash Redis를 함께 사용하는 방법을 알아보겠습니다. 먼저 새 Next.js 앱을 생성합니다:

npx create-next-app@latest

명령어 실행 후 몇 가지 프로젝트 옵션을 선택하게 됩니다. 대부분의 경우 기본값으로 진행해도 무방하지만, 이번 데모에서는 반드시 TypeScript와 app 디렉터리 옵션을 활성화하세요.

의존성 설치

앱 생성이 완료되면 다음 의존성들을 설치해야 합니다:

npm install ai langchain openai @upstash/redis

엄밀히 말해 필수는 아니지만, Vercel AI SDK를 사용하면 OpenAI의 응답을 Next.js 프론트엔드로 스트리밍하기가 한결 수월해집니다. 우리는 Redis 클라이언트 생성을 위해 @upstash/redis만 직접 다루면 되고, 나머지는 LangChain이 알아서 처리해 줍니다.

환경 변수 설정

마지막으로 앞서 언급한 사전 준비물에 해당하는 환경 변수들이 필요합니다. 이름이 정확히 일치하지 않으면 자동으로 읽히지 않으므로, 아래와 똑같이 지정해야 합니다! 새 프로젝트 루트의 .env 파일에 추가하면 됩니다:

UPSTASH_REDIS_REST_URL="https://********.upstash.io"
UPSTASH_REDIS_REST_TOKEN="********"
OPENAI_API_KEY="sk-********"

기본 채팅 클라이언트 만들기

Next.js가 자동으로 여러 파일을 생성해 준 것을 확인할 수 있을 겁니다. 우리는 app 디렉터리의 일부 파일만 다룰 것이므로, publicapp 디렉터리 안의 기존 파일은 모두 삭제해도 좋습니다.

먼저 앱의 뼈대가 될 기본적인 app/layout.tsx를 작성합니다:

import type { PropsWithChildren } from "react";
 
export default function RootLayout({ children }: PropsWithChildren) {
 return (
 <html lang="en">
 <body>{children}</body>
 </html>
 );
}

다음으로, 사용자의 메시지를 입력받을 기본 폼이 필요합니다. 이는 app/page.tsx에 추가할 수 있습니다:

export default function Home() {
 return (
 <main>
 <form>
 <input placeholder="Enter a message..." />
 <button type="submit">Send</button>
 </form>
 </main>
 );
}

LangChain과 Upstash Redis로 세션 간 유지되는 채팅 기록 구현하기

Vercel AI SDK는 useChat이라는 유용한 훅(hook)을 제공하여 채팅 앱의 UI를 아주 손쉽게 구성할 수 있게 해줍니다. 이 훅은 채팅 메시지 스트리밍과 입력 필드 상태 관리를 대신 처리해 줍니다. 훅을 사용하려면 파일 최상단에 "use client" 지시문을 추가해 React에게 클라이언트 컴포넌트임을 알려줘야 합니다. 그다음 useChat 훅에서 다음 속성들을 구조 분해 할당할 수 있습니다:

  • messages: 주고받은 메시지들의 배열입니다.
  • input: 입력 필드의 현재 값입니다.
  • handleInputChange: 입력 값을 갱신하는 함수입니다.
  • handleSubmit: 메시지를 엔드포인트로 전송하는 함수입니다.
"use client";
 
import { useChat } from "ai/react";
 
export default function Home() {
 const { messages, input, handleInputChange, handleSubmit } = useChat();
 
 return (
 <main>
 <form onSubmit={handleSubmit}>
 <input
 value={input}
 onChange={handleInputChange}
 placeholder="Enter a message..."
 />
 <button type="submit">Send</button>
 </form>
 </main>
 );
}

useChat 훅은 내부적으로 handleSubmit이 호출될 때 input을 자동으로 messages에 추가하고 리렌더링을 트리거하므로, UI 갱신을 직접 신경 쓸 필요가 없습니다. 또한 입력 필드를 비운 뒤 지정된 엔드포인트(기본값은 /api/chat)로 API 요청을 보냅니다.

마지막으로 폼 위에 messages를 렌더링해 보겠습니다:

<main>
 <section>
 {messages.map((message) => (
 <p key={message.id}>{message.content}</p>
 ))}
 </section>
 
 {/* snip */}
</main>

API 엔드포인트 만들기

먼저 엔드포인트를 담당할 app/api/chat/route.ts 파일을 생성합니다. Next.js는 페이지뿐 아니라 API 엔드포인트에도 파일 기반 라우팅을 사용하기 때문에, 새 파일의 폴더 구조가 앞서 언급한 기본 엔드포인트인 /api/chat과 자연스럽게 일치하게 됩니다.

Upstash Redis를 사용하기 때문에 우리 엔드포인트는 엣지 호환됩니다. 엔드포인트에서 const runtime = "edge"를 export하여 이를 명시할 수 있습니다. 엔드포인트 본문에서는 useChat 훅이 채워 준 messages 필드를 꺼낼 수 있으며, 이를 통해 최신 메시지를 LangChain에 전달할 수 있습니다:

import { type NextRequest } from "next/server";
 
import { LangChainStream, StreamingTextResponse } from "ai";
 
export const runtime = "edge";
 
export async function POST(req: NextRequest) {
 const { messages } = await req.json();
 const { stream, handlers } = LangChainStream();
 
 const latestMessage = messages[messages.length - 1];
 
 return new StreamingTextResponse(stream);
}

앞서 살펴본 useChat처럼 LangChainStream 역시 구조 분해 할당할 수 있는 몇 가지 속성을 반환합니다.

  • stream: LangChain 처리 결과가 최종적으로 담기게 되는 ReadableStream입니다.
  • handlers: LangChain에 전달할 수 있는 LLM 콜백 함수들을 포함하는 객체입니다.

체인(chain)을 본격적으로 구현하기 전에 몇 가지 클래스를 추가로 임포트해야 합니다:

import { Redis } from "@upstash/redis";
import { ConversationChain } from "langchain/chains";
import { ChatOpenAI } from "langchain/chat_models/openai";
import { BufferMemory } from "langchain/memory";
import { UpstashRedisChatMessageHistory } from "langchain/stores/message/upstash_redis";

이제 Redis 클라이언트를 생성하고 체인에 사용할 메모리를 설정할 수 있습니다. 여기서는 모델을 직접 호출하는 대신 호출할 수 있는 ConversationChain을 만듭니다. 이는 사람과 AI 간의 대화를 원활하게 주고받도록 설계된 커스텀 체인입니다. 체인에는 커스텀 BaseMemory 구현체를 전달할 수 있으며, 이를 통해 메시지를 저장하고 불러옵니다. 여기서는 BufferMemoryUpstashRedisChatMessageHistory를 함께 사용해 메시지를 Upstash Redis에 저장합니다:

// snip
const latestMessage = messages[messages.length - 1];
 
const memory = new BufferMemory({
 chatHistory: new UpstashRedisChatMessageHistory({
 sessionId: new Date().toLocaleDateString(),
 client: Redis.fromEnv(),
 }),
});
 
const model = new ChatOpenAI({
 modelName: "gpt-3.5-turbo",
 streaming: true,
});
 
const chain = new ConversationChain({ llm: model, memory });
// snip

@upstash/redis에서 export된 Redis 클래스로 새 Redis 클라이언트를 생성합니다. 이 클래스는 환경 변수를 자동으로 로드하는 편리한 메서드를 제공하며, 이는 ChatOpenAI의 동작과도 동일합니다. 환경 변수 이름만 올바르게 지정했다면 두 클래스 모두 별도의 인자를 넘길 필요가 없습니다.

실제 앱에서는 사용자 ID나 다른 고유 식별자를 sessionId로 사용해 사용자 간에 메시지가 섞이지 않도록 하는 것이 좋습니다. 다만 이번 데모에서는 편의상 현재 날짜를 사용하겠습니다. 참고로 UpstashRedisChatMessageHistory는 캐시의 수명을 설정하는 sessionTTL 같은 추가 설정 옵션도 제공합니다.

모델에서 streaming을 활성화하는 것이 중요합니다. 이렇게 해야 앞서 구조 분해한 handlers 객체를 사용해 체인의 결과를 stream으로 파이핑할 수 있습니다. 마지막으로 최신 메시지와 handlers 객체를 인자로 넘겨 체인을 호출합니다:

// snip
const chain = new ConversationChain({ llm: model, memory });
 
chain.call({
 input: latestMessage.content,
 callbacks: [handlers],
});
 
return new StreamingTextResponse(stream);
// snip

앞서 추출한 latestMessage 객체가 LLM의 프롬프트로 사용됩니다. 또한 체인에 handlers 객체를 전달해 처리 결과가 stream으로 흘러가도록 합니다.

마치며

완성했습니다! 이제 npm run dev로 앱을 실행하고 AI와 대화를 시작할 수 있습니다. 응답은 자동으로 앞서 만든 클라이언트로 스트리밍되며, 대화 기록은 Upstash Redis에 저장되어 세션이 바뀌어도 그대로 유지됩니다.

LangChain과 Upstash Redis로 세션 간 유지되는 채팅 기록 구현하기

LangChain과 Upstash Redis로 세션 간 유지되는 채팅 기록 구현하기