Java + Spring 实现 Hermes Agent:从源码看多模型接入、子代理、人审与沙箱
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": "..." }] // useServerMemory=false 时带 }响应:一条 SSE 流,每种事件一个type
响应是text/event-stream,每条data:是一个ChatEvent(@JsonInclude(NON_NULL),空字段不序列化)。核心是type+ 几个载荷字段,按事件类型取用:
publicrecordChatEvent(Stringtype,// 事件类型Stringdata,// token/reasoning 的文本增量,或 approval 的 requestIdStringname,// 子代理事件时 = 子代理名(前端按它分组渲染)Stringreason,// finish 的结束原因Map<String,Object>usage,// finish 时可选携带 token 用量List<ToolCallRef>toolCalls,// tool_call / approval_request 的被调工具List<ToolResultRef>toolResults){}// tool_result 的返回几个关键事件类型的格式约定:
| type | 载荷在哪 | 说明 |
|---|---|---|
token | data | 正文文本增量(流式回答) |
reasoning | data | 思考增量(DeepSeekreasoning_content/ Anthropic thinking) |
tool_call | toolCalls[] | 模型决定调哪些工具(id / name / arguments) |
tool_result | toolResults[] | 工具执行完的返回 |
approval_request | data=requestId,toolCalls单元素 | HITL 待审批,requestId 原样回填/ai-api/chat/approval |
finish/error | reason/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/ChatResponse:
publicclassMyModelChatModelimplementsChatModel,StreamingChatModel{@OverridepublicChatResponsecall(Promptprompt){/* 调 /call,翻译响应 */}@OverridepublicFlux<ChatResponse>stream(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.enabled=true 开启)接进ModelRouter之后,ChatClient那一整套——advisor、工具调用、记忆、HITL、SSE 事件、子代理——全部自动复用。这就是 Spring AI 抽象的价值:模型这一层可插拔,换模型不动上层。
三、子代理:委派、沙箱复用与"不嵌套"
复杂任务让主 agent 一把梭,上下文很快就爆。子代理的思路是把多步任务委派给一个有独立上下文窗口的子代理去跑,主对话只看最终结果。
这套"主 agent 派活、子代理各自领任务去跑"的模式现在很火。像腾讯的 WorkBuddy 这类产品,本质也是类似思路——把一个任务拆给多个"数字员工"(子智能体)并行去做,你一次性呼叫多个"员工"帮你把事情办完。我们这里的subagents+Task工具就是同一类实现:主 agent 是调度方,子代理是干活的。
子代理用 Claude 风格的.md文件定义(frontmatter 写 name/description/tools/disallowedTools/skills + 正文 system prompt),请求里给 url,AgentCacheService下载缓存后由ChatService.buildTaskTool装配成Task+TaskOutput一对工具。几个实现要点:
沙箱复用:子代理复用主 agent 的沙箱工具(同一个Sandbox实例),但不嵌套——子代理拿不到TaskTool,层级扁平,不能再 spawn 子代理。
Claude 内置子代理的取舍:includeClaudeBuiltinSubagents=false时不用库里的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这种能改文件、跑命令的,不能模型说跑就跑。
怎么实现:装饰 ToolCallingManager
HITL 的拦截点选在模型决定调工具、但还没真正执行的那一刻——ToolCallingManager.executeToolCalls。做法是实现一个ToolCallingManager装饰器包在官方实现外面:主 agent 用ObservableToolCallingManager,子代理用SubagentToolCallingManager,两者都是薄壳,真正的逻辑在共享的HitlToolCallingGate里。
装饰器本身只做一件事——把调用转发给 gate,自己持有 delegate:
classObservableToolCallingManagerimplementsToolCallingManager{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 + decision),ApprovalRegistry.complete把对应 future 唤醒,工具循环继续。
fail-safe:任何非 APPROVE 路径都不放行
这是整个 gate 的底线。awaitDecision把每一种异常都归到 DECLINE:
privateDecisionawaitDecision(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,把它作为一条finishReason=toolCallLimitExceeded的正常 chunk 下发并停循环——同步和流式都不进 error channel,SSE 流不会被破坏。
privatestaticToolCallingManagerwithToolCallLimit(ToolCallingManagerm,Integermax){if(max==null||max<=0)returnm;// 不收紧,走官方默认 40/150varlimited=ToolCallingManager.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前缀选 provider(claude*→Anthropic、deepseek-chat/reasoner→DeepSeek、qwen/glm/...→自定义模型,其余→OpenAI),ChatClient.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,对着源码读比看文章更直接。
