AB
AiBoss
チュートリアル

用 serdes-ai 在 Rust 中搭建可测试的 LLM 代理并控制循环停止

チュートリアル

用 serdes-ai 在 Rust 中搭建可测试的 LLM 代理并控制循环停止

serdes-ai 是 Rust 生态中一个对标 Pydantic AI 的代理框架,组件命名与 Python 版接近,但实际行为需要自行验证。本教程用内置的 FunctionModel 替换模型层,在不调用真实 LLM、不需要 API 密钥的前提下,复现工具调用往返、用 usage_limits 中断失控循环、通过 output_tool 接收结构化输出,并整理出 README 与实际 API 不一致、usage 缺失导致上限失效等常见问题。

serdes-ai 是 Rust 生态里一个对标 Pydantic AI 的代理框架,代理、工具、结构化输出、用量上限这些组件都能找到对应物。但「组件名字对得上」和「写起来手感一样、该停的地方会停」是两件事。本教程面向两类读者:想在 Rust 里搭 LLM 代理的开发者,以及有 Pydantic AI 经验、正在评估 Rust 版本是否可用的团队。全文把模型层换成框架自带的 FunctionModel(用 Rust 闭包直接返回模型响应的测试用模型),因此整个过程不调用任何真实 LLM,也不需要 API 密钥,可以稳定复现「代理循环在哪里停、在哪里不停」。

需要先说明验证边界:以下内容覆盖的是「模型层被替换为 FunctionModel」这一范围。OpenAI、Anthropic 等真实提供方的调用、流式输出、MCP、图执行都没有实际运行过。文中涉及的版本号、下载量等易变信息,请以官网当前信息为准。

准备工作

环境要求很轻:一套可用的 Rust 工具链即可,验证环境为 Linux 上的 rustc 1.97.0 与 cargo 1.97.0。因为代码不发起真实模型请求,所以不需要任何提供方的 API 密钥。

先建工程:

cargo new serdes-ai-try
cd serdes-ai-try

在 Cargo.toml 里加入五个依赖。serdes-ai 使用 0.2.6,其余为常规配套:

