惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

T
Threat Research - Cisco Blogs
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
月光博客
月光博客
V
Vulnerabilities – Threatpost
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
S
Secure Thoughts
Microsoft Azure Blog
Microsoft Azure Blog
Blog — PlanetScale
Blog — PlanetScale
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
T
Tailwind CSS Blog
S
SegmentFault 最新的问题
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
云风的 BLOG
云风的 BLOG
The Last Watchdog
The Last Watchdog
L
LINUX DO - 热门话题
酷 壳 – CoolShell
酷 壳 – CoolShell
WordPress大学
WordPress大学
AWS News Blog
AWS News Blog
美团技术团队
G
Google Developers Blog
宝玉的分享
宝玉的分享
www.infosecurity-magazine.com
www.infosecurity-magazine.com
C
CXSECURITY Database RSS Feed - CXSecurity.com
Recent Commits to openclaw:main
Recent Commits to openclaw:main
I
InfoQ
小众软件
小众软件
Google DeepMind News
Google DeepMind News
P
Privacy & Cybersecurity Law Blog
Stack Overflow Blog
Stack Overflow Blog
Webroot Blog
Webroot Blog
D
DataBreaches.Net
IT之家
IT之家
PCI Perspectives
PCI Perspectives
人人都是产品经理
人人都是产品经理
Hacker News: Ask HN
Hacker News: Ask HN
L
LangChain Blog
SecWiki News
SecWiki News
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
C
Cisco Blogs
T
Threatpost
P
Proofpoint News Feed
Y
Y Combinator Blog
Cloudbric
Cloudbric
T
Tor Project blog
量子位
博客园_首页
B
Blog
Hugging Face - Blog
Hugging Face - Blog
GbyAI
GbyAI
D
Darknet – Hacking Tools, Hacker News & Cyber Security

博客园_首页

