斗破Google ADK Java苍穹-第8境斗宗-踏空布阵-Workflow与Resume
斗宗踏空:用 Workflow 与 HITL 掌控数字人的执行空间
一个 Agent 包打天下,中断只能重跑,重跑就可能多退一次钱。
[读者] 已跑通会话与记忆、正要把数字人推向生产的 Java 后端
[痛点] 流程顺序靠模型自由发挥,中断只能从头发起,重跑就重复执行
[现在读] Workflow、Resume、HITL 是同一套执行空间控制的三个面
[读完] 能把数字人改造成可编排、可暂停、可批准、可原地续跑的确定性流程
[旧方案] 单 Agent 用 instruction 约束步骤,出错就从头发起
|
v
[新需求] 危险动作前必须停下来等人批准,批准后接着跑
|
v
[冲突] 重跑恢复的是流程位置,重放出来的是业务副作用
|
v
[后果] 重复退款、重复工单、账目对不上、客诉升级
我是老李,带一个小团队维护一套企业售后数字人,Java 写的,背后是订单系统和工单系统。上一境我们把它套上了 Harness 治理层,工具调用、参数校验、超时熔断都收敛到一个统一入口,数字人总算不那么容易闯祸了。
但真上生产之后,最扎眼的麻烦不在工具本身,在流程。用户一句话进来,数字人得先辨意图、再查订单、再查政策、再决定要不要动钱、最后回话。这套步骤今天全靠一个 LlmAgent 的 instruction 撑着:模型心情好就按顺序走,心情不好就跳步、并步,甚至自作主张先退款再补查政策。
更麻烦的是中断。用户在手机上等到一半切走了,客服系统超时把同一条请求重投了一遍,数字人从头再跑一次,于是钱退了两遍。查日志的时候你会发现,两条 invocation 长得几乎一模一样,只是各自生成了一个退款单号。
流程为什么不能交给模型自由规划?
中断之后,到底该重跑还是该续跑?
危险动作要暂停等人批准,可这个"暂停"到底存在哪里?
选错任何一个,你的数字人就会从"偶尔答错"升级成"偶尔多退钱"。
01、故事
售后数字人叫小周,挂在客服系统后面。现场是这样的:用户在对话框里说"我上周买的那台净水器滤芯不对,要退",客服系统把这句话连同 sessionId、订单号一起转给我们的服务。
任务是:把一句自然语言,变成一笔真实的、可追溯的退款单。
V1 的实现很朴素——一个 LlmAgent,instruction 里写五步,tools 挂三个:查订单、查政策、退款。跑了两周,客服反馈"大部分时候没问题"。
然后接连出了三件事。
第一件,用户断线重连,重发同一句话,退款接口被调了两次,财务对账时发现同一笔 89 元退了两回。
第二件,运营明确要求:退款金额超过阈值必须人工审批。这条要求我们当时是写进 instruction 的——"金额大于 500 元先询问用户"。实测里它有时问、有时不问,同一个订单跑十次能问出三种结果。
第三件,查订单和查政策明明是两件互不相干的事,却串行跑了两趟模型,一次响应十几秒,客服主管天天在群里问"能不能快点"。
这三件事看起来是三个 bug,其实是同一件事:执行空间没有边界。谁在跑、跑到哪一步、跑到哪一步之后绝不能自动往下、跑不动了从哪接上,全都没有答案。
V2 的方向就此定了:意图识别 → 并行取数 → 危险动作前锁住空间 → 批准后原地续跑。把"空间锁"这件事,从提示词里的祈使句,变成 Runtime 里的状态。
一句自然语
五步靠模型
断线重来后
钱退了两回
02、问题
旧方案失效得很彻底:指令式编排把模型当调度器,而调度器需要的是契约,不是建议。
四个失效点。顺序不确定——instruction 是建议,采样一变顺序就变。中断不可续——没有恢复点,客户端重投等于整体重跑。副作用无边界——工具不幂等,重跑就重放。审批不可靠——合规判定写在提示词里,等于把审计权交给温度参数。
业务影响是直接的:重复退款要人工冲账,重复建单让工单系统数据脏掉,审批留痕缺失在合规检查时说不清楚。
技术表现同样明确:事件流里没有可恢复执行的标记,中断就是中断;工具入参里没有任何业务幂等键,两次调用在服务端看来是两笔独立业务;异常处理只有一个 catch,网络抖动和"订单不存在"走同一条路,要么全重试要么全终止。
本境要拿到"空间锁",完成标准必须可验证:
- 同一 businessKey 重复投递,退款副作用只发生一次。
- 审批点暂停后,用同一 session 续跑,已完成步骤不重复执行。
- 并行子 Agent 的结果通过 outputKey 进入 Session State,下游节点可引用。
- 错误分为可恢复、不可恢复、需人工三类,最大恢复次数可配置。
- 以上能力在同一个数字人项目里跑通,并有测试结果。
提示词当契约
中断当没发生
副作用无锁
出事才知疼
03、原理
本境只需要五个原理,但它们决定了后面所有的架构和排错。
第一,Workflow Agent 是控制流,LlmAgent 是决策点。SequentialAgent 按顺序执行子 Agent,ParallelAgent 并发执行并在汇聚点等待,LoopAgent 在带终止条件的前提下循环。它们的共同点是:编排由代码决定,不由模型猜。
反直觉判断:把流程写死不是削弱 Agent,而是把不确定性压缩到少数几个决策点上。只有当你能数清"这次执行最多有几个地方会产生不可预测输出",你才可能为每个点设计恢复策略。一个能自由规划的 Agent,恢复点是不可枚举的。
第二,结果不靠上下文窗口传递,靠 Session State。每个子 Agent 可以用 outputKey 把自己的最终输出写进会话状态,下游 Agent 在 instruction 里用模板变量引用。这样并行分支的结果不会互相冲掉,也不依赖"模型记得上一步说了什么"。
第三,Retry、Resume、Replay 是三件事,必须分开。Retry 是同一个调用失败后重发,关心的是这次调用成不成;Resume 是一个 invocation 中断后从事件流断点继续,跳过已完成步骤;Replay 是把历史事件重放一遍来重建状态。
三者里只有 Resume 能减少重复执行,而 Resume 能减少的是流程步骤的重复,减少不了副作用的重复——因为框架不知道你的退款接口有没有副作用,只有业务知道。
第四,HITL 的暂停不是线程挂起。执行到需要人工确认的动作前,Runtime 写入一个等待输入的暂停事件并让出,会话状态留在存储里;审批结果回来后,带着同一 session 继续跑。所以这个"锁"能扛住进程重启,也能扛住用户第二天再来点同意。
第五,错误必须分级。可恢复错误(网络抖动、限流)允许 Retry,且有最大次数;不可恢复错误(订单不存在、参数非法)立即终止转人工;需人工错误(金额超阈值、政策冲突)不重试,直接进 HITL。分级依据不是异常类型,而是"这个步骤能不能安全重放"。
编排交给码
决策留给模
状态存会话
恢复才可靠
04、架构
[输入] 用户消息 + userId + sessionId + businessKey
|
v
[模块] SequentialAgent(refund_workflow) 编排四个阶段
|
v
[数据/状态] Session State: intent / orderSnapshot / policySnapshot / approvalDecision / execResult
|
v
[处理] 意图识别 → ParallelAgent 并行取数 → 危险动作前 HITL 暂停 → 执行 → 总结
|
v
[输出] Event 流 + 用户回复 + 幂等退款单号
边界先说清楚:Workflow Agent 负责"步骤可见、可恢复、可暂停",不负责"怎么理解用户"。意图识别仍然由 LlmAgent 完成,Workflow 只保证它跑完、结果进 state、并在动钱之前停下来。所以这套架构不会让数字人变聪明,只会让它变可控。
收益有三个。恢复点可枚举——每一步都是一个事件,断在哪一步是确定的。并行分支独立——查订单和查政策互不阻塞,结果各自落 key。审批点可审计——暂停事件、审批人、审批时间都在事件流里,合规检查时有据可查。
代价也说清楚。编排变成代码,业务想改流程要改代码或配置,比改一句提示词重得多;并行分支数量受下游限流约束,两个查询并发没问题,二十个就要重新算容量;state 的键名成了新契约,outputKey 和模板变量对不上就是一个静默故障。
适用条件是:步骤相对稳定、包含不可逆动作、需要审批留痕的业务。反过来,开放式探索类任务——比如让数字人自己研究一个陌生问题——硬编码 Workflow 只会把它按住,那种场景该用 LlmAgent 的规划能力。
输入先锁键
状态各归位
危险先停手
输出可回溯
05、实战一次
环境:JDK 21,Maven 3.9+,Google ADK Java 1.11.0,分支 chapter/08-workflow-resume。
依赖(坐标与版本以 ADK Java 1.11.0 官方发布为准):
<dependency>
<groupId>com.google.adk</groupId>
<artifactId>google-adk</artifactId>
<version>1.11.0</version>
</dependency>
先建四个 LlmAgent 节点。意图识别节点负责把自然语言压成两行结构化文本,并用 outputKey 落进 state:
BaseAgent intentAgent = LlmAgent.builder()
.name("intent_agent")
.model("gemini-2.0-flash")
.description("识别售后意图并抽取订单号")
.instruction("""
你是售后意图识别节点。读取用户消息,只输出两行:
intent=<refund|query|other>
orderId=<订单号,没有则 unknown>
""")
.outputKey("intent")
.build();
两个取数节点分别查订单和查政策,通过 ParallelAgent 并发执行。它们各自写自己的 key,互不覆盖:
BaseAgent orderQueryAgent = LlmAgent.builder()
.name("order_query_agent")
.model("gemini-2.0-flash")
.instruction("""
根据订单号查询订单快照,调用 queryOrder 工具。
上游意图结果:{intent}
""")
.tools(FunctionTool.create(new OrderTools(), "queryOrder"))
.outputKey("orderSnapshot")
.build();
BaseAgent policyQueryAgent = LlmAgent.builder()
.name("policy_query_agent")
.model("gemini-2.0-flash")
.instruction("查询退款政策知识库,调用 searchPolicy 工具。")
.tools(FunctionTool.create(new PolicyTools(), "searchPolicy"))
.outputKey("policySnapshot")
.build();
BaseAgent gatherParallel = ParallelAgent.builder()
.name("gather_parallel")
.subAgents(List.of(orderQueryAgent, policyQueryAgent))
.build();
执行节点读三个上游 key 做判断,总结节点只读执行结果:
BaseAgent execAgent = LlmAgent.builder()
.name("exec_agent")
.model("gemini-2.0-flash")
.instruction("""
基于以下信息判断能否退款:
意图:{intent}
订单:{orderSnapshot}
政策:{policySnapshot}
符合政策则调用 refundOrder 执行退款,否则说明原因。
""")
.tools(FunctionTool.create(refundTools, "refundOrder"))
.outputKey("execResult")
.build();
BaseAgent summarizeAgent = LlmAgent.builder()
.name("summarize_agent")
.model("gemini-2.0-flash")
.instruction("根据 {execResult} 用一句话回复用户,不得编造未出现的信息。")
.outputKey("finalReply")
.build();
V1 的退款工具还没有幂等键,这是故意的——它正是第 06 章要暴露的问题:
public class RefundTools {
@Schema(description = "对订单执行退款")
public Map<String, Object> refundOrder(
@Schema(description = "订单号") String orderId,
@Schema(description = "退款金额,单位分") long amountFen) {
RefundResult r = refundClient.refund(orderId, amountFen);
return Map.of("refundId", r.refundId(), "status", r.status());
}
}
装配 Workflow 并启动:
SequentialAgent refundWorkflow = SequentialAgent.builder()
.name("refund_workflow")
.description("售后退款确定性流程")
.subAgents(List.of(intentAgent, gatherParallel, execAgent, summarizeAgent))
.build();
Runner runner = Runner.builder()
.appName("dh-ch08")
.agent(refundWorkflow)
.sessionService(new InMemorySessionService())
.build();
Session session = runner.sessionService()
.createSession("dh-ch08", "user-42", null, null).blockingGet();
runner.runAsync("user-42", session.id(),
Content.fromParts(Part.fromText("订单 A10086 我要退款")),
RunConfig.builder().build())
.blockingForEach(e -> System.out.println(
e.author() + " | " + e.content()
+ " | stateDelta=" + e.actions().stateDelta().keySet()));
未在当前环境实测,以下为预期结果(具体 API 签名与包名需按官方文档核验):
$ mvn -q exec:java -Dexec.mainClass=com.laoli.dh.ch08.Ch08Application
intent_agent | intent=refund, orderId=A10086 | stateDelta=[intent]
order_query_agent | A10086 / 89.00 CNY / PAID | stateDelta=[orderSnapshot]
policy_query_agent | 签收 7 天内可全额退款 | stateDelta=[policySnapshot]
exec_agent | refundId=R20260101-0001, status=SUCCESS | stateDelta=[execResult]
summarize_agent | 已为您提交退款申请,预计 1-3 个工作日到账 | stateDelta=[finalReply]
到这里 V1 是能跑的:顺序固定、并行取数、状态落 key。但中断恢复、审批锁、幂等这三件事一件都没做,第 06 章就来拆它。
编排先落地
状态已归位
恢复尚未做
隐患埋其中
06、排查
第一条链,下游说"我没查到订单"。
现象:summarize_agent 回复"我这边没有查到您的订单信息,请提供订单号",但订单号明明在第一条消息里。
怀疑:orderSnapshot 没进 state,或者模板变量名对不上。
检查:把每个事件打印出来,重点看 e.actions().stateDelta().keySet(),再逐个对照每条 instruction 里写的模板变量名。
证据:事件流里 order_query_agent 确实写入了 key 为 orderSnapshot 的状态增量,而 exec_agent 的 instruction 里写的是 {order_snapshot},两者只差一个下划线。
根因:模板变量名与 outputKey 不一致。这种错误不会抛异常,运行时直接替换成空字符串,模型看到的是"订单:(空)",于是它礼貌地告诉用户没查到。
修复:统一命名约定(小驼峰、语义后缀),并在应用启动时校验所有 instruction 中出现的模板变量是否都有对应的 outputKey 声明。
第二条链,同一订单退了两笔钱。
现象:财务对账发现订单 A10086 出现两笔 89 元退款,时间相差 40 秒。
怀疑:Runner 因为客户端重投,跑了两遍完整 invocation。
检查:拉出这两条 invocation 的事件流和退款工具入参做对比。
证据:两条 invocation 的退款调用参数完全相同——orderId=A10086、amountFen=8900——但退款服务各自生成了一个退款单号,说明服务端把它们当成了两笔独立业务。
根因:一是流程中断没有恢复点,客户端重投就等于从头执行;二是退款工具没有任何业务幂等键,重复调用必然重复落账。框架能告诉你在哪一步,但框架不知道这一步有没有副作用。
修复:开启可恢复执行,让 invocation 能从断点续跑;同时给退款工具加 businessKey,服务端按 key 去重。
第三条链,审批时有时无。
现象:同一笔 800 元退款,有时直接执行,有时停下来问。
怀疑:合规判定写在 instruction 里,本质是概率行为。
检查:同一输入重复执行若干次,统计 exec_agent 是否产生审批请求事件(这是验证方案,需实际执行统计,不是已测得结果)。
证据判据:如果多次执行中审批事件出现次数不等于执行次数,就说明合规判定没有被工程化。
根因:金额阈值是一道确定性规则,却被交给了模型判断。
修复:把阈值判断从 instruction 里挪到代码层,超过阈值一律走 HITL 暂停,模型无权决定。
错误尝试: 第一反应是把 LoopAgent 的 maxIterations 从 1 调到 5,想着"流程失败就自己多试几次"。这个方向是错的:LoopAgent 的语义是"再跑一遍循环体",不是"从断点续跑"。在退款这种带不可逆副作用的场景里,重试次数越多,重复退款的发生面越大。重试只应该作用在无副作用、可安全重放的调用上——比如查订单接口抖了一下,重查一次没问题;但退款接口没有幂等键时,重试就是事故放大器。
静默的错名
无声的重复
概率的审批
都是设计债
07、优化
四条修改,全部来自第 06 章的证据。
根因一:命名契约不落代码。修改:建立状态键命名约定,并在启动阶段扫描所有 instruction 中的模板变量,与全部 outputKey 做比对,缺一即启动失败。原因:这类错误静默且难查,必须在最早的时刻暴露。新行为:变量名写错时应用起不来,而不是线上答错。验证:故意把 exec_agent 的 {orderSnapshot} 改成 {order_snapshot},确认启动报错。
根因二:中断无恢复点,副作用无幂等。修改分两层。第一层,给退款工具加 businessKey:
public class RefundTools {
private final IdempotencyStore store;
@Schema(description = "对订单执行退款,businessKey 用于业务幂等")
public Map<String, Object> refundOrder(
@Schema(description = "订单号") String orderId,
@Schema(description = "退款金额,单位分") long amountFen,
@Schema(description = "业务幂等键") String businessKey) {
return store.executeOnce(businessKey, () -> {
RefundResult r = refundClient.refund(orderId, amountFen);
return Map.of("refundId", r.refundId(), "status", r.status());
});
}
}
第二层,让 Runner 以可恢复方式运行,使中断的 invocation 能从断点继续,而不是从头再来。原因:Resume 保护流程步骤不重复,幂等键保护业务副作用不重复,两者缺一不可。新行为:客户端重投同一 businessKey,第二次调用直接返回第一次的结果,不再产生新退款单。验证方案:同一 businessKey 连发两次,检查退款服务只产生一个退款单号(需实际执行验证)。
根因三:审批靠概率。修改:把阈值判断提成代码节点,超过阈值的动作前触发 HITL 暂停,写入审批卡片(业务 key、金额、订单快照、政策依据、申请人),等审批结果回来原地续跑。原因:合规是确定性规则,不该由采样决定。新行为:一次执行要么产生审批请求事件,要么根本不进入执行节点,不存在"碰运气"。
根因四:错误不分级。修改:在调用点按"能不能安全重放"分三类——可恢复错误允许 Retry,上限可配置,超过上限转人工;不可恢复错误立即终止;需人工错误直接进 HITL。原因:异常类型说明不了可重放性,只有业务语义能说明。验证方案:注入超时、注入订单不存在、注入金额超阈值三类故障,观察是否分别走 Retry、终止、HITL 三条路径(需实际执行验证)。
最后把 LoopAgent 用在对的地方:轮询"审批结果是否已回"这种无副作用动作,配上明确的最大轮次和退出条件。它不是重试按钮,它是一个带边界的等待循环。
键名先对齐
幂等在业务
审批出提示词
重试有边界
08、演进
[同一输入:用户重连后重发"订单 A10086 我要退款"]
|
+--[V1] 单 Agent 片段自由编排 / 中断视为新请求从头跑 / 无幂等键
| 代价:两笔 89 元退款,靠人工冲账
|
+--[V2] SequentialAgent 固定步骤 / 事件流断点续跑 / businessKey 去重 / 超阈值 HITL 暂停
| 代价:编排进代码,流程变更需要发版或改配置
|
[Trade-off]
得到:可枚举的恢复点、不可逆动作的单次副作用、可审计的审批留痕、可并行的取数阶段
失去:模型自由编排的灵活性,以及"改一行提示词就能调流程"的轻量
边界:步骤稳定、含不可逆动作、需审批留痕的业务才值得这么写
正确性上,V1 依赖模型每次采样都听话,V2 把顺序和阈值都变成代码事实,只有意图识别和文案生成还留给模型。稳定性上,V1 在重投、断线、进程重启三种情况下都会失真,V2 至少把前两种收敛到确定行为。复杂度和成本是升的:多了状态键契约、幂等存储、审批流,运维面变大,一次 invocation 的事件数也明显增加。
适用范围要说清楚:这套 V2 适合售后、账务、工单这类"步骤稳定 + 有不可逆动作"的场景。如果数字人做的是开放式咨询、方案探索,强行套 Workflow 只会把它按住——那种场景该保留 LlmAgent 的自由规划,只在外围用 Harness 兜边界。
遗留问题也有两个:一是 businessKey 的生成责任目前还在调用侧,如果调用侧生成不稳定,幂等就形同虚设;二是审批卡片依赖人工及时响应,长期挂起的暂停会话需要过期策略,这块本境没做。
一版靠运气
二版靠契约
得到是可控
失去是轻巧
09、洞见
9.1 确定性流程是恢复能力的前提,不是模型的对手
把编排从提示词搬进代码,表面上看是"不信任模型",实际上是在为恢复能力铺路。恢复的前提是知道"断在哪一步",而一个由模型自由规划的流程,步骤本身就是不确定的,你无法为它定义断点。Workflow Agent 的价值不在于它比模型聪明,而在于它把执行空间切成了一段段可命名、可观察、可续跑的区间。
9.2 反直觉判断:Resume 的正确性由业务幂等决定,而不是由框架恢复点决定
很多人以为开了可恢复执行,重复执行问题就解决了。事实是框架只能保证流程位置不重复,它不知道你调的退款接口有没有副作用。断点之前的步骤倒是没问题,但断点那一刻正在执行的动作呢——框架只能告诉你它可能执行了,无法告诉你它是不是执行成功了。所以幂等键必须长在业务侧,且必须由调用侧在发起时就确定,而不是由服务端在收到时才生成。
9.3 反直觉判断:HITL 要锁在副作用之前,而不是决策之前
直觉上,暂停应该发生在"要做决定了"的时候——让人类来决定要不要退。但真正需要锁住的是副作用边界:决策可以在代码里完成,只要在调用不可逆动作前停下来等人批。把锁放在决策之前,你会得到一堆"请确认是否继续"的噪音;把锁放在副作用之前,每一次暂停都对应一个具体的、带金额和业务键的动作,卡片才有信息量,审批才有意义。
9.4 错误分级应该按"能不能安全重放"来分,而不是按异常类型分
同样是超时,查订单超时可以重试,退款超时不能直接重试。异常类型完全相同,处置方式却应该相反。所以错误分级的判据不是 TimeoutException 还是 IllegalArgumentException,而是这一步有没有副作用、重放一次会不会造成业务损失。这条判断决定了 Retry 策略该挂在哪一层——挂在无副作用层,而不是挂在统一异常处理器里。
断点可枚举
幂等归业务
锁在副作用
分级看重放
10、系统落地
原来有什么:一个 LlmAgent 加三个工具,加上上一境做的 Harness 治理层,会聊天、有记忆、能调工具,但流程顺序靠提示词、中断靠重跑、审批靠运气。
本篇新增了什么:一层确定性编排(SequentialAgent 顺序 + ParallelAgent 并行取数 + LoopAgent 有界等待)、一套基于 outputKey 与 Session State 的节点间契约、一个可恢复的执行模型(断点续跑而非整体重跑)、一道锁在副作用之前的 HITL 暂停与审批卡片、一组带 businessKey 的幂等工具,以及一份按"可安全重放"划分的错误分级策略。
现在能做什么:数字人可以稳定地跑完"意图识别 → 并行查订单与政策 → 超阈值暂停审批 → 原地续跑执行 → 总结回复"这条链路;同一 businessKey 重复投递不会重复退款;断线重连后接着上次的位置跑;每一步的状态与审批留痕都在事件流里可查。
还缺什么:审批卡片的呈现层还没接,目前只有事件流里的结构化数据;暂停会话的过期与清理策略没有;幂等存储现在是内存实现,换生产存储要重新评估一致性;并行分支的数量还没有跟下游限流对齐。
下一步怎么走:把这条 Workflow 从单个 JVM 里放出去——让部分节点变成远程能力,本地只保留编排与治理。这需要一套跨进程的调用协议和 Agent 之间的协作约定,也正是下一境要处理的空间问题。
编排已成形
暂停能续跑
副作用的锁
还在业务侧
11、小结
Q1 → 流程不能交给模型自由规划,因为自由规划的步骤不可枚举,无法定义恢复点;
编排必须由代码决定,模型只保留少数几个决策点
Q2 → 中断应该续跑而不是重跑:Resume 让流程从事件流断点继续,
幂等键让副作用只发生一次,两者缺一不可
Q3 → 暂停不是线程挂起,而是一次写入了等待输入事件的状态让出;
它必须锁在副作用之前,并带着业务键一起落进会话存储
状态 → 数字人已能在同一项目中跑通确定性多步骤流程、并行取数、
超阈值 HITL 暂停与批准后原地续跑,并有幂等与错误分级兜底
三问皆有解
一境见空间
能停也能续
锁在动钱前
12、作业
12.1 理解题:为什么把流程写死反而提升了 Agent 的可用性?
参考答案:因为可用性的一部分是"出错后能不能恢复",而恢复的前提是断点可枚举。自由规划的流程每次步骤都可能不同,你无法为它定义"跑到哪一步了";确定性编排把执行切成可命名的区间,每一步都对应事件流里的一条记录,恢复、审计、评测才有着力点。这不是削弱模型,是把不确定性收敛到少数决策点上。
12.2 实战题:给现有的退款节点加上审批暂停,并保证批准后不重复执行。
参考答案:把金额阈值判断从 instruction 提到代码节点;超阈值时不调用退款工具,而是触发一次需要人工输入的暂停,写入审批卡片(businessKey、订单号、金额、政策依据);审批结果回来后用同一 session 续跑,退款工具接收 businessKey 并在服务端按 key 去重。验证方式是同一 businessKey 连续触发两次,确认只产生一个退款单号——这类验证必须实际跑,不能靠推断。
12.3 排障题:并行取数阶段只有订单结果进了 state,政策结果丢了,怎么定位?
参考答案:先打印每个事件的 actions().stateDelta().keySet(),确认 policyQueryAgent 到底有没有写出状态增量。如果有增量但下游读不到,检查下游引用的模板变量名与 outputKey 是否一致——这类不匹配不会抛异常,只会静默替换成空字符串。如果连增量都没有,检查 policyQueryAgent 是否提前结束或抛出被吞掉的异常。最后把状态键命名校验加进启动流程,让它在启动期而不是运行期失败。
12.4 架构判断题:既然 LoopAgent 能循环,能不能用它替代 Retry 逻辑?
参考答案:不能直接替代。LoopAgent 的语义是重复执行循环体,它不区分"这一步是查询还是退款"。把它当重试按钮用,等于把整个循环体连同副作用一起重放。正确的做法是:重试策略挂在无副作用的调用点上,带上限和退避;LoopAgent 只用于有明确退出条件的等待类场景,比如轮询审批结果是否返回,并且必须配置最大轮次。
写死非保守
是为可恢复
循环非重试
语义要分清
13、思考
这一境真正的转折点,不是学会了三个 Workflow Agent 的用法,而是承认了一件事:不可逆动作不能交给概率。
过去我们把"金额超过 500 先问一下"写进提示词,本质是把合规责任外包给了采样温度。今天我们把它提成代码节点,锁在副作用之前,暂停、审批、续跑全都在事件流里留痕。这不是让数字人变笨了,是让它做的事可以被追溯、被中断、被接上。
另一个必须带走的判断是:恢复是两件事,框架负责恢复位置,业务负责恢复边界。任何声称"开了可恢复执行就不会重复"的说法,都忽略了副作用那一半。业务幂等键必须由发起方在第一步就确定,而不是由执行方在最后一刻生成。
所以当你要给数字人加一条新流程时,先问三个问题:这条流程的步骤能不能枚举?哪些步骤有不可逆副作用?这些副作用的业务键由谁生成?三个都答得上来,才值得写 Workflow。
空间先划定
动钱先落锁
恢复分两半
各归各的位

浙公网安备 33010602011771号