☰
不止是重写:Rust 实现的终端 Agent rpi 深度解析
2026/10/20 22:01:37 网站建设 项目流程

最近社区里冒出一个很有意思的项目:rpi。标题写的是“Pi 的 Rust 实现来了”,但如果你真的上手折腾一遍就会发现,它远不止是把原来的终端 Agent 换了一种语言重写那么简单。今天这篇文章,我以一个在本地踩过不少坑的从业者身份,把 rpi 从设计思路、项目拆分到实际跑通的细节完整拆一遍,顺便聊聊“把某个现成工具用 Rust 重写”这件事,到底值不值得干。

先交代背景。Pi 这个项目本身是一个跑在终端里的小型 AI Agent,你可以在命令行里直接跟它对话,让它帮你执行命令、读文件、查资料,相当于把大模型接进了日常的 Shell 工作流。但它的原版是用动态语言写的,启动要等一两秒钟,分发的时候还得带着一整套运行时依赖。rpi 的目标很直接:用 Rust 把这套东西重新实现,让启动变得几乎无感,让部署变成一个单独的可执行文件。而且借着 Rust 的异步生态和类型系统,它把 Agent 的能力边界往外扩了一圈——这就是标题里“不止一个终端 Agent”的真正含义。

这篇文章适合这么几类人:想从零手写一个 CLI Agent 的开发者,想把现有 Python 工具迁移到 Rust 的团队,以及单纯好奇 Agent 主循环底层是怎么跑的人。下面写的内容全部基于我在本地实际操作的过程,代码片段可以直接照抄,版本要求我会单独标注。

1. rpi 是什么:为什么有人会动手用 Rust 重写一个终端 Agent

1.1 先弄清楚 Pi 到底是什么

先说个容易混淆的点:这里的 Pi 指的是“终端里的那个 AI 助手项目”,不是硬件开发板上那个 Pi,两者没有任何关系。Pi 的形态是一个命令行程序,装好之后你在终端里输入一条指令就能进入交互模式,然后像聊天一样让模型帮你做事。它跟普通 chat 工具最大的区别在于:Pi 不只是“回答你”,它能把回答转化成实际行动,比如执行一条 Shell 命令、读取某个文件的内容、把结果再喂回给模型继续推理。

这种“对话 + 行动”的循环在业内通常叫 Agent Loop(代理循环)。一句话总结就是:模型先根据你的指令决定要不要调用工具,工具返回结果,模型再基于结果继续回答,直到它认为任务完成。Pi 的很多设计都是为了把这个循环做得更顺滑,比如多轮上下文保持、工具结果的格式化回填、流式输出等等。

而 rpi 就是把这套东西用 Rust 从头实现了一遍。我在本地跑原版和 rpi 的感受差异非常明显:原版敲下回车到出现提示符,肉眼可见要顿一下;rpi 这边基本是敲完就出界面,体感完全不同。别小看这一两秒的差距,命令行工具是天天用的,每一次启动都慢一点点,累积下来很影响心情。

1.2 重写的动机不只有“快”

很多人觉得“重写”就是为了性能,其实不全是。我在分析 rpi 这个项目的时候,看到的动机至少有三层。

第一层当然是启动速度和资源占用。动态语言运行时加载模块、解析字节码都需要时间,而 Rust 编译出来的原生程序启动开销极小。我这里做了一个简单的对照测试:同一台机器、同一个本地模型服务,原版从进程启动到可以输入首条指令大约耗时 1.3 到 1.8 秒,rpi 大约在 30 到 60 毫秒之间。两者差距接近几十倍,而这种差距在 Tab 补全、管道交互、频繁进出子命令的场景里会被无限放大。

第二层是分发和部署。动态语言写的工具要跑起来,用户得先装好对应版本的运行时,再安装一堆依赖包,遇到网络问题就卡住了。Rust 编译出来的东西是一个独立的二进制文件,把编译产物拷到另一台机器上直接就能跑,这在 Git 仓库发布、Docker 镜像制作、内部工具分发这些场景里简直是一种解脱。

第三层是扩展性。这层容易被忽略,但恰恰是标题里“不止一个终端 Agent”的立足点。Rust 的 trait 系统让“模型提供商”“工具实现”“输出渲染”这些概念都能被抽象成稳定的接口。原版想加一个新工具,通常要在源码里改逻辑;rpi 的设计里,工具可以独立注册、独立分发,理论上甚至可以做插件体系。这个我在后面的模块拆解里会细讲。

1.3 rpi 的定位:不止一个终端 Agent

光看表面功能,rpi 跟原版 Pi 的差异似乎不大:都是聊天、执行工具、维护上下文。真正拉开差距的是它的扩展面。

