Rust AI CLI 工具开发:从零构建一个智能命令行助手

Rust AI CLI 工具开发:从零构建一个智能命令行助手

cover

一、为什么用 Rust 写 AI CLI 工具

我之前用 Python 写过几个 CLI 小工具,调用 OpenAI API 做文本处理。Python 写起来快,但打包分发是个噩梦——用户要么装 Python 环境,要么用 PyInstaller 打包出 50MB 的可执行文件。而且 Python CLI 的启动速度慢,冷启动要几百毫秒,体验很差。

Rust 在 CLI 工具上有天然优势:编译成单文件二进制,无运行时依赖,冷启动在 5ms 以内。对于 AI CLI 工具来说,还有一个关键优势——Rust 的异步运行时(Tokio)在处理大量并发 HTTP 请求时性能远超 Python 的 asyncio。当你需要同时调用多个 AI API 做结果对比时,这个差距会很明显。

当然,代价是开发速度慢。我写第一个版本花了三天,同等功能的 Python 版本半天就搞定了。但编译器逼着你把错误处理做完整,反而让后续迭代更安心。

二、AI CLI 工具的架构设计:请求编排与流式输出

AI CLI 工具的核心流程是:解析命令行参数 → 构建 Prompt → 调用 AI API → 流式输出结果。关键设计决策在于请求编排和流式输出。

flowchart TB
    A[CLI 入口<br/>clap 解析参数] --> B[Prompt 构建<br/>模板 + 上下文]
    B --> C{请求模式}

    C -->|单模型| D[直接调用<br/>单一 AI API]
    C -->|多模型对比| E[并发调用<br/>Tokio::join!]

    D --> F[流式响应处理<br/>SSE 解析]
    E --> F

    F --> G{输出模式}

    G -->|终端流式| H[逐 Token 输出<br/>termcolor 高亮]
    G -->|管道模式| I[完整输出<br/>stdout 管道友好]
    G -->|文件输出| J[写入文件<br/>Markdown 格式]

    subgraph 错误处理
        K[API 限流 → 指数退避重试]
        L[网络超时 → 超时配置]
        M[Token 超限 → 自动截断]
    end

    F --> K
    F --> L
    B --> M

流式输出是 AI CLI 工具体验的关键。用户敲完命令后盯着空白屏幕等 10 秒是不可接受的。SSE(Server-Sent Events)协议让 API 逐 Token 返回,CLI 逐 Token 显示,用户立刻看到输出在"打字"。

三、生产级代码实现:AI CLI 工具核心模块

3.1 CLI 参数解析与 Prompt 构建

use clap::{Parser, ValueEnum};
use std::path::PathBuf;

/// AI 智能命令行助手
#[derive(Parser, Debug)]
#[command(name = "ai-cli", version, about = "AI 驱动的命令行工具")]
struct Cli {
    /// 输入文本或问题
    #[arg(value_name = "PROMPT")]
    prompt: String,

    /// AI 模型选择
    #[arg(short, long, default_value = "gpt-4o-mini")]
    model: String,

    /// 输出模式
    #[arg(short, long, default_value = "stream")]
    output: OutputMode,

    /// 系统提示词文件
    #[arg(short, long)]
    system_prompt: Option<PathBuf>,

    /// 最大 Token 数
    #[arg(short, long, default_value_t = 2048)]
    max_tokens: u32,

    /// 温度参数(0.0-2.0)
    #[arg(short, long, default_value_t = 0.7)]
    temperature: f32,
}

#[derive(Debug, Clone, ValueEnum)]
enum OutputMode {
    /// 流式输出到终端
    Stream,
    /// 完整输出(适合管道)
    Full,
    /// 输出到文件
    File,
}

/// 构建 API 请求体
fn build_request_body(
    prompt: &str,
    model: &str,
    system_prompt: Option<&str>,
    max_tokens: u32,
    temperature: f32,
    stream: bool,
) -> serde_json::Value {
    let mut messages = Vec::new();

    // 系统提示词
    // 为什么把系统提示词放最前面:OpenAI
    // 的 Chat API 按消息顺序处理,
    // 系统提示词在最前面能确保模型
    // 优先遵循指令
    if let Some(sys) = system_prompt {
        messages.push(serde_json::json!({
            "role": "system",
            "content": sys
        }));
    }

    messages.push(serde_json::json!({
        "role": "user",
        "content": prompt
    }));

    serde_json::json!({
        "model": model,
        "messages": messages,
        "max_tokens": max_tokens,
        "temperature": temperature,
        "stream": stream
    })
}

3.2 流式响应处理与重试机制

use reqwest::Client;
use tokio_stream::StreamExt;
use std::time::Duration;

/// AI API 客户端
struct AiClient {
    client: Client,
    api_key: String,
    base_url: String,
    max_retries: u32,
}

impl AiClient {
    fn new(api_key: String, base_url: String) -> Self {
        // 为什么设置超时为 60 秒:AI API 的
        // 首 Token 延迟通常在 1-5 秒,
        // 但长文本生成可能持续 30 秒以上;
        // 60 秒足够覆盖大多数场景
        let client = Client::builder()
            .timeout(Duration::from_secs(60))
            .connect_timeout(Duration::from_secs(10))
            .build()
            .expect("HTTP 客户端创建失败");

        Self {
            client,
            api_key,
            base_url,
            max_retries: 3,
        }
    }