Linux实操--组管理、权限管理和定时任务 Java + EasyExcel 实现单个接口导出多个Excel Mem0 源码解析系列(二):提示词工程的深度剖析 Openclaw TaskFlow究竟是什么?和普通Skill技能有什么区别 博文阅读密码验证 - 博客园 嘉立创开源:应该是全网MicroPython教程最多的开发板 Hermes Agent 集成实践:从协议到生产 2026年AI编程工具横评:Cursor、Codex、Claude Code、Zed、Windsurf Java程序员必看的RAG入门教程 2026 AI效率神器:Superpowers + Claude Code 保姆级教程 本地大模型部署全攻略:从 0 到 1 玩转 Ollama 【从0到1构建一个ClaudeAgent】内存管理-上下文压缩 .NET 高级开发 | 设计、实现一个事件总线框架 电子小白入门之NE555 3. WorkBuddy:隐藏玩法,一键召唤专家,让 AI 以"专家身份"给你干活 和AI一起搞事情#3:Claude Teammate 游戏开发翻车实录 【OpenClaw】通过 Nanobot 源码学习架构---(7)Memory C# .NET 周刊|2026年3月3期 我在 Debian 11 上把 K8s 单机搭起来了,过程没你想的那么顺(/opt 目录版) 深度学习进阶(七)Data-efficient Image Transformer CLI+Skill搭建浏览器AI自动化框架,告别一切重复枯燥任务 告别Token账单无底洞:OpenClaw本地部署,重塑企业数据主权的唯一解 FastAPI+Vue:文件分片上传+秒传+断点续传,这坑我帮你踩平了! SBTI 爆火后,我做了个程序员版的 CBTI。。已开源 + 附开发过程 多模态检索开始进入工程期:用 Sentence Transformers 搭建可落地的 Multimodal RAG 100多行代码实现一个最简单的Agent(用ReAct) Claude Code 通关手册(八):推荐 5 个 Hooks,代码质量提升 3 倍 老板:“有人截图了!”。安全部门:“收到,马上查暗水印!” - why技术 技术之外,皆是人间 C#/.NET/.NET Core技术前沿周刊 | 第 69 期(2026年4.01-4.12) Snack JSONPath 项目架构分析 Claude Code Buddy 小析:一个非核心功能,如何体现产品的细节完成度 AI新时代下的图床管理方案-Cloudflare图床+MCP+Skills方案指南 化繁为简:顺丰速运App如何通过 HarmonyOS SDK实现专业级空间测量 从零实现富文本编辑器#13-React非编辑节点的内容渲染 AI开发-python-langchain框架(3-23-OpenAI Functions风格Tool Calling智能助手) .NET + AI 进阶实战:基于类的技能开发 - 打造可治理的 Agent 能力模块 【从0到1构建一个ClaudeAgent】规划与协调-技能 上周热点回顾(4.6-4.12) 电子小白的工具三件套:面包板、杜邦线、万能板 单表五亿数据的查询优化 | Mysql、StarRocks 2. WorkBuddy:从“我是谁”到“帮我干活” C# 如何减少代码运行时间:7 个实战技巧 基于HelixToolkit.SharpDX 渲染3D模型 - 笺上知微 从零开始的双臂具身VLA起源及现阶段发展综述 - SkyXZ 记对 xonsh shell 的使用, 脚本编写, 迁移及调优 - pluvium27 受够了Vibe Coding的失控?换个起点,让AI事半功倍 从开始配置漏洞环境到漏洞复现流程 - 難しい 关于10年工作经验的程序员对OpenClaw的实战经验分享以及看法 - 虚无境 Any metadata 的内存布局 C# .NET 周刊|2026年3月2期 - InCerry 我帮你测过了,测试圈排名第二的 Skill 依然很牛逼 Skill Discovery | 无监督技能发现的经典工作总结 - MoonOut PbootCMS 网站内容数量多导致访问慢?这些实用优化方案帮你提速! - 家兴网络技术工作室 上下文工程是什么?过时了么?一文讲明白! - 一枫说码 网站漏洞怎么发现并修复?一篇实用指南(附完整流程) - 家兴网络技术工作室 开了 TUN 模式还是直连?90% 的人都踩过这个坑 Github日报|2026年04月12日 - AI一族 AScript扩展多种脚本语言 - rockey627 AI 学习笔记:Agent 的记忆机制 你能被装进一个文件里吗?——7 万人把同事"蒸馏"成了 AI - 我没有三颗心脏 Claude Code 通关手册(七):给 AI 装上技能包——Skills 完全指南 - 暮色之狐 在浏览器中快速编辑代码:VSCode Web 集成实践 - Newbe36524 蒸馏自己 skill?基于 Deepseek 的蒸馏器,丐版蒸馏方式,简单便捷 - To_Carpe_Diem Spring AI Aliababa和AgentScope,哪个更好? - 苏三说技术 Etsy 把 1000 个 MySQL 分片迁进 Vitess:425TB 数据背后的真正问题不是性能,而是运维规模 MicroPython LVGL基础知识和概念:底层渲染与性能优化 - FreakStudio 数据库草图算法 Python 潮流周刊#146:CPython 引入 Rust 的进展 - 豌豆花下猫 最小生成树 - mofei1116 红日靶场七:从外网入口、容器逃逸到 AD 接管的完整利用链复盘 - YouDiscovered1t 分享四款开源且实用的 Kafka 管理工具 - 追逐时光者 vLLM 权重加载机制全解析:从挑战到理想架构 LCT 学习笔记 - ACehomoxue Avalonia UI 12.0.0 正式发布:架构演进和性能飞跃 - 张善友 当 AI Agent 把调用链拉长,延迟开始成为一门生意 conhost.exe 无法显示 U+2717 - 145a 太秀了,我把自己蒸馏成了 Skill!已开源 - 程序员鱼皮 ASP.NET Core 内存缓存实战:一篇搞懂该怎么配、怎么避坑 基于 Ghostty 带有分割标签页和为 Claude 编程设计的通知终端 - BugShare AI 焊死入口:教育的“操作系统级”重塑 - 郝hai 初级Java开发工程师使用sql脚本编写代码的过程是简单而且不糊涂 - CoderOilStation Claude Code通关手册(六):MCP协议完全指南 - 暮色之狐 边框灯光环绕动画特效实现指南 - Newbe36524 开源:子木蒸馏版的 SEO 审计工具 seo-audit-skill v1.0 我所理解的Python元模型 【从0到1构建一个ClaudeAgent】规划与协调-TodoWrite - 程序员Seven Claude 和 Codex 在审计 Skill 上性能差异探究 - ACai_sec AScript如何实现中文脚本引擎 - rockey627 【渗透测试】HTB Season10 Garfield 全过程wp - dynasty_chenzi Android 开发者为什么必须掌握 AI 能力?端侧视角下的技术变革 树状数组正确性证明 - AC-wyr 你的 AI 焦虑,可能比 AI 本身更危险——ATM 机没有消灭银行柜员,但恐慌消灭了你的判断力 - 我没有三颗心脏 一个拉胯的分库分表方案有多绝望?整个部门都在救火! - 冰河团队 动态规划入门必学之走方格问题 - Ofnoname PostgREST 与 PostgreSQL 角色权限配置全解析(生产级实践) - SheepDog1998 使用 UEFI 图形输出协议 GOP 在屏幕上显示图像的方法 - 阿源- Claude Code通关手册(五):组建你的AI专家团队,子代理系统 - 暮色之狐 一个程序员到架构师的催婚路之感悟(整整10年后的催婚相亲感悟) - MisterLip 用 Agent Skill 自动生成工作周报 - 赵康
Java + Spring实现Hermes Agent之龙虾、Skills、Mcp和沙箱代码执行环境思路
TechMoeTiger · 2026-05-23 · via 博客园_首页

《Java + Spring 实现 Hermes Agent 之龙虾、Skills、MCP 和沙箱代码执行环境思路》

前言

