grok-build 是 xAI 开源的终端 AI 编程助手,支持全屏 TUI、Headless 脚本、编辑器内嵌三种运行模式。Grok-build 项目是一个非常规范的 Rust 工程,本文从工程管理角度拆解其实践,聚焦可迁移到其他项目的通用做法。
| 维度 | 数字 | 后文关联 |
|---|---|---|
| workspace 成员 | 92 个(75 codegen + 11 common + 1 build + 1 prod + 4 third_party) | 依赖治理、lint 集中、分层约束的前提 |
| workspace 统一管理的依赖 | 257 条 | 版本单点管理的必要性 |
pub(crate) 使用 |
6,043 处 | API 表面管理的量化证据 |
| build.rs | 仅 5 个 | 编译期节制的直接结论 |
技术栈基线:edition 2024,工具链固定 stable 1.94.0。最终产物来自 crate xai-grok-pager-bin。
一、依赖治理:单点决策 + lint 纪律
1.1 版本决策权收归单点
先排除一个常见误解:crate A 写 serde = "1.0.200"、crate B 写 serde = "1.0.210",Cargo 不会产生两份 serde。这两个约束 semver 兼容,Cargo 的解析器会挑一个同时满足两者的版本,锁文件里只留一条记录。所以在这一档,版本号散落的坏处纯粹是维护性的:升级要改 N 处、容易漏、review 时看不出全仓实际跑的是哪个版本。
真正的多版本共存发生在跨 major 边界 (1.x vs 2.x),以及 0.x 版本的跨 minor 边界 (0.11 vs 0.12------Cargo 对 0.x 把 minor 当 major 处理)。Cargo 认为这些是不同的库,会同时编译进产物。这时链接体积膨胀、编译时间拉长才真的出现,而最扎手的是类型不兼容:reqwest 0.11 和 0.12 的 Client、RequestBuilder 是两组毫不相干的类型,一个 crate 拿到 0.11 的 builder 传给期望 0.12 的函数,编译直接失败。项目越大,这类跨边界传值越频繁。
这条规则在任何项目里都能自己验证:cargo tree --duplicates 会列出所有被编译进多份的库,输出里出现的都是跨 major 边界的分裂,semver 兼容的差异不会出现在里面。
集中管理因此解决两档不同的问题:日常升级只改一处;跨 major 的版本分裂必须显式写进根文件,不可能"不小心"发生。grok-build 的做法是把所有依赖集中到根 Cargo.toml 的 [workspace.dependencies]:
toml
[workspace.dependencies]
tokio = { version = "1", features = ["full"] }
anyhow = { version = "1", features = ["backtrace"] }
reqwest = { version = "0.12", features = ["rustls-tls", "stream", "json", "multipart", "http2", "blocking", "socks"], default-features = false }
thiserror = "2"
schemars = "1"
# ... 共 257 条
子 crate 一律写 dep = { workspace = true },不重复版本号。内部 crate 之间的路径依赖也登记在这里:
toml
xai-grok-shell = { path = "crates/codegen/xai-grok-shell" }
xai-tool-runtime = { path = "crates/common/xai-tool-runtime" }
重命名某个 crate 的目录时只需改根文件一处。这个模式类似 Maven BOM,本质是版本决策权单点化。这条实践几乎没有引入成本:两三个 crate 的 workspace 也能一行改过去,规模越大收益越大,不必等到 90 个 crate 才动手。
还有两个细节:
anyhow统一开启backtracefeature,错误链自带回溯,全仓库行为一致。reqwest统一default-features = false后显式列出 features,例如:TLS 实现固定为 rustls,避免 native-tls 在不同平台引入系统 OpenSSL 依赖。
1.2 fork 依赖用 patch 固定
对必须修改的上游依赖,用 [patch.crates-io] 固定到自家 fork 的具体 rev:
toml
[patch.crates-io]
async-openai = { git = "https://github.com/our-forks/async-openai.git", rev = "95b52eb..." }
fork 而不是 vendor,patch 而不是改版本号,rev 而不是分支:三个选择都指向同一个目标,让"我们改了上游什么"可追溯、可回滚。
1.3 lint 集中管理,每条例外都有回收条件
根 Cargo.toml 的 [workspace.lints.clippy] 段全仓库生效,子 crate 用 [lints] workspace = true 一行继承(93 个 crate 中 75 个这样做;未继承的主要是 vendor 进树的 third_party,它们不套用 grok-build 自己的 lint 标准)。每条 allow 都写了原因,例如:
toml
# prost 0.14 renders proto doc-comment bullet lists in a way that trips this
# lint on generated code. Kept in sync with bazel/lint/linters.bzl.
doc_lazy_continuation = "allow"
# Allow uninlined_format_args lint to prevent main from breaking after merges
# ...(十行注释解释老分支合并后破坏 main CI 的具体机制)
# TODO: -> "deny" once/if merge queue enabled
uninlined_format_args = "allow"
# The `fastrace::trace(properties = { ... })` proc-macro expands `"{param}"`
# into `format!("{}", param)`, which clippy flags as useless_format.
# Suppressed until the upstream crate fixes its codegen (fixed in 0.7.16+).
useless_format = "allow"
这些注释的共同点是都留了解除条件。uninlined_format_args 等 merge queue 启用后改回 deny,useless_format 等升级到修好这个问题的上游版本,doc_lazy_continuation 则提醒改动时要同步 bazel 侧的 linters.bzl。
没写解除条件的 allow 通常会一直留在配置里。后来的人既不知道它当初为什么加,也判断不出现在还需不需要,于是谁都不去动它。写清楚了,条件满足的时候才有人敢删。
仓库根还有一个 28 行的 clippy.toml,其中 disallowed-methods 把两条工程约定提升到 lint 层面强制执行:
toml
disallowed-methods = [
{ path = "std::fs::canonicalize", reason = "returns \\\\?\\ verbatim paths on Windows; use dunce::canonicalize" },
{ path = "std::process::Command::spawn", reason = "an unenrolling child outlives its session; use xai_tty_utils::ProcessScope::enroll" },
{ path = "tokio::process::Command::spawn", reason = "..." },
{ path = "portable_pty::SlavePty::spawn_command", reason = "... enroll the shell with xai_tty_utils::ProcessScope::enroll_terminal_pid" },
]
禁用裸 canonicalize 是因为 Windows 上它返回 \\?\C:\... 形式的 verbatim 路径,会破坏外部工具、泄漏进 prompt、污染路径相等性判断;禁用裸 spawn 是因为未登记的子进程会活得比启动它的 session 更久。用 lint 禁止错误 API 并指向正确替代,比 code review 里反复纠正可靠得多。
二、crate 组织:目录即架构,分层即约束
2.1 目录分类的信号作用
92 个 crate 平铺不可接受,grok-build 用目录表达归属:
bash
crates/
common/ → 跨产品可复用的通用库(11 个)
codegen/ → grok-build 产品代码(75 个)
build/ → 编译期构建工具库(1 个)
third_party/ → 安全审计后 vendor 的第三方代码(4 个)
prod/mc/ → 生产微服务共享类型(1 个)
看到 crates/common/xai-circuit-breaker 就知道可复用,看到 crates/codegen/xai-grok-pager 就知道是产品专属,不需要读代码。
细粒度拆分不是免费的:每个 crate 都有自己的 Cargo.toml、可见性边界和文档要维护,crate 数量多了,cargo metadata 和 IDE 索引都会变慢。grok-build 的 92 个 crate 对应它的团队规模和 monorepo 语境,如果项目只有五六个模块,硬套这种拆分只会多出样板代码。可迁移的是"用目录表达归属"的思路,不是"crate 拆得越多越好"。
2.2 严格分层,依赖方向只能向下
Rust 编译器天然禁止 crate 间循环依赖,grok-build 在此基础上把 92 个 crate 组织成严格分层: 
补充:Rust 编译器禁止的只是"循环依赖",不是"跨层依赖"。Layer 1 直接依赖 Layer 3,编译器不会拦。grok-build 仓库里也没有 cargo-deny 之类的依赖策略工具强制这张分层图,层级靠目录约定和 code review 维持。也就是说,分层在这里是纪律,不是编译器保证;想在自己的项目里把它变成硬约束,需要另配依赖检查工具。
2.3 依赖倒置:底层定义接口,上层提供实现
前置概念: Rust 的 trait 类似其他语言的接口(interface)。
Arc<dyn Trait>是一种"线程安全的接口指针":持有它的代码不需要知道具体实现是什么,只需要知道它满足接口契约。这是 Rust 实现依赖注入的标准手法。