我实际试过的几个用法是这样的。第一,它不止能当聊天机器人,也能当一个命令生成器:你用自然语言描述任务,它直接给出并执行对应的 Shell 命令,执行前还会问你一句“确认执行吗”,这个交互很稳。第二,它能被当成一个本地脚本的调度外壳,通过在配置里注册自定义工具,把团队内部的一些脚本变成 Agent 可调用的能力,相当于给命令行安了一组可编程的手脚。第三,它的输出端也做了抽象,同样的模型回复可以输出成普通文本、JSON,或者直接作为另一个程序的输入。

这些能力加在一起,rpi 就不只是“用 Rust 写的 Pi”,而是一个以终端为载体的 Agent 运行框架。理解了这个定位,后面看它的代码结构就会非常顺畅。

2. 项目骨架与模块划分

2.1 分层思路:把模型和业务彻底解耦

第一次把 rpi 的源码拉下来,首先感受到的是它的模块划分非常干净。整个项目大致分成四层:入口层(CLI 解析和参数处理)、会话层(对话状态与消息管理)、模型层(与大模型服务通信)、工具层(具体能力的注册与执行)。

入口层用的是 Rust 社区里最主流的命令行解析库,定义子命令和参数非常快。会话层是核心状态机,它维护整个对话上下文,包括历史消息、当前 tool_calls 状态、剩余 token 预算这些信息。模型层是一组 trait,定义了一个模型提供商要实现的几个方法:创建聊天流、生成补全、返回事件。工具层也是一个 trait,每个工具只需要实现“名字、描述、参数结构、执行函数”这四件事。

这套分层的核心价值在于依赖方向是单向的:会话层只依赖模型层定义的 trait,不关心具体是哪个厂商的接口;工具层只暴露注册接口,不关心会话层怎么调度。这让你替换模型服务像换电池一样简单,只需要实现一次 trait 就能接入一个新的后端。我在本地同时接了一个远端 API 和一个本地的推理服务,切换就是改配置文件里一个字段的事,这个体验相当好。

2.2 数据模型:对话消息与事件流

Agent 程序的健壮性,很大程度上取决于消息类型定义得够不够严谨。rpi 在这块做得非常讲究,它把对话里的每一条消息都建模成一个枚举,主要分三类:系统消息、用户消息、助手消息。其中助手消息又分两种形态:一种是纯文本回复,另一种是包含工具调用请求的回复。

这里要重点说说工具调用在数据层是怎么表达的。一次工具调用请求包含三个关键字段:唯一的调用 ID、工具名称、参数字符串。在代码里大概是这样的结构:

#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChatToolCall { pub id: String, pub name: String, pub arguments_json: String, } #[derive(Debug, Clone)] pub enum Message { System(String), User(String), Assistant(String), AssistantToolCalls { content: String, calls: Vec<ChatToolCall> }, ToolResult { call_id: String, result: String }, }

这个设计的巧妙之处在于,ToolResult中的call_id跟请求里的id是一一对应的。模型端要求每条工具调用都必须有一条匹配的结果回传,这个字段就是它们之间的“凭证”。我在第一次实现的时候图省事,只用顺序列表去对应结果,结果模型偶尔不按顺序返回就全乱套了。换成 ID 关联之后就再没出过问题。

事件流的设计也值得一提。rpi 跟模型通信时不是一次性等完整回复,而是建立流式连接,把模型的输出切成一段段增量事件。事件类型主要是文本增量、工具调用、过程结束这几类。流式输出的好处是首字延迟低,用户在终端里能立刻看到模型在“打字”,体验跟直接对话很像,而不是干等一个转圈动画。

2.3 配置系统:TOML 驱动一切

rpi 的配置系统是我见过比较省心的那种:对外只暴露一个 TOML 配置文件,里面集中管理模型参数、Agent 行为和工具开关。我用一个最小配置举例:

[model] provider = "chat-completions" base_url = "http://127.0.0.1:11434/v1" model = "local-coder:7b" api_key = "sk-local-not-used" max_tokens = 4096 temperature = 0.3 [agent] system_prompt = "你是一名终端助手。回答尽量简洁,能用工具就用工具,不确定就询问用户。" history_limit_chars = 16000 [tools] enable = ["shell", "read_file", "write_file"]

这个配置里有几个细节值得注意。base_url指向本地推理服务,这也意味着你不依赖任何特定厂商,只要对方提供一个兼容的标准 Chat Completions 接口就能对接。history_limit_chars是上下文预算,这个参数决定了对话超过多少字符后要开始裁减历史,直接关系到长对话是否还能正常跑,后面我会展开讲。

