← Lecture Note
M7실습60분 · ~18 슬라이드

LLM API 통합 (Claude / OpenAI)

callLLM() 하나로 통합한다

학습 목표

두 프로바이더를 단일 함수로 추상화하고, JSON 강제·강건 파서·게이트웨이를 붙인다.

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_URLhttps://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 }) })를 호출하고, 응답의 summarykeywords를 화면에 표시합니다. 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를 용도에 맞게 제한해 두면 비용 스파이크와 응답 지연을 동시에 방지할 수 있습니다.
LAB · 실습

간단한 채팅 API + UI