ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

用Rust重写终端Agent:rpi架构设计与核心实现详解

用Rust重写终端Agent:rpi架构设计与核心实现详解

Pi 这个终端 Agent 项目,从去年底开始就在开发者圈子里传开了。它的定位很朴素:不搞网页、不搞 App,就在终端里给你一个常驻的 AI 助手,能对话、能读项目文件、能帮你跑命令、能调外部接口。最近社区里有人把它的核心用 Rust 重写了一遍,项目叫 rpi,也就是 Rust 版的 Pi。我一开始也以为这只是把 Python 换成 Rust 的"移植工程",但实际用了几个星期之后发现,rpi 做的事情比"换个语言"要多得多——它把终端 Agent 这个概念往外扩了一圈。这篇文章我把 rpi 的定位拆解、技术选型、核心实现、关键代码和踩坑记录都整理出来,给想自己上手写终端 Agent 的人一份可以照着抄的参考。

1. 项目定位:为什么需要再实现一个终端 Agent

1.1 原版 Pi 解决了什么需求

原版 Pi 的核心价值在于"让 AI 在终端里真正干上活"。它不像普通聊天机器人那样只回文本,而是可以理解你的意图,然后调用本地能力——比如搜索当前目录下的文件、读取代码片段、执行测试命令、请求天气接口。这些操作全部发生在终端会话里,不需要切窗口,不需要开浏览器。

这个模式对开发者来说特别顺手,因为终端本来就是多数开发者的工作台。你正在调试一段代码,直接在终端里向 Pi 提问"这个函数哪里调用过",它会自己 grep、自己读文件、自己把结论列出来,比手动 IDE 操作快得多。原版 Pi 之所以能流行,不是它技术上有多少独创,而是它把"Agent + 终端"这个组合做到了开箱即用。

1.2 重写成 Rust 的三个直接动机

原版 Pi 跑在解释型语言运行时上,这在实际使用中带来三个比较烦人的问题。

第一个是分发成本。每次换环境都要处理依赖、虚拟环境、版本冲突。对个人用还可以忍受,但想做成一个丢给同事就能用的工具,这一步非常劝退。

第二个是启动速度。终端 Agent 的使用场景是高频短交互,如果敲完命令要等两三秒才出现提示符,体感会非常差。解释型脚本的启动开销在大型项目里会被生态依赖放大,实测能到 1 到 2 秒以上。

第三个是并发与监听的工程成本。终端 Agent 要做流式输出、并发调用多个工具、处理取消信号、维护会话状态,这些用解释型语言写起来确实快,但写到后面性能边界和类型混乱的问题会越来越明显。

Rust 恰好在这三个方向上都比较好地回应:编译成单一二进制,零运行时依赖;启动通常是几十毫秒级别;tokio 生态做流和并发非常顺手,类型系统能把工具调用协议里的很多错误在编译期就挡掉。

1.3 "不止一个终端 Agent"具体指什么

rpi 的项目名值得分开看:r 是 Rust,pi 是 Pi。但作者在 README 里其实埋了一句核心定位——"rpi is not just a terminal agent"。

这句话落地下来是四个具体能力,缺一不可:

  • 内置工具调用协议:不是靠解析自然语言里的关键字来猜要执行什么,而是让每个工具声明结构化 JSON Schema,模型按 Schema 生成调用参数,参数校验由运行时完成。

  • 插件机制:rpi 可以扫描指定目录下的子命令或共享库,把它们注册成新工具。相当于给 Agent 留了一个开放的扩展口,不用改主程序就能加能力。

  • 结构化输出:除了普通对话流,还提供 JSON 模式输出,方便在脚本里直接消费 Agent 的结果,而不是去解析一段带 Markdown 的自然语言。

  • 上下文压缩策略:对话变长之后自动压缩历史记忆,保留关键信息,避免 token 无限膨胀导致每次请求越来越慢、越来越贵。

这四点加在一起,rpi 就不只是"终端里的聊天机器人",而是一个可以被集成进工作流的终端 Agent 基础设施。

2. 整体架构与技术选型思路

2.1 依赖选型的四个维度

rpi 的依赖选型思路可以归纳成四个维度:终端交互、异步运行时、数据序列化、错误处理。

