一、项目全景:我们在做什么

这是一个ERP + AI 智能体系统。简单来说,就是把一个传统的 ERP(物料管理、库存查询、出入库单据、质检管理)接入大模型,让用户用自然语言就能完成业务操作。

比如用户说"查询物料 ZL001 的库存",AI 会自动调用 queryStock 工具查数据库,返回真实库存数据——不是编的。

再比如用户在知识库里上传了公司采购流程文档,之后问"公司的采购流程是什么",AI 会做 RAG 检索,基于文档内容给出准确回答,还能多轮追问"那第二步呢"“输出简单流程”。

系统架构一张图

<span leaf="">┌──────────────┐ &nbsp; &nbsp; ┌──────────────┐ &nbsp; &nbsp; ┌──────────────────────────────────┐</span>

二、技术栈与版本清单

后端依赖(pom.xml 核心部分)

| 依赖

|

版本

|

用途

|

踩坑指数

Spring Boot

|

3.5.16

|

Web + Security + Redis + Validation

|

⭐⭐⭐⭐

| |

Spring AI

|

1.0.9

|

Ollama 对话 / pgvector 向量存储 / Tika 文档解析

|

⭐⭐⭐⭐⭐

| |

MyBatis-Plus

|

3.5.8 (spring-boot3-starter)

|

ORM + 分页 + 审计字段自动填充

|

⭐⭐⭐⭐

| |

PostgreSQL JDBC

|

42.7.13

|

数据库驱动

|

| |

pgvector

|

v0.8.5 (PG17 编译版)

|

向量相似度检索

|

⭐⭐⭐

| |

JWT (jjwt)

|

0.12.6

|

Token 生成/验证/刷新

|

⭐⭐⭐⭐

| |

Knife4j (OpenAPI3)

|

4.5.0

|

接口文档

|

⭐⭐⭐

| |

Hutool

|

5.8.46

|

工具类

|

| |

Lombok

|

1.18.44

|

实体简化

|

⭐⭐

| |

JDK

|

17(编译用 21)

|

运行环境

|

⭐⭐⭐

|

前端依赖(package.json)

| 依赖

|

版本

|

用途

Vue

|

^3.5.13

|

框架

| |

Vue Router

|

^4.5.0

|

路由

| |

Pinia

|

^2.3.0

|

状态管理

| |

Axios

|

^1.7.9

|

HTTP 请求

| |

Element Plus

|

^2.9.1

|

UI 组件库

| |

markdown-it

|

^14.1.0

|

AI 回复 Markdown 渲染

| |

Vite

|

^6.2.4

|

构建工具

|

基础设施

| 组件

|

版本

|

说明

PostgreSQL

|

17.10

|

业务库 + 向量库

| |

pgvector

|

v0.8.5-pg17

|

向量扩展

| |

Redis

|

7.x

|

Token 缓存 + 角色缓存

| |

Ollama

|

最新版

|

本地大模型推理引擎

| |

Nginx

|

1.30.4

|

反向代理 + 静态资源

| |

Nacos

|

-

|

配置中心(预留,未启用)

|


三、环境搭建:从空机器到能跑

3.1 PostgreSQL 17 + pgvector 安装

安装 PostgreSQL 17,这个没什么好说的,下一步下一步。

安装 pgvector 扩展——这里是第一个坑。

<span leaf="">现象:执行&nbsp;<span>CREATE</span>&nbsp;EXTENSION IF&nbsp;<span>NOT</span>&nbsp;<span>EXISTS</span>&nbsp;vector;</span>

根因:手动安装 pgvector 时只复制了 share/extension/ 下的 SQL 和 .control 文件,漏掉了lib/vector.dll共享库。

解决:pgvector 安装需要三部分缺一不可:

| 文件

|

目标路径

|

作用

vector.dll PostgreSQL17/lib/

共享库(缺了就报 58P01)

| | vector.control

 + vector--*.sql

| PostgreSQL17/share/extension/ |

SQL 定义

| |

头文件(可选)

| PostgreSQL17/include/server/extension/vector/ |

编译用

|

复制完后重新执行 CREATE EXTENSION IF NOT EXISTS vector; 即可。

3.2 Ollama 安装与模型下载

安装 Ollama:从官网下载 Windows 安装包,一路下一步,默认端口 11434。

