"大模型会说话,但不会干活。Skills Agent 的意义,就是让它真正上手。"
Git 仓库: langchain4j-spring-agent/langchain4j-spring-ai/langchain4j-spring-ai-skills
一、你是不是也踩过这个坑?
接了个需求:用 LLM 帮用户自动打开浏览器、搜索内容、导出报告。
然后你发现------
- OpenAI 的 Function Calling?工具描述写了一堆,模型偶尔幻觉出个不存在的工具名;
- LangChain 的 Tool?Python 写了半天,Java 那边没法直接集成;
- MCP?协议够好,但写起来不简单,测起来更麻烦。
有没有一种方案,让工具定义简单到写个 Markdown 文件就行,然后 AI 开箱即用?
有的。这就是本文要聊的 Skills Agent。
二、这玩意儿到底是什么?
langchain4j-spring-ai-skills 是我在 langchain4j-spring-agent 这个开源脚手架里实现的一个可独立部署的 AI Agent 服务。
它的核心思路只有一句话:
把工具定义写进
SKILL.md文件,放到本地目录,AI 自动加载、调用、热更新。
整体效果如下图:


技术栈一览:
| 层级 | 技术选型 |
|---|---|
| 后端框架 | Spring Boot 4.0.2 + Java 17 |
| AI 能力 | LangChain4j 1.12.2 + Spring AI 1.1.3 |
| 工具引擎 | langchain4j-skills + langchain4j-experimental-skills-shell |
| 持久化 | MySQL + MyBatis-Plus + Druid 连接池 |
| 缓存/记忆 | Redis(多轮会话滑动窗口 + 摘要压缩) |
| 接口文档 | SpringDoc OpenAPI (Swagger UI) |
| 前端 | Vue3 + TypeScript + Vite + Pinia + Element Plus |
三、核心架构:五层分层,职责不乱
别被"AI Agent"这个词唬住了,架构其实很清晰:
┌──────────────────────────────────────────────┐
│ Vue3 前端 / REST 接口调用 │
├──────────────────────────────────────────────┤
│ SkillsAgentController / SessionController │ ← REST 层
├──────────────────────────────────────────────┤
│ SkillsChatService │ ← 对话编排层
│ (构建 AiServices + ToolProvider + 记忆) │
├──────────────────────────────────────────────┤
│ SkillsFacade → SkillsFacadeManager │ ← 技能门面层
│ SkillRegistry → SkillSource │
├──────────────────────────────────────────────┤
│ SkillsMemoryWindowService / SummaryService │ ← 记忆压缩层(Redis)
├──────────────────────────────────────────────┤
│ MySQL: session / history / model 三张表 │ ← 持久化层
└──────────────────────────────────────────────┘
最关键的一个类:SkillsFacade
它是整个 Skills 能力对外暴露的统一入口,封装了 SkillsFacadeManager,对上层屏蔽了 registry/source 的所有细节:
java
public class SkillsFacade {
private final SkillsFacadeManager manager;
public ToolProvider toolProvider() { return manager.toolProvider(); }
public String availableSkills() { return manager.availableSkills(); }
public int skillCount() { return manager.skillCount(); }
public List<SkillSummary> skillSummaries() { return manager.skillSummaries(); }
public void refresh() { manager.refresh(); }
public boolean isEnabled() { return manager.isEnabled(); }
}
一个 refresh() 就能热重载 skills 目录,不重启服务,技能即时生效。
四、AI 怎么"拿到"工具?--- ToolProvider 装配细节
很多人以为 LangChain4j 的工具只能用 @Tool 注解标在 Java 方法上,其实还有更灵活的方式:ToolProvider。
langchain4j-skills 提供了把 Markdown 技能文件转成 ToolProvider 的能力,装配逻辑在 ToolProviderAssembler:
java
@Bean
public ToolProviderAssembler toolProviderAssembler() {
return SkillBundle::getToolProvider; // 从 SkillBundle 直接取出 ToolProvider
}
然后在对话时,这样把它塞进 AiServices:
java
SkillsAgentAiService service = AiServices.builder(SkillsAgentAiService.class)
.streamingChatModel(streamingChatModel)
.toolProvider(skillsFacade.toolProvider()) // ← 技能工具注入
.hallucinatedToolNameStrategy(request -> // ← 幻觉工具名兜底
ToolExecutionResultMessage.from(request,
skillsFacade.buildMissingToolMessage(request.name())))
.systemMessage(skillsFacade.buildSystemPrompt()) // ← 系统提示词注入
.build();
注意这里特意处理了 幻觉工具名(hallucinatedToolName)------当模型调用了一个不存在的工具时,不是直接报错,而是返回一条友好提示,让模型有机会自我修正。这个细节很多 demo 都忽略了。
五、多轮记忆怎么不撑爆 Context?--- 滑动窗口 + 摘要压缩
这是 Agent 工程里最容易被忽视的问题:随着对话轮次增加,历史消息会把 context window 撑爆。
我的方案是 Redis 滑动窗口 + 触发式摘要压缩,配置如下:
yaml
nda:
skills:
agent:
memory:
enabled: true
summary-trigger-rounds: 10 # 超过 10 轮用户提问,触发摘要压缩
keep-recent-messages: 8 # 压缩后保留最近 8 条消息
max-messages: 60 # 总上限 60 条
ttl-seconds: 604800 # 会话 TTL 7 天
对话时的记忆拼接流程:
java
// 1. 追加用户消息到 Redis 滑动窗口
memoryWindowService.append(sessionId, "user", question);
// 2. 持久化到 MySQL 历史表
historyManager.saveChatMessage(sessionId, "user", question);
// 3. 拼接完整上下文(含摘要 + 近期消息 + 任务执行约束)
String context = contextBuilder.buildContext(sessionId);
// 4. 对话完成后异步触发压缩(不阻塞当前响应)
summaryService.summarizeIfNeededAsync(sessionId, streamingChatModel);
摘要是用大模型自己压缩的,不是关键词截取,所以对复杂上下文保留效果明显好于简单滑动窗口。
六、模型管理:不再硬编码 API Key
早期很多项目把 API Key 直接写在配置文件里,换个模型要重启服务。
这里改成数据库驱动的模型管理,建表一张:
sql
CREATE TABLE aigc_skills_model (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
model_code VARCHAR(64) NOT NULL, -- 唯一模型标识
model_name VARCHAR(128) NOT NULL, -- 如 qwen-plus
provider VARCHAR(32) NOT NULL DEFAULT 'openai-compatible',
base_url VARCHAR(255) NOT NULL,
api_key VARCHAR(255) NOT NULL,
enabled TINYINT(1) NOT NULL DEFAULT 1,
is_default TINYINT(1) NOT NULL DEFAULT 0,
log_requests TINYINT(1) NOT NULL DEFAULT 1,
log_responses TINYINT(1) NOT NULL DEFAULT 1,
UNIQUE KEY uk_model_code (model_code)
);
前端模型管理页效果:

