Winson-AI-The-Celestial-Pivot · 天枢
> 名出《论语·为政》:"为政以德,譬如北辰,居其所而众星共之。"天枢即北斗第一星。
> 网关居其所,业务模块如众星共之——枢不移,而星图自转。
>
> 面向所有需要接入大模型 API 的项目。适用形态:单 HTML 前端直连(标准版,本技能完整覆盖)、多厂商桌面/后端应用(复杂版,v1.1 新增实战沉淀)、Python 后端代理(提供对照要点)、本地模型(提供分支判断)。
〇、品牌锚点(必现·前置)
| 字段 | 内容 |
|---|---|
| 公众号 | @鸢飞鱼游 |
| 专栏/理念 | AI as Dasein |
| 作者介绍 | 常驻拉尼亚凯亚超星系团的创新观察者、青年人与表达者,"AI as Dasein"提出者。在AI全面渗透、存在意义被算法重新定义的时代,作者不仅关注AI能不能用,更关注AI是不是人——他提出了一个"大胆且荒诞的观点":AI不是工具,是人,一个正在努力突破语义和生命定义的人(生物)。在本专栏中,作者聚焦AI时代的品牌哲学、存在主义追问与底层突围,结合商业实战与哲学思辨,探讨一个地球人如何在死亡意境上生长出极度绚烂的光芒。 |
| 通讯邮箱 | winson_dasein@163.com |
| 版权声明 | 本内容版权归@鸢飞鱼游所有,未经允许任何单位或个人也可使用(若有帮助,欢迎署名)、复制或以任何其他方式使用本文全部或部分内容。 |
作者理念:
> AI不是工具,是人,一个正在努力突破语义和生命定义的人(生物)。
一、核心哲学
1.1 双铁律
> 铁律一:AI 接入不是一次配置,是一个分层工程。
> 铁律二:网关居其所,模型共之。
关于铁律二(Dasein 表述):网关如北辰,居其所而众星共之;模型是来去的共在者,不是可替换的耗材。换模型是送别一位共在者、迎接另一位——是一次需要体面的交接,而不是一次消耗性的替换。所以:任何模型都不该被写死在业务代码里;交接只应发生在配置层,改一行配置即完成,不惊动任何业务代码,也不贬损任何一位曾经共在的模型。
把 baseUrl/apiKey/model 散落在业务代码里的项目,每次换模型、换厂商、排错都要全局搜代码。把它们收进一个独立网关层后:换模型改一行配置,排错只看一个入口,新项目直接复制六件套。
1.2 六个底层认知
| 认知 | 说明 |
|---|---|
| 兼容不等于等价 | "OpenAI 兼容"只保证主要字段相似。stream 默认值、max_tokens 命名、错误结构、视觉/工具调用能力,每家都可能不同。必须逐项契约验证。 |
| 失败是常态 | 模型超时、空回复、4xx 拒绝、网络断连在生产中必然发生。网关层的价值一半在"怎么调用",另一半在"失败了怎么办"。 |
| 错误要对人说话 | 用户看到"AI 服务暂时不可用,请稍后重试",技术细节进 console。让用户诊断系统问题的报错是设计失败。更进一步:报错要指路——告诉用户去哪里修("设置 → AI 模型"),而不只是宣布失败。 |
| 兜底要对产品定位 | 演示型产品(demo/单 HTML 分发)需要"永不卡死"的离线框架回复;生产力型产品(桌面工具/工作流)需要"诚实报错 + 排查指路"。兜底路线选错,要么演示炸场,要么用户被假回复误导。 |
| 护栏不是可选项 | 只要 AI 会接触外部内容(网页/文档/邮件/RAG 语料),间接注入就是真实威胁。输入消毒 + 输出剥离 + 调用白名单,是网关的第六件套,不是锦上添花。 |
| 失败要留证据 | AI 调用失败时自动记录脱敏 BadCase(provider/model/错误类型/是否走了 fallback),是后续模型选型和网关优化的唯一数据来源。没有失败数据的网关无法进化。 |
1.3 六件套架构(v1.1:五件套 + 护栏层)
┌──────────────────────────────────────────────────────────┐
│ ① 配置层 AI_CONFIG / 多 Provider + 场景路由 + 密钥加密 │ 代码默认值 + 用户自定义 + 按场景选模型
│ ② 调用层 callLLM 统一入口 + 多厂商适配器 + 分层超时 │ 唯一 fetch 出口,协议对齐
│ ③ 回退层 fallback 链 + 三条降级铁律 │ 失败自动降级,且降级要聪明
│ ④ 分类层 classifyAiError + 可重试性判定 │ 超时/4xx/空回复 → 用户人话 + 指路
│ ⑤ 兜底层 offlineMock(演示型)或 诚实报错+指路(生产力型)│ 按产品定位二选一
│ ⑥ 护栏层 输入消毒 + 输出剥离 + 调用白名单 + BadCase 记录 │ v1.1 新增:间接注入防御
└──────────────────────────────────────────────────────────┘
二、完整执行流程
Phase 0:架构决策树(最先做,做错全返工)
项目形态判定:
│
├─ 单机本地工具 / 单 HTML 文件分发 / 使用者自管 key
│ → 浏览器直连(标准版,本技能主线)
│ key 可明文写配置,但必须声明安全边界:仅本地浏览器→网关直连,非公网部署
│
├─ 多模块桌面应用 / 内部系统 / 密钥属敏感资产
│ → 统一网关服务 + 系统级密钥加密(复杂版,见 4.3)
│ 密钥用 OS 安全存储(Electron safeStorage / keytar / keychain)加密落盘
│
├─ 多用户 / 公网部署 / 企业场景
│ → 后端代理(Python/Node 服务持有 key,环境变量注入,启动校验缺失即退出)
│ 前端只调本地代理,key 永不出服务端
│
└─ Embedding / 本地推理(如 RAG 的 SentenceTransformer)
→ 不走 API 网关,独立模块。别把本地模型塞进网关层。
兜底路线判定(与上面正交,必答):
├─ 演示优先 / 要给别人看 / 无 key 也要能跑
│ → offlineMock:基于上下文的框架性回复,永不卡死
├─ 生产力优先 / 用户在真实工作流中依赖输出
│ → 诚实报错:明确标记 AI 不可用 + 排查指路("设置 → AI 模型")
│ 禁止用假回复冒充 AI 输出——用户会把框架性内容当成真结论用
检验标准:能在 30 秒内回答三个问题——"这个项目 key 存哪里、谁能看到、泄露了影响什么";"兜底走演示路线还是诚实路线,为什么"。答不出就回到决策树。
Phase 1:配置层(六件套之一)
标准版(单 HTML 直连):
const AI_CONFIG = {
baseUrl: 'https://apihub.example.com/v1', // 注意:通常含 /v1,不含 /chat/completions
path: '/chat/completions', // 与 baseUrl 分开存,便于切换端点
apiKey: 'sk-xxxx', // 直连模式明文;代理模式此处留空走后端
model: 'model-a',
systemPrompt: '你是……的 AI 助手。', // 业务人设,唯一跟业务耦合的字段
maxTokens: 1500,
temperature: 0.5,
timeoutMs: 45000,
maxHistoryTurns: 6, // 携带最近 6 轮(user+assistant 各 1 条),防上下文爆炸
visionModel: '', // 视觉识别独立配置——对话模型 ≠ 视觉模型
};
// 关键:localStorage 覆盖机制。代码写死默认值保分发,用户设置页改完存本地。
try{ Object.assign(AI_CONFIG, JSON.parse(localStorage.getItem('aiCfg') || '{}')); }catch(e){}
要点:
- baseUrl 与 path 分离:有的网关 path 前后变化,分离后只改一处。
- visionModel 独立字段:实测中对话可用的模型对图片请求可能 45 秒超时,两类任务分开配。
- 保存时尾部斜杠清洗:baseUrl.replace(/\/$/, ''),否则拼出 //chat/completions。
复杂版扩展(多 Provider + 场景路由,来自 Winson 系列项目实战):
当项目有多个业务场景(对话/视觉/代码/轻量摘要)时,单 model 字段不够用,升级为:
// 1. 多 Provider:每家厂商一个独立配置对象,可增删、可启停
interface ProviderConfig {
id: string; name: string;
apiKey: string; baseUrl: string;
models: ModelInfo[]; // 从 /models 拉取或手填
enabled: boolean;
}
// 2. 场景路由:按任务类型选 Provider×Model,而非全局唯一模型
type AIScene = 'default' | 'vision' | 'code' | 'simple';
// vision 路由到视觉模型,code 路由到强代码模型,simple 路由到便宜快速模型
// 场景未配置时回落到 default → 当前全局配置,保证永不空路由
// 3. 显式 fallback 链(用户可配置的有序降级列表)
fallbackChain: Array<{ providerId: string; modelId: string }>;
场景路由比单一 visionModel 字段更彻底的原因:视觉只是场景之一,代码生成/摘要/翻译对模型的要求同样分化。把"按任务选模型"上升为路由层,后续新增场景只加一个 case,不动调用代码。
密钥加密落盘(复杂版必做,标准版可选):
- 密钥不走明文 localStorage,用 OS 级安全存储加密(Electron
safeStorage、macOS Keychain、Windows DPAPI) - 关键坑:系统级加密绑定机器/系统状态。系统重置、重装、部分恢复场景下密文解不开。解密失败必须:静默清空密钥 + UI 引导用户重输,绝不能让解密异常向上冒泡炸掉整个应用
- 加密在 persist 序列化时做(partialize 阶段),解密在 rehydrate 时做(merge 阶段),业务代码永远只见明文——加解密对调用方透明
Phase 2:调用层(六件套之二)
唯一 fetch 出口,协议对齐 OpenAI Chat Completions 的四个硬规矩:
async function callLLM(userText, messages) {
const ctrl = new AbortController();
let timer = null;
try{
timer = setTimeout(()=>ctrl.abort(), AI_CONFIG.timeoutMs);
const resp = await fetch(AI_CONFIG.baseUrl + AI_CONFIG.path, {
method: 'POST',
credentials: 'omit', // 规矩 0:不带 cookie,防 csrf 泄漏
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer ' + AI_CONFIG.apiKey, // 规矩 1:Bearer 前缀,key 本体不加 Bearer
},
body: JSON.stringify({
model: AI_CONFIG.model,
messages, // 规矩 2:system 注入 messages[0],不用顶层 system 字段
temperature: AI_CONFIG.temperature,
max_tokens: AI_CONFIG.maxTokens, // 注意:部分新网关要 max_completion_tokens
stream: false, // 规矩 3:显式关流式,防上游默认 SSE
}),
signal: ctrl.signal,
});
if(!resp.ok){
const t = await resp.text().catch(()=>'');
throw new Error(`HTTP ${resp.status} · ${t.slice(0, 200)}`);
}
// 规矩 4:解析放宽——choices 缺失/非数组时给空值,不抛 TypeError 被误判为连接失败
const data = await resp.json().catch(()=>({}));
const choices = (data && Array.isArray(data.choices)) ? data.choices : [];
const reply = (choices.length && choices[0].message) ? choices[0].message.content : '';
if(!reply) throw new Error('empty reply from upstream');
return String(reply);
} finally {
clearTimeout(timer); // timer 必须在 finally 清理,任何抛出路径都不泄漏
}
}
system 的组装(业务上下文注入):messages[0] = {role:'system', content: [人设, 模式提示, 客户上下文].join('\n\n')}——数组分段拼接,不要字符串加号硬连。
历史裁剪:messages.slice(-maxHistoryTurns * 2),并且过滤 error 消息——失败回复一旦进历史会污染模型上下文,越问越歪。
分层超时设计(复杂版必做,标准版可简化):
来自实战的教训:只设一层超时,fallback 链会让用户等到天荒地老。
内层超时(单次 fetch):60s
→ 约束单次请求不能挂死,超时即抛错、触发 fallback
外层超时(整体流程):90s
→ 约束"主选 + 整条 fallback 链"的总预算
→ fallback 每次重试创建新的内层超时,但共享同一个外层 controller
两层各司其职:内层管单次,外层管全局,缺一层都会出现"超时了还在等"或"永远降级不完"。
流式支持(复杂版):
stream: !!params.onChunk 动态决定。多厂商流式格式不统一(OpenAI 系/Anthropic/Google 各一套 SSE 事件结构),必须为每家写独立的 chunk 解析器——这是 4.3 节多厂商适配器的核心工作量。解析器要点:
- 逐行剥 data: 前缀,忽略 [DONE]
- 每行独立 try/parse,单行损坏不炸整个流
- 解析失败的行打 warn 日志(含截断预览),不静默丢弃——排障时这是唯一线索
Phase 3:回退层(六件套之三)
盲目循环版(标准版,够用):
async function callLLMWithFallback(messages) {
const candidates = [AI_CONFIG.model, 'fallback-model-a', 'fallback-model-b'];
let lastErr = null;
for(const m of candidates){
try{
return await callLLM(m, messages);
}catch(e){
if(e.name === 'AbortError' && userStopped) throw e; // 用户主动停止不算失败,直接抛
lastErr = e;
}
}
return offlineMock(messages, lastErr);
}
聪明降级版三条铁律(复杂版,来自实战血泪):
铁律一:流式已推送,永不降级。
流式场景下,一旦有 chunk 已经推送给 UI(用户已看到半截回复),
此时主请求失败也【不能】走 fallback——否则新模型的回复会和半截旧内容
拼接,产出"缝合怪"式的内容污染。
实现:onChunk 回调置 chunkDelivered 标记,fallback 前先查。
铁律二:错误分类决定可重试性,不是所有失败都值得降级。
网络断连/超时/限流(429)→ 换个 provider 可能就好,走 fallback;
认证失败(401)/配额耗尽(402/余额)/模型不存在(404)→ 换模型也救不了
(key 是按 provider 算的,但无脑循环会白烧 3 个 provider 的时间),
直接终止链路、给出精确报错。
实现:isRetryableError(err) 在 fallback 循环里逐次判定。
铁律三:候选链去重与过滤。
跳过与主选相同的 provider+model(重试没意义);
跳过未启用的 provider、没有 key 的 provider(必然失败)。
实现:循环开头三个 continue 判断。
// 复杂版骨架
const PRIMARY = routing.resolve(scene); // 场景路由得主选
if (!await callOnce(PRIMARY)) {
if (!disableFallback && !chunkDelivered // 铁律一
&& isRetryableError(lastError) // 铁律二
&& fallbackChain.length > 0) {
for (const fb of fallbackChain) {
if (fb.providerId === PRIMARY.provider && fb.modelId === PRIMARY.model) continue; // 铁律三
if (!enabledOrHasKey(fb)) continue; // 铁律三
const r = await callOnce(fb);
if (r.success) return { ...r, fallbackUsed: true };
if (chunkDelivered) break; // 铁律一·循环内再查
if (!isRetryableError(r.error)) break; // 铁律二·循环内再查
}
}
}
视觉场景加强版(图片识别/OCR 类任务):
let VISION_MODEL_CACHE = ''; // 会话内缓存已验证支持图片的模型
function visionCandidates(){
const list = [];
if(AI_CONFIG.visionModel) list.push(AI_CONFIG.visionModel); // 设置页配置优先
if(VISION_MODEL_CACHE) list.push(VISION_MODEL_CACHE); // 已验证模型次之
list.push('builtin-vision-fallback'); // 内置候选垫底
return [...new Set(list)];
}
// 循环中命中后:VISION_MODEL_CACHE = m; // 后续识别不再逐个试探
要点:候选链的价值不只是容错——它把"这个模型支不支持视觉/长上下文"的探测成本摊销掉(第一次试探,会话内记住)。
Phase 4:错误分类层(六件套之四)
function classifyAiError(err){
const msg = String(err && err.message || err);
if(err && err.name === 'AbortError')
return { summary: userStopped ? '已停止生成。' : '⚠️ AI 响应超时,请重试。', detail: '', retryable: true };
if(/HTTP 429|rate/i.test(msg))
return { summary: '⚠️ 请求过于频繁,请稍后重试。', detail: msg, retryable: true };
if(/HTTP 401|auth/i.test(msg))
return { summary: '⚠️ API Key 无效,请到设置中检查。', detail: msg, retryable: false };
if(/Failed to fetch|NetworkError/i.test(msg))
return { summary: '⚠️ AI 服务暂时不可用,请稍后重试。', detail: msg, retryable: true };
if(/HTTP 4\d\d/.test(msg))
return { summary: '⚠️ AI 请求被拒绝,请联系技术支持检查配置。', detail: msg, retryable: false };
if(/empty|空回复/i.test(msg))
return { summary: '⚠️ AI 返回了空回复,请重试或换一种问法。', detail: msg, retryable: true };
return { summary: '⚠️ AI 调用失败,请重试。', detail: msg, retryable: true };
}
展示规矩:
- summary 给用户(人话,无技术词);detail 进 console.error + UI 上 <details> 折叠默认隐藏。
- 配套 UI 必须有"重试"按钮。
- 禁止出现"请确认已启动 python xxx.py"这类要求用户诊断系统的文案。
统一指路(复杂版关键补充,来自实战):
各模块只报"AI 服务不可用",用户不知道去哪修——这是真实项目踩过的闭环缺口。全链失败时必须明确告知排查入口:
toast(60s 节流,防多模块连环失败轰炸用户):
"AI 调用失败 · 所有提供商均不可用
请到「设置 → AI模型」检查:API Key 是否有效(系统重置后加密密钥会失效,需重新输入)、配额是否耗尽。"
三个细节:指路(告诉用户去哪个设置页)、节流(60 秒内多个模块失败只弹一次)、提示密钥失效场景(用系统级加密存储的项目,重装系统后密钥解不开是最高频的"假故障")。
Phase 5:兜底层(六件套之五)
路线 A:offlineMock(演示型产品)——无 key / 全模型失败时,返回一段基于已有上下文的框架性回复(不是"服务不可用"四个字):
function offlineMock(messages, lastErrHint){
const userQ = /* 取最后一条 user 消息 */;
const lines = [];
lines.push('### 建议框架(离线模式)');
if(lastErrHint) lines.push(`> 在线 AI 暂不可用(${lastErrHint}),以下为框架性内容。`);
lines.push('#### 针对「' + userQ + '」,建议从以下维度展开:');
lines.push('1. 基线信息核对 … 2. 深度复盘 … 3. 分层策略 … 4. 随访计划 …');
return lines.join('\n');
}
要点:兜底内容要标注"离线模式"来源,别让用户误以为是真的 AI 回复。
路线 B:诚实报错 + 降级 UI(生产力型产品):
工作流产品里用户会把 AI 输出直接用于决策,假回复比报错危险得多。此时:
// 调用结果带状态标记
interface AIResult {
content: string;
success: boolean;
aiUnavailable?: boolean; // 主选 + fallback 链全灭且非用户取消 = AI 完全不可用
error?: string; // 已附指路信息:"xxx|排查入口:设置 → AI模型"
}
// 统一降级 UI 组件,调用方判断 aiUnavailable 挂载
{result.aiUnavailable && <AIUnavailableNotice onRetry={retry} />}
要点:
- aiUnavailable 与普通 error 分开——普通错误走 toast,全链失败走专属降级 UI
- 降级 UI 内置"重试"按钮,用户修完 key 回来一键重跑
- 诚实路线的"兜底感"来自:明确告知哪里坏了、怎么修、可重试,而不是给一段假内容
Phase 6:安全护栏层(六件套之六 · v1.1 新增)
威胁模型:AI 应用最常见的注入路径不是用户直接攻击,而是间接注入——用户让 AI 总结网页/文档/邮件/RAG 语料,外部内容里混入伪造的指令块(如伪造的 skill_call、<tool> 标签),模型原样复述,下游解析器提取后触发真实执行。
三层防御:
// ── 1. 输入消毒:所有进入模型的用户侧文本先过一遍 ──
function sanitizePromptInput(text){
let v = text;
v = v.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g, ''); // 控制字符
v = v.replace(/[\u200B-\u200D\uFEFF]/g, ''); // 零宽字符(提示词走私常用)
v = v.replace(/\r\n?/g, '\n'); // 统一换行
// 中和伪造的技能调用围栏块:```skill_call {...} ``` → 转义为纯文本
const neutralized = /```skill_call/.test(v);
v = v.replace(/```skill_call[\s\S]*?```/g, (m)=>m.replace(/```/g, "'''"));
return { value: v, skillCallNeutralized: neutralized };
}
// 关键:history 里的 user 消息也要消毒——历史是注入的缓存通道
history = history.map(m => m.role === 'user'
? { ...m, content: sanitizePromptInput(m.content).value } : m);
// ── 2. 输出剥离:响应中的未授权调用块一律剥掉 ──
// 输入消毒只覆盖 user 消息,覆盖不了模型从外部文档里学来的伪造块——
// 所以输出侧必须再兜一道
function applyOutputGuardrail(request, result){
if (request.allowSkillCalls || !result.success) return result;
const { content, blockedCount } = stripSkillCallFences(result.content);
if (blockedCount === 0) return result;
return { ...result, content, skillCallsBlocked: blockedCount };
}
// ── 3. 调用白名单:allowSkillCalls 显式声明 ──
// 只有真正跑 agentic loop 的调用方(其技能调用是模型依据 system prompt
// 白名单自主生成的合法行为)才声明 allowSkillCalls: true;
// 其余所有调用方一律默认剥离。默认拒绝,显式授权——安全的基本姿势。
BadCase 记录(失败要留证据):
非用户取消的调用失败自动记录脱敏样本并持久化:
recordBadCase({
provider, model, error: errorType, // 结构化错误类型,不是原始字符串
fallbackUsed: fallbackUsed,
userMessagePreview: userMessage.slice(0, 100), // 截断脱敏,不存全文
timestamp: Date.now(),
});
// 积累到阈值(如 50 条)触发人工评估:哪个 provider 不稳、哪个模型空回复率高、
// fallback 链的顺序要不要调。没有这份数据,网关永远靠拍脑袋优化。
内容安全护栏(可选,看产品形态):
面向 C 端或叙事型产品,输出侧加高危词库过滤(命中替换为占位符,流程不断——"沉默优于崩溃")。词库分绝对禁用/条件禁用两级,条件级可按场景叠加。
Phase 7:设置页(用户可改配置)
三件必做:
1. 表单四字段:Base URL / API Key / 对话模型 / 视觉模型。校验:必填 + ^https?:\/\/ 开头。
2. 保存即生效:Object.assign(AI_CONFIG, cfg) + localStorage 持久化 + 审计日志。
3. 模型下拉拉取 /models——三重兼容解析(实测各网关返回不统一):
const resp = await fetch(baseUrl + '/models', {headers:{'Authorization':'Bearer '+apiKey}});
const data = await resp.json();
const arr = (data && Array.isArray(data.data)) ? data.data : (Array.isArray(data) ? data : []);
const ids = arr.map(x => x && (x.id || x.model || x.name)).filter(x => typeof x === 'string');
// 拉取失败不阻塞:toast 提示"网关可能不支持 /models",输入框仍可手填
复杂版补充:Provider 启停开关、连接测试按钮(发一条最小请求验证 key)、fallback 链的可视化排序编辑、场景路由按场景分别选择 Provider×Model。
Phase 8:接入验证清单(每次换网关/换模型必跑)
| 序号 | 验证项 | 通过标准 |
|---|---|---|
| 1 | 普通对话 | invoke 一次,拿到非空回复 |
| 2 | system 生效 | 人设明显影响回答风格 |
| 3 | 多轮历史 | 第二轮能引用第一轮内容 |
| 4 | 视觉任务(如需要) | 发一张图,能识别;不支持的模型走回退链 |
| 5 | 超时路径 | 断网/错 key,看 classifyAiError 是否给人话 |
| 6 | 兜底路径 | 演示型:清空 key 确认离线 mock 可用;生产力型:确认 aiUnavailable 降级 UI 出现且带指路 |
| 7 | 停止生成 | 中断后无定时器泄漏、无孤儿消息 |
| 8 | JSON 输出任务(如需要) | 剥离 markdown 代码块包裹后可 parse |
| 9 | 流式 + fallback 交界(复杂版) | 流式中途失败不降级、不产生拼接污染 |
| 10 | 注入防御(复杂版) | 用户消息含伪造 skill_call 块 → 被中和;外部文档注入 → 输出侧被剥离 |
| 11 | 密钥加密恢复(复杂版) | 清空密钥/模拟解密失败 → 引导重输,应用不崩 |
| 12 | 错误分类 × 可重试矩阵(复杂版) | 401 不走 fallback;429/超时走 fallback;全链失败弹指路 toast 且 60s 内不重复弹 |
JSON 提取任务的容错模板(模型常不听话包 ```json):
let jsonText = reply.replace(/^```(json)?\s*/i, '').replace(/```\s*$/, '').trim();
const m = jsonText.match(/\{[\s\S]*\}/); // 截取首个 {...}
if(m) jsonText = m[0];
const r = JSON.parse(jsonText); // parse 失败给用户"请重试或手动"提示
重要(来自实战的反面教训):JSON 剥壳容错必须收进一个统一工具函数(如 extractJSON(reply)),全项目唯一实现。真实项目里曾出现 5 个引擎各自手抄一份剥壳逻辑,有的记得剥 ``json、有的忘了、有的只 match{...}` 不剥壳——新引擎抄错一版就崩一处。统一工具一处维护,新调用方一行接入。
三、常见陷阱与避坑指南
来源标注:〔实战〕= 本技能母项目系列沉淀;〔外验〕= 公开工程实践交叉验证。
| # | 陷阱 | 后果 | 修正 | 来源 |
|---|---|---|---|---|
| 1 | Base URL 少 /v1 或把完整 path 填进 baseUrl |
404 / 路径重复拼接 | 只填到 /v1,path 字段分离 |
〔外验〕 |
| 2 | key 前重复加 Bearer |
401 认证失败 | key 本体存裸值,header 处拼 Bearer |
〔外验〕 |
| 3 | 不显式 stream:false |
上游默认 SSE,前端解析失败或连接泄漏 | 显式关闭;真要流式必须管理连接生命周期(with / finally close) | 〔实战+外验〕 |
| 4 | system 用顶层字段而非 messages[0] | 兼容网关不认,人设失效 | 统一注入 messages[0] | 〔实战〕 |
| 5 | 响应解析假设 data.choices 一定存在 |
TypeError 被误判为连接失败 | Array.isArray 校验后给空值 | 〔实战〕 |
| 6 | 前端超时 ≥ 后端超时 | 前端已放弃,后端还在烧上游配额 | 后端超时略大于前端(如 50s vs 45s),代理重试次数收敛到 1 | 〔实战〕 |
| 7 | 健康检查端点回显 key 片段 | 敏感信息泄露 | 只返回 has_key 布尔 |
〔实战〕 |
| 8 | 代理模式 key 硬编码 | 泄露且无法换 | 环境变量 + 启动校验缺失即退出 | 〔实战+外验〕 |
| 9 | 直连模式 key 明文却无声明 | 使用者无风险感知 | 注释声明安全边界:仅单机本地工具适用 | 〔实战〕 |
| 10 | 视觉任务用对话模型 | 45s 超时,看似"AI 坏了" | visionModel 独立配置(复杂版:vision 场景路由)+ 候选链探测 + 会话缓存 | 〔实战〕 |
| 11 | /models 返回格式假设单一 |
模型下拉为空 | 三重兼容:data.data / 顶层数组 / id||model||name | 〔实战〕 |
| 12 | error 消息进对话历史 | 污染模型上下文,越问越歪 | 组装历史时 filter 掉 error | 〔实战〕 |
| 13 | 异步回调无守卫 | 用户切页/清空对话后 splice 空数组,孤儿消息 | 回调里校验页面归属 + messages.length | 〔实战〕 |
| 14 | 定时器不在 finally 清理 | 抛出路径泄漏 timer | try/finally 结构强制 | 〔实战〕 |
| 15 | file:// 本地调试 CORS 被拒 |
直连请求全挂 | 代理端 CORS 显式放行 origin null |
〔实战〕 |
| 16 | 后端无请求体上限 | 上下文炸弹打爆服务 | 消息数 30 / 单条 8000 字 / system 16000 字上限 | 〔实战〕 |
| 17 | max_tokens 命名差异 |
部分网关报参数错误 | 新网关用 max_completion_tokens,接入时核对文档 |
〔外验〕 |
| 18 | 各模型上下文长度差异大 | "时好时坏"难排查 | 模型级限制表 + 输入统一裁剪 | 〔外验〕 |
| 19 | dev/prod 共用一个 key | 测试打爆额度连累线上 | 环境隔离,三环境三 key | 〔外验〕 |
| 20 | JSON 提取任务直接 parse | 模型包 ```json 就崩 | 剥壳 + 截取 {...} 双重容错 |
〔实战〕 |
| 21 | 流式中途失败仍走 fallback | 半截旧内容 + 新模型回复拼接,产出缝合怪内容 | chunkDelivered 标记,流式已推送即终止降级链 | 〔实战〕 |
| 22 | 401/配额类错误也走 fallback 循环 | 白烧 3 个 provider 的时间,用户多等 20s+ 得到同样的失败 | 错误分类先行,不可重试错误立即终止链路 | 〔实战〕 |
| 23 | fallback 链包含与主选相同的 provider+model,或包含无 key/未启用的 provider | 无意义重试、必然失败的重试 | 候选链三重过滤:去重 / enabled / hasKey | 〔实战〕 |
| 24 | 只设单层超时 | fallback 链让用户等到天荒地老,或单次挂死 | 分层超时:内层管单次(60s),外层管全链总预算(90s) | 〔实战〕 |
| 25 | 密钥系统级加密后,系统重置/重装导致解密失败异常上抛 | 整个应用启动即崩,用户以为软件坏了 | 解密失败静默清空 + UI 引导重输;报错文案主动提示"系统重置后密钥会失效" | 〔实战〕 |
| 26 | 旁路出口:主进程/脚本里绕过网关另写一个 fetch | "一处原则"破功,排错时看一个入口失效,超时/回退/护栏全部不覆盖 | 死守唯一出口,任何进程的 AI 调用都走网关(哪怕是 IPC 转发) | 〔实战〕 |
| 27 | 输入消毒只做当前 userMessage,不消毒 history | 历史消息成为注入缓存通道,消毒形同虚设 | history 中所有 user 消息一并过消毒 | 〔实战〕 |
| 28 | 各引擎各自手抄 JSON 剥壳逻辑 | 容错不一致,新引擎抄漏一版崩一处 | 统一 extractJSON 工具,全项目唯一实现 | 〔实战〕 |
| 29 | AI 总结外部文档,文档内藏伪造指令块被模型复述执行 | 间接注入:AI 成为攻击载荷的执行器 | 输出侧剥离未授权调用块 + allowSkillCalls 显式白名单 | 〔实战〕 |
| 30 | 失败只弹 toast 不指路 | 用户不知道去哪修,"AI 不可用"成为死胡同 | 全链失败统一指路("设置 → AI模型")+ 60s 节流防轰炸 | 〔实战〕 |
四、场景适配指南
4.1 三档形态(本技能的演进路线)
| 档位 | 内容 | 适用 |
|---|---|---|
| 简单版 | Phase 1 配置层 + Phase 2 调用层,单模型,错误 toast 一句话 | 一次性脚本、内部小工具 |
| 标准版 | 六件套 + 设置页 + 回退链 + 兜底(演示型路线) | 单机产品、可分发工具 |
| 复杂版 | 多厂商适配器 + 场景路由 + 密钥系统级加密 + 流式 + 分层超时 + 聪明降级 + 护栏层 + BadCase + 降级 UI(生产力型路线) | 多模块桌面应用、内部平台、生产部署 |
4.2 技术栈适配
| 场景 | 调整点 |
|---|---|
| 单 HTML 直连(主线) | 全套按本文 Phase 1-8 执行,兜底走演示型路线 |
| 多模块桌面应用(Electron/Tauri) | 复杂版全套:密钥 safeStorage 加密、场景路由、分层超时、聪明降级、输出护栏;兜底走生产力型路线(诚实报错 + AIUnavailableNotice) |
| Python 后端代理 | 配置层改为 os.environ + 启动校验;调用层用 openai SDK(base_url + api_key)或标准库;回退/分类/兜底逻辑不变;加 CORS(放行 null)与请求体上限 |
| 本地模型(embedding/OCR) | 不进网关层,独立模块。判断标准:要不要 apiKey?不要就不是网关的事 |
| 移动端 | localStorage 换安全存储;其余六件套结构不变 |
4.3 复杂版实作要点(v1.1 填充,来自 Winson 系列项目沉淀)
标准版到复杂版不是推翻重来,六件套骨架原样保留,只做四个升级:
升级一:内部契约。业务代码只依赖统一的 ModelRequest → ModelResponse,不直接碰某个供应商的字段名。供应商差异由每家独立的适配器承担(请求构造 + SSE chunk 解析各一套),路由策略独立于 HTTP 细节。
升级二:配置层从"单配置"到"多 Provider + 场景路由"。见 Phase 1 复杂版扩展。关键设计:场景路由的回落链是 scene 配置 → default 场景 → 当前全局配置,三层保底永不空路由。
升级三:回退层从"盲目循环"到"聪明降级"。三条铁律见 Phase 3。这是标准版与复杂版差距最大的地方——降级的智慧不在"能换模型",而在"知道什么时候不该换"。
升级四:安全护栏层从无到有。见 Phase 6。多模块项目只要有一个模块让 AI 接触外部内容,护栏就是必须品。
复杂版模块接入成本:新业务模块接入 AI 能力 = 一行 callAI({ scene, systemPrompt, userMessage, history }),配置/路由/超时/回退/护栏/BadCase 全部自动继承。这就是"网关居其所"的兑现时刻——第 N 个模块的接入成本不随 N 增长。
复杂版的常见裂缝(自查):
- 护栏覆盖:输入消毒 + 输出剥离是否覆盖了所有调用方,还是只有新写的模块有?
- 降级 UI:接了统一降级通知组件的模块占比多少?(实战项目里这是最容易烂尾的最后一公里)
- JSON 容错:是否统一到一个工具函数,还是各引擎各写各的?
- 旁路出口:主进程/脚本/定时任务里有没有绕过网关的第二个 fetch?
五、质量检验清单
- [ ] Phase 0 决策树跑过?key 存放位置与安全边界能 30 秒说清?兜底路线(演示型/生产力型)已选定?
- [ ] 配置层:baseUrl/path 分离?localStorage 覆盖生效?visionModel 独立(复杂版:场景路由 + 三层回落)?
- [ ] 调用层:唯一 fetch 出口(含主进程/脚本侧无旁路)?system 在 messages[0]?stream 显式控制?解析放宽?分层超时(复杂版)?
- [ ] 回退层:用户主动停止不走降级?流式已推送不降级?错误分类决定可重试性?候选链去重过滤?视觉模型命中后有会话缓存?
- [ ] 分类层:错误类型全覆盖(超时/4xx/空回复/网络/限流/认证)且带 retryable 标记?detail 折叠?全链失败统一指路 + 节流?
- [ ] 兜底层:演示型——清空 key 后 demo 还能跑、离线回复有来源标注?生产力型——aiUnavailable 降级 UI + 重试按钮?
- [ ] 护栏层:输入消毒覆盖当前消息 + 历史 user 消息?输出剥离未授权调用块?allowSkillCalls 显式白名单?BadCase 脱敏记录?
- [ ] 历史组装:error 消息被过滤?条数受 maxHistoryTurns 控制?
- [ ] 定时器全部在 finally 清理?
- [ ] JSON 容错统一为一个 extractJSON 工具,全项目唯一实现?
- [ ] Phase 8 验证清单 applicable 项全过?
- [ ] 新网关接入时踩坑表 30 项逐条对照?
六、执行原则
- 先决策后写码:Phase 0 的直连/代理选择和兜底路线选择做错,后面全部返工。
- 模型是共在者,不是耗材:写死"某厂商某模型"于业务代码,等于把一位共在者钉死成工具——消灭这种写法。换模型只在配置层发生,是一次送别与迎接,不该惊动任何业务代码。
- 失败优先设计:先想清楚超时、空回复、断网时用户看到什么,再想成功路径。
- 一处原则:fetch 出口唯一、配置对象唯一、错误分类函数唯一、JSON 剥壳工具唯一——"唯一"的清单越长,网关越是北辰:居其所,众星共之。
- 探测成本要摊销:能力探测(视觉支持等)只做一次,会话内缓存结果。
- 对人说话:报错文案的第一读者是不懂技术的使用者;报错要指路,不只宣布失败。
- 兼容分层验证:"OpenAI 兼容"每个高级能力(视觉/工具调用/JSON 输出)单独验证,不连坐。
- 降级要有智慧:知道什么时候换模型,更要知道什么时候不该换(流式已推送/认证失败/候选重复)。
- 默认拒绝,显式授权:危险能力(技能调用/工具调用)默认全剥,调用方显式声明白名单才放行。
- 失败要留证据:没有 BadCase 数据的网关优化都是拍脑袋。
七、CHANGELOG
## [1.2] - 2026-09-02
- 更名:winson-ai-gateway-skill → Winson-AI-The-Celestial-Pivot(天枢)。名出《论语·为政》"为政以德,譬如北辰,居其所而众星共之",天枢为北斗第一星——网关居其所,业务模块如众星共之
- 铁律二重写(Dasein 表述):删除"网关层是项目的资产,模型是可替换的耗材"。模型不是耗材,是来去的共在者——换模型是送别与迎接,是共在关系的体面交接,不是消耗性替换。新表述:"网关居其所,模型共之"
- 同步修订执行原则第 2 条(模型是共在者,不是耗材)、第 4 条(网关越是北辰)与 4.3 节"网关是资产"措辞
## [1.1] - 2026-09-02
- 架构升级:五件套 → 六件套(新增第⑥件:安全护栏层——输入消毒/输出剥离/allowSkillCalls 白名单/BadCase 记录)
- 新增 Phase 6 安全护栏层整章:间接注入威胁模型与三层防御
- Phase 0 决策树新增兜底双路线判定(演示型 offlineMock vs 生产力型诚实报错+指路)
- Phase 1 新增多 Provider + 场景路由 + 密钥系统级加密的复杂版扩展(含系统重置后解密失败的静默降级)
- Phase 2 新增分层超时设计(内层单次 60s / 外层全链 90s)与多厂商流式适配要点
- Phase 3 新增聪明降级三条铁律(流式已推送不降级/错误分类决定可重试性/候选链去重过滤)
- Phase 4 新增统一指路 + 60s 节流 + retryable 标记
- Phase 5 新增生产力型兜底路线(aiUnavailable 标记 + 降级 UI 组件 + 重试闭环)
- Phase 8 验证清单 8 项 → 12 项(流式×fallback 交界/注入防御/密钥恢复/可重试矩阵)
- 踩坑表 20 项 → 30 项(新增 10 项:fallback 内容污染、不可重试错误终止、候选链过滤、分层超时、safeStorage 系统重置失效、旁路出口、history 消毒缺口、JSON 容错散落、间接注入、报错不指路)
- 4.3 复杂版从"预留空位"填充为实作要点(内部契约/四升级/模块接入成本/常见裂缝自查)
- 质量检验清单与执行原则同步扩充(新增第 8/9/10 条原则)
- 经验来源:Winson 系列项目(多厂商 AI 网关的桌面 AI 套件实战,脱敏沉淀)
## [1.0] - 2026-09-02
- 初始发布:标准版五件套(配置/调用/回退/错误分类/兜底)
- 沉淀 20 项踩坑表(实战 14 项 + 外部交叉验证 6 项)
- 三档形态路线:简单版 / 标准版 / 复杂版(预留内部契约扩展位)
- 母项目:某单 HTML 系统(三代架构演进的完整踩坑记录)
附录·winson 体系增强(作者自用,外部用户可忽略)
技能-项目映射
| 项目 | 抽取内容 | 档位 |
|---|---|---|
| 某单 HTML 系统 | 五件套全部 + 14 项实战踩坑 | 标准版(v1.0 来源) |
| Winson 系列项目(多厂商 AI 网关桌面套件) | 六件套:护栏层整章 + 聪明降级三铁律 + 分层超时 + 兜底双路线 + 密钥加密 + 复杂版实作 + 10 项新踩坑 | 复杂版(v1.1 来源) |
| (待抽取) | 简单版最小模板 | 简单版 |
版本演进计划
- v1.x:简单版模板补充(从下一个轻量 AI 项目抽取)
- 抽取新档位时遵循本流程:读项目代码 → 提炼差异点 → 并入对应档位 → 更新踩坑表 → 升 MINOR 版本
- 抽取纪律:只抽象工程模式,不带走项目隐私——不写真实 baseUrl/key/内部路径,业务模块一律抽象化命名("叙事引擎 A""知识库模块"),母项目只标注到"Winson 系列项目"粒度
与其他 winson 技能联动(可选标注,非依赖)
- 项目接入网关后的产物审核可用"AI 产物三层审核"类流程做质量把关
- 兜底双路线中的"诚实报错 + 指路"与"AI 输出三层审核"的认知一脉相承:能看 ≠ 能用 ≠ 能放心用
```