本文以一个名为 DeskAgent 的企业知识助手为例,完整拆解如何从 0 到 1 构建一款可交付的 AI 桌面应用。它支持本地文档导入、企业知识库问答、Agent 工具调用、流式输出、多模型切换、权限控制、审计日志和跨平台打包。

一、为什么是桌面端 AI 应用

很多企业 AI 需求并不适合做成一个网页聊天框:

  • 企业资料可能位于本地磁盘,或者只能通过内网访问;
  • 用户需要读取文件、整理目录、生成报告,而不是只问一个问题;
  • 密钥、模型配置和业务数据不能直接暴露在浏览器端;
  • 用户希望在办公软件、文件管理器和 AI 助手之间快速切换;
  • 弱网、离线和本地模型场景需要更强的客户端能力。

Electron 适合承载这类产品,但“能打开窗口”距离“企业级可交付”还有很长一段距离。真正的难点集中在四个部分:

  1. 如何把 Electron 的主进程、预加载脚本和渲染进程隔离清楚;
  2. 如何让 Agent 稳定地规划任务、调用工具并控制风险;
  3. 如何把企业文档处理成可检索的知识库,并让检索结果真正帮助模型回答;
  4. 如何统一接入云模型、企业网关和本地模型,同时支持测试、监控和发布。

本文使用 TypeScript,前端示例采用 React,数据层采用 SQLite,向量检索层通过统一接口抽象出来。这样既可以使用本地向量库,也可以替换成企业内部的 pgvector、Milvus 或其他服务。

二、先定义一个可交付的最小产品

在开始写代码前,先把产品边界收窄。DeskAgent 的第一版只解决一个明确问题:

用户导入企业文档后,可以基于这些文档提问;当问题需要执行动作时,Agent 可以调用受控工具,并在高风险操作前请求用户确认。

第一版包含以下能力:

  • 导入 Markdown、TXT、PDF 和 DOCX 文档;
  • 文档解析、分块、向量化和索引;
  • 基于知识库的问答,并展示引用来源;
  • 支持普通对话和 Agent 模式;
  • 支持流式输出和取消任务;
  • 支持 OpenAI 兼容接口、企业网关和本地 Ollama;
  • 支持工具权限、用户确认和审计日志;
  • 支持 Windows、macOS 和 Linux 打包。

暂时不做复杂的多智能体协作、自动修改系统文件、无限制执行 Shell 命令和“完全自主”的后台任务。这些能力会显著扩大安全面和测试成本,应该在基础链路稳定之后再引入。

三、整体架构:把边界画清楚

Electron 应用至少包含三个运行边界:

┌─────────────────────────────────────────────────────────┐
│ Renderer:React UI                                      │
│ 聊天界面 / 知识库 / 设置 / 任务状态                      │
└───────────────────────┬─────────────────────────────────┘
                        │ window.desktopAPI
┌───────────────────────▼─────────────────────────────────┐
│ Preload:受控桥接层                                     │
│ 类型化 IPC / 事件订阅 / 参数校验                         │
└───────────────────────┬─────────────────────────────────┘
                        │ IPC
┌───────────────────────▼─────────────────────────────────┐
│ Main:可信业务层                                        │
│ Agent Runtime / RAG Pipeline / Model Gateway / Storage   │
│ 文件系统访问 / 权限确认 / 审计 / 更新 / 打包            │
└─────────────────────────────────────────────────────────┘

推荐的目录结构如下:

