用 Rust 写成的开源 AI 编程代理。21 个 crate、800+ 单元测试,三层设计目标贯穿始终------崩溃可恢复、行为可审计、执行可沙箱。 这篇是系列的开篇,先把整个系统的骨架讲清楚,后续文章再逐个区域深入。
0. 先建立心智模型
Grodex 是什么?
一句话:Grodex 是一个跑在终端里的 AI 编程助手 ,为什么起这个名字,因为很多的模块的设计理念来自开源的Codex和Grok-Build:
| 特性 | 通俗解释 | 实现手段 |
|---|---|---|
| 崩溃可恢复 | 进程被杀、断电、电脑重启,会话还能从断点接着干 | append-only 日志 rollout.jsonl + resume 命令 |
| 行为可审计 | 每一步都留有记录,出问题能回溯、能复现 | 17 条运行时不变量,每个动作都写日志 |
| 执行可沙箱 | 模型想跑 rm -rf 时,要么被拦下,要么在"牢房"里跑 |
macOS Seatbelt 内核级沙箱 + 权限审批 |
| 供应商无关 | OpenAI / Anthropic / DeepSeek 随便换,还能自动故障转移 | 统一请求/事件模型 + 3 套协议适配器 |
| 权限有边界 | 派生的"子 Agent"权限严格小于主 Agent | 委托信封 DelegationEnvelope |
三个基本概念(给第一次接触 Agent 的人)
看懂整个架构,只需要先分清这三个"时间单位":
- Session(会话) :一次完整的对话。从你启动
grodex run到退出,是一个 Session。它有自己的 ID,会持久化到磁盘。 - Turn(回合) :你每发一条消息就是一个 Turn。回合内模型可能"想 → 干 → 看结果 → 再想"循环很多次,直到完成你的目标。
- Step(步骤):回合内的一次"思考 + 干一批活"。模型每做一次推理、随后调用一批工具、等到结果,就是一个 Step。
类比:Session 是一次完整的手术,Turn 是手术中的一个操作(比如"缝合"),Step 是一次缝针。一针缝下去→看效果→再缝一针,直到缝合完毕。
一次对话的完整旅程
下面这张时序图是全篇最重要的图。先看它,再带着问题去读后面的区域详解。

整条链路里反复出现的三个动作,是 Grodex 的设计灵魂:
- 先落盘、再动作------任何会产生副作用的动作(改文件、跑命令)都必须先写日志,日志写不成,动作就不做(fail-closed)。
- 模型看到的和执行的是同一份快照------本轮内模型只能调用它"看见"的那份工具菜单,防止幻觉调用不存在的东西。
- 日志即事实,内存即投影------所有内存里的状态(对话、审批票、工具结果)都是从日志"重建"出来的投影,进程没了,日志还在。
1. 整体架构
1.1 一张图看懂八大区域
Grodex 可以切成八个核心区域 + 三个横向支撑。下面这张图先把数据流看明白(每个区域内部的组件见各区域详解):

几个关键阅读点:
- 数据流是从上往下、再回流 :前端 → 协议 → 装配 → 控制面 → Agent Loop;Loop 向下拉取"模型、安全、知识"三个资源;能力(工具)要经过安全内核的审批与沙箱门禁才能被调用(所以安全内核画在能力和 Loop 之间);这四类资源产生的每一条关键事件,都汇入底部的日志;日志又是控制面崩溃恢复时唯一的重建来源,所以有一条回流的虚线。
- 前端是"可插拔"的:TUI、CLI、未来任何桌面/Web 前端,都通过同一条 ACP 协议和引擎对话。这是支撑①(协议层)存在的意义。
- 装配层是"唯一接线员" :21 个 crate 不是散装的,由
SessionRuntimeBuilder一处统一装配,run/serve/resume三条入口共用同一个装配函数。
1.2 区域一览
| # | 区域 | 解决什么问题 | 关键 crate |
|---|---|---|---|
| 1 | 入口与装配 | 21 个模块如何被组织成一个能跑的进程 | grodex-cli |
| 2 | Session 控制面 | 会话怎么创建、流转、结束;谁拥有对话历史 | grodex-loop |
| 3 | Agent Loop | 一次对话怎么被拆成 Turn/Step 并执行完成 | grodex-loop |
| 4 | 模型与上下文 | 如何同时兼容三家厂商,上下文满了怎么办 | grodex-provider / sampler / prompt |
| 5 | 能力来源 | 模型能调用什么:内置工具、技能、MCP、子 Agent | grodex-tools / skills / mcp / capability |
| 6 | 安全内核 | 危险操作如何被拦下、如何被"关进牢房"执行 | grodex-permission / sandbox / auth |
| 7 | 持久化恢复 | 崩溃后如何从精确断点重建会话 | grodex-rollout |
| 8 | 知识与子 Agent | 跨会话记忆 + 派生子 Agent 并行干活 | grodex-memory / subagent |
| 支撑① | ACP 协议与前端解耦 | 引擎与前端如何通过统一事件流对话 | grodex-protocol / tui |
| 支撑② | 配置系统 | 配置从哪来、怎么分层合并、热更新 | grodex-config |
| 支撑③ | 供应商路由与故障转移 | 主模型挂了如何自动切换备用 | grodex-sampler / provider |
1.3 工程视图:21 个 crate 的分层
所有代码放在一个 Cargo workspace 里,21 个 crate 分四层。依赖方向有一条铁律:一切向地基 grodex-core 汇流。
分层总览(每层的 crate 清单):
text
入口二进制 grodex-cli · grodex-tui
编排核心 grodex-loop ← 依赖 13 个内部 crate,最大消费者
功能层 sampler · tools · prompt · permission · sandbox · auth · subagent · mcp
知识层 memory · skills
类型/协议层 protocol · config · rollout · capability · provider · auth-types · sandbox-types
地基(零依赖) grodex-core ← ID / 状态机 / ContextItem / 工具契约
下面换用一张流程图 把"分层 + 依赖方向"一起表达(箭头 = 依赖方向,全部朝地基 grodex-core 汇流):

