用 Rust 写个 AI 运维助手:从环境配置到 GLM-4 接入

0 阅读

为什么选 Rust 写 AI 助手

在 AI Agent 和自动化脚本泛滥的今天,大多数工具还是 Python 写的。Python 确实快,但在需要长期运行、高并发或者对内存敏感的场景下,Rust 的优势就出来了。它没有垃圾回收的停顿,二进制文件体积小,部署起来也方便——扔到一个 Linux 服务器上就能跑,不用配一堆虚拟环境。

这个项目叫 rust-shell-assistant,核心逻辑很简单:用户输入自然语言,AI 把它翻译成 Shell 命令,用户确认后执行。听起来不复杂,但要把这件事做得稳定、安全,还得处理好网络请求、异步运行时、系统调用这些底层细节,Rust 的类型系统和所有权机制能帮上大忙。

这里用的大模型服务来自蓝耘(Lanyun)的 MAAS 平台,接的是智谱的 GLM-4.7。选它主要是因为中文指令遵循能力不错,而且接口兼容 OpenAI 标准,改个 URL 就能用,省去了适配不同厂商 SDK 的麻烦。

先把开发环境搭好

在 Linux(以 Debian/Ubuntu 为例)上搞 Rust 开发,第一步不是写代码,而是把编译链装全。Rust 编译器 rustc 最后生成二进制文件时,得调用系统的链接器。如果项目里用了涉及网络加密的库(比如 reqwest 默认带的 TLS 支持),那就离不开 OpenSSL。

安装系统依赖

很多新手直接装 rustup,结果编译时报错说找不到 openssl 或 pkg-config。这是因为 Rust 的 openssl-sys crate 是通过 FFI(外部函数接口)调用 C 语言版的 OpenSSL 库的,所以系统里得有头文件和静态库。

先更新包列表,然后装上构建 essentials 和 OpenSSL 开发包:

sudo apt update
sudo apt install curl build-essential pkg-config libssl-dev

build-essential 里包含了 GCC、Make 和 libc 开发库,这是编译任何非纯 Rust 项目的基石。libssl-dev 则提供了 OpenSSL 的头文件,缺了它,cargo build 会在链接阶段直接挂掉。

安装 Rust 工具链

官方推荐的安装方式是用 rustup:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh

这个脚本会自动检测你的 CPU 架构(x86_64 或 aarch64),下载对应的预编译二进制。装完后,它会把 $HOME/.cargo/bin 加到 PATH 里。为了让当前终端立即生效,执行:

. "$HOME/.cargo/env"

顺便把这行加到 ~/.bashrc 或 ~/.zshrc 里,不然下次登录还得手动 source。

验证一下:

rustc --version
cargo --version

能看到版本号就说明环境没问题了。

接入蓝耘 MAAS 平台

这个助手的“脑子”是 GLM-4.7。蓝耘的 MAAS 平台提供兼容 OpenAI 格式的 API,这意味着我们不需要引入复杂的专用 SDK,直接用通用的 HTTP 客户端发 JSON 请求就行。

  1. 注册与获取 Key:去蓝耘控制台注册账号,创建一个 API Key。这串字符是你的身份凭证,也是计费的依据,千万别硬编码在代码里提交到 Git。
  2. 确认端点:GLM-4.7 的聊天补全接口地址通常是 https://maas-api.lanyun.net/v1/chat/completions。模型路径填 /maas/zhipuai/GLM-4.7。

这些信息后面都要写到环境变量里。

项目结构与依赖管理

用 Cargo 初始化项目:

cargo new rust-shell-assistant
cd rust-shell-assistant

关键依赖解析

Cargo.toml 里的依赖选型直接决定了项目的健壮性。以下是几个核心 crate 的作用:

  • tokio: Rust 异步生态的事实标准。我们要发 HTTP 请求、处理用户输入,这些都可能是阻塞操作,用 tokio 的 full 特性可以拿到完整的异步运行时支持。
  • reqwest: 基于 tokio 的高级 HTTP 客户端。开启 json 特性后,它能自动把 Rust 结构体序列化成 JSON 发送,也能把响应反序列化回来,省去了手动拼字符串的麻烦。
  • serde / serde_json: 序列化框架。通过 derive 宏,给结构体加上 #[derive(Serialize, Deserialize)],JSON 转换就全自动了。
  • clap: 命令行参数解析。用 derive 模式定义结构体,它自动生成帮助信息、参数校验,比手写 args 解析优雅得多。
  • rustyline: 实现类似 Bash 的交互式输入体验,支持历史记录、光标移动。没有它,用户输错一个字得重打整行,体验极差。
  • colored: 给终端输出加点颜色,区分用户输入、AI 回复和系统提示,视觉上更清晰。
  • dotenv: 从 .env 文件加载环境变量,符合 12-Factor App 原则,方便本地调试和部署隔离。
  • anyhow / thiserror: 错误处理组合拳。anyhow 用于应用层快速传播错误,thiserror 用于定义具体的错误类型。