src/
├─ main/
│  ├─ main.ts                 # Electron 生命周期
│  ├─ ipc.ts                  # IPC 注册与请求校验
│  ├─ agent/
│  │  ├─ runtime.ts           # Agent 主循环
│  │  ├─ tools.ts             # 工具定义与权限
│  │  └─ events.ts            # 流式事件
│  ├─ rag/
│  │  ├─ ingest.ts            # 文档导入
│  │  ├─ chunker.ts           # 文本分块
│  │  ├─ retriever.ts         # 检索
│  │  └─ vector-store.ts      # 向量存储接口
│  ├─ models/
│  │  ├─ types.ts             # 统一模型协议
│  │  ├─ registry.ts          # 模型注册中心
│  │  └─ providers/           # 各供应商适配器
│  └─ storage/
│     ├─ database.ts          # SQLite
│     └─ audit.ts             # 审计日志
├─ preload/
│  └─ index.ts
└─ renderer/
   ├─ App.tsx
   └─ api.ts

关键原则只有一句话:渲染进程负责展示,主进程负责可信操作。 API Key、文件系统、数据库、模型请求和工具执行都不要直接放在渲染进程里。

四、搭建 Electron + TypeScript 基础工程

可以使用 Vite 作为渲染层构建工具,Electron 负责桌面壳。核心依赖示例:

npm create vite@latest desk-agent -- --template react-ts
cd desk-agent
npm install electron zod better-sqlite3
npm install -D electron-builder concurrently wait-on tsx @types/node @types/better-sqlite3

package.json 中保留清晰的开发、构建和发布命令:

{
  "scripts": {
    "dev": "concurrently \"vite\" \"wait-on http://localhost:5173 && electron .\"",
    "build": "tsc -b && vite build && electron-builder",
    "lint": "eslint .",
    "test": "vitest run",
    "release:win": "npm run build -- --win",
    "release:mac": "npm run build -- --mac",
    "release:linux": "npm run build -- --linux"
  },
  "build": {
    "appId": "com.example.deskagent",
    "productName": "DeskAgent",
    "files": ["dist/**", "dist-electron/**"],
    "directories": { "output": "release" },
    "win": { "target": ["nsis"] },
    "mac": { "target": ["dmg"] },
    "linux": { "target": ["AppImage"] }
  }
}

实际项目中,Electron 主进程和预加载脚本通常由单独的 TypeScript 配置编译到 dist-electron。不要把开发服务器地址写死到生产逻辑中,应该根据 app.isPackaged 选择开发地址或本地 index.html

五、Electron 安全基线:先把 IPC 做对

下面是创建窗口时建议保留的安全配置:

// src/main/main.ts
import { app, BrowserWindow } from 'electron';
import path from 'node:path';

function createWindow() {
  const window = new BrowserWindow({
    width: 1440,
    height: 920,
    minWidth: 980,
    minHeight: 640,
    webPreferences: {
      preload: path.join(__dirname, '../preload/index.js'),
      contextIsolation: true,
      nodeIntegration: false,
      sandbox: true,
    },
  });

  if (app.isPackaged) {
    window.loadFile(path.join(__dirname, '../dist/index.html'));
  } else {
    window.loadURL('http://localhost:5173');
  }
}

app.whenReady().then(() => {
  createWindow();
  app.on('activate', () => {
    if (BrowserWindow.getAllWindows().length === 0) createWindow();
  });
});

app.on('window-all-closed', () => {
  if (process.platform !== 'darwin') app.quit();
});

预加载脚本只暴露业务需要的最小 API:

// src/preload/index.ts
import { contextBridge, ipcRenderer } from 'electron';

contextBridge.exposeInMainWorld('desktopAPI', {
  ask: (input: { conversationId: string; message: string }) =>
    ipcRenderer.invoke('agent:ask', input),
  cancel: (taskId: string) => ipcRenderer.invoke('agent:cancel', taskId),
  importFiles: () => ipcRenderer.invoke('knowledge:import-files'),
  onAgentEvent: (listener: (event: AgentEvent) => void) => {
    const handler = (_event: Electron.IpcRendererEvent, value: AgentEvent) =>
      listener(value);
    ipcRenderer.on('agent:event', handler);
    return () => ipcRenderer.removeListener('agent:event', handler);
  },
});

