跳到正文

架构评审:任务状态、等待与预算闭环 ​

一句话:平台能不能落地,不看组件是否齐全,而看一次任务从进入到终止的每个等待点是否可解释、每份预算是否有边界、每次“完成”是否有证据;状态不显式,资源就释放不了,界面就说不清,观测就分不清慢在哪。

为什么需要 ​

前二十一篇把组件和接缝基本铺齐了,但组件图完整不等于闭环。评审时最常被问住的不是“有没有这个组件”,而是四个更具体的问题:

  • 这个任务现在到底在干什么?是在排队、在等审批、在等工具返回,还是已经卡死?
  • 等审批的这两个小时,沙箱、租约、配额还占着吗?能不能先释放,批完再恢复?
  • 预算烧完了会怎样?是悄悄截断、给一个半成品,还是明确失败并列出没做完的部分?
  • 模型说“已完成”,凭什么算完成?谁验收、拿什么验收、验收记录在哪?

这四个问题指向同一条主线:任务状态不能由消息历史推断,必须维护一份可机读的权威状态。消息只是输入输出,历史只适合回放;真正的状态要显式记录目标、阶段、计划、已确认事实、预算、等待原因与完成依据。状态显式化以后,运行层才敢在等待时释放资源,界面才能说明在等什么,观测才能区分是执行慢、审批慢还是工具慢。

预算也不是事后统计,而是外部终止边界:在每一步之前预留,在每一步之后扣减,耗尽时给出明确终态和未完成清单。没有这条边界,长任务会无限烧钱,短任务会被静默截断,账单和体验两头失控。

这篇按“评审方法 → 四块契约 → 场景走线 → 代码级落地 → 组件改造 → 验收用例”组织。读完之后,应该能直接建表、加接口、改内核钩子、补指标,而不是只知道几个原则。

核心机制 ​

一、评审方法:拿一张状态卡走线 ​

评审不按组件清单逐项打勾,而按一次真实任务走线。每到一个位置,问八个问题:

#问题答不上来的含义
1谁触发,带什么身份与幂等键入口不可归属,重试会重复执行
2当前状态的权威记录在哪,谁写状态在进程内存或消息里,重启即丢
3现在处于哪个阶段,谁能推进状态机不完整,卡住后无人能判断
4在等什么,等待原因能否机读界面只能显示“处理中”,告警无法归因
5等待期间占用了什么,何时释放审批两小时,沙箱也占两小时
6预算有哪些维度,谁预留、谁检查预算只统计不拦截,耗尽后仍在调用
7完成的证据是什么,谁验收模型说完成就算完成,无法追责
8失败/取消/超时后的终态与未完成清单用户只看到“失败”,不知道哪些已生效

走线的终点不是“请求返回 200”,而是任务进入一个明确终态,且该终态有证据、有负责人、有后续动作。任何一跳答不上来,这条链路就不算闭环。

二、权威任务状态:一张可机读的记录 ​

任务状态建议单独存一张表,不要从消息、日志或模型上下文里反推。消息是投影,事件日志是历史,当前状态是物化视图。

字段作用缺失后果
task_id全局唯一,幂等与追踪重复执行、无法对账
tenant / session_id / turn_id归属、隔离与上下文计费串租户、审计无法归属
parent_task_id子任务聚合与预算分层多 Agent 任务无法汇总
goal目标文本 + 结构化成功判据“做完了”没有判定标准
phase当前阶段枚举只能靠猜,卡住无法定位
plan步骤清单与每步状态不知道进行到哪一步
confirmed_facts已确认事实及来源、时间、置信度模型假设被当成事实反复使用
budget多维预算的限额、已用、预留耗尽后仍继续执行
wait_reason等待类型、关联 ID、开始时间、SLA界面与告警都说不清在等什么
completion_evidence产物、测试、引用、审批、回执“完成”无法验证
lease_owner / lease_epoch / version单写者、栅栏与乐观锁双写、旧副本覆盖新状态
terminal_reason / unfinished_items终态原因与未完成清单用户不知道哪些已生效、能否续跑
trace_id串起模型、工具、审批与交付事故无法复盘

状态机建议至少覆盖这些阶段:

这张图的关键不是阶段多,而是四条分叉:从执行进入等待、从等待回到执行、从执行进入终态、完成后进入验收。每条分叉都要有触发事件、负责写入的组件和超时动作;少一条,任务就会卡在某个“看起来还在跑”的状态里。

三条写规则:

  1. 单写者:只有持有租约的执行者能改 phase、plan、budget;旧 epoch 的写回一律拒绝;
  2. 状态与事件同事务:每次状态变更同时写事件日志与 outbox,避免“状态变了、消息没发”;
  3. 状态不存长文本:大输入、工具结果、产物放对象存储,状态里只放引用与校验和。

一个够用的状态记录大致长这样:

