【WMS 仓储系统集成 AI Agent 实战】第 4 讲:15 张表 + 19 个 AI 工具——让大模型真正"摸到"数据库
前三讲把地基打完了。这一讲是整个项目的业务核心:ERP 仓储域怎么建模、AI 怎么通过工具调用读写真实数据、库存防超卖怎么保证。还有一个 8B 模型特有的坑:数字参数 100% 成功,字符串参数时灵时不灵。
本讲复现环境与版本
| 项 | 版本/说明 |
|---|---|
| Spring AI | 1.0.9(@Tool / @ToolParam / DefaultToolCallingManager) |
| 模型 | hermes3:latest 8B(踩坑期)→ qwen2.5:7b-instruct-q4_K_M(修复后) |
| MyBatis-Plus | 3.5.8(spring-boot3-starter) |
| PostgreSQL | 17.10 + pgvector v0.8.5-pg17 |
| Spring Boot | 3.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 容器)
- 复现环境:
LocalErpFacadeImpl与FeignErpFacadeImpl同时贴@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 方法 | 功能 |
|---|---|---|
| MaterialQueryTool | queryMaterial / getMaterialByCode | 按关键词/编码查物料 |
| MaterialManageTool | createMaterial / updateMaterial / deleteMaterial / listEnabledMaterials | 物料 CRUD |
| StockQueryTool | queryStock / checkLowStock | 库存查询 + 低库存预警 |
| InOrderCreateTool | createInOrder | 创建入库单(校验物料存在) |
| InOrderManageTool | getInOrderByNo / listInOrdersByStatus / confirmInOrder / cancelInOrder / completeInOrder | 入库单全生命周期 |
| OutOrderCreateTool | createOutOrder | 创建出库单(校验库存充足) |
| OutOrderManageTool | getOutOrderByNo / listOutOrdersByStatus / confirmOutOrder / cancelOutOrder / completeOutOrder | 出库单全生命周期 |
| KnowledgeSearchTool | searchKnowledge | 知识库检索(无 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=null | hermes3:latest 8B · Spring AI 1.0.9 | Hermes3 对字符串参数填充不可靠 | 换 qwen2.5:7b-instruct-q4_K_M |
| 2 | 启动报 found 2 beans | Boot 3.5.16(Spring 6.x) | 桩实现裸挂 @Service | @Primary 或条件注解 |
| 3 | schema.sql 报 dollar quote | Boot 3.2/3.5 ScriptUtils + PG 17 | 不支持 $$ | 触发器逻辑改 MetaObjectHandler |
| 4 | 草稿状态就能完成入库 | 8B 模型不读状态机 | @Tool description 无前置条件 | 前置条件写进 description |
写在最后
这一讲之后,AI 已经能读写真实业务数据了。但现在的交互是"一问一答"——用户盯着空白屏幕等模型把整段话生成完。
下一篇讲流式体验:SSE 逐字输出 + 工具调用过程实时可视化(用户能看到"AI 正在查询库存…"的卡片)。其中 Reactor Flux 冷流的坑(一个 No StreamAdvisors available 异常查了一晚上)值得单独一讲。