不要直接暴露 ipcRenderershellfs 或任意通道转发函数。所有 IPC 输入都要在主进程再次校验,前端校验只能改善交互,不能承担安全责任。

// src/main/ipc.ts
import { ipcMain } from 'electron';
import { z } from 'zod';

const askSchema = z.object({
  conversationId: z.string().uuid(),
  message: z.string().trim().min(1).max(20_000),
});

ipcMain.handle('agent:ask', async (event, rawInput) => {
  const input = askSchema.parse(rawInput);
  const sender = event.sender;

  // 生产环境还应校验 sender 是否来自本应用的窗口。
  return agentRuntime.start({
    ...input,
    emit: (agentEvent) => sender.send('agent:event', agentEvent),
  });
});

六、统一多模型接入:不要让业务代码绑定供应商

模型供应商会变化,企业网关的认证方式也可能变化。Agent 和 RAG 层只应该依赖统一协议,不应该到处出现某个供应商的 SDK 调用。

先定义最小模型接口:

// src/main/models/types.ts
export type ChatMessage = {
  role: 'system' | 'user' | 'assistant' | 'tool';
  content: string;
  toolCallId?: string;
};

export type ModelRequest = {
  model: string;
  messages: ChatMessage[];
  tools?: ToolSchema[];
  temperature?: number;
  signal?: AbortSignal;
};

export type ModelEvent =
  | { type: 'text-delta'; text: string }
  | { type: 'tool-call'; name: string; arguments: string; callId: string }
  | { type: 'done'; usage?: { inputTokens: number; outputTokens: number } };

export interface ModelProvider {
  readonly id: string;
  stream(request: ModelRequest): AsyncIterable<ModelEvent>;
  embed(texts: string[]): Promise<number[][]>;
}

对于兼容 OpenAI Chat Completions 协议的服务,可以实现一个通用适配器。云端模型、企业代理和本地 Ollama 往往只需要替换 baseUrl、认证头和模型名:

// src/main/models/providers/openai-compatible.ts
export class OpenAICompatibleProvider implements ModelProvider {
  readonly id: string;

  constructor(
    id: string,
    private readonly baseUrl: string,
    private readonly apiKey: string,
  ) {
    this.id = id;
  }

  async *stream(request: ModelRequest): AsyncIterable<ModelEvent> {
    const response = await fetch(`${this.baseUrl}/chat/completions`, {
      method: 'POST',
      headers: {
        'content-type': 'application/json',
        authorization: `Bearer ${this.apiKey}`,
      },
      body: JSON.stringify({ ...request, stream: true }),
      signal: request.signal,
    });

    if (!response.ok || !response.body) {
      throw new Error(`Model request failed: ${response.status}`);
    }

    for await (const chunk of readSse(response.body)) {
      const event = parseProviderChunk(chunk);
      if (event) yield event;
    }
  }

  async embed(texts: string[]) {
    const response = await fetch(`${this.baseUrl}/embeddings`, {
      method: 'POST',
      headers: {
        'content-type': 'application/json',
        authorization: `Bearer ${this.apiKey}`,
      },
      body: JSON.stringify({ model: 'text-embedding-model', input: texts }),
    });

    if (!response.ok) throw new Error(`Embedding request failed: ${response.status}`);
    const payload = await response.json() as { data: Array<{ embedding: number[] }> };
    return payload.data.map((item) => item.embedding);
  }
}

模型注册中心负责配置和选择:

// src/main/models/registry.ts
export class ModelRegistry {
  private readonly providers = new Map<string, ModelProvider>();

  register(provider: ModelProvider) {
    this.providers.set(provider.id, provider);
  }

  get(providerId: string) {
    const provider = this.providers.get(providerId);
    if (!provider) throw new Error(`Unknown model provider: ${providerId}`);
    return provider;
  }
}

生产环境建议把“供应商”和“模型”拆成两层配置。供应商决定连接方式和认证,模型决定上下文长度、能力标签、价格和是否支持工具调用。这样才能在模型故障时按能力降级,而不是简单地把字符串改成另一个模型名。

