从 0 到 1 构建 Agent:为什么需要 Agent Loop,核心代码怎么写
如果你已经接通了大模型 API,做出了一个能聊天的小应用,下一步可能就想让它帮你干点事:读一下项目文件,改一处配置,再跑个命令看看结果。
但从“能聊天”走到“能执行”,中间那段代码该怎么写?模型已经返回了工具名称和参数,是直接调用函数就行,还是要再请求一次模型?网上讲 Agent 的文章不少,真要自己动手时,往往还是卡在这里。
上一篇聊了用 Rust 重写 Pi,以及启动时间的测试。这篇就顺着这个问题看实现。我们选一个能亲手检查的任务:让 Agent 把文字写进文件,再告诉我们执行结果。
从调用一次模型开始
如果已经接过大模型 API,下面这个流程应该很熟悉:
用户输入 → 请求模型 → 显示回答让模型解释代码、总结文章,这样就能工作。
但把请求换成“把 hello 写进 out.txt”,问题就来了。模型可以生成写文件的代码,也可以返回一个结构化的工具调用,但文件不会因为返回了这段内容就自动出现。
你可以打开目录检查一下:模型回复里说了“怎么写”,文件却还没有出现。执行这一步,要由我们的程序接上。
假设给模型提供了一个 write 工具,告诉它需要 path 和 content 两个参数。它可能返回这样的调用。这里是便于阅读的示意,不是某家服务的完整 API 格式:
{
"id": "call_001",
"name": "write",
"arguments": {
"path": "out.txt",
"content": "hello"
}
}拿到这个 JSON,你可能会觉得接下来很简单:按名称找到函数,传入参数,写文件就好了。
所以还要把结果回传给它。写入成功,它可以回复完成;路径不存在或者没有权限,它也有机会根据错误继续处理。
一次模型调用,开始变成了几次调用之间的往返。
一个 Agent 至少要考虑什么
开始写的时候,很容易只盯着“调用模型”和“执行函数”这两段。等第一次遇到工具报错、消息丢失或者任务停不下来,才发现还有一些状态没人管。我们先用这张表检查一下,写文件这个任务需要谁负责什么。
| 要考虑的事 | 在写文件任务里是什么 |
|---|---|
| 模型接口 | 发出请求,接收文本或工具调用 |
| 上下文 | 保留用户请求、模型调用和工具结果 |
| 工具定义 | 告诉模型 write 是什么,参数怎么填 |
| 工具执行 | 查找实现、验证参数、实际写入文件 |
| 继续与结束 | 工具执行后再问模型,得到最终回答后结束 |
| 错误和取消 | 参数错了、模型断流了、用户要求停止,分别怎么办 |
如果要把它做成可用的交互工具,还需要进度事件。否则命令跑了半分钟,界面上什么都没有,用户很难判断它是在工作还是卡住了。
看到这些名词,可能会觉得一个小 Agent 也太麻烦了。这里可以先收住范围:第一版只要求任务跑完、结果能对上、失败时知道停在哪里。会话持久化和上下文压缩,等这条流程通了再加。
为什么需要 Agent Loop
把写文件的完整过程画出来:
用户:把 hello 写进 out.txt
↓
模型:调用 write(path, content)
↓
程序:执行 write,得到结果
↓
把工具结果加入上下文,再请求模型
↓
模型:文件已写入
↓
结束如果任务永远只有“写一次文件”,把这些步骤顺着写下来就够了。但你大概很快会想让它“先读配置,再修改配置,最后跑检查”。这时检查可能失败,模型还得根据错误再改一次,往返次数就不固定了。
因此需要一个循环,反复做三件事:请求模型,执行工具,把结果交回去。每一轮都要判断还能不能继续。
先看一个简化版。下面是教学伪代码,省略了流式事件、取消和钩子,不能直接编译:
loop {
let reply = call_model(&messages, &tools).await?;
let calls = collect_tool_calls(&reply);
messages.push(reply);
if calls.is_empty() {
break;
}
for call in calls {
let result = execute_tool(call).await;
messages.push(result);
}
}这里可以停一下,看 messages.push(reply)。第一次写很容易只保存用户输入和工具结果,觉得模型的调用已经执行过,就不用留了。但下一次请求需要知道“谁要求执行了什么”,结果里的调用 ID 也要能对应到前面的请求。所以这条 assistant 消息同样要保留。
这个循环也没有替我们解决无限重试。模型可能反复调用失败的工具,或者一直认为任务没有结束。实际应用还需要轮次、时间和费用预算,以及能从外部取消的入口。
接下来把这段伪代码,对照到 pi-rust 的实现。
先跑通一个能写文件的例子
光看循环,可能还没有“它真的执行了”的感觉。可以先跑仓库里的 examples/tools,看一次文件写入的结果,再回头读源码:
git clone https://github.com/bigfish1913/pi-rust.git
cd pi-rust
cargo run --locked -q -p tools-example本次运行得到的业务输出如下,编译警告没有放进来:
tools example: 4 messages after run
wrote out.txt (28 bytes): "hello from the tools example"
AgentEnd observed; tool ran against OsExecutionEnv.这里用的是 FauxProvider,也就是预先写好响应的模拟模型,不需要 API key。工具是真执行的,文件写在临时目录,示例读取并检查内容后会清理目录。首次构建可能需要下载依赖。
四条消息对应:
User:写入文件
Assistant:发出 write 工具调用
ToolResult:工具执行结果
Assistant:最终回复这四条消息可以当成后面的阅读地图。如果源码里类型太多,一时看乱了,就回到这里:用户输入之后,是模型调用、工具结果,最后才是回复。这个例子使用模拟模型,验证的是运行时把这些步骤接起来了。
示例创建工具时,先给出执行环境:
let env = Arc::new(OsExecutionEnv::with_cwd(tmp.clone()));
let env_dyn: Arc<dyn rpi_tools::ExecutionEnv> = env.clone();
let mut_env: Arc<dyn rpi_tools::MutatingEnv> = env.clone();
let ctx = ExecutionToolContext::new(env_dyn, Some(mut_env));
let write_tool = create_write_tool(&ctx);
let bash_tool = create_bash_tool(&ctx, None);OsExecutionEnv 使用真实操作系统,tmp 是示例前面创建的临时目录。Arc 让这些对象可以共享;dyn ExecutionEnv 表示调用方通过接口使用它。
这里还创建了 bash 工具,但本次脚本只调用 write。注册了一个工具,不等于这一轮会执行它。
然后安排模型的两次响应:
let script = FauxScript::new()
.with_tool_call(
"write",
serde_json::json!({
"path": "out.txt",
"content": "hello from the tools example"
}),
)
.with_text("Done — wrote the file.");
let provider = FauxProvider::new(script);
let model = provider.default_model().clone();最后把模型入口和工具放进 Agent:
let agent = AgentBuilder::new()
.model(model)
.stream_fn(make_stream_fn(provider))
.tools(vec![write_tool, bash_tool])
.build()
.expect("agent builds");这些是工具示例中的片段。完整文件还包含 import、事件订阅、prompt、文件断言,以及 make_stream_fn,运行时请使用完整示例。
make_stream_fn 在这个例子里负责桥接 Provider 的异步接口和同步返回流的 StreamFn。它使用 Tokio 的 block_in_place 和 block_on;先把它当成示例适配器即可,不要把它照搬到所有异步应用中。
第一步,把新请求放进上下文
主入口在 run_agent_loop。函数签名如下:
pub async fn run_agent_loop(
prompts: Vec<AgentMessage>,
context: AgentContext,
config: AgentLoopConfig,
emit: Arc<dyn AgentEmitter>,
stream_fn: StreamFn,
) -> Result<NewMessages, AgentError>prompts 是这次输入,context 带着已有历史和工具,config 管模型及运行选项。emit 用来发布进度事件,stream_fn 是模型调用入口。
进入函数后,先做这件事:
let mut new_messages: Vec<AgentMessage> = prompts.clone();
let mut current_context = AgentContext {
system_prompt: context.system_prompt.clone(),
messages: {
let mut v = context.messages.clone();
v.extend(prompts);
v
},
tools: context.tools.clone(),
};这里有两份消息集合,作用不同。
如果你在这里疑惑“为什么要放两份消息”,区别在于范围:current_context.messages 是已有历史加上本次输入,后面的模型请求要用它;new_messages 只记这次新增的消息,最后返回给调用方。把这两个用途分开,后面看到 push 时就不容易混了。
接着发布 AgentStart、TurnStart 和用户消息事件,进入 run_loop。到这里还没有请求模型,只是准备好了运行状态。
第二步,把上下文交给模型,消费流式响应
真正发起这一轮请求的是 stream_assistant_response。
Agent 内部消息不一定能原样发给模型,所以这里会先做可选的上下文转换,再调用 convert_to_llm。之后构造模型使用的上下文,核心片段如下:
let llm_context = rpi_ai::types::Context {
system_prompt: if context.system_prompt.is_empty() {
None
} else {
Some(context.system_prompt.clone())
},
messages: llm_messages,
tools: context.tools.iter().map(|t| t.schema().clone()).collect(),
};可以重点看最后一行。我们没有把 Rust 函数发给模型,只发工具 schema,也就是名称、描述和参数约束。模型据此选择工具、填写参数,函数实现仍留在自己的程序里。这也是为什么仅仅“告诉模型有个 write 工具”,还不能让文件落盘。
解析凭据和请求选项后,模型调用收敛成这一行:
let mut response = stream_fn(&config.model, &llm_context, &opts);返回的是事件流,要继续消费:
while let Some(event) = response.next().await {
// 按事件类型处理,下面解释各分支
}循环体有几个分支,作用可以这样理解:
| 模型事件 | 运行时怎么处理 |
|---|---|
Start | 给上下文加入当前 assistant 消息的占位,发布 MessageStart |
| 文本、思考或工具调用的增量 | 发布 MessageUpdate,供界面显示 |
Done 或 Error | 取得最终消息,替换占位,发布 MessageEnd |
源码没有在每个 delta 到来时都深拷贝整条增长中的消息到上下文,而是在终止事件时放入最终版本。界面则从更新事件里拿进度。
这也说明“看见工具调用的一部分”和“可以执行工具”之间有差别。pi-rust 的主循环先取得这一轮最终 assistant 消息,再从中提取调用。半截 JSON 不会在这里直接拿去写文件。
第三步,从回答里找出工具调用
回到 run_loop。拿到 assistant 消息后,先检查模型是否错误或中止;再从内容块里收集调用:
let tool_calls: Vec<ToolCall> = message
.content
.iter()
.filter_map(|c| match c {
Content::ToolCall(tc) => Some(tc.clone()),
_ => None,
})
.collect();一条 assistant 消息可以包含文本,也可以包含多个工具调用。这里保留所有 ToolCall,顺序和原内容一致。
随后决定这一批怎么处理:
has_more_tool_calls = false;
if !tool_calls.is_empty() {
let batch = if matches!(message.stop_reason, StopReason::Length) {
fail_tool_calls_from_truncated_message(&tool_calls, emit).await?
} else {
execute_tool_calls(current_context, &message, &tool_calls, config, emit).await?
};
tool_results.extend(batch.messages);
has_more_tool_calls = !batch.terminate;
// 后面把结果加入上下文
}这个片段省略了结果入库的循环,下一节再看。
如果模型因长度限制结束,当前实现会让这一轮的工具调用全部失败,不执行其中任何一个。即使某个调用看上去完整,它也采用同一条规则。
has_more_tool_calls 这个名字容易让人误读成“还有工具没执行”。这里它控制是否继续下一次模型请求:刚执行了一批工具,通常需要让模型看结果,因此继续;如果这一批明确要求终止,则不再因此继续。
第四步,找到工具,检查参数,再执行
prepare_tool_call 按调用名称找工具:
let tool = current_context
.tools
.iter()
.find(|t| t.schema().name == tool_call.name)
.cloned();没找到时,生成一个错误结果,不会随便找个命令替代执行。
找到后,先运行工具的 prepare_arguments,再按 schema 验证参数。源码中的验证调用是:
let validated_args = match validate_tool_arguments(tool.schema(), &prepared_tool_call) {
Ok(v) => v,
Err(e) => {
return Prepared::Immediate {
result: create_error_tool_result(&e.to_string()),
is_error: true,
};
}
};只有名称和参数都符合要求,才进入执行阶段。中间还可以通过 before_tool_call 阻止调用,并检查取消状态。
有个实现细节要注意:这个钩子如果替换了参数,当前代码不会再做一次 schema 验证,因此钩子提供的是受信任的替换。参数验证也不等于执行权限检查,schema 正确的文件路径仍然可能越过应用允许的范围。
工具最终通过 AgentTool 接口执行。实际执行调用位于 execute_prepared_tool_call:
let child_token = config.signal.child_token();
match tool
.execute(&tool_call.id, args.clone(), child_token, on_update)
.await
{
// Ok 转成成功结果,Err 转成带 is_error 的错误结果
}这里省略了 match 分支。on_update 用来回报工具进度,取消 token 传给工具,让它有机会响应停止请求。工具仍然需要主动处理取消,传了 token 并不意味着外部副作用会自动回滚。
普通执行错误会被转换成工具结果交回模型。例如文件写入失败,模型下一轮能看到错误,而不是整个程序直接丢掉上下文。
第五步,结果回到上下文,循环才连起来
工具执行完,结果需要对应到原调用。构造结果消息时有这些字段,以下是节选:
tool_call_id: tool_call.id.clone(),
tool_name: tool_call.name.clone(),
content: result.clone().into_content(),
is_error,tool_call_id 把结果和调用连起来。它比一句“写入成功”多提供了一个关键的信息:这是哪次调用的结果。
主循环再把消息放回去:
for result in &tool_results {
let am = AgentMessage::ToolResult(Box::new(result.clone()));
current_context.messages.push(am.clone());
new_messages.push(am);
}现在再回看开头那个疑问:文件写完后,模型怎么知道成功了?答案就在这两次 push 里。下一次调用模型,它看到的历史已经包含用户请求、assistant 发出的调用和执行结果。
在写文件例子中,模拟 Provider 的下一步返回完成消息。没有新的工具调用,也没有待处理的用户消息,这次运行就结束。这样才得到前面看到的四条消息。
源码为什么还有两层循环
简化伪代码只有一个 loop,实际实现的结构是这样的节选:
loop {
let mut has_more_tool_calls = true;
while has_more_tool_calls || !pending_messages.is_empty() {
// 模型请求、工具执行,以及新的 steering 消息
}
let follow_ups = drain_follow_up(config).await;
if !follow_ups.is_empty() {
pending_messages = follow_ups;
continue;
}
break;
}内层处理当前任务的工具往返,也处理用户运行中发来的 steering 消息。比如 Agent 正在工作,用户补充“先不要改那个文件”,这条消息在代码规定的轮次边界进入上下文。
外层检查 follow-up,也就是当前任务结束后排队要做的事。没有后续任务,就结束整个运行。
如果两层循环一下子不好理解,可以先按本篇的小例子读:没有排队消息时,主要关注内层的模型与工具往返。等这条线清楚了,再加上“用户中途补充要求”和“做完后还有下一个任务”这两种情况。
能循环以后,还要补哪些约束
剩下几个问题,做实际工具时很快就会遇到。
能不能并行执行? 两个独立的读取可以并行,修改同一份文件就得考虑顺序。pi-rust 同时检查全局执行模式和每个工具的模式;这一批里只要有工具要求顺序执行,整批就走顺序路径。并行模式下,完成事件按完成顺序发出,结果消息按原调用顺序整理。
什么时候必须停? 错误、中止、工具批次终止和 should_stop_after_turn 都是入口。接真实模型时,还应在应用或 Harness 配置预算,限制时间、轮次或费用。一个 while 本身提供不了这些限制。
工具能碰到哪里? 执行环境负责落地操作,应用还要决定文件范围、命令权限和审批规则。OsExecutionEnv 使用真实系统,不能因为有这一层接口就把它称作安全沙箱。
中断后能不能恢复? 本篇的核心循环处理内存上下文。持久化和恢复在 Harness;外部操作已经发生但结果还没保存时,恢复逻辑不能随意重放有副作用的工具。
如果你准备自己写一版,可以先沿用本文的写文件任务。跑完以后检查文件内容,再检查历史里有没有那四条消息。然后故意传错一个参数,看看错误能不能回到模型;再尝试取消一次执行。这样每加一项能力,你都知道它具体解决了哪个问题。
本文源码基线为 1138ab1007f41cc9d24c494845e6019019e91d43。片段按阅读顺序节选,伪代码和省略处已标注。完整入口见工具示例,核心实现见 agent_loop.rs。