☰
Java手写Skill调度器:Function Calling与动态代理实战
2026/10/17 8:17:59 网站建设 项目流程

做 AI Agent 这块,Java 生态比起 Python 生态确实少了点“即插即用”的爽快感,但只要涉及模型需要主动调用业务能力,函数调用(Function Calling)就是绕不开的核心环节。我在某个内部项目里被这块绊了不少次,后面索性不依赖重量级框架,从零手写了一个轻量的 Skill 调度器。今天这篇文章就完整拆解一下我的做法:怎么解析 Function Calling 协议,怎么用动态代理把 Java 方法包装成 Agent 能调用的技能,以及整个调度链路里那些文档里不会写的坑。

如果你是正在做 Java 侧 Agent 应用、对 LangChain 式封装感到不放心、或者想真正理解“模型如何调用你的代码”的开发者,这篇文章应该能给你一个从零到一、可落地可扩展的参考实现。

1. 动手之前:这个调度器到底要解决什么问题

先说清楚我的使用场景。系统里有一个 Java 后端服务,它跟大模型之间反复交互:用户提问 -> 模型判断要不要调用工具 -> 如果需要就返回一个结构化的“调用请求” -> 后端真正执行对应方法 -> 把结果回传给模型 -> 模型再组织自然语言回复。这个环路上最尴尬的一层,就是“模型说要调用某某函数,Java 这边怎么把函数找出来、把参数填进去、把结果返回来”。

市面上有现成的 Agent 框架,例如带 function calling 抽象的那些 Java AI 库,用起来确实省事,但它们往往替你做了大量“魔法”:注解扫描、对象映射、链路追踪通通封装在黑盒里。对于一个需要精细控制、要兼容内部 RPC 接口、还要定制超时和鉴权的系统来说,黑盒恰恰是最麻烦的。我不想为了一个调度功能引入整套框架,也不想在模型返回格式稍微变一下的时候只能干瞪眼。

所以我的目标非常明确:

  • 零强依赖,核心调度逻辑只用 JDK 自带能力加一个 JSON 处理库;
  • 协议解析可配置,能兼容类 OpenAI 风格的工具调用返回结构;
  • 技能注册足够简单,一个注解、一个接口就能把一个 Java 方法暴露给模型;
  • 执行链路完全透明,调用谁、传了什么参、返回了什么,每一步都能打印出来排查。

这个调度器不关心模型怎么来的,也不关心请求是怎么发给模型的,它只负责一个纯粹的“翻译+路由+执行”动作:把模型返回的 Function Calling 指令,变成 Java 世界里一次真实的方法调用。

2. 架构设计:注册、解析、调度、回传四层职责

我一开始也想过把代码全塞进一个类里,图省事。但真正接入两个技能之后就发现不行:每个方法都有元数据,参数要校验,结果要格式化,超时和异常要处理,全挤在一起根本没法维护。后来我按职责切成了四层。

2.1 四个模块各管一段

第一层是技能注册中心(SkillRegistry),负责保存所有可被模型调用的技能元数据,包括技能名称、描述、参数结构、对应的方法句柄。第二层是协议解析器(ProtocolParser),专门从模型返回的 JSON 文本里抽出“要调用哪个函数、参数是什么”。第三层是调度执行器(SkillDispatcher),拿到解析结果后,在注册中心找到匹配的技能,完成参数绑定并执行。第四层是结果装配器(ResultAssembler),把执行结果统一包装成模型期望的 tool 消息格式。

这四层串起来就是一条完整链路:

模型响应 JSON -> 协议解析器 -> 技能注册中心查找 -> 调度执行器参数绑定 -> 目标方法执行 -> 结果装配器回传

2.2 为什么这样拆分

最大的原因是便于排查。我曾经遇到一个诡异问题:模型一直在调用一个已经不存在的旧技能。如果解析和查找逻辑写在一起,你只能看到“找不到函数”的报错,却看不到模型到底返回了什么、被解析成了什么。拆开之后,每一层都能单独打印输入输出,问题定位就是几分钟的事。