API Key 应该放在哪里

有三种常见方案:

  • 企业统一网关:客户端只持有短期令牌,推荐用于组织内分发;
  • 系统密钥链:使用 Windows Credential Manager、macOS Keychain 或 Linux Secret Service;
  • 用户手动配置:适合开发工具,不适合把长期密钥写入配置文件。

无论选择哪种方案,都不要把 Key 放在 React 状态、前端构建产物或日志里。模型请求应该由主进程发起,日志中只记录供应商、模型、耗时和 token 用量等脱敏信息。

七、RAG 的关键不是“接向量库”,而是数据链路

RAG 可以拆成两个阶段。

7.1 离线索引阶段

选择文件
   ↓
读取与解析
   ↓
清洗标题、页眉、页脚和重复内容
   ↓
按语义和长度分块
   ↓
生成 Embedding
   ↓
写入向量库、全文索引和来源元数据

每个 chunk 至少保存以下字段:

export type DocumentChunk = {
  id: string;
  documentId: string;
  text: string;
  embedding: number[];
  source: {
    fileName: string;
    filePath: string;
    page?: number;
    heading?: string;
  };
  contentHash: string;
  createdAt: string;
};

contentHash 很重要。文件没有变化时,不应该每次导入都重复解析和向量化。对于大文件,还可以按文件哈希和解析器版本建立索引缓存。

7.2 在线检索阶段

用户问题
   ↓
问题改写或拆分
   ↓
生成查询向量
   ↓
向量召回 + 全文召回
   ↓
去重、过滤、重排
   ↓
截断到上下文预算
   ↓
交给模型生成答案

只做向量相似度搜索通常不够。企业文档里有大量产品名、编号、合同条款和缩写,关键词检索往往比纯向量检索更可靠。实践中可以使用混合检索:

export interface VectorStore {
  upsert(chunks: DocumentChunk[]): Promise<void>;
  hybridSearch(input: {
    query: string;
    embedding: number[];
    topK: number;
    filters?: Record<string, string>;
  }): Promise<Array<DocumentChunk & { score: number }>>;
}

一个实用的分块函数如下。它保留重叠窗口,避免关键信息刚好被切到两个 chunk 之间:

export function splitText(text: string, size = 900, overlap = 120): string[] {
  const normalized = text.replace(/\r\n/g, '\n').replace(/[ \t]+/g, ' ').trim();
  if (!normalized) return [];

  const chunks: string[] = [];
  let start = 0;
  while (start < normalized.length) {
    const end = Math.min(start + size, normalized.length);
    chunks.push(normalized.slice(start, end));
    if (end === normalized.length) break;
    start = Math.max(end - overlap, start + 1);
  }
  return chunks;
}

这段代码是通用基线,不代表所有文档都应该按字符切割。Markdown 可以按标题切分,PDF 需要尽量保留页码,表格要避免被拆散,代码文档则可以按函数或类切分。分块策略应该按数据类型评估,而不是只调一个数字。

7.3 组装上下文并要求引用

function buildRagContext(results: Array<DocumentChunk & { score: number }>) {
  return results.map((item, index) => {
    const location = [item.source.fileName, item.source.page && `${item.source.page}`]
      .filter(Boolean)
      .join(' / ');
    return `[${index + 1}] ${location}\n${item.text}`;
  }).join('\n\n');
}

const systemPrompt = `
你是企业知识助手。只能根据 <context> 中的资料回答事实问题。
资料不足时明确说“当前资料无法确认”,不要编造。
回答末尾使用 [1]、[2] 的格式引用来源。
涉及删除、发送、发布、付款或权限变更时,不得自行执行,必须请求用户确认。
`;

RAG 的质量不能只看“模型说得像不像”。至少要同时评估:召回是否包含正确 chunk、答案是否使用了资料、引用是否对应原文、资料不足时是否拒答。建议准备一组脱敏的标准问题,持续记录 Recall@K、引用准确率和无依据回答率。