终端交互层,rpi 用的是行编辑库而不是全屏 TUI 库。原因是终端 Agent 的主要交互模式是"输入一行命令、看一段流式输出",不是操作表格或菜单。行编辑库提供多行编辑、历史记录、补全提示就够了,比引入完整 TUI 更轻,也不会在输出流式文本时跟界面渲染产生冲突。

异步运行时选 tokio,这个基本没有悬念。终端 Agent 的核心工作模式就是"等待用户输入 -> 发起请求 -> 流式读取响应 -> 可能同时调用多个工具 -> 汇总输出",这正好是 tokio 最擅长的领域。要注意的是,流式输出的情况下,网络读取和终端写入必须解耦,否则一个慢接口会卡住整个交互,tokio 的任务机制天然方便做这个隔离。

数据序列化用 serde 全家桶,因为工具调用协议本质上是"模型生成 JSON -> 运行时解析 JSON -> 映射成 Rust 类型"这个链路。serde 的 derive 能直接把一个结构体变成 JSON Schema 的输入源,减少手写校验样板。

错误处理用 anyhow 做应用层聚合,错误信息要能一路带着工具名、参数、源错误信息打印出来,这样排查问题时才不用逐层翻代码。

2.2 三层架构:交互层、引擎层、工具层

rpi 的内部结构是三个清晰的层级,层与层之间通过 trait 和消息类型解耦。

交互层负责所有跟"人"相关的事:读取输入、渲染输出、处理终端尺寸变化、响应 Ctrl+C。这一层不关心模型是什么、工具怎么实现,只负责把用户的话递下去、把结果展示出来。好处是以后想换一个交互界面——比如从命令行改成桌面小窗——交互层可以直接换掉,引擎层完全不用动。

引擎层是核心,负责跑 Agent 循环:组织当前消息列表、调用模型、拿到响应、判断是否需要调用工具、如果需要就执行工具、把结果追加回上下文、再继续请求模型。引擎层还负责上下文管理、token 估算、会话历史压缩。这一层是整个项目的心脏,也是最容易写砸的地方——把所有状态都塞进一个大结构体里,不如用一个小型状态机来接管。

工具层是能力的集合。每个工具就是一个实现了统一 trait 的对象,有自己的名称、描述、参数 Schema,以及一个 async execute 方法。工具层不依赖引擎层,所以插件可以独立编译、独立测试。rpi 在设计上刻意做到"引擎不知道具体有哪些工具",工具列表由启动时的注册过程注入。这个解耦让新增工具变得非常简单——不需要碰核心代码。

2.3 事件流与消息生命周期

理解 rpi 最好从一条用户消息的完整生命周期入手。用户在终端敲下一句话,回车后发生的过程是:

  1. 交互层把输入文本包成 UserMessage,放进消息列表,然后触发一轮引擎循环。

  2. 引擎把整个消息列表发给模型服务,请求开启流式返回。

  3. 模型返回一段响应。如果响应是纯文本,引擎把文本块逐段转发给交互层,交互层实时渲染到终端。

  4. 如果响应里带有工具调用请求,引擎先停止转发文本,把工具名和参数交给工具层执行。

  5. 工具层执行完毕后返回 ToolResult 消息,引擎把结果追加进消息列表,然后带着新上下文再次调用模型。

  6. 模型拿到工具结果后生成最终回复,这个过程可能反复多次,直到模型的响应里不再包含工具调用意图。

这个"模型 -> 工具 -> 模型"的循环是 Agent 的本质。rpi 在这里做了一个很关键的设计:工具执行阶段和文本渲染阶段完全分离。也就是说模型在流式输出文本的时候,用户可以实时看到内容;但当模型决定调用工具的时候,输出流会暂停,等工具结果返回后才继续。避免出现"文本和工具结果交错显示"的杂乱局面。

3. 核心功能实现细节

3.1 终端交互:REPL 循环与行编辑

rpi 的交互层实现了一个标准的 REPL 循环:读入一行、处理、输出、回到读入状态。听起来简单,但终端环境的细节远比想象中多。

行编辑这块,rpi 启用了多行输入支持。因为终端 Agent 经常要处理粘贴进来的代码块、JSON、多行命令,如果只有单行输入,用户会非常痛苦。多行输入的判定规则是"括号是否闭合、是否以反斜杠结尾、当前是否在 Markdown 代码块内",这三条规则覆盖了绝大多数输入场景。

