如何在 Electron 中接入 OpenAI 兼容的大模型 API:Snaptium 的主进程代理实践

现在很多 AI 应用都支持 OpenAI、硅基流动、OpenRouter、Groq 以及其他兼容 OpenAI API 协议的模型服务。

对于普通 Web 应用,调用大模型 API 并不复杂:

ts 复制代码
await fetch(`${baseUrl}/chat/completions`, {
  method: 'POST',
  headers: {
    Authorization: `Bearer ${apiKey}`,
    'Content-Type': 'application/json',
  },
  body: JSON.stringify({
    model,
    messages,
  }),
});

但在 Electron 桌面应用中,事情会稍微复杂一些。

如果直接在 Vue、React 等渲染进程中请求模型接口,会遇到几个问题:

  • API Key 和请求逻辑散落在界面代码中;
  • 不同功能可能重复实现模型调用;
  • 跨域和网络权限更难统一处理;
  • 渲染进程能够直接访问过多底层能力;
  • 后续接入多个模型服务商时,维护成本越来越高。

在开发开源 AI 知识管理工具 Snaptium 时,我最终采用了下面这套调用链:

text 复制代码
Vue Renderer
    ↓
Renderer AI Service
    ↓
Preload Bridge
    ↓
Electron IPC
    ↓
Main Process AI Service
    ↓
OpenAI Compatible API

本文将结合 Snaptium 的实际实现,介绍如何在 Electron 中设计一套相对清晰的 OpenAI 兼容大模型接入架构。


一、为什么不在渲染进程中直接请求模型

Electron 应用通常包含三个主要部分:

text 复制代码
Renderer Process
Preload
Main Process

其中,Renderer Process 负责页面和交互,角色类似普通浏览器页面。

Main Process 负责:

  • 文件系统访问;
  • 系统窗口管理;
  • 本地数据库;
  • 网络请求;
  • 系统托盘;
  • 应用更新;
  • 其他 Node.js 能力。

如果直接在 Renderer 中调用模型 API:

ts 复制代码
const response = await fetch('https://api.example.com/v1/chat/completions', {
  headers: {
    Authorization: `Bearer ${apiKey}`,
  },
});

虽然实现简单,但会让业务页面同时承担:

  • 模型配置读取;
  • API 地址拼接;
  • 请求头构建;
  • 超时控制;
  • 错误解析;
  • 响应格式兼容。

当 AI 写作、知识问答、自动续写等功能逐渐增加后,很容易出现多套重复逻辑。

因此,Snaptium 将实际的模型请求集中放在 Electron Main Process 中。

渲染进程只负责表达一个业务意图:

text 复制代码
请根据这些消息生成回答

至于使用哪个服务商、哪个 API Key、哪个模型以及请求哪个地址,都由主进程统一处理。


二、定义统一的 AI 服务来源

为了支持多个兼容 OpenAI API 的服务商,Snaptium 将每个模型服务定义为一个 AI Source。

一个 AI Source 主要包含:

ts 复制代码
interface AiSource {
  id: string;
  name: string;
  baseUrl: string;
  apiKey: string;
  aiModel: string;
  capabilities: string[];
}

对应含义如下:

字段 作用
id 服务配置唯一标识
name 用户自定义的服务名称
baseUrl OpenAI 兼容接口地址
apiKey 用户自己的 API Key
aiModel 默认模型名称
capabilities 支持的能力类型

例如,用户可以创建这样一条配置:

json 复制代码
{
  "id": "siliconflow",
  "name": "硅基流动",
  "baseUrl": "https://api.siliconflow.cn/v1",
  "apiKey": "sk-xxxxxxxx",
  "aiModel": "Qwen/Qwen3-8B",
  "capabilities": ["chat", "embedding", "reranker"]
}

这里并没有把供应商写死在业务代码中。

只要某个平台兼容 OpenAI API,用户就可以填写:

text 复制代码
Base URL
API Key
Model

完成接入。

这种设计实际上就是桌面 AI 应用中常见的 BYOK:

Bring Your Own Key,用户使用自己的 API Key。


三、渲染进程只保留业务调用

Snaptium 在 Renderer 中建立了统一的 aiService

业务组件不会直接执行 fetch,而是调用:

