chengjie 1 月之前
父节点
当前提交
48320f3203
共有 4 个文件被更改,包括 219 次插入62 次删除
  1. 46 0
      doc/ai_generation_model_config.md
  2. 137 19
      src/api/yjbdc/aiController.js
  3. 27 43
      src/api/yjbdc/yjbdcController.js
  4. 9 0
      src/config/index.js

+ 46 - 0
doc/ai_generation_model_config.md

@@ -0,0 +1,46 @@
1
+# AI 文章生成模型配置
2
+
3
+## 唯一更换位置
4
+
5
+文章生成接口实际使用的大模型 provider key 只从这里读取:
6
+
7
+`src/config/index.js` -> `commonConfig.aiGeneration.articleProvider`
8
+
9
+当前默认值:
10
+
11
+```js
12
+articleProvider: process.env.AI_GENERATION_PROVIDER || 'doubao-deepseek-v4-pro-260425'
13
+```
14
+
15
+以后更换文章生成大模型,只改这个配置项,或在服务器启动环境中设置。这个值可以是火山、阿里、腾讯、讯飞、OpenRouter、百度等任一已经在 `src/api/yjbdc/aiController.js` 的 `AIProviderFactory` 中支持的 provider key。
16
+
17
+```bash
18
+AI_GENERATION_PROVIDER=doubao-deepseek-v4-pro-260425
19
+```
20
+
21
+示例:
22
+
23
+```bash
24
+AI_GENERATION_PROVIDER=doubao-deepseek-v4-pro-260425
25
+AI_GENERATION_PROVIDER=ali-qwen-max
26
+AI_GENERATION_PROVIDER=tencent-hunyuan-turbos-latest
27
+AI_GENERATION_PROVIDER=xf-yun-spark-x1
28
+AI_GENERATION_PROVIDER=openrouter-moonshotai/kimi-k2
29
+```
30
+
31
+如果前端需要显示不同名称、版本号或预计生成时间,同一处配置同步调整:
32
+
33
+```js
34
+articleProviderVersion
35
+articleProviderContent
36
+articleProviderBuildSecond
37
+```
38
+
39
+## 当前策略
40
+
41
+- 后端忽略前端传入的 `AIVersion`,统一使用 `config.aiGeneration.articleProvider`。
42
+- `aiController.generateArticle(content, provider)` 的第二个参数只保留旧调用兼容,不再参与模型选择。
43
+- `/api/GetYJBDCGenerateConfig` 只返回当前配置的一个模型选项,避免前端显示已不可用的模型。
44
+- 不配置备用 provider,避免自动切到已不可用的模型或供应商。
45
+- 如果配置了不支持的 provider key,服务会显式报错,不会静默回退到某一家。
46
+- 使用火山 provider 时,VolcesAI 请求保留节流和 429 退避重试,减少 RPM 超限错误;使用其他 provider 时不受这组火山专用节流参数影响。

+ 137 - 19
src/api/yjbdc/aiController.js

@@ -3,6 +3,98 @@ import crypto from 'crypto';
3 3
 import config from '../../config/index.js';
4 4
 import { enhanceFormsOfWords } from './enhanceFormsOfWords.js';
5 5
 