分层架构的经典难题:底层 HTTP 客户端需要加认证头,但 OAuth token 刷新逻辑在上层。如果底层直接调用上层,就破坏了"依赖只能向下"的约束。
grok-build 的解法是在底层 crate 只定义 trait(接口),不提供完整实现:
rust
// crates/codegen/xai-grok-auth/src/visibility.rs
// 底层定义:我需要一个"能提供认证信息"的东西,但不关心它怎么获取 token
pub trait HttpAuth: Send + Sync {
fn apply(&self, builder: reqwest::RequestBuilder, base_url: &str)
-> reqwest::RequestBuilder;
}
// crates/codegen/xai-grok-auth/src/auth_provider.rs
#[async_trait::async_trait]
pub trait AuthCredentialProvider: HttpAuth + Send + Sync + 'static {
fn snapshot(&self) -> CredentialSnapshot;
async fn refresh_after_unauthorized(&self) -> bool;
}
#[async_trait::async_trait] 不是可以省掉的装饰。Rust 1.75 起 trait 里能直接写原生 async fn,但这样的 trait 不是 dyn-compatible 的------原生 async fn 返回的是每个实现各不相同的匿名 Future 类型,编译器无法为它生成统一的 vtable 条目,写 dyn AuthCredentialProvider 会直接编译失败。async_trait 宏把返回类型重写成 Pin<Box<dyn Future + Send>>,抹平成一个固定类型,代价是每次调用一次堆分配。本节整套方案就架在 Arc<dyn AuthCredentialProvider> 上,所以这个宏是必需的。
上层 xai-grok-shell 提供完整的 OAuth 实现,运行时通过 Arc<dyn AuthCredentialProvider> 注入到底层。底层代码永远不知道、也不依赖上层的具体类型。
这个 crate 只有 535 行源码,它的 description 直接写明角色:"Auth dependency-inversion seam: HttpAuth + AuthCredentialProvider traits"。项目里有一批这样的"薄 seam crate",它们的存在理由就是解耦依赖方向。
2.4 隔离 crate:版本冲突的防火墙
MCP 协议库 rmcp 2.1 硬性要求 reqwest >= 0.13.2,而 workspace 主体及一整条传递依赖链(opentelemetry-otlp、oauth2、xai-mixpanel 等)都钉在 0.12。全局升级会触发连锁反应,grok-build 的答案是建一个隔离 crate xai-grok-mcp,让 0.13 只在这一个 crate 内部存在:
rust
//! Quarantines `rmcp` 2.1 and `reqwest` 0.13. ...
//! reqwest 0.13 is now a fully private impl detail of [`servers`]; no
//! re-export. Consumers reach `rmcp` model types through this namespace
//! (`xai_grok_mcp::rmcp::*`).
pub use rmcp;
隔离手段有三个要点:
- reqwest 0.13 不进 workspace 统一台账,只在该 crate 的 Cargo.toml 直接声明,是对"版本单点管理"的一次有记录的例外,把爆炸半径限制在一个文件里;
- 消费方一律通过
xai_grok_mcp::rmcp::*访问 MCP 类型,依赖树里看不到 0.13; - 隔离区内外共享 TLS 根证书时,用版本中立的 DER 字节作单一真源,跨边界不传递任何一侧的类型化对象。
判断是否值得建隔离 crate 的标准:冲突方是否被一整条传递依赖链钉死,升级是否会触发跨 crate 连锁改动。两个答案都是"是"时,隔离比升级便宜。
2.5 组合根:二进制只接线
最终二进制 xai-grok-pager-bin 本身只做四件事:配置全局分配器(jemalloc)、初始化崩溃处理器、初始化沙箱、调用 xai_grok_pager::run()。所有业务逻辑都在库 crate 中,可以在库测试里验证,不需要启动完整进程。
三、错误处理:按受众分层的三套体系

