用 Rust 从零构建 AI Agent —— 工具调用循环详解

为什么 Agent 的核心就是一个循环
“Agent”这个词现在到处都在用,但说实话,它最核心的东西出奇地简单——就是一个循环。
模型被问到一个问题,它判断自己没法直接回答,于是输出一段结构化文本,告诉程序“我需要用这个工具”。程序执行工具,把结果喂回模型,模型再判断是继续调用工具还是给出最终答案。这个来回的过程反复进行,直到模型说“够了,我可以回答了”。
听起来是不是有点太简单了?但事实就是如此。GitHub Copilot、Claude Code、Cursor 这些你能叫得上名字的 Agent,底层都是这个结构。推理、规划、反思——这些东西是在这个循环外面一层层加上的,而不是替换了它。
如果你理解了这层循环,后面再复杂的 Agent 架构你都能看懂。
工具使用:模型从"说"到"做"的关键一步
纯粹的语言模型本质上就是一个函数:输入文本,输出文本。它没有记忆,不能操作外部世界,只能不断生成文字。你问它"帮我读一下项目里的 Cargo.toml",它做不到——不是它不想做,而是它根本没有能力去做。
工具使用(tool use)改变了一切。它让模型能输出一种特殊的结构化文本——里面包含了工具名称和参数。你的程序负责识别这种文本,实际去调用对应的工具,然后把结果回传给模型。模型再根据结果决定下一步。
这就赋予了模型真正的行动能力。
你可能会问:那模型怎么知道怎么调用一个它从未见过的工具?
答案是通用泛化。Anthropic 在 Claude 的后训练阶段喂了大量工具使用的示例——模型被给予一组工具定义,在其中挑选合适的、输出结构化调用、接收结果、继续下一步。这种训练下,模型学会的不是某个具体工具的用法,而是"看到任何新工具都能用"的能力。所以同一个 Claude 可以驱动几十种完全不同的工具,而不用重新训练。
这也意味着工具定义是每次请求都带上来的,不是一个"预先注册"的步骤。每次调用 API,请求体里同时携带了完整的工具定义和完整的对话历史。
一个真实的 Agent 实现:Eugene
Enzo Lombardi 最近用约 200 行 Rust 写了一个叫 Eugene 的 Agent,直接对接 Claude 的 Messages API,核心功能就是读取项目文件。代码不多,但每一个细节都值得认真看一遍。
工具:read_file——沙箱里的文件读取
fn read_file(args: ReadFileArgs) -> Result<String> {
let root = sandbox_root()?;
let resolved = root.join(Path::new(&args.path));
let canonical = resolved
.canonicalize()
.map_err(|e| anyhow!("cannot resolve {}: {e}", args.path))?;
if !canonical.starts_with(&root) {
bail!("path {} escapes the sandbox", args.path);
}
let bytes = std::fs::read(&canonical)?;
let text = String::from_utf8_lossy(&bytes).into_owned();
Ok(text.chars().take(MAX_FILE_CHARS).collect())
}
两个关键细节:
沙箱边界检查:canonicalize() 先解析路径的规范形式(消除 .、..、符号链接等),然后用 starts_with(&root) 确认最终路径仍在项目根目录内。这是防止路径穿越攻击的标准做法——如果用户(也就是模型)试图用 ../../etc/passwd 这种路径逃出沙箱,代码会直接拒绝。
输出截断:工具结果被截断到 4000 个字符。上下文窗口虽然很大,但不是无限的。如果一个工具把几兆的二进制日志倒进对话,后面的所有消息都会被淹没。工具作者有责任控制返回的数据量。你可以把模型想象成一个按字节计费的端点——多付一块钱,多传一个字节。
参数结构体则利用 serde 做免费校验:
#[derive(Debug, Deserialize)]
struct ReadFileArgs {
path: String,
}
模型多传了一个不存在的字段?serde_json::from_value 会拒绝。漏了必需的 path?同样被拒绝。类型系统在这里替你做了输入验证。
工具定义:告诉模型"你能做什么"
模型看到的不是你的 Rust 代码,而是一个 JSON 描述:
fn read_file_definition() -> Tool {
Tool {
name: "read_file",
description: "Read a UTF-8 text file from the current project.",
input_schema: serde_json::json!({
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "File path relative to the project root.",
}
},
"required": ["path"]
}),
}
}
这是标准的 JSON Schema。模型在没有 path 时会拒绝调用,会把 path 格式化为字符串,不会自己编造额外字段。描述字段尤其重要——模型全靠它来理解工具的用途和适用场景。写得好的描述能让模型在正确的时机选择正确的工具;写得差的描述会让模型犹豫或者选错。
API 通信:reqwest + JSON 请求体
Anthropic 的 API 就是一个 POST 端点,两个自定义头,一个 JSON 请求体:
async fn send(
http: &reqwest::Client,
api_key: &str,
tools: &[Tool],
messages: &[Message],
) -> Result<Response> {
let body = Request {
model: MODEL,
max_tokens: MAX_TOKENS,
system: SYSTEM_PROMPT,
tools,
messages,
};
let response = http
.post(ANTHROPIC_URL)
.header("x-api-key", api_key)
.header("anthropic-version", ANTHROPIC_VERSION)
.json(&body)
.send()
.await?;
// ...
}
重点说两个参数:
max_tokens:这是助手单次回复的 token 预算,不是整个对话的总量。四千个 token 对短对话很舒服——它能输出一篇中等长度的回答,但不会 runaway 到花掉你几十块钱。
system:系统提示词。这个 Eugene 实现里把它写成一个静态字符串,这在单工具场景下没问题。当你加更多工具、需要输出格式控制、 persona 版本管理或者回归测试时,单字符串的 system prompt 就不够用了——这也是下一篇文章要解决的问题。
主循环:整个 Agent 的心脏
循环的逻辑分三步:发请求、看响应、跑工具或退出。
let mut messages: Vec<Message> = vec![Message {
role: "user",
content: vec![Block::Text { text: user_question }],
}];
for _ in 0..MAX_TURNS {
let response = send(&http, &api_key, &tools, &messages).await?;
messages.push(Message {
role: "assistant",
content: response.content.clone(),
});
}
这里首先要明确一点:API 是无状态的。Claude 不记得你上一轮问过什么,不记得它自己上一轮说了什么,也不记得它之前用过哪些工具。全部对话历史都存在你的 messages 数组里,每次请求你都把完整的历史发过去。
这也解释了为什么 messages.push 是原样塞入——下一轮模型需要看到自己之前的推理过程、之前发出的 tool_use_id,才能把工具结果和对应的调用匹配起来。
MAX_TURNS 是个安全阀。没有上限的循环会让模型陷入死循环——反复调用同一个坏掉的工具、或者调用工具但不看结果又再调用一次。6 轮对玩具 Agent 足够了,失控了也只会花几毛钱。生产级 Agent 会设更高,再加上基于 token 消耗的截止条件。
检测工具调用:从响应内容里挑出 tool_use
let tool_uses: Vec<(&str, &str, &serde_json::Value)> = response
.content
.iter()
.filter_map(|block| match block {
Block::ToolUse { id, name, input } => Some((id.as_str(), name.as_str(), input)),
_ => None,
})
.collect();
if tool_uses.is_empty() || response.stop_reason != "tool_use" {
// 模型说完了,输出结果
for block in &response.content {
if let Block::Text { text } = block {
println!("{text}");
}
}
return Ok(());
}
这里有两个退出条件:要么没有 tool_use 块,要么模型的 stop_reason 不是 tool_use。注意区分这两个条件——模型可能返回了 tool_use 块但 stop_reason 是别的值,这时候不能直接退出,因为可能还有后续的工具调用。
执行工具并回传结果
let results: Vec<Block> = tool_uses
.iter()
.map(|(id, name, input)| match run_tool(name, input) {
Ok(content) => Block::ToolResult {
tool_use_id: id.to_string(),
content,
is_error: false,
},
Err(e) => Block::ToolResult {
tool_use_id: id.to_string(),
content: format!("ERROR: {e}"),
is_error: true,
},
})
.collect();
messages.push(Message {
role: "user",
content: results,
});
两个关键细节:
工具结果以 user 身份回传:这是 Anthropic 对话格式的规定——工具结果被视为人类提供的信息。所以 role 是 "user" 而不是 "assistant"。如果这里写错,模型会产生困惑。
is_error 标志:工具执行失败时,把 is_error 设为 true。这告诉模型"工具出错了,你自己想办法处理"。如果不设这个标志,模型会把错误信息当作正常数据来解读,可能沿着错误的方向继续推理。这是个很实用的错误处理策略。
一个真实运行的过程
设置好 ANTHROPIC_API_KEY,运行:
cargo run -- "Cargo.toml 用的是哪个 Rust 版本?"
终端停顿一秒,输出 2024。
完整的过程是:
- 请求发出:系统提示 + 工具定义 + 用户问题
- Claude 判断"我凭记忆答不出来,需要读文件" → 返回
tool_use块:read_file(path="Cargo.toml") - 程序执行
read_file,拿到Cargo.toml前 4000 字符 - 工具结果以
user消息回传 - Claude 看到文件内容后,直接作答:“项目用的是 Rust 2024 版本。”
总共两次 API 往返,一次文件读取,大约一秒。模型从未碰过你的磁盘——它只决定"要读什么",你的程序负责"实际去读"。
从循环到框架:演化而非替代
看完这 200 行代码,你可能会想"这也太简单了,真正的 Agent 不是这样的"。确实是这样的——但那 200 行就是真正的 Agent 基底。Flug 工程学上后来的所有扩展(ReAct、Plan-and-Execute、反射、多 Agent 路由),本质上都没有改变这个循环的形状。它们是在循环外面加了一层决策逻辑,而不是替换了循环本身。
有趣的工作不在循环里。循环是最简单的那部分——发请求、跑工具、传结果。真正值得投入精力的是:工具怎么设计、提示怎么写、轮次之间要携带什么状态、错误怎么处理、什么时候该停下来。这些才是让一个 Agent 从"能跑"变成"好用"的关键。
下一步可以试试
加一个 list_files 工具,让模型能先列目录再挑文件再读取。你会发现不需要改循环——只是工具的 hands 变多了,Agent 看起来就聪明了。循环还是同一个循环。
在中国使用这篇文章的方案
原示例用的是 Anthropic Claude API,在中国大陆有网络限制。实际使用时可以考虑以下方案:
- 通过兼容接口接入国产大模型:如通义千问、智谱 GLM-5 等已支持工具调用能力的模型,只需将
send函数中的端点地址、认证头和请求体结构调整为对应模型的格式。 - 使用本地推理 + 工具层:通过 Ollama 等工具在本地部署开源模型(如 Qwen),在模型外层自己实现同样的工具调用循环,完全不依赖外网。
- 代理转发:通过已搭建的代理服务将兼容 Anthropic 协议的请求转发到可用的后端模型。
对 Rust 底层开发和 AI 编程感兴趣?关注「全栈之巅-梦兽编程」公众号,每周更新 Rust 技术深度文章与 AI 编程实战干货。
也欢迎了解 梦兽编程 AI 编程助手服务 ,帮你把 AI 编程工具真正用到日常开发里。