八、实现 Agent:有限状态机比无限循环更可靠

Agent 的本质是一个受约束的循环:模型提出下一步,系统判断是否允许,工具执行后把结果交回模型,直到得到最终答案或触发上限。

接收用户请求
   ↓
检索相关知识
   ↓
模型规划下一步
   ├─ 输出文本 → 返回答案
   ├─ 调用只读工具 → 执行并回传结果
   └─ 调用高风险工具 → 请求用户确认

先定义工具,而不是让模型直接调用任意函数:

type ToolRisk = 'read' | 'write' | 'external';

type ToolDefinition<T> = {
  name: string;
  description: string;
  risk: ToolRisk;
  input: z.ZodType<T>;
  execute: (input: T, context: ToolContext) => Promise<string>;
};

const listFilesTool: ToolDefinition<{ directory: string }> = {
  name: 'list_files',
  description: '列出用户明确授权目录中的文件名',
  risk: 'read',
  input: z.object({ directory: z.string().min(1) }),
  async execute(input, context) {
    const directory = context.allowedPaths.resolve(input.directory);
    const files = await fs.promises.readdir(directory, { withFileTypes: true });
    return files.filter((item) => item.isFile()).map((item) => item.name).join('\n');
  },
};

工具执行前至少做四件事:

  1. 用 Zod 校验参数;
  2. 检查路径是否位于用户授权范围;
  3. 根据风险等级决定是否需要确认;
  4. 记录调用者、参数摘要、结果状态和耗时。

Agent 主循环的核心可以写成如下形式:

async function runAgent(input: AgentInput): Promise<void> {
  const controller = new AbortController();
  const messages = await buildMessagesWithRag(input);

  for (let step = 0; step < 8; step += 1) {
    const events = model.stream({
      model: input.model,
      messages,
      tools: toolRegistry.schemas(),
      signal: controller.signal,
    });

    let toolCall: ToolCall | undefined;
    for await (const event of events) {
      if (event.type === 'text-delta') input.emit({ type: 'text', text: event.text });
      if (event.type === 'tool-call') toolCall = parseToolCall(event);
      if (event.type === 'done') input.emit({ type: 'usage', usage: event.usage });
    }

    if (!toolCall) {
      input.emit({ type: 'completed' });
      return;
    }

    const tool = toolRegistry.get(toolCall.name);
    const args = tool.input.parse(toolCall.arguments);
    if (tool.risk !== 'read' && !(await input.confirm(tool.name, args))) {
      messages.push({ role: 'tool', content: '用户拒绝了该操作', toolCallId: toolCall.id });
      continue;
    }

    const result = await tool.execute(args, input.context);
    messages.push({ role: 'tool', content: result, toolCallId: toolCall.id });
  }

  throw new Error('Agent reached the maximum number of steps');
}

这里有三个重要的工程约束:

  • 设置最大步数,防止模型陷入循环;
  • 为每次任务设置超时、取消信号和 token 预算;
  • 工具结果要限制长度,避免一次调用把整个文件或目录塞进上下文。

不要把 exec(command) 作为通用工具交给模型。若业务确实需要执行命令,应该使用固定命令模板、参数白名单、独立进程、超时和工作目录隔离,并且默认要求用户确认。

九、流式输出与任务取消

AI 应用最影响体感的功能之一是流式输出。不要等模型生成完整答案后再一次性返回,而是把事件定义成稳定协议:

type AgentEvent =
  | { type: 'started'; taskId: string }
  | { type: 'thinking'; step: number }
  | { type: 'text'; text: string }
  | { type: 'source'; source: SourceRef }
  | { type: 'confirmation-required'; requestId: string; tool: string; input: unknown }
  | { type: 'usage'; usage?: Usage }
  | { type: 'completed' }
  | { type: 'failed'; message: string };