三个值得注意的设计决策:
grodex-loop是最大的消费者(依赖 13 个内部 crate)。它是 Agent 引擎的编排者,把采样、配置、权限、沙箱、记忆、工具、子 Agent、提示词全部串起来。- 前端解耦肉眼可见 :
grodex-tui只依赖grodex-protocol(协议)和少量工具 crate,完全不依赖引擎 loop。想加一个 Web 前端,只需要让它会说 ACP 协议。 grodex-core是唯一的公共地基,零内部依赖,只放纯数据类型(状态机枚举、ID、ContextItem、工具契约)。所有 crate 都向它汇流,但它自己不依赖任何东西,保证了依赖不会成环。
2. 八大区域详解
区域 1 · 入口与装配:让 21 个模块变成一个能跑的进程
这段解决什么问题: 一个工程越大,最怕的不是"某块功能没写",而是"功能写了但入口忘了接上"。区域 1 回答的是:这一切从哪里开始?
Grodex 的答案是一个 "单一装配根" ------SessionRuntimeBuilder(crates/grodex-cli/src/runtime.rs)。它把 9 大组件按固定顺序一次接线完成:
scss
配置加载 → 配置热更新 → 凭证与密钥 → 采样器(模型客户端)
→ 权限引擎 → 沙箱运行时 → 会话 + 日志写入器
→ 回合协调器(注册所有工具)→ 长期记忆 → 会话主管(SessionSupervisor)
装配完成后,run(交互式 REPL)、serve(作为 ACP 服务器)、resume(恢复旧会话)三条入口共用同一个装配函数。这就是"模块不空转"的工程哲学------代码在不在生产链路里,取决于装配根有没有用它,而不是模块自己声明了存在。
CLI 提供的命令(crates/grodex-cli/src/main.rs)本身也是一张"能力清单":
| 命令 | 用途 |
|---|---|
grodex run |
启动新交互会话 |
grodex tui |
启动终端 UI(内部 spawn serve 作为引擎子进程) |
grodex serve |
作为 ACP 服务器跑在 stdio 上(给前端用) |
grodex resume <sid> |
从崩溃断点恢复旧会话 |
grodex replay <sid> |
重放日志,打印人类可读的对话过程 |
grodex inspect / dump <sid> |
结构化查看 / 导出原始 JSONL 日志 |
grodex eval <sid> |
跑记忆检索评测 |
grodex prompt explain / dump |
干跑提示词装配,看 system prompt 由哪些节点构成 |
想深入:
crates/grodex-cli/src/runtime.rs(装配根)、crates/grodex-cli/src/main.rs(CLI 命令)。
区域 2 · Session 控制面:谁拥有会话的"生老病死"
这段解决什么问题: 一个会话不是一堆随意的消息堆在一起,它有自己的状态机、有严格的写入纪律。区域 2 管的是:会话的生命周期、对话历史的唯一所有权。
Grodex 的 Session 控制面是一个**"三 Actor"架构**(借鉴自 Grok 的设计):
| Actor | 职责 | 通俗理解 |
|---|---|---|
| SessionSupervisor | 会话级事件循环,收命令、派发回合、处理完成 | 手术室的主刀 |
| ChatStateActor | 独占对话历史的写入权,所有消息必须经过它 | 病历的唯一执笔人 |
| TurnCoordinator | 单个回合的生命周期 + 工具并行调度 | 一台手术的执行者 |
会话状态是个显式的状态机(crates/grodex-core/src/state.rs),而且"会话"和"回合"是两个嵌套在一起的状态机 ------回合状态在会话的 Running 内部流转:

