过去一年“Agent”这个词被说烂了:会思考、会调用工具、能自己拆解任务并持续执行。大多数教程都用Python写,但在需要高并发、可控资源、长期运行稳定的场景里(爬取、自动化运营、链上监控、日志分析、交易风控),Rust反而更合适:性能强、内存安全、可观测性好、部署简单。这篇文章带你
过去一年“Agent”这个词被说烂了:会思考、会调用工具、能自己拆解任务并持续执行。
大多数教程都用 Python 写,但在需要高并发、可控资源、长期运行稳定的场景里(爬取、自动化运营、链上监控、日志分析、交易风控),Rust 反而更合适:性能强、内存安全、可观测性好、部署简单。

这篇文章带你用 Rust 写一个“能跑起来”的 Agent:
注:本文不绑定某一家模型厂商,接口用“可替换的 LLM Client”封装。你可以接 OpenAI、Azure、Anthropic、火山、通义、智谱等。
一个实用 Agent 通常有这几个部件:
我们要实现的最小可用版本(MVP):
建议用这样的目录结构:
agent-rs/
src/
main.rs
agent/mod.rs
agent/loop.rs
llm/mod.rs
tools/mod.rs
tools/http.rs
tools/fs.rs
memory/mod.rs
memory/short_term.rs
memory/long_term.rs
types.rs
核心思想:
先把“协议”定下来:LLM 怎么告诉我们要调用工具?工具返回什么?循环怎么停止?
// src/types.rs
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChatMessage {
pub role: String, // "system" | "user" | "assistant" | "tool"
pub content: String,
pub name: Option<String>, // tool name if role == "tool"
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolSpec {
pub name: String,
pub description: String,
pub input_schema: serde_json::Value, // JSON Schema
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum AgentAction {
// LLM 要求调用工具
ToolCall {
tool_name: String,
input: serde_json::Value,
},
// LLM 认为任务完成
Final {
answer: String,
},
}
这里我们用一个简单约定:让 LLM 输出 JSON,解析成 AgentAction。
(很多厂商支持原生 tool calling;但用 JSON 输出更通用。)
// src/tools/mod.rs
use async_trait::async_trait;
use serde_json::Value;
use anyhow::Result;
#[async_trait]
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
fn spec(&self) -> crate::types::ToolSpec;
async fn call(&self, input: Value) -> Result<Value>;
}
pub struct ToolRegistry {
tools: std::collections::HashMap<String, std::sync::Arc<dyn Tool>>,
}
impl ToolRegistry {
pub fn new() -> Self { Self { tools: Default::default() } }
pub fn register<T: Tool + 'static>(&mut self, tool: T) {
self.tools.insert(tool.name().to_string(), std::sync::Arc::new(tool));
}
pub fn list_specs(&self) -> Vec<crate::types::ToolSpec> {
self.tools.values().map(|t| t.spec()).collect()
}
pub fn get(&self, name: &str) -> Option<std::sync::Arc<dyn Tool>> {
self.tools.get(name).cloned()
}
}
两个工具:HTTP GET + 读文件(你后面可扩展到 DB / RPC / Kafka 等)
// src/tools/http.rs
use async_trait::async_trait;
use serde_json::{json, Value};
use anyhow::{Result, anyhow};
pub struct HttpGetTool;
#[async_trait]
impl crate::tools::Tool for HttpGetTool {
fn name(&self) -> &str { "http_get" }
fn spec(&self) -> crate::types::ToolSpec {
crate::types::ToolSpec {
name: self.name().into(),
description: "Send HTTP GET request and return response text".into(),
input_schema: json!({
"type": "object",
"properties": {
"url": {"type":"string"}
},
"required": ["url"]
}),
}
}
async fn call(&self, input: Value) -> Result<Value> {
let url = input.get("url").and_then(|v| v.as_str()).ok_or_else(|| anyhow!("missing url"))?;
let resp = reqwest::get(url).await?.text().await?;
Ok(json!({ "text": resp }))
}
}
// src/tools/fs.rs
use async_trait::async_trait;
use serde_json::{json, Value};
use anyhow::{Result, anyhow};
pub struct ReadFileTool;
#[async_trait]
impl crate::tools::Tool for ReadFileTool {
fn name(&self) -> &str { "read_file" }
fn spec(&self) -> crate::types::ToolSpec {
crate::types::ToolSpec {
name: self.name().into(),
description: "Read a local text file (UTF-8)".into(),
input_schema: json!({
"type":"object",
"properties": { "path": {"type":"string"} },
"required":["path"]
}),
}
}
async fn call(&self, input: Value) -> Result<Value> {
let path = input.get("path").and_then(|v| v.as_str()).ok_or_else(|| anyhow!("missing path"))?;
let text = tokio::fs::read_to_string(path).await?;
Ok(json!({ "text": text }))
}
}
短期记忆:就是对话上下文(消息列表),注意要做窗口裁剪,否则 tokens 爆掉。
// src/memory/short_term.rs
use crate::types::ChatMessage;
pub struct ShortTermMemory {
pub messages: Vec<ChatMessage>,
pub max_messages: usize,
}
impl ShortTermMemory {
pub fn new(max_messages: usize) -> Self {
Self { messages: vec![], max_messages }
}
pub fn push(&mut self, msg: ChatMessage) {
self.messages.push(msg);
if self.messages.len() > self.max_messages {
let overflow = self.messages.len() - self.max_messages;
self.messages.drain(0..overflow);
}
}
pub fn all(&self) -> &[ChatMessage] {
&self.messages
}
}
长期记忆:先做一个最简单的本地 JSONL 追加写(后续你可换 SQLite/向量库)。
// src/memory/long_term.rs
use anyhow::Result;
use serde_json::Value;
use tokio::io::AsyncWriteExt;
pub struct LongTermMemory {
path: String,
}
impl LongTermMemory {
pub fn new(path: impl Into<String>) -> Self {
Self { path: path.into() }
}
pub async fn append_event(&self, event: &Value) -> Result<()> {
let mut f = tokio::fs::OpenOptions::new()
.create(true).append(true)
.open(&self.path).await?;
f.write_all(event.to_string().as_bytes()).await?;
f.write_all(b"\n").await?;
Ok(())
}
}
// src/llm/mod.rs
use async_trait::async_trait;
use anyhow::Result;
use crate::types::{ChatMessage, ToolSpec};
#[async_trait]
pub trait LlmClient: Send + Sync {
async fn complete(
&self,
system_prompt: &str,
messages: &[ChatMessage],
tools: &[ToolSpec],
) -> Result<String>;
}
你可以实现一个 OpenAIClient / AnthropicClient / LocalModelClient。
本文重点是 Agent 架构,所以这里先不展开厂商细节(只要返回一个字符串即可)。
我们让 LLM 每轮输出严格 JSON:
{"type":"ToolCall","tool_name":"read_file","input":{"path":"..."}}{"type":"Final","answer":"..."}// src/agent/loop.rs
use anyhow::{Result, anyhow};
use serde_json::Value;
use crate::types::{AgentAction, ChatMessage};
pub struct AgentLoop<L: crate::llm::LlmClient> {
pub llm: std::sync::Arc<L>,
pub tools: crate::tools::ToolRegistry,
pub short_memory: crate::memory::short_term::ShortTermMemory,
pub long_memory: crate::memory::long_term::LongTermMemory,
pub system_prompt: String,
pub max_steps: usize,
}
impl<L: crate::llm::LlmClient> AgentLoop<L> {
pub async fn run(&mut self, user_goal: &str) -> Result<String> {
self.short_memory.push(ChatMessage {
role: "user".into(),
content: user_goal.into(),
name: None,
});
for step in 0..self.max_steps {
let tool_specs = self.tools.list_specs();
let raw = self.llm
.complete(&self.system_prompt, self.short_memory.all(), &tool_specs)
.await?;
let action: AgentAction = serde_json::from_str(&raw)
.map_err(|e| anyhow!("LLM output is not valid AgentAction JSON: {e}. raw={raw}"))?;
match action {
AgentAction::Final { answer } => {
self.long_memory.append_event(&serde_json::json!({
"type":"final",
"step": step,
"answer": answer
})).await?;
return Ok(answer);
}
AgentAction::ToolCall { tool_name, input } => {
let tool = self.tools.get(&tool_name)
.ok_or_else(|| anyhow!("tool not found: {tool_name}"))?;
let out = tool.call(input).await?;
// 记录工具返回到短期记忆(供下一轮推理)
self.short_memory.push(ChatMessage {
role: "tool".into(),
name: Some(tool_name.clone()),
content: out.to_string(),
});
// 也可记到长期记忆里,方便追溯
self.long_memory.append_event(&serde_json::json!({
"type":"tool_result",
"step": step,
"tool": tool_name,
"output": out
})).await?;
}
}
}
Err(anyhow!("max_steps reached without Final"))
}
}
一个好用的 system prompt 通常要做到:
示例:
const SYSTEM_PROMPT: &str = r#"
You are a helpful AI agent.
You MUST respond in valid JSON that matches one of:
1) {"type":"ToolCall","tool_name": "...", "input": {...}}
2) {"type":"Final","answer":"..."}
Rules:
- Use tools when you need external data.
- Tool input MUST follow the tool's JSON schema.
- If you have enough information, end with Final.
- If repeated tool calls do not improve progress, summarize and Final.
"#;
// src/main.rs
mod types;
mod llm;
mod tools;
mod memory;
mod agent;
use tools::{ToolRegistry};
use tools::http::HttpGetTool;
use tools::fs::ReadFileTool;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// 1) LLM client(你自己实现一个)
let llm = std::sync::Arc::new(MyLlmClient::new_from_env()?);
// 2) tools
let mut registry = ToolRegistry::new();
registry.register(HttpGetTool);
registry.register(ReadFileTool);
// 3) memory
let short_memory = memory::short_term::ShortTermMemory::new(30);
let long_memory = memory::long_term::LongTermMemory::new("agent_events.jsonl");
// 4) agent loop
let mut agent = agent::loop_::AgentLoop {
llm,
tools: registry,
short_memory,
long_memory,
system_prompt: SYSTEM_PROMPT.into(),
max_steps: 20,
};
let goal = "Read ./README.md and summarize it, then fetch https://example.com and compare topics.";
let answer = agent.run(goal).await?;
println!("{answer}");
Ok(())
}
写出 MVP 不难,难的是“上线跑一个月不崩”。给你几个关键点:
reqwest 配超时tokio-retry 或自己写指数退避governor(你如果做过 Tokio 并发控制,会很顺)当 LLM 一次规划多个独立动作时,你可以并发执行:tokio::join! / FuturesUnordered。
注意:并发后要合并 observation,并保持输出可追溯(step id)。
tracing + tracing-subscriber长期记忆最好做:
Rust Agent 适合哪些场景?如果你要做的是:
那 Rust 真的很合适。
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!