[dependencies]
serdes-ai = "0.2.6"
tokio = { version = "1", features = ["full"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
anyhow = "1"

依赖解析完成后,Cargo.lock 中连同自身 crate 在内共有 208 个包。首次构建耗时未做测量,实际时间取决于机器与网络。

有一点必须提前知道:README 里的 Quick Start 写的是 serdes-ai = "0.1"、Agent::new(model) 和 .tool(CalculatorTool),这套写法在 0.2.6 上编译不过。0.2.6 里既没有 Agent::new,也没有 AgentBuilder::tool,会直接报 E0599。正确入口是 AgentBuilder::<(), String>::new(model),工具通过 tool_fn、tool_fn_async 或 tool_with_executor 注册。

操作步骤

第一步:用 FunctionModel 复现一次工具调用往返

FunctionModel::new 接收一个闭包,闭包的入参是此前的消息列表与设置,返回值是 ModelResponse。下面的模型第一次返回对 word_count 工具的调用,第二次(消息里已经出现 ToolReturn 时)返回纯文本。

判断 ToolReturn 是否存在,这里用的是把消息的 Debug 表示转成字符串再做包含判断的简易做法,够用但不严谨,生产代码应改为结构化匹配。

use serde::Deserialize;
use serdes_ai::core::{ModelResponse, ModelResponsePart};
use serdes_ai::models::FunctionModel;
use serdes_ai::prelude::*;
use serdes_ai::tools::ToolReturn;

#[derive(Deserialize)]
struct Args {
    text: String,
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    // 第 1 次返回 tool_call,第 2 次(带 ToolReturn)返回文本
    let model = FunctionModel::new(|messages, _settings| {
        let n = messages.len();
        let has_ret = format!("{:?}", messages).contains("ToolReturn");
        println!("[model] call: messages={n} has_tool_return={has_ret}");
        if has_ret {
            ModelResponse::text("4 words.")
        } else {
            ModelResponse::with_parts(vec![ModelResponsePart::tool_call(
                "word_count",
                serde_json::json!({"text": "hello rust agent world"}),
            )])
        }
    });

    let agent = AgentBuilder::<(), String>::new(model)
        .system_prompt("You count words.")
        .tool_fn("word_count", "Count words", |_ctx, a: Args| {
            let n = a.text.split_whitespace().count();
            println!("[tool] word_count -> {n}");
            Ok(ToolReturn::text(n.to_string()))
        })
        .build();

    let r = agent.run("count", ()).await?;
    println!("output={:?} usage={:?}", r.output, r.usage);
    Ok(())
}

tool_fn 闭包里的 a: Args 是模型返回的 JSON 参数(这里是 {"text": "hello rust agent world"})反序列化后的结果。

运行后日志如下:

[model] call: messages=2 has_tool_return=false
[tool] word_count -> 4
[model] call: messages=4 has_tool_return=true
output="4 words." usage=RunUsage { request_tokens: 0, response_tokens: 0, total_tokens: 0, request_count: 0, tool_call_count: 1, cache_creation_tokens: None, cache_read_tokens: None }

工具往返按预期完成:模型被调用两次,工具执行一次并数出 4 个词。但 usage 里有一处不对劲——模型明明调用了两次,tool_call_count 也正确记为 1,request_count 却是 0。原因是 FunctionModel 返回的 ModelResponse 没有附带 usage,于是请求数和 token 数都按 0 统计。这个 0 会在下一步变成上限失效的直接原因。

第二步:用 usage_limits 中断停不下来的代理

真实 LLM 有时会调用根本不存在的工具名。为了复现这种情况,这里构造一个每次都调用未注册的 ghost_tool 的模型,观察 usage_limits 能否把它拦住。

use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use serdes_ai::core::{ModelResponse, ModelResponsePart};
use serdes_ai::models::FunctionModel;
use serdes_ai::prelude::*;

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let calls = Arc::new(AtomicUsize::new(0));
    let c = calls.clone();

    let model = FunctionModel::new(move |_m, _s| {
        let n = c.fetch_add(1, Ordering::SeqCst) + 1;
        if n <= 12 {
            println!("[model] call #{n}: ghost_tool");
        }
        if n > 12 {
            std::process::exit(3); // 止不住时的安全网
        }
        ModelResponse::with_parts(vec![ModelResponsePart::tool_call(
            "ghost_tool",
            serde_json::json!({}),
        )])
        .with_usage(serdes_ai::core::RequestUsage::with_tokens(10, 5))
    });

    let agent = AgentBuilder::<(), String>::new(model)
        .max_tool_retries(1)
        .usage_limits(serdes_ai::agent::UsageLimits::new().requests(3))
        .build();

    match agent.run("go", ()).await {
        Ok(r) => println!("ok {:?}", r.output),
        Err(e) => println!(
            "err: {e} (model calls={})",
            calls.load(Ordering::SeqCst)
        ),
    }
    Ok(())
}

把文件放在 src/bin/ 下,用 cargo run --bin ghost 执行。日志为:

[model] call #1: ghost_tool
[model] call #2: ghost_tool
[model] call #3: ghost_tool
[model] call #4: ghost_tool
err: Usage limit exceeded: Request count limit exceeded: 4 > 3 (model calls=4)

第 4 次模型调用越过 requests(3) 的上限,以错误形式返回,符合预期。

真正值得记下来的是这段代码的初版:当时响应上没有挂 .with_usage(RequestUsage::with_tokens(10, 5)),同样设置了 requests(3),模型调用却一路超过 12 次仍未停止,最后靠安全网 exit(3) 强行截断。一旦给响应补上 usage,第 4 次就停了。也就是说,上限判定依赖响应里携带的 usage 汇总,这与第一步里 request_count: 0 的现象完全吻合。

另一个观察:调用未注册的工具名并不会报错,模型调用会继续下去。max_tool_retries(1) 设了也没能拦住,最终让循环停下的是 usage_limits。

第三步:用 output_tool 接收结构化输出

结构化输出的做法是:让模型把 JSON 作为某个「只用于输出」的工具(这里叫 submit_summary)的参数返回,框架再把它反序列化成 Rust 类型。下面的模型第一次把 words 写成字符串 "four"(类型不符),第二次改成数字 4。

use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use serde::Deserialize;
use serdes_ai::core::{ModelResponse, ModelResponsePart, RequestUsage};
use serdes_ai::models::FunctionModel;
use serdes_ai::prelude::*;

#[derive(Debug, Deserialize)]
struct Summary {
    words: u32,
    verdict: String,
}

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let schema = serde_json::json!({
        "type": "object",
        "properties": {
            "words": { "type": "integer" },
            "verdict": { "type": "string" }
        },
        "required": ["words", "verdict"]
    });

    let calls = Arc::new(AtomicUsize::new(0));
    let c = calls.clone();

    // 第 1 次:words 为字符串,参数不合法;第 2 次:参数正确
    let model = FunctionModel::new(move |_m, _s| {
        let n = c.fetch_add(1, Ordering::SeqCst) + 1;
        let args = if n == 1 {
            serde_json::json!({"words": "four", "verdict": "short"})
        } else {
            serde_json::json!({"words": 4, "verdict": "short"})
        };
        println!("[model] call #{n}: submit_summary {args}");
        ModelResponse::with_parts(vec![ModelResponsePart::tool_call(
            "submit_summary",
            args,
        )])
        .with_usage(RequestUsage::with_tokens(10, 5))
    });

    let agent = AgentBuilder::<(), String>::new(model)
        .output_tool::<Summary>("submit_summary", schema)
        .max_output_retries(2)
        .build();

    match agent.run("summarize", ()).await {
        Ok(r) => println!(
            "ok: {:?} (model calls={})",
            r.output,
            calls.load(Ordering::SeqCst)
        ),
        Err(e) => println!(
            "err: {e} (model calls={})",
            calls.load(Ordering::SeqCst)
        ),
    }
    Ok(())
}

