概述
欢迎阅读《用 Rust 构建你自己的迷你 Claw Code》。在接下来的七个章节中,你将从零开始实现一个迷你 coding agent——一个类似 Claude Code 或 OpenCode 的小型程序——它接收提示词(Prompt),与大语言模型(LLM)对话,并使用*工具(Tool)*与真实世界交互。之后,一系列扩展章节将添加流式传输、终端界面(TUI)、用户输入、计划模式等功能。
在本书结束时,你将拥有一个能运行 Shell 命令、读写文件、编辑代码的 agent,全部由 LLM 驱动。在第 6 章之前不需要 API 密钥,到那时默认模型是
openrouter/free
——OpenRouter 上的免费端点,无需任何费用。
什么是 AI agent?
LLM 本身只是一个函数:文本输入,文本输出。让它总结 doc.pdf,它要么拒绝,要么产生幻觉——因为它没有办法打开文件。
Agent 通过给 LLM 提供工具(Tool) 来解决这个问题。工具就是你的代码可以运行的函数——读取文件、执行 Shell 命令、调用 API。Agent 在一个循环中运行:
- 将用户的提示词发送给 LLM。
- LLM 判断它需要读取
doc.pdf,并输出一个工具调用。 - 你的代码执行
read工具,将文件内容返回。 - LLM 现在有了文本内容,返回一个摘要。
LLM 从不直接接触文件系统。它只是请求,而你的代码执行。这个循环——请求、执行、反馈——就是全部的核心思想。
LLM 如何使用工具?
LLM 无法执行代码。它是一个文本生成器。所以“调用工具“实际上意味着 LLM 输出一个结构化请求,然后由你的代码完成剩余工作。
当你向 LLM 发送请求时,你会在对话旁附上一个工具定义(Tool Definition) 列表。每个定义包含一个名称、一段描述和一个描述参数的 JSON Schema。对于我们的 read 工具,它看起来像这样:
{
"name": "read",
"description": "Read the contents of a file.",
"parameters": {
"type": "object",
"properties": {
"path": { "type": "string" }
},
"required": ["path"]
}
}
LLM 读取这些定义的方式与读取用户提示词相同——它们只是输入的一部分。当它决定需要读取一个文件时,它不会运行任何代码。它生成一个结构化输出,如:
{ "name": "read", "arguments": { "path": "doc.pdf" } }
同时附带一个信号,表示“我还没完成——我发起了一个工具调用。“你的代码解析这个输出,运行真正的函数,并将结果作为新消息发送回去。然后 LLM 在包含该结果的上下文中继续生成。
以下是“总结 doc.pdf“示例的完整交互过程:
sequenceDiagram
participant U as User
participant A as Agent
participant L as LLM
participant T as read tool
U->>A: "Summarize doc.pdf"
A->>L: prompt + tool definitions
L-->>A: tool_call: read("doc.pdf")
A->>T: read("doc.pdf")
T-->>A: file contents
A->>L: tool result (file contents)
L-->>A: "Here is a summary: ..."
A->>U: "Here is a summary: ..."
LLM 的唯一职责是决定调用哪个工具以及传递什么参数。你的代码完成实际工作。
伪代码中的最小 agent
以下是上述示例的代码形式:
tools = [read_file]
messages = ["Summarize doc.pdf"]
loop:
response = llm(messages, tools)
if response.done:
print(response.text)
break
// LLM 想要调用一个工具——执行它并将结果反馈回去。
for call in response.tool_calls:
result = execute(call.name, call.args)
messages.append(result)
这就是整个 agent。本书的其余部分就是用 Rust 实现每一个组件——llm 函数、工具,以及连接它们的类型。
工具调用循环
以下是单次 agent 调用的流程:
flowchart TD
A["👤 User prompt"] --> B["🤖 LLM"]
B -- "StopReason::Stop" --> C["✅ Text response"]
B -- "StopReason::ToolUse" --> D["🔧 Execute tool calls"]
D -- "tool results" --> B
- 用户发送提示词。
- LLM 要么返回文本(完成),要么请求一个或多个工具调用。
- 你的代码执行每个工具并收集结果。
- 结果作为新消息反馈给 LLM。
- 从第 2 步重复,直到 LLM 返回文本。
这就是整个架构。其他一切都是实现细节。
我们将构建什么
我们将构建一个简单的 agent 框架,包含:
4 个工具:
| 工具 | 功能 |
|---|---|
read | 读取文件内容 |
write | 将内容写入文件(按需创建目录) |
edit | 替换文件中的精确字符串 |
bash | 运行 Shell 命令并捕获输出 |
1 个提供者(Provider):
| Provider | 用途 |
|---|---|
OpenRouterProvider | 通过 OpenAI 兼容 API,经 HTTP 与真实 LLM 通信 |
测试使用 MockProvider,它返回预配置的响应,这样你无需 API 密钥就能运行完整的测试套件。
项目结构
该项目是一个 Cargo 工作区(Workspace),包含三个 crate 和一本教程书:
mini-claw-code/
Cargo.toml # 工作区根目录
mini-claw-code/ # 参考实现(先别偷看!)
mini-claw-code-starter/ # 你的代码——在这里实现功能
mini-claw-code-xtask/ # 辅助命令 (cargo x ...)
mini-claw-code-book/ # 本教程
- mini-claw-code 包含完整的、可运行的实现。它的存在是为了让测试套件能验证练习是可解的,但你应该在自己尝试之前避免阅读它。
- mini-claw-code-starter 是你的工作 crate。每个源文件包含结构体定义、带有
unimplemented!()函数体的 trait 实现,以及文档注释提示。你的任务是将unimplemented!()调用替换为真正的代码。 - mini-claw-code-xtask 提供
cargo x辅助工具,包含check、solution-check和book命令。 - mini-claw-code-book 就是这本 mdbook 教程。
前置条件
在开始之前,请确保你有:
- 已安装 Rust(需要 1.85+,用于 edition 2024)。从 https://rustup.rs 安装。
- 基础的 Rust 知识:所有权、结构体、枚举、模式匹配,以及
Result/Option。如果你已读过《The Rust Programming Language》的前半部分,就可以开始了。 - 一个终端和一个文本编辑器。
- mdbook(可选,用于在本地阅读教程)。使用
cargo install mdbook mdbook-mermaid安装。
在第 6 章之前你不需要 API 密钥。第 1 到 5 章使用 MockProvider 进行测试,所以一切都在本地运行。
设置
克隆仓库并验证能否构建:
git clone https://github.com/odysa/mini-claw-code.git
cd mini-claw-code
cargo build
然后验证测试工具是否工作:
cargo test -p mini-claw-code-starter ch1
测试应该会失败——这是预期的!你在第 1 章的任务就是让它们通过。
如果 cargo x 不工作,请确保你在工作区根目录(包含顶层 Cargo.toml 的目录)。
章节路线图
| 章节 | 主题 | 你将构建什么 |
|---|---|---|
| 1 | 核心类型 | MockProvider ——通过构建测试辅助工具来理解核心类型 |
| 2 | 你的第一个工具 | ReadTool ——读取文件 |
| 3 | 单轮对话 | single_turn() ——显式匹配 StopReason,一轮工具调用 |
| 4 | 更多工具 | BashTool、WriteTool、EditTool |
| 5 | 你的第一个 Agent SDK! | SimpleAgent ——将 single_turn() 泛化为循环 |
| 6 | OpenRouter Provider | OpenRouterProvider ——与真实 LLM API 通信 |
| 7 | 简单的 CLI | 将所有组件连接成一个带有对话记忆的交互式 CLI |
| 8 | 奇点时刻 | 你的 agent 现在可以编写自身的代码了——接下来是什么 |
第 1–7 章是动手实践:你在 mini-claw-code-starter 中编写代码并运行测试来检验你的成果。第 8 章标志着向扩展章节(第 9 章以后)的过渡,这些章节将引导你阅读参考实现:
| 章节 | 主题 | 新增内容 |
|---|---|---|
| 9 | 更好的 TUI | Markdown 渲染、加载动画、折叠的工具调用 |
| 10 | 流式传输 | 带有 SSE 解析和 AgentEvent 的 StreamingAgent |
| 11 | 用户输入 | AskTool ——让 LLM 向你提出澄清性问题 |
| 12 | 计划模式 | PlanAgent ——带有审批门控的只读计划阶段 |
第 1–7 章遵循相同的节奏:
- 阅读本章以理解概念。
- 在
mini-claw-code-starter/src/中打开相应的源文件。 - 将
unimplemented!()调用替换为你的实现。 - 运行
cargo test -p mini-claw-code-starter chN检验你的成果。
准备好了?让我们开始构建一个 agent。
下一步
前往第 1 章:核心类型,了解基础类型——StopReason、Message 和 Provider trait——并构建 MockProvider,你将在接下来四个章节中使用的测试辅助工具。
第一章:核心类型
在本章中,你将了解组成 agent 协议的各种类型——
StopReason、AssistantTurn、Message 以及 Provider trait。它们是
构建所有其他功能的基石。
为了验证你的理解,你将实现一个小型测试辅助工具:
MockProvider,一个返回预配置响应的结构体,让你在没有 API 密钥的情况下
测试后续章节的代码。
目标
理解核心类型,然后实现 MockProvider,使其满足以下要求:
- 使用一个包含预设响应的
VecDeque<AssistantTurn>来创建它。 - 每次调用
chat()返回序列中的下一个响应。 - 如果所有响应都已消费完毕,则返回一个错误。
核心类型
打开 mini-claw-code-starter/src/types.rs。这些类型定义了
agent 与任何 LLM 后端之间的协议。
以下是它们之间的关系:
classDiagram
class Provider {
<<trait>>
+chat(messages, tools) AssistantTurn
}
class AssistantTurn {
text: Option~String~
tool_calls: Vec~ToolCall~
stop_reason: StopReason
}
class StopReason {
<<enum>>
Stop
ToolUse
}
class ToolCall {
id: String
name: String
arguments: Value
}
class Message {
<<enum>>
System(String)
User(String)
Assistant(AssistantTurn)
ToolResult(id, content)
}
class ToolDefinition {
name: &'static str
description: &'static str
parameters: Value
}
Provider --> AssistantTurn : returns
Provider --> Message : receives
Provider --> ToolDefinition : receives
AssistantTurn --> StopReason
AssistantTurn --> ToolCall : contains 0..*
Message --> AssistantTurn : wraps
Provider 接收消息和工具定义,返回一个
AssistantTurn。该回合的 stop_reason 告诉你接下来该做什么。
ToolDefinition 及其构建器
#![allow(unused)]
fn main() {
pub struct ToolDefinition {
pub name: &'static str,
pub description: &'static str,
pub parameters: Value,
}
}
每个工具(tool)声明一个 ToolDefinition,告诉 LLM 它能做什么。
parameters 字段是一个 JSON Schema 对象,描述该工具的参数。
与其每次手动构建 JSON,ToolDefinition 提供了一个构建器(builder)API:
#![allow(unused)]
fn main() {
ToolDefinition::new("read", "Read the contents of a file.")
.param("path", "string", "The file path to read", true)
}
new(name, description)创建一个带有空参数 schema 的定义。param(name, type, description, required)添加一个参数并返回self,因此你可以链式调用。
从第二章开始,你将在每个工具中使用这个构建器。
StopReason 和 AssistantTurn
#![allow(unused)]
fn main() {
pub enum StopReason {
Stop,
ToolUse,
}
pub struct AssistantTurn {
pub text: Option<String>,
pub tool_calls: Vec<ToolCall>,
pub stop_reason: StopReason,
}
}
ToolCall 结构体保存单次工具调用的信息:
#![allow(unused)]
fn main() {
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: Value,
}
}
每个工具调用都有一个 id(用于将结果匹配回请求)、一个 name
(调用哪个工具)和 arguments(工具将要解析的 JSON 值)。
LLM 的每个响应都带有一个 stop_reason,告诉你模型为什么停止生成:
StopReason::Stop– 模型已完成。检查text获取响应内容。StopReason::ToolUse– 模型想要调用工具。检查tool_calls。
这就是原始的 LLM 协议:模型告诉你接下来该做什么。在第三章中,
你将编写一个函数,显式地对 stop_reason 进行 match 来处理每种情况。
在第五章中,你将把该 match 包裹在一个循环中,创建完整的 agent。
Provider trait
#![allow(unused)]
fn main() {
pub trait Provider: Send + Sync {
fn chat<'a>(
&'a self,
messages: &'a [Message],
tools: &'a [&'a ToolDefinition],
) -> impl Future<Output = anyhow::Result<AssistantTurn>> + Send + 'a;
}
}
这段代码的意思是:“一个 Provider 是能够接收一组消息和一组工具定义,
并异步返回一个 AssistantTurn 的东西。”
Send + Sync 约束意味着 provider 必须可以安全地在线程间共享。
这很重要,因为 tokio(异步运行时)可能会在线程之间移动任务。
注意 chat() 接收的是 &self 而不是 &mut self。真正的 provider
(OpenRouterProvider)不需要可变性——它只是发送 HTTP 请求。
如果将 trait 设计为 &mut self,就会强制每个调用者持有独占访问权,
这是不必要的限制。代价是:MockProvider(测试辅助工具)确实
需要修改其响应列表,因此它必须使用内部可变性(interior mutability)
来遵守该 trait。
Message 枚举
#![allow(unused)]
fn main() {
pub enum Message {
System(String),
User(String),
Assistant(AssistantTurn),
ToolResult { id: String, content: String },
}
}
对话历史是一个 Message 值的列表:
System(text)– 系统提示词,设置 agent 的角色和行为。 通常是历史记录中的第一条消息。User(text)– 来自用户的提示。Assistant(turn)– 来自 LLM 的响应(文本、工具调用或两者兼有)。ToolResult { id, content }– 执行工具调用的结果。id与ToolCall::id匹配,以便 LLM 知道该结果属于哪个调用。
从第三章构建 single_turn() 函数开始,你将使用这些变体。
为什么 Provider 使用 impl Future 而 Tool 使用 #[async_trait]
你可能会注意到,在第二章中 Tool trait 使用了 #[async_trait],而
Provider 直接使用 impl Future。区别在于 trait 的使用方式:
Provider以泛型方式使用(SimpleAgent<P: Provider>)。 编译器在编译时知道具体类型,因此impl Future可以工作。Tool作为 trait 对象(Box<dyn Tool>)存储在一个包含不同工具类型的集合中。 Trait 对象需要统一的返回类型,#[async_trait]通过对 future 进行装箱来提供这一点。
当实现使用 impl Future 的 trait 时,你可以在 impl 块中直接写
async fn——Rust 会自动将其脱糖为 impl Future 形式。所以虽然
trait 定义写的是 -> impl Future<...>,你的实现可以直接写
async fn chat(...)。
如果现在还不太理解这个区别,到第五章看到两种模式同时使用时就会豁然开朗。
ToolSet – 工具集合
还有一个类型你将从第三章开始使用:ToolSet。它包装了一个
HashMap<String, Box<dyn Tool>>,按名称索引工具,在执行工具调用时
提供 O(1) 查找。你可以用构建器来创建它:
#![allow(unused)]
fn main() {
let tools = ToolSet::new()
.with(ReadTool::new())
.with(BashTool::new());
}
你不需要实现 ToolSet——它已经在 types.rs 中提供。
实现 MockProvider
现在你已经理解了这些类型,让我们付诸实践。MockProvider 是一个
测试辅助工具——它通过返回预设响应而不是调用真实 LLM 来实现 Provider。
你将在第 2 到第 5 章中使用它来测试工具和 agent 循环,而无需 API 密钥。
打开 mini-claw-code-starter/src/mock.rs。你会看到结构体和方法签名
已经布置好,函数体为 unimplemented!()。
使用 Mutex 实现内部可变性
MockProvider 需要在每次调用 chat() 时从列表中移除响应。
但 chat() 接收的是 &self。如何通过共享引用进行修改呢?
Rust 的 std::sync::Mutex 提供了内部可变性(interior mutability):
你将值包装在 Mutex 中,调用 .lock().unwrap() 即可获得一个可变的守卫(guard),
即使是通过 &self。锁确保同一时间只有一个线程访问数据。
#![allow(unused)]
fn main() {
use std::collections::VecDeque;
use std::sync::Mutex;
struct MyState {
items: Mutex<VecDeque<String>>,
}
impl MyState {
fn take_one(&self) -> Option<String> {
self.items.lock().unwrap().pop_front()
}
}
}
第一步:结构体字段
结构体已经有了你需要的字段:一个 Mutex<VecDeque<AssistantTurn>>
用于保存响应。这是预先提供的,以便方法签名能够编译通过。
你的任务是实现使用该字段的方法。
第二步:实现 new()
new() 方法接收一个 VecDeque<AssistantTurn>。我们需要 FIFO 顺序——
每次调用 chat() 应该返回第一个剩余的响应,而不是最后一个。
VecDeque::pop_front() 恰好以 O(1) 的时间复杂度完成这项工作:
flowchart LR
subgraph "VecDeque (FIFO)"
direction LR
A["A"] ~~~ B["B"] ~~~ C["C"]
end
A -- "pop_front()" --> out1["chat() → A"]
B -. "next call" .-> out2["chat() → B"]
C -. "next call" .-> out3["chat() → C"]
因此在 new() 中:
- 将输入的 deque 包装在
Mutex中。 - 存储到
Self中。
第三步:实现 chat()
chat() 方法应该:
- 锁定 mutex。
pop_front()取出下一个响应。- 如果有响应,返回
Ok(response)。 - 如果 deque 为空,返回一个错误。
mock provider 有意忽略 messages 和 tools 参数。
它不关心“用户“说了什么——只是返回下一个预设响应。
将 Option 转换为 Result 的一个实用模式:
#![allow(unused)]
fn main() {
some_option.ok_or_else(|| anyhow::anyhow!("no more responses"))
}
运行测试
运行第一章的测试:
cargo test -p mini-claw-code-starter ch1
测试验证内容
test_ch1_returns_text:创建一个包含一个文本响应的MockProvider。 调用一次chat()并检查文本是否匹配。test_ch1_returns_tool_calls:创建一个包含一个工具调用响应的 provider。 验证工具调用的名称和 id。test_ch1_steps_through_sequence:创建一个包含三个响应的 provider。 调用chat()三次,验证它们按正确顺序返回(First、Second、Third)。
这些是核心测试。还有一些额外的边界情况测试(空响应、队列耗尽、 多个工具调用等),一旦你的核心实现正确,它们也会通过。
回顾
你已经学习了定义 agent 协议的核心类型:
StopReason告诉你 LLM 是已完成还是想要调用工具。AssistantTurn承载 LLM 的响应——文本、工具调用或两者兼有。Provider是任何 LLM 后端都要实现的 trait。
你还构建了 MockProvider,一个测试辅助工具,你将在接下来的四章中
使用它来模拟 LLM 对话,无需 HTTP 请求。
下一步
在第二章:你的第一个工具中,你将实现
ReadTool——一个读取文件内容并将其返回给 LLM 的工具。
第二章:你的第一个工具(Tool)
现在你已经有了一个 mock provider,是时候构建你的第一个工具了。你将实现
ReadTool —— 一个读取文件并返回其内容的工具。这是我们 agent 中最简单的工具,
但它引入了所有其他工具都遵循的 Tool trait 模式。
目标
实现 ReadTool,使其满足以下要求:
- 声明其名称、描述和参数 schema。
- 当以
{"path": "some/file.txt"}作为参数调用时,读取文件并以字符串形式返回 其内容。 - 缺少参数或文件不存在时产生错误。
关键 Rust 概念
Tool trait
打开 mini-claw-code-starter/src/types.rs,查看 Tool trait:
#![allow(unused)]
fn main() {
#[async_trait::async_trait]
pub trait Tool: Send + Sync {
fn definition(&self) -> &ToolDefinition;
async fn call(&self, args: Value) -> anyhow::Result<String>;
}
}
两个方法:
definition()返回工具的元数据:名称、描述,以及描述其参数的 JSON Schema。 LLM 使用这些信息来决定调用哪个工具以及如何格式化参数。call()实际执行工具。它接收一个包含参数的serde_json::Value,并返回 一个字符串结果。
ToolDefinition
#![allow(unused)]
fn main() {
pub struct ToolDefinition {
pub name: &'static str,
pub description: &'static str,
pub parameters: Value,
}
}
正如你在第一章中看到的,ToolDefinition 有一个用于声明参数的 builder API。对于
ReadTool,我们需要一个名为 "path"、类型为 "string" 的必填参数:
#![allow(unused)]
fn main() {
ToolDefinition::new("read", "Read the contents of a file.")
.param("path", "string", "The file path to read", true)
}
在底层,builder 构造了你在第一章中看到的 JSON Schema。最后一个参数(true)
将该参数标记为必填。
为什么使用 #[async_trait] 而不是普通的 async fn?
你可能会好奇,为什么我们使用 async_trait 宏而不是直接在 trait 中编写
async fn。原因是trait 对象兼容性(trait object compatibility)。
稍后在 agent 循环中,我们会将工具存储在一个 ToolSet 中 —— 一个基于 HashMap
的集合,通过统一接口存放不同类型的工具。这需要动态分发(dynamic dispatch),
意味着编译器需要在编译时知道返回类型的大小。
trait 中的 async fn 会为每个实现生成不同的、大小各异的 Future 类型。这会破坏
动态分发。#[async_trait] 宏会自动将 async fn 改写为返回
Pin<Box<dyn Future<...>>> 的方法,无论哪个工具产生它,其大小都是固定已知的。
你只需编写普通的 async fn 代码,宏会替你处理装箱(boxing)。
以下是 agent 调用工具时的数据流:
flowchart LR
A["LLM 返回<br/>ToolCall"] --> B["args: JSON Value<br/>{"path": "f.txt"}"]
B --> C["Tool::call(args)"]
C --> D["Result: String<br/>(文件内容)"]
D --> E["作为 ToolResult<br/>发回给 LLM"]
LLM 从不直接接触文件系统。它产生一个 JSON 请求,你的代码执行请求,然后返回 一个字符串。
实现
打开 mini-claw-code-starter/src/tools/read.rs。结构体、Default 实现和方法
签名已经提供好了。
记得在你的 impl Tool for ReadTool 块上添加 #[async_trait::async_trait] 注解。
starter 文件中已经预置了这个注解。
第一步:实现 new()
创建一个 ToolDefinition 并将其存储在 self.definition 中。使用 builder:
#![allow(unused)]
fn main() {
ToolDefinition::new("read", "Read the contents of a file.")
.param("path", "string", "The file path to read", true)
}
第二步:definition() —— 已提供
definition() 方法已经在 starter 中实现了 —— 它只是简单地返回
&self.definition。这里不需要做任何工作。
第三步:实现 call()
这是真正的工作所在。你的实现应该:
- 从
args中提取"path"参数。 - 异步读取文件。
- 返回文件内容。
以下是大致结构:
#![allow(unused)]
fn main() {
async fn call(&self, args: Value) -> anyhow::Result<String> {
// 1. 提取 path
// 2. 使用 tokio::fs::read_to_string 读取文件
// 3. 返回内容
}
}
一些有用的 API:
args["path"].as_str()返回Option<&str>。使用来自anyhow的.context("missing 'path' argument")?将None转换为描述性错误。tokio::fs::read_to_string(path).await异步读取文件。链式调用.with_context(|| format!("failed to read '{path}'"))?以获得清晰的错误信息。
就是这样 —— 提取路径,读取文件,返回内容。
运行测试
运行第二章的测试:
cargo test -p mini-claw-code-starter ch2
测试验证内容
test_ch2_read_definition:创建一个ReadTool,检查其名称是"read"、 描述非空,且"path"在必填参数中。test_ch2_read_file:创建一个包含已知内容的临时文件,用文件路径调用ReadTool,检查返回的内容是否匹配。test_ch2_read_missing_file:用一个不存在的路径调用ReadTool,验证它 返回错误。test_ch2_read_missing_arg:用一个空的 JSON 对象(没有"path"键)调用ReadTool,验证它返回错误。
还有一些额外的边界情况测试(空文件、Unicode 内容、错误的参数类型等),一旦你的 核心实现正确,这些测试也会通过。
回顾
你通过实现 Tool trait 构建了你的第一个工具。关键模式:
ToolDefinition::new(...).param(...)声明工具的名称、描述和参数。#[async_trait::async_trait]放在impl块上,让你可以编写async fn call()同时保持 trait 对象兼容性。tokio::fs用于异步文件 I/O。anyhow::Context用于添加描述性错误信息。
agent 中的每个工具都遵循完全相同的结构。一旦你理解了 ReadTool,其余的工具
都是在此基础上的变体。
下一步
在第三章:单轮调用中,你将编写一个函数,通过匹配
StopReason 来处理一轮工具调用。
第三章:单轮交互(Single Turn)
你已经有了一个 provider 和一个工具。在进入完整的 agent 循环之前,让我们
先看看原始协议:LLM 返回一个 stop_reason,告诉你它是已经完成了还是想要
使用工具。在本章中,你将编写一个函数,处理恰好一个提示词(prompt),最多
进行一轮工具调用。
目标
实现 single_turn(),使其:
- 将提示词发送给 provider。
- 对
stop_reason进行模式匹配(match)。 - 如果是
Stop—— 返回文本。 - 如果是
ToolUse—— 执行工具,将结果发回,返回最终文本。
没有循环,只有一轮。
关键 Rust 概念
ToolSet —— 一个工具的 HashMap
函数签名接收 &ToolSet 而不是原始的切片(slice)或向量(vector):
#![allow(unused)]
fn main() {
pub async fn single_turn<P: Provider>(
provider: &P,
tools: &ToolSet,
prompt: &str,
) -> anyhow::Result<String>
}
ToolSet 包装了一个 HashMap<String, Box<dyn Tool>>,并按定义名称索引工具。
这样在执行工具调用时可以实现 O(1) 查找,而不需要遍历列表。构建器 API 会自动
从每个工具的定义中提取名称:
#![allow(unused)]
fn main() {
let tools = ToolSet::new().with(ReadTool::new());
let result = single_turn(&provider, &tools, "Read test.txt").await?;
}
对 StopReason 进行 match
这是核心教学要点。与其检查 tool_calls.is_empty(),不如显式地对 stop reason
进行匹配:
#![allow(unused)]
fn main() {
match turn.stop_reason {
StopReason::Stop => { /* 返回文本 */ }
StopReason::ToolUse => { /* 执行工具 */ }
}
}
这使得协议变得可见。LLM 告诉你该做什么,而你显式地处理每种情况。
以下是 single_turn() 的完整流程:
flowchart TD
A["prompt"] --> B["provider.chat()"]
B --> C{"stop_reason?"}
C -- "Stop" --> D["返回文本"]
C -- "ToolUse" --> E["执行每个工具调用"]
E --> F{"工具出错?"}
F -- "Ok" --> G["result = output"]
F -- "Err" --> H["result = 错误信息"]
G --> I["推入 Assistant 消息"]
H --> I
I --> J["推入 ToolResult 消息"]
J --> K["再次调用 provider.chat()"]
K --> L["返回最终文本"]
与完整 agent 循环(第五章)的关键区别在于,这里没有外层循环。如果 LLM 第二次
请求使用工具,single_turn() 不会处理 —— 那是 agent 循环的职责。
实现
打开 mini-claw-code-starter/src/agent.rs。你会看到 single_turn() 的函数
签名在文件顶部,位于 SimpleAgent 结构体之前。
步骤 1:收集工具定义
ToolSet 有一个 definitions() 方法,返回所有工具的 schema:
#![allow(unused)]
fn main() {
let defs = tools.definitions();
}
步骤 2:创建初始消息
#![allow(unused)]
fn main() {
let mut messages = vec![Message::User(prompt.to_string())];
}
步骤 3:调用 provider
#![allow(unused)]
fn main() {
let turn = provider.chat(&messages, &defs).await?;
}
步骤 4:对 stop_reason 进行匹配
这是函数的核心:
#![allow(unused)]
fn main() {
match turn.stop_reason {
StopReason::Stop => Ok(turn.text.unwrap_or_default()),
StopReason::ToolUse => {
// 执行工具,发送结果,获取最终答案
}
}
}
对于 ToolUse 分支:
- 对于每个工具调用,找到匹配的工具并调用它。先将结果收集到一个
Vec中 —— 你需要turn.tool_calls来完成这一步,所以还不能移动(move)turn。 - 推入
Message::Assistant(turn),然后为每个结果推入Message::ToolResult。 推入 assistant 轮次会移动turn,这就是为什么你必须事先收集结果。 - 再次调用 provider 以获取最终答案。
- 返回
final_turn.text.unwrap_or_default()。
查找和执行工具的逻辑与你在 agent 循环(第五章)中使用的相同:
#![allow(unused)]
fn main() {
println!("{}", tool_summary(call));
let content = match tools.get(&call.name) {
Some(t) => t.call(call.arguments.clone()).await
.unwrap_or_else(|e| format!("error: {e}")),
None => format!("error: unknown tool `{}`", call.name),
};
}
tool_summary() 辅助函数会将每个工具调用打印到终端,让你可以看到 agent 正在
使用哪些工具以及传递了什么参数。例如,[bash: ls -la] 或
[read: src/main.rs]。(参考实现使用 print!("\x1b[2K\r...") 而不是
println! 来在打印之前清除 thinking... 指示行 —— 你将在第七章看到这个
模式。目前使用普通的 println! 就可以了。)
错误处理 —— 永远不要让循环崩溃
注意工具错误是被捕获的,而不是被传播的。.unwrap_or_else() 将任何错误
转换为类似 "error: failed to read 'missing.txt'" 的字符串。这个字符串作为
普通的工具结果发送回 LLM。然后 LLM 可以决定下一步怎么做 —— 尝试不同的文件、
使用其他工具,或者向用户解释问题。
未知工具也是同样的处理方式 —— 不是 panic,而是将错误消息作为工具结果发送回去。
这是一个关键的设计原则:agent 循环永远不应该因为工具失败而崩溃。工具操作的 是真实世界(文件、进程、网络),失败是意料之中的。如果你把错误信息提供给 LLM, 它足够聪明来进行恢复。
以下是成功工具调用的消息序列:
sequenceDiagram
participant ST as single_turn()
participant P as Provider
participant T as ReadTool
ST->>P: [User("Read test.txt")] + tool defs
P-->>ST: ToolUse: read({path: "test.txt"})
ST->>T: call({path: "test.txt"})
T-->>ST: "file contents..."
Note over ST: 推入 Assistant + ToolResult
ST->>P: [User, Assistant, ToolResult]
P-->>ST: Stop: "Here are the contents: ..."
ST-->>ST: 返回文本
以下是工具失败时的情况(例如文件未找到):
sequenceDiagram
participant ST as single_turn()
participant P as Provider
participant T as ReadTool
ST->>P: [User("Read missing.txt")] + tool defs
P-->>ST: ToolUse: read({path: "missing.txt"})
ST->>T: call({path: "missing.txt"})
T--xST: Err("failed to read 'missing.txt'")
Note over ST: 捕获错误,作为结果使用
Note over ST: 推入 Assistant + ToolResult("error: failed to read ...")
ST->>P: [User, Assistant, ToolResult]
P-->>ST: Stop: "Sorry, that file doesn't exist."
ST-->>ST: 返回文本
错误不会使 agent 崩溃。它变成了一个工具结果,由 LLM 读取并做出响应。
运行测试
运行第三章的测试:
cargo test -p mini-claw-code-starter ch3
测试验证了什么
-
test_ch3_direct_response:Provider 返回StopReason::Stop。single_turn应该直接返回文本。 -
test_ch3_one_tool_call:Provider 返回带有read工具调用的StopReason::ToolUse,然后返回StopReason::Stop。验证文件已被读取 并返回最终文本。 -
test_ch3_unknown_tool:Provider 为一个不存在的工具返回StopReason::ToolUse。验证错误消息作为工具结果发送并返回最终文本。 -
test_ch3_tool_error_propagates:Provider 请求对一个不存在的文件 进行read。错误应该被捕获并作为工具结果发送回 LLM(而不是使函数崩溃)。 然后 LLM 用文本进行响应。
还有一些额外的边界情况测试(空响应、一轮中的多个工具调用等),一旦你的核心 实现正确,它们就会通过。
回顾
你已经编写了 LLM 协议的最简处理程序:
- 对
StopReason进行匹配 —— 模型告诉你下一步该做什么。 - 没有循环 —— 你最多处理一轮工具调用。
ToolSet—— 一个基于 HashMap 的集合,按名称 O(1) 查找工具。
这是基础。在第五章中,你将把同样的逻辑包装在循环中,创建完整的 agent。
下一步
在第四章:更多工具中,你将实现另外三个工具:
BashTool、WriteTool 和 EditTool。
第四章:更多工具
你已经实现了 ReadTool 并理解了 Tool trait 模式。现在你将实现另外三个工具:BashTool、WriteTool 和 EditTool。每个工具都遵循相同的结构——定义 schema、实现 call()——因此本章通过重复练习来巩固这一模式。
在本章结束时,你的 agent 将拥有与文件系统交互和执行命令所需的全部四个工具。
flowchart LR
subgraph ToolSet
R["read<br/>读取文件"]
B["bash<br/>运行命令"]
W["write<br/>写入文件"]
E["edit<br/>替换字符串"]
end
Agent -- "tools.get(name)" --> ToolSet
目标
实现三个工具:
- BashTool——运行 shell 命令并返回其输出。
- WriteTool——将内容写入文件,按需创建目录。
- EditTool——替换文件中的精确字符串(必须恰好出现一次)。
关键 Rust 概念
tokio::process::Command
Tokio 提供了对 std::process::Command 的异步(async)封装。你将在 BashTool 中使用它:
#![allow(unused)]
fn main() {
let output = tokio::process::Command::new("bash")
.arg("-c")
.arg(command)
.output()
.await?;
}
这会运行 bash -c "<command>" 并捕获 stdout 和 stderr。output 结构体的 stdout 和 stderr 字段是 Vec<u8> 类型,你可以使用 String::from_utf8_lossy() 将它们转换为字符串。
bail!() 宏
anyhow::bail!() 宏是立即返回错误的简写形式:
#![allow(unused)]
fn main() {
use anyhow::bail;
if count == 0 {
bail!("not found");
}
// 等价于:
// return Err(anyhow::anyhow!("not found"));
}
你将在 EditTool 中用它来进行验证。
确保导入它:use anyhow::{Context, bail};。starter 文件的 edit.rs 中已经包含了这个导入。
create_dir_all
当写入文件到类似 a/b/c/file.txt 的路径时,父目录可能不存在。tokio::fs::create_dir_all 会创建整个目录树:
#![allow(unused)]
fn main() {
if let Some(parent) = std::path::Path::new(path).parent() {
tokio::fs::create_dir_all(parent).await?;
}
}
工具 1:BashTool
打开 mini-claw-code-starter/src/tools/bash.rs。
Schema
使用你在第二章学到的构建器模式(builder pattern):
#![allow(unused)]
fn main() {
ToolDefinition::new("bash", "Run a bash command and return its output.")
.param("command", "string", "The bash command to run", true)
}
实现
call() 方法应该:
- 从 args 中提取
"command"。 - 使用
tokio::process::Command运行bash -c <command>。 - 捕获 stdout 和 stderr。
- 构建结果字符串:
- 以 stdout 开头(如果非空)。
- 追加以
"stderr: "为前缀的 stderr(如果非空)。 - 如果两者都为空,返回
"(no output)"。
思考一下如何组合 stdout 和 stderr。如果两者都存在,你需要用换行符分隔它们。类似这样:
#![allow(unused)]
fn main() {
let mut result = String::new();
if !stdout.is_empty() {
result.push_str(&stdout);
}
if !stderr.is_empty() {
if !result.is_empty() {
result.push('\n');
}
result.push_str("stderr: ");
result.push_str(&stderr);
}
if result.is_empty() {
result.push_str("(no output)");
}
}
工具 2:WriteTool
打开 mini-claw-code-starter/src/tools/write.rs。
Schema
#![allow(unused)]
fn main() {
ToolDefinition::new("write", "Write content to a file, creating directories as needed.")
.param("path", "string", "The file path to write to", true)
.param("content", "string", "The content to write to the file", true)
}
实现
call() 方法应该:
- 从 args 中提取
"path"和"content"。 - 如果父目录不存在,则创建它们。
- 将内容写入文件。
- 返回确认消息,如
"wrote {path}"。
创建父目录的方法:
#![allow(unused)]
fn main() {
if let Some(parent) = std::path::Path::new(path).parent() {
tokio::fs::create_dir_all(parent).await
.with_context(|| format!("failed to create directories for '{path}'"))?;
}
}
然后写入文件:
#![allow(unused)]
fn main() {
tokio::fs::write(path, content).await
.with_context(|| format!("failed to write '{path}'"))?;
}
工具 3:EditTool
打开 mini-claw-code-starter/src/tools/edit.rs。
Schema
#![allow(unused)]
fn main() {
ToolDefinition::new("edit", "Replace an exact string in a file (must appear exactly once).")
.param("path", "string", "The file path to edit", true)
.param("old_string", "string", "The exact string to find and replace", true)
.param("new_string", "string", "The replacement string", true)
}
实现
call() 方法是其中最有趣的。它应该:
- 从 args 中提取
"path"、"old_string"和"new_string"。 - 读取文件内容。
- 计算
old_string在内容中出现的次数。 - 如果计数为 0,返回错误:未找到该字符串。
- 如果计数大于 1,返回错误:该字符串不唯一。
- 替换唯一的匹配项并将文件写回。
- 返回确认消息,如
"edited {path}"。
验证很重要——要求恰好匹配一次可以防止在错误位置进行意外编辑。
flowchart TD
A["读取文件"] --> B["计算 old_string<br/>的匹配次数"]
B --> C{"count?"}
C -- "0" --> D["错误:未找到"]
C -- "1" --> E["替换 + 写回文件"]
C -- ">1" --> F["错误:不唯一"]
E --> G["返回 "edited path""]
常用 API:
content.matches(old).count()计算子字符串出现的次数。content.replacen(old, new, 1)替换第一次出现的匹配。bail!("old_string not found in '{path}'")用于未找到的情况。bail!("old_string appears {count} times in '{path}', must be unique")用于不唯一的情况。
运行测试
运行第四章的测试:
cargo test -p mini-claw-code-starter ch4
测试验证内容
BashTool:
test_ch4_bash_definition:检查名称为"bash"且"command"为必填参数。test_ch4_bash_runs_command:运行echo hello并检查输出包含"hello"。test_ch4_bash_captures_stderr:运行echo err >&2并检查 stderr 被捕获。test_ch4_bash_missing_arg:传入空 args 并期望返回错误。
WriteTool:
test_ch4_write_definition:检查名称为"write"。test_ch4_write_creates_file:写入临时文件并读回验证。test_ch4_write_creates_dirs:写入a/b/c/out.txt并验证目录已创建。test_ch4_write_missing_arg:只传入"path"(无"content")并期望返回错误。
EditTool:
test_ch4_edit_definition:检查名称为"edit"。test_ch4_edit_replaces_string:将包含"hello world"的文件中的"hello"编辑为"goodbye",并检查结果为"goodbye world"。test_ch4_edit_not_found:尝试替换不存在的字符串并期望返回错误。test_ch4_edit_not_unique:尝试在包含"aaa"(三次出现)的文件中替换"a"并期望返回错误。
还有针对每个工具的额外边界情况测试(错误的参数类型、缺少参数、输出格式检查等),这些测试在你的核心实现正确后就会通过。
回顾
你现在拥有了四个工具,它们都遵循相同的模式:
- 使用
::new(...).param(...)构建器调用定义ToolDefinition。 - 从
definition()返回&self.definition。 - 在
impl Tool块上添加#[async_trait::async_trait]并编写async fn call()。
这是有意为之的设计。Tool trait 使得每个工具从 agent 的角度来看都是可互换的。agent 不需要知道也不关心工具内部如何工作——它只需要 definition(用于告知 LLM)和 call 方法(用于执行)。
下一步
有了 provider 和四个工具,现在是时候将它们连接起来了。在第五章:你的第一个 Agent SDK!中,你将构建 SimpleAgent——核心循环,它向 provider 发送提示、执行工具调用,并不断迭代直到 LLM 给出最终答案。
第五章:你的第一个 Agent SDK!
这是所有内容汇聚在一起的章节。你已经有了一个返回 AssistantTurn 响应的
Provider,以及四个可以执行操作的工具。现在你要构建 SimpleAgent ——将它们
连接起来的循环。
这是本教程的“顿悟“时刻。Agent 循环(Agent Loop)短得出人意料,但正是它将 LLM 变成了一个真正的 Agent。
什么是 Agent 循环?
在第三章中,你实现了 single_turn() ——一次提示、一轮工具调用、一个最终答案。
当 LLM 读完单个文件就能获得所有所需信息时,这已经足够了。但现实任务往往更加
复杂:
“找到这个项目中的 bug 并修复它。”
LLM 可能需要读取五个文件、运行测试套件、编辑源文件、再次运行测试,然后才能 给出报告。每一步都是一次工具调用,而 LLM 无法提前规划好所有调用,因为上一次 调用的结果决定了下一步该做什么。它需要一个循环。
Agent 循环就是这个循环:
flowchart TD
A["User prompt"] --> B["Call LLM"]
B -- "StopReason::Stop" --> C["Return text"]
B -- "StopReason::ToolUse" --> D["Execute tool calls"]
D -- "Push assistant + tool results" --> B
- 将消息发送给 LLM。
- 如果 LLM 表示“我完成了“(
StopReason::Stop),返回其文本。 - 如果 LLM 表示“我需要工具“(
StopReason::ToolUse),执行工具调用。 - 将助手的回合和工具结果追加到消息历史中。
- 回到第 1 步。
这就是每一个编程 Agent 的完整架构 ——Claude Code、Cursor、OpenCode、Copilot 都是如此。细节上有所不同(流式输出、并行工具调用、安全检查),但核心循环始终 相同。而你即将用大约 30 行 Rust 代码来构建它。
目标
实现 SimpleAgent,使其满足:
- 它持有一个 Provider 和一组工具。
- 你可以使用构建者模式(Builder Pattern)注册工具(
.tool(ReadTool::new()))。 run()方法实现工具调用循环:提示 -> Provider -> 工具调用 -> 工具结果 -> Provider -> … -> 最终文本。
关键 Rust 概念
带特征约束的泛型(Generics with Trait Bounds)
#![allow(unused)]
fn main() {
pub struct SimpleAgent<P: Provider> {
provider: P,
tools: ToolSet,
}
}
<P: Provider> 意味着 SimpleAgent 对任何实现了 Provider 特征的类型都是
泛型的。当你使用 MockProvider 时,编译器会生成专门针对 MockProvider 的
代码。当你使用 OpenRouterProvider 时,它会为该类型生成代码。逻辑相同,
Provider 不同。
ToolSet ——特征对象的 HashMap
tools 字段是一个 ToolSet,它内部封装了一个
HashMap<String, Box<dyn Tool>>。每个值都是一个堆分配的特征对象(Trait
Object),它实现了 Tool,但具体类型可以不同。一个可能是 ReadTool,另一个
可能是 BashTool。HashMap 的键是工具的名称,在执行工具调用时可以实现 O(1)
查找。
为什么使用特征对象(Box<dyn Tool>)而不是泛型?因为你需要一个异构集合
(Heterogeneous Collection)。Vec<T> 要求所有元素都是相同类型。而
Box<dyn Tool> 可以擦除具体类型,将它们全部存储在同一个接口背后。
这也是 Tool 特征使用 #[async_trait] 的原因——该宏将 async fn 重写为
一个带有统一类型的装箱 Future(Boxed Future),使其能在不同的工具实现之间
保持一致。
构建者模式(Builder Pattern)
tool() 方法按值接收 self(而不是 &mut self)并返回 Self:
#![allow(unused)]
fn main() {
pub fn tool(mut self, t: impl Tool + 'static) -> Self {
// 将工具加入集合
self
}
}
这样你就可以链式调用:
#![allow(unused)]
fn main() {
let agent = SimpleAgent::new(provider)
.tool(BashTool::new())
.tool(ReadTool::new())
.tool(WriteTool::new())
.tool(EditTool::new());
}
impl Tool + 'static 参数接受任何实现了 Tool 且具有 'static 生命周期的
类型(意味着它不借用临时数据)。在方法内部,你将它推入 ToolSet,后者会将其
装箱并按名称索引。
实现
打开 mini-claw-code-starter/src/agent.rs。结构体定义和方法签名已经提供好了。
第 1 步:实现 new()
存储 Provider 并初始化一个空的 ToolSet:
#![allow(unused)]
fn main() {
pub fn new(provider: P) -> Self {
Self {
provider,
tools: ToolSet::new(),
}
}
}
这一步很简单直接。
第 2 步:实现 tool()
将工具推入集合,返回 self:
#![allow(unused)]
fn main() {
pub fn tool(mut self, t: impl Tool + 'static) -> Self {
self.tools.push(t);
self
}
}
第 3 步:实现 run() ——核心循环
这是 Agent 的心脏。以下是流程:
- 从所有已注册的工具中收集工具定义。
- 创建一个
messages向量,以用户的提示作为起始。 - 循环:
a. 调用
self.provider.chat(&messages, &defs)获取一个AssistantTurn。 b. 对turn.stop_reason进行匹配:StopReason::Stop——LLM 完成了,返回turn.text。StopReason::ToolUse——对每个工具调用:- 按名称查找匹配的工具。
- 用参数调用它。
- 收集结果。
c. 将
AssistantTurn作为Message::Assistant推入。 d. 将每个工具结果作为Message::ToolResult推入。 e. 继续循环。
仔细思考数据流。执行工具后,你需要同时推入助手的回合(这样 LLM 能看到它请求 了什么)和工具结果(这样它能看到发生了什么)。这为 LLM 提供了完整的上下文, 以便决定下一步该做什么。
收集工具定义
在 run() 的开头,从 ToolSet 中收集所有工具定义:
#![allow(unused)]
fn main() {
let defs = self.tools.definitions();
}
循环结构
这就是 single_turn()(来自第三章)包裹在循环中的版本。不再只处理一轮,而是
在 loop 内部对 stop_reason 进行 match:
#![allow(unused)]
fn main() {
loop {
let turn = self.provider.chat(&messages, &defs).await?;
match turn.stop_reason {
StopReason::Stop => return Ok(turn.text.unwrap_or_default()),
StopReason::ToolUse => {
// 执行工具调用,收集结果
// 推入消息
}
}
}
}
查找和调用工具
对于每个工具调用,在 ToolSet 中按名称查找:
#![allow(unused)]
fn main() {
println!("{}", tool_summary(call));
let content = match self.tools.get(&call.name) {
Some(t) => t.call(call.arguments.clone()).await
.unwrap_or_else(|e| format!("error: {e}")),
None => format!("error: unknown tool `{}`", call.name),
};
}
tool_summary() 辅助函数会将每个工具调用打印到终端——每个工具一行,附带其关键
参数,这样你就可以实时观察 Agent 在做什么。例如:[bash: cat Cargo.toml] 或
[write: src/lib.rs]。
错误处理
工具错误通过 .unwrap_or_else() 捕获并转换为字符串,然后作为工具结果发送回
LLM。这与第三章中的模式相同,在这里尤为关键,因为 Agent 循环会运行多次迭代。
如果工具错误导致循环崩溃,Agent 会在遇到第一个不存在的文件或失败的命令时就
终止。相反,LLM 会看到错误并能够恢复——尝试不同的路径、调整命令或解释问题。
> What's in README.md?
[read: README.md] <-- 工具失败(文件未找到)
[read: Cargo.toml] <-- LLM 恢复,尝试另一个文件
Here is the project info from Cargo.toml...
未知工具的处理方式相同——将错误字符串作为工具结果返回,而不是崩溃。
推入消息
执行完一个回合的所有工具调用后,推入助手消息和工具结果。你需要先收集结果
(因为 turn 会被移动到 Message::Assistant 中):
#![allow(unused)]
fn main() {
let mut results = Vec::new();
for call in &turn.tool_calls {
// ... 执行并收集 (id, content) 对
}
messages.push(Message::Assistant(turn));
for (id, content) in results {
messages.push(Message::ToolResult { id, content });
}
}
顺序很重要:先推入助手消息,再推入工具结果。这与 LLM API 期望的格式一致。
运行测试
运行第五章的测试:
cargo test -p mini-claw-code-starter ch5
测试验证的内容
-
test_ch5_text_response:Provider 立即返回文本(不使用工具)。Agent 应 返回该文本。 -
test_ch5_single_tool_call:Provider 先请求一个read工具调用,然后 返回文本。Agent 应执行工具并返回最终文本。 -
test_ch5_unknown_tool:Provider 请求一个不存在的工具。Agent 应优雅地 处理(将错误字符串作为工具结果返回)并继续获取最终文本。 -
test_ch5_multi_step_loop:Provider 在两个回合中分别请求read,然后 返回文本。验证循环能够运行多次迭代。 -
test_ch5_empty_response:Provider 返回None作为文本且没有工具调用。 Agent 应返回空字符串。 -
test_ch5_builder_chain:验证.tool().tool()链式调用能够编译——这是 对构建者模式的编译时检查。 -
test_ch5_tool_error_propagates:Provider 请求对一个不存在的文件执行read。错误应被捕获并作为工具结果发送回去。然后 LLM 返回文本。验证循环不会 因工具失败而崩溃。
还有一些额外的边界情况测试(三步循环、多工具流水线等),一旦你的核心实现正确, 它们也会通过。
看它全部运作起来
测试通过后,花点时间欣赏你所构建的成果。仅用 run() 中大约 30 行代码,你就
拥有了一个可用的 Agent 循环。以下是当测试运行
agent.run("Read test.txt") 时发生的事情:
- 消息:
[User("Read test.txt")] - Provider 返回:针对
read的工具调用,参数为{"path": "test.txt"} - Agent 调用
ReadTool::call(),获取文件内容 - 消息:
[User("Read test.txt"), Assistant(tool_call), ToolResult("file content")] - Provider 返回:文本响应
- Agent 返回文本
Mock Provider 使整个过程确定性且可测试。但完全相同的循环也适用于真正的 LLM
Provider——你只需将 MockProvider 替换为 OpenRouterProvider。
总结
Agent 循环是框架的核心:
- 泛型(
<P: Provider>)使其能与任何 Provider 协同工作。 ToolSet(Box<dyn Tool>的 HashMap)通过名称实现 O(1) 的工具查找。- 构建者模式使配置过程简洁优雅。
- 错误韧性 ——工具错误被捕获并发送回 LLM,而不是向上传播。循环永远不会因工具失败而崩溃。
- 循环本身很简单:调用 Provider,匹配
stop_reason,执行工具,将结果反馈回去,重复。
下一步
你的 Agent 可以工作了,但目前只能使用 Mock Provider。在
第六章:OpenRouter Provider 中,你将实现
OpenRouterProvider,它通过 HTTP 与真正的 LLM API 通信。这就是将你的 Agent
从测试工具变成真正可用工具的关键。
第六章:OpenRouter Provider
到目前为止,所有功能都通过 MockProvider 在本地运行。在本章中,你将实现 OpenRouterProvider —— 一个通过 HTTP 使用 OpenAI 兼容的 chat completions API 与真实 LLM 通信的 provider。
这是让你的 agent 真正运转起来的一章。
目标
实现 OpenRouterProvider,使其能够:
- 通过 API 密钥和模型名称创建实例。
- 将我们内部的
Message和ToolDefinition类型转换为 API 格式。 - 向 chat completions 端点发送 HTTP POST 请求。
- 将响应解析回
AssistantTurn。
关键 Rust 概念
Serde 派生宏与属性
openrouter.rs 中的 API 类型已经提供好了 —— 你不需要修改它们。但理解它们会有所帮助:
#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize, Clone, Debug)]
pub(crate) struct ApiToolCall {
pub(crate) id: String,
#[serde(rename = "type")]
pub(crate) type_: String,
pub(crate) function: ApiFunction,
}
}
使用到的关键 serde 属性:
-
#[serde(rename = "type")]—— JSON 字段名为"type",但type是 Rust 的保留关键字。因此结构体字段命名为type_,serde 在序列化/反序列化时自动重命名。 -
#[serde(skip_serializing_if = "Option::is_none")]—— 当值为None时,在 JSON 中省略该字段。这很重要,因为 API 期望某些未使用的字段不存在(而非为null)。 -
#[serde(skip_serializing_if = "Vec::is_empty")]—— 对空向量同理。如果没有工具,我们完全省略tools字段。
reqwest HTTP 客户端
reqwest 是 Rust 中标准的 HTTP 客户端 crate。使用模式如下:
#![allow(unused)]
fn main() {
let response: MyType = client
.post(url)
.bearer_auth(&api_key)
.json(&body) // 将 body 序列化为 JSON
.send()
.await
.context("request failed")?
.error_for_status() // 将 4xx/5xx 转换为错误
.context("API returned error status")?
.json() // 将响应反序列化为 JSON
.await
.context("failed to parse response")?;
}
每个方法返回一个 builder 或 future,你可以链式调用。? 运算符在每一步传播错误。
impl Into<String>
多个方法使用 impl Into<String> 作为参数类型:
#![allow(unused)]
fn main() {
pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self
}
这接受任何可以转换为 String 的类型:String、&str、Cow<str> 等。在方法内部,调用 .into() 获取 String:
#![allow(unused)]
fn main() {
api_key: api_key.into(),
model: model.into(),
}
dotenvy
dotenvy crate 从 .env 文件加载环境变量:
#![allow(unused)]
fn main() {
let _ = dotenvy::dotenv(); // 如果 .env 存在则加载,忽略错误
let key = std::env::var("OPENROUTER_API_KEY")?;
}
let _ = 丢弃返回值,因为 .env 文件不存在也没关系(变量可能已经在环境中了)。
API 类型
文件 mini-claw-code-starter/src/providers/openrouter.rs 开头有一组 serde 结构体。它们表示 OpenAI 兼容的 chat completions API 格式。以下是简要说明:
请求类型:
ChatRequest—— POST 请求体:模型名称、消息、工具ApiMessage—— 单条消息,包含 role、content 和可选的 tool callsApiTool/ApiToolDef—— API 格式的工具定义
响应类型:
ChatResponse—— API 响应:一个 choices 列表Choice—— 单个选项,包含一条消息和finish_reasonResponseMessage—— 助手的响应:可选的 content 和可选的 tool calls
Choice 上的 finish_reason 字段告诉你模型为什么停止生成。在你的 chat() 实现中将其映射到 StopReason:"tool_calls" 对应 StopReason::ToolUse,其他值对应 StopReason::Stop。
这些类型已经完整实现了。你的任务是实现 使用 它们的方法。
具体实现
第一步:实现 new()
初始化全部四个字段:
#![allow(unused)]
fn main() {
pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self {
client: reqwest::Client::new(),
api_key: api_key.into(),
model: model.into(),
base_url: "https://openrouter.ai/api/v1".into(),
}
}
}
第二步:实现 base_url()
一个简单的 builder 方法,用于覆盖 base URL:
#![allow(unused)]
fn main() {
pub fn base_url(mut self, url: impl Into<String>) -> Self {
self.base_url = url.into();
self
}
}
第三步:实现 from_env_with_model()
- 使用
dotenvy::dotenv()加载.env(忽略返回值)。 - 从环境变量中读取
OPENROUTER_API_KEY。 - 用密钥和模型调用
Self::new()。
使用 std::env::var("OPENROUTER_API_KEY") 并链式调用 .context(...) 以便在密钥缺失时提供清晰的错误信息。
第四步:实现 from_env()
这是一行代码,使用默认模型 "openrouter/free" 调用 from_env_with_model。这是 OpenRouter 上的免费模型 —— 无需充值即可开始使用。
第五步:实现 convert_messages()
此方法将我们的 Message 枚举转换为 API 的 ApiMessage 格式。遍历消息并对每个变体进行匹配:
-
Message::System(text)转换为 role 为"system"、content: Some(text.clone())的ApiMessage。其他字段为None。 -
Message::User(text)转换为 role 为"user"、content: Some(text.clone())的ApiMessage。其他字段为None。 -
Message::Assistant(turn)转换为 role 为"assistant"的ApiMessage。将content设为turn.text.clone()。如果turn.tool_calls非空,将每个ToolCall转换为ApiToolCall:#![allow(unused)] fn main() { ApiToolCall { id: c.id.clone(), type_: "function".into(), function: ApiFunction { name: c.name.clone(), arguments: c.arguments.to_string(), // Value -> String }, } }如果
tool_calls为空,设置tool_calls: None(而非Some(vec![]))。 -
Message::ToolResult { id, content }转换为 role 为"tool"、content: Some(content.clone())且tool_call_id: Some(id.clone())的ApiMessage。
第六步:实现 convert_tools()
将每个 &ToolDefinition 映射为 ApiTool:
#![allow(unused)]
fn main() {
ApiTool {
type_: "function",
function: ApiToolDef {
name: t.name,
description: t.description,
parameters: t.parameters.clone(),
},
}
}
第七步:实现 chat()
这是核心方法,它将所有部分整合在一起:
- 用模型、转换后的消息和转换后的工具构建
ChatRequest。 - 使用 bearer auth 将其 POST 到
{base_url}/chat/completions。 - 将响应解析为
ChatResponse。 - 提取第一个 choice。
- 将
tool_calls转换回我们的ToolCall类型。
工具调用的转换是最棘手的部分。API 返回的 function.arguments 是一个 字符串(JSON 编码),但我们的 ToolCall 将其存储为 serde_json::Value。因此你需要解析它:
#![allow(unused)]
fn main() {
let arguments = serde_json::from_str(&tc.function.arguments)
.unwrap_or(Value::Null);
}
unwrap_or(Value::Null) 处理参数字符串不是有效 JSON 的情况(对于行为正常的 API 来说不太可能发生,但做好防御总是好的)。
以下是 chat() 方法的骨架代码:
#![allow(unused)]
fn main() {
async fn chat(
&self,
messages: &[Message],
tools: &[&ToolDefinition],
) -> anyhow::Result<AssistantTurn> {
let body = ChatRequest {
model: &self.model,
messages: Self::convert_messages(messages),
tools: Self::convert_tools(tools),
};
let response: ChatResponse = self.client
.post(format!("{}/chat/completions", self.base_url))
// ... bearer_auth, json, send, error_for_status, json ...
;
let choice = response.choices.into_iter().next()
.context("no choices in response")?;
// 将 choice.message.tool_calls 转换为 Vec<ToolCall>
// 将 finish_reason 映射为 StopReason
// 返回 AssistantTurn { text, tool_calls, stop_reason }
todo!()
}
}
补全 HTTP 调用链和响应转换逻辑。
运行测试
运行第六章的测试:
cargo test -p mini-claw-code-starter ch6
第六章的测试验证了转换方法(convert_messages 和 convert_tools)、构造函数逻辑,以及使用本地 mock HTTP 服务器的完整 chat() 方法。测试 不会 调用真实的 LLM API,因此不需要 API 密钥。还有一些额外的边界情况测试,一旦你的核心实现正确就会通过。
可选:实时测试
如果你想使用真实 API 进行测试,请设置 OpenRouter API 密钥:
- 在 openrouter.ai 注册。
- 创建 API 密钥。
- 在工作区根目录创建
.env文件:
OPENROUTER_API_KEY=sk-or-v1-your-key-here
然后尝试构建并运行第七章的聊天示例。但首先,请读完本章,然后继续第七章,在那里你将把所有东西连接起来。
总结
你已经实现了一个真正的 HTTP provider,它能够:
- 通过 API 密钥和模型名称(或从环境变量)构建实例。
- 在内部类型和 OpenAI 兼容的 API 格式之间进行转换。
- 发送 HTTP 请求并解析响应。
关键模式:
- Serde 属性 用于 JSON 字段映射(
rename、skip_serializing_if)。 reqwest提供流式 builder API 的 HTTP 客户端。impl Into<String>实现灵活的字符串参数。dotenvy用于加载.env文件。
你的 agent 框架现在已经完整了。每一个部分 —— 工具、agent 循环和 HTTP provider —— 都已实现并通过测试。
下一步
在第七章:简单的 CLI 中,你将把所有内容连接成一个带有对话记忆的交互式 CLI。
第七章:一个简单的 CLI
你已经构建了所有组件:用于测试的模拟提供者(mock provider)、四个工具、 agent loop 以及 HTTP 提供者。现在是时候把它们全部组装成一个 可以工作的 CLI 了。
目标
为 SimpleAgent 添加一个 chat() 方法,并编写 examples/chat.rs,使得:
- agent 能够记住对话内容——每个提示都建立在之前的对话基础上。
- 它打印
>,读取一行输入,运行 agent,然后打印结果。 - 在 agent 工作时显示
thinking...指示器。 - 持续运行,直到用户按下 Ctrl+D(EOF)。
chat() 方法
打开 mini-claw-code-starter/src/agent.rs。在 run() 下方你会看到 chat()
方法的签名。
为什么需要一个新方法?
run() 每次调用时都会创建一个新的 Vec<Message>。这意味着 LLM 没有之前
对话的记忆。一个真正的 CLI 应该向前传递上下文,这样 LLM 才能说“我已经读过
那个文件了“或“正如我之前提到的“。
chat() 通过接受调用者传入的消息历史来解决这个问题:
#![allow(unused)]
fn main() {
pub async fn chat(&self, messages: &mut Vec<Message>) -> anyhow::Result<String>
}
调用者在调用前推入 Message::User(...),而 chat() 负责追加助手的回合。
当它返回时,messages 包含了完整的对话历史,可以直接用于下一轮。
实现
循环体与 run() 完全相同。唯一的区别是:
- 使用传入的
messages而不是创建新的 vec。 - 在
StopReason::Stop时,在推入Message::Assistant(turn)之前克隆文本 ——因为推入操作会移动turn,所以你需要先提取文本。 - 推入
Message::Assistant(turn),使历史记录包含最终响应。 - 返回克隆的文本。
#![allow(unused)]
fn main() {
pub async fn chat(&self, messages: &mut Vec<Message>) -> anyhow::Result<String> {
let defs = self.tools.definitions();
loop {
let turn = self.provider.chat(messages, &defs).await?;
match turn.stop_reason {
StopReason::Stop => {
let text = turn.text.clone().unwrap_or_default();
messages.push(Message::Assistant(turn));
return Ok(text);
}
StopReason::ToolUse => {
// 与 run() 相同的工具执行逻辑 ...
}
}
}
}
}
ToolUse 分支与 run() 中完全一样:执行每个工具,收集结果,推入助手回合,
推入工具结果。
所有权细节
在 run() 中你可以直接 return Ok(turn.text.unwrap_or_default()),
因为函数不再需要 turn 了。在 chat() 中你还需要将
Message::Assistant(turn) 推入历史记录。由于推入操作会移动 turn,
你必须先提取文本:
#![allow(unused)]
fn main() {
let text = turn.text.clone().unwrap_or_default();
messages.push(Message::Assistant(turn)); // 移动 turn
return Ok(text); // 返回克隆的副本
}
相比 run() 这只是一行的改动,但它很重要。
CLI
打开 mini-claw-code-starter/examples/chat.rs。你会看到一个包含
unimplemented!() 的骨架。把它替换成完整的程序。
第 1 步:导入
#![allow(unused)]
fn main() {
use mini_claw_code_starter::{
BashTool, EditTool, Message, OpenRouterProvider, ReadTool, SimpleAgent, WriteTool,
};
use std::io::{self, BufRead, Write};
}
注意 Message 的导入——你需要它来构建历史向量。
第 2 步:创建提供者和 agent
#![allow(unused)]
fn main() {
let provider = OpenRouterProvider::from_env()?;
let agent = SimpleAgent::new(provider)
.tool(BashTool::new())
.tool(ReadTool::new())
.tool(WriteTool::new())
.tool(EditTool::new());
}
和之前一样——这里没有新内容。(在第十一章中你会在这里
添加 AskTool,这样 agent 就可以向你提出澄清性问题。)
第 3 步:系统提示词和历史向量
#![allow(unused)]
fn main() {
let cwd = std::env::current_dir()?.display().to_string();
let mut history: Vec<Message> = vec![Message::System(format!(
"You are a coding agent. Help the user with software engineering tasks \
using all available tools. Be concise and precise.\n\n\
Working directory: {cwd}"
))];
}
系统提示词(system prompt)是历史记录中的第一条消息。它告诉 LLM 应该扮演 什么角色。有两点需要注意:
-
提示词中不包含工具名称。 工具定义是通过 API 单独发送的。系统提示词 专注于行为——做一个 coding agent,使用任何可用的工具,简洁精确。
-
包含了工作目录。 LLM 需要知道自己在哪里,这样
read和bash等 工具调用才能使用正确的路径。这正是真正的 coding agent 所做的——Claude Code、 OpenCode 和 Kimi CLI 都会在系统提示词中注入当前目录(有时还包括平台、 日期等信息)。
历史向量存在于循环之外,在整个会话过程中积累每一个用户提示、助手响应和工具 结果。系统提示词保持在最前面,在每一轮中为 LLM 提供一致的指令。
第 4 步:REPL 循环
#![allow(unused)]
fn main() {
let stdin = io::stdin();
loop {
print!("> ");
io::stdout().flush()?;
let mut line = String::new();
if stdin.lock().read_line(&mut line)? == 0 {
println!();
break;
}
let prompt = line.trim();
if prompt.is_empty() {
continue;
}
history.push(Message::User(prompt.to_string()));
print!(" thinking...");
io::stdout().flush()?;
match agent.chat(&mut history).await {
Ok(text) => {
print!("\x1b[2K\r");
println!("{}\n", text.trim());
}
Err(e) => {
print!("\x1b[2K\r");
println!("error: {e}\n");
}
}
}
}
几点需要注意:
history.push(Message::User(...))在调用 agent 之前添加用户提示。chat()会追加剩余的内容。print!(" thinking...")在 agent 工作时显示状态。需要flush()是 因为print!(没有换行符)不会自动刷新缓冲区。\x1b[2K\r是一个 ANSI 转义序列:“清除整行,将光标移到第 1 列。” 这会在打印响应之前清除thinking...文本。当 agent 打印工具摘要时也会 自动清除(因为tool_summary()使用了相同的转义序列)。stdout.flush()?在print!之后确保提示符和思考指示器立即显示。read_line在 EOF(Ctrl+D)时返回0,从而跳出循环。- agent 的错误会被打印出来而不是导致崩溃——这使得即使某个请求失败, 循环也能继续运行。
main 函数
用异步 main 包裹所有内容:
#[tokio::main]
async fn main() -> anyhow::Result<()> {
// 第 1-4 步放在这里
Ok(())
}
完整程序
把所有内容放在一起,整个程序大约 45 行。这就是你构建的框架的优美之处—— 最终的组装非常简单直接,因为每个组件都有清晰的接口。
运行完整测试套件
运行完整的测试套件:
cargo test -p mini-claw-code-starter
这会运行第 1 章到第 7 章的所有测试。如果全部通过,恭喜——你的 agent 框架 已经完成且经过了全面测试。
测试验证了什么
第 7 章的测试是集成测试,它们组合了所有组件:
- 写入后读取流程:写入文件,读回内容,验证内容正确。
- 编辑流程:写入文件,编辑文件,读回结果。
- 多工具流水线:在多个回合中使用 bash、write、edit 和 read。
- 长对话:五步工具调用序列。
大约有 10 个集成测试,覆盖了完整的 agent 流水线。
运行聊天示例
要使用真实的 LLM 进行尝试,你需要一个 API 密钥。在工作区根目录创建一个
.env 文件:
OPENROUTER_API_KEY=sk-or-v1-your-key-here
然后运行:
cargo run -p mini-claw-code-starter --example chat
你会看到一个交互式提示符。尝试一个多轮对话:
> List the files in the current directory
thinking...
[bash: ls]
Cargo.toml src/ examples/ ...
> What is in Cargo.toml?
thinking...
[read: Cargo.toml]
The Cargo.toml contains the package definition for mini-claw-code-starter...
> Add a new dependency for serde
thinking...
[read: Cargo.toml]
[edit: Cargo.toml]
Done! I added serde to the dependencies.
>
注意第二个提示(“What is in Cargo.toml?”)无需重复上下文就能正常工作—— LLM 已经从第一次交互中知道了目录列表。这就是对话历史的作用。
按 Ctrl+D(或 Ctrl+C)退出。
你已经构建了什么
让我们退后一步,看看完整的全貌:
examples/chat.rs
|
| creates
v
SimpleAgent<OpenRouterProvider>
|
| holds
+---> OpenRouterProvider (HTTP to LLM API)
+---> ToolSet (HashMap<String, Box<dyn Tool>>)
|
+---> BashTool
+---> ReadTool
+---> WriteTool
+---> EditTool
chat() 方法驱动整个交互过程:
User prompt
|
v
history: [User, Assistant, ToolResult, ..., User]
|
v
Provider.chat() ---HTTP---> LLM API
|
| AssistantTurn
v
Tool calls? ----yes---> Execute tools ---> append to history ---> loop
|
no
|
v
Append final Assistant to history, return text
在所有文件中大约 300 行 Rust 代码,你已经拥有了:
- 一个基于 trait 的工具系统,带有 JSON schema 定义。
- 一个通用的 agent 循环,可以与任何提供者配合使用。
- 一个用于确定性测试的模拟提供者。
- 一个用于真实 LLM API 的 HTTP 提供者。
- 一个带有对话记忆的 CLI,将所有这些串联在一起。
接下来的方向
这个框架是有意做得精简的。以下是一些扩展思路:
流式响应(Streaming responses) ——不再等待完整响应,而是在 token 到达时
逐步输出。这意味着需要将 chat() 改为返回 Stream 而不是单个
AssistantTurn。
Token 限制 ——跟踪 token 使用量,当上下文窗口满时截断旧消息。
更多工具 ——添加网络搜索工具、数据库查询工具,或者任何你能想到的工具。
Tool trait 使得添加新功能变得很容易。
更丰富的 UI ——添加旋转动画、Markdown 渲染或折叠式工具调用显示。
参见 mini-claw-code/examples/tui.rs,其中使用 termimad 实现了这三个功能。
你构建的基础是扎实的。每一个扩展都只是在现有模式上添加内容,而不是重写。
Provider trait、Tool trait 和 agent 循环是你接下来想要构建的一切的基石。
下一步
前往第八章:奇点——你的 agent 现在可以修改它自己的 源代码了,我们将讨论这意味着什么,以及接下来该何去何从。
第八章:奇点
你的 agent 已经可以编辑自身代码并开始自我进化了。从现在起,你不再需要手动编写任何代码。
扩展章节
接下来的扩展章节将逐步讲解参考实现。 你不需要亲自编写代码——通过阅读来理解设计思路, 然后让你的 agent 来实现它们(当然也可以自己动手练习):
- 第九章:更好的终端界面 – Markdown 渲染、加载动画、折叠工具调用。
- 第十章:流式输出 – 使用
StreamingAgent实现 token 实时流式输出。 - 第十一章:用户输入 – 让 LLM 向你提出澄清性问题。
- 第十二章:规划模式 – 只读规划与审批门控。
除了扩展章节之外,这里还有更多值得探索的方向:
- 并行工具调用 – 使用
tokio::join!并发执行多个工具调用。 - Token 用量追踪 – 在接近上下文限制时截断旧消息。
- 更多工具 – 网络搜索、数据库查询、HTTP 请求。
Tooltrait 让扩展变得简单。 - MCP – 将你的工具暴露为 MCP 服务器,或连接外部 MCP 服务。
第九章:更好的 TUI
chat.rs 命令行界面虽然能用,但它只会输出纯文本,并且显示每一次工具调用。一个真正的 coding agent 应该具备 Markdown 渲染、思考动画以及在 agent 忙碌时折叠工具调用的能力。
参见 mini-claw-code/examples/tui.rs 中的参考实现。它使用了:
termimad:在终端中进行内联 Markdown 渲染。crossterm:用于原始终端模式(raw terminal mode),在第十一章的方向键选择 UI 中会用到。- 加载动画(animated spinner)(
⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏):在 agent 思考时循环播放。 - 折叠工具调用:超过 3 次工具调用后,后续调用会折叠为
... and N more计数器,保持输出整洁。
TUI 构建在第十章 StreamingAgent 的 AgentEvent 流之上。事件循环使用 tokio::select! 来多路复用三个事件源:
- agent 事件(
AgentEvent::TextDelta、ToolCall、Done、Error)——渲染流式文本、工具调用摘要或最终输出。 - 用户输入请求,来自
AskTool(第十一章)——暂停加载动画,显示文本提示或方向键选择列表。 - 定时器心跳(Timer ticks)——推进加载动画。
本章仅为讲解说明,无需编写代码。请阅读 examples/tui.rs 了解各部分如何协同工作,或者让你的 mini-claw-code agent 为你构建一个 TUI。
第十章:流式输出(Streaming)
在第六章中,你构建了 OpenRouterProvider::chat(),它会等待整个响应返回后才继续。这样虽然可行,但用户在所有 token 生成完毕之前只能盯着空白屏幕。真正的 coding agent 会在 token 到达时立即打印出来——这就是流式输出。
本章将添加流式支持,并构建 StreamingAgent——SimpleAgent 的流式版本。你将:
- 定义一个
StreamEvent枚举,表示实时增量数据。 - 构建一个
StreamAccumulator,将增量数据收集为完整的AssistantTurn。 - 编写
parse_sse_line()函数,将原始的服务器推送事件(Server-Sent Events)转换为StreamEvent。 - 定义
StreamProvidertrait——Provider的流式版本。 - 为
OpenRouterProvider实现StreamProvider。 - 构建用于无 HTTP 测试的
MockStreamProvider。 - 构建
StreamingAgent<P: StreamProvider>——一个具有实时文本流的完整 agent 循环。
这些改动完全不影响 Provider trait 和 SimpleAgent。流式输出是在现有架构之上叠加的功能层。
为什么需要流式输出?
没有流式输出时,较长的响应(比如 500 个 token)会让 CLI 看起来像卡住了。流式输出解决了三个问题:
- 即时反馈 —— 用户在几毫秒内就能看到第一个词,而不必等待数秒才看到完整响应。
- 提前取消 —— 如果 agent 走错方向,用户可以按 Ctrl-C 中断,无需等待完整响应。
- 进度可见 —— 看到 token 逐个到达,可以确认 agent 正在工作,而非卡住。
SSE 的工作原理
兼容 OpenAI 的 API 通过
服务器推送事件(SSE)
支持流式输出。你在请求中设置 "stream": true,服务器就不会返回一个大的 JSON 响应,而是发送一系列文本行:
data: {"choices":[{"delta":{"content":"Hello"},"finish_reason":null}]}
data: {"choices":[{"delta":{"content":" world"},"finish_reason":null}]}
data: {"choices":[{"delta":{},"finish_reason":"stop"}]}
data: [DONE]
每行以 data: 开头,后跟一个 JSON 对象(或哨兵值 [DONE])。与非流式响应的关键区别在于:每个块不再有包含完整文本的 message 字段,而是有一个 delta 字段,只包含新增部分。你的代码逐个读取这些 delta,立即打印,并将它们累积为最终结果。
流程如下:
sequenceDiagram
participant A as Agent
participant L as LLM (SSE)
participant U as User
A->>L: POST /chat/completions (stream: true)
L-->>A: data: {"delta":{"content":"Hello"}}
A->>U: print "Hello"
L-->>A: data: {"delta":{"content":" world"}}
A->>U: print " world"
L-->>A: data: [DONE]
A->>U: (done)
工具调用的流式方式相同,只是使用 tool_calls delta 而非 content delta。工具调用的名称和参数会分片到达,你需要将它们拼接起来。
StreamEvent
打开 mini-claw-code/src/streaming.rs。StreamEvent 枚举是我们用于流式增量数据的领域类型:
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq)]
pub enum StreamEvent {
/// 一段助手文本片段。
TextDelta(String),
/// 一个新的工具调用已开始。
ToolCallStart { index: usize, id: String, name: String },
/// 正在进行的工具调用的更多参数 JSON。
ToolCallDelta { index: usize, arguments: String },
/// 流已完成。
Done,
}
}
这是 SSE 解析器和应用程序其余部分之间的接口。解析器产生 StreamEvent;UI 消费它们用于显示;累加器将它们收集为 AssistantTurn。
StreamAccumulator
累加器是一个简单的状态机。它维护一个持续更新的 text 缓冲区和一个部分工具调用列表。每次 feed() 调用都会追加到适当的位置:
#![allow(unused)]
fn main() {
pub struct StreamAccumulator {
text: String,
tool_calls: Vec<PartialToolCall>,
}
impl StreamAccumulator {
pub fn new() -> Self { /* ... */ }
pub fn feed(&mut self, event: &StreamEvent) { /* ... */ }
pub fn finish(self) -> AssistantTurn { /* ... */ }
}
}
实现很直观:
TextDelta→ 追加到self.text。ToolCallStart→ 如果需要,扩展tool_calls向量,在给定索引处设置id和name。ToolCallDelta→ 在给定索引处追加到参数字符串。Done→ 无操作(我们在finish()中处理完成逻辑)。
finish() 消耗累加器并构建一个 AssistantTurn:
#![allow(unused)]
fn main() {
pub fn finish(self) -> AssistantTurn {
let text = if self.text.is_empty() { None } else { Some(self.text) };
let tool_calls: Vec<ToolCall> = self.tool_calls
.into_iter()
.filter(|tc| !tc.name.is_empty())
.map(|tc| ToolCall {
id: tc.id,
name: tc.name,
arguments: serde_json::from_str(&tc.arguments)
.unwrap_or(Value::Null),
})
.collect();
let stop_reason = if tool_calls.is_empty() {
StopReason::Stop
} else {
StopReason::ToolUse
};
AssistantTurn { text, tool_calls, stop_reason }
}
}
注意 arguments 是作为原始字符串累积的,只在最后才解析为 JSON。这是因为 API 会发送类似 {"pa 和 th": "f.txt"} 这样的参数片段——在拼接之前它们不是有效的 JSON。
解析 SSE 行
parse_sse_line() 函数接收 SSE 流中的单行,返回零个或多个 StreamEvent:
#![allow(unused)]
fn main() {
pub fn parse_sse_line(line: &str) -> Option<Vec<StreamEvent>> {
let data = line.strip_prefix("data: ")?;
if data == "[DONE]" {
return Some(vec![StreamEvent::Done]);
}
let chunk: ChunkResponse = serde_json::from_str(data).ok()?;
// ... 从 chunk.choices[0].delta 中提取事件
}
}
SSE 块类型与 OpenAI 的 delta 格式对应:
#![allow(unused)]
fn main() {
#[derive(Deserialize)]
struct ChunkResponse { choices: Vec<ChunkChoice> }
#[derive(Deserialize)]
struct ChunkChoice { delta: Delta, finish_reason: Option<String> }
#[derive(Deserialize)]
struct Delta {
content: Option<String>,
tool_calls: Option<Vec<DeltaToolCall>>,
}
}
对于工具调用,第一个块包含 id 和 function.name(表示新的工具调用)。后续块只包含 function.arguments 片段。解析器在 id 存在时发出 ToolCallStart,在参数字符串非空时发出 ToolCallDelta。
StreamProvider trait
正如 Provider 定义了非流式接口,StreamProvider 定义了流式接口:
#![allow(unused)]
fn main() {
pub trait StreamProvider: Send + Sync {
fn stream_chat<'a>(
&'a self,
messages: &'a [Message],
tools: &'a [&'a ToolDefinition],
tx: mpsc::UnboundedSender<StreamEvent>,
) -> impl Future<Output = anyhow::Result<AssistantTurn>> + Send + 'a;
}
}
与 Provider::chat() 的关键区别在于 tx 参数——一个 mpsc 通道发送端。实现在事件到达时通过该通道发送 StreamEvent,同时返回最终累积的 AssistantTurn。这使调用者既能获取实时事件,又能获取完整结果。
我们将 StreamProvider 与 Provider 分开,而不是在现有 trait 上添加方法。这意味着 SimpleAgent 和所有现有代码完全不受影响。
为 OpenRouterProvider 实现 StreamProvider
该实现将 SSE 解析、累加器和通道整合在一起:
#![allow(unused)]
fn main() {
impl StreamProvider for OpenRouterProvider {
async fn stream_chat(
&self,
messages: &[Message],
tools: &[&ToolDefinition],
tx: mpsc::UnboundedSender<StreamEvent>,
) -> anyhow::Result<AssistantTurn> {
// 1. 构建带有 stream: true 的请求
// 2. 发送 HTTP 请求
// 3. 在循环中读取响应块:
// - 缓冲传入的字节
// - 按换行符拆分
// - 对每个完整行调用 parse_sse_line()
// - 将每个事件 feed() 到累加器
// - 通过 tx 发送每个事件
// 4. 返回 acc.finish()
}
}
}
缓冲细节很重要。HTTP 响应可能以任意字节块到达,不一定与 SSE 行边界对齐。因此我们维护一个 String 缓冲区,追加每个块,只处理完整行(按 \n 拆分):
#![allow(unused)]
fn main() {
let mut buffer = String::new();
while let Some(chunk) = resp.chunk().await? {
buffer.push_str(&String::from_utf8_lossy(&chunk));
while let Some(newline_pos) = buffer.find('\n') {
let line = buffer[..newline_pos].trim_end_matches('\r').to_string();
buffer = buffer[newline_pos + 1..].to_string();
if line.is_empty() { continue; }
if let Some(events) = parse_sse_line(&line) {
for event in events {
acc.feed(&event);
let _ = tx.send(event);
}
}
}
}
}
MockStreamProvider
为了测试,我们需要一个不发起 HTTP 调用的流式 provider。MockStreamProvider 包装了现有的 MockProvider,并从每个预设的 AssistantTurn 合成 StreamEvent:
#![allow(unused)]
fn main() {
pub struct MockStreamProvider {
inner: MockProvider,
}
impl StreamProvider for MockStreamProvider {
async fn stream_chat(
&self,
messages: &[Message],
tools: &[&ToolDefinition],
tx: mpsc::UnboundedSender<StreamEvent>,
) -> anyhow::Result<AssistantTurn> {
let turn = self.inner.chat(messages, tools).await?;
// 从完整的 turn 合成流式事件
if let Some(ref text) = turn.text {
for ch in text.chars() {
let _ = tx.send(StreamEvent::TextDelta(ch.to_string()));
}
}
for (i, call) in turn.tool_calls.iter().enumerate() {
let _ = tx.send(StreamEvent::ToolCallStart {
index: i, id: call.id.clone(), name: call.name.clone(),
});
let _ = tx.send(StreamEvent::ToolCallDelta {
index: i, arguments: call.arguments.to_string(),
});
}
let _ = tx.send(StreamEvent::Done);
Ok(turn)
}
}
}
它逐字符发送文本(模拟逐 token 的流式输出),并将每个工具调用作为 start + delta 对发送。这让我们可以在没有任何网络调用的情况下测试 StreamingAgent。
StreamingAgent
现在到了重头戏。StreamingAgent 是 SimpleAgent 的流式版本。它具有相同的结构——一个 provider、一组工具和一个 agent 循环——但它使用 StreamProvider 并实时发出 AgentEvent::TextDelta 事件:
#![allow(unused)]
fn main() {
pub struct StreamingAgent<P: StreamProvider> {
provider: P,
tools: ToolSet,
}
impl<P: StreamProvider> StreamingAgent<P> {
pub fn new(provider: P) -> Self { /* ... */ }
pub fn tool(mut self, t: impl Tool + 'static) -> Self { /* ... */ }
pub async fn run(
&self,
prompt: &str,
events: mpsc::UnboundedSender<AgentEvent>,
) -> anyhow::Result<String> { /* ... */ }
pub async fn chat(
&self,
messages: &mut Vec<Message>,
events: mpsc::UnboundedSender<AgentEvent>,
) -> anyhow::Result<String> { /* ... */ }
}
}
chat() 方法是流式 agent 的核心。让我们逐步解析:
#![allow(unused)]
fn main() {
pub async fn chat(
&self,
messages: &mut Vec<Message>,
events: mpsc::UnboundedSender<AgentEvent>,
) -> anyhow::Result<String> {
let defs = self.tools.definitions();
loop {
// 1. 建立流式通道
let (stream_tx, mut stream_rx) = mpsc::unbounded_channel();
// 2. 启动一个转发器,将 StreamEvent::TextDelta
// 转换为 AgentEvent::TextDelta 发送给 UI
let events_clone = events.clone();
let forwarder = tokio::spawn(async move {
while let Some(event) = stream_rx.recv().await {
if let StreamEvent::TextDelta(text) = event {
let _ = events_clone.send(AgentEvent::TextDelta(text));
}
}
});
// 3. 调用 stream_chat —— 既进行流式传输,又返回最终结果
let turn = self.provider.stream_chat(messages, &defs, stream_tx).await?;
let _ = forwarder.await;
// 4. 与 SimpleAgent 相同的 stop_reason 逻辑
match turn.stop_reason {
StopReason::Stop => {
let text = turn.text.clone().unwrap_or_default();
let _ = events.send(AgentEvent::Done(text.clone()));
messages.push(Message::Assistant(turn));
return Ok(text);
}
StopReason::ToolUse => {
// 执行工具,推送结果,继续循环
// (与 SimpleAgent 相同的模式)
}
}
}
}
}
该架构有两个通道同时工作:
flowchart LR
SC["stream_chat()"] -- "StreamEvent" --> CH["mpsc channel"]
CH --> FW["forwarder task"]
FW -- "AgentEvent::TextDelta" --> UI["UI / events channel"]
SC -- "feeds" --> ACC["StreamAccumulator"]
ACC -- "finish()" --> TURN["AssistantTurn"]
TURN --> LOOP["Agent loop"]
转发器任务是一个桥梁:它从 provider 接收原始的 StreamEvent,并将 TextDelta 事件转换为 AgentEvent::TextDelta 发送给 UI。这使得 provider 的流式协议与 agent 的事件协议保持分离。
注意 AgentEvent 现在多了一个 TextDelta 变体:
#![allow(unused)]
fn main() {
pub enum AgentEvent {
TextDelta(String), // 新增 —— 流式文本片段
ToolCall { name: String, summary: String },
Done(String),
Error(String),
}
}
在 TUI 中使用 StreamingAgent
TUI 示例(examples/tui.rs)使用 StreamingAgent 来提供完整体验:
#![allow(unused)]
fn main() {
let provider = OpenRouterProvider::from_env()?;
let agent = Arc::new(
StreamingAgent::new(provider)
.tool(BashTool::new())
.tool(ReadTool::new())
.tool(WriteTool::new())
.tool(EditTool::new()),
);
}
agent 被包装在 Arc 中,以便与派生的任务共享。每一轮会派生 agent 并使用加载动画处理事件:
#![allow(unused)]
fn main() {
let (tx, mut rx) = mpsc::unbounded_channel();
let agent = agent.clone();
let mut msgs = std::mem::take(&mut history);
let handle = tokio::spawn(async move {
let _ = agent.chat(&mut msgs, tx).await;
msgs
});
// UI 事件循环 —— 打印 TextDelta,为工具调用显示加载动画
loop {
tokio::select! {
event = rx.recv() => {
match event {
Some(AgentEvent::TextDelta(text)) => print!("{text}"),
Some(AgentEvent::ToolCall { summary, .. }) => { /* 加载动画 */ },
Some(AgentEvent::Done(_)) => break,
// ...
}
}
_ = tick.tick() => { /* 更新加载动画 */ }
}
}
}
将此与第九章的 SimpleAgent 版本对比:结构几乎完全相同。唯一的区别是 TextDelta 事件让我们能在 token 到达时立即打印,而不必等待完整的 Done 事件。
运行测试
cargo test -p mini-claw-code ch10
测试验证了:
- 累加器:文本组装、工具调用组装、混合事件、空输入、多个并行工具调用。
- SSE 解析:文本 delta、工具调用 start/delta、
[DONE]、非 data 行、空 delta、无效 JSON、完整的多行序列。 - MockStreamProvider:文本响应合成逐字符事件;工具调用响应合成 start + delta 事件。
- StreamingAgent:纯文本响应、工具调用循环和多轮对话历史——全部使用
MockStreamProvider进行确定性测试。 - 集成测试:模拟 TCP 服务器发送真实的 SSE 响应给
stream_chat(),验证返回的AssistantTurn和通过通道发送的事件。
总结
StreamEvent表示实时增量数据:文本片段、工具调用开始、参数片段和完成信号。StreamAccumulator将增量数据收集为完整的AssistantTurn。parse_sse_line()将原始 SSEdata:行转换为StreamEvent。StreamProvider是Provider的流式版本——它添加了一个mpsc通道参数用于实时事件。MockStreamProvider包装MockProvider,为测试合成流式事件。StreamingAgent是SimpleAgent的流式版本——相同的工具循环,但带有实时TextDelta事件转发给 UI。Providertrait 和SimpleAgent保持不变。流式输出是在此之上叠加的增量功能。
第十一章:用户输入
你的 agent 可以读取文件、运行命令、编写代码——但它无法向你提问。如果它不确定该采用哪种方案、操作哪个文件,或者是否要执行一个破坏性操作,它只能靠猜测。
真正的编程 agent 通过 ask tool(询问工具) 来解决这个问题。Claude Code 有 AskUserQuestion,Kimi CLI 有审批提示。LLM 调用一个特殊工具,agent 暂停执行,用户输入答案。答案作为工具结果返回,执行继续。
在本章中,你将构建:
- 一个
InputHandlertrait,抽象用户输入的收集方式。 - 一个
AskTool,供 LLM 调用来向用户提问。 - 三种 handler 实现:CLI、基于 channel 的(用于 TUI)以及 mock(用于测试)。
为什么需要 trait?
不同的 UI 以不同方式收集输入:
- CLI 应用打印到 stdout 并从 stdin 读取。
- TUI 应用通过 channel 发送请求,等待事件循环收集答案(可能通过方向键选择)。
- 测试需要提供预设答案,无需任何 I/O。
InputHandler trait 让 AskTool 能与这三者配合使用,而不需要知道具体使用的是哪一个:
#![allow(unused)]
fn main() {
#[async_trait::async_trait]
pub trait InputHandler: Send + Sync {
async fn ask(&self, question: &str, options: &[String]) -> anyhow::Result<String>;
}
}
question 是 LLM 想要询问的内容。options 切片是一个可选的选项列表——如果为空,用户输入自由文本。如果非空,UI 可以呈现一个选择列表。
AskTool
AskTool 实现了 Tool trait。它接收一个 Arc<dyn InputHandler>,以便 handler 可以跨线程共享:
#![allow(unused)]
fn main() {
pub struct AskTool {
definition: ToolDefinition,
handler: Arc<dyn InputHandler>,
}
}
工具定义
LLM 需要知道工具接受哪些参数。question 是必需的(字符串类型)。options 是可选的(字符串数组)。
对于 options,我们需要一个数组类型的 JSON schema——param() 无法表达这一点,因为它只处理标量类型(scalar type)。所以首先,给 ToolDefinition 添加 param_raw():
#![allow(unused)]
fn main() {
/// 使用原始 JSON schema 值添加一个参数。
///
/// 用于 `param()` 无法表达的复杂类型(数组、嵌套对象)。
pub fn param_raw(mut self, name: &str, schema: Value, required: bool) -> Self {
self.parameters["properties"][name] = schema;
if required {
self.parameters["required"]
.as_array_mut()
.unwrap()
.push(serde_json::Value::String(name.to_string()));
}
self
}
}
现在工具定义同时使用 param() 和 param_raw():
#![allow(unused)]
fn main() {
impl AskTool {
pub fn new(handler: Arc<dyn InputHandler>) -> Self {
Self {
definition: ToolDefinition::new(
"ask_user",
"Ask the user a clarifying question...",
)
.param("question", "string", "The question to ask the user", true)
.param_raw(
"options",
json!({
"type": "array",
"items": { "type": "string" },
"description": "Optional list of choices to present to the user"
}),
false,
),
handler,
}
}
}
}
Tool::call
call 的实现提取 question,通过辅助函数解析 options,然后委托给 handler:
#![allow(unused)]
fn main() {
#[async_trait::async_trait]
impl Tool for AskTool {
fn definition(&self) -> &ToolDefinition {
&self.definition
}
async fn call(&self, args: Value) -> anyhow::Result<String> {
let question = args
.get("question")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing required parameter: question"))?;
let options = parse_options(&args);
self.handler.ask(question, &options).await
}
}
/// 从工具参数中提取可选的 `options` 数组。
fn parse_options(args: &Value) -> Vec<String> {
args.get("options")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}
}
parse_options 辅助函数让 call() 专注于正常路径(happy path)。如果 options 缺失或不是数组,则默认为空 vec——handler 将此视为自由文本输入。
三种 handler
CliInputHandler
最简单的 handler。打印问题,列出编号选项(如果有),从 stdin 读取一行,并解析编号答案:
#![allow(unused)]
fn main() {
pub struct CliInputHandler;
#[async_trait::async_trait]
impl InputHandler for CliInputHandler {
async fn ask(&self, question: &str, options: &[String]) -> anyhow::Result<String> {
let question = question.to_string();
let options = options.to_vec();
// 使用 spawn_blocking,因为 stdin 是同步的
tokio::task::spawn_blocking(move || {
// 显示问题和编号选项(如果有)
println!("\n {question}");
for (i, opt) in options.iter().enumerate() {
println!(" {}) {opt}", i + 1);
}
// 读取答案
print!(" > ");
io::stdout().flush()?;
let mut line = String::new();
io::stdin().lock().read_line(&mut line)?;
let answer = line.trim().to_string();
// 如果用户输入了有效的选项编号,则解析它
Ok(resolve_option(&answer, &options))
}).await?
}
}
/// 如果 `answer` 是一个匹配某个选项的数字,返回该选项。
/// 否则返回原始答案。
fn resolve_option(answer: &str, options: &[String]) -> String {
if let Ok(n) = answer.parse::<usize>()
&& n >= 1
&& n <= options.len()
{
return options[n - 1].clone();
}
answer.to_string()
}
}
resolve_option 辅助函数让闭包体保持简洁。它使用了 let-chain 语法(在 Rust 1.87 / edition 2024 中稳定):多个条件用 && 连接,包括 let Ok(n) = ... 模式绑定。如果用户输入 "2" 且有三个选项,则解析为 options[1]。否则返回原始文本。
注意 for 循环在切片为空时什么都不做——不需要特殊的 if 分支。
在简单的 CLI 应用中使用它,例如 examples/chat.rs:
#![allow(unused)]
fn main() {
let agent = SimpleAgent::new(provider)
.tool(BashTool::new())
.tool(ReadTool::new())
.tool(WriteTool::new())
.tool(EditTool::new())
.tool(AskTool::new(Arc::new(CliInputHandler)));
}
ChannelInputHandler
对于 TUI 应用,输入收集发生在事件循环中,而非工具内部。ChannelInputHandler 通过 channel 桥接这一差距:
#![allow(unused)]
fn main() {
pub struct UserInputRequest {
pub question: String,
pub options: Vec<String>,
pub response_tx: oneshot::Sender<String>,
}
pub struct ChannelInputHandler {
tx: mpsc::UnboundedSender<UserInputRequest>,
}
}
当 ask() 被调用时,它通过 channel 发送一个 UserInputRequest 并等待 oneshot 响应:
#![allow(unused)]
fn main() {
#[async_trait::async_trait]
impl InputHandler for ChannelInputHandler {
async fn ask(&self, question: &str, options: &[String]) -> anyhow::Result<String> {
let (response_tx, response_rx) = oneshot::channel();
self.tx.send(UserInputRequest {
question: question.to_string(),
options: options.to_vec(),
response_tx,
})?;
Ok(response_rx.await?)
}
}
}
TUI 事件循环接收请求并按自己的方式渲染——可以是简单的文本提示,也可以是使用 crossterm 在 raw 终端模式下实现的方向键导航选择列表。
MockInputHandler
用于测试,在队列中预先配置答案:
#![allow(unused)]
fn main() {
pub struct MockInputHandler {
answers: Mutex<VecDeque<String>>,
}
#[async_trait::async_trait]
impl InputHandler for MockInputHandler {
async fn ask(&self, _question: &str, _options: &[String]) -> anyhow::Result<String> {
self.answers.lock().await.pop_front()
.ok_or_else(|| anyhow::anyhow!("MockInputHandler: no more answers"))
}
}
}
这遵循与 MockProvider 相同的模式——从前端弹出,空时报错。注意这里使用的是 tokio::sync::Mutex(配合 .lock().await),而非 std::sync::Mutex。原因是:ask() 是一个 async fn,锁守卫(lock guard)必须跨越 .await 边界持有。std::sync::Mutex 的守卫是 !Send 的,因此跨 .await 持有它无法编译。tokio::sync::Mutex 产生一个 Send 安全的守卫,可以在异步上下文中使用。与第一章中的 MockProvider 对比,后者使用 std::sync::Mutex,因为其 chat() 方法不会跨 .await 持有守卫。
工具摘要
更新 agent.rs 中的 tool_summary(),以便在终端输出中为 ask_user 调用显示 "question":
#![allow(unused)]
fn main() {
let detail = call.arguments
.get("command")
.or_else(|| call.arguments.get("path"))
.or_else(|| call.arguments.get("question")) // <-- 新增
.and_then(|v| v.as_str());
}
Plan mode 集成
ask_user 是只读的——它收集信息而不修改任何内容。将其添加到 PlanAgent 的默认 read_only 集合中(参见第十二章),这样 LLM 在规划阶段也能提问:
#![allow(unused)]
fn main() {
read_only: HashSet::from(["bash", "read", "ask_user"]),
}
接入整合
将模块添加到 mini-claw-code/src/tools/mod.rs:
#![allow(unused)]
fn main() {
mod ask;
pub use ask::*;
}
并从 lib.rs 重新导出:
#![allow(unused)]
fn main() {
pub use tools::{
AskTool, BashTool, ChannelInputHandler, CliInputHandler,
EditTool, InputHandler, MockInputHandler, ReadTool,
UserInputRequest, WriteTool,
};
}
运行测试
cargo test -p mini-claw-code ch11
测试验证了:
- 工具定义:schema 包含
question(必需)和options(可选数组)。 - 仅问题:
MockInputHandler为仅包含问题的调用返回答案。 - 带选项:工具正确地将 options 传递给 handler。
- 缺少问题:缺少
question参数返回错误。 - handler 耗尽:空的
MockInputHandler返回错误。 - Agent 循环:LLM 调用
ask_user,获取答案,然后返回最终文本。 - 先询问再调用工具:
ask_user之后跟着另一个工具调用(例如read)。 - 多次询问:两次连续的
ask_user调用,使用不同的答案。 - Channel 往返:
ChannelInputHandler通过 oneshot channel 发送请求并接收响应。 - param_raw:
param_raw()正确地将数组参数添加到ToolDefinition。
回顾
InputHandlertrait 抽象了 CLI、TUI 和测试中的输入收集方式。AskTool让 LLM 暂停执行并向用户提问。param_raw()扩展了ToolDefinition,支持数组等复杂 JSON schema 类型。- 三种 handler:
CliInputHandler用于简单应用,ChannelInputHandler用于 TUI 应用,MockInputHandler用于测试。 - Plan mode:
ask_user默认是只读的,因此在规划阶段也能使用。 - 纯增量变更:无需修改
SimpleAgent、StreamingAgent或任何现有工具。
第十二章:计划模式
真正的 coding agent 可能是危险的。给 LLM 提供 write、edit 和 bash 工具,它可能会改写你的配置、删除文件,或者执行破坏性命令——而这一切都发生在你来得及审查之前。
计划模式(Plan Mode) 通过两阶段工作流解决这个问题:
- 计划阶段 —— agent 使用只读工具(
read、bash和ask_user)探索代码库。它不能写入、编辑或修改任何内容。它返回一个描述其意图的计划。 - 执行阶段 —— 用户审查并批准计划后,agent 再次运行,此时所有工具都可用。
这正是 Claude Code 的计划模式的工作方式。在本章中,你将构建 PlanAgent —— 一个带有调用方驱动的批准门控的流式 agent。
你将完成以下任务:
- 构建带有
plan()和execute()方法的PlanAgent<P: StreamProvider>。 - 注入一个系统提示词(System Prompt),告诉 LLM 它处于计划模式。
- 添加一个
exit_plan工具,LLM 在计划就绪时调用它。 - 实现双重防御:定义过滤 加上 执行守卫。
- 让调用方驱动两个阶段之间的批准流程。
为什么需要计划模式?
考虑以下场景:
User: "Refactor auth.rs to use JWT instead of session cookies"
Agent (no plan mode):
→ calls write("auth.rs", ...) immediately
→ rewrites half your auth system
→ you didn't want that approach at all
使用计划模式:
User: "Refactor auth.rs to use JWT instead of session cookies"
Agent (plan phase):
→ calls read("auth.rs") to understand current code
→ calls bash("grep -r 'session' src/") to find related files
→ calls exit_plan to submit its plan
→ "Plan: Replace SessionStore with JwtProvider in 3 files..."
User: "Looks good, go ahead."
Agent (execute phase):
→ calls write/edit with the approved changes
关键洞察:同一个 agent 循环适用于两个阶段。唯一的区别是哪些工具可用。
设计
PlanAgent 与 StreamingAgent 具有相同的结构 —— 一个提供者(Provider)、一个 ToolSet 和一个 agent 循环。三个新增部分使其成为 plan agent:
- 一个
HashSet<&'static str>,记录在计划阶段允许使用的工具。 - 一个系统提示词,在计划阶段开始时注入。
- 一个
exit_plan工具定义,LLM 在计划就绪时调用。
#![allow(unused)]
fn main() {
pub struct PlanAgent<P: StreamProvider> {
provider: P,
tools: ToolSet,
read_only: HashSet<&'static str>,
plan_system_prompt: String,
exit_plan_def: ToolDefinition,
}
}
两个公开方法驱动两个阶段:
plan()—— 注入系统提示词,仅使用只读工具和exit_plan运行 agent 循环。execute()—— 使用所有工具运行 agent 循环。
两者都委托给一个私有的 run_loop(),该方法接受一个可选的工具过滤器。
构建器
构造过程遵循与 SimpleAgent 和 StreamingAgent 相同的构建器模式(Builder Pattern):
#![allow(unused)]
fn main() {
impl<P: StreamProvider> PlanAgent<P> {
pub fn new(provider: P) -> Self {
Self {
provider,
tools: ToolSet::new(),
read_only: HashSet::from(["bash", "read", "ask_user"]),
plan_system_prompt: DEFAULT_PLAN_PROMPT.to_string(),
exit_plan_def: ToolDefinition::new(
"exit_plan",
"Signal that your plan is complete and ready for user review. \
Call this when you have finished exploring and are ready to \
present your plan.",
),
}
}
pub fn tool(mut self, t: impl Tool + 'static) -> Self {
self.tools.push(t);
self
}
pub fn read_only(mut self, names: &[&'static str]) -> Self {
self.read_only = names.iter().copied().collect();
self
}
pub fn plan_prompt(mut self, prompt: impl Into<String>) -> Self {
self.plan_system_prompt = prompt.into();
self
}
}
}
默认情况下,bash、read 和 ask_user 是只读的。(第十一章添加了 ask_user,使 LLM 可以在计划阶段提出澄清性问题。).read_only() 方法允许调用方覆盖此设置——例如,如果你想要更严格的模式,可以在计划阶段排除 bash。
.plan_prompt() 方法允许调用方覆盖系统提示词——这对于安全审计或代码审查等专用 agent 很有用。
系统提示词
LLM 需要知道它处于计划模式。否则,它会尝试用它看到的任何工具来完成任务,而不是产出一个深思熟虑的计划。
plan() 在对话开始时注入一个系统提示词:
#![allow(unused)]
fn main() {
const DEFAULT_PLAN_PROMPT: &str = "\
You are in PLANNING MODE. Explore the codebase using the available tools and \
create a plan. You can read files, run shell commands, and ask the user \
questions — but you CANNOT write, edit, or create files.\n\n\
When your plan is ready, call the `exit_plan` tool to submit it for review.";
}
注入是有条件的——如果调用方已经提供了 System 消息,plan() 会尊重它:
#![allow(unused)]
fn main() {
pub async fn plan(
&self,
messages: &mut Vec<Message>,
events: mpsc::UnboundedSender<AgentEvent>,
) -> anyhow::Result<String> {
if !messages
.first()
.is_some_and(|m| matches!(m, Message::System(_)))
{
messages.insert(0, Message::System(self.plan_system_prompt.clone()));
}
self.run_loop(messages, Some(&self.read_only), events).await
}
}
这意味着:
- 首次调用:没有系统消息 → 注入计划提示词。
- 重新计划:系统消息已存在 → 跳过。
- 调用方提供了自己的:调用方的系统消息 → 予以保留。
这就是真实 agent 的工作方式。Claude Code 在进入计划模式时会切换其系统提示词。OpenCode 使用完全独立的 agent 配置,为 plan 和 build agent 设置不同的系统提示词。
exit_plan 工具
如果没有 exit_plan,计划阶段会在 LLM 返回 StopReason::Stop 时结束——与任何对话的结束方式相同。这是模糊的:LLM 是完成了计划,还是只是停止了对话?
真实的 agent 通过显式信号来解决这个问题。Claude Code 有 ExitPlanMode。OpenCode 有 exit_plan。LLM 调用该工具来表示“我的计划已准备好供审查“。
在 PlanAgent 中,exit_plan 是一个存储在结构体上的工具定义——并未注册到 ToolSet 中。这意味着:
- 在计划阶段:
exit_plan与只读工具一起被注入到工具列表中。LLM 可以看到并调用它。 - 在执行阶段:
exit_plan不在工具列表中。LLM 不知道它的存在。
当 agent 循环看到 exit_plan 调用时,它立即返回计划文本(LLM 在该轮次的文本):
#![allow(unused)]
fn main() {
// 处理 exit_plan:标记计划完成
if allowed.is_some() && call.name == "exit_plan" {
results.push((call.id.clone(), "Plan submitted for review.".into()));
exit_plan = true;
continue;
}
}
在工具调用循环之后,plan_text 捕获 LLM 在本轮次的文本(即计划本身),并将该轮次推入消息历史:
#![allow(unused)]
fn main() {
let plan_text = turn.text.clone().unwrap_or_default();
messages.push(Message::Assistant(turn));
}
如果 exit_plan 在工具调用中被调用,则流程结束:
#![allow(unused)]
fn main() {
if exit_plan {
let _ = events.send(AgentEvent::Done(plan_text.clone()));
return Ok(plan_text);
}
}
计划阶段现在有两条退出路径:
StopReason::Stop—— LLM 自然停止(向后兼容)。exit_plan工具调用 —— LLM 显式发出计划完成信号。
两者都有效。exit_plan 路径更好,因为它是明确的。
双重防御
工具过滤仍然使用两层保护:
第一层:定义过滤
在 plan() 期间,只有只读工具定义加上 exit_plan 被发送给 LLM。模型在其工具列表中完全看不到 write 或 edit:
#![allow(unused)]
fn main() {
let all_defs = self.tools.definitions();
let defs: Vec<&ToolDefinition> = match allowed {
Some(names) => {
let mut filtered: Vec<&ToolDefinition> = all_defs
.into_iter()
.filter(|d| names.contains(d.name))
.collect();
filtered.push(&self.exit_plan_def);
filtered
}
None => all_defs,
};
}
在 execute() 期间,allowed 为 None,因此所有已注册的工具都会被发送——而 exit_plan 不会被包含。
第二层:执行守卫
如果 LLM 以某种方式“幻觉“出一个被阻止的工具调用,执行守卫会捕获它并返回错误 ToolResult,而不是执行该工具:
#![allow(unused)]
fn main() {
if let Some(names) = allowed
&& !names.contains(call.name.as_str())
{
results.push((
call.id.clone(),
format!(
"error: tool '{}' is not available in planning mode",
call.name
),
));
continue;
}
}
错误作为工具结果返回给 LLM,这样它就知道该工具被阻止并调整其行为。文件永远不会被触及。
共享的 agent 循环
plan() 和 execute() 都委托给 run_loop()。唯一不同的参数是 allowed:
#![allow(unused)]
fn main() {
pub async fn plan(
&self,
messages: &mut Vec<Message>,
events: mpsc::UnboundedSender<AgentEvent>,
) -> anyhow::Result<String> {
// 系统提示词注入(前面已展示)
self.run_loop(messages, Some(&self.read_only), events).await
}
pub async fn execute(
&self,
messages: &mut Vec<Message>,
events: mpsc::UnboundedSender<AgentEvent>,
) -> anyhow::Result<String> {
self.run_loop(messages, None, events).await
}
}
plan() 传入 Some(&self.read_only) 来限制工具。execute() 传入 None 来允许所有工具。
run_loop 本身与第十章中 StreamingAgent::chat() 完全相同,增加了以下内容:
- 工具定义过滤(计划阶段:只读工具 +
exit_plan;执行阶段:全部工具)。 exit_plan处理器,当 LLM 发出计划完成信号时中断循环。- 被阻止工具的执行守卫。
调用方驱动的批准流程
批准流程完全在调用方中实现。PlanAgent 不会请求批准——它只执行被调用的阶段。这使 agent 保持简单,并允许调用方实现任何他们想要的批准用户体验。
以下是典型流程:
#![allow(unused)]
fn main() {
let agent = PlanAgent::new(provider)
.tool(ReadTool::new())
.tool(WriteTool::new())
.tool(EditTool::new())
.tool(BashTool::new());
let mut messages = vec![Message::User("Refactor auth.rs".into())];
// 阶段 1:计划(只读工具 + exit_plan)
let (tx, _rx) = mpsc::unbounded_channel(); // 使用 _rx 处理流式事件
let plan = agent.plan(&mut messages, tx).await?;
println!("Plan: {plan}");
// 向用户展示计划,获取批准
if user_approves() {
// 阶段 2:执行(所有工具)
messages.push(Message::User("Approved. Execute the plan.".into()));
let (tx2, _rx2) = mpsc::unbounded_channel();
let result = agent.execute(&mut messages, tx2).await?;
println!("Result: {result}");
} else {
// 带反馈重新计划
messages.push(Message::User("No, try a different approach.".into()));
let (tx3, _rx3) = mpsc::unbounded_channel();
let revised_plan = agent.plan(&mut messages, tx3).await?;
println!("Revised plan: {revised_plan}");
}
}
请注意同一个 messages 向量在各阶段之间共享。这至关重要——当 LLM 进入执行阶段时,它能看到自己的计划、用户的批准(或拒绝)以及所有之前的上下文。重新计划只需将反馈作为 User 消息推入并再次调用 plan()。
sequenceDiagram
participant C as 调用方
participant P as PlanAgent
participant L as LLM
C->>P: plan(&mut messages)
P->>L: [仅 read, bash, ask_user, exit_plan 工具]
L-->>P: 读取文件,调用 exit_plan
P-->>C: "计划:..."
C->>C: 用户审查计划
alt 批准
C->>P: execute(&mut messages)
P->>L: [所有工具]
L-->>P: 写入/编辑文件
P-->>C: "完成。"
else 拒绝
C->>P: plan(&mut messages) [带反馈]
P->>L: [仅 read, bash, ask_user, exit_plan 工具]
L-->>P: 修改后的计划
P-->>C: "修改后的计划:..."
end
接入项目
将模块添加到 mini-claw-code/src/lib.rs:
#![allow(unused)]
fn main() {
pub mod planning;
// ...
pub use planning::PlanAgent;
}
就是这样。和流式处理一样,计划模式是一个纯粹的增量功能——不需要修改任何现有代码。
运行测试
cargo test -p mini-claw-code ch12
测试验证以下内容:
- 文本响应:当 LLM 立即停止时,
plan()返回文本。 - 读取工具允许:
read在计划阶段可以执行。 - 写入工具阻止:
write在计划阶段被阻止;文件不会被创建;错误ToolResult被发送回 LLM。 - 编辑工具阻止:
edit同样的行为。 - 执行阶段允许写入:
write在执行阶段正常工作;文件会被创建。 - 完整的计划-执行流程:端到端流程——计划阶段读取文件,批准后执行阶段写入文件。
- 消息连续性:计划阶段的消息延续到执行阶段,包括注入的系统提示词。
- read_only 覆盖:
.read_only(&["read"])将bash从计划阶段中排除。 - 流式事件:计划阶段会发出
TextDelta和Done事件。 - 提供者错误:空的 Mock 正确传播错误。
- 构建器模式:链式调用
.tool().read_only().plan_prompt()可编译。 - 系统提示词注入:
plan()在位置 0 注入系统提示词。 - 系统提示词不重复:调用
plan()两次不会添加第二条系统消息。 - 尊重调用方的系统提示词:如果调用方提供了
System消息,plan()不会覆盖它。 exit_plan工具:LLM 调用exit_plan发出计划完成信号;plan()返回计划文本。- 执行阶段无
exit_plan:在execute()期间,exit_plan不在工具列表中。 - 自定义计划提示词:
.plan_prompt(...)覆盖默认提示词。 - 带
exit_plan的完整流程:计划阶段读取文件 → 调用exit_plan→ 批准 → 执行阶段写入文件。
总结
PlanAgent通过一个共享的 agent 循环,将计划(只读)与执行(所有工具)分离。- 系统提示词:
plan()注入一条系统消息,告诉 LLM 它处于计划模式——哪些工具可用、哪些被阻止,以及它应该在完成时调用exit_plan。 exit_plan工具:LLM 显式发出计划完成信号,就像 Claude Code 的ExitPlanMode。它在计划阶段被注入,在执行阶段不可见。- 双重防御:定义过滤阻止 LLM 看到被阻止的工具;执行守卫捕获幻觉产生的调用。
- 调用方驱动的批准:agent 不管理批准——调用方将批准/拒绝作为
User消息推入并调用相应的阶段。 - 消息连续性:同一个
messages向量贯穿两个阶段,为 LLM 提供完整的上下文。 - 流式处理:两个阶段都使用
StreamProvider并发出AgentEvent,与StreamingAgent一致。 - 纯粹增量:无需修改
SimpleAgent、StreamingAgent或任何现有代码。
第十三章:subagent
复杂任务很难处理。即使是最优秀的大语言模型(LLM),当一个提示(prompt)要求它 研究代码库、设计方案、编写代码并验证结果——同时还要保持连贯的对话时,也会力不从心。 上下文窗口(context window)被填满,模型失去焦点,质量开始下降。
subagent 通过分解来解决这个问题:父 agent 为每个子任务生成一个 subagent。subagent 拥有自己的消息历史和工具集,运行至完成后返回一个摘要。父 agent 只看到最终答案——一个干净、聚焦的结果,不包含 subagent 内部推理的噪声。
这正是 Claude Code 的 Task 工具 的工作方式。当 Claude Code 需要探索大型 代码库或处理独立的子任务时,它会生成一个 subagent 来完成工作并汇报结果。OpenCode 和 Anthropic Agent SDK 也使用了相同的模式。
在本章中,你将构建 SubagentTool——一个能够生成临时 subagent 的 Tool 实现。
你将完成以下内容:
- 为
Arc<P>添加一个 blanketimpl Provider,使父子 agent 可以共享同一个 Provider。 - 构建
SubagentTool<P: Provider>,使用基于闭包的工具工厂(tool factory)和 构建器方法(builder methods)。 - 实现
Tooltrait,包含内联的 agent 循环和轮次限制。 - 将其作为模块接入并重新导出。
为什么需要 subagent?
考虑以下场景:
User: "Add error handling to all API endpoints"
Agent (no subagents):
→ reads 15 files, context window fills up
→ forgets what it learned from file 3
→ produces inconsistent changes
Agent (with subagents):
→ spawns child: "Add error handling to /api/users.rs"
→ child reads 1 file, writes changes, returns "Done: added Result types"
→ spawns child: "Add error handling to /api/posts.rs"
→ child does the same
→ parent sees clean summaries, coordinates the overall task
关键洞察:subagent 就是一个 Tool。它接收任务描述作为输入,在内部完成工作,
然后返回一个字符串结果。父 agent 的循环不需要任何特殊处理——它调用 subagent 工具
的方式与调用 read 或 bash 完全相同。
通过 Arc<P> 共享 Provider
父 agent 和 subagent 需要使用同一个 LLM Provider。在生产环境中,这意味着共享 HTTP 客户端、API 密钥和配置。克隆 Provider 会导致连接重复。我们希望以低成本 的方式共享它。
答案是 Arc<P>。但有一个问题:我们的 Provider trait 使用了 RPITIT
(return-position impl Trait in trait),这意味着它不是对象安全的
(object-safe)。我们不能使用 dyn Provider。我们可以使用 Arc<P>(其中
P: Provider)——但前提是 Arc<P> 本身也实现了 Provider。
一个 blanket impl 可以解决这个问题。在 types.rs 中:
#![allow(unused)]
fn main() {
impl<P: Provider> Provider for Arc<P> {
fn chat<'a>(
&'a self,
messages: &'a [Message],
tools: &'a [&'a ToolDefinition],
) -> impl Future<Output = anyhow::Result<AssistantTurn>> + Send + 'a {
(**self).chat(messages, tools)
}
}
}
这通过解引用(deref)委托给内部的 P。现在 Arc<MockProvider> 和
Arc<OpenRouterProvider> 都是合法的 Provider。现有代码完全不受影响——如果
你之前传递的是 MockProvider,它仍然可以正常工作。Arc 包装是可选的。
SubagentTool 结构体
#![allow(unused)]
fn main() {
pub struct SubagentTool<P: Provider> {
provider: Arc<P>,
tools_factory: Box<dyn Fn() -> ToolSet + Send + Sync>,
system_prompt: Option<String>,
max_turns: usize,
definition: ToolDefinition,
}
}
这里有三个设计决策:
使用 Arc<P> 作为 Provider。 父 agent 创建 Arc::new(provider),保留一个
克隆给自己,并传递一个克隆给 SubagentTool。两者共享同一个底层 Provider。
成本低、安全,无需克隆 HTTP 客户端。
使用闭包工厂生产工具。 工具是 Box<dyn Tool>——它们不可克隆(Clone)。
每次 subagent 生成都需要一个全新的 ToolSet。Fn() -> ToolSet 闭包可以按需
生产。这天然可以捕获 Arc 来共享状态:
#![allow(unused)]
fn main() {
let provider = Arc::new(OpenRouterProvider::from_env()?);
SubagentTool::new(provider, || {
ToolSet::new()
.with(ReadTool::new())
.with(WriteTool::new())
.with(BashTool::new())
})
}
max_turns 安全限制。 没有这个限制,一个困惑的 subagent 可能会无限循环。
默认值为 10——对实际任务来说足够宽裕,对防止失控循环来说足够严格。
构建器(Builder)
构造过程使用与代码库其他部分相同的流式构建器风格(fluent builder pattern):
#![allow(unused)]
fn main() {
impl<P: Provider> SubagentTool<P> {
pub fn new(
provider: Arc<P>,
tools_factory: impl Fn() -> ToolSet + Send + Sync + 'static,
) -> Self {
Self {
provider,
tools_factory: Box::new(tools_factory),
system_prompt: None,
max_turns: 10,
definition: ToolDefinition::new(
"subagent",
"Spawn a child agent to handle a subtask independently. \
The child has its own message history and tools.",
)
.param(
"task",
"string",
"A clear description of the subtask for the child agent to complete.",
true,
),
}
}
pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
self.system_prompt = Some(prompt.into());
self
}
pub fn max_turns(mut self, max: usize) -> Self {
self.max_turns = max;
self
}
}
}
工具定义暴露了一个 task 参数——LLM 写一个清晰的描述来说明 subagent 应该做什么。
简洁而有效。
Tool trait 实现
SubagentTool 的核心是它的 Tool::call() 方法。它内联了一个最小化的 agent
循环——与 SimpleAgent::chat() 相同的协议(调用 Provider、执行工具、循环),
但增加了轮次限制、不输出到终端,并使用局部拥有的消息向量(message vec):
#![allow(unused)]
fn main() {
#[async_trait::async_trait]
impl<P: Provider + 'static> Tool for SubagentTool<P> {
fn definition(&self) -> &ToolDefinition {
&self.definition
}
async fn call(&self, args: Value) -> anyhow::Result<String> {
let task = args
.get("task")
.and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("missing required parameter: task"))?;
let tools = (self.tools_factory)();
let defs = tools.definitions();
let mut messages = Vec::new();
if let Some(ref prompt) = self.system_prompt {
messages.push(Message::System(prompt.clone()));
}
messages.push(Message::User(task.to_string()));
for _ in 0..self.max_turns {
let turn = self.provider.chat(&messages, &defs).await?;
match turn.stop_reason {
StopReason::Stop => {
return Ok(turn.text.unwrap_or_default());
}
StopReason::ToolUse => {
let mut results = Vec::with_capacity(turn.tool_calls.len());
for call in &turn.tool_calls {
let content = match tools.get(&call.name) {
Some(t) => t
.call(call.arguments.clone())
.await
.unwrap_or_else(|e| format!("error: {e}")),
None => format!("error: unknown tool `{}`", call.name),
};
results.push((call.id.clone(), content));
}
messages.push(Message::Assistant(turn));
for (id, content) in results {
messages.push(Message::ToolResult { id, content });
}
}
}
}
Ok("error: max turns exceeded".to_string())
}
}
}
有几点值得注意:
没有使用 tokio::spawn。 subagent 在父 agent 的 Tool::call() future 内
运行。这是有意为之的——生成一个后台任务会增加协调复杂性(通道、join handle、
取消机制)。内联运行保持了简单性和确定性。
全新的消息历史。 subagent 仅以系统提示(可选)和作为 User 消息的任务描述
开始。它永远看不到父 agent 的对话。当 subagent 完成时,只有其最终文本作为工具结果
返回给父 agent。subagent 的内部消息会被丢弃。
轮次限制是软错误。 当超过 max_turns 时,工具返回一个错误字符串而不是
Err(...)。这让父 LLM 看到失败并决定如何处理(用更简单的任务重试、尝试不同
的方法等),而不是让整个 agent 循环崩溃。
Provider 错误会向上传播。 如果 LLM API 在 subagent 运行期间失败,错误通过
? 冒泡到父 agent。这是有意的——API 错误是基础设施故障,而非任务失败。
接入模块
在 mini-claw-code/src/lib.rs 中添加模块并重新导出:
#![allow(unused)]
fn main() {
pub mod subagent;
// ...
pub use subagent::SubagentTool;
}
使用示例
以下是如何为父 agent 接入 subagent 工具:
#![allow(unused)]
fn main() {
use std::sync::Arc;
use mini_claw_code::*;
let provider = Arc::new(OpenRouterProvider::from_env()?);
let p = provider.clone();
let agent = SimpleAgent::new(provider)
.tool(ReadTool::new())
.tool(WriteTool::new())
.tool(BashTool::new())
.tool(SubagentTool::new(p, || {
ToolSet::new()
.with(ReadTool::new())
.with(WriteTool::new())
.with(BashTool::new())
}));
let result = agent.run("Refactor the auth module").await?;
}
父 LLM 在其工具列表中看到 subagent,与 read、write 和 bash 并列。
当任务足够复杂时,LLM 可以选择通过 subagent 委派——或者直接使用其他工具处理。
由 LLM 自行决定。
你也可以给 subagent 设置专门的系统提示:
#![allow(unused)]
fn main() {
SubagentTool::new(provider, || {
ToolSet::new()
.with(ReadTool::new())
.with(BashTool::new())
})
.system_prompt("You are a security auditor. Review code for vulnerabilities.")
.max_turns(15)
}
运行测试
cargo test -p mini-claw-code ch13
测试验证了以下场景:
- 文本响应:subagent 立即返回文本(没有工具调用)。
- 使用工具:subagent 在回答前使用
ReadTool。 - 多步骤:subagent 跨多个轮次进行多次工具调用。
- 超过最大轮次:轮次限制被强制执行,返回错误字符串。
- 缺少任务参数:缺少
task参数时报错。 - Provider 错误:subagent 的 Provider 错误传播到父 agent。
- 未知工具:subagent 优雅地处理未知工具。
- 构建器模式:链式调用
.system_prompt().max_turns()能够编译通过。 - 系统提示:配置系统提示后 subagent 正确运行。
- 写入工具:subagent 写入文件,父 agent 之后继续工作。
- 父 agent 继续:subagent 完成后父 agent 恢复自己的工作。
- 历史隔离:subagent 的消息不会泄露到父 agent 的消息向量中。
总结
SubagentTool是一个生成临时 subagent 的Tool。父 agent 只看到最终答案。Arc<P>blanket impl 让父子 agent 共享 Provider 而无需克隆。完全向后兼容。- 闭包工厂 为每次 subagent 生成产生一个全新的
ToolSet,因为Box<dyn Tool>不可克隆。 - 内联 agent 循环 配合
max_turns守卫,使SimpleAgent保持不变。不需要tokio::spawn——subagent 在Tool::call()内运行。 - 消息隔离:subagent 的内部消息局限于
call()future 中。只有最终文本传回 父 agent。 - 单一
task参数:LLM 写一个清晰的任务描述;subagent 处理其余部分。 - 纯增量修改:唯一对现有代码的改动是
types.rs中的 blanket impl。其他 都是新代码。
(Translation pending)
This chapter has not been translated to Chinese yet. Please refer to the English version.
(Translation pending)
This chapter has not been translated to Chinese yet. Please refer to the English version.
(Translation pending)
This chapter has not been translated to Chinese yet. Please refer to the English version.
(Translation pending)
This chapter has not been translated to Chinese yet. Please refer to the English version.
(Translation pending)
This chapter has not been translated to Chinese yet. Please refer to the English version.
(Translation pending)
This chapter has not been translated to Chinese yet. Please refer to the English version.
(Translation pending)
This chapter has not been translated to Chinese yet. Please refer to the English version.
(Translation pending)
This chapter has not been translated to Chinese yet. Please refer to the English version.