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

성능 최적화: LangChain, Redis, QStash를 활용한 주문형(On-Demand) LLM 캐싱 구현하기

일부 애플리케이션에서는 사용자의 요청이 들어올 때마다 LLM에 직접 질의하는 것이 현실적이지 않을 수 있습니다. 프롬프트를 API로 전송하고 응답을 기다리는 과정은 상당한 시간이 소요되기 때문입니다. PDF나 오디오 파일에서 데이터를 추출해 LLM에 전달하는 것처럼 LangChain을 활용하는 복잡한 작업일수록 지연 시간이 길어져 사용자 경험에 더 큰 부담이 됩니다.

물론 스트리밍 방식도 많은 경우에 효과적인 해결책입니다. 하지만 사용자가 애플리케이션에 접속하기 전에 미리 모든 작업을 처리해두면, 사용자는 캐시된 응답을 거의 즉각적으로 받아볼 수 있습니다. 이는 모든 사용자에게 동일한 입력값을 사용하는 도서 요약 서비스나 뉴스 요약기 같은 애플리케이션에서 특히 유용합니다. 그렇다면 사용자 입력 없이 LangChain이 응답을 캐싱하도록 하려면 어떻게 해야 할까요?

필요한 것은 온디맨드로 호출할 수 있는 마이크로서비스입니다. 이 마이크로서비스는 프롬프트를 LangChain으로 전송하고, Upstash Redis를 통해 응답을 캐싱할 수 있어야 합니다. 또한 실수로 Rate Limit(요청 한도)을 초과하지 않도록 Upstash의 Rate Limiting SDK를 함께 사용합니다.

마이크로서비스 호출에는 QStash가 가장 다재다능한 선택지입니다. 원하는 시점에 응답을 캐싱하도록 설정할 수 있고, 필요하다면 크론(cron) 작업까지 전달할 수 있습니다. QStash는 마이크로서비스 사용량을 모니터링할 수 있는 대시보드도 제공합니다. 엔드포인트가 과부하 상태이거나 다른 문제가 발생하더라도 QStash가 HTTP 요청을 자동으로 재시도하여 메시지가 확실하게 전달되도록 보장합니다.

마이크로서비스는 엣지 환경을 위한 가볍고 빠른 웹 프레임워크인 Hono.js로 구축합니다. 이 데모에서는 Cloudflare Worker를 사용해 마이크로서비스를 호스팅하지만, Upstash 덕분에 다른 엣지/서버리스 런타임을 포함해 사실상 어느 곳에나 배포할 수 있습니다.

이 데모의 전체 소스 코드는 여기에서 확인할 수 있습니다.

사전 준비 사항

  • Upstash Redis 데이터베이스
  • QStash 환경 변수
  • OpenAI API 키

시작하기

프로젝트 생성

대부분의 create-<package> 형태의 npm 패키지와 달리, create-hono는 비어 있는 디렉터리에서 실행해야 합니다. 먼저 프로젝트를 위한 새 빈 디렉터리를 만들고 해당 디렉터리로 이동합니다:

mkdir langchain-qstash
cd langchain-qstash

v1.0.0 릴리스 이후 Bun을 사용해 프로젝트를 스캐폴딩합니다. 물론 npm, pnpm, yarn으로도 create-hono를 사용할 수 있습니다. 템플릿 선택 단계에서 반드시 cloudflare-workers를 선택하세요. bun이 별도 옵션으로 보일 수 있지만, 이것은 본 데모에서 사용하는 템플릿이 아닙니다.

bun create hono@latest

의존성 설치

이제 선호하는 패키지 매니저로 아래 명령어를 실행해 나머지 의존성을 설치합니다:

bun install @upstash/qstash @upstash/ratelimit @upstash/redis langchain openai

프로젝트 설정

글 작성 시점 기준으로 create-hono는 기본적으로 lockfile을 .gitignore에 포함합니다. 필수는 아니지만, 아래와 같이 .gitignore를 업데이트해 lockfile을 제외할 수 있습니다:

node_modules
dist
.wrangler
.dev.vars
wrangler.toml

이제 사전 준비 사항의 환경 변수들을 설정합니다. 이미 버전 관리에서 제외된 wrangler.toml 파일에 추가하면 됩니다:

[vars]
QSTASH_CURRENT_SIGNING_KEY="sig_********"
QSTASH_NEXT_SIGNING_KEY="sig_********"
UPSTASH_REDIS_REST_URL="https://********.upstash.io"
UPSTASH_REDIS_REST_TOKEN="********"
OPENAI_API_KEY="sk-********"

마지막으로 src/index.ts를 수정해 앞서 설정한 환경 변수들의 타입을 정의합니다:

