1. 运维日常的割裂感:为什么我决定自己造一个工作台
干运维这行的朋友应该都有同感:一天下来,屏幕上最少开着三个窗口。左边一个终端连着服务器,中间一个记事本记着各种命令和配置片段,右边还挂着一个聊天窗口,要么是跟同事确认变更窗口,要么是在群里同步故障进展。三个窗口来回切,切到最后自己都忘了刚才那条命令是从哪个机器上抄下来的。
这个项目标题里的“三开”两个字,精准地戳中了我的痛点。所谓三开,就是SSH 客户端、记事本、聊天工具同时运行。SSH 客户端负责连服务器执行命令,记事本负责存命令模板、IP 清单、变更记录,聊天工具负责沟通协调。这三者之间没有任何数据流通,全靠人脑和剪贴板做“中间件”。一次简单的重启服务操作,可能要经历“聊天框确认→记事本找命令→SSH 粘贴执行→回聊天框汇报”四个步骤,中间任何一个环节出错,轻则重来,重则引发生产事故。
我做的这款Mini Assistant,定位就是一个AI 运维工作台。它不是要替代专业的 SSH 客户端,也不是要做一个全能聊天软件,而是把“连服务器、查资料、记东西、问 AI、同步信息”这几件事收拢到一个界面里。目标用户很明确:每天需要登录多台服务器做重复性操作、又不想在多个工具之间反复横跳的中小团队运维人员。哪怕你刚入行不久,只要管着三五台机器,这个思路都能直接拿去用。
下面我会从整体设计思路、核心功能拆解、实操落地过程、踩坑排查四个维度,把这个工作台从想法到跑通的完整过程讲清楚。代码和配置都会给到可直接参考的版本,你照着改改就能跑起来。
2. 整体设计思路:不做大而全,只做“刚好够用”
2.1 为什么是“工作台”而不是“客户端”
一开始我也想过,是不是直接给现有 SSH 客户端写个插件就行了。调研了一圈发现,主流 SSH 客户端的插件体系要么太重,要么扩展能力有限,想在里面塞一个 AI 对话面板和命令知识库,改造成本比从头写一个还高。而且插件形态受宿主限制,哪天宿主升级了接口变了,维护成本全压在自己身上。
所以最终选定的路线是:做一个独立的桌面工作台,把 SSH 连接能力作为其中一个模块,而不是把工作台塞进 SSH 客户端里。这个决策背后的逻辑是,运维的核心诉求不是“连上服务器”这个动作本身,而是“完成一次变更并留下记录”这个完整闭环。SSH 只是闭环里的一环,记事本和沟通也是。工作台形态可以自由编排这三者的关系,插件形态则处处受制。
技术选型上,桌面端用Electron做壳,前端用Vue 3 + TypeScript,SSH 连接层用Node.js 的 ssh2 库,AI 对话走通用的大模型 API,本地数据存储用SQLite。这套组合的好处是:前端生态成熟,写界面快;ssh2 库稳定,支持交互式 shell 和 SFTP;SQLite 单文件存储,备份迁移都方便,不需要额外部署数据库服务。
2.2 三个核心模块的职责划分
工作台内部拆成三个核心模块,每个模块职责单一,通过一个统一的事件总线通信。
连接管理模块负责维护 SSH 会话池。每台服务器对应一个会话对象,包含连接状态、当前工作目录、最近执行的命令历史。会话池的好处是,切换服务器时不需要重新输入密码或密钥,直接复用已有连接。这里有个细节:会话空闲超过一定时间(我设的是 15 分钟)自动断开,避免占用服务器连接数,但连接配置和命令历史保留在本地。
知识库模块负责管理命令模板、服务器清单、变更记录。命令模板支持变量占位符,比如systemctl restart {{service_name}},执行时弹出输入框让你填具体服务名。服务器清单除了 IP、端口、用户名,还可以打标签,比如“生产”“测试”“数据库”,方便按标签批量操作。变更记录是自动生成的,每次执行命令后,把命令内容、目标服务器、执行时间、返回结果摘要写进 SQLite,后续排查问题时可以直接搜。
AI 助手模块负责两件事:一是把自然语言转成可执行的命令或操作步骤,二是对命令执行结果做解读。比如你输入“看看磁盘还剩多少空间”,它会生成df -h并提示你选择目标服务器;执行完返回结果后,它会把关键信息提取出来,告诉你哪个分区快满了。这个模块不直接执行命令,所有 AI 生成的命令都需要人工确认后才下发,这是安全底线。
2.3 数据流转与安全边界
三个模块之间的数据流转遵循一个原则:AI 可以读上下文,但不能直接写执行指令。具体来说,AI 助手能读取当前选中的服务器信息、最近的命令历史、知识库里的模板,但生成的命令必须经过一个“确认层”才能进入连接管理模块执行。确认层会做几件事:检查命令是否在危险命令黑名单里(比如rm -rf /、mkfs、dd写盘操作),检查目标服务器是否匹配命令的适用范围标签,记录操作人和时间戳。
这个设计参考了运维领域“四眼原则”的思路,虽然做不到双人复核,但至少保证 AI 不会在你不知情的情况下把危险命令发出去。实测下来,这个确认层拦截过好几次我手滑打错的命令,比如本来想重启 nginx 结果打成了重启整个网络服务。
3. 核心细节解析:从连接池到 AI 提示词
3.1 SSH 连接池的实现要点
连接池的核心是一个 Map 结构,key 是user@host:port,value 是 ssh2 的 Client 实例和对应的 shell 流。建立连接时,优先尝试密钥认证,密钥文件路径从本地配置读取;密钥认证失败再走密码认证,密码用系统密钥链加密存储,不落明文。
这里有个容易踩的坑:ssh2 库的 shell 流是单工的,同一时间只能有一个命令在执行。如果你在命令还没返回结果时又发了一条,两条命令的输出会混在一起,根本分不清哪条是哪条。我的处理方式是给每个会话加一个执行队列,新命令进来先排队,等上一条命令的exit事件触发后再执行下一条。队列长度设了上限(20 条),超过就提示用户清理。
另一个细节是命令超时处理。有些命令会卡住不返回,比如tail -f或者等待输入的交互式命令。我给每条命令设了默认 30 秒超时,超时后发送Ctrl+C中断,并把已捕获的输出返回给用户,标记为“超时中断”。这个超时时间可以在设置里按服务器调整,比如数据库服务器可以设长一点。
3.2 命令模板的变量系统设计
命令模板用双花括号做变量占位符,支持三种变量类型:文本变量、枚举变量、服务器标签变量。文本变量就是执行时弹框让你输入,比如{{service_name}};枚举变量预先定义可选值,比如{{env}}可选“生产/测试/开发”;服务器标签变量会自动替换成匹配标签的服务器列表,用于批量执行。
变量解析用了一个简单的正则替换引擎,按顺序处理:先替换服务器标签变量(因为可能影响目标服务器选择),再替换枚举变量(下拉选择),最后替换文本变量(输入框)。替换完成后,命令进入确认层,确认层会高亮显示所有被替换的部分,让你一眼看出哪些地方是动态填充的。
注意:变量值里如果包含 shell 特殊字符(比如
$、&、;),需要做转义处理,否则可能被 shell 解释成其他含义。我的做法是对变量值做单引号包裹,同时把值里的单引号替换成'\'',这是 shell 里处理单引号的标准做法。
3.3 AI 提示词工程:让模型输出可执行的命令
AI 助手的效果好坏,八成取决于提示词。我试过很多版本,最终稳定下来的提示词结构是这样的:
你是一个运维命令生成助手。根据用户的自然语言描述,生成一条可以在 Linux 服务器上执行的 shell 命令。 约束条件: 1. 只输出命令本身,不要输出解释、不要用 markdown 代码块包裹。 2. 如果用户描述模糊,选择最保守、影响最小的命令。 3. 禁止生成任何删除文件、格式化磁盘、修改系统关键配置的命令。 4. 如果无法确定用户意图,输出 "UNCERTAIN" 并附上你需要澄清的问题。 当前服务器信息: - 主机名:{{hostname}} - 操作系统:{{os_info}} - 最近执行的命令:{{recent_commands}} 用户描述:{{user_input}}这个提示词的关键在于约束条件第 3 条和第 4 条。第 3 条是硬性安全限制,第 4 条是让模型在不确定时主动停下来问,而不是瞎猜。实测下来,加了第 4 条之后,模型生成危险命令的概率明显下降,因为它有了“说不确定”这个出口,不用硬着头皮编一个命令出来。
3.4 本地存储的表结构设计
SQLite 里建了四张表:servers(服务器清单)、templates(命令模板)、execution_log(执行记录)、ai_conversations(AI 对话历史)。其中execution_log是核心表,字段包括:id、server_id、command、exit_code、output_summary、executed_at、operator。output_summary只存前 500 个字符,完整输出如果需要保留,单独存到文件里,数据库里只存文件路径。
这样设计的原因是,SQLite 单表数据量超过十万行之后,查询性能会明显下降。执行记录是增长最快的表,每天几百条很正常,所以做了冷热分离:最近 30 天的记录留在数据库里,更早的归档到文件,需要时再导入查询。
4. 实操过程:从零把工作台跑起来
4.1 环境准备与项目初始化
先确保本地有 Node.js 18 以上版本,然后初始化项目:
mkdir mini-assistant && cd mini-assistant npm init -y npm install electron vue@3 typescript ssh2 better-sqlite3 axios npm install -D electron-builder vite @vitejs/plugin-vue目录结构按功能划分:
mini-assistant/ ├── main/ # Electron 主进程 │ ├── index.ts # 入口 │ ├── ssh-pool.ts # SSH 连接池 │ └── storage.ts # SQLite 操作 ├── renderer/ # 前端界面 │ ├── App.vue │ ├── components/ │ │ ├── ServerList.vue │ │ ├── Terminal.vue │ │ ├── TemplatePanel.vue │ │ └── AiChat.vue │ └── main.ts ├── shared/ # 共享类型定义 │ └── types.ts └── package.json主进程和渲染进程通过ipcMain/ipcRenderer通信。所有 SSH 操作和数据库操作都在主进程做,渲染进程只负责界面展示和用户输入。这样做的原因是,ssh2 和 better-sqlite3 都是原生模块,在渲染进程里用会有兼容性问题,放主进程最稳妥。
4.2 SSH 连接池的核心代码
连接池的实现大概 200 行,核心逻辑如下:
import { Client } from 'ssh2'; interface Session { client: Client; stream: any; queue: string[]; busy: boolean; lastActive: number; } class SSHPool { private sessions = new Map<string, Session>(); async getSession(config: ServerConfig): Promise<Session> { const key = `${config.user}@${config.host}:${config.port}`; const existing = this.sessions.get(key); if (existing && existing.client) { existing.lastActive = Date.now(); return existing; } const client = new Client(); await new Promise<void>((resolve, reject) => { client.on('ready', resolve).on('error', reject).connect({ host: config.host, port: config.port, username: config.user, privateKey: config.privateKeyPath ? fs.readFileSync(config.privateKeyPath) : undefined, password: config.password, readyTimeout: 10000, }); }); const stream = await new Promise<any>((resolve) => { client.shell({ term: 'xterm-256color' }, (err, s) => resolve(s)); }); const session: Session = { client, stream, queue: [], busy: false, lastActive: Date.now() }; this.sessions.set(key, session); return session; } async exec(session: Session, command: string, timeout = 30000): Promise<string> { if (session.busy) { session.queue.push(command); return ''; } session.busy = true; return new Promise((resolve) => { let output = ''; const timer = setTimeout(() => { session.stream.write('\x03'); resolve(output + '\n[超时中断]'); }, timeout); const onData = (data: Buffer) => { output += data.toString(); }; session.stream.on('data', onData); session.stream.write(command + '\n'); const checkExit = setInterval(() => { if (output.includes('__CMD_DONE__')) { clearInterval(checkExit); clearTimeout(timer); session.stream.removeListener('data', onData); session.busy = false; resolve(output.replace('__CMD_DONE__', '')); if (session.queue.length > 0) { const next = session.queue.shift()!; this.exec(session, next); } } }, 200); }); } }这里用了一个小技巧:在命令末尾追加; echo __CMD_DONE__,通过检测这个标记来判断命令是否执行完毕。比单纯等exit事件更可靠,因为有些命令不会触发exit。
4.3 命令模板与变量替换的实现
模板存储格式是 JSON,存在 SQLite 的templates表里:
{ "name": "重启服务", "command": "systemctl restart {{service_name}} && systemctl status {{service_name}}", "variables": [ { "key": "service_name", "type": "text", "label": "服务名称", "default": "nginx" } ], "tags": ["生产", "测试"] }变量替换函数:
function renderTemplate(template: string, values: Record<string, string>): string { return template.replace(/\{\{(\w+)\}\}/g, (_, key) => { const val = values[key] ?? ''; return `'${val.replace(/'/g, "'\\''")}'`; }); }注意这里对变量值做了单引号包裹和转义,防止注入。虽然是自己用的工具,但安全习惯要养好,万一哪天分享给同事用,不至于出问题。
4.4 AI 对话模块的接入
AI 模块通过 HTTP 请求调用大模型 API,请求体里带上系统提示词和用户输入。为了控制成本,我做了两层缓存:相同问题的回答缓存 24 小时,相同命令的解读结果缓存 7 天。缓存存在 SQLite 里,key 是输入内容的哈希值。
对话界面用了一个简单的消息列表,用户消息和 AI 回复交替显示。AI 回复如果是命令,会渲染成一个带“执行”按钮的卡片,点击后把命令填入命令输入框,并自动选中当前服务器。这个交互设计比让 AI 直接执行要安全得多,也符合运维人员“先看再执行”的习惯。
4.5 打包与分发
开发完成后用 electron-builder 打包:
npx electron-builder --config electron-builder.yml配置文件里指定目标平台为当前系统,输出格式选dir方便调试,正式发布再改成nsis(Windows)或dmg(macOS)。打包时注意把better-sqlite3和ssh2标记为external,这两个原生模块需要单独处理,不能直接打进 asar 包。
5. 常见问题与排查技巧实录
5.1 连接相关的问题
问题一:密钥认证失败,但命令行 ssh 能连上。
这种情况多半是密钥格式问题。ssh2 库对密钥格式有要求,OpenSSH 新格式的密钥(以-----BEGIN OPENSSH PRIVATE KEY-----开头)在旧版本 ssh2 里不支持。解决办法是升级 ssh2 到最新版,或者用ssh-keygen -p -m PEM -f keyfile把密钥转成 PEM 格式。
问题二:连接成功但执行命令没输出。
检查 shell 流是否正常建立。有些服务器默认 shell 是/sbin/nologin或者受限 shell,这种情况下client.shell()会失败。可以改用client.exec()执行单条命令,虽然不支持交互,但至少能拿到输出。
问题三:中文乱码。
服务器 locale 设置问题。在建立连接后先执行export LANG=en_US.UTF-8或者export LANG=zh_CN.UTF-8,根据服务器实际支持的 locale 来设。如果服务器没装中文 locale,那就只能显示英文,这个没办法。
5.2 命令执行相关的问题
问题四:命令执行到一半卡住。
大概率是命令在等待输入。比如apt install会问 yes/no,passwd会等密码输入。解决办法是在命令前加DEBIAN_FRONTEND=noninteractive或者用yes |管道。更通用的做法是在命令末尾加< /dev/null,把标准输入重定向到空设备。
问题五:输出内容太多,界面卡死。
终端组件渲染大量文本时性能会急剧下降。我的处理方式是:输出超过 1000 行时,只渲染最后 500 行,前面的折叠起来,提供一个“查看完整输出”的按钮,点击后把完整输出写到临时文件,用系统默认编辑器打开。
问题六:批量执行时部分服务器失败。
批量执行需要做错误隔离,一台失败不能影响其他台。我的做法是每台服务器独立执行,结果分别记录,最后汇总成一个报告,标出成功和失败的服务器。失败的原因也记录下来,方便排查。
5.3 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 连接超时 | 网络不通或防火墙拦截 | telnet host port测试端口 | 检查网络和防火墙规则 |
| 认证失败 | 密钥格式不对或密码错误 | 用命令行 ssh 验证 | 转换密钥格式或重置密码 |
| 命令无输出 | shell 流未建立 | 检查 shell 类型 | 改用 exec 模式 |
| 中文乱码 | locale 设置问题 | locale命令查看 | 设置 LANG 环境变量 |
| 执行卡住 | 命令等待输入 | 查看命令是否需要交互 | 重定向标准输入 |
| 输出过多卡顿 | 终端渲染性能瓶颈 | 观察输出行数 | 限制渲染行数 |
| 批量执行部分失败 | 单台服务器异常 | 查看失败服务器日志 | 错误隔离,单独重试 |
5.4 几个踩过的坑
坑一:SQLite 并发写入报错。better-sqlite3 是同步 API,多个地方同时写会报SQLITE_BUSY。解决办法是开 WAL 模式,并且把所有写操作串行化,用一个队列管理。
坑二:Electron 打包后原生模块找不到。需要把better-sqlite3和ssh2的.node文件手动复制到打包目录,并在主进程里用process.resourcesPath拼接路径加载。
坑三:AI 生成的命令包含 markdown 代码块标记。模型有时候不听话,还是会输出```bash这样的标记。解决办法是在解析前先做一次清洗,把代码块标记去掉。
坑四:命令历史记录泄露敏感信息。有些命令包含密码,比如mysql -u root -p'password'。我的处理是在记录前做一次脱敏,把-p后面的内容替换成***。这个规则可以配置,默认开启。
6. 一些个人体会和后续想法
这个工作台我从有这个想法到跑通第一个版本,大概花了三个周末。中间推翻重来过一次,最初想把所有功能都塞进一个界面,结果界面乱得没法用。后来改成左侧服务器列表、中间终端、右侧 AI 对话和模板面板的三栏布局,才顺手起来。
实际用下来,最大的收益不是 AI 帮我生成了多少命令,而是所有操作都有了记录。以前在聊天框里说“我重启一下 nginx”,说完就完了,过两天想查是什么时候重启的、重启前后有什么变化,根本找不到。现在每次执行命令都自动记录,搜索一下就能看到完整的时间线。这个价值比 AI 本身还大。
后续想加的功能有两个:一是操作回放,把一次变更涉及的所有命令按时间顺序串起来,生成一个可回放的操作序列,方便复盘和交接;二是多机协同,把多台服务器的执行结果做对比,比如批量检查磁盘空间,自动标出异常的那台。这两个功能都不难,核心数据模型已经支持了,只是界面还需要打磨。
如果你也想自己搭一个,我的建议是先从连接池和命令记录做起,这两个是基础,做好了就能明显感觉到效率提升。AI 模块可以后面再加,它是个锦上添花的东西,不是必需品。另外,安全确认层一定要有,哪怕只是简单的危险命令黑名单,也能帮你避免很多手滑事故。