LLM은 AI Agent의 두뇌입니다. 어떤 프로바이더를 쓰든 호출 코드가 흩어지면 나중에 모델을 바꿀 때 전체 코드를 뜯어고쳐야 합니다. 이 모듈에서는 Claude와 OpenAI를 단 하나의 callLLM() 함수로 감싸고, Vercel AI Gateway를 통해 비용·레이턴시를 한눈에 관리하는 방법을 익힙니다.
패키지 설치
npm install @anthropic-ai/sdk openai
두 SDK를 모두 설치합니다. @anthropic-ai/sdk는 Claude 전용, openai는 GPT 계열뿐 아니라 Vercel AI Gateway의 OpenAI 호환 엔드포인트에도 사용합니다.
lib/llm.ts — callLLM() 통합 함수
lib/llm.ts 파일 하나에 두 프로바이더를 추상화합니다. 호출부는 provider/model 문자열만 바꾸면 됩니다.
// lib/llm.ts
import Anthropic from "@anthropic-ai/sdk";
import OpenAI from "openai";
export interface LLMMessage {
role: "system" | "user" | "assistant";
content: string;
}
export interface LLMOptions {
model: string; // "anthropic/claude-sonnet-4" | "openai/gpt-4o" 등
messages: LLMMessage[];
temperature?: number;
maxTokens?: number;
jsonMode?: boolean;
}
export async function callLLM(options: LLMOptions): Promise<string> {
const { model, messages, temperature = 0.7, maxTokens = 1024, jsonMode = false } = options;
const [provider] = model.split("/");
if (provider === "anthropic") {
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
const system = messages.find((m) => m.role === "system")?.content ?? "";
const userMessages = messages
.filter((m) => m.role !== "system")
.map((m) => ({ role: m.role as "user" | "assistant", content: m.content }));
const response = await client.messages.create({
model: model.replace("anthropic/", ""),
system,
messages: userMessages,
temperature,
max_tokens: maxTokens,
});
return (response.content[0] as Anthropic.TextBlock).text;
}
// OpenAI / Vercel AI Gateway (OpenAI-compatible)
const baseURL = process.env.VERCEL_AI_GATEWAY_URL; // 게이트웨이 사용 시
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY, ...(baseURL ? { baseURL } : {}) });
const response = await client.chat.completions.create({
model: model.replace("openai/", ""),
messages: messages as OpenAI.ChatCompletionMessageParam[],
temperature,
max_tokens: maxTokens,
...(jsonMode ? { response_format: { type: "json_object" } } : {}),
});
return response.choices[0].message.content ?? "";
}
핵심 설계 원칙: 호출부는
model문자열만 다르고, 나머지 코드는 동일합니다. 나중에 Gemini나 다른 프로바이더를 추가하고 싶다면 이 파일만 수정하면 됩니다.
System + User 프롬프트 작성 원칙
- System 프롬프트: 역할·어조·출력 형식 등 불변 지시사항. Claude에서는 별도
system필드, OpenAI에서는role: "system"메시지. - User 프롬프트: 실제 요청. 동적 값(이름, 문서 내용 등)은 여기에 삽입.
- 보고서·분석처럼 결정론적 결과가 필요할 때는
temperature: 0.2를 명시하세요.
JSON 강제 + extractJSON() 강건 파서
LLM이 JSON을 반환해야 할 때, OpenAI는 jsonMode: true로 강제할 수 있지만 Claude는 텍스트 안에 JSON을 감쌀 때가 있습니다. 두 경우 모두 안전하게 파싱하는 유틸을 만들어 둡니다.
// lib/llm.ts 에 추가
export function extractJSON<T = unknown>(raw: string): T {
// 마크다운 코드펜스 제거
const stripped = raw.replace(/^```(?:json)?\n?/i, "").replace(/\n?```$/i, "").trim();
try {
return JSON.parse(stripped) as T;
} catch {
// JSON 블록만 추출 시도
const match = stripped.match(/\{[\s\S]*\}|\[[\s\S]*\]/);
if (match) return JSON.parse(match[0]) as T;
throw new Error(`JSON 파싱 실패: ${raw.slice(0, 200)}`);
}
}
사용 예시:
const raw = await callLLM({
model: "anthropic/claude-sonnet-4",
messages: [
{ role: "system", content: "반드시 JSON 객체로만 응답하세요. {summary: string, tags: string[]}" },
{ role: "user", content: `다음 글을 6단으로 요약해줘:\n\n${article}` },
],
temperature: 0.2,
jsonMode: true,
});
const result = extractJSON<{ summary: string; tags: string[] }>(raw);
Vercel AI Gateway
Vercel AI Gateway는 Claude·OpenAI·Gemini 등 여러 프로바이더 호출을 단일 엔드포인트로 프록시합니다. 대시보드에서 토큰 소비량·비용·레이턴시를 모델별로 비교할 수 있습니다.
| 설정 항목 | 값 예시 |
|---|---|
VERCEL_AI_GATEWAY_URL | https://gateway.ai.vercel.app/v1 |
| 모델 문자열 | "anthropic/claude-sonnet-4" |
| 인증 | Vercel 프로젝트 토큰 자동 주입 |
callLLM()에서 baseURL을 게이트웨이 URL로 넘기면 OpenAI SDK가 그대로 Claude를 포함한 모든 모델을 호출합니다. Claude를 게이트웨이 경유로 쓸 때는 provider/model 문자열을 OpenAI 호환 경로로 라우팅하므로, anthropic/ 분기 대신 openai/ 분기를 타도록 모델 문자열을 조정하거나 게이트웨이 전용 분기를 추가하세요.
비용 모니터링 팁: Vercel 대시보드 → AI Gateway → Usage에서 모델별 일일 토큰을 확인하세요. 프로덕션 배포 전에
max_tokens를 용도에 맞게 제한해 두면 비용 스파이크를 예방할 수 있습니다.
실습 (Lab)
목표
Claude로 텍스트를 받아 6단 JSON 요약을 반환하는 API 라우트와, 간단한 채팅 UI를 만듭니다.
1단계 — 환경 변수 설정
.env.local에 추가합니다.
ANTHROPIC_API_KEY=sk-ant-...
OPENAI_API_KEY=sk-...
2단계 — API 라우트 작성
app/api/chat/route.ts 파일을 생성합니다.
import { NextRequest, NextResponse } from "next/server";
import { callLLM, extractJSON } from "@/lib/llm";
export async function POST(req: NextRequest) {
const { text } = await req.json();
if (!text) return NextResponse.json({ error: "text required" }, { status: 400 });
const raw = await callLLM({
model: "anthropic/claude-sonnet-4",
messages: [
{
role: "system",
content:
"당신은 글 요약 전문가입니다. 반드시 JSON으로만 응답하세요.\n" +
'형식: { "summary": "6단 요약 문장", "keywords": ["키워드1", "키워드2"] }',
},
{ role: "user", content: `다음 글을 6단으로 요약하세요:\n\n${text}` },
],
temperature: 0.2,
jsonMode: true,
});
const result = extractJSON<{ summary: string; keywords: string[] }>(raw);
return NextResponse.json(result);
}
3단계 — 간단한 채팅 UI 연결
app/page.tsx에서 fetch("/api/chat", { method: "POST", body: JSON.stringify({ text }) })를 호출하고, 응답의 summary와 keywords를 화면에 표시합니다. Tailwind로 max-w-2xl mx-auto p-6 컨테이너와 <textarea>, <button> 하나면 충분합니다.
4단계 — 동작 확인
npm run dev 후 텍스트를 입력하면 JSON 요약이 반환되는지 확인합니다. 응답이 정상이면 model을 "openai/gpt-4o"로 바꿔 동일하게 동작하는지도 검증하세요.
핵심 정리
callLLM()은provider/model문자열로 Claude·OpenAI를 단일 인터페이스로 감싸며, 나머지 코드는 프로바이더에 무관합니다.extractJSON()은 마크다운 펜스와 불완전한 래핑을 제거한 뒤 파싱하므로 Claude와 OpenAI 모두 안전하게 처리합니다.- 결정론적 결과(보고서·분류)가 필요할 때는
temperature: 0.2를 명시하세요. - Vercel AI Gateway의
provider/model문자열 하나로 모델을 전환하고, 대시보드에서 비용·레이턴시를 모니터링합니다. jsonMode: true는 OpenAI에서response_format: { type: "json_object" }로 매핑되고, Claude에서는 System 프롬프트로 JSON 출력을 유도합니다.max_tokens를 용도에 맞게 제한해 두면 비용 스파이크와 응답 지연을 동시에 방지할 수 있습니다.
간단한 채팅 API + UI