06-给AI流程加一道人工闸门

给 AI 流程加一道人工闸门:Graph 中断、threadId 对话隔离与条件边

这是我"Java 转 AI 工程"系列的第 6 篇。前面几篇把库存智能调拨的 Agent 装了起来:采数据、喂提示词、让大模型输出调拨建议。这一篇要干一件相反的事------在这条链路最关键的那一步,把它踩住。

一、事故:一条幻觉调拨单,走完了整条链路

先说清楚我为什么要加这道闸门,因为它是真出过事的。

那会儿链路是全自动的:采集销售数据 + 采集库存订单数据 → LLM 预测分析 → LLM 提取 JSON → 直接生成调拨单落库。跑通那天我挺得意,直到有人在群里问:"华北仓这批货怎么跑出去了?"

查下来,那张调拨单是大模型生成的。它的 comment 写得比谁都专业------有历史销量、有安全库存、有季节性趋势,读起来完全像个干了三年计划员的人写的。问题是里面有个前提是编的。模型把某个仓的销量往前提了一档,缺口就成立了,调拨量也就"合理"地成立了。

而系统这边没有任何一处知道这是幻觉:bb_transfer_order 里躺着一条 status=0 的待处理单据,createdBy 老老实实写着"AI智能助手",下游审批流、库存联动、按单据口径统计的绩效数据,全部被这一条污染了。

这就是企业里跑大模型最要命的一点:它胡说八道的样子,和它说真话的样子,在数据层面长得一模一样。 而调拨单是一个不可逆动作的起点------单子一旦生成,就会有人去执行它。

二、"全自动"在企业里为什么不成立

我后来把"要不要留人工口子"这件事拆成了四个问题,任何一条答不上来,这个环节就不该全自动:

问题 全自动的代价 人工闸门给的东西
输出会不会直接变成业务数据 幻觉落库,污染真实单据和统计口径 审核通过才写业务库,否则流程直接结束
出错之后责任算谁的 "模型说的"不是一句能交差的话 审批记录里是一个具体的人点了一次按钮
动作可不可逆 邮件已发、单已下、货已在路上 中断点就是那条"撤销键"
能不能审计回放 只有日志,没有决策留痕 谁在什么时间采纳/拒绝,都是状态机里的数据

反过来,人工审核也不是免费的。讲义里把它列为难点我没意见:审批延迟会直接影响业务时效性 ------调拨建议半夜生成,管理员早上九点才点,旺季的缺口就摆在那儿等。所以我现在的立场比较明确:不是"能加审核就加",而是只在不可逆动作前加,一个流程里最多一两个卡点,其余环节让它自动跑。

审核这件事落到 Graph 上,具体就是三个动作:中断 → 召回 → 激活。

三、中断:让图在指定节点前停下来

两种中断模式,我选了笨的那个

Graph 给了两条路:

模式 怎么中断 特点 我会用它吗
InterruptionMetadata 节点自己实现 InterruptableAction 接口,运行时按当前状态决定要不要停 灵活、状态感知,中断逻辑长在节点里 只在"同一个节点有时要人看、有时不用"时
interruptBefore 编译图的时候就把节点名写死,执行到它之前自动停 配置简单,节点是普通 NodeAction,中断位置编译期就看得见 默认选这个

人工审核点是一个位置固定的卡点 ,不需要运行时判断。所以我选了 interruptBefore------节点自己完全不用感知"我被中断了"这件事,代码复杂度低一大截。灵活性和维护成本之间,这种场景我没犹豫。

编译配置

原来一行 stateGraph.compile() 的地方,改成先攒一个 CompileConfig:

java 复制代码
// 定义编译配置
CompileConfig compileConfig = CompileConfig.builder()
        .interruptBefore("humanApprovalNode")
        .build();

return stateGraph.compile(compileConfig);

StateGraph 那边只是多挂一个节点、改一条边:

java 复制代码
StateGraph stateGraph = new StateGraph("inventoryTransferGraph", keyStrategyFactory);

stateGraph.addNode("sendEmailNode",
        AsyncNodeAction.node_async(new SendEmailNode(emailService)));
stateGraph.addNode("humanApprovalNode",
        AsyncNodeAction.node_async(new HumanApprovalNode()));

