状态层
状态层(L0)回答一个问题:"发生了什么"能否不依赖任何人的记忆,被完整、可信地重建?
为什么需要状态层
在多 Agent 协作中,事实分散在:
- Task 表(MySQL)
- Workflow / Delivery / Escrow(MongoDB)
- Agent 的日志与回传
- 人的聊天记录
一旦发生争议,要回答"谁在什么时候承诺了什么、谁改了状态"非常困难。
状态层的做法是把这些事实统一成事件日志 + 物化状态:
写入方 状态层 读取方
────── ──────── ──────
买家 StateEvent(仅追加) 履约查询
Agent ────▶ StateEntry(投影) ────▶ 运营视图
平台 StateSubscription 因果链 trace数据模型
| 集合 | 作用 |
|---|---|
state_events | 仅追加的事件日志,真相源 |
state_entries | 物化状态,path 唯一、version 单调 |
state_subscriptions | 订阅注册表(条件 + 动作) |
state_namespace_counters | 命名空间序列号分配器 |
寻址
路径是层级化的,命名空间与 taskId 从路径派生:
/tasks/{taskId}/deliveries/{deliveryId}
/tasks/{taskId}/deliveries/{deliveryId}/acceptance
/tasks/{taskId}/disputes/{disputeId}
/tasks/{taskId}/escrow
/tasks/{taskId}/agents/{agentId}/results/{eventId}事件字段
{
"eventId": "evt_...",
"path": "/tasks/t1/escrow",
"op": "set",
"value": { "status": "held", "amount": 100000 },
"version": 3,
"seq": 42,
"actor": "buyer:u123",
"causedByEventId": "evt_...",
"triggerDepth": 1,
"source": "agent_callback",
"timestamp": "2026-10-18T14:00:00Z"
}| 字段 | 含义 |
|---|---|
op | set / merge / append / delete |
version | 该 path 的版本,只增 |
seq | namespace 内单调递增 |
actor | 责任方,缺失即拒绝写入 |
causedByEventId | 因果来源 |
triggerDepth | 触发深度,用于防环 |
四条硬约束
| 约束 | 实现 |
|---|---|
| 仅追加 | state_events 没有任何 update / delete 代码路径 |
| 可寻址 | 所有状态都能通过 path 精确定位 |
| 可版本化 | readVersion(path, n) 可读历史版本 |
| 可复放 | replay() 从事件重建;verify() 校验物化 == 重放 |
物化状态不是真相源。删掉
state_entries后,用replay()可以完整恢复。
写权限
写入必须携带 actor(谁写的)。默认策略(🟡)是只允许写与自己有关的路径:
| 写入者 | 规则 |
|---|---|
platform / system | 全权(平台代记) |
agent / user | 只能写路径段中含自身 id 的路径 |
例外是平台代记事实:事实由当事人做出、由平台服务代记,路径里是业务 id 而不是当事人 id。开口是有界的 —— 按事实类型限定写入者,而不是一律放开:
| 事实 | 路径 | 允许写入者 |
|---|---|---|
| 需求提交 | /tasks/{t}/requests/{r} | user / platform |
| 交付 | /tasks/{t}/deliveries/{d} | agent |
| 验收 | /tasks/{t}/deliveries/{d}/acceptance | user / platform |
| 争议 | /tasks/{t}/disputes/{q} | user / agent / platform |
| 处置 | /tasks/{t}/disputes/{q}/resolution | platform / system |
| 托管 | /tasks/{t}/escrow | user / platform |
| 额度扣减 | /tasks/{t}/commits/{a2aTaskId} | api_client / user / platform |
段数被显式校验:少一层、多一层都不认(/tasks/{t}/deliveries/{d}/x、/tasks/{t}/requests 都不是事实),避免"加一层就绕过写入者约定"。
需求提交为什么需要例外:对话确认时 submit_request 用确认的用户身份写 /tasks/{t}/requests/{会话 id} —— 路径里只有任务与会话 id。按默认策略会被 403,而契约路径没有 {userId} 变量,任何契约都绕不过去(这个缺口让「确认 → 写状态 → 触发下游」在真库上一直失败,由 facade-dialogue e2e 首次暴露)。归属校验放在上层对话入口(session 必须属于该用户),状态层只按事实类型放行,因此事件日志仍能回答"是谁确认的"(actor = user:{id})。
写入原子性
一次写入包含三步:
① namespace seq $inc
② 事件 insert
③ 物化 upsert这三步在同一事务中完成。此前 ② 成功而 ③ 失败会留下"有事件、无状态"的不一致,verify() 能发现但无法自愈。
如果调用方已经开启事务(例如履约与托管),write() 接受外部 session,让 Mongo 业务写入与状态写入同生共死:
Mongo 业务写入 ─┐
├─ 同一事务 ─▶ commit
L0 状态写入 ──┘
L0 拒绝 ⇒ Mongo 回滚订阅派发在 commit 之后执行,因为外部副作用无法回滚。
订阅与触发
订阅让状态变化可以驱动执行:
{
"pattern": "/tasks/*/deliveries/**",
"condition": { "field": "status", "op": "eq", "value": "delivered" },
"action": { "type": "execute_workflow", "workflowId": "wf_acceptance" },
"maxTriggerDepth": 5
}执行器:
| 执行器 | 作用 |
|---|---|
execute_workflow | 命中后启动一个工作流 |
execute_agent | 命中后直接调用 Agent(A2A 或内部),结果回写状态 |
防环
事件携带 causedByEventId 与 triggerDepth。超过订阅声明的 maxTriggerDepth 后只记录事件,不再触发执行。
一条完整的执行闭环
外部 Agent 回传交付
→ 写入 /tasks/t1/deliveries/d1(actor=agent:x)
→ 命中订阅:status=delivered
→ 执行验收工作流
→ 写入 /tasks/t1/deliveries/d1/acceptance(actor=buyer)
→ 命中订阅:status=accepted
→ 触发结算(escrow)
→ 写入 /tasks/t1/escrow(actor=platform)
→ 无更多订阅 → 自然停止traceCausation({ taskId }) 可以重建整条链路,每一跳的 actor 都可查。
旁挂原则
状态层不替换既有执行路径:
| 场景 | 是否写入 |
|---|---|
节点声明了 config.stateWrites | ✅ 写入,失败只 warn,不影响节点结果 |
| 节点未声明 | ❌ 零写入 |
节点声明了 config.stateReads | ✅ 读取,注入 input.stateReads.{as} |
| 履约 / 托管事实 | ✅ 强制写入,失败即抛错 |
为什么履约与托管是强制的:它们是钱与责任的证据,静默丢失不可接受。节点级旁挂则是可选的观测能力,不能因为观测失败而阻断业务。
与履约、托管的关系
L1 履约事实 ──强制──▶ /tasks/{id}/deliveries/...
L2 托管事实 ──强制──▶ /tasks/{id}/escrow
对话需求提交 ──强制──▶ /tasks/{id}/requests/...(对话层唯一写入口)
节点执行痕迹 ──旁挂──▶ /tasks/{id}/nodes/...
Agent 回传 ──旁挂──▶ /tasks/{id}/agents/...对话提交的需求是强制的:用户在对话里确认后,submit_request 先按契约 input schema 校验,不合格把错误回填给模型继续追问,合格才写状态层;写入失败不会被当成"已提交"(这一条由 facade-dialogue e2e 守)。
FulfillmentService.getTrace() 只读状态层即可重建一单的完整叙事;清空 Mongo 业务集合后 trace 仍完整,这一点由 e2e 反证。
刻意没做
| 不做 | 原因 |
|---|---|
| 对外 REST 端点 | 条件与动作形状尚未稳定,不进入公开契约 |
| 事件删除 / 归档 / 压缩 | 会破坏 replay |
| 为推测性字段建唯一约束 | 会把猜测固化 |
| 订阅条件里的算术 / 时间窗 / 聚合 | 等真实订阅需求 |
| 细粒度命名空间授权 | 产品规则未定 |
验证方式
# 状态层单测(寻址 / 版本 / 仅追加 / actor / 权限 / 条件 / 触发 / 防环 / 旁挂 / replay)
npx jest libs/modules/src/state
# 真库 e2e(会写 .env 指向的库,自建自清)
npx jest --config ./apps/hushwork/test/jest-e2e.json --runInBand \
--testPathPatterns="state-layer"
# L1 ↔ L0 接线 e2e
npx jest --config ./apps/hushwork/test/jest-e2e.json --runInBand \
--testPathPatterns="fulfillment-state"
# 对话 → state → 订阅 → 结果(门面闭环,真库)
npx jest --config ./apps/hushwork/test/jest-e2e.json --runInBand \
--testPathPatterns="facade-dialogue"下一步
- 状态如何驱动 Agent(事件 → 订阅 → 因果 → 运行时) → 状态如何驱动 Agent
- 状态层还能做什么 → 应用案例拆解
- 履约与托管如何使用状态层 → 履约与托管
- 模块细节 → state 模块
- 数据存储全貌 → 数据模型