一个自己开发的 Agent Harness-总览篇

用 Rust 写成的开源 AI 编程代理。21 个 crate、800+ 单元测试,三层设计目标贯穿始终------崩溃可恢复、行为可审计、执行可沙箱。 这篇是系列的开篇,先把整个系统的骨架讲清楚,后续文章再逐个区域深入。


0. 先建立心智模型

Grodex 是什么?

一句话:Grodex 是一个跑在终端里的 AI 编程助手 ,为什么起这个名字,因为很多的模块的设计理念来自开源的CodexGrok-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 的设计灵魂:

  1. 先落盘、再动作------任何会产生副作用的动作(改文件、跑命令)都必须先写日志,日志写不成,动作就不做(fail-closed)。
  2. 模型看到的和执行的是同一份快照------本轮内模型只能调用它"看见"的那份工具菜单,防止幻觉调用不存在的东西。
  3. 日志即事实,内存即投影------所有内存里的状态(对话、审批票、工具结果)都是从日志"重建"出来的投影,进程没了,日志还在。

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 汇流):

三个值得注意的设计决策:

  1. grodex-loop 是最大的消费者(依赖 13 个内部 crate)。它是 Agent 引擎的编排者,把采样、配置、权限、沙箱、记忆、工具、子 Agent、提示词全部串起来。
  2. 前端解耦肉眼可见 :grodex-tui 只依赖 grodex-protocol(协议)和少量工具 crate,完全不依赖引擎 loop。想加一个 Web 前端,只需要让它会说 ACP 协议。
  3. 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

关键设计:非法迁移在编译期就被拒绝SessionStateTurnStateToolCallState 都是纯 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.rschat_state.rscommand.rs;crates/grodex-core/src/state.rsid.rs


区域 3 · Agent Loop:一次对话怎么被执行完

这段解决什么问题: 这是引擎的心脏。用户说了一句话,引擎怎么把它变成一系列"推理 → 调用工具 → 看结果 → 再推理",直到完成目标?

核心逻辑在 TurnCoordinator::run()(crates/grodex-loop/src/turn_coordinator.rs),它是一个 for _step in 0..max_steps 的循环,每一轮 Step 做五件事:

  1. 冻结能力快照 :回合开始时,把当前所有已注册工具生成一份不可变的"工具菜单"。整个回合内,模型看到的菜单就是这一份,中途注册/注销工具不影响进行中的回合
  2. 检查上下文是否该压缩:对话超过 token 阈值(默认窗口的 85%),就触发压缩(见区域 4)。
  3. 采样:把系统提示 + 对话历史 + 能力菜单拼成一次模型请求,发给采样器。
  4. 解析模型输出 :得到文本 / 思考 / 工具调用。
    • 模型没有调用工具 → 回合自然结束;
    • 模型调用工具 → 进入下一步。
  5. 并行分发工具并回收结果 :
    • 先做 JSON Schema 参数校验,不合规的直接回错误给模型;
    • 先写日志,再执行------每个工具调用都先落盘"已开始执行",写不成就不执行;
    • 标记为"串行"的工具(如需要独占资源的)强制排队,其余并行 tokio::spawn;
    • 结果按模型调用顺序提交(而不是按完成顺序),保证对话历史确定;
    • 超大结果被"卸载"到 blob 存储,只回给模型 2KB 预览。

循环直到模型不再调用工具,或步数用尽(默认 10)。步数用尽时会强制跑一次"无工具"的采样,让模型总结已完成/未完成的事。

CapabilityManager:如何防止模型"幻觉"调用不存在的工具。双保险:

  • 回合冻结:模型只能看到回合开始时那份冻结的工具菜单;
  • 解析失败即拒绝:工具名没注册、或快照已过期,直接返回明确的错误给模型,绝不静默 no-op,也绝不悄悄"回退到最新版本"(那会让模型看到的和执行的不一致)。

SessionReducer:日志的"逆向播放器" (区域 7 的搭档)。它不是执行者,而是"从日志重放回现场"的人:把 rollout.jsonl 逐条事件折叠回对话历史,用于崩溃恢复、会话重放、审计。它同时强制校验不变量:日志 seq 不能有缺口、能力代次必须单调、回合结束时不能有悬空的工具调用。

想深入: crates/grodex-loop/src/turn_coordinator.rscapability_manager.rsreducer.rsstep.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.rsprepared.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_idparent_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.rsbreaker.rsretry.rscrates/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 说一句话试试。
相关推荐
一开1 小时前
一个自己开发的 Agent Harness-Agent Loop篇
后端
一开1 小时前
一个自己开发的 Agent Harness-Tool/Skill/Mcp篇
后端
思考着亮1 小时前
10.MySQL 锁机制
后端
思考着亮1 小时前
9.MySQL 性能分析与优化
后端
我的div丢了肿么办1 小时前
go语言中基本数据类型的转换
后端·go
XuCoder1 小时前
你写的每条 SQL 都没加过锁,可 MySQL 凭什么不怕两个事务打架?
数据库·后端
尼古拉斯-托尔斯泰-赵四1 小时前
Go 语言,你需要了解的一些规则
开发语言·后端·golang
程序员贺加贝1 小时前
列表导出不够用-SaaS-ERP-单据详情导出的-Provider-模板与文档型-Excel-设计
java·后端·设计模式·架构·excel
SimonKing1 小时前
写文档的最佳搭档:Typora+PicList+SM.MS
java·后端·程序员