AI Agent开发实录02:Java程序员用Spring AI Alibaba 1.1.2.0从0到1手搓生产级 AI Agent —— 串起完整 Graph

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>-->
<!--            &lt;!&ndash; 阿里百炼大模型服务平台 &ndash;&gt;-->
<!--            <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 是终点节点之一,不需要再往状态机写数据。adoptLinkrejectLink 先留空,这是给后面审批流程预留的口子。把链接填进去之后,审批人点邮件里的按钮就能直接批,不用再登录系统操作。

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 默认配置基本够用,只需要确认 prefixsuffix 跟你放模板的位置对得上就行。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,回填给明细表,最后批量保存明细。这是一个标准的"主从表写入"流程,没什么特别的,但要注意两点:

  1. 明细表必须先拿到主表 ID 才能保存,否则外键为空;
  2. 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_ordertransfer_type=1(AI 智能调拨),status=0(待审核),明细表里的 productId、quantity 都对应上了 AI 输出的数据。整个链路从数据采集到 AI 分析到入库,全部跑通了。

三. 小结

本节就是按部就班的完成,有几个难点是发送邮件以及mapstruct要添加build。

回头看看这个开发过程,有几个点是个人觉得比较值得记下来的:

1. Graph 开发的套路比想象中简单

没开始做之前觉得 Graph 编排很复杂,什么 DAG、条件边、并行节点之类的名词一堆。实际做下来发现核心流程非常固定:实现 NodeAction → 在 GraphConfig 里 addNode → addEdge 连线 → 调接口测试。六个节点全是这个套路,唯一的区别在业务逻辑。框架本身的复杂度很低,真正花时间的是调 prompt 和补业务细节。

2. 状态 key 管理是个痛点

随着节点越来越多,状态 key 也越来越多------saleRecordDatainventoryOrderDatanowProductInventoryDatainventoryTransferStrinventoryTransferJsonStr......每次新增都要在 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 的状态回写和条件分支,比这一期要复杂一些。

四. 代码

gitee.com/wavaya88/Do...

相关推荐
东小西1 小时前
第10篇:《老板说“查一下这个月谁业绩最好”,我让AI自己写了SQL》
openai·ai编程
武子康1 小时前
从 OpenAI 披露的自主 Agent 越界事件看:高能力模型评估为什么需要一份可验证的 Containment Contract
人工智能·openai·agent
李剑一1 小时前
Kimi K3太牛了!但老板说API太贵,让我本地化部署,我算完成本,他涨红脸沉默不语了!其实成本不高,三千万足矣。
aigc·ai编程
leeyi2 小时前
ToolsNode 源码拆解:工具并行执行、中间件洋葱与 HITL Rerun(第65篇-E51)
aigc·agent·ai编程
赫媒派2 小时前
Postgres LN 扩展性优化:2.9K 到 60K/秒
ai编程
颜进强2 小时前
从零搭建私人 RAG 实战:用 Markdown 沉淀技术决策与业务决策
前端·后端·ai编程
码农进化录2 小时前
Java 程序员的 AI 进化论 | AI 加 Postman 跑接口测试,省了三天活
java·后端·openai
怕浪猫3 小时前
第8章 前端交互与可视化:构建Agent的用户界面
aigc·openai·ai编程
鱼樱前端3 小时前
AI 会不会取代前端?我看了 2026 年 7 月整个市场,给你一个不吓人的答案
前端·程序员·ai编程