Cargo.toml 大致长这样:

[dependencies]
tokio = { version = "1.35", features = ["full"] }
reqwest = { version = "0.11", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
clap = { version = "4.4", features = ["derive"] }
colored = "2.1"
rustyline = "13.0"
anyhow = "1.0"
dotenv = "0.15"
log = "0.4"
env_logger = "0.11"

核心模块实现

项目拆成几个模块:ai_client 负责跟大模型说话,shell_executor 负责执行命令,config 管配置,main 串联流程。

AI 客户端:封装 HTTP 请求

ai_client.rs 的核心是构造符合 OpenAI 格式的 JSON 请求。我们定义 ChatRequest 和 ChatResponse 结构体,映射 API 的字段。

关键点在于 Prompt 的设计。为了让 AI 只返回命令,不加废话,System Prompt 必须写得足够强硬:

let system_prompt = r#"你是一个专业的 Linux Shell 命令助手。
你的任务是将用户的自然语言描述转换为准确的 Shell 命令。

规则:
1. 只返回命令本身,不要有任何解释或额外文字
2. 如果需要多个命令,用 && 连接
3. 确保命令安全且符合最佳实践
4. 优先使用常见的 Linux 命令
5. 不要使用危险的命令(如 rm -rf / 等)
"#;

然后用 reqwest 发起 POST 请求。注意设置超时时间,避免网络卡顿时程序假死。

pub async fn chat(&self, messages: Vec<Message>) -> Result<String> {
    let request_body = ChatRequest {
        model: self.config.model.clone(),
        messages,
        max_tokens: self.config.max_tokens,
        temperature: self.config.temperature,
    };

    let mut request = self.client.post(&self.config.api_url).json(&request_body);

    if let Some(api_key) = &self.config.api_key {
        request = request.bearer_auth(api_key);
    }

    let response = request.send().await.context("发送 API 请求失败")?;

    if !response.status().is_success() {
        let status = response.status();
        let error_text = response.text().await.unwrap_or_default();
        anyhow::bail!("API 请求失败: {} - {}", status, error_text);
    }

    let chat_response: ChatResponse = response.json().await.context("解析 API 响应失败")?;
    
    chat_response.choices.first()
        .map(|choice| choice.message.content.clone())
        .context("API 响应中没有内容")
}

Shell 执行器:安全第一

拿到 AI 返回的命令字符串后,不能直接扔给 system()。shell_executor.rs 做了两件事:安全检查和中转执行。

首先是黑名单过滤。虽然 AI 被要求不生成危险命令,但模型可能会幻觉,或者被恶意 Prompt 注入。所以在执行前,代码里硬编码了一些高危模式:

fn is_dangerous_command(&self, command: &str) -> bool {
    let dangerous_patterns = vec![
        "rm -rf /",
        "rm -rf /*",
        "mkfs",
        "dd if=/dev/zero",
        "> /dev/sda",
        ":(){ :|:& };:", // Fork bomb
        "chmod -R 777 /",
    ];
    dangerous_patterns.iter().any(|pattern| command.contains(pattern))
}

如果命中任何一条,直接拒绝执行并报错。

其次是跨平台执行。Linux/macOS 用 sh -c,Windows 用 cmd /C。这里用 std::process::Command 捕获 stdout 和 stderr,而不是直接打印,这样主程序可以对结果进行格式化展示。

pub fn execute(&self, command: &str) -> Result<CommandResult> {
    if self.is_dangerous_command(command) {
        anyhow::bail!("检测到危险命令,拒绝执行: {}", command);
    }

    let output = if cfg!(target_os = "windows") {
        Command::new("cmd").args(["/C", command]).output()
    } else {
        Command::new("sh").arg("-c").arg(command).output()
    }.context("执行命令失败")?;

    Ok(CommandResult::from_output(output))
}

配置管理:环境变量优先

config.rs 采用分层策略。优先读环境变量,没有则用默认值。这样在本地开发时改 .env 文件即可,部署到服务器时直接注入环境变量,无需改代码。

pub fn from_env() -> Result<Self> {
    dotenv::dotenv().ok(); // 忽略加载失败,允许无 .env 文件

    let api_url = env::var("AI_API_URL")
        .unwrap_or_else(|_| "https://maas-api.lanyun.net/v1/chat/completions".to_string());
    
    // ... 其他字段类似
    
    Ok(Config { api_url, model, api_key, max_tokens, temperature, timeout_seconds })
}

主程序:REPL 循环

main.rs 是入口。用 clap 解析参数,支持两种模式:

  1. 单次查询:rust-shell-assistant --query "列出文件",适合脚本调用。
  2. 交互模式:不带参数启动,进入 REPL 循环。

交互模式里,用 rustyline 读取用户输入。除了普通查询,还加了几个特殊指令:

  • exit/quit: 退出。
  • help: 显示帮助。
  • history: 查看历史命令。
  • explain <command>: 让 AI 解释某条命令的意思,适合学习。

主循环逻辑大致如下:

loop {
    let readline = rl.readline(">> ");
    match readline {
        Ok(line) => {
            let line = line.trim();
            if line.is_empty() { continue; }
            if matches!(line, "exit" | "quit") { break; }
            
            // 处理特殊指令...
            
            // 普通查询:AI 生成命令 -> 用户确认 -> 执行
            handle_query(&ai_client, &shell_executor, line, &mut history).await?;
        }
        Err(ReadlineError::Interrupted) => continue,
        Err(ReadlineError::Eof) => break,
        Err(err) => { eprintln!("Error: {:?}", err); break; }
    }
}

踩坑记录:编译与链接

开发过程中遇到两个典型问题,记录下来供参考。

1. OpenSSL 链接错误

初次编译时,报错 could not find openssl via pkg-config。这是因为 reqwest 默认启用 native-tls,它在 Linux 上依赖系统的 OpenSSL。解决方法前面提过:安装 pkg-config 和 libssl-dev。

如果不想装系统库,也可以在 Cargo.toml 里给 reqwest 加上 default-features = false 并启用 rustls-tls 特性,这样就用纯 Rust 实现的 TLS 库,不依赖系统 OpenSSL。但对于运维工具来说,依赖系统库通常更稳妥,因为系统库会随安全补丁更新。

2. Trait 约束与类型推导

在使用 colored 库时,遇到 the trait bound &String: Colorize is not satisfied 的错误。colored 的扩展方法主要实现在 &str 上。虽然 Rust 有 Deref Coercion,但在某些泛型上下文或引用层级较多时,自动推导会失效。

解决办法是显式调用 .as_str() 或者确保引入 use colored::Colorize; 并在正确的类型上调用。另外,对于结构体中暂时未使用的字段(如 exit_code),加上 #[allow(dead_code)] 属性可以消除编译警告,保持输出干净。

实战演示

编译发布版:

cargo build --release

生成的二进制在 target/release/rust-shell-assistant。可以把它复制到 /usr/local/bin 方便全局调用。

交互模式

运行程序,输入:“查看当前目录大文件”。

AI 返回:du -ah . | sort -rh | head -n 10

用户输入 y 确认,程序执行并展示结果。整个过程不到两秒,响应速度取决于网络和模型推理速度。

解释模式

输入:“explain chmod 777”。

AI 返回:“该命令将文件权限设置为所有用户可读、可写、可执行。这通常是不安全的,建议仅用于测试环境。”

这种即时反馈对新手运维很有帮助。

成本监控

每次调用都会消耗 Token。在蓝耘控制台可以看到详细的用量统计。对于个人开发者来说,GLM-4 的性价比很高,日常运维查询的成本几乎可以忽略不计。

总结

用 Rust 写 AI 助手,最大的感受是“稳”。类型系统逼你在编译期就把错误处理想清楚,异步运行时让并发变得简单,二进制部署免去了环境配置的痛苦。虽然前期上手曲线比 Python 陡,但一旦跑通,维护成本很低。

这个工具目前还比较基础,未来可以加入更多功能:比如支持多轮对话上下文、集成更多系统监控指标、甚至通过 Function Calling 直接调用预设的工具函数,而不仅仅是生成 Shell 字符串。对于运维工程师来说,这样一个贴身的小助手,确实能省下不少查文档的时间。