当前位置: 首页 > news >正文

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

Agent 走,选几块绕不开的东西聊聊我们当前的做法和踩过的坑:

大纲

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

资料参考

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

  • Spring AI Community 项目集——agent-sandboxspring-ai-agent-utils的仓库
  • Spring AI Community 官方介绍——这个社区是怎么成立的,定位是什么
  • Spring AI Generic Agent Skills——Skills 这块的官方思路,本文第三节会对应到
  • JavaClaw / ClawRunr——本文里"短期 + 长期记忆"和"JobRunr 任务调度"的真实参考实现
  • ToolCall概念——本文里工具调用@Tool,模型自动触发的本地代码工具

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

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

层级谁来写落在哪里给模型的方式
短期会话历史框架(Advisor)自动写{workspace}/conversations/chat-{channel}.yamlMessageChatMemoryAdvisor每轮注入历史
长期跨会话事实暴露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..."}

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

常见的 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 了,可以期待一下。

分类: web

标签: spring ai, Hermes Agent, Spring AI Skills, 龙虾, 服务端龙虾, openclaw, Spring AI 沙箱, Code Interpreter

免责声明:本内容来自平台创作者,博客园系信息发布平台,仅提供信息存储空间服务。

好文要顶 关注我 收藏该文 微信分享

TechMoeTiger
粉丝 - 4 关注 - 4

+加关注

0

0

升级成为会员

« 上一篇: 网站集成微软账户一键登录(支持Windows Hello 面容、指纹等)-微软Azure的Oauth2.0的集成

posted @ 2026-05-23 14:44 TechMoeTiger 阅读(357) 评论(0) 收藏 举报

刷新页面

http://www.jsqmd.com/news/1068731/

相关文章:

  • GlusterFS冗余存储池在Ubuntu上的可靠性实践
  • 银河麒麟V10安装Wireshark:权限配置与抓包实战指南
  • Qoder CN Credits机制详解:AI编码助手的算力计量与精算实践
  • Ubuntu 20.04 + MySQL 8.0 构建三节点MGR高可用集群实战
  • 渗透测试信息收集:从OSINT到攻击面绘制的完整实战指南
  • 银行卡BIN码全解析:从编码原理到支付路由与风控实战
  • JavaScript数组方法实战:map/filter/forEach的语义契约与工程避坑
  • 2026年外贸独立站建站全攻略:从SEO到GEO,你的网站正在被AI“面试”
  • 联合查询注入攻击原理与防御实战:从手工注入到自动化工具
  • 嵌入式测试第 40 天:智能手表/手环嵌入式测试拆解
  • 1023. 【USACO题库】2.1.4 Healthy Holsteins健康的好斯坦奶牛
  • CSS静态页脚实现原理与Flexbox最佳实践
  • React对接DigitalOcean API:从零搭建前端数据流水线
  • Jenkins Job DSL:用代码管理CI/CD配置的实践指南
  • HPE StoreOnce认证绕过漏洞深度剖析与应急响应实战指南
  • Kafka数据迁移三模式:备份、导入与全栈迁移原理与Ubuntu 18.04实战
  • 深度解析:抖店行业资质与商品创建合规体系及实操准则
  • Python 3 Web API开发实战:超时重试认证与健壮性设计
  • AI Agent核心原理与工程落地五模块详解
  • 后端开发必看!6种服务端主动推送方案的实战对比
  • Ubuntu 18.04 部署 code-server 实战指南:Docker+HTTPS+ROS 全栈配置
  • Ubuntu 20.04 LEMP部署实战:Nginx+PHP7.4+MySQL8.0完整配置
  • Wireshark网络协议分析实战:从抓包入门到故障排查精要
  • LLM生产环境稳定性指南:从OOM到长尾延迟的防御体系
  • App Platform自定义域名、SSL与CDN配置原理与实战
  • Cursor编辑器深度解析:项目级语义感知与AI原生编码工作流
  • FileZilla Client 3.70.4 官方版下载(Windows/macOS/Linux,夸克网盘)
  • JMeter安装配置全攻略:从零搭建性能测试环境
  • Ubuntu 14.04 上用 Terraform 部署 Node.js 的实战方案
  • Gemini 3.1 Pro五大核心技巧:解锁高阶推理与结构化输出