十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

从cargo视角拆解claw-code:Rust运行时中的会话、压缩与MCP集成

从cargo视角拆解claw-code:Rust运行时中的会话、压缩与MCP集成 先说清楚这篇的定位。claw-code 这个项目我在本地跑了挺久也翻过源码。它不是那种纯 API 封装的玩具而是一个把会话、压缩、MCP、提示构造这些概念真正落到二进制里的运行时。作为常年用 cargo 管理 Rust 项目的人我最关心的就是这些听起来很上层的概念在系统语言里到底对应什么样的数据结构、生命周期和调用链所以这篇我不讲界面操作也不讲怎么配 MCP server 能跑通 demo而是带着你把源码拆开看从 cargo 的视角找出每个模块的真实落点。如果你是想写自己的 Agent 运行时、或者想在 Rust 里接 MCP、再或者只是想搞明白提示构造和提示模板到底差在哪这篇文章应该能给你一张非常具体的地图。1. 从 Cargo.toml 看运行时定位1.1 definitive runtime 到底在说什么刚拿到 claw-code 的源码时我最先翻的不是 src而是仓库根目录的 Cargo.toml。Rust 项目跟别的语言不一样它的依赖声明本身就暴露了架构倾向。你在 Cargo.toml 里看到 tokio、serde、tower、async-trait 这种组合基本能猜到这是一个异步运行时驱动的常驻进程看到 flate2、zstd、snap 这种压缩库就说明它不只是做文本处理还在处理有体积敏感的数据流再看到跟 MCP 相关的 crate或者它自己在 workspace 里定义了 mcp 子 crate那就说明工具调用协议不是塞在业务代码里的杂毛而是被当成了一等公民。definitive runtime这个词我会拆成两层理解。第一层是运行时它负责把用户输入的指令、历史会话、工具输出、模型响应这些离散事件组织成一个持续运行的循环。第二层是definitive也就是决定性的意味着这些机制不是插件式的、不是事后打补丁的而是从进程启动那一刻就存在于主循环里的基础能力。换成代码语言来说会话、压缩、MCP、提示构造这些名词最终都会落到某个 struct、某个 trait、某个异步任务的实现细节里。我建议你拿到任何 Rust 项目都先做一件事跑cargo tree。它会把你从看代码的局部视角拉出来进入看依赖的全局视角。一个运行时的骨架在cargo tree的输出里写得很清楚谁负责 IO、谁负责序列化、谁负责并发、谁负责协议一目了然。1.2 工作区结构多个 crate 如何协同claw-code 在仓库组织上大概率不是单 crate 的怪东西而是一个 cargo workspace。为什么要拆多个 crate最直接的好处是编译边界清晰。会话管理、压缩、MCP client、提示构造这四块逻辑如果堆在同一个 crate 里哪怕只是改一个结构体字段cargo 也会重新检查整个 crate 的类型关系。把它们拆开之后cargo 可以只重编受影响的 crate增量编译体验会好很多。从依赖方向看一个合理的分层是这样的claw-code入口二进制 ├── claw-session会话状态、持久化、恢复 ├── claw-compress压缩与摘要归档 ├── claw-mcp协议客户端、工具注册、JSON-RPC ├── claw-prompt提示构造、上下文组装、token 估算 └── claw-core共享类型消息、工具定义、运行时错误这种结构下核心数据模型放在 claw-core下游四个模块都依赖它但模块之间不互相依赖。claw-mcp 不需要知道会话是怎么持久化的claw-prompt 只需要把组装好的消息结构传给会话层去追加记录。这就是编译视角的解耦。对源码分析来说cargo workspace 的另一个好处是测试边界也拆出来了。你可以单独对 claw-session 跑一轮单元测试验证快照恢复逻辑也可以单独对 claw-compress 跑一轮基准测试看不同压缩等级下的耗时和体积。下面每个模块我都会用这种单独验证 整体联动的思路来拆。2. 会话层拆解状态在 Rust 里如何流转2.1 Session 的生命周期模型会话层是所有 Agent 类项目的核心瓶颈因为整个运行时的工作都是在维护一段有状态的对话。在 Rust 里这个状态通常不会到处复制而是集中放在一个被共享所有权包裹的结构里。我按常见实现还原一下claw-code 里大概会有一个类似下面的结构pub struct Session { id: Uuid, history: VecMessage, metadata: SessionMeta, state: SessionState, } pub enum SessionState { New, Running { active_tool_call: OptionToolCallId }, Waiting { continuation: Continuation }, Finished { summary: OptionSummary }, }关键在这个SessionState枚举。它把会话从新开始到运行中再到等待工具返回最后到结束/已摘要的状态机显式地建模出来了。为什么不用一个简单的bool is_running因为 Agent 运行时的真实流程不是线性的。模型可能会请求调用 MCP 工具此时会话必须挂起保留现场等工具结果返回后再把控制权交回给模型。这个挂起-恢复的中间态如果不用枚举建模后续代码很容易出现一堆标志位互相打架的场面。实际运行时这个Session会被包进ArcRwLockSession或者Arctokio::sync::RwLockSession。为什么是读写锁而不是互斥锁因为并发模型里有读多写少的场景多个事件循环可能在同时检查会话的元数据而真正写历史的线程只有一个。读写锁能让读请求并行写请求独占吞吐量会好看一点。生命周期大体可以这么梳理用户输入到达创建或加载 Session。会话进入 Running把用户消息追加进 history。运行时调用提示构造模块把 history 组装成消息序列。模型返回文本或工具调用请求。如果是文本直接追加结果本轮结束。如果是工具调用会话切成 Waiting记录当前 tool_call_id。工具结果返回后会话恢复 Running继续下一轮。这个循环就是 runtime 的核心事件循环。会话层负责保证状态不丢、不乱、不重复。2.2 持久化快照、序列化与原子写说到状态不丢就得聊持久化。claw-code 的会话持久化会落到本地文件路径一般约定在用户配置目录或项目目录下比如.claw/sessions/session_id.json。每次会话发生关键状态变化比如工具调用完成、模型响应写入、上下文被压缩归档时都会触发一次持久化。这里有一个很多新手容易踩坑的地方直接把文件write覆盖写。如果进程在写文件写到一半时崩溃磁盘上留下的就是一个截断的 JSON下次启动反序列化直接报错。正确做法是写临时文件 fsync 原子重命名fn save_snapshot(path: PathBuf, data: SessionSnapshot) - Result() { let tmp_path path.with_extension(json.tmp); let mut file BufWriter::new(File::create(tmp_path)?); serde_json::to_writer(mut file, data)?; file.flush()?; file.get_ref().sync_all()?; fs::rename(tmp_path, path)?; Ok(()) }先写.tmp文件调用sync_all确保数据落到磁盘再用rename替换旧文件。因为rename在同一个文件系统内是原子的所以任何时刻打开会话文件看到的要么是旧版本要么是新版本不会出现半个文件。序列化格式方面JSON 是最容易调试的但体积偏大如果希望启动加载更快、文件更小claw-code 可能允许切换成 Postcard 或 MessagePack。实际取舍看你需要什么调试期用 JSON稳定期可以切成二进制格式。我的建议是底层持久化用二进制格式另外导出一个 JSON 副本用于排查问题。2.3 并发恢复与多会话隔离一个 Agent 进程很多时候是同时对多个项目、多个任务开多个会话的。会话层的隔离实际上就是对状态的所有权边界做隔离。每个 Session 有独立的 Uuid有独立的文件路径内存里各自独立加锁互不干扰。典型的实现是所有 Session 的元数据放在一个DashMapUuid, ArcRwLockSession里按 id 快速查找。这里值得注意的坑是锁的持有时间。不要在持锁状态下做网络请求或模型调用。比如当前会话要调用 MCP server 工具不要握着RwLock的写锁等工具返回那会把整个事件循环卡死。正确做法是先把要发送的工具调用参数 clone 出来释放锁等外部返回后再拿锁追加结果。锁的粒度越小并发能力越强这是并发编程里永远的真理。3. 压缩模块传输效率与内存开销的平衡3.1 运行时里为什么会有压缩你可能会想一个对话历史能有多大至于上压缩吗但当你把 MCP 工具的输出、代码文件片段的快照、大段日志统统塞进会话历史之后事情就不一样了。一次工具调用可能返回几百 KB 的 JSON连续几轮之后消息体积完全可能撑爆模型的上下文窗口。压缩在这里有两个层面的作用。第一层是网络层面的数据压缩。客户端和模型 API 服务之间的请求体如果很大压缩能显著减少传输时间尤其是网络带宽不太宽裕的时候。第二层是语义压缩这就更有意思了。当你发现上下文已经太长继续把所有原始消息发给模型不现实时你会把早期对话交给一个摘要模型生成一段简短的总结然后把总结放回上下文把原始细节移到归档区。claw-code 的压缩模块干的其实是这两件事字节压缩物理层面和语义压缩上下文管理层面。对源码分析来说我建议先看它 import 了哪些压缩 crate再顺着Compressor这个 trait 找到实现判断它默认用的是哪种算法、什么级别。3.2 压缩算法对比与选型Rust 生态里常见的压缩库有几个flate2gzip/deflate、zstd、snap、lz4。它们不是哪个更好的关系而是哪个更适合这个场景的关系。我按典型表现整理一下算法压缩率压缩速度解压速度内存占用适合场景gzip (flate2)中高慢中中通用、兼容性最好zstd高中快中需要高压缩率且解压要快lz4低极快极快低对延迟极其敏感snap中低快快低数据流缓存场景claw-code 如果是在本地做归档我猜默认会更倾向 zstd。原因在于会话归档是写入一次、读回多次的场景压缩率低意味着存档文件小解压缩快意味着恢复会话时加载速度快。而且 zstd 支持字典压缩如果你有一批结构相似的 JSON 会话文件字典训练还能再压一截。不过如果是做实时网络传输的 body 压缩zstd 就不一定是最优了因为压缩速度不如 lz4 快在 CPU 受限的场景下会拖慢请求延迟。3.3 压缩参数调优实践里的真实取舍zstd 的压缩等级从 1 到 22等级越高压得越小但耗时也越长。实测下来等级 3 是性价比很高的默认值等级 10 以上对会话 JSON 这种文本数据提升有限耗时会增加好几倍。我一般会在配置里开放一个选项让用户自己选择pub enum CompressionPreset { Fast, // zstd level 1 Balanced, // zstd level 3 Max, // zstd level 10 } impl CompressionPreset { pub fn level(self) - i32 { match self { CompressionPreset::Fast 1, CompressionPreset::Balanced 3, CompressionPreset::Max 10, } } }除了压缩等级还有一个容易忽略的参数是window_log。zstd 的窗口大小影响内存占用默认值对大多数机器没问题但在内存受限的环境跑服务时需要关注一下解压端的内存限制否则会收到window size too large之类的问题。至于语义压缩把长对话摘要成短总结源码里的实现思路通常是判断当前上下文的预估 token 数是否超过阈值一旦超过就启动一个摘要循环从最早的对话开始逐段生成摘要然后替换掉原文。这个阈值不能拍脑袋定需要根据你用的模型上下文窗口来设。比如模型窗口是 128K token触发阈值可以设在 96K留出 32K 的余量给工具输出和系统提示词。4. MCP 集成协议落地与工具调用链4.1 MCP 到底在解决什么问题MCPModel Context Protocol这两年几乎成了 Agent 工具调用的事实标准。它解决的问题很朴素不同 AI 应用需要调用不同的外部工具每个工具的调用方式都不一样有的走 HTTP有的走命令行有的读本地文件。如果没有统一协议每接一个工具就要写一套定制代码而且每个 Agent 应用都要重复造一遍轮子。MCP 做的事情就是把工具抽象成一个标准接口包括三件事工具发现、工具调用、工具结果返回。它底层采用 JSON-RPC 2.0 作为消息格式传输层可以是 stdio子进程通信或 HTTP。claw-code 里对 MCP 的集成本质就是实现一个标准的 JSON-RPC client并通过一套统一 trait 把所有外部工具接入自己的工具调用循环。4.2 用 Trait 抽象工具注册表在 Rust 里MCP 工具的通用抽象通常是这样的#[async_trait] pub trait McpTool: Send Sync { fn name(self) - str; fn description(self) - str; async fn call(self, args: Value) - ResultValue, McpError; }这个 trait 一旦定义好所有外部工具都能被包装成同样形状的东西。claw-code 的运行时里会维护一个注册表本质上就是一个HashMapString, Arcdyn McpToolkey 是工具名value 是工具的实现。为什么要用 trait object 而不是枚举因为工具的数量和种类是动态的。你今天连了一个文件搜索工具明天又接了一个数据库查询工具它们都实现同一个 trait注册进去就行。用枚举的话每加一个工具就要改枚举和 match 分支维护成本高得多。MCP client 的启动流程一般是这样读取配置拿到要连接的 MCP server 列表。对每个 server创建一个子进程或者建立 HTTP 连接。发送initialize请求协商协议版本和 capabilities。发送tools/list请求拿到该 server 支持的工具列表。把每个工具包装成McpTool实现注册进工具注册表。运行时把工具描述发给模型模型发起调用时路由到对应工具。4.3 工具调用的错误处理链路MCP 工具的调用链是最容易出问题的地方。模型返回一个工具调用请求比如{name: read_file, arguments: {path: /tmp/foo}}运行时需要做如下几件事在把参数透传给工具之前一定要做参数校验。JSON-RPC 的约定是参数为 JSON Value但具体 tool 内部需要的是特定结构。简单粗暴地把 Value 直接塞给工具一旦字段名对不上返回的错误信息会很难看。我给 claw-code 这类项目写代码时一般会在接入层先用serde_json::from_value把参数反序列化成强类型结构体失败的话返回一个结构化的InvalidParams错误而不是让工具内部 panic。工具调用可能耗时较长也可能是阻塞型的。所以运行时必须给每个工具调用设置超时时间一般会包一层tokio::time::timeout超时后把它当作调用失败处理并把这个失败信息作为工具结果的一部分返回给模型。模型看到工具报错之后它自己会决定是换一种方式重试还是直接告诉用户出错了。这层设计是让 Agent 具备容错能力的关键。还需要考虑工具并发调用的问题。如果模型一次返回了多个工具调用请求运行时要不要并发执行这取决于工具之间的依赖关系。如果两个工具无依赖并发执行能省一半时间如果工具 A 的输出是做工具 B 输入的前置条件那就必须顺序执行。好的运行时设计会把工具调用结果收集成一个 Map模型之后可以用$tool_outputs(read_file)之类的引用语法来获取之前的结果实现跨工具的上下文传递。工程上我建议在 MCP 模块里把日志写得非常详细。因为工具调用是黑盒出了问题如果不记录完整请求和响应 body排查会非常痛苦。claw-code 的实践是每个工具调用都生成一个 trace id日志里可以按 trace id 串起整条调用链路从模型发起调用到工具返回结果到结果重新喂回模型。5. 提示构造从结构化数据到系统语言5.1 提示构造为什么不能靠字符串拼接提示构造prompt construction在很多人印象里就是把几个字符串加在一起。但在一个真正的运行时里提示构造是一个独立的、有缓存的、有 token 预算管理的过程。为什么因为现代大模型对话接口吃的是消息列表不是一段拼好的文本。每条消息有 role、有 contentcontent 可能还包括图片、工具调用记录、工具返回结果。claw-code 的提示构造模块核心职责是把会话历史、系统指令、工具描述、用户当前输入这四部分拼成一个结构化的消息列表。我用一个简化模型来描述pub struct PromptContext { pub system: VecContentBlock, pub messages: VecMessage, pub tools: VecToolSpec, pub metadata: PromptMeta, }system是系统提示通常包含角色设定和行为准则messages是按时间排序的对话消息tools是当前可用的 MCP 工具描述列表metadata则是 token 统计、截断标记等辅助信息。真实场景里提示构造最复杂的是处理消息顺序和截断。因为上下文窗口有限你不能总是把全部消息都塞进去。运行时需要决定哪些消息保留原文、哪些消息被摘要替代、哪些消息直接丢弃。这个决策过程完全可以独立成一个策略模块prompt 构造器只是执行策略给出的方案。5.2 构造流程中的缓存与增量更新每次模型交互都从头构造完整 prompt 是很浪费的尤其当会话历史很长的时候。一个优化思路是增量构造只把新增的消息追加到之前构造好的消息列表尾部并更新 token 计数。但在工具调用场景下增量构造有个麻烦一旦中间的某条消息被摘要替换了后面的所有消息的相对位置和引用关系都会变。比如最早工具输出的内容被摘要了后面模型还引用着那个输出文件路径摘要里没写完整就会产生误导。所以在触发摘要替换时最好把整个 prompt 重新构造一遍而不是继续增量拼接。token 预估也是提示构造里很关键的一环。不可能每次构造完都把整个 prompt 发给模型 API 去数 token那样网络开销太大。运行时一般会用一个本地 tokenizer 来做估算。在 Rust 生态里常见方案是把模型对应的 tokenizer 的 BPE 词表编译进来离线计算 token 数。这样 prompt 构造器可以在发给 API 之前就判断是否超限超限了先压缩或截断而不是等到 API 返回 400 再处理。5.3 压缩模块与提示构造的联动压缩和提示构造不是两个孤立的模块它们需要在同一个数据流里协作。我举个例子假设模型上下文窗口是 32K token当前会话历史已经有 40K token。这时 prompt 构造器检测到超限它会调用压缩模块压缩模块把最老的 10K token 的历史消息摘要成 2K token 的摘要块放回消息列表的开头然后原本被摘要覆盖的消息被移入归档区。经过这个操作prompt 总长降到 32K 以内可以正常发送。这个联动过程必须保护好消息引用的完整性。如果归档区里某个工具输出被摘要了但后续消息里模型还在引用它运行时最好在摘要文本中保留关键路径或关键值或者给模型一个提示更早的完整输出已经归档如需查看请调用归档检索工具。这样模型在被截断的信息下也有补救手段。还有一个工程细节构造 prompt 时工具描述顺序也会影响模型表现。把最常用的、跟当前任务最相关的工具排在前面模型更容易选中正确工具。运行时可以维护一个工具使用频率的计数器动态调整工具描述列表的排序。这个排序逻辑不用太复杂一个简单按last_used_at倒序就够了。6. 常见问题与排查实操记录6.1 用 cargo 查看依赖冲突和无效运行时组件源码分析过程中最高频的报错是cargo 多个 crate 版本冲突。claw-code 拆成 workspace 之后A crate 依赖某个库的 v1B crate 依赖同一个库的 v2cargo build 时就会出现两条依赖分支有时无伤大雅有时会引入行为不一致。排查方法很简单cargo tree -d它会列出所有重复依赖的 crate 和版本帮你看清是哪两个上游依赖引入了不同版本。如果是 dev-dependencies 的差异可以忽略如果是正常依赖的差异且涉及关键类型比如 serde、tokio建议用cargo update -p crate --precise version统一版本避免类型不匹配。还有一类问题是运行时组件不可用。打开二进制时会看到某类错误提示找不到某个 runtime。我自己的排查习惯是三步走检查ldd输出确认动态库是否齐全。检查目标平台是否匹配比如 x86_64 的二进制在 arm64 机器上跑不出来。检查二进制默认的资源目录、模型缓存目录是否被移动过。对 Rust 项目来说这种问题大多数是环境变量CLAW_HOME或配置目录指向了错误位置运行时找不到资源文件。6.2 MCP 工具连不上握手与调用排查MCP 集成最典型的故障是配置里写了 server启动时却看不到任何工具。这类问题我建议按下面的顺序查现象排查方向常用手段initialize 报错检查 server 地址和认证单独用 curl 或命令行工具测试 server 接口tools/list 返回空检查 server 端是否注册了工具查看 server 日志调用超时检查工具本身是否有阻塞给工具调用加超时和 trace id返回错误格式检查 JSON-RPC 的 result 结构打印原始响应 body还有一个常见问题是 MCP server 与当前运行时之间的传输协议不匹配比如一个走 stdio一个走 HTTP却把连接方式配反了。这种只能靠日志定位所以在 MCP 模块里打印完整握手包非常有必要。6.3 会话恢复后状态错乱我实际跑会话恢复时遇到最多的坑是持久化时机太晚导致崩溃的时候最新几轮对话丢了。修正思路是关键事件即持久化每完成一轮用户消息 - 模型响应立刻保存一次快照。不要等整个任务跑完再存任务可能跑几十分钟中途崩一次全没了。恢复时还有一个细节恢复出来的历史消息可能包含上次未完成的工具调用比如调用发出去了但结果还没回来就崩溃了。恢复逻辑必须处理这种孤儿调用要么重新执行并等待结果要么给模型标记为调用失败让模型决定重试。我最推荐第二种因为它不需要额外维护一个重新执行的队列出错成本也低。6.4 提示构造超长导致接口报错另一个高频场景是提示词超长。明明本地预估 token 只有 28K发上去却报错说超限。这通常是估值和实际不匹配导致的。原因往往是本地 tokenizer 版本和线上模型版本不一致。排查办法临时打印一次完整 prompt 的字符数跟本地估算值对比如果差异巨大就去更新本地 tokenizer 词表。如果确认是语义压缩力度不够可以考虑提高摘要触发阈值或者开启更激进的截断策略。但我个人不建议把触发阈值设得离模型窗口太近一旦工具返回一个大输出就会立刻超限直接被 API 拒绝。留 20%-30% 的余量是比较稳的做法。7. 一点个人体会这个项目我分析完之后最大的感触是所谓definitive runtime不是说哪段代码写得多么惊艳而是它把 Agent 运行中几个最关键的横切关注点——状态维护、体积控制、外部工具协议、上下文组织——全部用系统语言的静态类型表达出来了。写 Rust 的人都知道类型系统能把很多运行时错误提前到编译期而 claw-code 在架构上正是这么做的Session 的状态流转用枚举建模MCP 工具的通用能力用 trait 抽象压缩模块通过 trait object 支持多算法切换提示构造用结构化的中间表示而不是字符串粘贴。如果你也想搭自己的 Agent 运行时我建议你按同样的顺序来先定会话模型再定上下文压缩策略再接 MCP最后把提示构造做成独立模块。这个顺序和依赖方向是一致的改模块时不用推倒重来这也是 cargo workspace 给你最大的底气。最后分享一个我踩过几次坑之后养成的习惯给代码加 tracing 日志时多在模块边界打点少在函数内部打点。模块边界的日志是黑盒观测的关键你看得到进入 MCP 调用 / 离开 MCP 调用、构造 prompt 完成 / token 数是多少就足够定位绝大多数问题了。函数内部的日志再多排错时反而会淹没关键信息。
返回列表