☰
Java + Spring实现Hermes Agent:龙虾、Skills、Mcp与沙箱代码执行环境配置思路
2026/9/26 12:26:18 网站建设 项目流程

1. 为什么要在 Java + Spring 里搭 Hermes Agent

Hermes Agent 是一套把「记忆、任务调度、技能热插拔、MCP 工具、沙箱执行」拼在一起的 Agent 运行骨架。放到 Java + Spring 技术栈里,它要解决的核心问题是:让一个 Spring Boot 服务既能跟大模型对话,又能在对话之外记住事实、定时干活、按需加载技能包、复用外部工具生态,并且把模型生成的代码关进隔离环境里跑。适合谁?适合已经有一套 Spring Boot 后端、想在上面长出 Agent 能力、又不想把整套东西重写成 Python 的团队。

我把它拆成四个模块来落地:龙虾(记忆管理,短期会话历史 + 长期事实清单)、Skills(按请求热插拔的技能包)、Mcp(复用外部工具协议)、沙箱代码执行环境(把 Bash / Read / Write / Edit 关进容器)。这四个模块共享一个 workspace 目录,短期记忆由框架兜底写,长期记忆由模型自己用文件工具维护,Skills 和沙箱通过 seeding 把脚本喂进隔离环境,Mcp 则按请求开短连接拿工具回调。整条链路里,模型看到的工具签名始终是 Spring AI 的@Tool,隔离边界藏在方法实现里,业务代码不用改。

下面按「前置准备 → 可复制配置 → 验证请求 → 排错」的顺序走一遍,配置骨架可以直接抄。

2. TaoToken 前置:统一 Key 与 API 通道

在接 Mcp 和模型之前,先把模型通道统一掉。TaoToken 提供 OpenAI 兼容的 API 通道,一个 Key 可以走多个模型,省得每个 provider 配一套 base-url 和鉴权。官网入口在 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 地址是 https://taotoken.net/api (这个不加 UTM)。

你需要先拿到 API Key,再去控制台确认通道可用。拿 Key 的入口在 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite ,接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。如果你后面要长期跑编码类 Agent,可以看 Coding Plan: https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。

注意:Key 只放在服务端环境变量或配置中心,别写进前端请求体,也别提交到仓库。Mcp 的鉴权头同理,走服务端注入。

拿到 Key 之后,Spring AI 这边直接复用 OpenAI starter,把 base-url 指到 TaoToken 的 API 地址即可。这样ChatModel、ChatClient、advisor、tool call、memory 这一整套都能直接用,不用自己实现协议。

3. 可复制配置:Spring Boot 骨架 + Mcp settings.json

3.1 application.yml 骨架

spring: ai: openai: base-url: https://taotoken.net/api api-key: ${TAOTOKEN_API_KEY} chat: options: model: deepseek-chat temperature: 0.3 chat: workspace: ${user.home}/hermes-workspace sandbox: mode: LOCAL # LOCAL / DOCKER image: ghcr.io/spring-ai-community/agents-runtime:latest mcp: request-timeout-seconds: 30