第二个原因是协议版本兼容。模型提供方的返回结构可能从 v1 升级到 v2,字段名可能变化。只要协议解析器这一层做好抽象,上层调度完全不用动。我后面在接不同模型服务时,这个设计帮了大忙,只替换了Parser的实现类,其它代码一行没改。

3. Function Calling 协议解析:从模型响应到结构化调用

3.1 一条典型的模型返回长什么样

以目前最流行的工具调用风格为例,模型通常不会直接返回“我要调用 getWeather”,而是返回一段带有工具调用标记的 JSON。拆开看核心结构大概是:

{ "choices": [ { "message": { "role": "assistant", "content": null, "tool_calls": [ { "id": "call_123456", "type": "function", "function": { "name": "getWeather", "arguments": "{\"city\": \"上海\", \"date\": \"2025-06-10\"}" } } ] } } ] }

注意 arguments 字段是一个字符串,是转义后的 JSON,要先解包成对象,再取里面的键值对。这是我最早踩坑的地方,很多人直接当成嵌套对象解析,结果报类型不匹配错误。

3.2 解析器需要抽出哪些关键字段

对一个调度器来说,最重要的信息有四块:调用ID,用于后续结果关联;函数名,用于路由;参数对象,用于绑定;还有调用类型,目前绝大多数是 function,但保留字段有利于后面兼容。解析器的内部模型设计得很朴素,就是一个 Java 记录类再加一个解析方法:

public record ToolCall(String callId, String type, String name, Map<String, Object> arguments) { } public final class ToolCallParser { private final ObjectMapper mapper = new ObjectMapper(); public List<ToolCall> parse(String responseJson) throws Exception { JsonNode root = mapper.readTree(responseJson); JsonNode choices = root.path("choices"); if (choices.isMissingNode() || !choices.isArray() || choices.isEmpty()) { throw new IllegalArgumentException("响应中没有 choices 数组"); } JsonNode message = choices.get(0).path("message"); JsonNode toolCalls = message.path("tool_calls"); if (toolCalls.isMissingNode() || !toolCalls.isArray()) { return Collections.emptyList(); } List<ToolCall> result = new ArrayList<>(); for (JsonNode callNode : toolCalls) { String id = callNode.path("id").asText(""); String type = callNode.path("type").asText("function"); JsonNode functionNode = callNode.path("function"); String name = functionNode.path("name").asText(""); String rawArgs = functionNode.path("arguments").asText("{}"); Map<String, Object> args = parseArguments(rawArgs); result.add(new ToolCall(id, type, name, args)); } return result; } private Map<String, Object> parseArguments(String rawArgs) throws Exception { if (rawArgs == null || rawArgs.isBlank()) { return Map.of(); } return mapper.readValue(rawArgs, new TypeReference<Map<String, Object>>() { }); } }

3.3 容错比想象中更重要

这里我要专门说一下容错。模型输出的 JSON 并不是永远规范的,我实际遇到过字段名带空格、arguments 为空字符串、choices 数组返回多个候选等状况。最典型的一个问题是:某些模型服务会在 arguments 里多包一层键,比如{"parameters": {"city": "上海"}},而标准格式是直接{"city": "上海"}。

我的处理方式是:解析器只负责“尽量宽松地抽出参数”,不在这里做业务校验。参数到底是对是错,留给调度执行器判断。比如 parseArguments 遇到非对象节点就返回空 Map,而不是抛异常,这样避免一次解析失败导致整个会话中断。宽松解析、严格校验,这个原则帮我躲过很多线上事故。

4. 动态代理注册:把 Java 方法变成 Agent 技能

4.1 为什么选动态代理而不是手工注册

选型的时候我对比过几种方案:注解反射注册、接口实现注册、Spring 托管注册。最终选了“注解标注接口方法 + 动态代理生成实现”的组合。原因很简单,我希望业务侧写代码时感觉不到“我在为模型写技能”,只是写一个正常的 Java 接口方法,然后加个注解说明“这个方法可以被模型调用”。