历史记录也是必须的。rpi 的历史文件放在用户配置目录下,按日期滚动归档。这里有一个实操经验:历史记录一定要异步持久化,不要在交互主线程里同步写文件。我在早期的版本里吃过这个亏,当历史文件增大到几 MB 后,每次回车都会卡一下,就是因为同步写文件阻塞了主循环。

终端原始模式的管理是最容易踩坑的地方。进入读行状态前要切换终端为 raw mode,让程序能读取每个按键的实时输入;但退出时要保证终端恢复到正常模式。rpi 的处理方式是使用 RAII 风格的保护类型,在进入 REPL 前把终端状态保存起来,在程序退出时无论正常还是 panic 都要恢复。这个必须在 Release 构建下反复测试,因为 Debug 构建往往掩盖了时序问题。

3.2 工具调用协议:从 JSON Schema 到类型安全

工具调用是 rpi 的核心卖点。它的协议设计遵循当前主流的 function calling 思路:每个工具都描述成一个 JSON Schema,模型生成参数 JSON,运行时校验后执行。

rpi 的注册方式值得单独讲一下。它用过程宏把"定义一个工具"这件事压缩成了一个函数加一层属性标注:

#[tool( name = "read_file", description = "读取指定路径的文件内容", param(name = "path", r#type = "string", description = "文件路径"), param(name = "max_lines", r#type = "integer", description = "最多读取行数", optional) )] async fn read_file(path: String, max_lines: Option<usize>) -> Result<String, ToolError> { // 实际读取逻辑 Ok(contents) }

这个宏在编译期生成两个关键东西:一个是符合模型要求的 JSON Schema 描述,另一个是"从参数 JSON 反序列化出 Rust 类型并调用函数"的桥接层。使用宏而不是手写注册逻辑的理由很简单:手写容易漏字段,而且参数名一旦改了,手写的 JSON 字符串和实际代码会悄悄失配,编译期根本发现不了。

这里有一个设计决策值得所有终端 Agent 参考:工具执行必须和主循环隔离。rpi 把每个工具调用放到独立 tokio 任务中执行,并设置超时。某个工具卡死不会连累整个 Agent 会话。超时时间默认 30 秒,可通过配置文件调整。很多 Agent 项目栽在这个细节上——工具执行一卡,整个终端就死锁了。

3.3 插件体系:开放扩展而不破坏核心

rpi 的插件机制走的是"模块化目录扫描"路线,而不是一开始就上动态链接库。它的工作原理很简单:rpi 启动时读取插件目录,发现可执行的插件子命令,然后通过预设的通信协议跟它们交互。

这样做的好处有三个。第一,插件可以用任何语言编写,只要它遵循协议即可,不要求插件作者会 Rust。第二,插件进程崩溃不会拖垮主进程,最多损失一次调用。第三,调试插件非常自由,可以直接在终端里手动执行插件命令看输出。

目录扫描的玩法是:把插件目录里的每个可执行文件当成一个工具,文件名去掉前缀就是工具名。rpi 在调用插件工具时,把参数 JSON 通过 stdin 传进去,插件执行完把结果 JSON 写到 stdout,rpi 再解析这个 JSON 拼回 ToolResult。这里必须在协议文档里写清楚编码规范和错误格式,因为插件一旦出现跨平台换行符或者非 UTF-8 输出,解析就会出问题。

等等,动态库加载我用了很久吗?其实在第一版设计讨论时我们也考虑过直接用动态链接库做插件,让 Rust 写的插件以 cdylib 形式加载。这个方案性能最好,但跨平台打包、版本不兼容、ABI 稳定性都是深坑。最终采用子进程方案,牺牲一点性能换来了极大的灵活性和稳定性。对终端 Agent 场景来说,插件的调用频率远低于核心工具,这点性能损耗可以接受。

3.4 上下文管理:token 估算与压缩策略

终端 Agent 跟普通聊天不一样,它会不断把工具执行的输出追加进消息列表。如果不加管理,几轮下来上下文就会膨胀到模型窗口上限,然后请求失败或者费用爆表。rpi 的上下文管理分成三层:估算、截断、压缩。

