用 Rust 构建 Agent 应用的高性能框架:langchainrust 架构全景
一个 LLM Agent 应用要用到的东西很多:模型接入、提示词、链式组合、状态机编排、工具调用、检索增强、多轮记忆、护栏、评测、可观测,还要跟 MCP 工具、别的 Agent 互联。langchainrust 用 21 个 crate、5 层架构把它们组织成一套完整运行时。本文从地基拆到屋顶,每个设计点都附真实源码与取舍。
问题场景:LLM 应用工程化,难的不是「调模型」
写一个能用的 LLM 应用很简单------调一个 chat 接口就完了。写一个能上线、能迭代、能出错的 LLM 应用,是另一回事:
| 真实需求 | 对应系统 |
|---|---|
| 一个应用要换 5 家模型 | 供应商抽象 |
| 提示词 + 模型 + 解析器串起来 | 组合层 |
| 模型自己决定调哪个工具 | Agent 循环 |
| 多步骤工作流、条件分支、循环 | 状态机编排 |
| 问答要带自己的文档 | RAG |
| 多轮对话要记住上下文 | 记忆 |
| Agent 调的工具来自外部进程 | MCP |
| 多个 Agent 互相派活 | A2A |
| 不让模型输出泄露密钥 | 护栏 |
| 换模型后质量掉了没 | 评测 |
| 上线后出了事故怎么查 | 可观测 |
很多生态的答案是把这些拆成几十个互相依赖的包。langchainrust 的答案是:一个 facade、五层架构、21 个 crate 。它回答的是「这个框架是干嘛的」------LLM Agent 应用的一套完整运行时,不是某个单点的工具。
为什么是 Rust?
LangChain 这类框架起家于动态语言生态。用 Rust 重写,不是跟风,是冲着四个工程问题去的:
| 动态语言生态的工程问题 | Rust 的回应 |
|---|---|
弱类型:字段写错、None 冒出来,运行时才炸 |
类型系统把大部分错误挡在编译期 |
| 全局解释器锁:多线程模型推理并不同时跑 | 无锁并发,多 worker 真并行 |
| GC 停顿:延迟抖动不可控 | 无 GC,确定性释放 |
| 解释器 + 依赖地狱:冷启动慢、部署重 | 单二进制,cargo build --release 一个文件拷走 |
但对框架作者来说,Rust 有个更贴合 LLM 编排的特性------零成本抽象。同样是「把提示词、模型、解析器串成一条链」,动态语言靠运行时的 dict 传来传去、鸭子类型兜底;Rust 可以在编译期把链的类型算清楚,还能在该擦除的地方擦除(后面「高性能从哪来」一节细讲)。
整体架构:一个 facade,五层,21 个 crate
仓库是 21-crate 的 workspace。用户只依赖门面 langchainrust(即 crates/lc),它把全部公共 API 再导出。依赖只往下走------lc-shared / lc-schema 垫底,被所有人依赖,环依赖问题就这样从结构上消掉。
langchainrust (facade, crates/lc)
┌──────────────────────┼───────────────────────┐
基础层 Foundation 能力层 Capabilities 组合层 Composition
lc-shared lc-providers lc-chains
lc-schema lc-embeddings lc-langgraph
lc-core lc-prompts lc-agents
lc-tools lc-rag
lc-tools-derive lc-vector-stores
lc-memory
lc-sessions
┌──────────────────────┼───────────────────────┐
互联层 Protocol 质量层 Quality
lc-mcp lc-guardrails
lc-a2a lc-evaluation
lc-callbacks
| 层 | crate | 回答的问题 |
|---|---|---|
| 基础层 | lc-shared lc-schema lc-core |
类型地基:Message / Document / Runnable / BaseChatModel / BaseTool |
| 能力层 | lc-providers lc-embeddings lc-prompts lc-tools lc-tools-derive |
模型、向量、提示词、工具,每个能力一个抽象 |
| 组合层 | lc-chains lc-langgraph lc-agents lc-rag lc-vector-stores lc-memory lc-sessions |
把能力编排成应用:链、状态图、Agent、RAG、记忆 |
| 互联层 | lc-mcp lc-a2a |
跟外部世界打交道:工具协议、Agent 间协议 |
| 质量层 | lc-guardrails lc-evaluation lc-callbacks |
上线前的护栏、评测,上线后的可观测 |
下面逐层拆,每层拿一个真实组件看它是怎么设计的。
五层逐一拆解
基础层:类型地基
最底下的三个 crate 定义了整个框架的「字」------所有上层都建立在它们之上:
lc-schema:Message(角色 + 内容 + 多模态附件)。LLM 应用最核心的数据结构,就一个类型。lc-shared:跨 crate 的基础类型Document/VectorDocument/SearchResult/ToolCall/TextSplitter。把「文档」这种东西定义在最底层,上层随便用。lc-core:执行层。最关键的抽象是Runnable------LCEL 的组合子,四个基础动作invoke/batch/stream/transform。它定义了「一个可执行的东西长什么样」。
模型的统一入口也在这一层,BaseChatModel 一个 trait 概括所有模型供应商:
rust
#[async_trait]
pub trait BaseChatModel: BaseLanguageModel<Vec<Message>, LLMResult> {
async fn chat(
&self,
messages: Vec<Message>,
config: Option<RunnableConfig>,
) -> Result<LLMResult, Self::Error>;
async fn stream_chat(
&self,
messages: Vec<Message>,
config: Option<RunnableConfig>,
) -> Result<Pin<Box<dyn Stream<Item = Result<String, Self::Error>> + Send>>, Self::Error>;
}
注意两件事:返回类型里的 Self::Error------每家供应商的关联错误类型不同 ,这正是异构模型池要靠类型擦除来统一的根源(见后面 RouterLLM 的 ModelAdapter);而 stream_chat 返回 + Send 的流、方法本身是 async------任何实现者都能被扔进多线程运行时并发调用,没有 GIL 卡着。
能力层:模型、向量、提示词、工具
这一层每个能力一个抽象、一堆实现,把「换供应商」变成「改一个构造器」:
rust
let deepseek = DeepSeekChat::from_env();
let moonshot = MoonshotChat::with_model("moonshot-v1-128k");
let claude = AnthropicChat::from_env();
let ollama = OllamaChat::new("llama3.2");
lc-providers 一家供应商一个 crate 内部模块,但都实现了 BaseChatModel------应用代码从不多写一个 match。OpenAI 系的小厂(DeepSeek / Qwen / Moonshot / Zhipu / Mistral)复用 OpenAI 的请求路径,只保留各自的错误变体(ProviderError::DeepSeek),新供应商接入成本被压到极低。
提示词在 lc-prompts,PromptTemplate 解析一次、缓存分段------不是每次调用都重 parse 。工具在 lc-tools,最有料的是 #[tool] 宏:一个普通函数标一下,自动变成 BaseTool:
rust
#[tool(description = "Greets a person by name")]
fn greet(
#[param(desc = "The name of the person to greet")] name: String,
) -> Result<String, ToolError> {
Ok(format!("Hello, {}!", name))
}
宏自动展开出一个实现 BaseTool 的类型(测试里直接 GreetTool::new()),工具名、参数 schema、JSON 序列化全部生成------写工具像写普通函数,Agent 调用的工具却带上了完整类型信息,不需要手写工具 schema。
组合层:链、图、智能体、RAG、记忆
这层是「全家桶」的核心,也是代码最厚的地方。三个编排范式,由简到繁:
一、LCEL 链 ------Runnable 的 pipe 把一切串起来。v0.15 之后,提示词、记忆、原生模型、解析器、RAG 全是 Runnable,五样东西能 pipe 成一条链:
rust
// 提示词 + 模型 + 解析器
let prompt = ChatPromptTemplate::from_messages([
Message::system("你是一个简洁的 Rust 助手,只输出结论。"),
Message::human("{question}"),
]);
let qa_chain = prompt.pipe(llm.clone()).pipe(StrOutputParser::new());
// 记忆 + 模型 + 解析器(读记忆 → 拼输入 → 生成 → 写回,封装在一个 Runnable 里)
let memory = ConversationBufferMemory::new().with_return_messages(true);
let chat_chain = RunnableWithMessageHistory::new(llm.clone(), memory)
.pipe(StrOutputParser::new());
// RAG:检索增强生成作为链的一段
let pipeline = RAGPipelineBuilder::new()
.llm(llm)
.retriever(retriever)
.retrieve_k(2)
.build()?;
let rag_chain = RagRunnable::new(Arc::new(pipeline));
pipe 的类型是编译器算出来的------prompt.pipe(llm) 返回什么类型、能不能接着 .pipe(parser),在编译期就知道 ,接错了根本编译不过。动态语言里同样的链,错误要到运行时第一行才炸;Rust 在 cargo build 就告诉你。
二、LangGraph 状态图 ------链是「线性」的,工作流是「有分支、有循环」的。lc-langgraph 提供节点 + 边的图:
rust
let compiled = GraphBuilder::<AgentState>::new()
.add_node_fn("greet", |state: &AgentState| {
Ok(StateUpdate::full(AgentState::new(format!("你好:{}", state.input))))
})
.add_node_fn("reply", |state: &AgentState| {
let mut s = state.clone();
s.set_output(format!("回复:{}", state.input));
Ok(StateUpdate::full(s))
})
.add_edge(START, "greet")
.add_edge("greet", "reply")
.add_edge("reply", END)
.compile()?;
let result = compiled.invoke(AgentState::new("世界".to_string())).await?;
状态被显式建模(AgentState),节点是纯函数(&state -> Result<StateUpdate>),边决定流向。加条件边、FanOut/FanIn 并行、Reducer 合并、Checkpointer 持久化------Agent 应用的工作流从「if-else 糊在一起」变成「声明式的图」。
三、AgentExecutor 循环 ------Agent 的本质是「模型自己决定下一步」。lc-agents 把「翻译」和「执行」拆开:BaseAgent 把模型输出翻译成决定(Action / Finish),AgentExecutor 是那个唯一的真实循环:
rust
let tools: Vec<Arc<dyn BaseTool>> = vec![Arc::new(Calculator::new())];
let agent = FunctionCallingAgent::new(llm, tools.clone(), None);
let executor = AgentExecutor::new(Arc::new(agent) as Arc<dyn BaseAgent>, tools)
.with_max_iterations(3)
.with_verbose(true);
let result = executor.invoke("What is 25 + 17?".to_string()).await?;
循环不是无限转的------max_iterations 被钳在 [1, 100],工具调用有超时,LLM 调用带指数退避重试,并行动作有信号量限流。这些是「生产加固」,不是可选项:Agent 循环一旦失控,就是无限的 API 账单。
记忆在 lc-memory(缓冲/窗口/摘要/摘要+原始四种),会话在 lc-sessions,检索在 lc-rag(BM25 / 向量 / 混合 RRF 融合 / GraphRAG)。所有检索器实现同一个 RetrieverTrait------换检索策略不换管道代码。
互联层:MCP 工具协议 + A2A 智能体协议
MCP(Model Context Protocol) 让 Agent 调「外部的工具」------一个独立进程暴露的工具。lc-mcp 实现了完整的 client + server,Stdio 和 SSE 两种传输,全部 6 种原语(Resources / Prompts / Completion / Elicitation / Roots / Sampling)。工具经 MCPToolAdapter 实现 BaseTool,MCP 工具和本地工具在 Agent 眼里没有区别:
rust
// MCP 工具混进本地工具,同一个 Vec<Arc<dyn BaseTool>>
let tools: Vec<Arc<dyn BaseTool>> = vec![
Arc::new(Calculator::new()),
Arc::new(MCPToolAdapter::new(mcp_client.clone(), tool_definition)),
];
A2A(Agent-to-Agent) 解决「Agent 之间怎么派活」。lc-a2a 实现了完整的任务生命周期状态机------9 个状态、14 条合法转移、终态不可复活,加上空串幂等占位挡请求重放、std::sync::Mutex + RAII 守卫防并发抢单、LRU+TTL 管内存。一个 BaseChain 包进 A2AServer 就变成网络上的 Agent:
rust
let server = A2AServer::new(Arc::new(chain) as Arc<dyn langchainrust::BaseChain>)
.with_auth_token("secret-token")
.with_streaming(64);
server.serve_on(listener).await?;
两者怎么分工 :A2A 管 Agent ↔ Agent,A2A 之下是 MCP 管 Agent → 工具。一个多智能体系统里,顶层 Agent 通过 A2A 给子 Agent 派活,每个 Agent 再通过 MCP 调工具------两个协议各司其职、垂直叠加。
质量层:护栏、评测、可观测
LLM 应用和传统应用最大的区别:输出不可控。质量层是专门跟「不可控」对抗的。
护栏(lc-guardrails) 。输入和输出分开拦,而且类型上就分死------InputGuardrailResult 只有 Pass/Block,OutputGuardrailResult 才有 Modify。「Modify 只能用在输出」这件事被编译器强制执行,不是靠约定:
rust
let config = GuardrailsConfig::new()
.with_input(Arc::new(MaxLengthGuardrail::new(1000)))
.with_output(Arc::new(SensitiveInfoGuardrail::new()));
let mut guarded = GuardedAgent::new(executor, config);
let result = guarded.invoke("What is 2 + 2?".to_string()).await?;
if guarded.violations().is_empty() {
println!("→ 未触发任何护栏 ✓");
}
内置护栏里有个值得单独讲的:SensitiveInfoGuardrail 对「普通提及」放行、对「具体模式」拦截------"请联系 user@example.com" 拦(具体邮箱),"你的 password 字段建议改用环境变量" 放行(普通提及)。还可以挂 LLM 裁判对「赋值式提及」(password=hunter2)二次判断真实泄露才拦。护栏不是粗暴的关键词黑名单,是分级、上下文敏感的。
评测(lc-evaluation) 。10+ 评测器(ExactMatch / Bleu / EmbeddingSimilarity / LLMAsJudge / Faithfulness......),EvalRunner 把「全部用例 × 全部评测器」跑成一个报告------换模型、换提示词之后质量降没降,一测便知。
可观测(lc-callbacks) 。SpanGuard 用 RAII 保证追踪 span 一定被关闭------不管链路成功、报错还是 panic,析构函数兜底,「漏关 span」这种可观测性事故从结构上不存在。
高性能从哪来?
标题说是「高性能框架」,那就得回答:性能到底从哪来?不是某一行魔法,是五条设计原则叠出来的:
| 设计点 | 性能/可靠性收益 | 实现手段 |
|---|---|---|
| 类型系统当边界 | 组合错误、状态错误编译期拦截,不是运行时炸 | Runnable 的 pipe 类型;Guardrail 输入/输出类型分离 |
| 无 GC、无 GIL | 多 worker 真并行;延迟无抖动 | 所有模型/工具 Send + Sync;tokio async |
| 零成本抽象,该擦除才擦除 | 泛型组合零开销;异构场景才 dyn |
Box<dyn BaseChatModel> 仅在需要类型擦除处(如 RouterLLM) |
| 流式一等公民 | 首 token ~1s;流式结构化输出不断句 | stream_chat + PartialJsonParser |
| 资源治理内建 | 内存不爆、坏节点不拖垮 | LLMCache LRU、A2A 存储 LRU+TTL、MCP 退避重连+熔断 |
| 生产加固前置 | 确定性失败路径先消掉 | 工具超时、LLM 指数退避、max_iterations 钳制、CancellationToken 贯穿 |
单挑一个最能体现「Rust 味」的点:Runnable 的组合是静态类型,但异构池用类型擦除 。RouterLLM 里,Box<dyn RoutedModel> 让不同供应商的模型共存一个槽位池,同时 ModelAdapter<M> 在构造时把错误统一成 RouterError------泛型组合零开销、需要多态的地方才擦除,两个都不浪费。同样的设计在动态语言里只能是运行时 dict。
冷启动 也值得一提:一个 agent 服务 cargo build --release 出一个二进制,拷贝即跑,没有解释器启动、没有 import 扫描。需要按秒冷启动一批实例的场景下,这是实打实的部署成本差。
实战:五个能力串成一条链
仓库的 lcel_compose 示例把「提示词 + 记忆 + 模型 + 解析器 + RAG」五个能力 pipe 进一个程序------这就是全家桶的终点形态:
rust
// 1. 提示词 + 模型 + 解析器
let qa_chain = prompt.pipe(llm.clone()).pipe(StrOutputParser::new());
// 2. 记忆 + 模型 + 解析器(多轮对话,记忆自动读写)
let chat_chain = RunnableWithMessageHistory::new(llm.clone(), memory)
.pipe(StrOutputParser::new());
// 3. RAG 链:BM25 本地检索(不依赖向量服务)+ LLM 生成
let rag_chain = RagRunnable::new(Arc::new(pipeline));
// 三个链都是 Runnable,调用方式完全一致
qa_chain.invoke(vars, None).await?; // 回答问题
chat_chain.invoke("我叫什么名字?".into(), None).await?; // 记得上一轮
rag_chain.invoke("Rust 有哪些核心特性?".into(), None).await?; // 检索+生成
想再往上走,把链包进 AgentExecutor 就是会调工具的 Agent;包进 GuardedAgent 就带上护栏;包进 A2AServer 就变成能被别的 Agent 调用的网络服务。从一条链到一个多智能体系统,每一步都只加一层包装,不换地基。
总结
| 层 | 代表 crate | 解决的问题 |
|---|---|---|
| 基础层 | lc-core lc-schema lc-shared |
类型地基:Runnable / Message / Document |
| 能力层 | lc-providers lc-embeddings lc-tools |
换模型/换向量/写工具,只改构造器 |
| 组合层 | lc-langgraph lc-agents lc-rag lc-chains |
链、状态图、Agent 循环、RAG 编排 |
| 互联层 | lc-mcp lc-a2a |
Agent 调外部工具、Agent 之间派活 |
| 质量层 | lc-guardrails lc-evaluation lc-callbacks |
输出不可控的对抗:护栏、评测、可观测 |
langchainrust 的设计哲学是:LLM 应用的「不可控」,不该靠运行时小心应对,该靠类型系统和架构纪律在编译期就按住一部分、在结构上消灭一部分。无 GC 换来真并行,类型擦除换来组合自由,状态机表换来终态不可复活,RAII 换来资源永不泄漏------每一条都是 Rust 给 LLM 应用工程化的回答。