AI Agent 开发实录 02:Java 程序员用Spring AI Alibaba 1.1.2.0从0到1手搓生产级 AI Agent ------ 串起完整 Graph
前一期把环境搭好、跑通了第一个节点 saleRecordDataNode,算是把"状态机能跑通"这件事验证掉了。
今天接着把剩余节点全部写完,把整个 Graph 串起来。最终的 Graph 长这样:

后面的开发其实是有固定套路的:一个节点一个节点往上加,每个节点加完单独测一下,确认数据能正常流入流出,再继续加下一个。核心就三步:写 Node → 注册到 GraphConfig → 连边 → 测试。真正花时间的反而是业务逻辑和提示词调优,Graph 本身的编排反而是最简单的部分。
注意:本文承接第 01 期,环境、数据库、项目骨架在第 01 期已经搭好,不再重复。
一. 按常规流程完成上图
1.1 库存调拨数据采集
第 01 期已经把销售数据采集(saleRecordDataNode)做完了,接下来补上历史调拨数据的采集节点。这个节点的写法和销售数据采集几乎一模一样------查数据库、序列化成 JSON、写回状态机。
之所以要把历史调拨数据也拉进来,是因为后面 PredictNode 做分析时需要三份数据:销售数据、当前库存、历史调拨记录。三者缺一不可------光有销售数据不知道仓库间的调拨习惯,光有库存不知道销售趋势,光有调拨记录不知道当前库存水位。
1.1.1 创建 CollectInventoryOrderNode
java
package vip.wayhua.ivy.ai.agent.nodes;
import cn.hutool.json.JSONUtil;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import vip.wayhua.ivy.ai.agent.constant.Constant;
import vip.wayhua.ivy.ai.agent.dto.InventoryOrderDto;
import vip.wayhua.ivy.ai.agent.service.InventoryService;
import vip.wayhua.ivy.ai.core.utils.JsonUtils;
import vip.wayhua.ivy.ai.core.utils.StringUtils;
import java.util.List;
import java.util.Map;
public class CollectInventoryOrderNode implements NodeAction {
private static final Logger log = LoggerFactory.getLogger(CollectInventoryOrderNode.class);
InventoryService inventoryService;
public CollectInventoryOrderNode(InventoryService inventoryService) {
this.inventoryService = inventoryService;
}
/***
* 编写业务逻辑
* 1.从状态机获取productId
* 2.写sql去查询这个商品对应的历史调拨情况 例如 某年的某个商品id 从某仓调到某仓 总数是多少
* 维度: 年份、季度、商品、仓库
* @param state
* @return
* @throws Exception
*/
@Override
public Map<String, Object> apply(OverAllState state) throws Exception {
// 1.从状态机获取到商品id
String productId = state.value(Constant.KeyName.PRODUCT_ID, "");
log.info("CollectInventoryOrderNode->productId->" + productId);
if (StringUtils.isEmpty(productId)) {
return Map.of();
}
// 2.写sql去查询这个商品对应的历史调拨情况 例如 某年的某个商品id 从某仓调到某仓 总数是多少
List<InventoryOrderDto> inventoryOrderDtos = inventoryService.collectInventoryOrderDataByProductId(productId);
String inventoryOrderData = JsonUtils.toJson(inventoryOrderDtos);
log.info(Constant.KeyName.INVENTORY_ORDER_DATA + " =[{}]", inventoryOrderData);
return Map.of(Constant.KeyName.INVENTORY_ORDER_DATA, inventoryOrderData);
}
}
这里有个细节:商品 ID 为空时直接返回空 Map,不往下查了。这和处理销售数据时一样------上游没传 productId,节点就不做事。Graph 里的每个节点都应该是"可空跑"的,不能因为缺一个参数就炸了整个流程。
1.1.2 InventoryService添加方法以及实现
java
@Override
public List<InventoryOrderDto> collectInventoryOrderDataByProductId(String productId) {
return dao.collectInventoryOrderDataByProductId(productId);
}
Service 层就一行,直接转调 dao。这个模式在三个数据采集节点里完全一致:Controller → Node → Service → Dao。我个人习惯把 SQL 收敛在 Dao 层,Service 不做复杂逻辑,Node 只做编排。层次分明,后面要改也只需要动一处。
1.1.3 dao编写
sql
SELECT
p.id as productId,
p.product_code,
p.product_name,
YEAR(o.transfer_date) as year,
QUARTER(o.transfer_date) as quarter,
SUM(i.transfer_quantity) as totalTransferQty,
o.source_warehouse_id,
w.warehouse_code as sourceWarehouseCode,
w.warehouse_name as sourceWarehouseName,
o.target_warehouse_id,
ww.warehouse_code as targetWarehouseCode,
ww.warehouse_name as targetWarehouseName
FROM bb_transfer_order o
JOIN bb_transfer_order_item i on o.id=i.transfer_order_id
JOIN bb_product p on i.product_id=p.id
JOIN bb_warehouse w on o.source_warehouse_id=w.id
JOIN bb_warehouse ww on o.target_warehouse_id=ww.id
WHERE p.id ='1' and o.status=3
GROUP BY p.id,YEAR(o.transfer_date),QUARTER(o.transfer_date) ,w.id,ww.id

