llm-chat/backend/services/LLMApiClient.js

315 lines
8.1 KiB
JavaScript
Raw Permalink Normal View History

const axios = require('axios');
const { llmConfig, getHeaders } = require('../config/llm');
class LLMApiClient {
constructor() {
this.baseURL = llmConfig.baseURL;
this.model = llmConfig.model;
this.timeout = llmConfig.timeout;
this.maxRetries = llmConfig.maxRetries;
this.retryDelay = llmConfig.retryDelay;
// 创建axios实例
this.client = axios.create({
baseURL: this.baseURL,
timeout: this.timeout,
headers: getHeaders()
});
// 配置请求拦截器
this.client.interceptors.request.use(
(config) => {
console.log(`LLM API请求: ${config.method?.toUpperCase()} ${config.url}`);
return config;
},
(error) => {
console.error('LLM API请求拦截器错误:', error);
return Promise.reject(error);
}
);
// 配置响应拦截器
this.client.interceptors.response.use(
(response) => {
console.log(`LLM API响应: ${response.status} ${response.config.url}`);
return response;
},
(error) => {
console.error('LLM API响应错误:', error.response?.data || error.message);
return Promise.reject(error);
}
);
}
/**
* 调用聊天完成API
*/
async createChatCompletion(messages, options = {}) {
try {
const requestBody = {
model: options.model || this.model,
messages: this.formatMessages(messages),
...llmConfig.defaultOptions,
...options
};
// 移除undefined值
Object.keys(requestBody).forEach(key => {
if (requestBody[key] === undefined) {
delete requestBody[key];
}
});
console.log('发送LLM请求:', JSON.stringify(requestBody, null, 2));
const response = await this.makeRequestWithRetry('/chat/completions', requestBody);
// 返回标准格式的响应
return this.formatResponse(response.data);
} catch (error) {
console.error('LLM API调用失败:', error);
throw this.handleError(error);
}
}
/**
* 创建流式聊天完成
*/
async createStreamChatCompletion(messages, options = {}) {
try {
const requestBody = {
model: options.model || this.model,
messages: this.formatMessages(messages),
stream: true,
...llmConfig.defaultOptions,
...options
};
// 移除undefined值
Object.keys(requestBody).forEach(key => {
if (requestBody[key] === undefined) {
delete requestBody[key];
}
});
const response = await this.client.post('/chat/completions', requestBody, {
responseType: 'stream'
});
return response.data;
} catch (error) {
console.error('流式LLM API调用失败:', error);
throw this.handleError(error);
}
}
/**
* 带重试的请求方法
*/
async makeRequestWithRetry(endpoint, data, retryCount = 0) {
try {
const response = await this.client.post(endpoint, data);
return response;
} catch (error) {
// 检查是否应该重试
if (this.shouldRetry(error, retryCount)) {
console.log(`请求失败,${this.retryDelay}ms后进行第${retryCount + 1}次重试...`);
await this.delay(this.retryDelay);
return this.makeRequestWithRetry(endpoint, data, retryCount + 1);
}
throw error;
}
}
/**
* 判断是否应该重试
*/
shouldRetry(error, retryCount) {
// 超过最大重试次数
if (retryCount >= this.maxRetries) {
return false;
}
// 网络错误或服务器错误可以重试
if (error.code === 'ECONNABORTED' || error.code === 'ENOTFOUND') {
return true;
}
// HTTP状态码500+可以重试
if (error.response && error.response.status >= 500) {
return true;
}
// 429 Too Many Requests 可以重试
if (error.response && error.response.status === 429) {
return true;
}
return false;
}
/**
* 延迟函数
*/
delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* 格式化消息数组
*/
formatMessages(messages) {
// 确保有系统消息
const formattedMessages = [...messages];
// 检查是否已有系统消息
const hasSystemMessage = formattedMessages.some(msg => msg.role === 'system');
if (!hasSystemMessage && llmConfig.systemPrompt) {
formattedMessages.unshift({
role: 'system',
content: llmConfig.systemPrompt
});
}
return formattedMessages;
}
/**
* 格式化API响应
*/
formatResponse(data) {
if (!data || !data.choices || data.choices.length === 0) {
throw new Error('LLM API返回无效响应');
}
const choice = data.choices[0];
const message = choice.message;
return {
id: data.id,
model: data.model,
content: message.content,
role: message.role,
finishReason: choice.finish_reason,
usage: data.usage,
created: data.created
};
}
/**
* 处理API错误
*/
handleError(error) {
if (error.response) {
// API返回错误响应
const status = error.response.status;
const data = error.response.data;
switch (status) {
case 400:
return new Error(`请求参数错误: ${data.error?.message || '无效请求'}`);
case 401:
return new Error('API密钥无效或未提供');
case 403:
return new Error('API访问被拒绝请检查权限');
case 404:
return new Error('API端点不存在');
case 429:
return new Error('请求频率过高,请稍后重试');
case 500:
return new Error('LLM服务内部错误');
case 503:
return new Error('LLM服务暂时不可用');
default:
return new Error(`LLM API错误 (${status}): ${data.error?.message || '未知错误'}`);
}
} else if (error.request) {
// 网络错误
if (error.code === 'ECONNABORTED') {
return new Error('请求超时,请检查网络连接');
} else if (error.code === 'ENOTFOUND') {
return new Error('无法连接到LLM服务请检查网络和API地址');
} else {
return new Error(`网络错误: ${error.message}`);
}
} else {
// 其他错误
return new Error(`未知错误: ${error.message}`);
}
}
/**
* 检查API连接状态
*/
async checkConnection() {
try {
const testMessages = [
{ role: 'user', content: 'Hello' }
];
await this.createChatCompletion(testMessages, {
max_tokens: 10,
temperature: 0
});
return { status: 'connected', message: 'LLM API连接正常' };
} catch (error) {
return {
status: 'error',
message: error.message,
error: error
};
}
}
/**
* 获取可用模型列表如果API支持
*/
async getModels() {
try {
const response = await this.client.get('/models');
return response.data;
} catch (error) {
console.warn('获取模型列表失败:', error.message);
return null;
}
}
/**
* 计算tokens估算简单实现
*/
estimateTokens(text) {
// 简单估算:英文按空格分割,中文按字符数
const englishWords = (text.match(/[a-zA-Z]+/g) || []).length;
const chineseChars = (text.match(/[\u4e00-\u9fff]/g) || []).length;
const otherChars = text.replace(/[a-zA-Z\u4e00-\u9fff\s]/g, '').length;
return englishWords + chineseChars + Math.ceil(otherChars / 2);
}
/**
* 截断消息以适应token限制
*/
truncateMessages(messages, maxTokens = 4000) {
let totalTokens = 0;
const truncatedMessages = [];
// 从最后一条消息开始,向前累加
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
const messageTokens = this.estimateTokens(message.content);
if (totalTokens + messageTokens > maxTokens && truncatedMessages.length > 0) {
break;
}
truncatedMessages.unshift(message);
totalTokens += messageTokens;
}
return truncatedMessages;
}
}
module.exports = LLMApiClient;