json
{
  "task_id": "task-8842-03",
  "tenant": "tenant-a",
  "session_id": "s-8842",
  "phase": "waiting_approval",
  "goal": {"text": "生成周报并发给本人", "success": ["artifact:weekly-report", "delivery:im"]},
  "plan": [
    {"step": "query_data", "status": "done"},
    {"step": "render_report", "status": "done"},
    {"step": "send_im", "status": "waiting"}
  ],
  "confirmed_facts": [{"fact": "统计口径=自然周", "source": "user", "at": "2026-10-24T09:00:00Z"}],
  "budget": {
    "tokens": {"limit": 120000, "used": 38200, "reserved": 0},
    "tool_calls": {"limit": 20, "used": 6, "reserved": 0},
    "wall_clock_ms": {"limit": 600000, "used": 120000}
  },
  "wait_reason": {"type": "approval", "id": "approval-778", "since": "2026-10-24T09:02:00Z", "sla_ms": 3600000},
  "completion_evidence": [],
  "lease_owner": "worker-7",
  "lease_epoch": 12,
  "version": 41,
  "trace_id": "00-4bf92f...-01"
}

三、等待是一等状态:等什么、占什么、何时放 ​

把“等待”压成 running 是资源浪费和排障困难的根源。等待至少要分成这些类型,每类有自己的占用与释放策略:

等待类型典型场景等待期间占用可释放什么恢复触发超时动作
排队热池空、并发满配额预留、队列位置未创建的沙箱不占有容量/位次前移429/202 + 可重试时间
审批危险命令、外发消息、权限提升审批工单、可能的沙箱默认快照后释放沙箱;若审批对象必须活在进程里,保留短租约审批结论超 SLA 升级或过期
用户输入缺参数、需确认任务记录、可选沙箱快照后释放用户补充过期并给未完成清单
工具执行长查询、构建、爬取工具连接、可能的沙箱不需要工作区的工具可释放沙箱工具返回/心跳取消工具并标记
外部回调Webhook、人工处理、第三方回调登记、幂等键释放沙箱与模型连接回调到达TTL + 人工接管
人工验收结果复核、合规确认产物、验收工单释放全部计算资源接受/退回超时提醒,不自动通过
模型限流429、配额窗口重试预算释放沙箱Retry-After退避/换候选/失败

三条硬规则:

  1. wait_reason 必须机读:类型、关联 ID、开始时间、SLA 四件套齐全;界面文案、告警路由、Runbook 都从这里取,不要各写一套判断。
  2. 等待默认释放计算资源:审批、输入、外部回调、人工验收都不需要沙箱持续运行;先快照再释放,恢复时按任务 ID 水合。只有“审批对象必须活在当前进程”时才保留短租约,并在状态里标 resumable=false。
  3. 恢复靠任务 ID,不靠进程存活:客户端重连、Pod 漂移、审批隔夜,都从权威状态恢复;进程只是执行器,不是状态容器。

四、预算:外部终止边界,不是事后统计 ​

预算至少覆盖八个维度,每个维度都要有计量点、预留方式与耗尽动作:

维度计量点预留方式耗尽动作
Token网关计量每次模型调用前预留上限停止新调用,给部分结果与未完成清单
成本网关计量按价格版本折算后预留同上;交互式可询问是否追加
工具调用次数Toolkit每次调用前原子加一停止工具阶段,标记未完成
墙钟时间Scheduler/任务记录deadline_atexpired 终态,保留检查点
步数/轮次内核循环max_steps停止循环,输出当前计划状态
重试次数任务账本attempt 上限死信/人工队列,不再自动重投
沙箱时长Sandbox Manager租约与 TTL快照后停止,恢复需重新准入
并发槽位配额服务原子预留 + 租约排队或 429,不超卖

预算的关键不是“记了多少钱”,而是在每一步边界上检查并拦截。推荐做法:

  • 任务创建时写入总预算;每次模型/工具调用前 reserve,成功后 consume,失败 release;
  • 检查点放在“一步开始前”和“一步结束后”,不要只在任务结束时汇总;
  • 预算耗尽产生 expired 终态,附 unfinished_items、已完成产物、可续跑检查点;
  • 交互式任务可以询问用户“追加预算/接受部分结果”,批处理任务直接失败并告警,定时任务标记失败并通知 owner。

还要区分三个容易混的概念:deadline 是外部墙钟边界,budget 是资源边界,timeout 是单次操作边界。deadline 到了不一定预算耗尽,预算耗尽也不一定到 deadline;三者各自触发不同的终态与通知。

五、完成:证据与验收,不是模型说“好了” ​

“完成”至少分四层,不能压成一个布尔值:

层级含义证据谁确认
executed执行器认为步骤跑完步骤状态、工具返回执行器
verified结果通过自动检查测试、Schema 校验、引用可解析验证器
accepted业务 owner 认可结果验收记录、审批意见人/业务系统
delivered结果已送达目标渠道消息 ID、下载回执、卡片状态渠道适配器

completion_evidence 至少能放五类证据:产物 ID 与 checksum、测试/校验结果、引用来源、审批记录、交付回执。没有证据的“完成”只能标 completed_unverified,不能直接进入 accepted。

同时把操作审批与结果验收分开:审批回答“这个动作能不能做”,验收回答“这个结果算不算完成”。把两者合成一个按钮,会出现“批准了发送”被误读为“接受了内容”的经典事故。

失败、取消、超时也必须给 unfinished_items:哪些步骤没做、哪些副作用已发生且不可回滚、哪些产物可复用、从哪里可以续跑。没有这份清单,用户只能看到一个红色状态,运维只能从头猜。

六、五条场景走线 ​

S1 个人助理生成周报并发到 IM ​

入口是浏览器 Cookie,经 Higress 认证、Hub Router 按 subject 路由到个人实例;任务状态在 PostgreSQL,沙箱按会话创建。

