现在很多 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 中。
项目地址:
- GitHub:github.com/jetyu/Snapt...
- 官网:snaptium.com
- Microsoft Store:apps.microsoft.com/detail/9p4h...
欢迎体验,也欢迎提交 Issue 和改进建议。