【WMS 仓储系统集成 AI Agent 实战】第 4 讲:15 张表 + 19 个 AI 工具——让大模型真正“摸到“数据库

0 阅读9分钟

【WMS 仓储系统集成 AI Agent 实战】第 4 讲:15 张表 + 19 个 AI 工具——让大模型真正"摸到"数据库

前三讲把地基打完了。这一讲是整个项目的业务核心:ERP 仓储域怎么建模、AI 怎么通过工具调用读写真实数据、库存防超卖怎么保证。还有一个 8B 模型特有的坑:数字参数 100% 成功,字符串参数时灵时不灵。

本讲复现环境与版本

版本/说明
Spring AI1.0.9(@Tool / @ToolParam / DefaultToolCallingManager)
模型hermes3:latest 8B(踩坑期)→ qwen2.5:7b-instruct-q4_K_M(修复后)
MyBatis-Plus3.5.8(spring-boot3-starter)
PostgreSQL17.10 + pgvector v0.8.5-pg17
Spring Boot3.5.16 · JDK 17

本讲问题均按「版本号 → 复现环境 → 真实报错 → 项目实际现象」四要素记录。

业务建模:15 张表

WMS 仓储域的建模,核心是理解单据状态机库存三态

单据状态机(入库单/出库单通用)

0 草稿(DRAFT) → 1 待审批 → 2 已审批(APPROVED) → 3 进行中 → 4 已完成(COMPLETED)
                     ↓                              ↓
                   5 已作废(CANCELLED) ←────────────┘

状态流转的库存联动是关键:

动作库存影响
入库单完成quantity += 实际入库量(记录不存在则自动创建)
出库单确认lockedQuantity += 申请量(锁定,防超卖)
出库单完成quantity -= 实际出库量lockedQuantity -= 申请量
出库单作废lockedQuantity -= 申请量(释放锁定)

库存三态

// erp_stock 表
private BigDecimal quantity;          // 总量
private BigDecimal lockedQuantity;    // 冻结量(已确认未完成的出库单占用)
private BigDecimal inTransitQuantity; // 在途量
// 可用量 = quantity - lockedQuantity

表清单

业务表 10 张 + 系统表 5 张:

erp_material            物料主数据
erp_stock               库存(物料×仓库唯一)
erp_in_order            入库单
erp_out_order           出库单
erp_quality_inspection  质检单(合格→自动完成入库)
ai_document             知识库文档
ai_document_chunk       文档分片
ai_doc_category         文档分类字典
sys_user / sys_role / sys_user_role / sys_operation_log
ai_config               AI 参数(单行表)
ai_config_log / ai_chat_log
+ vector_store          pgvector 向量表

一个 SQL 设计决策

我在 schema.sql 里放弃了触发器和 $$ 函数——Spring Boot 的 ScriptUtils 不支持 PostgreSQL 的 $$ 美元引用语法(报 Unterminated dollar quote)。

审计字段填充(createTime/updateTime/createBy/updateBy)全部改用 MyBatis-Plus 的 MetaObjectHandler 在应用层实现:

public class MyMetaObjectHandler implements MetaObjectHandler {
    @Override
    public void insertFill(MetaObject metaObject) {
        this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
        this.strictInsertFill(metaObject, "createBy", String.class, currentUsername());
    }
    // updateFill 同理
}

逻辑更清晰,还避免了触发器这种"看不见的逻辑"带来的排查困难。


关键设计:ErpBusinessFacade 解耦层

AI 工具类不直接注入任何 ERP Service 或 Mapper,统一走 Facade 接口:

AI Tool 类(8 个)──→ ErpBusinessFacade(接口,40+ 方法)
                          ├── LocalErpFacadeImpl  @Primary  当前生产:进程内直调 ERP Service
                          └── FeignErpFacadeImpl  预留:未来微服务拆分时走 Feign 远程调用

这个设计买到了什么?AI 层与 ERP 层的单向依赖。将来 ERP 拆成独立微服务,AI 层代码一行不改,换个 Facade 实现就行。

这里有个插曲值得说。ErpBusinessFacade 接口有两个 @Service 实现,启动直接报:

📋 问题档案

  • 版本:Spring Boot 3.5.16(Spring Framework 6.x 容器)
  • 复现环境LocalErpFacadeImplFeignErpFacadeImpl 同时贴 @Service,任一 AI 工具类注入 ErpBusinessFacade,启动必现
  • 真实报错(启动日志原文):