下载模型(这是整个项目最关键的决策之一):

<span leaf=""><span># 主智能体模型:负责 Function Calling、ERP 业务操作</span></span>

| 模型

|

选型过程

|

踩坑经历

qwen2.5:7b-instruct-q4_K_M

|

最初用 hermes3:latest(8B),但发现字符串参数绑定时好时坏——问"查 ZL001 库存"经常 param=null 反问"请输入物料编码"。换成 Qwen2.5 后 5 次测试 0 次 param=null,工具调用参数绑定稳定性显著提升

|

Hermes3 的 Function Calling 对字符串参数填充不可靠

| |

qwen2.5:7b

|

RAG 和文档解析对工具调用没要求,用 instruct 版浪费

|

一开始两个客户端共用一个模型,后来分开

| |

bge-m3

|

一开始没配 embedding 模型,Spring AI 默认调 mxbai-embed-large,本地没装 → 上传文档向量化直接 404。bge-m3 中文效果最好,1024 维,约 1.2GB

|

不配 embedding 模型 = 知识库功能直接废掉

|

<span leaf=""><span>spring</span>:</span>

Java Config 里的关键参数(OllamaChatClientConfig.java):

<span leaf="">OllamaOptions<span>.builder</span>()</span>

关于 keepAlive 的坑:不能用 "-1"(Ollama 的永久驻留值)。Spring AI 的 keepAlive 是字符串类型,Ollama 按 Go duration 解析 "-1" 会报 time: missing unit in duration "-1"(HTTP 400)。必须用带单位的超长时长,如 "876000h"


四、踩坑实录:30+ 个问题的根因与解决方案

下面按问题分类整理,每类给出根因分析和修复方案。这些是真实开发中遇到的问题,不是教科书示例。

4.1 Spring Boot 依赖冲突(5 个问题)

<span leaf="">现象:启动报 BeanDefinitionStoreException... factoryBeanObjectType: java.lang.String</span>

经验法则:springdoc 版本对应关系——2.x → Spring Boot 3.x3.0.x → Spring Boot 4.x。别看到新版本就升。

<span leaf="">现象:<span>factoryBeanObjectType</span>: java.<span>lang</span>.<span>String</span></span>
<span leaf="">现象:PSQLException: Unterminated dollar quote started at position&nbsp;<span>65</span></span>
<span leaf="">现象:<span>No</span>&nbsp;qualifying bean... expected single matching bean but found&nbsp;<span>2</span></span>

4.2 Spring Security 6 认证(4 个致命问题)

这一组问题是整个项目最花时间的部分,涉及全站 401、SSE 异步丢认证等诡异 bug。