ts 复制代码
const result = await aiService.generate({
  messages: [
    {
      role: 'user',
      content: '请总结当前笔记',
    },
  ],
});

内部再通过 Electron Bridge 请求主进程:

ts 复制代码
export const aiService = {
  async generate(request: AiGenerateRequest) {
    return await electronApi.aiChat.generate({
      messages: [...request.messages],
      systemPrompt: request.systemPrompt,
      promptPreset: request.promptPreset,
    });
  },
};

这样做有几个好处。

首先,Vue 组件不需要知道底层接口地址。

其次,页面不需要自己读取当前使用的 API Key 和模型。

最后,所有 AI 功能都可以复用统一入口。

渲染进程只负责:

text 复制代码
组织消息
展示加载状态
展示模型回答
处理用户交互

网络协议和服务商差异由主进程处理。


四、通过 Preload 暴露最小化接口

Snaptium 没有把整个 ipcRenderer 暴露给页面,而是在 Preload 中只开放固定能力:

ts 复制代码
const electronAPI = {
  aiChat: Object.freeze({
    generate: (payload: AiChatPayload) =>
      ipcRenderer.invoke('ai-chat:generate', payload),

    generateCompletion: (payload: AiChatPayload) =>
      ipcRenderer.invoke('ai-chat:generate-completion', payload),
  }),
};

Renderer 能调用的只有:

ts 复制代码
window.electronAPI.aiChat.generate(...)

而不能在页面中随意调用任意 IPC Channel。

推荐保持这样的调用关系:

text 复制代码
Renderer
只能调用预定义方法
    ↓
Preload
映射为固定 IPC Channel
    ↓
Main
注册对应 Handler

不要直接暴露:

ts 复制代码
contextBridge.exposeInMainWorld('electron', {
  ipcRenderer,
});

否则渲染进程可以向任意 Channel 发送任意数据,Electron 的边界会失去意义。


五、在 IPC 边界验证输入

Renderer 发来的数据不能直接信任。

即使这是桌面应用,渲染进程依然应该被视为不可信边界。因此 Snaptium 使用 Zod 验证 IPC 参数。

例如普通对话请求:

ts 复制代码
const AiChatGenerateSchema = z.object({
  messages: z.array(
    z.object({
      role: z.enum(['system', 'user', 'assistant']),
      content: z.string(),
    }),
  ),
  systemPrompt: z.string().optional(),
  promptPreset: z.string().optional(),
});

在 IPC Handler 中先解析:

ts 复制代码
const validatedPayload = AiChatGenerateSchema.parse(payload);

验证通过后,才会进入后续业务逻辑。

这一步可以阻止一些异常输入,例如:

json 复制代码
{
  "messages": "这不是数组"
}

或者:

json 复制代码
{
  "messages": [
    {
      "role": "unknown",
      "content": 123
    }
  ]
}

在 Electron 项目中,建议对以下 IPC 数据全部进行边界校验:

  • AI 消息;
  • 文件路径;
  • 用户配置;
  • 模型参数;
  • 导入导出参数;
  • 系统命令;
  • 外部 URL。

不要因为 IPC 来自自己的页面,就直接把它当成可信数据。


六、由主进程解析当前模型配置

Renderer 提交的请求中,不需要包含:

text 复制代码
API Key
Base URL
Model Provider

主进程通过 aiConfigService 读取应用设置,并解析出当前实际使用的配置。

大致流程如下:

text 复制代码
读取应用配置
    ↓
找到当前 AI Source
    ↓
检查是否支持 Chat
    ↓
确定当前模型
    ↓
拼接 Chat Endpoint

对于 OpenAI 兼容接口,聊天地址一般是:

text 复制代码
{baseUrl}/chat/completions

对应实现可以简化为:

ts 复制代码
function resolveChatEndpoint(baseUrl: string): string {
  const normalizedBaseUrl = baseUrl.trim().replace(/\/+$/, '');

  if (!normalizedBaseUrl) {
    throw new Error('Missing chat base URL');
  }

  return `${normalizedBaseUrl}/chat/completions`;
}

移除末尾多余的 / 很重要。

否则用户填写:

text 复制代码
https://api.example.com/v1/