type Bindings = {
 QSTASH_CURRENT_SIGNING_KEY: string;
 QSTASH_NEXT_SIGNING_KEY: string;
 UPSTASH_REDIS_REST_URL: string;
 UPSTASH_REDIS_REST_TOKEN: string;
 OPENAI_API_KEY: string;
};
 
const app = new Hono<{ Bindings: Bindings }>();

서명 키(signing key)를 두 개 발급받는 이유는 키 롤오버 시 환경 변수를 즉시 갱신하지 않아도 되기 때문입니다. 현재 서명 키 검증에 실패하면 QStash가 자동으로 다음(next) 서명 키를 사용하기 때문입니다.

개발 환경 실행

이 단계에서 wrangler는 환경 변수를 읽고 프로젝트를 배포할 수 있습니다. 선호하는 패키지 매니저로 다음 명령어를 실행하세요:

bun run dev

wrangler가 Hono.js 서버를 구동하고, 로컬 8787 포트 URL을 제공해 프로젝트를 테스트할 수 있습니다. 로컬 테스트 대신 바로 엣지 미리보기 세션을 시작하고 싶다면 package.json의 dev 스크립트를 다음과 같이 수정하세요:

"dev": "wrangler dev src/index.ts --remote",

Cloudflare Worker의 설정과 로그를 확인하려면 먼저 워커를 배포해야 합니다:

bun run deploy

이 명령어는 Cloudflare 로그인 과정을 안내하며, 인증 후 첫 배포를 자동으로 진행합니다.

미들웨어 작성

디버깅을 위해 QStash에서 받은 요청을 로그로 남겨두면 유용합니다. Begin log stream 버튼을 누르면 Cloudflare Worker 대시보드의 "Logs" 탭에서 확인할 수 있습니다. Hono.js는 라우터에 추가할 수 있는 로거(logger) 미들웨어를 기본 제공합니다:

import { Hono } from "hono";
import { logger } from "hono/logger";
 
// snip
 
const app = new Hono<{ Bindings: Bindings }>();
app.use("*", logger());
 
// snip

성능 최적화: LangChain, Redis, QStash를 활용한 주문형(On-Demand) LLM 캐싱 구현하기

QStash를 Cloudflare Workers에 연결

Hono.js로 API 엔드포인트를 개발하기 전에, QStash가 보낸 메시지를 가로채고 유효하지 않은 서명이 담긴 요청은 폐기하는 방법이 필요합니다. 다행히 Hono.js는 항상 핸들러보다 먼저 실행되는 커스텀 미들웨어를 구현할 수 있는 방법을 제공하며, 필요에 따라 미들웨어를 별도 파일로 분리해 관리할 수 있을 만큼 견고합니다.

미들웨어에서는 QStash의 Receiver(수신자)를 활용합니다. src/middleware/verify.ts라는 새 파일을 만들고 MiddlewareHandler 타입의 함수를 export합시다:

import { Receiver } from "@upstash/qstash";
import { type MiddlewareHandler } from "hono";
 
declare global {
 interface Response {
 locals: {
 query: string;
 };
 }
}
 
export const verify: MiddlewareHandler = async (ctx, next) => {
 const receiver = new Receiver({
 currentSigningKey: ctx.env.QSTASH_CURRENT_SIGNING_KEY,
 nextSigningKey: ctx.env.QSTASH_NEXT_SIGNING_KEY,
 });
};

Hono.js는 각 미들웨어와 핸들러에 ctx(컨텍스트) 객체를 전달합니다. 이는 Cloudflare Workers의 ExecutionContext와 완전히 동일하지는 않지만 동일한 정보를 담고 있습니다. Hono의 ctx 객체는 Cloudflare Workers의 기본 fetch 핸들러에 전달되는 request 객체, environment, execution context를 하나의 객체로 통합한 것과 거의 같습니다.

또한 전역 Response 인터페이스에 커스텀 locals 속성을 추가했습니다. Express와 달리 Hono는 기본적으로 res.locals 객체를 생성하지 않습니다. 이를 활용해 나중에 핸들러에 쿼리를 전달할 것입니다. 이제 앞서 정의한 타입이 적용된 환경 변수에 접근해 Receiver 인스턴스를 생성합니다.

이제 Receiver로 요청의 서명을 검증할 수 있습니다:

// snip
 
const body = await ctx.req.text();
ctx.res.locals = {
 query: JSON.parse(body).query,
};
 
const isValid = await receiver
 .verify({
 signature: ctx.req.headers.get("Upstash-Signature")!,
 body,
 })
 .catch((err) => {
 console.error(err);
 return false;
 });
 
if (!isValid) {
 return new Response("Invalid signature", { status: 401 });
}
 
