Skip to content

状态如何驱动 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 决定它在哪个世界 ​

text
/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 做判定 ​

jsonc
{
  "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 把上游事实喂给下游 ​

触发只解决「谁来执行」,不解决「执行时看得见什么」。

节点可以声明:

jsonc
"stateReads": [
  { "path": "/tasks/${taskId}/deliveries/${deliveryId}", "as": "delivery" }
]

读到的值注入 input.stateReads.delivery(专用键,不平铺)。 写侧对称:

jsonc
"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() 取历史快照。

一条完整链路 ​

门面确认后的单点交付:

text
用户确认
  → /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):

跳路径sourcedepthcausedBy
①/tasks/T/statusapi0—
②/tasks/T/stage/reviewedworkflow_node1①
③/tasks/T/stage/settledworkflow_node2②
④/tasks/T/stage/finalizedworkflow_node3③
停———④ 不再命中

这条链证明:状态可以驱动执行、执行可以继续写状态、整条链可重建且会停。 它不证明业务上一单真实交付能自动跑完——那需要履约与托管层。


AgentRuntime 在哪一环 ​

AgentRuntime 只做一件事:

systemPrompt + input + budget → output

它不知道状态、订阅、队列,也不持有会话。连接发生在触发器里:

入口谁调用输入结果
对话对话服务多轮历史自然语言 + 提交请求
状态唤醒触发器 → 队列 → 消费者事件快照(按 input schema 校验)结构化 envelope 写回契约路径
任务节点任务执行器节点输入(可合并 stateReads)节点结果(可旁挂写状态)

状态唤醒的关键设计:输入取事件快照,而不是执行时再读状态。 异步、重放、乱序下,「执行时读到的当前值」可能已经变了; 用快照才能保证「这次唤醒处理的就是那条事实」。

失败时写 errors/{eventId},不冒充结果。失败不是交付, 但它必须落进状态层,否则「唤醒失败」在因果链里是隐形的。


契约如何变成订阅 ​

门面的触发逻辑不是手写 if,而是一条编译链:

text
AgentProfile.stateContract   静态声明
        │ compile(显式发布)
        ▼
state_subscriptions          触发权(运行时唯一读取的层)
        │ pattern + condition
        ▼
execute_agent → AgentRuntime

编译器会:

  1. 补默认 schema;
  2. 把契约版本与完整快照冻结进订阅;
  3. 拒绝两个订阅写同一个 output path;
  4. 推导并冻结交付方式(本地技能 / 只登记 / 外部供给方)。

三条硬约束:只有 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,会互相抢)

两个关键区分:

  1. 上架 ≠ 订阅。active 只代表可被发现、可对话;发布契约才代表 「状态写入会叫醒它」。
  2. submit_request 的 triggered 数量是直接信号。它是 0,说明这次确认 没有任何订阅命中——不是下游慢,是没人接。

想确认一个环境有没有在跑:

bash
# 启用的订阅数量(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 目前只返回编排草案

下一步 ​

Released under the MIT License.