
1. 整体设计思路与方案选型为什么编排层需要一门自研DSL做Java后端的人应该都有体会这两年AI应用接入的姿势越来越多了但真正把Multi-Agent协作落到生产环境路径还是比较曲折。Spring AI把模型对话、Function Calling这类基础能力封装得比较温和MCP协议又把外部工具接入标准化了但Agent之间的编排和工具层的组织还是要靠我们自己去设计。这篇文章分享一下我用Spring AI 自研DSL搭建工具层的完整过程包括方案选型、DSL语法设计、Function Calling与MCP的集成细节以及生产环境里踩过的那些坑。1.1 Multi-Agent协作里的工具编排到底难在哪先说结论多Agent协作本身不是难点难的是控制每个Agent“能干什么、能用什么工具、怎么把结果传给下一个环节”。如果不做任何约束让Agent完全自由地决定工具调用顺序遇到复杂任务时很容易陷入死循环或者输出来回摇摆。我之前用纯工程代码写过一个多Agent调度器思路很直接定义一个Orchestrator服务里面塞了一个状态机按预设流程依次调用不同的Agent。问题是Agent的输入输出都是自然语言状态机里全是if/else判断硬编码的痕迹非常重。每加一个Agent或者调整一次协作链路都要改调度器代码改完还要重新部署。扩展性很差而且这种“编排逻辑”和“业务含义”混在一起过两个月再看基本上就得靠猜。后来我换了一个思路把“Agent之间怎么协作”、“Agent能用哪些工具”、“工具结果怎么汇聚”这些描述性的东西和具体执行逻辑分离开。这就是我设计DSL的初衷。DSL的全称是领域特定语言说白了就是为某个业务场景量身定制的“小语言”。这里我用它来描述多Agent协作的任务流让编排变得像写配置文件一样清晰而不是像写业务代码一样绕。1.2 为什么选Spring AI做底座而不是裸调模型API很多Java开发者最初接触大模型都是从OpenAI SDK或者HttpClient封装开始的。我的体会是如果只是做一个聊天Demo裸调API完全够用甚至代码更少。但一旦涉及到Function Calling和动态工具路由裸调API的维护成本会迅速上升。Spring AI在这个场景下的价值有三个第一它把Function Calling做成了声明式注解。你只需要在Bean方法上标注Tool框架会自动把方法描述、参数Schema暴露给模型模型请求时会自动带上工具定义响应中的工具调用参数由框架帮你解析好然后映射到Java方法入参。这一步省掉了大量手写JSON Schema和参数解析的重复劳动。第二它内置了ChatClient和Agent交互的简化模型。消息历史管理、AssistantMessage与ToolResponseMessage的切换、上下文窗口截断策略这些细节框架都帮你处理了。我们不用关心每个消息角色该怎么构造只需要告诉ChatClient“这个Agent配了哪些工具”。第三Spring AI对MCP协议的支持是原生级别的。它提供了基于MCPModel Context Protocol的客户端启动器和工具注册机制可以把任意MCP Server暴露的工具直接变成Spring Bean。这意味着工具层可以不再局限于本地硬编码函数而是可以通过标准协议接入远程能力。当然Spring AI也不是银弹。它的多Agent编排能力其实很弱基本没有提供类似LangGraph那种状态图机制。所以我们的策略是模型对话和Function Calling用Spring AI协作编排用自研DSL两者配合而不是互相替代。1.3 自研DSL的定位只做编排胶水不做通用语言设计DSL之前我给自己定了一条原则DSL永远不碰重业务逻辑它只描述“谁先做、谁后做、用哪个工具”。为什么会有这个原则因为AI领域的变化太快了如果你把DSL设计得太重量级比如加入循环、条件分支、变量赋值甚至自定义函数那它实际上就变成了另一门编程语言。到时候既要维护DSL语法解析器又要兼容模型返回格式的变化工作量会爆炸。所以我最终设计的DSL只包含以下几个核心概念agent声明一个Agent实例配置它绑定的模型、工具列表、角色提示词。route定义协作路由说明消息从哪个Agent发出后下一个由谁处理。tool声明一个工具引用可以是本地函数也可以是MCP工具。fallback兜底策略当Agent连续执行达到上限或工具调用失败时走预设的降级路径。这个DSL的执行方式是解释执行。我用一个解析器把DSL文本转成内存中的协作图Graph然后由一个轻量级执行器逐节点推进。模型只负责“对话和工具选择”DSL执行器只负责“流程控制和工具分发”各干各的。DSL文本长什么样后面我会给一个完整示例。2. 核心机制拆解DSL语法、Function Calling与MCP集成方式2.1 DSL语法设计从Agent声明到协作路由先放一个DSL示例你们直观感受一下。这是我压在内部项目里的一个描述文件模拟了一个“售后工单自动处理”场景涉及三个Agentflow: order-refund-task description: 自动拆解售后工单并处理退款申请 agent: - name: coordinator model: qwen-max system: | 你是售后工单协调员负责解析用户需求 判断是否需要退款处理并把结构化结果交给下个Agent。 tools: - order.query - order.validate - name: refund_executor model: qwen-max system: | 你是退款执行员负责根据工单信息发起退款。 只能使用退款工具禁止自行修改工单状态。 tools: - payment.refund - notify.user - name: inspector model: qwen-max system: | 你是审核员检查退款结果是否满足工单诉求 输出最终审核结论。 tools: - order.finish route: - from: coordinator to: refund_executor condition: coordinator.result.status need_refund - from: refund_executor to: inspector condition: refund_executor.result.operation refund_done fallback: - when: agent_error action: notify.human这里用YAML承载DSL好处是解析成本低、可读性强团队新人一看就懂。route部分用condition定义了流转条件条件是针对前一个Agent返回值中某个字段的布尔判断。fallback表示当某个Agent执行出错时通知人工介入。为什么不用PythonX或JSON作为DSL格式因为YAML更容易写注释语法干扰少。JSON在复杂嵌套时括号太多人眼阅读效率低。当然这基于个人偏好你们如果团队习惯JSON完全可以替换。2.2 Function Calling在Spring AI中的正确打开方式Spring AI的Function Calling是构建工具层的核心。我这里的做法是凡是在DSL里声明为tool的本地函数统一以Tool注解方式暴露。看一下我真实项目里的一个工具类Component public class OrderTools { Tool(description 查询订单基本信息根据订单号返回订单状态和金额) public OrderInfo queryOrder(ToolParam(description 订单号) String orderId) { return orderService.query(orderId); } Tool(description 校验订单是否满足退款条件返回校验码) public ValidationResult validateOrder(ToolParam(description 订单号) String orderId) { return orderService.validate(orderId); } Tool(description 发起退款返回退款单号) public RefundResult refund(ToolParam(description 订单号) String orderId, ToolParam(description 退款金额) BigDecimal amount) { return refundService.apply(orderId, amount); } }使用Tool注解后Spring AI会在模型对话时自动把这些方法的能力描述加进请求。注意工具描述写得越清晰模型正确选择和填参的概率越高。ToolParam里的description也别省它对参数Schema的生成影响很大。我之前踩过一个坑有一个工具方法叫doProcess()描述写成“处理订单”结果模型每次遇到模糊请求时都先调它经常传错参数。后来改成processRefundEligibilityCheck加完整描述后准确率提升明显。工具命名规范和描述语义化比调模型参数还管用。2.3 MCP协议接入方案预构建客户端与工具桥接MCPModel Context Protocol是这两年AI应用集成里非常重要的一个协议它解决的是“工具接入标准化”的问题。以前第三方平台要暴露能力给AI要么提供一个HTTP API要么输出一份OpenAPI文档模型用起来还得自己做适配。MCP定义了一套类似于“工具市场”的协议Claude、Cursor这些已经深度支持Spring AI也做了MCP Client适配。Spring AI接入MCP主要有两条路径路径一使用预构建的MCP客户端StarterSpring AI提供了spring-ai-starter-model-mcp这类依赖依赖管理会自动创建MCP Client连接你配置的MCP Server地址。你配置好后MCP Server暴露的所有工具会自动注册为可调用工具。我在本地跑了一个MCP Server作为实验暴露了一个“计算物流运费”的工具配置如下spring: ai: mcp: client: enabled: true name: logistics-mcp type: SSE connection: url: http://localhost:8090/mcp然后把这些MCP工具注入到ChatClient里Configuration public class McpToolConfig { private final McpToolUtils mcpToolUtils; public McpToolConfig(McpToolUtils mcpToolUtils) { this.mcpToolUtils mcpToolUtils; } Bean public ChatClient chatClient(ChatClient.Builder builder) { ToolCallback[] mcpTools mcpToolUtils.getMcpToolCallbacks(); return builder.defaultTools(mcpTools).build(); } }路径二本地工具桥接MCP实际项目中很多工具还是本地已有的服务不可能全改造成MCP Server。我的方案是在DSL工具层里做了一层桥接本地Tool函数用Spring AI原生机制注册远程MCP工具通过MCP工具回调注册两种方式统一到同一个工具注册表里DSL只管“逻辑工具名”执行器负责找到对应的实际调用实现。这里有个细节要注意MCP工具回调返回的结果一般是一个CallToolResult对象里面嵌套的内容结构并不是单纯的字符串。我们在桥接层需要把MCP的结构解析成统一的ToolExecutionResult这样后续DSL路由判断逻辑不用关心这个工具到底是本地还是远程的。3. 实操环节实现从依赖配置到端到端协作链路跑通3.1 项目骨架与依赖配置项目我用的标准Spring Boot 3.2 Java 17Maven管理。核心依赖如下dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-core/artifactId version1.0.0-M3/version /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-openai/artifactId version1.0.0-M3/version /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-starter-model-mcp/artifactId version1.0.0-M3/version /dependency dependency groupIdorg.springframework.ai/groupId artifactIdspring-ai-mcp-client/artifactId version1.0.0-M3/version /dependency dependency groupIdorg.yaml/groupId artifactIdsnakeyaml/artifactId version2.2/version /dependency这里顺便说一句版本坑Spring AI的版本更新很频繁M1、M2、M3之间API变动大。如果你们依赖了spring-ai-starter-model-openai注意它会在本地自动下载模型相关依赖网络不好的时候容易中途失败。建议配置阿里云镜像仓库能省很多时间。3.2 DSL解析引擎实现DSL解析器我分成两层YAML映射层 语义校验层。YAML映射层把YAML文本映射成WorkflowDefinition对象语义校验层检查Agent引用、工具引用、路由条件是否合法。先看映射层的核心结构public class WorkflowDefinition { private String flow; private ListAgentNode agents; private ListRouteRule routes; private ListFallbackRule fallbacks; } public class AgentNode { private String name; private String model; private String system; private ListString tools; } public class RouteRule { private String from; private String to; private String condition; }用SnakeYAML直接加载几乎零成本。语义校验层主要检查两点from引用的Agent是否在agents列表里路由条件里的字段名是否能对应到Agent返回的JSON结构里这两个校验都能在启动时做如果DSL写错了启动直接报错而不是运行时才炸。这一步是我强烈建议加上的生产环境里节省了很多排障时间。执行器的核心逻辑如下public class WorkflowExecutor { public WorkflowResult execute(WorkflowDefinition definition, String userInput) { AgentNode current definition.agents.get(0); MapString, Object memory new HashMap(); int round 0; int maxRounds 10; while (round maxRounds) { AgentResult agentResult chatWithAgent(current, userInput, memory); memory.put(current.getName() .result, agentResult.getData()); RouteRule rule matchRoute(definition.routes, current.getName(), agentResult.getData()); if (rule null) { return new WorkflowResult(true, agentResult, memory); } AgentNode next findAgent(definition.agents, rule.getTo()); if (next null) { throw new WorkflowException(route target not found: rule.getTo()); } current next; round; } return new WorkflowResult(false, new AgentResult(max_round_exceeded), memory); } }这个执行器非常简单没有引入复杂的状态机框架。原因在于我只需要顺序路由和条件路由状态机反而让语义更绕。如果你之后要做并行Agent调用或者子流程嵌套再考虑接入更多编排能力也不迟。3.3 工具层组装标准函数、MCP工具与Agent绑定工具注册表的设计是我觉得最有参考价值的一部分。具体做法Component public class ToolRegistry { private final MapString, ToolCallback toolCallbacks new ConcurrentHashMap(); PostConstruct public void init() { // 注册本地 Tool 注解方法 ToolCallback orderQuery ToolCallbacks.from(orderTools).stream() .filter(c - c.getToolDefinition().name().equals(queryOrder)) .findFirst().orElseThrow(); toolCallbacks.put(order.query, orderQuery); // 注册MCP工具回调 McpToolCallback mcpToolCallback ...; // 从MCP Client取 toolCallbacks.put(logistics.fee, mcpToolCallback); } public ToolCallback get(String logicalName) { return toolCallbacks.get(logicalName); } }这个逻辑名和实际回调的映射解决了DSL里tools列表和Spring AI ToolCallbacks之间对不上的问题。DSL里写的order.query是一个逻辑别名实际可以映射到本地函数或MCP工具。这样以后接新工具时只需要在注册表加一行映射DSL不用改。Agent绑定工具时我构造的是独立的ChatClient实例每个Agent有自己的模型和工具列表ChatClient coordinatorClient ChatClient.builder(chatModel) .defaultSystem(agentNode.getSystem()) .defaultTools(definitionToolNames.stream() .map(toolRegistry::get) .toArray(ToolCallback[]::new)) .build();一个Agent一个ChatClient实例的做法能避免工具列表互相污染。如果你图省事共享一个ChatClient模型很容易在上下文切换时选错工具。3.4 一条端到端的Multi-Agent协作链路我拿一个“物流订单售后处理”流程走一遍整条链路如下第一步用户输入“我买的东西还没发货我要退款”。第二步coordinator Agent解析这句话构造了一个内部JSON包含订单号提取结果和意图判断。它把原始用户输入和当前上下文一起发送给模型模型会决定调用order.query和order.validate这两个工具。执行器拿到工具结果后把它塞回消息历史再让模型产出最终的结构化路由结果。第三步执行器解析coordinator的返回结果检查result.status need_refund命中路由规则把下一步指向refund_executor。第四步refund_executor Agent收到coordinator传递的结构化上下文后调用payment.refund工具发起退款并调用notify.user给用户发通知。这一步实际是后台异步操作Agent只是发起了指令。第五步inspector Agent根据refund_executor的结果做最终审核然后调用order.finish结单。我从实际运行日志里摘一段简化后的过程[coordinator] call tool: order.query(orderIdA20240421001) [coordinator] call tool: order.validate(orderIdA20240421001) [coordinator] route: need_refund - refund_executor [refund_executor] call tool: payment.refund(orderIdA20240421001, amount199.00) [refund_executor] call tool: notify.user(orderIdA20240421001, typerefund_started) [refund_executor] route: refund_done - inspector [inspector] call tool: order.finish(orderIdA20240421001) [inspector] final: refund processed and order closed整个过程如果手动用代码写编排三四个Agent加上各种分支判断代码量至少在500行往上。DSL只需要几十行描述和一个通用执行器。4. 踩坑实录Multi-Agent工具层的常见问题与排查4.1 Agent循环执行为什么会把上下文跑炸最开始做DSL执行器的时候我没有限制最大轮数结果遇到一个递归型Agent任务模型不停地在两个Agent之间来回切换消息历史越积越长最终导致token超限报错。排查后发现两个问题根源第一路由条件写得过于宽松。condition判断字段时如果字段不存在我默认返回true结果导致无论Agent最终输出什么都命中流转规则。第二执行器里没有兜底的maxRounds控制。修复方案分两方面路由条件判断时字段不存在默认走false并且把字段路径打印到日志。执行器增加maxRounds和maxTokenCount双重限制。maxRounds控制协作轮数maxTokenCount控制累计上下文大小。我还加了“循环检测”逻辑如果同一对from - to连续出现3次以上直接中断流程并触发fallback。这个在排查Agent“绕圈”时特别有效。4.2 MCP工具偶发超时如何设置合理的超时策略MCP工具通过SSE传输时WebClient的默认超时时间较长但实际工具调用很依赖外部服务的响应速度。有一次我在MCP Server端跑了一个比较重的查询工具耗时3秒多前端一直转圈模型那边等不到工具返回直接把调用请求重发了一遍导致重复操作。排查时发现两个问题Spring AI的MCP客户端会话建立和工具调用共用一个连接长时间占用SSE连接时连接池不够用。工具调用的响应没有设置业务级超时默认等完整响应。我的调整方式spring: ai: mcp: client: connection: sse: connect-timeout: 3s read-timeout: 15s write-timeout: 5s同时在执行器工具调用外层加了一个超时装饰器ToolExecutionResult result CompletableFuture.supplyAsync(() - toolCallback.call(toolInput)) .orTimeout(10, TimeUnit.SECONDS) .exceptionally(ex - ToolExecutionResult.timeout());工具超时后的结果会作为一条system消息告知模型“工具调用超时”模型可以在下一轮中选择重试或者换一条路径。这种机制比直接抛异常要温和业务容错性好很多。4.3 工具命名冲突与入参序列化问题本地函数和MCP工具混用后出现了一个隐蔽的bug本地有一个getUserInfo方法MCP Server那边也有一个getUserInfo方法两个工具注册到ChatClient时都变成了ToolCallback模型在选择工具时不知道选哪个甚至报出“Tool name should be unique”异常。排查后发现Spring AI的ToolCallbacks.from()默认用方法名作为工具名两个同名工具自然冲突。解决方法是给工具回调重命名ToolCallback renamedCallback ToolCallbacks.from(orderTools) .stream() .filter(c - c.getToolDefinition().name().equals(getUserInfo)) .map(c - new DefaultToolCallback( ToolDefinition.builder(c.getToolDefinition()) .name(local.getUserInfo) .build(), c.getToolExecutor())) .findFirst() .orElseThrow();另一个问题是入参序列化。MCP工具的入参格式是McpSchema.CallToolRequest包裹的JSON结构与Spring AI本地工具的ToolInput类型不同。我在桥接层要做一次参数转换把MCP的argumentsMap转换成对应Java方法的参数列表。一开始偷懒用ObjectMapper.convertValue结果遇到枚举类型、日期格式通通失败。后来统一改成了显式的参数类解析每个MCP工具写一个独立的参数适配器。4.4 工具调用幂等性与缓存策略AI生成的工具调用参数是不稳定的。模型可能对同一个请求生成两个几乎一样的工具调用比如先调用queryOrder又调用一次queryOrder只是换了个参数名。这在实际业务里很致命如果这两个调用都是退款操作就重复退款了。我采用的策略在工具注册表外层加一层“幂等拦截器”针对特定业务工具比如退款、通知、结单生成工具调用的唯一key基于Agent名 工具名 参数hash。用ConcurrentHashMap做本地调用去重若3秒内相同key的工具调用已经执行过直接返回上一次结果。public ToolExecutionResult callWithIdempotency(ToolCallback callback, String agentName, JsonNode input) { String key agentName : callback.getToolDefinition().name() : input.toString(); if (idempotentCache.containsKey(key)) { return idempotentCache.get(key); } ToolExecutionResult result callback.call(input); idempotentCache.put(key, result); return result; }对于queryOrder这类只读工具也建议加一个短时缓存能显著减少模型反复调用同一个查询工具的耗时。实测下来一个流程查询类工具能少跑3到5次外部调用。4.5 关于模型系统性提示词的隐性影响最后分享一个很多人忽略的点Agent的system提示词要严格约束“不要做什么”而不是只说“你要做什么”。例如refund_executor的提示词里我加了“禁止自行修改工单状态”这个约束实际在模型决策工具调用时起了很大作用。如果只写“你是退款执行员负责处理退款”模型可能会在上文里看到工单状态字段顺手去调用order.finish破坏流程语义。我在DSL的Agent定义中增加了一个guardrails字段专门放这些负面约束。执行器会在构造ChatClient时把guardrails和system拼在一起。DSL的原子性不仅体现在流程层还体现在每个Agent的行为约束层。5. 工具层后续扩展设想与个人体会目前这个DSL工具层在内部跑了两周稳定支撑了售后工单自动处理、库存查询辅助决策、客服话术生成三类业务场景。我个人体会最深的一点是DSL的语法设计一定要克制只表达“编排意图”不要试图表达“计算逻辑”。如果DSL可以做变量运算、循环嵌套它的解析复杂性会直线上升反而成为团队维护负担。后续我计划给DSL增加两个能力一是并行路由让多个Agent对同一份输入并行处理后再汇聚结果二是人工审批节点在退款金额超过阈值时插入等待人工确认的步骤。这两个能力都是基于当前执行器简单改造就能实现的因为核心的工具层和Agent层已经定型了。另外想提醒一点如果你所在团队已经有一套成熟的规则引擎或流程编排系统比如工作流引擎先评估是否可以直接复用不要急于自研DSL。自研DSL适用于“流程本身的规则不复杂但需要与模型动态决策结合”的场景。在这个场景里它比通用工作流引擎更轻比硬编码编排更灵活是目前Java生态里做Multi-Agent生产落地比较接地气的路线。