渲染层只消费事件,不关心模型供应商的 SSE 格式。这样可以做到:

  • 云模型换成本地模型时,UI 不需要重写;
  • Agent 调用工具时,界面可以显示明确的状态;
  • 用户点击停止时,主进程可以调用 AbortController.abort()
  • 未来增加任务队列或后台任务时,协议仍然可复用。

需要注意背压和事件顺序。文本增量过于频繁时,可以在主进程做 30 到 80 毫秒的批量合并;任务完成、失败和取消事件必须幂等,避免窗口重连或重复点击造成状态错乱。

十、数据存储与隐私设计

本地数据库可以用 SQLite 保存以下数据:

conversations       会话基本信息
messages            用户消息、助手消息和工具消息
documents           文件哈希、解析状态、来源路径
chunks              chunk 文本、向量索引引用和元数据
model_profiles      模型名称、能力标签和非敏感配置
audit_events        工具调用、确认、失败和导出记录

建议遵循几个原则:

  • 原始文档与索引分开管理,删除文档时同步删除 chunk;
  • 对话数据提供“删除全部本地数据”入口;
  • 导入文件默认只保存路径和哈希,是否复制原文件要让用户明确选择;
  • 日志默认脱敏,不记录完整提示词、文档内容和 API Key;
  • 数据库迁移必须有版本号,不能在启动时无条件重建;
  • 多用户环境下,不能仅靠本地路径判断权限,企业版本仍需接入身份和权限系统。

如果使用本地模型和本地 Embedding,敏感数据可以不离开设备;但“本地模型”不等于“天然安全”,仍要考虑模型文件来源、进程权限、缓存文件和导出功能。

十一、错误处理与可观测性

生产环境最常见的问题不是模型不会回答,而是网络超时、上下文超限、文件解析失败、向量维度不一致和用户中途关闭窗口。

建议把错误分成可展示给用户的业务错误和只写日志的技术细节:

class AppError extends Error {
  constructor(
    message: string,
    readonly code: 'MODEL_UNAVAILABLE' | 'DOCUMENT_PARSE_FAILED' | 'PERMISSION_DENIED' | 'TASK_CANCELLED',
    readonly retryable = false,
  ) {
    super(message);
  }
}

每个任务至少记录以下指标:

  • 首 token 延迟和完整响应耗时;
  • 输入、输出 token 数和估算成本;
  • 检索耗时、召回数量和最终使用数量;
  • Agent 步数、工具调用次数和确认拒绝次数;
  • 文档解析成功率、索引耗时和失败原因;
  • 模型错误率、超时率和降级次数。

日志要有 taskIdconversationIdtraceId,但不要把用户全文作为默认日志字段。桌面应用即使没有中心化监控,也可以先把结构化日志写入应用数据目录,再提供用户主动导出的诊断包。

十二、测试策略:不要只测“模型能回答”

AI 应用应该把确定性部分和非确定性部分分开测试。

确定性测试包括:

  • IPC 输入校验和来源校验;
  • 路径授权、工具参数校验和风险确认;
  • 文档解析、分块、哈希去重和数据库迁移;
  • SSE 解析、事件顺序、取消和超时;
  • 模型适配器的错误映射和重试策略。

模型相关测试可以使用固定假模型:

class FakeProvider implements ModelProvider {
  readonly id = 'fake';

  async *stream() {
    yield { type: 'text-delta' as const, text: '这是测试答案。' };
    yield { type: 'done' as const, usage: { inputTokens: 10, outputTokens: 6 } };
  }

  async embed(texts: string[]) {
    return texts.map(() => [1, 0, 0]);
  }
}

然后测试 Agent 是否能正确处理正常回答、工具调用、用户拒绝、最大步数、模型超时和取消。对于 RAG,准备一组小型脱敏数据集,验证答案引用是否指向正确文件和页码。

