llm-chat/backend/services/LLMApiClient.js

315 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;