程序再拼接:

text 复制代码
/chat/completions

可能得到:

text 复制代码
https://api.example.com/v1//chat/completions

虽然部分服务器能够兼容,但不应该依赖这种行为。


七、在主进程统一封装模型请求

Snaptium 使用 remoteAiService 集中处理 OpenAI 兼容请求。

核心调用结构如下:

ts 复制代码
async function chat(config: ChatConfig) {
  return await request(config.endpoint, config.apiKey, {
    model: config.model,
    messages: config.messages,
    max_tokens: config.max_tokens ?? 512,
    temperature: config.temperature ?? 0.7,
    stream: false,
  });
}

底层请求统一处理:

ts 复制代码
async function request(
  endpoint: string,
  apiKey: string,
  payload: Record<string, unknown>,
) {
  const controller = new AbortController();
  const timeoutId = setTimeout(() => controller.abort(), 30_000);

  try {
    const response = await fetch(endpoint, {
      method: 'POST',
      headers: {
        Authorization: `Bearer ${apiKey}`,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify(payload),
      signal: controller.signal,
    });

    if (!response.ok) {
      throw new Error(`HTTP ${response.status}: ${response.statusText}`);
    }

    return await response.json();
  } finally {
    clearTimeout(timeoutId);
  }
}

集中封装后,可以统一处理:

  • Authorization 请求头;
  • Content-Type;
  • 请求超时;
  • HTTP 状态码;
  • JSON 响应解析;
  • 服务商错误信息;
  • 日志记录;
  • 默认模型参数。

否则每增加一个 AI 功能,都要复制一遍请求代码。


八、不要只返回 HTTP 状态码

不同模型服务商返回错误的格式并不完全相同。

常见格式包括:

json 复制代码
{
  "error": {
    "message": "Invalid API key"
  }
}

也可能是:

json 复制代码
{
  "message": "Insufficient balance"
}

因此,错误处理不能只写:

ts 复制代码
throw new Error(response.statusText);

应该尝试提取服务商返回的具体信息:

ts 复制代码
function extractErrorMessage(
  body: unknown,
  fallback: string,
): string {
  if (typeof body !== 'object' || body === null) {
    return fallback;
  }

  const result = body as {
    error?: {
      message?: string;
    };
    message?: string;
  };

  if (result.error?.message) {
    return result.error.message;
  }

  if (result.message) {
    return result.message;
  }

  return fallback;
}

这样用户看到的会是:

text 复制代码
Invalid API key

或者:

text 复制代码
Insufficient balance

而不是没有实际意义的:

text 复制代码
Unauthorized
Bad Request
Internal Server Error

九、增加连接测试能力

让用户填写 Base URL、API Key 和模型名称后,最好提供"测试连接"按钮。

测试 Chat 模型时,可以发送一个极小请求:

json 复制代码
{
  "model": "Qwen/Qwen3-8B",
  "messages": [
    {
      "role": "user",
      "content": "hi"
    }
  ],
  "max_tokens": 5
}

这样既可以验证:

  • Base URL 是否正确;
  • API Key 是否有效;
  • 模型名称是否存在;
  • 当前账户是否有权限;
  • 网络是否能够访问服务商。

测试请求一定要限制输出长度,否则用户每次点击"测试连接",都可能产生不必要的模型费用。


十、一次完整请求是如何执行的

最终,一次 AI 写作请求会经过下面这些步骤。

1. Vue 组件发起业务请求

ts 复制代码
await aiService.generate({
  messages: [
    {
      role: 'user',
      content: selectedText,
    },
  ],
  promptPreset: 'rewrite',
});

2. Renderer Service 调用 Bridge

ts 复制代码
electronApi.aiChat.generate(payload);

3. Preload 转换为 IPC 请求

ts 复制代码
ipcRenderer.invoke('ai-chat:generate', payload);

4. Main Process 验证参数

ts 复制代码
const validatedPayload = AiChatGenerateSchema.parse(payload);

5. 主进程解析当前模型来源

ts 复制代码
const assistantConfig =
  await aiConfigService.resolveAssistantConfig();

6. 调用 OpenAI 兼容接口

ts 复制代码
const response = await remoteAiService.chat({
  endpoint: assistantConfig.endpoint,
  apiKey: assistantConfig.apiKey,
  model: assistantConfig.model,
  messages,
  max_tokens: 1000,
});

7. 将结果返回 Renderer

ts 复制代码
return {
  success: true,
  answer: response.choices?.[0]?.message?.content,
};

整个过程可以概括为:

text 复制代码
用户操作
  ↓
Vue 业务层
  ↓
Preload Bridge
  ↓
IPC 参数校验
  ↓
读取当前 AI Source
  ↓
主进程请求模型
  ↓
返回生成结果

十一、这种架构的优点

支持多个模型服务商

业务功能不关心用户使用的是:

  • OpenAI;
  • 硅基流动;
  • OpenRouter;
  • Groq;
  • 自建兼容服务;
  • 其他 OpenAI 兼容平台。

只需要切换 AI Source。

避免请求逻辑散落

所有远程模型调用统一由主进程服务管理。

后续修改请求头、超时时间和错误格式时,不需要修改多个页面。

保持 Electron 分层边界

Renderer 负责页面和业务编排,Main Process 负责底层能力。

方便后续扩展

在统一请求层之上,可以继续增加:

  • SSE 流式输出;
  • 请求取消;
  • 模型调用统计;
  • AI 网关;
  • 用户配额;
  • 服务商故障切换;
  • 重试和熔断。

这些能力都不需要让 Vue 页面理解不同供应商的底层细节。


十二、当前实现仍需要注意的问题

API Key 的本地存储

BYOK 模式下,API Key 最终需要保存在用户设备中。

密码输入框只能避免 Key 直接显示在页面上,并不等于已经安全加密。

更完善的做法是使用操作系统安全存储,例如:

  • Windows Credential Manager;
  • macOS Keychain;
  • Linux Secret Service;
  • Electron safeStorage

请求取消需要贯穿整个调用链

Renderer 中调用 AbortController.abort(),并不会自动取消已经通过 ipcRenderer.invoke 发到主进程的请求。

要实现真正取消,需要:

text 复制代码
Renderer 生成 requestId
    ↓
Main Process 保存 AbortController
    ↓
Renderer 发送 cancel IPC
    ↓
Main Process 调用 abort()

SSE 流式输出需要独立设计

普通的 ipcRenderer.invoke 更适合一次请求、一次返回。

如果需要逐字输出,通常要设计:

text 复制代码
开始请求 Channel
Chunk 事件 Channel
完成事件 Channel
错误事件 Channel
取消请求 Channel

流式输出涉及 SSE 解析、事件生命周期和请求取消,适合单独作为一个主题实现。


总结

在 Electron 中接入 OpenAI 兼容的大模型 API,真正需要解决的并不只是一个 fetch 请求,而是如何保持清晰的架构边界。

Snaptium 当前采用的核心思路是:

text 复制代码
Renderer 不直接请求模型
Preload 只暴露有限能力
IPC 入口负责参数校验
Main Process 统一读取模型配置
Remote AI Service 统一处理网络请求

这种方式比在 Vue 组件中直接调用模型接口多了一些代码,但随着 AI 功能和模型服务商数量增加,维护成本会明显更低。

本文中的架构已经应用在开源 AI 知识管理工具 Snaptium 中。

项目地址:

欢迎体验,也欢迎提交 Issue 和改进建议。

相关推荐
Oneslide1 小时前
根分区爆满却找不到大文件?深度解析 Linux df 与 du 不一致的经典故障
后端
魏祖潇1 小时前
framework 整合实战——DDD/TDD/SDD 三件套在 framework 仓的真实落地
人工智能·后端
神奇小汤圆2 小时前
责任链模式 + 策略模式:优雅处理多级请求的方式
后端
神奇小汤圆2 小时前
没啃透无锁队列,高并发底层你只懂了皮毛!
后端
大鸡腿同学2 小时前
大模型是怎么训练出来的?
后端
lizhongxuan3 小时前
判断一个人懂不懂 agent harness
后端
非洲农业不发达3 小时前
windows终端体验大升级,让你拥有macos级别的美化
前端·后端
妙码生花4 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(十七):登录接口完善,登录页接口整合,解决跨域
前端·后端·ai编程