最后再做 Electron 端到端测试,重点覆盖:启动、导入文件、发起任务、流式显示、确认弹窗、取消任务和退出重启后的数据恢复。

十三、跨平台打包与工程化交付

Electron 的发布不只是执行一次构建命令,还要处理:

  • 应用图标、产品名和版本号;
  • Windows 安装包、macOS 签名与公证、Linux 发布格式;
  • 原生模块在不同平台的重建;
  • 自动更新渠道和回滚策略;
  • 配置文件迁移与数据目录兼容;
  • 许可证、隐私政策和第三方依赖声明。

CI 中建议按平台分别构建,并把构建产物、版本号、Git 提交号和依赖锁文件关联起来。发布前至少执行一次干净环境安装,避免本机 node_modules 恰好掩盖原生模块或打包配置问题。

安全更新必须校验签名或可信发布源。自动更新失败时,要保留上一个可启动版本,不要因为更新程序异常导致整个应用无法打开。

十四、从 Demo 到企业版的演进路线

可以按照下面的顺序迭代:

阶段 1:单机对话 + 单一模型
   ↓
阶段 2:文档导入 + RAG + 引用来源
   ↓
阶段 3:工具调用 + 权限确认 + 审计
   ↓
阶段 4:多模型路由 + 失败降级 + 成本控制
   ↓
阶段 5:企业身份、团队知识库、策略中心和远程运维

不要一开始就把所有能力堆到一个“超级 Agent”里。每加入一个工具,都要明确它能读取什么、修改什么、代表谁执行、失败如何恢复,以及用户如何撤销。

十五、常见误区

误区一:把 API Key 写进前端代码

前端构建产物可以被解包和搜索,任何写入渲染层的长期密钥都应视为已经泄露。

误区二:只使用向量搜索

编号、专有名词和短关键词很容易被向量相似度漏掉。混合检索和元数据过滤通常更稳。

误区三:让模型自由执行系统命令

模型输出不是权限系统。工具必须有白名单、参数校验、授权范围、超时和审计。

误区四:用更大的上下文掩盖检索问题

把更多文档塞给模型会增加成本和噪声。应先改进解析、分块、召回、重排和来源展示。

误区五:只在开发机上验证打包

原生模块、文件权限、路径格式和系统密钥链都可能在目标平台失败。安装包必须在干净环境进行冒烟测试。

十六、上线前检查清单

  • contextIsolation 已开启,nodeIntegration 已关闭;
  • 渲染进程没有 API Key、文件系统和数据库权限;
  • IPC 通道有明确命名,并在主进程做输入校验;
  • 所有工具都有风险等级、参数 Schema 和授权范围;
  • 高风险操作必须经过用户确认;
  • Agent 有最大步数、超时、取消和错误恢复;
  • 文档导入支持哈希去重和失败重试;
  • RAG 答案展示可核验的来源;
  • 日志脱敏,并能通过任务 ID 追踪一次请求;
  • 多模型配置区分供应商、模型能力和凭证;
  • Windows、macOS、Linux 至少完成目标平台冒烟测试;
  • 自动更新、回滚和数据迁移经过验证。

总结

Electron 解决的是 AI 应用的桌面承载问题,Agent 解决的是任务编排问题,RAG 解决的是企业知识接入问题,多模型适配层解决的是供应商变化问题,而工程化交付决定了产品能不能真正进入用户环境。

一款可靠的企业级 AI 桌面应用,不是把聊天窗口嵌入 Electron,再接上一个模型 API。它需要清晰的进程边界、受控的工具体系、可评估的知识检索、统一的模型协议、可取消的流式任务、可追踪的审计记录,以及经过签名和验证的跨平台交付链路。

如果你正在启动类似项目,建议先完成“单机对话 + 文档引用 + 一个只读工具”这条最小闭环,再逐步增加写操作、多模型路由和企业协作能力。这样每一步都能被测试、被度量,也更容易在真实业务中持续演进。


更多推荐