输出:

[model] call #1: submit_summary {"verdict":"short","words":"four"}
[model] call #2: submit_summary {"verdict":"short","words":4}
ok: Summary { words: 4, verdict: "short" } (model calls=2)

第一次类型不匹配后模型被再次调用,第二次参数正确,得到 Summary { words: 4, verdict: "short" },模型共调用 2 次。

那么如果模型始终不修正参数呢?用同样的结构,把模型换成每次都返回 words:"four",分别以 max_output_retries(2) 和 max_output_retries(0) 运行,两种设置下模型调用都超过 8 次,最终由自建安全网截断。max_output_retries(2) 的日志:

[model] call #1
[model] call #2
[model] call #3
[model] call #4
[model] call #5
[model] call #6
[model] call #7
[model] call #8
[model] call #9
STOP: >8 calls

max_output_retries(0) 同样一路调到第 9 次。设置值改变而行为不变,说明至少在这个组合下,max_output_retries 并不能充当非法输出循环的安全网。从源码结构看,工具调用处理先于输出校验执行,而 max_output_retries 只在 validate_output 失败的分支里计数——这只是阅读源码得出的推测,并非已确认的结论,真实提供方的模型上是否同样如此也未验证。

一个完整示例

把上面三块拼起来,可以得到一个不依赖任何 API 密钥、可反复运行的本地验证程序:先用 FunctionModel 模拟一次正常的工具往返,确认 tool_fn 的参数反序列化与 ToolReturn 回传路径通畅;再用一个始终调用未注册工具的模型,确认 usage_limits 在响应携带 usage 时能按 requests(n) 截断;最后用 output_tool 验证结构化输出的类型校验与重试路径。

