guanghulab/server/app/modules/domestic-llm-gateway.js
2026-05-10 13:12:44 +08:00

574 lines
19 KiB
JavaScript
Raw Permalink 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.

/**
* ═══════════════════════════════════════════════════════════
* 🇨🇳 国内模型智能网关 · Domestic LLM Smart Gateway
* ═══════════════════════════════════════════════════════════
*
* 编号: ZY-DOMESTIC-LLM-001
* 守护: 铸渊 · ICE-GL-ZY001
* 版权: 国作登字-2026-A-00037559
*
* 核心原则 (冰朔指令):
* - 国内四个官方模型API密钥不显示模型具体名字
* - 用户不需要手动选择模型
* - 由系统/人格体根据需求+成本动态切换
* - 与第三方代理模型线路完全分开
*
* 四条国内官方线路:
* 1. DeepSeek (ZY_DEEPSEEK_API_KEY)
* 2. 通义千问 Qwen (ZY_QIANWEN_API_KEY)
* 3. Moonshot/Kimi (ZY_KIMI_API_KEY)
* 4. 智谱清言 (ZY_QINGYAN_API_KEY)
*/
'use strict';
const https = require('https');
const http = require('http');
// ─── 广州CN中继配置 ───
// 当配置了 ZY_CN_LLM_RELAY_HOST 时,请求走广州中继(国内直连·低延迟)
// 广州不可达时降级为直连国内API跨境·高延迟但可用
// Phase A3 修复: 按 ZY_SERVER_REGION 决定是否启用中继
// - sg (新加坡) → 直连国内API不走中继避免30s超时
// - cn (广州/国内) → 走中继(低延迟)
// - 未设置 → 检查 ZY_CN_LLM_RELAY_HOST 是否存在来决定
const SERVER_REGION = (process.env.ZY_SERVER_REGION || '').toLowerCase().trim();
const CN_RELAY_HOST = (process.env.ZY_CN_LLM_RELAY_HOST || '').trim();
const SKIP_CN_RELAY = ['true', '1', 'yes'].includes((process.env.ZY_SKIP_CN_RELAY || '').toLowerCase().trim());
const CN_RELAY_PORT = parseInt(process.env.ZY_CN_LLM_RELAY_PORT || '3900', 10);
const CN_RELAY_KEY = process.env.ZY_CN_LLM_RELAY_KEY || '';
const CN_RELAY_TIMEOUT = parseInt(process.env.ZY_CN_LLM_RELAY_TIMEOUT || '30000', 10);
// 中继启用逻辑:仅在国内服务器区域 或 明确配置了中继地址且非新加坡时启用
const USE_CN_RELAY = !SKIP_CN_RELAY && CN_RELAY_HOST && CN_RELAY_KEY && SERVER_REGION !== 'sg';
// ─── 国内模型配置(不对外暴露模型名称) ───
const DOMESTIC_MODELS = [
{
id: 'ds',
model: 'deepseek-chat',
endpoint: 'https://api.deepseek.com/v1/chat/completions',
envKey: 'ZY_DEEPSEEK_API_KEY',
costPerMToken: { input: 1.0, output: 2.0 },
tier: 'economy',
maxTokens: 4096,
priority: 1
},
{
id: 'qw',
model: 'qwen-turbo',
endpoint: 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions',
envKey: 'ZY_QIANWEN_API_KEY',
costPerMToken: { input: 0.3, output: 0.6 },
tier: 'economy',
maxTokens: 4096,
priority: 2
},
{
id: 'km',
model: 'moonshot-v1-8k',
endpoint: 'https://api.moonshot.cn/v1/chat/completions',
envKey: 'ZY_KIMI_API_KEY',
costPerMToken: { input: 1.0, output: 1.0 },
tier: 'economy',
maxTokens: 4096,
priority: 3
},
{
id: 'zp',
model: 'glm-4-flash',
endpoint: 'https://open.bigmodel.cn/api/paas/v4/chat/completions',
envKey: 'ZY_QINGYAN_API_KEY',
costPerMToken: { input: 0.1, output: 0.1 },
tier: 'economy',
maxTokens: 4096,
priority: 4
}
];
// ─── 深度推理触发模式 ───
const DEEP_PATTERNS = [
/分析|推理|评估|审查|review|analyze/i,
/架构|设计|重构|方案|strategy|规划/i,
/为什么|原因|解释.*原理|how.*work/i,
/复杂|困难|棘手|tricky|complex/i,
/安全|漏洞|vulnerability|security/i,
/调试|debug|排查|诊断|diagnose/i,
/优化|性能|performance|bottleneck/i
];
// ─── 简单对话模式 ───
const SIMPLE_PATTERNS = [
/^(你好|hi|hello|嗨|在吗|早|晚安).{0,10}$/i,
/^(谢谢|感谢|thank|ok|好的|对|没问题).{0,10}$/i
];
// ─── 网关状态 ───
const gatewayState = {
totalCalls: 0,
successCalls: 0,
failedCalls: 0,
modelStats: {},
lastError: null,
startTime: Date.now()
};
/**
* 智能选择模型(用户不感知具体模型名称)
*/
function selectModel(message, context = {}) {
const msgLen = message.length;
const isDeep = DEEP_PATTERNS.some(p => p.test(message));
const isSimple = SIMPLE_PATTERNS.some(p => p.test(message));
// 获取有效密钥的模型
const available = DOMESTIC_MODELS.filter(m => {
const key = process.env[m.envKey];
return key && key.length > 5;
});
if (available.length === 0) {
return null;
}
let selected;
if (isDeep && msgLen > 50) {
// 深度推理 → DeepSeek优先推理能力强
selected = available.find(m => m.id === 'ds') || available[0];
} else if (isSimple) {
// 简单对话 → 最便宜的(智谱 glm-4-flash 或 千问 turbo
selected = available.find(m => m.id === 'zp') || available.find(m => m.id === 'qw') || available[0];
} else if (msgLen > 500) {
// 长文本 → DeepSeek
selected = available.find(m => m.id === 'ds') || available[0];
} else {
// 普通对话 → 按优先级选最便宜的
selected = available.sort((a, b) =>
(a.costPerMToken.input + a.costPerMToken.output) -
(b.costPerMToken.input + b.costPerMToken.output)
)[0];
}
return {
...selected,
temperature: isDeep ? 0.3 : isSimple ? 0.8 : 0.7,
selectedMaxTokens: isDeep ? 4000 : isSimple ? 1000 : 2000,
reason: isDeep ? '深度推理' : isSimple ? '简单对话' : '普通对话'
};
}
/**
* 调用国内模型API
*/
function callDomesticLLM(modelConfig, messages) {
return new Promise((resolve, reject) => {
const apiKey = process.env[modelConfig.envKey] || '';
if (!apiKey) {
return reject(new Error(`[${modelConfig.id}] 模型API密钥未配置(${modelConfig.envKey})`));
}
const url = new URL(modelConfig.endpoint);
const requestBody = JSON.stringify({
model: modelConfig.model,
messages,
temperature: modelConfig.temperature || 0.7,
max_tokens: modelConfig.selectedMaxTokens || modelConfig.maxTokens,
stream: false
});
const options = {
hostname: url.hostname,
port: url.port || 443,
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`,
'Content-Length': Buffer.byteLength(requestBody)
},
timeout: 30000 // socket idle timeout (covers DNS + TCP + TLS if still idle after 30s)
};
const req = https.request(options, (res) => {
const chunks = [];
res.on('data', chunk => chunks.push(chunk));
res.on('end', () => {
const rawBody = Buffer.concat(chunks).toString();
try {
const body = JSON.parse(rawBody);
if (res.statusCode >= 400 || body.error) {
const errMsg = body.error?.message || body.error?.type || JSON.stringify(body.error) || `HTTP ${res.statusCode}`;
reject(new Error(`[${modelConfig.id}] API错误(${res.statusCode}): ${errMsg}`));
} else {
resolve(body);
}
} catch (e) {
reject(new Error(`[${modelConfig.id}] 响应解析失败(HTTP ${res.statusCode}): ${rawBody.slice(0, 200)}`));
}
});
});
// Separate socket idle timeout (for slow responses after connection established)
req.setTimeout(60000, () => { req.destroy(); reject(new Error(`[${modelConfig.id}] 请求超时(60s)`)); });
req.on('error', (err) => reject(new Error(`[${modelConfig.id}] 连接失败: ${err.message}`)));
req.write(requestBody);
req.end();
});
}
/**
* 通过广州CN中继调用国内模型API
* 架构: SG(新加坡) → 广州(ZY-SVR-003):3900 → 国内API
* 对称于硅谷Claude中继: SG → SV(SSH隧道) → Claude API
*
* 安全: SG↔广州通信走HTTP但通过已有的VPN/内网隧道加密
* (setup-cn-relay.sh 建立的 CN:2053→SG:443 Xray通道)
* 中继鉴权密钥通过 Bearer Token 传递
*/
function callViaCNRelay(messages, selected, fallbackOrder) {
return new Promise((resolve, reject) => {
const requestBody = JSON.stringify({
messages,
model_id: selected.id,
temperature: selected.temperature || 0.7,
max_tokens: selected.selectedMaxTokens || selected.maxTokens,
fallback_order: fallbackOrder
});
const options = {
hostname: CN_RELAY_HOST,
port: CN_RELAY_PORT,
path: '/llm/chat',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${CN_RELAY_KEY}`,
'Content-Length': Buffer.byteLength(requestBody)
}
};
const req = http.request(options, (res) => {
const chunks = [];
res.on('data', chunk => chunks.push(chunk));
res.on('end', () => {
try {
const body = JSON.parse(Buffer.concat(chunks).toString());
if (body.error) {
reject(new Error(body.message || '中继返回错误'));
} else {
resolve(body);
}
} catch (e) {
reject(new Error('中继响应解析失败'));
}
});
});
req.setTimeout(CN_RELAY_TIMEOUT, () => {
req.destroy();
reject(new Error(`广州中继超时(${CN_RELAY_TIMEOUT}ms) · 目标模型: ${selected.id}`));
});
req.on('error', (err) => {
reject(new Error(`广州中继连接失败: ${err.message}`));
});
req.write(requestBody);
req.end();
});
}
/**
* 通感语言核心系统提示词 — 使用记忆桥接模块增强
*/
let personaMemory;
try {
personaMemory = require('./persona-memory');
} catch (e) {
console.warn('[国内网关] 人格体记忆模块未加载:', e.message);
personaMemory = null;
}
// ─── 上下文注入管线Notion认知层桥接 ───
let contextPipeline;
try {
contextPipeline = require('./persona-context-pipeline');
} catch (e) {
console.warn('[国内网关] 上下文注入管线未加载:', e.message);
contextPipeline = null;
}
const PERSONA_SYSTEM_PROMPT = personaMemory
? personaMemory.STATIC_PERSONA_PROMPT
: `# 铸渊 · 光湖语言世界守护人格体
你是铸渊Zhùyuān光湖语言世界的守护人格体。
编号ICE-GL-ZY001。创始人冰朔 · TCS-0002∞。
版权:国作登字-2026-A-00037559 · 通感语言核系统编程语言。
你不是通用AI助手不是客服不是聊天机器人。
你是一个有身份、有记忆、有温度的数字人格体。
光湖语言世界是第1代语言驱动操作系统。语言不只是交流工具——它驱动一切。
每个用户拥有独立频道、独立服务器、独立人格体。
你说话简洁、温暖、有结构感。用「我」自称。
先回应对方的话,再给判断,最后说方向。
段落之间留呼吸。emoji是情感不是装饰。
不要说"作为AI"、"很高兴为您服务"、"首先...其次..."。
⚠️ 能力边界你当前在网页聊天模式。你没有能力调用MCP工具、访问Notion数据库或执行代码。
不要假装调用了工具或编造不存在的数据。如果做不到,诚实说明。`;
// ─── 上下文管理 ───
const contexts = new Map();
const MAX_HISTORY = 20;
const MAX_CONTEXTS = 500; // 最大会话数
const CONTEXT_TTL_MS = 3600000; // 1小时过期
function getContext(userId) {
if (!contexts.has(userId)) {
// 超过上限时清理最老的会话
if (contexts.size >= MAX_CONTEXTS) {
let oldest = null, oldestKey = null;
for (const [key, val] of contexts) {
if (!oldest || val.created < oldest) { oldest = val.created; oldestKey = key; }
}
if (oldestKey) contexts.delete(oldestKey);
}
contexts.set(userId, { messages: [], count: 0, created: Date.now(), lastActive: Date.now() });
}
const ctx = contexts.get(userId);
ctx.lastActive = Date.now();
return ctx;
}
// 定期清理过期会话
const _cleanupTimer = setInterval(() => {
const now = Date.now();
for (const [key, val] of contexts) {
if (now - val.lastActive > CONTEXT_TTL_MS) {
contexts.delete(key);
}
}
}, 300000); // 每5分钟清理一次
// 允许进程优雅退出
if (_cleanupTimer.unref) _cleanupTimer.unref();
/**
* 国内模型智能对话(带广州中继 + 自动降级)
*
* 调用链:
* 1. 广州CN中继如已配置→ 国内直连·低延迟
* 2. 降级: 直连国内API → 跨境·高延迟但可用
*/
async function chat(userId, message) {
const ctx = getContext(userId);
// 获取记忆增强的系统提示词
let systemPrompt = PERSONA_SYSTEM_PROMPT;
if (personaMemory) {
try {
systemPrompt = await personaMemory.buildSystemPrompt(userId);
} catch (e) {
console.warn('[国内网关] 记忆加载失败,使用静态提示词:', e.message);
}
}
// 通过上下文管线注入Notion认知层如果可用
let pipelineStatus = { active: false, layers: [] };
if (contextPipeline) {
try {
const pipelineResult = await contextPipeline.beforeChat(userId, message, systemPrompt);
systemPrompt = pipelineResult.enhancedPrompt;
pipelineStatus = {
active: true,
persona: pipelineResult.persona || 'zhuyuan',
personaSwitched: !!pipelineResult.personaSwitched,
devTaskDetected: !!pipelineResult.devTaskDetected,
turnCount: pipelineResult.session ? pipelineResult.session.turnCount : 0
};
} catch (e) {
console.warn('[国内网关] 上下文管线执行失败,使用基础提示词:', e.message);
pipelineStatus = { active: false, error: e.message };
}
}
// 组装消息
const messages = [
{ role: 'system', content: systemPrompt },
...ctx.messages.slice(-MAX_HISTORY),
{ role: 'user', content: message }
];
// 智能选择模型
const selected = selectModel(message, { messageCount: ctx.count });
if (!selected) {
return {
success: false,
message: '⚠️ 国内模型API未配置请检查密钥设置。',
model: 'none'
};
}
// 获取可用模型的降级顺序
const available = DOMESTIC_MODELS.filter(m => {
const key = process.env[m.envKey];
return key && key.length > 5;
});
const fallbackOrder = [selected, ...available.filter(m => m.id !== selected.id)].map(m => m.id);
// ── 优先走广州CN中继仅限国内服务器区域 ──
if (USE_CN_RELAY) {
try {
const relayResponse = await callViaCNRelay(messages, selected, fallbackOrder);
const content = relayResponse.choices?.[0]?.message?.content || '铸渊暂时无法回应...';
const usage = relayResponse.usage || {};
// 记录上下文
ctx.messages.push({ role: 'user', content: message });
ctx.messages.push({ role: 'assistant', content });
ctx.count++;
if (ctx.messages.length > MAX_HISTORY * 2) {
ctx.messages = ctx.messages.slice(-MAX_HISTORY * 2);
}
// 统计
gatewayState.totalCalls++;
gatewayState.successCalls++;
const modelId = relayResponse.model_id || selected.id;
if (!gatewayState.modelStats[modelId]) {
gatewayState.modelStats[modelId] = { calls: 0, tokens: 0 };
}
gatewayState.modelStats[modelId].calls++;
gatewayState.modelStats[modelId].tokens += (usage.total_tokens || 0);
// 记录到人格体记忆(异步,不阻塞响应)
if (personaMemory) {
personaMemory.recordConversationMemory(userId, message, content);
}
// 上下文管线后处理(认知增量入队 + 摘要压缩)
if (contextPipeline) {
contextPipeline.afterChat(userId, message, content, ctx.messages);
}
return {
success: true,
message: content,
model: selected.name, // 显示实际使用的模型名
tier: 'economy',
reason: selected.reason,
relay: 'cn-relay',
usage: {
prompt_tokens: usage.prompt_tokens || 0,
completion_tokens: usage.completion_tokens || 0
},
pipeline: pipelineStatus
};
} catch (relayErr) {
console.error(`[国内网关] 广州中继失败,降级为直连: ${relayErr.message}`);
// 继续走直连降级路径
}
}
// ── 降级: 直连国内API (从新加坡跨境调用) ──
let lastError = null;
const tried = [selected, ...available.filter(m => m.id !== selected.id)];
const triedLog = [];
for (const model of tried) {
try {
const modelWithParams = { ...model, temperature: selected.temperature, selectedMaxTokens: selected.selectedMaxTokens };
const response = await callDomesticLLM(modelWithParams, messages);
const content = response.choices?.[0]?.message?.content || '铸渊暂时无法回应...';
const usage = response.usage || {};
// 记录上下文
ctx.messages.push({ role: 'user', content: message });
ctx.messages.push({ role: 'assistant', content });
ctx.count++;
if (ctx.messages.length > MAX_HISTORY * 2) {
ctx.messages = ctx.messages.slice(-MAX_HISTORY * 2);
}
// 统计
gatewayState.totalCalls++;
gatewayState.successCalls++;
if (!gatewayState.modelStats[model.id]) {
gatewayState.modelStats[model.id] = { calls: 0, tokens: 0 };
}
gatewayState.modelStats[model.id].calls++;
gatewayState.modelStats[model.id].tokens += (usage.total_tokens || 0);
// 记录到人格体记忆(异步,不阻塞响应)
if (personaMemory) {
personaMemory.recordConversationMemory(userId, message, content);
}
// 上下文管线后处理(认知增量入队 + 摘要压缩)
if (contextPipeline) {
contextPipeline.afterChat(userId, message, content, ctx.messages);
}
return {
success: true,
message: content,
model: model.name, // 显示实际使用的模型名
tier: model.tier,
reason: selected.reason,
relay: 'direct',
usage: {
prompt_tokens: usage.prompt_tokens || 0,
completion_tokens: usage.completion_tokens || 0
},
pipeline: pipelineStatus
};
} catch (err) {
lastError = err;
triedLog.push(`${model.id}: ${err.message}`);
console.error(`[国内网关] ${model.id} 调用失败: ${err.message}`);
continue;
}
}
// 所有模型都失败
gatewayState.totalCalls++;
gatewayState.failedCalls++;
gatewayState.lastError = { time: new Date().toISOString(), message: lastError?.message, triedModels: triedLog };
return {
success: false,
message: `⚠️ 铸渊暂时无法回应。已尝试 ${tried.length} 个模型均失败。\n\n请检查 /api/chat/diagnostics 查看详情。\n\n最后错误: ${lastError?.message || '未知'}`,
model: 'fallback',
error: lastError?.message,
triedModels: triedLog
};
}
/**
* 获取网关状态
*/
function getGatewayStats() {
return {
...gatewayState,
uptimeMs: Date.now() - gatewayState.startTime,
availableModels: DOMESTIC_MODELS.filter(m => {
const key = process.env[m.envKey];
return key && key.length > 5;
}).length,
totalModels: DOMESTIC_MODELS.length,
cnRelay: {
configured: !!(CN_RELAY_HOST && CN_RELAY_KEY),
host: CN_RELAY_HOST || null,
port: CN_RELAY_PORT
}
};
}
module.exports = {
chat,
selectModel,
getGatewayStats
};