블로그 목록
AI

Vercel AI SDK 미들웨어로 LLM 호출 로깅·캐싱하기 — wrapLanguageModel 활용

LLM을 붙인 앱이 커지면 항상 같은 고민이 생긴다. 프롬프트가 실제로 뭐가 나갔는지 보고 싶고, 똑같은 요청을 매번 다시 돌려서 토큰을 태우는 게 아깝다.

처음엔 generateText를 감싸는 헬퍼 함수를 만들어서 앞뒤에 console.log랑 캐시 체크를 넣었다. 근데 스트리밍 쓰는 곳, generateObject 쓰는 곳마다 헬퍼가 갈라지니까 금방 지저분해졌다. 호출부마다 로깅 코드가 흩뿌려지는 게 싫었다.

Vercel AI SDK의 wrapLanguageModel을 쓰면 이걸 모델 레벨에서 한 겹으로 처리할 수 있다. 미들웨어를 모델에 씌워두면, 그 모델을 쓰는 모든 호출이 자동으로 그 미들웨어를 거친다.

로깅 미들웨어

LanguageModelV2Middleware 인터페이스로 transformParams, wrapGenerate, wrapStream 세 훅을 구현할 수 있다. 로깅은 wrapGenerate만 있으면 된다.

import type { LanguageModelV2Middleware } from '@ai-sdk/provider';

const loggingMiddleware: LanguageModelV2Middleware = {
  wrapGenerate: async ({ doGenerate, params }) => {
    console.log('[LLM] prompt:', JSON.stringify(params.prompt));
    const result = await doGenerate();
    console.log('[LLM] usage:', result.usage);
    return result;
  },
};

doGenerate()가 실제 모델 호출이다. 앞뒤로 원하는 걸 끼우고, 결과는 그대로 흘려보내면 된다.

캐싱 미들웨어

같은 파라미터로 온 요청이면 모델을 안 부르고 저장해둔 결과를 반환한다. 데모라 메모리 Map을 썼는데, 실제로는 Redis 같은 걸 쓰면 된다.

const cache = new Map<string, any>();

const cachingMiddleware: LanguageModelV2Middleware = {
  wrapGenerate: async ({ doGenerate, params }) => {
    const key = JSON.stringify(params);
    if (cache.has(key)) {
      console.log('[LLM] cache hit');
      return cache.get(key);
    }
    const result = await doGenerate();
    cache.set(key, result);
    return result;
  },
};

모델에 씌우기

미들웨어는 배열로 넘길 수 있고, 앞에 온 게 바깥 껍질이 된다. 캐시를 바깥에 두면, 캐시 히트일 때 로깅과 실제 호출을 통째로 건너뛴다.

import { wrapLanguageModel } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';

const model = wrapLanguageModel({
  model: anthropic('claude-sonnet-4-5'),
  middleware: [cachingMiddleware, loggingMiddleware],
});

// 호출부는 하나도 안 바뀐다
const { text } = await generateText({ model, prompt: '한 줄 요약해줘' });

호출하는 쪽 코드는 그대로다. model 하나만 감싼 걸로 바꿔주면 끝이라, 이미 짜둔 코드를 건드릴 필요가 없다.


핵심은 로깅·캐싱 같은 횡단 관심사를 호출부에서 떼어내 모델 한 곳에 모으는 거다. 환경변수로 개발에서만 로깅 미들웨어를 끼우거나, 재시도·레이트리밋을 같은 방식으로 얹는 것도 자연스럽게 확장된다. 헬퍼 함수 흩뿌리기 전에 미들웨어부터 떠올리면 코드가 훨씬 깔끔해진다.