当命令行遇上AI:用Rust打造你的第一个智能Agent工具

cover

一、从重复操作到智能协作:CLI工具的AI进化之路

做后端开发这些年,我一直在和命令行打交道。部署、调试、日志分析,每天重复着相似的命令序列。直到有一天,我发现自己花在"敲命令"上的时间,比真正思考问题的时间还多。

传统的CLI工具是"你说我做"模式。你输入指令,它执行,没有上下文,没有记忆。但AI Agent改变了这个等式。一个智能Agent能理解你的意图,维护对话状态,甚至主动建议下一步操作。

从后端转Rust的过程中,我一直在想:能不能用Rust的性能优势,结合大语言模型的能力,做一个真正好用的智能命令行Agent?这篇文章就是我这段时间探索的记录。踩了不少坑,也有几个"啊哈时刻",希望能帮到同样在这条路上的朋友。

二、智能Agent的架构:不只是"调API"

很多人觉得AI CLI工具就是"把用户输入丢给API,拿到结果打印出来"。这种理解太浅了。一个真正可用的智能Agent,核心在于状态管理工具调用编排

先看整体架构:

graph TB
    A[用户输入] --> B[意图解析层]
    B --> C{意图分类}
    C -->|命令执行| D[Shell工具]
    C -->|文件操作| E[文件系统工具]
    C -->|代码分析| F[AST解析工具]
    C -->|知识问答| G[LLM直接回答]
    D --> H[结果聚合]
    E --> H
    F --> H
    G --> H
    H --> I[上下文管理器]
    I --> J[响应生成]
    J --> K[终端输出]
    I -.->|历史状态| B

关键设计点有三个:

第一,工具注册与发现机制。 Agent需要知道自己能做什么。每个工具是一个独立的struct,实现统一的Tooltrait。Agent启动时扫描注册表,把工具描述注入到LLM的prompt中。

第二,对话状态持久化。 用户可能在多轮对话中逐步构建一个复杂操作。Agent需要记住"之前做了什么"、"当前工作目录在哪"、"哪些文件已经被修改"。

第三,安全边界。 AI可以建议命令,但危险操作必须经过确认。这不是可选项,是必须项。

三、从零实现一个可运行的智能Agent

下面是核心代码,我保证每一段都能跑。先定义工具trait和Agent主体:

use anyhow::{Result, Context};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;

/// 工具定义:每个Agent可调用的能力都实现这个trait
/// 为什么用trait而不是enum?因为工具会不断扩展,
/// trait让新增工具不需要修改核心调度逻辑
#[async_trait::async_trait]
pub trait Tool: Send + Sync {
    /// 工具名称,LLM通过这个名字调用
    fn name(&self) -> &str;
    /// 工具描述,直接注入prompt,告诉LLM何时使用
    fn description(&self) -> &str;
    /// 参数的JSON Schema,约束LLM的输入格式
    fn parameters_schema(&self) -> serde_json::Value;
    /// 执行工具逻辑
    async fn execute(&self, args: serde_json::Value) -> Result<String>;
}