await next();

먼저 컨텍스트에서 request 객체를 가져옵니다. Hono.js는 fetch, URL, Request, Response 같은 웹 표준 API만 사용하므로, Cloudflare Workers 외에도 수많은 엣지/서버리스 환경에서 실행될 수 있다는 장점이 있습니다.

request 객체에서 요청 본문을 텍스트로 읽고, 커스텀 JWT가 담긴 Upstash-Signature 헤더를 확인합니다. Receiver에 서명과 요청 본문을 전달하면 JWT에 포함된 서명을 검증할 수 있습니다. .catch 핸들러에서는 에러를 로그로 남기고 false를 반환해 서명이 유효하지 않음을 표시합니다.

요청 본문을 소비(consume)했기 때문에 이후 실제 핸들러에서는 본문에 접근할 수 없습니다. 본문은 한 번만 읽을 수 있는 ReadableStream이기 때문입니다. 대신 쿼리를 핸들러에 전달하려면 응답의 locals 객체에 저장하면 됩니다. 마지막으로 서명이 유효하지 않으면 401 Unauthorized 응답을 반환하고, 유효하다면 next()를 호출해 다음 미들웨어나 핸들러로 진행합니다.

Rate Limiting 추가

이 엔드포인트에는 개인 OpenAI API 키를 연결할 것이므로, 의도치 않게 요청 한도를 초과하지 않도록 하는 것이 중요합니다. 다행히 Upstash는 Rate Limiting SDK를 제공해 엔드포인트에 손쉽게 레이트 리밋을 적용할 수 있습니다. 요청 수가 임계값을 초과하면 429 Too Many Requests 응답을 반환합니다.

src/middleware/ratelimit.ts 파일에 새 미들웨어를 만들어 시작해 보겠습니다:

import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis/cloudflare";
import { type MiddlewareHandler } from "hono";
 
export const ratelimit: MiddlewareHandler = async (ctx, next) => {
 const redis = new Redis({
 url: ctx.env.UPSTASH_REDIS_REST_URL,
 token: ctx.env.UPSTASH_REDIS_REST_TOKEN,
 });
 
 const ratelimit = new Ratelimit({
 redis,
 limiter: Ratelimit.slidingWindow(10, "10 s"),
 analytics: true,
 });
 
 await next();
};

여기서는 Upstash Redis 데이터베이스를 Rate Limiting SDK에 연결합니다. Cloudflare Workers 환경에서는 Redis.fromEnv()가 환경 변수를 자동으로 읽지 못하기 때문에 ctx.env의 환경 변수로 직접 Redis 인스턴스를 생성해야 합니다. analytics가 true이면 SDK는 식별자별 호출 기록 캐시를 유지하기 위해 자동으로 Redis를 호출하며, 기본 접두사로 @upstash/ratelimit를 사용합니다.

SDK는 Redis 대신 Map을 사용하는 임시 캐시(ephemeral cache)도 지원합니다. 극단적인 부하 상황에서 시간과 리소스를 절약할 수 있습니다:

const cache = new Map(); // 미들웨어 핸들러 외부에 선언
 
// snip
 
const ratelimit = new Ratelimit({
 ephemeralCache: cache,
 // snip
});

SDK에는 limiter(제한기)도 전달합니다. 이는 요청을 어떻게 제한할지 알려주는 함수로, 여기서는 10초당 최대 10개의 요청을 허용하도록 설정된 slidingWindow 제한기를 사용합니다.

SDK는 요청마다 식별자(identifier)를 지정할 수 있으며, 주어진 기간 내 요청 횟수를 자동으로 추적합니다. 실제 애플리케이션에서는 사용자 IP 주소를 식별자로 사용할 수 있지만, 본 데모에서는 모든 요청을 하나의 레이트 리밋으로 묶기 위해 고정 문자열을 사용합니다:

// snip
 
const identifier = "openai";
const { success } = await ratelimit.limit(identifier);
 
if (!success) {
 return new Response("Too many requests", { status: 429 });
}
 
await next();
// snip

레이트 리밋에 걸린 요청에는 429 Too Many Requests 응답을 반환하고, 그렇지 않으면 next()를 호출해 계속 진행합니다. 참고로 여러 개의 Redis 데이터베이스를 SDK에 연결했다면 데이터베이스 간 동기화가 필요한데, Vercel Edge나 Cloudflare Workers에서는 이로 인해 처리되지 않은 프로미스(dangling promise)가 발생할 수 있습니다. 이 경우 다음과 같이 해결할 수 있습니다:

const { pending, success } = await ratelimit.limit(identifier);
 
ctx.event.waitUntil(pending);