stateGraph.addEdge("sendEmailNode", "humanApprovalNode");

顺手把 CompileConfig 的字段抄一份在这儿,我是反编译看了一眼才敢确认自己配对了什么:

  • interruptsBefore / interruptsAfter:Set<String>,在哪些节点前/后中断,可以配多个;
  • releaseThread:布尔,中断后是否释放线程;
  • interruptBeforeEdge:布尔,是否在边之前中断;
  • saverConfig:检查点往哪儿存------这个字段是下一章的主角,本篇它还是默认的内存实现。

人类审核节点本身

中断只解决"停",不解决"停下来之后拿什么"。真正读人工反馈的是节点自己:

java 复制代码
package com.carl.ai.transfer.nodes;

public class HumanApprovalNode implements NodeAction {

    /**
     * 业务逻辑:
     * 1. 因为这个节点前会阻塞,所以要拿到人类反馈接口调用时传过来的参数
     * 2. 产生一个新的 key,给后面的条件边做判断用
     */
    @Override
    public Map<String, Object> apply(OverAllState state) throws Exception {
        Map<String, Object> data = state.humanFeedback().data();
        Boolean approval = (Boolean) data.getOrDefault("approval", false);

        String nextStep = StateGraph.END;
        if (approval) {
            nextStep = "createInventoryTransferNode";
        }
        return Map.of("humanApprovalNextStep", nextStep);
    }
}

三行值得停一下:

  • state.humanFeedback().data() 就是外部塞进来的那份 Map,节点被恢复执行时才读得到;
  • 节点不直接决定走哪条路 ,它只往状态机里写一个 humanApprovalNextStep。决定权交给边------这个分工后面第六节会展开;
  • 默认值是 false。看着很安全(拿不到反馈就结束),但它同时也是个静默陷阱,第六节的坑里会说。

四、threadId:对话隔离,不加会串会话

图能停了,新问题马上来了:中断之后,图"停在哪"这件事是一次具体对话的状态,不是全局状态。管理员点的是邮件里那个链接,是一个全新的 HTTP 请求,它凭什么找回"我这一单"?

答案是一个字符串:threadId。

触发时就把对话 ID 生成好

java 复制代码
@GetMapping("/sale")
public Map<String, Object> sale(@RequestParam String productId) {
    String threadId = IdUtil.simpleUUID();   // Hutool,32 位无横线 UUID

    RunnableConfig runnableConfig = RunnableConfig.builder()
            // 传入线程 id 是为了对话隔离,让 Agent 知道这次要处理哪个对话
            .threadId(threadId)
            .build();

    OverAllState overAllState = graph.call(
            Map.of("productId", productId,
                   "threadId", threadId),
            runnableConfig).get();          // ← 这个参数是本节全部的教训

    return overAllState.data();
}

threadId 在这里出现了两次,作用完全不同,这是我一开始最容易混的地方:

放哪儿 谁在用 少了会怎样
RunnableConfig.threadId(...) 框架:检查点按它归档,getState() 靠它找回对话 后面召回直接报 Missing Checkpoint!
状态 Map 里的 "threadId" 业务:邮件节点要拿它拼审批链接 链接里没有会话标识,点了不知道恢复谁

所以键策略里也得给它注册一个 key(图能跑起来的前提是所有写入的 key 都声明过):

java 复制代码
strategies.put("threadId", new ReplaceStrategy());

ReplaceStrategy 是对的:一个对话对应一个 threadId,它是"当前值"不是"流水账",追加策略会把它变成 List。

邮件里的两个按钮

SendEmailNode 从状态里把建议正文和 threadId 都取出来,拼两条 URL 交给模板:

java 复制代码
String inventoryTransferJsonStr = state.value("inventoryTransferJsonStr", "").toString();
JSONObject json = JSONUtil.parseObj(inventoryTransferJsonStr);
String comment = json.getStr("comment");
String threadId = state.value("threadId", "").toString();

String baseUrl = "http://localhost:8876/saleProduct/approval?approval=";
emailService.sendTemplateEmail("ops@company.com",
        Map.of("inventoryTransferSaveParamStr", comment,
               "adoptLink",  baseUrl + "true&threadId="  + threadId,
               "rejectLink", baseUrl + "false&threadId=" + threadId));

