AI 驱动的 CLI 工具开发:用 Rust 构建智能命令行 Agent

AI 驱动的 CLI 工具开发:用 Rust 构建智能命令行 Agent

cover

一、命令行工具不该只是"命令的集合",它应该理解你的意图

我之前用 Python 写过不少 CLI 工具,argparse 一套、subcommand 一堆,最后变成一个"命令字典"——你得记住每个子命令的参数和顺序。后来接触了 Rust 的 clap 和大模型 API,突然想到:如果 CLI 工具能理解自然语言呢?不用记命令,直接说"帮我找出最近 7 天修改过的 .rs 文件",工具自己解析意图、选择子命令、填充参数。

这不是科幻。Rust 的强类型系统和错误处理让 CLI 工具的骨架很稳,加上大模型的自然语言理解能力,就能构建一个"听得懂人话"的命令行 Agent。这篇文章记录我从零开始踩坑的全过程。

二、智能 CLI Agent 的架构设计

flowchart TB
    A[用户输入<br/>自然语言或命令] --> B[输入解析层]
    B --> B1[传统解析<br/>clap 子命令]
    B --> B2[NLU 解析<br/>大模型意图识别]

    B1 --> C[意图路由]
    B2 --> C

    C --> C2[文件搜索意图]
    C --> C3[代码分析意图]
    C --> C4[系统操作意图]
    C --> C5[未知意图<br/>回退到大模型]

    C2 --> D[工具执行层]
    C3 --> D
    C4 --> D
    C5 --> D

    D --> D1[文件系统操作<br/>walkdir + regex]
    D --> D2[代码解析<br/>tree-sitter]
    D --> D3[Shell 命令<br/>安全沙箱执行]

    D1 --> E[结果格式化<br/>rich 终端输出]
    D2 --> E
    D3 --> E

    E --> F[反馈学习<br/>记录用户修正]

    style B2 fill:#e3f2fd
    style C5 fill:#fff3e0
    style F fill:#e8f5e9

智能 CLI Agent 的三层架构:输入解析层(同时支持传统命令和自然语言)、意图路由层(将解析结果映射到具体工具)、工具执行层(安全地执行操作并格式化输出)。关键设计是"双通道输入"——既保留传统 CLI 的精确性,又支持自然语言的便利性。

三、代码实现与分析

3.1 项目骨架与依赖

# Cargo.toml
[package]
name = "ai-cli-agent"
version = "0.1.0"
edition = "2021"

[dependencies]
clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
walkdir = "2"
regex = "1"
colored = "2"
indicatif = "0.17"
anyhow = "1"

3.2 双通道输入解析

use clap::{Parser, Subcommand};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;

/// AI 驱动的智能命令行工具
#[derive(Parser, Debug)]
#[command(name = "ai", about = "智能命令行 Agent")]
struct Cli {
    #[command(subcommand)]
    command: Option<Commands>,

    /// 自然语言输入(当不使用子命令时)
    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
    natural_input: Vec<String>,
}

#[derive(Subcommand, Debug)]
enum Commands {
    /// 搜索文件
    Search {
        /// 搜索模式(支持正则)
        pattern: String,
        /// 搜索目录
        #[arg(short, long, default_value = ".")]
        path: PathBuf,
        /// 文件类型过滤
        #[arg(short, long)]
        file_type: Option<String>,
    },
    /// 分析代码结构
    Analyze {
        /// 目标路径
        path: PathBuf,
    },
    /// 查看系统信息
    Info,
}

/// 大模型返回的意图解析结果
#[derive(Debug, Serialize, Deserialize)]
struct ParsedIntent {
    intent: String,
    parameters: serde_json::Value,
    confidence: f64,
}

/// NLU 解析器:调用大模型解析自然语言
struct NluParser {
    client: reqwest::Client,
    api_url: String,
    api_key: String,
}

impl NluParser {
    fn new(api_key: String) -> Self {
        Self {
            client: reqwest::Client::new(),
            api_url: "https://api.openai.com/v1/chat/completions".to_string(),
            api_key,
        }
    }

    async fn parse(&self, input: &str) -> anyhow::Result<ParsedIntent> {
        let system_prompt = r#"
你是一个命令行工具的意图解析器。将用户的自然语言输入解析为结构化意图。

支持的意图:
- search: 搜索文件,参数 { pattern, path, file_type }
- analyze: 分析代码结构,参数 { path }
- info: 查看系统信息,参数 {}
- unknown: 无法识别的意图

返回 JSON 格式:{ "intent": "...", "parameters": {...}, "confidence": 0.0-1.0 }
"#;

        let body = serde_json::json!({
            "model": "gpt-4o-mini",
            "messages": [
                { "role": "system", "content": system_prompt },
                { "role": "user", "content": input }
            ],
            "temperature": 0.1,
            "max_tokens": 256,
        });

        let response = self.client
            .post(&self.api_url)
            .header("Authorization", format!("Bearer {}", self.api_key))
            .json(&body)
            .send()
            .await?;

        let resp_json: serde_json::Value = response.json().await?;
        let content = resp_json["choices"][0]["message"]["content"]
            .as_str()
            .unwrap_or("{}");

        let parsed: ParsedIntent = serde_json::from_str(content)
            .unwrap_or(ParsedIntent {
                intent: "unknown".to_string(),
                parameters: serde_json::Value::Null,
                confidence: 0.0,
            });

        Ok(parsed)
    }
}

