从 0 到 1 构建 Agent:为什么需要 Agent Loop,核心代码怎么写

4,988 字#Rust #Pi

如果你已经接通了大模型 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。