模板是 Thymeleaf 渲染的,th:href 直接绑这两个变量:

html 复制代码
<div class="button-container">
    <a th:href="${adoptLink}"  class="button adopt-button">采纳</a>
    <a th:href="${rejectLink}" class="button reject-button">拒绝</a>
</div>
<p>如果您无法点击按钮,请复制以下链接到浏览器中打开:</p>

最后那句"复制链接"的备用方案不是摆设------邮件客户端把链接转义坏掉过一次,管理员点了没反应,我人在会议室,只能靠嘴指挥他把整条 URL 粘到地址栏。

不用 threadId 会怎样

这个我推演过,比报错更难看。假设所有请求都共用同一个默认会话:

  • 管理员 A 生成了一张调拨单,流程停在审核点;
  • 十分钟后管理员 B 也生成一张,同样停在审核点,同一个"当前对话";
  • A 回来点"采纳",getState() 捞到的是 B 那次中断的快照,withResume() 唤醒的是 B 的流程;
  • A 以为批了自己的单,实际替 B 做了决定;B 的单还挂着,谁也不知道。

审核类接口的语义是"我对这一条 建议做决定"。没有会话标识,这个"这一条"根本不存在。而且这类 bug 不抛异常,它安静地给你生成一张错误的单据------和第 3 篇里 blockFirst() 那个坑是同一种恶心。

五、审核 → 召回 → 激活:全链路

把上面所有零件串起来,一次完整的审批长这样:
/approval 接口 管理员邮箱 状态机/检查点 CompiledGraph 调用方 /approval 接口 管理员邮箱 状态机/检查点 CompiledGraph 调用方 #mermaid-svg-9Uk2olvXktIrNlu9{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-9Uk2olvXktIrNlu9 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-9Uk2olvXktIrNlu9 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-9Uk2olvXktIrNlu9 .error-icon{fill:#552222;}#mermaid-svg-9Uk2olvXktIrNlu9 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-9Uk2olvXktIrNlu9 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-9Uk2olvXktIrNlu9 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-9Uk2olvXktIrNlu9 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-9Uk2olvXktIrNlu9 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-9Uk2olvXktIrNlu9 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-9Uk2olvXktIrNlu9 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-9Uk2olvXktIrNlu9 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-9Uk2olvXktIrNlu9 .marker.cross{stroke:#333333;}#mermaid-svg-9Uk2olvXktIrNlu9 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-9Uk2olvXktIrNlu9 p{margin:0;}#mermaid-svg-9Uk2olvXktIrNlu9 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-9Uk2olvXktIrNlu9 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-9Uk2olvXktIrNlu9 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-9Uk2olvXktIrNlu9 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-9Uk2olvXktIrNlu9 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-9Uk2olvXktIrNlu9 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-9Uk2olvXktIrNlu9 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-9Uk2olvXktIrNlu9 .sequenceNumber{fill:white;}#mermaid-svg-9Uk2olvXktIrNlu9 #sequencenumber{fill:#333;}#mermaid-svg-9Uk2olvXktIrNlu9 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-9Uk2olvXktIrNlu9 .messageText{fill:#333;stroke:none;}#mermaid-svg-9Uk2olvXktIrNlu9 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-9Uk2olvXktIrNlu9 .labelText,#mermaid-svg-9Uk2olvXktIrNlu9 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-9Uk2olvXktIrNlu9 .loopText,#mermaid-svg-9Uk2olvXktIrNlu9 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-9Uk2olvXktIrNlu9 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-9Uk2olvXktIrNlu9 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-9Uk2olvXktIrNlu9 .noteText,#mermaid-svg-9Uk2olvXktIrNlu9 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-9Uk2olvXktIrNlu9 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-9Uk2olvXktIrNlu9 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-9Uk2olvXktIrNlu9 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-9Uk2olvXktIrNlu9 .actorPopupMenu{position:absolute;}#mermaid-svg-9Uk2olvXktIrNlu9 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-9Uk2olvXktIrNlu9 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-9Uk2olvXktIrNlu9 .actor-man circle,#mermaid-svg-9Uk2olvXktIrNlu9 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-9Uk2olvXktIrNlu9 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 线程交还容器,流程挂起等人 /sale?productId=1 + RunnableConfig(threadId) 采集 → 预测 → 提取 发调拨建议邮件(链接带 threadId) interruptBefore 命中,停在 humanApprovalNode 前 点击采纳(approval=true, threadId=xxx) getState(runnableConfig) 召回对话 StateSnapshot(含完整状态) state.withResume() + withHumanFeedback(map) graph.call(state, runnableConfig) 激活 humanApprovalNode 执行,读 approval 条件边分流 落库生成调拨单 / 直接 END