建议的组织方式是在 src/bin/ 下放三个独立文件,分别对应 cargo run --bin roundtrip、cargo run --bin ghost、cargo run --bin structured。每个程序都自带一个「调用次数计数器 + 超阈值退出」的安全网,例如在闭包里 fetch_add 后判断 n > 12 就 std::process::exit(3)。这一步不是可选项:在 usage 缺失或重试设置不生效的情况下,没有安全网的测试会一直跑下去,把 CI 卡死。

三个程序都跑通,说明模型层替换、工具注册、上限判定、结构化输出这四条路径在本机是通的。它们跑通并不代表真实提供方也如此,这一点在下一节展开。

注意事项

README 与 0.2.6 的 API 不一致

README 的 Quick Start 仍停留在 0.1 系写法,Agent::new 与 .tool(...) 在 0.2.6 中都不存在,编译报 E0599。改用 AgentBuilder::<(), String>::new(model) 搭配 tool_fn 系列方法。

自己实现 Tool trait 时的 E0053

Tool trait 的 call 接收的是 serdes_ai::tools::RunContext。如果签名里写的是从 prelude 引入的 RunContext<()>,会与 trait 定义的类型不匹配并报 E0053。两个同名类型存在于不同路径,签名中必须使用 serdes_ai::tools::RunContext。本教程最终统一用 tool_fn 编写,trait 实现能否编译通过未做确认。

UsageLimits 有两个同名类型

框架里存在两个 UsageLimits:core 侧的 serdes_ai::UsageLimits 使用 max_requests 字段,不能传给 AgentBuilder::usage_limits,会报 E0308;agent 侧的 serdes_ai::agent::UsageLimits 提供 requests(u32) 方法,可以传。因为类型名相同,靠编辑器补全很容易选错,建议始终写全路径 serdes_ai::agent::UsageLimits::new().requests(3)。

设了 usage_limits 却不停

先检查模型响应里有没有 usage。用 FunctionModel 自建的模型,如果不加 .with_usage(RequestUsage::with_tokens(..)),请求数会一直按 0 统计,上限永远够不着。真实提供方的模型实现是否返回 usage,本次未验证。用 FunctionModel 写测试时这一条尤其关键:忘了挂 usage 的测试里 usage_limits 形同虚设,若测试本身依赖「有上限所以不会死循环」的假设,测试就会挂住不结束。

重试类设置的作用范围比名字看起来窄

问题观察到的现象应对
README 与 API 不一致Agent::new 与 .tool(...) 在 0.2.6 不存在(E0599)改用 AgentBuilder 与 tool_fn 系列
响应无 usage 时上限失效request_count 恒为 0,requests(3) 下仍超过 12 次自建模型必须挂 usage
未注册工具导致循环max_tool_retries(1) 下仍持续调用务必设置 usage_limits
非法结构化输出导致循环max_output_retries 取 0 或 2 都超过 9 次在 max_output_retries 之外另设安全网

max_tool_retries 和 max_output_retries 从名字看像是「有限次截断循环」的开关,但在本次观察中,未注册工具的循环和结构化输出参数持续出错的循环都没被它们拦住。唯一有效的是 usage_limits,且前提是响应携带 usage。

采用前值得先确认的几点

计划使用的提供方模型实现是否返回 usage、usage_limits 是否真的生效;结构化输出连续失败时,究竟哪个设置能让循环停下;流式输出、MCP、图执行等本次未运行的功能在目标场景下是否可用;以及固定版本后能否持续跟踪仓库的更新节奏。这些都可以先用 FunctionModel 在本地搭出复现测试,在产生真实调用费用之前把问题暴露出来。若要在真实提供方上运行,除 usage_limits 外最好再自建一层总时长或总调用次数的上限,避免循环失控时费用膨胀。版本、下载量等数据请以官网当前信息为准。