workspace是四个模块共享的根目录,短期会话历史落在{workspace}/conversations/,长期事实清单落在{workspace}/AGENT.md和{workspace}/memories/*.md。迁机器时把整个 workspace 拷过去就行。

3.2 短期记忆:文件版 ChatMemoryRepository

Spring AI 自带的InMemoryChatMemoryRepository进程一重启就清空,做 Agent 不够用。写一个落到 YAML 的实现,每个会话一个文件:

@Component public class FileSystemChatMemoryRepository implements ChatMemoryRepository { private final Path conversationsDir; public FileSystemChatMemoryRepository(@Value("${chat.workspace}") String workspace) { this.conversationsDir = Path.of(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 saveAll(String id, List<Message> msgs) { // 写 frontmatter + body,只追加增量 } @Override public void deleteByConversationId(String id) { // 删文件 } }

conversationId直接用通道名(web、telegram-123、discord-456),多通道天然隔离。这里有个坑:Spring 原生的MessageWindowChatMemory内部用HashSet,会把消息顺序打乱,DeepSeek 这类对顺序敏感的模型会直接报错。换成LinkedHashSet保留顺序,并且把窗口化从写入侧挪到读取侧——磁盘留全量,给模型时再截最近 N 条。

3.3 长期记忆:让模型自己维护 AGENT.md

长期记忆不专门搞 MemoryTool,复用 Read / Write / Edit 三个通用文件工具,让模型自己在 workspace 里维护事实清单。装配时把FileSystemTools给模型,MessageChatMemoryAdvisor给框架:

ChatClient.builder(chatModel) .defaultSystem(p -> p.text(agentPrompt).param("WORKSPACE", workspace)) .defaultTools(FileSystemTools.builder().build()) .defaultAdvisors( ToolCallAdvisor.builder().build(), MessageChatMemoryAdvisor.builder(chatMemory).build() ) .build();

@Tool的 description 就是给模型看的说明书,Spring AI 会把它拼进 JSON Schema 发给模型,写不写得清楚直接决定模型用不用得对。Write 的描述里要把「先 Read 再 Write」「不要主动创建文档文件」这些约束讲明白。

3.4 Mcp 接入:settings.json 示例

Mcp 按请求开短连接,请求里带mcpConfig,服务端 connect → initialize → 拿 callbacks → 喂给模型 → 请求结束 close。settings.json 示例:

{ "mcpServers": { "github": { "url": "https://mcp.example.com/github/mcp", "headers": { "Authorization": "Bearer ${GITHUB_TOKEN}" } }, "brave-search": { "url": "https://mcp.example.com/brave?key=${BRAVE_KEY}" } } }

构建逻辑放在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(); clients.add(client); } return new McpSession(clients); }

McpSession实现AutoCloseable,同步接口用 try-with-resources,流式接口在doFinally里关。多个 server 中某一个 initialize 失败时,要把已经打开的 client 都closeGracefully再抛异常,不能留半开状态。

3.5 沙箱执行环境

模型生成的 shell 或 Python 代码绝对不能直接在宿主机上跑。做法是本地@Tool方法加沙箱执行环境,工具签名还是用 Spring AI 的@Tool暴露给模型,方法实现里不直接Runtime.exec,而是把命令通过统一的Sandbox接口转出去:

@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. 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) .build(); ExecResult r = sandbox.exec(spec); return formatForLlm(r); }

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

Sandbox sandbox = switch (props.getMode()) { case DOCKER -> DockerSandbox.builder().image(props.getImage()).build(); case LOCAL -> LocalSandbox.builder().tempDirectory("chat-sandbox-").build(); };

平时开发用 LOCAL 起得快,生产或跑不可信 skill 就切 DOCKER。沙箱按请求级 try-with-resources 管,每次/chat或/chat/stream开一个、结束关一个,别在进程里复用。

4. 验证请求:从对话到工具调用

配置搭好后,先验证模型通道通不通。用模型对话入口 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=chat&utm_campaign=rewrite 发一条消息,确认 Key 和 base-url 没问题。

然后在 Spring Boot 里发一个带 Skills 和 Mcp 的请求:

curl -N -X POST http://localhost:8080/chat/stream \ -H "Content-Type: application/json" \ -d '{ "userId": 1001, "assistantId": 7, "sessionId": "s-xxx", "query": "把附件 csv 画成折线图", "skills": [ {"name": "chart-maker", "url": "https://cdn.example.com/skills/chart-maker-0.3.zip"} ], "mcpConfig": { "github": { "url": "https://mcp.example.com/github/mcp", "headers": {"Authorization": "Bearer ghp_xxx"} } } }'

预期看到的事件序列类似:

event: reasoning {"text": "让我先查一下..."} event: tool_call [{"id": "c1", "name": "Bash", "args": "ls skills/"}] event: tool_result [{"id": "c1", "name": "Bash", "result": "chart-maker"}] event: token {"text": "找到了 chart-maker,我用它来画图..."}

tool_call和tool_result这两个事件需要额外处理。Spring AI 的ToolCallAdvisor打开streamToolCallResponses(true)后,含 toolCalls 的中间响应会透传,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); if (!refs.isEmpty()) sink.tryEmitNext(ChatEvent.toolResult(refs)); } catch (RuntimeException e) { log.warn("emit tool_result failed: {}", e.getMessage()); } return result; } }

装配时把它喂给ToolCallAdvisor,再把 sink 跟主流 Flux 合一下:

ChatClient.create(chatModel).prompt().user(req.query()) .advisors(ToolCallAdvisor.builder() .toolCallingManager(observable) .streamToolCallResponses(true) .build()) .stream().chatResponse() .mergeWith(toolEventSink.asFlux()) .map(this::toEvent);

5. 本篇常见错排查

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

Mcp 鉴权 401。鉴权要走 headers 字段,配合httpRequestCustomizer注入,每次 POST 都带上,否则 server 端直接 401。

Mcp 连接泄漏。MCP 走 HTTP 长流或 stdio 子进程,忘了 close 会泄漏连接和进程。McpSession实现AutoCloseable,同步接口用 try-with-resources,流式接口在doFinally里关。

沙箱环境变量丢失。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 掉,不要留孤儿容器或临时目录。

消息顺序错乱。前面提过,MessageWindowChatMemory内部HashSet会打乱顺序,换成LinkedHashSet并把窗口化挪到读取侧。

模型路由不生效。写一个ModelRouter按 modelName 前缀路由到不同 Bean,/chat/stream入口拿请求里的 modelName 解析一下就行。每个 provider 自己的@Bean配置照常写,路由这层只是个 switch。

6. 继续往下走

四个模块跑通之后,下一步是把任务调度接进来。用 JobRunr 做长期任务:模型用工具调用TaskTool创建/调度任务,TaskManager落库并往JobScheduler塞一条,到点了 JobRunr 反序列化 lambda、回调TaskHandler.executeTask(taskId)执行。@Job(retries = 3)一行就能让失败自动重试三次。注意taskId而不是整个 Task 对象作为参数,JobRunr 要把 lambda 序列化进存储,参数得是简单可序列化的值。

如果你要长期跑编码类 Agent,Coding Plan 那条通道更适合: https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding-plan&utm_campaign=rewrite 。接入过程中遇到 Mcp 或沙箱的问题,先翻接入文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,再对着 API Keys 页面确认通道状态 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api-keys&utm_campaign=rewrite 。控制台在 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,可以看调用记录和用量。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询