上一篇 里我用 Spring AI 1.0 GA 跑通了 RAG、窗口记忆和最基础的函数调用,能用,但离一个像样的 Agent 还差不少。这年Spring Ai先后推出了Mcp、Skills、和沙箱环境执行等重要项目。这篇接着往 Hermes 风格的 Agent 走,选几块绕不开的东西聊聊我们当前的做法和踩过的坑:

大纲

  • 记忆管理:从内存窗口换成基于文件的记忆,短期会话历史和长期
  • 任务调度:用 JobRunr 给 Agent 加上长期任务能力,一次性、定时、cron 周期都能跑
  • Skills:让能力包能跟着请求热插拔,不重启进程就能切
  • MCP:让 Agent 直接复用 Model Context Protocol 生态里现成的工具,MCP动态注册。
  • 沙箱代码执行环境:安全的代码执行agent-sandbox,把模型生成的代码关进容器跑,ChatGPT同款的Code Interpreter能力

资料参考

下面这些资料这篇文章里反复会引用到,先放在这儿方便查阅:


一、记忆管理:短期 + 长期融合

Spring AI 自带的 InMemoryChatMemoryRepository 进程一重启就清空了,做 Agent 显然不够用。我们参考了 JavaClaw 和 Claude Code 的做法,把记忆分成两层,都落到同一个 workspace 目录下:

层级 谁来写 落在哪里 给模型的方式
短期会话历史 框架(Advisor)自动写 {workspace}/conversations/chat-{channel}.yaml MessageChatMemoryAdvisor 每轮注入历史
长期跨会话事实 暴露Read/Write/Edit工具 模型写 {workspace}/AGENT.md{workspace}/memories/*.md 在 system prompt 里告诉它去哪读,按需 Read

融合点就是这个共享的 workspace。短期由框架兜底,每条消息进来都自动追加;长期由模型自己决定什么时候要写,怎么组织文件名。两层互不打架。

短期:写一个文件版 ChatMemoryRepository

每个会话一个 YAML 文件,frontmatter 记时间,body 是消息列表,编辑器里也能直接打开看:

---
createdAt: 2026-03-21T10:00:00Z
updatedAt: 2026-03-21T10:05:30Z
---
- user: |
    今天北京天气怎么样?
- assistant: |
    今天北京晴,气温 18~26℃。

实现就是 Spring AI 的 ChatMemoryRepository 接口。可以参考 Ref/JavaClaw/.../FileSystemChatMemoryRepository.java,核心方法长这样:

@Component
public class FileSystemChatMemoryRepository implements AppendableChatMemoryRepository {

    private final Path conversationsDir;   // {workspace}/conversations

    @Override
    public List<Message> findByConversationId(String id) {
        Path f = conversationsDir.resolve("chat-" + id + ".yaml");
        return Files.exists(f)
                ? ChatYamlSerializer.deserialize(YamlParser.parse(Files.readString(f)).body())
                : List.of();
    }

    @Override
    public void appendAll(String id, List<Message> msgs) {
        // 只追加增量,避免每次都把整段历史读出来再写回去
        saveAll(id, Stream.concat(findByConversationId(id).stream(), msgs.stream()).toList());
    }

    @Override public void saveAll(String id, List<Message> msgs) { /* 写 frontmatter + body */ }
    @Override public void deleteByConversationId(String id)     { /* 删文件 */ }
}

这里 conversationId 我们直接用通道名(webtelegram-123discord-456),多通道之间天然就隔离开了,迁机器只要把 conversations/ 拷过去就行。

另外,JavaClaw 还顺手 fork 了 Spring 原生的 MessageWindowChatMemory:内部 HashSet 换成 LinkedHashSet 保留顺序,并且把窗口化从写入侧挪到读取侧——磁盘上留全量,给模型时再截最近 N 条。生产里建议照抄,原版那个 HashSet 会把消息顺序打乱,DeepSeek 这类对消息顺序敏感的模型会直接报错。

长期:让模型自己用 FileSystemTools 维护记忆

JavaClaw 这边没有专门搞一个 MemoryTool,思路是复用 Read / Write / Edit 这些通用文件工具,让模型自己在 workspace 里维护 AGENT.md,写成 Claude Code 风格的事实清单:

{workspace}/
├── AGENT.md                 长期事实清单,开机时让模型读一下
├── conversations/
│   ├── chat-web.yaml        短期:Web 通道历史
│   └── chat-telegram-123.yaml
└── memories/                可选:分类别的记忆文件
    ├── user_profile.md
    └── project_q2.md

装配也没多少东西,关键就是 FileSystemTools 给模型,MessageChatMemoryAdvisor 给框架:

ChatClient.builder(chatModel)
    .defaultSystem(p -> p.text(agentPrompt)
                         .param("WORKSPACE", workspace))    // 告诉模型 workspace 在哪
    .defaultTools(FileSystemTools.builder().build())        // 注册 Read/Write/Edit 三个工具给模型
    .defaultAdvisors(
        ToolCallAdvisor.builder().build(),
        MessageChatMemoryAdvisor.builder(chatMemory).build()   // 短期由 Advisor 接管
    )
    .build();

