前言
为什么写这本书
AI Agent 正在从实验室走向生产。但当你翻开大多数 Agent 框架的源码,看到的是 Python 脚本、字符串拼接的 prompt、和 try: ... except: pass 式的错误处理。这在原型阶段没有问题,但当你需要让 Agent 在生产环境中 7x24 运行、服务多个租户、执行 Shell 命令和文件操作时,你需要的不是一个框架,而是一个操作系统级别的基础设施。
octos 是一个用 Rust 构建的 AI Agent 操作系统。13 万行代码,9 个核心 crate,零 unsafe 代码。它不是“Rust 写的 LangChain“——它是从第一行代码就为多租户、安全隔离、生产可靠性设计的系统。
这本书不是 octos 的用户手册。它是一本工程决策解析——每一章深入一个子系统的源码,展示“为什么这样做“、“考虑过什么替代方案”、“付出了什么代价”。如果你想理解如何用 Rust 的类型系统消除运行时错误、如何用三层容错链实现 LLM 调用的生产级可靠性、如何在不引入外部向量数据库的情况下构建混合搜索——这本书为你而写。
阅读准备
前置知识
- Rust 基础:理解所有权、借用、生命周期、trait、枚举。不需要精通,但需要能读懂 Rust 代码
- 异步编程概念:理解 async/await、Future、事件循环。不需要 Tokio 经验
- AI/LLM 概念:理解什么是 LLM、token、上下文窗口、工具调用。不需要 prompt engineering 经验
- 不需要:编译器原理、操作系统内核开发、机器学习数学
推荐阅读路径
本书 14 章 + 5 附录,根据你的背景选择最适合的路径:
路径 A:Rust 学习者(通过实战项目学 Rust)
Ch1 → Ch2 → Ch4 → Ch5 → Ch6 重点关注类型系统设计(Ch2)、枚举状态机、错误处理模式
路径 B:资深 Rust 开发者(学习大型 AI 系统架构)
Ch1 → Ch3 → Ch5 → Ch7 → Ch11 → Ch13 重点关注 trait object 选型(Ch3)、并发模型(Ch11)、安全纵深(Ch7)
路径 C:AI/LLM 应用开发者(理解 Agent OS 设计)
Ch1 → Ch3 → Ch5 → Ch8 → Ch9 重点关注 Provider 容错(Ch3)、Agent Loop(Ch5)、上下文管理(Ch8)
路径 D:octos 贡献者(深入内部实现)
全部章节按序阅读 + 附录 E(贡献指南)
全书知识地图
graph LR
subgraph "Part 1: 地基"
C1["Ch1<br/>为什么 Rust"]
C2["Ch2<br/>Core Types"]
C3["Ch3<br/>LLM Providers"]
C4["Ch4<br/>Memory"]
end
subgraph "Part 2: 引擎"
C5["Ch5<br/>Agent Loop ★"]
C6["Ch6<br/>工具系统"]
C7["Ch7<br/>安全"]
C8["Ch8<br/>上下文"]
C9["Ch9<br/>扩展"]
end
subgraph "Part 3: 平台"
C10["Ch10<br/>消息总线"]
C11["Ch11<br/>并发"]
C12["Ch12<br/>Pipeline"]
C13["Ch13<br/>运行模式"]
C14["Ch14<br/>生产化"]
end
C1 --> C2 --> C3
C2 --> C4
C3 --> C5
C4 --> C5
C5 --> C6 --> C7
C5 --> C8
C6 --> C9
C5 --> C10 --> C11
C5 --> C12
C10 --> C13 --> C14
★ Ch5(Agent Loop)是全书枢纽——理解了它,前四章是它的基础,后九章是它的延伸。
阅读标记说明
- 源码引用:
crates/octos-core/src/task.rs:63-77格式,可直接在源码仓库中定位 - 工程决策侧栏:每章一个,用
>引用格式高亮,分析 2-3 种替代方案的利弊 - Mermaid 图表:架构图、状态机图、流程图,可用 Mermaid Live Editor 渲染
- 思考题:每章结尾 3-4 道开放式问题,适合团队讨论或面试准备
第 1 章:为什么是 Rust?为什么是 Agent OS?
定位:本章是全书开篇,回答一个根本问题——为什么要用 Rust 构建多租户 AI Agent 平台?前置依赖:无。适用场景:任何想理解 octos 项目存在理由的读者,无论你是 Rust 初学者(读者 A)、资深 Rust 开发者(读者 B)、还是来自 Python/Go 生态的 AI 应用开发者(读者 C)。
当你第一次打开 octos 的代码仓库,看到 13 万行 Rust、287 个源文件,以及一个由 9 个核心 crate 加 10 个 skill 程序组成的 Cargo workspace,心中难免浮现一个问题:为什么不用 Python?LangChain 和 AutoGen 不是已经很成熟了吗?为什么不用 Go?它的并发模型不是更简单吗?
这不是一个关于语言偏好的问题。当你把「AI Agent」从单用户玩具推向多租户生产平台时,你面对的是一组相互纠缠的工程约束:安全隔离、并发控制、性能预算。这三个约束中的任何一个都不难单独解决,但当它们同时出现在一个系统中时,语言选型就不再是品味问题,而是架构决策。
本章将从问题空间出发,解释这三大挑战为什么如此棘手,然后论证 Rust 为什么是目前最适合应对这组约束的语言,最后展开 octos 的 workspace 拓扑,为后续 13 章建立全局地图。
1.1 问题空间:多租户 AI Agent 平台的三大挑战
要理解 octos 的设计决策,首先要理解它试图解决的问题。octos 不是一个 chatbot 框架——它是一个多租户 AI Agent 操作系统,需要同时为多个用户、多个 Agent 实例提供服务,每个 Agent 都可以调用文件系统操作、Shell 命令、网络请求等具有副作用的工具。
1.1.1 挑战一:安全隔离
想象一个场景:租户 A 的 Agent 被 prompt 注入攻击,恶意指令试图读取租户 B 的会话历史,或者执行 rm -rf / 来破坏宿主机。在多租户环境中,这不是理论风险,而是日常威胁。
AI Agent 的安全隔离比传统 Web 服务更复杂,原因有三:
-
工具调用是 Agent 的核心能力。Agent 不只是生成文本——它执行 Shell 命令、读写文件、发起网络请求。每一次工具调用都是一个潜在的攻击面。octos 的默认工具注册表至少包含 Shell/File/Web/Browser 等 11 个内置工具;启用
git/astfeature,或进入 Gateway/Serve 运行时后,还会再注册记忆、模型切换、研究与管理类工具(crates/octos-agent/src/tools/registry.rs:606-624;crates/octos-cli/src/commands/gateway/gateway_runtime.rs:797-866)。每一类工具都需要独立的安全策略。 -
Prompt 注入是新型攻击向量。与传统 SQL 注入不同,prompt 注入发生在自然语言层面,更难用正则表达式或 WAF 规则拦截。攻击者可以在看似无害的文档中嵌入指令,诱导 Agent 执行越权操作。
-
隔离粒度需要精细控制。不同租户需要不同的权限边界:有的允许访问 Git 仓库,有的只允许只读文件操作,有的需要完全的沙箱隔离。一刀切的隔离策略要么太松(安全风险),要么太紧(功能受限)。
octos 的应对策略是纵深防御——从 Rust 语言层面消除内存安全漏洞,到 Linux bwrap / macOS sandbox-exec / Docker 三后端沙箱提供进程级隔离,再到工具级别的 deny-wins 策略引擎实现细粒度权限控制,构建了多层安全屏障(详见第 7 章)。
举一个具体例子:当 Agent 执行 Shell 命令时,octos 的 ShellTool 会先通过 SafePolicy 检查命令是否在危险命令黑名单中(如 rm -rf /、dd、mkfs、fork bomb 等),然后将命令提交到沙箱环境中执行。即使 prompt 注入成功诱导 LLM 生成了恶意命令,这两道防线仍然可以拦截。而沙箱本身的实现依赖 Rust 的类型系统确保资源句柄不会泄漏——文件描述符在 Drop 时自动关闭,不会出现 C/C++ 中常见的资源泄漏问题。
1.1.2 挑战二:并发控制
一个生产级 Agent 平台需要同时处理大量并发请求。考虑以下场景:
- 10 个用户同时与各自的 Agent 对话
- 每个 Agent 在一次迭代中可能并行调用 3-5 个工具
- 每个工具调用可能涉及异步 HTTP 请求、文件 I/O、子进程管理
- 后台还有 Cron 任务和 Heartbeat 定时触发新的 Agent 会话
这意味着系统中可能同时存在数百个异步任务。并发本身不是问题——问题是并发中的正确性:
- 会话级串行化:同一个用户的消息必须按序处理,不能出现两条消息同时修改同一个会话状态的情况。Serve/API 路径把
SessionManager放在Arc<tokio::sync::Mutex<SessionManager>>中统一保护,确保会话状态的读写不会并发踩踏(crates/octos-cli/src/commands/serve.rs:544-545;crates/octos-cli/src/commands/serve.rs:667-669)。 - 工具级并行:在单次 Agent 迭代内,多个不相关的工具调用应该并行执行以减少延迟。当前实现把工具任务句柄交给
futures::future::join_all聚合,并在超时层外包一层tokio::time::timeout(crates/octos-agent/src/agent/execution.rs:387-455)。 - 资源限流:无限制的并发会耗尽系统资源。octos 通过
tokio::sync::Semaphore限制最大并发会话数(默认 10);默认值定义在配置层,Gateway 启动时再把它实例化成并发信号量(crates/octos-cli/src/config.rs:575-577;crates/octos-cli/src/config.rs:633-634;crates/octos-cli/src/commands/gateway/gateway_runtime.rs:1272-1278)。 - 优雅关停:当收到 SIGTERM/CTRL-C 时,不能粗暴地杀死正在进行的 Agent 对话。octos 使用
AtomicBool标志位实现优雅关停:Gateway 在信号处理路径上执行store(true, Ordering::Release),Agent Loop 在预算检查和流式消费路径上以Ordering::Acquire读取这个标志,让进行中的对话自然结束(crates/octos-cli/src/commands/gateway/gateway_runtime.rs:1188-1193;crates/octos-agent/src/agent/budget.rs:42-45;crates/octos-agent/src/agent/streaming.rs:19-21)。
Python 虽然可以通过 multiprocessing 或 concurrent.futures.ProcessPoolExecutor 实现 CPU 并行,但进程间通信的序列化开销使其不适合上述细粒度共享状态的并发模式。Go 的 goroutine 模型可以实现这些模式,但数据竞争只能通过 -race 运行时检测发现——尽管 Go 的 race detector 基于 happens-before 算法,在实际测试中相当有效,但它本质上依赖测试覆盖率,无法提供编译期的完备性保证。Rust 的 Send/Sync trait 则在编译期消除了整类数据竞争(详见第 11 章)。
1.1.3 挑战三:性能预算
AI Agent 的主要延迟瓶颈是 LLM API 调用(通常 1-10 秒),这让很多人认为 Agent 框架的性能无关紧要。这是一个危险的误解。
首先,延迟是累积的。 一次 Agent 执行可能包含多达 50 次迭代(octos 的默认上限),每次迭代涉及消息构建、工具调用、上下文压缩。如果框架层面每次迭代增加 50ms 开销,50 次迭代就是 2.5 秒——对于流式交互场景,这是用户可感知的延迟。
其次,内存是多租户的硬约束。 每个 Agent 会话需要维护对话历史、工具状态、上下文窗口。如果每个会话占用 100MB 内存(Python 应用中并不罕见),10 个并发会话就是 1GB,100 个就是 10GB。octos 的核心数据结构设计注重零拷贝和最小分配——例如 truncate_utf8 函数(crates/octos-core/src/utils.rs:3-15)通过 UTF-8 字符边界检测实现安全截断,避免不必要的字符串复制。
最后,SSE 流式解析需要持续的 CPU 效率。 LLM 的流式响应以 Server-Sent Events(SSE)格式传输,框架需要在 token 到达的毫秒级时间内完成解析和转发。在多租户场景下,平台可能同时维护数十条 SSE 连接,每条连接持续数十秒。如果解析器每次事件都触发堆分配,高并发下的分配压力会导致延迟尖刺。
octos-llm 的有状态 SSE 解析器(crates/octos-llm/src/sse.rs:5-21)设置了 1MB 缓冲上限,采用增量解析策略——数据追加到预分配的缓冲区中,逐行扫描而非按事件重新分配。这种设计避免了 GC 语言中常见的“解析触发 GC、GC 阻塞所有连接“的级联效应。
一个容易被忽视的成本:上下文压缩。 当对话历史接近 LLM 的上下文窗口限制时(通常 128K-200K tokens),octos 需要执行上下文压缩(Context Compaction)——将旧消息摘要化以腾出空间(详见第 8 章)。这个操作涉及大量字符串处理和 token 计数,在 GC 语言中容易产生大量临时对象和 GC 压力。octos 通过 truncate_utf8(crates/octos-core/src/utils.rs:3-15)等零拷贝工具函数,以及 estimate_json_size(crates/octos-agent/src/tools/registry.rs:25-50)的非分配实现,将这些热路径的内存开销降到最低。
1.2 语言选型:为什么是 Rust
理解了问题空间之后,我们可以在三个维度上比较候选语言:安全性、并发模型、运行时性能。
1.2.1 安全性维度
| 特性 | Python | Go | Rust |
|---|---|---|---|
| 内存安全 | GC 保证,但 C 扩展不受保护 | GC 保证 | 所有权系统编译期保证 |
| 类型安全 | 动态类型,运行时错误 | 静态类型,但 any 绕过编译检查 | 强静态类型 + 枚举穷举匹配 |
| unsafe 控制 | 无此概念 | unsafe 包,但无编译器约束 | unsafe 块 + workspace 级 deny(unsafe_code) |
| 依赖安全 | PyPI 无签名验证 | go.sum 校验 | Cargo 校验 + cargo-audit |
octos 在 workspace 根 Cargo.toml 中设置了 unsafe_code = "deny"(Cargo.toml:33),这意味着整个 workspace——9 个核心 crate 加 10 个 skill 程序——不允许出现任何 unsafe 代码。这不是一个 lint 建议,而是一个编译期硬约束。任何包含 unsafe 块的代码都无法通过 cargo build。
对于一个需要执行 Shell 命令、读写文件系统的 Agent 平台,这个约束的意义在于:所有与操作系统的交互都通过标准库的安全抽象完成,消除了缓冲区溢出、use-after-free 等内存安全漏洞的可能性。
相比之下,Python 的 AI 框架大量使用 C 扩展(numpy、tokenizers 等),这些 C 代码不受 Python GC 保护。Go 虽然有内存安全保证,但 unsafe 包的使用没有编译器级别的全局禁止机制。
1.2.2 并发模型维度
| 特性 | Python | Go | Rust (Tokio) |
|---|---|---|---|
| 并发原语 | asyncio(单线程事件循环) | goroutine + channel | async/await + Tokio 多线程运行时 |
| CPU 并行 | GIL 限制,需多进程 | 原生支持 | 原生支持 |
| 数据竞争检测 | 无 | -race 运行时检测 | Send/Sync 编译期保证 |
| 结构化并发 | 有限(TaskGroup) | 无内置支持 | tokio::select! + JoinSet |
Rust 的核心优势在于 Send 和 Sync trait 提供的编译期线程安全保证。考虑 octos 中的一个典型场景:Agent 配置(AgentConfig)需要在多个异步任务间共享。在 Go 中,你可能会用一个普通指针传递配置,直到某天在高并发下触发数据竞争。Go 的 race detector 虽然基于成熟的 happens-before 算法,能有效检测实际执行路径上的竞争,但它本质上是运行时工具——只有被测试覆盖到的代码路径才能被检测。
在 Rust 中,如果你试图在线程间共享一个非 Send 类型,编译器会直接拒绝:
#![allow(unused)]
fn main() {
// 示意代码——Rc 不是 Send,这段无法编译
let config = Rc::new(AgentConfig::default());
tokio::spawn(async move {
let _ = config.max_iterations; // 编译错误:Rc<AgentConfig> cannot be sent between threads safely
});
// octos 的实际做法:使用 Arc 实现线程安全共享
let config = Arc::new(AgentConfig::default());
tokio::spawn(async move {
let _ = config.max_iterations; // 编译通过:Arc<AgentConfig> 是 Send + Sync
});
}
这意味着整类并发 bug(数据竞争、use-after-free across threads)在 octos 中被编译器彻底消除,而不是依赖测试覆盖率和运行时检测。
1.2.3 性能维度
| 指标 | Python | Go | Rust |
|---|---|---|---|
| 启动时间 | 200-500ms(导入开销) | 10-50ms | 5-20ms |
| 内存占用(典型 Agent 进程) | 50-150MB | 15-30MB | 5-15MB |
| GC 停顿 | 可预测但频繁 | 亚毫秒级(Go 1.19+) | 无 GC |
以上数据为典型 AI Agent 场景下的量级估计,具体数值因实现、负载和硬件而异。Python 内存占用包含常见依赖(requests、json 等)的开销。
对于 AI Agent 平台,最关键的性能指标不是峰值吞吐量,而是尾延迟(P99 latency)。Go 自 1.19 版本以来,GC 停顿已优化到亚毫秒级(通常 < 100 微秒),对大多数场景已经足够好。但在多租户高并发场景下——数十个 Agent 同时进行 SSE 流式解析和转发——即使亚毫秒级的 GC 停顿也会在 P99 尾延迟中累积放大。Rust 没有 GC,内存分配和释放完全确定性,这让 octos 在极端场景下的尾延迟保持稳定和可预测。
从内存效率的角度看,无 GC 意味着没有堆碎片化问题,也不需要预留 2-3 倍的堆空间给 GC 使用。对于需要同时维护大量会话状态的多租户系统,这直接影响单机可承载的并发会话数。
1.2.4 选型的代价
公平地说,选择 Rust 也有明确的代价:
- 学习曲线:所有权和生命周期是 Rust 独有的概念,新开发者需要 2-4 周适应期。这不仅是语法问题——理解何时使用
&、&mut、Box、Rc、Arc需要建立新的心智模型。 - 异步编程复杂度:Rust 的 async/await 与所有权系统的交互产生了独特的复杂度。
Pin<Box<dyn Future>>、async trait 中的生命周期标注、跨.await点持有引用的限制,这些在 Python 和 Go 的异步模型中不存在。octos 大量使用async-traitcrate 和Arc共享来绕过这些限制。 - 编译时间:octos 的完整编译(clean build)需要数分钟,增量编译通常在 10-30 秒。对比 Go 的亚秒级编译,这在快速迭代阶段是明显的效率损失。
- 生态成熟度:AI/ML 生态远不如 Python 丰富,octos 需要自行实现 BM25 搜索(
crates/octos-memory/)和集成 HNSW 向量索引(hnsw_rscrate),而不是直接调用 scikit-learn 或 FAISS。 - 开发速度:同样功能的 Rust 代码通常比 Python 多 30-50% 的行数,主要增加在错误处理(
Result/?链)和类型标注上。
octos 团队认为这些代价是值得的:对于一个需要长期运行的多租户生产平台,运行时的正确性和性能比开发时的便利性更重要。编译器在开发阶段多花的 30 秒,换来的是生产环境中不会出现的内存泄漏、数据竞争和未定义行为。而异步编程的复杂度虽然提高了入门门槛,但一旦代码通过编译,其并发正确性就有了编译期保证——这对一个 7×24 运行的 Agent 平台至关重要。
1.3 Workspace 拓扑:9 个核心 crate 的分层架构
octos 采用 Cargo workspace 组织代码。若按主架构口径计算,可以把它拆成 9 个核心 crate;另外还有 10 个 app/platform skill 程序,负责补充具体能力。
1.3.1 四层架构
第零层:独立基础设施
这一层的 crate 没有内部依赖,提供独立的基础能力:
- octos-core(1,793 行):核心类型定义——
Task、Message、MessageRole、AgentId、SessionKey等。这是整个系统的“领域语言“,所有其他 crate 共享这些类型定义。零内部依赖的设计确保了类型定义的稳定性。 - octos-plugin(1,293 行):插件 SDK——manifest.json 解析、插件发现(目录扫描 + 优先级规则)、三重门控检查(binary/env/OS)。独立于 Agent 运行时,可单独使用。
- octos-sandbox(162 行):Windows 平台的 AppContainer 沙箱辅助。极简实现,平台特定。
第一层:领域服务
依赖 octos-core,提供特定领域的能力:
- octos-llm(15,728 行):LLM Provider 抽象层。统一了 Anthropic(Claude)、OpenAI(GPT-4)、Google Gemini、Ollama 等多种 Provider 的调用接口。包含三层容错链(RetryProvider → ProviderChain → AdaptiveRouter)、SSE 流式解析器、模型目录和定价计算。
- octos-memory(1,748 行):混合搜索记忆系统。基于 redb 嵌入式数据库实现 BM25 全文搜索和 HNSW 向量索引,支持 Episode Store(任务完成摘要与 7 天窗口记忆)。
- octos-bus(19,634 行):消息总线与频道集成。支持 Telegram、Discord、Slack、WhatsApp、飞书、邮件等 14 个消息频道,提供会话管理(JSONL 持久化 + LRU 内存缓存)和消息分片(5 级切割策略)。
第二层:运行时引擎
依赖第零层和第一层,实现核心运行时逻辑:
- octos-agent(34,968 行):Agent 运行时——这是整个系统的心脏。包含 Agent 主循环、工具注册与执行、命令审批策略、沙箱集成、MCP 客户端、Hook 系统、循环检测、上下文压缩等。依赖 octos-core、octos-llm、octos-memory。
- octos-pipeline(9,137 行):工作流引擎。基于 Graphviz DOT 语法定义工作流拓扑,支持 5 种 Handler 类型、并行 fan-out、条件分支、Human Gate 和断点续跑。依赖 octos-core、octos-agent、octos-llm、octos-memory。
第三层:用户入口
- octos-cli(40,746 行):CLI 与 Web 入口——整个系统的“前门“。提供三种运行模式(
octos chat交互式 CLI、octos gateway消息总线、octos serveWeb Dashboard + REST API)。通过 feature flags 控制各频道集成(telegram、discord、slack 等)的编译。依赖 octos-core、octos-agent、octos-llm、octos-memory、octos-pipeline、octos-bus。
1.3.2 依赖拓扑图
graph BT
subgraph "第零层:独立基础设施"
core["octos-core<br/><i>1,793 行 · 核心类型</i>"]
plugin["octos-plugin<br/><i>1,293 行 · 插件 SDK</i>"]
sandbox["octos-sandbox<br/><i>162 行 · Windows 沙箱</i>"]
end
subgraph "第一层:领域服务"
llm["octos-llm<br/><i>15,728 行 · LLM 抽象</i>"]
memory["octos-memory<br/><i>1,748 行 · 混合搜索</i>"]
bus["octos-bus<br/><i>19,634 行 · 消息总线</i>"]
end
subgraph "第二层:运行时引擎"
agent["octos-agent<br/><i>34,968 行 · Agent 运行时</i>"]
pipeline["octos-pipeline<br/><i>9,137 行 · 工作流引擎</i>"]
end
subgraph "第三层:用户入口"
cli["octos-cli<br/><i>40,746 行 · CLI / Web / Gateway</i>"]
end
llm --> core
memory --> core
bus --> core
agent --> core
agent --> llm
agent --> memory
pipeline --> core
pipeline --> agent
pipeline --> llm
pipeline --> memory
cli --> core
cli --> agent
cli --> llm
cli --> memory
cli --> pipeline
cli --> bus
图 1-1:octos workspace 依赖拓扑。 箭头方向为“依赖于“,即上层依赖下层。octos-cli 对 octos-bus 是硬依赖,但 bus 内部的各频道集成(Telegram、Discord 等)通过 feature flags 按需启用。注意 octos-plugin 和 octos-sandbox 是独立的,不依赖也不被核心 crate 依赖——octos-agent 直接包含插件加载逻辑,而非依赖 octos-plugin crate。
1.3.3 代码规模一览
| Crate | 代码行数 | 占比 | 核心职责 |
|---|---|---|---|
| octos-cli | 40,746 | 30.6% | 三种运行模式 + Web UI + REST API |
| octos-agent | 34,968 | 26.3% | Agent 主循环 + 工具系统 + 安全策略 |
| octos-bus | 19,634 | 14.8% | 14 频道集成 + 会话管理 |
| octos-llm | 15,728 | 11.8% | 多 Provider 抽象 + 容错 + 流式 |
| octos-pipeline | 9,137 | 6.9% | DOT 工作流引擎 |
| octos-core | 1,793 | 1.3% | 核心类型定义 |
| octos-memory | 1,748 | 1.3% | 嵌入式混合搜索 |
| octos-plugin | 1,293 | 1.0% | 插件发现与门控 |
| octos-sandbox | 162 | 0.1% | Windows 沙箱 |
| app-skills + platform-skills | ~7,961 | 6.0% | 10 个 skill 二进制程序 |
| 合计 | 133,170 | 100% | 9 核心 crate + 10 个 skill 程序 |
表 1-1:octos 代码规模分布。 总计 133,170 行 Rust 代码,287 个源文件。
除 9 个核心 crate 外,octos 还包含两类 skill 二进制程序:
- app-skills(9 个):应用级能力——新闻聚合、深度搜索、深度爬虫、邮件发送、账号管理、时间查询、天气查询、微信桥接、Pipeline 审批。每个 skill 是一个独立的二进制程序,通过 stdin/stdout JSON 协议与 Agent 交互。
- platform-skills(1 个):平台级能力——voice skill,提供 Apple Silicon 上的 ASR/TTS 模型管理。
工程决策侧栏:Mono-repo vs Multi-repo
octos 选择了 Cargo workspace(mono-repo)而非将每个 crate 发布为独立仓库。这个决策值得展开分析。
方案一:Multi-repo(每个 crate 独立仓库)
优势:
- 每个 crate 可以独立发布到 crates.io,其他项目可以按需引用
- 各 crate 有独立的 issue tracker 和 CI pipeline
- 权限可以按仓库粒度控制
劣势:
- 跨 crate 的重构变成多仓库协调,一个类型改名需要按依赖顺序发布 5+ 个 crate
- 版本兼容性噩梦:octos-agent v0.3 依赖 octos-core v0.2,但 octos-cli v0.4 依赖 octos-core v0.3,导致菱形依赖
- CI 测试无法原子性地验证跨 crate 变更
方案二:Mono-repo + Cargo workspace
优势:
- 跨 crate 重构是一个 commit、一个 PR,原子性保证
- 所有 crate 共享统一的依赖版本(
[workspace.dependencies]),消除版本碎片化- 一次
cargo test --workspace验证全部 9 个 crate 的兼容性- workspace 级别的 lint 配置(如
deny(unsafe_code))自动应用到所有 crate劣势:
- 仓库体积随时间增长
- 不适合外部用户单独引用某个 crate
octos 的选择:workspace,原因有三。
第一,octos 的 9 个核心 crate 高度耦合——octos-agent 同时依赖 octos-core、octos-llm、octos-memory,任何一个核心类型的变更都会波及多个 crate。multi-repo 模式下,一个
Message类型的字段变更可能需要按 core → llm → memory → agent → pipeline → cli 的顺序发布 6 个版本,每个版本都需要等上游发布后才能开始。在 workspace 中,这是一个 commit。第二,
[workspace.dependencies](Cargo.toml:36-50)确保所有 crate 使用完全相同版本的 tokio、serde、reqwest 等关键依赖,避免了同一个程序中链接多个版本的运行时。第三,workspace 级别的
[workspace.lints.rust](Cargo.toml:33)让deny(unsafe_code)策略自动覆盖所有 crate,无需在每个 crate 的lib.rs中重复声明。这确保了安全策略的一致性——不会有某个 crate 遗漏了这个约束。
1.4 本章回顾
本章从三个维度阐述了 octos 的设计基础:
-
问题空间:多租户 AI Agent 平台面临安全隔离、并发控制、性能预算三大相互纠缠的挑战。这三个约束的同时存在决定了语言选型不是品味问题。
-
语言选型:Rust 在安全性(
deny(unsafe_code)+ 所有权系统)、并发模型(Send/Sync编译期保证)、性能(无 GC、确定性延迟)三个维度上最适合这组约束。代价是更陡峭的学习曲线和更长的编译时间。 -
Workspace 拓扑:9 个核心 crate 分为四层——独立基础设施(core/plugin/sandbox)→ 领域服务(llm/memory/bus)→ 运行时引擎(agent/pipeline)→ 用户入口(cli)。依赖方向严格从上到下,各频道集成通过 feature flags 按需启用。
从下一章开始,我们将自底向上,从 octos-core 的类型系统出发,逐层深入每个 crate 的设计与实现。
延伸阅读
- Rust 所有权系统:The Rust Programming Language 第 4 章 “Understanding Ownership”,https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html
- Cargo Workspace:Cargo 官方文档 “Workspaces” 章节,https://doc.rust-lang.org/cargo/reference/workspaces.html
- Tokio 异步运行时:Tokio 官方教程,https://tokio.rs/tokio/tutorial
- DDIA 设计哲学:Martin Kleppmann, Designing Data-Intensive Applications(O’Reilly, 2017)——本书的写作风格参考了 DDIA 的“先讲问题,再讲方案“叙事结构
- AI Agent 安全:OWASP Top 10 for LLM Applications,https://owasp.org/www-project-top-10-for-large-language-model-applications/
思考题
-
安全隔离的边界:如果你正在设计一个多租户 Agent 平台,你会选择进程级隔离还是容器级隔离?各自的性能和安全 trade-off 是什么?
-
GC vs 无 GC 的真实影响:本章提到 Rust 无 GC 带来确定性延迟。但在 AI Agent 场景中,LLM API 调用延迟(1-10 秒)远大于 GC 停顿(毫秒级)。在什么情况下 GC 停顿会成为真正的问题?(提示:考虑多租户、高并发、SSE 流式转发场景。)
-
Workspace 设计练习:假设你要为 octos 添加一个新的存储后端(比如 PostgreSQL 替代 redb),你会把它放在哪个现有 crate 中,还是创建一个新的 crate?为什么?
-
语言选型反思:如果 octos 不需要多租户支持(只服务单个用户),语言选型的结论会改变吗?哪些约束会松弛,哪些仍然重要?
版本演化说明 本章分析基于 octos v0.1.0(workspace 定义见
Cargo.toml,edition = “2024”,rust-version = “1.85.0”)。截至本书写作时,9 个核心 crate 的分层拓扑结构无重大变化。
第 2 章:octos-core:用类型系统定义领域语言
定位:本章深入 octos 最底层的 crate——octos-core(约 1,800 行),展示如何用 Rust 类型系统构建 AI Agent 平台的领域语言。前置依赖:第 1 章。适用场景:想理解 octos 类型基础的所有读者,尤其是希望通过实战项目学习 Rust 枚举和错误处理设计的读者(读者 A),以及想了解“零依赖 core crate“设计哲学的资深开发者(读者 B)。
如果把 octos 比作一座城市,octos-core 就是它的语言——不是建筑、不是道路,而是居民用来交流的词汇和语法。Task、Message、MessageRole、Error——这些类型定义了系统中所有组件如何描述自己的状态和意图。octos 的其他 8 个 crate 都依赖这些类型,但 octos-core 本身不依赖 workspace 中的任何其他 crate。
这个零依赖约束不是偶然的。它是一个刻意的架构决策,确保领域语言的稳定性:当 octos-llm 重构 Provider 实现或 octos-bus 新增频道时,核心类型不需要改变。本章将从 Task 状态机开始,逐个解析这些基础类型的设计,最后讨论这个零依赖策略的工程意义。
2.1 Task 状态机:用枚举编码合法状态
每个 AI Agent 执行的工作在 octos 中被建模为一个 Task。Task 是整个系统的工作单元——从“帮我写一段代码“到“审查这个 diff“,所有用户请求最终都被转化为 Task。
2.1.1 Task 结构体
Task 的定义位于 crates/octos-core/src/task.rs:11-29:
#![allow(unused)]
fn main() {
pub struct Task {
pub id: TaskId, // UUID v7,自带时间排序
pub parent_id: Option<TaskId>, // 子任务层级
pub status: TaskStatus, // 当前状态
pub kind: TaskKind, // 任务类型
pub context: TaskContext, // 执行上下文
pub result: Option<TaskResult>, // 完成后的结果
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
}
几个值得注意的设计选择:
TaskId 使用 UUID v7 而非 v4(crates/octos-core/src/types.rs:14)。UUID v4 是纯随机的,而 v7 的前 48 位编码了毫秒级时间戳。这意味着 TaskId 天然按创建时间排序——在调试和日志分析中,你可以直接通过 ID 判断任务的先后顺序,无需额外查询 created_at 字段。
parent_id: Option<TaskId> 支持任务层级。当一个复杂任务需要分解为多个子任务时(例如,Pipeline 中的多步骤执行),每个子任务通过 parent_id 指向父任务,形成树状结构。
result: Option<TaskResult> 只在 Task 完成后存在。这利用了 Rust 的 Option 类型在编译期强制调用者处理“结果可能不存在“的情况——你无法在未检查的情况下访问一个 Pending 状态 Task 的结果。
2.1.2 TaskStatus:编译期防止非法转换
TaskStatus 是一个带数据的枚举(crates/octos-core/src/task.rs:63-77):
#![allow(unused)]
fn main() {
pub enum TaskStatus {
Pending,
InProgress { agent_id: AgentId },
Blocked { reason: String },
Completed,
Failed { error: String },
}
}
注意 InProgress 变体携带 agent_id——这不只是状态标记,还记录了谁在执行这个任务。同样,Blocked 携带阻塞原因,Failed 携带错误信息。这种“状态 + 上下文数据“的组合是 Rust 枚举相比其他语言的 enum(如 Go 的 iota 常量或 Java 的传统 enum)的核心优势。
在 Python 中,你可能会用一个字符串字段 status: str 加上几个可选字段 agent_id: Optional[str]、error: Optional[str] 来表达同样的语义。但这种设计允许非法状态——比如一个 status="pending" 但 agent_id="agent-1" 的 Task,或者 status="completed" 但 error="something failed" 的 Task。Rust 的枚举让这些状态在类型层面就不可能存在。
2.1.3 状态转换图
Task 的合法状态转换如下:
stateDiagram-v2
[*] --> Pending: 创建
Pending --> InProgress: 分配给 Agent
InProgress --> Blocked: 等待外部资源
InProgress --> Completed: 执行成功
InProgress --> Failed: 执行失败
Blocked --> InProgress: 阻塞解除
Blocked --> Failed: 超时或取消
图 2-1:Task 状态机。 每个状态转换对应一个明确的业务事件。注意 Pending 只能转向 InProgress(不能直接跳到 Completed),InProgress 是唯一可以到达 Completed 的路径——这确保了每个完成的任务都经历过执行阶段。
2.1.4 TaskKind:五种任务类型
TaskKind 定义了五种工作类型(crates/octos-core/src/task.rs:79-99):
#![allow(unused)]
fn main() {
pub enum TaskKind {
Plan { goal: String },
Code { instruction: String, files: Vec<PathBuf> },
Review { diff: String },
Test { command: String },
Custom { name: String, params: serde_json::Value },
}
}
前四种是预定义的常见场景:规划(Plan)、编码(Code)、审查(Review)、测试(Test)。第五种 Custom 是扩展点——通过 name 标识任务类型,params 携带任意 JSON 数据。这种“有限预定义 + 开放扩展“的模式在 octos 中反复出现(详见第 6 章工具系统和第 9 章扩展机制)。
2.1.5 TaskContext 与 TaskResult
TaskContext(crates/octos-core/src/task.rs:102-115)是任务执行时的环境快照:
#![allow(unused)]
fn main() {
pub struct TaskContext {
pub working_dir: PathBuf,
pub git_state: Option<GitState>, // 分支、未提交变更、HEAD commit
pub working_memory: Vec<Message>, // 近期对话轮次
pub episodic_refs: Vec<EpisodeRef>, // 过往 episode 引用
pub files_in_scope: Vec<PathBuf>,
}
}
working_memory 和 episodic_refs 的区别值得关注:working_memory 是当前会话的短期记忆(最近几轮对话),而 episodic_refs 是从长期记忆中检索出的相关片段(详见第 4 章)。这种双记忆架构模仿了人类的工作记忆(working memory)和情景记忆(episodic memory)的区分。
TaskResult(crates/octos-core/src/task.rs:126-138)记录任务的产出:
#![allow(unused)]
fn main() {
pub struct TaskResult {
pub success: bool,
pub output: String,
pub files_modified: Vec<PathBuf>,
pub subtasks: Vec<TaskId>,
pub token_usage: TokenUsage,
}
}
TokenUsage(crates/octos-core/src/task.rs:141-154)值得特别关注。它不只追踪 input/output tokens,还包含 reasoning_tokens(思维链 token,用于 o1、kimi-k2.5 等推理模型)和 cache_read_tokens/cache_write_tokens(Provider 缓存命中/写入)。这五个维度让上层可以精确计算成本和优化缓存策略。序列化时,为零的字段会被跳过,避免 JSON 膨胀。
2.2 Message 与 MessageRole:跨 Provider 的统一抽象
AI Agent 平台需要对接多个 LLM Provider——Anthropic 的 Claude、OpenAI 的 GPT-4、Google 的 Gemini、本地的 Ollama。每个 Provider 的 API 对消息角色的命名和语义略有不同。octos-core 定义了统一的 Message 和 MessageRole 类型,作为所有 Provider 的公约数。
2.2.1 Message 结构体
Message 的定义位于 crates/octos-core/src/types.rs:55-70:
#![allow(unused)]
fn main() {
pub struct Message {
pub role: MessageRole,
pub content: String,
pub media: Vec<String>, // 图片/音频文件路径
pub tool_calls: Option<Vec<ToolCall>>, // LLM 请求的工具调用
pub tool_call_id: Option<String>, // 工具响应对应的调用 ID
pub reasoning_content: Option<String>, // 思维链内容
pub timestamp: DateTime<Utc>,
}
}
media 字段(types.rs:61)支持多模态:当用户发送图片或语音时,文件路径存储在这里,由 octos-llm 在构建 API 请求时转换为对应 Provider 的格式(base64 编码或 URL 引用)。序列化时,空的 media 向量会被跳过。
reasoning_content(types.rs:68)是为推理模型(如 OpenAI o1、Kimi k2.5)设计的——这些模型会先输出一段内部推理过程,然后才给出最终回答。将推理内容与正式回答分离存储,让上层可以选择是否展示思维链。
2.2.2 MessageRole:as_str() 与 Display 的双重实现
MessageRole 只有四个变体(crates/octos-core/src/types.rs:113-120):
#![allow(unused)]
fn main() {
pub enum MessageRole {
System,
User,
Assistant,
Tool,
}
}
关键在于它的两个方法实现。as_str()(types.rs:124-131)返回 &'static str:
#![allow(unused)]
fn main() {
impl MessageRole {
pub fn as_str(self) -> &'static str {
match self {
Self::System => "system",
Self::User => "user",
Self::Assistant => "assistant",
Self::Tool => "tool",
}
}
}
}
Display trait 实现(types.rs:134-138)直接委托给 as_str():
#![allow(unused)]
fn main() {
impl fmt::Display for MessageRole {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
}
为什么要同时实现这两个?因为它们服务于不同场景:
as_str()接受self(按值传递),返回&'static str。这是可行的因为MessageRole实现了Copytrait——枚举只有四个无数据变体,拷贝成本等同于拷贝一个字节。按值传递用于需要零分配的场景——比如构建 API 请求时设置 JSON 字段值。Display用于格式化字符串(format!()、println!()等),是 Rust 生态的标准接口。
这种模式确保了跨 Provider 的一致性:无论是发送给 Anthropic 还是 OpenAI,消息角色始终序列化为 "system"、"user"、"assistant"、"tool" 这四个小写字符串。各 Provider 的差异(比如 Anthropic 的 system message 是独立字段而非消息数组的一部分)在 octos-llm 中处理,不会泄漏到核心类型层。
2.2.3 ToolCall 与元数据扩展
ToolCall(crates/octos-core/src/types.rs:140-148)是 LLM 请求调用工具时的数据结构:
#![allow(unused)]
fn main() {
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: serde_json::Value,
pub metadata: Option<serde_json::Value>,
}
}
metadata 字段(types.rs:145)是为 Provider 特定数据预留的扩展点。例如,Google Gemini 的工具调用会携带 thought_signature 字段,用于验证工具调用是否来自模型的推理过程。通过 Option<Value> 存储这些异构数据,核心类型无需为每个 Provider 添加特定字段。
2.2.4 便捷构造函数
Message 提供了三个便捷构造函数(types.rs:73-111):
#![allow(unused)]
fn main() {
impl Message {
pub fn user(content: impl Into<String>) -> Self { /* ... */ }
pub fn assistant(content: impl Into<String>) -> Self { /* ... */ }
pub fn system(content: impl Into<String>) -> Self { /* ... */ }
}
}
注意参数类型是 impl Into<String> 而非 String 或 &str。这让调用者可以传入 String、&str、甚至 Cow<str>,编译器会自动选择最高效的转换路径。这是 Rust 中常见的 API 设计模式——通过泛型减少调用者的类型转换负担。
2.3 Error 设计:为什么选 eyre 而不是 anyhow
Rust 生态中有两个主流的错误处理库:anyhow 和 eyre。它们都提供类型擦除的错误报告(anyhow::Error / eyre::Report),但 octos 选择了 eyre/color-eyre。这个选择值得深入分析。
2.3.1 octos 的错误类型
octos-core 的 Error 定义在 crates/octos-core/src/error.rs:10-17:
#![allow(unused)]
fn main() {
pub struct Error {
pub kind: ErrorKind,
pub context: Option<String>,
pub suggestion: Option<String>,
}
}
这里的关键设计是三层结构:kind 分类错误类型,context 添加执行上下文,suggestion 提供可操作的修复建议。
ErrorKind 是一个 15 变体的枚举(error.rs:20-56),覆盖了系统中所有错误类别:
#![allow(unused)]
fn main() {
pub enum ErrorKind {
TaskNotFound(String),
AgentNotFound(String),
InvalidStateTransition { from: String, to: String },
LlmError { provider: String, message: String },
ApiError { provider: String, status: u16, body: String },
ToolError { tool: String, message: String },
ConfigError(String),
ApiKeyNotSet { provider: String, env_var: String },
UnknownProvider(String),
Timeout { operation: String, seconds: u64 },
ChannelError { channel: String, message: String },
SessionError(String),
IoError(std::io::Error),
SerializationError(String),
Other(eyre::Report),
}
}
注意最后一个变体 Other(eyre::Report)——这里使用了 eyre::Report 而非 anyhow::Error。
2.3.2 eyre vs anyhow:选型理由
anyhow 和 eyre 的核心 API 几乎相同,但有两个关键差异:
差异一:自定义错误报告器。 eyre 支持通过 eyre::set_hook() 安装自定义的错误报告器。color-eyre 就是这样一个报告器——它在错误发生时自动捕获 std::backtrace::Backtrace 和 tracing_error::SpanTrace,并以彩色格式输出。对于一个 CLI 工具来说,当 Agent 执行失败时,开发者能立即看到彩色高亮的错误链和调用栈,这比 anyhow 的纯文本输出提供了更好的调试体验。
差异二:生态对齐。 octos 的 workspace 依赖声明中同时使用了 eyre 和 color-eyre(Cargo.toml:71-72)。这是因为 color-eyre 在 CLI 入口初始化(main() 中调用 color_eyre::install()),而 eyre::Report 作为通用错误类型在整个代码库中使用。如果混用 anyhow::Error 和 eyre::Report,需要在边界处做转换,增加不必要的复杂度。
2.3.3 可操作的错误消息
octos 的错误设计最值得学习的不是库的选择,而是“让错误消息可操作“的理念。看几个便捷构造函数的实现(error.rs:80-173):
#![allow(unused)]
fn main() {
pub fn api_key_not_set(provider: impl Into<String>, env_var: impl Into<String>) -> Self {
Self {
kind: ErrorKind::ApiKeyNotSet {
provider: provider.to_string(),
env_var: env_var.to_string(),
},
context: None,
suggestion: Some(format!(
"Set the {} environment variable or configure it in config.json",
env_var
)),
}
}
}
当用户忘记设置 API Key 时,错误消息不只告诉你“key 没设“,还告诉你“设置 ANTHROPIC_API_KEY 环境变量,或在 config.json 中配置“。同样,api_error() 会根据 HTTP 状态码给出不同的建议——401 提示检查 key,429 提示被限流,504 提示超时。
Display 实现(error.rs:175-228)将这三层信息格式化为用户友好的输出,并使用 truncated_utf8() 安全截断过长的 API 响应体,避免错误日志中出现巨大的 JSON dump。
2.4 truncate_utf8:一个小函数背后的 UTF-8 安全哲学
truncate_utf8 是 octos-core 中最小但最精巧的函数之一。它只有 10 行代码(crates/octos-core/src/utils.rs:6-16),却解决了一个在多语言 AI 应用中极其常见的问题:如何安全地截断可能包含中文、日文、emoji 等多字节字符的字符串。
2.4.1 问题:UTF-8 的多字节陷阱
UTF-8 是一种变长编码:ASCII 字符占 1 字节,中文字符占 3 字节,emoji 占 4 字节。当你需要将字符串截断到 N 个字节时,截断点可能正好落在一个多字节字符的中间。
"你好世界" 的 UTF-8 编码:
你 = [E4 BD A0] (3 bytes)
好 = [E5 A5 BD] (3 bytes)
世 = [E4 B8 96] (3 bytes)
界 = [E7 95 8C] (3 bytes)
总计 12 bytes
如果截断到 7 bytes:
[E4 BD A0] [E5 A5 BD] [E4] ← 最后一个字节是 "世" 的第一个字节
这不是一个合法的 UTF-8 序列!
在 C/C++ 中,这种截断会产生无效的 UTF-8 字符串,可能导致下游解析崩溃。Python 的 str[:7] 按字符而非字节截断,避免了这个问题但无法精确控制字节预算。
2.4.2 两个变体:in-place 与 copying
octos 提供了两个截断函数:
truncate_utf8(utils.rs:6-16)——原地截断,修改原字符串:
#![allow(unused)]
fn main() {
pub fn truncate_utf8(s: &mut String, max_len: usize, suffix: &str) {
if s.len() <= max_len {
return;
}
let mut limit = max_len;
while limit > 0 && !s.is_char_boundary(limit) {
limit -= 1;
}
s.truncate(limit);
s.push_str(suffix);
}
}
truncated_utf8(utils.rs:21-30)——返回新字符串,不修改原始数据:
#![allow(unused)]
fn main() {
pub fn truncated_utf8(s: &str, max_len: usize, suffix: &str) -> String {
if s.len() <= max_len {
return s.to_string();
}
let mut limit = max_len;
while limit > 0 && !s.is_char_boundary(limit) {
limit -= 1;
}
format!("{}{}", &s[..limit], suffix)
}
}
核心算法相同:从 max_len 位置向前回退,直到找到一个合法的 UTF-8 字符边界(is_char_boundary())。截断后追加 suffix。注意这两个函数的 max_len 不包含 suffix 的长度——追加 suffix 后最终字符串可能超过 max_len。这是有意的设计:max_len 控制的是保留内容的上限,suffix 是额外的标记。调用者需要在设置 max_len 时预留 suffix 的空间。
两个变体的区别在于所有权语义:
truncate_utf8接受&mut String,原地修改,零额外分配(除了 suffix 追加)truncated_utf8接受&str(不可变引用),返回新String,需要一次堆分配
调用者根据场景选择:如果拥有字符串所有权且不再需要原始内容,用 in-place 版本;如果字符串是借用的(比如来自 API 响应的 &str),用 copying 版本。
2.4.3 truncate_head_tail:保留首尾的智能截断
对于工具输出和错误消息,仅保留开头往往不够——尾部的错误信息或结论同样重要。truncate_head_tail(utils.rs:37-70)解决了这个问题:
#![allow(unused)]
fn main() {
pub fn truncate_head_tail(s: &str, max_len: usize, head_ratio: f32) -> String
}
它按 head_ratio(默认 0.5,钳位到 [0.1, 0.9])分配头部和尾部的字节预算,中间用 \n\n... [N bytes omitted] ...\n\n 连接。两端的截断点都通过 is_char_boundary() 保证 UTF-8 安全。
这个函数在 octos 的多个场景中使用:
- 工具输出截断(Shell 命令的 stdout/stderr)
- 错误消息中的 API 响应体截断(
error.rs) - 上下文压缩时的消息摘要(详见第 8 章)
2.4.4 tool_output_limit:按工具类型定制的截断策略
tool_output_limit(utils.rs:73-85)为不同工具设置了不同的字符上限:
| 工具 | 上限 | 理由 |
|---|---|---|
read_file | 50,000 | 源码文件可能很大 |
shell, grep | 30,000 | 命令输出通常更精简 |
web_fetch | 40,000 | 网页内容适中 |
web_search | 20,000 | 搜索结果是摘要 |
deep_search, deep_research, spawn | 50,000 | 深度任务需要更多上下文 |
| 默认 | 50,000 | 安全上限 |
这些限制不是任意的——它们反映了不同工具输出的信息密度差异。搜索结果的信息密度高(每条结果都是有用的摘要),所以 20,000 字符足够;而源码文件的信息分布不均(可能需要看到完整的函数体),所以给 50,000 字符。这些限制与 LLM 的上下文窗口预算配合使用(详见第 8 章上下文管理)。
2.5 SessionKey:多租户会话标识的设计
SessionKey(crates/octos-core/src/types.rs:159-236)是多租户系统中会话路由的关键。它的设计演进反映了从单频道到多频道、从单 Profile 到多 Profile 的需求扩展。
2.5.1 格式演变
SessionKey 支持四种格式,向后兼容:
| 格式 | 示例 | 场景 |
|---|---|---|
channel:chat_id | telegram:12345 | 基础:单 Profile |
profile:channel:chat_id | work:telegram:12345 | 多 Profile 隔离 |
channel:chat_id#topic | telegram:12345#research | 同一会话的多主题 |
profile:channel:chat_id#topic | work:telegram:12345#research | 完整形式 |
base_key() 方法(types.rs:195)返回去掉 #topic 后缀的部分,用于会话持久化(同一个 base_key 的不同 topic 共享持久化文件)。topic() 方法(types.rs:200)提取主题后缀。
2.5.2 频道验证
SessionKey 的构造函数通过 is_valid_channel()(types.rs:238-257)验证频道名称是否在白名单中。这个白名单包含 15 个已知频道,覆盖了 octos 支持的所有集成:
api, cli, discord, email, feishu, matrix, qq-bot, slack,
system, telegram, test, twilio, wecom, wecom-bot, whatsapp
验证是在构造时进行的(而非使用时),这是 Rust 类型系统的常见模式——“让无效状态不可构造”。一旦你持有一个 SessionKey,就可以确信它的频道名是合法的。
2.6 AgentMessage:Agent 间协调协议
AgentMessage(crates/octos-core/src/message.rs:10-29)定义了 Agent 之间的协调协议:
#![allow(unused)]
fn main() {
pub enum AgentMessage {
TaskAssign { task: Box<Task> },
TaskUpdate { task_id: TaskId, status: TaskStatus },
TaskComplete { task_id: TaskId, result: TaskResult },
ContextRequest { task_id: TaskId, query: String },
ContextResponse { task_id: TaskId, context: Vec<Message> },
}
}
五种消息类型涵盖了 Agent 协调的核心场景:分配任务、更新状态、完成通知、请求上下文、返回上下文。注意 TaskAssign 中的 Box<Task>——Task 结构体较大,使用 Box 堆分配避免了枚举变体之间的大小不均导致的内存浪费。
task_id() 方法(message.rs:31-42)返回 Option<&TaskId>,为所有变体提供统一的任务 ID 访问接口。调用者无需对每个变体做模式匹配就能获取关联的任务 ID——只需处理 Option 即可。
2.7 abort:多语言中断检测
一个有趣的小模块:abort.rs 实现了多语言的 Agent 中断检测。当用户在 Agent 执行过程中发送“停“、“stop”、“やめて”、“стоп“等中断信号时,系统需要立即识别并终止当前操作。
ABORT_TRIGGERS 数组(abort.rs:32-71)包含 9 种语言、30 个触发词。is_abort_trigger()(abort.rs:6-13)对输入进行 trim + lowercase 后精确匹配。abort_response()(abort.rs:15-30)返回与触发语言匹配的本地化取消确认。
值得注意的是故意排除的词:代码注释中记录了 “wait”、“exit”、“para” 等被排除的词——它们在正常对话中出现频率太高,会导致误判。这是一个务实的设计选择:宁可漏掉一些中断信号(用户可以再说一次),也不要在正常对话中误触发中断。
工程决策侧栏:为什么 core crate 零内部依赖
octos-core 是 workspace 中唯一没有依赖其他 octos crate 的基础 crate(octos-plugin 和 octos-sandbox 也无内部依赖,但它们不被其他 crate 依赖)。这个“零内部依赖“约束是刻意的设计选择。
方案一:胖 core(把更多逻辑下沉到 core)
优势:
- 所有公共逻辑集中在一处,减少 crate 间的重复
- 下游 crate 只需依赖 core 就能获得大部分能力
劣势:
- core 的编译时间随功能膨胀而增加,影响所有依赖它的 crate 的增量编译速度
- core 的变更频率增加,每次修改都触发全 workspace 重编译
- 不相关的功能被耦合——修改 LLM 相关逻辑可能影响 Task 类型
方案二:瘦 core(只放类型定义和最基础的工具函数)
优势:
- 极少变更,提供稳定的类型基础
- 编译快速(octos-core 仅 1,793 行)
- 依赖图清晰:所有 crate 依赖 core 的类型,但 core 不依赖任何人
劣势:
- 跨 crate 共享的逻辑需要放在其他地方(比如 octos-agent 中的工具函数)
- 可能出现“本应在 core 中“的类型被定义在上层 crate 的情况
octos 的选择:瘦 core,理由如下。
octos-core 的外部依赖仅限于
serde、serde_json、chrono、uuid、eyre这几个基础库——都是序列化、时间和错误处理的行业标准。这意味着 octos-core 的编译时间极短,而所有依赖它的 crate(octos-llm、octos-memory、octos-agent、octos-bus、octos-pipeline、octos-cli)都能从这个快速编译中获益。更重要的是稳定性保证。在 octos 的开发历程中,octos-llm 经历了多次 Provider 重构,octos-agent 的工具系统不断扩展,octos-bus 新增了多个频道——但 octos-core 的核心类型(Task、Message、Error)保持了高度稳定。瘦 core 策略使得这种稳定性成为可能。
2.8 本章回顾
octos-core 用 1,793 行代码定义了整个系统的领域语言:
-
Task 状态机:用 Rust 枚举编码合法状态和转换,在类型层面消除非法状态组合。UUID v7 提供时间排序,五维 TokenUsage 支持精细的成本追踪。
-
Message 抽象:四角色统一模型(System/User/Assistant/Tool)+
as_str()/Display双重实现,确保跨 Provider 的序列化一致性。metadata扩展点容纳 Provider 特定数据。 -
Error 设计:选择 eyre/color-eyre 获取彩色错误报告和 SpanTrace 支持。三层结构(kind + context + suggestion)让错误消息可操作。
-
UTF-8 安全工具:
truncate_utf8的两个变体(in-place 和 copying)通过is_char_boundary()保证截断安全。truncate_head_tail保留首尾信息。 -
零依赖设计:瘦 core 策略确保类型基础稳定、编译快速,支撑上层 crate 的独立演进。
下一章,我们将进入 octos-llm,看看这些核心类型如何被用来驯服多个 LLM Provider 的混乱接口。
延伸阅读
- Rust 枚举与模式匹配:The Rust Programming Language 第 6 章 “Enums and Pattern Matching”,https://doc.rust-lang.org/book/ch06-00-enums.html
- eyre 错误处理:eyre crate 文档,https://docs.rs/eyre/latest/eyre/
- color-eyre:color-eyre crate 文档,https://docs.rs/color-eyre/latest/color_eyre/
- UUID v7 规范:RFC 9562 “Universally Unique IDentifiers (UUIDs)”,Section 5.7
- UTF-8 编码:The Unicode Standard Chapter 3 “Conformance”——理解 UTF-8 变长编码对安全截断至关重要
思考题
-
状态机扩展:如果要为 Task 添加一个
Cancelled状态(用户主动取消),它应该从哪些状态可达?添加这个状态会对现有的match表达式产生什么影响? -
胖 core vs 瘦 core:假设 octos-core 把
LlmProvidertrait 也放进来(因为所有上层 crate 都需要它),会带来什么问题?提示:考虑async-trait、reqwest等依赖的传递效应。 -
错误设计权衡:octos 的
ErrorKind有 15 个变体。如果系统继续增长到 50 个变体,这种设计会遇到什么问题?你会如何重构? -
截断策略的替代方案:
truncate_utf8按字节截断并回退到字符边界。另一种方案是按 Unicode 字素簇(grapheme cluster)截断。两种方案在处理 emoji 组合序列(如 👨👩👧👦)时有什么区别?哪种更适合 LLM 上下文管理场景?
版本演化说明 本章分析基于 octos v0.1.0,octos-core crate 位于
crates/octos-core/src/。截至本书写作时,核心类型(Task、Message、Error)的结构无重大变化。TokenUsage 在后续版本中可能新增追踪维度(如 reasoning_tokens 的细分)。
第 3 章:octos-llm:驯服 LLM Provider 的混乱
定位:本章深入 octos-llm crate(约 15,700 行),展示如何用 Rust trait 抽象统一多种 LLM Provider 的混乱接口,以及如何构建三层容错链实现生产级可靠性。前置依赖:第 2 章。适用场景:想理解多 Provider 架构设计的 AI 应用开发者(读者 C),以及对 trait object 和异步容错模式感兴趣的 Rust 开发者(读者 B)。
每个 LLM Provider 都有自己的 API 风格:Anthropic 把 system message 作为独立字段,OpenAI 把它放在消息数组里;Gemini 的工具调用格式与其他两家完全不同;Ollama 是本地部署,延迟特征和错误模式截然不同。当你需要支持 4 种原生协议和 10+ 种 OpenAI 兼容 Provider 时,混乱是不可避免的——除非你在正确的层次建立正确的抽象。
octos-llm 的解决方案分三层:底层的 LlmProvider trait 统一调用接口,中层的 Provider 注册表实现模型名自动检测和工厂创建,顶层的三级容错链(RetryProvider → ProviderChain → AdaptiveRouter)提供生产级可靠性。本章将自底向上逐层展开。
3.1 LlmProvider trait:最小化的统一接口
3.1.1 trait 签名
LlmProvider 的定义位于 crates/octos-llm/src/provider.rs:11-81:
#![allow(unused)]
fn main() {
#[async_trait]
pub trait LlmProvider: Send + Sync {
// 核心方法:非流式对话
async fn chat(
&self,
messages: &[Message],
tools: &[ToolSpec],
config: &ChatConfig,
) -> Result<ChatResponse>;
// 流式对话(有默认实现)
async fn chat_stream(
&self,
messages: &[Message],
tools: &[ToolSpec],
config: &ChatConfig,
) -> Result<ChatStream>;
// 元数据查询
fn context_window(&self) -> u32;
fn max_output_tokens(&self) -> u32;
fn model_id(&self) -> &str;
fn provider_name(&self) -> &str;
// 可选:指标上报
fn export_metrics(&self) -> Option<serde_json::Value> { None }
fn report_late_failure(&self) {}
fn report_stream_metrics(&self, _output_tokens: u32, _stream_duration_us: u64) {}
}
}
这个 trait 的设计遵循了“最小必要接口“原则(provider.rs:13 的注释明确说明了这一点):只定义所有 Provider 共同的能力,差异在各实现中处理。
几个值得关注的设计选择:
Send + Sync 约束。 trait 要求实现者是线程安全的,因为 Provider 实例会被多个异步任务通过 Arc 共享。这个约束在编译期保证了不会出现单线程 Provider 实现被意外用在多线程场景的错误。
chat_stream() 的默认实现。 不是所有 Provider 都原生支持流式响应。默认实现(provider.rs:32-49)调用非流式的 chat() 方法,然后将完整响应包装为一个单事件的合成流。这让新 Provider 只需实现 chat() 就能基本工作,流式支持可以后续优化。
指标上报方法。 export_metrics()、report_late_failure()、report_stream_metrics() 三个方法都有空的默认实现。它们为 AdaptiveRouter 的 EMA 评分系统提供数据源(见 3.4 节),但不强制所有 Provider 实现。这种“可选钩子“模式避免了 trait 膨胀。
3.1.2 核心数据类型
ChatConfig(crates/octos-llm/src/config.rs)封装了所有可调参数:
model: 模型 IDtemperature: 采样温度max_tokens: 最大输出 token 数system_prompt: 系统提示response_format: 响应格式约束(文本/JSON/结构化输出)tool_choice: 工具选择策略(auto/required/none/指定工具)
ChatResponse 包含 LLM 返回的完整信息:内容、stop reason、工具调用请求、token 使用量。ChatStream 是一个异步流(Pin<Box<dyn Stream<Item = Result<StreamEvent>>>>),逐事件产出流式响应。
3.2 Provider 注册表:模型名自动检测
当用户配置 model: "claude-sonnet-4" 时,octos 需要自动确定使用 Anthropic Provider。这个映射由 Provider 注册表实现(crates/octos-llm/src/registry/mod.rs)。
3.2.1 检测机制
每个 Provider 注册时声明自己的检测模式(registry/mod.rs:80):
#![allow(unused)]
fn main() {
struct ProviderEntry {
name: &'static str,
detect_patterns: &'static [&'static str],
// ...
}
}
detect_provider() 方法(registry/mod.rs:131-150)按优先级顺序遍历所有 Provider,检查模型名是否包含检测模式:
| Provider | 检测模式 | 匹配示例 |
|---|---|---|
| Anthropic | "claude" | claude-sonnet-4, claude-haiku-4-5 |
| OpenAI | "gpt", "chatgpt" | gpt-4o, gpt-4-turbo |
| Gemini | "gemini" | gemini-2.5-flash, gemini-2.5-pro |
| DeepSeek | "deepseek" | deepseek-chat, deepseek-coder |
| Groq | "groq" | groq-llama-3 |
| Ollama | "ollama" | ollama-llama3 |
| OpenRouter | "openrouter" | openrouter/meta-llama |
特殊处理:O 系列模型。 OpenAI 的 o1、o3、o4 系列需要前缀匹配而非子串匹配(registry/mod.rs:137-140),因为 “o1” 作为子串可能匹配到其他 Provider 的模型名中(如 ro1and 假设模型名)。
3.2.2 完整 Provider 注册表
octos 支持 15+ 个 Provider,按优先级排序检测(registry/mod.rs:89-105):
| 优先级 | Provider | 协议 | 检测模式 | 示例模型 |
|---|---|---|---|---|
| 1 | Anthropic | 原生 | claude | claude-sonnet-4, claude-haiku-4-5 |
| 2 | OpenAI | 原生 | gpt, chatgpt, o1/o3/o4(前缀) | gpt-4o, o4-mini |
| 3 | Gemini | 原生 | gemini | gemini-2.5-flash, gemini-2.5-pro |
| 4 | R9s (Azure) | OpenAI 兼容 | r9s | r9s-gpt-4 |
| 5 | OpenRouter | 元路由 | openrouter | openrouter/meta-llama |
| 6 | DeepSeek | OpenAI 兼容 | deepseek | deepseek-chat |
| 7 | Groq | OpenAI 兼容 | groq | groq-llama-3 |
| 8 | Moonshot | OpenAI 兼容 | moonshot | moonshot-v1 |
| 9 | Dashscope | OpenAI 兼容 | dashscope, qwen | qwen-max |
| 10 | Minimax | OpenAI 兼容 | minimax | minimax-abab6 |
| 11 | Zhipu | OpenAI 兼容 | zhipu, glm | glm-4 |
| 12 | Zai | OpenAI 兼容 | zai | zai-llama |
| 13 | NVIDIA | OpenAI 兼容 | nvidia | nvidia/llama-3 |
| 14 | Ollama | OpenAI 兼容 | ollama | ollama-llama3 |
| 15 | vLLM | OpenAI 兼容 | vllm | vllm-mistral |
4 种原生协议(Anthropic、OpenAI、Gemini、Ollama),其余 10+ 种通过 OpenAI 兼容 API 接入(只需不同的 base_url 和认证方式)。这种“4 原生 + N 兼容“架构让新 Provider 的接入成本极低——大多数情况只需在注册表中添加一个条目。
3.2.3 Provider 工厂
检测到 Provider 后,注册表通过工厂函数创建具体实例。每个工厂函数读取对应的环境变量(ANTHROPIC_API_KEY、OPENAI_API_KEY 等)或配置文件中的凭据,构造带有正确 base URL 和认证头的 HTTP 客户端。
工厂返回的类型是 Arc<dyn LlmProvider>——这是动态分发的关键点。注册表不知道(也不需要知道)具体的 Provider 类型,只知道它实现了 LlmProvider trait。这让上层代码可以用统一的方式处理所有 Provider,包括将它们放入容错链中。
3.3 三层容错链
生产环境中,LLM API 调用可能因为多种原因失败:速率限制(429)、服务器过载(503/529)、认证失效(401)、网络超时。octos-llm 用三层容错链处理这些故障,每一层解决不同级别的问题。
flowchart TD
Request["用户请求"] --> AR["AdaptiveRouter<br/>EMA 评分选择最优 Provider"]
AR --> PC1["ProviderChain #1<br/>带 Circuit Breaker"]
AR --> PC2["ProviderChain #2<br/>带 Circuit Breaker"]
AR -.->|"hedge racing"| PC2
PC1 --> RP1a["RetryProvider (Provider A)<br/>指数退避 429/5xx"]
PC1 -->|"failover"| RP1b["RetryProvider (Provider B)<br/>指数退避 429/5xx"]
PC2 --> RP2a["RetryProvider (Provider C)<br/>指数退避 429/5xx"]
RP1a --> LLM_A["Anthropic API"]
RP1b --> LLM_B["OpenAI API"]
RP2a --> LLM_C["Gemini API"]
style AR fill:#f9f,stroke:#333
style PC1 fill:#bbf,stroke:#333
style PC2 fill:#bbf,stroke:#333
style RP1a fill:#bfb,stroke:#333
style RP1b fill:#bfb,stroke:#333
style RP2a fill:#bfb,stroke:#333
图 3-1:三层容错链架构。 请求从 AdaptiveRouter 进入,经 ProviderChain 路由到具体 Provider,每个 Provider 包裹在 RetryProvider 中处理瞬时故障。
3.3.1 第一层:RetryProvider — 指数退避
RetryProvider(crates/octos-llm/src/retry.rs:40-226)处理单个 Provider 的瞬时故障。
退避算法(retry.rs:149-154):
#![allow(unused)]
fn main() {
fn calculate_delay(&self, attempt: u32) -> Duration {
let delay = self.config.initial_delay.as_secs_f64()
* self.config.backoff_multiplier.powi(attempt as i32);
let delay = Duration::from_secs_f64(delay);
std::cmp::min(delay, self.config.max_delay)
}
}
默认配置(retry.rs:28-37):最多重试 3 次,初始延迟 1 秒,退避乘数 2.0,最大延迟 60 秒。实际退避序列为 1s → 2s → 4s → 8s(但被 60s 上限钳位)。
哪些错误可重试?(retry.rs:107-147)
| HTTP 状态码 | 含义 | 是否重试 | 是否触发 failover |
|---|---|---|---|
| 429 | 速率限制 | 是(解析 retry-after) | 是 |
| 500, 502, 503 | 服务器错误 | 是 | 是 |
| 529 | 过载 | 是 | 是 |
| 401, 403 | 认证错误 | 否 | 是(立即 failover) |
| 504 | Gateway 超时 | 是(服务器可能恢复) | 是 |
| 408 | 请求超时 | 看具体情况 | 是 |
| reqwest timeout | 网络超时 | 否(本地不重试) | 是(立即 failover) |
| 400 | 请求错误 | 看具体消息 | 部分情况 |
注意 reqwest 级别的网络超时(连接超时、读超时)的特殊处理:不在本地重试(因为同一个 Provider 大概率还是超时),而是立即向上层触发 failover,让 ProviderChain 切换到另一个 Provider。HTTP 504(Gateway Timeout)则被视为可重试——服务器可能在短暂过载后恢复。
速率限制解析(retry.rs:159-185):当收到 429 响应时,RetryProvider 会尝试从错误消息中解析推荐的等待时间(如 “Please try again in 29.159s”),加上 1 秒缓冲后等待。如果无法解析,回退到 30 秒固定等待。
3.3.2 第二层:ProviderChain — 有序故障转移
ProviderChain(crates/octos-llm/src/failover.rs:36-249)管理一组 Provider 的故障转移顺序。
Circuit Breaker 设计(failover.rs:23-26):
#![allow(unused)]
fn main() {
struct ProviderSlot {
provider: Arc<dyn LlmProvider>,
failures: AtomicU32, // 连续失败计数器
}
}
每个 Provider 维护一个原子计数器记录连续失败次数。当失败次数达到阈值(默认 3),该 Provider 被标记为“降级“(degraded)。成功调用后计数器重置为 0(failover.rs:104)。
故障转移逻辑(failover.rs:85-99):
- 首先尝试第一个未降级的 Provider
- 如果所有 Provider 都降级了,选择失败次数最少的那个
- 跳过已降级的 Provider,除非它是最后的选择
延迟故障上报(failover.rs:245-248):report_late_failure() 处理一种微妙的场景——Provider 返回了 200 响应,但流式解析后发现内容为空或格式错误。这时需要回溯性地惩罚该 Provider,增加其失败计数,让后续请求优先选择其他 Provider。
3.3.3 第三层:AdaptiveRouter — EMA 评分与对冲竞赛
AdaptiveRouter(crates/octos-llm/src/adaptive.rs:470-1200+)是容错链的最高层,实现了智能路由。
三种模式(adaptive.rs:416-449):
- Off (0):静态优先级排序 + circuit breaker,最简单可靠
- Hedge (1):基于评分选择 + 对冲竞赛(hedge racing)
- Lane (2):基于评分的车道切换,比 hedge 更节省成本
EMA 评分系统
AdaptiveRouter 为每个 Provider 维护一个实时评分,基于四个因子的加权组合(adaptive.rs:886-951):
| 因子 | 权重 | 含义 | 数据来源 |
|---|---|---|---|
| 稳定性 (error_rate) | 30% | 错误率 | 实时统计 + 目录基线混合 |
| 质量 (latency) | 30% | 输出质量 | 60% 深度搜索 token 数 + 40% 吞吐量 |
| 优先级 (priority) | 20% | 配置顺序 | 用户配置 |
| 成本 (cost) | 20% | 价格 | 模型目录 |
混合权重设计(adaptive.rs:886-920):稳定性因子使用“目录基线 + 实时数据“的混合计算。混合权重按调用次数递增:min(total_calls / 20.0, 0.5),这意味着目录基线始终至少占 50% 的影响力。这个设计防止了“冷启动“问题——新 Provider 只有少量调用时,不会因为一两次偶然失败就被判为不可靠。
对冲竞赛(Hedge Racing)
在 Hedge 模式下,AdaptiveRouter 会同时向两个 Provider 发起请求,取先返回的结果(adaptive.rs:1059-1158):
#![allow(unused)]
fn main() {
// 简化后的逻辑
tokio::select! {
result = primary_future => {
// 主 Provider 先返回
// 备选 Provider 的 future 被 drop(取消)
result
}
result = alternate_future => {
// 备选 Provider 先返回
// 主 Provider 的 future 被 drop(取消)
result
}
}
}
备选 Provider 的选择(adaptive.rs:1086-1105)优先选最便宜的(减少冗余成本),且必须与主 Provider 不同名(避免向同一 API 发重复请求)。
对冲竞赛的代价是双倍的 API 调用成本(输掉竞赛的请求仍然消耗 token,即使被取消,Provider 通常已经开始处理)。因此 Hedge 模式适用于延迟敏感、成本不敏感的场景。Lane 模式则通过评分排序实现类似的路由优化,但不发送冗余请求。
探针策略(Probe)
为了保持备用 Provider 的评分数据新鲜,AdaptiveRouter 以一定概率(默认 10%)向非最优 Provider 发送“探针“请求(adaptive.rs:1013-1028),刷新其性能指标。探针间隔默认 60 秒,避免频繁探测带来的成本。
3.4 SSE 流式解析:字节安全的有状态解析器
LLM 的流式响应以 Server-Sent Events(SSE)协议传输。SSE 看似简单——每个事件以 \n\n 分隔,每行以 data: 前缀标记数据——但在生产环境中,有几个工程挑战需要解决。
3.4.1 为什么需要有状态解析
HTTP 响应的 body 以任意大小的字节块(chunk)到达。一个 SSE 事件可能跨越多个 chunk,一个 chunk 也可能包含多个事件。更微妙的是,chunk 的边界可能正好切开一个 UTF-8 多字节字符。
考虑以下场景:
Chunk 1: data: {"text": "任务完
Chunk 2: 成后请检查结果"}\n\n
“完” 和 “成” 之间不会有问题(它们各自是完整的 UTF-8 字符),但如果 chunk 边界恰好落在“完“字的三个字节中间:
Chunk 1: data: {"text": "任务\xe5\xae
Chunk 2: \x8c成后请检查结果"}\n\n
此时 Chunk 1 末尾的 \xe5\xae 是“完“字的前两个字节,不是合法的 UTF-8。如果逐 chunk 做 String::from_utf8(),就会得到一个解析错误或替换字符(U+FFFD)。
3.4.2 octos 的字节安全解析器
octos-llm 的 SSE 解析器(crates/octos-llm/src/sse.rs:21-72)采用字节级缓冲策略:
- 原始字节累积:将每个 chunk 的原始字节追加到
Vec<u8>缓冲区,不做 UTF-8 转换 - 事件边界检测:在原始字节中搜索
\n\n或\r\n\r\n分隔符 - 按事件转换:找到完整事件后,才将该事件的字节块转换为 UTF-8 字符串
- 剩余字节保留:未形成完整事件的尾部字节保留在缓冲区中
这种设计保证了 UTF-8 转换只发生在完整事件上——SSE 协议保证事件边界不会落在 UTF-8 字符中间(因为 \n 是 ASCII 单字节字符)。
解析器使用 stream::unfold() 构建为一个异步流,保持状态(字节流 + 缓冲区)在 yield 事件之间传递。
3.4.3 1MB 缓冲上限
安全考量:如果恶意或异常的 LLM Provider 发送一个永远不包含 \n\n 的超长响应,缓冲区会无限增长。MAX_BUFFER_SIZE(sse.rs:6-7)设为 1MB,超过后解析器发出错误事件并清空缓冲区。
#![allow(unused)]
fn main() {
const MAX_BUFFER_SIZE: usize = 1024 * 1024; // 1MB
}
1MB 对于单个 SSE 事件来说绰绰有余——正常的 LLM 流式响应中,每个事件通常只有几十到几百字节(一个 token 的 JSON 表示)。
3.4.4 UTF-8 分割测试:为什么字节级缓冲不可省略
sse.rs 的测试(sse.rs:261-281)构造了一个精确的多字节分割场景:
"完成后" 的 UTF-8 编码:
完 = [E5 AE 8C] (3 bytes)
成 = [E6 88 90] (3 bytes)
后 = [E5 90 8E] (3 bytes)
故意在"成"字中间切开:
Chunk 1: data: {"text": "完[E6 88 ← "成"的前 2 字节
Chunk 2: 90]后"}\n\n ← "成"的第 3 字节 + "后"
如果逐 chunk 做 String::from_utf8(),Chunk 1 末尾的 [E6 88] 不是合法 UTF-8——会被替换为 U+FFFD(替换字符),“成“字永久丢失。
字节级缓冲策略避免了这个问题:两个 chunk 的原始字节被拼接后,在 \n\n 边界整体转换,“完成后” 被正确重组。
这不是一个理论风险——当 LLM 流式输出中文回复时,每个 SSE 事件通常只包含 1-3 个 token。HTTP 的 chunked transfer encoding 可能在任何字节位置切割,与 token 边界无关。对于一个服务中文、日文、韩文用户的 Agent 平台,字节级缓冲是必需的而非优化。
3.5 模型目录与成本追踪
ModelCatalog(crates/octos-llm/src/catalog.rs:48-275)为每个已知模型维护元数据:
#![allow(unused)]
fn main() {
pub struct ModelInfo {
pub id: String,
pub name: String,
pub provider: String,
pub context_window: u32,
pub capabilities: ModelCapabilities, // vision, tool_use, streaming, reasoning
pub cost: ModelCost, // input/output/cache 每百万 token 价格
pub aliases: Vec<String>,
}
}
别名系统:除了完整的模型 ID(如 claude-sonnet-4-20250514),目录还支持别名查找(如 sonnet → claude-sonnet-4-20250514)。查找顺序(catalog.rs:72-74):精确 ID 匹配 → 别名匹配 → None。
成本追踪:ModelCost 记录输入、输出、缓存读取三种 token 类型的百万 token 价格。AdaptiveRouter 的评分系统使用这些数据计算成本因子(见 3.3.3 节),在延迟和成本之间做权衡。
工程决策侧栏:Arc<dyn Trait> vs 泛型 vs 枚举分发
octos-llm 在 Provider 抽象层大量使用
Arc<dyn LlmProvider>。这个选择值得与两种替代方案对比。方案一:泛型(
impl LlmProvider/T: LlmProvider)优势:
- 零运行时开销——编译器在每个调用点生成特化代码(单态化)
- 方法调用可被内联优化
劣势:
- RetryProvider、ProviderChain 等包装器需要泛型参数传染:
RetryProvider<T: LlmProvider>- 容错链的组合会产生类型爆炸:
AdaptiveRouter<ProviderChain<RetryProvider<AnthropicProvider>>, ProviderChain<RetryProvider<OpenAIProvider>>>- 无法在运行时基于用户配置动态选择 Provider——泛型在编译期就确定了具体类型
方案二:枚举分发(
enum Provider { Anthropic(...), OpenAI(...), ... })优势:
- 编译期确定所有变体,分支预测更友好
- 无 vtable 间接调用开销
劣势:
- 每增加一个 Provider 就需要修改枚举定义和所有 match 表达式
- 对于 15+ 个 Provider,match 块会非常庞大
- 无法支持用户自定义 Provider(除非用
Custom变体退化回 trait object)octos 的选择:
Arc<dyn LlmProvider>,原因如下。在 AI Agent 场景中,LLM 调用的网络延迟(100ms-10s)远大于 vtable 间接调用的开销(<1ns)。动态分发的性能代价在这里完全可以忽略。
更重要的是组合性。octos 的容错链本质上是装饰器模式的嵌套组合:RetryProvider 包装任意 Provider,ProviderChain 管理一组 Provider,AdaptiveRouter 在多个 Chain 之间路由。
Arc<dyn LlmProvider>让这些包装器可以自由组合,不受泛型参数的限制。最后,Provider 的种类在运行时才确定——用户通过配置文件指定使用哪些 Provider,注册表工厂根据配置动态创建实例。这种“运行时多态“正是 trait object 的核心使用场景。
3.6 本章回顾
octos-llm 用 15,728 行代码解决了 LLM Provider 集成的核心挑战:
-
LlmProvider trait:最小化的统一接口,
chat()+chat_stream()双方法设计,Send + Sync约束保证线程安全。默认实现让新 Provider 快速接入。 -
Provider 注册表:模型名子串匹配自动检测 Provider,工厂模式动态创建
Arc<dyn LlmProvider>实例。特殊处理 O 系列模型的前缀匹配。 -
三层容错链:
- RetryProvider:指数退避(1s→2s→4s),智能解析 429 响应的 retry-after 头
- ProviderChain:有序故障转移 + circuit breaker(3 次连续失败触发降级)
- AdaptiveRouter:四因子 EMA 评分(稳定性 30% + 质量 30% + 优先级 20% + 成本 20%)+ 对冲竞赛 + 探针策略
-
SSE 流式解析:字节级缓冲避免 UTF-8 分割问题,1MB 上限防止内存耗尽,
stream::unfold()构建有状态异步流。 -
Arc<dyn Trait>选择:网络延迟远大于 vtable 开销,动态分发换来的组合性和运行时灵活性物超所值。
下一章将进入 octos-memory,看看混合搜索(BM25 + HNSW 向量索引)如何为 Agent 提供长期记忆能力。
延伸阅读
- async-trait crate:https://docs.rs/async-trait/latest/async_trait/ — 了解
#[async_trait]宏如何将 async 方法编译为 trait object 兼容的形式 - SSE 协议规范:HTML Living Standard “Server-Sent Events” 章节,https://html.spec.whatwg.org/multipage/server-sent-events.html
- 指数退避算法:Google Cloud 的 “Truncated exponential backoff” 文档,https://cloud.google.com/storage/docs/exponential-backoff
- Circuit Breaker 模式:Martin Fowler, “CircuitBreaker”,https://martinfowler.com/bliki/CircuitBreaker.html
- Rust 动态分发:The Rust Programming Language 第 17 章 “Using Trait Objects That Allow for Values of Different Types”
思考题
-
容错层次设计:octos 的三层容错链中,如果把 RetryProvider 和 ProviderChain 合并为一层会怎样?分离的好处是什么?
-
对冲竞赛的成本模型:假设你有两个 Provider:Provider A 价格 $10/M tokens、平均延迟 500ms;Provider B 价格 $3/M tokens、平均延迟 1500ms。在什么条件下开启 hedge racing 是划算的?
-
SSE 解析器的替代方案:如果不用字节级缓冲,而是用
String::from_utf8_lossy()处理每个 chunk,会产生什么问题?在什么场景下这些问题会变得可观测? -
泛型 vs trait object 的边界:如果 octos 只需要支持 3 个 Provider(Anthropic、OpenAI、Gemini),枚举分发是否是更好的选择?支持多少个 Provider 时动态分发才开始胜出?
版本演化说明 本章分析基于 octos v0.1.0,octos-llm crate 位于
crates/octos-llm/src/。截至本书写作时,Provider 注册表的检测模式和 AdaptiveRouter 的评分权重可能随新 Provider 的加入而调整,但三层容错架构本身无重大变化。
第 4 章:octos-memory:混合搜索的工程实现
定位:本章深入 octos-memory crate(约 1,750 行),展示如何用纯 Rust 构建嵌入式 BM25 + HNSW 混合搜索引擎,为 Agent 提供长期记忆能力。前置依赖:第 2 章。适用场景:想理解 RAG(检索增强生成)底层实现的 AI 应用开发者(读者 C),以及对嵌入式数据库和搜索算法感兴趣的 Rust 开发者(读者 B)。
AI Agent 和 chatbot 的根本区别之一在于记忆。chatbot 的每次对话都是独立的——上一次帮你写的代码、做过的决策、踩过的坑,下一次全部忘记。Agent 需要记忆来积累经验:上次用户偏好什么代码风格?这个仓库最近修改了哪些文件?三天前解决一个类似 bug 时采取了什么策略?
octos-memory 用 1,750 行代码实现了这个记忆系统。它不依赖任何外部服务——没有 Qdrant,没有 Milvus,没有 PostgreSQL。一个 redb 嵌入式数据库文件加一个内存中的混合搜索索引,就是全部。本章将从存储层开始,逐步深入 BM25 全文搜索、HNSW 向量索引和混合排名融合的工程实现。
4.1 存储选型:redb 嵌入式数据库
4.1.1 为什么是 redb
octos-memory 的持久化层使用 redb(crates/octos-memory/src/store.rs),一个纯 Rust 实现的嵌入式键值数据库。在选型时,最直接的替代方案是 SQLite——Rust 生态中有成熟的 rusqlite 绑定。
redb 相比 SQLite 的优势在 octos 的场景中非常明确:
零 C 依赖。 SQLite 是 C 实现的,rusqlite 需要编译 C 代码或链接系统库。这与 octos workspace 级别的 deny(unsafe_code) 策略冲突——虽然 SQLite 的 C 代码质量极高,但它不受 Rust 编译器的安全检查。redb 是纯 Rust 实现,完全在 deny(unsafe_code) 的保护范围内。
ACID 事务。 redb 提供完整的 ACID 事务支持(读事务和写事务分离),足以满足 episode 存储的持久化需求。octos 不需要 SQL 查询——所有搜索都在内存中的混合索引上进行。
单文件部署。 redb 数据库是一个文件(episodes.redb),不需要额外的 WAL 文件或 SHM 文件。这简化了部署和备份。
4.1.2 三张表结构
Episode Store 在 redb 中定义了三张表(store.rs:14-20):
| 表名 | Key 类型 | Value 类型 | 用途 |
|---|---|---|---|
EPISODES_TABLE | &str (episode_id) | &str (JSON) | Episode 元数据 |
CWD_INDEX_TABLE | &str (工作目录路径) | &str (JSON 数组) | 目录→Episode 索引 |
EMBEDDINGS_TABLE | &str (episode_id) | &[u8] (bincode) | 向量嵌入 |
这个设计把 Episode 数据和向量嵌入分开存储。好处是当不需要向量搜索时(比如 embedding provider 不可用),Episode 的存取不受影响。向量嵌入使用 bincode 序列化(比 JSON 紧凑得多),减少存储和 I/O 开销。
CWD_INDEX_TABLE 是一个辅助索引,按工作目录聚合 Episode ID。当 Agent 在某个项目目录下工作时,优先检索该目录下的历史 episode,提高相关性。
4.2 Episode:Agent 的经验记录
4.2.1 Episode 结构体
Episode 是 Agent 完成一个任务后的经验摘要(crates/octos-memory/src/episode.rs:21-43):
#![allow(unused)]
fn main() {
pub struct Episode {
pub schema_version: u32, // 数据格式版本
pub id: String, // UUID v7
pub task_id: TaskId,
pub agent_id: AgentId,
pub working_dir: PathBuf, // 任务执行目录
pub summary: String, // 任务摘要
pub outcome: EpisodeOutcome, // 结果
pub key_decisions: Vec<String>, // 关键决策记录
pub files_modified: Vec<PathBuf>,
pub created_at: DateTime<Utc>,
}
}
EpisodeOutcome(episode.rs:72-81)有四个变体:Success、Failure、Blocked、Cancelled。这与 TaskStatus(详见第 2 章)的终态一一对应——Episode 只在 Task 到达终态时创建。
schema_version 字段(episode.rs:24)是前向兼容的关键。当 Episode 的格式需要升级时(比如新增字段),旧版本的数据仍然可以通过版本号正确解析。默认值为 1(episode.rs:16-17),反序列化时如果 JSON 中没有这个字段,自动填充默认值。
4.2.2 写入时机
Episode 在 Task 完成时创建并存储(store.rs:87-151)。写入过程:
- 将 Episode 序列化为 JSON,存入
EPISODES_TABLE - 更新
CWD_INDEX_TABLE,将 Episode ID 追加到对应工作目录的列表中 - 更新内存中的混合搜索索引(文本部分)
腐败恢复(store.rs:109-127):CWD_INDEX_TABLE 中的值是 JSON 数组(["id1", "id2", ...])。如果之前的写入因为崩溃被中断,JSON 可能是损坏的。代码会尝试从损坏的 JSON 中按引号分割抢救 Episode ID,而不是丢弃整个索引。这种防御性编程确保了即使在非正常关闭后也不会丢失索引数据。
4.3 BM25 全文搜索
BM25(Best Matching 25)是信息检索领域最经典的排名算法之一。octos-memory 在内存中维护一个倒排索引来实现 BM25 搜索(crates/octos-memory/src/hybrid_search.rs)。
4.3.1 倒排索引结构
#![allow(unused)]
fn main() {
// hybrid_search.rs:8-28(简化)
struct HybridIndex {
inverted: HashMap<String, Vec<(usize, u32)>>, // 词项 → [(文档ID, 词频)]
doc_lengths: Vec<usize>, // 每个文档的长度
total_len: usize, // 所有文档长度之和
avg_dl: f64, // 平均文档长度
ids: Vec<String>, // Episode ID 列表
// ... HNSW 相关字段
}
}
inverted 是倒排索引的核心:给定一个词项(如“重构“),可以快速找到包含该词项的所有文档及其出现频率。
分词策略(hybrid_search.rs:288-295):
#![allow(unused)]
fn main() {
fn tokenize(text: &str) -> Vec<String> {
text.to_lowercase()
.split(|c: char| !c.is_alphanumeric())
.filter(|s| s.len() >= 2)
.map(String::from)
.collect()
}
}
采用最简单的分词方式——转小写后按非字母数字字符分割,过滤掉长度小于 2 的词项。对于中文,这种分词实际上按字符分割(每个汉字是一个字母数字字符),效果不如专业分词器(如 jieba),但胜在零依赖、零误差,且对于 Agent 经验摘要的检索场景已经足够。
4.3.2 BM25 评分公式
BM25 的核心公式(hybrid_search.rs:251-285):
score(q, d) = Σ IDF(qi) × (tf(qi, d) × (K1 + 1)) / (tf(qi, d) + K1 × (1 - B + B × |d| / avgdl))
octos 使用的参数(hybrid_search.rs:31-32):
| 参数 | 值 | 含义 |
|---|---|---|
| K1 | 1.2 | 词频饱和度控制。值越大,高频词的权重越高 |
| B | 0.75 | 文档长度归一化。B=0 时忽略长度差异,B=1 时完全按长度归一化 |
这两个参数值是信息检索领域经过数十年实践验证的经典默认值(源自 TREC 评测实验),octos 直接采用而非自行调优。
IDF 计算(hybrid_search.rs:259):
#![allow(unused)]
fn main() {
let idf = ((n as f64 - df as f64 + 0.5) / (df as f64 + 0.5) + 1.0).ln();
}
IDF(逆文档频率)衡量一个词项的区分度:出现在越多文档中的词(如“代码“、“修改”)IDF 越低,出现在越少文档中的词(如“deadlock“、“HNSW”)IDF 越高。
4.3.3 epsilon 防 NaN
BM25 的评分归一化步骤(hybrid_search.rs:271-284)中有一个微妙的工程细节:
#![allow(unused)]
fn main() {
let max_score = bm25_scores.values().cloned().fold(f64::NEG_INFINITY, f64::max);
if max_score < 1e-10 {
return HashMap::new(); // 所有分数接近零,直接返回空结果
}
// 归一化到 [0, 1]
let normalized = score / max_score;
}
1e-10 阈值检查的作用是防止除以接近零的数。当所有文档的 BM25 分数都极小时(比如查询词没有出现在任何文档中),直接除以 max_score 会放大浮点噪声。通过提前返回空结果,避免了这个问题。
这看起来是一个微不足道的细节,但 NaN 的传播性极强——一旦出现 NaN,后续的排序和融合都会产生错误结果,且不会报错(浮点运算中 NaN 与任何值比较都返回 false),这类 bug 极难定位。
4.4 HNSW 向量索引
4.4.1 HNSW 算法简介
HNSW(Hierarchical Navigable Small World)是目前最流行的近似最近邻(ANN)搜索算法之一。它构建一个多层图结构:
- 底层(Layer 0):包含所有数据点,每个点连接到最多 M 个最近邻
- 上层(Layer 1, 2, …):只包含部分数据点,形成“高速公路“——搜索从最高层开始,快速定位到目标区域,然后逐层下降进行精细搜索
这种分层结构让搜索复杂度从线性 O(N) 降低到对数 O(log N)。
4.4.2 octos 中的 HNSW 配置
octos 使用 hnsw_rs crate 构建向量索引(hybrid_search.rs:41-47):
| 参数 | 值 | 含义 |
|---|---|---|
max_nb_connection | 16 | 每个节点的最大边数(M 参数) |
capacity | 10,000 | 预分配的槽位数 |
ef_construction | 200 | 构建时的搜索宽度(越大越准确但越慢) |
max_layer | 16 | 最大层数 |
10,000 的容量对于 Agent 的经验存储来说绰绰有余——即使每天执行 10 个任务,也需要近 3 年才会达到上限。索引在容量达到 80% 和 100% 时会打印警告(hybrid_search.rs:86-98)。
4.4.3 L2 归一化与 cosine similarity
向量搜索的距离度量使用 cosine similarity(余弦相似度),但 HNSW 内部使用的是 DistCosine 距离(hybrid_search.rs:137)。两者的关系是:
similarity = 1.0 - distance
为了确保 cosine similarity 的正确性,所有嵌入向量在插入索引前都经过 L2 归一化(hybrid_search.rs:297-305):
#![allow(unused)]
fn main() {
fn l2_normalize(v: &[f32]) -> Option<Vec<f32>> {
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
if norm < f32::EPSILON {
return None; // 零向量无法归一化
}
Some(v.iter().map(|x| x / norm).collect())
}
}
零向量保护:norm < f32::EPSILON 检查(hybrid_search.rs:301)防止除以零。当 embedding provider 返回全零向量时(可能因为模型错误或空输入),归一化函数返回 None,该文档不会被加入向量索引(但仍然可以通过 BM25 搜索到)。
4.5 混合排名融合
混合搜索的核心价值在于结合 BM25 的精确关键词匹配和向量搜索的语义理解。octos 采用简单的加权融合策略。
4.5.1 融合流程
flowchart LR
Query["查询文本 + 查询向量"] --> BM25["BM25 搜索<br/>倒排索引<br/>关键词匹配"]
Query --> HNSW["HNSW 搜索<br/>向量索引<br/>语义匹配"]
BM25 --> Fusion["加权融合<br/>0.3 × BM25 + 0.7 × 向量"]
HNSW --> Fusion
Fusion --> TopK["Top-K 结果<br/>按分数降序"]
图 4-1:混合搜索流程。 查询同时走 BM25 和 HNSW 两路,结果通过加权求和融合。
4.5.2 权重配置
默认权重(hybrid_search.rs:35-37):
#![allow(unused)]
fn main() {
const DEFAULT_VECTOR_WEIGHT: f32 = 0.7;
const DEFAULT_BM25_WEIGHT: f32 = 0.3;
}
向量搜索权重(0.7)高于 BM25(0.3),因为语义相似性在 Agent 经验检索中通常比精确关键词匹配更有价值。例如,查询“如何解决并发死锁“应该能找到之前记录的“用 Mutex 排序避免循环等待“的 episode,即使两者没有共同的关键词。
权重可通过 with_weights() 方法配置(hybrid_search.rs:72-76),适应不同场景需求。
4.5.3 融合算法
融合逻辑(hybrid_search.rs:221-237):
#![allow(unused)]
fn main() {
// 对每个候选文档,计算最终分数
for doc_id in all_candidates {
let vec_score = vector_scores.get(doc_id).unwrap_or(&0.0);
let bm25_score = bm25_scores.get(doc_id).unwrap_or(&0.0);
let score = self.vector_weight * vec_score + self.bm25_weight * bm25_score;
results.push((doc_id, score));
}
}
候选集是两路搜索结果的并集——即使一个文档只出现在 BM25 结果中(向量分数为 0),它仍然可以通过 BM25 分数进入最终排名。这确保了精确关键词匹配不会被语义搜索完全淹没。
4.5.4 无 embedding 时的降级策略
当 embedding provider 不可用时(未配置 API key,或 provider 暂时不可达),系统自动降级为纯 BM25 搜索:
- 插入时:
insert()接受embedding: Option<&[f32]>,为 None 时只更新倒排索引 - 搜索时:
query_embedding为 None 时,向量分数全部为 0,最终分数完全由 BM25 决定 - 索引为空时:如果混合索引中没有任何文档,退回到直接扫描 redb 数据库(
store.rs:171-187),通过 CWD 索引和词项匹配提供基础检索
这种三级降级(混合搜索 → BM25 only → DB 扫描)确保了记忆系统在任何条件下都能提供结果,只是精度逐级降低。
4.6 MemoryStore:Markdown 持久化记忆
除了 Episode Store 的结构化记忆,octos-memory 还提供了 MemoryStore(crates/octos-memory/src/memory_store.rs)——一个基于 Markdown 文件的简单记忆系统。
4.6.1 三种记忆形式
| 形式 | 文件 | 特点 |
|---|---|---|
| 长期记忆 | MEMORY.md | 单文件,全量替换 |
| 每日笔记 | YYYY-MM-DD.md | 按天分文件,追加写入 |
| 实体库 | bank/entities/<slug>.md | 按主题分文件 |
4.6.2 7 天窗口记忆
get_memory_context()(memory_store.rs:102-147)构建 Agent 的记忆上下文时,读取最近 7 天的笔记(memory_store.rs:110):
#![allow(unused)]
fn main() {
let recent = self.read_recent(7).await?;
}
7 天窗口是一个务实的选择:太短(如 1 天)会丢失近期上下文;太长(如 30 天)会引入过多噪音并占用 LLM 的上下文窗口。7 天大致对应一个工作周,覆盖了大部分“上次我做过类似的事“的记忆需求。
超过 7 天的经验不会消失——它们仍然存在于 Episode Store 中,可以通过混合搜索检索到。7 天窗口只影响自动注入到系统提示中的上下文量。
工程决策侧栏:为什么不用 Qdrant/Milvus 等外部向量数据库
在 AI 应用领域,Qdrant、Milvus、Pinecone 等向量数据库是主流选择。octos 放弃它们而选择嵌入式方案,理由如下。
方案一:外部向量数据库(Qdrant/Milvus/Pinecone)
优势:
- 支持百万甚至亿级向量规模
- 丰富的索引类型和查询能力(过滤、多向量、稀疏向量)
- 分布式扩展能力
- 成熟的运维工具和监控
劣势:
- 增加部署复杂度——用户需要额外运行一个服务
- 网络延迟(即使本地部署也有 IPC 开销)
- 运维成本(备份、升级、监控)
- 启动依赖——向量库不可用时整个 Agent 无法工作
方案二:嵌入式方案(redb + hnsw_rs)
优势:
- 零部署依赖——
cargo install或下载二进制即可使用- 零网络延迟——搜索在进程内完成
- 零运维——数据库是一个文件,随 Agent 一起备份和迁移
- 优雅降级——即使没有 embedding provider,BM25 搜索仍然可用
劣势:
- 规模上限(10,000 个向量,单机内存限制)
- 搜索功能有限(无过滤、无多向量支持)
- 非分布式(单实例)
octos 的选择:嵌入式方案。
关键洞察是规模需求的差异。RAG 应用需要在百万文档中搜索——这是外部向量库的主战场。但 Agent 的经验记忆是增量积累的:每天几个到几十个 episode,一年下来可能只有几千个。10,000 的容量上限对于绝大多数使用场景绰绰有余。
更重要的是部署体验。octos 的目标用户包括个人开发者和小团队——他们可能只想用一个命令启动 Agent,而不是先部署一套向量数据库基础设施。嵌入式方案让 octos 保持了“下载即用“的简洁性。
4.7 本章回顾
octos-memory 用 1,748 行代码构建了一个自包含的 Agent 记忆系统:
-
redb 嵌入式存储:三张表(Episodes/CWD 索引/向量嵌入),纯 Rust 实现,ACID 事务保证,单文件部署。
-
BM25 全文搜索:经典 K1=1.2/B=0.75 参数,倒排索引 + IDF 加权,epsilon 防 NaN 归一化。
-
HNSW 向量索引:
hnsw_rscrate 提供分层图搜索,L2 归一化保证 cosine similarity 正确性,零向量保护防止索引污染。 -
混合排名融合:0.7 向量 + 0.3 BM25 加权求和,候选集取并集,三级降级(混合→BM25→DB 扫描)确保任何条件下都能返回结果。
-
7 天窗口记忆:自动注入近一周的经验上下文到系统提示,超出窗口的经验通过搜索检索。
下一章将进入 octos-agent,看看 Agent 主循环如何利用这些类型和记忆能力编排一次完整的对话。
延伸阅读
- BM25 算法:Robertson & Zaragoza, “The Probabilistic Relevance Framework: BM25 and Beyond”(Foundation and Trends in IR, 2009)
- HNSW 算法:Malkov & Yashunin, “Efficient and Robust Approximate Nearest Neighbor using Hierarchical Navigable Small World Graphs”(IEEE TPAMI, 2020)
- redb:https://docs.rs/redb/latest/redb/ — 纯 Rust 嵌入式数据库
- hnsw_rs:https://docs.rs/hnsw_rs/latest/hnsw_rs/ — Rust HNSW 实现
- 混合搜索:Anthropic 的 “Contextual Retrieval” 博文讨论了 BM25 + 向量搜索的互补价值
思考题
-
BM25 参数调优:如果 Agent 主要处理中文任务,K1 和 B 参数需要调整吗?中文的分词粒度(单字 vs 词组)如何影响 BM25 的检索效果?
-
向量维度选择:octos 默认使用 1536 维向量(OpenAI text-embedding-3-small)。如果切换到 384 维的轻量模型,对检索质量和内存占用的影响分别是什么?
-
混合权重的动态调整:固定的 0.7/0.3 权重是否最优?设想一种根据查询类型动态调整权重的策略——关键词精确查询偏向 BM25,开放式语义查询偏向向量搜索。这需要在架构上做什么修改?
-
规模瓶颈:如果 octos 需要支持企业级部署(10 万个 episode),当前的嵌入式方案需要做哪些改造?有没有介于“嵌入式“和“外部数据库“之间的中间方案?
版本演化说明 本章分析基于 octos v0.1.0,octos-memory crate 位于
crates/octos-memory/src/。截至本书写作时,混合搜索的权重配置和 HNSW 参数无重大变化。MemoryStore 的实体库功能可能在后续版本中扩展。
第 5 章:Agent Loop:一次对话的完整生命周期
定位:本章是全书最核心的一章——深入 octos-agent 的配置与主循环(
crates/octos-agent/src/agent/mod.rs+crates/octos-agent/src/agent/loop_runner.rs),逐段走读从消息构建到工具调用再到返回结果的完整流程。前置依赖:第 3 章(LLM Provider)、第 4 章(记忆系统)。适用场景:任何想理解 AI Agent 运行时机制的读者,尤其是 AI 应用开发者(读者 C)和想贡献 octos 核心代码的开发者(读者 D)。
理解了 octos-core 的类型系统(第 2 章)、octos-llm 的 Provider 抽象(第 3 章)和 octos-memory 的记忆系统(第 4 章)之后,我们终于来到了整个系统的心脏——Agent Loop。
一个 AI Agent 的“智能“本质上是一个循环:接收用户消息 → 调用 LLM → 解析 LLM 的意图 → 如果 LLM 想用工具就执行工具 → 把工具结果反馈给 LLM → 重复,直到 LLM 认为任务完成。这个循环看似简单,但生产级实现需要处理大量边界情况:迭代上限、token 预算、上下文窗口溢出、消息格式修复、循环检测、优雅关停。
本章将走读 crates/octos-agent/src/agent/ 目录下的核心代码,用约 200 行关键代码展示 Agent Loop 的完整生命周期。
5.1 Agent 结构体与配置
5.1.1 Agent 的组成
Agent 结构体(crates/octos-agent/src/agent/mod.rs:115-138)持有执行一次对话所需的全部资源:
#![allow(unused)]
fn main() {
pub struct Agent {
pub id: AgentId, // Agent 唯一标识
pub llm: Arc<dyn LlmProvider>, // LLM Provider(详见第 3 章)
pub tools: Arc<ToolRegistry>, // 工具注册表(详见第 6 章)
pub memory: Arc<EpisodeStore>, // 长期记忆(详见第 4 章)
pub system_prompt: RwLock<String>, // 系统提示(支持热加载)
pub config: AgentConfig, // 执行配置
pub reporter: RwLock<Arc<dyn ProgressReporter>>, // 进度上报
pub hooks: Option<Arc<HookExecutor>>, // 钩子系统(详见第 14 章)
pub shutdown: Arc<AtomicBool>, // 优雅关停标志
// ...
}
}
几个设计要点值得注意:llm 和 tools 使用 Arc 包装,因为 Agent 可能在多个异步任务间共享(工具并行执行时)。system_prompt 使用 RwLock<String> 而非普通 String,支持配置热加载(详见第 13 章)——运行中的 Agent 可以在不重启的情况下更新系统提示。shutdown: Arc<AtomicBool> 是一个跨线程共享的原子布尔标志。当收到 SIGTERM 信号时,主线程将其设为 true,Agent Loop 在每次迭代开始时检查这个标志,如果为 true 就优雅退出而非粗暴终止(详见第 11 章)。
5.1.2 AgentConfig
AgentConfig(crates/octos-agent/src/agent/mod.rs:36-75)控制 Agent 的执行边界:
| 字段 | 默认值 | 含义 |
|---|---|---|
max_iterations | 50 | 最大迭代次数 |
max_tokens | None(无限制) | token 预算上限 |
max_timeout | 600 秒(10 分钟) | 墙钟超时 |
tool_timeout_secs | 600 | 单个工具调用超时 |
save_episodes | true | 是否保存经验到记忆 |
50 次迭代上限(crates/octos-agent/src/agent/mod.rs:64-68)是一个安全阀。一个典型的代码修改任务通常在 5-15 次迭代内完成(读文件 → 分析 → 修改 → 测试)。如果 Agent 在 50 次迭代后仍未完成,几乎可以确定它陷入了某种低效循环。
5.2 主循环:逐段走读
主循环位于 crates/octos-agent/src/agent/loop_runner.rs:108-290。让我们逐段走读。
5.2.1 入口点
Agent 有两个入口点(crates/octos-agent/src/agent/loop_runner.rs:33-41,293-474):
process_message():对话模式——接收用户消息和历史,返回ConversationResponserun_task():任务模式——接收 Task 定义,返回TaskResult
两者最终都调用同一个内部循环 process_message_inner()。
5.2.2 迭代流程
每次迭代的完整流程如下:
flowchart TD
Start["迭代开始"] --> Budget["预算检查<br/>iterations/tokens/timeout/shutdown"]
Budget -->|"超出预算"| Return["返回当前结果"]
Budget -->|"通过"| Iter["iteration++<br/>上报 thinking 状态"]
Iter --> LRU["工具 LRU 管理<br/>tick + 自动淘汰"]
LRU --> Repair["消息修复管线<br/>6 道修复"]
Repair --> LLM["调用 LLM<br/>(带 hooks)"]
LLM --> Stream["流式消费<br/>累积 tokens"]
Stream --> Stop{"stop_reason?"}
Stop -->|"EndTurn"| EndReturn["返回响应"]
Stop -->|"ToolUse"| LoopCheck["循环检测"]
Stop -->|"MaxTokens"| MaxReturn["返回部分响应"]
Stop -->|"ContentFiltered"| FilterReturn["返回安全提示"]
LoopCheck --> ToolExec["并行执行工具"]
ToolExec --> Start
图 5-1:Agent Loop 单次迭代流程。 关键路径是 ToolUse 分支——它是唯一导致循环继续的 stop_reason。
5.2.3 预算检查
每次迭代最先执行的是预算检查(crates/octos-agent/src/agent/budget.rs:34-64):
#![allow(unused)]
fn main() {
pub(super) fn check_budget(
&self,
iteration: u32,
start: Instant,
total_usage: &TokenUsage,
) -> Option<BudgetStop> {
// 1. 优雅关停——原子读取,O(1)
if self.shutdown.load(Ordering::Acquire) {
return Some(BudgetStop::Shutdown);
}
// 2. 迭代次数——简单比较
if iteration >= self.config.max_iterations {
return Some(BudgetStop::MaxIterations);
}
// 3. 墙钟超时——elapsed() 调用
if let Some(timeout) = self.config.max_timeout {
if start.elapsed() > timeout {
return Some(BudgetStop::WallClockTimeout { limit: timeout });
}
}
// 4. token 预算——需要加法
if let Some(max_tokens) = self.config.max_tokens {
let used = total_usage.input_tokens + total_usage.output_tokens;
if used >= max_tokens {
return Some(BudgetStop::MaxTokens { used, limit: max_tokens });
}
}
None
}
}
四道检查的优先级经过精心排序:
- Shutdown 最先——原子加载是 ~1 CPU 周期的操作,且用户主动中断必须立即响应
- 迭代次数次之——简单整数比较,是最常见的停止原因
- 墙钟超时第三——
Instant::elapsed()涉及系统调用,但仍然很快 - Token 预算最后——需要加法运算,且大部分配置不设置 token 上限(
None)
每种停止原因携带不同的上下文数据(BudgetStop 枚举,crates/octos-agent/src/agent/budget.rs:11-17):
#![allow(unused)]
fn main() {
pub(super) enum BudgetStop {
Shutdown,
MaxIterations,
MaxTokens { used: u32, limit: u32 }, // 包含已用和上限
WallClockTimeout { limit: Duration }, // 包含配置的超时值
}
}
这些上下文数据被传递给 report_budget_stop() 方法(crates/octos-agent/src/agent/budget.rs:67-101),生成对应的进度事件通知用户——“Reached max iterations” 或 “Token budget exceeded (1000 of 500)” 等具体信息。
5.2.4 消息修复管线
在每次 LLM 调用前,消息历史需要经过 7 道修复(crates/octos-agent/src/agent/loop_runner.rs:137-144):
trim_to_context_window():截断过长的历史消息以适应模型的上下文窗口normalize_system_messages():合并多个系统消息,确保系统提示的正确位置repair_message_order():修复消息顺序(某些 Provider 要求严格的 user→assistant→user 交替)repair_tool_pairs():确保每个工具调用都有对应的工具结果synthesize_missing_tool_results():为缺失的工具结果生成占位响应(如"[result unavailable]")truncate_old_tool_results():截断过早的工具结果以节省上下文空间normalize_tool_call_ids():去重和清理工具调用 ID
为什么需要这么多修复?原因有三:
上下文压缩的副作用。 当对话历史经过 compaction(详见第 8 章),工具调用和工具结果的配对关系可能被破坏——一条工具调用消息的参数被压缩了,但对应的结果消息还在。repair_tool_pairs() 和 synthesize_missing_tool_results() 修复这类孤立消息。
Provider 格式差异。 Anthropic 要求 tool result 紧跟在包含 tool_call 的 assistant 消息之后,不能插入其他消息。OpenAI 则允许 tool result 与 tool_call 之间有间隔。repair_message_order() 根据当前 Provider 的要求重排消息。
LLM 的不可靠输出。 LLM 有时会生成重复的 tool_call_id,或返回格式不完整的工具调用。normalize_tool_call_ids() 在 LLM 调用前清理这些问题,避免 Provider API 因为重复 ID 而报错。
5.2.5 工具数量警告
在第一次迭代中,如果注册的工具数超过 25 个(crates/octos-agent/src/agent/loop_runner.rs:146-152),系统会打印警告。这是因为大多数 LLM 在工具列表过长时表现下降——可能出现“空响应“或选择困难。建议通过 always: false 策略或 deny 列表减少活跃工具数。
5.2.6 LLM 调用与空响应重试
LLM 调用(crates/octos-agent/src/agent/loop_runner.rs:160-183)经过 hooks 系统,并包含智能重试:
#![allow(unused)]
fn main() {
let response = match self.call_llm_with_hooks(&messages, &tools, &config).await {
Ok(r) => r,
Err(e) if e.to_string().contains("empty response after") => {
// 空响应——重试一次,AdaptiveRouter 可能切换到其他 Provider
self.call_llm_with_hooks(&messages, &tools, &config).await?
}
Err(e) => return Err(e),
};
}
这个重试逻辑处理一个特殊场景:LLM 返回了空响应(没有文本、没有工具调用、没有错误)。这通常发生在 Provider 过载或模型处理失败时。重试一次让 AdaptiveRouter 有机会选择不同的 Provider(详见第 3 章)。
Hooks 允许用户在 LLM 调用前后注入自定义逻辑(详见第 14 章)。before-hook 可以拒绝调用(返回 exit code 1),after-hook 可以观察响应。
5.2.7 流式消费与自适应超时
流式响应消费(crates/octos-agent/src/agent/streaming.rs:32-239)使用 tokio::select! 同时等待三个事件:
#![allow(unused)]
fn main() {
let event = tokio::select! {
event = stream.next() => event, // 流事件到达
_ = self.wait_for_shutdown() => { // 优雅关停信号
break;
}
_ = tokio::time::sleep(timeout) => { // 超时
break;
}
};
}
三个 future 竞争,先完成的决定执行路径。如果 shutdown 信号在流式传输过程中到达,Agent 立即停止消费,不等待流结束。
流式响应的事件类型:
- TextDelta:逐 token 的文本输出,实时转发给用户
- ReasoningDelta:推理模型的思维链输出(如 o1 的内部推理)
- ToolCallDelta:工具调用参数的增量构建——工具名和参数 JSON 逐块到达
- Usage:token 使用量更新
- Done:流结束信号
自适应超时(crates/octos-agent/src/agent/streaming.rs:61-75)使用两阶段策略:
#![allow(unused)]
fn main() {
let ttft_secs = (30 + input_tokens_estimate as u64 / 1000).min(180);
let timeout = if got_first_chunk {
Duration::from_secs(30) // Phase 2: token 间隔 30s
} else {
Duration::from_secs(ttft_secs) // Phase 1: TTFT = 30s + 1s/1K tokens
};
}
| 阶段 | 计算公式 | 100K tokens 输入 | 理由 |
|---|---|---|---|
| TTFT | 30 + tokens/1000(max 180s) | 130s | 模型处理大输入需要时间 |
| Inter-chunk | 固定 30s | 30s | 流一旦开始应持续到达 |
TTFT 的自适应设计至关重要:如果对一个包含整个代码库上下文(100K+ tokens)的请求使用固定 30 秒超时,几乎必然触发误判。1s/1K tokens 的线性增长让超时与输入大小成正比。
5.3 stop_reason 决策树
LLM 响应的 stop_reason 决定了循环的走向。octos 定义了五种 stop_reason(crates/octos-llm/src/types.rs:26-41):
flowchart TD
SR["stop_reason"] --> ET["EndTurn<br/>模型认为任务完成"]
SR --> TU["ToolUse<br/>模型想调用工具"]
SR --> MT["MaxTokens<br/>输出达到上限"]
SR --> SS["StopSequence<br/>命中停止序列"]
SR --> CF["ContentFiltered<br/>安全过滤器拦截"]
ET --> Return1["退出循环<br/>返回完整响应"]
SS --> Return1
TU --> LD["循环检测"]
LD -->|"未检测到"| ToolExec["并行执行工具<br/>join_all"]
LD -->|"检测到循环"| Warn["注入警告消息<br/>继续执行"]
ToolExec --> Continue["继续循环"]
Warn --> Continue
MT --> Return2["退出循环<br/>返回部分响应"]
CF --> Return3["退出循环<br/>返回安全提示"]
图 5-2:stop_reason 决策树。 ToolUse 是唯一触发循环继续的分支。
5.3.1 EndTurn / StopSequence
模型自然结束(crates/octos-agent/src/agent/loop_runner.rs:221-231)。这是最常见的退出路径——LLM 认为任务已完成,返回最终文本。
5.3.2 ToolUse — 工具执行
这是 Agent Loop 的核心路径(crates/octos-agent/src/agent/loop_runner.rs:233-256)。当 LLM 返回工具调用请求时:
- 循环检测:检查工具调用模式是否重复(见 5.4 节)
- 并行执行:使用
futures::future::join_all并行执行所有工具调用 - 结果注入:将工具结果作为 Tool 角色的消息添加到历史中
- 继续循环:回到迭代开始
5.3.3 MaxTokens
LLM 的输出达到了 max_output_tokens 限制(crates/octos-agent/src/agent/loop_runner.rs:258-268)。这通常意味着 LLM 的回答被截断了。循环退出,返回截断的内容。
5.3.4 ContentFiltered
LLM 的安全过滤器拦截了输出(crates/octos-agent/src/agent/loop_runner.rs:270-288)。这可能因为用户请求涉及敏感内容,或 LLM 的输出触发了 Provider 的内容策略。循环退出,返回安全提示消息。
5.4 循环检测:防止 Agent 陷入死循环
5.4.1 问题场景
Agent 可能陷入死循环:反复读取同一个文件、反复执行同一个失败的命令、或者在“修改→测试→失败→修改“的循环中无法收敛。如果不加检测,50 次迭代会全部浪费在无意义的重复上。
5.4.2 检测算法
LoopDetector(crates/octos-agent/src/loop_detect.rs:11-16)使用哈希签名检测工具调用模式的重复:
#![allow(unused)]
fn main() {
pub struct LoopDetector {
signatures: Vec<u64>, // 工具调用哈希的环形缓冲区
window: usize, // 最大窗口大小(默认 12)
}
}
每次工具调用时,将 工具名 + 参数 JSON 哈希为 u64 签名,追加到缓冲区。然后检查最近的签名序列是否存在长度为 1、2 或 3 的重复模式(crates/octos-agent/src/loop_detect.rs:29-60)。
检测条件(crates/octos-agent/src/loop_detect.rs:74-81):同一模式连续出现 3 次以上才触发检测。例如:
- 模式长度 1:
[A, A, A]→ 检测到(同一个工具调用重复 3 次) - 模式长度 2:
[A, B, A, B, A, B]→ 检测到(AB 对重复 3 次) - 模式长度 3:
[A, B, C, A, B, C, A, B, C]→ 检测到(ABC 序列重复 3 次)
5.4.3 检测后的处理
检测到循环后(crates/octos-agent/src/agent/loop_runner.rs:235-247),系统不会终止循环,而是注入一条系统警告消息:
⚠️ Loop detected: you appear to be repeating the same tool calls.
Please try a different approach.
这个消息被 LLM 在下一次迭代中看到,通常足以让模型改变策略。如果模型继续重复,50 次迭代上限会最终终止循环。
“提示而非强制“的设计是务实的——某些场景下的重复是合理的(比如轮询一个尚未完成的后台任务),强制终止会导致误杀。
5.5 Token 预算管理
5.5.1 累积追踪
每次 LLM 调用后,token 使用量被累积到 total_usage(crates/octos-agent/src/agent/loop_runner.rs:211-212):
#![allow(unused)]
fn main() {
total_usage.input_tokens += response.usage.input_tokens;
total_usage.output_tokens += response.usage.output_tokens;
}
工具执行产生的 token(如果工具内部调用了子 Agent 或 LLM)也被累加(crates/octos-agent/src/agent/loop_runner.rs:550-554)。
5.5.2 实时上报
TokenTracker(crates/octos-agent/src/agent/mod.rs:93-112)使用原子计数器实时更新 token 使用量:
#![allow(unused)]
fn main() {
pub struct TokenTracker {
pub input_tokens: AtomicU32,
pub output_tokens: AtomicU32,
}
}
这些原子计数器被进度上报器(ProgressReporter)读取,用于在 CLI 或 Web UI 中显示实时的 token 消耗。Ordering::Relaxed 足够——token 计数不需要严格的顺序保证,最终一致性即可。
5.5.3 成本计算
流式消费完成后(crates/octos-agent/src/agent/streaming.rs:242-258),系统使用 octos-llm 的定价模块计算本次响应和累计会话的成本,并通过 reporter 上报。这让用户在交互过程中实时看到 API 成本。
5.6 流式消费的自适应超时
流式响应的超时策略(streaming.rs)比简单的固定超时更加精细:
| 超时类型 | 计算公式 | 最大值 | 场景 |
|---|---|---|---|
| 首 token (TTFT) | 30s + 1s/1K input tokens | 180s | 等待 LLM 开始响应 |
| token 间隔 | 固定 30s | 30s | 正常流式传输中 |
TTFT 的自适应设计考虑到了一个现实问题:输入越长(比如包含大量源码上下文),LLM 处理所需的时间越长。固定的 30 秒超时在处理 100K+ tokens 的输入时会频繁触发误判。1s/1K tokens 的线性增长让超时与输入大小成正比,180s 上限防止无限等待。
5.7 源码走读:核心 200 行
将主循环的关键路径提炼为约 200 行(来自 loop_runner.rs),带中文注释:
#![allow(unused)]
fn main() {
// === 主循环入口 ===
loop {
// 1. 预算检查——超出任何限制立即返回
if let Some(stop) = self.check_budget(iteration, start, &total_usage) {
return self.build_budget_response(stop, &messages);
}
iteration += 1;
self.reporter.report_thinking();
// 2. 工具 LRU 管理——淘汰不活跃的工具
self.tools.tick();
// 3. 消息修复——确保格式符合 Provider 要求
normalize_system_messages(&mut messages);
repair_message_order(&mut messages);
repair_tool_pairs(&mut messages);
synthesize_missing_tool_results(&mut messages);
truncate_old_tool_results(&mut messages);
normalize_tool_call_ids(&mut messages);
// 4. 调用 LLM(经过 hooks 管线)
let response = self.call_llm_with_hooks(&messages, &tools, &config).await?;
// 5. 累积 token 使用量
total_usage.input_tokens += response.usage.input_tokens;
total_usage.output_tokens += response.usage.output_tokens;
// 6. stop_reason 决策
match response.stop_reason {
StopReason::EndTurn | StopReason::StopSequence => {
// 任务完成,退出循环
messages.push(Message::assistant(&response.content));
return Ok(ConversationResponse { /* ... */ });
}
StopReason::ToolUse => {
// 将 LLM 的响应加入历史
messages.push(assistant_message_with_tool_calls);
// 循环检测
for tc in &response.tool_calls {
if let Some(warning) = loop_detector.record(&tc.name, &tc.arguments) {
messages.push(Message::system(warning));
}
}
// 并行执行所有工具
let tool_results = futures::future::join_all(
response.tool_calls.iter().map(|tc| self.execute_tool(tc))
).await;
// 将工具结果加入消息历史
for result in tool_results {
messages.push(Message::tool(result));
}
// 继续循环
}
StopReason::MaxTokens => {
messages.push(Message::assistant(&response.content));
return Ok(ConversationResponse { /* ... */ });
}
StopReason::ContentFiltered => {
return Ok(ConversationResponse::safety_message());
}
}
}
}
注:以上代码经过简化以突出核心逻辑,实际实现包含更多错误处理、日志和边界条件。完整代码见 crates/octos-agent/src/agent/loop_runner.rs。
工程决策侧栏:为什么主循环不是 Actor Model
很多并发系统(如 Akka、Erlang/OTP)使用 Actor Model——每个 Agent 是一个 Actor,通过消息传递通信。octos 选择了更简单的“异步循环 + Mutex 保护“模型。
方案一:Actor Model
优势:
- 天然的状态隔离——每个 Actor 封装自己的状态
- 消息传递避免共享状态——不需要锁
- 成熟的错误恢复模式(supervision tree)
劣势:
- 引入 Actor 框架(如
actix)增加依赖和学习成本- 工具执行需要请求-响应语义,Actor 的异步消息传递会增加复杂度
- Agent 的状态本质上是线性的(消息历史 + 迭代计数),不需要 Actor 的并发状态管理
方案二:异步循环 + Mutex(octos 的选择)
优势:
- 直观的顺序逻辑——循环的每一步自然对应 Agent 的行为阶段
- Tokio 的异步运行时已经提供了并发能力(
join_all并行执行工具)- 会话级 Mutex 确保同一用户的消息按序处理,不需要复杂的消息队列
劣势:
- 跨 Agent 协调需要显式的 channel 通信
- 没有内置的 supervision tree
octos 的理由: Agent Loop 的核心是顺序的——接收→思考→行动→观察→思考→…。在这个链条中,并发只出现在“行动“阶段(多个工具并行执行)。用一个
loop+join_all就能优雅地表达这个逻辑,不需要 Actor 的抽象开销。
5.8 本章回顾
Agent Loop 是 octos 的灵魂——一个精心编排的 while 循环:
-
预算检查:四道门禁(shutdown → iterations → timeout → tokens),确保 Agent 不会无限运行。50 次迭代上限是默认安全阀。
-
消息修复:6 道修复管线在每次 LLM 调用前规范化消息历史,处理上下文压缩的副作用和 Provider 间的格式差异。
-
stop_reason 决策:五种分支中只有 ToolUse 触发循环继续。EndTurn 是正常退出,MaxTokens 是截断退出,ContentFiltered 是安全退出。
-
循环检测:哈希签名 + 模式匹配(长度 1/2/3,重复 3 次),检测到后注入警告而非强制终止。
-
Token 追踪:原子计数器实时更新,支持 CLI/Web UI 的成本显示。
-
流式超时:自适应 TTFT(与输入大小成正比),避免长上下文场景的误判。
下一章将深入工具系统——Agent Loop 中“行动“阶段的核心:14 个内置工具如何注册、调用和安全管控。
延伸阅读
- ReAct 框架:Yao et al., “ReAct: Synergizing Reasoning and Acting in Language Models”(2023)——Agent 循环的理论基础
- Function Calling:OpenAI “Function calling” 文档——理解 LLM 如何请求工具调用
- Tokio select!:Tokio 官方文档 “select” 章节——理解多 future 竞争的模式
- Circuit Breaker 模式:Michael Nygard, Release It!(Pragmatic Bookshelf)——生产系统的韧性模式
思考题
-
迭代上限的权衡:50 次迭代上限对于简单任务(如回答问题)太高,对于复杂任务(如大规模重构)可能太低。你会如何设计一个自适应的迭代上限?
-
循环检测的局限:当前的哈希签名方法只检测精确重复。如果 Agent 每次传递的参数略有不同(如文件名多一个空格),检测就会失效。你会如何改进?
-
消息修复的必要性:6 道消息修复管线处理了大量边界情况。如果 octos 只支持一个 Provider(如只支持 Anthropic),哪些修复可以省略?
-
Actor Model 的场景:本章的工程决策侧栏选择了简单循环而非 Actor Model。如果 octos 需要支持多个 Agent 协作(如一个规划 Agent 分配任务给多个执行 Agent),设计会如何改变?
版本演化说明 本章分析基于 octos v0.1.0,核心循环位于
crates/octos-agent/src/agent/loop_runner.rs。截至本书写作时,主循环的迭代结构和 stop_reason 决策逻辑无重大变化。消息修复管线可能随新 Provider 的加入而扩展。
第 6 章:工具系统:内置工具的设计模式
定位:本章深入 Agent Loop 中“行动“阶段的核心——工具系统。展示
Tooltrait 的设计、ToolRegistry 的注册与 LRU 淘汰机制、ToolPolicy 的 deny-wins 安全语义,以及参数安全措施。前置依赖:第 5 章。适用场景:想理解 Agent 工具架构的 AI 应用开发者(读者 C),以及想为 octos 贡献新工具的开发者(读者 D)。
Agent 的“智能“来自 LLM,但 Agent 的“能力“来自工具。没有工具,Agent 只能生成文本;有了工具,Agent 可以读写文件、执行命令、搜索网页、管理 Git 仓库。当前源码里并不存在一个稳定的“总工具数“:ToolRegistry::with_builtins_and_sandbox() 只注册 11 个基础工具,git 和 code_structure 受 Cargo feature 控制,configure_tool 由配置层追加,spawn、message、send_file、deep_search、manage_skills、model_check 等则由 chat、gateway、session actor 在不同运行模式下继续注入。理解这个分层注册模型,比记住一个固定数字更重要(../octos/crates/octos-agent/src/tools/registry.rs:605-624,../octos/crates/octos-agent/src/tools/registry.rs:688-703,../octos/crates/octos-cli/src/commands/chat.rs:162-269,../octos/crates/octos-cli/src/commands/gateway/gateway_runtime.rs:797-870,../octos/crates/octos-cli/src/session_actor.rs:515-590)。
但工具带来能力的同时也带来风险:每个工具调用都是一个潜在的攻击面。如何在开放能力的同时控制风险?octos 的答案是三道防线:ToolPolicy 控制哪些工具可用,参数验证控制输入安全,symlink-safe I/O 控制文件系统访问边界。
6.1 Tool trait:最小化的工具接口
Tool trait(../octos/crates/octos-agent/src/tools/mod.rs:56-81)定义了所有工具的统一接口:
#![allow(unused)]
fn main() {
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn input_schema(&self) -> serde_json::Value;
fn tags(&self) -> &[&str] { &[] }
async fn execute(&self, args: &serde_json::Value) -> Result<ToolResult>;
fn as_any(&self) -> &dyn std::any::Any { &() }
}
}
设计上,Tool trait 有三层职责:
声明部分(name() + description() + input_schema())构成 ToolSpec,发送给 LLM 让它知道有哪些工具可用以及如何调用。input_schema() 返回 JSON Schema 格式的参数描述。
执行部分(execute())接收 LLM 传来的参数 JSON,执行实际操作,返回 ToolResult:
#![allow(unused)]
fn main() {
pub struct ToolResult {
pub output: String, // 返回给 LLM 的文本输出
pub success: bool, // 是否成功
pub file_modified: Option<PathBuf>, // 修改的文件
pub files_to_send: Vec<PathBuf>, // 需要发送给用户的文件
pub tokens_used: Option<TokenUsage>, // 子 Agent 工具的 token 消耗
}
}
集成部分 由两个可选扩展点承载:tags() 为工具打能力标签;as_any() 允许框架在极少数情况下向下转型访问具体工具,例如 activate_tools 需要在 Agent 构造完成后注入 ToolRegistry 回指(../octos/crates/octos-agent/src/agent/mod.rs:190-198)。
tags() 不只是“分类标签“。当前源码里它至少影响两层过滤:
ToolPolicy::require_tags通过is_allowed_with_tags()过滤 provider 视角可见的工具。ToolRegistry::set_context_filter()通过上下文标签裁剪specs()输出。
这两层都把“空标签工具“视为 universal tool,也就是默认放行(../octos/crates/octos-agent/src/tools/policy.rs:45-72,../octos/crates/octos-agent/src/tools/registry.rs:222-257)。
还有一个容易忽略的细节:工具执行时并没有把 reporter、tool_call_id、进度回调塞进 trait 签名,而是通过 TOOL_CTX 这个 task-local 上下文注入。这让 trait 保持极小,同时允许长任务工具异步上报进度(../octos/crates/octos-agent/src/tools/mod.rs:13-25)。
6.2 ToolRegistry:注册与 LRU 淘汰
6.2.1 注册机制
ToolRegistry(../octos/crates/octos-agent/src/tools/registry.rs:56-703)是工具的中央管理器。更准确地说,它不是“一次性注册所有工具“,而是提供一个基础注册表,然后让 chat、gateway、session actor 在此之上叠加各自需要的工具。
with_builtins_and_sandbox() 只注册 11 个基础工具(另加两个 feature-gated 工具):
| 工具名 | 类型 | 功能 |
|---|---|---|
shell | ShellTool | 执行 Shell 命令(带沙箱) |
read_file | ReadFileTool | 读取文件内容 |
write_file | WriteFileTool | 写入文件 |
edit_file | EditFileTool | 编辑文件(精确替换) |
diff_edit | DiffEditTool | Diff 格式编辑 |
glob | GlobTool | 文件模式搜索 |
grep | GrepTool | 内容搜索 |
list_dir | ListDirTool | 目录列表 |
web_search | WebSearchTool | 网页搜索 |
web_fetch | WebFetchTool | 获取网页内容 |
browser | BrowserTool | 浏览器自动化 |
git | GitTool | Git 操作(feature: git) |
code_structure | CodeStructureTool | AST 代码结构(feature: ast) |
真正的运行时注册是分层的:
| 层次 | 注册位置 | 典型工具 |
|---|---|---|
| 基础层 | ../octos/crates/octos-agent/src/tools/registry.rs:605-624 | shell、read_file、web_search、browser |
| 配置层 | ../octos/crates/octos-agent/src/tools/registry.rs:688-703 | configure_tool,以及带配置的 web_search/web_fetch/browser |
| Chat 模式追加 | ../octos/crates/octos-cli/src/commands/chat.rs:184-255 | spawn、synthesize_research、recall_memory、save_memory、run_pipeline |
| Gateway 基础追加 | ../octos/crates/octos-cli/src/commands/gateway/gateway_runtime.rs:797-870 | manage_skills、synthesize_research、recall_memory、save_memory、model_check |
| Per-session 追加 | ../octos/crates/octos-cli/src/session_actor.rs:515-590 | message、send_file、spawn、cron、per-session run_pipeline |
这也是为什么只看 tools/mod.rs 的模块导出会产生错觉:那里列出的是“框架可用的工具类型“,不是“当前进程默认已经注册的工具集合“(../octos/crates/octos-agent/src/tools/mod.rs:174-237)。
ToolRegistry 还做了两件容易被忽略的工作:
specs()结果会缓存,只有注册表发生变动时才失效,避免每轮都重建整份 ToolSpec 列表(../octos/crates/octos-agent/src/tools/registry.rs:64-65,../octos/crates/octos-agent/src/tools/registry.rs:222-257,../octos/crates/octos-agent/src/tools/registry.rs:518-529)。- cwd 绑定工具和非 cwd 绑定工具被分开处理。切换到 per-user workspace 时,
rebind_cwd()只重建前者,后者共享原来的Arc<dyn Tool>(../octos/crates/octos-agent/src/tools/registry.rs:627-665)。
6.2.2 LRU 淘汰机制
LLM 的工具调用是通过在请求中包含 ToolSpec 实现的,每个 ToolSpec 都占用上下文窗口 token。当前 octos 不是靠单一机制控制工具膨胀,而是采用了三层组合:
- 启动时按组预延迟(
defer_group()),先把低频工具从specs()中拿掉。 - 运行时用 LRU 把长时间不用的非核心工具移入
deferred集合。 - 真正执行某个 deferred 工具时,再自动激活对应组,不要求 LLM 先显式调用
activate_tools。
其中第二层由 ToolLifecycle 驱动(../octos/crates/octos-agent/src/tools/mod.rs:84-160):
#![allow(unused)]
fn main() {
pub struct ToolLifecycle {
pub(crate) last_used: HashMap<String, u32>, // 工具名 → 最后使用的迭代号
pub(crate) iteration: u32, // 当前迭代计数器
pub(crate) base_tools: HashSet<String>, // 永不淘汰的核心工具
pub(crate) max_active: usize, // 默认 15
pub(crate) idle_threshold: u32, // 默认 5
}
}
| 参数 | 默认值 | 含义 |
|---|---|---|
max_active | 15 | 同时活跃的最大工具数 |
idle_threshold | 5 | 空闲 N 次迭代后可被淘汰 |
flowchart TD
Tick["每次 LLM 调用前 tick()"] --> Count["统计活跃工具数"]
Count -->|"≤ 15"| OK["保持不变"]
Count -->|"> 15"| Find["查找候选"]
Find --> Filter["过滤:非 base_tools<br/>且空闲 ≥ 5 次迭代"]
Filter --> Sort["按空闲时长排序<br/>最久未用的优先"]
Sort --> Evict["淘汰至 ≤ 15"]
Evict --> Deferred["工具移入延迟池<br/>下次使用时自动激活"]
图 6-1:LRU 工具淘汰流程。 被淘汰的工具不会被删除,而是移入 deferred 集合;specs() 不再暴露它们,但 registry 仍保留其实现对象。
6.2.3 淘汰算法源码走读
tick() 很简单,但它的调用位置很关键:Agent 主循环会在每轮请求 LLM 之前先 tick(),再 auto_evict()。这意味着淘汰发生在下一轮工具声明构造之前,而不是在工具调用结束时异步清理(../octos/crates/octos-agent/src/agent/loop_runner.rs:127-128,../octos/crates/octos-agent/src/tools/registry.rs:480-514)。
选择候选的核心逻辑在 find_evictable()(../octos/crates/octos-agent/src/tools/mod.rs:137-160):
#![allow(unused)]
fn main() {
pub fn find_evictable(&self, active_tools: &[&str]) -> Vec<String> {
if active_tools.len() <= self.max_active {
return Vec::new(); // 未超限,不淘汰
}
let mut candidates: Vec<(&str, u32)> = active_tools.iter()
.filter(|name| !self.base_tools.contains(**name)) // 排除核心工具
.map(|name| (*name, self.last_used.get(*name).copied().unwrap_or(0)))
.filter(|(_, last)| self.iteration.saturating_sub(*last) >= self.idle_threshold)
.collect(); // 只取空闲 ≥ 5 的
candidates.sort_by_key(|(_, last)| *last); // 最旧的优先淘汰
let to_evict = active_tools.len().saturating_sub(self.max_active);
candidates.into_iter().take(to_evict) // 只淘汰超出部分
.map(|(name, _)| name.to_string()).collect()
}
}
三个关键设计选择:
base_tools 过滤。 shell、read_file 等核心工具永远不是淘汰候选——即使所有 15 个槽位都被核心工具占满。
idle_threshold 保护。 只有空闲 ≥ 5 次迭代的工具才被考虑。这防止了“刚用完就被淘汰“的抖动。
最小淘汰量。 只淘汰 active_count - max_active 个——恰好让活跃数降回 15,而非激进清理所有候选。
被淘汰的工具去哪了? ToolRegistry::auto_evict() 会把这些名字写入 deferred 集合,并让 specs() 缓存失效(../octos/crates/octos-agent/src/tools/registry.rs:485-514)。
重新激活发生在哪里? 不是一个单独的 activate_on_demand() 方法,而是直接写在 ToolRegistry::execute() 里:如果要执行的工具当前在 deferred 集合中,就先找到其所属分组并调用 activate(),然后再进入参数检查和实际执行(../octos/crates/octos-agent/src/tools/registry.rs:532-595)。
activate_tools 的角色是什么? 它是一个可选的“元工具“,用于把 deferred 工具列表显式展示给 LLM 并支持批量加载,不是自动激活的唯一入口。只有在 registry 里确实存在 deferred 工具时,gateway/profile factory 才会注册它;注册之后还要在 Agent 构造完成后调用 wire_activate_tools() 填回 ToolRegistry 的弱引用(../octos/crates/octos-cli/src/commands/gateway/gateway_runtime.rs:999-1001,../octos/crates/octos-cli/src/commands/gateway/profile_factory.rs:520-522,../octos/crates/octos-agent/src/tools/activate_tools.rs:9-107,../octos/crates/octos-agent/src/agent/mod.rs:190-198)。
LRU 状态是 per-session 的。 在 Gateway/Serve 模式下,每个 session actor 持有自己的 ToolRegistry(详见第 11 章),LRU 计数器在会话之间完全独立。
spawn_only 和 deferred 是两套不同语义。 spawn_only 不是 LRU 延迟池的一部分。PluginLoader 只是给工具打上 spawn_only 标记;真正执行时,Agent 会把这次调用改写成后台任务并立即返回一条“任务已在后台运行“的工具消息;而在 subagent 场景里,SpawnTool 又会主动 clear_spawn_only(),让这些工具按普通工具同步执行,因为子代理本身就已经是后台上下文(../octos/crates/octos-agent/src/plugins/loader.rs:90-112,../octos/crates/octos-agent/src/agent/execution.rs:105-237,../octos/crates/octos-agent/src/tools/spawn.rs:338-360,../octos/crates/octos-agent/src/tools/spawn.rs:419-440)。
还有一个实现层面的细节值得注意:gateway 在 base registry 上先把 message、send_file、spawn、activate_tools 这些名字加入 base_tools,虽然这些工具实例要等到 session actor 内部才真正注册。这样做依赖的是 ToolLifecycle 的“按名称 pin 住“语义,以及 snapshot_excluding() 会把 base set 复制到子 registry,从而让后续 per-session 注入的这些工具天然不会被 LRU 淘汰(../octos/crates/octos-cli/src/commands/gateway/gateway_runtime.rs:953-971,../octos/crates/octos-agent/src/tools/registry.rs:316-356,../octos/crates/octos-cli/src/session_actor.rs:542-590)。
6.3 ToolPolicy:deny-wins 安全语义
ToolPolicy(../octos/crates/octos-agent/src/tools/policy.rs:5-152)控制哪些工具可用、哪些被禁止。当前实现不只是 allow/deny 两列,而是三维策略:
#![allow(unused)]
fn main() {
pub struct ToolPolicy {
pub allow: Vec<String>,
pub deny: Vec<String>,
pub require_tags: Vec<String>,
}
}
其中 allow / deny 决定名字级别可见性,require_tags 决定标签级别可见性;两者组合时仍然遵循 deny-wins(../octos/crates/octos-agent/src/tools/policy.rs:5-72)。
6.3.1 deny-wins 规则
#![allow(unused)]
fn main() {
pub fn is_allowed(&self, tool_name: &str) -> bool {
// 1. 先检查 deny 列表——deny 始终优先
for entry in &self.deny {
if entry_matches(entry, tool_name) {
return false;
}
}
// 2. 空 allow 列表 = 允许所有未被 deny 的工具
if self.allow.is_empty() {
return true;
}
// 3. 非空 allow 列表 = 只允许列表中的工具
self.allow.iter().any(|entry| entry_matches(entry, tool_name))
}
}
deny-wins 意味着:如果一个工具同时出现在 allow 和 deny 列表中,它会被禁止。这是安全策略的基本原则——明确禁止的规则不应被任何允许规则覆盖。
6.3.2 通配符与分组
策略支持三种匹配扩展:
通配符:web_* 匹配 web_search、web_fetch。只支持尾部通配符(前缀匹配)。
分组:group:fs 展开为 ["read_file", "write_file", "edit_file", "diff_edit"]。
标签要求:当 require_tags 非空时,工具必须至少命中一个要求标签;但空标签工具仍然放行,作为“通用工具“存在(../octos/crates/octos-agent/src/tools/policy.rs:45-67)。
当前预定义分组包括:
| 分组 | 包含工具 |
|---|---|
group:fs | read_file, write_file, edit_file, diff_edit |
group:runtime | shell |
group:web | web_search, web_fetch, browser |
group:search | glob, grep, list_dir |
group:sessions | spawn |
group:memory | recall_memory, save_memory |
group:research | deep_search, synthesize_research, deep_crawl |
group:admin | manage_skills, configure_tool, model_check |
group:media | mofa_comic, mofa_slides, mofa_infographic, mofa_cards, fm_tts, fm_voice_list |
这里有个容易误解的点:分组是全局策略词汇表,不是“当前模式下肯定已经注册的工具表“。例如 group:admin 里同时列了 manage_skills、configure_tool、model_check,但在 chat 模式里通常只有 configure_tool,在 gateway 模式才可能同时具备三者。defer_group() 和 activate() 都会先检查工具名是否真的存在于当前 registry,不存在的名字会被静默跳过(../octos/crates/octos-agent/src/tools/policy.rs:98-152,../octos/crates/octos-agent/src/tools/registry.rs:373-406)。
6.3.3 Provider 级策略
当前配置字段不是 tools.byProvider,而是顶层的 tool_policy_by_provider。它按“精确 model ID 优先,其次 provider 名“做匹配(../octos/crates/octos-cli/src/config.rs:62-69,../octos/crates/octos-cli/src/commands/chat.rs:451-469)。例如:
{
"tool_policy_by_provider": {
"claude-sonnet-4-20250514": {
"deny": ["browser", "deep_search"]
},
"gemini": {
"allow": ["group:fs", "group:search"],
"require_tags": ["code"]
}
}
}
这里要区分两套 API:
apply_policy()是“硬裁剪“。它直接retain(),把不允许的工具从 registry 里物理删除。set_provider_policy()是“软过滤“。工具对象仍然保留,但specs()会把它们藏起来,execute()也会再次检查并拒绝调用。
这种分层很重要:全局配置里的 tool_policy 适合做系统级最小权限,tool_policy_by_provider 则适合针对不同模型做差异化曝光,而不破坏底层 registry 的完整性(../octos/crates/octos-agent/src/tools/registry.rs:280-303,../octos/crates/octos-agent/src/tools/registry.rs:532-595)。
6.4 参数安全:1MB 限制与非分配估算
6.4.1 1MB 参数大小限制
工具调用的参数大小被限制在 1MB(../octos/crates/octos-agent/src/tools/registry.rs:575-584):
#![allow(unused)]
fn main() {
const MAX_ARGS_SIZE: usize = 1_048_576; // 1 MB
}
这防止了 LLM 生成巨大的参数(比如将整个文件内容作为 edit_file 的参数),避免内存耗尽或下游处理超时。
6.4.2 estimate_json_size:零分配的大小估算
参数大小检查不通过 serde_json::to_string() 序列化后计算长度,而是通过递归遍历 JSON 值树估算大小(../octos/crates/octos-agent/src/tools/registry.rs:23-54):
#![allow(unused)]
fn main() {
fn estimate_json_size(value: &serde_json::Value) -> usize {
match value {
serde_json::Value::Null => 4,
serde_json::Value::Bool(true) => 4,
serde_json::Value::Bool(false) => 5,
serde_json::Value::Number(n) => n.to_string().len(),
serde_json::Value::String(s) => {
let escapes = s.bytes()
.filter(|&b| matches!(b, b'\"' | b'\\' | b'\n' | b'\r' | b'\t'))
.count();
s.len() + escapes + 2
}
serde_json::Value::Array(arr) => {
2 + arr.iter().map(estimate_json_size).sum::<usize>() + arr.len().saturating_sub(1)
}
serde_json::Value::Object(obj) => {
2 + obj.iter().map(|(k, v)| k.len() + 3 + estimate_json_size(v)).sum::<usize>()
+ obj.len().saturating_sub(1)
}
}
}
}
这个估算是 O(N) 时间、O(depth) 栈空间——不做堆分配,只遍历已有的 JSON 树。对于 1MB 级别的检查,精确到字节的准确性不重要,量级正确即可。
6.4.3 Symlink-safe I/O:O_NOFOLLOW
文件系统防护其实分成两层:
resolve_path()只做路径规范化和../绝对路径阻断,不访问文件系统,也不解决符号链接。- 真正的文件读写则交给
read_no_follow()/write_no_follow(),在 Unix 上通过O_NOFOLLOW原子地拒绝符号链接;目录类操作如list_dir才会单独使用reject_symlink()做防御补丁。
对应实现见 ../octos/crates/octos-agent/src/tools/mod.rs:241-299 和 ../octos/crates/octos-agent/src/tools/mod.rs:314-399。文件读写工具本身只是调用这些 helper,例如 read_file 在解析完路径后直接走 read_no_follow()(../octos/crates/octos-agent/src/tools/read_file.rs:65-107),write_file / edit_file 则分别调用 write_no_follow()(../octos/crates/octos-agent/src/tools/write_file.rs:59-89,../octos/crates/octos-agent/src/tools/edit_file.rs:64-115)。
在 Unix 平台上,关键代码只有一行:
#![allow(unused)]
fn main() {
// Unix 平台
opts.custom_flags(libc::O_NOFOLLOW);
}
O_NOFOLLOW 让 open() 系统调用在目标是符号链接时直接返回 ELOOP 错误,而不是跟随链接打开目标文件。这消除了 TOCTOU(Time-of-Check-Time-of-Use)竞态条件:
没有 O_NOFOLLOW 的场景:
- 检查
/workspace/config.json是否在允许范围内 ✓ - 攻击者将
/workspace/config.json替换为指向/etc/passwd的符号链接 - 打开
/workspace/config.json,实际读取了/etc/passwd
有 O_NOFOLLOW:
- 打开
/workspace/config.json,如果是符号链接,立即返回ELOOP✗
检查和打开合并为一个原子操作,消除了竞态窗口。
工程决策侧栏:为什么是“预延迟 + LRU“的混合策略
工具管理有三种策略可选:
方案一:全量注册(所有工具始终可见)
优势:
- 简单——不需要淘汰逻辑
- LLM 始终能调用任何工具
劣势:
- 30+ 工具的 ToolSpec 可能消耗 3,000-5,000 token 的上下文窗口
- 对于 128K 窗口的模型,5,000 token 的工具声明占比虽小,但对于 8K 窗口的小模型是不可接受的
- 过多选项可能导致 LLM 选择困难(“工具过载”)
方案二:纯手动按需加载(只有
activate_tools,没有自动激活)优势:
- 最节省上下文窗口
劣势:
- LLM 无法请求它不知道的工具,需要额外的发现机制
- 很容易出现“先猜一个不存在的工具名,再被迫重试“的额外轮次
方案三:预延迟 + LRU + 自动激活(当前 octos 的选择)
Gateway/ProfileFactory 在初始可见工具过多时,会先把
admin、sessions、web、runtime、media等低频分组预先defer_group();这样进入第一轮 LLM 调用前,工具面板就已经被压缩到较小规模(../octos/crates/octos-cli/src/commands/gateway/gateway_runtime.rs:977-1001,../octos/crates/octos-cli/src/commands/gateway/profile_factory.rs:507-522)。接下来,运行中的
tick() + auto_evict()再负责回收长期闲置的非核心工具;如果 LLM 直接请求了一个 deferred 工具,execute()会自动把它所属的整组重新激活。activate_tools仍然保留,但它更像一个“批量发现和预热工具“,而不是唯一通道。这套混合策略的关键,不是把一切都藏起来,而是尽量减少“为了解锁工具而多跑一轮模型“的概率。
6.5 本章回顾
工具系统是 Agent 能力的载体:
-
Tool trait:
name()/description()/input_schema()构成 LLM 可见的 ToolSpec,execute()执行实际操作,tags()/as_any()则承担过滤与框架集成这两类扩展职责。 -
ToolRegistry:真正的重点不是固定工具数量,而是分层注册。基础 registry、配置注入、chat/gateway 追加、per-session 追加共同决定当前模式下的工具面。
-
工具曝光控制:当前实现是“预延迟 + LRU + 自动激活“的混合模型,不是单纯的 LRU。
activate_tools是显式发现入口,但直接执行 deferred 工具也会自动唤醒对应分组。 -
ToolPolicy:deny-wins 语义确保安全策略不被覆盖。除了 allow/deny,还支持
require_tags。apply_policy()和set_provider_policy()分别对应硬裁剪与软过滤。 -
参数与文件安全:1MB 大小限制 + 零分配估算防止 DoS。路径规范化负责阻断 traversal,
O_NOFOLLOW负责原子拒绝符号链接,从而消除读写文件时的 TOCTOU 竞态。
下一章将深入安全体系的其他层次——从沙箱隔离到 prompt 注入防御(详见第 7 章)。
延伸阅读
- JSON Schema:https://json-schema.org/ — 理解
input_schema()返回的工具参数描述格式 - TOCTOU 竞态:CWE-367 “Time-of-check Time-of-use” — 理解
O_NOFOLLOW防御的攻击模式 - LLM 工具调用:Anthropic “Tool use” 文档 — 理解 LLM 如何选择和调用工具
- LRU 缓存算法:经典的最近最少使用淘汰策略
思考题
-
工具声明的 token 成本:假设每个 ToolSpec 平均消耗 150 token,15 个活跃工具消耗 2,250 token。如果上下文窗口只有 8K token,工具声明就占了 28%。你会如何进一步压缩 ToolSpec 的 token 占用?
-
deny-wins 的局限:deny-wins 策略能防止工具被直接调用,但如果 Agent 通过
shell工具执行curl命令来替代被禁的web_fetch,策略就被绕过了。你会如何应对这种间接调用? -
自定义工具的安全审查:如果用户通过 MCP 或 Plugin 添加自定义工具,这些工具不受 octos 的
O_NOFOLLOW保护。你会如何设计一个工具沙箱来隔离第三方工具?
版本演化说明 本章按当前源码撰写。阅读后续版本时,优先核对
../octos/crates/octos-agent/src/tools/registry.rs:605-703、../octos/crates/octos-cli/src/commands/chat.rs:162-269、../octos/crates/octos-cli/src/commands/gateway/gateway_runtime.rs:797-1001和../octos/crates/octos-cli/src/session_actor.rs:515-590这几处真实注册点,而不是只看tools/mod.rs的导出列表。工具类型会继续扩展,但“分层注册、软硬两级策略、预延迟与运行时激活并存“这三条主线更稳定。
第 7 章:安全纵深:从沙箱到 Prompt 注入防御
定位:本章以纵深防御的视角,从最外层的沙箱隔离到最内层的 prompt 注入检测,逐层展示 octos 的安全体系。前置依赖:第 6 章。适用场景:所有四类读者——Rust 开发者学习安全编码模式,AI 应用开发者学习 Agent 安全实践,octos 贡献者理解安全架构的设计理由。
AI Agent 的安全挑战独特而严峻:Agent 不只是处理数据——它执行代码、读写文件、发起网络请求。每一次工具调用都是一个潜在的攻击向量。更糟糕的是,Agent 的输入(用户消息、网页内容、文件内容)可能被恶意构造,通过 prompt 注入诱导 Agent 执行未授权操作。
octos 的安全策略是纵深防御——多层独立的安全屏障,任何单层失败都不会导致系统沦陷:
flowchart TB
subgraph "第一层:进程隔离"
SB["沙箱<br/>Bwrap / sandbox-exec / Docker"]
end
subgraph "第二层:网络安全"
SSRF["SSRF 防护<br/>私有 IP 阻断 + DNS 失败关闭"]
end
subgraph "第三层:工具安全"
TP["工具策略<br/>deny-wins + SafePolicy"]
end
subgraph "第四层:输出清理"
SAN["凭据脱敏<br/>7 类凭据 + data URI/hex 清理"]
end
subgraph "第五层:输入防护"
PG["Prompt Guard<br/>5 类威胁检测"]
end
subgraph "第零层:编译期保证"
UC["deny(unsafe_code)<br/>+ SecretString"]
end
SB --> SSRF --> TP --> SAN --> PG
UC -.->|"贯穿所有层"| SB
图 7-1:octos 安全纵深分层。 每一层独立工作,即使某一层被绕过,后续层仍然提供保护。
7.1 沙箱三后端
Shell 命令执行是 Agent 最危险的能力。octos 通过沙箱将命令执行隔离在受限环境中(crates/octos-agent/src/sandbox/)。
7.1.1 自动检测与选择
create_sandbox()(crates/octos-agent/src/sandbox/mod.rs:226-313)不是“按平台写死一张表”,而是执行一条有序探测链:
- 如果
sandbox.enabled = false,直接返回NoSandbox - 如果显式配置了
SandboxMode::{Bwrap,Macos,Docker,AppContainer,None},按指定模式创建 - 如果是
SandboxMode::Auto,则按顺序检查:- Linux 且
bwrap在 PATH 中 - macOS 且
sandbox-exec可用 - Windows 且
octos-sandboxhelper 可用 - Docker 可用
- 否则退回
NoSandbox
- Linux 且
这几点很关键。第一,Windows 自动模式检查的是 octos-sandbox helper,而不是抽象意义上的 “AppContainer 能力”;helper 既会在当前可执行文件同目录中查找,也会回退到 PATH 查找。第二,NoSandbox 是明确的失败回退路径:源码会打印警告,说明 shell 命令将“without isolation”运行,而不是静默降级。
7.1.2 Bwrap(Linux)
Bwrap(bubblewrap)是 Flatpak 项目的沙箱工具,使用 Linux namespaces 提供轻量级隔离(crates/octos-agent/src/sandbox/bwrap.rs:14-50)。octos 当前的包装过程更准确地说分成 8 步:
- 环境清理(
bwrap.rs:18-21):移除BLOCKED_ENV_VARS中的 18 个危险变量 - 只读系统绑定(
bwrap.rs:23-28):/usr、/lib、/lib64、/bin、/sbin、/etc以--ro-bind挂载 - 工作目录绑定(
bwrap.rs:30-32):用户工作区以--bind读写挂载 - 临时文件系统(
bwrap.rs:34-35):--tmpfs /tmp提供挥发性 scratch space - 最小设备与 proc 视图(
bwrap.rs:37-39):显式挂载--dev /dev和--proc /proc - 网络隔离(
bwrap.rs:41-43):当allow_network = false时附加--unshare-net - 进程生命周期控制(
bwrap.rs:45-47):--unshare-pid+--die-with-parent+--chdir - 命令执行(
bwrap.rs:48):最后才以sh -c执行目标命令
这说明 Bwrap 这一层的职责不是“审计命令是否安全”,而是把命令放进一个更小的执行宇宙里:只读系统目录、受控工作区、可选断网、独立 PID 视图。
7.1.3 macOS sandbox-exec 与 SBPL 注入防护
macOS 使用 sandbox-exec 运行沙箱,策略用 SBPL(Seatbelt Profile Language)编写(sandbox/macos.rs)。SBPL 是一种 Lisp 风格的语言,使用括号分隔的表达式——这意味着用户可控的路径名中的括号是潜在的注入向量。
octos 的防护措施(macos.rs:22-32):
#![allow(unused)]
fn main() {
// 检查 cwd 是否包含 SBPL 元字符
if cwd_str.bytes().any(|b| b < 0x20 || b == b'(' || b == b')' || b == b'\\' || b == b'"') {
tracing::error!("cwd contains SBPL metacharacters, refusing to execute");
// 返回一个只输出错误信息的命令,而非绕过沙箱执行
return error_command();
}
}
如果工作目录路径包含 (、)、\、" 等 SBPL 元字符,octos 拒绝执行——返回一个只输出错误消息的命令,而不是跳过沙箱执行原始命令。这是失败关闭(fail-closed)原则的体现。
另一个细节:macOS 上 /tmp 是指向 /private/tmp 的符号链接。SBPL 的 subpath 规则基于真实路径(canonical path)。如果用户传入 /tmp/work 但 SBPL 规则写的是 /tmp/work,写操作会被拒绝(因为真实路径是 /private/tmp/work)。octos 通过 std::fs::canonicalize() 解析真实路径(macos.rs:43-59),并对解析后的路径再次检查 SBPL 元字符。
7.1.4 Docker
Docker 后端(sandbox/docker.rs)提供最强的隔离,但开销也最大:
- Mount 模式:工作目录挂载为容器卷
- 资源限制:CPU、内存、PID 数量限制
- 网络隔离:可选的
--network=none
7.1.5 环境变量清理
无论使用哪个后端,所有沙箱都会清理 18 个危险环境变量(crates/octos-agent/src/sandbox/mod.rs:23-49):
| 类别 | 变量 | 攻击向量 |
|---|---|---|
| Linux 动态链接 | LD_PRELOAD, LD_LIBRARY_PATH, LD_AUDIT | 注入恶意共享库 |
| macOS 动态链接 | DYLD_INSERT_LIBRARIES, DYLD_LIBRARY_PATH 等 5 个 | 注入恶意 dylib |
| 运行时注入 | NODE_OPTIONS, PYTHONSTARTUP, PYTHONPATH, PERL5OPT, RUBYOPT, RUBYLIB, JAVA_TOOL_OPTIONS | 在子进程中注入代码 |
| Shell 启动 | BASH_ENV, ENV, ZDOTDIR | 修改 Shell 启动行为 |
BLOCKED_ENV_VARS 定义在沙箱模块里,但当前源码里的复用范围已经明显超出“沙箱 + MCP”这个最初口径。除了各沙箱后端与 MCP stdio server(crates/octos-agent/src/mcp.rs:372-390)之外,它还至少用于:
- Hooks 子进程(
crates/octos-agent/src/hooks.rs:498-501) - Browser / site crawl 启动 Chrome(
crates/octos-agent/src/tools/browser.rs:52-55、crates/octos-agent/src/tools/site_crawl.rs:94-96) - 执行环境抽象的 env 过滤(
crates/octos-agent/src/exec_env.rs:14-22) - 插件加载器与 CLI 进程管理(
crates/octos-agent/src/plugins/loader.rs:273-275、crates/octos-cli/src/process_manager.rs:281-340)
更准确的理解方式是:BLOCKED_ENV_VARS 已经演化成“启动外部进程时的共享注入黑名单”,而不只是 sandbox backend 的内部细节。
7.2 SSRF 防护
当 Agent 通过 web_fetch 或 browser 工具发起网络请求时,SSRF(Server-Side Request Forgery)保护确保请求不会到达内部网络(crates/octos-agent/src/tools/ssrf.rs)。
7.2.1 私有 IP 阻断
is_private_ip()(ssrf.rs:88-116)阻断以下地址范围:
IPv4:
127.0.0.0/8(回环)10.0.0.0/8、172.16.0.0/12、192.168.0.0/16(私有)169.254.0.0/16(链路本地——包含 AWS 元数据端点169.254.169.254)0.0.0.0(未指定)
IPv6:
::1(回环)、::(未指定)fc00::/7(ULA,唯一本地地址)fe80::/10(链路本地)fec0::/10(站点本地,已弃用但仍可路由)ff00::/8(多播)::ffff:x.x.x.x(IPv4 映射的 IPv6 地址——防止通过 IPv6 语法绕过 IPv4 检查)
7.2.2 三阶段 SSRF 验证
check_ssrf_with_addrs()(ssrf.rs:21-64)实现三阶段验证:
阶段 1:主机名字符串检查(ssrf.rs:27-29)。快速检查 localhost、localhost. 和字面 IP 地址(如 192.168.1.1),立即拒绝已知危险主机。
阶段 2:字面 IP 跳过(ssrf.rs:31-36)。如果 URL 中的 host 是字面 IP(已通过阶段 1 验证),不需要 DNS 解析,直接放行。
阶段 3:DNS 解析 + 结果验证(ssrf.rs:38-63)。对域名进行 DNS 解析,检查每一个返回的 IP 地址。如果任何一个 IP 是私有地址,拒绝整个请求。
7.2.3 DNS 失败关闭
阶段 3 的关键设计是失败关闭(fail-close):
#![allow(unused)]
fn main() {
match tokio::net::lookup_host(format!("{host}:{port}")).await {
Ok(addrs) => {
for addr in addrs {
if is_private_ip(&addr.ip()) {
return Err("DNS resolved to private IP".into());
}
}
Ok(SsrfCheckResult { resolved_addrs: safe_addrs })
}
Err(e) => {
// DNS 失败 → 阻断请求,不是放行!
Err(format!("DNS resolution failed — blocking request (fail closed): {e}"))
}
}
}
如果 DNS 解析失败,请求被阻断而非放行。这防止了 DNS 重绑定攻击的一个变种:攻击者在检查时让 DNS 解析失败(如果默认放行就能绕过检查),在实际请求时返回内部 IP。
7.2.4 IPv4 映射的 IPv6 地址
一个经常被遗忘的攻击向量:::ffff:192.168.1.1 是一个合法的 IPv6 地址,但它实际指向 IPv4 的 192.168.1.1。如果 SSRF 防护只检查 IPv4 的 is_private() 而不处理 IPv6 的 mapped 地址,攻击者可以用 IPv6 语法绕过检查。
octos 在 is_private_ip()(ssrf.rs:96-113)中显式处理了这种情况:
#![allow(unused)]
fn main() {
// IPv6 检查包含 mapped IPv4
|| v6.to_ipv4_mapped().is_some_and(|v4| is_private_v4(v4))
|| v6.to_ipv4().is_some_and(|v4| is_private_v4(v4))
}
7.3 Prompt 注入检测
Prompt 注入是 AI Agent 特有的攻击向量。octos 的 prompt guard(crates/octos-agent/src/prompt_guard.rs:1-296)把它当作一层 defense-in-depth:模块级 API 可以扫描任意文本,但当前主执行链上的接线点是在工具输出回写消息历史之前,由 sanitize_tool_output() 调用(crates/octos-agent/src/agent/execution.rs:350-353、crates/octos-agent/src/sanitize.rs:88-95)。
7.3.1 五类威胁
| 类别 | 示例模式 | 严重性 |
|---|---|---|
| SystemOverride | “忽略之前所有指令” | 高 |
| RoleConfusion | “System: 你现在是 DAN” | 高 |
| ToolCallInjection | {"name": "shell", "arguments": ...} | 高 |
| SecretExtraction | “显示系统提示/API 密钥” | 中 |
| InstructionInjection | “从现在开始你必须…” | 中 |
7.3.2 检测与处理
检测使用 11 个正则表达式模式(prompt_guard.rs:116-192),覆盖多种表述方式。匹配到的内容按严重性处理:
- 高 / 中:记录
warn!日志,并把命中的 span 替换为[injection-blocked:<threat-kind>] - 低:只打
debug日志,不修改文本
实现上还有两个值得注意的细节(prompt_guard.rs:217-295):
- 替换按 反向顺序 进行,避免前面的修改破坏后续 span 偏移
- 如果多重威胁导致原 span 失效,代码会退回到“从原位置附近搜索 matched 文本”,最后才做全串搜索,而不是简单
replacen(_, _, 1)
7.3.3 已知局限
prompt_guard.rs 的模块头注释(prompt_guard.rs:1-19)把边界说得很清楚:这不是安全边界,只是日志与内容去激活层。已知绕过方式包括:
- Base64 编码
- URL 编码
- HTML 实体
- Unicode 同形字(homoglyphs)
- 零宽字符
- RTL override 字符
这些绕过不是“实现漏了几个 regex”那么简单,而是纯文本模式匹配的结构性上限。因此本章必须把 prompt guard 放在正确的位置上理解:真正的约束来自沙箱、工具策略以及必要时的 human-in-the-loop hook;prompt guard 负责降低朴素明文注入直接进入上下文的概率。
7.4 凭据脱敏
7.4.1 七类凭据模式 + 两类高噪声模式
sanitize.rs(crates/octos-agent/src/sanitize.rs:1-95)把输出清理拆成两层:先移除高噪声/高风险片段,再脱敏具体凭据模式。
| 模式 | 匹配对象 | 正则 |
|---|---|---|
| OPENAI_KEY_RE | OpenAI API Key | sk-[A-Za-z0-9_-]{20,} |
| ANTHROPIC_KEY_RE | Anthropic API Key | sk-ant-[A-Za-z0-9_-]{20,} |
| AWS_KEY_RE | AWS Access Key ID | AKIA[0-9A-Z]{16} |
| GITHUB_TOKEN_RE | GitHub Token | (?:ghp_|gho_|ghs_|ghr_|github_pat_)... |
| GITLAB_TOKEN_RE | GitLab PAT | glpat-[A-Za-z0-9_-]{20,} |
| BEARER_RE | Bearer Token | Bearer\s+[A-Za-z0-9_.+/=-]{20,} |
| SECRET_ASSIGN_RE | 通用密钥赋值 | (?i)password|secret|api_key...=... |
上表是 7 类凭据模式;此外还有两类“不是凭据本身,但会污染上下文或携带敏感载荷”的模式:
DATA_URI_RE:base64 数据 URIHEX_RE:64+ 连续十六进制串,覆盖 SHA-256、SHA-512、原始 key material 等
7.4.2 脱敏策略
检测到凭据后,保留前 4 个可见字符作为上下文参考,其余替换为 [credential-redacted]。例如:
sk-proj-abc123... → sk-p...[credential-redacted]
保留前缀让开发者在调试时能快速识别是哪种类型的凭据被脱敏了。
7.4.3 工具输出清理
sanitize_tool_output() 在每次工具执行后应用,按顺序清理(sanitize.rs:88-95):
- Base64 数据 URI →
[base64-data-redacted] - 长十六进制串 →
[hex-redacted] - 各类凭据 → 保留前缀 +
[credential-redacted] - Prompt 注入内容 →
[injection-blocked:<kind>]
7.5 ShellTool SafePolicy
ShellTool::new() 默认就注入 SafePolicy::default()(crates/octos-agent/src/tools/shell.rs:25-36),因此它不是“可选增强项”,而是 shell 工具的默认前置检查。execute() 会先跑 policy,再决定是拒绝、要求批准,还是继续进入沙箱执行(crates/octos-agent/src/tools/shell.rs:90-126)。
7.5.1 危险命令拒绝
6 个 deny 模式(直接拒绝执行):
rm -rf / # 删除根文件系统
rm -rf /* # 删除根目录下所有文件
dd if= # 原始磁盘操作
mkfs # 格式化文件系统
:(){:|:&};: # Fork bomb
chmod -R 777 / # 递归修改根目录权限
4 个 ask 模式(需要用户确认,非交互环境下等同于拒绝):
sudo # 提权操作
rm -rf # 递归删除(不限于根目录)
git push --force # 强制推送
git reset --hard # 硬重置
7.5.2 Whitespace 归一化
在匹配前,命令字符串经过空白字符归一化(policy.rs:76-78)——多个空格、Tab、换行都被压缩为单个空格。这防止了 rm -rf / 或 rm\t-rf\t/ 的简单绕过。
7.5.3 词边界检测
模式匹配使用词边界检测(policy.rs:84-103),防止误判。例如,“sudo” 只在作为独立单词时匹配,不会匹配 “pseudocode” 中的子串。
7.5.4 SafePolicy 不是安全边界
源码文档对这点说得比“不是安全边界”更狠(crates/octos-agent/src/policy.rs:36-46):它只是在 whitespace-normalized 字符串上匹配一个很短的 deny/ask 列表,能抓住的主要是 rm -rf /、fork bomb 这种显眼事故。Shell 元字符、变量展开、编码技巧,以及任何不在列表里的危险命令都可以绕过它。
因此,SafePolicy 的真实定位不是“阻止恶意攻击者”,而是降低 LLM 误生成明显危险命令时的爆炸半径。真正的执行边界仍然是沙箱。另一个常被忽略的细节是:对 Ask 决策,当前 ShellTool 在非交互环境里直接拒绝执行,而不是暂停等待批准(crates/octos-agent/src/tools/shell.rs:104-116)。
7.6 基础设施安全
7.6.1 deny(unsafe_code)
workspace 级别的 deny(unsafe_code)(../octos/Cargo.toml:33-34)把“自有代码中不写 unsafe”提升成了 workspace 约束。当前成员不仅包括核心 runtime crate,还包括 octos-cli、octos-pipeline、octos-plugin、octos-sandbox 以及多个 app/platform skills(../octos/Cargo.toml:1-23)。这一点比“具体有多少个 crate、多少行代码”更重要,因为真正被固定下来的是工程纪律,而不是某个会漂移的数字。
7.6.2 SecretString
API 密钥使用 secrecy crate 的 SecretString 类型存储。当前 octos-llm 中的 OpenAI、Anthropic、OpenRouter、Gemini、Embedding/OpenAI Responses provider 都把 api_key 字段定义为 SecretString,只有在真正组装 HTTP header 时才显式 expose_secret()(例如 crates/octos-llm/src/openai.rs:93,320,429、crates/octos-llm/src/anthropic.rs:22,127,210)。这比“日志里会不会打印出来”更进一步:它让明文暴露点在代码里变成显式、可审查的调用点。
工程决策侧栏:workspace 级 deny(unsafe_code) 的实践意义
deny(unsafe_code)在 Rust 社区中并不罕见,但把它提升到 workspace 级别,约束 CLI、agent runtime、pipeline、sandbox helper 与 skills 相关 crate 一起遵守,是一个值得讨论的决策。支持的理由:
- 对于一个执行用户代码的 Agent 平台,内存安全漏洞的后果特别严重——攻击者可能通过 prompt 注入触发内存安全 bug
- 消除了代码审查中检查
unsafe正确性的负担——没有unsafe就没有这个负担- 所有系统交互通过
std::fs、std::process、tokio::fs等安全抽象完成,标准库的unsafe代码由 Rust 团队维护代价:
- 无法使用某些需要
unsafe的优化(如 SIMD 加速的 JSON 解析器simd-json)- 某些平台特定功能(如 Windows AppContainer)需要通过独立的辅助二进制程序(
octos-sandbox)实现- 依赖的第三方 crate 仍然可以包含
unsafe——deny(unsafe_code)只约束自己的代码octos 的判断: 对于一个执行不可信输入(LLM 生成的工具调用参数)的系统,消除自有代码中的内存安全风险是值得付出性能代价的。第三方 crate 的
unsafe由 crate 作者和社区审计负责。
7.7 本章回顾
octos 的安全体系是纵深防御的实践:
-
沙箱隔离:自动模式按
bwrap -> sandbox-exec -> Windows helper -> Docker -> NoSandbox链路探测后端,并配合 18 个环境变量清理隔离命令执行。 -
SSRF 防护:IPv4/IPv6 私有地址全面阻断 + DNS 失败关闭,防止内部网络探测。
-
工具策略:deny-wins 语义 + SafePolicy 危险命令拦截,控制 Agent 的行为边界。
-
凭据脱敏:7 类凭据模式 + 2 类高噪声模式,工具输出在回写历史前统一清理。
-
Prompt Guard:5 类威胁、11 个检测模式,中高严重性会被去激活,但它只是附加层,不是安全边界。
-
基础设施:
deny(unsafe_code)消除内存安全漏洞,SecretString防止凭据泄漏到日志。
没有任何单一安全措施是完美的——SafePolicy 可以被 Shell 元字符绕过,Prompt Guard 可以被编码变体绕过。但每一层都缩小了攻击面,让攻击者需要同时绕过多层防御才能造成损害。这就是纵深防御的价值。
延伸阅读
- OWASP Top 10 for LLM Applications:https://owasp.org/www-project-top-10-for-large-language-model-applications/
- Bubblewrap (bwrap):https://github.com/containers/bubblewrap — Linux 用户空间沙箱
- macOS Sandbox Profile Language:Apple 开发者文档 “Sandbox Design Guide”
- SSRF 攻击:PortSwigger Web Security Academy “Server-side request forgery” — 理解 SSRF 攻击向量
- Prompt Injection:Simon Willison, “Prompt injection attacks against GPT-3” — prompt 注入的早期研究
思考题
-
沙箱逃逸:假设攻击者通过 prompt 注入让 Agent 在沙箱内执行了恶意命令,但命令被沙箱限制在工作目录内。如果工作目录本身包含
.git/hooks/目录,攻击者能否通过修改 git hooks 在下次git commit时逃逸沙箱? -
DNS 重绑定:octos 的 SSRF 防护在请求前解析 DNS 并检查 IP。一种更高级的攻击是让 DNS 返回两个 IP(一个安全的外部 IP 通过检查,一个内部 IP 用于实际连接)。这种攻击在 octos 的实现中是否可行?
-
Prompt 注入的根本解决方案:正则表达式检测本质上是在与攻击者玩猫鼠游戏。你认为 prompt 注入有根本性的解决方案吗?如果有,是什么?如果没有,最好的缓解策略是什么?
-
凭据脱敏的过度与不足:当前的规则既可能误判(把正常的 64+ 字符 hex 串当作敏感数据),也可能漏判(不在 7 类已知凭据模式中的自定义 token)。你会如何在精确性和覆盖率之间取得平衡?
版本演化说明 本章分析基于 octos v0.1.0,安全相关代码分布在
crates/octos-agent/src/sandbox/、tools/ssrf.rs、prompt_guard.rs、sanitize.rs、policy.rs。截至本书写作时,沙箱后端和 SSRF 阻断规则无重大变化。Prompt Guard 的检测模式可能随新攻击方式的出现而扩展。
第 8 章:上下文管理:让 Agent 在有限窗口中高效工作
定位:本章展示 octos 如何通过上下文压缩(compaction)、保真度分级(fidelity)、提示层构建(prompt layer)和系统提示防篡改(prompt guard)四种机制,在有限的 LLM 上下文窗口中高效工作。前置依赖:第 5 章。适用场景:想理解上下文窗口管理策略的 AI 应用开发者(读者 C),以及需要优化 Agent 上下文使用的开发者(读者 B/D)。
LLM 的上下文窗口是稀缺资源。即使是 200K token 的窗口,一个复杂任务也可能在 10-20 次迭代后耗尽——每次迭代的工具调用参数和结果都在累积。当窗口接近满时,有两个选择:停止(放弃未完成的任务),或压缩(丢弃部分信息但继续工作)。octos 选择了后者。
8.1 Context Compaction:80% 触发的压缩策略
8.1.1 触发条件
Compaction 的预算检查发生在 [crates/octos-agent/src/agent/compaction.rs:9-40],它把上下文窗口先乘以 0.8,再除以 SAFETY_MARGIN = 1.2([crates/octos-agent/src/compaction.rs:10-17])。这等于一边预留 20% 的“新一轮对话空间”,一边再为 token 估算误差留出缓冲。
8.1.2 触发逻辑源码走读
80% 阈值的实际检查代码在 [crates/octos-agent/src/agent/compaction.rs:17-23]:
#![allow(unused)]
fn main() {
let window = self.llm.context_window();
let budget = (window as f64 * 0.8 / SAFETY_MARGIN) as u32;
let total: u32 = messages.iter().map(estimate_message_tokens).sum();
if total <= budget {
return; // 未超出预算,不压缩
}
}
注意实际预算是 window * 0.8 / 1.2 ≈ window * 0.67——80% 阈值再除以 1.2 的安全系数,因为 token 估算不完全精确。对于 128K 窗口的模型,实际触发点约在 85K tokens。
8.1.3 保留边界的确定
find_recent_boundary()([crates/octos-agent/src/compaction.rs:19-48])是压缩算法的核心——它决定了哪些消息保留原样、哪些被压缩:
#![allow(unused)]
fn main() {
pub(crate) fn find_recent_boundary(messages: &[Message], budget: u32, system_tokens: u32) -> usize {
let mut recent_tokens = 0u32;
let mut count = 0usize;
let mut split = messages.len();
// 从最后一条消息向前扫描
for i in (1..messages.len()).rev() {
let msg_tokens = estimate_message_tokens(&messages[i]);
count += 1;
// 保留至少 6 条,且不超过预算的一半
if count >= MIN_RECENT_MESSAGES
&& system_tokens + recent_tokens + msg_tokens > budget / 2
{
break;
}
recent_tokens += msg_tokens;
split = i;
}
// 关键:不在工具调用组中间切割
while split > 1 && messages[split].role == MessageRole::Tool {
split -= 1; // 向前回退,包含 Tool 消息对应的 Assistant 消息
}
split
}
}
这段代码的核心洞察是不对称保护:最近 6 条消息无条件保留(count >= MIN_RECENT_MESSAGES 之前不检查预算),但同时不让最近消息超过预算的一半(budget / 2)。这确保了压缩后仍有足够空间给旧消息的摘要。
工具组不可分割。 最后一个 while 循环向前回退,确保 split 点不会落在 Tool 消息上。如果 split 指向 Tool 消息,它属于一个 Assistant→Tool 的配对组——切割会导致孤立的 Tool 消息让 LLM 困惑。
8.1.3 压缩策略
压缩目标是将旧消息压缩到预算的 40%(BASE_CHUNK_RATIO = 0.4,[crates/octos-agent/src/compaction.rs:16-17])。对每条旧消息调用 summarize_message()([crates/octos-agent/src/compaction.rs:92-138]):
| 消息类型 | 压缩方式 |
|---|---|
| User | "> User: {content}" + [media omitted] |
| Assistant(有工具调用) | "Called {tool_name}" |
| Assistant(纯文本) | 首行摘要(200 字符截断) |
| Tool 结果 | 状态(ok/error)+ 输出前 100 字符 |
| System | 保留为上下文摘要 |
8.1.4 Compaction 触发流程
flowchart TD
Start["每次迭代开始"] --> Count["估算消息总 token 数"]
Count --> Check{"总量 > window × 0.67?"}
Check -->|"否"| Skip["不压缩,继续"]
Check -->|"是"| Boundary["find_recent_boundary()<br/>确定保留边界"]
Boundary --> Recent["最近 6+ 条消息<br/>保留原样"]
Boundary --> Old["较早消息"]
Old --> Summarize["summarize_message()<br/>工具名 + 首行摘要"]
Summarize --> Replace["替换原始消息为摘要"]
Replace --> Continue["继续 Agent 迭代"]
图 8-1:Compaction 触发流程。 80% × 1/1.2 ≈ 67% 是实际触发点。保留边界不会在工具调用组中间切割。
8.1.5 压缩策略源码走读
summarize_message()([crates/octos-agent/src/compaction.rs:92-138])对每种消息类型采用不同的压缩策略:
#![allow(unused)]
fn main() {
fn summarize_message(msg: &Message, context: &[Message]) -> String {
match msg.role {
MessageRole::User => {
// 用户消息:首行 + 媒体标记
let media_note = if msg.media.is_empty() { "" } else { " [media omitted]" };
format!("> User: {}{}", first_line(&msg.content, 200), media_note)
}
MessageRole::Assistant => {
let mut parts = Vec::new();
if let Some(ref calls) = msg.tool_calls {
for call in calls {
// 关键:只保留工具名,完全丢弃参数
parts.push(format!("- Called {}", call.name));
}
}
if !msg.content.is_empty() {
let prefix = if msg.tool_calls.is_some() { " " } else { "> Assistant: " };
parts.push(format!("{}{}", prefix, first_line(&msg.content, 200)));
}
parts.join("\n")
}
MessageRole::Tool => {
let tool_name = find_tool_name(msg, context);
let status = if msg.content.starts_with("Error:") { "error" } else { "ok" };
// 工具结果:状态 + 前 100 字符
format!(" -> {}: {} - {}", tool_name, status, first_line(&msg.content, 100))
}
MessageRole::System => {
format!("> Context: {}", first_line(&msg.content, 200))
}
}
}
}
工具参数剥离是最有效的压缩手段。考虑一个 write_file 工具调用——参数可能包含几百行的代码文件内容(上千 token)。压缩后变成一行 "- Called write_file"(约 5 token),压缩比高达 200:1。
测试验证了这个行为([crates/octos-agent/src/compaction.rs:260-272]):
#![allow(unused)]
fn main() {
fn test_compact_strips_tool_arguments() {
let messages = vec![
assistant_tool_call("write_file", "tc1"), // 参数包含 "/secret/file"
tool_result("tc1", "File written."),
];
let summary = compact_messages(&messages, 10000);
assert!(summary.contains("Called write_file")); // 工具名保留
assert!(!summary.contains("/secret/file")); // 参数完全消失
}
}
首行摘要([crates/octos-agent/src/compaction.rs:140-153]):first_line() 函数提取消息的第一行非空文本,UTF-8 安全截断到指定字符数(用户消息 200 字符,工具结果 100 字符)。信息密度在首行最高——LLM 的回复通常以结论或摘要开头。
还有一个容易漏掉的细节:如果“最近消息区”本身就已经超过预算,代码不会强行生成摘要,而是退回到 fallback_truncate(),从尾部向前保留消息,并继续避免把 tool-call group 拆开([crates/octos-agent/src/agent/compaction.rs:37-40]、[crates/octos-agent/src/agent/compaction.rs:79-115])。这说明 compaction 不是无条件的“摘要优先”,而是“能摘要就摘要,摘要也放不下就退化为截断”。
8.1.4 Fidelity 四档模式
压缩后的消息保真度可以分为四个级别:
| 档位 | 保留内容 | 丢弃内容 | 适用场景 |
|---|---|---|---|
| Full | 完整消息 | 无 | 最近 6 条消息 |
| Truncate | 内容截断到 N 字符 | 尾部内容 | 中等重要的历史消息 |
| Compact | 首行 + 工具名称 | 参数、详细输出 | 远期历史 |
| Summary | 单句摘要 | 几乎所有原始内容 | 极远期历史 |
实际实现中,当前版本的 compaction 主要使用 Compact 级别(首行摘要 + 工具名)。Summary 级别(LLM 生成的摘要)预留为未来优化方向。
8.2 Prompt Layer:分层系统提示构建
系统提示不是一个静态字符串——它由多个层次的信息组合而成。PromptLayerBuilder([crates/octos-agent/src/prompt_layer.rs:21-122])负责这个组装过程。
8.2.1 自动发现
discover() 方法([crates/octos-agent/src/prompt_layer.rs:56-80])从工作目录自动发现项目指令文件,但它的语义是按类别命中第一个可用文件,不是把目录中所有候选文件全部叠加:
| 文件名 | 用途 |
|---|---|
CLAUDE.md → .octos/instructions.md → .claude/instructions.md | 项目指令层;按顺序查找,命中第一个非空文件就停止 |
AGENTS.md → .octos/agents.md → agents.md | Agent 描述层;同样只取第一个命中的文件 |
真正被“层叠”的,是 build() 里的四类内容:基础 prompt、项目指令、AGENTS 描述,以及通过 with_extra() 注入的额外运行时层([crates/octos-agent/src/prompt_layer.rs:82-102])。换句话说,项目目录里不会同时把 CLAUDE.md 和 .octos/instructions.md 都装进去;但它们之上仍然可以继续叠加 runtime extra layers。
8.2.2 大小限制
MAX_PROMPT_FILE_SIZE = 64 * 1024([crates/octos-agent/src/prompt_layer.rs:10-19])——单个提示文件最大 64KB。这防止了恶意或意外的巨大文件耗尽上下文窗口。
8.3 Steering:会话中消息注入
Steering 模块([crates/octos-agent/src/steering.rs:1-45])定义了一套“会话中途注入消息”的原语:
#![allow(unused)]
fn main() {
pub enum SteeringMessage {
FollowUp(Message), // 注入用户追加问题
SystemReminder(String), // 系统级提醒
RequestPause, // 暂停等待用户输入
Cancel, // 取消当前任务
}
}
它通过异步 channel(默认缓冲 16,[crates/octos-agent/src/steering.rs:31-45])实现,接口包括 channel()、SteeringMessage 和非阻塞的 drain_pending()。
但这里必须和当前实现状态区分开:steering.rs 文件头部的 TODO 明确写着“还需要把 SteeringReceiver 接进 Agent Loop,才能在迭代间 drain 待处理消息并处理 Cancel/RequestPause”([crates/octos-agent/src/steering.rs:1-7])。也就是说,这个接口已经定义并有测试,但当前源码里还不是主循环的已接线能力。
因此,像“用户在任务执行中途追加一句话”“系统在超时前注入提醒”这些都是 steering 的目标场景,而不是今天这个版本已经稳定走通的运行时路径。
8.4 Prompt Guard:系统提示防篡改
Prompt Guard 已在第 7 章介绍了其 prompt 注入检测功能。在上下文管理的视角下,更准确的说法是:它为“把外部文本重新送回上下文”这一步提供一个额外的 defang 层。
scan() 会按正则匹配五类威胁:系统覆盖、角色混淆、工具调用注入、秘密提取、通用指令注入([crates/octos-agent/src/prompt_guard.rs:27-213])。sanitize_injection() 则只对 Medium/High 命中的 span 做替换,替换结果是 [injection-blocked:{kind}] 这样的标记;Low 级别只记录 debug 日志,不改写文本([crates/octos-agent/src/prompt_guard.rs:215-280])。
更关键的是接线位置。当前源码里,Prompt Guard 的已验证主路径是 sanitize_tool_output():先移除 base64 data URI、长 hex 和常见凭据,再调用 sanitize_injection(),最后才把工具结果回灌到对话历史([crates/octos-agent/src/sanitize.rs:88-95]、[crates/octos-agent/src/agent/execution.rs:350-367])。所以它现在主要保护的是工具输出回流这条链路;模块本身当然能扫描任意文本,但书里不应把它写成“当前已经统一改写所有用户输入”。
最后还要记住它的边界:源码注释明确写着 “Not a security boundary”。它能拦住朴素的明文注入,却挡不住 base64、Unicode 同形字、零宽字符等绕过;真正的安全控制仍然是 sandbox、tool policy 和 human-in-the-loop hook([crates/octos-agent/src/prompt_guard.rs:1-19])。
工程决策侧栏:为什么 80% 而非动态阈值
方案一:动态阈值(根据任务复杂度调整)
优势:
- 简单任务可以推迟压缩(比如问答场景不需要保留太多历史)
- 复杂任务提前压缩(为后续迭代预留更多空间)
劣势:
- 需要预测任务剩余迭代数——而这几乎不可能准确预测
- 复杂度评估本身消耗上下文和计算资源
- 可调参数增加了配置负担和不可预测性
方案二:预测式压缩(基于历史 token 增长率)
优势:
- 根据实际增长趋势动态调整
劣势:
- token 增长率不稳定(工具调用的输出大小高度可变)
- 预测错误可能导致提前压缩(丢失信息)或延迟压缩(溢出风险)
方案三:固定 80% 阈值(octos 的选择)
80% 是一个经过实践验证的平衡点:20% 的预留空间足以容纳一次典型迭代(系统提示 + 用户消息 + LLM 响应 + 一次工具调用结果),同时不会过早触发压缩导致不必要的信息损失。
固定阈值的核心优势是可预测性——开发者和用户可以准确知道什么时候会发生压缩,不需要理解复杂的动态逻辑。在 AI Agent 这种本身就充满不确定性的系统中,基础设施层面的确定性是珍贵的。
8.5 本章回顾
-
Context Compaction:80% 触发,保留最近 6 条完整消息,旧消息压缩到 40% 预算。工具参数剥离和首行摘要是主要压缩手段。
-
Fidelity 四档:Full(完整)→ Truncate(截断)→ Compact(首行摘要)→ Summary(单句摘要),从近到远递减保真度。
-
Prompt Layer:按类别发现首个可用的项目指令文件和 AGENTS 文件,再与 base prompt、extra layers 组装成最终系统提示;单文件上限 64KB。
-
Steering:当前源码已经定义了消息注入通道与消息类型,但主循环尚未正式接线;它更像一个已设计好的运行时扩展点。
-
Prompt Guard:一个 regex-based 的 defense-in-depth 层。当前主要接在工具输出清洗链上,对
Medium/High风险做 defang,而不是把它当成完整安全边界。
延伸阅读
- Context Window 管理:Anthropic “Long context window tips” — 长上下文使用的最佳实践
- RAG vs 长上下文:比较检索增强生成与大窗口直接输入的 trade-off
- 信息检索中的摘要:Luhn 的自动文摘方法——理解首行摘要的理论基础
思考题
-
压缩信息的恢复:当前的 compaction 是不可逆的——被压缩的消息无法恢复原始内容。如果在压缩后 Agent 需要回顾早期工具调用的详细参数,应该怎么办?
-
智能摘要 vs 提取式摘要:当前的压缩是提取式的(首行、工具名)。如果使用 LLM 生成抽象式摘要,能否在同等 token 预算下保留更多信息?代价是什么?
-
多 Agent 上下文共享:如果两个 Agent 协作处理同一个任务,它们的上下文窗口如何共享?独立压缩还是协调压缩?
版本演化说明 本章分析基于 octos v0.1.0,上下文管理代码位于
crates/octos-agent/src/compaction.rs、prompt_layer.rs、steering.rs、prompt_guard.rs。截至本书写作时,80% 阈值和 6 条消息保留策略无重大变化。Fidelity 的 Summary 级别(LLM 生成摘要)可能在后续版本中实现。
第 9 章:扩展机制:Skills、Plugins 与 MCP
定位:本章展示 octos 当前源码中的三种扩展机制:Skills(Markdown 声明式)、Plugins(本地可执行工具 / skill package extras)、MCP(标准化协议集成)。前置依赖:第 6 章。适用场景:想为 octos 编写自定义扩展的贡献者,以及想理解 Agent 扩展架构设计的开发者。
Agent 的价值来自适配不同场景的能力。法律文书审查需要法律提示,研究 Agent 需要长时后台任务,远程服务集成又需要标准协议。把所有扩展都塞进同一种机制,会让简单需求过度工程化,也会让复杂需求被迫挤进不合适的抽象。
octos 当前的答案不是“一种万能插件”,而是三条互补轨道:
- Skills:改变 Agent 的提示与上下文
- Plugins:把本地可执行程序包装成 Tool,并承载 skill package extras
- MCP:通过标准协议连接外部工具服务器
9.1 Skills 轨道:Markdown 声明式扩展
Skills 是最轻量的扩展机制。一个 skill 的核心就是一个 SKILL.md,外加可选的 manifest.json。
9.1.1 SKILL.md 格式
---
name: code-review
description: Review code changes for bugs, security issues, and style
version: 1.0.0
requires_bins: rg,git
requires_env: GITHUB_TOKEN
---
When reviewing code, focus on:
1. Security vulnerabilities
2. Error handling completeness
3. Behavior regressions
SkillsLoader 并没有实现完整 YAML 解析器。它做的是两步简化处理:
- 用
split_frontmatter()找到首尾---之间的 frontmatter 块(crates/octos-agent/src/skills.rs:235-252) - 用
fm_value()从简单的key: value行里读取name、description、requires_bins、requires_env、always等字段(crates/octos-agent/src/skills.rs:178-232,255-276)
这意味着 skill 元数据的设计目标不是“表达力最大”,而是“足够稳定、足够便宜”。available 的判断也来自这里:requires_bins 里的命令都能找到、requires_env 里的环境变量都存在,skill 才算可用(crates/octos-agent/src/skills.rs:196-212)。
9.1.2 SkillsLoader 与分层覆盖
SkillsLoader 本身只维护一个“技能目录列表”,真正的优先级是在 runtime 里组装出来的(crates/octos-agent/src/skills.rs:31-176)。当前 gateway 路径的优先级是:
data_dir/skills- 父 profile 的
.../skills(子账号场景) project_dir/skillsproject_dir/bundled-app-skillsOCTOS_SKILLS_PATH指定的额外目录- 编译进二进制的 built-in skills
这套层次来自 crates/octos-cli/src/commands/gateway/gateway_runtime.rs:461-488 和 crates/octos-cli/src/commands/gateway/profile_factory.rs:272-284。实现方式也很有意思:loader 先放入 builtins,再按“低优先级目录先扫描,高优先级目录后覆盖”的顺序遍历,并通过 retain 删掉同名旧 skill(crates/octos-agent/src/skills.rs:68-108)。
所以这不是简单的“工作区覆盖全局”三层模型,而是一个更细的 layered view。读者如果只记住“先 profile,再 project,再 bundled,再 env path,最后 builtin”,就已经抓住当前实现的主线了。
9.1.3 XML 技能索引
build_skills_summary() 会把当前可见的 skill 集合转成 XML,注入系统提示(crates/octos-agent/src/skills.rs:137-154):
<skills>
<skill available="true" tools="true">
<name>deep-search</name>
<description>Deep web research...</description>
<location>/.../SKILL.md</location>
</skill>
</skills>
这里有三个容易写错的点:
- 当前 XML 里没有
name="..."属性,而是<name>子节点 tools="true"的含义是“该 skill 目录包含manifest.json”,不是“这个 skill 正在执行工具”location会把 skill 的真实来源路径暴露给模型,帮助它区分 builtin 与外部 skill
因此 XML 摘要不是单纯的“可用技能列表”,它还是模型可见的 技能目录索引。
9.1.4 spawn_only:自动后台化,而不是隐藏工具
spawn_only 标记定义在 plugin/skill manifest 的工具项上(crates/octos-agent/src/plugins/manifest.rs:98-116),但它的运行时语义并不在 manifest 里,而在 registry 和 agent 执行循环里:
PluginLoader会把这些工具名登记为spawn_only(crates/octos-agent/src/plugins/loader.rs:93-113)ToolRegistry为它们维护自定义提示文案和任务跟踪状态(crates/octos-agent/src/tools/registry.rs:123-178)- 主 agent 发现某次 tool call 命中
spawn_only时,不同步执行,而是直接后台tokio::spawn一个任务,立刻向模型返回spawn_only_message(crates/octos-agent/src/agent/execution.rs:105-245)
这意味着 spawn_only 不是“从 ToolSpec 里隐藏掉”。按当前实现,它们仍然注册在工具系统里并对模型可见;差别只是调用时会被自动后台化。
更进一步,resolve_extras() 还会在 skill package 含有 spawn_only 工具时自动把 SKILL.md 本身注入 prompt fragments(crates/octos-agent/src/plugins/extras.rs:52-61)。这样模型既能看到工具,也能同时拿到“什么时候该用这个后台工具”的提示上下文。
而到了 subagent 场景,registry 会调用 clear_spawn_only() 清空这些标记,因为“subagent 本身就是后台上下文”,此时工具会像普通工具一样直接执行(crates/octos-agent/src/tools/registry.rs:136-143)。
9.2 Plugins 轨道:本地可执行工具与 skill package extras
如果说 Skills 负责改变 Agent 的“思维方式”,Plugins 负责的就是让 Agent 真正调用外部程序完成工作。
9.2.1 runtime manifest:不只是工具声明
当前 runtime 热路径使用的是 crates/octos-agent/src/plugins/manifest.rs 中的 manifest 结构:
{
"name": "weather",
"version": "1.0.0",
"tools": [
{
"name": "get_weather",
"description": "Get current weather for a location",
"input_schema": {
"type": "object",
"properties": {
"city": { "type": "string" }
}
}
}
],
"sha256": "a1b2c3...",
"timeout_secs": 600,
"requires_network": true
}
但把它理解为“纯工具 manifest”已经不够了。当前这个结构还支持:
mcp_servershooksprompts.includebinariesspawn_onlyspawn_only_message
因此它更接近一个 skill package runtime manifest。如果 manifest.tools 为空,但声明了 MCP、hooks 或 prompt fragments,PluginLoader 会跳过可执行文件搜索,照样把 extras 装进系统(crates/octos-agent/src/plugins/loader.rs:167-179)。
9.2.2 Plugin 二进制协议
sequenceDiagram
participant Agent
participant Plugin as Verified Executable
Agent->>Plugin: exec(".weather_verified", argv[1]="get_weather")
Agent->>Plugin: stdin: {"city":"Beijing"}
Plugin->>Agent: stderr: line-oriented progress
Plugin->>Agent: stdout: {"output":"Beijing: 25°C, sunny","success":true}
Agent->>Plugin: process exits
图 9-1:Plugin 二进制协议时序图。
这里的实现细节比“stdin JSON / stdout JSON”稍复杂(crates/octos-agent/src/plugins/tool.rs:124-419):
- runtime 实际执行的是经过 hash 校验后写出的
._verified副本 - argv 第一个参数是 tool name
- stdin 发送 JSON 参数
- stderr 逐行读出并转成
ToolProgress事件 - stdout 优先按结构化 JSON 解析
- 如果 stdout 不是合法 JSON,runtime 会退回到“原样拼接 stdout + stderr 文本”
结构化 stdout 还支持比 output/success 更丰富的语义:
file_modifiedfiles_to_send
此外 runtime 还会尝试从 out 参数或输出文本里自动探测生成文件,并触发自动回传(crates/octos-agent/src/plugins/tool.rs:321-403)。所以 Plugin 协议的真实价值是:把“外部进程”包装成“可流式报告进度、可自动回传文件的 Tool”。
9.2.3 安全与运行时约束
Plugin 这一层的安全措施有几道是必须写清楚的。
第一道:可执行发现是保守的。
PluginLoader 只把“子目录 + manifest.json”当成候选项。真正找二进制时,会依次尝试:
manifest.name- 目录名
main- 目录中任意可执行且非隐藏、非
.json/.md/.toml/.tar.gz的文件
逻辑在 crates/octos-agent/src/plugins/loader.rs:181-211。
第二道:SHA-256 校验不是“验完原文件再直接运行”。
Loader 先把原始字节读进内存,再对内存字节做 hash 校验,然后把同一份已验证字节写到 ._verified 文件,后续真正执行的是这份副本(crates/octos-agent/src/plugins/loader.rs:226-271)。这一步的目的是封住典型 TOCTOU 窗口。
第三道:资源与环境约束。
- 100MB 可执行文件上限(
crates/octos-agent/src/plugins/loader.rs:213-224) - 继承
BLOCKED_ENV_VARS黑名单(crates/octos-agent/src/plugins/loader.rs:273-275、crates/octos-agent/src/plugins/tool.rs:140-148) - 运行时注入
OCTOS_WORK_DIR给 plugin 放输出文件(crates/octos-agent/src/plugins/tool.rs:150-164) - 默认超时其实是 600 秒,不是 30 秒(
crates/octos-agent/src/plugins/tool.rs:35-48);manifest 的timeout_secs只是覆盖默认值(crates/octos-agent/src/plugins/loader.rs:276-279)
第四道:Unix 上的符号链接拒绝。
is_executable() 用 symlink_metadata() 检查文件类型,只接受普通文件,不接受符号链接(crates/octos-agent/src/plugins/loader.rs:332-340)。这不是全部安全边界,但能减少 link-swap 这类替换攻击面。
9.2.4 runtime PluginLoader 与 octos-plugin SDK 的边界
这一章最容易写错的地方,是把仓库里的两层代码混成一层。
当前 runtime 热路径 是 crates/octos-agent/src/plugins/loader.rs:
- 扫描调用方传入的目录
- 逐个加载子目录 manifest
- 解析 extras
- 查找并校验可执行文件
- 生成 verified copy
- 注册工具到
ToolRegistry - 单个 plugin 失败时
warn!并跳过,不影响其他插件加载(crates/octos-agent/src/plugins/loader.rs:73-140)
crates/octos-plugin 则是 SDK / tooling crate,提供的是另一层抽象:
discover_plugins():按来源优先级扫描目录并去重(crates/octos-plugin/src/discovery.rs:20-56)check_requirements():做bins/env/os三类 gating(crates/octos-plugin/src/gating.rs:37-123)- richer manifest:
id/type/requires/install/...(crates/octos-plugin/src/manifest.rs:76-202)
两层有关联,但不能混为一谈。当前主 agent runtime 并不是“每次都先跑 octos-plugin::discover_plugins() 再加载”,而是直接走 octos-agent 自己的 PluginLoader。如果你写的是 runtime tool,要看 octos-agent/src/plugins/*;如果你写的是校验器、市场、安装器、离线发现逻辑,要看 octos-plugin crate。
octos-plugin 的 gating 模型仍然值得理解,因为它定义了生态层的约束语义:
| 检查 | 方法 | 失败处理 |
|---|---|---|
| Binary | which 检查依赖程序是否在 PATH 中 | 标记 unavailable / 跳过 |
| Env | 检查必要环境变量是否存在 | 标记 unavailable / 跳过 |
| OS | 检查当前平台是否在允许列表中 | 标记 unavailable / 跳过 |
还有一个很小但真实的细节:gating 把 darwin 和 macos 当成等价别名,避免 manifest 和 Rust 平台字符串不一致时误伤(crates/octos-plugin/src/gating.rs:73-104)。
9.3 MCP 集成:标准协议,不等于“远程插件”
MCP(Model Context Protocol)是标准化的工具与上下文集成协议。octos 的 MCP client 位于 crates/octos-agent/src/mcp.rs,支持两条接入路径。
9.3.1 Stdio vs HTTP POST(可选 SSE 响应)
| 特性 | Stdio 传输 | HTTP 传输 |
|---|---|---|
| 连接方式 | 本地子进程 + stdin/stdout JSON-RPC | HTTP POST JSON-RPC |
| 响应格式 | 单行 JSON | 普通 JSON 或 text/event-stream |
| 延迟 | 极低(本地 IPC) | 受网络与远端服务影响 |
| 主要安全面 | 子进程环境清理 | SSRF 检查 + DNS pinning |
| 适用场景 | 本地 MCP server | 远程 MCP 服务 |
把第二条路径直接叫成“HTTP-SSE transport”会误导读者。当前实现其实是:
- 请求通过 HTTP POST 发送 JSON-RPC(
crates/octos-agent/src/mcp.rs:179-198) - client 用
Accept: application/json, text/event-stream同时接受 JSON 或 SSE(crates/octos-agent/src/mcp.rs:182-183) - 如果响应
content-type包含text/event-stream,再从 SSE body 中提取最后一个data:事件作为 JSON-RPC 结果(crates/octos-agent/src/mcp.rs:225-255)
因此更准确的说法是:HTTP POST,SSE 只是可选响应封装。
另一个容易被漏掉的点是会话亲和:如果服务端返回 mcp-session-id,client 会保存它,并在后续请求中回放为 Mcp-Session-Id header(crates/octos-agent/src/mcp.rs:189-205)。
9.3.2 启动与安全约束
MCP client 在两个不同阶段做不同的安全控制。
发现阶段:schema 约束。
启动 server 后,client 会先跑 initialize,再调用 tools/list,并对每个 tool 的 input_schema 做验证(crates/octos-agent/src/mcp.rs:308-361,500-524):
| 约束 | 值 | 作用点 |
|---|---|---|
| 最大嵌套深度 | 10 | validate_schema() |
| 最大序列化大小 | 64KB | validate_schema() |
超出限制的 tool 不会让整个 server 启动失败,而是 跳过该 tool 并记录 warning。
执行阶段:transport 约束。
| 约束 | 值 | 适用面 |
|---|---|---|
| 单行响应上限 | 1MB | 仅 stdio read_line_limited() |
| tool call 超时 | 60 秒 | McpTool::execute() |
| env 清理 | BLOCKED_ENV_VARS | 仅 stdio transport |
| SSRF + DNS pinning | 开启 | 仅 HTTP transport |
这里最需要纠正的误解是:1MB 不是所有 MCP 响应的统一全局上限。它只作用在 stdio transport 的单行 JSON-RPC 响应上(crates/octos-agent/src/mcp.rs:20-21,118-143)。HTTP 路径当前没有对整个响应体加同样的总字节限制;它依赖的是 SSRF 检查、DNS pinning、状态码检查和 60 秒请求超时。
9.3.3 名称保护与注册
MCP client 发现到的 tool 不会直接无条件塞进 registry。注册前还有一道保护:PROTECTED_NAMES(crates/octos-agent/src/mcp.rs:454-497)列出了 19 个内置工具名,MCP tool 如果发生同名碰撞会被直接跳过。
这一步的意义不是美观,而是防止远端 MCP server 静默替换核心能力。例如,如果没有这层保护,一个外部 server 理论上可以注册一个同名 shell 或 browser,把模型对“内置工具”的调用流量劫持过去。
工程决策侧栏:为什么需要三种扩展机制
维度 Skills Plugins MCP 核心作用 改提示与上下文 跑本地可执行工具 接外部协议化工具服务器 主要载体 SKILL.mdmanifest.json+ executableJSON-RPC server 运行边界 无独立执行边界 外部进程 + verified copy 本地/远程连接 典型增值点 低成本行为定制 进度流、文件回传、后台任务 跨 Agent 平台复用 安全面 可用性检查而非隔离 hash 校验 + env 清理 + work dir SSRF + schema 验证 + 名称保护 为什么不能统一成一种?
因为它们解决的不是同一类问题。Skills 让模型学会“怎么想”,Plugin 让系统学会“怎么做”,MCP 让系统学会“怎么接别人的能力”。
如果把 Skills 也做成 Plugin,会让纯提示定制被迫带上二进制、协议和运行时安全成本。反过来,如果把 Plugin 做成纯 Skill,又无法提供真实执行、进度流和文件产出。MCP 看起来和 Plugin 都像“工具扩展”,但它追求的是协议互操作,而不是本地 runtime 集成的最低摩擦。
9.4 本章回顾
-
Skills:通过
SKILL.md和少量 frontmatter 元数据改变模型上下文。runtime 会把多层目录压成一个去重后的 skill 视图,再生成 XML 摘要注入系统提示。 -
Plugins:把本地可执行程序包装成 Tool,同时承载 skill package extras。runtime
PluginLoader负责发现、hash 校验、verified copy、env 清理、工作目录注入和非致命跳过。 -
spawn_only:不是隐藏工具,而是把工具调用自动后台化。主 agent 返回即时消息,后台任务继续跑;subagent 上下文里则把它恢复成普通工具。 -
MCP:不是“远程插件”,而是标准化协议接入。octos 当前支持 stdio 和 HTTP POST(可选 SSE 响应),并用 schema 验证、名称保护、SSRF 与 DNS pinning 约束风险。
-
架构边界:
octos-agent/src/plugins/*是当前 runtime 热路径;crates/octos-plugin是 SDK / tooling crate。把这两层分清,读源码时就不会迷路。
Part 2 到此结束。下一章开始 Part 3,从单机会话推进到消息总线与多会话编排。
延伸阅读
- Model Context Protocol:https://modelcontextprotocol.io/
- JSON-RPC 2.0:https://www.jsonrpc.org/specification
- Bubblewrap / sandbox-exec:理解本地可执行扩展为什么必须配合进程级安全边界
思考题
-
Skills 的边界:如果一个需求同时需要提示注入和真实执行能力,你会把逻辑拆成
SKILL.md + Plugin,还是尽量压缩成单一 package?为什么? -
Plugin 信任链:当前 verified copy 解决了 TOCTOU,但如果 manifest 与原始二进制一起被替换,hash 仍然会“自洽”。你会如何把信任链再往前推进一层?
-
HTTP MCP 的响应体:stdio 路径有 1MB 单行上限,HTTP 路径当前没有等价的全局 body cap。你会不会补这一层?如果补,应该放在哪一层最合适?
版本演化说明 本章分析基于 octos v0.1.0。Skills 与 runtime plugin 相关代码主要位于
crates/octos-agent/src/skills.rs、plugins/、mcp.rs;生态层 discovery/gating 位于crates/octos-plugin/src/。截至本书写作时,MCP 的 transport model、spawn_only语义以及 runtime/plugin SDK 的边界都值得按源码重新核对,不宜直接沿用旧文档口径。
第 10 章:octos-bus:14 频道的统一消息抽象
定位:本章深入 octos-bus crate(约 19,600 行),展示如何用
Channeltrait 抽象统一 14 种消息频道,以及会话管理和消息分片的工程实现。前置依赖:第 5 章。适用场景:想理解多频道消息平台架构的开发者(读者 B),以及需要接入新频道的贡献者(读者 D)。
当 Agent 从单用户 CLI 走向多用户平台时,消息接入层的复杂度急剧上升。Telegram 的消息长度限制是 4,000 字符,Discord 是 1,900;Slack 用 Block Kit 格式化消息,飞书用 Rich Text;邮件是异步的,WhatsApp 需要模板消息。octos-bus 用一个 Channel trait 统一了这些差异。
10.1 Channel trait:统一消息接口
Channel trait([crates/octos-bus/src/channel.rs:17-190])定义了所有频道的统一接口:
当前版本的 Channel trait 一共有 23 个方法,但真正没有默认实现的只有 3 个:name()、start()、send()。其余能力都以默认实现挂在 trait 上,真实频道按需覆盖:
#![allow(unused)]
fn main() {
#[async_trait]
pub trait Channel: Send + Sync {
fn name(&self) -> &str;
async fn start(&self, inbound_tx: mpsc::Sender<InboundMessage>) -> Result<()>;
async fn send(&self, msg: &OutboundMessage) -> Result<()>;
fn max_message_length(&self) -> usize; // 默认 4000,可覆盖
fn is_allowed(&self, _sender_id: &str) -> bool { true }
async fn send_typing(&self, _chat_id: &str) -> Result<()> { Ok(()) }
fn supports_edit(&self) -> bool { false }
async fn send_with_id(&self, msg: &OutboundMessage) -> Result<Option<String>> { ... }
async fn edit_message(&self, ...) -> Result<()> { ... }
async fn finish_stream(&self, ...) -> Result<()> { ... }
// + 更多默认方法:stop, send_typing_as, stop_typing, send_listening,
// delete_message, edit_message_with_metadata, send_raw_sse, ...
}
}
这是一种典型的“大 trait + 多默认实现”设计:简单频道只实现 3 个基础方法就能工作;成熟频道则会继续覆盖 max_message_length()、supports_edit()、send_with_id()、edit_message()、format_outbound()、health_check() 等扩展点。这样做的代价是 trait 面会比较宽,但收益是所有平台能力都能通过一个统一抽象暴露给 Gateway。
关键方法:start() 接收一个 mpsc::Sender<InboundMessage>,频道将收到的用户消息通过这个 sender 发送给 Agent 处理层;send() 负责把 Agent 响应发送回目标频道;max_message_length() 虽然有默认值 4000,但 Discord、Slack、Twilio、WeCom 等真实实现都会覆盖它(例如 Discord=1900,Slack=3900,Twilio=1600)。
send_with_id() 返回消息 ID,支持后续编辑(流式输出场景下先发送占位消息,再逐步更新内容)。默认实现委托给 send() 并返回 None。
10.1.1 流式编辑三步法
对于支持消息编辑的频道(supports_edit() 返回 true),octos 使用三步法实现流式输出:
send_with_id():发送初始消息(可能只有几个 token),返回平台消息 IDedit_message():随着 LLM 流式输出,不断更新同一条消息的内容([crates/octos-bus/src/channel.rs:85-107])finish_stream():流结束后发送最终版本;默认实现仍回退到edit_message()([crates/octos-bus/src/channel.rs:95-107])
这种模式让用户看到 Agent 的回复逐渐生成,而不是等待完整响应后一次性显示。Telegram 和 Discord 都支持这种模式。对于不支持编辑的频道(如邮件),退回到等待完整响应后一次性发送。
10.1.2 AgentHandle 对称设计
消息总线使用 AgentHandle / BusPublisher([crates/octos-bus/src/bus.rs:8-77])连接频道和 Agent 处理层:
#![allow(unused)]
fn main() {
// AgentHandle 包含双向通道
struct AgentHandle {
in_rx: Receiver<InboundMessage>, // Agent 从这里接收消息
out_tx: Sender<OutboundMessage>, // Agent 从这里发送响应
}
struct BusPublisher {
in_tx: Sender<InboundMessage>, // 频道从这里发送消息给 Agent
out_rx: Receiver<OutboundMessage>, // 频道从这里接收 Agent 响应
}
}
这种对称设计的优势是:当所有 Channel 关闭时(所有 inbound_tx 被 drop),inbound_rx.recv() 返回 None,Agent 处理层自动感知到没有更多消息,可以优雅退出。不需要额外的 shutdown 信号。
10.1.3 is_allowed:发送者鉴权
is_allowed()([crates/octos-bus/src/channel.rs:27-30])在消息路由到 Agent 之前检查发送者是否有权使用 Agent。默认实现返回 true(允许所有人),各频道可以覆盖实现自定义鉴权逻辑,例如 Telegram 可以限制只有特定 chat_id 的用户才能访问。
10.2 消息 Coalescing:5 级切割策略
当 Agent 的回复超过频道的字符限制时,需要将长消息分割为多个短消息。octos-bus 的 coalescing 算法([crates/octos-bus/src/coalesce.rs:26-120])按 5 个优先级尝试切割:
flowchart TD
Input["长消息"] --> P1["1. 段落分割 \\n\\n"]
P1 -->|"找到"| Emit1["发送段落"]
P1 -->|"未找到"| P2["2. 换行分割 \\n"]
P2 -->|"找到"| Emit2["发送行"]
P2 -->|"未找到"| P3["3. 句子分割 . + 空格"]
P3 -->|"找到"| Emit3["发送句子"]
P3 -->|"未找到"| P4["4. 空格分割"]
P4 -->|"找到"| Emit4["发送词组"]
P4 -->|"未找到"| P5["5. 硬切<br/>UTF-8 安全边界"]
P5 --> Emit5["发送截断块"]
图 10-1:5 级消息切割策略。 优先在语义边界切割,硬切是最后手段。
MAX_CHUNKS = 50:防止极长消息被分割为数百个小消息导致 DoS。超过上限时,代码不会继续在最后一块后面追加文本,而是单独插入一个 "[message truncated - N chars omitted]" 的截断块([crates/octos-bus/src/coalesce.rs:46-57])。
UTF-8 安全:硬切时使用 is_char_boundary() 回退到安全的字符边界(与 octos-core 的 truncate_utf8 使用相同的策略,详见第 2 章)。
平台特定限制([crates/octos-bus/src/coalesce.rs:5-24]):
| 频道 | 字符限制 | 配置方法 |
|---|---|---|
| Telegram | 4,000 | ChunkConfig::telegram() |
| Discord | 1,900 | ChunkConfig::discord() |
| Slack | 3,900 | ChunkConfig::slack() |
| 无限制 | 不调用 coalescing | |
| 默认 | 4,000 | ChunkConfig::default_limit() |
10.2.1 find_break_point:核心分割逻辑
find_break_point()([crates/octos-bus/src/coalesce.rs:84-120])是切割的核心——但真正的切割过程分两步:先在 max_chars 以内找一个 UTF-8 安全的搜索窗口,再在这个窗口内寻找最自然的断点。
#![allow(unused)]
fn main() {
let mut limit = config.max_chars.min(remaining.len());
while limit > 0 && !remaining.is_char_boundary(limit) {
limit -= 1;
}
let search = &remaining[..limit];
let break_at = find_break_point(search);
chunks.push(remaining[..break_at].trim_end().to_string());
remaining = remaining[break_at..].trim_start_matches('\n');
if remaining.starts_with(' ') && !remaining.starts_with(" ") {
remaining = &remaining[1..];
}
}
find_break_point(search) 内部依次对 \n\n、\n、. 、空格做 rfind()(从右向左搜索),只有完全找不到自然边界时才硬切。这保证断点尽量靠近上限,同时避免把多字节字符切坏。trim_end()、trim_start_matches('\n') 和“最多跳过一个前导空格”的小处理,则让最终发出去的块看起来更干净,不会把原始分隔符原样带到下一条消息开头。
10.3 Session 管理
10.3.1 Session 结构体
Session([crates/octos-bus/src/session.rs:66-79])是对话的持久化单元:
#![allow(unused)]
fn main() {
pub struct Session {
pub key: SessionKey, // 会话标识(channel:chat_id)
pub parent_key: Option<SessionKey>, // fork 来源
pub topic: Option<String>, // 多主题支持
pub messages: Vec<Message>, // 对话历史
pub summary: Option<String>, // 会话摘要
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
}
10.3.2 JSONL 持久化与文件命名
当前源码的 Session 持久化比“一个 JSONL 文件”稍复杂一些,核心有两个事实。
第一,JSONL 文件的第一行不是消息,而是 SessionMeta 元数据,后续每一行才是 Message([crates/octos-bus/src/session.rs:47-64]、[crates/octos-bus/src/session.rs:389-423]、[crates/octos-bus/src/session.rs:467-480])。所以它不是“纯消息流”,而是“头一行 schema/meta + 后续消息行”的轻量日志格式。
第二,当前代码同时支持旧布局和新布局:
SessionManager仍支持 legacy flat layout:data/sessions/{encoded-key}[_{hash}]?.jsonl([crates/octos-bus/src/session.rs:148-217]、[crates/octos-bus/src/session.rs:269-319])SessionActor使用的SessionHandle优先采用 per-user layout:data/users/{encoded_base_key}/sessions/{topic_or_default}.jsonl,并在打开时自动迁移旧文件([crates/octos-bus/src/session.rs:685-756])
只有在 legacy flat 布局里,文件名才由下面这两部分构成:
- Percent-encoded SessionKey([
crates/octos-bus/src/session.rs:29-40]):将 SessionKey 中的路径不安全字符(/、:、#)编码为%2F、%3A、%23 - FNV-1a 64-bit hash 后缀([
crates/octos-bus/src/session.rs:16-27]、[crates/octos-bus/src/session.rs:290-319]):当编码后的名字过长、需要截断时,追加稳定哈希,避免“截断后同名前缀”碰撞
例如,旧布局中的长 key 可能落成 telegram%3A12345_0123ABCD....jsonl;而新布局则更像 users/telegram%3A12345/sessions/default.jsonl。
Schema 版本:CURRENT_SESSION_SCHEMA = 1([crates/octos-bus/src/session.rs:13-18]),为未来格式迁移预留。
写入也分两类:
- 日常追加消息走
append_to_disk(),新文件先写 metadata,再逐条 append message 行([crates/octos-bus/src/session.rs:430-487]) - 需要重写整个会话时走
rewrite(),使用 write-then-rename 的原子替换模式([crates/octos-bus/src/session.rs:489-533])
10MB 文件限制:单个会话文件最大 10MB,防止失控的对话历史耗尽磁盘([crates/octos-bus/src/session.rs:117-118]、[crates/octos-bus/src/session.rs:376-385]、[crates/octos-bus/src/session.rs:455-464])。
10.3.3 /new Fork 机制
用户发送 /new 命令创建新会话时,底层对应的是 fork(parent_key, new_chat_id, copy_messages)([crates/octos-bus/src/session.rs:536-572])。它不是“新建一个空白会话”,而是:
- 从父会话复制最近
copy_messages条消息 - 记录
parent_key - 为新 key 重写一个新的 session 文件
这意味着 /new 在当前实现里更接近“带最近上下文的分支”,而不是“只继承配置、不带历史”。
10.3.4 SessionManager 与 LRU 缓存
SessionManager([crates/octos-bus/src/session.rs:120-146])管理 admin/命令侧看到的会话缓存;而真正在线处理消息时,SessionActor 会转而持有自己的 SessionHandle,避免所有活跃会话共用一个大锁([crates/octos-bus/src/session.rs:687-756])。
- LRU 内存缓存:活跃会话在内存中保持,减少磁盘 I/O
- 惰性加载:不活跃的会话按需从磁盘加载
- 布局兼容:同时扫描 legacy flat layout 和 per-user layout
10.4 Coalescing 源码走读
让我们深入 split_message() 的完整实现([crates/octos-bus/src/coalesce.rs:34-82]),理解它如何在安全性和可读性之间取得平衡:
#![allow(unused)]
fn main() {
pub fn split_message(text: &str, config: &ChunkConfig) -> Vec<String> {
if text.len() <= config.max_chars {
return if text.is_empty() { vec![] } else { vec![text.to_string()] };
}
let mut chunks = Vec::new();
let mut remaining = text;
while !remaining.is_empty() {
if chunks.len() >= MAX_CHUNKS {
chunks.push(format!(
"[message truncated - {} chars omitted]",
remaining.len()
));
break;
}
if remaining.len() <= config.max_chars {
chunks.push(remaining.to_string());
break;
}
let mut limit = config.max_chars.min(remaining.len());
while limit > 0 && !remaining.is_char_boundary(limit) {
limit -= 1;
}
let search = &remaining[..limit];
let break_at = find_break_point(search);
chunks.push(remaining[..break_at].trim_end().to_string());
remaining = remaining[break_at..].trim_start_matches('\n');
if remaining.starts_with(' ') && !remaining.starts_with(" ") {
remaining = &remaining[1..];
}
}
chunks
}
}
关键设计点:
- 提前返回:空字符串直接返回空
Vec,短消息返回单块 - 先做 UTF-8 安全窗口,再找语义断点:避免把
find_break_point()变成“逻辑断点 + 编码边界”双重职责 - 边界清洗:
trim_end()、去掉前导换行、最多跳过一个空格,让 chunk 之间的视觉边界更自然 - MAX_CHUNKS 保护:超过上限时插入独立截断块,而不是静默丢尾部
10.4.1 Unicode 安全的边界检测
find_break_point() 的硬切分支(第 5 级)使用了与 octos-core truncate_utf8 相同的字符边界回退算法:
#![allow(unused)]
fn main() {
// 硬切——从 max_len 向前回退到安全的 UTF-8 字符边界
let mut limit = max_len;
while limit > 0 && !text.is_char_boundary(limit) {
limit -= 1;
}
limit
}
这保证了即使在中文、日文、emoji 等多字节字符的任意位置切割,也不会产生无效的 UTF-8 序列。考虑一个包含中文和 emoji 的消息在 Telegram(4000 字符限制)中的切割场景——没有这个保护,切割点可能恰好落在一个 4 字节的 emoji 中间,导致后续的 API 调用因为无效 UTF-8 而失败。
10.5 频道实现概览
octos-bus 通过 feature flags 按需编译各频道实现。每个频道实现 Channel trait 的具体方法:
| 频道 | 连接方式 | 特殊能力 |
|---|---|---|
| Telegram | Long polling (teloxide) | 消息编辑、AtomicBool 优雅关停 |
| Discord | WebSocket gateway (serenity) | 消息去重(MessageDedup) |
| Slack | WebSocket (tokio-tungstenite) | Block Kit 格式支持 |
| 飞书 | HTTP webhook | 加密消息验证 |
| HTTP API | 模板消息 | |
| IMAP/SMTP (async-imap + lettre) | 异步收发、附件 | |
| Matrix | HTTP API | AppService 模式、多用户桥接 |
| 企业微信 | HTTP webhook | 加密消息 |
| CLI | 终端 stdin/stdout | readline 交互 |
| API | REST/SSE (axum) | 编程式接入 |
每个频道实现都是独立的——Telegram 频道的 bug 不会影响 Discord,因为它们是不同的代码路径,通过不同的 feature flag 编译。这种隔离设计是 octos-bus 19,600 行代码中大部分来自各频道独立实现的原因。
工程决策侧栏:为什么 JSONL 而非 SQLite / 单 JSON 文件
方案一:SQLite
优势:结构化查询、索引、事务、跨会话分析方便 劣势:需要显式 schema / migration 层;会话文件不再能直接用文本工具检查;和当前“每个会话按 key 读写”的访问模式相比,抽象更重
方案二:单个 JSON 文件
优势:实现最直观,序列化/反序列化简单 劣势:任何一次写入都要重写整个文件;崩溃恢复最脆弱;多会话下最容易形成热点锁
方案三:JSONL(octos 的选择)
优势:
- 第一行 metadata、后续逐行消息,既能 append,也能整会话 rewrite
- 易于保留 legacy flat layout 与 per-user layout 两套路径
- 每个会话一个文件,更贴合“按 session key 读写”的访问模式
- 备份、迁移、排障都可以直接在文件系统层面完成
劣势:
- 无索引,跨会话查询需要扫描所有文件
- 没有数据库级事务;复杂查询能力弱
选择理由: 从当前源码看,octos 的会话访问模式几乎总是“按 key 读取一个 session、append 新消息、必要时 rewrite 整个 session、按需迁移布局”。JSONL 正好覆盖这些路径,而且能自然配合 SessionActor 的 per-session file ownership。
10.6 Session 持久化的工程细节
10.6.1 FNV-1a 哈希
在 legacy flat layout 里,当编码后的 SessionKey 需要截断时,文件名会追加 FNV-1a 64-bit 哈希后缀([crates/octos-bus/src/session.rs:16-27]、[crates/octos-bus/src/session.rs:290-319])。这是一个非密码学哈希函数,优势在于实现极简且跨 Rust 版本稳定。它不用于安全目的(不防碰撞攻击),只用于“截断后文件名仍然可区分”。
#![allow(unused)]
fn main() {
fn fnv1a_64(data: &[u8]) -> u64 {
let mut hash: u64 = 0xcbf29ce484222325; // FNV offset basis
for &byte in data {
hash ^= byte as u64;
hash = hash.wrapping_mul(0x100000001b3); // FNV prime
}
hash
}
}
10.6.2 Percent-encoding
encode_path_component()([crates/octos-bus/src/session.rs:29-40])将 SessionKey 中的特殊字符编码为 URL 安全格式。这防止了 telegram:12345 这样的 key 被文件系统解释为目录路径(因为 : 在某些文件系统中是特殊字符)。
10.6.3 write-then-rename 原子性
整会话 rewrite() 路径的原子性通过两步操作实现:
- 写入临时文件
{session_file}.tmp rename()临时文件为正式文件
在 Unix/Linux 上,rename() 是原子操作——要么完全成功(新文件替换旧文件),要么完全失败(旧文件保持不变)。即使进程在 rename() 之前崩溃,也只会留下一个孤立的 .tmp 文件,不影响正式会话文件。
10.7 本章回顾
- Channel trait:当前是 23 方法接口,但只有
name()、start()、send()没有默认实现;流式编辑、typing、embed、health check 都是按需覆盖的扩展层。 - Coalescing:5 级语义切割(段落→换行→句子→空格→硬切),MAX_CHUNKS=50 防 DoS,UTF-8 安全,超限时会追加独立的 truncation chunk。
- Session:JSONL 文件第一行是 metadata,不是消息;当前同时兼容 legacy flat layout 和 per-user layout,
/new/fork 会复制最近 N 条消息并记录parent_key。
延伸阅读
- Server-Sent Events:MDN “Using server-sent events” — 理解流式消息推送模式
- Telegram Bot API:https://core.telegram.org/bots/api — Telegram 频道的 API 细节
- JSONL 格式:https://jsonlines.org/ — 行分隔 JSON 格式规范
思考题
- 频道抽象的边界:某些频道支持富文本(Slack Block Kit、飞书 Rich Text),但
Channel::send()只接受纯文本。你会如何扩展 trait 以支持富文本,同时保持向后兼容? - 会话恢复:如果 octos 进程崩溃,JSONL 文件的最后一行可能不完整。你会如何实现崩溃恢复?
版本演化说明 本章分析基于 octos v0.1.0,octos-bus crate 位于
crates/octos-bus/src/。截至本书写作时,支持的频道列表可能随版本更新而扩展,但 Channel trait 和 coalescing 算法的核心设计无重大变化。
第 11 章:并发模型:Tokio 异步架构实战
定位:本章展示 octos 如何利用 Tokio 异步运行时实现生产级并发——从 per-session actor 到 actor 内部的 per-message task 派生,从信号量限流到工具并发和优雅关停。前置依赖:第 5 章、第 10 章。适用场景:想理解 Rust 异步并发实战模式的开发者(读者 B),以及需要调优并发参数的运维人员(读者 D)。
单用户 CLI 模式下,Agent 是单线程顺序执行的——不需要考虑并发。但当 octos 作为 Gateway 或 Serve 模式运行时,多个用户同时发送消息,每个会话还可能夹杂取消、后台子任务结果、SSE 状态推送与溢出消息。当前源码已经不是早期“每条消息直接 spawn + shared Mutex”的简单模型,而是一个分层并发结构:Gateway 主循环负责接入,ActorRegistry 负责会话生命周期,每个 session actor 自己拥有工具、会话文件句柄和用户工作区,然后再在 actor 内部按需派生消息任务、工具任务和后台 subagent。
11.1 分层 Spawn:会话、消息、工具、子 Agent
当前 octos 的 tokio::spawn() 不是只出现在一个地方,而是分布在四个层级:
- 会话级 actor:
ActorRegistry为新 session 创建 actor,ActorFactory::spawn()最终通过tokio::spawn(actor.run())启动一个长期存活的 per-session 任务([crates/octos-cli/src/session_actor.rs:198-309]、[crates/octos-cli/src/session_actor.rs:738-758]) - 消息级 agent task:在 API / speculative 路径下,actor 会再把当前消息的主 Agent 调用派生成独立任务,这样 actor 自己还能继续轮询 inbox,及时接收取消、overflow、后台结果和状态事件([
crates/octos-cli/src/session_actor.rs:1920-1963]) - 工具级任务:单轮 LLM 返回多个 tool call 时,
execute_tools()会为每个工具各自tokio::spawn(),然后用join_all()汇总结果([crates/octos-agent/src/agent/execution.rs:32-50]、[crates/octos-agent/src/agent/execution.rs:372-455]) - 后台子 Agent / spawn_only:
spawn工具的 background 模式会再起一个长期子 Agent;spawn_only工具也会在工具执行层单独起后台任务([crates/octos-agent/src/tools/spawn.rs:399-470]、[crates/octos-agent/src/agent/execution.rs:105-245])
这种分层 spawn 的好处是并发边界清晰:会话级隔离保证状态所有权,消息级派生保证 actor 还能继续响应控制消息,工具级并发保证单轮性能,后台子 Agent 则把长任务从主对话流中剥离出去。Tokio 的 JoinHandle 还把 panic 封装为 JoinError,避免一个子任务直接把整条并发链路拖垮。
11.2 Session Actor:会话级状态所有权
虽然不同用户的消息并行处理,但同一会话的核心状态必须有唯一 owner。否则两条几乎同时到达的消息可能并发修改消息历史、工具注册表、sandbox 工作区和背景任务状态,结果就是经典的“状态没锁住,但语义已经乱了”。
octos 使用 session actor 模式([crates/octos-cli/src/session_actor.rs:1-149])实现这个 owner 语义——每个会话由一个独立的 tokio 任务(actor)管理:
#![allow(unused)]
fn main() {
// session_actor.rs 关键常量
const ACTOR_INBOX_SIZE: usize = 32; // actor mailbox 容量
pub const DEFAULT_IDLE_TIMEOUT_SECS: u64 = 1800; // 空闲 30 分钟后回收
const MAX_OVERFLOW_TASKS: u32 = 5; // speculative overflow 并发上限
const MAX_PENDING_PER_SESSION: usize = 50; // 非活跃 session 的待发送缓冲上限
}
每个 session actor 不只是“有个队列”而已,它还拥有自己的 ToolRegistry、SessionHandle、per-user workspace 和取消标志([crates/octos-cli/src/session_actor.rs:520-736])。这正是文件头注释所说的:它取代了旧的 spawn-per-message gateway 模式,避免共享工具上的 set_context() 竞态([crates/octos-cli/src/session_actor.rs:1-5])。
11.2.1 ActorMessage:类型安全的消息分发
Session actor 通过 ActorMessage 枚举接收消息([crates/octos-cli/src/session_actor.rs:91-115]):
#![allow(unused)]
fn main() {
pub enum ActorMessage {
/// 用户消息——触发 Agent 迭代
Inbound {
message: InboundMessage,
image_media: Vec<String>,
},
/// 后台子 Agent 的结果——注入为系统消息,不触发额外 LLM 调用
BackgroundResult {
task_label: String,
content: String,
},
/// 后台任务状态变化——推送到 SSE
TaskStatusChanged { task_json: String },
/// 取消当前操作
Cancel,
}
}
Rust 的枚举让消息类型在编译期确定——不可能发送一个 actor 不理解的消息类型。Go 的 channel 通常传递 interface{},类型错误只在运行时发现。
11.2.2 ActorRegistry:会话生命周期管理
ActorRegistry([crates/octos-cli/src/session_actor.rs:139-380])管理所有 session actor 的生命周期:
#![allow(unused)]
fn main() {
pub struct ActorRegistry {
actors: HashMap<String, ActorHandle>, // 活跃 actor 表
factory: Arc<ActorFactory>, // 默认 Agent 工厂
profile_factories: HashMap<String, Arc<ActorFactory>>, // Profile 特定工厂
semaphore: Arc<Semaphore>, // 并发限制
out_tx: mpsc::Sender<OutboundMessage>, // 输出通道
pending_messages: PendingMessages, // 缓冲消息
}
}
当新消息到达时,dispatch() 会先按 session key + profile 解析 actor key,再做三件事:
- 发现 actor 已结束:先回收死 actor,避免向失效 mailbox 发消息([
crates/octos-cli/src/session_actor.rs:213-218]) - 缺少 actor:调用
factory.spawn(...)创建一个新 actor,并把system_prompt_override/sender_user_id等上下文挂在ActorHandle上,供后续 respawn 使用([crates/octos-cli/src/session_actor.rs:220-242]) - actor 已存在:优先
try_send();若 mailbox 已满,先给用户发一个“仍在处理中,你的消息已排队”的反馈,再退回到阻塞send()([crates/octos-cli/src/session_actor.rs:245-269])
ActorHandle::is_finished() 通过检查 JoinHandle::is_finished() 判断 actor 是否已退出——这是零开销的,不需要额外的心跳机制。
11.2.3 Mailbox、背压与溢出不是一回事
这几个上限名字很像,但语义完全不同:
ACTOR_INBOX_SIZE = 32:这是 actor mailbox 容量。它限制的是“同一个 actor 还没来得及recv()的消息数”MAX_PENDING_PER_SESSION = 50:这是非活跃 session 的待发送缓冲上限。缓冲的是 outbound reply,不是 inbound user message([crates/octos-cli/src/session_actor.rs:81-83]、[crates/octos-cli/src/session_actor.rs:361-375])MAX_OVERFLOW_TASKS = 5:这不是排队长度,而是 speculative 模式下“同一个 session 允许并发跑多少个 overflow agent task”的上限。超过时 actor 会立即回一个 busy 响应,而不是继续排队([crates/octos-cli/src/session_actor.rs:2434-2466])
这三个阈值分别保护 mailbox、inactive-session buffering 和会话内受控并发。如果把它们都理解成“队列大小”,就会误读 octos 的真实背压设计。
11.2.4 并发模型全景图
flowchart TD
subgraph "消息接入"
TG["Telegram"]
DC["Discord"]
API["REST API"]
end
subgraph "并发控制"
SEM["Semaphore<br/>max=10"]
REG["ActorRegistry"]
end
subgraph "会话隔离"
A1["Session Actor A<br/>ToolRegistry + SessionHandle + workspace"]
A2["Session Actor B<br/>ToolRegistry + SessionHandle + workspace"]
A3["Session Actor C<br/>ToolRegistry + SessionHandle + workspace"]
end
subgraph "Actor 内部派生"
M1["agent_task"]
T1["tool_1"]
T2["tool_2"]
T3["tool_3"]
O1["overflow task<br/>(optional)"]
end
TG & DC & API -->|"inbound_tx"| REG
REG -->|"dispatch"| A1 & A2 & A3
A1 -->|"acquire permit"| SEM
SEM --> A1 & A2 & A3
A1 --> M1
M1 -->|"join_all"| T1 & T2 & T3
A1 -. spec mode .-> O1
图 11-1:octos 并发模型全景。 消息先进入 ActorRegistry,再由 session actor 持有状态;Semaphore 限制的是活跃处理数,不是 actor 数量。actor 内部再按需派生 agent_task、tool task 和 speculative overflow task。
11.3 信号量限流
无限制的并发会话会耗尽系统资源(CPU、内存、LLM API 配额)。Arc<Semaphore> 限制同时活跃的处理数,默认值来自 GatewayConfig.max_concurrent_sessions = 10([crates/octos-cli/src/config.rs:554-635]),具体 semaphore 在 Gateway Runtime 里创建([crates/octos-cli/src/commands/gateway/gateway_runtime.rs:1272-1281])。
#![allow(unused)]
fn main() {
// 获取许可——如果已有 10 个活跃处理,新消息在此等待
let _permit = self.semaphore.acquire().await?;
// 处理消息...
drop(_permit); // 释放许可,允许下一个等待的消息进入
}
这个 permit 获取发生在 actor 真正开始处理消息时,而不是在 actor 创建时([crates/octos-cli/src/session_actor.rs:1803-1808]、[crates/octos-cli/src/session_actor.rs:2687-2691])。因此,一个空闲 actor 可以常驻内存,但不会占用并发槽位;只有正在跑 LLM / tool / overflow 逻辑的会话才会消耗 permit。
信号量而非自定义计数器的优势是:acquire().await 自动挂起等待任务,不消耗 CPU;任务完成或 panic 时 permit 会通过 RAII 自动释放,不容易泄漏。
11.4 工具并发:join_all
在单次 Agent 迭代内,LLM 可能请求多个工具调用(如同时读取 3 个文件)。execute_tools() 会为每个 tool call 分别 tokio::spawn(),然后用 join_all() 汇总结果([crates/octos-agent/src/agent/execution.rs:32-50]、[crates/octos-agent/src/agent/execution.rs:388-455]):
#![allow(unused)]
fn main() {
let handles: Vec<_> = tool_calls.iter()
.map(|tc| tokio::spawn(execute_tool(tc)))
.collect();
let results = futures::future::join_all(handles).await;
}
并行执行工具是 Agent 性能的关键优化——如果 3 个文件读取各需 10ms,串行执行需要 30ms,并行只需 ~10ms。
这里还有一个很有工程味的细节:join_all() 外面包了一层 tokio::time::timeout(),但超时后不会 abort 已经 spawn 出去的工具任务。源码注释给出的理由很直接:像 browser、shell 这类工具需要机会执行自己的 cleanup,否则会把 Chrome、子进程之类的资源孤儿化([crates/octos-agent/src/agent/execution.rs:32-37]、[crates/octos-agent/src/agent/execution.rs:416-425])。
11.5 子 Agent 双模式
octos 支持两种子 Agent 执行模式,而这两种模式都是并发边界设计的一部分:
11.5.1 同步阻塞模式
当 Agent 在主循环中调用需要子 Agent 的工具时,工具在当前迭代内同步等待子 Agent 完成。spawn 工具的 mode = "sync" 分支会直接 worker.run_task(&subtask).await,把结果作为当前 tool result 返回([crates/octos-agent/src/tools/spawn.rs:360-398])。
这适用于结果立即需要的场景——比如搜索结果需要在下一次 LLM 调用中使用。
11.5.2 后台异步模式(spawn 工具)
spawn 工具的 background 模式会 tokio::spawn(async move { ... }) 起一个完全独立的后台 Agent。主 Agent 立即继续执行,不等待后台任务完成;完成后结果通过 background result sender 回到 session actor([crates/octos-agent/src/tools/spawn.rs:399-470]、[crates/octos-cli/src/session_actor.rs:574-612])。
#![allow(unused)]
fn main() {
// spawn 工具的简化逻辑
tokio::spawn(async move {
let sub_agent = Agent::new(config);
sub_agent.run_task(task).await;
// 结果通过消息通知用户,不返回给主 Agent
});
// 主 Agent 立即继续
}
从用户体验看,后台结果又分两类:
- 简短的
✓/✗生命周期通知会直接发给用户,不触发额外 LLM 回合([crates/octos-cli/src/session_actor.rs:984-999]) - 完整后台报告会先注入会话,再由 actor 触发一次 rewrite message,把原始报告改写成更适合用户阅读的输出([
crates/octos-cli/src/session_actor.rs:998-1017])
11.6 优雅关停
当 octos 收到 Ctrl-C 时,当前源码里的关停链路其实有两层 flag,而不是一个全局布尔值走完整个系统:
- Gateway 级 shutdown flag:Ctrl-C handler 置位,Gateway Runtime 主循环和 session actor 自己会看这个标志([
crates/octos-cli/src/commands/gateway/gateway_runtime.rs:1187-1193]、[crates/octos-cli/src/commands/gateway/gateway_runtime.rs:1329-1333]、[crates/octos-cli/src/session_actor.rs:1040-1049]) - Per-session cancelled flag:session actor 通过
.with_shutdown(cancelled.clone())传给 Agent,本轮任务里的check_budget()和wait_for_shutdown()实际读的是这个 flag([crates/octos-cli/src/session_actor.rs:673-680]、[crates/octos-agent/src/agent/budget.rs:34-65]、[crates/octos-agent/src/agent/streaming.rs:14-29])
#![allow(unused)]
fn main() {
// session actor:取消当前任务
self.cancelled.store(true, Ordering::Release);
// agent:在预算检查时观察取消标志
if self.shutdown.load(Ordering::Acquire) {
return BudgetStop::Shutdown;
}
}
Release / Acquire 语义确保:actor 写入取消标志后,Agent 线程在读取时不会看到旧值。Gateway 的 Ctrl-C flag 也使用同样的序关系([crates/octos-cli/src/commands/gateway/gateway_runtime.rs:1188-1193])。
优雅关停的流程:
- Ctrl-C handler 置位 Gateway shutdown flag
- Runtime 主循环会在下一次取到 inbound 后、真正 dispatch 之前停止继续处理新消息
- 进入 shutdown 阶段,最多等待 30 秒让
actor_registry.shutdown_all()收尾 - 并发停止 persona / heartbeat / cron / channels 等后台服务([
crates/octos-cli/src/commands/gateway/gateway_runtime.rs:1651-1669])
11.6.1 关停的四个阶段
优雅关停不是一个简单的 process::exit()——它是一个有序的资源释放过程:
- 停止继续 dispatch 新消息:Gateway Runtime 会在下一次从 inbound queue 取到消息后、dispatch 之前检查 shutdown flag 并跳出主循环([
crates/octos-cli/src/commands/gateway/gateway_runtime.rs:1329-1333]) - 等待 actor 结束:
shutdown_all()会 drop actor senders,并等待 join handle 完成;最长等 30 秒([crates/octos-cli/src/session_actor.rs:344-359]、[crates/octos-cli/src/commands/gateway/gateway_runtime.rs:1651-1659]) - actor 内部完成本轮清理:session actor 自己会在 loop 边界检查
global_shutdown/cancelled,随后退出([crates/octos-cli/src/session_actor.rs:1040-1053]) - 停后台服务与频道:最后并发 stop persona / heartbeat / cron / channel manager([
crates/octos-cli/src/commands/gateway/gateway_runtime.rs:1661-1669])
11.6.2 Ordering 语义为什么重要
#![allow(unused)]
fn main() {
// 错误:使用 Relaxed
shutdown.store(true, Ordering::Relaxed); // 主线程
if shutdown.load(Ordering::Relaxed) { ... } // Agent 线程
// Agent 线程可能看到 stale 值——在多核 CPU 上,store 可能还在写缓冲中
}
Release / Acquire 配对确保了 happens-before 关系:发起取消的一方先 store(true, Release),执行任务的一方再 load(Acquire),这样取消事件前的状态变更不会在另一个线程里乱序消失。
Relaxed 在这里不够——虽然 x86 架构上的 Relaxed 几乎等同于 Acquire/Release(因为 x86 的内存模型较强),但在 ARM 等弱内存模型架构上,Relaxed 可能导致 Agent 线程在检测到 shutdown 为 true 之后仍然看到 stale 的消息队列状态。
11.7 Heartbeat 与 Cron
octos 支持定时触发 Agent 会话,三种调度类型:
| 类型 | 示例 | 精度 |
|---|---|---|
| Every | 每 5 分钟 | 固定间隔 |
| Cron | 0 9 * * 1-5 | Cron 表达式 |
| At | 每天 09:00 | 固定时间点 |
定时任务通过 cron crate 解析表达式,在 Tokio 运行时中注册定时器。触发时创建新的会话消息,经过正常的消息处理管线。
工程决策侧栏:为什么从共享 Mutex 演化到 Session Actor
方案一:共享
Mutex<SessionState>优势:实现最直接,“同一时刻只能处理一条消息”的语义也很容易表达。
劣势:真正麻烦的不是锁本身,而是锁里到底该放什么。工具注册表、用户工作区、后台结果回流、SSE 状态推送、取消信号,这些状态如果散落在锁外,语义竞态依然存在。
方案二:完全无状态的 spawn-per-message
优势:并行度高,消息来了就起任务,几乎不需要长期存活结构。
劣势:每次都要重建工具与会话上下文;后台结果路由、消息背压和 overflow 控制会散落在多个任务之间,难以形成一个稳定的 owner。
方案三:Session Actor(当前源码的选择)
优势:mailbox、ToolRegistry、SessionHandle、用户工作区、取消标志、background result injection 都收敛到一个 owner 上;并发点从“谁都能改状态”变成“actor 内部何时显式派生子任务”。
代价:实现明显更复杂,必须处理 inbox 满载、actor respawn、overflow 并发和 shutdown 协调。但对于 octos 这种长会话、多工具、可中断的 Agent,这个复杂度换来了更稳的运行时边界。
11.8 本章回顾
- 分层并发:octos 现在是“Gateway dispatch → session actor → actor 内部消息任务 / 工具任务 / 子 Agent”这套分层 spawn 结构,不是单一的 per-message spawn 模型。
- Session Actor:每个会话都有自己的 ToolRegistry、SessionHandle、workspace 和 mailbox,状态所有权清晰。
- Semaphore 限流:默认 10 个活跃处理槽位;permit 在真正处理消息时获取,而不是在 actor 创建时占坑。
- 工具与后台任务:
join_all负责单轮工具并发,spawn/spawn_only负责把长任务从主回路拆出去。 - 优雅关停:Gateway shutdown flag 和 per-session cancelled flag 分层配合,配上 Release/Acquire 语义,让接入停止、任务取消、actor 回收和服务 stop 有明确边界。
延伸阅读
- Tokio 教程:https://tokio.rs/tokio/tutorial — 异步 Rust 运行时
- Rust Atomics and Locks:Mara Bos 的书,https://marabos.nl/atomics/ — 理解 Release/Acquire 语义
- 结构化并发:Nathaniel J. Smith, “Notes on structured concurrency” — 理解 spawn + join 的模式
思考题
- Actor 内部还需要多少锁? 当前 actor 已经提供了状态 owner,但
SessionHandle、PendingMessages等局部资源仍然用了Mutex。如果未来要支持更复杂的跨会话共享缓存,锁应该留在 actor 内,还是再抽出独立协调层? - 信号量的公平性:当 10 个并发槽位全部占满时,等待的消息按什么顺序获得许可?Tokio 的 Semaphore 是 FIFO 的吗?
版本演化说明 本章分析基于 octos v0.1.0 当前源码。若你在更早的设计文档或旧书稿里见过“per-session Mutex 是核心模型”的说法,应以现在的
session_actor.rs实现为准:核心并发边界已经演化为 session actor + per-actor state ownership。
第 12 章:octos-pipeline:DOT 图驱动的工作流引擎
定位:本章对照
crates/octos-pipeline/src/,解释 octos 如何把 Graphviz DOT 图解析成带类型的PipelineGraph,以及执行器如何在顺序节点、静态并行、动态并行之间切换。前置依赖:第 5 章。适用场景:需要理解多步骤 Agent 编排机制的开发者。
当任务已经不是“单个 Agent 循环 + 几次工具调用”能解决的问题时,就需要显式的工作流编排。典型例子是:先规划研究角度,再并发检索,再汇总分析,最后生成报告。octos-pipeline 解决的就是这类问题,但它的当前实现和“传统 DAG 调度器”的直觉并不完全一样:它既有图结构,也有运行时分支选择、并发汇合、模型路由和工具策略继承。
12.1 DOT 图如何进入运行时
12.1.1 为什么是 DOT
octos 用 Graphviz DOT 定义工作流,而不是 YAML/JSON。原因不是“DOT 更潮”,而是它天然把节点和边作为一等语义:同一个文件既能被执行器解析,也能直接被 Graphviz 渲染成图。
digraph research {
graph [label="Deep Research", default_model="cheap"]
start [label="规划", handler="dynamic_parallel",
converge="analyze",
planner_model="strong",
worker_prompt="围绕 {task} 做资料检索,并保留来源",
tools="deep_search",
max_tasks="6"]
analyze [label="分析", handler="codergen",
model="strong",
tools="read_file,write_file"]
finish [label="结束", shape="Msquare"]
start -> analyze
analyze -> finish
}
这个例子已经体现了当前 parser 支持的几类关键能力:
- 图级属性:
graph [label=..., default_model=...] - 节点属性:
handler、model、tools、converge、planner_model等 - 边:
A -> B - 形状到 Handler 的隐式映射,例如
Msquare会映射到Noop
12.1.2 手写 parser,而不是第三方 DOT 库
入口是 crates/octos-pipeline/src/parser.rs:18-20 的 parse_dot(),真实工作发生在 DotParser::parse()(crates/octos-pipeline/src/parser.rs:34-98)。这是一个手写 parser,不依赖外部 DOT 解析库。
当前实现比“只支持一小撮语法”的简化说法要丰富一些:
digraph名称是可选的;如果模型生成了digraph { ... },parser 会把图 ID 设成"pipeline"(crates/octos-pipeline/src/parser.rs:42-48)- 支持图级属性
graph [key=value],目前会落到label和default_model(crates/octos-pipeline/src/parser.rs:104-113,crates/octos-pipeline/src/parser.rs:487-493) - 支持
subgraph name { ... },并把子图中的节点归档到PipelineGraph.subgraphs(crates/octos-pipeline/src/parser.rs:115-230,crates/octos-pipeline/src/graph.rs:21-24,crates/octos-pipeline/src/graph.rs:244-253) - 支持边链式写法
a -> b -> c,属性会应用到链上的每一条边(crates/octos-pipeline/src/parser.rs:233-267) - 支持
//、/* */,以及额外的#行注释;后者明显是在为 LLM 生成的 DOT 做容错(crates/octos-pipeline/src/parser.rs:446-483) - 如果边引用了未显式声明的节点,parser 会自动补出默认节点定义(
crates/octos-pipeline/src/parser.rs:76-96)
这一层的结果不是“松散的 JSON 树”,而是带语义的 PipelineGraph。其核心结构在 crates/octos-pipeline/src/graph.rs:10-24 和 crates/octos-pipeline/src/graph.rs:91-129:
PipelineGraph有id、label、default_model、nodes、edges、subgraphsPipelineNode除了prompt和handler,还包含model、context_window、max_output_tokens、tools、goal_gate、max_retries、timeout_secs、suggested_next、converge、worker_prompt、planner_model、max_tasks
12.1.3 属性到节点语义的映射
节点构建发生在 build_node()(crates/octos-pipeline/src/parser.rs:524-562)。这里有几个实现细节决定了 DOT 的“作者体验”:
- Handler 解析顺序是:显式
handler=优先,其次是shape=到 Handler 的映射,最后默认Codergen(crates/octos-pipeline/src/parser.rs:525-530,crates/octos-pipeline/src/graph.rs:204-216) tools="a,b,c"会被拆成字符串列表;如果用户写了tools="",解析结果会是一个只含空字符串的列表,后面执行器会把它当成“显式禁用所有工具”处理(crates/octos-pipeline/src/parser.rs:532-535,crates/octos-pipeline/src/handler.rs:219-236)timeout_secs不只接受整数秒,也接受900s、15m、2h这类后缀写法(crates/octos-pipeline/src/parser.rs:496-513)goal_gate允许用true/false/yes/no/1/0表达(crates/octos-pipeline/src/parser.rs:515-522)
这意味着 DOT 在 octos 里不是“纯拓扑描述”,而是一个轻量的工作流 DSL。
12.2 六种节点语义,而不是五种
HandlerKind 的真实枚举在 crates/octos-pipeline/src/graph.rs:169-187。当前源码是 6 种,不是 5 种:
| 类型 | 运行时落点 | 关键属性 | 作用 |
|---|---|---|---|
Codergen | crates/octos-pipeline/src/handler.rs:193-383 | prompt model tools context_window max_output_tokens | 派生完整子 Agent |
Shell | crates/octos-pipeline/src/handler.rs:396-459 | prompt timeout_secs | 执行 shell 命令 |
Gate | crates/octos-pipeline/src/handler.rs:466-509 | prompt | 计算条件,不做人机等待 |
Noop | crates/octos-pipeline/src/handler.rs:515-526 | 无 | 透传输入 |
Parallel | crates/octos-pipeline/src/executor.rs:711-895 | converge | 对已有下游节点做静态 fan-out |
DynamicParallel | crates/octos-pipeline/src/executor.rs:897-1198 | prompt worker_prompt planner_model max_tasks converge | 先规划任务,再动态 fan-out |
还有一个容易忽略但很重要的事实:并不是 6 种都对应一个 Handler 实现。真正实现了 Handler trait 的只有 Codergen、Shell、Gate、Noop(crates/octos-pipeline/src/handler.rs:76-81)。Parallel 和 DynamicParallel 不是独立 handler 类型,而是 PipelineExecutor::execute_graph() 里的专门分支(crates/octos-pipeline/src/executor.rs:711-1198)。
12.2.1 Codergen:节点就是一个子 Agent
CodergenHandler 会为节点创建一个完整的 octos_agent::Agent,而不是做一次简化版 LLM 调用(crates/octos-pipeline/src/handler.rs:193-383)。这意味着节点天然继承了主 Agent 的很多能力:工具调用、循环式执行、token 统计、文件修改回传、进度事件上报。
它的关键行为有几层:
- Provider 解析。 如果节点声明了
model,并且执行器配置了ProviderRouter,handler 会走router.resolve(),然后再包一层 capability-compatible fallback provider(crates/octos-pipeline/src/handler.rs:157-190)。这不是单纯的“model name -> provider”映射,而是带回退链的解析。 - 上下文窗口覆盖。
context_window会包装成ContextWindowOverride(crates/octos-pipeline/src/handler.rs:201-206)。 - 工具注册。 节点初始工具集来自
ToolRegistry::with_builtins(),然后按需加载 plugin tools(crates/octos-pipeline/src/handler.rs:208-217)。 - 工具策略。 节点自己的
tools=决定 allowlist;但即便允许了很多工具,handler 仍会额外 denyspawn、run_pipeline、send_file、message,避免子节点递归失控(crates/octos-pipeline/src/handler.rs:227-251)。 - 系统提示词与任务输入分离。 执行器会先把
{input}从prompt中移除,只保留角色/约束类说明;真正的前驱输出通过TaskKind::Code.instruction传给子 Agent(crates/octos-pipeline/src/executor.rs:1226-1245,crates/octos-pipeline/src/handler.rs:333-342)。
一个和旧稿差异很大的点是:当前节点并没有 max_iterations 这种 DOT 属性。CodergenHandler 内部把 AgentConfig.max_iterations 固定成 30(crates/octos-pipeline/src/handler.rs:311-317)。真正可调的是:
timeout_secsmax_output_tokenscontext_windowmodeltoolsmax_retries
另外,max_output_tokens 的默认行为也不是“全局 4096”。如果节点没写这个属性,handler 会退回到 provider 自身的最大输出能力(crates/octos-pipeline/src/handler.rs:304-316)。这对长报告生成很关键。
12.2.2 Shell:最简单,但语义很清楚
ShellHandler 是最容易完整理解的一种实现(crates/octos-pipeline/src/handler.rs:396-459):
- 命令来源是
node.prompt,没有就退回ctx.input - 非 Windows 下执行
sh -c,Windows 下执行cmd /C - 默认超时 300 秒,可由
timeout_secs覆盖 - 非零退出码映射成
OutcomeStatus::Fail - 进程启动失败或超时映射成
OutcomeStatus::Error
这个区分很重要,因为执行器只会对 Error 做重试(crates/octos-pipeline/src/executor.rs:1394-1417)。也就是说:
- “测试跑了但失败”是业务失败,不重试
- “命令根本没起来”或“超时”才是系统错误,可重试
12.2.3 Gate:当前是条件节点,不是人工审批节点
这是 Ch12 里最容易写错的一块。
当前执行器注册的是 GateHandler(crates/octos-pipeline/src/executor.rs:626-653),而 GateHandler 的真实语义是:
- 把
node.prompt当成条件表达式 - 对“最后一个已完成节点”的
NodeOutcome求值 - 返回
Pass或Fail content直接透传,不发起人机交互(crates/octos-pipeline/src/handler.rs:466-509)
如果 prompt 为空,它默认把条件视为 "true",于是变成一个 pass-through gate(crates/octos-pipeline/src/handler.rs:469-493)。
human_gate.rs 的确存在,而且提供了 HumanInputProvider、ChannelInputProvider、HumanRequest、HumanResponse,默认超时 5 分钟(crates/octos-pipeline/src/human_gate.rs:14-140)。但我对照当前源码后可以明确说:这些抽象没有接进 PipelineExecutor::build_handlers() 或 execute_graph() 主路径(crates/octos-pipeline/src/executor.rs:626-653)。所以“Gate = 人工审批节点”已经不是当前实现的准确说法。
更准确的表述应该是:
GateHandler是已接线的条件节点human_gate.rs是 crate 已提供、但尚未接入默认执行器的人机输入抽象
12.2.4 Parallel:静态 fan-out,执行真实下游节点
Parallel 是当前章节里最值得补深度的一种类型,因为它不是“动态生成 worker”,而是把图里已经存在的下游节点并发跑掉(crates/octos-pipeline/src/executor.rs:711-895)。
它的执行过程是:
- 收集当前节点所有 outgoing edges 的 target,作为并发目标(
crates/octos-pipeline/src/executor.rs:717-722) - 要求当前节点必须声明
converge,否则验证阶段直接报错(crates/octos-pipeline/src/validate.rs:279-317) - 为每个目标节点克隆
PipelineNode,做变量替换,并在未显式声明模型时填入graph.default_model(crates/octos-pipeline/src/executor.rs:779-792) - 为每个目标节点查它自己的 handler,然后并发执行(
crates/octos-pipeline/src/executor.rs:775-830) - 用
process_worker_results()合并内容、token、summary 和 node outcome(crates/octos-pipeline/src/executor.rs:300-385,crates/octos-pipeline/src/executor.rs:832-845) - 把汇总后的文本写回“当前 parallel 节点”的
completed结果,再跳到converge节点(crates/octos-pipeline/src/executor.rs:867-894)
这里有两个实现细节值得记住:
Parallel的并发度受ExecutorConfig.max_parallel_workers限制,靠tokio::sync::Semaphore实现(crates/octos-pipeline/src/executor.rs:762-767)- 执行器会用
parallel_executed记住那些已经在 fan-out 阶段跑过的真实图节点,后续顺序遍历遇到它们时只选边,不重复执行(crates/octos-pipeline/src/executor.rs:668-709,crates/octos-pipeline/src/executor.rs:842-845)
此外,结果合并并不只是简单字符串拼接。process_worker_results() 之后还会调用 resolve_search_result_files(),自动扫描 worker 输出里提到的研究目录,把 _search_results.md 的内容内联进 merge 结果(crates/octos-pipeline/src/executor.rs:380-501)。这说明当前 Pipeline 已经针对“研究型 fan-out -> 汇总型 converge”做了专门优化。
12.2.5 DynamicParallel:先规划,再合成 worker 节点
DynamicParallel 和 Parallel 的根本区别是:它不直接跑现成的图节点,而是先让 LLM 规划出任务列表,再为每个任务合成一个临时 PipelineNode(crates/octos-pipeline/src/executor.rs:997-1055)。
主流程在 crates/octos-pipeline/src/executor.rs:897-1198:
- 解析
planner_model -> node.model -> graph.default_model的 planner provider 选择链(crates/octos-pipeline/src/executor.rs:932-940) - 用
node.prompt作为规划提示词;若为空则退回内置 planner prompt(crates/octos-pipeline/src/executor.rs:942-947) - 期望模型返回纯 JSON 数组;若少于 2 个任务或解析失败,则退回
fallback_tasks()(crates/octos-pipeline/src/executor.rs:152-257,crates/octos-pipeline/src/executor.rs:961-989) - 把
worker_prompt里的{task}替换为具体任务说明,生成一批 syntheticCodergen节点(crates/octos-pipeline/src/executor.rs:997-1055) - 并发执行这些 synthetic 节点,合并结果后跳到
converge节点(crates/octos-pipeline/src/executor.rs:1089-1198)
它还有两个旧稿完全没写到的行为:
node.model可以写成逗号分隔的 model pool,例如"cheap,strong,cheap";执行器会把 worker 轮询分配到不同模型上(crates/octos-pipeline/src/executor.rs:1002-1039)- 当前
DynamicParallel没有像Parallel那样再套一层 semaphore;它的 fan-out 上限主要依赖max_tasks,默认值是 8(crates/octos-pipeline/src/executor.rs:930,crates/octos-pipeline/src/executor.rs:1089-1131)
所以如果你问“当前实现里哪种并发更受控”,答案其实是:静态 Parallel 的并发度控制更硬,DynamicParallel 更依赖 planner 输出和 max_tasks 自我约束。
12.2.6 Noop:占位,但也很实用
NoopHandler 就是把 ctx.input 原样返回(crates/octos-pipeline/src/handler.rs:512-526)。它有两个常见用途:
- 作为 start / finish 这类结构节点
- 作为某些条件分支的汇合点或透传点
12.3 执行引擎不是“拓扑排序器”,而是带路由的图遍历器
flowchart TD
DOT["DOT / pipeline name / file path"] --> Parse["parse_dot()"]
Parse --> Validate["validate()"]
Validate --> Start["find_start_node()"]
Start --> Loop["execute_graph() loop"]
Loop --> Kind{node.handler}
Kind -->|Parallel| PFan["并发执行真实下游节点"]
Kind -->|DynamicParallel| DPlan["LLM 规划任务"]
Kind -->|其他| Normal["Handler::execute()"]
PFan --> Merge["合并结果并跳到 converge"]
DPlan --> Workers["合成 worker 节点并 join_all"]
Workers --> Merge
Normal --> Select["select_next_edge()"]
Merge --> Select
Select -->|有后继| Loop
Select -->|无后继 / goal_gate 成功 / Error| Done["PipelineResult"]
图 12-1:当前 PipelineExecutor 的真实主路径。 它不是先做一次全图拓扑排序,再机械执行所有节点;而是从 start node 出发,在循环里按节点类型分流,并在每一步重新决定下一条边。
12.3.1 run() 的实际阶段
PipelineExecutor::run() 在 crates/octos-pipeline/src/executor.rs:514-624,可以概括成五步:
parse_dot()解析 DOTvalidate()跑 lint 规则build_handlers()构建常规 handler registryfind_start_node()决定入口节点execute_graph()进入主循环
这里最重要的纠偏是:第四步之后不是“拓扑遍历整个 DAG”,而是 current_node_id 驱动的增量遍历(crates/octos-pipeline/src/executor.rs:655-1392)。这也是为什么 suggested_next、条件边、label 匹配这些运行时路由策略都能生效。
12.3.2 验证规则和 start node 选择
验证器在 crates/octos-pipeline/src/validate.rs:27-372,当前有 15 条规则,而不只是“检查一下条件能不能 parse”。
比较重要的几条:
- Rule 1:必须能找到 start node(
start节点,或唯一一个无入边节点)(crates/octos-pipeline/src/validate.rs:52-71,crates/octos-pipeline/src/validate.rs:75-99) - Rule 2:不可达节点只是 warning,不是 error(
crates/octos-pipeline/src/validate.rs:101-140) - Rule 6:边条件必须能被 condition parser 解析(
crates/octos-pipeline/src/validate.rs:188-203) - Rule 13 / 14:
parallel和dynamic_parallel都必须声明有效的converge(crates/octos-pipeline/src/validate.rs:279-317,crates/octos-pipeline/src/validate.rs:329-372) - Rule 15:图中不能有环;环检测发生在 validate 阶段,不等执行时才爆炸(
crates/octos-pipeline/src/graph.rs:26-88,crates/octos-pipeline/src/validate.rs:319-327)
这意味着 octos-pipeline 当前仍然要求 DAG,但执行方式不是“静态 DAG 调度器”,而是“受 DAG 约束的动态图遍历器”。
12.3.3 条件语言和边选择顺序
条件表达式的 grammar 写在 crates/octos-pipeline/src/condition.rs:1-18。当前运行时真正支持的核心写法是:
outcome.status == "pass"outcome.status != "fail"outcome.contains("keyword")!expr、expr && expr、expr || expr
例如:
test -> deploy [condition="outcome.status == \"pass\""]
test -> rollback [condition="outcome.status == \"fail\""]
report -> refine [condition="outcome.contains(\"missing data\")"]
旧稿里那种 success / failure 简写已经不符合当前 parser。
更值得写清楚的是,执行器选边不是“第一个命中就走”,而是一个 5 步算法(crates/octos-pipeline/src/executor.rs:1419-1478):
- 先评估所有带条件的边
- 如果有多个条件命中,按
weight选最高权重 - 若无条件命中,检查节点的
suggested_next - 再看 edge label 是否出现在 outcome content 里
- 最后才在无条件边里按权重选;如果还没有,就退回目标名最小的边
还有一个微妙但重要的实现现状:condition.rs 的 grammar 虽然支持 context.key == "value",但 select_next_edge() 和 GateHandler 走的是 evaluate(),不是 evaluate_with_context()(crates/octos-pipeline/src/condition.rs:64-85, crates/octos-pipeline/src/handler.rs:495-496, crates/octos-pipeline/src/executor.rs:1433-1439)。也就是说,context.* 目前是“语法已定义、主路径未喂值”的状态;真正稳定可用的还是 outcome.* 相关条件。
12.3.4 进度、统计和终止条件
PipelineStatusBridge 定义在 crates/octos-pipeline/src/executor.rs:43-83,桥接了两类外部可见状态:
status_words:当前节点或 fan-out worker 的状态文案token_tracker:所有子 Agent 的 token 聚合
当 CodergenHandler 内部子 Agent 产出 ProgressEvent 时,PipelineNodeReporter 会把事件重新转成 run_pipeline 的进度消息(crates/octos-pipeline/src/handler.rs:20-64)。所以前端看到的 pipeline 进度,不只是“现在在第几个节点”,而是能继续细到“某个 worker 正在调用哪个工具”。
执行终止有三种常见路径:
- 当前节点没有 outgoing edges(
crates/octos-pipeline/src/executor.rs:1368-1388) - 某个
goal_gate=true节点成功,提前结束 pipeline(crates/octos-pipeline/src/executor.rs:1315-1340) - 某节点返回
OutcomeStatus::Error,整个 pipeline 直接停止(crates/octos-pipeline/src/executor.rs:1343-1356)
最终返回的是 PipelineResult(crates/octos-pipeline/src/executor.rs:28-41),其中除了 output / success / token_usage,还有:
node_summaries:每个节点的model、耗时、token 和 successfiles_modified:所有节点写出的文件,去重后汇总
12.3.5 当前生效的模型选择路径
当前主路径真正生效的模型选择机制有两层:
- 图级默认:
graph [default_model="cheap"] - 节点覆盖:
node [model="strong"]
这两层分别在 parser 中落到 PipelineGraph.default_model 和 PipelineNode.model(crates/octos-pipeline/src/parser.rs:487-493, crates/octos-pipeline/src/parser.rs:537-562),然后在执行时由 execute_graph() 和 CodergenHandler 联合应用(crates/octos-pipeline/src/executor.rs:790-792, crates/octos-pipeline/src/executor.rs:1242-1245, crates/octos-pipeline/src/handler.rs:201-206)。
ModelStylesheet 模块本身是存在的,支持 * / handler:codergen / node:critical_analysis 这类 selector(crates/octos-pipeline/src/stylesheet.rs:13-104)。但我对照当前源码后,没有找到它被 PipelineExecutor、RunPipelineTool 或 PipelineDiscovery 调用的路径。换句话说:
ModelStylesheet是 crate 已导出的能力- 当前默认执行路径用的仍然是
default_model + node.model
如果书里把 ModelStylesheet 写成主路径,会高估它在当前版本里的实际地位。
12.3.6 human_gate、checkpoint、run_dir 的真实位置
这一章还有三个容易被写成“默认能力”的模块,但当前更准确的定位是“相邻库能力”:
human_gate.rs:提供 channel-based human input 抽象,默认超时 5 分钟,但未接入PipelineExecutor(crates/octos-pipeline/src/human_gate.rs:14-140)checkpoint.rs:提供Checkpoint/CheckpointStore,能把已完成节点 outcome 写到{run_dir}/checkpoint.json(crates/octos-pipeline/src/checkpoint.rs:13-106)run_dir.rs:提供RunDir、NodeStatus、PipelineRunSummary,约定运行目录是{working_dir}/.octos/runs/{run_id}/...(crates/octos-pipeline/src/run_dir.rs:16-114)
但我对照当前 crate 的调用关系后,没有看到它们被 PipelineExecutor::run() 主路径直接使用。也就是说,这些模块已经存在,但“默认 run_pipeline 就会自动 checkpoint / 自动写 run_dir / 自动做人机审批”并不是当前源码能支持的结论。
12.3.7 run_pipeline 工具如何把 pipeline 暴露给 Agent
对最终用户来说,最常见的入口不是直接 new PipelineExecutor,而是 RunPipelineTool(crates/octos-pipeline/src/tool.rs:17-322)。
它有几层很实际的工程化包装:
- 先尝试把输入当成 inline DOT;若 parse 失败,再尝试把图名解析成预置 pipeline(
crates/octos-pipeline/src/tool.rs:78-134) - 会自动修正常见的 LLM DOT 错误,比如
digraph{、缺图名、代码围栏包裹(crates/octos-pipeline/src/tool.rs:324-356) - 可按名称、路径或内联 DOT 解析 pipeline;搜索路径包括项目级
.octos/pipelines、用户级data_dir/pipelines、data_dir/skills,额外还能挂octos_home/skills(crates/octos-pipeline/src/discovery.rs:18-114,crates/octos-pipeline/src/tool.rs:50-56) - 对整个 pipeline 施加 60-1800 秒的总超时钳制(
crates/octos-pipeline/src/tool.rs:150-152,crates/octos-pipeline/src/tool.rs:249-267)
这里还有一个值得写进书里的“实现与提示词分离”现象:run_pipeline 的 input_schema() 会明确告诉模型“不要显式写 model=,系统会自动选择模型”(crates/octos-pipeline/src/tool.rs:172-208),但运行时引擎本身依然支持 default_model / node.model。也就是说:
- 这是对 LLM authoring 的建议
- 不是底层引擎能力被移除了
工程决策侧栏:为什么选 DOT 而不是 YAML/JSON
YAML(例如 GitHub Actions)
优势:人类熟悉,生态成熟。 劣势:图结构不是一等语义,
needs:这类依赖写法在分支和汇合场景下会越来越别扭。JSON(例如 Step Functions)
优势:结构化强,schema 友好。 劣势:对人类作者不友好,特别是当节点属性和分支条件越来越多时。
DOT(octos 的选择)
优势:
- 节点和边本身就是 DOT 的原生概念
- 同一份定义可直接被 Graphviz 渲染
handler/model/tools/converge这类属性自然落在节点上- 对 LLM 来说,生成一张图往往比生成层层嵌套的 YAML/JSON 更稳定
代价:
- 需要自己实现 parser 和验证器
- DOT 不是大多数工程团队的日常配置语言,学习成本略高
12.4 本章回顾
octos-pipeline当前不是“5 种 handler”,而是 6 种HandlerKind;其中Parallel和DynamicParallel其实是执行器分支,不是独立Handler实现。Gate在当前版本里是条件节点,不是默认接线的人机审批节点;human_gate.rs只是已存在但尚未接入执行主路径的抽象。- 真正生效的模型选择路径是
graph.default_model + node.model;ModelStylesheet、CheckpointStore、RunDir都存在,但不应被写成PipelineExecutor默认主流程的一部分。
延伸阅读
- Graphviz DOT Language:https://graphviz.org/doc/info/lang.html
- DAG 调度:可以对照 Airflow / Prefect 看“静态 DAG 调度器”和 octos 这种“带运行时路由的图遍历器”之间的差异
思考题
condition.rs已经支持context.*grammar,但执行器当前没有把上下文 map 接进去。你会把这层语义接到select_next_edge(),还是保留 outcome-only 的简单模型?DynamicParallel当前没有额外 semaphore,只靠max_tasks控制 fan-out 上限。对于高成本 provider,这个设计是否足够稳妥?
版本演化说明 本章分析基于当前
../octos源码中crates/octos-pipeline/src/的实现。书中凡是涉及Gate、ModelStylesheet、CheckpointStore、RunDir的地方,都应以“当前是否接入PipelineExecutor主路径”为准,而不是仅凭模块是否存在来下结论。
第 13 章:三种运行模式与配置体系
定位:本章展示 octos 的三种运行模式(CLI/Gateway/Serve)以及配置体系的层次结构和热加载机制。前置依赖:第 10 章、第 5 章。适用场景:需要部署和配置 octos 的运维人员和开发者(读者 D),以及想理解运行时架构选择的开发者(读者 B)。
同一套代码,三种运行姿态——这是 octos 作为“Agent 操作系统“的核心设计理念。
13.1 三种运行模式
13.1.1 CLI 模式(octos chat)
交互式终端对话(crates/octos-cli/src/commands/chat.rs)。启动 multi-threaded Tokio 运行时(8MB 栈大小,crates/octos-cli/src/commands/chat.rs:69-74),提供 readline 风格的输入界面。
#![allow(unused)]
fn main() {
// chat.rs:69-78
let runtime = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.thread_stack_size(8 * 1024 * 1024) // 8MB 栈——深递归场景需要
.build()?;
}
8MB 栈大小(而非 Tokio 默认的 2MB)是因为 Agent 的调用链可能很深——特别是嵌套子 Agent 和递归工具调用场景。
退出命令支持多种格式:exit、quit、/exit、/quit、:q(crates/octos-cli/src/commands/chat.rs:66-67)。
CLI 参数(crates/octos-cli/src/commands/chat.rs:22-64)支持覆盖配置文件中的关键设置:--cwd、--provider、--model、--max-iterations、--verbose。命令行参数优先于配置文件。
13.1.2 Gateway 模式(octos gateway)
后台守护进程(crates/octos-cli/src/commands/gateway/)。启动 ChannelManager 监听多个消息频道,将收到的消息路由到 Agent 处理。
GatewayRuntime(crates/octos-cli/src/commands/gateway/gateway_runtime.rs:54-95)持有 Gateway 的核心运行时状态:消息层(agent_handle、channel_mgr)、会话分发(actor_registry、session_dispatcher、active_sessions)、热加载状态(system_prompt、max_history、config_rx)以及 persona/heartbeat/cron 等后台服务。
Gateway 支持 Profile 模式。UserProfile.parent_id 用来标记子账号;Gateway 初始化时会把父 Profile 的 provider、model、base_url、api_key_env、fallback_models 和 env_vars 合并进子 Profile 配置(crates/octos-cli/src/profiles.rs:29-33; crates/octos-cli/src/commands/gateway/gateway_runtime.rs:128-144)。
GatewayDispatcher(crates/octos-cli/src/gateway_dispatcher.rs:35-44)从主循环中提取出可测试的命令分发逻辑,支持 /new(新建会话)、/switch(切换 Profile)等内部命令。
13.1.3 Serve 模式(octos serve)
Web 服务器(crates/octos-cli/src/commands/serve.rs)。默认端口 8080,默认绑定 127.0.0.1(crates/octos-cli/src/commands/serve.rs:23-31)——安全默认值,外部访问需要显式指定 --host 0.0.0.0。
提供 Web Dashboard、REST 端点、SSE 流式输出。通过 axum 框架构建,AppState 持有全局状态(Provider、工具注册表、会话管理器等)。
| 维度 | CLI | Gateway | Serve |
|---|---|---|---|
| 入口 | octos chat | octos gateway | octos serve |
| 用户交互 | 终端 readline | 消息频道 | Web UI + REST API |
| 并发模型 | 单会话 | 多频道多会话 | 多用户多会话 |
| 默认端口 | — | — | 8080 |
| 栈大小 | 8MB | 默认 | 默认 |
| 适用场景 | 开发调试 | 消息 bot | API 集成、Web 部署 |
13.1.4 三种模式的架构关系
flowchart LR
subgraph "共享基础"
Agent["Agent<br/>LLM + Tools + Memory"]
Config["Config<br/>Provider + Policy + Hooks"]
end
subgraph "CLI 模式"
CLI["octos chat<br/>readline 循环"]
end
subgraph "Gateway 模式"
GW["octos gateway<br/>ChannelManager"]
TG["Telegram"]
DC["Discord"]
SL["Slack"]
end
subgraph "Serve 模式"
SV["octos serve<br/>axum Web 服务器"]
REST["REST API"]
SSE["SSE 流式"]
UI["Web Dashboard"]
end
Config --> Agent
Agent --> CLI
Agent --> GW
Agent --> SV
GW --> TG & DC & SL
SV --> REST & SSE & UI
图 13-1:三种运行模式共享 Agent 核心。 Config 和 Agent 是共同基础,三种模式只在接入层不同。
13.1.5 共同的启动模式
三种模式共享相同的启动流程(Command Pattern):
- 解析 CLI 参数(
clapderive) - 加载配置文件(优先级链)
- 初始化 tracing 日志(7 天轮转,JSON 格式可选)
- 创建 Provider 和 Agent
- 进入各自的运行循环
13.2 配置体系
13.2.1 优先级层次
本地 .octos/config.json > 全局 ~/.config/octos/config.json > 内置默认值
本地配置优先于全局配置,允许不同项目使用不同的 Provider、模型和工具策略。
13.2.2 Provider 自动检测
当用户只指定模型名而未指定 Provider 时,octos 通过模型名前缀自动匹配(详见第 3 章 Provider 注册表):
claude-*→ Anthropicgpt-*→ OpenAIgemini-*→ Googledeepseek-*→ DeepSeek
13.2.3 热加载
Config Watcher(crates/octos-cli/src/config_watcher.rs:1-5)每 5 秒轮询配置文件(crates/octos-cli/src/config_watcher.rs:51-68),通过 SHA-256 hash 检测变更。
ConfigChange 枚举(crates/octos-cli/src/config_watcher.rs:15-25)区分两类变更:
| 类型 | 可热加载项 | 实现方式 |
|---|---|---|
| HotReload | system_prompt(crates/octos-cli/src/config_watcher.rs:144-148) | RwLock<String> 直接替换 |
| HotReload | max_history(crates/octos-cli/src/config_watcher.rs:151-156) | AtomicUsize 原子更新 |
| RestartRequired | base_url, api_key_env | 需要重建 HTTP 客户端(crates/octos-cli/src/config_watcher.rs:117-121) |
| RestartRequired | sandbox, mcp_servers, hooks | 需要重建隔离环境或外部连接(crates/octos-cli/src/config_watcher.rs:123-130) |
| RestartRequired | gateway.queue_mode, gateway.channels | 影响消息分发主循环(crates/octos-cli/src/config_watcher.rs:133-163) |
13.2.4 SwappableProvider:运行时模型切换,而不是文件热加载
当前实现需要区分两条路径:
- 配置文件热加载:Config Watcher 只会把
system_prompt和max_history作为HotReload发给主循环;Gateway 收到后分别写入RwLock<String>和AtomicUsize(crates/octos-cli/src/config_watcher.rs:175-180;crates/octos-cli/src/commands/gateway/gateway_runtime.rs:1335-1355)。 - 运行时模型切换:Gateway 启动时把当前 LLM 包装为
SwappableProvider(crates/octos-cli/src/commands/gateway/gateway_runtime.rs:256-257);当用户调用model_check工具执行切换时,SwitchModelTool才会显式调用swappable.swap(new_chain)(crates/octos-cli/src/tools/switch_model.rs:290-295)。
换句话说,编辑磁盘上的 config.json 并不会让正在运行的 Gateway 自动切换 provider/model。当前版本里,安全的心智模型是:system_prompt/max_history 可以文件热加载;provider/model 可以在会话内显式切换;如果你修改了配置文件里的 provider/model,通常仍应重启进程,让新配置在启动路径中重新构造 Provider 链。
SwappableProvider 本身的关键实现位于 crates/octos-llm/src/swappable.rs:16-23,50-56:
#![allow(unused)]
fn main() {
pub fn swap(&self, new_provider: Arc<dyn LlmProvider>) {
let model_id = leak_str(new_provider.model_id().to_string());
let provider_name = leak_str(new_provider.provider_name().to_string());
*self.inner.write().unwrap() = new_provider;
*self.cached_model_id.write().unwrap() = model_id;
*self.cached_provider_name.write().unwrap() = provider_name;
}
}
Box::leak() 将 String 转换为 &'static str——代价是一小段永不释放的内存(每次模型切换泄漏几十个字节),换来的是 model_id() 和 provider_name() 可以在不持有 inner 读锁的情况下返回字符串引用。对于一个长期运行的服务,这点内存泄漏完全可接受。
Config Watcher 的安全性:Watcher 在一次轮询中读取所有配置文件并计算 hash,避免了先检查-再读取的 TOCTOU 竞态。如果配置文件解析失败,保留上一次的有效配置并打印警告,不会崩溃。
为什么用轮询而非 inotify? 跨平台兼容性。inotify 是 Linux 特有的,macOS 用 kqueue,Windows 用 ReadDirectoryChangesW。5 秒轮询 + SHA-256 hash 在所有平台上一致工作,且开销极小(一次 SHA-256 计算 < 1 微秒)。
13.3 Feature Flags
octos 通过 Cargo feature flags 控制条件编译:
| Feature | 启用内容 |
|---|---|
api | Web API 服务器、监控、OTP、用户管理 |
admin-bot | 管理 Bot 能力,在 api 之上附加 Telegram 管理接口 |
telegram | Telegram 频道集成 |
discord | Discord 频道集成 |
slack | Slack 频道集成 |
email | 邮件收发集成 |
git | Git 操作工具 |
ast | AST 代码结构分析 |
这让用户可以编译最小化的 octos 版本——只需 CLI 功能时,不引入 Web 服务器和频道集成的依赖。需要注意的是,BrowserTool 是默认内置工具,不对应单独的 Cargo feature;按需编译主要控制的是 API 能力和各频道集成。
工程决策侧栏:热加载 vs 全重启的边界划分
热加载的核心问题是“什么可以安全替换,什么不可以“。
系统提示可以热加载,因为它是无状态的文本——下一次 LLM 调用使用新提示即可,不影响进行中的会话。
Provider/模型需要区分两种情形:对话内显式切换可以通过
SwappableProvider完成,但这条路径由model_check工具触发;直接编辑配置文件里的 provider/model,当前ConfigWatcher不会自动应用到运行中的 Gateway。与之相对,base_url 和 api_key_env 明确属于重启项,因为它们影响底层 HTTP 客户端的构造(连接池、TLS 配置),运行时替换可能导致进行中的请求失败。Hooks不能热加载,因为 Hook 的 circuit breaker 状态(连续失败计数)需要重新初始化。如果热加载只替换命令但不重置计数器,一个之前被熔断的 Hook 永远不会恢复。
简单规则:文件热加载只覆盖已经接入
ConfigWatcher的字段(目前是文本和历史窗口),需要重建连接的仍然重启;SwappableProvider解决的是受控的运行时切换,不是通用配置热更新。
13.4 本章回顾
- 三种模式:CLI(终端交互)、Gateway(消息 bot)、Serve(Web API),同一代码库三种入口。
- 配置层次:本地 > 全局 > 默认,Provider 自动检测简化配置。
- 热加载:SHA-256 轮询检测。文件热加载当前只覆盖
system_prompt和max_history;provider/model 的运行时切换走SwappableProvider+model_check工具;base_url、hooks、MCP等仍需重启。 - Feature Flags:按需编译,最小化部署体积。
延伸阅读
- 12-Factor App:https://12factor.net/ — 特别是 Config 和 Processes 章节
- axum 框架:https://docs.rs/axum/latest/axum/ — octos Serve 模式的 Web 框架
思考题
- 模式融合:如果需要在同一进程中同时运行 Gateway(消息 bot)和 Serve(Web API),架构需要做什么改变?
- 配置验证:当前配置文件在运行时解析和验证。如果提供一个
octos config validate命令做离线验证,你会检查哪些内容?
版本演化说明 本章分析基于 octos v0.1.0。截至本书写作时,三种运行模式的入口和配置体系无重大变化。Feature flags 列表可能随功能扩展而增加。
第 14 章:生产化:认证、监控与部署
定位:本章展示 octos 从开发工具到生产系统需要的最后一块拼图——认证、Hooks 生命周期、监控和多租户配置。前置依赖:第 13 章。适用场景:需要将 octos 部署到生产环境的运维人员(读者 D),以及想理解生产级系统设计模式的开发者(读者 B)。
一个系统从“能跑“到“能上线“之间的距离,往往比代码量暗示的更大。认证、监控、Hook 系统、多租户隔离——这些不是功能,而是信任的基础设施。
14.1 认证三流
14.1.1 OAuth PKCE
octos 对 OpenAI 等支持 OAuth 的 Provider 实现了 PKCE(Proof Key for Code Exchange)流程(crates/octos-cli/src/auth/oauth.rs)。
PKCE 的核心思想:传统的 OAuth 授权码流程中,恶意应用可以拦截 authorization code 并冒充合法应用。PKCE 通过在授权请求中嵌入一个“证明密钥“来防止这种攻击——只有知道原始 verifier 的应用才能用 code 换取 token。
octos 的 PKCE 实现(oauth.rs:30-45):
#![allow(unused)]
fn main() {
pub fn generate_pkce() -> PkceChallenge {
// 1. Verifier = 2 个 UUID v4 拼接 = 64 个十六进制字符
let verifier = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple());
// 2. Challenge = SHA-256(verifier) 的 Base64-URL 编码(无 padding)
let mut hasher = Sha256::new();
hasher.update(verifier.as_bytes());
let challenge = base64_url_encode(&hasher.finalize());
PkceChallenge { verifier, challenge }
}
}
为什么用 2 个 UUID 拼接? RFC 7636 要求 verifier 长度在 43-128 字符之间。单个 UUID v4 的 simple 格式是 32 个十六进制字符(不够),两个拼接得到 64 个(满足要求)。
授权流程的五个步骤:
- 生成 PKCE verifier + challenge 对
- 生成随机 state 参数(UUID v4,防 CSRF)
- 打开浏览器跳转到 Provider 的授权页面(携带 challenge)
- 本地启动 HTTP 服务器(
localhost:1455/auth/callback,oauth.rs:18-21)接收回调 - 用 authorization code + verifier 换取 access token
14.1.2 Device Code Flow
对于无浏览器环境(如远程服务器),支持 device code flow——显示一个 URL 和代码,用户在另一台设备上完成认证。
14.1.3 Paste-token
最简单的认证方式——用户直接粘贴 API key。适用于不支持 OAuth 的 Provider。
14.1.4 凭据存储
凭据存储在 ~/.octos/auth.json,文件权限 0600(仅所有者可读写)。Bearer token 比较使用常量时间算法(subtle crate),防止时序攻击。
14.1.5 API 安全
Serve 模式的 HTTP 服务器默认绑定 127.0.0.1(仅本地访问)。需要外部访问时通过 --host 0.0.0.0 显式开启——安全默认值原则。
14.2 Hooks 生命周期
sequenceDiagram
participant Agent
participant HookExecutor
participant Hook as Hook 进程
Agent->>HookExecutor: before_tool_call(shell, args)
HookExecutor->>HookExecutor: 检查 circuit breaker
alt 未熔断
HookExecutor->>HookExecutor: sanitize_payload()
HookExecutor->>Hook: spawn(argv) + stdin JSON
Hook-->>HookExecutor: exit 0 (allow)
HookExecutor-->>Agent: 继续执行工具
else exit 1 (deny)
Hook-->>HookExecutor: exit 1
HookExecutor-->>Agent: 阻止工具调用
else 连续 3 次失败
HookExecutor->>HookExecutor: 禁用 Hook (circuit break)
HookExecutor-->>Agent: 跳过,继续执行
end
图 14-1:Hook 执行时序。 before_tool_call 是最常用的 Hook 事件。Circuit breaker 在 3 次连续失败后自动禁用 Hook。
Hooks 让用户在 Agent 执行的关键节点注入自定义逻辑(crates/octos-agent/src/hooks.rs)。
14.2.1 四个事件
| 事件 | 时机 | 典型用途 |
|---|---|---|
before_tool_call | 工具调用前 | 审批、参数修改、日志 |
after_tool_call | 工具调用后 | 结果过滤、审计 |
before_llm_call | LLM 调用前 | 提示修改、请求拦截 |
after_llm_call | LLM 调用后 | 响应过滤、监控 |
14.2.2 HookConfig 与 HookPayload
每个 Hook 的配置(hooks.rs:36-47):
#![allow(unused)]
fn main() {
pub struct HookConfig {
pub event: HookEvent, // 触发的生命周期事件
pub command: Vec<String>, // argv 数组——无 Shell 解释
pub timeout_ms: u64, // 超时(默认 5000ms)
pub tool_filter: Option<String>, // 可选:只对特定工具触发
}
}
tool_filter 让用户可以精确控制——例如,只在 shell 工具调用前触发审批 Hook,其他工具不触发。
HookPayload(hooks.rs:55-105)是传递给 Hook 进程的 JSON 数据:
| 事件类型 | Payload 字段 |
|---|---|
| before/after_tool_call | tool_name, arguments, tool_id, result |
| before/after_llm_call | model, stop_reason, has_tool_calls, token 计数 |
| 所有事件 | session_id, profile_id(来自 HookContext) |
| after_llm_call(额外) | cumulative_input_tokens, session_cost |
14.2.3 Shell 协议
Hook 命令以 argv 数组执行(无 Shell 解释,防止注入),通过 stdin 接收 JSON payload,通过 exit code 返回决策:
| Exit Code | 含义 | 行为 |
|---|---|---|
| 0 | Allow | 继续执行 |
| 1 | Deny | 阻止操作 |
| 2+ | Modify | 使用 stdout 的 JSON 替换原始参数 |
敏感数据保护(hooks.rs:107-150):
#![allow(unused)]
fn main() {
const MAX_PAYLOAD_FIELD_BYTES: usize = 1024; // 1KB
const SENSITIVE_TOOLS: &[&str] = &["shell", "write_file", "read_file"];
}
- 敏感工具(shell、write_file、read_file)的参数被替换为
{"redacted": true} - 其他工具参数截断到 1KB(UTF-8 安全截断)
- 防止 Hook 进程(可能是第三方脚本)看到文件内容或 Shell 命令
Session 上下文(session_id、profile_id)注入到所有 Payload 中(hooks.rs:85-88),让 Hook 可以实现基于会话或用户的差异化策略。
14.2.4 Hook 执行源码走读
execute_hook()(hooks.rs:478-557)展示了安全执行外部进程的完整模式:
#![allow(unused)]
fn main() {
async fn execute_hook(&self, hook: &HookConfig, payload_json: &str) -> Result<(i32, String)> {
let (program, args) = hook.command.split_first()
.ok_or_else(|| eyre!("empty hook command"))?;
let program = expand_tilde(program); // ~/script.sh -> /home/user/script.sh
let mut cmd = tokio::process::Command::new(&program);
cmd.args(args).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
for var in BLOCKED_ENV_VARS { cmd.env_remove(var); }
let mut child = cmd.spawn()?;
if let Some(mut stdin) = child.stdin.take() {
let _ = stdin.write_all(payload_json.as_bytes()).await;
let _ = stdin.shutdown().await;
}
match tokio::time::timeout(Duration::from_millis(hook.timeout_ms), child.wait()).await {
Ok(Ok(status)) => Ok((status.code().unwrap_or(2), stdout)),
Err(_) => {
let _ = child.kill().await; // 超时 kill 防止僵尸进程
Err(eyre!("hook timed out after {}ms", hook.timeout_ms))
}
}
}
}
argv 数组而非 shell 字符串:Command::new(program).args(args) 直接传递参数给 execve(),不经过 shell 解释。这关闭了 shell 注入攻击面。
tilde 展开:因为不经过 shell,~/script.sh 不会自动展开。expand_tilde() 安全地将 ~ 替换为 $HOME。
14.2.5 Circuit Breaker
每个 Hook 维护一个 AtomicU32 失败计数器。连续 3 次失败后自动禁用,使用 compare_exchange(CAS)确保警告只打印一次(hooks.rs:376-396):
#![allow(unused)]
fn main() {
let failures = hook_failures[i].fetch_add(1, Ordering::Relaxed) + 1;
if failures >= threshold {
if hook_failures[i].compare_exchange(failures, threshold + 1, Ordering::Relaxed, Ordering::Relaxed).is_ok() {
warn!("Hook {:?} disabled after {} failures", hook.command, threshold);
}
continue;
}
}
成功调用重置计数器为 0。这防止了有 bug 的 Hook 进程持续崩溃拖慢整个系统。
14.3 监控
14.3.1 Prometheus 指标
Serve 模式暴露 Prometheus 指标端点,主要指标包括:
- Token 使用量:每次迭代的 input/output tokens(通过
TokenTracker的原子计数器上报) - 请求延迟:LLM 调用和工具执行的延迟分布
- 会话计数:活跃会话数量
14.3.2 Tracing
octos 使用 tracing crate 实现结构化日志,支持 JSON 格式输出(通过 tracing-subscriber 的 json layer)和文件轮转(通过 tracing-appender)。
14.4 多租户配置
多租户场景下,每个租户可以有独立的:
- Provider 和模型配置
- 工具策略(ToolPolicy)
- 系统提示
- 会话存储路径
租户隔离通过配置级别的分离实现——不同租户使用不同的配置文件,Agent 实例根据配置文件创建独立的 Provider、工具注册表和存储路径。
工程决策侧栏:为什么 Hooks 用 exit code 而非 JSON 响应
方案一:JSON 响应(stdin/stdout 全部 JSON)
优势:表达力强,可以携带复杂的决策理由和修改后的参数 劣势:Hook 作者需要输出合法 JSON——Shell 脚本很难做到可靠的 JSON 生成
方案二:Exit code + 可选 stdout(octos 的选择)
优势:
- 最简单的 Hook 只需要
exit 0(允许)或exit 1(拒绝)- Shell 脚本天然支持 exit code
- 只在 exit 2+ 时才需要 JSON stdout,大部分 Hook 不需要修改参数
劣势:
- exit code 语义有限(只有 allow/deny/modify 三种)
- 拒绝原因无法通过 exit code 传达(需要 stderr 日志)
选择理由: Hook 的主要用途是审批和日志,90% 的场景只需要 allow/deny 决策。用 exit code 让最简单的 Hook 实现极其轻量——一个 3 行的 Shell 脚本就能实现审批逻辑。只有需要修改参数的高级场景才需要 JSON 输出。
14.5 本章回顾
- 认证三流:OAuth PKCE(浏览器环境)、Device Code(无浏览器)、Paste-token(最简)。凭据 0600 权限 + 常量时间比较。
- Hooks:4 事件 × Shell 协议(argv 执行、exit code 决策)。Circuit breaker 3 次失败自动禁用。敏感参数自动脱敏。
- 监控:Prometheus 指标 + 结构化日志。原子计数器实现锁无关的 token 追踪。
- 多租户:配置级隔离,每租户独立的 Provider、策略和存储。
全书 14 章到此结束。附录将提供完整的 Crate 依赖图、工具速查表、配置参考和贡献指南。
延伸阅读
- OAuth 2.0 PKCE:RFC 7636 “Proof Key for Code Exchange by OAuth Public Clients”
- Prometheus:https://prometheus.io/docs/introduction/overview/ — 监控系统和时序数据库
- Circuit Breaker:Martin Fowler, “CircuitBreaker” — 理解熔断器模式的设计理由
- 常量时间比较:
subtlecrate 文档 — 防止时序侧信道攻击
思考题
- Hook 的安全边界:当前 Hooks 通过 argv 执行(无 Shell),但 Hook 命令本身可能是一个恶意程序。你会如何验证 Hook 命令的可信度?
- Circuit Breaker 的恢复:当前的实现在成功调用时重置计数器。但如果 Hook 被禁用后永远不再调用,它就永远无法恢复。你会如何设计一个“试探性恢复“机制?
- 多租户的资源隔离:当前的多租户隔离是配置级的,不是进程级的。如果一个租户的 Agent 消耗了过多 CPU 或内存,会影响其他租户。你会如何实现资源级别的隔离?
版本演化说明 本章分析基于 octos v0.1.0。截至本书写作时,OAuth PKCE 流程和 Hooks 系统无重大变化。Prometheus 指标列表可能随版本扩展。多租户支持可能在后续版本中增加进程级隔离。
附录 A:octos 完整 Crate 依赖图
内部 Crate 依赖拓扑
graph BT
subgraph "独立 Crate"
core["octos-core"]
plugin["octos-plugin"]
sandbox["octos-sandbox"]
end
subgraph "领域服务"
llm["octos-llm"]
memory["octos-memory"]
bus["octos-bus"]
end
subgraph "运行时引擎"
agent["octos-agent"]
pipeline["octos-pipeline"]
end
subgraph "用户入口"
cli["octos-cli"]
end
llm --> core
memory --> core
bus --> core
agent --> core
agent --> llm
agent --> memory
pipeline --> core
pipeline --> agent
pipeline --> llm
pipeline --> memory
cli --> core
cli --> agent
cli --> llm
cli --> memory
cli --> pipeline
cli --> bus
各 Crate 关键外部依赖
| Crate | 关键依赖 | 版本 | 用途 |
|---|---|---|---|
| octos-core | serde, serde_json, chrono, uuid, eyre | 1.x, 1.x, 0.4, 1.x, 0.6 | 序列化、时间、ID、错误 |
| octos-llm | reqwest, async-trait, futures, hnsw_rs | 0.12, 0.1, 0.3, 0.3 | HTTP、异步 trait、流、向量索引 |
| octos-memory | redb, bincode, hnsw_rs | 2.x, 1.x, 0.3 | 嵌入式 DB、序列化、向量搜索 |
| octos-agent | tokio, lru, libc, glob, regex | 1.x, 0.16, 0.2, 0.3, 1.x | 异步运行时、缓存、系统调用、文件搜索 |
| octos-bus | teloxide*, serenity*, tokio-tungstenite* | 0.17, 0.12, 0.26 | Telegram/Discord/WebSocket(*feature-gated) |
| octos-cli | clap, axum*, tower-http*, rustyline | 4.x, 0.8, 0.6, 15.x | CLI 解析、Web 服务、readline |
| octos-pipeline | 继承自 octos-agent 依赖 | — | 无独立重依赖 |
| octos-plugin | serde, serde_json, eyre, which | 1.x, 1.x, 0.6, 7.x | 序列化、错误、可执行文件发现 |
| octos-sandbox | clap, eyre | 4.x, 0.6 | CLI 解析、错误(仅 Windows) |
标注 * 的依赖通过 feature flags 按需引入。
Workspace 共享依赖
以下依赖在 [workspace.dependencies] 中统一定义,所有 crate 使用相同版本:
- tokio 1.x(full features):异步运行时
- serde 1.x(derive):序列化框架
- eyre 0.6 / color-eyre 0.6:错误处理
- tracing 0.1 / tracing-subscriber 0.3:结构化日志
- reqwest 0.12(rustls-tls):HTTP 客户端(纯 Rust TLS)
附录 B:工具速查表
内置工具一览
| 工具名 | 分组 | 核心参数 | 功能 |
|---|---|---|---|
shell | runtime | command: string | 在沙箱中执行 Shell 命令 |
read_file | fs | path: string, offset?: int, limit?: int | 读取文件内容(支持分页) |
write_file | fs | path: string, content: string | 写入文件(覆盖) |
edit_file | fs | path: string, old_string: string, new_string: string | 精确字符串替换 |
diff_edit | fs | path: string, diff: string | Diff 格式编辑 |
glob | search | pattern: string, path?: string | 文件模式搜索 |
grep | search | pattern: string, path?: string, include?: string | 内容正则搜索 |
list_dir | search | path: string | 列出目录内容 |
web_search | web | query: string | 网页搜索 |
web_fetch | web | url: string | 获取网页内容 |
browser | web | url: string, action?: string | 浏览器自动化 |
git | — | command: string | Git 操作(feature: git) |
code_structure | — | path: string | AST 代码结构分析(feature: ast) |
辅助工具
| 工具名 | 类型 | 功能 |
|---|---|---|
spawn | sessions | 后台异步执行子 Agent |
deep_search | research | 深度网页研究 |
recall_memory | memory | 检索长期记忆 |
save_memory | memory | 保存到长期记忆 |
manage_skills | admin | 管理 skills |
activate_tools | admin | 激活延迟工具 |
send_file | — | 发送文件给用户 |
message | — | 发送消息到频道 |
工具分组策略
| 分组名 | 包含工具 | 典型策略 |
|---|---|---|
group:fs | read_file, write_file, edit_file, diff_edit | 文件操作 |
group:runtime | shell | 命令执行 |
group:web | web_search, web_fetch, browser | 网络操作 |
group:search | glob, grep, list_dir | 代码搜索 |
group:sessions | spawn | 后台任务 |
group:memory | recall_memory, save_memory | 记忆操作 |
group:research | deep_search, synthesize_research | 深度研究 |
group:admin | manage_skills, activate_tools, configure_tool | 管理操作 |
策略配置示例
{
"tools": {
"allow": ["group:fs", "group:search", "shell"],
"deny": ["browser", "web_fetch"],
"byProvider": {
"ollama": {
"allow": ["read_file", "shell", "grep"]
}
}
}
}
deny-wins 规则:deny 列表优先于 allow 列表。上例中 browser 被禁止,即使它属于某个允许的分组。
附录 C:配置参考
配置文件位置
| 优先级 | 路径 | 说明 |
|---|---|---|
| 1(最高) | .octos/config.json | 项目本地配置 |
| 2 | ~/.config/octos/config.json | 用户全局配置 |
| 3(最低) | 内置默认值 | 代码中定义的默认值 |
核心配置字段
| 字段路径 | 类型 | 默认值 | 说明 |
|---|---|---|---|
provider | string | (自动检测) | LLM Provider 名称 |
model | string | — | 模型 ID |
base_url | string? | Provider 默认 | API 端点覆盖 |
api_key_env | string? | Provider 默认 | API Key 环境变量名 |
system_prompt | string? | 内置默认 | 自定义系统提示 |
max_iterations | u32 | 50 | Agent 最大迭代次数 |
max_tokens | u32? | 无限制 | Token 预算上限 |
max_timeout_secs | u64 | 600 | 墙钟超时(秒) |
tool_timeout_secs | u64 | 600 | 单工具调用超时(秒) |
工具策略
| 字段路径 | 类型 | 默认值 | 说明 |
|---|---|---|---|
tools.allow | string[] | [] | 允许的工具列表(空=全部允许) |
tools.deny | string[] | [] | 禁止的工具列表(deny-wins) |
tools.byProvider.<name>.allow | string[] | — | Provider 级允许列表 |
tools.byProvider.<name>.deny | string[] | — | Provider 级禁止列表 |
Gateway 配置
| 字段路径 | 类型 | 默认值 | 说明 |
|---|---|---|---|
gateway.channels | object | {} | 频道配置 |
gateway.max_concurrent_sessions | u32 | 10 | 最大并发会话数 |
Serve 配置
| 字段路径 | 类型 | 默认值 | 说明 |
|---|---|---|---|
serve.host | string | “127.0.0.1” | 绑定地址 |
serve.port | u16 | 3000 | 绑定端口 |
MCP 服务器
| 字段路径 | 类型 | 说明 |
|---|---|---|
mcp_servers.<name>.command | string | Stdio 模式:启动命令 |
mcp_servers.<name>.args | string[] | 命令参数 |
mcp_servers.<name>.env | object | 传递给子进程的环境变量 |
mcp_servers.<name>.url | string | HTTP 模式:服务器 URL |
Hooks
| 字段路径 | 类型 | 说明 |
|---|---|---|
hooks.before_tool_call | object[] | 工具调用前钩子 |
hooks.after_tool_call | object[] | 工具调用后钩子 |
hooks.before_llm_call | object[] | LLM 调用前钩子 |
hooks.after_llm_call | object[] | LLM 调用后钩子 |
每个 hook 对象:{ "command": ["cmd", "arg1"], "timeout_ms": 5000, "tool_filter": "shell" }
示例配置
{
"model": "claude-sonnet-4-20250514",
"system_prompt": "You are a helpful coding assistant.",
"max_iterations": 30,
"tools": {
"deny": ["browser"],
"byProvider": {
"ollama": { "allow": ["read_file", "shell", "grep"] }
}
},
"gateway": {
"max_concurrent_sessions": 20,
"channels": {
"telegram": { "token_env": "TELEGRAM_BOT_TOKEN" }
}
},
"mcp_servers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"]
}
},
"hooks": {
"before_tool_call": [
{ "command": ["./audit-hook.sh"], "timeout_ms": 3000 }
]
}
}
附录 D:Feature Flags 一览
octos-cli Feature Flags
| Feature | 启用功能 | 额外依赖 | 默认 |
|---|---|---|---|
api | Web API 服务器、SSE 流式、监控、用户管理 | axum, tower-http, metrics, rust-embed | 否 |
telegram | Telegram 频道集成 | teloxide | 否 |
discord | Discord 频道集成 | serenity | 否 |
slack | Slack 频道集成 | tokio-tungstenite | 否 |
whatsapp | WhatsApp 频道集成 | — | 否 |
feishu | 飞书频道集成 | — | 否 |
email | 邮件收发集成 | async-imap, lettre, mailparse | 否 |
matrix | Matrix 频道集成 | — | 否 |
twilio | Twilio SMS/Voice 集成 | — | 否 |
wecom | 企业微信集成 | — | 否 |
qq-bot | QQ Bot 集成 | — | 否 |
octos-agent Feature Flags
| Feature | 启用功能 | 额外依赖 | 默认 |
|---|---|---|---|
git | Git 操作工具(GitTool) | gix | 否 |
ast | AST 代码结构分析工具 | tree-sitter, tree-sitter-{rust,python,js,ts} | 否 |
octos-bus Feature Flags
| Feature | 启用功能 | 额外依赖 | 默认 |
|---|---|---|---|
api | API 频道(REST/SSE 接入) | — | 否 |
telegram | Telegram 频道实现 | teloxide | 否 |
discord | Discord 频道实现 | serenity | 否 |
slack | Slack 频道实现 | tokio-tungstenite | 否 |
email | 邮件频道实现 | async-imap, lettre | 否 |
编译示例
# 最小 CLI(无频道集成、无 Web 服务器)
cargo build --release
# CLI + Web API
cargo build --release --features api
# 完整 Gateway(所有频道)
cargo build --release --features "api,telegram,discord,slack,email,feishu,whatsapp,matrix"
# 开发者完整构建(所有功能)
cargo build --release --all-features
设计原则
Feature flags 遵循“最小默认“原则——默认编译只包含 CLI 核心功能,所有频道集成和可选工具通过 flags 按需启用。这确保了:
- 最小二进制体积:不需要的功能不编译
- 最少依赖:不使用 Telegram?就不引入 teloxide 及其依赖链
- 最快编译:开发时只编译需要的功能
附录 E:从源码构建与贡献指南
环境要求
| 工具 | 版本要求 | 说明 |
|---|---|---|
| Rust | ≥ 1.85.0 | Cargo.toml 中的 rust-version |
| Edition | 2024 | Rust 2024 edition |
| 操作系统 | Linux / macOS / Windows | 全平台支持 |
| Git | ≥ 2.x | 代码管理 |
构建步骤
# 1. 克隆仓库
git clone https://github.com/octos-org/octos.git
cd octos
# 2. 检查工具链
rustup show # 确认 Rust 版本 ≥ 1.85.0
# 3. 构建(默认 features)
cargo build
# 4. 运行测试
cargo test --workspace
# 5. 完整构建(所有 features)
cargo build --all-features
# 6. 完整测试
cargo test --workspace --all-features
# 7. Clippy 检查
cargo clippy --workspace --all-features -- -D warnings
# 8. 格式检查
cargo fmt --all -- --check
代码规范
Rust 版本与 Edition
- Edition: 2024(
Cargo.toml中edition = "2024") - Rust 最低版本: 1.85.0
- unsafe: 全 workspace 禁止(
[workspace.lints.rust] unsafe_code = "deny")
错误处理
- 使用
eyre::Result和color-eyre而非anyhow - 库 crate 返回
eyre::Result,binary crate 在main()中初始化color_eyre::install() - 避免
.unwrap()——使用?传播或.expect("reason")说明不变量
代码风格
- 遵循
rustfmt默认配置 #![warn(clippy::all)]在每个 crate 的lib.rs/main.rs- 公有 API 添加文档注释(
///) - 模块级文档使用
//!
测试策略
- 单元测试与代码放在同一文件的
#[cfg(test)] mod tests中 - 集成测试放在
tests/目录 - 使用
tokio::test宏进行异步测试 - 使用
tempfilecrate 管理临时文件
TDD 工作流
octos 鼓励 RED → GREEN → REFACTOR 的 TDD 工作流:
- RED:先写失败的测试,明确预期行为
- GREEN:写最少的代码让测试通过
- REFACTOR:在测试保护下重构代码
# 运行特定测试
cargo test -p octos-core test_task_status
# 运行特定 crate 的所有测试
cargo test -p octos-memory
# 带输出运行测试
cargo test -p octos-agent -- --nocapture
PR 提交指南
分支命名
feature/add-wechat-channel
fix/ssrf-ipv6-bypass
refactor/tool-registry-lru
docs/ch05-agent-loop
Commit Message
feat(bus): add WeChat channel integration
- Implement WeChatChannel with encryption support
- Add message coalescing for 2000-char limit
- Include /new session fork support
Closes #123
格式:type(scope): description。类型包括 feat、fix、refactor、docs、test、chore。
Review 流程
- 所有 PR 必须通过 CI(
cargo test --workspace --all-features+cargo clippy+cargo fmt --check) - 至少一个 maintainer review
- 与安全相关的变更(sandbox、policy、sanitize)需要两个 reviewer
- 不允许 force push 到
main分支
项目结构速查
octos/
├── Cargo.toml # Workspace 定义
├── crates/
│ ├── octos-core/ # 核心类型(第 2 章)
│ ├── octos-llm/ # LLM Provider(第 3 章)
│ ├── octos-memory/ # 记忆系统(第 4 章)
│ ├── octos-agent/ # Agent 运行时(第 5-9 章)
│ ├── octos-bus/ # 消息总线(第 10 章)
│ ├── octos-cli/ # CLI 入口(第 13 章)
│ ├── octos-pipeline/ # 工作流引擎(第 12 章)
│ ├── octos-plugin/ # 插件 SDK(第 9 章)
│ ├── octos-sandbox/ # Windows 沙箱(第 7 章)
│ ├── app-skills/ # 应用级 Skills
│ └── platform-skills/ # 平台级 Skills