NoUniqueBeanDefinitionException: No qualifying bean of type
'com.erp.ai.erp.facade.ErpBusinessFacade' available:
expected single matching bean but found 2: feignErpFacadeImpl,localErpFacadeImpl
  • 项目实际现象:加了一个 Feign 远程调用的预留桩实现(方法体全是 TODO)后,项目直接起不来。桩类也是类,贴了 @Service 就会被扫描注册
No qualifying bean ... expected single matching bean but found 2

因为 FeignErpFacadeImpl 是个预留的桩实现(方法体全是 TODO),也挂了 @Service。解法是在 LocalErpFacadeImpl 上加 @Primary

划重点:预留实现的桩类不应裸挂 @Service 与生产实现并存。要么 @Primary 明确主从,要么桩类加 @Profile/@ConditionalOnMissingBean 控制加载条件。


19 个 @Tool 方法

Spring AI 的工具注册非常简洁,一个注解搞定:

@Component
public class StockQueryTool {

    private final ErpBusinessFacade erpFacade;

    @Tool(description = "查询指定物料的库存信息,包括可用量、冻结量和在途量。支持物料编码(如 MAT-001)或数字ID")
    public StockInfo queryStock(
            @ToolParam(description = "物料编码或物料数字ID,例如 'MAT-001' 或 '1'") String code,
            ToolContext context) {
        // 内部自动识别:数字 → 按 ID 查;否则按编码查
        return erpFacade.queryStockByCodeOrId(code);
    }

    @Tool(description = "查询库存量低于指定阈值的所有物料,用于低库存预警")
    public List<Stock> checkLowStock(
            @ToolParam(description = "库存阈值,低于该值预警") BigDecimal threshold,
            ToolContext context) {
        return erpFacade.queryLowStock(threshold);
    }
}

完整清单(8 个类 19 个方法):

工具类@Tool 方法功能
MaterialQueryToolqueryMaterial / getMaterialByCode按关键词/编码查物料
MaterialManageToolcreateMaterial / updateMaterial / deleteMaterial / listEnabledMaterials物料 CRUD
StockQueryToolqueryStock / checkLowStock库存查询 + 低库存预警
InOrderCreateToolcreateInOrder创建入库单(校验物料存在)
InOrderManageToolgetInOrderByNo / listInOrdersByStatus / confirmInOrder / cancelInOrder / completeInOrder入库单全生命周期
OutOrderCreateToolcreateOutOrder创建出库单(校验库存充足)
OutOrderManageToolgetOutOrderByNo / listOutOrdersByStatus / confirmOutOrder / cancelOutOrder / completeOutOrder出库单全生命周期
KnowledgeSearchToolsearchKnowledge知识库检索(无 ToolContext)

写 @Tool 的三条经验

经验 1:description 是写给模型看的,要具体到"什么时候该用我"

// ❌ 模糊
@Tool(description = "查询库存")

// ✅ 具体且有边界
@Tool(description = "查询指定物料的库存信息,包括可用量、冻结量和在途量。仅查询,不修改任何数据")

经验 2:参数名要短、要直白

queryStock(String materialCodeOrId)queryStock(String code)。模型对参数名的"理解成本"直接影响绑定成功率,这在后面踩坑部分细说。

经验 3:写操作工具要把"前置条件"写进 description

@Tool(description = "完成入库单,实际入库数量会增加到库存。要求单据状态为已审批")

不然模型会在草稿状态就尝试完成入库,然后拿到一堆业务异常。


库存防超卖

出库确认时锁定库存,核心校验逻辑:

@Override
@Transactional
public boolean lockStock(Long stockId, BigDecimal qty) {
    Stock stock = getById(stockId);
    BigDecimal available = stock.getQuantity().subtract(stock.getLockedQuantity());
    if (available.compareTo(qty) < 0) {
        throw new IllegalArgumentException("可用库存不足,当前可用: " + available + ", 需求: " + qty);
    }
    stock.setLockedQuantity(stock.getLockedQuantity().add(qty));
    return updateById(stock);
}

说实话,这个实现在单机 + 事务场景够用,但严格来说"读-判断-写"不是原子的,高并发下仍有超卖窗口。要彻底解决得用数据库乐观锁(version 字段)或 UPDATE ... WHERE quantity - locked >= ? 条件更新。当前系统的并发量(企业内部几十个用户)用事务 + 校验已经足够,但这个边界要心里有数

入库完成时"记录不存在则自动创建":

