我的 AI CLI 工具 30 天演进:从单文件脚本到多 crate 工程的完整历程
2026/7/25 8:05:22 网站建设 项目流程

我的 AI CLI 工具 30 天演进:从单文件脚本到多 crate 工程的完整历程

一、第 1 天到第 7 天:一个 main.rs 打天下

最早的需求极其简单:在终端里输入ai "这个错误怎么修",直接拿到 GPT 的回答。用reqwest发 HTTP 请求,再用serde_json解析返回,第一个版本就这样诞生了。

// ============================================================ // 第 1 天的代码:全部塞在 main.rs 里 // ============================================================ use reqwest::Client; use serde_json::Value; /// 向 OpenAI API 发送请求,获取对话补全 /// prompt: 用户输入的问题 /// api_key: 从环境变量读取的 API Key async fn ask_ai(prompt: &str, api_key: &str) -> Result<String, Box<dyn std::error::Error>> { let client = Client::new(); // 构建请求体,messages 是 OpenAI Chat API 的核心结构 let body = serde_json::json!({ "model": "gpt-4o-mini", "messages": [{"role": "user", "content": prompt}], "max_tokens": 2048 }); let resp = client.post("https://api.openai.com/v1/chat/completions") .header("Authorization", format!("Bearer {}", api_key)) .json(&body) .send() .await?; let json: Value = resp.json().await?; // 从嵌套的 JSON 里把回答内容抠出来 let answer = json["choices"][0]["message"]["content"] .as_str() .unwrap_or("无响应") .to_string(); Ok(answer) } #[tokio::main] async fn main() { let prompt = std::env::args().skip(1).collect::<Vec<_>>().join(" "); let api_key = std::env::var("OPENAI_API_KEY").expect("请设置 OPENAI_API_KEY"); match ask_ai(&prompt, &api_key).await { Ok(answer) => println!("{}", answer), Err(e) => eprintln!("错误: {}", e), } }

这时候的代码极度丑陋:没有配置管理、没有错误分类、没有会话上下文。但它的确能用。前七天我一直在加功能:支持流式输出、支持多轮对话、支持替换模型参数。main.rs从 150 行膨胀到 1200 行——典型的"上帝文件"。

二、第 8 天到第 14 天:第一次分模块——"能跑就行"到"能用就行"

到了第二周,每次改一行代码就要重新编译整个项目 20 秒——对一个单文件项目来说这太离谱了。而且我发现一个致命问题:如果想把 OpenAI 换成 Claude,就要到处改代码。

于是我做了第一次架构拆分:提取providertrait。

// ============================================================ // src/provider.rs — AI Provider 抽象层 // ============================================================ use async_trait::async_trait; /// AI 服务提供者的统一接口 /// 定义这个 trait 的目的:以后换模型不需要改动上层业务逻辑 #[async_trait] pub trait AiProvider: Send + Sync { /// 发送一句话,获得模型回答 async fn chat(&self, message: &str) -> Result<String, ProviderError>; /// 流式对话,回调函数逐 token 返回(用于打字机效果) async fn chat_stream( &self, message: &str, on_token: &(dyn Fn(String) + Send + Sync), ) -> Result<(), ProviderError>; /// 获取 provider 名称(用于日志) fn name(&self) -> &str; } /// Provider 层的统一错误类型 #[derive(Debug, thiserror::Error)] pub enum ProviderError { #[error("网络请求失败: {0}")] Network(#[from] reqwest::Error), #[error("API 返回错误: {0}")] Api(String), #[error("配置缺失: {0}")] Config(String), }

拆分后目录变成了:

  • src/provider.rs— AI 抽象层
  • src/providers/openai.rs— OpenAI 实现
  • src/providers/claude.rs— Claude 实现(后来加的)
  • src/config.rs— 配置管理
  • src/cli.rs— 命令行参数解析

编译时间降到 12 秒,因为改一个 provider 不会触发其他模块重编译。但这也带来了新问题:我没想清楚模块间的依赖关系,导致cli.rs同时依赖了config.rs和所有provider,形成了一张紊乱的依赖图。

三、第 15 天到第 21 天:从 lib crate 到 workspace 架构

第三周是我真正"学会工程化"的一周。我把项目拆成了 Cargo workspace:

ai-cli/ ├── crates/ │ ├── ai-core/ # 核心抽象(AiProvider trait、错误类型) │ ├── ai-provider-openai/ # OpenAI 适配器 │ ├── ai-provider-claude/ # Claude 适配器 │ ├── ai-config/ # 配置解析层 │ └── ai-cli/ # CLI 入口(binary crate) ├── Cargo.toml # workspace 根配置 └── README.md

这次重构最大的收获不是"看起来更高级了",而是:编译隔离极其明显。改一行ai-config的代码,只重编译 4 个 crate 而不是全部。增量编译从 12 秒降到了 2~3 秒。而且测试变得非常独立,ai-core不依赖任何外部服务,测试秒过。

四、第 22 天到第 30 天:最后一个关卡 —— 插件系统

真正让我"开悟"的,是第四周决定做插件系统。这个 AI CLI 不只是聊天工具了,我让它能执行预定义的"技能":比如ai "帮我查一下这个仓库的 git log",agent 会自动调用 git 命令。

我想到的方案是:让每个"技能"实现一个Skilltrait,在编译期通过inventorycrate 做自动注册。

// ============================================================ // ai-core/src/skill.rs — 技能插件系统 // ============================================================ use async_trait::async_trait; /// 技能插件接口 /// 每个技能实现这个 trait,编译时通过 inventory 自动注册 #[async_trait] pub trait Skill: Send + Sync { /// 技能名称(如 "git-log") fn name(&self) -> &str; /// 技能描述,会注入到 system prompt 中 fn description(&self) -> &str; /// 执行技能,传入用户意图,返回执行结果 async fn execute(&self, intent: &str) -> Result<String, SkillError>; } /// 注册一个技能到全局 registry /// 使用 inventory::submit! 在编译时自动收集 inventory::collect!(Box<dyn Skill>); /// 用宏简化技能注册 #[macro_export] macro_rules! register_skill { ($skill:expr) => { inventory::submit!(Box::new($skill) as Box<dyn Skill>); }; }

到这里,这个项目才算真正有了"软件工程"的味道。它不是一团能跑的代码,而是一个结构清晰、扩展方便、可以长期维护的工具了。

插件系统上线后踩了一个坑:inventory::collect!的注册顺序是不确定的,导致两个技能注册了同一个名称但执行优先级不同。CI 里全部通过,生产环境运行时注册顺序变了,行为完全错乱。最后用HashMap<String, Box<dyn Skill>>替代了inventory,按名称显式注册,问题解决。

五、总结

30 天从 1 个文件到 workspace + 插件系统,这段经历对我这个来说是一个重要的拐点。三个最深的教训:

  1. "能跑"和"能维护"之间的鸿沟比想象中大。单文件 1200 行不是不能工作,但每次改代码的心智负担会指数级增长。
  2. 把 trait 抽象做对是 Rust 项目最重要的设计决策。好的抽象让换模型、换后端像换积木一样简单;坏的抽象会变成到处Box<dyn Any>的地狱。
  3. 尽早拆 crate,即使项目还小。workspace 的编译隔离效果是实打实的,习惯一开始就规划清楚模块边界,比事后重构省太多精力。

下个月我不打算再加功能了——先把测试补到 80% 覆盖率,然后写一份像样的文档。如果你也在写自己的 AI 工具,希望这些经历对你有用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询