动态代理最大的好处是把“接口定义”和“执行逻辑”解耦。你声明一个接口,运行时不关心真正的实现类是谁,而是交给代理在调用时统一处理元数据校验、参数转换、日志打印。这样注册技能和实现业务彻底分开。

4.2 定义技能注解

先定义一个注解,用来打在接口方法上:

@Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) public @interface Skill { String name() default ""; String description() default ""; String paramSchema() default "{}"; }

name 为空时默认取方法名,description 用来给模型看,paramSchema 描述参数结构,可以直接写 JSON Schema 字符串,也可以以后从别的配置源加载。实际用下来,paramSchema 这个字段让调度器在决定要不要调用前就能做初步校验,非常有用。

4.3 代理处理器要做什么

核心是一个 InvocationHandler。每次模型调用技能,最终都会落到这里。它要完成三件事:根据方法找到技能元数据;把调用参数从 Map 转为方法需要的类型;反射调用真实业务对象。流程写成代码大概是这样:

public class SkillInvocationHandler implements InvocationHandler { private final Object target; private final SkillRegistry registry; public SkillInvocationHandler(Object target, SkillRegistry registry) { this.target = target; this.registry = registry; } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { if (method.isDefault()) { return InvocationHandler.invokeDefault(proxy, method, args); } if (method.getDeclaringClass() == Object.class) { return method.invoke(this, args); } SkillMetadata metadata = registry.getByMethod(method); if (metadata == null) { throw new IllegalStateException("方法未注册为技能: " + method.getName()); } // 实际调用参数已经在 args 内,因为代理内部会把 Map 转换好再反射 return method.invoke(target, args); } }

等等,这里有个设计细节很多人会漏:动态代理的 invoke 方法里拿到的 args,已经是 JVM 按方法签名类型准备好的参数数组。真正从 Map 转成类型化参数的过程,发生在更上层。你需要决定在哪一层做转换。我的做法是放在调度器里,代理只负责“转发”,转换逻辑单独维护,方便复用。

4.4 通过工厂类创建代理

有了注解和处理器,就可以写一个工厂类,把一个业务接口变为可调用的代理对象:

public class SkillProxyFactory { private final SkillRegistry registry; public SkillProxyFactory(SkillRegistry registry) { this.registry = registry; } public <T> T create(Class<T> skillInterface, T target) { // 扫描接口方法,注册技能元数据 for (Method method : skillInterface.getDeclaredMethods()) { Skill skill = method.getAnnotation(Skill.class); if (skill != null) { String name = skill.name().isBlank() ? method.getName() : skill.name(); registry.register(name, new SkillMetadata(name, skill.description(), skill.paramSchema(), skillInterface.getName(), method.getName())); } } return (T) Proxy.newProxyInstance( skillInterface.getClassLoader(), new Class<?>[]{skillInterface}, new SkillInvocationHandler(target, registry) ); } }

上面的代码有一个必须注意的坑:注册方法时不能只存 Method 对象。因为不同类加载器加载同一个接口时,Method 对象并不相等,直接拿来当 key 会查询失败。我的做法是注册索引用“接口全限定名 + 方法名”组合,查找时同样拼接,绕开对象相等性问题。

5. 调度执行与结果回传:参数绑定、异常处理、上下文缝合

5.1 把 JSON 参数绑定到方法参数

模型返回的参数是一个 Map,而你的 Java 方法可能是String city, String date,也可能是LocalDate date,甚至是一个自定义对象。这里必须做一次类型转换。我用的 Jackson,核心逻辑是:根据方法的参数类型逐个把 Map 中的值转成目标类型。

public Object[] bindArguments(Method method, Map<String, Object> argsMap) { Class<?>[] paramTypes = method.getParameterTypes(); Object[] args = new Object[paramTypes.length]; for (int i = 0; i < paramTypes.length; i++) { String paramName = getParameterName(method, i); Object rawValue = argsMap.get(paramName); if (rawValue == null) { args[i] = getDefaultValue(paramTypes[i]); // 返回默认值,而不是直接丢异常 continue; } args[i] = convertValue(rawValue, paramTypes[i]); } return args; }

参数名获取不能靠 JDK 默认的反射,因为编译后参数名会丢失。有两个办法:编译时加-parameters参数,运行时通过method.getParameters()[i].getName()拿到;或者在注解上显式声明参数名。我建议在技能注解里加一个paramNames属性,因为有些方法参数名经过字节码插件处理后变得不可读,显式声明最靠谱。

5.2 三种典型结果形态

执行完业务方法后,返回值需要被统一打包。模型侧期望的结果就是一段 text,所以无论你返回什么,最终都要序列化成字符串。我总结了三种常见结果形态:

  • 简单对象:直接序列化成 JSON 给模型;
  • 集合或数组:序列化成 JSON 数组;
  • void 或无返回值:返回{"success": true}之类固定消息。

这里有个小设计值得分享:如果业务方法抛出异常,不要直接让异常穿透到上层中断对话,而是捕获后包装成一段 result 返回,里面带"error"字段和可读的提示。模型拿到之后会自行判断怎么向用户解释。把错误交给模型处理,比让整个会话崩溃要合理得多。

5.3 回传消息格式的拼装

最后一步是把执行结果组装成模型期待的 tool 消息。格式一般长这样:

{ "role": "tool", "tool_call_id": "call_123456", "content": "{\"city\": \"上海\", \"temperature\": 28}" }

我们在解析层保存了 tool_call_id,就是为了在这里用。这一层代码很简单,但要注意:content 必须是字符串,绝不能直接把对象塞进去,很多模型服务会因此解析失败。

6. 完整链路实测:一个天气查询技能从注册到执行

光讲原理容易飘,我把一个最小可运行案例贴出来,从定义接口到跑通一次完整调用。假设我们的 Agent 需要一个天气查询技能。

6.1 定义技能接口与实现

public interface IWeatherSkill { @Skill(name = "getWeather", description = "查询指定城市、指定日期的天气情况", paramSchema = "{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"},\"date\":{\"type\":\"string\"}}}") String getWeather(String city, String date); } public class WeatherSkillImpl implements IWeatherSkill { @Override public String getWeather(String city, String date) { // 真实场景这里会去调内部气象服务,这里简化处理 return "{\"city\":\"" + city + "\",\"date\":\"" + date + "\",\"weather\":\"晴\",\"temperature\":28}"; } }

6.2 注册并创建代理

SkillRegistry registry = new SkillRegistry(); SkillProxyFactory factory = new SkillProxyFactory(registry); IWeatherSkill weatherSkill = factory.create(IWeatherSkill.class, new WeatherSkillImpl()); // 模拟模型返回的 function call 请求 String modelResponse = """ { "choices": [ { "message": { "role": "assistant", "content": null, "tool_calls": [ { "id": "call_20250610_001", "type": "function", "function": { "name": "getWeather", "arguments": "{\\"city\\": \\"上海\\", \\"date\\": \\"2025-06-10\\"}" } } ] } } ] } """;

6.3 执行一次完整调度

SkillDispatcher dispatcher = new SkillDispatcher(registry); ResultAssembler assembler = new ResultAssembler(); ToolCallParser parser = new ToolCallParser(); List<ToolCall> calls = parser.parse(modelResponse); for (ToolCall call : calls) { // 找到技能 SkillMetadata metadata = registry.getByName(call.name()); if (metadata == null) { return assembler.buildErrorResult(call.callId(), "未找到技能: " + call.name()); } // 绑定参数并执行 Object result = dispatcher.dispatch(metadata, call.arguments()); // 回传结果 String message = assembler.buildToolResult(call.callId(), String.valueOf(result)); // 把 message 拼到消息列表里发给模型,后续模型会组织自然语言回复 System.out.println(message); }

跑出来的回传消息类似:

{"role": "tool", "tool_call_id": "call_20250610_001", "content": "{\"city\":\"上海\",\"date\":\"2025-06-10\",\"weather\":\"晴\",\"temperature\":28}"}

这一步走完,整个骨架已经能支撑一个最简 Agent 工具调用闭环。接下来就是接真实模型、丰富技能库、加缓存和监控。

7. 避坑记录:序列化、名称冲突、并发、超时那些事

从手写第一版到稳定运行,我记录了一批真实踩过的问题。这些坑大概率你也会碰上,提前列出来能省很多事。

7.1 参数顺序与参数名必须稳定

模型并不关心你的 Java 方法参数顺序,它按参数名传参。如果你的方法有两个参数且类型都是字符串,顺序定错了,结果可能完全反了。所以我强烈建议:在技能注解里显式定义参数名列表,同时把paramSchema里的属性名当作唯一标准。接口方法参数名、注解参数名、Schema 属性名三者必须一致,否则会出现非常难排查的“模型传参对,但业务方法接错值”。

7.2 技能名称冲突不能静默覆盖

多个接口里可能存在同名方法。注册中心如果你不做名称冲突检测,后注册的会把先注册的覆盖掉,线上调用时行为就变了。我在注册中心加了一段逻辑:注册同一个名称时直接抛出异常,强制要求命名唯一。这个“暴力”规则在高版本接口迭代时非常有用,能第一时间发现重复注册问题。

7.3 并发环境下 Method 反射调用并非线程安全的最好选择

Java 反射调用同一 Method 对象在多线程下性能会退化,早期 JDK 尤其明显。我的建议是:高频技能不要每次都反射,可以在注册时用 MethodHandle 预解析,调度时直接调用。换成 MethodHandle 之后,我的调度器在并发压测下吞吐提升明显,代码改动量还很小。注册中心里额外存一个 MethodHandle 字段,调度执行器优先用它。

7.4 必须做技能执行超时控制

大模型应用最怕的就是某个技能卡住,导致整个会话转圈。我在调度器里包了一层超时控制:用线程池执行技能调用,通过 Future.get 指定超时时间(我常用 5 秒),超时后返回一个“技能调用超时”的错误消息,而不是让调用线程阻塞。注意使用线程池时一定要设置队列大小和拒绝策略,防止系统整体被拖垮。

7.5 参数中嵌套对象时的校验策略

有的技能参数是一个地址对象,模型可能只给了一半字段。我处理时区分“必填”和“可选”。必填字段缺失就在调度前拦截返回错误;可选字段缺失则填默认值。这个校验放在调度器参数绑定前后,不要依赖模型一定传全。模型不是程序,它可能忘,校验必须是我们自己的兜底。

7.6 不要让代理类承载业务状态

我的动态代理接口实现类被要求必须是无状态或轻状态的。如果业务实现类里存了会话状态,多轮对话之间会互相污染。正确做法是把会话相关数据放到调用上下文里,由调度器统一维护,业务方法只处理一次调用内的数据。

8. 后续扩展与我的实际体会

这个调度器写完之后,我最大的感悟是:直接裸写比用框架更能理解这套机制。框架帮你把细节藏起来,但一旦出问题,你连排查方向都没有。自己写一遍之后,再去看那些框架的实现,基本一眼就能明白它做了什么。

如果后续继续演进,我建议几个方向。一是把技能元数据直接导出成模型需要的工具描述列表,这样注册一次,既能给模型当工具定义,又能给调度器当路由表。二是增加技能调用的链路追踪字段,每个调用带上 traceId,方便在日志系统里串起来看。三是做技能版本管理,同一个技能可以同时存在 v1 和 v2,灰度切换时不需要重启服务。

最后分享一个使用上的技巧:给技能命名的时候,尽量用“动词+名词”的格式,比如sendOrderMessage、queryUserAccount。模型对语义化的函数名理解准确度明显更高,名字起得模糊,模型误调用的概率会直线上升。这个细节看起来不起眼,但实际效果比我预想的要明显得多。

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

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

立即咨询