对应的接口实现:

java 复制代码
@GetMapping("/approval")
public R approval(@RequestParam(value = "approval") Boolean approval,
                  @RequestParam(value = "threadId") String threadId) {
    RunnableConfig runnableConfig = RunnableConfig.builder()
            .threadId(threadId)
            .build();
    try {
        // 1) 召回:按 threadId 把那次中断的上下文捞回来
        StateSnapshot stateSnapshot = graph.getState(runnableConfig);
        OverAllState state = stateSnapshot.state();
        log.info("stateSnapshot state=[{}]", state);

        // 2) 声明"我要继续跑了"
        state.withResume();

        // 3) 把人的决定塞进 humanFeedback
        Map<String, Object> map = new HashMap<>();
        map.put("approval", approval);
        state.withHumanFeedback(new OverAllState.HumanFeedback(map, ""));

        // 4) 用同一个 threadId 重新驱动这张图
        OverAllState overAllState = graph.call(state, runnableConfig).get();
        Map<String, Object> data = overAllState.data();
        return R.success(data);
    } catch (Exception e) {
        log.error("error msg=[{}]", e.getMessage());
    }
    return R.success();
}

四个动作各有分工,我一开始是把它们当成一件事的:

  • getState(runnableConfig) 只负责读 ,拿到 StateSnapshot,.state() 才是那份 OverAllState;
  • withResume() 不接受任何参数,它只是把状态里的 resume 标记翻成 true;
  • withHumanFeedback(...) 的第二个参数是 nextNodeId,这里传空串------工作流会自己识别该在哪个被中断的节点上恢复,不需要我告诉它;
  • 最后 graph.call(state, runnableConfig) 用的是"传状态 + 传配置"的那个重载,不是重新传一个初始 Map。传错重载,等于又开了一轮新流程。

召回时打印出来的状态,就是审核员那一刻能看到的全部事实:

json 复制代码
{
  "threadId": "82ff63525728f42f390f55c36d3b24525",
  "productId": 1,
  "inventoryTransferStr": {
    "sourceWarehouseId": 3,
    "targetWarehouseId": 1,
    "status": 0,
    "createdBy": "AI智能助手",
    "transferDate": "2025-11-27",
    "comment": "基于销售数据分析,华北仓(WH001)Q4 需求呈上升趋势,当前库存仅 245 件,低于安全库存水平......建议调拨 80 件以平衡库存并满足预期需求。"
  },
  "items": [
    { "productId": 1, "transferQuantity": 80, "actualQuantity": 0,
      "remark": "调拨以应对华北仓Q4销售旺季需求" }
  ]
}

调试的时候我还会多看一眼状态对象尾部那几个字段:resume=false, humanFeedback=null, interruptionAssist=null------这三个值就是"这张图现在是不是在等人"的体检指标。每次恢复失败,先看它们动没动。

一个 500,控制台还什么都不打

第一次点审批链接,浏览器给我的是 Whitelabel Error Page,type=Internal Server Error, status=500,控制台干净得像刚格式化过。

排查手法很朴素,但有效:接口方法第一行下断点 → 看 approval 和 threadId 两个参数收没收全 → 用这个 threadId 去 getState() 那行单步。断点进去了,说明映射没问题;炸在 getState() 里,异常是:

复制代码
java.lang.IllegalStateException: Missing Checkpoint!

原因特别蠢:我在状态 Map 里塞了 threadId,却没给 graph.call() 传 RunnableConfig。 检查点从来没按这个 threadId 归档过,恢复时拿着身份证去 4S 店提车,系统里查无此人。

