给 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 的那条路,两者的恢复语义不一样。