为什么 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

完整的过程是:

  1. 请求发出:系统提示 + 工具定义 + 用户问题
  2. Claude 判断"我凭记忆答不出来,需要读文件" → 返回 tool_use 块:read_file(path="Cargo.toml")
  3. 程序执行 read_file,拿到 Cargo.toml 前 4000 字符
  4. 工具结果以 user 消息回传
  5. Claude 看到文件内容后,直接作答:“项目用的是 Rust 2024 版本。”

总共两次 API 往返,一次文件读取,大约一秒。模型从未碰过你的磁盘——它只决定"要读什么",你的程序负责"实际去读"。


从循环到框架:演化而非替代

看完这 200 行代码,你可能会想"这也太简单了,真正的 Agent 不是这样的"。确实是这样的——但那 200 行就是真正的 Agent 基底。Flug 工程学上后来的所有扩展(ReAct、Plan-and-Execute、反射、多 Agent 路由),本质上都没有改变这个循环的形状。它们是在循环外面加了一层决策逻辑,而不是替换了循环本身。

有趣的工作不在循环里。循环是最简单的那部分——发请求、跑工具、传结果。真正值得投入精力的是:工具怎么设计、提示怎么写、轮次之间要携带什么状态、错误怎么处理、什么时候该停下来。这些才是让一个 Agent 从"能跑"变成"好用"的关键。


下一步可以试试

加一个 list_files 工具,让模型能先列目录再挑文件再读取。你会发现不需要改循环——只是工具的 hands 变多了,Agent 看起来就聪明了。循环还是同一个循环。


在中国使用这篇文章的方案

原示例用的是 Anthropic Claude API,在中国大陆有网络限制。实际使用时可以考虑以下方案:

  1. 通过兼容接口接入国产大模型:如通义千问、智谱 GLM-5 等已支持工具调用能力的模型,只需将 send 函数中的端点地址、认证头和请求体结构调整为对应模型的格式。
  2. 使用本地推理 + 工具层:通过 Ollama 等工具在本地部署开源模型(如 Qwen),在模型外层自己实现同样的工具调用循环,完全不依赖外网。
  3. 代理转发:通过已搭建的代理服务将兼容 Anthropic 协议的请求转发到可用的后端模型。

对 Rust 底层开发和 AI 编程感兴趣?关注「全栈之巅-梦兽编程」公众号,每周更新 Rust 技术深度文章与 AI 编程实战干货。

也欢迎了解 梦兽编程 AI 编程助手服务 ,帮你把 AI 编程工具真正用到日常开发里。