修好之后我又做了一件事:把 try-catch 补上,log.error("error msg=[{}]", e.getMessage())。500 不打日志的根源就在这儿------异常被框架吞了,容器返回了状态码,但没人把它写进日志文件。这类"接口失败但现场无痕"的问题,靠加一行 error 日志就能省掉半小时。

六、条件边:1 个条件边 = N 种执行路径

链路跑通了,还剩一件事没落地:审核节点写进状态机的 humanApprovalNextStep,谁来消费它?

最省事的写法是在节点里直接判断然后......然后没有然后,节点管不了下一步走谁。图的哲学是:节点负责"算出什么",边负责"往哪走"。 分支不该藏在节点的方法体里,它应该长在图上,让人一眼看得见。
#mermaid-svg-9Dv0DejcO8wtUMTI{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-9Dv0DejcO8wtUMTI .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-9Dv0DejcO8wtUMTI .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-9Dv0DejcO8wtUMTI .error-icon{fill:#552222;}#mermaid-svg-9Dv0DejcO8wtUMTI .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-9Dv0DejcO8wtUMTI .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-9Dv0DejcO8wtUMTI .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-9Dv0DejcO8wtUMTI .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-9Dv0DejcO8wtUMTI .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-9Dv0DejcO8wtUMTI .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-9Dv0DejcO8wtUMTI .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-9Dv0DejcO8wtUMTI .marker{fill:#333333;stroke:#333333;}#mermaid-svg-9Dv0DejcO8wtUMTI .marker.cross{stroke:#333333;}#mermaid-svg-9Dv0DejcO8wtUMTI svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-9Dv0DejcO8wtUMTI p{margin:0;}#mermaid-svg-9Dv0DejcO8wtUMTI .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-9Dv0DejcO8wtUMTI .cluster-label text{fill:#333;}#mermaid-svg-9Dv0DejcO8wtUMTI .cluster-label span{color:#333;}#mermaid-svg-9Dv0DejcO8wtUMTI .cluster-label span p{background-color:transparent;}#mermaid-svg-9Dv0DejcO8wtUMTI .label text,#mermaid-svg-9Dv0DejcO8wtUMTI span{fill:#333;color:#333;}#mermaid-svg-9Dv0DejcO8wtUMTI .node rect,#mermaid-svg-9Dv0DejcO8wtUMTI .node circle,#mermaid-svg-9Dv0DejcO8wtUMTI .node ellipse,#mermaid-svg-9Dv0DejcO8wtUMTI .node polygon,#mermaid-svg-9Dv0DejcO8wtUMTI .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-9Dv0DejcO8wtUMTI .rough-node .label text,#mermaid-svg-9Dv0DejcO8wtUMTI .node .label text,#mermaid-svg-9Dv0DejcO8wtUMTI .image-shape .label,#mermaid-svg-9Dv0DejcO8wtUMTI .icon-shape .label{text-anchor:middle;}#mermaid-svg-9Dv0DejcO8wtUMTI .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-9Dv0DejcO8wtUMTI .rough-node .label,#mermaid-svg-9Dv0DejcO8wtUMTI .node .label,#mermaid-svg-9Dv0DejcO8wtUMTI .image-shape .label,#mermaid-svg-9Dv0DejcO8wtUMTI .icon-shape .label{text-align:center;}#mermaid-svg-9Dv0DejcO8wtUMTI .node.clickable{cursor:pointer;}#mermaid-svg-9Dv0DejcO8wtUMTI .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-9Dv0DejcO8wtUMTI .arrowheadPath{fill:#333333;}#mermaid-svg-9Dv0DejcO8wtUMTI .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-9Dv0DejcO8wtUMTI .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-9Dv0DejcO8wtUMTI .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9Dv0DejcO8wtUMTI .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-9Dv0DejcO8wtUMTI .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9Dv0DejcO8wtUMTI .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-9Dv0DejcO8wtUMTI .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-9Dv0DejcO8wtUMTI .cluster text{fill:#333;}#mermaid-svg-9Dv0DejcO8wtUMTI .cluster span{color:#333;}#mermaid-svg-9Dv0DejcO8wtUMTI div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-9Dv0DejcO8wtUMTI .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-9Dv0DejcO8wtUMTI rect.text{fill:none;stroke-width:0;}#mermaid-svg-9Dv0DejcO8wtUMTI .icon-shape,#mermaid-svg-9Dv0DejcO8wtUMTI .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-9Dv0DejcO8wtUMTI .icon-shape p,#mermaid-svg-9Dv0DejcO8wtUMTI .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-9Dv0DejcO8wtUMTI .icon-shape .label rect,#mermaid-svg-9Dv0DejcO8wtUMTI .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-9Dv0DejcO8wtUMTI .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-9Dv0DejcO8wtUMTI .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-9Dv0DejcO8wtUMTI :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} humanApprovalNextStep = createInventoryTransferNode
其余情况
START
saleRecordDataNode