6
+const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
7
+
8
+const toPositiveInteger = (value, fallback) => {
9
+    const number = Number(value);
10
+    return Number.isFinite(number) && number > 0 ? Math.floor(number) : fallback;
11
+};
12
+
13
+const VOLCES_MIN_INTERVAL_MS = toPositiveInteger(config.aiGeneration?.volcesMinIntervalMs, 15000);
14
+const VOLCES_MAX_RETRIES = toPositiveInteger(config.aiGeneration?.volcesMaxRetries, 2);
15
+const VOLCES_TIMEOUT_MS = toPositiveInteger(config.aiGeneration?.volcesTimeoutMs, 90000);
16
+
17
+let volcesNextRequestAt = 0;
18
+let volcesQueue = Promise.resolve();
19
+
20
+function getAxiosStatus(error) {
21
+    return error?.response?.status || error?.status;
22
+}
23
+
24
+function getExternalErrorCode(error) {
25
+    return error?.response?.data?.error?.code || error?.code || '';
26
+}
27
+
28
+function isRateLimitError(error) {
29
+    return getAxiosStatus(error) === 429 || String(getExternalErrorCode(error)).includes('RateLimit');
30
+}
31
+
32
+function isModelUnavailableError(error) {
33
+    return getAxiosStatus(error) === 404 || String(getExternalErrorCode(error)).includes('InvalidEndpointOrModel');
34
+}
35
+
36
+function sanitizeExternalError(error) {
37
+    return {
38
+        status: getAxiosStatus(error),
39
+        code: getExternalErrorCode(error),
40
+        message: error?.response?.data?.error?.message || error?.message
41
+    };
42
+}
43
+
44
+function createAIProviderError(provider, model, error) {
45
+    const status = getAxiosStatus(error);
46
+    const code = getExternalErrorCode(error);
47
+    const messageParts = [`${provider} API failed`];
48
+
49
+    if (status) {
50
+        messageParts.push(`status ${status}`);
51
+    }
52
+    if (code) {
53
+        messageParts.push(code);
54
+    }
55
+    if (error?.message) {
56
+        messageParts.push(error.message);
57
+    }
58
+
59
+    const providerError = new Error(messageParts.join(': '));
60
+    providerError.name = 'AIProviderError';
61
+    providerError.provider = provider;
62
+    providerError.model = model;
63
+    providerError.status = status;
64
+    providerError.code = code;
65
+    providerError.isExternalAIProviderError = true;
66
+    providerError.isRateLimit = isRateLimitError(error);
67
+    providerError.isModelUnavailable = isModelUnavailableError(error);
68
+    providerError.details = sanitizeExternalError(error);
69
+    providerError.cause = error;
70
+    return providerError;
71
+}
72
+
73
+async function waitForVolcesSlot() {
74
+    const previousQueue = volcesQueue;
75
+
76
+    let release;
77
+    volcesQueue = new Promise(resolve => {
78
+        release = resolve;
79
+    });
80
+
81
+    await previousQueue.catch(() => {});
82
+    try {
83
+        const waitMs = Math.max(0, volcesNextRequestAt - Date.now());
84
+        if (waitMs > 0) {
85
+            console.warn(`火山云AI请求节流,等待${Math.ceil(waitMs / 1000)}秒`);
86
+            await sleep(waitMs);
87
+        }
88
+        volcesNextRequestAt = Date.now() + VOLCES_MIN_INTERVAL_MS;
89
+    } finally {
90
+        release();
91
+    }
92
+}
93
+
94
+function delayVolcesRequests(delayMs) {
95
+    volcesNextRequestAt = Math.max(volcesNextRequestAt, Date.now() + delayMs);
96
+}
97
+
6 98
 /**
7 99
  * AI平台接口类
8 100
  * 定义了所有AI平台需要实现的方法
@@ -110,23 +202,35 @@ class VolcesAIProvider extends AIProvider {
110 202
             };
111 203
         }
112 204
 
113
-        try {
114
-            console.log(`火山云${this.version}`);
115
-            //console.log(JSON.stringify(postJSON));
116
-            // 移除encodeURI,直接使用原始URL
117
-            const response = await axios.post(this.url, postJSON, { headers: this.headers });
118
-            return response.data.choices[0].message.content;
119
-        } catch (error) {
120
-            console.error("VolcesAI API error:", error);
121
-            // 添加更详细的错误日志
122
-            if (error.response) {
123
-                console.error("错误详情:", {
124
-                    status: error.response.status,
125
-                    data: error.response.data
205
+        let lastError = null;
206
+
207
+        for (let attempt = 1; attempt <= VOLCES_MAX_RETRIES + 1; attempt++) {
208
+            try {
209
+                await waitForVolcesSlot();
210
+                console.log(`火山云${this.version}`);
211
+                //console.log(JSON.stringify(postJSON));
212
+                // 移除encodeURI,直接使用原始URL
213
+                const response = await axios.post(this.url, postJSON, {
214
+                    headers: this.headers,
215
+                    timeout: VOLCES_TIMEOUT_MS
126 216
                 });
217
+                return response.data.choices[0].message.content;
218
+            } catch (error) {
219
+                lastError = error;
220
+
221
+                if (isRateLimitError(error) && attempt <= VOLCES_MAX_RETRIES) {
222
+                    const backoffMs = Math.max(60000, VOLCES_MIN_INTERVAL_MS * (attempt + 1));
223
+                    delayVolcesRequests(backoffMs);
224
+                    console.warn(`火山云AI接口限流,第${attempt}次失败,等待${Math.ceil(backoffMs / 1000)}秒后重试`);
225
+                    await sleep(backoffMs);
226
+                    continue;
227
+                }
228
+
229
+                throw createAIProviderError('VolcesAI', this.model, error);
127 230
             }
128
-            throw error;
129 231
         }
232
+
233
+        throw createAIProviderError('VolcesAI', this.model, lastError);
130 234
     }
131 235
 }
132 236
 
@@ -538,25 +642,39 @@ class AIProviderFactory {
538 642
             case 'baidu-ernie-4.5-turbo-vl-32k-preview':
539 643
                 return new BaiduAIProvider("ernie-4.5-turbo-vl-32k-preview");
540 644
             default:
541
-                return new VolcesAIProvider("doubao-kimi-k2-250711"); // 默认使用火山云1.5
645
+                throw new Error(`Unsupported AI provider: ${provider}`);
542 646
         }
543 647
     }
544 648
 }
545 649
 
650
+AIProviderFactory.getProvider(config.aiGeneration?.articleProvider || 'doubao-deepseek-v4-pro-260425');
651
+
546 652
 /**
547 653
  * 生成文章的主函数
548 654
  * @param {string} content - 生成文章的提示内容
549
- * @param {string} provider - AI提供者名称,默认为'volces'
655
+ * @param {string} provider - 保留兼容旧调用,实际提供者统一读取 config.aiGeneration.articleProvider
550 656
  * @returns {Promise<string>} - 返回生成的文章JSON字符串
551 657
  */