估算层用轻量 tokenizer 对消息文本做近似统计,不调用模型接口,只算字符级加权。这个估算值不准确,但足够用来决定是否触发压缩逻辑。真正的 token 数只由模型服务返回的用量字段校正,运行时会维护一个校正系数。

截断层处理的是硬边界:当消息列表总长度超过配置阈值时,把最旧的对话消息裁掉。这里有一个重要的经验:裁旧消息时不能只看时间顺序,还要保留系统提示、最近一次工具结果和用户最新提问这三部分不能动,否则模型会丢失执行上下文,出现答非所问。

压缩层更进一步,用"摘要 + 关键事实"的方式把一部分旧对话替换成结构化摘要。注意压缩不是简单删文本——我在实际测试中发现,粗鲁截断会造成模型"失忆",表现为重复提问、忘记之前给出的结论。压缩应该保留:用户提过的明确要求、已经完成的操作、尚未完成的事项。这三类是必须带入后续上下文的核心记忆。

4. 实操过程:从零搭建 rpi 的关键代码

4.1 工程初始化与依赖清单

如果你也想照这个思路搭一个,工程初始化的第一步是确认 Rust 工具链版本,以及选择合适的 edition。我习惯用最新的稳定版,因为过程宏和 async trait 的特性支持在不断变化,太旧的版本会出现不必要的编译问题。

依赖清单按功能分组比较好维护:

  • 终端交互:行编辑库、终端控制库
  • 异步与网络:tokio 全家桶、HTTP 客户端、流式读取相关组件
  • 序列化:serde、serde_json
  • 命令行入口:clap
  • 错误处理:anyhow、thiserror
  • 配置管理:toml 解析、目录查找

这里要特别提醒:异步 HTTP 客户端在流式场景下必须开启流式响应读取能力,否则整个响应会先缓冲到内存再一起返回,流式渲染就名存实亡了。配置这个参数非常隐蔽,我见过不少人在这上面排查了两三天。

4.2 主循环与异步任务拆分

主程序的框架可以概括为一个不断运行的交互循环,但这个循环本身不做重活。核心模式是:主循环接收输入,把工作交给 tokio 任务,然后通过 channel 接收事件来做渲染。

#[tokio::main] async fn main() -> anyhow::Result<()> { let config = Config::load()?; let tools = ToolRegistry::from_config(&config)?; let engine = AgentEngine::new(config.model, tools); let mut line_editor = LineEditor::new()?; line_editor.load_history()?; loop { // 读取用户输入,None 表示用户主动退出 let Some(input) = line_editor.read_line()? else { break; }; line_editor.add_history(&input)?; engine.handle_user_message(input).await?; } line_editor.save_history()?; Ok(()) }

看起来简单,但真正的复杂度藏在handle_user_message里。这个函数需要把流式输出、工具调用、异常退出、用户中断全部收拢成一个状态机。我自己写的时候踩了一个大坑:一开始把"等待模型返回"和"等待工具返回"直接顺序摆放,结果遇到模型先返回一段文本、再请求工具、又在工具执行期间用户按了 Ctrl+C 的情况,整个状态就乱了。后来改成显式状态机,把几个阶段标成 WaitingModel / RunningTool / RenderingText,才彻底解决。

4.3 一个完整的工具调用示例

工具注册用过程宏,但执行链路要展开来看才能真正理解。拿一个"查询今天日期"的工具举例,它的注册代码大致是:

#[tool( name = "get_now", description = "获取当前时间和日期,格式为 RFC 3339", param(name = "timezone", r#type = "string", description = "时区,如 Asia/Shanghai", optional) )] fn get_now(timezone: Option<String>) -> Result<String, ToolError> { let tz = timezone.unwrap_or_else(|| "UTC".into()); let now = chrono::Utc::now().with_timezone(&tz.parse()?); Ok(now.to_rfc3339()) }

当模型返回一个工具调用请求时,rpi 会拿到 JSON 格式的参数包,然后做四件事:找到对应工具、校验参数类型、执行异步函数、把结果封装成标准消息追加回上下文。这里有一个容易被忽略的细节:工具返回结果要有明确的"截断标记"。比如读取文件工具,如果文件太长,要主动截断并追加"(内容已截断,总行数 N 行)"。否则一个 2 万行的文件内容全塞进上下文,后续模型响应的质量和速度都会断崖式下降。

