【第53篇】Graph-商品信息智能分析
简介: AI一段商品描述,它同时帮你做两件事——写营销文案 + 提取结构化属性,最后拼成一个完整的商品信息对象返回给你。
技术栈:Spring AI Alibaba 1.1.0.0 · 并行图编排(Parallel Graph)· 通义千问 qwen-max
一、概述
电商后台的商品运营,每天你要上架几百个商品,每个商品都需要:
- 写一段吸引人的营销口号(slogan)
- 填一堆规格参数(材质、颜色、季节等)
这两件事其实可以交给 AI,但它们彼此独立、互不依赖——写 slogan 的时候不需要等规格提取完,提取规格的时候也不需要等 slogan 写完。
这就是**并行图(Parallel Graph)**的经典场景:把一个大任务拆成多个独立子任务同时执行,最后合并结果。
举个例子
你输入:
“一款高品质、舒适的纯棉T恤,有蓝、红、绿三种颜色可选,适合夏季穿着。”
系统会同时启动两个 AI 调用:
| 任务 | AI 输出 |
|---|---|
| 📝 营销文案生成 | “清凉一夏,纯棉随心 —— 舒适本真,尽在一抹色彩!” |
| 🔍 规格参数提取 | {"material":"纯棉", "colors":["蓝","红","绿"], "season":"夏季"} |
然后合并成一个完整的 Product 对象返回。
二、项目结构一览
product-analysis-graph/
├── pom.xml # Maven 依赖
├── product-enrich.http # IDEA HTTP Client 测试文件(推荐用这个)
├── src/main/java/com/alibaba/example/graph/product/
│ ├── ProductAnalysisApplication.java # Spring Boot 入口
│ ├── controller/
│ │ └── ProductController.java # REST 接口:接收描述,返回完整商品信息
│ ├── model/
│ │ └── Product.java # 数据模型(Java Record)
│ ├── serializer/
│ │ └── ProductStateSerializer.java # 自定义状态序列化器(支持多态类型)
│ └── conf/
│ └── ProductGraphConfiguration.java # ⭐ 核心:图编排定义
└── resources/
└── application.yml # 应用配置(模型、API Key、端口)
整个项目只有 6 个 Java 文件,非常精简。核心逻辑全在 ProductGraphConfiguration 里。
三、核心模块逐层拆解
3.1 数据模型:Product.java
public record Product(
String slogan, // 营销口号(AI 生成)
String material, // 材质(从描述提取)
List<String> colors,// 可选颜色列表
String season // 适用季节
) {}
这里用了 Java 14+ 的 record 语法,一行代码定义了一个不可变数据载体。四个字段正好对应"营销文案 + 三个规格属性"。
💡 设计思考:
record很简洁,但扩展性有限。如果以后要加品牌、价格、尺码等字段,就得改代码。后文会给出更灵活的替代方案。
3.2 应用配置:application.yml
spring:
application:
name: product-analysis-graph
cloud:
ai:
dashscope:
api-key: ${AI_DASHSCOPE_API_KEY} # 从环境变量读取
chat:
options:
model: qwen-max # 通义千问最强模型
server:
port: 8080
几个关键点:
- 模型选型:
qwen-max是通义千问系列里能力最强的,但也是最贵的。对于"提取结构化参数"这种确定性任务,qwen-plus性价比更高,成本只有qwen-max的几分之一。 - API Key 管理:通过
${AI_DASHSCOPE_API_KEY}从环境变量注入,避免硬编码泄露。 - 端口:8080,无 context-path,接口直接暴露在根路径。
3.3 REST 接口:ProductController.java
@RestController
public class ProductController {
private final CompiledGraph compiledGraph;
public ProductController(
@Qualifier("productAnalysisGraph") StateGraph productAnalysisGraph)
throws GraphStateException {
// 1. 配置状态持久化(内存检查点)
SaverConfig saverConfig = SaverConfig.builder()
.register(SaverEnum.MEMORY.getValue(), new MemorySaver())
.build();
// 2. 编译图:把定义的图结构编译成可执行对象
this.compiledGraph = productAnalysisGraph.compile(
CompileConfig.builder()
.saverConfig(saverConfig)
.build()
);
}
@PostMapping("/product/enrich")
public Product enrichProduct(@RequestBody String productDesc)
throws GraphRunnerException {
// 构造初始状态:把用户输入放进状态池
Map<String, Object> initialState = Map.of("productDesc", productDesc);
// 执行图编排
RunnableConfig runnableConfig = RunnableConfig.builder().build();
Optional<OverAllState> result = compiledGraph.invoke(initialState, runnableConfig);
// 从最终状态中提取结果
return (Product) result.get()
.value("finalProduct")
.orElseThrow();
}
}
这段代码做了什么?
-
构造函数里编译图:Spring 启动时,把
ProductGraphConfiguration中定义的StateGraphBean 注入进来,调用.compile()生成CompiledGraph。编译时注册了MemorySaver——这是一个内存状态检查点机制,用于记录每一步执行后的状态快照。⚠️ 注意:当前代码虽然注册了
MemorySaver,但接口调用时并没有传threadId,也没有使用resume()或状态恢复功能。所以它现在只是"预留能力",实际每次请求都是独立执行。 -
POST 接口接收纯文本:
@RequestBody String productDesc直接接收一段商品描述字符串。生产环境建议改成 JSON 结构(如{"description": "..."}),方便后续扩展商品 ID、分类等元数据。 -
执行与取值:
compiledGraph.invoke()触发整张图的执行,最终从状态池中取出finalProduct返回。
3.4 为什么需要自定义序列化器?
在深入图配置之前,必须先理解 ProductStateSerializer 存在的意义。
图引擎中的状态流转
图执行过程中,所有节点共享一个全局状态对象 OverAllState,本质上是一个 Map<String, Object>。节点 A 生成的 Product 对象要传给节点 B,中间可能涉及:
- 状态快照(checkpoint):执行到某一步时保存状态,用于中断恢复
- 序列化传输:如果图分布在不同进程/线程,状态需要被序列化传递
默认的 PlainTextStateSerializer 用简单 JSON 做序列化,但它有个致命问题:不支持多态类型。
问题场景
假设 specificationExtractionNode 生成了一个 Product 对象并存入状态:
state.put("productSpec", new Product(...)); // 类型是 Product
默认序列化器把它变成 JSON 字符串。当另一个节点读取时,反序列化回来的却是 LinkedHashMap,而不是 Product。这时如果代码里写:
Product p = (Product) state.value("productSpec").orElseThrow();
就会抛出 ClassCastException。
解决方案:嵌入类型信息
ProductStateSerializer 通过 Jackson 的 activateDefaultTyping 在 JSON 中嵌入 @class 类型标记:
public class ProductStateSerializer extends PlainTextStateSerializer {
private final ObjectMapper mapper;
public ProductStateSerializer(AgentStateFactory<OverAllState> stateFactory) {
super(stateFactory);
this.mapper = new ObjectMapper();
// 关键:开启多态类型信息,JSON 中会带上 @class 字段
this.mapper.activateDefaultTyping(
this.mapper.getPolymorphicTypeValidator(),
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
);
this.mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
}
@Override
public void writeData(Map<String, Object> data, ObjectOutput out) throws IOException {
String json = mapper.writeValueAsString(data);
out.writeUTF(json);
}
@Override
public Map<String, Object> readData(ObjectInput in) throws IOException {
String json = in.readUTF();
return mapper.readValue(json, new TypeReference<Map<String, Object>>() {});
}
@Override
public OverAllState cloneObject(OverAllState state) throws IOException {
// 深克隆:先序列化再反序列化
String json = mapper.writeValueAsString(state.data());
Map<String, Object> rawMap = mapper.readValue(json, new TypeReference<>() {});
return stateFactory().apply(rawMap);
}
}
序列化后的 JSON 大概长这样:
{
"productSpec": {
"@class": "com.alibaba.example.graph.product.model.Product",
"slogan": null,
"material": "纯棉",
"colors": ["蓝", "红", "绿"],
"season": "夏季"
}
}
这样反序列化时 Jackson 就能根据 @class 还原出真正的 Product 对象。
⚠️ 安全提示:
activateDefaultTyping在跨版本或开放输入场景下存在安全风险(反序列化漏洞)。生产环境建议配合PolymorphicTypeValidator严格限制允许反序列化的类,或使用更安全的显式类型注解(@JsonTypeInfo+@JsonSubTypes)。
3.5 ⭐ 图编排核心:ProductGraphConfiguration.java
这是整个项目的灵魂。我们把它拆开,一步一步看一张"并行图"是怎么画出来的。
第一步:定义状态合并策略
KeyStrategyFactory keyStrategyFactory = new KeyStrategyFactoryBuilder()
.addPatternStrategy("productDesc", new ReplaceStrategy())
.addPatternStrategy("slogan", new ReplaceStrategy())
.addPatternStrategy("productSpec", new ReplaceStrategy())
.addPatternStrategy("finalProduct", new ReplaceStrategy())
.build();
图引擎允许多个节点同时读写状态。当两个节点都想往同一个 key 里写数据时,该怎么合并?ReplaceStrategy 表示直接覆盖——后写入的值覆盖先写入的值。在这个项目里,每个 key 只由一个节点负责写入,所以覆盖策略完全够用。
第二步:定义三个节点
// 节点 A:营销文案生成
NodeAction marketingCopyNode = state -> {
String productDesc = (String) state.value("productDesc").orElseThrow();
String slogan = client.prompt()
.user("Generate a catchy slogan for a product with the following description: "
+ productDesc)
.call()
.content();
return Map.of("slogan", slogan);
};
// 节点 B:规格参数提取
NodeAction specificationExtractionNode = state -> {
String productDesc = (String) state.value("productDesc").orElseThrow();
Product productSpec = client.prompt()
.user("Extract product specifications from the following description: "
+ productDesc)
.call()
.entity(Product.class); // 直接映射为 Product 对象
return Map.of("productSpec", productSpec);
};
// 节点 C:合并结果
NodeAction mergeNode = state -> {
String slogan = (String) state.value("slogan").orElseThrow();
Product productSpec = (Product) state.value("productSpec").orElseThrow();
Product finalProduct = new Product(
slogan,
productSpec.material(),
productSpec.colors(),
productSpec.season()
);
return Map.of("finalProduct", finalProduct);
};
三个节点分工明确:
- marketingCopy:读
productDesc,调 LLM 写 slogan,输出到slogan - specificationExtraction:读
productDesc,调 LLM 提取结构,输出到productSpec - merge:读
slogan和productSpec,组装成finalProduct
第三步:构建图结构
StateGraph graph = new StateGraph(keyStrategyFactory, serializer);
graph.addNode("marketingCopy", node_async(marketingCopyNode))
.addNode("specificationExtraction", node_async(specificationExtractionNode))
.addNode("merge", node_async(mergeNode))
.addEdge(START, "marketingCopy") // START → 并行节点1
.addEdge(START, "specificationExtraction") // START → 并行节点2
.addEdge("marketingCopy", "merge") // 节点1 → merge
.addEdge("specificationExtraction", "merge") // 节点2 → merge
.addEdge("merge", END); // merge → END
关键设计:
node_async(...)把节点包装成异步执行。因为marketingCopy和specificationExtraction都从START出发、互不依赖,图引擎会自动并行调度它们。merge节点有两个入边,图引擎会等待两个前置节点都完成后才执行。
图的可视化
调试输出
代码最后还打印了 PlantUML 图到控制台,方便你直观地看到图结构:
GraphRepresentation repr = graph.getGraph(
GraphRepresentation.Type.PLANTUML,
"Product Analysis Graph"
);
System.out.println(repr.content());
四、完整执行流程:从 HTTP 请求到 JSON 返回
我们用一段具体的请求,走一遍完整的调用链路。
请求
POST http://localhost:8080/product/enrich
Content-Type: text/plain
一款高品质、舒适的纯棉T恤,有蓝、红、绿三种颜色可选,适合夏季穿着。
执行时序
各阶段状态变化
| 阶段 | 状态池内容 |
|---|---|
| 初始 | {"productDesc": "一款高品质..."} |
| 并行执行后 | {"productDesc": "...", "slogan": "清凉一夏...", "productSpec": Product(...)} |
| merge 后 | {"productDesc": "...", "slogan": "...", "productSpec": ..., "finalProduct": Product(...)} |
最终响应
{
"slogan": "清凉一夏,纯棉随心 —— 舒适本真,尽在一抹色彩!",
"material": "纯棉",
"colors": ["蓝", "红", "绿"],
"season": "夏季"
}
五、潜在问题与纠正
5.1 specificationExtractionNode 的类型转换风险
问题代码:
Product productSpec = client.prompt()
.user("Extract product specifications from..." + productDesc)
.call()
.entity(Product.class);
隐患:你没有在 Prompt 里约束输出格式。AI 可能在 JSON 外面包一层自然语言(比如 “Here is the extracted information: {…}”),导致 entity() 反序列化失败。
推荐改进:
String specJson = client.prompt()
.user("请从以下商品描述中提取规格信息,只返回纯 JSON,不要任何其他文字:\n"
+ productDesc
+ "\n\n格式要求:{\"material\":\"材质\",\"colors\":[\"颜色1\"],\"season\":\"季节\"}")
.call()
.content();
ObjectMapper mapper = new ObjectMapper();
Product productSpec = mapper.readValue(specJson, Product.class);
先拿字符串,自己反序列化,中间没有黑盒。Prompt 里明确说"只返回 JSON",成功率大幅提升。
5.2 mergeNode 的强制类型转换
Product productSpec = (Product) state.value("productSpec").orElseThrow();
如果序列化/反序列化过程中类型信息丢失,productSpec 可能是 LinkedHashMap,这里直接抛 ClassCastException。
改进方案:
Object rawSpec = state.value("productSpec").orElseThrow();
Product productSpec;
if (rawSpec instanceof Product p) {
productSpec = p;
} else if (rawSpec instanceof Map m) {
ObjectMapper mapper = new ObjectMapper();
productSpec = mapper.convertValue(m, Product.class);
} else {
throw new IllegalStateException("Unexpected type: " + rawSpec.getClass());
}
5.3 MemorySaver 注册了但未使用
SaverConfig saverConfig = SaverConfig.builder()
.register(SaverEnum.MEMORY.getValue(), new MemorySaver())
.build();
MemorySaver 用于检查点(checkpoint)——支持中断恢复、多次调用状态持久化。但当前代码:
- 没有传
threadId - 没有调用
resume()或updateState()
建议:如果只是 Demo,可以去掉 SaverConfig 减少序列化开销;如果想演示检查点能力,需要在 RunnableConfig 中传入 threadId,并在 Controller 中提供恢复接口。
5.4 README 文档错误
README 中写的是 GET /product/enrich,但代码实际是 @PostMapping。从语义上,有请求体、有副作用的操作应该用 POST,文档需要修正。
5.5 模型成本
qwen-max 能力强但贵。对于结构化提取任务,qwen-plus 性价比更高:
spring:
cloud:
ai:
dashscope:
chat:
options:
model: qwen-plus
5.6 Product Record 的扩展性
当前 Product 固定四个字段,面对不同品类(食品、数码、家电)时不够灵活。
改进建议:把规格改为动态 Map
public record Product(
String slogan,
Map<String, Object> specifications, // 动态键值对
String category // 商品分类
) {}
六、改进建议与进阶方向
6.1 中文 Prompt 优化
当前 Prompt 全英文,对中文商品描述的提取效果不如中文 Prompt 稳定。建议把 Prompt 改成中文,并加入 few-shot 示例:
.user("""
请从以下商品描述中提取结构化信息,只返回 JSON:
描述:%s
示例输出:
{
"material": "纯棉",
"colors": ["蓝", "红", "绿"],
"season": "夏季"
}
""".formatted(productDesc))
6.2 异常处理与 Fallback
当前代码一路 .orElseThrow(),任何环节出错都返回 500。建议加上兜底:
@PostMapping("/product/enrich")
public ResponseEntity<?> enrichProduct(@RequestBody String productDesc) {
try {
Map<String, Object> initialState = Map.of("productDesc", productDesc);
Optional<OverAllState> result = compiledGraph.invoke(initialState, RunnableConfig.builder().build());
Product product = (Product) result.get().value("finalProduct").orElseThrow();
return ResponseEntity.ok(product);
} catch (GraphRunnerException e) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(Map.of("error", "AI 服务暂时不可用", "detail", e.getMessage()));
} catch (Exception e) {
// 兜底:返回基础结构
return ResponseEntity.ok(new Product("", "", List.of(), ""));
}
}
6.3 流式响应(Streaming)
当前是阻塞式等待两个 LLM 调用都完成才返回。可以升级为 StreamingGraph + SSE(Server-Sent Events),做到:
- 营销文案一生成,立刻推给前端
- 规格提取完,再推一次
- 最后合并推送
适合对实时性要求高的场景。
6.4 函数调用(Function Calling)替代纯 Prompt
对于复杂的规格提取,可以定义一个 Tool(函数),让 AI 通过函数调用来输出结构化数据:
@Tool(description = "提取商品规格")
public Product extractSpec(
@ToolParam(description = "材质") String material,
@ToolParam(description = "颜色列表") List<String> colors,
@ToolParam(description = "适用季节") String season
) {
return new Product("", material, colors, season);
}
这种方式输出格式 100% 可控,天然支持类型校验,比纯 Prompt 更可靠。
七、部署实操指南
环境准备
| 要求 | 建议值 | 查验命令 |
|---|---|---|
| JDK | 17+(推荐 21) | java -version |
| Maven | 3.8+ | mvn -v |
| DashScope API Key | 有效且余额充足 | 灵积控制台 |
| 网络 | 能访问 dashscope.aliyuncs.com |
大陆服务器最佳 |
1. 配置 API Key
# 方式一:环境变量(推荐)
export AI_DASHSCOPE_API_KEY="sk-你的真实key"
# 方式二:项目根目录创建 key.env
echo "AI_DASHSCOPE_API_KEY=sk-你的真实key" > key.env
⚠️ 不要把 Key 提交到 Git!确认
.gitignore已忽略key.env。
2. 编译构建
cd /home/tht/examples-main/spring-ai-alibaba-graph-example
mvn clean install -DskipTests
常见问题:
- 依赖下载失败:检查网络,换阿里云 Maven 镜像
- Java 版本不对:确认
JAVA_HOME指向 JDK 17+ - 内存不足:
export MAVEN_OPTS="-Xmx512m"
3. 启动应用
cd product-analysis-graph
mvn spring-boot:run
启动成功后,控制台会打印 PlantUML 图结构。如果 8080 被占:
mvn spring-boot:run -Dspring-boot.run.arguments="--server.port=8081"
4. 测试验证
curl -X POST http://localhost:8080/product/enrich \
-H "Content-Type: text/plain" \
-d "一款高品质、舒适的纯棉T恤,有蓝、红、绿三种颜色可选,适合夏季穿着。"
预期返回:
{
"slogan": "清凉一夏,纯棉随心 —— 舒适本真,尽在一抹色彩!",
"material": "纯棉",
"colors": ["蓝", "红", "绿"],
"season": "夏季"
}
多测几个场景:
# 电子产品
curl -X POST http://localhost:8080/product/enrich \
-H "Content-Type: text/plain" \
-d "高性能蓝牙降噪耳机,黑色白色可选,续航40小时,适合差旅通勤。"
# 食品
curl -X POST http://localhost:8080/product/enrich \
-H "Content-Type: text/plain" \
-d "有机认证的云南小粒咖啡豆,深烘焙,250克袋装,适合手冲。"
💡 你会发现"咖啡豆"这类商品不太适配当前的
Product模型(没有material/colors/season的对应概念),这再次印证了扩展性改进的必要性。
5. 生产打包与部署
# 打包
mvn clean package -DskipTests -pl product-analysis-graph -am
# 产物位置
ls product-analysis-graph/target/product-analysis-graph-*.jar
systemd 服务配置:
[Unit]
Description=Product Analysis Graph Service
After=network.target
[Service]
Type=simple
EnvironmentFile=/opt/app/key.env
WorkingDirectory=/opt/app/product-analysis-graph
ExecStart=/usr/bin/java -Xms256m -Xmx512m -jar product-analysis-graph-*.jar --server.port=8080
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
6. 快速排障
| 症状 | 解法 |
|---|---|
启动报错 UnsatisfiedDependency |
检查 pom.xml 版本,mvn clean install 重来 |
| 接口 500 | 看日志,大概率是 LLM 调用失败或 JSON 解析错误 |
| “No suitable serializer found” | 检查 ProductStateSerializer 是否正确注册 |
| 响应慢(30秒+) | 换 qwen-plus 模型试试 |
| 返回乱码 | 确认请求 Content-Type: text/plain |
八、总结
这是一个优秀的并行图编排教学 Demo,它用最少的代码展示了 Spring AI Alibaba Graph 的核心能力——并行节点、状态传递、图编译与执行。代码干净、逻辑清晰,是理解"图编排思维"的最佳入门示例。
如果要改造成生产级服务,重点补三件事:
- 异常处理 —— LLM 超时、格式错误不能炸掉整个请求
- 输入校验 —— 空字符串、超长文本需要截断或拒绝
- 输出兜底 —— AI 不听话时返回保底结构,保证接口可用性
本文基于 spring-ai-alibaba 1.1.0.0 版本源码整理,建议对照实际代码动手跑一遍,效果更佳。
更多推荐


所有评论(0)