구체적인 방법은 사용하는 라이브러리에 따라 다를 수 있습니다. Hono는 웹 표준 API를 사용하므로 event.waitUntil 메서드로 프로미스가 resolve될 때까지 기다릴 수 있습니다. 다만 본 데모에서는 단일 Redis 데이터베이스만 사용하므로 이 부분은 신경 쓰지 않아도 됩니다.

QStash 메시지 수신 처리

QStash로 HTTP 요청을 보낼 때 지정하는 목적지(destination)는 Cloudflare Workers 엔드포인트의 URL입니다. Hono.js로 이 엔드포인트의 핸들러를 만들어 봅시다. src/index.ts에 새 핸들러를 추가하고, 앞서 작성한 미들웨어를 활성화합니다:

// snip
import { ratelimit } from "./middleware/ratelimit";
import { verify } from "./middleware/verify";
 
// snip
 
app.post("/api/announce", ratelimit, verify, async (ctx) => {});
 
// snip

이 핸들러에서는 LangChain으로 주어진 프롬프트에 대한 응답을 생성하고, Upstash Redis로 결과를 캐싱합니다:

// snip
 
import { Redis } from "@upstash/redis/cloudflare";
import { UpstashRedisCache } from "langchain/cache/upstash_redis";
import { OpenAI } from "langchain/llms/openai";
 
// snip
 
app.post("/api/announce", ratelimit, verify, async (ctx) => {
 const redis = new Redis({
 url: ctx.env.UPSTASH_REDIS_REST_URL,
 token: ctx.env.UPSTASH_REDIS_REST_TOKEN,
 });
 
 const cache = new UpstashRedisCache({ client: redis });
 const model = new OpenAI({
 cache,
 openAIApiKey: ctx.env.OPENAI_API_KEY,
 });
 
 const query = ctx.res.locals.query;
 const result = await model
 .call(query)
 .then((result) => {
 console.log(result);
 return result;
 })
 .catch((err) => console.error(err));
 
 return new Response(result ?? "", { status: 200 });
});
 
// snip

여기서는 LangChain 캐싱 설정에 필요한 클래스들을 import합니다. 그런 다음 OpenAI 모델과 Upstash Redis 캐시를 사용해 체인을 구성하고, Upstash Redis 인스턴스를 캐시에 전달합니다. Cloudflare Workers에서는 API 키를 자동으로 읽을 수 없으므로, OpenAI API 키 역시 모델에 직접 전달해야 합니다.

그다음 앞서 res.locals에 저장해둔 쿼리에 접근해 모델을 호출하고 결과를 로그로 남깁니다. 마지막으로 결과를 응답으로 반환하거나, 문제가 발생한 경우 빈 문자열을 반환합니다. 이 단계에서 POST 요청을 보내 응답을 확인하며 엔드포인트를 테스트할 수 있습니다. 먼저 package.json의 deploy 스크립트를 다시 실행합니다:

bun run deploy

Cloudflare Workers 엔드포인트 URL을 확인한 뒤 curl로 POST 요청을 보낼 수 있습니다:

curl -XPOST \
 "https://qstash.upstash.io/v2/publish/https://<YOUR_API_URL>.workers.dev/api/announce" \
 -H "Authorization: Bearer <YOUR_QSTASH_TOKEN>" \
 -H "Content-Type: application/json" \
 -d "{ \"query\": \"What's the derivative of e^x?\" }"

Upstash는 이런 요청을 더 쉽게 보낼 수 있는 QStash 콘솔을 제공합니다. 크론(cron) 작업을 지정해 요청을 반복 실행하는 것도 마찬가지로 간단합니다. QStash는 요청 본문을 그대로 엔드포인트에 전달하며, 우리가 보내는 JSON 페이로드에서 query는 모델에 전달할 프롬프트입니다:

{
 "query": "What's the derivative of e^x?"
}

성능 최적화: LangChain, Redis, QStash를 활용한 주문형(On-Demand) LLM 캐싱 구현하기

마무리

Rate Limiting SDK는 식별자별 호출 횟수를 성공적으로 캐싱합니다:

성능 최적화: LangChain, Redis, QStash를 활용한 주문형(On-Demand) LLM 캐싱 구현하기

마찬가지로 LangChain이 생성한 콘텐츠도 Upstash Redis 데이터베이스에 성공적으로 캐싱됩니다:

성능 최적화: LangChain, Redis, QStash를 활용한 주문형(On-Demand) LLM 캐싱 구현하기

마지막으로 응답은 Cloudflare Worker 로그에 기록됩니다:

성능 최적화: LangChain, Redis, QStash를 활용한 주문형(On-Demand) LLM 캐싱 구현하기