降SpringAI阿里第11掌-鱼跃于渊-图谱Graph
降SpringAI阿里第11掌-鱼跃于渊-图谱Graph
图不是画出来的,是状态合并出来的
[读者] 已经把 ChatClient、Tool、MCP、RAG 都跑通,但流程还是塞在一个大 Service 里
[痛点] 分支、并行、人工确认、断点恢复全靠 if-else,状态散落在十几个局部变量里
[现在读] 数字人售后处理要并行取数、要人工兜底,串行方法已经顶不住
[读完] 能用 StateGraph 把售后流程建模成显式图,做到可可视化、可中断、可恢复
[旧方案] 一个 handleAfterSale() 方法里串行调用意图识别、知识检索、订单查询、风险判断
|
v
[新需求] 知识检索与订单查询要并行;高风险工单必须停下来等人确认;进程重启后能接着跑
|
v
[冲突] 串行方法把“执行顺序”和“状态归属”揉在一起,人与机器的边界无处安放
|
v
[后果] 人工确认只能靠阻塞等待或另起一张表,恢复靠重跑,一次售后要翻三段日志
我是老李,前面十掌我们把地基铺完了:选型、环境、数字人底座、ChatClient、Memory、Streaming、Tool、MCP、RAG,一路走到 ReactAgent。每一掌都在往数字人项目里加能力,但加的姿势一直是“写方法、串调用”。
这一掌要动的是更下面的东西。数字人项目的售后页上,用户发来一句话:订单要退款、问物流、催发货。这句话进来之后,系统要做的事其实是四件:判断意图、查知识库、查订单、判断风险。前两件可以并排跑,第三件依赖前两件的结果,第四件的输出不是文字,而是“停下来等人”。
问题就出在“停下来等人”这四个字上。它不是一次方法调用能表达的东西,它是一条流程的边界。
当一条售后请求既要查知识、又要查订单,还要在金额超限时停下来等人,你还打算用 if-else 编排吗?
当“人工确认”这个动作要等上半小时,进程重启之后,这条流程还能从原处继续吗?
当选错了编排方式,是把代码写复杂了,还是把业务状态给写丢了?
01、故事
现场是数字人项目的售后运营台。左边是会话窗口,右边是工单列表,中间有一栏“待人工处理”。
任务是:把用户发来的一句自然语言售后请求,走完意图识别、知识检索、订单查询、风险判断、人工确认、最终答复这六步,并且每一步都要能单独看、单独测、单独改。
第一版方案很朴素,所有人第一版都会这么写。一个 AfterSaleService.handle(String question),里面从上到下:
public String handle(String question) {
String intent = classify(question);
String knowledge = searchKnowledge(question);
String order = queryOrder(question);
String risk = assessRisk(order);
if ("HIGH".equals(risk)) {
String decision = humanReviewService.blockAndWait(order);
if (!"APPROVE".equals(decision)) {
return "已转人工,请稍候。";
}
}
return compose(intent, knowledge, order);
}
这个版本能跑,而且在本地跑得很好。运营同学点一下,秒回。
然后需求开始变。知识检索要接 RAG 了,要 300 毫秒;订单查询要接外部 ERP,要 800 毫秒。两个加起来一秒一,用户在页面上就得干等。产品说,能不能一起查。
再然后,人工确认从“运营在页面上点一下”变成了“运营主管在手机上审批,可能半小时后才点”。原来的 blockAndWait 直接卡死一个 Tomcat 线程,半小时后线程池就没了。
再再然后,运维要发版。发版那一刻,所有卡在“等人工确认”的工单,全部回到起点。运营主管第二天点开审批列表,发现昨天点了同意的工单,今天又要重新点一遍。
冲突就很清楚了:handle() 这个方法里,执行顺序是代码写死的,状态是局部变量持有的,人机边界是一个 if 塞进去的。三者耦合在一起,任何一个需求变化,都要重写整个方法。
流程写进方法
状态散落各处
需求多拐一道
代码就散了架
02、问题
旧方案失效得很具体。
业务影响上,最贵的是“重复审批”。运营主管的审批动作被系统重放,用户等了两遍,主管点了两遍,工单时间线里出现两条一模一样的记录。
技术表现上,有三条同时发作:
第一条,串行导致响应时间是两个外部依赖之和。知识检索和订单查询之间没有任何数据依赖,却被迫排队。
第二条,阻塞导致线程被长期占用。人工确认的等待时间由人决定,不由系统决定,用线程去等人是把不确定的时延放进线程池。
第三条,状态在内存里。局部变量随方法结束而消亡,随进程重启而消失,没有任何地方能回答“这条工单现在卡在哪一步”。
我们要的完成标准是可以被验证的,不是“感觉变好了”。这一掌的验收线有四条:
一是图可视化。整条售后流程能画出来,节点是节点,边是边,条件分支是条件分支,不需要读代码才能知道流程。
二是可中断。流程能在“人工确认”这个节点前停下来,把控制权交回给调用方,调用方不必阻塞线程。
三是可恢复。拿着同一个会话标识再次进入,流程从断点处继续,前面的意图识别、知识检索、订单查询不重跑。
四是状态可持久化。中断点的状态能落到某个存储里,进程重启后仍然拿得到。
这四条里,前两条是编排问题,后两条是状态问题。它们其实是同一个问题的两面。
旧路越走越窄
人工卡在中途
重启从头再来
一次售后三查
03、原理
Graph Runtime 的世界里,有三样东西:State、Node、Edge。
Node 是一个执行单元,它接收当前状态,做一件事,返回一个 Map<String, Object>。注意它返回的不是新状态,是“对这个状态的部分修改”。
Edge 描述节点之间的流转关系。普通边是确定的下一个节点;条件边由一段逻辑决定走向哪个节点。
State 是这一切之间的桥。它持有一个 key-value 结构,每个 key 有一个策略(KeyStrategy),决定当多个节点都往同一个 key 写值时,最终这个 key 该是什么。
这里有个反直觉判断:节点不修改状态,节点只是提交变更请求,真正决定状态的是一套归约规则。
大多数人第一次看 StateGraph,会以为它是“把 if-else 画成图”。不是。它的核心不在节点,在归约。节点的代码可以完全正确,归约策略配错了,结果照样是错的——而且错得很安静,不报异常,只是数据没了。
为什么?因为并行分支。当 knowledge 和 biz 两个节点并行执行、同时写状态时,框架必须回答一个问题:这两个写操作怎么合并。如果没有显式声明策略,就会落到默认行为上,通常是后者覆盖前者。
这解释了三件事。
第一,为什么并行不是“加个线程池”就行。并行真正要处理的是合并语义,不是并发本身。
第二,为什么中断和恢复在 Graph 里是原生能力,在方法调用里不是。因为状态被显式建模了,它才能被序列化、被落盘、被重新载入。方法里的局部变量天然不可序列化。
第三,为什么官方建议“一般 Agent 开发优先用 Agent Framework,需要精细流程和状态控制再直接用 Graph”。Agent Framework 帮你把状态归约、工具循环、中断这些都封好了;直接上 Graph,你拿到的是全部控制权,代价是所有归约规则都得自己想清楚。
还有一个概念要提前说清楚:threadId。它属于运行时配置(RunnableConfig),不属于业务状态。中断点、检查点、恢复位置,都是按这个标识索引的。把它当成业务字段塞进状态里,恢复就会失败——后面第 06 章会踩这个坑。
节点只管返回
归约才定真相
顺序不是逻辑
合并才是契约
04、架构
[输入] 售后页传来一句话:“我的订单 A20240617 想退款,已经签收了”
|
v
[模块] StateGraph:intent → (knowledge ∥ biz) → risk → humanReview? → reply
|
v
[数据/状态] OverAllState:intent / knowledgeHits / orderInfo / riskLevel / humanDecision / messages
|
v
[处理] 并行分支汇合 → 条件边判定 → interruptBefore 暂停 → CheckpointSaver 落盘 → 恢复继续
|
v
[输出] 答复文本(或“已转人工”),并留下一条可回放的状态快照
边界在哪?图负责“流程结构 + 状态归约 + 中断恢复”,不负责业务语义。什么算高风险、退款政策怎么写、订单从哪个系统查,这些全部留在节点内部,用你原来的 Service 就行。图是编排层,不是业务层。
收益有三个。流程结构变成可序列化的数据,于是可视化、可测试、可中断。状态变成显式的 key-value,于是可落盘、可回放。人机边界变成一个节点,于是可以挂在图上而不是塞在 if 里。
代价也有三个。第一,学习成本。KeyStrategy、CompileConfig、SaverConfig、interruptBefore,每一个都要理解语义,配错了不会报错。第二,调试路径变长。一次请求跨多个节点,日志要按节点切。第三,图本身是有状态的,一旦上线后修改了图结构,历史检查点可能无法在新图上恢复,需要版本化考虑。
适用条件:分支数量在三个以上、存在人工介入节点、或者需要跨请求续跑。三条一条都不占的流程,用方法调用更省事。
先画边再画点
边界写在图上
代价是多学一层
收益是能中断
05、实战一次
环境与版本
JDK:21 LTS
框架:alibaba/spring-ai-alibaba v1.1.2.2
模块:spring-ai-alibaba-graph-core
分支:chapter/11-graph-core
依赖
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-graph-core</artifactId>
<version>1.1.2.2</version>
</dependency>
坐标与包名以 v1.1.2.2 源码目录 spring-ai-alibaba-graph-core/src/main/java 为准,导入前建议对照源码确认一次。
配置:状态归约策略
V1 先跑通,所以策略表故意写得“刚刚够用”——只声明业务上明确的 key,其余交给默认行为。这一版会留下一个坑,第 06 章来挖。
package com.oldli.dh.ch11;
import com.alibaba.cloud.ai.graph.KeyStrategy;
import com.alibaba.cloud.ai.graph.KeyStrategyFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.HashMap;
import java.util.Map;
@Configuration
public class StateStrategyConfig {
@Bean
public KeyStrategyFactory afterSaleKeyStrategies() {
return () -> {
Map<String, KeyStrategy> strategies = new HashMap<>();
strategies.put("input", KeyStrategy.REPLACE);
strategies.put("intent", KeyStrategy.REPLACE);
strategies.put("orderInfo", KeyStrategy.REPLACE);
strategies.put("riskLevel", KeyStrategy.REPLACE);
strategies.put("humanDecision", KeyStrategy.REPLACE);
strategies.put("messages", KeyStrategy.REPLACE);
return strategies;
};
}
}
核心实现:把售后流程装配成图
package com.oldli.dh.ch11;
import com.alibaba.cloud.ai.graph.CompileConfig;
import com.alibaba.cloud.ai.graph.CompiledGraph;
import com.alibaba.cloud.ai.graph.KeyStrategyFactory;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.StateGraph;
import com.alibaba.cloud.ai.graph.action.AsyncEdgeAction;
import com.alibaba.cloud.ai.graph.action.AsyncNodeAction;
import com.alibaba.cloud.ai.graph.checkpoint.config.SaverConfig;
import com.alibaba.cloud.ai.graph.checkpoint.savers.MemorySaver;
import com.alibaba.cloud.ai.graph.exception.GraphStateException;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
@Configuration
public class AfterSaleGraphConfig {
private static final String INTENT = "intent";
private static final String KNOWLEDGE = "knowledge";
private static final String BIZ = "biz";
private static final String RISK = "risk";
private static final String HUMAN = "humanReview";
private static final String REPLY = "reply";
@Bean
public CompiledGraph afterSaleGraph(KeyStrategyFactory strategies) throws GraphStateException {
StateGraph graph = new StateGraph("after-sale", strategies);
graph.addNode(INTENT, AsyncNodeAction.node_async(state ->
CompletableFuture.completedFuture(
Map.of("intent", classify(String.valueOf(state.value("input").orElse("")))))));
graph.addNode(KNOWLEDGE, AsyncNodeAction.node_async(state ->
CompletableFuture.completedFuture(
Map.of("knowledgeHits",
searchKnowledge(String.valueOf(state.value("input").orElse("")))))));
graph.addNode(BIZ, AsyncNodeAction.node_async(state ->
CompletableFuture.completedFuture(
Map.of("orderInfo",
queryOrder(String.valueOf(state.value("input").orElse("")))))));
graph.addNode(RISK, AsyncNodeAction.node_async(state -> {
String orderInfo = String.valueOf(state.value("orderInfo").orElse(""));
return CompletableFuture.completedFuture(Map.of("riskLevel", assessRisk(orderInfo)));
}));
graph.addNode(HUMAN, AsyncNodeAction.node_async(state -> {
String decision = String.valueOf(state.value("humanDecision").orElse("PENDING"));
return CompletableFuture.completedFuture(Map.of("humanDecision", decision));
}));
graph.addNode(REPLY, AsyncNodeAction.node_async(state ->
CompletableFuture.completedFuture(Map.of("messages", compose(state)))));
graph.addEdge(StateGraph.START, INTENT);
graph.addEdge(INTENT, KNOWLEDGE);
graph.addEdge(INTENT, BIZ);
graph.addEdge(List.of(KNOWLEDGE, BIZ), RISK);
graph.addConditionalEdges(RISK,
AsyncEdgeAction.edge_async(state ->
"HIGH".equals(String.valueOf(state.value("riskLevel").orElse("LOW")))
? HUMAN : REPLY),
Map.of(HUMAN, HUMAN, REPLY, REPLY));
graph.addEdge(HUMAN, REPLY);
graph.addEdge(REPLY, StateGraph.END);
CompileConfig compileConfig = CompileConfig.builder()
.saverConfig(SaverConfig.builder().register(new MemorySaver()).build())
.interruptBefore(HUMAN)
.build();
return graph.compile(compileConfig);
}
private static String classify(String input) {
if (input.contains("退款") || input.contains("退货")) {
return "REFUND";
}
if (input.contains("物流") || input.contains("快递")) {
return "LOGISTICS";
}
return "OTHER";
}
private static String searchKnowledge(String input) {
return "退款政策:签收后 7 天内无理由,已拆封商品需人工审核。";
}
private static String queryOrder(String input) {
return "订单 A20240617,金额 899.00,状态 已签收,签收时间 2024-06-15。";
}
private static String assessRisk(String orderInfo) {
Matcher matcher = Pattern.compile("金额\\s*([0-9]+(\\.[0-9]+)?)").matcher(orderInfo);
double amount = matcher.find() ? Double.parseDouble(matcher.group(1)) : 0d;
return amount > 500d ? "HIGH" : "LOW";
}
private static String compose(OverAllState state) {
String intent = String.valueOf(state.value("intent").orElse("OTHER"));
Object hits = state.value("knowledgeHits").orElse(List.of());
String knowledge = hits instanceof List<?> list
? list.stream().map(String::valueOf).collect(Collectors.joining(" | "))
: String.valueOf(hits);
String order = String.valueOf(state.value("orderInfo").orElse(""));
String risk = String.valueOf(state.value("riskLevel").orElse("LOW"));
String decision = String.valueOf(state.value("humanDecision").orElse("PENDING"));
if ("HIGH".equals(risk) && !"APPROVE".equals(decision)) {
return "该工单金额较高,已转人工审核,请稍候。";
}
return "【意图】" + intent + "\n" + knowledge + "\n" + order + "\n已受理,预计 24 小时内处理完成。";
}
}
调用与恢复
package com.oldli.dh.ch11;
import com.alibaba.cloud.ai.graph.CompiledGraph;
import com.alibaba.cloud.ai.graph.NodeOutput;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.RunnableConfig;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Flux;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
@Component
public class AfterSaleRunner {
private final CompiledGraph graph;
public AfterSaleRunner(CompiledGraph graph) {
this.graph = graph;
}
public void runOnce(String question) throws Exception {
String threadId = UUID.randomUUID().toString();
RunnableConfig config = RunnableConfig.builder().threadId(threadId).build();
Optional<OverAllState> first = graph.invoke(Map.of("input", question), config);
first.ifPresent(state -> System.out.println("[第一次调用-风险等级] "
+ state.value("riskLevel").orElse("-")));
Optional<OverAllState> resumed = graph.invoke(Map.of("humanDecision", "APPROVE"), config);
resumed.ifPresent(state -> System.out.println("[恢复后-答复] "
+ state.value("messages").orElse("-")));
}
public Flux<NodeOutput> stream(String question) {
RunnableConfig config = RunnableConfig.builder().threadId(UUID.randomUUID().toString()).build();
return graph.stream(Map.of("input", question), config);
}
}
invoke / stream / RunnableConfig.builder() 的具体重载以 v1.1.2.2 源码为准,导入时对照 spring-ai-alibaba-graph-core/src/main/java 确认。
启动
./mvnw -pl chapter/11-graph-core -am spring-boot:run
请求
curl -X POST http://localhost:8080/after-sale/run \
-H 'Content-Type: application/json' \
-d '{"question":"我的订单 A20240617 想退款,已经签收了"}'
验证(示例输出,未在当前环境实测)
[第一次调用-风险等级] HIGH
[恢复后-答复] 【意图】REFUND
退款政策:签收后 7 天内无理由,已拆封商品需人工审核。
订单 A20240617,金额 899.00,状态 已签收,签收时间 2024-06-15。
已受理,预计 24 小时内处理完成。
到这一步,V1 跑通了:图能编译、能执行、能在 humanReview 前停下来、能用同一个 threadId 恢复。但这段输出里已经藏了一个问题——注意看“退款政策”那一行,它还在。等你换个写法,它就不在了。
一次跑通为先
两种数据并取
三处状态落盘
四方边界分明
06、排查
诊断一:并行跑完之后,知识片段整段消失
现象:图能跑通,最终答复里只剩订单信息,knowledgeHits 那一行是空的,但 knowledge 节点确实被调用了。
怀疑:并行分支里 knowledge 节点根本没执行,或者结果被丢弃了。
检查:在 knowledge 节点里加一行日志,把节点名和返回值打出来;同时把最终状态的 key 集合全部打印,确认 knowledgeHits 这个 key 是否存在。
证据:日志里 knowledge 节点的执行记录和返回值都出现了;但最终状态里 knowledgeHits 存在,值却是空列表,说明这个 key 被写入过多次,前面的值没留下。
根因:afterSaleKeyStrategies() 里没有显式声明 knowledgeHits。没有声明就落到默认行为(等价于覆盖),knowledge 和 biz 并行提交变更时,后到的把先到的挤掉了。节点没错,归约策略错了。
修复:把 knowledgeHits 显式声明为累积策略,让每次提交都追加而不是替换。
诊断二:人工确认后,流程从头又跑了一遍
现象:第一次调用确实停在 humanReview 前面;第二次带着同一个 threadId 调 invoke,意图识别和订单查询又执行了一遍,日志里出现了两份。
怀疑:检查点没保存,或者恢复时找不到对应的检查点。
检查:在第一次 invoke 之后立刻查询该 threadId 的历史状态,看是否有快照;同时确认 CompileConfig 里是否注册了 Saver。
证据:历史状态为空,CompileConfig 中 saverConfig 确实注册了 MemorySaver,但两次调用的 threadId 不一致——第一次是从状态里的 sessionId 字段取的,第二次是从 HTTP 请求头取的。
错误尝试: 一开始把 threadId 当作业务字段写进 OverAllState(state.put("threadId", ...)),恢复的时候从状态里读。这个做法是错的,而且错得很难发现。因为检查点是按 RunnableConfig 里的 threadId 做索引的,状态里那份副本根本不参与查找;状态里读得到,检查点却找不到,表现就是“参数明明对,恢复就是重跑”。threadId 是运行时配置,不是业务状态,两者不能混。
修复:统一从 RunnableConfig.builder().threadId(...) 注入,业务层不再持久化这个字段;同时把 threadId 写进日志,方便对账。
诊断三(顺带发现):流式输出时下游拿不到中间状态
现象:graph.stream(...) 返回的 Flux 里,每个元素的节点名对,但状态的快照粒度不清楚,前端只能看到答复文本,看不到风险等级。
怀疑:NodeOutput 拿不到状态。
检查:订阅时把 NodeOutput 的节点名和状态 key 都打出来。
证据:每个元素都带节点名和该节点输出后的状态,只是前端消费时只取了 messages 这一个 key。
根因:不是框架问题,是消费端只读了一个字段。
修复:前端按节点名分流,比如 risk 节点的输出用于渲染风险提示条,reply 节点的输出用于渲染气泡。
现象先于猜想
证据先于结论
策略错了地方
日志说了真话
07、优化
V2 只做第 06 章证据指向的修改,不做别的。
修改一:把 knowledgeHits 的策略显式声明为累积
strategies.put("knowledgeHits", KeyStrategy.APPEND);
原因:诊断一证明它在并行分支下被覆盖了。新行为是每次节点提交都追加进同一个 list,compose 里那段 hits instanceof List<?> 的分支开始真正生效。验证方式:跑同一条售后请求,看最终状态里 knowledgeHits 是否同时包含知识检索节点的返回值和业务节点补充的片段,并且顺序稳定。
修改二:把检查点落在显式配置上,并固定 threadId 的来源
SaverConfig saverConfig = SaverConfig.builder()
.register(new MemorySaver())
.build();
CompileConfig compileConfig = CompileConfig.builder()
.saverConfig(saverConfig)
.interruptBefore(HUMAN)
.build();
原因:诊断二证明恢复失败不是检查点的问题,是索引键不一致。改动分两半:代码侧保留 saver,业务侧把 threadId 的生成点收敛到入口一处。新行为是同一个 threadId 的第二次 invoke 从 humanReview 之后继续,意图识别与订单查询不再重跑。验证方式:用同一个 threadId 调用两次,观察日志里 intent 节点只出现一次。
MemorySaver 只适合单进程验证。要满足“状态可持久化”这条验收线,需要换成文件或数据库实现;可选的 saver 类型以 v1.1.2.2 的 checkpoint.savers 包为准。
修改三:把业务查询节点换成 ReactAgent.asNode()
原因:诊断一里能看出,biz 节点做的不只是一次查询,真实场景下它需要多轮工具调用(查订单、查物流、查售后记录)。与其在节点里手写循环,不如把这一格交给 Agent Framework,再从图里调用它。
ReactAgent bizAgent = ReactAgent.builder()
.name("bizAgent")
.model(chatModel)
.instruction("你是订单查询助手,按用户诉求调用订单与物流工具,返回结构化的订单事实。")
.tools(orderTools)
.build();
graph.addNode(BIZ, bizAgent.asNode());
这就是官方那句定位的落地方式:一般 Agent 开发优先 Agent Framework,需要精细流程/状态控制时,再把它作为一格塞进 Graph。两者不是替代关系,是嵌套关系。ReactAgent.builder() 与 asNode() 的具体方法名以 v1.1.2.2 的 graph.agent 包源码为准。
修改四:中断点前移一格
原因:interruptBefore(HUMAN) 意味着图在进入人工节点之前暂停。如果写成 interruptAfter(HUMAN),人工节点里任何副作用都会先执行再暂停,恢复时可能重复执行。保持 before 语义更稳。
根因指向策略
修在归约之处
换掉查询节点
补上落盘一步
08、演进
[同一输入] “我的订单 A20240617 想退款”
|
+--[V1] 串行方法 + 默认归约:一次调用走完,人工确认靠阻塞,重启即丢失
| 代价:可读性随分支数量下降,状态无法回答“卡在哪一步”
|
+--[V2] StateGraph + 显式策略 + 检查点:并行取数、条件边分流、interruptBefore 暂停、threadId 恢复
| 代价:多一层状态模型要学,图结构变更要考虑历史检查点兼容
|
[Trade-off]
得到:可中断、可恢复、可视化、可扩展
失去:一份方法调用的直观,换成一份图与状态的契约
适用边界:分支 ≥ 3、存在人工节点、或需要跨请求续跑时,Graph 的收益才盖过学习成本
正确性上,V1 和 V2 对“低风险工单”的答复是一样的;对“高风险工单”,V1 只能阻塞等人,V2 能把等人这件事变成一个可观察的状态。
稳定性上,V1 的失败模式是线程耗尽,属于雪崩型;V2 的失败模式是检查点写失败,属于局部型,影响面被限制在单条工单。
复杂度上,V1 是一个类里的两百行;V2 是一个配置类加一个装配类加一个 runner,代码量更多,但每一块的职责边界清晰,可以单独写测试。
成本上,V1 几乎零学习成本,V2 需要理解 KeyStrategy、CompileConfig、SaverConfig、RunnableConfig 四组概念,并且每加一个状态 key 都要想一次策略。
适用范围上,V1 适合单步对话、无人工介入、无跨请求状态的场景;V2 适合多分支、有并行、有人工确认、需要审计的流程。
遗留问题有三个。一是检查点存储还是内存实现,重启即失效,需要换持久化 saver。二是图结构版本化没做,上线后改图可能让历史检查点无法恢复。三是缺可观测性,节点耗时可观测但还没有统一采集。
同一句售后话
两条路径分岔
一边串行到底
一边可断可续
09、洞见
9.1 状态归约是图的契约,不是节点的实现细节
诊断一证明了这一点:两个节点的代码都正确,结果仍然错。反直觉判断:在图里,写状态的代码不掌握状态的最终形态,声明策略的那一行才掌握。 这意味着评审 Graph 代码时,第一眼该看的不是节点里写了什么,而是策略表里声明了什么。策略表少一行,可能会让一个功能静默失效。
9.2 并行不是线程问题,是合并问题
很多人第一次做并行分支,第一反应是加线程池、加 CompletableFuture。但真正让人翻车的是“两份结果怎么写进同一个 key”。反直觉判断:并行分支的难点不在同时跑,而在跑完之后谁说了算。 想清楚合并语义,并行才是安全的;没想清楚,并行等于制造竞态。
9.3 中断点必须落在副作用之前
interruptBefore 和 interruptAfter 一字之差,语义完全不同。人工节点一旦执行,它的副作用(写审批表、发通知)就已经发生了;此时再暂停,恢复时就可能重复执行。把中断点前移到副作用之前,本质是把“决策”和“执行”拆开,这是所有需要人工介入的流程都应该遵守的边界。
9.4 threadId 是运行时配置,不是业务状态
诊断二里那个错误尝试值得单独拎出来:把 threadId 塞进状态,看起来只是换了个存放位置,实际上破坏了索引一致性。区分“流程状态”和“运行配置”是使用 Graph 时最重要的心智模型之一。凡是框架用来定位资源的标识,都不该由业务层复制一份。
9.5 先用 Agent Framework,需要时再下沉到 Graph
官方给的建议是分层的:一般 Agent 开发优先 Agent Framework,需要精细流程或状态控制时再直接用 Graph。这不是版本高低,是抽象层级的选择。这一掌最后把 biz 节点换成 ReactAgent.asNode(),就是这种分层在同一个系统里的具体形态——外层用图管流程,内层用 Agent 管工具循环。
并行不是线程
是状态的合并
中断不是暂停
是边界的显形
10、系统落地
原来有什么:数字人项目里有注册登录、项目 CRUD、标题主题色背景图、开场白结束语、Agent 模型与 System Prompt、Web 运行页、LiveKit 实时交互。前面十掌加上了 ChatClient 对话、Memory 与 Streaming、Tool 调用、MCP 接入、RAG 检索、ReactAgent。
本篇新增什么:一个 Graph Runtime 层。数字人项目的售后处理不再是一个 Service 方法,而是 after-sale 这张图。图里六个节点、一组并行边、一条条件边、一个中断点、一个检查点存储。
现在能做什么:售后请求进来之后,知识检索与订单查询并行执行;风险判定结果决定是直接答复还是挂起到人工节点;人工审批落下来时,系统带着同一个 threadId 从断点继续,前面的步骤不重跑;过程中每一步的状态都显式可见。
还缺什么:检查点还在内存里,进程重启就没了;图结构没有版本标识,改图要考虑历史兼容;没有可视化面板,流程图还停留在文档里;可观测性只有日志,没有指标。
下一步如何演进:把 MemorySaver 换成可持久化的 saver 实现,让检查点跨进程;给图加版本号,恢复时校验;把节点执行信息接到可观测体系里;然后把更多流程从方法调用迁到图上。
原来只有一条路
现在多了一个岔
能停下等一个人
还能接着往下走
11、小结
Q1 → 不能再靠 if-else 编排。分支到三个以上、并且存在人工介入时,流程结构必须变成显式数据,否则每加一个条件就要重写方法
Q2 → 能。前提是状态被显式建模、检查点被持久化、并且两次调用使用同一个 threadId
Q3 → 两者都会付出。代码复杂度上升是显性成本,状态写丢是隐性成本;隐性成本更贵,因为它不报错
状态 → 售后处理已成为一张可编译、可中断、可恢复的 StateGraph,并行分支与条件边均已落地
第 11 掌交付的是一个状态模型。它把“流程走到哪了”从程序计数器里搬到了可序列化的数据里,这为后面的多 Agent 协作、跨服务续跑、以及人工审批留出了接口。
一图收尽分支
一策定住归约
一中断可续跑
一次验收成型
12、作业
12.1 理解题:为什么说“节点不修改状态”?
参考答案:因为状态的所有权归 OverAllState,节点返回的是一份 Map<String, Object>,表示对状态的变更请求。当存在并行分支时,多个节点可能在同一次执行中提交对同一个 key 的修改,框架需要一个统一的合并规则来回答“最终值是什么”。这个规则由 KeyStrategy 决定。理解这一点之后,就能解释为什么节点代码全对、最终结果仍然错——问题出在策略声明上,不在节点实现上。
12.2 实战题:给售后图补一个“工单归档”节点,并保证它只执行一次。
参考答案:把归档节点放在 reply 之后、END 之前,并确认中断点设置在归档之前。同时给归档动作加一个幂等键,键值用 threadId 加节点名。原因是中断恢复时,框架会从检查点继续执行,如果归档节点位于中断点之后,理论上不会重复;但如果进程在归档执行完、检查点写入之前崩溃,恢复后仍可能重放。幂等键是这类边界动作的兜底手段,比依赖执行顺序更可靠。
12.3 排障题:一个并行分支的图,运行结果里某个 key 的值时有时无,怎么定位?
参考答案:按四步走。第一步,在两个并行节点里各打一行日志,确认它们都执行了、返回值都对。第二步,打印最终状态的 key 集合,确认那个 key 是否真的存在。第三步,检查策略表里是否显式声明了这个 key——没声明就会落到默认行为。第四步,把策略改成累积后再跑一次,观察值是否稳定。如果日志显示节点都执行了、key 也存在、值却是最后一次写入的内容,基本可以判定是归约策略问题。
12.4 架构判断题:一个只有三步、无分支、无人工介入的流程,要不要上 Graph?
参考答案:不建议。Graph 的收益来自分支管理、人工边界和跨请求状态三件事,这三件一件都不占的时候,引入 Graph 只会增加一层需要理解的抽象,并且要

浙公网安备 33010602011771号