<span leaf="">现象:登录成功,但后续所有 /api/** 和 /ai/** 请求都返回 401</span>

过滤器顺序正确链路:

<span leaf=""><span>SecurityContextHolderFilter</span>&nbsp; ← 负责加载/保存 SecurityContext</span>

这是最隐蔽的坑:addFilterBefore 和 addFilterAfter 一词之差,全站 401。

<span leaf="">现象:SSE 首个数据块正常,后续数据块在异步调度时抛 AccessDeniedException</span>

原理:SecurityContextHolder 是 ThreadLocal,SSE 异步线程默认无法继承。必须用 RequestAttributeSecurityContextRepository 把 Context 存到 request 属性,且必须新建 Context 对象让 Filter 能检测到变更。

<span leaf="">现象:Token&nbsp;<span>30</span>&nbsp;分钟过期,开发环境频繁被踢出</span>
<span leaf="">现象:Token 过期后直接跳转登录页,无法自动续期</span>

4.3 Nginx 反向代理(4 个问题)

<span leaf="">现象:通过 http://localhost/ai/chat(80 端口)访问,登录后请求 401</span>
<span leaf="">现象:AI 对话流式输出卡住,token 攒批下发,体感”假死”</span>

排查技巧:Vite dev server (5173) 的 /ai 走 http-proxy 默认流式透传不受影响,故"慢"多发于经 Nginx(80)/dist 访问的链路。通过对比 dev 和 Nginx 链路可快速定位。

<span leaf="">现象:Nginx 透传已生效,但请求头里的 token 是旧 token</span>
<span leaf="">现象:登录后刷新 /ai/knowledge 页面出现 401 JSON</span>

4.4 SSE 流式对话(5 个问题)

<span leaf="">现象:前端收到 application/json,无法流式解析</span>
<span leaf="">现象:前端收到 data:data:xxx</span>

关键认知:Spring Boot 3.2 的 WebFlux SSE 模式下,框架自动加 data: 前缀和 \n\n 结尾,手动包装会导致双重前缀。

<span leaf="">现象:AI 对话流式接口抛 IllegalStateException: No StreamAdvisors available to execute</span>
<span leaf="">现象:No primary or single unique&nbsp;<span>constructor</span>&nbsp;found&nbsp;<span>for</span>&nbsp;<span>interface</span>&nbsp;<span>ServerHttpResponse</span></span>
<span leaf="">现象:接口有返回但页面不展示</span>

4.5 AI 模型对接与工具调用(5 个问题)

<span leaf="">现象:AI 不调用工具,直接编造回答</span>
<span leaf="">现象:问”查询物料&nbsp;<span>ZL001</span>&nbsp;的库存”,<span>AI</span>&nbsp;反问”请输入物料编码”</span>
<span leaf="">现象:首次对话首 token 等待约 57 秒,用户以为系统卡死</span>

CPU 推理特殊说明:本机为 CPU 推理(size_vram=0),冷加载 8B 模型约 57s 是物理瓶颈,无 GPU 加速无法绕过。keepAlive 只能消除"第二次及以后"的冷加载。

<span leaf="">现象:queryStock 原参数为&nbsp;<span>Long</span>&nbsp;materialId,用户输入物料编码 MAT-<span>001</span>&nbsp;无法匹配</span>
<span leaf="">现象:AI 回复内容随机出现加粗、标题样式</span>

4.6 知识库 RAG(6 个问题)

<span leaf="">现象:上传文档时向量化&nbsp;<span>404</span></span>
<span leaf="">现象:vector_store 表从未创建,日志显示 ”<span>Skipping</span>&nbsp;the schema initialization”</span>
<span leaf="">现象:大文档上传&nbsp;<span>413</span>&nbsp;Request Entity Too&nbsp;<span>Large</span></span>
<span leaf="">根因(三层):</span>
<span leaf="">现象:前一轮已问到采购流程并返回结果,追问”输出简单流程”时提示”未检索到相关内容”</span>
<span leaf="">现象:改了 chunkSize/chunkOverlap 后检索结果没变化</span>

4.7 并发安全与兜底机制(4 个问题)

<span leaf="">现象:同一会话快速连续发送多条消息,AI 回复内容串流</span>
<span leaf="">现象:Ollama 服务挂了,AI 对话请求一直卡到 300s 超时才返回错误</span>
<span leaf="">现象:理论上无限个 SSE 连接可耗尽线程池</span>

这个坑极难发现:Spring MVC SSE 的异步订阅机制让 Semaphore 的执行时机难以预测。Flux.defer 的 lambda 是在订阅时执行,而 Spring MVC 返回 Flux 后 Tomcat 线程就释放了,订阅时机不确定。必须把并发控制在方法体里同步执行。

<span leaf="">现象:高并发下工具调用事件丢失或串流</span>

五、核心架构设计要点

5.1 双 ChatClient 分工

| Bean 名称

|

模型

|

职责

|

工具

hermesChatClient

qwen2.5:7b-instruct-q4_K_M

|

ERP 智能体对话 + Function Calling

|

8 个 Tool 类、19 个 @Tool 方法

| | qwenChatClient |

qwen2.5:7b

|

RAG 知识库问答 + 文档解析 + 查询改写

|

无工具

|

5.2 三层配置热加载

<span leaf="">优先级:Bean 兜底默认值 &lt; application.yml (erp.ai.*) &lt; 页面配置 (ai_config 表)</span>
  • AiConfigServiceImpl

    :@PostConstruct 从 DB 加载到 volatile 内存缓存

  • saveConfig()

     写 DB + 刷新缓存——热加载,无需重启

  • 敏感凭证(数据库/Redis 密码、Ollama 地址)只在 yml,永不上配置页面

  • 配置变更审计:ai_config_log 表记录谁/何时/改了什么/旧值→新值/IP

5.3 混合检索 RAG

<span leaf="">用户问题</span>

5.4 19 个业务工具清单

| 工具类

|

@Tool 方法

|

功能

MaterialQueryTool

|

queryMaterial / getMaterialByCode / checkLowStock

|

物料查询/低库存预警

| |

MaterialManageTool

|

createMaterial / updateMaterial / deleteMaterial / listEnabledMaterials

|

物料 CRUD

| |

StockQueryTool

|

queryStock

|

库存查询(支持编码和数字 ID)

| |

InOrderCreateTool

|

createInOrder

|

创建入库单

| |

InOrderManageTool

|

getInOrderByNo / listInOrdersByStatus / confirmInOrder / cancelInOrder / completeInOrder

|

入库单管理

| |

OutOrderCreateTool

|

createOutOrder

|

创建出库单

| |

OutOrderManageTool

|

getOutOrderByNo / listOutOrdersByStatus / confirmOutOrder / cancelOutOrder / completeOutOrder

|

出库单管理

| |

KnowledgeSearchTool

|

searchKnowledge

|

知识库检索

|


六、排查方法论

6.1 401 问题分层排查法

<span leaf="">① curl 直连后端端口(绕过 Nginx<span>/</span>前端)</span>

6.2 SSE 问题排查清单

| 现象

|

检查项

Content-Type 不是 text/event-stream

|

Controller 是否加了 produces

| |

data:data: 双重前缀

|

是否手动拼了 data: 前缀

| |

首个 chunk 后报 AccessDeniedException

|

SecurityContext 是否新建对象 + RequestAttributeSecurityContextRepository

| |

No StreamAdvisors available

|

Flux 是否被多次订阅(用 share/publish.autoConnect)

| |

流式假死/卡顿

|

Nginx 是否 proxy_buffering off + X-Accel-Buffering: no

| |

首字 57 秒

|

Ollama keepAlive 是否过期 → 模型冷加载

|

6.3 模型工具调用排查

<span leaf="">① 直接调 Ollama API(绕过 Spring AI)验证模型是否返回 tool_calls</span>


七、写在最后

这些坑为什么会出现?

  1. Spring AI 1.0 还是里程碑版本:文档不完善,很多行为靠踩坑才知道(如 Flux 双订阅、keepAlive 字符串限制)

  2. Spring Security 6 的变化:过滤器顺序、SecurityContext 新建 vs 修改,这些在 Spring Security 5 时代不是问题

  3. 8B 模型的能力边界:Function Calling 参数绑定稳定性、中文理解能力,跟在线大模型有差距

  4. SSE + 异步 + 认证的组合:三个复杂机制叠加,任何一个出问题都会导致整个链路崩溃

给后来者的建议

  1. 先把 Ollama 模型选对:qwen2.5 比 hermes3 在中文工具调用上稳定得多

  2. Spring Security 6 过滤器顺序:JWT 过滤器必须在 SecurityContextHolderFilter之后

  3. SSE 流别订阅两次:用 share() 或 publish().autoConnect(2)

  4. Nginx 一定要 proxy_buffering off:否则 SSE 体验极差

  5. embedding 模型一定要配:不配 = 知识库功能直接废

  6. 并发控制放控制器方法体里:别放 Flux.defer 里,订阅时机不确定

  7. keepAlive 用带单位的超长时长:别用 "-1",会 400

技术选型反思

| 选对了的

|

选错了/后悔的

Spring AI + Ollama 本地部署(数据不出企业)

|

hermes3 作为主智能体(应一开始就用 qwen2.5)

| |

pgvector(PostgreSQL 原生扩展,无需额外向量库)

|

springdoc 3.0.3(应直接用 2.5.0)

| |

MyBatis-Plus(审计字段自动填充替代触发器)

|

前端 SPA 路由 /ai/ 与后端 API /ai/ 前缀重叠

| |

SSE 流式对话(用户体验好)

|

最初没配 embedding 模型(导致知识库调试很久)

| |

双 ChatClient 分工(智能体 vs RAG)

|

-

|


本文涵盖了从环境搭建到上线全过程的 30+ 个实战问题,每个都附根因分析和解决方案。如果你也在做 Spring AI + Ollama 的企业级落地,这篇能帮你少走至少 2 周弯路。

觉得有用的话,点个赞支持一下,后面还会分享更多实战经验。你们在做AI项目时踩过什么离谱的坑?评论区聊聊 👇