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_rs crate 做编码检测和转换。
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 工具时踩过的坑,一起进步。
789



被折叠的 条评论
为什么被折叠?



