从零用 Spring AI 搭建 RAG + Tool Calling 岗位分析系统:全流程实战与踩坑记录
本文项目基于 Spring AI Alibaba 构建
企业引入 AI 就一定要人员优化吗?格力的答案是"零人员流失"。本文记录我用 Spring AI + 通义千问,从零搭建一个"岗位自动化评估 + 转岗路径建议"系统的完整过程——包括流式输出、RAG 知识库、Tool Calling、Advisor 编排,以及那些文档里不会告诉你的坑。
为什么做这个项目
企业引入 AI 的默认叙事是"降本增效 = 减少人员"。但格力在引入自动化时给出了另一个答案:找出老职工原有技能和新岗位之间的"最大公约数",让员工觉得"我不是从头再来,是在老经验上添新本事"。结果是转岗后技能等级提升,收入平均增长 8%,数年来转岗成功率 100%,没有一名老职工因为技术升级而掉队。
我想把这个理念产品化——用 AI 评估岗位受自动化影响的程度,同时给出可操作的转岗路径建议。不是告诉员工"你的岗位面临调整",而是告诉他"你的经验里有哪些能力在新岗位上更有价值"。
于是有了 ai-augmented-employer-toolkit,一个基于 Spring AI + 通义千问的开源项目。
技术选型
| 组件 | 选择 | 理由 |
|---|---|---|
| 框架 | Spring Boot 4.1.1 | 主流 Java 框架 |
| AI 集成 | Spring AI 2.0.0-M1 | 统一的 AI 抽象层,未来可切换模型供应商 |
| 模型适配 | Spring AI Alibaba 2.0.0-M1.1 | 通义千问 DashScope 原生支持 |
| LLM | 通义千问 qwen-plus | 性价比高,中文能力强 |
| Embedding | text-embedding-v3 | DashScope 最新嵌入模型 |
| 向量存储 | SimpleVectorStore(内存) | 零配置启动,demo 够用 |
| 文档解析 | Apache Tika | 统一处理 Markdown + PDF |
| 前端 | 原生 HTML + JS | 单文件,无构建工具依赖 |
选 Spring AI 而不是直接调 DashScope SDK 的原因是:Spring AI 提供了 ChatClient、Advisor、Tool Calling、VectorStore 这些高级抽象,后续如果需要从通义千问切换到 OpenAI 或其他模型,改一行配置就行,业务代码不用动。
v0.1:让基础链路跑通
核心能力
v0.1 的目标很简单:输入一段岗位描述,输出结构化的分析结果。
用户输入岗位描述 → ChatClient → LLM → BeanOutputConverter → 结构化 JSON
分析结果包含三个维度:
- automationRatio:0~1 之间的自动化评估率
- summary:分析摘要,哪些任务易被自动化、哪些需要人类判断
- transitionPath:转岗建议——可迁移技能、目标岗位、能力差距、鼓励语
流式输出(SSE)
用户体验上,等 AI 生成完整回复再一次性返回太慢了。v0.1 实现了 SSE 流式输出,前端用 fetch + ReadableStream 消费,实现打字机效果:
// 后端:返回 Flux<String>
@PostMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<String>> analyzeStream(@RequestParam String sessionId,
@RequestBody AnalyzeRequest request) {
return streamingChatClient.prompt()
.user(request.getJobDescription())
.stream()
.content()
.map(content -> ServerSentEvent.<String>builder()
.data(content)
.build());
}
// 前端:ReadableStream 消费
var reader = res.body.getReader();
var decoder = new TextDecoder();
while (true) {
var result = await reader.read();
if (result.done) break;
buffer += decoder.decode(result.value, {
stream: true });
// 解析 SSE data: 行,追加到页面
}
Structured Output
同步接口用 BeanOutputConverter 让 LLM 严格返回 JSON Schema 格式的数据。Spring AI 会自动在系统提示词中注入格式指令:
BeanOutputConverter<AnalyzeResponse> converter =
new BeanOutputConverter<>(AnalyzeResponse.class);
String raw = analyzeChatClient.prompt()
.user(jobDescription + converter.getFormat())
.call()
.content();
AnalyzeResponse response = converter.convert(raw);
DTO 上的 @JsonPropertyDescription 注解会被自动提取为 JSON Schema 描述,告诉 LLM 每个字段应该怎么填:
public class AnalyzeResponse {
@JsonPropertyDescription("0.0到1.0之间的数值,表示岗位受自动化影响的程度")
private Double automationRatio;
@JsonPropertyDescription("一段简洁的分析摘要")
private String summary;
private TransitionPath transitionPath;
}
但 BeanOutputConverter 有一个坑——它的格式指令会污染流式输出,后面详细讲。
会话记忆
分析完一个岗位后,用户通常会追问:"这个岗位具体需要学什么?""转岗后薪资预期?"。为了让 AI 能基于上下文回答,实现了一个基于 ConcurrentHashMap 的内存会话记忆,滑动窗口最多保留 16 条消息(8 轮对话):
public class ConversationMemoryStore {
private final ConcurrentHashMap<String, Deque<Message>> store = new ConcurrentHashMap<>();
private static final int MAX_MESSAGES = 16;
public void add(String sessionId, Message message) {
store.compute(sessionId, (k, messages) -> {
if (messages == null) messages = new ArrayDeque<>();
messages.addLast(message);
while (messages.size() > MAX_MESSAGES) messages.pollFirst();
return messages;
});
}
}
v0.2:RAG 知识库 + Tool Calling + Advisor 编排
v0.1 跑通了基础链路,但分析全靠 LLM 的通用知识。系统提示词里只有一个格力案例,AI 回答追问时只能凭经验"编"数据。v0.2 的目标是让 AI 有据可依。
整体数据流
用户输入岗位描述 / 追问
│
▼
ChatClient.prompt()
├── .defaultAdvisors(QuestionAnswerAdvisor) ← RAG:检索知识库相关文档
├── .defaultTools(SalaryTool) ← Tool:按需调用薪资查询
├── .messages(history) ← Memory:多轮对话上下文
└── .user(input).stream().content()
│
▼
LLM:综合 RAG 上下文 + Tool 结果 + 对话历史 → 生成回答
│
▼
SSE 流式输出 → 前端 Markdown 渲染 + 来源引用标签
三条链路(RAG、Tool、Memory)在 ChatClient 中通过 Advisor 和 Tool 注册,互相解耦,各自可独立开关。
RAG 知识库
知识文档
在 src/main/resources/knowledge/ 下放了 4 份 Markdown 文档:
- career-transition-cases.md:6 个真实转岗成功案例,每个有原岗位、受影响风险、可迁移技能、目标岗位、薪资变化
- industry-automation-report.md:制造、物流、金融、零售、客服 5 个行业的自动化现状和 2030 年预测
- salary-benchmarks.md:按岗位 × 城市等级组织的薪资数据表
- skill-mapping-guide.md:常见"易受影响技能"到"高价值技能"的映射矩阵
这些文档就是 AI 的"知识库",比系统提示词里的单个案例丰富得多。
自动加载
KnowledgeBaseService 实现 ApplicationRunner,应用启动时自动扫描、解析、分块、嵌入:
@Component
public class KnowledgeBaseService implements ApplicationRunner {
private final VectorStore vectorStore;
private final TokenTextSplitter splitter = TokenTextSplitter.builder()
.withChunkSize(400)
.withMinChunkSizeChars(80)
.withMinChunkLengthToEmbed(20)
.build();
@Override
public void run(ApplicationArguments args) throws Exception {
Resource[] resources = new PathMatchingResourcePatternResolver()
.getResources("classpath:knowledge/*.md");
for (Resource resource : resources) {
List<Document> documents = new TikaDocumentReader(resource).get();
List<Document> chunks = splitter.apply(documents);
// 添加 source 元数据,前端用来显示引用来源
chunks.forEach(chunk -> chunk.getMetadata().put("source", resource.getFilename()));
vectorStore.add(chunks);
}
}
}
启动日志会打印:知识库加载完成,Markdown 文档 42 块,PDF 文档 0 块。
向量存储
默认用 SimpleVectorStore(内存),零配置即可运行。通过 @ConditionalOnProperty 实现自适应:
@Bean
@ConditionalOnProperty(
name = "spring.ai.vectorstore.pgvector.url",
matchIfMissing = true,
havingValue = "__NEVER_MATCH__"
)
public VectorStore simpleVectorStore(EmbeddingModel embeddingModel) {
return SimpleVectorStore.builder(embeddingModel).build();
}
没有配 PostgreSQL → 用内存;配了 → pgvector 自动配置接管。
Tool Calling:薪资查询
当用户追问"数据分析师在一线城市的薪资预期?"时,AI 不应该凭经验编一个数字。SalaryTool 用 @Tool 注解实现了一个模拟薪资查询工具:
@Component
public class SalaryTool {
@Tool(description = "查询指定职业在不同城市等级的薪资范围。当用户询问转岗后薪资预期、待遇对比时调用此工具。")
public String getSalaryRange(
@ToolParam(description = "职业名称") String roleName,
@ToolParam(description = "城市等级:一线/二线/三线", required = false) String cityTier) {
// 内置 8 个转岗方向 × 3 线城市的薪资数据
// 返回格式化的薪资范围文本
}
}
覆盖的 8 个转岗方向:数据分析师、Python 开发工程师、项目经理、客户成功经理、自动化运维工程师、商业分析师、产品经理、人力资源数字化专员。
AI 会根据用户的提问自动判断是否需要调用这个工具,调用时会自动传入参数,拿到结果后整合进回复。整个过程对开发者透明,只需要注册工具就行。
Advisor 编排:QuestionAnswerAdvisor
QuestionAnswerAdvisor 是 Spring AI 提供的 RAG 编排器。把它注册到 ChatClient 后,每次用户发消息,它会自动:
- 从 VectorStore 中检索 top-5 相关文档(similarityThreshold=0.6)
- 把检索到的文档内容注入到对话上下文中
- LLM 基于这些上下文生成回答
QuestionAnswerAdvisor qaAdvisor = QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(SearchRequest.builder()
.similarityThreshold(0.6)
.topK(5)
.build())
.build();
ChatClient.builder(chatModel)
.defaultSystem(systemPrompt)
.defaultAdvisors(qaAdvisor) // RAG
.defaultTools(salaryTool) // Tool Calling
.build();
系统提示词中追加了引用指引,要求 AI 在引用知识库数据时标注来源文件名,前端会把 (filename.md) 格式的引用渲染成紫色标签。
踩坑记录
这部分是文档里找不到的实战经验。
坑 1:DashScope 自动配置项需要手动排除
现象:项目引入 spring-ai-alibaba-starter-dashscope 后启动报错,提示找不到 DashScopeMultimodalEmbeddingAutoConfiguration。
根因:这个 starter 的自动配置注册表中包含了 DashScopeMultimodalEmbeddingAutoConfiguration 和 DashScopeAudioAutoConfiguration 两个配置项,但在当前版本中这两个功能的实现类尚未包含在发布包中。
解法:在 application.yml 中手动排除这两个自动配置项即可正常启动:
spring:
autoconfigure:
exclude:
- com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeMultimodalEmbeddingAutoConfiguration
- com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeAudioAutoConfiguration
这是 Milestone 预览版本中的已知情况,社区已有相关讨论(#4883、#4869),预计后续正式版本会修复。如果你的项目只需要文本对话和嵌入功能,按上述配置排除即可。
坑 2:pgvector 传递依赖拖垮整个启动
现象:加了 spring-ai-starter-vector-store-pgvector(标记 <optional>true</optional>)后,项目启动报 Failed to determine a suitable driver class。
根因:依赖链是 pgvector starter → spring-boot-starter-jdbc → DataSourceAutoConfiguration。Maven 的 <optional> 只阻止向下游项目传递,在当前项目中仍会被引入。Spring Boot 检测到 JDBC 就自动创建 DataSource,但你没配数据库连接,于是报错。
尝试过的方案:排除 DataSourceAutoConfiguration + PgVectorStoreAutoConfiguration → 不够,还有其他自动配置类连锁触发。
最终方案:移除 pgvector 依赖,用 SimpleVectorStore 做 demo。后续需要持久化时再加回来。
这个坑在两个仓库(spring-ai 和 spring-ai-alibaba)都没有人报过,已经准备了 issue 提交给上游。
坑 3:BeanOutputConverter 与流式输出不兼容
现象:同步接口用 BeanOutputConverter 很好用,但直接用到流式接口时,输出的文本里会夹杂着 JSON Schema 格式指令。
根因:BeanOutputConverter 会在系统提示词中注入一大段 JSON Schema 说明。流式输出时,LLM 可能把这些格式指令当作内容的一部分输出,或者格式指令的 echo 出现在流式文本开头。
解法:拆成两个 ChatClient Bean——同步分析用带 Converter 的,流式追问用不带 Converter 的,靠系统提示词本身引导 JSON 输出,前端侧用 tryParseJson() 兜底解析。
// 同步分析:严格的 BeanOutputConverter
String raw = analyzeChatClient.prompt()
.user(jobDescription + converter.getFormat())
.call().content();
// 流式追问:干净的 ChatClient,无格式指令
Flux<String> stream = streamingChatClient.prompt()
.user(question)
.stream().content();
坑 4:知识库目录不存在导致启动崩溃
现象:KnowledgeBaseService 扫描 classpath:knowledge/pdf/*.pdf 时,如果 pdf/ 子目录不存在,Spring 的 PathMatchingResourcePatternResolver 直接抛 FileNotFoundException,而不是返回空数组。
解法:try-catch 包裹,目录不存在时打 warn 日志继续启动:
try {
pdfCount = ingestResources("classpath:knowledge/pdf/*.pdf");
} catch (Exception e) {
log.warn("跳过 PDF 目录加载(目录不存在或无法访问): {}", e.getMessage());
}
坑 5:前端 Markdown 渲染缺失
现象:AI 追问回复里的 **加粗**、## 标题、- 列表 全部原样显示,看起来像乱码。
根因:之前的渲染链路是 escapeHtml(fullText),把所有内容当纯文本处理。
解法:引入 marked.js,先注入引用标签再渲染 Markdown:
function renderMarkdown(text) {
// 先替换引用标签
var withCitations = text.replace(
/\(([a-zA-Z0-9\u4e00-\u9fff_-]+\.(md|pdf|txt|docx))\)/g,
'<span class="source-citation">$1</span>'
);
// 再渲染 Markdown
return marked.parse(withCitations);
}
前端 UI 设计
v0.2 对前端做了全面重设计:
格力案例横幅:从占满首屏的大块横幅改为可折叠的紧凑条幅,点击展开详情,把首屏空间让给核心交互。
仪表盘 + 摘要横向并排:环形图和风险标签在左,分析摘要在右,一个卡片看到核心结论。600px 以下自动纵向堆叠。
对话气泡式追问:不再是只显示最新一轮回复,每次追问以用户/AI 气泡形式追加,保留完整对话上下文。支持 Markdown 渲染和来源引用标签。
项目结构
src/main/java/io/github/aiaugmentedemployertoolkit/
├── config/
│ ├── ChatClientConfig.java # ChatClient 配置(注入 Advisor + Tool)
│ └── VectorStoreConfig.java # 自适应向量存储
├── controller/
│ └── AnalyzeController.java # REST 接口(同步 + SSE 流式)
├── dto/
│ ├── AnalyzeRequest.java # 请求体
│ ├── AnalyzeResponse.java # 响应体(@JsonPropertyDescription)
│ └── TransitionPath.java # 转岗路径
├── service/
│ ├── AnalyzeService.java # 核心业务逻辑
│ ├── ConversationMemoryStore.java # 内存会话记忆
│ └── KnowledgeBaseService.java # 知识库加载
└── tool/
└── SalaryTool.java # 薪资查询工具(@Tool)
src/main/resources/
├── application.yml
├── prompts/analyze-prompt.txt # 系统提示词
├── knowledge/ # 知识库文档
│ ├── career-transition-cases.md
│ ├── industry-automation-report.md
│ ├── salary-benchmarks.md
│ └── skill-mapping-guide.md
└── static/index.html # 前端页面
从踩坑到上游贡献
在排查问题的过程中,我发现有些坑不是我的代码问题,而是框架本身的 bug。于是我做了整理,向上游提交了反馈:
DashScope 自动配置冲突:在 alibaba/spring-ai-alibaba#4883 下评论,补充了
DashScopeAudioAutoConfiguration也需要排除的信息和完整的 workaround。pgvector 传递依赖问题:在 spring-projects/spring-ai 提交了新 issue,包含完整的依赖链分析、复现步骤和三个修复建议。这个问题此前在两个仓库都没有人报告过。
BeanOutputConverter 与流式不兼容:在 spring-projects/spring-ai#2214 下评论,把问题从"JSON 截断"提升到"架构不兼容"的层面,附带了拆双 ChatClient 的完整 workaround。
使用一个框架时认真记录遇到的问题,整理成清晰的 issue 反馈给维护者,本身就是对开源社区有价值的贡献。
快速体验
git clone https://github.com/zaojiaoci/ai-augmented-employer-toolkit.git
cd ai-augmented-employer-toolkit
# 配置通义千问的访问凭证(具体环境变量名见项目 README)
./mvnw spring-boot:run
打开 http://localhost:8089,输入一段岗位描述即可体验。
写在最后
这个项目从 v0.1 到 v0.2 的演进,其实体现了使用 Spring AI 做实际项目的典型路径:先跑通基础链路(ChatClient + 流式输出 + 结构化输出),再加上 RAG 和 Tool Calling 让 AI 有据可依,最后通过 Advisor 编排把多条能力链路组合起来。
Spring AI 目前还在 Milestone 阶段(2.0.0-M1),API 还在快速迭代,文档也不够完善。但这恰恰是早期参与者的机会——你踩的每一个坑,都可能成为社区需要的内容。
项目 GitHub 地址:ai-augmented-employer-toolkit
项目 Gitee 地址:ai-augmented-employer-toolkit
如果觉得有帮助,欢迎 Star 和 Fork。