Rust AI CLI 开发复盘:两周踩过的 7 个坑和对应的防范模式
一、这个项目的由来:我为什么用 Rust 写 AI CLI
这个 AI CLI 项目最开始的需求很简单:在终端里直接调用 OpenAI 兼容的 API,读取本地文件作为上下文,然后把 AI 的回答直接输出到终端或者写回文件。听起来不复杂,对吧?Python 写大概半天就能跑。但我偏要用 Rust 来写,一方面想练手,另一方面也是被 Rust 的单文件编译能力吸引——编译完就是一个二进制文件,拷到哪都能跑。
结果呢?两周时间踩了 7 个比较典型的坑。这篇文章就当一次完整复盘,分享踩坑经历和对应的预防方法。
flowchart LR A["需求分析\n想用Rust写AI CLI"] --> B["原型开发\n快速搭出demo"] B --> C["踩坑阶段\n网络错误/内存暴涨\n/序列化bug/异步混乱"] C --> D["重构\n引入错误处理模式\n/流式解析/资源管理"] D --> E["可发布版本\n单文件二进制\n/支持多平台"]二、踩坑实录:7 个坑的完整还原
坑 1:HTTP 客户端无超时,请求卡死 3 分钟
我最早用的是reqwest的默认配置,直接reqwest::get(url)。有次网络波动,API 服务响应很慢,终端直接卡住没有任何反馈,我一度以为是死循环。
原因:reqwest默认不设超时,TCP 连接可以挂很久。
教训:任何网络请求必须显式设置连接超时和读取超时。
use reqwest::Client; use std::time::Duration; fn build_client() -> Result<Client, reqwest::Error> { // 构建带超时配置的 HTTP 客户端 Client::builder() // TCP 连接最多等 10 秒 .connect_timeout(Duration::from_secs(10)) // 整个请求最多等 60 秒(AI API 响应可能比较慢) .timeout(Duration::from_secs(60)) // 连接池空闲超时 .pool_idle_timeout(Duration::from_secs(90)) .build() }坑 2:流式响应解析用了错误的解析方式
AI API 的流式响应是 SSE(Server-Sent Events)格式,一行一个data: {...}。我最开始想当然地用serde_json::from_str直接解析整行,结果发现有些行前面有空格、有些行是data: [DONE](不是合法 JSON),还有空行。
正确做法是逐行读取、跳过空行和[DONE]标记、再解析 JSON:
use futures::StreamExt; use serde_json::Value; async fn parse_sse_stream( mut stream: impl StreamExt<Item = reqwest::Result<bytes::Bytes>> + Unpin, ) { while let Some(chunk) = stream.next().await { let chunk = match chunk { Ok(c) => c, Err(e) => { eprintln!("读取流数据失败: {}", e); break; } }; let text = String::from_utf8_lossy(&chunk); for line in text.lines() { let line = line.trim(); // 去除前后空格 if line.is_empty() { continue; // 跳过空行 } // SSE 格式:以 "data: " 开头 let data = match line.strip_prefix("data: ") { Some(d) => d, None => continue, }; // 流结束标记,不是 JSON if data == "[DONE]" { println!("\n[AI 响应完成]"); return; } // 解析 JSON 并取出文本内容 if let Ok(json) = serde_json::from_str::<Value>(data) { if let Some(content) = json["choices"][0]["delta"]["content"].as_str() { print!("{}", content); } } } } }坑 3:文件读取时忘记处理非 UTF-8 编码
在读取用户指定的本地文件作为 AI 上下文时,我用std::fs::read_to_string,结果用户给了一个 GBK 编码的 Windows 批处理文件,程序直接 panic。
修正方案:使用encoding_rscrate 做编码检测和转换。
use encoding_rs::*; use std::fs; fn read_file_auto_encoding(path: &str) -> Result<String, Box<dyn std::error::Error>> { // 先按字节读取,不做 UTF-8 假设 let bytes = fs::read(path)?; // 尝试 UTF-8(绝大多数情况) if let Ok(s) = String::from_utf8(bytes.clone()) { return Ok(s); } // UTF-8 失败,用 encoding_rs 检测编码 // GBK 是中文 Windows 环境最常遇到的非 UTF-8 编码 let (cow, encoding, _had_errors) = GBK.decode(&bytes); if !encoding.is_utf8() { eprintln!("注意:文件编码为 {},已自动转换", encoding.name()); } Ok(cow.into_owned()) }坑 4:命令行参数解析不支持中文路径
用clap处理命令行参数时,我在 Windows 上测试发现中文路径传入后被截断。查了半天发现是终端编码设置问题,但最终决定用clap的OsString参数类型,绕过编码转换。
坑 5:异步运行时混用导致 panic
我在main里用了tokio::main,但在某个函数里又block_on了一个嵌套 runtime,结果 panic 说 "Cannot start a runtime from within a runtime"。这是新手常见的异步混淆——在已有 tokio 上下文中又新建 runtime。
修正:统一使用async/await,不要混用block_on。
坑 6:无节制的字符串拼接导致内存暴涨
流式输出时,我把每次 AI 返回的 delta 内容追加到一个String上。处理一个 10 万字的文档时,内存从几十 MB 飙到 500MB+。因为String的push_str在容量不足时会重新分配并复制全部数据。
对策:用Vec<String>收集片段,最后join("")或预先reserve容量。
坑 7:API Key 硬编码在代码里差点提交到 GitHub
这个太不好意思说了——我居然在main.rs写了let api_key = "sk-xxxxxxxx";。还好提交前用cargo check的时候无意中看了一眼 diff,惊出一身冷汗。
正确做法:环境变量 +.env文件 +.gitignore:
use std::env; fn load_api_key() -> Result<String, String> { // 优先读环境变量,不存在时读 .env 文件 env::var("OPENAI_API_KEY") .or_else(|_| { // 加载 .env 文件(开发环境用) dotenvy::dotenv().ok(); env::var("OPENAI_API_KEY") }) .map_err(|_| "未找到 OPENAI_API_KEY,请设置环境变量或 .env 文件".into()) }三、防范模式总结:从踩坑中提炼的三条原则
flowchart TD A["外部输入"] --> B{"编码假设检查"} B -->|已知编码| C["按预期处理"] B -->|未知编码| D["自动检测 + 降级"] E["网络调用"] --> F{"超时设置"} F -->|已设置| G["正常请求"] F -->|未设置| H["阻断:必须设置超时"] I["资源使用"] --> J{"有上界吗?"} J -->|有| K["安全"] J -->|无| L["加限制:pre-allocate / buffer-cap"]- 所有外部输入均做编码检测:不假设文件、网络响应、命令行参数是 UTF-8。
- 所有 I/O 操作均设超时:HTTP 请求、文件读取、子进程调用都要有 timeout。
- 资源使用必须有上界:缓冲区大小、并发数、内存使用量都要设上限。
四、对自学者的建议:工具该有的样子
这个项目让我学到最重要的不是某个技术点,而是一种意识转变——从"能跑就行"到"能在各种极端情况下稳健运行"。自学容易陷入"写出来就完了"的心态,但真正的工程是要考虑边界情况的。
我的建议是:把你写的工具发给朋友用。他一定会在你意想不到的场景下触发你的 bug。这是最快的成长方式。
mindmap root((AI CLI 开发心得)) 编码 不假设 UTF-8 用 encoding_rs 测试 GBK、Shift-JIS 网络 超时必须设 SSE 逐行解析 重试加退避 资源 预留容量 限制缓冲区 监控内存 安全 密钥用环境变量 .env 入 .gitignore 不打印敏感信息五、总结
这两周踩的坑看似很多,但每一个其实都是 Rust 乃至系统编程的"基础课"——超时、编码、内存管理、异步模型、密钥安全,这些东西在任何语言里都是必学的,Rust 只是让问题暴露得更早(也因此更痛苦)。
如果你也在用 Rust 写工具,希望这篇文章能帮你少走我走过的弯路。特别是自学的朋友,别怕踩坑,每踩一个坑都是一次真正理解"为什么"的机会。下次分享我打算整理 Rust 生命周期标注的心法,期待和大家交流。
欢迎在评论区分享你写 CLI 工具时踩过的坑,一起进步。