552 658
 async function generateArticle(content, provider) {
659
+    const currentProvider = config.aiGeneration?.articleProvider || 'doubao-deepseek-v4-pro-260425';
660
+
553 661
     try {
554
-        const aiProvider = AIProviderFactory.getProvider(provider);
662
+        const aiProvider = AIProviderFactory.getProvider(currentProvider);
555 663
         //console.log(JSON.stringify(aiProvider));
556 664
         const result = await aiProvider.generateArticle(content);
557 665
         return result;
558 666
     } catch (error) {
559
-        console.error("Generate article error:", error);
667
+        if (error?.isExternalAIProviderError) {
668
+            console.error("Generate article error:", {
669
+                provider: error.provider,
670
+                model: error.model,
671
+                status: error.status,
672
+                code: error.code,
673
+                message: error.message
674
+            });
675
+        } else {
676
+            console.error("Generate article error:", error);
677
+        }
560 678
         throw error;
561 679
     }
562 680
 }
@@ -710,4 +828,4 @@ export default {
710 828
     enhanceFormsOfWords,
711 829
     validateAndFixJSON,
712 830
     normalizeArticleFields
713
-};
831
+};

+ 27 - 43
src/api/yjbdc/yjbdcController.js

@@ -86,35 +86,14 @@ export async function GenerateArticle(ctx) {
86 86
             };
87 87
             content = JSON.stringify(content);
88 88
 
89
-            // 从请求参数中获取AI提供者,如果没有指定则使用默认值
90
-            let aiProvider = '';
91
-            for (let i = 0; i < menuConfig.AIVersion.length; i++) {
92
-                if (menuConfig.AIVersion[i].Version == params.AIVersion) {
93
-                    aiProvider = menuConfig.AIVersion[i].Model;
94
-                    break;
95
-                }
96
-            }
97
-
98
-            //给用户一些比较好的体验
99
-            
100
-            // 按照权重概率分配AI提供商:40%概率使用ali-Moonshot-Kimi-K2-Instruct,60%概率使用doubao-kimi-k2-250711
101
-            aiProvider = stringUtils.weightedRandom({
102
-                // 'ali-Moonshot-Kimi-K2-Instruct': 40,
103
-                // 'ali-Moonshot-Kimi-K2-Instruct': 60,
104
-                //'doubao-deepseek-v3-250324': 100,
105
-                //'doubao-kimi-k2-250711': 100,
106
-                //'doubao-deepseek-v3-2-251201': 100,
107
-                //'doubao-seed-1-8-251228': 100,
108
-                'doubao-deepseek-v4-pro-260425':100,
109
-                //'ali-Moonshot-kimi-k2.5': 100,
110
-            });
89
+            const aiProvider = config.aiGeneration.articleProvider;
111 90
            
112 91
 
113 92
             try {
114 93
                 //开始时间
115 94
                 let timeStart = new Date().getTime();
116 95
                 // 使用aiController生成文章
117
-                let result2 = await aiController.generateArticle(content, aiProvider);
96
+                let result2 = await aiController.generateArticle(content);
118 97
 
119 98
                 //console.log(result2);
120 99
                 //debugger;
@@ -391,17 +370,14 @@ export async function GetYJBDCGenerateConfig(ctx) {
391 370
     }
392 371
 
393 372
 
394
-    if (param.UserID > 3) {
395
-        result.AIVersion.splice(2, result.AIVersion.length - 2);
396
-        if (param.UserID == 185) {
397
-            const configArr = constantClass.GetYJBDCGenerateConfig();
398
-            result.AIVersion.push(configArr.AIVersion[5]);
399
-        }
400
-        // if (param.UserID<4){
401
-        //     const configArr=constantClass.GetYJBDCGenerateConfig();
402
-        //     result.AIVersion.push(configArr.AIVersion[6]);
403
-        // }
404
-    }
373
+    result.AIVersion = [{
374
+        Version: config.aiGeneration.articleProviderVersion,
375
+        BuildSecond: config.aiGeneration.articleProviderBuildSecond,
376
+        Model: config.aiGeneration.articleProvider,
377
+        Content: config.aiGeneration.articleProviderContent,
378
+        CSS: "Selected"
379
+    }];
380
+
405 381
     ctx.body = { "errcode": 10000, result: result };
406 382
 }