    /// 流式调用 AI API
    async fn stream_chat(
        &self,
        body: &serde_json::Value,
    ) -> Result<(), Box<dyn std::error::Error>> {
        let mut retry_count = 0;

        loop {
            match self.try_stream(body).await {
                Ok(()) => return Ok(()),
                Err(e) => {
                    // 只对可重试错误进行重试
                    // 为什么区分可重试错误:429 限流
                    // 和 5xx 服务端错误可以重试,
                    // 4xx 客户端错误重试无意义
                    if !self.is_retryable(&e) {
                        return Err(e);
                    }

                    retry_count += 1;
                    if retry_count > self.max_retries {
                        return Err(e);
                    }

                    // 指数退避:1s, 2s, 4s
                    // 为什么用指数退避而非固定间隔:
                    // 限流通常是滑动窗口策略,
                    // 固定间隔可能持续撞窗口;
                    // 指数退避给服务端恢复时间
                    let delay = Duration::from_secs(
                        2_u64.pow(retry_count - 1));
                    eprintln!(
                        "请求失败,{}秒后重试({}/{}): {}",
                        delay.as_secs(),
                        retry_count,
                        self.max_retries,
                        e
                    );
                    tokio::time::sleep(delay).await;
                }
            }
        }
    }

    /// 尝试流式请求
    async fn try_stream(
        &self,
        body: &serde_json::Value,
    ) -> Result<(), Box<dyn std::error::Error>> {
        let response = self.client
            .post(format!("{}/chat/completions",
                         self.base_url))
            .header("Authorization",
                    format!("Bearer {}", self.api_key))
            .header("Content-Type", "application/json")
            .json(body)
            .send()
            .await?;

        let status = response.status();
        if !status.is_success() {
            let error_text = response.text().await?;
            return Err(format!(
                "API 错误 {}: {}", status, error_text
            ).into());
        }

        // 解析 SSE 流
        let mut stream = response.bytes_stream();
        while let Some(chunk) = stream.next().await {
            let chunk = chunk?;
            let text = String::from_utf8_lossy(&chunk);

            for line in text.lines() {
                if let Some(data) = line.strip_prefix("data: ") {
                    if data == "[DONE]" {
                        println!(); // 换行
                        return Ok(());
                    }

                    if let Ok(parsed) =
                        serde_json::from_str::<serde_json::Value>(data)
                    {
                        if let Some(content) = parsed
                            .get("choices")
                            .and_then(|c| c.get(0))
                            .and_then(|c| c.get("delta"))
                            .and_then(|d| d.get("content"))
                            .and_then(|c| c.as_str())
                        {
                            print!("{}", content);
                            // 为什么用 flush:stdout
                            // 默认是行缓冲,不 flush
                            // 的话 Token 不会立即显示
                            use std::io::Write;
                            std::io::stdout().flush()?;
                        }
                    }
                }
            }
        }

        Ok(())
    }

    /// 判断错误是否可重试
    fn is_retryable(
        &self,
        error: &Box<dyn std::error::Error>,
    ) -> bool {
        let msg = error.to_string();
        // 429 限流、5xx 服务端错误、网络超时
        msg.contains("429")
            || msg.contains("500")
            || msg.contains("502")
            || msg.contains("503")
            || msg.contains("timeout")
    }
}

3.3 main 函数与运行时初始化

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let cli = Cli::parse();

    // 从环境变量读取 API Key
    // 为什么用环境变量而非命令行参数:
    // API Key 是敏感信息,命令行参数
    // 会出现在进程列表和 Shell 历史中,
    // 环境变量更安全
    let api_key = std::env::var("OPENAI_API_KEY")
        .map_err(|_| "请设置 OPENAI_API_KEY 环境变量")?;

    let base_url = std::env::var("OPENAI_BASE_URL")
        .unwrap_or_else(|_| "https://api.openai.com/v1"
            .to_string());

    // 读取系统提示词
    let system_prompt = cli.system_prompt
        .as_ref()
        .map(|path| std::fs::read_to_string(path))
        .transpose()?
        .map(|s| s.trim().to_string());

    // 构建请求体
    let stream = matches!(cli.output, OutputMode::Stream);
    let body = build_request_body(
        &cli.prompt,
        &cli.model,
        system_prompt.as_deref(),
        cli.max_tokens,
        cli.temperature,
        stream,
    );

    // 发起请求
    let client = AiClient::new(api_key, base_url);
    client.stream_chat(&body).await?;

    Ok(())
}

四、Rust AI CLI 工具的边界:什么时候不该用 Rust

快速原型阶段:如果你只是想验证一个 AI 工具的想法,Python + Click 三十分钟就能跑起来。Rust 的编译时间和类型系统在原型阶段是负担,不是保护。

重度依赖 AI 生态:LangChain、LlamaIndex 这些框架只有 Python 和 TypeScript 版本。如果你的工具需要复杂的 Agent 编排、RAG 管线,用 Rust 需要从头实现这些组件,投入产出比很低。

团队 Rust 经验不足:Rust 的学习曲线陡峭,如果团队里没人写过 Rust,用 Rust 写 AI 工具的维护成本会很高。一个编译错误卡两小时是常态——至少我刚开始学的时候是这样。

适合用 Rust 的场景:需要分发的单文件工具、对启动速度敏感的场景、需要高并发 API 调用的场景、需要系统级能力(文件监控、进程管理)的场景。

五、总结

用 Rust 开发 AI CLI 工具的核心收益是分发简单(单文件二进制)和性能好(冷启动快、并发强)。核心成本是开发速度慢和 AI 生态弱。我的建议是:如果工具需要分发给其他人用,Rust 值得投入;如果只是自己用,Python 更快。流式输出是体验的关键,SSE 解析和逐 Token 打印的实现并不复杂,但 flush 和错误处理容易踩坑。重试机制用指数退避,API Key 用环境变量,这两点是安全性和可靠性的底线。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值