简介: AI一段商品描述,它同时帮你做两件事——写营销文案 + 提取结构化属性,最后拼成一个完整的商品信息对象返回给你。

技术栈:Spring AI Alibaba 1.1.0.0 · 并行图编排(Parallel Graph)· 通义千问 qwen-max


一、概述

电商后台的商品运营,每天你要上架几百个商品,每个商品都需要:

  1. 写一段吸引人的营销口号(slogan)
  2. 填一堆规格参数(材质、颜色、季节等)

这两件事其实可以交给 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();
    }
}

这段代码做了什么?

  1. 构造函数里编译图:Spring 启动时,把 ProductGraphConfiguration 中定义的 StateGraph Bean 注入进来,调用 .compile() 生成 CompiledGraph。编译时注册了 MemorySaver——这是一个内存状态检查点机制,用于记录每一步执行后的状态快照。

    ⚠️ 注意:当前代码虽然注册了 MemorySaver,但接口调用时并没有传 threadId,也没有使用 resume() 或状态恢复功能。所以它现在只是"预留能力",实际每次请求都是独立执行。

  2. POST 接口接收纯文本@RequestBody String productDesc 直接接收一段商品描述字符串。生产环境建议改成 JSON 结构(如 {"description": "..."}),方便后续扩展商品 ID、分类等元数据。

  3. 执行与取值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:读 sloganproductSpec,组装成 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(...) 把节点包装成异步执行。因为 marketingCopyspecificationExtraction 都从 START 出发、互不依赖,图引擎会自动并行调度它们。
  • merge 节点有两个入边,图引擎会等待两个前置节点都完成后才执行。
图的可视化

触发

触发

输出 slogan

输出 productSpec

输出 finalProduct

START

marketingCopy
生成营销文案

specificationExtraction
提取结构化属性

merge
合并结果

END

调试输出

代码最后还打印了 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恤,有蓝、红、绿三种颜色可选,适合夏季穿着。

执行时序

通义千问 (规格提取) 通义千问 (营销文案) Graph Engine ProductController HTTP Client 通义千问 (规格提取) 通义千问 (营销文案) Graph Engine ProductController HTTP Client 初始状态 {productDesc: "..."} par [并行执行] 等待两个节点都完成 POST /product/enrich Body: 商品描述 1 invoke(initialState) 2 Prompt: 生成营销文案 3 slogan: "清凉一夏..." 4 Prompt: 提取规格参数 5 productSpec: Product(...) 6 merge 节点执行 组装 finalProduct 7 最终状态 8 JSON 响应 9

各阶段状态变化

阶段 状态池内容
初始 {"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 的核心能力——并行节点、状态传递、图编译与执行。代码干净、逻辑清晰,是理解"图编排思维"的最佳入门示例。

如果要改造成生产级服务,重点补三件事:

  1. 异常处理 —— LLM 超时、格式错误不能炸掉整个请求
  2. 输入校验 —— 空字符串、超长文本需要截断或拒绝
  3. 输出兜底 —— AI 不听话时返回保底结构,保证接口可用性

本文基于 spring-ai-alibaba 1.1.0.0 版本源码整理,建议对照实际代码动手跑一遍,效果更佳。

Logo

欢迎加入DeepSeek 技术社区。在这里,你可以找到志同道合的朋友,共同探索AI技术的奥秘。

更多推荐