3.1 库边界:thiserror 定义领域错误
28 个 crate 使用 thiserror,用于调用者需要 match 的类型化错误:
rust
#[derive(Debug, thiserror::Error)]
pub enum AgentBuildError {
#[error("failed to parse agent definition: {0}")]
ParseError(String),
#[error("IO error during agent construction: {0}")]
IoError(#[from] std::io::Error),
}
3.2 运行时:ToolError 区分"给模型看的"和"给开发者看的"
工具系统的错误需要跨进程序列化、需要驱动引擎的重试/降级决策、还需要回传给 AI 模型。xai-tool-runtime/src/error.rs(554 行)的头部文档定义了语义契约:
rust
//! `ToolError` is a struct with a `kind` discriminator and a tool-provided
//! `detail` string. The `detail` is the model-facing message --- tools MUST
//! provide a human-readable explanation of what went wrong, since this text
//! is sent back to the model to inform its next action.
pub struct ToolError {
pub kind: ToolErrorKind, // 19 种错误分类
pub detail: String, // 发送给 AI 模型的信息
#[serde(skip)]
source: Option<anyhow::Error>, // 开发者调试用,NOT sent to the model
pub details: Option<Value>, // 结构化元数据(retry_after 等)
}
ToolErrorKind 的 19 个变体每个都有文档注释解释与相邻变体的区别,因为这些区分直接决定 UI 文案:
rust
/// The caller's usage pool / billing balance is exhausted (out
/// of credits). Payment-required-shaped; distinct from
/// `RateLimited` so the surface can show "out of credits"
/// rather than "try again later".
UsagePoolExhausted,
选择 struct 而非 enum 的原因很实际:它要承载 model-facing 的 detail 字符串和可选的结构化 details,同时用 #[serde(skip)] 隐藏不该暴露给模型的内部错误链。
3.3 应用编排层:anyhow 串联调用链
25 个 crate 使用 anyhow(anyhow::Result 签名 341 处),集中在 session 管理、命令分发等最上层流程。这一层不需要调用者 match 错误类型,只需要把完整错误链路传给日志和用户,workspace 统一开启的 backtrace feature 在这里发挥作用。
三者的分界线:调用者是否需要根据错误类型做不同处理? 需要用 typed error,不需要用 anyhow;错误需要跨进程传输(上 wire)或直接呈现给模型/用户,则手写 wire 契约并区分受众。
四、文档文化:写"为什么",不写"是什么"
4.1 crate 级文档交代存在理由
85 个 lib.rs 中 73 个有 //! 模块文档(其中 64 个就在前五行内,剩下几个因为 #![forbid(unsafe_code)] 之类的内部属性排在前面),且普遍写动机而非功能。几个例子:
rust
//! Lightweight Mixpanel HTTP tracking client.
//! This is a minimal replacement for `mixpanel-rs` that uses `reqwest 0.12`
//! instead of `reqwest 0.11`, avoiding a duplicate HTTP stack in the binary.
//! Only the `track` API is implemented since that's all we use.
// xai-mixpanel:为什么不用现成的库
//! Cross-platform system sleep/wake notifications.
//! The motivating use case: an OIDC token refresh that is *in flight when the
//! laptop sleeps* can lose its rotated successor token ... On wake the client
//! is holding a dead refresh token and the user is forced to re-login.
// xai-system-power:这个 crate 是为了防什么事故
//! Telemetry engine for Grok Build sessions ...
//! Extracted from `xai-file-utils` per review feedback so telemetry has
//! its own ownership boundary (see CODEOWNERS) ...
// xai-grok-telemetry:crate 拆分的组织原因也写进文档
"Extracted from X per review feedback"、"This package exists so ... (cargo cycle)"、"deliberately does not depend on X" 这类交代架构决策与历史的句子随处可见。新人读 lib.rs 头部就能理解这个 crate 在系统中的位置和边界。
4.2 注释密度高的位置有明确规律
- Cargo.toml 的 feature 与依赖旁(为什么需要这个 feature、为什么这个 crate 要三个 jemalloc 包);
#[doc(hidden)]旁(为什么暴露、谁在用);- allow lint 旁(为什么豁免、何时收回);
- unsafe 旁(为什么 sound);
- 枚举相邻变体之间(区别是什么,因为这决定 UI 文案)。
行内注释常用 // WHY: 前缀标记非显而易见的决策。
五、测试体系:密度与基建并重
#[test] 有 24,759 处、#[tokio::test] 有 5,253 处(文本计数,口径见开头说明------真实用例数会少一些),撑起这个数量的是测试基础设施。先看没有基建会发生什么:测试依赖宿主机的 git 版本和 locale,本地能过、CI 随机挂;某个测试忘了清理,写进开发者真实的 HOME 目录;用例多了以后超时在 CI 高负载下随机变红,大家开始用 #[ignore] 回避。下面的每项设计都对应其中一个问题。
5.1 两层测试基建 crate
底层:crates/common/xai-test-utils(无业务依赖),核心能力:
- Hermetic git :
ensure_hermetic_git_on_path()把静态链接的 git 二进制前置进 PATH,测试不依赖系统安装的 git;git_command_with_identity()固定作者身份并屏蔽宿主配置(GIT_CONFIG_GLOBAL=/dev/null、GIT_CONFIG_NOSYSTEM=1、LC_ALL=C。注释解释了固定语言的原因:测试断言逐字检查 git 输出的文案,locale 不固定,换个环境断言就碎); - repo fixture 工厂 :
seed_repo_with_remote(建 bare origin)、reflog_only_commit(制造只有 reflog 引用的丢弃提交)、make_feature_branch(rebase 场景),把复杂 git 状态变成一行调用; - tracing 断言 :
MessagePrefixCounter是一个 tracing Layer,按日志消息前缀计数,断言某条被 instrument 的代码路径执行了几次。约定生产代码把日志前缀导出为pub const,测试不重复字符串。
业务层:crates/codegen/xai-grok-test-support,README 开头就是一份 API 契约,并带强制 freshness 规则:
Freshness rule: update this README in the same PR that changes
src/. Reviewers should treat asrc/diff without a README diff as incomplete.
它提供:
- MockInferenceServer (
src/mock_server.rs,2248 行):基于 axum 的真实 HTTP 服务器,绑定127.0.0.1:0,服务/v1/chat/completions、/v1/responses等端点。响应优先级是"命名期望(typed matcher)> FIFO 脚本队列 > echo/fixed 模式",带请求日志、per-expectation barrier。 - TestSandbox :拥有临时根目录、隔离 HOME、显式
GROK_HOME;子进程用env_clear()+ 最小允许列表,杜绝测试碰到真实环境。 - scaled() :所有 harness 超时统一乘
GROK_TEST_TIMEOUT_SCALE,共享 runner 负载高时放大超时,而不是让测试随机变红。
5.2 PTY 端到端:真实终端仿真 + 声明式场景
crates/codegen/xai-grok-pager-pty-harness 是一套分层 PTY 测试基建,同一套 API 服务三类消费者(回归场景、基准测试、本地问题复现)。最上层是 45 个声明式 YAML 场景(放在 crates/codegen/xai-grok-pager/tests/ 下):
yaml
name: mock-response
terminal: { rows: 40, cols: 110 }
mock:
response: "SCRIPTED_MOCK_RESPONSE rendered by the real pager ..."
steps:
- action: wait_for_text
text: Quit
timeout_ms: 20000
- action: type_text
text: "hello scripted tui"
- action: keys
keys: "<Enter>"
- action: assert_not_contains
text: panicked
- action: screenshot
name: response
数据驱动让新增 e2e 场景的成本从"写 Rust 代码"降到"写 YAML"。
5.3 test-support feature:跨 crate 测试面的受控暴露
全仓 61 处 #[cfg(any(test, feature = "test-support"))],8 个 crate 定义了 test-support feature:
toml
# xai-grok-shell/Cargo.toml 的 dev-dependencies
xai-grok-bundle = { workspace = true, features = ["test-support"] }
xai-grok-memory = { workspace = true, features = ["test-support"] }
跨 crate 测试要访问内部状态,又不想把它变成公开 API,feature 门控加注释约定让"测试面"成为显式的、可审计的、生产构建物理不可达的表面。
5.4 mock 全部手写,无 mock 框架
全仓没有 mockall。所有 mock 都是手写 trait 实现,分三类:测试私有的行为 mock(如 MockProvider 实现 AuthCredentialProvider 并用 Mutex<u32> 计 refresh 次数);跨 crate 共享的确定性 mock(如 MockEmbeddingProvider 用 blake3(text) 生成确定性伪向量);基础设施级 mock 服务(MockInferenceServer、故障注入代理)。
配套的是生产代码预留注入 seam:CircuitBreaker::with_clock、dyn EmbeddingProvider、TerminalBackend trait。测试替换实现,而不是 patch 行为。
这里的代价要摆出来:mockall 这类框架用宏生成 mock,省掉手写 mock 的样板代码,代价是不透明的宏展开、额外的编译开销,以及接口一变测试就脆断。grok-build 选择手写 mock,是接受维护成本换确定性和可读性。测试数量少、接口稳定的项目,用现成框架完全合理,不必照搬。
六、API 表面管理:默认可见性收紧
6.1 pub(crate) 是默认选择
全仓库 pub(crate) 共 6,043 处。内部实现一律 pub(crate),对外表面精选 re-export。为什么不干脆全 pub 省事?多人协作的大仓库里,pub 是一种承诺:每个 pub 条目都可能有下游依赖者,改名或删除就是破坏性变更,API 表面越大,重构越慢。默认收紧,内部重组才有腾挪空间。以 xai-circuit-breaker 为例,整个滑动窗口实现都是 crate 私有:
rust
pub(crate) const MAX_WINDOW_ENTRIES: usize = 10_000;
pub(crate) struct SlidingWindow { ... }
lib.rs 的组织有三种典型风格:
- 门面聚合式 (
xai-grok-shell):大 crate 用pub use xai_grok_bundle as bundle;把兄弟 crate 重命名成自己的模块,对外呈现统一模块树; - 单一入口式 (
xai-tool-runtime):核心类型统一 re-export 到 crate 根,甚至转发底层 crate 的类型:
rust
pub use xai_tool_protocol::{StreamingSpec, ToolCallId, ToolId, ToolScope};
效果是工具作者不需要知道每个类型住在哪个模块、哪个 crate:
rust
// 没有根部 re-export 时,import 要横跨模块和 crate
use xai_tool_runtime::tool::{Tool, ToolStream};
use xai_tool_runtime::error::ToolError;
use xai_tool_protocol::{ToolId, ToolCallId};
// 有了之后,全部从一个入口进
use xai_tool_runtime::{Tool, ToolStream, ToolError, ToolId, ToolCallId};
- 私模块 + 精选导出式 (
xai-grok-config):一半模块是私有mod,跨 crate 需要的条目用pub use逐项列出。
6.2 测试接缝要显式标注
为了让集成测试访问某些内部类型,不得不 pub 时,统一用 #[doc(hidden)] 标注并注释原因:
rust
/// Exposed as `pub` solely so the replay-trace integration test in
/// `tests/trace_replay.rs` can drive the gate against synthetic JSON
/// fixtures. Not part of the public API.
#[doc(hidden)]
pub struct CollectedTodoGateInput { ... }
更精细的做法是结构体 #[doc(hidden)] pub 但字段保持 pub(super):测试能传递它,却永远无法直接构造它。
6.3 unsafe 的边界用 crate 粒度划定
5 个核心 crate 的 lib.rs 第一行写着 #![forbid(unsafe_code)],这是编译器级禁令:forbid 比 deny 更硬,deny 可以被代码里的 #[allow] 覆盖,forbid 不行。被禁的恰好是被最多人依赖的协议/运行时核心,unsafe 的潜在影响面最大,所以锁得最死。
七、并发模型:统一的 Actor 形态
前置概念: 并发编程有两种主流思路管理共享状态。一种是加锁(Mutex):多个线程可以同时访问同一块数据,但同一时刻只允许一个线程操作,其他线程排队等待。另一种是消息传递(Actor):把数据交给一个独立运行的"管家"(actor),外界想读写数据就给管家发消息,管家按顺序处理。Actor 没有锁竞争和死锁风险,代价是多一次消息收发的开销。
7.1 六处实现,同一个模式

项目里至少六处 actor 实现,结构完全一致:
- Command enum :定义所有可能的消息。不需要回复的消息(fire-and-forget)不带 reply 字段;需要回复的消息内嵌一个一次性回信通道(
oneshot::Sender); - Handle:外界拿到的"信箱地址"。本质是消息发送端的包装,可以 Clone 后分发到多处,谁都能往信箱里塞消息;
- Actor task :独立运行的后台任务,死循环从信箱里取消息并顺序处理。用
tokio::select!同时监听消息通道和取消信号,优先响应取消; - 事件通道:另一条从 actor 向外广播状态变化的通道。
以 xai-hunk-tracker 为例:
rust
pub enum HunkTrackerCommand {
// === 不需要回复的命令 ===
RecordAgentWrite { path, content, prompt_index, previous_content },
HandleFileChange { path: PathBuf },
// === 需要回复的命令(内嵌回信通道) ===
HunkAction { hunk_id: HunkId, action: HunkAction,
reply: oneshot::Sender<Result<(), HunkActionError>> },
}
Handle 还提供 noop() 空实现(丢弃所有命令),供测试和关闭场景使用。
7.2 什么时候用 actor,什么时候还是用锁
这里要避免一个误读:这个项目并没有排斥锁。Mutex<...> / RwLock<...> 全仓有 632 处,§5.5 里那个统计 refresh 次数的 Mutex<u32> 就是一例。actor 和锁在这里是分工,不是替代。
分工的界线大致是状态有没有独立的生命周期:
- 用锁 :临界区短、进出明确、没有后台任务参与的共享数据。典型是计数器、缓存表、一个
Arc<Mutex<HashMap<..>>>配置快照。加锁读一下、改一下就走,锁的持有范围肉眼可见。 - 用 actor :状态需要长期存活、要响应多路事件、有自己的启动与关闭时序、并且要保证一串操作按顺序完成。
xai-hunk-tracker要同时接收 agent 写文件、文件系统变更、用户操作三路输入,还要维护 hunk 的演进状态------这种情况下用锁,就得开始推理"持锁期间能不能 await""两把锁的获取顺序是否一致",而 actor 天然只有一个执行体在改状态,这些问题不存在。
actor 的代价是一次 channel 收发的间接开销和多一层样板(Command enum + Handle),对 IO 密集的应用开销可以忽略,但样板是实打实的。所以判据不是"锁不好",而是这份状态的并发推理成本是否已经超过一层消息传递的样板成本。
7.3 取消:CancellationToken 层次树 + DropGuard
前置概念: 后台任务启动后需要一种机制告诉它"该退出了"。
CancellationToken就是这样一个信号旗:创建时它处于"未取消"状态,调用cancel()后所有监听它的任务都会收到通知。token 可以构成父子关系:父 token 取消时,所有子 token 自动取消。
rust
pub fn spawn_transport_liveness(...) -> TransportLivenessHandle {
let token = CancellationToken::new();
let drop_guard = token.clone().drop_guard(); // RAII:drop 时自动 cancel
tokio::spawn(async move { /* select token.cancelled() 与 tick */ });
TransportLivenessHandle { _cancel: drop_guard }
}
Handle 被 drop 时析构触发 token.cancel(),后台任务自动退出。不需要显式 stop 方法,不会忘记清理。这是 Rust 所有权系统的典型应用:资源的生命周期绑定到变量,变量离开作用域时自动释放。
八、类型系统作为契约
前置概念: Rust 的 newtype 模式是给现有类型套一层薄包装(
struct UserId(String)),编译器会把UserId和String当作完全不同的类型,不能互相赋值。这避免了"把用户 ID 传到需要工具 ID 的地方"这类 bug。serde 是 Rust 生态的序列化/反序列化框架,Deserializetrait 控制 JSON 等格式如何转成 Rust 类型。
8.1 opaque_id! 宏:构造即校验的 newtype
所有跨线传输的 ID 都有专属 newtype,防止意外混用。xai-tool-protocol/src/ids.rs 用一个宏批量生成:
rust
//! Every wire-traveling id has a dedicated newtype to prevent accidental
//! mixing ... Constructors validate; `Deserialize` re-uses the constructor,
//! so values that round-trip from the wire share the same invariants as
//! values built locally.
macro_rules! opaque_id {
($name:ident $(, extra_validator = $validator:path)?) => { ... };
}
opaque_id!(SessionId);
opaque_id!(ToolId, extra_validator = validate_tool_id); // 格式 {namespace}:{name}
opaque_id!(ServerId, extra_validator = validate_server_id); // auto: 前缀保留
关键在手写的 Deserialize:反序列化复用 new() 的校验逻辑,非法值在进系统的第一个瞬间就报错,而不是在某个下游消费点炸开。配套 IdError(thiserror)区分 Empty / InvalidFormat / ReservedPrefix。
8.2 schemars:给模型看的 JSON Schema 从类型生成
workspace 统一 schemars = "1",全仓 190 处使用,三种用法:
- 生成工具的输入 schema 发给模型 。类型侧用
#[schemars(description = "...")]精确控制模型看到的字段描述,与 Rust doc 注释分离维护(前者面向模型,后者面向开发者); - wire 契约校验 :
#[schemars(deny_unknown_fields)]+#[serde(try_from = "Wire")],反序列化强制走构造函数校验; - 后处理 schema 输出 :
schema_utils.rs展平 schemars 对Option<Enum>生成的anyOf/$ref结构,让发给模型的 schema 更简洁。
类型即契约在这里是双向的:对内,反序列化走校验构造器;对外,schema 从同一份类型定义生成,模型看到的和代码解析的永远不会漂移。
九、构建与编译期
9.1 profile 分档:开发速度 vs 分发性能
Rust 编译有两个核心参数影响速度与性能的取舍:codegen-units(编译单元数,越多编译越快但优化越差)和 lto(链接时优化,打开后跨 crate 联合优化但链接更慢)。grok-build 用不同 profile 在两个极端之间切换:
toml
[profile.dev]
codegen-units = 128 # 切成 128 个编译单元并行,换编译速度
lto = false
opt-level = 0
incremental = true
debug = "line-tables-only" # 只留行号表,够出栈回溯,不背完整 DWARF
split-debuginfo = "unpacked" # 调试信息不进二进制,链接更快
panic = "abort"
[profile.release-dist] # 分发给最终用户
inherits = "release"
lto = "thin" # 跨 crate 优化,链接时间可接受
codegen-units = 1 # 单编译单元,最大化优化
debug = 1 # 前提:先生成符号,才有东西可提取
strip = false # 不在编译期剥离,交由 CI 后置处理
split-debuginfo = "off" # 符号留在产物里,等 CI 统一抽出
debug = 1 和 strip = false 必须一起看,这是最容易漏掉的一环:release 默认不带调试信息,只写 strip = false 是无用的------没有符号,"后置提取"就无从提取。正确的顺序是编译期生成符号(debug = 1)、保留在产物里(strip = false + split-debuginfo = "off"),CI 再抽出 .debug/.dSYM 上传符号服务器,最后 strip 二进制发给用户。分发产物小,线上崩溃栈仍能还原。
另一个真实取舍是 panic 策略。[profile.release] 用 panic = "abort"------去掉 unwind 表,二进制更小、代码更快,崩溃直接终止。但服务端 profile [profile.x-prod] 特意改回 panic = "unwind":服务进程里一个请求 panic 不该带走整个进程,tokio 需要 unwind 才能把 panic 收敛在单个 task 里(JoinHandle 返回 Err(JoinError::is_panic))。同一个仓库里两种取向并存,取决于产物是 CLI 还是长驻服务。
这些参数在小项目里几乎感知不到差异,是针对全量编译明显变慢的大型 workspace 的调优项。另一处关键选择是 Thin LTO 而非 Fat LTO:跨 crate 优化收益略低于 Fat LTO,链接时间显著更短。
9.2 工具链版本固定 + 升级纪律
toml
[toolchain]
# - bump one point version at a time
# - wait at least a couple of weeks after the release before bumping
# After bumping the version, run
# cargo check --all-targets --workspace
# cargo clippy --all-targets --workspace
channel = "1.94.0"
每次只升一个小版本、新版发布后等两周、升级后跑全量 check 和 clippy。避免踩到刚发布的 regression,也控制每次升级引入的 lint 变化量。
9.3 build.rs 的节制
92 个 crate 只有 5 个 build.rs,分三类场景:版本信息嵌入(2 个)、Protobuf 代码生成(1 个)、外部二进制资产捆绑(2 个)。
proto 代码生成抽成独立的构建期库 crates/build/xai-proto-build,供各 crate 的 build.rs 调用,不复制粘贴。它自己实现 rerun-if-changed(注释直言上游 tonic-build 的实现不正确),用 protoc --dependency_out 拿真实依赖列表。
构建期下载的外部资产做固定 SHA-256 校验,提供环境变量覆盖(离线构建),任何一环缺失有明确降级路径而不是构建失败。
十、安全防线
10.1 vendor 代码的审计原则
处理不可信输入的依赖(如渲染 AI 模型输出的 Mermaid 库)vendor 进树,而非只钉 crates.io 版本。原因:vendor 给出完整审计面,并免疫上游 yank。
vendor 代码的本地修改逐条写进 Cargo.toml 头部注释,每条标注安全动机。例如把上游的 static mut 无同步计数器替换成 AtomicUsize,消除数据竞争。
10.2 运行时防线
secrets sanitizer (crates/codegen/xai-grok-secrets):用正则检测 API key、AWS 凭证、PEM 私钥等,在日志与遥测出站前打码。它的设计取向是宁可漏报不误伤------词边界锚定、设长度下限,避免短值误报。
一般安全检测宁可误报也不漏报。这里反过来的理由是定位不同。误伤的代价在这个场景里非常高:一旦把正常内容当成密钥打码,用户看到的日志和错误信息就变成一串 ***,排障直接失效;如果打码逻辑还作用在发给模型的上下文上,误伤等于污染模型输入,破坏功能本身。更关键的是,它不是唯一防线------真正防止密钥泄漏靠的是不把密钥放进会被日志的结构、权限最小化、以及凭证轮转,sanitizer 只是最后一层网。一层纵深防御为了不砸坏主流程而选择保守,是合理的;如果它是唯一防线,这个取舍就得反过来。
sandbox fail-closed :进程启动时应用内核级沙箱(Linux 用 Landlock,macOS 用 Seatbelt)。沙箱无法应用时直接失败退出,不降级运行 。这里不给降级路径是刻意的:降级运行意味着用户以为自己有沙箱保护、实际没有,而这个产品会执行模型生成的命令,"以为有防护"比"明确没有防护"更危险。宁可启动失败让用户看见问题,也不静默移除一层隔离。注意这与 §9.3 构建期资产"缺失有明确降级路径"是相反的取向------构建期缺个可选资产不影响安全边界,运行期缺沙箱影响。取向该不该给降级,取决于失效时丢的是便利还是安全属性。
可选配置防御式解析 :GROK_EXTRA_CA_BUNDLE 默认关闭,未设置时零 I/O------不去探测文件、不吞异常,配置项不启用就完全不存在。启用后有 1 MiB 大小上限,防止指向一个巨大文件(或误指向 /dev/zero 这类特殊文件)时在启动期做无界读取。这是"外部可控输入必须有上界"的常规应用,位置在启动路径上所以尤其重要:这里卡住,进程连起不来。
共同点:每条都写明了失效时的行为(打码保守失效、沙箱失败退出、配置未设即不存在)。安全配置最容易腐化的地方不是正常路径,而是没人想过的失效路径。
十一、可迁移的工程原则
从 grok-build 的实践中可以提取出不限于 Rust 的通用原则:
- 依赖版本单点管理。Maven BOM、Go workspace、npm workspaces、Cargo workspace.dependencies 是同一个思路:版本号只出现一次。收益有两层------日常升级只改一处;跨 major 的版本分裂必须显式声明,不会悄悄发生。
- lint 例外必须带回收条件。每条 allow 写清原因、同步责任方、收回时机。用 disallowed-methods 把"禁用错误 API"提升到 lint 层面。
- 分清哪些约束编译器真的能守 。Rust 禁止 crate 循环依赖,环状结构不可能被"不小心"引入;但跨层依赖编译器不拦,分层方向只能靠目录约定、review 或 cargo-deny 之类的依赖检查工具维持。写文档和做技术选型时不要把"没有循环依赖"说成"分层被强制"。Java/Go 连循环依赖都要靠 ArchUnit、go-cleanarch 等工具补位。
- 组合根让逻辑可测试。所有逻辑放库里,最终二进制只接线。
- 错误处理按受众分层。调用者需要 match 就用 typed error;错误要跨进程传输或直接呈现给模型/用户,就手写 wire 契约并区分"给使用者看的"和"给开发者看的";其余用 anyhow。
- 状态的并发形态按生命周期选。短临界区照常用锁;状态长期存活、要响应多路事件、有启停时序时用 actor。选定之后全项目统一同一套 Command/Handle/Actor 形态,不要每处发明一遍。
- 测试基建值得独立成 crate。hermetic 工具、fixture 工厂、mock 服务器、超时缩放,这些是测试密度的乘数。跨 crate 测试面用 feature 门控,生产构建物理不可达。
- 确定性是测试纪律:能虚拟时钟化的用虚拟时钟;剩下必须等真实时间的地方,让判定条件是轮询到状态而不是 sleep 的时长;perf 测试断言不变量而非墙钟;共享 CI 用超时缩放而非容忍随机失败。
- 文档写"为什么"。crate 的存在理由、feature 的豁免原因、unsafe 的 soundness 论证、枚举变体之间的区别,这些是代码本身无法表达的信息。
- 安全属性是架构约束,不是一次性配置 。除了写清防护做了什么,更要写清失效时的行为:该 fail-closed 还是可降级,判据是失效时丢的是便利还是安全属性。
结语
grok-build 的多数选择本身不新奇。92 个 crate 遵循同一套依赖管理、actor 形态、错误分层、lint 纪律和注释风格,新人理解一个 crate 就理解了全部。工程决策的上下文固化在代码旁边(Cargo.toml 注释、allow 旁的回收条件、lib.rs 头部的拆分原因),不依赖口口相传。一致性加决策留痕,是这个规模的项目能持续运转的前提。
附录:可直接带到其他工程的 agent.md
以下是从本文提炼的最通用规则,适用于多数多 crate 的 Rust 项目,规模越大越适用。每条都是"规则在前、理由在括号里",方便扫读时先看到"做什么"。
markdown
# Rust 工程实践守则
## 依赖与 Lint
1. 所有依赖版本集中在根 `Cargo.toml` 的 `[workspace.dependencies]`,子 crate 写 `{ workspace = true }`,升级只改一处。(Cargo 本就合并 semver 兼容版本,集中管理防的是跨 major/0.x 跨 minor 的版本分裂悄悄发生,以及升级时漏改)
2. 修改上游依赖用 `[patch.crates-io]` 固定到 fork 的具体 rev,不用分支。(让"改了上游什么"可追溯、可回滚)
3. lint 放 `[workspace.lints]` 统一继承;每条 `allow` 必须附注释写明为什么豁免、什么条件下收回。(防止 lint 豁免无限累积成技术债)
4. 用 clippy `disallowed-methods` 禁用易错 API,`reason` 里指向正确替代。(比 code review 反复纠正可靠)
## Crate 组织
1. 依赖方向只能向下;底层需要上层能力时在底层定义 trait,上层提供实现,运行时用 `Arc<dyn Trait>` 注入。(编译器只禁循环依赖,不禁跨层依赖,分层方向要靠约定、review 或 cargo-deny 维持)
2. trait 里有 `async fn` 又要做成 `Arc<dyn Trait>` 时,加 `#[async_trait::async_trait]`。(原生 `async fn` 返回匿名 Future,不是 dyn-compatible,直接写 `dyn Trait` 编译不过)
3. 二进制 crate 只做组合根(初始化 + 调用库 `run()`),所有业务逻辑放库 crate。(业务逻辑可在库测试中验证,不需要启动完整进程)
4. 默认可见性 `pub(crate)`,对外表面用精选 `pub use` 逐项导出。(内部重组不破坏外部使用者的 import)
## 错误处理
1. 调用者需要 match 错误类型时用 thiserror 定义 typed error。(调用者能按变体做不同处理)
2. 应用编排层用 `anyhow::Result` + `.context()` 串联,不定义错误枚举。(不需要类型区分时省掉样板)
3. 错误要跨进程传输或直接呈现给用户/模型时,区分"给使用者看的消息"和"给开发者看的内部错误链",后者用 `#[serde(skip)]` 隐藏。(防止内部实现细节泄漏给终端用户或 AI 模型)
## 并发
1. 短临界区、无后台任务参与的共享数据照常用锁,持锁期间不 `await`。(锁不是坏东西,actor 的样板成本是实打实的)
2. 状态长期存活、要响应多路事件、有启停时序时改用 actor(Command enum + Handle + Actor task),并全项目统一同一套形态。(这类状态用锁需要推理持锁 await 与加锁顺序,成本高于一层消息传递)
3. 取消用 `CancellationToken` 层次树;后台任务用 `drop_guard()`,drop 即取消。(资源清理绑定所有权,不会忘记 stop)
## 测试
1. 能虚拟时钟化的时间逻辑一律用 tokio `start_paused = true` 或手写 MockClock。(测试不花真实时间,结果不随 CI 负载波动)
2. 真实子进程、文件系统 watcher、真实 socket 这类边界允许等真实时间,但 sleep 不得承担断言语义,判定条件必须是轮询到状态或收到事件。(红线:测试的通过/失败不能由某个 sleep 的时长决定)
3. mock 手写 trait 实现,生产代码预留注入 seam(`with_clock`、`dyn Provider`)。(测试替换实现而不是 patch 行为,重构时不脆断)
4. 跨 crate 测试面用 `feature = "test-support"` 门控。(测试面显式可审计,生产二进制物理上不含测试代码)
## 文档
1. crate 头部 `//!` 写存在理由,不写功能描述。(新人读头部就能理解 crate 在系统中的位置和边界)
2. 非显而易见的决策用 `// WHY:` 前缀。(后来者不需要猜测决策动机)
3. allow、unsafe、`#[doc(hidden)]` 旁边都要有注释说明理由。(每处例外都有可追溯的责任记录)
## 构建
1. `rust-toolchain.toml` 固定工具链版本,每次只升一个小版本,升级后跑全量 check + clippy。(避免踩刚发布的 regression,控制每次升级的变化量)
2. 分发 profile 里 `debug` 和 `strip` 一起设:保留 `debug = 1` + `strip = false`,由 CI 提取符号后再 strip。(release 默认不带调试信息,只写 `strip = false` 没有符号可提取)
3. panic 策略按产物选:CLI 用 `abort`,长驻服务用 `unwind`。(服务里一个请求 panic 不该带走整个进程,tokio 需要 unwind 才能把 panic 收敛在单个 task)
4. build.rs 只在代码生成、版本嵌入、资产打包三类场景使用,能不用就不用。(减少编译复杂度,保护增量编译性能)
## 安全
1. 安全相关初始化 fail-closed,保护无法应用时直接失败退出。("以为有防护、实际没有"比"明确没有防护"更危险)
2. 可选配置默认关闭、零 I/O,启用后设大小上限,解析失败只 warn 不 fail。(未启用的配置项就应该完全不存在;外部可控输入必须有上界)
3. 上面两条方向相反,判据是:失效时丢的是安全属性就 fail-closed,丢的是便利就可降级。每处安全配置都要写明失效时的行为。(安全配置腐化通常发生在没人想过的失效路径上)