斗破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,网络抖动和"订单不存在"走同一条路,要么全重试要么全终止。

本境要拿到"空间锁",完成标准必须可验证:

  1. 同一 businessKey 重复投递,退款副作用只发生一次。
  2. 审批点暂停后,用同一 session 续跑,已完成步骤不重复执行。
  3. 并行子 Agent 的结果通过 outputKey 进入 Session State,下游节点可引用。
  4. 错误分为可恢复、不可恢复、需人工三类,最大恢复次数可配置。
  5. 以上能力在同一个数字人项目里跑通,并有测试结果。

提示词当契约
中断当没发生
副作用无锁
出事才知疼

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。

空间先划定
动钱先落锁
恢复分两半
各归各的位

posted @ 2026-10-10 22:22  李福春  阅读(1)  评论(0)    收藏  举报