public boolean receiveOrCreate(Long materialId, String warehouseCode, BigDecimal qty,
                                String materialCode, String materialName) {
    Stock stock = getByMaterialAndWarehouse(materialId, warehouseCode);
    if (stock == null) {
        stock = new Stock();
        // ... 填充物料冗余字段 ...
        save(stock);
    }
    stock.setQuantity(stock.getQuantity().add(qty));
    stock.setLastInDate(LocalDateTime.now());
    return updateById(stock);
}

注意 erp_stock 上有 (material_id, warehouse_code) 唯一约束兜底——并发首次入库时靠约束挡住重复创建。


本讲最大的坑:字符串参数绑定不稳定

📋 问题档案

  • 版本hermes3:latest(8B)→ qwen2.5:7b-instruct-q4_K_M · Spring AI 1.0.9
  • 复现环境:对话页发送「查询物料 ZL001 的库存」「查 MAT-001 库存」等含字符串编码的指令,每句连测多次
  • 真实报错(后端日志,Spring AI 工具调用参数原文):
ToolExecutionRequest: name=queryStock arguments={"code": null}
  • 项目实际现象:反问「请输入物料编码」的那几次,queryStock 根本没拿到参数;而「查 1 号物料」这类数字 ID 指令从未失败

现象:用户说"查询物料 ZL001 的库存",AI 有时正常调用工具,有时反问"请输入物料编码"。日志显示反问的那几次,queryStock 根本没被调用或者参数是 null

有意思的规律:传数字 ID("查 1 号物料")100% 成功,传字符串编码("查 MAT-001")时好时坏

排查路径:

① 直接 curl 调 Ollama /api/chat(绕过 Spring AI)验证原始 tool_calls
   → 无 tool_calls 或 arguments 里 code 为 null
   → 排除 Spring AI 侧问题,锁定模型本身

结论:hermes3:latest (8B) 的 Function Calling 对字符串参数填充不可靠

修复分两步:

第一步:简化参数名(缓解):

// 参数名从 materialCodeOrId 改成 code
@ToolParam(description = "物料编码或物料数字ID,例如 'MAT-001' 或 '1'") String code

有一定缓解但没根治。

第二步:换模型(根治):

ollama pull qwen2.5:7b-instruct-q4_K_M

换完 5 次测试 0 次 param=null,问题彻底消失。

敲黑板:8B 级别模型的 Function Calling 能力差异很大,选型时必须用自己业务的真实参数形态实测——尤其字符串编码类参数。别信模型介绍页的"支持 Function Calling"标签,那个门槛很低。


验证:让 AI 真正跑一遍业务

curl -X POST http://localhost:8089/ai/chat/sync \
  -H "Authorization: Bearer <token>" \
  -H "Content-Type: application/json" \
  -d '{
    "message": "帮我创建一张入库单,物料编码 MAT-001,数量 100,供应商是华为,仓库 WH01",
    "conversationId": "test-001"
  }'

后端日志能看到完整的调用链:

ToolEventAdvisor  → tool_call: createInOrder {materialCode: "MAT-001", quantity: 100, ...}
ErpBusinessFacade → 生成单号 IN-20260824-a1b2c3d4,写入 erp_in_order
ToolEventAdvisor  → tool_result: 入库单创建成功,单号 IN-20260824-a1b2c3d4
AI 最终回复       → "已为您创建入库单 IN-20260824-a1b2c3d4,物料 MAT-001 数量 100..."

数据库里真的多了一条记录。这一刻,"AI 智能体"才名副其实。


本讲踩坑清单

#涉及版本根因解法
1字符串参数 param=nullhermes3:latest 8B · Spring AI 1.0.9Hermes3 对字符串参数填充不可靠换 qwen2.5:7b-instruct-q4_K_M
2启动报 found 2 beansBoot 3.5.16(Spring 6.x)桩实现裸挂 @Service@Primary 或条件注解
3schema.sql 报 dollar quoteBoot 3.2/3.5 ScriptUtils + PG 17不支持 $$触发器逻辑改 MetaObjectHandler
4草稿状态就能完成入库8B 模型不读状态机@Tool description 无前置条件前置条件写进 description

写在最后

这一讲之后,AI 已经能读写真实业务数据了。但现在的交互是"一问一答"——用户盯着空白屏幕等模型把整段话生成完。

下一篇讲流式体验:SSE 逐字输出 + 工具调用过程实时可视化(用户能看到"AI 正在查询库存…"的卡片)。其中 Reactor Flux 冷流的坑(一个 No StreamAdvisors available 异常查了一晚上)值得单独一讲。