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 하나만 감싼 걸로 바꿔주면 끝이라, 이미 짜둔 코드를 건드릴 필요가 없다.
핵심은 로깅·캐싱 같은 횡단 관심사를 호출부에서 떼어내 모델 한 곳에 모으는 거다. 환경변수로 개발에서만 로깅 미들웨어를 끼우거나, 재시도·레이트리밋을 같은 방식으로 얹는 것도 자연스럽게 확장된다. 헬퍼 함수 흩뿌리기 전에 미들웨어부터 떠올리면 코드가 훨씬 깔끔해진다.