先在数据库里用硬编码 p.id='1' 跑一遍,确认 SQL 没问题再改成参数化。这个习惯挺重要的------别上来就直接写 #{productId},万一 SQL 报错你还得排查是语法问题还是参数问题。
java
package vip.wayhua.ivy.ai.agent.dao;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import tk.mybatis.mapper.common.Mapper;
import vip.wayhua.ivy.ai.agent.dto.InventoryOrderDto;
import vip.wayhua.ivy.ai.agent.pojo.Inventory;
import java.util.List;
public interface InventoryMapper extends Mapper<Inventory> {
@Select("""
SELECT
p.id as productId,
p.product_code,
p.product_name,
YEAR(o.transfer_date) as year,
QUARTER(o.transfer_date) as quarter,
SUM(i.transfer_quantity) as totalTransferQty,
o.source_warehouse_id,
w.warehouse_code as sourceWarehouseCode,
w.warehouse_name as sourceWarehouseName,
o.target_warehouse_id,
ww.warehouse_code as targetWarehouseCode,
ww.warehouse_name as targetWarehouseName
FROM bb_transfer_order o
JOIN bb_transfer_order_item i on o.id=i.transfer_order_id
JOIN bb_product p on i.product_id=p.id
JOIN bb_warehouse w on o.source_warehouse_id=w.id
JOIN bb_warehouse ww on o.target_warehouse_id=ww.id
WHERE p.id =#{productId} and o.status=3
GROUP BY p.id,YEAR(o.transfer_date),QUARTER(o.transfer_date) ,w.id,ww.id
""")
List<InventoryOrderDto> collectInventoryOrderDataByProductId(@Param("productId")String productId);
}
这里只查 status=3(已完成)的调拨单,因为只有已完成的调拨才有参考价值。待审核、已取消的调拨单不应该影响 AI 的分析判断。
Java 17 的文本块语法(""")写长 SQL 确实比之前用字符串拼接舒服太多了,不用一堆 + 和 \n,SQL 拿出去直接能在 Navicat 里跑。
InventoryOrderDto
java
package vip.wayhua.ivy.ai.agent.dto;
public class InventoryOrderDto {
private String productId;
private String productCode;
private String productName;
private String year;
private Integer quarter;
private Long totalTransferQty;
private String sourceWarehouseId;
private String sourceWarehouseCode;
private String sourceWarehouseName;
private String targetWarehouseId;
private String targetWarehouseCodo;
private String targetWarehouseName;
public String getProductId() {
return productId;
}
public void setProductId(String productId) {
this.productId = productId;
}
public String getProductCode() {
return productCode;
}
public void setProductCode(String productCode) {
this.productCode = productCode;
}
public String getProductName() {
return productName;
}
public void setProductName(String productName) {
this.productName = productName;
}
public String getYear() {
return year;
}
public void setYear(String year) {
this.year = year;
}
public Integer getQuarter() {
return quarter;
}
public void setQuarter(Integer quarter) {
this.quarter = quarter;
}
public Long getTotalTransferQty() {
return totalTransferQty;
}
public void setTotalTransferQty(Long totalTransferQty) {
this.totalTransferQty = totalTransferQty;
}
public String getSourceWarehouseId() {
return sourceWarehouseId;
}
public void setSourceWarehouseId(String sourceWarehouseId) {
this.sourceWarehouseId = sourceWarehouseId;
}
public String getSourceWarehouseCode() {
return sourceWarehouseCode;
}
public void setSourceWarehouseCode(String sourceWarehouseCode) {
this.sourceWarehouseCode = sourceWarehouseCode;
}
public String getSourceWarehouseName() {
return sourceWarehouseName;
}
public void setSourceWarehouseName(String sourceWarehouseName) {
this.sourceWarehouseName = sourceWarehouseName;
}
public String getTargetWarehouseId() {
return targetWarehouseId;
}
public void setTargetWarehouseId(String targetWarehouseId) {
this.targetWarehouseId = targetWarehouseId;
}
public String getTargetWarehouseCodo() {
return targetWarehouseCodo;
}
public void setTargetWarehouseCodo(String targetWarehouseCodo) {
this.targetWarehouseCodo = targetWarehouseCodo;
}
public String getTargetWarehouseName() {
return targetWarehouseName;
}
public void setTargetWarehouseName(String targetWarehouseName) {
this.targetWarehouseName = targetWarehouseName;
}
}
DTO 里有一个小拼写问题------targetWarehouseCodo,应该是 targetWarehouseCode。不过这不影响运行,MyBatis 映射用的是列别名而不是字段名,只要列别名对了就行。后面有空再统一改。
1.1.4 添加节点以及边
java
stateGraph.addNode(Constant.NodeName.INVENTORY_ORDER_DATA,
AsyncNodeAction.node_async(new CollectInventoryOrderNode(inventoryService)));
// 添加Edge
stateGraph.addEdge(StateGraph.START, Constant.NodeName.SALE_RECORD);
stateGraph.addEdge(Constant.NodeName.SALE_RECORD, StateGraph.END);
stateGraph.addEdge(StateGraph.START, Constant.NodeName.INVENTORY_ORDER_DATA);
stateGraph.addEdge(Constant.NodeName.INVENTORY_ORDER_DATA, StateGraph.END);
注意这里两个数据采集节点(销售和调拨)都从 START 出发------它们是并行的,互不依赖。这和后面 PredictNode 不一样:PredictNode 必须等两个采集节点都跑完才能开始。
目前每个采集节点都直接连到了 END,这只是临时测试用的。等 PredictNode 加进来之后,这两个 END 边会被替换掉。所以在加边的过程中,GraphConfig 的代码会频繁改动------这是正常的,不代表你设计有问题,而是 Graph 本身就是一步步生长出来的。
1.1.5 测试

ini
inventoryTransferStr=[[{"warehouseId":"1","productId":"1","quantity":500,"lockedQuantity":0,"updatedTime":1745719200000,"id":"1"},{"warehouseId":"2","productId":"1","quantity":320,"lockedQuantity":50,"updatedTime":1745719200000,"id":"2"},{"warehouseId":"3","productId":"1","quantity":150,"lockedQuantity":0,"updatedTime":1745719200000,"id":"3"},{"warehouseId":"4","productId":"1","quantity":80,"lockedQuantity":0,"updatedTime":1745719200000,"id":"4"}]]
两个节点都跑通了,状态机里现在有销售数据、当前库存、历史调拨数据三份数据。接下来才是重头戏------把这三份数据喂给大模型。
1.2 llm预测分析、调拨建议
这里进行了一些小尝试,如Ollama的各种版本尝试。我也做一些补充。
PredictNode 是整个 Graph 的核心------前面的采集节点都是为它准备数据,后面的 ExtractNode、SendEmailNode、CreateInventoryTransferNode 都是消化它的产出。Prompt 的质量直接决定了 AI 给出的调拨建议靠不靠谱。
1.2.1 Ollama部署
1.2.1.1 docker-compose 部署
yml
version: '3'
services:
ollama:
image: ollama/ollama:latest
container_name: ollama
restart: unless-stopped
environment:
- OLLAMA_ORIGINS=*
- TZ=Asia/Shanghai
ports:
- "11434:11434"
volumes:
- /export/data/ollama:/root/.ollama
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: all
capabilities: [gpu]
docker-compose up -d
docker ps
将docker-compose up -d 编写到autorun.sh中,同时给权限 chmod +x autorun.sh

1.2.1.2 模型
Ollama 官方模型库:ollama.com/library
bash
docker exec -it ollama /bin/bash
docker exec -it ollama sh
ollama list
ollama run deepseek-r1:7b

java.agentscope.io/v2/zh/intro...
arduino
ollama run qwen3.5:2b
ollama run qwen3.5:9b
本地部署 Ollama 主要是为了省 API 费用,开发调试阶段调用量大,用千问的 API 一分一分地跑也不便宜。但实际跑下来,deepseek-r1:7b 在我的机器上(没有独显)基本跑不动,qwen3.5:2b 倒是能跑但效果明显不如云端的千问 max。所以后面正式用的时候还是切回了 dashscope 的 API。本地方案留着当备选,哪天机器升级了再说。
1.2.2 PredictNode
java
package vip.wayhua.ivy.ai.agent.nodes;
import cn.hutool.core.date.DateUtil;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import reactor.core.publisher.Flux;
import vip.wayhua.ivy.ai.agent.constant.Constant;
import vip.wayhua.ivy.ai.core.utils.StringUtils;
import java.util.Map;
public class PredictNode implements NodeAction {
private static final Logger log = LoggerFactory.getLogger(PredictNode.class);
private final ChatClient chatClient;
public PredictNode(ChatClient chatClient) {
this.chatClient = chatClient;
}
@Override
public Map<String, Object> apply(OverAllState state) throws Exception {
// 1.从状态机获取到商品id
String productId = state.value(Constant.KeyName.PRODUCT_ID, "");
log.info("CollectInventoryOrderNode->productId->" + productId);
if (StringUtils.isEmpty(productId)) {
return Map.of();
}
String saleRecordData = state.value(Constant.KeyName.SALE_RECORD_DATA, "");
String nowProductInventoryData = state.value(Constant.KeyName.NOW_PRODUCT_INVENTORY_DATA, "");
String inventoryOrderData = state.value(Constant.KeyName.INVENTORY_ORDER_DATA, "");
String saleDate = DateUtil.today();
Flux<String> content = chatClient.prompt()
.system("""
# 角色:
你是一名专业的供应链分析师和库存管理专家,擅长基于销售数据和季节趋势进行精准的库存预测和调拨规划。
# 任务:
基于提供的商品历史销售数据、商品历史库存调拨数据、当前库存状况和日期信息,严格基于用户提供的销售数据和库存数据进行分析;
分析销售模式,预测未来需求,并生成一份科学合理的库存调拨单。
# 分析要求:
##第一步:销售模式分析
1. 识别销售的季节性规律和趋势
2. 分析促销活动对销售的影响程度
3. 计算季度环比增长率和同比增长率
## 第二步:需求预测
1. 基于历史数据和当前季度位置,预测未来周期的需求量
2. 考虑季节性因素和增长趋势
3. 评估预测的不确定性范围
## 第三步:库存优化计算
1. 计算各仓库的安全库存水平,减少库存积压与缺货风险
2. 识别库存过剩或不足的仓库
3. 确定最优的库存分配方案
# 输出要求:
生成一份格式化的"库存调拨单";
输出结果以JSON格式输出,便于系统解析;
不可凭空虚构数据,若数据不足,请说明假设。
请始终输出清晰、可直接用于业务系统的数据结构,JSON格式数据中禁止出现运算。
只返回一个仓库的调拨意见即可。
将详细的解释说明放在comment字段中。
输出示例结构:
{
"sourceWarehouseId": 1,
"targetWarehouseId": 2,
"status":0,
"createdBy": "AI智能助手",
"transferType": 1,
"transferDate": "2025-11-12",
"comment": "解释说明"
"items": [
{
"productId": 11,
"transferQuantity": 直接给出具体数值,
"actualQuantity": 0,
"remark": "备注"
},
...
]
}
""")
.user(t -> t.text("""
输入数据:
商品id:{productId}
商品历史销售数据: {saleRecordData}
商品历史库存调拨数据: {inventoryOrderData}
当前库存状况:{nowProductInventoryData}
销售日期是: {saleDate}
""").params(Map.of("productId", productId,
"saleRecordData", saleRecordData,
"inventoryOrderData", inventoryOrderData,
"nowProductInventoryData", nowProductInventoryData,
"saleDate", saleDate)))
.stream().content();
StringBuilder sb = new StringBuilder();
content.doOnNext(c -> sb.append(c)).blockLast();
log.info("predictNode result=[{}]", sb.toString());
return Map.of(Constant.KeyName.INVENTORY_TRANSFER_STR, sb.toString());
}
}
这个节点的 system prompt 花了比较长的时间调试。几个关键决策:
- 三步分析法:先分析销售模式,再做需求预测,最后算库存方案。让模型有一个结构化的思考路径,而不是直接跳到结论。这个"链式思考"的方式在实践中确实比直接问"怎么调拨"效果要好;
- JSON 输出约束:明确要求输出 JSON、禁止运算、只要一个仓库的建议。不约束的话模型会输出一大段分析报告,后面的节点没法自动解析;
- 数据边界:提示词里写死了"严格基于用户提供的数据"、"不可凭空虚构",这是为了防止模型编造数据。在实际测试中,不写这句模型偶尔会脑补一些不存在的销售数字。
blockLast() 是 Reactor 的阻塞操作------因为节点是同步的,需要等流式响应全部返回再继续。如果用异步方式,状态机拿不到结果就往下走了。
修改配置
java
stateGraph.addNode(Constant.NodeName.PREDICT_NODE,
AsyncNodeAction.node_async(new PredictNode(chatClientBuilder.build())));
// 添加Edge
stateGraph.addEdge(StateGraph.START, Constant.NodeName.SALE_RECORD);
stateGraph.addEdge(Constant.NodeName.SALE_RECORD, Constant.NodeName.PREDICT_NODE);
stateGraph.addEdge(StateGraph.START, Constant.NodeName.INVENTORY_ORDER_DATA);
stateGraph.addEdge(Constant.NodeName.INVENTORY_ORDER_DATA, Constant.NodeName.PREDICT_NODE);
stateGraph.addEdge(Constant.NodeName.PREDICT_NODE, StateGraph.END);
边的关系在这一步发生了变化:两个采集节点不再直连 END,而是汇聚到了 PredictNode。这就形成了一个"DAG 汇聚"的模式------多个上游节点并行执行,都完成后 PredictNode 才启动。
关于 chatClientBuilder.build():这里直接在 GraphConfig 里 new ChatClient 然后传给节点构造器。ChatClient 每次 build() 出来的是一个独立实例,不需要 Spring 管理。但如果后面节点多了(ExtractNode 也要用一个),最好在 GraphConfig 里 build() 一次,把同一个实例传给多个节点,避免重复创建。
测试结果

json
{
"sourceWarehouseId": 1,
"targetWarehouseId": 3,
"status": 0,
"createdBy": "AI智能助手",
"transferType": 1,
"transferDate": "2026-07-26",
"comment": "当前日期为2026年7月26日,处于Q3初期。基于2024-2025年历史数据,广州仓(Q3)销售呈显著上升趋势(2024Q3:73台),且历年Q3均为旺季前备货期。2024年Q3北京向上海调拨40台、Q4向广州调拨60台,显示华南在下半年需求强劲。2025Q1广州销量50台虽低于2024Q1(45台同比+11%),但结合季节性规律,预计2026Q3广州需求将延续增长态势。北京仓作为主枢纽库存相对充足,建议提前向广州中心仓调拨80台以应对Q3旺季及潜在促销需求,该数量参考了2024Q4调拨量并考虑同比增长因素,同时预留安全库存避免缺货风险。",
"items": [
{
"productId": 1,
"transferQuantity": 80,
"actualQuantity": 0,
"remark": "基于2024-2025年Q3销售趋势及历史调拨模式预测,支持广州仓2026Q3旺季备货"
}
]
}
效果还不错------模型正确识别了北京→广州的调拨方向,数量 80 台也比较合理(参考了 Q4 调拨 60 台加上同比增长)。comment 里的分析逻辑也说得通:Q3 旺季前备货、华南需求增长、北京仓库存充足。这个结果拿去给人审是能用的。
但这里有一个问题:模型返回的不一定是纯 JSON。有时候前面会带一段分析文字,有时候 JSON 被包在 json... 里。这就是为什么后面必须加一个 ExtractNode 来专门提纯 JSON------PredictNode 只管生成,格式化交给下游。
1.2.3 Ollama测试
前面是修改后的版本。
java
Flux<String> content = chatClient.prompt()
.user(t -> t.text("""
用户输入内容为:{content}
帮我生成一份调拨意见
""")
.param("content", productId + saleRecordData + nowProductInventoryData + inventoryOrderData)
)
.stream().content();
正常的qwen3.7-max-2026-05-20
yaml
### 📦 高端智能手机Pro (SKU-001) 调拨优化意见书
**报告周期**:2024年Q1 - 2025年Q1
**分析对象**:高端智能手机Pro (SKU-001)
**涉及仓库**:北京中心仓、上海中心仓、广州中心仓、成都中心仓
---
### 一、 核心数据摘要
#### 1. 各仓销售趋势与占比(2024全年)
| 仓库 | Q1销量 | Q2销量 | Q3销量 | Q4销量 | 2024全年总量 | 占比 | 趋势特征 |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| 北京中心仓 | 135 | 123 | 157 | 250 | 665 | 40.8% | 强劲增长,Q4达峰值 |
| 上海中心仓 | 93 | 87 | 110 | 170 | 460 | 28.2% | 稳步上升,Q4爆发 |
| 广州中心仓 | 45 | 58 | 73 | 107 | 283 | 17.3% | 持续攀升,增速最快 |
| 成都中心仓 | 24 | 20 | 30 | 50 | 124 | 7.6% | 体量小但Q4翻倍 |
| **合计** | **297** | **288** | **370** | **577** | **1,532** | **100%** | **Q4为绝对旺季** |
#### 2. 现有调拨记录回顾
| 时间 | 调出仓 | 调入仓 | 数量 | 对应调入仓当季销量 | 调拨/销量比 | 评价 |
| :--- | :--- | :--- | :--- | :--- | :--- | :--- |
| 2024 Q1 | 北京 | 广州 | 50 | 45 | 111% | ⚠️ 调拨量略高于实际销量,稍显激进 |
| 2024 Q3 | 北京 | 上海 | 40 | 110 | 36% | ✅ 合理补充,但可能仍不足 |
| 2024 Q4 | 北京 | 广州 | 60 | 107 | 56% | ✅ 较Q1更精准,匹配度提升 |
| 2025 Q1 | 北京 | 成都 | 35 | 20 | 175% | ⚠️ 大幅超配,需关注库存消化 |
> **关键发现**:所有调拨均由北京中心仓单向发出,北京仓承担了全国分拨枢纽角色。2025年Q1对成都的调拨量(35)远超其历史单季最高销量(Q4=50),存在滞销风险。
---
### 二、 问题诊断
1. **调拨节奏滞后于销售增长**:广州仓销量从Q1的45增至Q4的107(+138%),但Q4调拨仅60台,调拨增速未跟上销售增速,可能导致旺季缺货。
2. **成都仓2025Q1过度补货**:35台的调拨量接近其2024全年销量的28%,而Q1通常是淡季(2024Q1仅24台)。若无特殊促销计划,极易形成积压。
3. **上海仓调拨频次不足**:上海作为第二大销售仓(占比28%),全年仅在Q3获得一次40台调拨,其余时间依赖本地库存。考虑到Q4销量达170台,供应链弹性不足。
4. **缺乏逆向调拨机制**:数据中未见任何从低动销仓向高需求仓的回流调拨,库存调配呈单向刚性。
---
### 三、 调拨优化建议
#### 🔴 紧急调整项(2025 Q1执行)
- **削减成都调拨量**:建议将2025Q1北京→成都的调拨从35台下调至 **20-25台**,与其Q1历史均值及季节性系数匹配。释放的10-15台配额优先支援上海或广州。
- **增设上海Q1补货**:2025Q1上海预测销量约75台(已提供数据),建议追加 **20-30台** 从北京调入,避免重演2024年上半年调拨空白的问题。
#### 🟡 中期策略优化(2025 Q2起)
| 优化方向 | 具体措施 | 预期效果 |
| :--- | :--- | :--- |
| **建立动态调拨模型** | 按"上季销量×季节系数×安全库存天数"计算调拨量,替代经验值 | 调拨/销量比稳定在60%-80%区间 |
| **增加上海调拨频次** | 由年度1-2次提升至每季度至少1次 | 降低上海仓缺货风险,平滑库存波动 |
| **设置调拨上限预警** | 单次调拨量不超过目标仓近两季平均销量的70% | 防止类似2025Q1成都超配情况再现 |
| **启动月度库存审视** | 每月末对比各仓存销比,触发临时调拨或促销 | 提前识别滞销/缺货信号 |
#### 🟢 长期网络优化
- **评估区域分仓职能**:广州仓销售增速显著(2024年环比增长138%),建议评估将其升级为华南区域分拨中心的可行性,减少对北京仓的长距离调拨依赖。
- **引入跨仓直调能力**:允许上海↔广州之间根据实时库存互调,缩短响应链路。
---
### 四、 风险提示
1. **2025Q1成都库存监控**:若已执行35台调拨,需在Q1末进行专项盘点,如动销率低于50%,立即启动区域内促销或回流北京/广州。
2. **北京仓承压测试**:作为唯一调出仓,需确保其自身Q4旺季备货不受外调影响。建议设定北京仓最低安全库存红线(建议不低于当季预测销量的120%)。
3. **数据质量注意**:原始调拨数据中`targetWarehouseCodo`字段值为null,建议核实系统数据完整性,避免后续自动化调拨逻辑出错。
---
**编制说明**:本意见基于2024Q1-2025Q1历史销售与调拨数据分析生成,实际执行请结合当前实时库存、在途订单及市场营销计划综合决策。建议每月初更新数据并滚动修订调拨计划。
切换Ollama
添加Ollama引用,同时取消dashscope的引用
xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-ollama</artifactId>
</dependency>
<!-- <dependency>-->
<!-- <groupId>com.alibaba.cloud.ai</groupId>-->
<!-- <!– 阿里百炼大模型服务平台 –>-->
<!-- <artifactId>spring-ai-alibaba-starter-dashscope</artifactId>-->
<!-- </dependency>-->
添加配置
yaml
spring:
ai:
ollama:
base-url: http://192.168.55.130:11434
chat:
model: deepseek-r1:7b
ollama run qwen3.5:2b
我的电脑跑不起来,暂时就不测试了
本地 Ollama 这条路暂时走不通,主要卡在显存上。7b 模型至少要 8G 显存才能流畅推理,纯 CPU 跑的话一个字一个字往外蹦,体验太差。不过代码层面是通的------Spring AI 的 ChatClient 抽象层屏蔽了不同提供商的差异,换模型只需要改 pom 和 yaml,代码一行不用动。这也是 Spring AI 设计得好的地方。
1.3 JSON提取节点ExtractNode
PredictNode 的输出不一定干净------有时候模型会在 JSON 前后加分析文字,有时候用 Markdown 代码块包起来。下游的 CreateInventoryTransferNode 需要严格的 JSON 才能反序列化成 TransferOrderParam,所以中间加了一个 ExtractNode 来"清洗"数据。
java
package vip.wayhua.ivy.ai.agent.nodes;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.client.ChatClient;
import reactor.core.publisher.Flux;
import vip.wayhua.ivy.ai.agent.constant.Constant;
import java.util.Map;
/****
* 提取JSON数据节点
*/
public class ExtractNode implements NodeAction {
private static final Logger log = LoggerFactory.getLogger(ExtractNode.class);
private final ChatClient chatClient;
public ExtractNode(ChatClient chatClient) {
this.chatClient = chatClient;
}
@Override
public Map<String, Object> apply(OverAllState state) throws Exception {
String inventoryTransferStr = state.value(Constant.KeyName.INVENTORY_TRANSFER_STR, "");
Flux<String> content = chatClient.prompt()
.system("""
# 角色:
你是一个数据处理专家,能够从文本数据中提取JSON数据并且修正格式
# 输出要求
仅输出JSON数据,禁止改变原来的数据类型,并且不要输出任何解释、任何备注或者Markdown标记。
输出时不要包含 ```json 或者```之类的包裹符号,也不要包含多余的文字。
""")
.user(t -> t.text("""
文本数据: {content}
""").param("content", inventoryTransferStr))
.stream().content();
StringBuilder sb = new StringBuilder();
content.doOnNext(c -> sb.append(c)).blockLast();
log.info("ExtractNode result=[{}]", sb.toString());
return Map.of(Constant.KeyName.INVENTORY_TRANSFER_JSON_STR, sb.toString());
}
}
ExtractNode 的思路其实很直接------再用一次大模型,让它从脏数据里把纯 JSON 摘出来。这里 system prompt 非常强硬,"禁止"、"不要"重复了好几次,就是为了防止模型又画蛇添足。实际测试下来,这种约束对千问 max 效果很好,但在小模型上偶尔还是会加 ````json` 标记。
一个有意思的点:ExtractNode 和 PredictNode 用的是同一个 ChatClient 实例,只是 prompt 完全不同。前者是供应链分析师,后者是数据清洗工------同一个模型,换个角色就能干完全不同的活。
添加Config
java
stateGraph.addNode(Constant.NodeName.EXTRACT_NODE,
AsyncNodeAction.node_async(new ExtractNode(chatClientBuilder.build())));
// 添加Edge
stateGraph.addEdge(StateGraph.START, Constant.NodeName.SALE_RECORD);
stateGraph.addEdge(Constant.NodeName.SALE_RECORD, Constant.NodeName.PREDICT_NODE);
stateGraph.addEdge(StateGraph.START, Constant.NodeName.INVENTORY_ORDER_DATA);
stateGraph.addEdge(Constant.NodeName.INVENTORY_ORDER_DATA, Constant.NodeName.PREDICT_NODE);
stateGraph.addEdge(Constant.NodeName.PREDICT_NODE, Constant.NodeName.EXTRACT_NODE);
stateGraph.addEdge(Constant.NodeName.EXTRACT_NODE, StateGraph.END);
到这里 Graph 的链路就变成了:采集节点 → PredictNode → ExtractNode → END。后面还要往 ExtractNode 后面接邮件和创建调拨单两个节点。
测试


从截图可以看到,ExtractNode 成功从 PredictNode 的输出中提取出了干净的 JSON。状态机里现在有两个跟调拨相关的 key:inventoryTransferStr(原始输出)和 inventoryTransferJsonStr(清洗后的 JSON)。下游节点用 INVENTORY_TRANSFER_JSON_STR,需要看原始分析内容的可以用 INVENTORY_TRANSFER_STR。
二. 复杂节点
2.1 邮件发送SendEmailNode
邮件发送节点跟前面几个节点不太一样------前面的节点都在"生产数据",这个节点是"消费数据并产生外部效果"。它从状态机拿到 JSON 后不写回状态机,而是调邮件服务发出去。
这里邮件通知是个关键环节:AI 生成的调拨建议不能直接执行,得先发给审批人看一眼。所以邮件里包含了调拨建议的内容、采纳和拒绝的链接(虽然链接现在是空的,后面做审批流程时再补)。
2.1.1 SendEmailNode
java
package vip.wayhua.ivy.ai.agent.nodes;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import vip.wayhua.ivy.ai.agent.constant.Constant;
import vip.wayhua.ivy.ai.agent.service.EmailService;
import java.util.Map;
/***
* 发送邮件
*/
public class SendEmailNode implements NodeAction {
EmailService emailService;
public SendEmailNode(EmailService emailService) {
this.emailService = emailService;
}
@Override
public Map<String, Object> apply(OverAllState state) throws Exception {
String inventoryTransferJsonStr = state.value(Constant.KeyName.INVENTORY_TRANSFER_JSON_STR, "");
JSONObject jsonObject = JSONUtil.parseObj(inventoryTransferJsonStr);
String comment = jsonObject.getStr(Constant.KeyName.COMMENT);
// String threadId = state.value(Constant.KeyName.THREAD_ID, "");
emailService.sendTemplateEmail("48366939@qq.com",
Map.of(Constant.KeyName.INVENTORY_TRANSFER_SAVE_PARAM_STR, comment,
Constant.KeyName.ADOPT_LINK, "",
Constant.KeyName.REJECT_LINK, ""));
return Map.of();
}
}
注意这里返回了空 Map------SendEmailNode 是终点节点之一,不需要再往状态机写数据。adoptLink 和 rejectLink 先留空,这是给后面审批流程预留的口子。把链接填进去之后,审批人点邮件里的按钮就能直接批,不用再登录系统操作。
2.1.2 添加pom
xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
邮件配置
smtp.126.com
CDnDp94ee2tBF4ZU

发送邮件EmailServiceImpl
java
package vip.wayhua.ivy.ai.agent.service.impl;
import jakarta.annotation.Resource;
import jakarta.mail.internet.MimeMessage;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.stereotype.Service;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import vip.wayhua.ivy.ai.agent.service.EmailService;
import java.util.Map;
@Service
public class EmailServiceImpl implements EmailService {
private static final Logger log= LoggerFactory.getLogger(EmailServiceImpl.class);
@Value("${spring.mail.username}")
private String from;
@Resource
private JavaMailSender javaMailSender;
@Resource
private TemplateEngine templateEngine;
@Override
public void sendTemplateEmail(String to, Map<String, String> variables) {
Context context=new Context();
variables.forEach(context::setVariable);
String htmlContent = templateEngine.process("EmailTemplate", context);
this.sendHtmlEmail(to,htmlContent);
}
private void sendHtmlEmail(String to, String htmlContent) {
try{
MimeMessage mimeMessage = javaMailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8");
helper.setFrom(from);
helper.setTo(to);
helper.setSubject("AI智能库存调拨单通知");
helper.setText(htmlContent,true);
javaMailSender.send(mimeMessage);
log.info("HTML email sent success,to=[{}]",to);
}catch (Exception e){
log.error("Failed to send html emial",e);
throw new RuntimeException("Failed to send email",e);
}
}
}
邮件用 Thymeleaf 模板渲染 HTML,比直接在代码里拼 HTML 字符串强多了。模板文件放在 resources/templates/EmailTemplate.html,可以随时调样式,不用改 Java 代码。
这里有一个容易忽略的点:126 邮箱的授权码(CDnDp9...)不是登录密码,是要去 126 邮箱设置里单独生成的。用登录密码的话会报 535 认证失败。
2.1.3 配置
java
stateGraph.addNode(Constant.NodeName.SEND_EMAIL_NODE,
AsyncNodeAction.node_async(new SendEmailNode(emailService)));
// 添加Edge
stateGraph.addEdge(StateGraph.START, Constant.NodeName.SALE_RECORD);
stateGraph.addEdge(Constant.NodeName.SALE_RECORD, Constant.NodeName.PREDICT_NODE);
stateGraph.addEdge(StateGraph.START, Constant.NodeName.INVENTORY_ORDER_DATA);
stateGraph.addEdge(Constant.NodeName.INVENTORY_ORDER_DATA, Constant.NodeName.PREDICT_NODE);
stateGraph.addEdge(Constant.NodeName.PREDICT_NODE, Constant.NodeName.EXTRACT_NODE);
stateGraph.addEdge(Constant.NodeName.EXTRACT_NODE, Constant.NodeName.SEND_EMAIL_NODE);
stateGraph.addEdge(Constant.NodeName.SEND_EMAIL_NODE, StateGraph.END);
2.1.4 邮件application配置
yaml
spring:
mail:
host: smtp.126.com
port: 465
username: wayhua@126.com
password: CDnDp9---
properties:
mail:
smtp:
auth: true
starttls:
enable: true
required: true
socketFactory:
port: 465
class: javax.net.ssl.SSLSocketFactory
fallback: false
2.1.5 thymeleaf
thymeleaf配置基本上不用做太多改变
yaml
spring:
thymeleaf:
cache: true
check-template: true
check-template-location: true
servlet:
content-type: text/html
enabled: true
encoding: UTF-8
excluded-view-names: ''
mode: HTML
prefix: classpath:/templates/
suffix: .html
Thymeleaf 默认配置基本够用,只需要确认 prefix 和 suffix 跟你放模板的位置对得上就行。cache: true 在生产环境挺好的,但开发时建议关了,不然改完模板不重启看不到效果。
2.1.6测试
邮件发送成功。
2.2 自动生成调拨单CreateInventoryTransferNode
先做创建自动生成调拨单,不管审核情况,调通再说。
人工审批还是比较麻烦的。将在下一期讨论。
这个节点是整个流程的"落地"节点------把 AI 生成的 JSON 转成数据库里的调拨单记录。流程是:提取 JSON → 反序列化 → 生成单号 → 写入主表和明细表。因为涉及两张表的写入,这里用了 MapStruct 做对象转换,省得手写一堆 setter。
2.2.1 生成的json数据 !

创建TransferOrderItemParam
java
package vip.wayhua.ivy.ai.agent.dto;
public class TransferOrderItemParam {
private Integer transferOrderId;
private Integer productId;
private Integer transferQuantity;
private String remark;
//setter/getter
}
TransferOrderParam
java
package vip.wayhua.ivy.ai.agent.dto;
import java.util.List;
public class TransferOrderParam {
private String orderNo;
private Integer sourceWarehouseId;
private Integer targetWarehouseId;
private Integer status;
private Integer transferType;
private String transferDate;
private String comment;
private String createdBy;
private List<TransferOrderItemParam> items;
//setter/getter
}
这两个 Param 类是 AI JSON 和数据库表之间的"中转站"。字段名直接对应 ExtractNode 输出的 JSON key,通过 Jackson 反序列化直接映射。如果 JSON 里多出了不认识的字段,默认会被忽略,不会报错------这对容错性来说是好事。
2.2.2 CreateInventoryTransferNode
java
package vip.wayhua.ivy.ai.agent.nodes;
import cn.hutool.json.JSONUtil;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import vip.wayhua.ivy.ai.agent.constant.Constant;
import vip.wayhua.ivy.ai.agent.dto.TransferOrderParam;
import vip.wayhua.ivy.ai.core.utils.JsonUtils;
import java.util.Map;
public class CreateInventoryTransferNode implements NodeAction {
private static final Logger log= LoggerFactory.getLogger(CreateInventoryTransferNode.class);
@Override
public Map<String, Object> apply(OverAllState state) throws Exception {
String inventoryTransferJsonStr = state.value(Constant.KeyName.INVENTORY_TRANSFER_JSON_STR , "");
TransferOrderParam transferOrderParam = JsonUtils.fromJsonObject( inventoryTransferJsonStr, TransferOrderParam.class);
log.info("transferOrderParam =[{}]",transferOrderParam);
// transferOrderService.create(transferOrderParam);
return Map.of();
}
}
先测试转换
TransferOrderParam和TransferOrderParam必须添加toString方法不然显示不出具体内容
ini
transferOrderParam =[TransferOrderParam{orderNo='null', sourceWarehouseId=1, targetWarehouseId=3, status=0, transferType=1, transferDate='2026-07-26', comment='当前日期为2026年7月26日,处于Q3季度中期。基于2024-2025年历史数据分析:1.季节性规律显著,Q4为销售峰值(2024年Q4全仓销量577台),Q3次之(2024年Q3销量370台),Q1/Q2相对平缓;2.广州仓(WH-GZ-001)在Q3呈现稳定增长趋势(2024年Q3销量73台,较Q2增长25.9%),且历史上Q3-Q4均需从北京仓调拨补货(2024年Q3未调拨但Q4调拨60台,显示Q3末备货需求);3.预测2026年Q3广州仓需求量约80-90台(基于2024年Q3基数73台及年均增长率约10%估算),考虑当前已入Q3下旬,需提前为Q4旺季备货并覆盖剩余Q3需求;4.北京仓作为主枢纽仓,历史承担主要调出职能,本次建议调拨45台以平衡广州仓Q3尾期销售及Q4初期安全库存,避免旺季缺货风险。注:因缺乏2026年实时库存数据,本建议基于历史销售比例与季节系数推算,实际执行前请校验各仓当前可用库存。', createdBy='AI智能助手', items=[TransferOrderItemParam{transferOrderId=null, productId=1, transferQuantity=45, remark='基于2024年Q3广州仓销量73台及Q4备货需求预测,结合历史调拨模式,建议补货45台以覆盖Q3剩余需求及Q4初期安全库存'}]}]
JSON 反序列化一次成功。这里踩了一个小坑:Lombok 生成的 toString 方法默认不包含父类字段,而 MapStruct 生成的 Converter 类又依赖 toString 来调试。统一加了 @Data 或者自己手写 toString 方法,日志里才能看到完整内容。
2.2.3 生成调拨单
java
package vip.wayhua.ivy.ai.agent.nodes;
import cn.hutool.json.JSONUtil;
import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.action.NodeAction;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import vip.wayhua.ivy.ai.agent.constant.Constant;
import vip.wayhua.ivy.ai.agent.dto.TransferOrderParam;
import vip.wayhua.ivy.ai.agent.service.TransferOrderService;
import vip.wayhua.ivy.ai.core.utils.JsonUtils;
import java.util.Map;
public class CreateInventoryTransferNode implements NodeAction {
private static final Logger log = LoggerFactory.getLogger(CreateInventoryTransferNode.class);
TransferOrderService transferOrderService;
public CreateInventoryTransferNode(TransferOrderService transferOrderService) {
this.transferOrderService = transferOrderService;
}
@Override
public Map<String, Object> apply(OverAllState state) throws Exception {
String inventoryTransferJsonStr = state.value(Constant.KeyName.INVENTORY_TRANSFER_JSON_STR, "");
TransferOrderParam transferOrderParam = JsonUtils.fromJsonObject(inventoryTransferJsonStr, TransferOrderParam.class);
log.info("transferOrderParam =[{}]", transferOrderParam);
transferOrderService.createTransferOder(transferOrderParam);
return Map.of();
}
}
TransferOrderServiceImpl
java
@Override
public void createTransferOder(TransferOrderParam param) {
param.setOrderNo(Sid.nextShort());
TransferOrder bbTransferOrder = convert.paramToModel(param);
save(bbTransferOrder);
//子单转换
List<TransferOrderItem> bbTransferOrderItems = convert.itemParamsToModels(param.getItems());
bbTransferOrderItems.forEach(item -> {
item.setTransferOrderId(bbTransferOrder.getId());
transferOrderItemService.save(item);
});
}
主表和明细表的写入逻辑:先生成单号,再把 Param 转成 Model(MapStruct),保存主表拿到主键 ID,回填给明细表,最后批量保存明细。这是一个标准的"主从表写入"流程,没什么特别的,但要注意两点:
- 明细表必须先拿到主表 ID 才能保存,否则外键为空;
Sid.nextShort()生成短单号,方便人看。如果用雪花 ID 或者 UUID,人眼辨识度太差,审批单上不好找。
转换必须添加止
xml
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.15.0</version>
<configuration>
<source>17</source>
<target>17</target>
<encoding>UTF-8</encoding>
<!-- ✅ 必须配置:注解处理器 -->
<annotationProcessorPaths>
<path>
<groupId>org.mapstruct</groupId>
<artifactId>mapstruct-processor</artifactId>
<version>${mapstruct.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
MapStruct 的注解处理器配置是必须的,不然编译期不会生成 Converter 的实现类,运行时报 ClassNotFoundException。这个坑在之前 DocSolo 项目里踩过,这次直接复制过来了。Java 17 下 MapStruct 需要用 annotationProcessorPaths 显式指定,因为 JEP 301(内部类增强)改了一些编译行为,老项目升级到 Java 17 时经常在这里踩坑。
2.2.4 测试


三条记录入库成功。主表 bb_transfer_order 里 transfer_type=1(AI 智能调拨),status=0(待审核),明细表里的 productId、quantity 都对应上了 AI 输出的数据。整个链路从数据采集到 AI 分析到入库,全部跑通了。
三. 小结
本节就是按部就班的完成,有几个难点是发送邮件以及mapstruct要添加build。
回头看看这个开发过程,有几个点是个人觉得比较值得记下来的:
1. Graph 开发的套路比想象中简单
没开始做之前觉得 Graph 编排很复杂,什么 DAG、条件边、并行节点之类的名词一堆。实际做下来发现核心流程非常固定:实现 NodeAction → 在 GraphConfig 里 addNode → addEdge 连线 → 调接口测试。六个节点全是这个套路,唯一的区别在业务逻辑。框架本身的复杂度很低,真正花时间的是调 prompt 和补业务细节。
2. 状态 key 管理是个痛点
随着节点越来越多,状态 key 也越来越多------saleRecordData、inventoryOrderData、nowProductInventoryData、inventoryTransferStr、inventoryTransferJsonStr......每次新增都要在 Constant 和 GraphConfig 两处同步更新,漏一处就报错。这个机制目前还很原始,后面如果状态 key 超过十几二十个,靠人工维护很容易出错。不知道 Graph 框架后面会不会支持自动注册或者约定大于配置的方式。
3. 节点解耦带来的调试便利
六个节点各自独立,出问题了很容易定位------看日志是哪个节点的输出不对,修那个节点就行,不影响其他节点。采集节点写好了,后面改 PredictNode 的 prompt 时完全不用动采集节点;ExtractNode 单独加进来也是插进去就行。这种"一个节点一个职责"的粒度,比在一个大方法里串行调用六个步骤好维护得多。
4. AI 输出的不确定性需要下游兜底
PredictNode 的输出不是 100% 可控的------prompt 写得再详细,模型偶尔还是会出幺蛾子(带 Markdown 标记、多输出一段分析文、JSON 字段名对不上)。ExtractNode 就是这个兜底方案。实际项目中这种"再调一次 AI 来清洗"的做法挺常见,比写正则可靠------正则遇到没见过的格式就崩了,AI 最少还能理解上下文。
5. 发邮件和 MapStruct 这两个小坑
126 邮箱的授权码和登录密码是两回事,用错直接 535。MapStruct 在 Java 17 下必须配置 annotationProcessorPaths,不然编译时不会生成实现类。这两个问题都不大,但第一次遇到时排查起来挺费时间,记录下来给后面提个醒。
6. 本地模型 vs 云端模型的取舍
尝试了 Ollama 本地部署,deepseek-r1:7b 在没 GPU 的机器上基本不能用。不过 Spring AI 的抽象层做得不错------换模型只需要改 pom 和 yaml,不影响业务代码。开发阶段用千问 max 保证效果,等本机配置升级了再切回 Ollama,成本上会更划算。这个"先跑通再优化"的策略在 AI 项目里挺实用。
下一期要处理人工审批流程------目前调拨单写入后直接是"待审核"状态,需要做个审批接口让审核人能采纳或拒绝,审批通过后还要回写库存表的 locked_quantity。这部分涉及 Graph 的状态回写和条件分支,比这一期要复杂一些。