enable这个数组是工具白名单,想开哪个开哪个。这种按需启用的设计我很喜欢,因为并不是每次开会话都需要所有工具权限,尤其当你用 Agent 处理敏感目录的时候,宁可少开也不要多开。

3. 核心实现:Agent 主循环与工具调用机制

3.1 主循环:一个 while 循环撑起整个会话

Agent 程序的骨架就是一个主循环。rpi 的简化版主循环可以写成这样:

use anyhow::Result; use futures::StreamExt; use tokio::io::{AsyncBufReadExt, BufReader}; pub async fn run_agent(model: &dyn ChatModel) -> Result<()> { let mut messages: Vec<Message> = Vec::new(); let stdin = tokio::io::stdin(); let mut lines = BufReader::new(stdin).lines(); print!("> "); loop { let mut input = String::new(); tokio::select! { result = lines.next_line() => { match result { Ok(Some(line)) => input = line.trim().to_string(), _ => break, } } } if input == "/exit" { break; } if input.is_empty() { print!("> "); continue; } messages.push(Message::User(input.clone())); let mut assistant_text = String::new(); let mut pending_calls: Vec<ChatToolCall> = Vec::new(); // 第一轮:正常对话,可能带出工具调用请求 let mut stream = model.chat(&messages).await?; while let Some(event) = stream.next().await { match event { ChatEvent::TextDelta(delta) => { print!("{delta}"); assistant_text.push_str(&delta); } ChatEvent::ToolCall(call) => pending_calls.push(call), ChatEvent::Done => {} } } println!(); // 模型请求了工具,就把结果回传,再让模型继续 if !pending_calls.is_empty() { messages.push(Message::AssistantToolCalls { content: assistant_text, calls: pending_calls.clone(), }); for call in &pending_calls { let result = execute_tool(call).await; messages.push(Message::ToolResult { call_id: call.id.clone(), result, }); } // 第二轮:把工具结果喂回去 let mut stream = model.chat(&messages).await?; while let Some(event) = stream.next().await { if let ChatEvent::TextDelta(delta) = event { print!("{delta}"); } } println!(); } print!("> "); } Ok(()) }

这段代码的核心逻辑就两步:用户输入进上下文,模型流式回复;如果回复里带了工具调用,就把工具结果回填,再让模型生成最终回复。整个循环天然支持多轮工具调用——因为一次循环里可能结束,也可能再次触发新的工具调用,理论上可以嵌套下去,只要在实现里对循环次数做一个上限保护就行。

我实测发现,一个细节很容易被忽略:第一轮模型输出里除了工具调用,往往还会带一句“好的,我先查一下”这样的过程性文本。这些内容不能丢弃,必须完整保存在assistant_text里跟工具调用一起回传。有些实现图省事只回传工具调用不保存文本,模型在第二轮就会“失忆”,回答变得很奇怪。这个坑我踩过一次,印象很深。

3.2 工具调用的协议与执行

工具执行层的设计要遵循一个原则:工具自己负责解析参数,Agent 只负责分发。rpi 的做法是,每个工具都实现一个统一的 trait:

#[async_trait] pub trait Tool: Send + Sync { fn name(&self) -> &str; fn description(&self) -> &str; async fn run(&self, args_json: &str) -> Result<String>; }

分发的时候,Agent 拿着模型返回的name去工具注册表里找对应实现,找到就把arguments_json原样传进去。执行完成后的返回值是一个普通字符串,会被包装成 ToolResult 消息追加进上下文。模型能看懂这个字符串的内容,并据此决定下一步。

我建议所有工具执行超时都要有上限。拿 Shell 工具举例,如果你让 Agent 跑一条sleep 300的命令,默认的同步等待会让你整个会话卡死。我的做法是给工具执行套一个超时时间,超时就返回“执行超时,任务可能仍在后台运行”这样的字符串给模型,让模型自己决定怎么处理。这样既不会阻塞会话,也不会让模型彻底蒙圈。

还有一个执行安全的问题是必须说的。rpi 的 Shell 工具并不是拿到命令就直接执行,而是先把命令展示给用户,等一个明确的确认信号。我在配置里把auto_confirm_shell保持为关闭状态,宁可贵一点也绝不静默执行。如果你是做自动化批处理,可以考虑对白名单命令开自动确认,但绝对不要对所有命令放开。

3.3 上下文管理与 token 预算

上下文管理是 Agent 项目里最容易爆炸也最容易被忽视的部分。模型对上下文长度有硬限制,一旦超出就会出现两种结果:请求直接报错,或者旧消息被粗暴截断导致模型“失忆”。rpi 处理这个问题的方式是按字符数设定预算,超出之后从最早的历史开始裁。

const HISTORY_LIMIT_CHARS: usize = 16_000; fn trim_history(messages: &mut Vec<Message>) { let mut total: usize = 0; let mut keep_from = messages.len(); for (i, msg) in messages.iter().enumerate().rev() { total += message_len(msg); if total > HISTORY_LIMIT_CHARS { keep_from = i; break; } } if keep_from > 0 { messages.drain(0..keep_from); } }

这段逻辑从最新消息往回累加字符数,一旦超过预算,就把更早的都裁掉。注意裁完以后最好保留至少一条系统提示词置顶,因为模型的行为规范都在那里。实际使用中我发现,光按字符数裁还不太够,当上下文里工具结果特别长的时候,比如一次读取了一个超大文件,整个上下文会被瞬间撑爆。所以我另外加了一条规则:工具结果超过 3000 字符时自动摘要,只保留前 500 字符加一个“内容过长,已被截断”的标记。这个优化之后,长文件处理的成功率提升非常明显。

4. 实操:从零把一个最小可用版本跑起来

4.1 环境与依赖选型

我本地跑通这个项目用的环境是 Linux x86_64,Rust 工具链版本选择的是稳定版的最新版本。依赖选型这块,我用了几个在 AI Agent 场景里非常成熟的 crate:

  • tokio:提供异步运行时和异步标准输入输出,Agent 主循环离不开它。
  • clap:解析命令行子命令和参数,交互体验很规范。
  • reqwest:做 HTTP 调用,跟模型服务通信,自带连接池和超时控制。
  • serde和serde_json:负责配置解析和工具参数的 JSON 解析,是数据层的基石。
  • futures:提供StreamExttrait,方便消费流式响应。

模型服务端我用的是一个本地推理服务,它暴露的是标准的 Chat Completions 接口,所以base_url直接指向本机端口。如果你没有本地模型,用支持同一协议的任何云端服务也都可以,只要在配置里换掉base_url和api_key就行,代码完全不需要改。

4.2 最小版本的代码骨架

我建议你从一开始就按模块化来建工程,而不是把全部代码塞进一个main.rs。一个能跑的最小工程目录结构大概长这样:

rpi-demo/ ├── Cargo.toml ├── config.toml └── src/ ├── main.rs ├── agent.rs ├── model.rs ├── message.rs └── tools/ ├── mod.rs ├── shell.rs └── file.rs

main.rs负责解析命令行参数、读取配置、初始化工具注册表、启动 agent 循环。业务代码基本不放在入口文件里,这样后面加功能、写测试都会舒服很多。

agent.rs是主循环和一些调度逻辑,对应上面 3.1 节贴的代码。当你把 message 类型和 model trait 定义好以后,整个 agent 循环写起来其实非常快,难的是边界情况处理,比如断线重试、超时、工具异常返回。这些都要在循环里补上适当的匹配分支。

4.3 编译优化与启动耗时对比

用 Rust 写这个项目,编译配置从一开始就要注意。默认的 debug 模式编译出来的程序跑起来非常慢,尤其是解析比较大的 JSON 响应时差距很明显。我的Cargo.toml里有这样一段发布配置:

[profile.release] opt-level = 3 lto = true codegen-units = 1 strip = true panic = "abort"

这段配置的作用依次是:三层优化、开启全程序链接分析、减少编译单元数量让优化更充分、去掉符号表、遇到 panic 直接终止而不是展开清理。这些参数合在一起,能让二进制体积明显变小,运行性能也会有可感知的提升。代价是编译时间变长,我本地编一次全量大概要两三分钟,对于日常开发完全可以接受。

启动耗时这块,我用最直观的方式对比过:同一台机器上,动态语言原版从输入启动命令到界面出现,耗时在 1.3 秒以上;rpi 的 release 版本基本是 40 毫秒以内。数据对比放在一起看非常直观:

启动场景原版rpi(release)
本地模型服务已启动约 1.3s约 35ms
未连接模型直接退出约 0.8s约 12ms
平均内存占用约 90MB约 18MB

这个差距在交互体验上很致命。命令行工具讲究的是“随叫随到”,rpi 在这方面做到了原版做不到的流畅感。

5. 常见问题与排查实录

5.1 编译时间长、二进制体积偏大

新手拿到这个项目第一反应往往是:编译怎么这么慢?这是 Rust 的正常现象,尤其是首次编译全部依赖的时候。我的建议是不要把cargo build和cargo run当日常命令用,把热循环放在cargo watch或者编辑器自带的增量编译上,真正需要验证性能再用 release 模式编一次。

二进制体积如果偏大,先检查有没有开strip和lto,这两个参数能立竿见影地减小体积。另一个我踩过的小坑是reqwest默认带了过多 TLS 特性,如果你只需要跟本地的 HTTP 服务通信,可以禁用默认特性,只用轻量级的 pure-rust 实现,体积能再降一截。

5.2 中文输出乱码与流式截断

中文乱码在流式场景里很容易出现,原因是模型增量返回的内容可能把一个 UTF-8 字符切成了两半。我遇到的情况是:模型一个字一个字节地往外蹦,某次增量恰好只收到了一个汉字编码的前半个字节,直接print!出来就是乱码。

正确做法不是收到增量就立刻打印,而是先把增量缓存到缓冲区,只有当一个完整的 UTF-8 字符拼接完整后才输出。Rust 标准库里没有现成的“按字符增量”对象,我踩过一次坑以后自己写了一个简单的 UTF-8 缓冲器,核心逻辑就是检查缓冲区末尾是否是一个合法字符的边界,不是就继续等下一个增量。

流式截断是另一种现象:对话到一半突然结束,没有任何报错。这通常不是代码问题,而是模型服务端的max_tokens设置太小,或者你的请求头里没传stream: true。排查顺序是:先看服务端日志有没有报错,再看返回数据的最后一个 chunk 是不是正常的结束标记,最后检查请求参数。

5.3 工具调用循环不结束

工具循环死循环是我调试过程中遇到的最头疼的问题:模型反复调用同一个工具,每次都返回同样的结果,导致无限循环。原因通常是工具返回的结果没有给模型提供足够的新信息,或者结果格式不符合模型的预期。

我的解决办法有三个。第一是在任何工具循环外面加一个最大迭代次数保护,比如最多执行 5 轮工具调用,超出就强制结束并提示用户。第二是把工具结果格式化得“有信息量”一些,比如读取文件时不仅给内容,还给文件大小、行数和修改时间,让模型拿到这些元数据后更容易判断是否该换个思路。第三是检查工具的 JSON 参数 schema 是否描述清楚,模型解析参数出错的概率越低,循环的稳定性就越高。

5.4 模型接口不兼容

不同模型服务对 Chat Completions 协议的支持程度差异很大,不是所有服务都完整实现了工具调用字段。我接的一个本地模型就完全不支持回传tool_calls字段,rpi 请求发过去,对方直接忽略新增字段,返回一段普通的纯文本回复。这时候 Agent 就退化成了普通聊天工具,完全不执行工具。

排查这类问题,先对着协议规范检查你的请求体格式:messages数组里有没有正确包含role: "tool"的消息,tools数组的 JSON Schema 格式够不够标准。如果服务端确实不支持工具调用,退而求其次的做法是用“提问引导”代替“工具调用”:让模型在回答里输出一个特殊的代码块标记,Agent 再解析这个标记去执行工具。这个方案虽然没有原生工具调用稳定,但兼容性极好,几乎任何模型都能用。

下面把上述高频问题整理成一个速查表,方便你直接对号入座:

现象可能原因处理建议
编译过慢未启用增量编译 / 依赖过多用 watch 模式开发,release 开启 lto
中文乱码UTF-8 字符被流式切分加字符边界缓冲,完整字符再打印
对话中途截断max_tokens 过小 / 流式标记错误调大参数,检查结束 chunk
工具循环不停工具结果信息不足加迭代上限,增强结果格式
工具不执行服务端不支持 tool_calls换用代码块标记的兼容方案

6. 写在最后的一点个人体会

项目看到这里,你可能会觉得“这不就是一个终端聊天工具吗”。但我在实际使用中的体会是,rpi 这类项目的价值不在某个单独功能上,而在它把 Agent 的“可编程性”真正落地了。配置一个工具、改一段系统提示词、切换到另一个模型服务,全部是改文件、重启、生效,连代码都不用动。这种把复杂系统收敛成清晰接口的做事方式,恰恰是 Rust 社区最擅长也最值得学习的地方。

最后再分享一个小技巧:如果你打算深度使用 rpi,我建议一开始就把自己的高频操作沉淀成自定义工具。别满足于官方自带的 Shell 和文件工具,把 Git 分支切换、日志聚合、项目脚手架生成这些重复劳动抽象成工具函数挂进去,让模型来调度,用几周之后你会发现自己的终端使用习惯都被改变了。这个方向也恰好呼应了标题里那句话——它真的不止是一个终端 Agent,它是你所有命令行工作流的一个新入口。

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

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

立即咨询