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

一、从重复操作到智能协作: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运行时支持。如果你也在做类似的事情,欢迎交流。我们都是在摸索中前进,重要的是保持真诚,保持动手。
更多推荐

所有评论(0)