这里的 FileSystemTools 是 spring-ai-community 的库,内部用 @Tool 注解把 Read / Write / Edit 三个方法暴露给模型。@Tooldescription 就是给模型看的"使用说明书",Spring AI 会把这段 description 拼到工具的 JSON Schema 里发给模型,所以写不写得清楚直接决定模型用不用得对

Write 大致长这样:

@Tool(name = "Write", description = """
    Writes a file to the local filesystem.

    Usage:
    - This tool will overwrite the existing file if there is one at the provided path.
    - If this is an existing file, you MUST use the Read tool first to read the file's
      contents. This tool will fail if you did not read the file first.
    - ALWAYS prefer editing existing files in the codebase. NEVER write new files unless
      explicitly required.
    - NEVER proactively create documentation files (*.md) or README files. Only create
      documentation files if explicitly requested by the User.
    - Only use emojis if the user explicitly requests it. Avoid writing emojis to files
      unless asked.
    """)
public String write(
    @ToolParam(description = "The absolute path to the file to write (must be absolute, not relative)") String filePath,
    @ToolParam(description = "The content to write to the file") String content) {
    // ... 真正落盘如果没有沙箱直IO接写,有沙箱就用沙箱提供的操作方法。
}

`Read` 和 `Edit` 同理,描述里把"什么时候用、参数怎么填、有什么限制"讲明白即可。

`.defaultTools(FileSystemTools.builder().build())` 这一行就把这三个工具加进了 `ChatClient` 可见的工具列表,模型这边看到的就跟任何普通函数工具一样。

调用时带上 `conversationId`,短期历史就会自动追加到对应 YAML:

