386 lines
10 KiB
JavaScript
386 lines
10 KiB
JavaScript
|
|
// src/services/llm.service.js
|
||
|
|
// LLM 서비스 - 멀티 프로바이더 지원
|
||
|
|
|
||
|
|
const axios = require('axios');
|
||
|
|
const { LLMProvider } = require('../models');
|
||
|
|
const logger = require('../config/logger.config');
|
||
|
|
|
||
|
|
class LLMService {
|
||
|
|
constructor() {
|
||
|
|
this.providers = [];
|
||
|
|
this.initialized = false;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 서비스 초기화
|
||
|
|
*/
|
||
|
|
async initialize() {
|
||
|
|
if (this.initialized) return;
|
||
|
|
|
||
|
|
try {
|
||
|
|
await this.loadProviders();
|
||
|
|
this.initialized = true;
|
||
|
|
logger.info('✅ LLM 서비스 초기화 완료');
|
||
|
|
} catch (error) {
|
||
|
|
logger.error('❌ LLM 서비스 초기화 실패:', error);
|
||
|
|
// 초기화 실패 시 기본 프로바이더 사용
|
||
|
|
this.providers = this.getDefaultProviders();
|
||
|
|
this.initialized = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 데이터베이스에서 프로바이더 로드
|
||
|
|
*/
|
||
|
|
async loadProviders() {
|
||
|
|
try {
|
||
|
|
const providers = await LLMProvider.getHealthyProviders();
|
||
|
|
|
||
|
|
if (providers.length === 0) {
|
||
|
|
logger.warn('⚠️ 활성 프로바이더가 없습니다. 기본 프로바이더 사용');
|
||
|
|
this.providers = this.getDefaultProviders();
|
||
|
|
} else {
|
||
|
|
this.providers = providers.map((p) => ({
|
||
|
|
id: p.id,
|
||
|
|
name: p.name,
|
||
|
|
endpoint: p.endpoint,
|
||
|
|
apiKey: p.apiKey,
|
||
|
|
modelName: p.modelName,
|
||
|
|
priority: p.priority,
|
||
|
|
maxTokens: p.maxTokens,
|
||
|
|
temperature: p.temperature,
|
||
|
|
timeoutMs: p.timeoutMs,
|
||
|
|
costPer1kInputTokens: parseFloat(p.costPer1kInputTokens) || 0,
|
||
|
|
costPer1kOutputTokens: parseFloat(p.costPer1kOutputTokens) || 0,
|
||
|
|
isHealthy: p.isHealthy,
|
||
|
|
config: p.config,
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
logger.info(`📥 ${this.providers.length}개 프로바이더 로드됨`);
|
||
|
|
} catch (error) {
|
||
|
|
logger.error('프로바이더 로드 실패:', error);
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 기본 프로바이더 설정 (환경 변수 기반)
|
||
|
|
*/
|
||
|
|
getDefaultProviders() {
|
||
|
|
const providers = [];
|
||
|
|
|
||
|
|
// Gemini
|
||
|
|
if (process.env.GEMINI_API_KEY) {
|
||
|
|
providers.push({
|
||
|
|
id: 'default-gemini',
|
||
|
|
name: 'gemini',
|
||
|
|
apiKey: process.env.GEMINI_API_KEY,
|
||
|
|
modelName: process.env.GEMINI_MODEL || 'gemini-2.0-flash',
|
||
|
|
priority: 1,
|
||
|
|
maxTokens: 8192,
|
||
|
|
temperature: 0.7,
|
||
|
|
timeoutMs: 60000,
|
||
|
|
costPer1kInputTokens: 0.00025,
|
||
|
|
costPer1kOutputTokens: 0.001,
|
||
|
|
isHealthy: true,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// OpenAI
|
||
|
|
if (process.env.OPENAI_API_KEY) {
|
||
|
|
providers.push({
|
||
|
|
id: 'default-openai',
|
||
|
|
name: 'openai',
|
||
|
|
endpoint: 'https://api.openai.com/v1/chat/completions',
|
||
|
|
apiKey: process.env.OPENAI_API_KEY,
|
||
|
|
modelName: process.env.OPENAI_MODEL || 'gpt-4o-mini',
|
||
|
|
priority: 2,
|
||
|
|
maxTokens: 4096,
|
||
|
|
temperature: 0.7,
|
||
|
|
timeoutMs: 60000,
|
||
|
|
costPer1kInputTokens: 0.00015,
|
||
|
|
costPer1kOutputTokens: 0.0006,
|
||
|
|
isHealthy: true,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
// Claude
|
||
|
|
if (process.env.CLAUDE_API_KEY) {
|
||
|
|
providers.push({
|
||
|
|
id: 'default-claude',
|
||
|
|
name: 'claude',
|
||
|
|
endpoint: 'https://api.anthropic.com/v1/messages',
|
||
|
|
apiKey: process.env.CLAUDE_API_KEY,
|
||
|
|
modelName: process.env.CLAUDE_MODEL || 'claude-3-haiku-20240307',
|
||
|
|
priority: 3,
|
||
|
|
maxTokens: 4096,
|
||
|
|
temperature: 0.7,
|
||
|
|
timeoutMs: 60000,
|
||
|
|
costPer1kInputTokens: 0.00025,
|
||
|
|
costPer1kOutputTokens: 0.00125,
|
||
|
|
isHealthy: true,
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
return providers;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 채팅 API 호출 (자동 fallback)
|
||
|
|
*/
|
||
|
|
async chat(params) {
|
||
|
|
const {
|
||
|
|
model,
|
||
|
|
messages,
|
||
|
|
temperature = 0.7,
|
||
|
|
maxTokens = 4096,
|
||
|
|
userId,
|
||
|
|
apiKeyId,
|
||
|
|
} = params;
|
||
|
|
|
||
|
|
// 초기화 확인
|
||
|
|
if (!this.initialized) {
|
||
|
|
await this.initialize();
|
||
|
|
}
|
||
|
|
|
||
|
|
const startTime = Date.now();
|
||
|
|
let lastError = null;
|
||
|
|
|
||
|
|
// 요청된 모델에 맞는 프로바이더 찾기
|
||
|
|
const requestedProvider = this.providers.find(
|
||
|
|
(p) => p.modelName === model || p.name === model
|
||
|
|
);
|
||
|
|
|
||
|
|
// 우선순위 순으로 프로바이더 정렬
|
||
|
|
const sortedProviders = requestedProvider
|
||
|
|
? [requestedProvider, ...this.providers.filter((p) => p !== requestedProvider)]
|
||
|
|
: this.providers;
|
||
|
|
|
||
|
|
// 프로바이더 순회 (fallback)
|
||
|
|
for (const provider of sortedProviders) {
|
||
|
|
if (!provider.isHealthy) {
|
||
|
|
logger.warn(`⚠️ ${provider.name} 건강하지 않음, 건너뜀`);
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
try {
|
||
|
|
logger.info(`🚀 ${provider.name} (${provider.modelName}) 시도 중...`);
|
||
|
|
|
||
|
|
const result = await this.callProvider(provider, {
|
||
|
|
messages,
|
||
|
|
maxTokens: maxTokens || provider.maxTokens,
|
||
|
|
temperature: temperature || provider.temperature,
|
||
|
|
});
|
||
|
|
|
||
|
|
const responseTime = Date.now() - startTime;
|
||
|
|
|
||
|
|
// 비용 계산
|
||
|
|
const cost = this.calculateCost(
|
||
|
|
result.usage.promptTokens,
|
||
|
|
result.usage.completionTokens,
|
||
|
|
provider.costPer1kInputTokens,
|
||
|
|
provider.costPer1kOutputTokens
|
||
|
|
);
|
||
|
|
|
||
|
|
logger.info(
|
||
|
|
`✅ ${provider.name} 성공 (${responseTime}ms, ${result.usage.totalTokens} tokens)`
|
||
|
|
);
|
||
|
|
|
||
|
|
return {
|
||
|
|
text: result.text,
|
||
|
|
provider: provider.name,
|
||
|
|
providerId: provider.id,
|
||
|
|
model: provider.modelName,
|
||
|
|
usage: result.usage,
|
||
|
|
responseTime,
|
||
|
|
cost,
|
||
|
|
};
|
||
|
|
} catch (error) {
|
||
|
|
logger.error(`❌ ${provider.name} 실패:`, error.message);
|
||
|
|
lastError = error;
|
||
|
|
|
||
|
|
// 다음 프로바이더로 fallback
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 모든 프로바이더 실패
|
||
|
|
throw new Error(
|
||
|
|
`모든 LLM 프로바이더가 실패했습니다: ${lastError?.message || '알 수 없는 오류'}`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 개별 프로바이더 호출
|
||
|
|
*/
|
||
|
|
async callProvider(provider, { messages, maxTokens, temperature }) {
|
||
|
|
const timeout = provider.timeoutMs || 60000;
|
||
|
|
|
||
|
|
switch (provider.name) {
|
||
|
|
case 'gemini':
|
||
|
|
return this.callGemini(provider, { messages, maxTokens, temperature });
|
||
|
|
case 'openai':
|
||
|
|
return this.callOpenAI(provider, { messages, maxTokens, temperature, timeout });
|
||
|
|
case 'claude':
|
||
|
|
return this.callClaude(provider, { messages, maxTokens, temperature, timeout });
|
||
|
|
default:
|
||
|
|
throw new Error(`지원하지 않는 프로바이더: ${provider.name}`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Gemini API 호출
|
||
|
|
*/
|
||
|
|
async callGemini(provider, { messages, maxTokens, temperature }) {
|
||
|
|
const { GoogleGenAI } = require('@google/genai');
|
||
|
|
|
||
|
|
const ai = new GoogleGenAI({ apiKey: provider.apiKey });
|
||
|
|
|
||
|
|
// 메시지 변환 (OpenAI 형식 -> Gemini 형식)
|
||
|
|
const contents = messages.map((msg) => ({
|
||
|
|
role: msg.role === 'assistant' ? 'model' : 'user',
|
||
|
|
parts: [{ text: msg.content }],
|
||
|
|
}));
|
||
|
|
|
||
|
|
// system 메시지 처리
|
||
|
|
const systemMessage = messages.find((m) => m.role === 'system');
|
||
|
|
const systemInstruction = systemMessage ? systemMessage.content : undefined;
|
||
|
|
|
||
|
|
const config = {
|
||
|
|
maxOutputTokens: maxTokens,
|
||
|
|
temperature,
|
||
|
|
};
|
||
|
|
|
||
|
|
const result = await ai.models.generateContent({
|
||
|
|
model: provider.modelName,
|
||
|
|
contents: contents.filter((c) => c.role !== 'system'),
|
||
|
|
systemInstruction,
|
||
|
|
config,
|
||
|
|
});
|
||
|
|
|
||
|
|
// 응답 텍스트 추출
|
||
|
|
let text = '';
|
||
|
|
if (result.candidates?.[0]?.content?.parts) {
|
||
|
|
text = result.candidates[0].content.parts
|
||
|
|
.filter((p) => p.text)
|
||
|
|
.map((p) => p.text)
|
||
|
|
.join('\n');
|
||
|
|
}
|
||
|
|
|
||
|
|
const usage = result.usageMetadata || {};
|
||
|
|
const promptTokens = usage.promptTokenCount ?? 0;
|
||
|
|
const completionTokens = usage.candidatesTokenCount ?? 0;
|
||
|
|
|
||
|
|
return {
|
||
|
|
text,
|
||
|
|
usage: {
|
||
|
|
promptTokens,
|
||
|
|
completionTokens,
|
||
|
|
totalTokens: promptTokens + completionTokens,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* OpenAI API 호출
|
||
|
|
*/
|
||
|
|
async callOpenAI(provider, { messages, maxTokens, temperature, timeout }) {
|
||
|
|
const response = await axios.post(
|
||
|
|
provider.endpoint,
|
||
|
|
{
|
||
|
|
model: provider.modelName,
|
||
|
|
messages,
|
||
|
|
max_tokens: maxTokens,
|
||
|
|
temperature,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
timeout,
|
||
|
|
headers: {
|
||
|
|
'Content-Type': 'application/json',
|
||
|
|
Authorization: `Bearer ${provider.apiKey}`,
|
||
|
|
},
|
||
|
|
}
|
||
|
|
);
|
||
|
|
|
||
|
|
return {
|
||
|
|
text: response.data.choices[0].message.content,
|
||
|
|
usage: {
|
||
|
|
promptTokens: response.data.usage.prompt_tokens,
|
||
|
|
completionTokens: response.data.usage.completion_tokens,
|
||
|
|
totalTokens: response.data.usage.total_tokens,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Claude API 호출
|
||
|
|
*/
|
||
|
|
async callClaude(provider, { messages, maxTokens, temperature, timeout }) {
|
||
|
|
// system 메시지 분리
|
||
|
|
const systemMessage = messages.find((m) => m.role === 'system');
|
||
|
|
const otherMessages = messages.filter((m) => m.role !== 'system');
|
||
|
|
|
||
|
|
const response = await axios.post(
|
||
|
|
provider.endpoint,
|
||
|
|
{
|
||
|
|
model: provider.modelName,
|
||
|
|
messages: otherMessages,
|
||
|
|
system: systemMessage?.content,
|
||
|
|
max_tokens: maxTokens,
|
||
|
|
temperature,
|
||
|
|
},
|
||
|
|
{
|
||
|
|
timeout,
|
||
|
|
headers: {
|
||
|
|
'Content-Type': 'application/json',
|
||
|
|
'x-api-key': provider.apiKey,
|
||
|
|
'anthropic-version': '2023-06-01',
|
||
|
|
},
|
||
|
|
}
|
||
|
|
);
|
||
|
|
|
||
|
|
return {
|
||
|
|
text: response.data.content[0].text,
|
||
|
|
usage: {
|
||
|
|
promptTokens: response.data.usage.input_tokens,
|
||
|
|
completionTokens: response.data.usage.output_tokens,
|
||
|
|
totalTokens:
|
||
|
|
response.data.usage.input_tokens + response.data.usage.output_tokens,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 스트리밍 채팅 (제너레이터)
|
||
|
|
*/
|
||
|
|
async *chatStream(params) {
|
||
|
|
// 현재는 간단한 구현 (전체 응답 후 청크로 분할)
|
||
|
|
// 실제 스트리밍은 각 프로바이더의 스트리밍 API 사용 필요
|
||
|
|
const result = await this.chat(params);
|
||
|
|
|
||
|
|
// 텍스트를 청크로 분할하여 전송
|
||
|
|
const chunkSize = 10;
|
||
|
|
for (let i = 0; i < result.text.length; i += chunkSize) {
|
||
|
|
yield {
|
||
|
|
text: result.text.slice(i, i + chunkSize),
|
||
|
|
done: i + chunkSize >= result.text.length,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 비용 계산
|
||
|
|
*/
|
||
|
|
calculateCost(promptTokens, completionTokens, inputCost, outputCost) {
|
||
|
|
const inputTotal = (promptTokens / 1000) * inputCost;
|
||
|
|
const outputTotal = (completionTokens / 1000) * outputCost;
|
||
|
|
return parseFloat((inputTotal + outputTotal).toFixed(6));
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 싱글톤 인스턴스
|
||
|
|
const llmService = new LLMService();
|
||
|
|
|
||
|
|
module.exports = llmService;
|