不少朋友拿到一个 Rust 写的 Agent 框架时,第一反应往往是:源码在眼前,却不知道从哪里开始读。好几万行代码,到处都是trait、异步 runtime、泛型约束,还有各种Arc<dyn ...>在飞来飞去,光看到类型签名就有点劝退。这篇文章不打算从架构顶层往下讲,也不准备把每个模块都过一遍,而是选一条最常走的路径——让一条普通消息从用户输入开始,在 pi-rust 里完整跑一圈,我们一路跟着它看下来,看每层代码到底在忙什么。适合有一定 Rust 基础、但还没系统读完过 Agent 项目源码的读者,也适合想快速给某个 Agent 项目做二次开发却苦于找不到入口的人。
我自己的经验是,源码阅读最大的坑从来不是“看不懂”,而是“不知道该看哪里”。Agent 框架尤其如此,因为它不是一条直线:主循环、工具注册、会话存储、上下文构建、观测上报、流式输出,这些模块互相引用,从main.rs逐行往下读,很容易在一个工具函数里陷进去半小时,然后忘了自己在追什么。这篇的思路就一句话:先跟着一条消息走,走完你就有地图了。
1. 拿到源码别急着从头读——先画出一条消息的主路径
1.1 为什么直接读源码会在 Agent 项目里迷路
传统 Web 服务代码读起来相对直观:请求进来,经过路由中间件,落到 handler,查数据库,返回 JSON。Agent 框架不太一样,它有一个核心循环,这个循环会根据大模型返回的内容决定下一步行动——可能是直接回复用户,也可能是解析出一堆工具调用,然后逐个执行、把结果回填进对话,再发给模型继续推理。这个循环本身就会在代码里形成多个分支,再加上会话持久化、工具调度、流式传输、日志埋点,模块之间的调用关系像一张网。
如果你拿cargo build完直接打开main.rs从头读,大概率会在某个地方被带偏。我就见过一个朋友读完tools目录里某个 HTTP 请求工具的实现后,开始研究起别人的 HTTP 客户端封装,回过神已经过去了一个多小时。读源码没有地图,就会发生这种事。
1.2 给一条消息画主路径的四步法
我总结了一套比较稳妥的“追消息”方法,直接说步骤:
| 步骤 | 做什么 | 目标 |
|---|---|---|
| 1 | 看 README 和 examples,挑一个最简单的 demo 主函数 | 找到入口函数和它接收的第一个真实输入 |
| 2 | 从入口函数出发,只追这一个输入的生命周期 | 认清入口如何调起核心处理逻辑 |
| 3 | 遇到分支先记录,不展开 | 区分主路和支路,避免迷路 |
| 4 | 把追过的调用链写成一列“从进到出”的步骤 | 得到一条可复述的消息流转路线 |
这个方法的重点在第三步:遇到分支先记录,不展开。流式输出是一个分支,工具执行是一个分支,错误重试又是一个分支。刚开始全部忽略,只有等主链路走通之后,再回头研究支路。
1.3 别急着读实现,先从函数签名和类型关系炼出主路
Rust 项目有个好处:类型和 trait 本身就是文档。我在看一个新 Rust 项目时,通常先看入口函数的签名长什么样。比如一个 Agent 的消息处理函数,常见的签名结构会是这样:
async fn handle_message( cx: &AppContext, session_id: &str, input: &str, ) -> Result<AgentResponse, AgentError>只看签名就能知道不少信息:它需要一个上下文(用来访问配置、存储、模型客户端),需要一个会话 ID(说明消息是挂在某个会话下面的),需要一段用户输入。返回的是一个 Agent 响应结构体。这样你还没读函数体,就已经知道这条消息大概要经过哪些依赖才能返回结果。
接下来看这个函数体里调用的那几个核心方法,把它们串起来,基本就是主路径。我当时会给这个方法涉及的每个重要结构体写一个一句话注释,比如“这是模型客户端的封装,主要作用是发请求、收响应”,不深入字段级细节。
1.4 在主路上标注“哪些模块是主线,哪些先不看”
追完入口后,我习惯画一个简单分类,帮助自己记住哪些模块属于主路、哪些属于支路。当时我给 pi-rust 这样归类:
| 模块 | 归属 | 理由 |
|---|---|---|
| 消息处理入口 | 主路 | 消息最先落在这里 |
| 会话与历史管理 | 主路 | 每次请求都要读取/更新 |
| 上下文组装器 | 主路 | 决定给模型发什么东西 |
| Agent 调度循环 | 主路 | Agent 的核心,必读 |
| 工具注册与执行 | 主路分支 | 常用功能,但可以稍后细看 |
| 向量存储/记忆 | 支路 | 不是每条消息都触发 |
| 流式输出通道 | 支路 | 非流式阅读时可跳过 |
| 各类事件上报 | 支路 | 不会改变消息流向 |
把这张表写下来之后,我后面读代码就只沿着“主路”走,偶尔进入“主路分支”,其他一概不碰。这个习惯帮我节省了大量时间,也推荐你试试。
2. 消息从入口落到 Agent 上下文:起点往往藏在 Session 里
2.1 会话不是“可选项”,而是消息携带的隐形状态
在 pi-rust 这类 Agent 框架里,一条消息并不是孤立的“你现在对我说了什么”,它天然属于某个会话。用户每次发消息,都会带上一个session_id(或者第一次建立会话时由服务端生成一个)。入口层拿到这个 ID 后,第一件事就是去存储里把该会话的历史消息捞出来。
这一步很关键,因为 Agent 之所以看起来“有记忆”,不是因为大模型记得你,而是框架在每次请求前把历史消息重新组装、发给模型。所以当你看到入口层代码里出现“加载历史”“反序列化之前的消息列表”“重建 session 状态”这类逻辑时,别跳过,它是主路的起点。
我读到的 pi-rust 实现中,会话管理器是一个独立的结构体,负责三件事:创建新会话、按 ID 查询会话、把新消息追加并持久化。你在追消息时,第一步先看它走了这三个方法中的哪一个;如果是一个老会话,那么大概率是“查询 + 追加”两步都走。
2.2 一个典型请求对象的字段结构,读源码时如何对照
虽然不同项目字段名不同,但 Agent 请求对象通常包含下面几类信息,你可以照着这些概念去源码里找对应结构体:
pub struct AgentRequest { pub session_id: String, pub user_input: String, pub stream: bool, // 是否流式返回 pub extra: HashMap<String, String>, // 附加参数,如温度/模型名覆盖 }我建议你在读 pi-rust 时,先花十分钟把它的请求结构体、响应结构体、消息枚举类型都找到,做一张对应表:
| 我在意的概念 | 在 pi-rust 里对应的类型 | 备注 |
|---|---|---|
| 用户输入 | 请求体里的某个字段 | 注意是文本还是结构化 |
| 会话 ID | 字符串字段 | 可能包装成 newtype |
| 历史消息 | 消息列表类型 | 由会话存储返回 |
| Agent 输出 | 响应体结构 | 可能包含文本和元信息 |
做这张表的目的是建立“术语映射”。否则你在看代码时,看到项目自己定义的Thought、Observation这类名称,会失去参照系。
2.3 入口层当黑盒看,不必陷入反序列化细节
很多 Agent 框架的入口是一个 HTTP 服务或者 REPL。对源码阅读者来说,HTTP 路由、JSON 反序列化、鉴权这些不是核心,我建议直接当黑盒跳过。真正的主路是从“拿到结构化请求体之后”开始的。你只需要确认一点:入口层最终调用了哪个核心函数,把请求传给了谁。记住这个函数的名字,它就是你的主线入口。
这一步做完,你手里已经握着一条明确的消息链起点:入口函数 → 会话加载 → 核心处理函数。
3. 给大模型的消息,是组装出来的而不是转发出来的
3.1 模型看到的不是你的一句话,而是一个消息数组
很多人第一次读 Agent 源码时会被“组装消息”这个过程惊到:原来发往大模型的根本不是一个字符串,而是一个messages数组,里面可能包含系统提示词、之前的多轮对话、当前用户输入,甚至还包括工具的定义和工具执行的结果。
我当时追 pi-rust 时,第一次看到它构造请求体的地方,确实花了不少时间。那段代码做的事情大概是:把系统 prompt 放在最前面;然后把 session 历史里面的消息映射成对话条目;再把当前输入追加进去;如果这个模型支持工具调用,还会把一串工具 schema 塞进请求里。串起来之后,才真正发给模型 API。
这个顺序通常很讲究:系统提示在最前,历史对话按时间排列,最后是当前问题。如果你在一个 Agent 项目里看到“消息组装”逻辑,建议认真看几遍,因为以后你想改 prompt 策略、加 few-shot 示例、调整工具描述,改的都是这块代码。
3.2 Token 预算与截断策略:消息太长怎么办
消息组装还有一个绕不开的问题:上下文窗口是有限的。Agent 框架一般会在组装完消息后做一个 token 估算,如果超了,就按策略裁剪历史。常见策略有几种:直接丢掉最早的对话;用摘要压缩前面的历史;只保留最近 N 条消息。
在源码里看到max_tokens、truncation、summary这类词时,基本就是在处理这个问题。我建议你读主路时,先搞清楚它默认用哪种截断策略就行,细节可以以后再看。真正影响你理解主链路的是:这条消息在发送给模型之前,经过了哪些准备步骤,而不是每一步具体怎么算。
3.3 上下文组装器是理解和改造 Agent 行为的关键切入点
如果你以后想在 pi-rust 上做定制,比如给 Agent 加一个系统级别的人设、改变工具描述格式、调整历史窗口策略,大概率会改到“上下文组装器”这块。它很像水管工手里的接头,一边接着存储层的历史数据,一边接着模型客户端的请求格式。读源码时把这个模块单独划出来,用高亮标记一下,等你要动手改需求时,它会是你的主要工作区。
我当时读完这块之后做了个练习:手动在纸上写出“一次请求发给模型的 messages 最终长什么样”。能写出来,说明你真的看懂了这段代码;写不出来,说明中途有些分支还没理清。这个练习很值得做。
4. Agent 主循环:一个循环就能解释“智能”的大部分秘密
4.1 为什么 Agent 不是“一次调用”而是“循环调用”
第一次接触大模型 API 的人,想法通常很简单:把用户问题发过去,等一个回答,返回给用户。Agent 框架不是这样的,它的核心是一个循环,这个循环不断重复以下步骤:
- 把当前完整的消息列表发送给模型;
- 模型返回“直接回答”或者“工具调用请求”;
- 如果是直接回答,就结束循环,返回给用户;
- 如果是工具调用,就去执行工具,把结果作为一条新消息回填进对话;
- 回到第 1 步,再次调用模型。
这个循环就是 Agent 的“思考 + 行动”过程。模型本身是无状态的,它只负责根据你给的消息生成下一步动作;框架则负责把动作落地、把结果喂回去,直到模型觉得可以给出最终答案。源码里体现出来的往往是一个while循环,循环里嵌套着一个大模型调用。
4.2 主循环的骨架长什么样(伪代码示意)
pi-rust 的主循环虽然经过层层封装,但骨架基本可以用下面的伪代码表示:
let mut messages = build_messages(...).await?; for step in 0..max_steps { let response = model_client.complete(&messages).await?; match response { ModelReply::Text(text) => { return Ok(text); } ModelReply::ToolCalls(calls) => { for call in calls { let result = tool_registry.execute(&call).await?; messages.push(result.as_message()); } } } } return Err(AgentError::MaxStepsReached);看到这个骨架,你就会明白几个关键点:循环退出条件有两个,一个是模型直接给文本,另一个是达到最大步数;模型返回工具调用时,框架不立即给用户任何东西,而是去执行工具并继续循环。所以你在读任何 Agent 项目时,只要找到这个循环,主链路的核心就抓住了。
4.3 读循环时重点看三个地方
第一,看消息是怎么增长的。工具调用结果被 push 进消息列表之后,下一轮模型就能看到这些新信息,这是 Agent 能够“反馈修正”的基础。
第二,看步数上限在哪设置。max_steps或max_iterations这类参数通常可以在配置文件或者请求参数里修改,它决定了一个任务最多能循环几轮,防止让 token 成本失控。
第三,看错误处理分布在哪个层级。工具执行报错之后,是直接终止整个 Agent,还是把错误文本作为消息喂回模型让模型修正?我见过的大多数实现会选后者,因为这样 Agent 更有机会自愈。但这也导致一个结果——错误处理逻辑遍布循环各处,读代码时看到Err分支不必紧张,大部分是“策略分支”而不是“崩溃分支”。
4.4 流式输出可以先忽略,不要让它打断主线
很多 Agent 框架支持流式输出:模型逐个 token 吐出来,后端通过 websocket 或者 SSE 推给前端。流式会让代码复杂度上一个台阶,因为主循环里每个模型调用都可能变成一个 Stream 对象,后面跟着一堆处理帧的逻辑。
我读 pi-rust 时,在入口处直接选择非流式路径,绕开了大部分流式代码。如果代码里因为流式而把主路拆得比较碎,我的做法是先找“非流式分支”或者“收集完整响应”的函数。等主线读完,再回头扫流式部分,会发现它其实是把同一个响应切成小块推送而已,本质并不难。关键是别在一开始就被流式带偏。
5. 工具调用子链:消息从这里跳出去,再跳回来
5.1 工具注册表与 trait 对象,Rust Agent 项目的经典抽象
Agent 循环里最精彩的部分就是工具调用。当模型返回一个tool_calls数组时,框架需要找到对应工具并执行。在 Rust 项目里,工具一般会被抽象成一个Tooltrait,然后各种各样的具体工具都实现这个 trait,最后被注册进一个工具注册表:
#[async_trait] pub trait Tool: Send + Sync { fn name(&self) -> &str; fn description(&self) -> &str; async fn run(&self, args: serde_json::Value) -> Result<String, ToolError>; }工具注册表通常是一个HashMap<String, Box<dyn Tool>>,查询时按名字取出来,然后调用run。看到Box<dyn Tool>时不要发怵,它只是一个“装着各种工具对象的盒子”,作用是让你可以统一调用,但具体执行逻辑还是落到每个工具自己的实现里。
5.2 一条 tool_call 的完整生命周期
我在 pi-rust 里顺着一个tool_call走了一遍,发现它要经历好几个转换阶段:
- 模型返回的原始 JSON 里有一个
tool_calls数组,每个元素包含id、name、arguments; - 框架解析出工具名和参数;
- 从注册表里查出对应工具;
- 把 arguments 反序列化成工具需要的强类型参数;
- 调用工具的执行函数;
- 把返回结果包装成一条代表“工具消息”的内容;
- 把这条消息 push 进对话消息列表;
- 下一轮循环时继续发给模型。
其中最麻烦的是第 2 和第 4 步:参数解析。因为模型生成的 arguments 是字符串形式的 JSON,而且偶尔会不符合预期结构。框架如果解析失败,通常不会直接崩溃,而是把错误信息也当作工具返回结果回填给模型,让模型自行修正。这解释了为什么源码里会出现大量serde_json::from_value和unwrap_or之类的代码。
5.3 工具结果是怎样“喂”回模型的
工具执行完,返回的可能是普通文本,也可能是结构化 JSON。比如一个搜索工具,可能返回一段搜索结果;一个计算器,可能返回一个数字。为了让模型在下一轮循环里“看到”这次结果,框架会把结果封装成一个带有角色标识的消息条目,比如role: "tool",再把这个条目追加到消息列表里。这个过程在主循环代码里通常只有几行,但它把“外部世界”和“模型推理”连接起来了,是整个 Agent 能够真正做事的核心环节。
我建议你阅读时,重点观察两个细节:结果消息里包不包含工具名或调用 ID,以便模型把多个工具的结果对应起来;失败的时候是直接返回错误还是作为正常消息回传。不同框架在这些细节上的取舍,会直接影响 Agent 的稳定性。
5.4 工具遍历别全读,挑一个简单的工具看完即可
pi-rust 里通常会内置不少工具:网络搜索、文件读取、时间获取、数学计算等等。你不需要把所有工具实现都读完。挑一个最简单的,比如“获取当前时间”或者“计算表达式”,把它的参数结构、执行函数、返回结果格式摸清楚,你就明白了“一个工具在代码里长什么样”。其他更复杂的工具无非是在这个模式上加了更多业务逻辑。
我当时的做法是:找到工具注册的入口,看注册顺序和名字;然后只精读一个简单工具;其余工具在需要的时候再回家线回来翻。这样既不会让自己陷进工具海洋,又拿到了足够的信息去理解主循环。
6. 读不进去的时候,用日志、测试和断点代替肉眼看代码
6.1 开日志追事件流,比逐行读代码快得多
如果你切实地对着源码读了两小时,还是觉得一团乱麻,我建议换个方式:让程序自己把路径走给你看。很多 Rust Agent 项目都接了tracing或log,你只需要在启动 demo 前设置环境变量打开 debug 级别日志。以 tracing 为例:
RUST_LOG=trace cargo run --example basic_chat然后你在终端里随便发一条消息,比如“今天天气怎么样”,会看到大量日志输出:会话加载耗时、消息组装完成、请求已发送到模型、模型返回了一个 tool_calls、正在执行搜索工具、工具返回结果、重新组装消息、再次请求模型、最终输出文本。这些日志就是主路径的脚印。你只要把关键节点记下来,再回头对照源码,立刻就能定位到每一段日志对应的代码位置。这个方法对我来说是最有效的“代码导航”。
6.2 集成测试是隐藏的源码地图
另一个经常被忽略的资源是测试代码。Agent 项目的tests目录里通常有集成测试,它们会用真实的模型客户端或者 mock 服务端跑整条消息链路,然后断言响应结果。看测试之前你可能不知道“某条消息应该引发哪几步”;看测试之后,你就知道“这个项目期望一条消息怎么被处理”。
比如一个测试可能这样写:构造一个带工具的 Agent,发送一条请求,断言最终回复包含某个关键词,并且工具确实被调用过。通过这个测试的代码路径,你能直接看到“入口 → 循环 → 工具执行 → 输出”的完整调用链。测试代码往往比源码更简洁,因为测试关注的是行为而不是内部实现细节,特别适合用来建立整体认知。
6.3 用 IDE 的调用层次和断点在关键函数处观察变量
如果项目日志不太完整,还可以借助 IDE。我在读 pi-rust 时,会在之前找到的“核心处理函数”入口打一个断点,然后以调试模式跑一条消息。断点第一次停下时,看参数里 session_id 和 input 的值;继续进入循环后,在每次“模型响应处理”那里再打一个条件断点。这样你能亲眼看到 messages 数组怎么逐轮增长、工具结果什么时候被加进去、最终模型是在第几步决定直接回复的。
有条件断点时尤其方便。比如你想知道“当模型返回工具调用时,代码走在哪个分支”,直接在分支处加断点,条件设置为模型响应里带tool_calls,这样不是每次循环都停,只在关键事件发生时停。调试过程虽然比“肉眼看代码”慢,但它带来的理解深度是读代码给不了的。
6.4 永远从“我要解决什么问题”反推要读哪一段
最后想分享一个心态上的技巧:不要抱着“把源码读完”的目标去看 Agent 项目。源码只是手段,你的目标通常很具体——可能是换掉默认的对话记忆策略,可能是加一个自定义工具,可能是让 Agent 支持流式输出。一旦明确了这个目标,你其实只需要精读主路径上的一小段,其他都可以跳读或者完全不看。
我自己后来给 pi-rust 加自定义工具时,只重点读了三块:工具 trait 怎么定义、注册表怎么初始化、工具结果如何回填消息列表。加起来不到一千行代码,就完成了需求。这不是我记性好,而是因为“跟消息走一遍”让我把这些代码在脑子里的地图位置记住了。
7. 跟完一条消息之后,你手里握着什么
走完这一圈,你对 pi-rust 应该有了一个清晰的坐标:入口层负责接收和解析请求;会话层负责维持历史;上下文组装器负责把历史、当前输入和工具定义打包成模型需要的形式;主循环是 Agent 的发动机,决定何时调模型、何时执行工具、何时结束;工具注册表是工具箱,按名取用。这条链路上的每一步,都能对应到源码里的具体文件和函数。
我不会在这里给你列“必读文件清单”,因为不同版本的项目文件组织差异不小,列清单反而限制了你的判断。我更希望你记住的是“跟消息走”的这个过程本身——它是通用的,换一个 Agent 框架也照样适用。往后你拿到新项目,第一反应不再是恐惧于它有多少代码,而是下意识地去找一条最小消息路径,然后从入口开始散步。我在读 pi-rust 过程中最大的收益其实不是读懂了某一个细节,而是建立了一套自己的源码阅读方式:先地图,后细节;先主线,后分支;先跑起来,再死磕。这套方式,才是真正可以长期复用的资产。