详情

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

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

佚名 2026-08-31 09:48:54

我的 AI CLI 工具 30 天演进:从单文件脚本到多 crate 工程的完整历程需要先看清适用场景和关键步骤,避免只记结论却忽略实际限制。

我的 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 Keyasync 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,就要到处改代码。

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

// ============================================================// 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 命令。

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

// ============================================================// 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 + 插件系统,这段经历对我这个来说是一个重要的拐点。三个最深的教训:

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

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

相关资讯
点击查看更多
游戏推荐
推荐专题
热门阅读
推荐下载