1. 为什么我要用 Rust 重写一个 TypeScript 写的 CLI 工具
先说背景。我手上有一个自己维护了一年多的命令行工具,叫 Pi,定位是一个轻量级的 AI Agent 编排器。它的核心工作很明确:接收用户输入的自然语言指令,拆解成若干步骤,调用不同的模型接口和本地工具,最后把结果拼装成可读的输出。整个工具大概有 8000 行 TypeScript 代码,依赖 Node 运行时,通过 npm 全局安装。
这个工具在日常使用中没什么大问题,功能也稳定。但有一个痛点一直困扰我:启动速度。每次执行pi "帮我整理一下这个目录下的日志"这样的命令,从按下回车到看到第一个字符输出,平均要等 1.2 到 1.8 秒。如果机器负载高一点,或者 npm 全局包多了之后,这个数字能飙到 2.5 秒以上。
你可能觉得一两秒不算什么,但对于一个 CLI 工具来说,这个延迟是致命的。CLI 的使用场景就是高频、短平快。我一天可能要调用它几十次,每次等两秒,累积起来就是几分钟的纯等待。更关键的是,这种延迟会让人产生"这个工具很重"的心理暗示,久而久之就不想用了。
于是我决定用 Rust 重写。结果:冷启动时间从平均 1.5 秒降到了 150 毫秒左右,快了整整 10 倍。这篇文章我会把整个重写过程拆开讲,包括技术选型、架构设计、踩过的坑、性能优化的具体手段,以及最终的效果对比。如果你也在维护一个 TypeScript 写的 CLI 工具,或者正在考虑用 Rust 做 Agent 相关的开发,这篇内容应该能给你不少参考。
2. 先搞清楚:TypeScript CLI 的启动时间到底花在哪了
在动手重写之前,我做了一件事:量化分析启动耗时。不搞清楚时间花在哪,重写就是盲目的。
2.1 用 Node 内置的 profiler 定位瓶颈
Node 提供了--cpu-prof参数,可以生成 CPU 性能分析文件。我用它跑了一次 Pi 的启动过程:
node --cpu-prof --cpu-prof-dir=./profiles ./dist/cli.js "test"然后用 Chrome DevTools 打开生成的.cpuprofile文件,看到了启动阶段的时间分布。结果和我预想的差不多,主要耗时集中在三个地方:
| 耗时环节 | 平均耗时 | 占比 |
|---|---|---|
| Node 运行时初始化 | 约 80ms | 6% |
| 模块加载与解析 | 约 620ms | 42% |
| 依赖库初始化 | 约 450ms | 30% |
| 业务逻辑执行 | 约 320ms | 22% |
模块加载是大头。Pi 依赖了不少 npm 包,包括命令行参数解析、HTTP 客户端、JSON 处理、日志库等等。每个包都有自己的依赖树,Node 的require机制需要逐个读取文件、解析、执行。虽然 V8 有编译缓存,但首次加载或者缓存失效时,这个开销非常明显。
2.2 依赖库初始化为什么这么慢
更隐蔽的问题在依赖库初始化。我用了某个流行的 HTTP 客户端库,它在模块加载时会做一堆事情:检测运行环境、初始化连接池配置、注册全局拦截器、加载 TLS 证书列表。这些操作在服务端场景下是合理的,但在 CLI 场景下完全是浪费——我可能只是执行一个本地文件操作,根本不需要 HTTP 功能。
还有一个日志库,它在初始化时会读取环境变量、检测终端颜色支持、加载配置文件。这些操作单个看起来不慢,但累积起来就是几百毫秒。
2.3 一个容易被忽略的细节:TypeScript 编译产物
Pi 是用 TypeScript 写的,编译目标是 CommonJS。编译后的代码里充满了__importDefault、__createBinding这样的辅助函数,每个模块文件都会生成一遍。虽然单个函数的执行时间可以忽略,但几千个模块累积起来,也是不可忽视的开销。
另外,TypeScript 的esModuleInterop和allowSyntheticDefaultImports这些选项,会让编译产物多一层包装。对于 CLI 这种对启动速度敏感的场景,这些包装都是成本。
这里给一个实操建议:如果你维护的 TypeScript CLI 工具启动慢,先用
node --cpu-prof跑一次,看看时间到底花在哪。不要凭感觉优化,数据会告诉你真相。
3. Rust 重写的整体架构设计
搞清楚瓶颈之后,我开始设计 Rust 版本。核心目标很明确:把启动时间压到 200ms 以内,同时保持功能完整。
3.1 为什么选 Rust 而不是 Go
在决定用 Rust 之前,我也考虑过 Go。Go 的编译速度快、部署简单、标准库丰富,对于 CLI 工具来说是很务实的选择。但最终选 Rust,有几个原因:
第一,启动速度。Rust 编译出的二进制是原生机器码,没有运行时初始化开销。Go 虽然也编译成原生二进制,但它的运行时需要初始化 goroutine 调度器、垃圾回收器等组件,启动时间通常在几毫秒到几十毫秒。Rust 没有 GC,没有运行时,启动就是操作系统加载二进制、跳转到入口点,几乎零开销。
第二,内存占用。Rust 的零成本抽象意味着我可以精确控制内存分配。对于 Agent 这种需要处理大量文本、维护会话状态的场景,内存效率很重要。
第三,生态。Rust 的 CLI 生态已经非常成熟:clap做参数解析、tokio做异步运行时、serde做序列化、reqwest做 HTTP 客户端。这些库的质量和性能都经过大规模验证。
当然,Rust 也有代价:开发速度比 TypeScript 慢,学习曲线陡,编译时间长。但对于一个已经想清楚架构的工具来说,这些代价是值得的。
3.2 模块划分与依赖选型
Rust 版本的 Pi 我划分了这几个核心模块:
- cli 模块:负责参数解析、命令路由、帮助信息生成。用
clap的 derive 模式,编译期生成解析代码,运行时几乎没有反射开销。 - agent 模块:核心编排逻辑,负责任务拆解、步骤调度、状态管理。
- provider 模块:模型接口的抽象层,支持不同的后端。
- tool 模块:本地工具的注册与调用,包括文件操作、命令执行、文本处理。
- config 模块:配置加载,支持文件、环境变量、命令行参数三级覆盖。
依赖选型上,我遵循一个原则:能不用异步就不用异步。CLI 工具的很多操作是顺序执行的,引入异步运行时反而增加复杂度。最终只有网络请求部分用了tokio,其他都是同步代码。
[dependencies] clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } serde_json = "1" reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } anyhow = "1"注意reqwest我关掉了默认特性,只启用json和rustls-tls。默认特性会引入一堆用不到的东西,比如原生 TLS、gzip 解压等,增加二进制体积和编译时间。
3.3 二进制体积优化
Rust 默认编译出的二进制文件比较大,因为包含了调试符号和标准库的完整实现。我在Cargo.toml里加了发布配置:
[profile.release] opt-level = "z" lto = true codegen-units = 1 strip = true panic = "abort"这几个配置的作用分别是:opt-level = "z"优化体积而非速度(对 CLI 来说体积更重要)、lto = true开启链接时优化、codegen-units = 1减少并行编译单元以换取更好的优化、strip = true去掉符号信息、panic = "abort"在 panic 时直接终止而不是展开栈。
最终二进制从默认的 12MB 压到了 3.8MB,启动速度也有小幅提升。
4. 核心模块的 Rust 实现细节
架构定下来之后,就是具体的编码。这部分我挑几个关键模块,讲讲实现思路和踩过的坑。
4.1 参数解析:clap 的 derive 模式怎么用
TypeScript 版本用的是commander,运行时解析参数。Rust 版本用clap的 derive 模式,在编译期生成解析代码。
use clap::{Parser, Subcommand}; #[derive(Parser)] #[command(name = "pi", version, about = "AI Agent 编排器")] struct Cli { /// 要执行的指令 #[arg(short, long)] prompt: Option<String>, /// 配置文件路径 #[arg(short, long, default_value = "~/.pi/config.toml")] config: String, #[command(subcommand)] command: Option<Commands>, } #[derive(Subcommand)] enum Commands { /// 初始化配置 Init, /// 列出可用工具 Tools, /// 执行单步任务 Run { task: String }, }这种写法的好处是类型安全:参数解析结果直接映射到结构体字段,编译期就能发现类型错误。而且clap生成的解析代码非常高效,没有反射,没有动态查找。
实操心得:
clap的 derive 模式虽然方便,但遇到复杂的参数依赖关系时,还是需要手写Arg和Command。我的建议是先用 derive 快速搭出骨架,遇到搞不定的场景再局部改成 builder 模式。
4.2 Agent 编排:状态机 vs 链式调用
Agent 的核心是把用户指令拆解成步骤,然后按顺序执行。TypeScript 版本用的是链式调用,每个步骤是一个 Promise,通过then串联。这种写法在 JS 里很自然,但在 Rust 里会很别扭,因为 Rust 的异步生态和所有权模型对链式调用不太友好。
我改用了状态机模式。每个任务是一个状态,状态之间的转移由执行结果决定:
enum TaskState { Pending, Running { step: usize }, WaitingForTool { tool: String, args: Vec<String> }, Completed { result: String }, Failed { error: String }, } struct Agent { state: TaskState, steps: Vec<Step>, context: HashMap<String, String>, } impl Agent { fn next(&mut self) -> Result<()> { match &self.state { TaskState::Pending => { self.state = TaskState::Running { step: 0 }; } TaskState::Running { step } => { let current = &self.steps[*step]; // 执行当前步骤,根据结果决定下一个状态 } // ... } Ok(()) } }状态机的好处是逻辑清晰,每个状态的转移条件都显式写在代码里,调试时很容易定位问题。而且 Rust 的match表达式对枚举的处理非常优雅,编译器会强制你处理所有分支。
4.3 工具调用:如何安全地执行本地命令
Pi 需要调用本地工具,比如读取文件、执行 shell 命令、处理文本。这部分是安全敏感区域,TypeScript 版本用的是child_process.exec,Rust 版本我用std::process::Command。
关键点是参数传递方式。绝对不要用字符串拼接的方式构造命令,那样会有注入风险。正确做法是把命令和参数分开传递:
use std::process::Command; fn run_tool(name: &str, args: &[String]) -> Result<String> { let output = Command::new(name) .args(args) .output()?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); return Err(anyhow!("工具执行失败: {}", stderr)); } Ok(String::from_utf8_lossy(&output.stdout).to_string()) }Command::new接收的是程序名,args接收的是参数数组,操作系统会正确处理参数边界,不存在注入问题。
另外,我加了一个工具白名单机制。只有注册在配置里的工具才能被调用,防止 Agent 执行任意命令。这个白名单在配置加载时校验,不在白名单里的工具直接拒绝。
4.4 配置加载:三级覆盖的实现
配置加载我设计了三层:默认配置、配置文件、命令行参数。优先级从低到高,后面的覆盖前面的。
#[derive(Deserialize, Default)] struct Config { model: Option<String>, api_base: Option<String>, max_steps: Option<usize>, tools: Option<Vec<String>>, } impl Config { fn load(path: &str, cli_args: &Cli) -> Result<Self> { let mut config = Config::default(); // 第一层:默认值 config.max_steps = Some(10); // 第二层:配置文件 if let Ok(content) = fs::read_to_string(path) { let file_config: Config = toml::from_str(&content)?; config.merge(file_config); } // 第三层:命令行参数 if let Some(model) = &cli_args.model { config.model = Some(model.clone()); } Ok(config) } }merge方法的逻辑是:如果新配置的字段是Some,就覆盖旧值;如果是None,保留旧值。这样每一层只需要关心自己显式设置的字段。
踩坑记录:配置文件路径我一开始用了
~展开,但 Rust 标准库不自动展开波浪号。需要手动处理HOME环境变量,或者用dirs这个 crate。我最后用了dirs::home_dir(),跨平台兼容性更好。
5. 性能优化的关键手段与实测数据
代码写完之后,就是性能调优。这部分我做了几轮迭代,每一轮都有明确的优化目标和测量数据。
5.1 启动路径的懒加载策略
Rust 虽然没有运行时初始化开销,但如果入口函数里做了太多事情,启动一样会慢。我的优化原则是:入口函数只做参数解析和命令路由,其他一切按需加载。
具体来说,配置文件读取、工具注册、模型客户端初始化,这些操作都延迟到真正需要的时候才执行。比如用户执行pi --help,就只走参数解析路径,不加载任何配置和工具。
fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { Some(Commands::Init) => init_config(), Some(Commands::Tools) => list_tools(), Some(Commands::Run { task }) => { // 只有执行任务时才加载完整配置 let config = Config::load(&cli.config, &cli)?; run_task(&task, config) } None => { if let Some(prompt) = cli.prompt { let config = Config::load(&cli.config, &cli)?; run_task(&prompt, config) } else { Cli::command().print_help()?; Ok(()) } } } }这个改动让--help和--version的响应时间降到了 20ms 以内,几乎无感。
5.2 减少二进制体积的编译配置
前面提到的Cargo.toml发布配置,实际效果很明显。我做了对比测试:
| 配置项 | 默认 release | 优化后 release |
|---|---|---|
| 二进制体积 | 12.3 MB | 3.8 MB |
| 冷启动时间 | 210ms | 150ms |
| 内存占用峰值 | 18 MB | 9 MB |
体积减小带来的启动提升,主要是因为操作系统加载更少的页面,减少了磁盘 I/O 和内存映射开销。
5.3 实测数据:冷启动与热启动对比
最终的性能数据,我用hyperfine做了基准测试。测试环境是一台 2021 款的笔记本,SSD,16GB 内存。
hyperfine --warmup 3 'pi --version' 'node ./dist/cli.js --version'结果:
| 命令 | 平均耗时 | 标准差 |
|---|---|---|
Rust 版本pi --version | 18ms | 2ms |
TypeScript 版本node cli.js --version | 180ms | 15ms |
| Rust 版本完整任务 | 152ms | 12ms |
| TypeScript 版本完整任务 | 1520ms | 180ms |
完整任务的对比更明显:从 1.5 秒降到 150 毫秒,正好 10 倍。这个提升主要来自三个方面:没有 Node 运行时初始化、没有模块加载开销、没有依赖库的冗余初始化。
5.4 内存占用的意外收获
除了启动速度,内存占用也大幅下降。TypeScript 版本运行时内存峰值在 45MB 左右,Rust 版本只有 9MB。对于在资源受限环境下运行 Agent 的场景,这个差异很有意义。
内存优化的主要来源是:没有 V8 堆、没有垃圾回收器、字符串处理用&str切片避免拷贝、集合类型预分配容量。
6. 重写过程中踩过的坑与排查技巧
这部分是我觉得最有价值的内容。Rust 重写不是一帆风顺的,我踩了不少坑,有些坑花了大半天才定位到。
6.1 异步运行时的初始化开销
一开始我在main函数上直接加了#[tokio::main],结果发现即使执行--version这样的命令,启动时间也有 80ms 左右。排查后发现,tokio的多线程运行时在初始化时会创建线程池,这个开销对于不需要异步的命令来说是浪费。
解决方案是按需初始化运行时。只在真正需要网络请求的地方创建运行时:
fn run_async_task<F, T>(future: F) -> T where F: std::future::Future<Output = T>, { tokio::runtime::Builder::new_current_thread() .enable_all() .build() .unwrap() .block_on(future) }用new_current_thread而不是new_multi_thread,因为 CLI 工具的异步任务通常是顺序的,不需要多线程调度。这个改动让需要网络请求的命令启动时间从 80ms 降到了 15ms。
6.2 字符串处理的性能陷阱
Rust 的字符串处理很灵活,但如果不注意,很容易产生不必要的拷贝。我一开始写代码时,习惯性地用String接收所有文本,结果发现处理大文件时内存占用很高。
后来改成了&str和Cow<str>:
use std::borrow::Cow; fn process_text(input: &str) -> Cow<str> { if input.contains("需要替换的内容") { Cow::Owned(input.replace("需要替换的内容", "替换后的内容")) } else { Cow::Borrowed(input) } }Cow的意思是"写时复制":如果不需要修改,就借用原始数据;如果需要修改,才分配新内存。这个模式在处理文本时非常实用。
6.3 常见问题速查表
我把重写过程中遇到的问题整理成了表格,方便快速查阅:
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 启动时间超过 200ms | 入口函数做了太多初始化 | 在入口函数各阶段打时间戳 | 延迟加载,按需初始化 |
| 二进制体积超过 10MB | 未开启 LTO 和 strip | ls -lh target/release/pi | 配置 release profile |
| 异步命令启动慢 | tokio 多线程运行时初始化 | 对比同步和异步命令的启动时间 | 改用 current_thread 运行时 |
| 内存占用高 | 字符串频繁拷贝 | 用valgrind或heaptrack分析 | 改用&str和Cow |
| 编译时间过长 | codegen-units 过多 | 观察cargo build输出 | 开发时用默认配置,发布时再优化 |
独家避坑技巧:Rust 的编译时间在开发阶段很影响体验。我的做法是开发时用
cargo check而不是cargo build,只做类型检查不生成二进制,速度快很多。需要实际运行时再用cargo run。
6.4 跨平台兼容性的注意事项
Pi 需要在多个操作系统上运行,跨平台兼容性是个绕不开的问题。我遇到的主要是路径处理和命令调用的差异。
路径处理上,Windows 用反斜杠,Unix 用正斜杠。Rust 的std::path::Path会自动处理这些差异,但字符串拼接路径时容易出错。我的做法是统一用PathBuf和join方法:
use std::path::PathBuf; let config_path = dirs::home_dir() .unwrap() .join(".pi") .join("config.toml");命令调用上,Windows 的cmd和 Unix 的sh行为不同。如果 Agent 需要执行 shell 命令,需要根据平台选择不同的解释器。我封装了一个函数处理这个差异:
fn shell_command(cmd: &str) -> Command { if cfg!(target_os = "windows") { let mut c = Command::new("cmd"); c.args(["/C", cmd]); c } else { let mut c = Command::new("sh"); c.args(["-c", cmd]); c } }7. 从 TypeScript 迁移到 Rust 的经验总结
最后聊聊迁移策略。8000 行 TypeScript 代码不是小数目,不可能一次性重写。我采用的是渐进式迁移。
7.1 渐进式迁移的步骤
第一步,先重写 CLI 入口和参数解析。这部分逻辑独立,改动风险小,而且能立刻看到启动速度的提升。Rust 版本先只支持--version和--help,其他命令转发给 TypeScript 版本。
第二步,重写配置加载和工具注册。这部分是纯逻辑,不涉及网络请求,迁移难度低。
第三步,重写 Agent 编排核心。这是最复杂的部分,需要仔细设计状态机和错误处理。
第四步,重写模型接口调用。这部分涉及异步和网络,放在最后。
每一步都保证 Rust 版本可以独立运行,通过环境变量控制走哪个版本。这样即使 Rust 版本有问题,也能快速回退。
7.2 哪些场景适合用 Rust 重写 CLI
不是所有 TypeScript CLI 都值得用 Rust 重写。我的判断标准是:
- 高频使用:如果工具每天被调用几十次以上,启动速度的提升能明显改善体验。
- 性能敏感:如果工具需要处理大量数据、执行复杂计算,Rust 的性能优势更明显。
- 分发需求:如果需要分发给不熟悉 Node 环境的用户,单个二进制文件比 npm 包更方便。
- 长期维护:如果工具会长期维护,Rust 的类型系统和错误处理能减少运行时问题。
反过来,如果工具只是内部偶尔使用、逻辑经常变动、团队不熟悉 Rust,那继续用 TypeScript 更务实。
7.3 给准备重写的朋友几条建议
第一,先测量再优化。不要凭感觉认为 TypeScript 慢,用 profiler 找到真正的瓶颈。
第二,不要追求一次性重写。渐进式迁移风险更低,而且能持续看到收益。
第三,重视错误处理。Rust 的Result和anyhow让错误处理更清晰,但需要改变写 TypeScript 时的思维习惯。
第四,善用社区 crate。CLI 领域的常见需求,比如参数解析、配置加载、进度条、颜色输出,都有成熟的 crate,不要重复造轮子。
第五,保持功能对等。重写过程中容易忍不住加新功能,但这会让迁移复杂度失控。先把现有功能完整迁移,再考虑新特性。
我在实际迁移中发现,最耗时的不是写 Rust 代码,而是理解原有 TypeScript 代码的隐含逻辑。很多行为没有文档,只能通过读代码和测试来推断。所以如果你的 TypeScript 项目缺少测试,建议先补测试再迁移,这样能保证行为一致性。
另外一个小技巧:迁移过程中保留 TypeScript 版本作为参考实现,遇到不确定的行为时,跑一下 TypeScript 版本看输出,然后让 Rust 版本对齐。这个"双跑对比"的方法能省很多调试时间。