阶段权威状态等待原因占用资源预算检查证据
接收queued无配额预留写入总预算消息 ID、幂等键
规划planning无沙箱启动中无计划步骤
查数据waiting_tooltool_call_id沙箱(工具需要工作区)工具调用次数查询结果引用
生成报告executing无沙箱、模型连接Token/成本产物 ID
等待外发审批waiting_approvalapproval_id默认快照后释放沙箱无审批记录
交付 IMexecuting无产出物服务、渠道适配器交付预算消息 ID/回执
完成completed无无最终结算产物 + 回执
用户验收accepted无无无验收记录

闭环检查:审批两小时不占沙箱;审批通过后按 task_id 恢复;IM 超时先查回执再重试;重复投递用 artifact_id + channel + recipient 幂等;预算耗尽给部分报告与未完成清单。

S2 每天 8 点的定时报表 ​

触发层每次创建一个新的 Agent 实例与唯一 Session(如 daily-report-20261024),任务状态先写 queued,再由 Outbox 投递。数据未到齐时进入 waiting_external,释放计算资源;涉及敏感数据外发时进入 waiting_approval,审批通过后继续。

验收看四件事:重复触发只产生一个任务;错过触发按 misfire 策略补跑或明确跳过;预算耗尽时保留已完成部分并通知 owner;账本完整,通道 TTL 到期不影响任务状态。

S3 编码长任务:危险命令审批与沙箱恢复 ​

Runtime Host 启动编码 Harness,任务状态记录 phase=executing、plan 与 budget。遇到危险命令进入 waiting_approval:如果审批对象是“即将执行的命令”,沙箱保留短租约并心跳续租;如果只是“最终 diff 是否合并”,先快照并释放沙箱。

长构建进入 waiting_tool,用工具心跳区分“慢”与“卡死”;超时就取消工具并标记该步骤失败。完成证据是 diff、测试结果与人工 Review,不是 Harness 的最后一句总结。取消时停止新步骤、等待在途工具、标记不可回滚副作用,再给出未完成清单。

S4 Skill 从草稿到上线 ​

这条链路虽然不是 Agent 任务,但状态模型完全适用:draft → reviewing → online → offline,wait_reason=review,审核期间不占运行时资源;完成证据是 scanner 报告、人工审批与 SHA-256;上线版本不可变,回滚只切 label。运行时拉取失败时用缓存并告警,不能静默降级到旧版本。

S5 员工离职与租户下线 ​

这是一条典型的“长等待 + 多资源回收”流程:pending_revocation 状态下,等待数据导出或交接确认;期间要列出并处理会话、个人 Key、下载 ticket、定时任务、Hosted Runtime、沙箱、工作区、快照、记忆与渠道绑定。完成证据是审计记录与资源清单,未完成项要明确标出“无法自动回收,需人工处理”。

七、委员会争议点 ​

争议结论理由
状态放事件日志还是单独表事件日志是权威历史,当前状态是物化视图,两者同事务写只读事件回放开销大;只读状态表丢历史
审批等待时能不能释放沙箱默认可以,先快照再释放;只有审批对象必须活在进程里才保留短租约审批可能隔夜,占着沙箱是纯浪费;短租约要标 resumable
预算应该记在哪任务记录负责预留,网关负责计费权威,内核自报只用于排障三处口径不同,混用会对不上账
模型说完成但没证据标 completed_unverified,不能进 accepted完成是证据与验收,不是自然语言声明
取消要不要杀进程协作式取消:停新步骤、等在途工具、标记不可回滚副作用,再决定是否杀直接杀会留下半写文件与未知副作用
多 Agent 预算怎么分父任务持有总预算,子任务分配子预算,父任务等待子任务并聚合证据共享一个计数器会互相偷预算,无法归因
状态会不会爆炸phase 用固定枚举,wait_reason 用“类型 + 明细”,不要为每个工具加阶段状态机可维护,扩展放在明细里
怎么证明真的闭环做故障演练:审批中杀 Worker、工具执行中耗尽预算、重复投递、快照失败、取消时工具仍在跑正常路径谁都能跑通,故障路径才暴露状态缺口

八、实现契约:最小表结构 ​

下面是建议的最小 schema,表名可换,字段语义不要省。任务状态放运行面 schema(示例 rt),控制面只读引用:

sql
CREATE TABLE rt.agent_task (
  task_id             text PRIMARY KEY,
  tenant_id           text NOT NULL,
  session_id          text NOT NULL,
  turn_id             text,
  parent_task_id      text,
  agent_key           text NOT NULL,
  agent_version       text NOT NULL,
  goal                jsonb NOT NULL,
  phase               text NOT NULL,
  plan                jsonb NOT NULL DEFAULT '[]',
  confirmed_facts     jsonb NOT NULL DEFAULT '[]',
  budget              jsonb NOT NULL,
  wait_reason         jsonb,
  completion_evidence jsonb NOT NULL DEFAULT '[]',
  terminal_reason     text,
  unfinished_items    jsonb,
  lease_owner         text,
  lease_epoch         bigint NOT NULL DEFAULT 0,
  lease_expires_at    timestamptz,
  version             bigint NOT NULL DEFAULT 1,
  idempotency_key     text,
  trace_id            text,
  deadline_at         timestamptz,
  created_at          timestamptz NOT NULL DEFAULT now(),
  updated_at          timestamptz NOT NULL DEFAULT now(),
  CHECK (phase IN ('queued','planning','executing','waiting_approval',
                   'waiting_input','waiting_tool','waiting_external',
                   'completed','notified','failed','cancelled','expired')),
  CHECK (jsonb_typeof(goal) = 'object')
);

