使用 Rust 开发 AI Agent:ExecutionContext、Event 与运行时循环实现
本节将上一节的 AI Agent 概念落地为 Rust 代码:用 UUID v4 生成 Event 与 ExecutionContext 的 ID,定义 content item、Event 和 ExecutionContext,改造 Tool trait,并在
runtime.rs中实现带工具调用、步数控制和消息组装逻辑的 Agent 循环,最终通过 example 验证了最大步数 8、实际执行 5 步、记录 12 条 Event 的过程。
背景与目标
- 杨旭继续讲“使用 Rust 开发 AI agent”。
- 本节目标是把上一节讲的概念用代码实现出来。
- 首先需要添加
create uuid,并启用它的v4feature。 - 上一节讲到有
event,每个event的 ID 使用 UUID 来生成。 - 这次要建一个
agent模块,把与 agent 密切相关的东西都放到这个文件夹里。 - 在
agent文件夹里,先做execucontext,也就是执行上下文。 - 建立两个文件:
event.rs和context.rs,别忘了加模块。 - 作者建议读者也按讲解顺序敲一遍代码;虽然视频里因为耗时太长是贴代码,但这些代码 AI 都能写,最好还是自己敲一遍。
核心要点
- 用
create uuid的v4feature 生成 Event 和 ExecutionContext 的 ID。 - 定义
content item枚举,表示与大型模型交互的三类内容:message、工具调用、工具执行结果。 - 使用
serde tag = tag标注,让枚举转 JSON 时自动增加type字段,而不是产生嵌套结构。 - 定义
event结构体,记录“谁在什么时候做了或说了什么”,并关联execucontext的 ID。 - 定义
ExecutionContext结构体,包含执行 ID、事件集合、当前步数计数器、临时状态state、最终结果final result,用于防止死循环并保存最终答案。 - 在
agent.rs中用pub use重新导出模块内容,减少引用层级。 - 修改 Tool trait,在
execute执行方法中加入 ExecutionContext 参数;所有工具实现都要相应修改,参数暂时不用,因此加下划线。 - 创建
runtime.rs,实现AgentResult、Agent以及核心run函数,把原来track complete里的东西和 agent 循环都放在这里。 run函数负责创建执行上下文、添加用户输入、组装消息、调用模型、处理工具调用、执行工具、记录事件、更新步数,并在无工具调用时收尾返回最终答案。- 通过 example
agent run验证:最大步数设为 8,实际走了 5 步,记录 12 条 Event。
详细解析
UUID v4 与 Event ID
- 需要添加
create uuid,并启用v4feature。 - 上一节讲到
event,每个event的 ID 使用 UUID 生成。 - 在
event结构体的new函数中,自己的 ID 用 UUIDnew出来,时间戳取当前时间戳。
content item 枚举:三类交互内容
content item 枚举用来表示与大模型交互的内容。之前在写 track complete 时,和大模型对话的交互内容主要有三类,因此这里用枚举变体表示。
| 变体 | 含义 | 字段 |
|---|---|---|
message | 普通文本信息,例如用户发给大模型的提示词,或 AI 给出的回答 | 角色(用户、大模型/AI/agent)、content 具体消息 |
| 工具调用申请 | 大模型调用工具的申请,即 track complete 里的 tool call | tool call ID、工具名称、参数 |
| 工具执行结果 | 工具调用返回的结果,即 tool result | 工具调用的 ID、名称、结果成功还是发生错误(又有一个枚举)、具体内容字段 |
message变体用于普通文本消息,角色表示是用户、大模型、AI 还是 agent,content是具体消息。- 工具调用变体需要 tool call ID、工具名称和参数三个字段。
- 工具执行结果变体需要工具调用的 ID、名称,以及结果到底是成功了还是发生错误,这里又有一个枚举,然后还有具体的内容字段。
- 这个枚举表示三类跟大模型交互的内容。
关于 serde tag = tag 标注或注解:
- 在 Rust 代码中建立这样一个消息,加上标注之后,转成的 JSON 会多一个
type字段。 - 它把变体
message变成一个type,相当于自动加了标签。 - 如果不加这个标注,不加这一行,结果就是一个嵌套的结构,原文描述为“嵌套的这么一个 z”。
event 结构体
event 结构体表示执行过程中发生的每一次事件或记录,即“谁在什么时候做了或者是说了什么”。
| 字段 | 含义 |
|---|---|
| ID | 用 UUID 生成的 event ID |
execucontext ID | event 最终属于 ExecutionContext,属于执行上下文的一个字段,因此把执行上下文的 ID 也列到字段里,表明关系 |
time stamp | 时间 |
content | 内容,说了或做了什么 |
new函数是关联函数。- 传入参数包括:
execucontextID、人、以及你说了或做了什么。 - 自己的 ID 用 UUID
new出来,时间戳使用当前时间戳。
ExecutionContext 结构体
context.rs 中的 exccontext 结构体,也就是执行上下文,字段如下:
| 字段 | 含义与作用 |
|---|---|
| 执行 ID | 每次执行生成一个 ID,用 UUID 生成 |
| 事件集合 | 把每次执行可能产生的很多 event 都放到这个集合里 |
current step | 当前执行步骤的计数器,记录 agent 当前已经执行到第几轮“思考行动、推理行动”循环 |
state | 存放临时数据、自定义数据,用于在工具之间共享,可以跨函数、跨步骤传递 |
final result | agent 完成任务后给出的最终答案或输出,是 Option 类型 |
重点说明:
current step的作用是防止死循环,控制步数上限。- 因为大语言模型有时候会陷入重复调用工具的死循环,所以需要这个计数器。
state的类型是serde_json value,可以存字符串、数字数组,甚至复杂的 JSON 对象,它充当 agent 临时的工作内存。final result是Option类型:- 如果是
None,表示任务还在进行中,或者出错了未能生成结果。 - 如果是
Some,表示任务结束了,里边包含最终呈现给用户的答案。 - 关联的
new函数随后实现。 - 还实现了
default,这个 trait 其实就是调用它的new。 - 另外有两个比较方便的方法:
- 添加 event 的方法,原文此处转写为“添加1万的爱了一万的”,对应添加
event。 - 增加步数的方法:每完成一轮“think at 推理行动”循环,当前步数就加一。
agent 模块导出与 Tool trait 改造
- 在
agent.rs中做一个动作:把刚才建立的那些内容使用pub use重新导出,省得引用它们的时候层级太多。 - 接下来修改 Tool trait,也就是原文“to 这个 trait”。
- 在
execute执行方法里,把 ExecutionContext 作为参数加上。 - 相应的工具实现都需要改一下。
- 这个参数暂时先不用,所以参数前加了一个下划线。
- 改完后没有错误了。
- 还有
complete,作者说后续就不用了,但现在还是先把它改过来,先让它没有错误。 - 到这里,工具 trait 完善完了,把
execu作为参数加到了execu方法里。
runtime.rs:AgentResult、Agent 与 run 循环
在 agent 文件夹创建一个 runtime.rs,把 track complete 那些东西,包括 agent 循环以后都放在这里,以后就用这个。别忘了模块。代码比较多。
AgentResult
struct AgentResult是每次 agent 执行的结果。- 它包括最终给出的答案,以及
execucontext执行上下文。 - 执行上下文主要是用来调试的。
Agent
agent 顾名思义就是我们的 agent,给它组装成一个 struct。字段包括:
| 字段 | 含义 |
|---|---|
model | 你选哪个模型 |
instruction | 系统提示,就是给大模型的系统人设 |
toolbox | 工具箱 |
| 最大执行步数 | 与执行计数器相对应,如果执行计数器超过它,那就得停止 |
- 给它做了一个
new关联函数,默认最大的步数就是 10。 - 有一个比较方便的方法:设置最大步数
with max step,把它的字段设置成你传进来的值。 - 主要的核心是
run函数。
run 函数主流程
run 函数就是当用户给你一个提示词时,应该跑一下的函数。这里把之前 track complete 里边那些东西放到这里了,并根据上一节讲的进行了优化。
一步一步来看:
run首先创建一个exccontext执行上下文。- 第一步把用户的输入作为一个 content 添加进去,类型是
message,就是消息,然后包裹在event里边。 - 后边跟之前
chat complete差不多,创建一个async OpenAIclient,然后把工具提取出来,也就是能用的工具。 - 进入 agent 的循环。
- 因为它是循环,首先要判断当前步数是否大于我们设置的最大步数。
- 如果大于等于它,就报个数,表示已经超过最大步数。
- 然后调用
build_message方法,把context传进去。它用来组装上下文这些消息。
build_message 函数
build_message 的核心职责是:把存在 execucontext 里边所有的 event、所有历史记录,翻译成 OpenAI 要求的标准对话格式,即 vector 的 request message。
实现逻辑:
- 先一个
message一个vector。 - 判断
self,也就是这个 agent:
- 如果它有系统提示词,就把系统提示词加进去。
- 然后循环它的
event。 - 每个
event里边可能有很多内容,再循环这些内容,处理三类内容:
- 如果是消息类的内容,查看它的角色:
- 如果角色是用户,就添加
user message。 - 否则,就添加
assistant的 message。 - 如果是工具调用:
- 首先组成一个
track complete message的 tool call 结构体,原文称为to call。 - 由于同一轮里边模型可能一次请求调用多个工具,这些 tool call 要合并进一条 assistant 的消息里边,否则 API 就会认为这是好几条不同的助手消息。
- 逻辑如下:
if分支:如果发现最后一条消息已经是 assistant 发出来的消息,就说明上一个content item也是同一次思考产生的工具调用,所以这时候不创建新的消息,而是通过get or insert with这种写法,直接把当前 tool call 追加到上一条的 tool calls 列表末尾。else分支:如果最后一条消息不是 AI、不是 assistant 的消息,比如上一条是用户发的消息或者是工具反馈的结果,就说明这是一个新轮次的工具调用。这时候会创建一条新的 assistant 消息,并把这个 tool call 作为第一个元素,放入列表的第一个元素。- 第三类内容是工具调用的结果:
- 这里只用到两个字段,其他的就用两个点忽略。
- 然后添加这种
to message到消息里面,也就是 tool message。
- 三种情况都处理完后,循环结束,把消息组装完的消息返回回去。
记录工具调用
回到 run 函数,这一轮最新的消息已经组装完了,把历史都加进去了,然后发送请求。这里设置了一个最大的 token 是 2048。然后得到响应,从响应里边得到 message,再判断是否有工具的调用。
如果有工具的调用,这里提取了另外两个方法:记录工具调用,以及调用具体的工具。
先看第一个方法:记录工具调用。
- 首先,这次请求工具的调用可能发生多个工具调用。
- 所以有个
waker,然后遍历 tool calls 每个 call。 - 如果是这种 function 函数类型的,就把它组成一个
content item,加到容器里边。 - 这个
content item包含 tool call ID、name 和参数。 - 循环完之后,再建立一个新的
event。 - 这个
event的 author 就是谁干的、谁执行的,这里填agent。 - 它的内容就是这些 tool call。
- 最后添加到
execucontext里边。 - 这就是记录工具调用。
执行工具调用
再看执行工具调用。它跟以前的不同,主要是把 ExecutionContext 传给每个工具。虽然目前还没用它,但这个接口已经留好了。
逻辑如下:
- 先创建一个结果的容器。
- 循环 tool calls。
- 判断每个 tool call:
- 如果是 function 类型的,就把它的函数名、参数提取出来。
- 然后记录一下日志。
- 取得相应的工具。
- 如果没取到工具,返回一个元组,里边包含这个枚举的错误和错误信息。
- 如果取得到工具了,就执行这个工具,即
tool.execute,传入context。 - 然后看执行的结果。
- 如果 ok,就返回一个元组,它是 success。
- 如果工具的结果发生错误,就返回错误这个枚举和一个信息。
- 最后再把它组成一个 tool result 的 content item,包含 tool call ID、函数名、状态成功还是错误,以及具体的结果内容。
- 这两个内容分别是元组的第一个和第二个元素。
- 最后再添加一个
event,这个event的 author 是工具,它的内容就是刚才组建的 result item。
无工具调用分支与收尾
回到 run 函数,如果大语言模型没有申请工具的调用,它就会走到 else 分支。这个 else 分支就是直接给出最终答案,程序进入这个分支就完成收尾并退出循环。
简单看一下:
- 首先看看到底有没有答案。
- 如果没有答案,就报个错。
- 如果有答案:
- 也添加一个 event,这个 event 的作者是 agent。
- 它的 content 是
message类型,角色是 AI,或者叫 assistant。 - 把它的 content 传给 content 字段。
- 而 ExecutionContext 的最终答案字段就是这里的 content。
- 最后返回
agent result,即最终 content 以及 context。 - 这个时候循环就退出了。
- 如果循环没有退出,别忘了在这里把计数器加 1。
- 这就是
run函数最核心的部分。
示例 agent run
接下来还是来到 agent.rs,再做一个 pub use,把 runtime 中的 agent 和 agent result 重新导出一下。然后来到 example,再创建一个 example,叫 agent run。把代码先添进去,然后来到 Cargo.toml,别忘了添加 example。
简单看一下这个 example:
- 前边这些都没有什么问题。
- 系统提示包括当前时间。
- 调用方式变了:这回调用
runtime里边的Agent。 - 先一个
agent,还有一个模型,然后系统提示或者叫指令,然后工具,列式调用最大的步数。 - 先设 8,看看能不能执行完。
- 接下来调用
agent run这个函数。 - 用户的输入跟上一节是一样的。
- 跑完之后看看它能不能分析出来结果,最终再打印一下
execucontext。 - 跑之前别忘了先把费用管理系统给跑起来,然后跑这个 example。
案例与数据:运行结果
- 在最大步数设为 8 的情况下,程序已经给出最终答案。
- 八步跑完了,本次执行一共走了 5 步。
- 记录了 12 条 Event,没有问题。
- 查看了 ExecutionContext ID,然后 event 序列如下:
| Event 序号 | 内容 |
|---|---|
| 第 1 个 event | 用户提示 |
| 第 2 个 event | 工具调用,这里边发生了四次工具调用 |
| 第 3 个 event | 结果,应该是四个结果 |
| 第 4 个 event(原文转写为“第1111万”) | 又一次工具调用,调用到计算器 |
| 第 5 个 event | 计算器的结果 |
| 第 6 个 event | 又是工具调用,又调计算器 |
| 第 7 个 event | 它的结果 |
| 第 8 个 event | 又是调计算器 |
| 第 9 个 event | 结果 |
| 第 10 个 event | 又是工具调用,调计算器 |
| 第 11 个 event | 它的结果 |
| 第 12 个 event | 最终答案:购买分析,具体内容原文未展开 |
最终答案原文描述为“购买分析啊怎么怎么怎么怎么样”。本节代码先实现到这里。
限制与待确认问题
- 原文是视频讲解转写,存在语音识别或转写错误,例如
create uu ID、U悠ID、execucontext、to call、to result、track complete、asthink、waker、第1111万等,需要结合上下文确认。 - ExecutionContext 虽然已经传给每个工具,但作者明确说目前还没用它,接口已经留好。
complete后续不再使用,但当前仍然先改过来,让它没有错误。- 发送请求时设置的最大 token 是 2048。
- Agent 的默认最大步数是 10;example 中先设为 8。
- 本次运行实际走了 5 步,最大步数限制未触发。
- 运行 example 之前,需要先把费用管理系统跑起来。
- 原文没有展示完整代码,也没有给出完整的用户输入和最终答案细节。
- 关于
serde tag = tag不加标注时“嵌套的这么一个 z”的具体结构,原文没有进一步展开。
行动清单
- 添加
create uuid,并启用v4feature。 - 建立
agent模块和agent文件夹。 - 在
agent文件夹中建立event.rs和context.rs,并添加模块声明。 - 实现
content item枚举,覆盖message、工具调用、工具执行结果三类内容。 - 为
content item加上serde tag = tag标注,使 JSON 序列化时增加type字段。 - 实现
event结构体及其new函数,使用 UUID 生成 event ID,并关联execucontextID。 - 实现
ExecutionContext结构体,包含执行 ID、事件集合、current step、state、final result。 - 为
ExecutionContext实现new、default、添加 event、增加步数等方法。 - 在
agent.rs中使用pub use重新导出相关模块内容。 - 修改 Tool trait,在
execute方法中加入 ExecutionContext 参数。 - 修改所有工具实现,暂时不用的参数加下划线;顺带把
complete改到没有错误。 - 创建
runtime.rs,实现AgentResult、Agent、run、build_message、记录工具调用、执行工具调用等逻辑。 - 在
agent.rs中再次pub use,导出runtime的Agent和AgentResult。 - 创建 example
agent run,并在Cargo.toml中添加 example。 - 在 example 中设置系统提示(包括当前时间)、模型、工具和最大步数 8。
- 启动费用管理系统,运行 example,检查最终答案、执行步数、Event 数量和打印出的 ExecutionContext。
总结
本节把上一节关于 AI Agent 的核心概念真正落地成 Rust 代码。实现重点包括:用 create uuid 的 v4 feature 生成 Event 和 ExecutionContext 的 ID;定义 content item 枚举来描述与大模型交互的三类内容;通过 serde tag = tag 控制 JSON 序列化结构;实现 event 和 ExecutionContext 来记录执行过程、控制步数、保存临时状态和最终结果;改造 Tool trait 使工具能够接收执行上下文;在 runtime.rs 中实现 Agent、AgentResult 以及核心 run 循环,包括消息组装、工具调用记录、工具执行、结果回填和最终答案收尾。最后通过 example agent run 验证:在最大步数 8 的设置下,实际执行 5 步,产生 12 条 Event,并成功输出购买分析结果。