前三讲把地基打完了。这一讲是整个项目的业务核心:ERP 仓储域怎么建模、AI 怎么通过工具调用读写真实数据、库存防超卖怎么保证。还有一个 8B 模型特有的坑:数字参数 100% 成功,字符串参数时灵时不灵。
本讲复现环境与版本
项 | 版本/说明 |
Spring AI | 1.0.9( |
模型 |
|
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 仓储域的建模,核心是理解单据状态机和库存三态。
单据状态机(入库单/出库单通用)
状态流转的库存联动是关键:
动作 | 库存影响 |
入库单完成 |
|
出库单确认 |
|
出库单完成 |
|
出库单作废 |
|
库存三态
表清单
业务表 10 张 + 系统表 5 张:
一个 SQL 设计决策
我在 schema.sql 里放弃了触发器和$$函数——Spring Boot 的 ScriptUtils 不支持 PostgreSQL 的$$美元引用语法(报Unterminated dollar quote)。
审计字段填充(createTime/updateTime/createBy/updateBy)全部改用 MyBatis-Plus 的MetaObjectHandler在应用层实现:
逻辑更清晰,还避免了触发器这种"看不见的逻辑"带来的排查困难。
关键设计:ErpBusinessFacade 解耦层
AI 工具类不直接注入任何 ERP Service 或 Mapper,统一走 Facade 接口:
这个设计买到了什么?AI 层与 ERP 层的单向依赖。将来 ERP 拆成独立微服务,AI 层代码一行不改,换个 Facade 实现就行。
这里有个插曲值得说。ErpBusinessFacade接口有两个@Service实现,启动直接报:
📋问题档案
- 版本:Spring Boot 3.5.16(Spring Framework 6.x 容器)
- 复现环境:
LocalErpFacadeImpl与FeignErpFacadeImpl同时贴@Service,任一 AI 工具类注入ErpBusinessFacade,启动必现
- 真实报错(启动日志原文):
- 项目实际现象:加了一个 Feign 远程调用的预留桩实现(方法体全是 TODO)后,项目直接起不来。桩类也是类,贴了 @Service 就会被扫描注册
因为FeignErpFacadeImpl是个预留的桩实现(方法体全是 TODO),也挂了 @Service。解法是在LocalErpFacadeImpl上加@Primary。
划重点:预留实现的桩类不应裸挂@Service与生产实现并存。要么@Primary明确主从,要么桩类加@Profile/@ConditionalOnMissingBean控制加载条件。
19 个 @Tool 方法
Spring AI 的工具注册非常简洁,一个注解搞定:
完整清单(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 是写给模型看的,要具体到"什么时候该用我"
经验 2:参数名要短、要直白
queryStock(String materialCodeOrId)→queryStock(String code)。模型对参数名的"理解成本"直接影响绑定成功率,这在后面踩坑部分细说。
经验 3:写操作工具要把"前置条件"写进 description
不然模型会在草稿状态就尝试完成入库,然后拿到一堆业务异常。
库存防超卖
出库确认时锁定库存,核心校验逻辑:
说实话,这个实现在单机 + 事务场景够用,但严格来说"读-判断-写"不是原子的,高并发下仍有超卖窗口。要彻底解决得用数据库乐观锁(version 字段)或UPDATE ... WHERE quantity - locked >= ?条件更新。当前系统的并发量(企业内部几十个用户)用事务 + 校验已经足够,但这个边界要心里有数。
入库完成时"记录不存在则自动创建":
注意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 工具调用参数原文):
- 项目实际现象:反问「请输入物料编码」的那几次,
queryStock根本没拿到参数;而「查 1 号物料」这类数字 ID 指令从未失败
现象:用户说"查询物料 ZL001 的库存",AI 有时正常调用工具,有时反问"请输入物料编码"。日志显示反问的那几次,queryStock根本没被调用或者参数是null。
有意思的规律:传数字 ID("查 1 号物料")100% 成功,传字符串编码("查 MAT-001")时好时坏。
排查路径:
结论:hermes3:latest (8B) 的 Function Calling 对字符串参数填充不可靠。
修复分两步:
第一步:简化参数名(缓解):
有一定缓解但没根治。
第二步:换模型(根治):
换完 5 次测试 0 次 param=null,问题彻底消失。
敲黑板:8B 级别模型的 Function Calling 能力差异很大,选型时必须用自己业务的真实参数形态实测——尤其字符串编码类参数。别信模型介绍页的"支持 Function Calling"标签,那个门槛很低。
验证:让 AI 真正跑一遍业务
后端日志能看到完整的调用链:
数据库里真的多了一条记录。这一刻,"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异常查了一晚上)值得单独一讲。