407 383
 
@@ -1341,16 +1317,28 @@ export async function GetWordDetail(ctx) {
1341 1317
         return;
1342 1318
     }
1343 1319
 
1320
+    const fetchWordDetail = async (word) => {
1321
+        try {
1322
+            const url = "https://www.kylx365.com/api/GetMiaoguoAISearch2?Word=" + encodeURIComponent(word);
1323
+            const response = await axios.get(url, { timeout: 10000 });
1324
+            return response.data?.result || [];
1325
+        } catch (error) {
1326
+            console.warn("GetWordDetail external lookup failed:", {
1327
+                word,
1328
+                status: error?.response?.status,
1329
+                message: error?.message
1330
+            });
1331
+            return [];
1332
+        }
1333
+    };
1334
+
1344 1335
     // 使用单词作为缓存键,为每个单词单独缓存结果
1345 1336
     const cacheKey = `GetWordDetail_${param.Word}`;
1346 1337
     let result = globalCache.get(cacheKey);
1347 1338
 
1348 1339
     if (!result) {
1349 1340
         // 缓存未命中,从数据库获取
1350
-        let url = "https://www.kylx365.com/api/GetMiaoguoAISearch2?Word=";
1351
-        const tokenurl=url + param.Word;
1352
-        result = await axios.get(tokenurl);
1353
-        result=result.data.result;
1341
+        result = await fetchWordDetail(param.Word);
1354 1342
 
1355 1343
         // 如果没有找到结果,尝试查找单词的原形
1356 1344
         if (!result || result.length === 0) {
@@ -1360,11 +1348,7 @@ export async function GetWordDetail(ctx) {
1360 1348
             // 尝试每个可能的原形
1361 1349
             for (const baseWord of possibleBaseWords) {
1362 1350
                 //console.log(`尝试查找单词 ${param.Word} 的可能原形: ${baseWord}`);
1363
-                const baseParam = { ...param, Word: baseWord };
1364
-                
1365
-                const tokenurl=url + baseParam;
1366
-                let baseResult = await axios.get(tokenurl);
1367
-                baseResult=baseResult.data.result;
1351
+                const baseResult = await fetchWordDetail(baseWord);
1368 1352
 
1369 1353
                 if (baseResult && baseResult.length > 0) {
1370 1354
                     //console.log(`找到单词 ${param.Word} 的原形 ${baseWord}`);

+ 9 - 0
src/config/index.js

@@ -23,6 +23,15 @@ const commonConfig = {
23 23
         maxPayloadLength: toNumber(process.env.ERROR_LOG_MAX_PAYLOAD_LENGTH, 8000),
24 24
         maxStackLength: toNumber(process.env.ERROR_LOG_MAX_STACK_LENGTH, 30000)
25 25
     },
26
+    aiGeneration: {
27
+        articleProvider: process.env.AI_GENERATION_PROVIDER || 'doubao-deepseek-v4-pro-260425',
28
+        articleProviderVersion: process.env.AI_GENERATION_PROVIDER_VERSION || 'zdp4f',
29
+        articleProviderContent: process.env.AI_GENERATION_PROVIDER_CONTENT || '字节deepseek_v4pro\n平均30秒生成',
30
+        articleProviderBuildSecond: toNumber(process.env.AI_GENERATION_PROVIDER_BUILD_SECOND, 30),
31
+        volcesMinIntervalMs: toNumber(process.env.VOLCES_AI_MIN_INTERVAL_MS, 15000),
32
+        volcesMaxRetries: toNumber(process.env.VOLCES_AI_MAX_RETRIES, 2),
33
+        volcesTimeoutMs: toNumber(process.env.VOLCES_AI_TIMEOUT_MS, 90000)
34
+    },
26 35
     CDNUrl: 'https://cdn.example.com/',
27 36
     database: {
28 37
         multipleStatements: true,