4.4 构建配置与启动耗时优化

终端 Agent 对启动速度很敏感,rpi 在发布构建上花了不少心思。核心思路是"能编译进去就编译进去,能延迟加载就延迟加载"。

编译配置上,开启 LTO 和瘦化符号是基本操作,能明显减小二进制体积。依赖的默认特性要逐个检查,比如 HTTP 客户端默认可能有大量用不到的 TLS 特性,按需裁剪可以砍掉一大块体积。

运行时优化上,配置文件读取和工具注册这些过程放到后台任务里预执行,提示符可以先出来,等用户敲完第一行输入时这些后台任务通常已经完成。效果就是体感启动延迟几乎为零。

5. 踩坑记录与常见问题排查实录

5.1 终端状态错乱:raw mode 与退出恢复

这是终端 Agent 最容易遇到、也最尴尬的问题。表现是程序异常退出后,终端停留在黑漆漆的奇怪状态,敲的字符都不显示,Ctrl+C 都没反应,只能靠reset命令恢复。

原因很简单:进入行编辑时终端进入了 raw mode,退出时没有恢复。只要程序在任何路径上退出而没走恢复逻辑,终端就留在原始状态。

rpi 的解法是双保险:第一,用 RAII 保护类型包裹终端状态,结构体析构时自动恢复;第二,注册进程退出信号处理,在收到退出信号时强制恢复终端。实测下来,panic 和普通退出都能覆盖到,唯一需要人工注意的场景是极端情况下的系统级杀进程。

5.2 流式输出的缓冲延迟

流式输出有个很隐蔽的问题:即使请求端已经开启流式,写入端如果没及时刷新缓冲区,用户看到的仍然是一段一段地蹦出来,而不是逐字显示。

我排查这个问题时,一度怀疑是模型接口的问题。后来发现是输出通道的缓冲策略过于激进。解决方法是设置合理的低水位阈值,并保证每收到一个数据块就立刻写入终端。另外注意终端的行缓冲模式,在非交互输出时必须显式刷新,否则内容会挤在缓冲区里直到换行才会出现。

5.3 工具返回体过大撑爆上下文

工具执行结果无限膨胀,是所有 Agent 项目绕不开的坑。rpi 早期的测试里出现过一次:让 Agent 读取整个日志目录,工具老老实实把所有文件内容全读回来了,上下文直接爆掉。

最终的策略组合是三层:工具执行前,引擎根据当前上下文余量估算本次工具结果的最大允许体积,超量的部分从一开始就不读取;工具执行后,结果要做摘要抽取,只保留匹配用户原始意图的关键信息;工具返回前,强制插入体积统计信息。经过这三道防线,上下文膨胀基本被控制住了。

5.4 跨平台路径与换行符差异

终端 Agent 注定要被开发者在各种系统上使用。rpi 在 Windows 上最常出问题的点集中在路径拼接和换行符处理。路径必须用通用的路径操作库,绝不能靠硬编码分隔符。换行符的问题更隐蔽:工具在 Windows 上输出的 CRLF 换行,如果原样塞进上下文,模型对代码块的解析会出奇怪的错。解决办法是在进入消息列表前统一做换行符规范化,文本进引擎,内容出来再按平台还原。

还有一个跨平台冷门坑:默认 shell 不一样。rpi 执行命令工具时要根据目标平台选择 shell 和参数形态,不能假设存在某个统一命令。这些细节不解决,插件工具在换系统后用不了,排查起来非常费劲。

写在后面

我在实际使用 rpi 的过程中最大的体会是:终端 Agent 的真正门槛不在"让模型聊天",而在"让模型稳定地跟真实系统打交道"。工具调用的协议设计、上下文的生命周期管理、终端状态的每一次切换,这些才是决定一个 Agent 好用还是难用的地方。rpi 把这个问题用 Rust 的工程化手段解了一遍,思路值得借鉴。如果你也在写类似的终端工具,强烈建议从最小的 REPL 循环开始,先把终端状态管理和流式输出跑稳,再逐渐加工具、加插件、加压缩策略——这些模块每一个单独拎出来都能做成独立文章,有机会我再逐块展开。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进