主题
架构评审:任务状态、等待与预算闭环
一句话:平台能不能落地,不看组件是否齐全,而看一次任务从进入到终止的每个等待点是否可解释、每份预算是否有边界、每次“完成”是否有证据;状态不显式,资源就释放不了,界面就说不清,观测就分不清慢在哪。
为什么需要
前二十一篇把组件和接缝基本铺齐了,但组件图完整不等于闭环。评审时最常被问住的不是“有没有这个组件”,而是四个更具体的问题:
- 这个任务现在到底在干什么?是在排队、在等审批、在等工具返回,还是已经卡死?
- 等审批的这两个小时,沙箱、租约、配额还占着吗?能不能先释放,批完再恢复?
- 预算烧完了会怎样?是悄悄截断、给一个半成品,还是明确失败并列出没做完的部分?
- 模型说“已完成”,凭什么算完成?谁验收、拿什么验收、验收记录在哪?
这四个问题指向同一条主线:任务状态不能由消息历史推断,必须维护一份可机读的权威状态。消息只是输入输出,历史只适合回放;真正的状态要显式记录目标、阶段、计划、已确认事实、预算、等待原因与完成依据。状态显式化以后,运行层才敢在等待时释放资源,界面才能说明在等什么,观测才能区分是执行慢、审批慢还是工具慢。
预算也不是事后统计,而是外部终止边界:在每一步之前预留,在每一步之后扣减,耗尽时给出明确终态和未完成清单。没有这条边界,长任务会无限烧钱,短任务会被静默截断,账单和体验两头失控。
这篇按“评审方法 → 四块契约 → 场景走线 → 代码级落地 → 组件改造 → 验收用例”组织。读完之后,应该能直接建表、加接口、改内核钩子、补指标,而不是只知道几个原则。
核心机制
一、评审方法:拿一张状态卡走线
评审不按组件清单逐项打勾,而按一次真实任务走线。每到一个位置,问八个问题:
| # | 问题 | 答不上来的含义 |
|---|---|---|
| 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 | 串起模型、工具、审批与交付 | 事故无法复盘 |
状态机建议至少覆盖这些阶段:
这张图的关键不是阶段多,而是四条分叉:从执行进入等待、从等待回到执行、从执行进入终态、完成后进入验收。每条分叉都要有触发事件、负责写入的组件和超时动作;少一条,任务就会卡在某个“看起来还在跑”的状态里。
三条写规则:
- 单写者:只有持有租约的执行者能改
phase、plan、budget;旧 epoch 的写回一律拒绝; - 状态与事件同事务:每次状态变更同时写事件日志与 outbox,避免“状态变了、消息没发”;
- 状态不存长文本:大输入、工具结果、产物放对象存储,状态里只放引用与校验和。
一个够用的状态记录大致长这样:
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 | 退避/换候选/失败 |
三条硬规则:
wait_reason必须机读:类型、关联 ID、开始时间、SLA 四件套齐全;界面文案、告警路由、Runbook 都从这里取,不要各写一套判断。- 等待默认释放计算资源:审批、输入、外部回调、人工验收都不需要沙箱持续运行;先快照再释放,恢复时按任务 ID 水合。只有“审批对象必须活在当前进程”时才保留短租约,并在状态里标
resumable=false。 - 恢复靠任务 ID,不靠进程存活:客户端重连、Pod 漂移、审批隔夜,都从权威状态恢复;进程只是执行器,不是状态容器。
四、预算:外部终止边界,不是事后统计
预算至少覆盖八个维度,每个维度都要有计量点、预留方式与耗尽动作:
| 维度 | 计量点 | 预留方式 | 耗尽动作 |
|---|---|---|---|
| Token | 网关计量 | 每次模型调用前预留上限 | 停止新调用,给部分结果与未完成清单 |
| 成本 | 网关计量 | 按价格版本折算后预留 | 同上;交互式可询问是否追加 |
| 工具调用次数 | Toolkit | 每次调用前原子加一 | 停止工具阶段,标记未完成 |
| 墙钟时间 | Scheduler/任务记录 | deadline_at | expired 终态,保留检查点 |
| 步数/轮次 | 内核循环 | 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_tool | tool_call_id | 沙箱(工具需要工作区) | 工具调用次数 | 查询结果引用 |
| 生成报告 | executing | 无 | 沙箱、模型连接 | Token/成本 | 产物 ID |
| 等待外发审批 | waiting_approval | approval_id | 默认快照后释放沙箱 | 无 | 审批记录 |
| 交付 IM | executing | 无 | 产出物服务、渠道适配器 | 交付预算 | 消息 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_at | 202 {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_seq | 200 {events, next_seq} | 410 游标过期 |
GET /v1/tasks/{id}/stream | SSE 订阅 | Last-Event-ID | text/event-stream | 410 需重读快照 |
POST /v1/tasks/{id}:cancel | 取消 | reason | 202 {phase} | 409 已终态 |
POST /v1/tasks/{id}:resume | 内部恢复 | lease_epoch、checkpoint_ref | 202 {phase} | 409 租约失效 |
POST /v1/tasks/{id}:accept | 验收 | accepted、comment、evidence_refs | 200 {phase} | 409 证据不足 |
POST /v1/approvals/{id}:decide | 审批 | decision、comment | 200 {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、DeadlineScanner | 10–15s 心跳、SLA | 过期任务只被回收一次 |
| Dataplane | 从事件日志转 SSE,支持 as_of | TaskEventStream | 游标保留窗口 | 断线续传不丢不重 |
| AgentScope Java 内核 | TaskContext、BudgetGuard、WaitReason、Evidence 钩子 | 内核 SPI 扩展点 | 步边界检查 | 每步都能读到预算与等待原因 |
| Toolkit | 工具调用前审批与预算预留 | ToolCallGuard、tool_call_id | 默认策略、超时 | 超预算不发起调用 |
| Memory / Workspace | 已确认事实持久化、快照指针 | ConfirmedFactStore、snapshot_ref | 作用域、TTL | 恢复后事实不丢 |
| Sandbox Manager | Suspend(task_id, spec) / Resume(task_id) | 快照指针 CAS、租约释放 | 快照 TTL、恢复超时 | 审批等待不占沙箱 |
| Runtime Scheduler SPI | 任务注册、租约、misfire | TaskScheduler 适配 | Quartz/XXL-Job 参数 | 重复触发只执行一次 |
| RocketMQ / LiteTopic | 事件类型、按会话有序、幂等消费 | task.* 事件、消费位点 | TTL、重投上限 | 重投不重复执行 |
| PostgreSQL | 任务、事件、预算、审批、Outbox | 上述六张表与索引 | 分区、保留期、HA | 状态与事件一致 |
| Redis | 只做租约缓存、限流,不做权威 | 租约 key、限流 key | TTL 小于 DB 租约 | Redis 丢失不影响正确性 |
| Nacos | 下发预算/超时/等待策略 | task-policy 配置 | 灰度、版本 | 配置变更可回滚 |
| HiMarket / Console | 任务时间线、等待原因、审批箱、预算展示 | 读任务 API | 文案映射 | 用户能看懂在等什么 |
| Higress / Hub Router | 透传 X-Task-Id 与 traceparent | Header 约定 | 超时、重试 | 不吞任务 ID |
| OTel Collector | task.wait、task.budget.check span | 属性规范 | 采样率 | 能按等待类型拆分 |
| 审计存储 | 终态、审批、验收、证据引用 | 审计事件 schema | 不可篡改、保留期 | 能还原一次任务全过程 |
| 产出物服务 | 证据引用、checksum、下载 ticket | artifact_id、sha256 | TTL、DLP | 完成证据可校验 |
| 渠道适配器 | 交付回执写回 completion_evidence | send/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: escalatesuspend(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.transition | task.id、from、to、reason、version | 谁在什么时候推进了状态 |
task.wait | wait.type、wait.id、wait.sla_ms、wait.duration_ms | 等的是审批、工具还是输入 |
task.budget.check | budget.dimension、used、limit、action | 预算在哪一步被拦截 |
指标至少这些,标签只放低基数:
| 指标 | 类型 | 标签 | 告警 |
|---|---|---|---|
agent_task_phase | Gauge | phase | 某阶段堆积 |
agent_task_wait_seconds | Histogram | wait_type | 审批 P99 超 SLA |
agent_task_budget_exhausted_total | Counter | dimension、task_type | 突增 |
agent_task_terminal_total | Counter | reason | 失败/过期比例 |
agent_task_resume_total | Counter | result | 恢复失败 |
agent_task_lease_expired_total | Counter | owner | 异常续租 |
界面文案直接从 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 接管 |
| T8 | SSE 断线后用 Last-Event-ID 重连 | 事件不丢不重,seq 连续 |
| T9 | 审批超 SLA | 任务转 expired,审批单关闭,通知 owner |
| T10 | 父任务带两个子任务 | 父任务等待子任务,预算与证据可聚合 |
故障演练至少每月一次,重点不是“跑通正常路径”,而是“杀掉一个组件后状态是否仍然可解释”。
十六、评审改进完成对照
本轮已把评审结论落回篇目;代码实现属于工程侧,文中给到可直接照做的表、接口与算法。
| 原缺口 | 已落到 | 具体改动 | 验收 |
|---|---|---|---|
| 任务状态字段不全 | 本篇 §二、§八;07 | 增加 task_state 字段组、状态机与六张表 | T2/T7 |
| 等待期间资源不释放 | 本篇 §三、§十二;21 | 增加等待分类、WaitPolicy、Suspend/Resume | T2/T9 |
| 预算只统计不拦截 | 本篇 §四、§十三;10 | 增加预留/扣减/释放与 expired 终态 | T3 |
| 完成缺证据与验收 | 本篇 §五;18 | 增加四级完成、completion_evidence、accepted | T4 |
| 指标未按等待拆分 | 本篇 §十四;14 | 增加 task.wait、task.budget.check 与等待指标 | 告警可按类型路由 |
| 任务表缺等待/证据列 | 20 §五 | 补“等待原因、完成证据、终态”三列 | 评审可逐行对照 |
| 沙箱释放规则不清 | 21 §九 | 补审批等待的释放/保留短租约规则 | T2/T6 |
| 术语与写作口径不一 | 23-术语与写作约定 | 统一状态、等待、预算、证据的定义 | 全栏一致 |
十七、异步受理与通知回执(G12)
异步场景(发起即走、完成通知)在现有状态机上只缺两处:受理语义与通知回执状态。
- 受理即返回:提交任务(带
Idempotency-Key)→ 校验(同键同体幂等返回原 task;同键不同体 409)→phase=queued→ 立即返回{task_id, accepted_at, events_url},不等待执行; - phase 增加
notified:completed后通知渠道(18 篇产出物交付)→ 发送成功(带回执)→completed → notified;notified → accepted(owner 验收)。通知未成功则停留completed(可重发,重发幂等,同一产物只收一次——T4 扩展); - 表结构变更:
rt.agent_task.phase CHECK增加'notified'(§八 已同步);通知回执存completion_evidence(type=delivery_receipt, id=渠道回执); - 事件:
submitted/accepted事件沿用seq幂等;通知发送入 outbox(§二 规则 2"状态与事件同事务"),SSE 断线Last-Event-ID重连不丢不重(T8)。
验收判据:提交幂等键同键不同体 409;受理立即返回;完成通知带回执证据;同一产物重复交付只收一次。
原生对齐(§6.5.6/6.5.7 复核):agentscope-service 原生已实现本节的表结构语义,可直落:
| 本设计 | 原生对应(JPA 实体,源码坐实) | 说明 |
|---|---|---|
| 事件表 + seq | builder_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) |
规则:
- 响应走渠道 + 幂等键(T1 复用),重试落同一
request_id; - 会签聚合进
wait_reason(全部通过才恢复,任一否决即 cancelled); - 挂起期间预算语义:等待不放配额(§三 已定),恢复后续租。
验收判据:request_id 全程可审计;deadline 超时 → expired + 通知 owner(T9);会签聚合正确;响应幂等。
原生对齐(§6.5.6/6.5.7 复核):agentscope-service 原生 builder_coord_hitl(HITL 工单)已实现本契约绝大部分字段:
| 本设计 | 原生字段 | 说明 |
|---|---|---|
request_id | tool_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 幂等) |
| channel | session 事件/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