- 你发消息 →
Idle → Running(开启 Turn); - 回合结束 → 回到
Idle(等下一句); - 退出 →
ShuttingDown → Closed。
关键设计:非法迁移在编译期就被拒绝 。SessionState、TurnState、ToolCallState 都是纯 Rust 枚举,transition_to() 里硬编码合法迁移表,写错直接编译不过------不是运行时字符串比对,而是类型系统内嵌的"状态机即法律"。
另一个关键点:ChatStateActor 是对话历史的唯一写者。并发写坏 transcript 这种事在架构上就不可能发生,因为写入口只有一个。这直接服务于恢复:内存里的对话历史随时可以从日志重建,所以没人能在"写坏它"上做文章。
ID 系统 (crates/grodex-core/src/id.rs):所有 ID 都是强类型 newtype(SessionId / TurnId / StepId / ToolCallId / OperationId......),编译器防串线------你不会把"会话 ID"错当成"回合 ID"用。其中 OperationId 是副作用操作的幂等键 ,CommitSequence 是工具调用在模型输出中的有序位置(保证即使工具并行返回,提交顺序也严格等于模型调用顺序)。
想深入:
crates/grodex-loop/src/supervisor.rs、chat_state.rs、command.rs;crates/grodex-core/src/state.rs、id.rs。
区域 3 · Agent Loop:一次对话怎么被执行完
这段解决什么问题: 这是引擎的心脏。用户说了一句话,引擎怎么把它变成一系列"推理 → 调用工具 → 看结果 → 再推理",直到完成目标?
核心逻辑在 TurnCoordinator::run()(crates/grodex-loop/src/turn_coordinator.rs),它是一个 for _step in 0..max_steps 的循环,每一轮 Step 做五件事:
- 冻结能力快照 :回合开始时,把当前所有已注册工具生成一份不可变的"工具菜单"。整个回合内,模型看到的菜单就是这一份,中途注册/注销工具不影响进行中的回合。
- 检查上下文是否该压缩:对话超过 token 阈值(默认窗口的 85%),就触发压缩(见区域 4)。
- 采样:把系统提示 + 对话历史 + 能力菜单拼成一次模型请求,发给采样器。
- 解析模型输出 :得到文本 / 思考 / 工具调用。
- 模型没有调用工具 → 回合自然结束;
- 模型有调用工具 → 进入下一步。
- 并行分发工具并回收结果 :
- 先做 JSON Schema 参数校验,不合规的直接回错误给模型;
- 先写日志,再执行------每个工具调用都先落盘"已开始执行",写不成就不执行;
- 标记为"串行"的工具(如需要独占资源的)强制排队,其余并行
tokio::spawn; - 结果按模型调用顺序提交(而不是按完成顺序),保证对话历史确定;
- 超大结果被"卸载"到 blob 存储,只回给模型 2KB 预览。
循环直到模型不再调用工具,或步数用尽(默认 10)。步数用尽时会强制跑一次"无工具"的采样,让模型总结已完成/未完成的事。
CapabilityManager:如何防止模型"幻觉"调用不存在的工具。双保险:
- 回合冻结:模型只能看到回合开始时那份冻结的工具菜单;
- 解析失败即拒绝:工具名没注册、或快照已过期,直接返回明确的错误给模型,绝不静默 no-op,也绝不悄悄"回退到最新版本"(那会让模型看到的和执行的不一致)。
SessionReducer:日志的"逆向播放器" (区域 7 的搭档)。它不是执行者,而是"从日志重放回现场"的人:把 rollout.jsonl 逐条事件折叠回对话历史,用于崩溃恢复、会话重放、审计。它同时强制校验不变量:日志 seq 不能有缺口、能力代次必须单调、回合结束时不能有悬空的工具调用。
想深入:
crates/grodex-loop/src/turn_coordinator.rs、capability_manager.rs、reducer.rs、step.rs;测试crates/grodex-loop/tests/scheduling_tests.rs(并行乱序提交)、crash_recovery.rs(6 类崩溃位置)。
区域 4 · 模型与上下文:同时伺候三家厂商 + 上下文塞不下了怎么办
这段解决什么问题: 两个老大难。第一,OpenAI、Anthropic、DeepSeek 的 API 长得完全不一样,代码里不该到处写 if provider == openai。第二,对话越来越长,模型上下文窗口装不下时,不能把历史"静默扔掉"。
统一 IR:给主循环一个"自己的语言"
Grodex 的做法是在中间插一层自研的统一中间表示(canonical IR):
css
主循环 只和三种自研类型对话:
ContextItem(对话条目)→ CanonicalModelRequest(请求)→ CanonicalModelEvent(事件流)
↑
厂商差异全部隔离在"适配层"里
OpenAI Responses / Chat Completions(DeepSeek·Qwen)/ Anthropic Messages
主循环永不按厂商分支(代码注释原话:"The Agent Loop must NEVER branch on provider or wire protocol type")。想加一家厂商,只需新增一对"编码器 + 解码器",主循环一行不改。目前支持:
- Responses(OpenAI 新版)
- ChatCompletions(OpenAI 兼容系,含 DeepSeek / Qwen 的 thinking 模式)
- Messages(Anthropic)
流式解析也有讲究:工具调用的参数是增量到达 的(参数写到一半还不是合法 JSON ),所以解码器在收到完整参数前绝不当 JSON 解析;思考内容(reasoning_content)按原样回传下一轮,否则 DeepSeek/Qwen 直接 400;每条请求恰好一个终态事件(成功或失败),由解码器状态机钉死。
换模型不是想换就换:LossinessGate
模型切换/故障转移时,新模型可能少了老模型的能力(比如不支持思考、不支持并行工具)。最危险的是悄悄降级------看起来在跑,能力其实没了。Grodex 有一道显式闸门:
- 必须能力(工具、压缩后端)丢了 → 拒绝切换;
- 可选能力降级 → 只有路由**白纸黑字声明"允许降级"**才放行,且每次降级都要发事件给 UI/日志可见。
还有一个"语义提交栅栏":一旦本轮已经产出过文本或工具调用的开头,就禁止透明换模型------已经播出去的内容不能重来。
上下文满了怎么办:Compaction(压缩)
对话长了,模型能同时记住的有限。Grodex 在 token 用量达到窗口 85% 时触发压缩,把最老的一段对话交给模型"总结成一段纪要",用纪要替换掉那段原文,保留最近的内容。整个过程分三步:计划(纯计算,无副作用)→ 准备(可序列化,可在另一台机器原样重放)→ 应用(原子替换)。
几个细节体现了工程素养:
- 压缩绝不留"悬空工具调用":切分点如果落在"工具调用"和它的"结果"之间,会把切分点前后移动,保证二者成对------否则模型会看到有调用没结果的坏历史。
- System Prompt 按"多久变一次"分四区:稳定区(A:内置指令/用户全局)放最前,易变区(B:工具列表、C:压缩基线、D:最近尾部)放后面。因为厂商的 prompt 缓存命中最怕前缀频繁变化,稳定前缀放前面才能最大化缓存命中。
- 记忆不进 system prompt :每轮检索出来的记忆结果,作为尾部的一个独立指令块注入,而不是烤进 system prompt------否则每轮前缀都变,缓存全废。
想深入:
crates/grodex-provider/(canonical IR、lossiness)、crates/grodex-sampler/(HTTP 客户端、3 套解码器)、crates/grodex-loop/src/context/(压缩)、crates/grodex-prompt/src/builder.rs(四区组装)。
区域 5 · 能力来源:模型能调用什么?
这段解决什么问题: 一个只懂说话的模型没有用,它得能"动手"。动手的方式有很多种,而且来源五花八门。区域 5 把四类来源统一成同一种"能力"概念。
| 来源 | 是什么 | 通俗理解 |
|---|---|---|
| 内置工具(×7) | 写死在二进制里的工具 | 出生自带的一双手 |
| Skill(技能) | 喂给模型的 Markdown 指令集 | 教它"怎么做某件事"的手册 |
| MCP 服务器 | 外部工具进程,stdio JSON-RPC | 外接的"工具箱" |
| 子 Agent 委托 | 派生子模型干活 | 交给实习生,给张"授权书" |
内置 7 个工具 (crates/grodex-tools/src/registry.rs):
| 工具 | 干什么 | 默认策略 |
|---|---|---|
read_file |
带行号读文件,支持多区间/正则锚点/哈希行模式 | 允许 |
write_file |
创建/覆盖文件,带"版本围栏"(自上次读后变了就拒写) | 需确认 |
edit_file |
精确字符串替换,支持批量编辑 + 重叠检测 | 需确认 |
exec |
跑 shell 命令,支持后台/限时返回部分输出/取消 | 需确认 |
apply_patch |
多文件补丁,整批作为事务原子应用、失败回滚 | 需确认 |
process_io |
与后台进程交互(poll / 写 stdin / 发信号) | 允许 |
read_artifact |
读回被"卸载"的大工具结果(blob) | 允许 |
关键设计是两阶段契约 :每个内置工具都拆成 prepare()(纯函数,不产生任何副作用,先算出"这次要干什么")和 execute()(真正动手)。为什么这么拆?因为审批和沙箱可以在"不动真实状态"的前提下先检查 ------权限引擎想看"exec 要跑什么命令",直接看 prepare() 的计划就行,不用真的跑一遍。
Skill 是 Markdown 指令集,放在 .grodex/skills/ 目录下,模型在 system prompt 里"看到"它们并按之行事。它有一个重要的信任机制 :用户目录的技能永远可信(全文注入),项目目录的技能只有项目被标记为 trusted 才注入全文,否则只显示名字和描述、正文打码(fail-closed)。
MCP 让 Grodex 能接外部工具服务器(每个服务器是一个独立子进程,通过 stdio JSON-RPC 通信)。MCP 工具会被包装成本地工具,名字带命名空间(mcp_{服务器}_{工具})避免冲突。
统一视角 :四类来源都被注册成同一种 CapabilityDescriptor(能力描述),并进一步区分两个概念:
- 能力描述(CapabilityDescriptor)------长期存在,"世界上有这个东西,谁能用";
- 一次已验证调用(PreparedCapabilityCall)------瞬时的,"这次这么干,凭哪次审批、按哪个版本执行"。
模型"能不能看见"某个能力由 ToolExposure 决定:Direct(直接进初始请求)、Deferred(不进初始上下文,靠工具搜索发现)、CodeMode(仅子 Agent)、AppOnly(模型永远看不见)。
想深入:
crates/grodex-tools/src/(7 个工具)、crates/grodex-skills/src/(技能发现与信任)、crates/grodex-mcp/src/(MCP 客户端)、crates/grodex-capability/src/descriptor.rs、prepared.rs。
区域 6 · 安全内核:危险动作如何被拦下、被关进牢房
这段解决什么问题: Agent 能改文件、能跑命令。一个被提示词注入欺骗的模型,可能想删你的文件、读你的密钥、给你装后门。安全内核是五层防线,每一层防一类问题。
| 防线 | 防什么 | 通俗理解 |
|---|---|---|
| ① 权限规则 | 模型乱调工具、读敏感文件 | 门禁制度:什么能进什么不能进 |
| ② 人工审批 | 高风险副作用没人把关 | 警卫:高危操作要你点头 |
| ③ 沙箱内核 | 即便被批准,也跑在"牢房"里 | 监狱:真出格了也出不去 |
| ④ 凭证租约 | 主 API Key 泄漏 | 保险柜:主密钥不拿出来,只给"一次性取款券" |
| ⑤ 子代理上界 | 子 Agent 借父权限越权 | 授权书:实习生只能干限定的事 |
① 权限规则
每条规则按"工具名 / 参数 / 命令 / 路径 / 网络"匹配,求值时做最严格合并 (Deny > Ask > Allow)。一条 deny /etc/* 必须盖过任何宽泛的 allow------哪怕 allow 规则写在后面、优先级更高。
② 人工审批:一次完整的审批链路
以"模型要执行 npm test"为例(这是区域 3 时序图的放大镜):

审批不只是"同意/拒绝"二选一,还有 Narrow(收窄) :用户可以选择"只批准改这一个文件",而不是批准整条命令。批准后签发的是一次性通行证 (PermissionLease),用一次就作废------崩溃恢复后的重试不能重放同一张批准。
③ 沙箱内核:macOS Seatbelt
macOS 上有 /usr/bin/sandbox-exec 这个系统自带命令,按一份 .sb 规则文件把子进程关进内核沙箱 。规则里写的 deny file-read* (subpath "/etc") 会变成子进程对 /etc 的真实 EPERM------不是软件"尽量拦",是操作系统内核强制 。Grodex 内置 readonly / workspace / restricted / full 四档 profile,多层 profile 取最严者生效。
Fail-closed 原则贯穿始终 :沙箱后端缺失、平台不支持、权限上限为 0 → 一律返回 Refused(拒绝执行),绝不静默裸跑。
④ 凭证租约
主 API Key 只存在 CredentialBroker 内部,绝不离开 broker。Agent 拿到的是一张"一次性取款券"(CredentialLease):没有 token 字段,只含租约 ID、绑定的端点、有效期。要用时 broker 内部校验后兑换成真实 token------泄露了取款券也没用,因为没有 broker 兑换不了;用过的券无法重放。
⑤ 子代理上界:授权书
父 Agent 派生子 Agent 时,签一张不可变的授权书 (DelegationEnvelope),里面写死:允许用哪些工具、权限最宽松到什么程度、跑在什么沙箱里、预算多少、能调多高 authority、父什么时候可以作废它。子 Agent 每次想调用工具都要先过四重校验:父没有撤销它、工具在清单内、authority 没超上限、请求的权限不比上限更宽松(不变量 #12:子权限 ≤ 父)。"子可以更严格,但不能更宽松"------这一条是关键设计。
想深入:
crates/grodex-permission/(策略、审批、租约)、crates/grodex-sandbox/src/platform.rs(Seatbelt 强制)、crates/grodex-auth/src/lease.rs(凭证租约)、crates/grodex-subagent/src/delegation.rs(授权书)。
区域 7 · 持久化恢复:崩溃后从精确断点接着干
这段解决什么问题: Agent 干一半,进程被 kill -9 杀了------内存里的对话、审批票、进行中的工具调用瞬间蒸发。区域 7 保证:这些状态每一次变化都被记录,重启后能精确重建。
机制核心是一个 append-only 的 JSONL 日志 rollout.jsonl (crates/grodex-rollout/):
- 单写者 actor :整个进程里只有一个"日志管家"(JournalActor)能写这个文件。所有写入走同一个 FIFO 队列,seq(序号)在管家内部、写盘之前分配 ;写入失败 seq 不消耗,保证日志永无缺口。
- append-only:只追加、不覆盖旧行,所以重放永远是确定的。
- fsync 分层 :普通事件(文本增量)每 8 条批量刷盘;关键事件(
ToolExecutionStarted/TurnCompleted/ApprovalRequested)立即强制刷盘。
"先落盘、再动作"的不变量#7 :工具进程在它的"已开始执行"事件落盘之前,绝不能被启动------否则崩溃后恢复时找不到它。结果也一样:结果落盘成功,下一步采样才允许读到它。
日志里记录了 35+ 类事件:对话(用户输入/模型输出)、工具调用的完整生命周期(预备→批准→开始→结束→提交)、审批与租约、上下文压缩、子 Agent 任务......每条事件带 seq、session_id、turn_id、step_id、generation。
崩溃恢复的完整故事:

这里有个很聪明的细节:对崩溃时"进行中"的工具调用,不是一律重放,也不是一律要人裁决 。按工具的副作用画像分类------只读/幂等的(比如 read_file)自动安全重放;非幂等的(比如 exec rm -rf)标记为 Indeterminate,弹给用户裁决"副作用到底是完成了、失败了、还是重试"。保护的就是这类"删了就回不来"的操作。
6 类崩溃位置的恢复测试(crates/grodex-loop/tests/crash_recovery.rs)钉死了这套保证:采样前、流式半途、工具结果写前、结果写后提交前、压缩替换前、回合完成落盘前。
一句话总结: 内存是投影,日志是事实。所有内存态都是日志的"可重建视图",所以进程没了,真相还在磁盘上。
想深入:crates/grodex-rollout/(日志写入、事件类型、恢复分类)、crates/grodex-loop/src/reducer.rs(重放器)、crates/grodex-cli/src/main.rs(resume 命令)、测试crates/grodex-loop/tests/crash_recovery.rs。
区域 8 · 知识与子 Agent:长期记忆 + 并行干活
这段解决什么问题: 两个"扩展能力"。第一,会话隔了几天,它还记不记得你的偏好、项目里做过的决策?第二,一个大任务能不能拆给多个"小 Agent"并行干?
记忆:两层完全不同的东西
Grodex 里"记忆"分两层,别混淆:
| 对话上下文 | 长期记忆 | |
|---|---|---|
| 是什么 | 这次对话发生的每一件事 | 提炼出来的跨会话知识 |
| 存哪 | JSONL 日志(区域 7) | SQLite 数据库 |
| 生命周期 | 随会话生死 | 跨会话存活 |
长期记忆刻意做成三路分离 (crates/grodex-memory/),各自独立索引、独立配额、独立注入,避免 Agent 把"怎么做"和"发生过什么"混为一谈:
- Skill(技能):会做什么、怎么做(只索引元数据,不索引正文);
- Memory(项目事实):稳定的知识------项目事实、用户偏好、架构决策、约束;
- Evidence(历史证据):某次历史事件、为什么得出某个结论。
检索技术是经典的 BM25 全文检索 (SQLite FTS5)+ 可选的向量检索 ,两者通过 RRF 算法融合排名。一个关键细节:BM25 分数会随词频、文档长度漂移,绝对阈值不可靠,所以用词覆盖硬规则 (TermCoverageGate)做"合格判定",BM25 只负责合格者之间的排序。向量/embedding 不可用时静默降级为纯全文检索(fail-open,不阻断这一轮)。
每个 Turn 开始时,Grodex 按当前问题检索一小块相关记忆,作为尾部指令块注入(见区域 4 的缓存友好设计)。检索走一个确定性规则路由器(IntentRouter):含"release/deploy/build"→ 开技能;含"为什么/上次"→ 开证据;纯时间/翻译请求 → 跳过。拿不准就全开(漏检的代价 > 空检索的代价)。
子 Agent:授权的实习生
主 Agent 可以派生子 Agent 并行干活(delegate_task 工具),每个子 Agent:
- 有独立上下文,不挤爆主上下文(长报告落盘,只回预览);
- 权限被授权书(DelegationEnvelope)冻结(区域 6 第⑤层),想越权在副作用发生前就被拒;
- 可审计可恢复:子 Agent 的创建/结束都写进日志,崩溃后重放能重建整棵"任务树"。
子 Agent 与主 Agent 之间通过一套协作协议 (六件套工具)通信:send_message(只投递、不打扰)、followup_task(投递并启动/排队)、wait_agent(有界等待,只允许等自己的后代,协议层消除互相等待的死锁)、mailbox_read(信箱读取)、list_agents(看任务树)、interrupt_agent(打断但不级联)。
想深入:
crates/grodex-memory/(数据库、检索器、路由器、eval)、crates/grodex-loop/src/supervisor.rs的"记忆注入"段、crates/grodex-subagent/(授权书、任务树、协作协议)、crates/grodex-loop/src/delegate_tool.rs。
3. 横向支撑(补充)
支撑① · ACP 协议与前端解耦
"引擎是服务,前端是客户端。" grodex serve 把引擎作为一个 ACP(Agent Client Protocol)服务器暴露在 stdio 上;grodex tui 是第一个 ACP 客户端,通过标准输入输出上的 JSON 帧与引擎对话。
统一事件信封 EventEnvelope 携带:seq(检测漏事件)、event_id、parent_event_id(事件因果)、causation_token(把工具结果关联回调用)、generation(拒绝迟到的旧代次事件)。这套设计的回报是:换前端不用改引擎,换引擎只要会讲 ACP。想加一个 Web 前端?让它实现 ACP 协议即可,引擎侧一行不用动。
想深入:
crates/grodex-protocol/(事件信封、帧格式、传输层)、crates/grodex-tui/(第一个客户端)、docs/17-frontend-acp-protocol-v2-design.md。
支撑② · 配置系统
配置不是"一个文件",而是一叠层------低层给默认值、高层覆盖,每一层都记录来源:
scss
Builtin(编译期默认) → System(/etc)→ User(~/.grodex/config.toml)
→ Workspace(项目配置)→ SessionFlag(命令行覆盖)
其中 workspace 层(项目 .grodex/config.toml)必须被显式标记为 trusted 才参与合并,否则隔离------防止"克隆一个仓库就跑它的配置/钩子"这类供应链问题。
配置还有一个 8 域代次计数器 (prompt / capability / policy / sandbox / provider / memory / ui / root)。为什么分域?UI 小改动不能打爆工具/prompt 缓存------只有安全收紧(policy)这类才立即生效,普通配置变化下一 Turn 再采纳。热更新走"最后一份有效配置"(last-known-good):新配置加载失败,保留旧的有效配置,不把用户锁在外面。
想深入:
crates/grodex-config/(分层、合并、代次、热更新)、docs/18-config-system-v2-design.md。
支撑③ · 供应商路由与故障转移
"一个主模型挂了,Agent 不会死。" 配置里可以把多个"供应商 + 模型"按优先级排成一条候选链:
toml
[model_routes.default]
sticky_scope = "turn"
[[model_routes.default.candidates]] # 按优先级从高到低
candidate_id = "primary"
provider_id = "openai"
model_id = "gpt-5"
主候选失败时自动切到下一个备用候选。但换源不是无脑的:
- 只有特定错误才允许换源 :网络错误、5xx、限流(429)可以;认证失败、参数错误、内容拒答不能换(换也白换)。
- 每个候选有独立的熔断器 :
Closed → Open → HalfOpen状态机。Open(熔断)后冷却 10 秒,到期放一个"探针"请求测试恢复,成功就回归,失败继续熔断。半开只放一个探针,防止探测风暴。 - "语义提交栅栏" :一旦本轮已经产出过文本或工具调用的开头,禁止透明换源------不能两个模型各生成一半。
想深入:
crates/grodex-sampler/src/route.rs、breaker.rs、retry.rs、crates/grodex-provider/src/lossiness.rs。
4. 贯穿全系统的设计哲学
看完八大区域,你会发现同样的三个动作反复出现。它们是 Grodex 的真正"宪法":
原则一:先落盘、再动作(Journal-first)
任何会产生副作用的动作,先写日志,写不成就不做。这回答了"崩溃了怎么恢复"------因为关键动作落盘早于动作本身,所以恢复时能精确知道"哪些还没发生、哪些发生了但没记录"。
原则二:Fail-closed(宁可拒绝,不可裸奔)
- 沙箱后端缺失 →
Refused,绝不静默裸跑; - 权限无规则匹配 → 默认
Ask(保守); - 日志读到坏行 → 大声报错,绝不静默跳过;
- 模型调不存在的工具 → 明确报错,绝不静默 no-op;
- 项目未 trusted → 技能正文打码、配置隔离。
在 Grodex 里,"静默"是最高级别的错误。
原则三:快照冻结(Snapshot freeze)
- 能力快照:回合内工具菜单不可变;
- 记忆快照:回合内检索结果稳定(同一 Turn 内重复查同一问题不漂移);
- 技能快照:回合开始时冻结技能版本;
- 压缩计划:可序列化、可复现。
冻结保证"模型看到的世界"和"实际执行的世界"是同一份,这是可审计性的基础------如果两边随时在变,你根本说不清模型是凭哪份信息做的决定。
17 条运行时不变量
这 17 条不只是"写在 README 里",而是用测试钉死的。挑几条最有代表性的:
| # | 不变量 | 通俗解释 |
|---|---|---|
| 7 | 结果 durable 后才能进入下一步采样 | 结果没落盘,模型就看不到,防止"幻影结果" |
| 12 | 子 Agent 权限 ≤ 父 Agent | 授权书的数学表达 |
| 13 | rollout.jsonl 是唯一事实源 |
内存全是投影,日志才是真相 |
| 15 | 工具 / 技能 / MCP 在一回合内稳定 | 模型看到的就是执行的 |
| 16 | 权限只能收紧,不能放松 | 撤销是单调的,回不去 |
工程实践:测试怎么保证这一切
- 800+ 单元测试全工作区绿;
- 6 类崩溃位置恢复测试:在每个"最容易崩"的点模拟崩溃,断言重建结果;
- 3 个乱序调度测试:50 路并发工具、乱序完成、顺序提交;
- 3 套 wire protocol 的 golden fixture:从真实 API 录制的事件流回放,断言每条请求恰好一个终态事件;
- macOS Seatbelt 真拒绝测试 :在沙箱里
cat被 deny 的路径,断言内核真的返回 EPERM。
5. 结语与导航
Grodex 用一个简单的观察贯穿了整个设计:Agent 的价值来自它能"动手",而它能被信任,来自动手之前的每一道闸门、动手之后的每一行日志。八大区域,前四个让它"会干",后四个让它"干得安全、干得可追溯"。
想继续深入,推荐这些入口:
- 从代码开始 :仓库根目录
grodex/下,先读crates/grodex-cli/src/runtime.rs(装配根),再读crates/grodex-loop/src/turn_coordinator.rs(心脏)。 - 从设计文档开始 :
docs/下有 13 份设计文档,每份对应一个子系统------09(Agent Loop)、11(上下文与恢复)、16(权限)、17(ACP)、08(记忆)是阅读优先级最高的几份。 - 从测试开始 :
crates/grodex-loop/tests/crash_recovery.rs(恢复)、scheduling_tests.rs(并发)是最能体会设计意图的两个测试文件。 - 从跑起来开始 :
cargo build --release && ./target/release/grodex run,然后按i说一句话试试。