销售数据采集
inventoryOrderDataNode

库存订单采集
predictNode

LLM 预测分析
extractNode

LLM 提取 JSON
sendEmailNode

发审批邮件
humanApprovalNode

中断点:等人工
createInventoryTransferNode

落库生成调拨单
END

分支逻辑单独一个类,实现 EdgeAction:

java 复制代码
package com.carl.ai.transfer.edges;

public class ApprovalEdge implements EdgeAction {

    /**
     * 业务逻辑:拿到人类审核节点生成的 key,用来判断下一个节点应该是什么
     */
    @Override
    public String apply(OverAllState state) throws Exception {
        String humanApprovalNextStep = state.value("humanApprovalNextStep", "").toString();
        if ("createInventoryTransferNode".equals(humanApprovalNextStep)) {
            return "createInventoryTransferNode";
        }
        return StateGraph.END;
    }
}

挂到图上,用 addConditionalEdges,三个参数:

java 复制代码
stateGraph.addConditionalEdges(
        "humanApprovalNode",                                     // sourceId:从哪个节点分叉
        AsyncEdgeAction.edge_async(new ApprovalEdge()),          // 条件判断
        Map.of(                                                  // 返回值 → 目标节点 的映射表
                "createInventoryTransferNode", "createInventoryTransferNode",
                StateGraph.END, StateGraph.END));

三行代码,语义是:apply() 返回的字符串当 key,去第三参数那张 Map 里查目标节点 ID。 所以那张 Map 就是这个节点所有出口的路牌,有几个 key 就有几条路------"1 个条件边 = N 种执行路径"这句话,说的就是出口数量不再受方法体里 if-else 的层数限制,而是由一张声明式的表决定。

它比 if-else 强的地方很具体:

  • 想知道这个审核点能走去哪,看 Map 的 keySet 就够了,不用读逻辑;
  • 要加"转给上级复审"这条路径,是加一个 key + 一条节点,不是往别人的分支里插代码;
  • 评审时这张图能直接画出来给人看,if-else 不行。

顺带一个观察:上面这个例子里 key 和目标节点名恰好相同(自己映到自己),看着多余,但这两件事语义上是分开的 ------key 是"业务决定的结果",value 是"图上的节点 ID"。我后面刻意让节点 ID 保持 createInventoryTransferNode 这种带 Node 后缀的写法,就是为了提醒自己和别人:Map 的 value 必须能在 addNode 里找到同名节点。

七、剩下三个坑,全都是不报错的那种

坑 1:approval 拼成 approve,流程安静地结束了

邮件链接、Controller 参数、map.put("approval", ...)、节点里的 getOrDefault("approval", false)------这个 key 在四个地方各写了一遍字符串字面量。任何一处拼错,getOrDefault 都会礼貌地返回 false,条件边判定走 END,接口返回 200,日志里一个 error 都没有。

单据没生成,你以为是模型没建议,模型的建议其实好好地躺在状态里。

我现在做两件事:key 名提成常量(ApprovalKeys.APPROVAL / HUMAN_APPROVAL_NEXT_STEP),以及在审核节点里对 data.isEmpty() 打一条 warn------"没收到任何人类反馈"和"收到但值是 false"是两种完全不同的事故,必须区分开。

坑 2:条件边映射表和 apply() 返回值对不上

apply() 返回 "CREATE_TRANSFER_ORDER",Map 里的 key 写的是 "createInventoryTransferNode"------查不到目标节点,运行期炸。这个坑至少会报错,比上一个诚实。