CREATE UNIQUE INDEX ux_agent_task_idem
  ON rt.agent_task (tenant_id, idempotency_key)
  WHERE idempotency_key IS NOT NULL;

CREATE INDEX ix_agent_task_phase
  ON rt.agent_task (tenant_id, phase, updated_at);

CREATE INDEX ix_agent_task_lease
  ON rt.agent_task (lease_expires_at)
  WHERE phase NOT IN ('completed','failed','cancelled','expired');

事件日志只追加,seq 按 task_id 单调递增;它是 SSE 续传与审计的共同来源:

sql
CREATE TABLE rt.agent_task_event (
  task_id     text NOT NULL,
  seq         bigint NOT NULL,
  event_type  text NOT NULL,
  from_phase  text,
  to_phase    text,
  payload     jsonb NOT NULL,
  actor       text NOT NULL,
  trace_id    text,
  created_at  timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (task_id, seq)
);

预算用“计数器 + 流水”两张表:计数器负责原子预留,流水负责对账:

sql
CREATE TABLE rt.agent_task_budget (
  task_id        text NOT NULL,
  dimension      text NOT NULL,
  limit_value    numeric NOT NULL,
  used_value     numeric NOT NULL DEFAULT 0,
  reserved_value numeric NOT NULL DEFAULT 0,
  PRIMARY KEY (task_id, dimension)
);

CREATE TABLE rt.agent_task_budget_ledger (
  entry_id   bigserial PRIMARY KEY,
  task_id    text NOT NULL,
  dimension  text NOT NULL,
  kind       text NOT NULL CHECK (kind IN ('reserve','consume','release')),
  amount     numeric NOT NULL,
  ref        text,
  created_at timestamptz NOT NULL DEFAULT now()
);

审批是独立工作项,不阻塞任务表:

sql
CREATE TABLE rt.approval (
  approval_id  text PRIMARY KEY,
  task_id      text NOT NULL,
  tenant_id    text NOT NULL,
  kind         text NOT NULL,
  request      jsonb NOT NULL,
  status       text NOT NULL CHECK (status IN ('pending','approved','rejected','expired')),
  decided_by   text,
  decided_at   timestamptz,
  sla_deadline timestamptz NOT NULL,
  created_at   timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX ix_approval_pending ON rt.approval (tenant_id, status, sla_deadline);

Outbox 与状态同事务写,Relay 异步投递:

sql
CREATE TABLE rt.outbox (
  id           bigserial PRIMARY KEY,
  aggregate_id text NOT NULL,
  event_type   text NOT NULL,
  payload      jsonb NOT NULL,
  published_at timestamptz,
  created_at   timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX ix_outbox_unpublished ON rt.outbox (id) WHERE published_at IS NULL;

工程约束:

  • 事件表按月分区,保留期按审计要求;任务表按 tenant_id 或时间分区,避免单表无限增长;
  • budget 同时保留 JSON 快照与计数器,JSON 用于展示,计数器用于原子判断;
  • 所有时间用 UTC,RFC3339;所有 ID 用不可猜的 ULID/UUID,不用自增主键暴露业务量;
  • phase 的迁移只允许代码中的白名单,不允许 SQL 直接改。

本篇给的是单任务闭环的最小充分 schema。多 Agent 协作与严格预算还要补三组对象:rt.task_run(一次执行尝试,带 attempt 与 lease_epoch)、gov.budget_grant / budget_lease / budget_ledger(授权、租约、流水)、rt.evidence(planned / issued / confirmed 三态)。字段、原子 SQL、停止条件与验收见 24 §四.4.4–§四.4.6;它们是对本篇的扩展,不改变 rt.agent_task 作为任务权威。

九、状态迁移的代码骨架 ​

状态迁移必须是“比较版本 + 更新 + 写事件 + 写 outbox”的同一事务:

python
def transition(task_id, expected_version, event, to_phase, patch, actor):
    with db.transaction():
        row = db.select_for_update("agent_task", task_id)
        if row.version != expected_version:
            raise Conflict("stale task version")
        if not allowed(row.phase, event, to_phase):
            raise InvalidTransition(f"{row.phase} -/-> {to_phase}")
        if row.lease_owner != actor or row.lease_epoch != patch["lease_epoch"]:
            raise Fenced("lease lost")

        new_version = row.version + 1
        db.update("agent_task", task_id,
                  phase=to_phase, version=new_version,
                  wait_reason=patch.get("wait_reason"),
                  terminal_reason=patch.get("terminal_reason"),
                  unfinished_items=patch.get("unfinished_items"),
                  updated_at=now())
        seq = db.next_seq(task_id)
        db.insert("agent_task_event", task_id=task_id, seq=seq,
                  event_type=event, from_phase=row.phase, to_phase=to_phase,
                  payload=patch, actor=actor)
        db.insert("outbox", aggregate_id=task_id,
                  event_type=f"task.{event}", payload=patch)
        return new_version

创建要幂等:先按 (tenant_id, idempotency_key) 查,命中且请求体一致就返回原任务;不一致返回 409。不要用“先查再插”的两段式,直接依赖唯一索引并捕获冲突。

租约要满足三条:

  • lease_epoch 每次接管加一,旧 epoch 的更新条件里必须带 AND lease_epoch = :epoch;
  • 执行者每 10–15 秒续租,续租失败立即停止新步骤;
  • 扫描器只回收“租约过期且未在终态”的任务,回收动作本身也走状态迁移,不直接改行。

取消是协作式的:

text
1. 状态迁移到 cancelled(或 waiting_* -> cancelled),写 terminal_reason;
2. 停止调度新步骤;向在途工具发 cancel,等待其返回或超时;
3. 标记不可回滚副作用(已发消息、已写外部系统、已扣款);
4. 写 unfinished_items 与可复用产物;
5. 释放租约与沙箱;不删除事件与产物。

预算耗尽是终态迁移,不是异常抛出后不了了之:

text
1. 当前步骤结束(或工具取消)后,发现 used + reserved >= limit;
2. 同一事务写 phase=expired、terminal_reason=budget_exhausted、
   unfinished_items=[未开始/未完成的步骤]、completion_evidence=[已完成产物];
3. 写 outbox,通知 owner 与界面;
4. 释放沙箱、租约与配额;保留检查点,允许“追加预算后续跑”。

十、API 契约 ​

任务 API 建议放在 Service Gateway 之后、由控制面提供,所有写接口都要 Idempotency-Key:

方法与路径作用关键请求成功响应主要错误
POST /v1/tasks创建任务session_id、agent_key、goal、budget、deadline_at202 {task_id, phase, version}409 幂等冲突、422 参数
GET /v1/tasks/{id}读权威状态—200 {phase, plan, budget, wait_reason, evidence, version}404
GET /v1/tasks/{id}/events?after_seq=拉事件after_seq200 {events, next_seq}410 游标过期
GET /v1/tasks/{id}/streamSSE 订阅Last-Event-IDtext/event-stream410 需重读快照
POST /v1/tasks/{id}:cancel取消reason202 {phase}409 已终态
POST /v1/tasks/{id}:resume内部恢复lease_epoch、checkpoint_ref202 {phase}409 租约失效
POST /v1/tasks/{id}:accept验收accepted、comment、evidence_refs200 {phase}409 证据不足
POST /v1/approvals/{id}:decide审批decision、comment200 {status}409 已决、410 过期

错误语义要稳定,客户端才能正确重试:

  • 409 Conflict:版本冲突、幂等键同键不同体、非法状态迁移;不要重试,先读状态;
  • 410 Gone:SSE 游标或下载 ticket 过期;重读快照或重新申请;
  • 422 Unprocessable:预算/参数/计划不可执行;修正后新建任务;
  • 429 Too Many Requests:配额或限流;按 Retry-After 退避;
  • 503 Service Unavailable:依赖暂不可用;可重试,但必须带同一幂等键。

SSE 事件至少包含:task.state.changed、task.wait.changed、task.budget.threshold、task.completed、task.failed。事件带 id=task_id:seq、event、data,data 里带 version,客户端据此做去重与乱序保护。

十一、组件改造清单 ​

这张表回答“哪个组件要配合改什么”。原则是:状态权威在任务账本,其他组件只做投影、执行或计量。

组件必须改造新增契约 / 表关键配置验收
Service Gateway任务 API、认证、幂等键透传POST/GET /v1/tasks*、Idempotency-Key请求体上限、超时、429同键重试只建一个任务
aistiod / Control任务目录、租约管理、恢复入口TaskStateStore、LeaseManager租约 TTL、扫描间隔控制面重启后任务可恢复
Scheduler取租约、续租、过期扫描、预算扫描LeaseScanner、DeadlineScanner10–15s 心跳、SLA过期任务只被回收一次
Dataplane从事件日志转 SSE,支持 as_ofTaskEventStream游标保留窗口断线续传不丢不重
AgentScope Java 内核TaskContext、BudgetGuard、WaitReason、Evidence 钩子内核 SPI 扩展点步边界检查每步都能读到预算与等待原因
Toolkit工具调用前审批与预算预留ToolCallGuard、tool_call_id默认策略、超时超预算不发起调用
Memory / Workspace已确认事实持久化、快照指针ConfirmedFactStore、snapshot_ref作用域、TTL恢复后事实不丢
Sandbox ManagerSuspend(task_id, spec) / Resume(task_id)快照指针 CAS、租约释放快照 TTL、恢复超时审批等待不占沙箱
Runtime Scheduler SPI任务注册、租约、misfireTaskScheduler 适配Quartz/XXL-Job 参数重复触发只执行一次
RocketMQ / LiteTopic事件类型、按会话有序、幂等消费task.* 事件、消费位点TTL、重投上限重投不重复执行
PostgreSQL任务、事件、预算、审批、Outbox上述六张表与索引分区、保留期、HA状态与事件一致
Redis只做租约缓存、限流,不做权威租约 key、限流 keyTTL 小于 DB 租约Redis 丢失不影响正确性
Nacos下发预算/超时/等待策略task-policy 配置灰度、版本配置变更可回滚
HiMarket / Console任务时间线、等待原因、审批箱、预算展示读任务 API文案映射用户能看懂在等什么
Higress / Hub Router透传 X-Task-Id 与 traceparentHeader 约定超时、重试不吞任务 ID
OTel Collectortask.wait、task.budget.check span属性规范采样率能按等待类型拆分
审计存储终态、审批、验收、证据引用审计事件 schema不可篡改、保留期能还原一次任务全过程
产出物服务证据引用、checksum、下载 ticketartifact_id、sha256TTL、DLP完成证据可校验
渠道适配器交付回执写回 completion_evidencesend/update/receipt渠道能力表重试不重复发送
Vault审批后短时凭据注入凭据租约TTL、作用域审批前拿不到凭据
K8s沙箱标签、网络策略、配额task_id label、ResourceQuota命名空间、限额能按任务归因资源

改造顺序建议:先 PostgreSQL 表与状态迁移,再内核钩子与预算,再沙箱 Suspend/Resume,最后接 SSE、控制台与告警。不要先做 UI,否则界面只能展示一个假的“处理中”。

十二、等待与恢复的实现 ​

等待策略配置化,避免把“审批是否释放沙箱”写死在代码里:

yaml
wait_policies:
  approval:
    release_compute: true
    snapshot: true
    sla: 1h
    on_timeout: expire
    resumable: true
  tool:
    release_compute: false
    heartbeat: 15s
    timeout: 10m
    on_timeout: cancel_tool
  input:
    release_compute: true
    snapshot: true
    sla: 24h
    on_timeout: expire
  external:
    release_compute: true
    sla: 30m
    on_timeout: escalate

suspend(task_id, wait_reason) 的顺序不能反:

text
1. CAS 迁移到 waiting_*,写 wait_reason(类型、ID、since、sla);
2. 若策略要求快照:调用 SandboxManager.Snapshot,拿到 snapshot_ref;
3. 若策略允许释放:调用 SandboxManager.Release,释放租约与配额;
4. 写事件与 outbox;返回 202。

resume(task_id) 的顺序同样不能反:

text
1. 校验任务仍在 waiting_*,且 wait_reason 已解除;
2. CAS 迁移到 executing,lease_epoch + 1,抢新租约;
3. 若有 snapshot_ref:SandboxManager.Resume;否则冷启动;
4. 从 plan 的 step_index、budget 计数器、confirmed_facts 水合;
5. 写事件;从断点继续,不重跑已完成步骤。

三个容易踩的边界:

  • 审批对象必须活在进程里:例如“批准后继续当前交互式命令”。此时不能释放沙箱,只能保留短租约并持续心跳;状态里标 resumable=false,租约过期就明确失败,不能假装能恢复。
  • 取消发生在等待中:直接把 waiting_* 迁到 cancelled,撤销审批单、释放快照与配额;不要等审批人先处理。
  • 恢复时快照读失败:明确 failed,terminal_reason=snapshot_unavailable;不要静默冷启动,否则用户会拿到一个空工作区。

十三、预算算法 ​

预算判断必须在数据库里原子完成,不能“先查再扣”:

sql
-- reserve:影响行数为 0 表示预算不足
UPDATE rt.agent_task_budget
SET reserved_value = reserved_value + :amount
WHERE task_id = :task_id
  AND dimension = :dimension
  AND used_value + reserved_value + :amount <= limit_value;

-- 成功后再写 ledger(kind='reserve');失败返回 429/预算耗尽

消费与释放:

sql
-- consume:预留转已用,actual 可能小于预留
UPDATE rt.agent_task_budget
SET reserved_value = reserved_value - :reserved,
    used_value = used_value + :actual
WHERE task_id = :task_id AND dimension = :dimension;

-- release:未使用的预留退回
UPDATE rt.agent_task_budget
SET reserved_value = reserved_value - :reserved
WHERE task_id = :task_id AND dimension = :dimension;

步边界检查顺序固定为:租约有效 → deadline 未到 → 预算可预留 → 审批已通过。任何一项失败都走终态或等待,不进入下一步。

按任务类型的耗尽策略:

任务类型预算耗尽时通知是否可续跑
交互式对话给部分结果,询问追加或缩小范围界面提示可,追加后从检查点继续
批处理失败,保留已完成分片与未完成清单告警 + 报告可,按分片续跑
定时任务标记失败,保留部分产物通知 owner可,下次触发重新评估
高风险操作直接终止,不自动追加安全告警需重新审批

十四、可观测契约 ​

Trace 至少补三类 span,否则仍然分不清慢在哪:

Span关键属性回答的问题
task.transitiontask.id、from、to、reason、version谁在什么时候推进了状态
task.waitwait.type、wait.id、wait.sla_ms、wait.duration_ms等的是审批、工具还是输入
task.budget.checkbudget.dimension、used、limit、action预算在哪一步被拦截

指标至少这些,标签只放低基数:

指标类型标签告警
agent_task_phaseGaugephase某阶段堆积
agent_task_wait_secondsHistogramwait_type审批 P99 超 SLA
agent_task_budget_exhausted_totalCounterdimension、task_type突增
agent_task_terminal_totalCounterreason失败/过期比例
agent_task_resume_totalCounterresult恢复失败
agent_task_lease_expired_totalCounterowner异常续租

界面文案直接从 wait_reason 映射,不要在前端拼字符串:

wait_reason.type文案用户动作
approval等待审批:去审批 / 取消
input需要补充:补充信息
tool正在执行:等待 / 取消
external等待外部系统:查看详情
queue排队中,前面还有 {n} 个等待
acceptance等待验收:接受 / 退回

告警路由也按类型走:审批慢发给流程 owner,工具慢发给工具 owner,模型限流发给平台值班,预算耗尽发给租户/业务 owner。全发到一个群,等于没有归因。

十五、验收用例 ​

下面十条能跑通,才算“代码级可落地”:

#场景预期
T1同一 Idempotency-Key 创建两次只产生一个任务,第二次返回同一 task_id
T2审批等待中杀 Worker任务仍在 waiting_approval;审批后能在新 Worker 恢复
T3工具执行中预算耗尽工具被取消或完成,任务转 expired,unfinished_items 含该步骤
T4同一产物重复交付渠道只收到一次,回执幂等
T5取消时工具仍在跑不再调度新步骤;在途工具被取消或标记;副作用清单完整
T6快照失败明确 failed,不静默冷启动
T7租约过期后旧 Worker 写回旧 epoch 被拒绝,新 Worker 接管
T8SSE 断线后用 Last-Event-ID 重连事件不丢不重,seq 连续
T9审批超 SLA任务转 expired,审批单关闭,通知 owner
T10父任务带两个子任务父任务等待子任务,预算与证据可聚合

故障演练至少每月一次,重点不是“跑通正常路径”,而是“杀掉一个组件后状态是否仍然可解释”。

十六、评审改进完成对照 ​

本轮已把评审结论落回篇目;代码实现属于工程侧,文中给到可直接照做的表、接口与算法。

原缺口已落到具体改动验收
任务状态字段不全本篇 §二、§八;07增加 task_state 字段组、状态机与六张表T2/T7
等待期间资源不释放本篇 §三、§十二;21增加等待分类、WaitPolicy、Suspend/ResumeT2/T9
预算只统计不拦截本篇 §四、§十三;10增加预留/扣减/释放与 expired 终态T3
完成缺证据与验收本篇 §五;18增加四级完成、completion_evidence、acceptedT4
指标未按等待拆分本篇 §十四;14增加 task.wait、task.budget.check 与等待指标告警可按类型路由
任务表缺等待/证据列20 §五补“等待原因、完成证据、终态”三列评审可逐行对照
沙箱释放规则不清21 §九补审批等待的释放/保留短租约规则T2/T6
术语与写作口径不一23-术语与写作约定统一状态、等待、预算、证据的定义全栏一致

十七、异步受理与通知回执(G12) ​

异步场景(发起即走、完成通知)在现有状态机上只缺两处:受理语义与通知回执状态。

  1. 受理即返回:提交任务(带 Idempotency-Key)→ 校验(同键同体幂等返回原 task;同键不同体 409)→ phase=queued → 立即返回 {task_id, accepted_at, events_url},不等待执行;
  2. phase 增加 notified:completed 后通知渠道(18 篇产出物交付)→ 发送成功(带回执)→ completed → notified;notified → accepted(owner 验收)。通知未成功则停留 completed(可重发,重发幂等,同一产物只收一次——T4 扩展);
  3. 表结构变更:rt.agent_task.phase CHECK 增加 'notified'(§八 已同步);通知回执存 completion_evidence(type=delivery_receipt, id=渠道回执);
  4. 事件:submitted/accepted 事件沿用 seq 幂等;通知发送入 outbox(§二 规则 2"状态与事件同事务"),SSE 断线 Last-Event-ID 重连不丢不重(T8)。

验收判据:提交幂等键同键不同体 409;受理立即返回;完成通知带回执证据;同一产物重复交付只收一次。

原生对齐(§6.5.6/6.5.7 复核):agentscope-service 原生已实现本节的表结构语义,可直落:

本设计原生对应(JPA 实体,源码坐实)说明
事件表 + seqbuilder_session_event(event_id/session_id/seq/event_type/payload_json/processed_at)事件流原生
outbox + 重发builder_session_event_outbox(event_seq/report_json/delivered_at/next_attempt_at/lease_until/worker_id/last_error)原生带重试计划与 worker 租约,更强
提交幂等builder_session_event_fence(attempt_id/session_id)+ builder_session_turn_command.admission_seq原生按 attempt_id 去重 + 顺序准入
受理/通知状态builder_session.status + 事件 event_type原生状态机

本节的幂等键/outbox 设计保留为契约语义,落地时映射到原生表(不另建同名表)。

十八、人机请求契约(G9) ​

Agent 主动请求人(审批/补信息/仲裁/确认)是"人机反向协作"——§二/§三已有 waiting_approval 状态与 SLA,补一等对象契约:

字段语义
request_id人机请求唯一标识(= wait_reason.id)
subject请求什么(审批 / 补充信息 / 仲裁 / 确认)
options可选回复(批准 / 拒绝 / 补充 / 超时默认)
assignee人/组;支持会签(multi_approve:全部通过或任一否决)
deadline截止时间(SLA;超时行为 = 任务 expired + 审批单关闭 + 通知 owner,T9 已有)
resumable挂起期间沙箱状态(快照已释放 → 恢复水合;短等待保留短租约)
channel通知/响应渠道(13 篇入口;响应经 Higress 幂等键回)
audit全程事件(agent_task_event:requested → responded → resumed)

规则:

  1. 响应走渠道 + 幂等键(T1 复用),重试落同一 request_id;
  2. 会签聚合进 wait_reason(全部通过才恢复,任一否决即 cancelled);
  3. 挂起期间预算语义:等待不放配额(§三 已定),恢复后续租。

验收判据:request_id 全程可审计;deadline 超时 → expired + 通知 owner(T9);会签聚合正确;响应幂等。

原生对齐(§6.5.6/6.5.7 复核):agentscope-service 原生 builder_coord_hitl(HITL 工单)已实现本契约绝大部分字段:

本设计原生字段说明
request_idtool_use_id(工具确认)/ approval_id(业务审批)+ agent_task_id/attempt_id/dispatch_generation/turn_id 定位原生双 id 体系(工具确认 vs 审批)
deadline(超时→expired)expires_at(原生索引 ix_builder_coord_hitl_expires)SLA 超时原生
assignee / 会签owner_id + approval_id(指定审批人)会签聚合为平台语义
resumable(挂起→水合)continuation_lease_id + continuation_ready挂起续租原生
audit(requested→responded→resumed)resolution_status + decision_version + resolved_allow/deny_message + 时间戳决议版本化原生(decision_version 幂等)
channelsession 事件/outbox 通知(G12 原生通道)渠道复用

本契约改为对齐原生语义:request_id=coord_hitl id、deadline=expires_at、响应=决议版本化;会签聚合与渠道映射为平台补。

最小上手 ​

按下面顺序做,第一周就能跑通一条最小闭环:

text
1. 建 rt.agent_task、rt.agent_task_event、rt.agent_task_budget 三张表;
2. 只实现四个阶段:queued、executing、waiting_approval、completed;
3. 创建接口带 Idempotency-Key,重复请求返回同一 task_id;
4. 工具调用前写 waiting_approval,快照并释放沙箱;审批后 resume;
5. 每次模型/工具调用前 reserve,耗尽转 expired 并写 unfinished_items;
6. 完成时强制写 artifact_id 与 delivery_receipt,否则标 completed_unverified;
7. 接 SSE,事件带 seq;断线用 Last-Event-ID 续传;
8. 跑 T1–T7 七条用例,再补 T8–T10。

一条最小请求:

bash
curl -X POST https://gateway.example.com/v1/tasks \
  -H "Authorization: Bearer $TOKEN" \
  -H "Idempotency-Key: weekly-report-20261024" \
  -H "Content-Type: application/json" \
  -d '{
    "session_id": "s-8842",
    "agent_key": "personal-assistant",
    "goal": {"text": "生成周报并发给本人", "success": ["artifact:weekly-report", "delivery:im"]},
    "budget": {"tokens": 120000, "tool_calls": 20, "wall_clock_ms": 600000},
    "deadline_at": "2026-10-24T10:00:00Z"
  }'

验收判据:审批等待不占沙箱;恢复后计划与预算不丢;预算耗尽有明确终态和未完成清单;重复投递只执行一次;界面能显示具体等待原因;Trace 能把排队、审批、工具、模型、交付拆开。

生产动作 ​

  • 先影子后切主:新状态机先双写、只读比对,确认与旧账本一致后再切读;不要一次性替换。
  • 状态机代码化:阶段与迁移写成枚举与守卫,禁止业务代码里散落 if status == ...。
  • 单写者与栅栏:任务状态只由租约持有者写,旧 epoch 写回拒绝;续租失败立即停手。
  • 等待原因一等公民:UI、告警、Runbook 都读同一个 wait_reason,不要各自推断。
  • 预算步进检查:每一步前预留、后扣减,耗尽即终态,不靠事后报表。
  • 完成必须带证据:没有证据只能算“执行完”,不能算“验收通过”。
  • 故障演练常态化:把审批、预算、快照、取消、重复投递五类故障写进发布门禁。
  • 保留期与删除:事件、审批、证据按审计要求保留;用户删除请求要能追到任务、产物与快照。

边界 ​

  • 不是所有轮次都要全量状态:短同步对话可以用轻量字段;长任务、异步任务、跨天审批才需要完整状态机。
  • 状态机不是工作流引擎:业务分支放 Workflow,任务状态只表达执行进度与等待。
  • 预算不是账单:账单以网关计量为准,预算是执行边界;两者要对账,但不能互相替代。
  • 释放不是免费:快照有存储与时间成本,恢复有冷启动;高频短任务不必每次释放。
  • 精确一次不存在:投递至少一次,状态迁移幂等,别把“恰好一次”写进承诺。
  • 本文 schema 是参考实现:表名、字段类型可按现有平台调整,但“状态、等待、预算、证据”四类语义不能省。

讲清楚 ​

任务状态不是消息历史,而是一张可机读的权威记录:目标、阶段、计划、已确认事实、预算、等待原因、完成依据。消息是投影,事件是历史,状态是当前事实。

等待是一等状态。wait_reason 决定界面说什么、告警发给谁、资源何时释放;默认先快照再释放,只有审批对象必须活在进程里才保留短租约。

预算是外部终止边界。每一步前预留、后扣减,耗尽产生明确终态与未完成清单,不能悄悄截断。

完成是证据加验收,不是模型说“好了”。审批与验收分开,失败与取消都要给未完成清单。

落地顺序是:先建任务账本与状态迁移,再改内核与工具钩子,再做沙箱 Suspend/Resume,最后接 SSE、控制台与告警。评审方法只有一句:拿一条真实任务走线,从入口走到终态;任何一跳说不清“谁写状态、在等什么、占什么、何时放、凭什么算完成”,这条链路就没有闭环。

适用:2026 年开源 Agent 平台运行态评审与实现 | 初稿 2026-10

把 AI 工程讲到既懂也会用