
Java Spring 实现 Hermes Agent从源码看多模型接入、子代理、人审与沙箱接上篇《Java Spring 实现 Hermes Agent 之龙虾、Skills、MCP 和沙箱代码执行环境思路》。上篇偏怎么搭起来这篇将完整实现并挑几个写起来最费劲、也最容易踩坑的模块讲它们内部到底怎么实现的。在上篇文章发布后有些朋友联系寻求源码参考其实上篇博客已经很详细了。今天总是把源码放出来了而且还升级了依赖版本Spring Boot 4.1 Spring AI 2.0.1。今天文章的所有代码路径都能在仓库里对上号建议对着hermes-agent-demo源码读有些注释是vibe coding不一定对。一、Agent应用的请求和响应长什么样一个 Agent 后端要先立住的是对外的协议——请求带什么、SSE 流里每种事件是什么格式。这个定清楚了后面所有功能都是往这套协议上挂。一个接口搞定模型切换、思考开关、mcp、skills、子智能体请求POST /ai-api/chat/stream一条流式对话请求大致是这些字段ChatRequest{ query: 把附件 csv 画成折线图, // 必填 modelName: deepseek-reasoner, // 路由到 DeepSeek provider thinking: enabled, // 深度思考开关 useServerMemory: true, // 服务端记忆 sessionId: s-xxx, userId: 1001, assistantId: 7, system: 你是..., // 用户人设 tools: [TodoWrite, WebSearch], // 内置工具白名单 skills: [{ name: ..., url: https://.../skill.zip }], subagents: [{ name: ..., url: https://.../agent.md }], mcpConfig: { github: { url: ..., headers: {...} } }, externalTools: [{ platform: dify, name: ..., inputSchema: {...} }], toolContext: { apiKey: sk-xxx }, // 机密参数不进对话历史 maxToolIterations: 25, bypassApproval: false, history: [{ role: 1, content: ... }] // useServerMemoryfalse 时带 }响应一条 SSE 流每种事件一个type响应是text/event-stream每条data:是一个ChatEventJsonInclude(NON_NULL)空字段不序列化。核心是type 几个载荷字段按事件类型取用publicrecordChatEvent(Stringtype,// 事件类型Stringdata,// token/reasoning 的文本增量或 approval 的 requestIdStringname,// 子代理事件时 子代理名前端按它分组渲染Stringreason,// finish 的结束原因MapString,Objectusage,// finish 时可选携带 token 用量ListToolCallReftoolCalls,// tool_call / approval_request 的被调工具ListToolResultReftoolResults){}// tool_result 的返回几个关键事件类型的格式约定type载荷在哪说明tokendata正文文本增量流式回答reasoningdata思考增量DeepSeekreasoning_content/ Anthropic thinkingtool_calltoolCalls[]模型决定调哪些工具id / name / argumentstool_resulttoolResults[]工具执行完的返回approval_requestdatarequestIdtoolCalls单元素HITL 待审批requestId 原样回填/ai-api/chat/approvalfinish/errorreason/usage/data结束 / 异常heartbeat无保活帧前端直接忽略subagent_*同上 name子代理名子代理的执行过程subagent_token/subagent_tool_call/subagent_approval_request…与主 agent 共用同一 SSE 流和同一审批回填通道这套格式是前端渲染的依据token/reasoning拼文本tool_call/tool_result渲染工具卡片approval_request弹出审批按钮subagent_*按name折叠成子代理区块。为什么工具事件不走流式 chunk 而要旁路Spring AI 2.0 GA 删掉了streamToolCallResponses并在流式路径上硬过滤掉所有hasToolCalls()的 chunk——下游 Flux 拿不到工具调用帧。所以tool_call/tool_result只能在工具真正执行的那一刻由工具管理层旁路 emit见 HITL 那节。二、自定义模型接入Spring AI 没 starter就自己实现两个接口有些模型服务商 Spring AI 没有现成 starter私有协议、内部网关、自研推理服务。与其等官方不如自己接。com.example.chat.mymodel就是一个完整范例。Spring AI 的接入点比想象中薄——只要把私有 HTTP 协议翻译成Prompt/ChatResponsepublicclassMyModelChatModelimplementsChatModel,StreamingChatModel{OverridepublicChatResponsecall(Promptprompt){/* 调 /call翻译响应 */}OverridepublicFluxChatResponsestream(Promptprompt){/* 调 /callStream转 Flux */}}配套分层MyModelChatModel implements ChatModel, StreamingChatModel // 适配层call() stream() MyModelApi 私有协议 HTTP 客户端RestClient 走 /call、WebClient 走 /callStream MyModelChatOptions 自定义选项model/temperature/thinking 透传/userId/assistantId MyModelProperties MyModelConfig 配置 Bean 装配my-model.enabledtrue 开启接进ModelRouter之后ChatClient那一整套——advisor、工具调用、记忆、HITL、SSE 事件、子代理——全部自动复用。这就是 Spring AI 抽象的价值模型这一层可插拔换模型不动上层。三、子代理委派、沙箱复用与不嵌套复杂任务让主 agent 一把梭上下文很快就爆。子代理的思路是把多步任务委派给一个有独立上下文窗口的子代理去跑主对话只看最终结果。这套主 agent 派活、子代理各自领任务去跑的模式现在很火。像腾讯的 WorkBuddy 这类产品本质也是类似思路——把一个任务拆给多个数字员工子智能体并行去做你一次性呼叫多个员工帮你把事情办完。我们这里的subagentsTask工具就是同一类实现主 agent 是调度方子代理是干活的。子代理用 Claude 风格的.md文件定义frontmatter 写 name/description/tools/disallowedTools/skills 正文 system prompt请求里给 urlAgentCacheService下载缓存后由ChatService.buildTaskTool装配成TaskTaskOutput一对工具。几个实现要点沙箱复用子代理复用主 agent 的沙箱工具同一个Sandbox实例但不嵌套——子代理拿不到TaskTool层级扁平不能再 spawn 子代理。Claude 内置子代理的取舍includeClaudeBuiltinSubagentsfalse时不用库里的TaskTool.Builder.build()它会无条件追加 4 个内置而是 fork 一份组装逻辑只放用户声明的。否则模型在工具描述里看到一堆没配的内置子代理会去硬调然后撞 “No subagent found”。纯文本子代理用户子代理单独存在无 skills、无 Claude 模式时不建沙箱。这种子代理没有文件工具会在它的 system prompt 末尾追加一句提示——明确告诉它你没有 Bash/Read/Write直接文本作答防止它幻觉去调没注册的工具。事件旁路子代理执行过程以subagent_token/subagent_tool_call/subagent_tool_result推到主 SSE 流name标来源子代理前端按子代理分组渲染格式见第一节。四、Human-in-the-Loop扩展 ToolCallingManager 插一个审批 gate工具能力越强越要有一道人的闸门。Write/Edit/Bash这种能改文件、跑命令的不能模型说跑就跑。怎么实现装饰 ToolCallingManagerHITL 的拦截点选在模型决定调工具、但还没真正执行的那一刻——ToolCallingManager.executeToolCalls。做法是实现一个ToolCallingManager装饰器包在官方实现外面主 agent 用ObservableToolCallingManager子代理用SubagentToolCallingManager两者都是薄壳真正的逻辑在共享的HitlToolCallingGate里。装饰器本身只做一件事——把调用转发给 gate自己持有 delegateclassObservableToolCallingManagerimplementsToolCallingManager{privatefinalToolCallingManagerdelegate;privatefinalHitlToolCallingGategate;OverridepublicToolExecutionResultexecuteToolCalls(Promptprompt,ChatResponsechatResponse){returngate.executeToolCalls(prompt,chatResponse,delegate);// 真正逻辑在 gate}}gate 里干的事HitlToolCallingGate.executeToolCalls模型决定调工具executeToolCalls 被调用 ├─ 旁路 emit tool_call 事件前端先看到要调什么 ├─ 逐个检查工具名是否命中白名单 required-tools │ ├─ 命中 → approvals.register(timeout) 拿 (requestId, future) │ │ emit approval_request 事件 立即补一条 heartbeat │ │ future.get() 阻塞等回填 │ └─ 未命中 → 直接进 approved 集合 ├─ approved 子集交给 delegate 真正执行declined 的合成已拒 ToolResponse └─ 按原始 tool_calls 顺序合并结果emit tool_result 事件前端把approval_request渲染成同意/拒绝按钮点了调POST /ai-api/chat/approval带 requestId decisionApprovalRegistry.complete把对应 future 唤醒工具循环继续。fail-safe任何非 APPROVE 路径都不放行这是整个 gate 的底线。awaitDecision把每一种异常都归到 DECLINEprivateDecisionawaitDecision(Pendingp){try{returnp.future().get();}catch(InterruptedExceptione){Thread.currentThread().interrupt();// 复位中断标志returnDecision.DECLINE;}catch(ExecutionExceptione){returnDecision.DECLINE;// orTimeout 到期}catch(RuntimeExceptione){returnDecision.DECLINE;}}超时靠CompletableFuture.orTimeout兜底不需要前端主动发 cancel前端断连、用户关页面、后台重启最后都落到 DECLINE。构造时 sink/policy/approvals 任一为 null 直接 NPE——宁可启动失败也不让半装配的 gate 进生产悄悄放行。两个容易踩的细节被拒工具的顺序被拒的合成ToolResponse必须按原始 tool_calls 顺序合并回去。DeepSeek/OpenAI 对tool_calls和tool消息的一一对应有严格校验顺序错了下一轮直接 400。SSE 保活心跳审批最长可能阻塞几分钟这期间 SSE 流上没有数据。nginx默认proxy_buffering on/Cloudflare/云 LB 会把刚发的approval_request帧 hold 在缓冲里不下发——前端永远收不到、按钮渲染不出死锁。所以 gate 在 emit 审批请求后立刻补一条 heartbeat另由/chat/stream管线周期性推心跳把缓冲撑满触发 flush控制器里同时加X-Accel-Buffering: no。这套是自定义的官方可能会封装Spring AI 2.0.1 还没有官方 HITL 抽象这套装饰ToolCallingManager插审批 gate是我们自己实现的。社区在往工具执行审批内置的方向演进等官方出了标准封装会把这套自定义 gate 迁过去——届时审批发起、回填、fail-safe 由框架统一提供业务只配白名单。现在这版可以先当官方落地前的参考。bypassApproval是请求级绕行开关true则本次所有需审批工具直接放行不发事件、不注册 future、不等回填。它是跳过审批流程而不是自动点同意。生产上这个字段别透传给终端用户由网关/BFF 按调用来源决定每个被绕行的工具都会落hitl.bypassed审计日志。五、工具迭代上限超限要优雅收尾不是打断模型进了调工具→看结果→再调工具的循环卡住就是无底洞。maxToolIterations限的是单轮内工具调用的总次数不是模型轮数。关键是超限的处理方式。2.0.1 起走官方DefaultToolCallingManager的ToolCallLimits超限后ToolCallingAdvisor捕获ToolCallLimitExceededException把它作为一条finishReasontoolCallLimitExceeded的正常 chunk 下发并停循环——同步和流式都不进 error channelSSE 流不会被破坏。privatestaticToolCallingManagerwithToolCallLimit(ToolCallingManagerm,Integermax){if(maxnull||max0)returnm;// 不收紧走官方默认 40/150varlimitedToolCallingManager.builder().maxTotalToolCalls(max);if(minstanceofObservableToolCallingManagerobs)returnobs.withDelegate(limited.build());if(minstanceofSubagentToolCallingManagersub)returnsub.withDelegate(limited.build());returnlimited.build();}注意这里因为 manager 是装饰器HITL 那层限额得通过withDelegate重建——委托的官方 manager 在构造时就建好了没法事后注入所以只能换个带限额的 delegate 再包一层。六、路由与思考动态切换模型 思考开关这块用 Spring AI 现成能力就够简单说两句。动态切换模型ModelRouter.providerOf按modelName前缀选 providerclaude*→Anthropic、deepseek-chat/reasoner→DeepSeek、qwen/glm/...→自定义模型其余→OpenAIChatClient.create(modelRouter.resolve(modelName))每请求重建。前端切模型 改请求体一个字段服务端不重启。思考开关thinking字段统一三态enabled/disabled/省略在buildOptionsBuilder里按 provider 映射到各家原生参数——Anthropic 给thinkingEnabled(budget)DeepSeek 给thinking(ENABLED)OpenAI 给reasoningEffort。DeepSeek 的思考内容走独立的reasoning_content字段由ReasoningExtractor统一抽取成reasoning事件。modelName一律透传给上游不被思考开关覆盖。七、沙箱会话复用与一把锁的并发模型沙箱本身上篇讲过Tool agent-sandbox这篇只补后来加的会话级复用和它的并发设计——SandboxSessionManager是写的时候最费脑子的一块。背景per-request 每次重建容器、重新 pip install体验很差。所以按(userId, assistantId, sessionId)复用 Docker 容器同一对话多轮请求共享一个沙箱。八、工具上下文机密参数走旁路不进对话历史有些值API key、租户 ID工具执行要用但绝不能进模型对话历史或工具的 JSON Schema——那会泄露给模型、被记进日志、跨轮被带出去。toolContext就是干这个的两条注入通道各取所需内置工具以ToolContext参数形式对Tool方法可见不进 JSON Schema沙箱脚本转成环境变量key 自动大写、非法字符转下划线Python 里os.environ[API_KEY]直接读。九、一个完整演示贪吃蛇小游戏最后用 Code Interpreter 把前面这些串一遍。让模型写一个贪吃蛇网页小游戏模型生成 HTML/JS →Write写进沙箱命中 HITL 白名单就弹审批→Bash起个静态服务跑起来 →ExportArtifact把产物导出成可预览/下载的 artifact。全程事件流式回显每一步前端都看得见。ExportArtifact导出的文件能直接预览HTML/图片/视频这类 inline 渲染不用下载下载链接由DownloadController按 MIME 类型决定 inline 还是 attachment。写在最后这篇没有总结清单。如果一定要说一条主线那就是Agent 后端真正费劲的地方不在调通模型而在那些模型之外的工程边界——协议怎么定、模型这种各家都不一样的能力怎么抽象、工具循环怎么兜底、人的闸门插在哪、并发和复用怎么做。这些在ChatEvent、HitlToolCallingGate、SandboxSessionManager、MyModelChatModel这几个类里都能看到具体写法。代码在 hermes-agent-demo对着源码读比看文章更直接。