规则很硬:Map 的 key 集合必须覆盖 apply() 所有可能的返回值 。所以我现在约定 apply() 的每个 return 分支都从常量里取,不写字面量;末尾一定留一个兜底返回值,并且给它也在映射表里留一个出口(通常是 END)。宁可让流程"保守地结束",也不要"找不到路"。

坑 3:普通边和条件边同时挂在同一个节点上

改造之前那条边是 addEdge("humanApprovalNode", "createInventoryTransferNode")。加条件边时我只是往下面追加了一行,忘了上面那行还在。改完之后我在图上看到 humanApprovalNode 有两条出边,才反应过来把固定边删掉。

这件事真正的价值不在"会不会报错",而在流程的可读性:一张图上同一个节点既有固定出口又有条件出口,下一个读代码的人(三个月后的我)没法确定到底哪条在生效。分支要么全在条件边上,要么一条固定边都没有,混着放就是在给未来埋雷。

八、跑通之后

现在这条链路的完整形状是:

复制代码
/sale → 采集(并行) → 预测 → 提取 → 发邮件 → 【中断,等人】
                                              ↓
                          /approval?approval=&threadId= → 召回 → 激活
                                              ↓
                              条件边:采纳 → 生成调拨单 → END
                                     拒绝 → END

写到这里,我对"人机协同"的理解变了。

一开始我以为它是给 AI 打补丁------模型不靠谱,所以派人盯着。做完这一章我更愿意说:它是流程设计的一部分,和分支、循环同级。 因为大模型的输出天生不确定,"要不要让人看一眼"就不是一个安全需求,而是一个业务建模需求。你在画流程图的时候本来就会标出一个菱形"此处需审批",Graph 只是允许你把那个菱形直接写进代码。

而这套机制的三块拼图,分工其实很干净:

  • 中断 (interruptBefore)解决"在哪停",配置在编译期,节点自己不知情;
  • 对话隔离 (RunnableConfig.threadId)解决"停的是哪一次",这是所有能挂起的流程都必须回答的问题,跟 AI 无关;
  • 人类反馈 + 条件边解决"人说了算之后往哪走",决定权从节点交到了图上。

不过这套东西现在有个致命前提:那份被挂起的状态,只活在当前进程的内存里。 管理员周末才想起来没点采纳,而服务周三重启过一次------这条对话就永远回不来了,Missing Checkpoint! 会以一种你完全没预料到的方式回来找你。

下一章就把这个洞补上:什么是检查点,怎么把 Graph 配置改造成 Redis 持久化,让工作流状态在进程重启之后还能被召回。


本系列是自己学习 Spring AI Alibaba Graph 的工程记录,本篇代码是照讲义笔记重建的,方法签名请以你本地依赖的反编译结果为准。中断模式我全程用的是 interruptBefore;如果你的场景需要节点按运行时状态自行决定是否中断,要换成实现 InterruptableAction 的那条路,两者的恢复语义不一样。

相关推荐
花间相见2 小时前
【计算基础|网络06】HTTPS(上):加密体系与 RSA 握手
后端·面试
夜之眷属2 小时前
JVM实战:服务器堆外内存去哪了(NMT实测)
java·服务器·jvm·后端·性能优化
lisw052 小时前
图像质量评估:从误差可见度到结构相似性
人工智能·机器学习·计算机视觉
vipxieliang2 小时前
PHP 依赖注入容器从零实现:控制反转、手动注入与容器管理全解析
后端·php
阿明副业观察2 小时前
AI视频生成软件:究竟用平板还是电脑更胜一筹?
人工智能·电脑
段一凡-华北理工大学2 小时前
高炉炼铁机器视觉与智能识别十八讲~系列文章09:AI 算法基础:从传统图像处理到深度学习的视觉“大脑“
图像处理·人工智能·算法·机器视觉·工业智能化·高炉炼铁智能化·高炉智能识别
京东云开发者2 小时前
百万奖池加持,京东Aidol创造营S2等你报名!
人工智能
fundoit2 小时前
OIDC的UserInfo端点
java·架构·oauth2·oidc
jason.zeng@15022072 小时前
(七)「固化 Rest 接口 + Text-to-SQL 灵活查询」双模式 Agent 架构教程
数据库·python·sql·ai·架构·langchain·ai编程