
解决什么问题Agent 长任务的状态一会儿跳、一会儿卡、偶尔还「幽灵完成」。本文用一个纯 JDK 17 的状态机内核把非法转移拦截、幂等键粒度、卡死检测滞后这三件事量出来。适合谁读在用 Java 写 Agent 编排、工作流引擎、任务调度网关的后端同学。能收获什么一份能直接编译运行的状态机内核、一套「静态体检 随机注入 重放对拍」的自测方法以及 4 组实测数字含看门狗滞后的毫秒分布。近期公开报道里Agent 的工程化被反复推到台前Atlassian 发布 Agentic Multiplayer Protocol要求企业 Agent 具备明确身份、限定权限和可复核的任务结果Anthropic 提出智能体技能发展路线图把版本化发布与安全指引写进同一份文档。这两个方向的共同点是Agent 不再是一段 prompt而是一个要有状态、要能被审计的长任务。而 Java 后端接住这句话的第一层就是任务状态机。状态机看着最简单——一张转移表而已。我把它写完、灌了 41 万次随机事件结果有三处值得单独说一说事件枚举里有、转移表里没有的「孤儿事件」会让心跳 100% 被拒幂等键选错粒度会让 93.7% 的任务终态分叉看门狗发现卡死的快慢和你调的那个超时阈值一点关系都没有。一、内核把状态机写成一张表而不是一堆 if先说清楚口径。以下所有数字都来自本机实采环境java version 17.0.18 2026-01-20 LTSbuild17.0.188-LTS-264Oracle Corporation、javac 17.0.18、os.name Windows 11、os.version 10.0、os.arch amd64运行统一带-Dfile.encodingUTF-8随机注入固定seed20261023。换 seed 或换 JDK 小版本第三到第五节的百分比会变复现前请先看第八节的判定标准。状态机最常见的写法是散落在业务代码里的if (status 3 ...)。改成表驱动之后「允许什么」变成了一份可静态分析的数据结构。private static final MapState, MapEvent, State TABLE new EnumMap(State.class); static { put(State.RECEIVED, Event.PLAN_READY, State.PLANNING); put(State.PLANNING, Event.APPROVE, State.RUNNING); put(State.PLANNING, Event.DENY, State.CANCELLED); put(State.PLANNING, Event.STEP_DONE, State.AWAITING_APPROVAL); put(State.AWAITING_APPROVAL, Event.APPROVE, State.RUNNING); put(State.RUNNING, Event.TOOL_INVOKED, State.TOOL_CALL); put(State.RUNNING, Event.STEP_DONE, State.SUCCEEDED); put(State.TOOL_CALL, Event.TOOL_RESULT, State.RUNNING); put(State.TOOL_CALL, Event.STEP_FAIL, State.RETRY_WAIT); put(State.RETRY_WAIT, Event.RETRY_TICK, State.RUNNING); // SPAWN_SUBTASK / SUBTASK_DONE 故意不登记来自尚未上线的子 Agent用来测「模型自己发明事件」 }事件入口只做三件事查表、拒绝或转移、刷新存活时间。public Outcome fire(Event e, long nowMs) { long s seq; MapEvent, State row TABLE.getOrDefault(cur, Map.of()); if (e Event.CANCEL !cur.terminal()) { // 安全通道任何非终态可取消 State from cur; cur State.CANCELLED; touch(nowMs, e, true); return new Outcome(true, from, cur, SAFETY_CANCEL, 安全通道取消, s); } if (e Event.HEARTBEAT heartbeatBypass !cur.terminal()) { counters.computeIfAbsent(e, k - new long[2])[0]; lastProgress nowMs; // 只续命不改状态 return new Outcome(true, cur, cur, LIVENESS_ONLY, 活性旁路状态不变, s); } State to row.get(e); if (to null) { touch(nowMs, e, false); return new Outcome(false, cur, cur, ILLEGAL_TRANSITION, cur 不接受 e 该状态登记事件 row.keySet() , s); } State from cur; cur to; touch(nowMs, e, true); return new Outcome(true, from, to, OK, null, s); }这里有两条不变量后面所有实验都在守它们转移后的状态永远非 null、进入终态之后不能再被踢出来。二、静态体检先别跑先问「谁走不到终态」反向 BFS 只要 20 行正向展开会爆写完整张表第一件事不是测用例是把表当成图来算。正向算「每个状态能不能到终态」要展开路径状态一多就是指数级反过来从终态沿入边做一次 BFS代价是线性的代码也短。/** 反向可达从终态沿入边遍历剩下的就是走不到终态的状态。 */ public static SetState nonTerminatingStates() { MapState, SetState incoming new EnumMap(State.class); for (State s : State.values()) incoming.put(s, new HashSet()); for (var en : TABLE.entrySet()) for (var t : en.getValue().entrySet()) if (t.getValue() ! null) incoming.get(t.getValue()).add(en.getKey()); SetState canEnd new HashSet(TERMINALS); ArrayDequeState q new ArrayDeque(canEnd); while (!q.isEmpty()) { State s q.poll(); for (State pre : incoming.get(s)) if (canEnd.add(pre)) q.add(pre); } ... }本机跑出来9 个状态全部可达终态RECEIVED、TOOL_CALL距终态 2 步其余非终态 1 步没有死锁态。但事件维度就出问题——枚举 12 个事件有 4 个在整张表里一次都没登记过事件枚举总数 12转移表里一次都没登记过的孤儿事件 [CANCEL, HEARTBEAT, SPAWN_SUBTASK, SUBTASK_DONE]4 个其中CANCEL是我有意在表外用安全通道实现的属于「看着孤儿其实有人管」剩下的HEARTBEAT、SPAWN_SUBTASK、SUBTASK_DONE是真孤儿。HEARTBEAT这个最典型写代码的人加了心跳事件准备续命但没往表里登记于是每一次心跳都会被判成非法转移。这类「枚举里有、表里没有」的错位靠 review 很难发现靠一段 20 行的静态检查能直接数出来。活性该走旁路不该进转移表诊断出HEARTBEAT全拒之后正确的修法不是给每个状态补一条HEARTBEAT → 自己的自环。心跳回答的是「它还活着吗」转移表回答的是「它下一步是什么」是两个正交维度把活性塞进状态表等于给 N 个状态补 N 条自环漏一个就是线上事故。我加了个开关做 A/B旁路模式下心跳只刷新存活时间、返回一个「状态不变」的LIVENESS_ONLY结果。同一份种子重跑 41 万次事件活性旁路开关对比HEARTBEAT 被拒 73947 次 - 49747 次其中「非终态上被拒」24200 次 - 0 次 总拒绝率 85.5% - 79.6%两轮的不变量违规均为 0 剩下的 49747 次心跳拒绝全部落在终态上——任务已经结束续命无意义这是设计意图。关键在于「非终态 24,200 → 0」这一项它证明旁路确实接管了所有该接管的心跳而不是把错误换了个地方冒。剩下那 49,747 次是终态上的正常拒绝留着它反而能让「已结束后还在发心跳」这种 bug 暴露出来。三、41 万次随机注入拒绝率不是越低越好固定种子跑 20000 段随机事件流每段最长 40 个事件事件来源故意混成三种口味55% 沿合法路径挑17% 塞心跳16% 抢跑没到审批闸门就发APPROVE12% 纯随机。 B. 随机事件注入seed2026102320000 段 × 最长 40 事件 事件总数 410111应用 5947914.5%拒绝 35063285.5% 不变量转移后状态为 null 的次数 0从终态又被踢出来的次数 0按事件类型拆开看信息量比总拒绝率大得多事件应用拒绝拒绝率怎么读HEARTBEAT073,947100%孤儿事件一次都没成过SPAWN_SUBTASK04,088100%模型自创事件全拦SUBTASK_DONE04,092100%同上STEP_DONE16,547180,08791.6%随机流里大量落在终态后APPROVE11,56562,86384.5%抢跑审批PLAN_READY18,1553,46716.0%起点事件本来就少前置CANCEL1,3622,81267.4%拒的都是「已终态还想取消」85.5% 的拒绝率是这轮实验的成功不是失败随机流就该被拒掉绝大多数否则说明状态机形同虚设。真正要看的是两个 100%——它们精确指出了「谁在被静默丢弃」。而两条不变量违规为 0说明非法事件被挡在门外时状态本身没有被污染。这里补一个反直觉提醒别用总拒绝率给状态机打分。同一个实现把随机流换成真实业务流拒绝率会从 85.5% 掉到几个点以内分叉信号藏在「哪个事件在哪个状态被拒」的二维分布里不在总量里。四、幂等键粒度93.7% → 0.0%多 Agent 编排里事件会重复投递也会乱序到达。最常见的写法是拿事件类型做幂等键——我拿同一份合法剧本、30% 重复投递、再随机乱序对拍了三种实现// V1幂等键 事件类型很多项目就这么写 SetObject seenType new HashSet(); for (Del d : stream) if (seenType.add(d.event())) fsm.fire(d.event(), 0L); // V2幂等键 逻辑序号但按到达顺序应用 SetInteger seenSeq new HashSet(); for (Del d : stream) if (seenSeq.add(d.logicalSeq())) fsm.fire(d.event(), 0L); // V3逻辑序号去重 按序号重排后再应用 ListDel ordered new ArrayList(); SetInteger s3 new HashSet(); for (Del d : stream) if (s3.add(d.logicalSeq())) ordered.add(d); ordered.sort(Comparator.comparingInt(Del::logicalSeq)); for (Del d : ordered) fsm.fire(d.event(), 0L);方案终态与干净参照分叉非法事件次数卡死的任务V1 幂等键 事件类型93.7%15,126大量停在RUNNINGV2 幂等键 逻辑序号80.1%18,401停在AWAITING_APPROVALV3 序号去重 按序重放0.0%0无V1 的分叉原因是致命的剧本里STEP_DONE出现两次PLANNING→AWAITING_APPROVAL和RUNNING→SUCCEEDED按类型去重会把第二次当成重复包丢掉于是任务永远走不到成功。这不是「幂等没生效」是幂等把合法事件误杀了——比没有幂等更难查因为它不报错。V2 去重粒度对了但仍有 80.1% 分叉乱序到达的事件被按到达顺序应用APPROVE早于PLAN_READY到就会撞上非法转移。去重解决「同一件事做了两遍」解决不了「事情还没轮到就先到」。只有 V3 同时处理重复和顺序分叉率归零。五、卡死检测看门狗滞后与超时阈值无关真实线程跑的实验台200 / 2000 个任务各自心跳 3~8 拍后停掉看门狗按固定间隔扫描deadline固定 1500 ms。滞后 判STALLED的时刻 −最后一次心跳 deadline。while (!Thread.currentThread().isInterrupted()) { long now System.currentTimeMillis(); boolean all true; for (Task t : tasks) { if (t.detectedAt null) { if (now - t.lastProgress DEADLINE_MS) t.detectedAt now; else all false; } } if (all) return; Thread.sleep(intervalMs); // 滞后几乎全部来自这一觉 }扫描间隔任务数滞后中位p95p99最差单轮扫描100 ms20042 ms1061061060 ms500 ms200225 ms3383383380 ms1000 ms200166 ms3403403400 ms100 ms200081 ms1011011010 ms500 ms2000171 ms3613613610 ms三点结论第一滞后的上界是「一个扫描间隔」和deadline无关。想把发现时间从 500 ms 压到 100 ms只能加密扫描把deadline从 1500 ms 调到 300 ms 只会误杀慢任务卡死照旧要等到下一次扫描才看得见。第二中位滞后并不随间隔严格单调1000 ms 那组反而比 500 ms 小因为任务过期时刻与扫描对齐是随机的别拿单次中位数当 SLA要看最差值。第三本轮 2000 个任务的单轮扫描耗时仍是 0 ms 量级——线性扫描的代价在这个任务规模上根本不是瓶颈别过早为它建索引。六、每事件开销与审计日志的代价状态机本身很便宜贵的是「拒绝时要说清楚为什么」。分支中位 µs/次差值来自fire()合法转移EnumMap 查表0.106—fire()非法转移查表 拼拒绝原因0.222那句row.keySet()字符串拼接0.11 µs 的差看着不大但审计日志是无条件拼的。我的建议是把detail延迟到真要落日志时再构造返回状态码 状态/事件枚举由调用方格式化。这条改动我没有单独测属于「知道成本在哪、按需取舍」的部分——不做成结论只提示方向。七、适用边界与风险提示单任务、单实例。本文的状态机内核不带持久化、不带并发多实例竞争。多副本同时消费同一个任务事件流时还需要外部序号或数据库行锁来定序本文的 V3 只在进程内成立。随机注入是探测工具不是流量模型。85.5% 的拒绝率来自「事件流本身就是乱的」不要拿它当线上指标真实业务流应该单独写剧本回放。看门狗只判存活不判正确。心跳正常但逻辑死循环的任务本文这套测不出来那需要进度指标步数、产出字节数而不只是心跳。不覆盖补偿与回滚。状态机管「下一步能不能走」Saga 的补偿链、部分成功的清理是另一层两者不要混在一张转移表里。平台与版本口径所有数字来自 JDK 17.0.18 / Windows 11os.version 10.0单进程用System.nanoTime()与System.currentTimeMillis()计时。分位数对调度抖动敏感换环境请重跑不要照抄毫秒值。AI 辅助说明代码、实验设计与文字均由我在本机完成并实跑未使用他人文章素材与示例。八、怎么复现以及判定成功cd articles/kb javac -encoding UTF-8 -d out Support.java cd ../demo18 javac -encoding UTF-8 -cp ../kb/out -d out FsmCore.java FsmFuzz.java StallWatch.java Art18Diagrams.java java -Dfile.encodingUTF-8 -cp ../kb/out;out FsmFuzz # A 静态体检 / B 41万注入 / C 幂等对拍 / D 开销 java -Dfile.encodingUTF-8 -cp ../kb/out;out StallWatch # 5 组配置的检测滞后判定标准对不上就先怀疑实验台别急着怀疑结论①FsmFuzz的 B 段必须打印不变量… 0不为 0 说明状态机漏了守卫②HEARTBEAT拒绝率应为 100%否则说明你把心跳错登进了转移表③ C 段 V3 必须是分叉 0.0% 非法事件 0 次V1/V2 只要有一个不是 0 就成立④StallWatch里漏检列必须是 0否则看门狗线程被提前 join 掉了。九、结论把这三条记住就够了状态机的第一道防线是「表外无事件」而心跳和子任务事件最容易漏在表外幂等键要用逻辑序号并且必须配一层按序重放只做去重会留下 80% 的分叉卡死检测的速度上限由扫描间隔决定不由超时阈值决定。41 万次注入没有让状态机产生一次非法状态说明表驱动 显式守卫这条路是对的但 V1 那种「把合法事件当重复包丢掉」的写法恰恰是这套机制自己内部的坑——它会安静地把任务停在半路。如果你的 Agent 任务现在还能出现「状态显示成功、但产物没落盘」先别查模型去把事件枚举和转移表 diff 一遍看看有没有孤儿事件在替你背锅。两个具体问题想听你的实践你的 Agent 事件流有没有带逻辑序号重复投递和乱序分别是谁在兜底卡死任务的超时阈值是多少你测过「真正判卡的滞后」吗收藏这篇当自测清单下次改状态机时按 A/B/C 三步跑一遍。