状态如何驱动 Agent
状态层不是「存结果的地方」,而是把事实变成可触发、可追溯、可复算的逻辑关系。 这一页讲清:一次状态写入如何变成一次 Agent 执行,执行结果又如何继续驱动下一步。
第一次接触这些概念,建议先读小白版:状态驱动 Agent 是怎么跑起来的。
为什么不是普通数据库
传统 CRUD 里,写一行数据只是改变一个名词,等别人来读。 状态层把每次写入都当作一个事件:
写入事实 → 事件 → 订阅匹配 → 条件命中 → 执行 → 写回新事实 → …这让状态从「被动查询的数据」变成「主动驱动流程的事实」。 它也带来三个普通数据库不回答的问题:
- 这次变化是怎么来的?
- 中间每一步是谁做的?
- 链条会不会无限自激?
状态层用 causedByEventId、actor、triggerDepth 分别回答它们。
五个构件
| 构件 | 作用 | 关键约束 |
|---|---|---|
| Path | 地址 | 只能 /tasks/{taskId}/...;* 保留给订阅 pattern |
| Event | 事实(真相源) | 仅追加;(namespace, seq) 唯一;必须带 actor |
| Entry | 当前值(投影) | path 唯一、version 单调;必须能被重放重建 |
| Subscription | 规则 | pattern + condition + action;有触发深度上限 |
| Executor | 解释器 | execute_workflow / execute_agent;运行时注册 |
一个容易混淆的点:Entry 不是真相源。真相源是事件日志;Entry 只是为了让「读当前值」 不必每次重放。verify() 会校验两者一致,不一致就是 bug。
八种逻辑关系
同一个事件,会在不同维度上长出不同的关系:
1. 寻址:path 决定它在哪个世界
/tasks/{taskId}/requests/{requestId}
/tasks/{taskId}/agents/{agentId}/results/{eventId}
/tasks/{taskId}/agents/{agentId}/errors/{eventId}
/tasks/{taskId}/deliveries/{deliveryId}namespace 取前两段,taskId 取第二段。taskId 是「一单的完整因果」的主键, 因此平台只接受任务域路径;平台级数据使用专用集合,不写进这里。
2. 时序:seq 给全序,version 给快照
seq在 namespace 内单调递增,是重放的稳定全序;version是同一个 path 的第几次写入,readVersion(path, n)回答「当时是什么」。
没有 seq,并发写入的先后不可判定;没有 version,下游无法判断 「验收时看到的交付是不是后来被改过的那一版」。
3. 因果:causedBy 给链,depth 给刹车
每个事件都带:
| 字段 | 回答的问题 |
|---|---|
causedByEventId | 这次变化由哪次写入触发 |
triggerDepth | 离根事件有多远 |
actor | 谁做的(用户 / Agent / 平台 / 系统) |
source | 从哪条路径写的(API / 节点 / 回调) |
traceCausation 把链拼成树。父事件不在查询范围内时,会返回 linksResolved=false 与 danglingCausedBy,不会假装链是完整的。
4. 触发:pattern 找候选,condition 做判定
{
"pattern": "/tasks/*/requests/*",
"condition": {
"all": [
{ "path": "$event.op", "op": "eq", "value": "set" },
{ "path": "$event.value.status", "op": "eq", "value": "confirmed" }
]
},
"action": { "type": "execute_agent", "agentId": "facade-vat-registration" },
"maxTriggerDepth": 3
}条件只支持白名单比较器:all / any / not,操作符 eq ne gt gte lt lte in contains exists, 路径只允许 $event.* 与 $state.*。
没有 eval,也不打算做图灵完备。复杂逻辑放进执行器(工作流 / Agent), 规则层只负责「谁该被叫醒」。
5. 数据依赖:stateReads 把上游事实喂给下游
触发只解决「谁来执行」,不解决「执行时看得见什么」。
节点可以声明:
"stateReads": [
{ "path": "/tasks/${taskId}/deliveries/${deliveryId}", "as": "delivery" }
]读到的值注入 input.stateReads.delivery(专用键,不平铺)。 写侧对称:
"stateWrites": [
{ "path": "/tasks/${taskId}/stage/reviewed", "valueFrom": "output" }
]硬约束:未声明就零读写;读不到 / 写失败只告警,不影响节点执行。 这让状态层可以逐步接入既有链路,而不是一次性重写。
6. 责任:actor 必填,source 说明来路
每次写入必须带 actor。它决定:
- 交付是谁做的;
- 验收是谁确认的;
- 争议时谁该负责。
source 区分写入路径:api / workflow_node / task_node / subscription / agent_callback / system。
平台代记履约/托管事实时,actor 仍然是当事人,不是模糊的「平台」。
7. 边界:depth 防环,旁挂防回归
新事件.triggerDepth = 上游.triggerDepth + 1
if (triggerDepth >= maxTriggerDepth) → 只记事件,不触发默认上限是 3。它不是性能参数,而是正确性参数: 没有它,「命中即写同一路径」的订阅会在一次写入里自激。
8. 幂等与重放:eventId 是执行的身份
- 异步派发是 at-least-once,执行器必须按
eventId幂等; - Agent 唤醒的 jobId 是
agent:{eventId}:{agentId}; - Agent 执行不自动重试——重复执行会重复消耗 token;
replay()重建物化,verify()校验一致;readVersion()取历史快照。
一条完整链路
门面确认后的单点交付:
用户确认
→ /tasks/T/requests/S
source=api, actor=user:U, depth=0
→ 订阅命中(pattern + condition)
→ execute_agent(facade-hs-code)
→ AgentRuntime 执行
→ /tasks/T/agents/facade-hs-code/results/E1
source=agent_callback, actor=agent:facade-hs-code,
depth=1, causedBy=E1
→ SSE result / GET .../result注意 results/{eventId} 里的 eventId 是触发事件的 id。 每次触发都落在不同路径,所以结果不会互相覆盖。
多跳链路(真实 e2e):
| 跳 | 路径 | source | depth | causedBy |
|---|---|---|---|---|
| ① | /tasks/T/status | api | 0 | — |
| ② | /tasks/T/stage/reviewed | workflow_node | 1 | ① |
| ③ | /tasks/T/stage/settled | workflow_node | 2 | ② |
| ④ | /tasks/T/stage/finalized | workflow_node | 3 | ③ |
| 停 | — | — | — | ④ 不再命中 |
这条链证明:状态可以驱动执行、执行可以继续写状态、整条链可重建且会停。 它不证明业务上一单真实交付能自动跑完——那需要履约与托管层。
AgentRuntime 在哪一环
AgentRuntime 只做一件事:
systemPrompt + input + budget → output它不知道状态、订阅、队列,也不持有会话。连接发生在触发器里:
| 入口 | 谁调用 | 输入 | 结果 |
|---|---|---|---|
| 对话 | 对话服务 | 多轮历史 | 自然语言 + 提交请求 |
| 状态唤醒 | 触发器 → 队列 → 消费者 | 事件快照(按 input schema 校验) | 结构化 envelope 写回契约路径 |
| 任务节点 | 任务执行器 | 节点输入(可合并 stateReads) | 节点结果(可旁挂写状态) |
状态唤醒的关键设计:输入取事件快照,而不是执行时再读状态。 异步、重放、乱序下,「执行时读到的当前值」可能已经变了; 用快照才能保证「这次唤醒处理的就是那条事实」。
失败时写 errors/{eventId},不冒充结果。失败不是交付, 但它必须落进状态层,否则「唤醒失败」在因果链里是隐形的。
契约如何变成订阅
门面的触发逻辑不是手写 if,而是一条编译链:
AgentProfile.stateContract 静态声明
│ compile(显式发布)
▼
state_subscriptions 触发权(运行时唯一读取的层)
│ pattern + condition
▼
execute_agent → AgentRuntime编译器会:
- 补默认 schema;
- 把契约版本与完整快照冻结进订阅;
- 拒绝两个订阅写同一个 output path;
- 推导并冻结交付方式(本地技能 / 只登记 / 外部供给方)。
三条硬约束:只有 active 的 profile 能发布;output.path 必须含 {eventId} 或 {requestId};旧版本订阅会被禁用。
常见误区:改了契约但没有重新发布,运行时仍然使用旧快照。
怎么读一条链
| 问题 | 方法 |
|---|---|
| 这次结果怎么来的 | traceCausation({ taskId }) 上溯 |
| 当时的值是什么 | readVersion(path, n) |
| 物化和事件一致吗 | verify(namespace) |
| 为什么没触发 | 检查 enabled、pattern、condition、max_depth |
| 为什么没执行 | 触发结果里的 no_executor / handler_error |
| 结果为什么没出现 | 看 errors/{eventId} 与输出路径渲染 |
排障顺序:事件是否写入 → 订阅是否命中 → 条件是否通过 → 执行器是否注册 → 是否被深度上限拦下 → 执行是否成功 → 回写路径是否正确。
今天真正在跑什么
机制存在不等于默认在跑。不做额外配置时:
| 行为 | 默认是否发生 |
|---|---|
任务执行写 batches / steps / decision | ✅ 会(留痕与恢复依据,不是订阅触发) |
| 外部 Agent 回传写交付 / 中断态 | ✅ 会 |
| 履约与托管事实写入 | ✅ 会(强制) |
门面对话写 requests | ✅ 会(有 taskId + 契约时) |
| 状态写入唤醒门面 | ❌ 只有存在 enabled 订阅且命中时才发生 |
| 26 个行业门面进入市场 | ❌ 默认 draft |
| 门面订阅自动发布 | ❌ 必须显式 --activate --publish,intake-only 跳过 |
| 15 个本地技能门面同时订阅 | ❌ 刻意不发布(共用同一 pattern,会互相抢) |
两个关键区分:
- 上架 ≠ 订阅。
active只代表可被发现、可对话;发布契约才代表 「状态写入会叫醒它」。 submit_request的triggered数量是直接信号。它是 0,说明这次确认 没有任何订阅命中——不是下游慢,是没人接。
想确认一个环境有没有在跑:
# 启用的订阅数量(0 ⇒ 状态驱动 agent 不会发生)
db.state_subscriptions.countDocuments({ enabled: true })
# 哪些门面 active、哪些订阅 enabled
db.agent_profiles.find({ status: 'active' }, { agentId: 1 })
db.state_subscriptions.find({ enabled: true }, { name: 1, pattern: 1 })测试证明了什么、没证明什么:
| 测试 | 证明了 | 没证明 |
|---|---|---|
state-layer 订阅驱动 Agent | 注册、命中、调用、回写、因果链、verify | 真实模型执行(agent 被 mock) |
facade-dialogue 对话闭环 | 事实形态、订阅命中、SSE 顺序、幂等 | 真实门面交付质量(下游是测试 writer) |
接线是端到端验证过的,业务交付不是。 两件事不能混为一谈。
边界
| 不能做 | 现状 |
|---|---|
| 条件里做算术、时间窗、跨事件聚合 | 只有白名单比较器 |
| 把状态层当通用消息队列 | 语义是事实 + 订阅,不是消息 + 消费组 |
| 跨 namespace 事务 | 只支持同库事务 |
| 删除 / 归档事件 | 会破坏重放,刻意不做 |
| 依赖自动重试 | Agent 唤醒 attempts=1 |
| 多实例共享 outbox worker | 当前单实例约束 |
| 把对话原文写进状态 | 只写事实与指针 |
| A2A 入站直接调用本地门面 | message/send 目前只返回编排草案 |
下一步
- 除了唤醒,状态层还能做什么 → 应用案例拆解
- 状态层的数据模型与约束 → 状态层
- 门面的三种入口与运行机制 → 门面 Agent 与 AgentRuntime
- 一次需求的完整生命周期 → 请求生命周期
- 履约与托管如何写入状态 → 履约与托管