/// 对话消息,用于维护上下文
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Message {
    pub role: String,
    pub content: String,
    /// 如果这轮调用了工具,记录工具名和结果
    pub tool_call: Option<ToolCallRecord>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCallRecord {
    pub tool_name: String,
    pub arguments: serde_json::Value,
    pub result: String,
}

/// Agent主体:管理对话状态和工具调度
pub struct Agent {
    /// 对话历史,用于多轮上下文
    history: Vec<Message>,
    /// 已注册的工具表
    tools: HashMap<String, Box<dyn Tool>>,
    /// 最大上下文轮数,防止token爆炸
    max_history: usize,
}

impl Agent {
    pub fn new(max_history: usize) -> Self {
        Self {
            history: Vec::new(),
            tools: HashMap::new(),
            max_history,
        }
    }

    /// 注册工具:新增能力不需要改Agent代码
    pub fn register_tool(&mut self, tool: Box<dyn Tool>) {
        self.tools.insert(tool.name().to_string(), tool);
    }

    /// 生成系统提示词,包含所有工具描述
    /// 这是Agent能"知道"自己能做什么的关键
    fn build_system_prompt(&self) -> String {
        let tool_descriptions: Vec<String> = self.tools.values().map(|t| {
            format!(
                "- {}: {}\n  参数: {}",
                t.name(),
                t.description(),
                t.parameters_schema()
            )
        }).collect();

        format!(
            "你是一个智能命令行助手。你可以使用以下工具:\n{}\n\
            当需要执行操作时,请用JSON格式回复:\n\
            {{\"tool\": \"工具名\", \"args\": {{...}}}}\n\
            如果直接回答,就用普通文本回复。",
            tool_descriptions.join("\n")
        )
    }

    /// 处理用户输入的核心循环
    pub async fn handle_input(&mut self, user_input: &str) -> Result<String> {
        // 记录用户消息
        self.history.push(Message {
            role: "user".into(),
            content: user_input.into(),
            tool_call: None,
        });

        // 裁剪历史,保留最近N轮,避免token超限
        if self.history.len() > self.max_history * 2 {
            let drain_count = self.history.len() - self.max_history * 2;
            self.history.drain(0..drain_count);
        }

        // 构建完整prompt并发送给LLM
        let system_prompt = self.build_system_prompt();
        let response = self.call_llm(&system_prompt).await
            .context("LLM调用失败,请检查网络和API配置")?;

        // 尝试解析为工具调用
        if let Ok(tool_call) = serde_json::from_str::<serde_json::Value>(&response) {
            if let Some(tool_name) = tool_call.get("tool").and_then(|v| v.as_str()) {
                let args = tool_call.get("args")
                    .cloned()
                    .unwrap_or(serde_json::json!({}));

                return self.execute_tool(tool_name, args).await;
            }
        }

        // 普通文本回复
        self.history.push(Message {
            role: "assistant".into(),
            content: response.clone(),
            tool_call: None,
        });

        Ok(response)
    }

    /// 执行工具并记录结果
    async fn execute_tool(&mut self, name: &str, args: serde_json::Value) -> Result<String> {
        let tool = self.tools.get(name)
            .ok_or_else(|| anyhow::anyhow!("未知工具: {}", name))?;

        let result = tool.execute(args).await
            .unwrap_or_else(|e| format!("工具执行失败: {}", e));

        self.history.push(Message {
            role: "assistant".into(),
            content: format!("调用了工具 {}", name),
            tool_call: Some(ToolCallRecord {
                tool_name: name.into(),
                arguments: args,
                result: result.clone(),
            }),
        });

        Ok(result)
    }

    /// 调用LLM的抽象方法(实际实现对接具体API)
    async fn call_llm(&self, system_prompt: &str) -> Result<String> {
        // 这里对接OpenAI / 本地模型等
        // 示例中省略HTTP调用细节
        todo!("对接你选择的LLM API")
    }
}

接下来实现一个具体的工具——文件读取:

/// 文件读取工具:让Agent能查看项目文件
pub struct FileReadTool;

#[async_trait::async_trait]
impl Tool for FileReadTool {
    fn name(&self) -> &str { "file_read" }

    fn description(&self) -> &str {
        "读取指定路径的文件内容,用于查看代码或配置"
    }

    fn parameters_schema(&self) -> serde_json::Value {
        serde_json::json!({
            "type": "object",
            "properties": {
                "path": {
                    "type": "string",
                    "description": "文件路径"
                }
            },
            "required": ["path"]
        })
    }

    async fn execute(&self, args: serde_json::Value) -> Result<String> {
        let path = args.get("path")
            .and_then(|v| v.as_str())
            .ok_or_else(|| anyhow::anyhow!("缺少path参数"))?;

        // 安全检查:禁止读取敏感路径
        // 为什么?因为LLM可能被prompt注入诱导读取/etc/passwd
        let forbidden = ["/etc/passwd", "/etc/shadow", ".env"];
        if forbidden.iter().any(|f| path.contains(f)) {
            return Ok(format!("安全限制:禁止读取 {}", path));
        }

        let content = tokio::fs::read_to_string(path).await
            .with_context(|| format!("无法读取文件: {}", path))?;

        // 截断过长的文件,避免token浪费
        let max_chars = 8000;
        if content.len() > max_chars {
            Ok(format!("{}...(文件过长,已截断)", &content[..max_chars]))
        } else {
            Ok(content)
        }
    }
}

使用方式:

#[tokio::main]
async fn main() -> Result<()> {
    let mut agent = Agent::new(10); // 保留最近10轮对话
    agent.register_tool(Box::new(FileReadTool));

    let response = agent.handle_input("帮我看看src/main.rs的内容").await?;
    println!("{}", response);
    Ok(())
}

四、架构权衡:没有银弹的设计选择

在实现过程中,我遇到了几个关键的权衡点,这里坦诚分享。

同步 vs 异步工具执行。 我选择了全异步。原因是文件IO和网络调用在Agent中太常见了,同步会阻塞整个事件循环。但代价是代码复杂度上升,async_trait的引入也增加了编译时间。如果你的工具都是纯CPU计算,同步可能更简单。

上下文窗口管理。 这是最头疼的问题。对话历史太长,token消耗爆炸;太短,Agent丢失关键上下文。我目前的策略是滑动窗口+摘要。滑动窗口保证最近N轮完整,摘要压缩更早的对话。但摘要本身也需要LLM调用,增加了延迟和成本。

工具调用的可靠性。 LLM并不总是能正确生成JSON格式的工具调用。我加了重试机制:解析失败时,把错误信息回传给LLM,让它修正。实测3次重试能覆盖95%以上的情况。剩下的5%,降级为普通文本回复。

安全边界的粒度。 一开始我做了白名单(只允许读取特定目录),结果太严格,什么也干不了。后来改成黑名单+确认机制:危险操作弹确认,其他放行。这是可用性和安全性的平衡。

五、总结

用Rust构建AI Agent工具,核心不是"调API",而是工具编排、状态管理和安全边界的设计。Rust的类型系统和所有权模型,在编译期就能帮你挡住很多运行时错误。虽然学习曲线陡峭,但当你看到Agent稳定运行一整天不出内存泄漏时,会觉得这些付出是值得的。

我还在继续完善这个项目,下一步计划加入插件系统和WASM运行时支持。如果你也在做类似的事情,欢迎交流。我们都是在摸索中前进,重要的是保持真诚,保持动手。

Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