支持:
- 新增/编辑模型(base_url + api_key 在线填写)
- 切换默认模型 (一键设置
is_default=1) - 启用/禁用(不删记录,仅切换状态)
- 每个会话独立绑定模型 (创建会话时传
modelCode)
七、会话管理:多会话隔离,历史消息分页回放
会话管理页效果:
会话主表 aigc_skills_session,历史消息表 aigc_skills_chat_history,设计要点:
- 每条消息有
message_type:chat(普通对话)或summary(摘要压缩记录) - 摘要记录额外标记
compressed=1,方便回放时区分 - 归档会话(
status=0)后继续 chat 会返回业务异常,提示创建新会话 - 消息支持按
messageType过滤分页,接口:GET /sessions/{sessionId}/messages?messageType=chat
八、对话页面:流式输出 + Markdown 渲染

前端对话页的关键能力:
- SSE 流式输出:字符级实时渲染,不等全文返回
- Markdown 渲染:Assistant 回复支持代码块、列表、表格样式
- 会话列表侧边栏:滚动分页加载,支持切换/归档
- 历史消息回放:向上滚动触发分页加载历史
- 回到底部悬浮按钮 + 新消息计数提示
后端流式接口:
java
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> streamChat(
@RequestParam(required = false) String sessionId,
@RequestParam @NotBlank String question) {
return skillsChatService.streamChat(sessionId, question);
}
九、接口文档:Swagger 开箱即用
引入 springdoc-openapi-starter-webmvc-ui 后,所有接口自动生成文档:
http://localhost:9015/swagger-ui.html
Controller 全部标注了中文说明:
java
@Tag(name = "Skills Agent", description = "skills agent 管理与对话接口")
@Operation(summary = "同步对话", description = "传入 question + 可选 sessionId,返回完整回答字符串")
@PostMapping("/chat")
public ApiResponse<SkillsChatResponse> chat(@Valid @RequestBody SkillsChatRequest request) { ... }
十、五分钟跑起来
10.1 数据库初始化
sql
-- 依次执行三个建表脚本
source langchain4j-spring-ai-skills/doc/init-skills-model.sql
source langchain4j-spring-ai-skills/doc/init-skills-session.sql
source langchain4j-spring-ai-skills/doc/init-skills-history.sql
-- 插入一条默认模型(替换为真实 key)
INSERT INTO aigc_skills_model(model_code, model_name, provider, base_url, api_key, enabled, is_default)
VALUES ('qwen-plus', 'qwen-plus', 'openai-compatible',
'https://dashscope.aliyuncs.com/compatible-mode/v1', 'YOUR_KEY', 1, 1);
10.2 配置 application.yml
yaml
server:
port: 9015
spring:
datasource:
druid:
url: jdbc:mysql://127.0.0.1:3306/langchain_skills?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai
username: root
password: your_password
data:
redis:
host: 127.0.0.1
port: 6379
nda:
skills:
source-type: FILESYSTEM
filesystem-path: ${user.dir}/.claude/skills # 存放 SKILL.md 文件的目录
prompt-inject: true
agent:
memory:
enabled: true
summary-trigger-rounds: 10
keep-recent-messages: 8
10.3 启动后端
powershell
Set-Location "D:\workspace\langchain4j-spring-agent\langchain4j-spring-ai"
mvn -pl langchain4j-spring-ai-skills -am spring-boot:run
10.4 启动前端
powershell
Set-Location "D:\workspace\langchain4j-spring-agent\langchain4j-spring-ai-ui\langchain4j-spring-ai-ui-skills"
pnpm install
pnpm dev
10.5 快速验证
powershell
# 健康检查
Invoke-RestMethod -Method Get -Uri "http://localhost:9015/api/skills-agent/health"
# 查看加载的技能列表
Invoke-RestMethod -Method Get -Uri "http://localhost:9015/api/skills-agent/info"
# 发起一次对话
$body = @{ question = "帮我打开浏览器搜索 langchain4j" } | ConvertTo-Json
Invoke-RestMethod -Method Post -Uri "http://localhost:9015/api/skills-agent/chat" `
-ContentType "application/json" -Body $body
十一、有哪些坑值得注意?
坑一:langchain4j-skills 版本和 langchain4j 主版本必须对齐
不对齐会出现 ClassCastException 或工具描述解析失败,找半天找不到原因。
坑二:流式对话需要前端正确处理 SSE 边界
后端用 TEXT_EVENT_STREAM_VALUE 输出,前端用 EventSource 或手动处理 fetch 流,不要用普通 Axios 请求流式接口。
坑三:Redis 里的记忆 key 要带 sessionId 隔离
我用 skills:session:{sessionId}:msgs 和 skills:session:{sessionId}:summary 分开存,不隔离的话多用户并发会串上下文。
坑四:幻觉工具名不处理会让对话直接报错
LangChain4j 提供了 hallucinatedToolNameStrategy,一定要配,否则模型一旦幻觉出个工具名,整条链路抛异常,用户体验很差。
十二、总结
Skills Agent 这套方案的核心价值在于:
- 工具与代码解耦 --- SKILL.md 文件定义工具,不需要每次改工具就重新部署
- 热加载 ---
/refresh接口一调,新技能立刻生效 - 记忆不爆炸 --- 滑动窗口 + 摘要压缩,长对话也稳
- 模型可切换 --- 数据库驱动,不同会话可以用不同模型
- 全栈可跑 --- 后端 Spring Boot + 前端 Vue3,有图有码,可直接 fork 跑起来
如果你也在做 AI Agent 相关的 Java 工程落地,欢迎 star 一下,有问题评论区见 👇