3.3 工具执行层

use colored::*;
use walkdir::WalkDir;
use regex::Regex;
use std::time::Instant;

/// 文件搜索工具
struct FileSearcher;

impl FileSearcher {
    /// 按模式搜索文件名
    fn search_by_name(
        pattern: &str,
        root: &PathBuf,
        file_type: Option<&str>,
    ) -> Vec<PathBuf> {
        let regex = Regex::new(pattern).unwrap_or_else(|_| {
            Regex::new(&regex::escape(pattern)).unwrap()
        });

        let start = Instant::now();
        let mut results = Vec::new();

        for entry in WalkDir::new(root)
            .follow_links(false)
            .into_iter()
            .filter_map(|e| e.ok())
        {
            let path = entry.path();

            // 文件类型过滤
            if let Some(ft) = file_type {
                if let Some(ext) = path.extension() {
                    if ext != ft {
                        continue;
                    }
                } else {
                    continue;
                }
            }

            // 文件名匹配
            if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
                if regex.is_match(name) {
                    results.push(path.to_path_buf());
                }
            }
        }

        let elapsed = start.elapsed();
        println!(
            "{} 找到 {} 个文件(耗时 {:?})",
            "✓".green(),
            results.len().to_string().yellow(),
            elapsed,
        );

        results
    }
}

/// 主入口
#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let cli = Cli::parse();

    // 双通道路由:优先处理子命令,否则走 NLU
    match cli.command {
        Some(Commands::Search { pattern, path, file_type }) => {
            let results = FileSearcher::search_by_name(
                &pattern, &path, file_type.as_deref(),
            );
            for path in &results {
                println!("  {}", path.display());
            }
        }
        Some(Commands::Analyze { path }) => {
            println!("{} 分析 {}", "→".blue(), path.display());
            // 代码分析逻辑
        }
        Some(Commands::Info) => {
            println!("{} 系统信息", "→".blue());
            println!("  OS: {}", std::env::consts::OS);
            println!("  Arch: {}", std::env::consts::ARCH);
        }
        None if !cli.natural_input.is_empty() => {
            // 自然语言通道
            let input = cli.natural_input.join(" ");
            println!("{} 理解中: {}", "🤖".to_string(), input.cyan());

            let api_key = std::env::var("OPENAI_API_KEY")
                .map_err(|_| anyhow::anyhow!("请设置 OPENAI_API_KEY 环境变量"))?;

            let parser = NluParser::new(api_key);
            let intent = parser.parse(&input).await?;

            println!(
                "  意图: {} (置信度: {:.0%})",
                intent.intent.green(),
                intent.confidence,
            );

            match intent.intent.as_str() {
                "search" => {
                    let pattern = intent.parameters["pattern"]
                        .as_str().unwrap_or("*");
                    let path = intent.parameters["path"]
                        .as_str()
                        .map(PathBuf::from)
                        .unwrap_or(PathBuf::from("."));
                    let file_type = intent.parameters["file_type"]
                        .as_str();
                    let results = FileSearcher::search_by_name(
                        pattern, &path, file_type,
                    );
                    for p in &results {
                        println!("  {}", p.display());
                    }
                }
                "unknown" => {
                    println!(
                        "{} 抱歉,我没理解你的意思。试试: ai search <pattern>",
                        "✗".red()
                    );
                }
                _ => {
                    println!("{} 暂不支持该意图", "✗".red());
                }
            }
        }
        None => {
            println!("{} 使用 ai <命令> 或 ai <自然语言>", "提示:".yellow());
        }
    }

    Ok(())
}

四、踩坑记录与架构权衡

NLU 延迟问题:大模型 API 调用通常需要 1-3 秒,对 CLI 工具来说太慢了。我的解决方案是:高频命令(search、info)直接用 clap 解析不走 NLU,只有"说不清楚"的输入才走 NLU。另外,可以缓存相似输入的解析结果,减少 API 调用。

安全沙箱的必要性:如果 NLU 解析出"删除所有 .tmp 文件"这样的意图,直接执行太危险。我的做法是:所有写操作(删除、移动、修改)都需要用户确认,且限制可操作的目录范围。Rust 的类型系统在这里帮了大忙——ReadOnlyActionWriteAction 是不同的 trait,编译期就能区分。

离线可用性:NLU 依赖大模型 API,断网就废了。我的折中方案是:离线时只支持传统命令模式,NLU 通道自动禁用并给出提示。长期方案是用本地小模型(如 GGUF 量化的 Llama 3)做 NLU,但 4GB 模型的推理速度在 CPU 上还是太慢。

成本控制:每次 NLU 调用消耗约 200 tokens,按 GPT-4o-mini 的价格约 $0.00003/次。个人用没问题,但如果做成团队工具,每天几百次调用就需要考虑成本。可以用更小的模型(如 GPT-4o-mini)做意图分类,只在需要时才调用大模型。

五、总结

用 Rust 构建智能 CLI Agent 的核心思路是"双通道输入":保留传统 CLI 的精确性,增加 NLU 的便利性。本文的关键实践为:用 clap 处理结构化命令、用大模型 API 解析自然语言意图、用 Rust 类型系统区分读写操作保证安全、用缓存和降级策略控制延迟和成本。这不是一个"成熟方案",而是一个学习过程中的记录——NLU 的延迟和安全沙箱是当前最大的挑战,后续会尝试本地模型和更严格的权限控制。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值