```java
chatClient.prompt(question)
    .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, "telegram-123"))
    .call().content();

短期记忆是 MessageChatMemoryAdvisor 在每轮请求前自动把消息追加到 YAML,不需要模型同意、模型也不知道。长期记忆刚好反过来——是模型在对话里觉得"这个事实值得记一下",自己发起一次工具调用 Write("AGENT.md", "..."),Spring AI 的工具调用循环再把这次调用路由到 FileSystemTools.write 真正写文件。框架在这条路径上只是传话筒,记什么、什么时候记,决定权在模型自己


二、任务调度:用 JobRunr 接长期任务

记忆解决了"它记得",但 Agent 还差一块——"它能在你不在的时候干活"。比如"每天早上 9 点帮我把昨天的日志汇总一下发到 Telegram",或者"30 分钟后提醒我开会"。一次性、定时、cron 周期,这些都不是会话内能搞定的,得有个真正的调度器。

JavaClaw 选了 JobRunr。我们看了一圈下来也觉得它对 Agent 场景挺合适:

  • 任务用 lambda 表达式调度(x -> x.executeTask(taskId)),JobRunr 帮你做序列化、持久化、重启恢复
  • 自带 dashboard,能看到队列里有什么、跑过什么、失败了几次
  • @Job(retries = N) 一行加重试
  • 跟 Spring Boot 集成顺,JobScheduler 直接注入

整体结构是三层:模型用工具调用 TaskTool 创建/调度任务 → TaskManager 落库并往 JobScheduler 塞一条 → 到点了 JobRunr 反序列化 lambda、回调 TaskHandler.executeTask(taskId) 执行。

TaskManager 这层薄到不行:

@Component
public class TaskManager {
    private final JobScheduler jobScheduler;
    private final TaskRepository taskRepository;

    public void create(String name, String desc) {                                  // 立即执行
        Task task = taskRepository.save(Task.newTask(name, desc));
        jobScheduler.<TaskHandler>enqueue(x -> x.executeTask(task.getId()));
    }

    public void schedule(LocalDateTime when, String name, String desc) {            // 一次性定时
        Task task = taskRepository.save(Task.newTask(name, desc));
        jobScheduler.<TaskHandler>schedule(when, x -> x.executeTask(task.getId()));
    }

    public void scheduleRecurrently(String cron, String name, String desc) {        // cron 周期
        RecurringTask task = taskRepository.save(RecurringTask.newRecurringTask(name, desc));
        jobScheduler.<RecurringTaskHandler>scheduleRecurrently(
                task.getName(), cron, x -> x.executeTask(task.getId()));
    }
}

到点真正干活的是 TaskHandler.executeTask——拿到 taskId,从仓库捞出任务描述,喂给 Agent 自己处理,写回状态。@Job(retries = 3) 一行就能让 JobRunr 在失败时自动重试三次:

@Component
public class TaskHandler {

    @Job(name = "%0", retries = 3)
    public void executeTask(String taskId) {
        Task task = taskRepository.getTaskById(taskId);
        Task inProgress = taskRepository.save(task.withStatus(Status.in_progress));
        try {
            TaskResult result = agent.prompt(taskId, formatTaskForAgent(inProgress), TaskResult.class);
            taskRepository.save(inProgress.withFeedback(result.feedback())
                                          .withStatus(result.newStatus()));
            notifyUser(task.getName(), result);                       // 通过 ChannelRegistry 推回去
        } catch (Exception e) {
            taskRepository.save(inProgress.withStatus(Status.todo));   // 失败回滚到 todo,让 retry 重新跑
            throw e;
        }
    }
}

注意一个小细节:taskId 而不是整个 Task 对象作为参数。JobRunr 要把这条 lambda 序列化进存储里,参数得是简单可序列化的值,存 ID、跑的时候再去仓库捞,是更稳的做法。

剩下就是把任务能力暴露给模型——一个 TaskTool,三个 @Tool 方法分别对应 createTask / scheduleTask / scheduleRecurringTask,描述里把"什么时候用、参数怎么填"写清楚就行:

@Tool(description = """
    Schedules a task using JobRunr that repeats at regular intervals based on a cron expression.
    Use this for recurring activities like daily reports, weekly checks, etc.
    - cronExpression: standard cron, e.g. '0 12 * * *' for daily at noon
    - name: short identifier (e.g. 'weekly-log-cleanup')
    - description: what the task should do
    """)
public String scheduleRecurringTask(String cronExpression, String name, String description) {
    taskManager.scheduleRecurrently(cronExpression, name, description);
    return "Task '" + name + "' scheduled with cron '" + cronExpression + "'.";
}

用户说"每天早上九点帮我同步一下昨天的 commits",模型自己就会拼出 cron 表达式调 scheduleRecurringTask。到点 JobRunr 触发,TaskHandler 让 Agent 真正执行,结果通过通道推回给用户。

几个值得注意的点:

任务的 conversationId 和聊天会话的 conversationId 不是一回事——JavaClaw 这边直接拿 taskId 当 Agent prompt 的 conversationId,意思是"这条任务有自己独立的对话上下文",不会跟用户的实时聊天混在一起。当然代价是任务结果回推时没有原始会话的上下文,注释里也标了 TODO。

JobRunr 默认用 H2 存储就能跑,生产环境换 Postgres / MySQL / Mongo 都行,存储层是插拔的。如果要做集群,多个实例共享同一个存储,JobRunr 自己会做 leader 选举和分片,业务代码不用变。

dashboard 默认在 /dashboard,部署到生产记得加鉴权或者关掉,里面能看到所有任务的执行历史和栈。

三、Skills 动态热插拔

Skill 我们没有写死成 Spring Bean,而是当成一包跟着请求进来的资源。请求里带一组 name + url,服务端按 userId/assistantId/sessionId 分桶把 zip 下载下来、解压、喂给 SkillsTool(让模型看见元数据)和 Sandbox(把脚本本体 seed 进容器)。同一个 ChatClient 每次请求按需重新组装一遍工具集,不用重启进程就能切换能力

请求长这样:

POST /chat/stream
{
  "userId": 1001,
  "assistantId": 7,
  "sessionId": "s-xxx",
  "query": "把附件 csv 画成折线图",
  "skills": [
    { "name": "pdf-extractor", "url": "https://cdn.example.com/skills/pdf-extractor-1.2.zip" },
    { "name": "chart-maker",  "url": "https://cdn.example.com/skills/chart-maker-0.3.zip"  }
  ]
}

切技能就是改这个 skills 数组,前端可以做成一个勾选列表,用户点哪个就带哪个。

/chat/stream 里大致是这么组装的:

public Flux<ChatEvent> streamChat(ChatRequest req) {

    // 1. 按 user/assistant/session 分桶下载和解压,命中缓存直接复用
    List<Resource> skillDirs = skillCache.resolve(
            req.userId(), req.assistantId(), req.sessionId(), req.skills());

    // 2. 有 skill 才起沙箱,并把 skill 文件 seed 进去
    Sandbox sandbox = skillDirs.isEmpty() ? null : sandboxFactory.create(skillDirs);

    // 3. SkillsTool 暴露目录元数据,让模型自己决定读哪一个
    ToolCallback[] skillTools = skillDirs.isEmpty()
            ? new ToolCallback[0]
            : new ToolCallback[]{ SkillsTool.builder().addSkillsResources(skillDirs).build() };

    // 4. 每请求新建 spec,工具集 = 内置 + 沙箱 + skill + MCP
    var spec = ChatClient.create(chatModel).prompt().user(req.query());
    if (sandbox != null) {
        spec.tools(new SandboxBashTool(sandbox, ...),
                   new SandboxFileSystemTools(sandbox),
                   finalAnswerTool);
    }
    spec.toolCallbacks(skillTools);

    // 5. 流式返回,结束时关沙箱
    return spec.stream().chatResponse()
               .map(this::toEvent)
               .doFinally(s -> { if (sandbox != null) sandbox.close(); });
}

下载skills缓存目录我们按 用户ID/助手ID/对话窗口ID 三段分桶.

skillDirs 为空时干脆不起沙箱,纯文本对话不用付容器启动开销。SkillsTool 本身只暴露元数据,脚本本体始终在沙箱里跑,宿主机不会被 skill 触达。

至于下载失败的容错——单个 skill 下载或解压失败不应该整次请求都挂掉,记一条 warn 跳过就好,其他 skill 继续生效;全都失败就退化成纯文本对话。

四、MCP:让 Agent 复用外部生态的工具

MCP(Model Context Protocol) 是 Anthropic 推的工具协议层。服务端按协议暴露 tools / resources / prompts,客户端连上就能用,自己这边不用再写一遍工具适配代码。Spring AI 1.0 给了 McpSyncClientSyncMcpToolCallbackProvider,能把任意 MCP server 的工具一键转成 ToolCallback[],塞进 ChatClient 就能让模型调用。GitHub、Slack、Filesystem、Playwright 这些现成 server 拿来即用。

我们的做法是按请求开一组短连接——请求里带 mcpConfig,服务端 connect → initialize → 拿 callbacks → 喂给模型 → 请求结束 close。不在进程里长连,避免连接泄漏,也方便用户随时切 server。

POST /chat/stream
{
  "query": "查一下仓库 spring-ai 最近的 issue,并搜一下相关网页",
  "mcpConfig": {
    "github": {
      "url": "https://mcp.example.com/github/mcp",
      "headers": { "Authorization": "Bearer ghp_xxx" }
    },
    "brave-search": {
      "url": "https://mcp.example.com/brave?key=xxx"
    }
  }
}

模型这一侧看到的就是 GitHub 加 Brave 两个 server 暴露的工具合集,按需调。

构建逻辑放在一个 DynamicMcpClientFactory 里,每请求出一个 McpSession

public McpSession build(Map<String, McpServerConfig> mcpConfig) {
    List<McpSyncClient> clients = new ArrayList<>();
    for (var entry : mcpConfig.entrySet()) {
        var cfg = entry.getValue();

        var transport = HttpClientStreamableHttpTransport
                .builder(originOf(cfg.url()))
                .endpoint(pathOf(cfg.url()))
                .httpRequestCustomizer((req, m, ep, body, ctx) ->
                        cfg.headers().forEach(req::header))   // 鉴权头注入
                .build();

        McpSyncClient client = McpClient.sync(transport)
                .requestTimeout(Duration.ofSeconds(30))
                .build();
        client.initialize();                                  // 握手并拉 tool 列表
        clients.add(client);
    }
    return new McpSession(clients);
}

public static final class McpSession implements AutoCloseable {
    private final List<McpSyncClient> clients;

    public ToolCallback[] getToolCallbacks() {
        return SyncMcpToolCallbackProvider.builder()
                .mcpClients(clients).build()
                .getToolCallbacks();                          // 合并多 server 的工具
    }
    @Override public void close() { clients.forEach(McpSyncClient::closeGracefully); }
}

/chat/stream 里和 skill 工具拼到一起就行:

try (McpSession mcp = mcpFactory.build(req.mcpConfig())) {
    spec.toolCallbacks(mcp.getToolCallbacks());
    return spec.stream().chatResponse().map(...);
}

这块当时踩过几个坑值得提一下。

一个是 URL 带 query string 的问题。MCP SDK 内部走的是 URI.resolve(base, endpoint),如果 endpoint 以 / 开头会把 base 上的 ?key=xxx 直接吞掉。后来我们把 URL 拆成 origin 和相对 endpoint+query 再喂给 builder 才解决。

第二个是鉴权要走 headers 字段,配合 httpRequestCustomizer 注入,每次 POST 都带上,否则 server 端 401。

第三个是连接生命周期——MCP 走 HTTP 长流或 stdio 子进程,忘了 close 会泄漏连接和进程。所以 McpSession 实现了 AutoCloseable,同步接口用 try-with-resources,流式接口在 doFinally 里关。

最后是多个 server 中某一个 initialize 失败的兜底:要把已经打开的 client 都 closeGracefully 再抛异常,不能留半开状态。

五、沙箱代码执行环境

模型生成的 shell 或 Python 代码绝对不能直接在宿主机上跑,一条 rm -rf 就足够把服务搞掉。常规做法是把执行能力封到容器里。但 Spring AI 的 @Tool 注解又特别顺手,不想为了沙箱就放弃这一套工具描述方式。

我们的做法是本地 @Tool 方法加沙箱执行环境,分工大致是:

LLM ──tool_call──► 本地 @Tool 方法 (Bash / Read / Write / Edit)
                        │
                        ▼
                  Sandbox.exec(...)   ← spring-ai-community/agent-sandbox
                        │
                        ▼
              LocalSandbox / DockerSandbox / E2BSandbox
                  (进程 / 容器 / 云端 microVM)

工具签名、描述、参数 Schema 还是用 Spring AI 的 @Tool 暴露给模型,方法实现里不直接 Runtime.exec,而是把命令通过统一的 Sandbox 接口转出去。后端跑在哪里——本机进程、Docker、还是 E2B microVM——只是个配置开关,业务代码完全不用改,这是 agent-sandbox 那套 API 给我们的。

跟 Skills 配合也很自然:SandboxFactory 把 skill 目录从宿主机复制到沙箱内部的 skills/<name>/ 路径下,模型在 Bash 里直接 python skills/pdf-extractor/run.py 就能跑。这一步我们叫 seeding(就像数据库 seed data 那个意思,沙箱起来是空的,得先把"种子文件"埋进去)——后面提到这个词都是指这件事。
@Tool 这层非常薄:

public class SandboxBashTool {
    private final Sandbox sandbox;
    private final Duration defaultTimeout;
    private final Map<String, String> envOverrides;

    @Tool(name = "Bash", description = """
        Execute a bash command inside an isolated sandbox container.
        Use for terminal ops like npm/pip/python/mvn; NOT for file IO
        — Read/Write/Edit have their own tools.
        Skill files live under ./skills/<name>/.
        """)
    public String bash(@ToolParam String command,
                       @ToolParam(required = false) Long timeout) {

        ExecSpec spec = ExecSpec.builder()
                .command("bash", "-lc", command)
                .timeout(timeoutOf(timeout))
                .env(envOverrides)                 // userId / apiKey 等机密参数的透传,类似于@Tool里面的ToolContext
                .build();

        ExecResult r = sandbox.exec(spec);         // 真正的隔离边界
        return formatForLlm(r);                    // stdout/stderr/exitCode 拼一下,截到 30k
    }
}

Read / Write / Edit 同理,都是 @Tool 方法里直接调 sandbox.files() 的 API:

@Tool(name = "Read",  description = "...") public String read(...)  { return sandbox.files().read(path); }
@Tool(name = "Write", description = "...") public String write(...) { sandbox.files().create(path, content); ... }
@Tool(name = "Edit",  description = "...") public String edit(...)  { /* read → replace → write */ }

模型这一侧看到的还是 Bash / Read / Write / Edit 四个常规工具,完全感觉不出来后面是个容器。

切后端就是配置一行的事:

# application.yml
chat:
  sandbox:
    mode: DOCKER        # LOCAL / DOCKER  (E2B 同理可扩)
    image: ghcr.io/spring-ai-community/agents-runtime:latest
Sandbox sandbox = switch (props.getMode()) {
    case DOCKER -> DockerSandbox.builder().image(props.getImage()).build();
    case LOCAL  -> LocalSandbox.builder().tempDirectory("chat-sandbox-").build();
};

平时开发用 LOCAL 起得快、调试方便;生产或者跑不可信 skill 就切 DOCKER;要更强隔离的话可以接 E2BSandbox,云端 Firecracker microVM,文档在 agent-sandbox 上。

几点经验:

沙箱按请求级 try-with-resources 来管,每次 /chat/chat/stream 开一个、结束关一个,别在进程里复用,容器会越攒越多。流式接口同样在 doFinally 里关掉。

LOCAL 模式我们留着是给开发用的,但启动时要打 warn——它没有任何隔离,别哪天被人误用到生产去。

环境变量在两种模式下要分两路注入。Docker 走 ExecSpec.env,对应 docker exec -e;Local 模式还得在命令前加 export ...,绕开 bash -lc 的 login profile 和 WSLENV 白名单导致的变量丢失问题,这个坑当时排了不短时间。

skill 文件 seed 进沙箱时记得跳过二进制——skill 默认是脚本加 Markdown 这类文本,遇到超过阈值或者读不出 UTF-8 的就直接 skip 加 warn,免得 SandboxFiles.create 把二进制损坏。

seeding 过程中要是抛了异常,要立刻把已经创建的 sandbox close 掉,不要留一个孤儿容器或临时目录。


六、可观察性:让用户看见 Agent 每一步

Agent 跑工具调用经常一轮接一轮,要是只把最终回答推给前端,用户那边就是十几秒甚至几十秒的空白,体验很差,出问题也没法排查。我们的做法是把整个工具调用循环里发生的事情都拆成事件吐到 SSE 流里——token 在出、思考在写、工具被调了、工具返回了什么,前端按事件类型渲染就行。

事件类型在 ChatEvent 里固定了几种:

public static ChatEvent token(String text)              { /* 流式 token */ }
public static ChatEvent reasoning(String text)          { /* DeepSeek 这类的思考过程 */ }
public static ChatEvent toolCall(List<ToolCallRef> c)   { /* 模型决定调哪些工具 */ }
public static ChatEvent toolResult(List<ToolResultRef> r) { /* 工具执行完返回了什么 */ }

前两个 Spring AI 默认就在流里给了,不用额外操心。麻烦的是后两个——尤其是 tool_result。Spring AI 的 ToolCallAdvisorstreamToolCallResponses(true) 打开之后,含 toolCalls 的中间 ChatResponse 会透传出来,所以 tool_call 事件很好转。但工具执行结果默认只会进到下一轮的 conversationHistory,不会作为独立 chunk 发出来。

解法是装饰一层 ToolCallingManager,在 executeToolCalls 后把本轮工具响应旁路到一个 sink:

class ObservableToolCallingManager implements ToolCallingManager {
    private final ToolCallingManager delegate;
    private final Sinks.Many<ChatEvent> sink;

    @Override
    public ToolExecutionResult executeToolCalls(Prompt prompt, ChatResponse resp) {
        ToolExecutionResult result = delegate.executeToolCalls(prompt, resp);   // 真正执行
        try {
            List<ChatEvent.ToolResultRef> refs = extractToolResponses(result);  // 从 history 末尾捞 ToolResponseMessage
            if (!refs.isEmpty()) sink.tryEmitNext(ChatEvent.toolResult(refs));
        } catch (RuntimeException e) {
            log.warn("emit tool_result failed: {}", e.getMessage());            // 观测不能影响主流程
        }
        return result;
    }
}

装配的时候把它喂给 ToolCallAdvisor,然后把 sink 跟主流 Flux 合一下:

Sinks.Many<ChatEvent> toolEventSink = Sinks.many().unicast().onBackpressureBuffer();
ToolCallingManager observable = new ObservableToolCallingManager(
        ToolCallingManager.builder().build(), toolEventSink);

ChatClient.create(chatModel).prompt().user(req.query())
    .advisors(ToolCallAdvisor.builder()
            .toolCallingManager(observable)
            .streamToolCallResponses(true)         // 含 toolCalls 的中间响应也透出来
            .build())
    .stream().chatResponse()
    .mergeWith(toolEventSink.asFlux())             // 主流 + 旁路 sink 合并
    .map(this::toEvent);

前端就能拿到一条完整的事件序列,类似这样:(Skills和mcp在spring ai里面也是工具触发都可以显示)

event: reasoning   {"text": "让我先查一下..."}
event: tool_call   [{"id": "c1", "name": "Bash", "args": "ls skills/"}]
event: tool_result [{"id": "c1", "name": "Bash", "result": "chart-maker\npdf-extractor"}]
event: token       {"text": "找到了两个 skill,"}
event: token       {"text": "我用 chart-maker..."}

前端展示效果如下,这样更像智能体了:
image

常见的 QA

Q:Spring AI 自带的 Starter 没覆盖到我想用的模型,怎么扩展?

两条路。简单的一条是套一层 OpenAI 兼容格式——大部分国产模型(千问、智谱、豆包、DeepSeek 自家也是)都提供 OpenAI 兼容端点,直接复用 spring-ai-starter-model-openai,把 base-url 改一下就能跑。

如果是私有协议或者要做特殊的请求/响应改写,那就自己实现 ChatModelStreamingChatModel。我们项目里就有一个,签名是这样:

public class MyModelChatModel implements ChatModel, StreamingChatModel {
    @Override public ChatResponse call(Prompt prompt) { ... }
    @Override public Flux<ChatResponse> stream(Prompt prompt) { ... }
}

两个方法填进去,剩下 ChatClient 那一整套(advisor、tool call、memory)就都能复用,不用动其它代码。

Q:怎么根据请求参数动态切换模型?

我们这边的做法是写一个 ModelRouter,按 modelName 前缀路由到不同的 Bean:

public ChatModel resolve(String modelName) {
    String lower = modelName == null ? "" : modelName.toLowerCase();
    if (lower.startsWith("claude"))         return anthropicChatModel;
    if (lower.startsWith("deepseek-chat"))  return deepSeekChatModel;
    if (lower.startsWith("qwen")
     || lower.startsWith("glm")
     || lower.startsWith("doubao")
     || lower.startsWith("openai-compatible"))  return myModelChatModel;
    return openAiChatModel;     // 默认走 OpenAI
}

然后 /chat/stream 入口拿请求里的 modelName 解析一下就行:

ChatModel chatModel = modelRouter.resolve(req.modelName());
ChatClient.create(chatModel).prompt().user(req.query())...

每个 provider 自己的 @Bean 配置照常写,路由这层只是个 switch。前端切模型 = 改请求体一个字段,服务端不重启。

Q:Spring AI 能上生产吗?

可以。1.0 GA 已经发了很长时间,已经相当稳定。开始Java没有成熟的Agent框架,用的Langchain。现在我们也从Langchain迁移回Spring Ai了。这套也是直接跑在生产上的。生态这一年长得很快——Anthropic、OpenAI、DeepSeek、Google、Ollama、各家国产兼容端点基本都有 starter,MCP、向量库、observability 也都接上了,社区还有 spring-ai-community 那一摞 utils 可以挑着用。Spring AI 2.0 马上也快 GA 了,可以期待一下。