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

《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 是消息列表,编辑器里也能直接打开看:

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

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

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 我们直接用通道名(web、telegram-123、discord-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 给框架:

java 复制代码
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 三个方法暴露给模型。@Tool 的 description 就是给模型看的"使用说明书",Spring AI 会把这段 description 拼到工具的 JSON Schema 里发给模型,所以写不写得清楚直接决定模型用不用得对。

Write 大致长这样:

java 复制代码
@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 这层薄到不行:

java 复制代码
@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 在失败时自动重试三次:

java 复制代码
@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,描述里把"什么时候用、参数怎么填"写清楚就行:

java 复制代码
@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 每次请求按需重新组装一遍工具集,不用重启进程就能切换能力。

请求长这样:

jsonc 复制代码
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 里大致是这么组装的:

java 复制代码
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 给了 McpSyncClient 和 SyncMcpToolCallbackProvider,能把任意 MCP server 的工具一键转成 ToolCallback[],塞进 ChatClient 就能让模型调用。GitHub、Slack、Filesystem、Playwright 这些现成 server 拿来即用。

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

jsonc 复制代码
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:

java 复制代码
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 工具拼到一起就行:

java 复制代码
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 这层非常薄:

java 复制代码
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:

java 复制代码
@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 四个常规工具,完全感觉不出来后面是个容器。

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

yaml 复制代码
# application.yml
chat:
  sandbox:
    mode: DOCKER        # LOCAL / DOCKER  (E2B 同理可扩)
    image: ghcr.io/spring-ai-community/agents-runtime:latest
java 复制代码
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 里固定了几种:

java 复制代码
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 的 ToolCallAdvisor 把 streamToolCallResponses(true) 打开之后,含 toolCalls 的中间 ChatResponse 会透传出来,所以 tool_call 事件很好转。但工具执行结果 默认只会进到下一轮的 conversationHistory,不会作为独立 chunk 发出来。

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

java 复制代码
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 合一下:

java 复制代码
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..."}

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

常见的 QA

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

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

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

java 复制代码
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:

java 复制代码
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 解析一下就行:

java 复制代码
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 了,可以期待一下。

相关推荐
Pniubi2 天前
基于openclaw实现自定义mcp/skill(第三章)
自研·skill·openclaw
po_potato2 天前
Hermes Agent 安装与 MCP 配置教程
教程·开发工具·mcp·hermes agent
码哥字节3 天前
给 Agent 加了 Redis 还是跨会话失忆,我排查后发现少了一层
spring ai·agent skills
guslegend3 天前
工程地基、Provider 与 ReAct 循环:Agent 核心能力的设计、实现与验收
人工智能·agent·react·架构设计·spring ai
海南java第二人3 天前
Spring AI 拦截器实现 RAG 检索的两种方式
spring ai
Pniubi3 天前
基于openclaw开发skill与自研agent中的skill开发区别
服务器·agent·skill·openclaw·龙虾
南归北隐3 天前
Spring AI Alibaba Graph框架实现RAG私有知识库问答
java·后端·spring·spring ai·java rag实现
EatFan4 天前
2026 后端 AI 工程化:Spring AI 2.0、MCP 协议与 Agent 内嵌如何收进 Java 生产系统
java·人工智能·spring·agent·spring ai·spring boot 3·mcp
Sayai4 天前
MCP Server 开发实战:Spring AI 把后端日志查询暴露给 AI Agent(7 条设计原则与踩坑实录)
java·人工智能·ai agent·spring ai·mcp
中间件XL5 天前
agent框架langgraph4j原理源码分析 图 III 执行图1原理分析
智能体·spring ai·langgraph4j·ai agent框架·图执行