# g2rain-agent-runtime State 规范
本文档描述 Runtime 在一次对话中保存哪些状态、存在哪里、何时读写、如何恢复。内容以当前代码实现为准,配套图见 runtime-state-model.svg。
目录
- 基本概念
- 存储布局
- 领域模型
- 一轮对话的生命周期
- 关键机制
- 配置项
- 错误码
- 实现约束与注意事项
1. 基本概念
1.1 三层状态
| 层 | 生命周期 | 内容 | 代码入口 |
| 会话(Conversation) | 跨多轮、跨多个任务 | 会话栅栏、对话消息、已结束任务的记录、实体索引、会话级共享值 | TurnScope(DefaultTurnScope) |
| 任务(Task) | 从开任务到终态 | 任务元数据、任务图、断点、任务级共享值、前情、写证据索引 | TurnScope 上的 task / graph / checkpoint |
| 节点(Node) | 图中一个节点的执行 | 输入、产出、便签、工具证据、工具原始报文 | NodeScope(DefaultNodeScope) |
一轮请求称为 Turn,用 traceId 标识。TurnScope 是本轮内存中的状态视图,轮末统一写回存储。
1.2 存储分工
| 存储 | 角色 | 说明 |
| Redis | 热态,读写以它为准 | 每轮开始从 Redis 读状态;轮中通过 flush.lua 原子写入 |
| MySQL | 冷存储与兜底 | 轮末 persist 统一写入;Redis 中的 key 缺失或过期时从 MySQL 回灌 |
两个原则:
- Redis 为准:同一轮里,任何状态变化先写 Redis(由版本栅栏保护),再落 MySQL。
- MySQL 兜底:Redis 失效后,用 MySQL 恢复对话、任务、断点和写证据,保证可以继续对话、不重复执行写操作。
1.3 写入点
| 写入点 | 时机 | 写什么 |
| S1 | 本轮用户消息进入时 | 用户消息:先 Redis,再同步写 MySQL chat_message |
| S2 | 写工具调用前(预登记) | 一条 STARTED 证据:先 Redis,再同步写 MySQL tool_call_record |
| S3 | 工具返回后(结算) | 证据终态、原始报文:先 Redis,再写 MySQL |
| flush | 轮中多次、轮末一次 | 本轮全部内存状态原子写入 Redis |
| persist | 轮末一次 | 本轮全部状态写入 MySQL(单事务) |
1.4 术语
| 术语 | 含义 |
| 栅栏(fence) | 会话状态中的 version。每轮开始时加 1;本轮所有 Redis 写入都必须携带这个版本,版本不一致即拒绝 |
| 断点(checkpoint) | 任务挂起等用户的那一刻保存的现场:问题、允许的动作、待执行的写操作 |
| 证据(evidence) | 一次业务工具调用的记录:状态、是否业务成功、是否空结果、摘要等 |
| 写证据索引(writes) | 按"工具名 + 幂等键"索引写操作的证据,用于防止重复写 |
| 发起节点 / 前置节点 | 委派关系中,调用 builtin_node_delegate 的节点是发起节点,被插入的子节点是前置节点 |
| 前情(prior) | 开新任务时,从已结束任务中带过来的摘要和实体 |
2. 存储布局
2.1 Redis Key
所有会话相关 key 使用 hash tag {organId:conversationId},保证同一会话的 key 落在同一个 Redis slot,Lua 脚本可以原子操作。下表中 {c} 表示 {organId:conversationId},<tid> 为任务 ID,<nid> 为节点 ID。
会话层
| Key | 类型 | 内容 | TTL |
g2rain:rt:{c}:state | Hash | 会话栅栏:userId、activeTaskId、version、lastTraceId、updatedAt | state.conversation-ttl(默认 7 天),每轮续期 |
g2rain:rt:{c}:dialogue | List | 对话消息 JSON,表头为最新;保留最近 state.dialogue-max 条 | 不设置 |
g2rain:rt:{c}:records | List | 已结束任务的 TaskRecord JSON;保留最近 state.records-max 条 | 不设置 |
g2rain:rt:{c}:entities | Hash | 实体索引:field 为 type:id,value 为 EntityEntry JSON | 不设置 |
g2rain:rt:{c}:shared | Hash | 会话级共享值:field 为键名,value 为 Sourced JSON | 会话 TTL,每轮续期 |
任务层(任务进行中存在,终态落库成功后删除)
| Key | 类型 | 内容 |
g2rain:rt:{c}:task:<tid>:metadata | Hash | 任务元数据(status、currentNodeId、agentId、goalSummary、request、finalMessage、suspendedAt)。同时作为任务组的"哨兵" |
g2rain:rt:{c}:task:<tid>:graph | Hash | 任务图:field 为 nodeId,value 为 TaskNode JSON;另有 field _edges 保存全部边 |
g2rain:rt:{c}:task:<tid>:checkpoint | String | 当前断点 TaskCheckpoint JSON;没有断点时删除 |
g2rain:rt:{c}:task:<tid>:shared | Hash | 任务级共享值:键名 → Sourced JSON |
g2rain:rt:{c}:task:<tid>:prior | String | 前情 Prior JSON |
g2rain:rt:{c}:task:<tid>:writes | Hash | 写证据索引:field 为 toolName\|idempotencyKey,value 为 {evidenceId, status, businessOk} |
节点层
| Key | 类型 | 内容 |
g2rain:rt:{c}:task:<tid>:node:<nid>:space | Hash | 节点空间:inputs、outputs、state、rounds、tokens、actions。单个字段过大时换成溢出占位(见 5.10) |
g2rain:rt:{c}:task:<tid>:node:<nid>:evidence | Hash | 证据头:field 为 evidenceId,value 为 EvidenceHead JSON |
g2rain:rt:{c}:task:<tid>:payload:<eid> | String | 工具原始报文 JSON |
任务组 TTL
| 场景 | TTL |
| 任务进行中 | state.task-ttl-running(默认 24 小时) |
| 任务挂起(SUSPENDED) | state.task-ttl-suspended(默认 2 小时) |
metadata(哨兵)在续期时 | 任务 TTL 减去 state.sentinel-gap(默认 10 分钟) |
节点 DONE 后的 payload | state.payload-done-ttl(默认 1 小时;配置为 0 表示立即删除)。仍有下游节点未结束的上游节点报文除外(下游会直接读上游证据) |
其他 key
| Key | 用途 |
g2rain:rt:lock:conv:{cid} | 会话锁,同一会话同一时刻只处理一个请求,拿不到锁立即失败 |
2.2 Lua 脚本
renew.lua(每轮开始执行一次)
| 参数 | 含义 |
KEYS[1] | 会话 state key |
ARGV[1] | 本轮 traceId |
ARGV[2] | 会话 TTL(毫秒) |
ARGV[3] | 会话共享 key,可为空串 |
ARGV[4] | 基线版本(Redis 已有值,或 Redis 缺失时从 MySQL 读回的 state_version) |
ARGV[5..] | 任务组 key 与 TTL,两两成对 |
执行步骤:
- 读出旧的
lastTraceId。 version 不存在时,先写入基线版本。 version 加 1,作为本轮栅栏。 lastTraceId 写为本轮 traceId,updatedAt 写为当前时间。 - 续期会话
state、会话共享 key,以及全部任务组 key。 - 返回
{version, 旧的 lastTraceId}。
旧的 lastTraceId 非空,说明上一轮没有正常收尾(见 5.2)。
flush.lua(轮中、轮末写 Redis)
| 参数 | 含义 |
KEYS[1] | 会话 state key |
ARGV[1] | 本轮栅栏版本 |
ARGV[2] | 改动列表 JSON:[{op, key, field, value, ttl}] |
执行步骤:
- 读出
state.version,与 ARGV[1] 不相等则直接返回 0,不做任何写入。 - 依次执行改动列表,返回 1。
op | 动作 |
HSET | 写 Hash 字段;带 ttl 时同时 PEXPIRE |
HDEL | 删除 Hash 字段(字段值为 null 时生成) |
SET | 写 String;带 ttl 时使用 PX |
DEL | 删除 key |
LPUSH_TRIM | 头部插入,再裁剪到 ttl 条(此处 ttl 表示保留条数) |
LREM | 从 List 删除指定值 |
EXPIRE | 续期(毫秒) |
返回 0 时 Runtime 抛出 STATE_FENCED,本轮放弃写入。
2.3 MySQL 表
所有表都带公共列 id、organ_id、create_time、update_time、version、delete_flag。租户条件由 MyBatis 拦截器自动追加。
| 表 | 用途 | 写入 | 读回 |
chat_conversation | 会话:conversation_id、user_id、title、active_task_id、session_shared、state_version、last_message_time | 首轮建行;轮末按 state_version CAS 更新 | Redis state 缺失时回灌栅栏、活动任务和会话共享 |
chat_message | 对话消息:message_id、role、message_type、content、task_id、node_id、round、trace_id、created_at_ms | 用户消息在 S1 写;其他消息在轮末写 | Redis dialogue 为空时回灌;历史消息接口读取 |
task_instance | 任务:task_id、agent_id、goal_summary、request、status、current_node_id、final_message、shared、prior、record、state_version、started_time(任务开启时刻)、suspended_time、finished_time、trace_id(开启任务那一轮)、source_message_id(触发开启的用户消息,入库后回填该消息的 task_id) | 轮末写;已存在时按 state_version CAS 更新 | 任务组 key 缺失时回灌;record 用于回灌 records |
task_graph_node | 节点:node_id、title、objective、skill_key、target(完成目标)、target_claimed、delegate_depth、status、result(节点结论)、inputs、outputs、state(完整节点空间 JSON)、error_code、error_message、started_time(首次运行)、finished_time(进入终态) | 轮末写 | 任务图、节点空间回灌 |
task_graph_edge | 边:from_node_id、to_node_id、edge_type | 轮末追加新边 | 任务图回灌 |
task_checkpoint | 断点:checkpoint_id、node_id、type、question、expected_fields、allowed_actions、suggestions、pending_action | 轮末写;断点被消费时逻辑删除(delete_flag=1) | 断点回灌 |
task_resume_record | 断点恢复记录:resume_id、checkpoint_id、message_id、resume_action、acted_at | 断点被消费时插入 | 审计用 |
tool_call_record | 工具调用:call_id(即 evidenceId)、tool_name、side_effect、request、args_hash、idempotency_key、round、response、result_summary、entities、payload_id、call_status、business_ok、empty_result、failure_kind、error_code、error_message、duration_ms | 写工具在 S2 插入、S3 更新;读工具在结算时插入;轮末补写证据状态 | 证据回灌、写操作去重 |
task_node_payload | 大对象:payload_id、payload_type(ARTIFACT 报文 / OUTPUT 节点空间字段)、payload_data | 报文或节点空间字段超过阈值时写 | 读取超大报文或溢出字段 |
3. 领域模型
3.1 会话层
ConversationFence(会话栅栏)
| 字段 | 类型 | 说明 |
conversationId | String | 会话 ID(不写入 Redis Hash) |
userId | String | 会话所属用户;与当前请求用户不一致时拒绝访问 |
activeTaskId | String? | 当前活动任务;无任务时为空 |
version | long | 栅栏版本 |
lastTraceId | String? | 正在处理的轮次;正常收尾后清空 |
updatedAt | long | 最近更新时间(毫秒) |
DialogueMessage(对话消息)
| 字段 | 类型 | 说明 |
messageId | String | 消息 ID |
role | DialogueRole | 发言方 |
type | MessageType | 消息类型 |
text | String | 正文 |
traceId | String | 所属轮次 |
taskId | String? | 所属任务 |
nodeId | String? | 所属节点。模型的中间文字、以及回答追问的用户消息会带上节点 ID |
round | Integer? | 节点内的 ReAct 轮次 |
createdAt | long | 创建时间(毫秒) |
| DialogueRole | 含义 |
USER | 终端用户 |
AGENT | 数字员工 |
| MessageType | 含义 |
USER_TEXT | 用户文本 |
AGENT_REPLY | 数字员工的回复,或节点内的中间文字 |
对话窗口的规则:
- 会话级对话(用于意图判定、来源校验)排除"
AGENT 且带 nodeId"的中间文字。 - 节点内历史只取
nodeId 等于该节点的消息,按 round 排序,并与该节点的证据交织组装。
TaskRecord(已结束任务记录)
| 字段 | 类型 | 说明 |
taskId | String | 任务 ID |
agentId | String | 执行的数字员工 |
goal | String? | 任务目标摘要 |
status | TaskStatus | 终态 |
summary | String? | 收口文案 |
entities | Map<String, EntityEntry> | 结束时的实体索引 |
artifacts | List | 产物引用(当前为空) |
closedAt | long | 结束时间 |
EntityEntry(实体)
实体是从工具结果或节点产出中识别出的业务对象(如文章、标签),用于后续任务引用。索引键为 type:id。
| 字段 | 类型 | 说明 |
displayName | String | 展示名 |
attrs | JSON | 标量属性 |
taskId | String? | 来源任务 |
evidenceId | String? | 来源证据 |
seenAt | long | 最近一次出现时间 |
mentions | int | 出现次数 |
共享值:Sourced / Source / SourceType
共享值、节点输入等都以"带来源的值"保存,以便判断一个值是否可信。
| 类型 | 结构 |
Sourced<T> | {value, source} |
Source | {type: SourceType, ref, path} |
| SourceType | 含义 | ref / path |
USER | 用户明确提供 | 用户消息 ID |
EVIDENCE | 工具证据 | evidenceId / 报文路径 |
NODE_OUTPUT | 上游节点产出 | nodeId / 字段名 |
SHARED | 任务级共享值 | 键名 |
SESSION | 会话级共享值 | 键名 |
PRIOR_TASK | 前情 | 实体键 |
MODEL | 大模型生成 | — |
CONTRACT | 契约常量 | — |
PLATFORM | 平台注入(登录身份等) | 字段名 |
3.2 任务层
| 字段 | 类型 | 说明 |
taskId | String | 任务 ID |
conversationId | String | 所属会话 |
status | TaskStatus | 任务状态 |
currentNodeId | String? | 当前焦点节点 |
agentId | Long? | 执行的数字员工 |
goalSummary | String? | 任务目标摘要 |
request | String? | 开任务时的用户原文;节点提示词的 userGoal 取它,摘要会丢细节 |
finalMessage | String? | 终态文案 |
suspendedAt | Long? | 挂起时间 |
| TaskStatus | 含义 | 是否终态 |
RUNNING | 运行中 | 否 |
SUSPENDED | 挂起,等用户 | 否 |
SUCCEEDED | 成功结束 | 是 |
FAILED | 失败结束 | 是 |
CANCELED | 已取消 | 是 |
TaskGraph / TaskNode / TaskEdge
| TaskNode 字段 | 类型 | 说明 |
nodeId | String | 节点 ID |
title | String? | 节点标题,用作过程提示 |
objective | String? | 节点目标 |
skillKey | String? | 执行的技能 |
target | String? | 完成目标:要完成的写工具;取值顺序为规划输出 → 契约 target → 首个实际写调用,见契约规范第 10 节 |
targetClaimed | boolean | 完成目标是否由首个写调用自动记录;为 true 时 GoalGate 不直接采用节点结论,交模型核验 |
result | String? | 节点结论:完成时 finish 的 output,GoalGate 据此核验目标并合成答复 |
status | NodeStatus | 节点状态,默认 PENDING |
dependsOn | List | 依赖的节点 |
errorCode / errorMessage | String? | 失败信息 |
delegateDepth | int | 委派深度,规划出的节点为 0 |
| TaskEdge 字段 | 说明 |
from | 上游节点 |
to | 下游节点 |
type | EdgeType |
| EdgeType | 含义 |
NORMAL | 普通依赖:上游 DONE 或 SKIPPED 后下游才能执行 |
DELEGATE | 委派:前置节点 → 发起节点。前置节点到达任意终态后,发起节点恢复执行 |
| NodeStatus | 含义 | 可调度 | 放开下游 | 终态 |
PENDING | 等上游 | 是 | 否 | 否 |
READY | 可执行 | 是 | 否 | 否 |
RUNNING | 执行中 | 否 | 否 | 否 |
WAITING_DELEGATE | 等前置节点 | 否 | 否 | 否 |
SUSPENDED | 等用户 | 否 | 否 | 否 |
DONE | 完成 | 否 | 是 | 是 |
SKIPPED | 跳过(所需信息已存在) | 否 | 是 | 是 |
FAILED | 失败 | 否 | 否 | 是 |
CANCELLED | 取消 | 否 | 否 | 是 |
节点状态迁移见 5.11。
TaskCheckpoint(断点)
| 字段 | 类型 | 说明 |
checkpointId | String | 断点 ID |
type | CheckpointType | 断点类型 |
question | String? | 展示给用户的问题。审批时由 Runtime 拼上待执行参数 |
allowedActions | List | 允许的恢复动作,默认取类型的默认动作 |
suggestions | List | 候选答案 |
pendingAction | PendingAction? | 审批通过后要执行的写操作 |
expectedFields | List | 追问时要补的参数路径 |
| CheckpointType | 场景 | 默认动作 | 不带动作时的默认恢复 |
FOLLOW_UP | 追问补充信息 | REPLY、CANCEL | REPLY |
APPROVAL | 写操作前请用户确认 | CONFIRM、REJECT、CANCEL | 无(必须明确动作) |
VERIFY | 写操作结果不确定,请用户核实是否已生效 | CONFIRM、REJECT | 无(必须明确动作) |
| ResumeAction | 含义 |
REPLY | 文字回答,用于追问 |
CONFIRM | 确认。审批时执行待批写操作;核实时表示"已生效" |
REJECT | 拒绝。审批时放弃该写操作;核实时表示"未生效" |
CANCEL | 放弃当前任务 |
| PendingAction 字段 | 说明 |
toolName | 待执行的写工具 |
args | 参数 JSON |
argsHash | 参数摘要,执行前校验参数未被改动 |
Prior(前情)
| 字段 | 类型 | 说明 |
tasks | List | 最近 state.prior-tasks-max 条已结束任务 |
entities | Map<String, EntityEntry> | 最多 state.prior-entities-max 个实体 |
artifacts | List | 产物引用(当前为空) |
reason | String | 生成原因 |
3.3 节点层
NodeSpace(节点空间)
| 字段 | 类型 | 说明 |
inputs | Map | 节点输入,开始执行时按契约 inputs 绑定,只补不覆盖 |
outputs | Map | 节点正式产出,通过 builtin_output_set 写入 |
state | Map | 节点便签,包含系统保留键(见下)和 builtin_state_set 写入的私有键 |
rounds | int | 已执行的 ReAct 轮数 |
tokens | long | token 累计(当前未统计,为 0) |
actions | List | 没有业务证据的动作:模型调用内置工具、调用执行前被拒或推迟、用户确认或拒绝挂起。保留最近 state.node-actions-max 条,说明按 state.node-action-max-chars 截断 |
节点历史的跨轮重建(RuntimeStateFacade.history):按轮次合并三类来源,每轮依次为"用户的话与用户动作 → 模型的文字与工具调用 → 调用返回"。
| 来源 | 内容 |
对话消息(带本节点 nodeId) | 用户的话、模型的文字 |
| 证据头 | 业务工具调用;返回内容未成功时取 detail,成功时取完整报文,都没有才用 resultBrief |
actions | 内置工具、被拒或推迟的调用及其返回;用户确认或拒绝 |
用户确认执行待批写操作、确认核实结果时,回应单独成一轮,排在派发结果与之后的模型调用之前。
| 保留便签键(NodeNoteKey) | 含义 |
awaiting | 发起节点正在等待的前置节点 ID |
delegateResult | 前置节点未成功时回给发起节点的结果,投影给模型一次后清除 |
entityOutputs | 节点完成时产出中的实体候选 |
EvidenceHead(证据头)
| 字段 | 类型 | 说明 |
evidenceId | String | 证据 ID,同时是 tool_call_record.call_id |
toolName | String | 工具名 |
sideEffect | SideEffect | 读/写 |
status | EvidenceStatus | 调用状态 |
businessOk | Boolean? | 业务是否成功(按契约 success 判定) |
empty | Boolean? | 结果是否为空 |
extractCounts | Map<String, Integer>? | 各抽取项条数 |
failureKind | FailureKind | 失败分类 |
idempotencyKey | String? | 幂等键 |
argsHash | String? | 参数摘要 |
argsBrief / resultBrief | String? | 脱敏后的参数、结果摘要 |
errorCode | String? | 错误码 |
entities | String? | 实体候选 JSON |
payloadId | String? | 超大报文的存储指针 |
round | int | 所在轮次 |
traceId | String | 所在 Turn |
detail | String? | 未成功(执行失败或业务失败)时返回给模型的完整说明"原因。下一步:…",按 react.observation-error-max-chars 截断;落库到 tool_call_record.error_message |
| EvidenceStatus | 含义 |
STARTED | 已预登记,工具尚未返回 |
SUCCESS | 已返回(是否业务成功看 businessOk) |
FAILED | 调用失败或被拦截 |
TIMEOUT | 读工具超时 |
CANCELLED | 未调用或已取消 |
UNKNOWN | 写工具可能已执行,但结果不确定 |
UNKNOWN_PRE_CALL | 预登记结果不确定,本次未调用 |
| FailureKind | 含义 |
NONE | 无失败 |
AUTH_FAILURE | 鉴权失败 |
PARAMETER_FAILURE | 参数不合法 |
BUSINESS_FAILURE | 业务拒绝 |
TRANSPORT_FAILURE | 传输失败 |
RATE_LIMIT | 限流 |
TIMEOUT | 超时 |
UNKNOWN | 无法归类 |
| SideEffect | 含义 |
NONE | 只读工具 |
WRITE | 写工具,调用前需确认、预登记,结果需结算 |
3.4 对外状态:TurnStatus
TurnStatus 是客户端唯一能看到的状态,随结果帧下发,由任务状态映射得到。
| TurnStatus | 含义 | 来源 |
DONE | 本轮完成 | 任务 SUCCEEDED,或本轮只回复、未开任务 |
WAITING | 等用户操作 | 任务 SUSPENDED |
FAILED | 本轮失败 | 任务 FAILED;或收口时任务仍为 RUNNING;或状态缺失 |
CANCELLED | 已取消 | 任务 CANCELED |
4. 一轮对话的生命周期
① 加锁
- 会话 ID 为空时,由服务端生成。
- 对
lock:conv:{cid} 加锁。拿不到锁立即失败,客户端收到失败结果帧。
② begin:装载状态
| 步骤 | 动作 | 读写 |
| 2.1 | 读会话 state;Redis 中没有时,从 chat_conversation 回灌 userId、activeTaskId、state_version | Redis state、MySQL chat_conversation |
| 2.2 | 会话用户与当前用户不一致 → CONVERSATION_FORBIDDEN | — |
| 2.3 | MySQL 中没有该会话 → 新建会话行 | MySQL chat_conversation |
| 2.4 | 读会话层:对话、记录、会话共享(Redis 优先,缺失回灌 MySQL);实体(只读 Redis) | Redis、MySQL |
| 2.5 | 有活动任务时,逐个读任务组:元数据、图、断点、任务共享、前情(Redis 优先,缺失回灌 MySQL) | Redis、MySQL |
| 2.6 | 执行 renew.lua:推进栅栏、续期、取回旧的 lastTraceId | Redis |
| 2.7 | 旧 lastTraceId 非空且任务在 RUNNING → 中断规整(见 5.2) | Redis |
| 2.8 | flush 一次,让规整结果落到 Redis | Redis |
| 2.9 | S1:用户输入非空时,追加用户消息(存在追问断点时,消息带上断点节点 ID 和轮次),flush,再同步写 chat_message | Redis、MySQL |
③ 过程提示
begin 完成后发送第一条过程提示(PROGRESS),客户端从这一帧拿到会话 ID。
④ 编排流水线
| 阶段 | 实现 | 对状态的影响 |
| 入口闸门 | DefaultTurnEntryGuard | 校验恢复动作;不合法时直接给出回复,不进入后续阶段 |
| 意图决策 | DefaultTurnDispatcher | 判定本轮意图(见下表) |
| 任务生命周期 | DefaultTaskLifecycle | 放弃旧任务(记恢复动作 CANCEL、任务置 CANCELED);开新任务(生成前情) |
| 技能装载 | DefaultHydrator | 加载数字员工的技能与契约 |
| 规划 | DefaultPlanner | 生成任务图,设置焦点节点;续跑时沿用已有图 |
| 图执行 | DefaultTaskGraphExecutor | 逐节点执行:打开节点空间 → 绑定输入 → ReAct 循环 → 产生副作用(挂起、清除挂起、停图) |
| 收口 | DefaultFinalizer | 生成本轮回复并追加为 AGENT 消息;任务到终态时关闭任务 |
| 意图(TurnMode) | 含义 |
START_FRESH | 新建任务,不继承前情 |
START_WITH_CONTEXT | 新建任务,继承上一任务的前情 |
CONTINUE_TASK | 在已有任务上继续,包括断点恢复 |
REPLACE_TASK | 放弃当前任务,同一数字员工按新目标开任务 |
SWITCH_AGENT | 放弃当前任务,换数字员工开任务 |
CANCEL_TASK | 放弃当前任务,不开新任务 |
REPLY_ONLY | 只回复,不推进任务 |
UNCERTAIN | 无法判断意图,只澄清,不推进任务 |
节点执行期间的状态写入:
- ReAct 每轮调用模型前 flush 一次。
- 写工具调用前执行 S2,返回后执行 S3(见 5.4)。
- 读工具返回后直接登记一条证据并写
tool_call_record。
⑤ flush
把本轮内存状态整体写入 Redis,包括:会话栅栏、新增消息、实体、会话共享、任务组全部字段、本轮打开过的节点的空间与证据、写证据索引、新增报文。
| 情况 | 结果 |
改动超过 state.flush-max-bytes | STATE_WRITE_FAILED |
| Redis 异常 | STATE_UNAVAILABLE |
| 栅栏不一致 | STATE_FENCED |
⑥ persist
在一个事务内写 MySQL,顺序如下:
- 以 CAS 推进
chat_conversation.state_version,同时写会话共享和活动任务。 - 写被替换掉的旧任务(任务、图、节点空间、记录)。
- 写当前任务:
task_instance(CAS)、节点、边、证据状态;断点被消费时写恢复记录并逻辑删除断点;仍有断点时写断点。 - 写本轮新增的非用户消息。
persist 成功后:
- 已结束的任务:在 Redis 中追加一条
TaskRecord,删除整个任务组,清空 activeTaskId。 - 再 flush 一次,清空
lastTraceId,标志本轮正常收尾。
persist 失败时重试一次;仍失败则记录错误日志,lastTraceId 保留,下一轮按中断处理。
⑦ 结果帧
发送 RESULT(见 SSE_EVENTS.md)。编排过程中发生异常时,会先把当前节点和任务标记为失败、追加失败回复,再发送失败结果帧。
⑧ 解锁
5. 关键机制
5.1 版本栅栏
| 环节 | 规则 |
| 推进 | 只有 renew.lua 在每轮开始时把 version 加 1 |
| Redis 校验 | 每次 flush.lua 都比较版本,不一致则拒绝写入 |
| MySQL 校验 | chat_conversation、task_instance 按 state_version < 本轮版本 条件更新,影响 0 行即 STATE_FENCED |
| Redis 失效后 | 以 MySQL 的 state_version 作为基线继续递增,版本不会回退 |
作用:同一会话如果出现过期的旧请求(例如超时后仍在执行),它的写入会被拒绝,不会覆盖新一轮的状态。
5.2 中断判定与规整
- 每轮开始时,
renew.lua 返回上一轮写入的 lastTraceId;正常收尾会把它清空。 - 返回值非空,说明上一轮没有正常收尾(进程退出、落库失败等)。
- 此时若任务处于
RUNNING,执行中断规整:
RUNNING 的节点退回 READY,可以重新执行; STARTED 的证据改为 UNKNOWN,后续按"写结果不确定"处理(见 5.5)。
5.3 断点
写入:节点挂起(追问、审批)或检测到不确定的写操作(核实)时,生成断点,任务置 SUSPENDED,焦点设为该节点。审批断点的 question 由 Runtime 拼上待执行参数(按"标签:值"格式,敏感字段打码,平台注入参数不显示)。
恢复入口规则(DefaultTurnEntryGuard):
| 请求 | 无活动断点 | 有活动断点 |
action 为 CONFIRM/REJECT/CANCEL | 回复"当前没有待确认的操作" | 动作在 allowedActions 内 → 恢复;否则回复"当前不能执行这个操作" |
action 为 REPLY | 正常处理 | 恢复(作为追问的回答) |
无 action,有文字 | 正常处理 | 交给意图判定:继续当前任务,或换任务 |
无 action,无文字 | — | 按断点类型的默认动作恢复(只有追问有默认动作 REPLY);否则回复"请先在页面上选择要执行的操作" |
| 请求的数字员工与任务不一致 | — | 换数字员工 |
审批断点挂起超过 state.reconfirm-after | — | 不直接恢复,交给意图判定重新确认 |
恢复执行:
| 断点 | 动作 | 结果 |
| FOLLOW_UP | REPLY | 唤醒节点;用户回答作为该节点的历史消息交给模型 |
| APPROVAL | CONFIRM | 校验参数摘要后执行待批写操作,再继续该节点 |
| APPROVAL | REJECT | 不执行待批写操作;节点 CANCELLED,任务 CANCELED,回复"已取消,未执行:{审批摘要}。如需换个做法,直接告诉我。" |
| VERIFY | CONFIRM | 不确定的证据改为 SUCCESS,不重复执行 |
| VERIFY | REJECT | 不确定的证据改为 FAILED,模型可以重新发起 |
| 任意 | CANCEL | 节点 CANCELLED,任务 CANCELED |
消费:节点被唤醒(或用户拒绝、取消而关闭挂起)时,断点即被消费:turn.checkpoint() 置空、任务回到 RUNNING,同时记下恢复动作。消费后的断点经 turn.resumed() 读取,确认续跑据此派发待批写操作,核实续跑据此改证据状态,追问回答据此认定参数。续跑中给模型的投影不再展示该断点。节点续跑后再次挂起时,新断点照常写入。persist 时先写一条 task_resume_record 并把旧 task_checkpoint 逻辑删除,再写入新断点(如有),避免 Redis 失效后被重新读回,也保证每次恢复都有记录。
5.4 证据与写操作去重
幂等键
| 契约 | 幂等键 |
配置了 idempotencyKey | 按路径字典序取参数值,拼成 toolName\|path=value…,取 SHA-256 |
| 未配置,或所列参数都缺失 | sha256(toolName \| argsHash) |
S2 预登记(EvidenceFlow.prewrite)
查重顺序:本节点证据 → Redis 写证据索引 → MySQL tool_call_record。
| 查到的证据 | 结果 | 说明 |
SUCCESS 且业务成功 | Reused | 不再调用工具,直接返回上次的结果 |
STARTED / UNKNOWN / UNKNOWN_PRE_CALL | Blocked | 拒绝调用,提示"进行中"或"待确认" |
| 没有,或上次失败 / 业务失败 | Proceed | 登记 STARTED 证据 → flush → 插入 tool_call_record → 调用工具 |
预登记写 MySQL 时:
- 暂时性异常 → 证据改为
UNKNOWN_PRE_CALL,本次不调用; - 其他数据库异常 → 证据改为
CANCELLED,抛出 STATE_WRITE_FAILED。
S3 结算(EvidenceFlow.settle):按工具结果更新证据状态、业务结果、空结果、抽取计数、实体;报文写入 Redis,超过 state.payload-inline-max-bytes 时另存 task_node_payload。
调用异常到证据状态的映射
| 错误 | 失败分类 | 读工具 | 写工具 |
| 超时 | TIMEOUT | TIMEOUT | UNKNOWN |
| 服务不可达 | TRANSPORT_FAILURE | FAILED | FAILED |
| IO 异常 | TRANSPORT_FAILURE | FAILED | UNKNOWN |
| 未知调用错误 | UNKNOWN | FAILED | UNKNOWN |
| MCP 工具错误 | BUSINESS_FAILURE | FAILED | FAILED |
| MCP 参数错误 | PARAMETER_FAILURE | FAILED | FAILED |
| MCP RPC 错误 | UNKNOWN | FAILED | UNKNOWN |
| 工具正常返回但业务失败 | BUSINESS_FAILURE | SUCCESS + businessOk=false | 同左 |
5.5 写结果不确定(VERIFY)
节点再次打开时,处理所有 UNKNOWN 的写证据:
- 契约为该写工具配置了
verify → 调用回查工具:回查成功记为 SUCCESS,否则 FAILED。 - 未配置回查 → 挂起
VERIFY 断点,询问用户"上次的写操作是否已完成"。
CONFIRM → SUCCESS; REJECT → FAILED,允许模型重新发起。
结论出来之前,同一个写操作不能再次调用,节点也不能完成。
5.6 委派
| 环节 | 行为 |
| 发起 | 模型调用 builtin_node_delegate(skillKey, objective)。要求:技能在可委派目录中(契约 delegatable=true,且只读);深度不超过 state.delegate-max-depth;同一发起节点下相同技能、相同目标的前置节点未曾失败 |
| 建图 | 新建 READY 的前置节点(深度 +1);增加 DELEGATE 边;发起节点依赖它,记下 awaiting,进入 WAITING_DELEGATE |
| 恢复 | 前置节点到达任意终态后,发起节点回到 READY |
| 回填 | 发起节点再次打开时:前置节点 DONE → 正常继续;前置节点失败或取消 → 写入 delegateResult,投影给模型一次 |
| 失败隔离 | 前置节点失败不会让任务失败,由发起节点决定后续处理 |
| 数据传递 | 发起节点可以通过 evidence.* 读取前置节点的证据,节点完成目标(target)也认可前置节点的成功证据 |
5.7 参数被拒后的处理
工具调用被参数校验拦下时,拦截器只返回观测"原因。下一步:…"(追问、先调指定工具或失败),不在节点上挂额外约束:
- 模型按"下一步"处理后正常继续;
- 模型无视提示再次以不合规参数调用,会被同一拦截器再次拒绝,并计入无进展次数,超过
react.max-no-progress-rounds 后节点失败。
5.8 任务收口、记录与实体
| 环节 | 行为 |
| 关闭 | 任务到终态时只修改内存中的状态;activeTaskId 在 persist 成功后才清空 |
| 记录 | persist 成功后生成 TaskRecord,写入 task_instance.record,并追加到 Redis records |
| 实体 | persist 时从本轮成功证据的 entities 和已完成节点的 entityOutputs 合并实体索引;超过 state.entities-max 时淘汰最早加入的 |
| 换任务 | 开新任务时,旧任务打包为 ClosedTask,随本轮 persist 一起落库,再删除旧任务组 |
5.9 前情
| 项 | 规则 |
| 生成时机 | 开新任务时 |
| tasks | 最近 state.prior-tasks-max 条任务记录 |
| entities | 最多 state.prior-entities-max 个实体 |
| 使用方 | 节点输入绑定(prior.<type>[.<attr>])、参数绑定、节点提示词、规划提示词 |
5.10 共享值与大对象
共享值
| 任务级 | 会话级 |
| 写入方式 | 契约 outputs.<k>.share = TASK,节点完成时写入 | 契约 outputs.<k>.share = SESSION,节点完成时写入 |
| 读取表达式 | shared.<k> | session.<k> |
| Redis | task:<tid>:shared | {c}:shared |
| MySQL | task_instance.shared | chat_conversation.session_shared |
| 生命周期 | 随任务结束删除 | 跨任务保留 |
共享值只有通过契约 share 才会写入,没有其他写入途径。
大对象
| 对象 | 阈值 | 处理 |
| 工具报文 | state.payload-inline-max-bytes | Redis 保存完整报文;MySQL 另存 task_node_payload(ARTIFACT),证据头记录 payloadId |
| 节点空间单个字段 | state.space-field-max-bytes | MySQL 另存 task_node_payload(OUTPUT),Redis 中存放溢出占位 {kind: "SPACE_SPILL", payloadId} |
| 用户消息 | state.message-max-bytes | 截断 |
报文读取顺序:本轮内存 → Redis → tool_call_record.response → task_node_payload。
5.11 节点状态迁移
| 事件 | 迁移到 | 是否停图 |
SELECTED(开始执行) | RUNNING | 否 |
LOOP_DONE | DONE | 否 |
LOOP_SUSPENDED | SUSPENDED | 是 |
LOOP_WAITING_DELEGATE | WAITING_DELEGATE | 否 |
LOOP_FAILED | FAILED | 是(委派前置节点除外) |
LOOP_CANCELLED / USER_CANCELLED / USER_REJECTED | CANCELLED | 是(委派前置节点除外) |
状态机之外的迁移:
| 场景 | 迁移 |
| 上游满足依赖 | PENDING → READY |
| 用户回应断点 | SUSPENDED → READY |
| 前置节点到达终态 | WAITING_DELEGATE → READY |
| 中断规整 | RUNNING → READY |
| 所需共享值已存在 | → SKIPPED |
| 检测到不确定写操作且需用户核实 | → SUSPENDED |
| 本轮异常 | 当前节点和运行中的节点 → FAILED |
调度规则:每次只执行一个可调度节点。
6. 配置项
前缀 g2rain.agent.runtime。
6.1 state
| 配置 | 默认 | 作用 |
state.conversation-ttl | 7d | 会话 state、会话共享的 TTL |
state.task-ttl-running | 24h | 任务进行中的任务组 TTL |
state.task-ttl-suspended | 2h | 任务挂起时的任务组 TTL |
state.sentinel-gap | 10m | 续期时哨兵比同组 key 短的时长 |
state.payload-inline-max-bytes | 524288 | 报文超过即另存 MySQL |
state.payload-done-ttl | 1h | 节点完成后报文的 TTL;0 表示立即删除 |
state.evidence-entities-max-chars | 2048 | 证据实体 JSON 的长度上限 |
state.state-size-log-enabled | true | 是否记录状态大小日志 |
state.space-field-max-bytes | 65536 | 节点空间字段溢出阈值 |
state.message-max-bytes | 4096 | 用户消息截断长度 |
state.dialogue-max | 60 | 对话窗口条数 |
state.records-max | 20 | 任务记录条数 |
state.entities-max | 200 | 实体索引上限 |
state.prior-tasks-max | 5 | 前情任务条数 |
state.prior-entities-max | 10 | 前情实体个数 |
state.flush-max-bytes | 1048576 | 单次 flush 改动上限 |
state.reconfirm-after | 24h | 审批断点过期时长 |
state.delegate-max-depth | 2 | 委派最大深度 |
state.write-confirm-bypass-tools | 空 | 免审批的写工具名单 |
6.2 react
| 配置 | 默认 | 作用 |
react.max-rounds | 32 | 单节点最大轮数 |
react.max-no-progress-rounds | 3 | 连续无进展上限 |
react.brief-max-chars | 200 | 参数、结果摘要长度 |
7. 错误码
| 错误码 | 名称 | 含义 |
agent_runtime.40310 | CONVERSATION_FORBIDDEN | 无权访问该会话 |
agent_runtime.40911 | STATE_FENCED | 栅栏冲突,本轮写入被拒 |
agent_runtime.50010 | STATE_WRITE_FAILED | 状态写入失败 |
agent_runtime.50011 | STATE_UNAVAILABLE | 状态存储不可用 |
agent_runtime.40912 | APPROVAL_EXPIRED | 审批已过期 |
agent_runtime.40913 | ARGS_HASH_MISMATCH | 待批参数与确认时不一致 |
agent_runtime.40030 | WRITE_PENDING_VERIFY | 写操作结果待确认,禁止重复调用 |
agent_runtime.40031 | WRITE_IN_FLIGHT | 写操作进行中,禁止重复调用 |
agent_runtime.42237 | DELEGATE_FORBIDDEN | 禁止委派(不在目录内或超过深度) |
agent_runtime.42238 | DELEGATE_BROKEN | 委派关系损坏 |
agent_runtime.42239 | DELEGATE_REPEATED | 重复委派已失败的同一目标 |
以上错误码用于服务端日志、节点错误信息和给模型的观测,客户端只看到统一的失败文案(见 SSE_EVENTS.md)。
8. 实现约束与注意事项
以下是当前实现的行为边界,扩展或排查问题时需要注意:
| # | 约束 |
| 1 | Redis 中 dialogue、records、entities 三个 key 不设置 TTL;entities 没有 MySQL 回灌 |
| 2 | 任务组回灌按 key 逐个进行:Redis 中缺哪个 key 就从 MySQL 读哪个,不做整组替换 |
| 3 | 每次 flush 都整体重写本轮涉及的状态,而不是只写变化的字段 |
| 4 | MySQL 各表没有业务唯一索引,写入采用"先查后写" |
| 5 | 任务图每次只调度一个节点 |
| 6 | 节点空间的 tokens 当前不统计 |
| 7 | TaskRecord.artifacts、Prior.artifacts 当前为空 |
| 8 | persist 两次失败后不抛出异常,客户端仍会收到结果帧;状态在下一轮按中断规整 |