AI Agent 与 Function Calling:Java 侧实现
别背公式:你的 Function Calling 不生效,不是模型傻,而是你的 JSON Schema 根本就是错的
一次线上事故:order-service 接入大模型做"查单改单"的 Agent,模型在 80% 的请求里拒绝调用 cancelOrder 函数,反而一本正经地告诉用户"请联系人工客服"。排查三天,最后发现不是 Prompt 写得差,而是我们生成的 tools JSON Schema 里,required 数组和 properties 的嵌套结构违反了 OpenAI 的严格校验——模型不是不调用,是它压根没收到合法的函数定义。
Function Calling 的本质不是"模型学会了调用",而是模型在受限的 token 生成空间里,被迫从你给定的函数列表中做一次结构化输出。模型本身不执行任何代码,它只是输出一个符合你 Schema 的 JSON 字符串。所以,Java 侧实现的核心不是"调 API",而是如何把 Java 方法签名无损地翻译成模型能理解的 JSON Schema,再把模型输出的 JSON 安全地还原成 Java 调用。
一、从 method 到 JSON Schema:反射是唯一出路,但坑在泛型和嵌套
你不可能手写每个函数的 Schema。order-service 里 20 个 Agent 动作,每个都有 3-5 个参数,手写必然出错。正确姿势是用反射解析 Java 方法,动态生成 Schema。
但反射拿不到参数名(除非 -parameters 编译参数),更拿不到泛型嵌套结构。看这段代码:
// order-service 的 Agent 动作定义
public class OrderAgentActions {
// 取消订单:支持批量 + 原因分类
public ApiResult<String> cancelOrders(
@AgentParam(description = "订单ID列表") List<String> orderIds,
@AgentParam(description = "取消原因") CancelReason reason
) {
// 实际业务逻辑...
return ApiResult.success("cancelled");
}
}
public enum CancelReason {
DUPLICATE_ORDER("重复下单"),
USER_REQUEST("用户主动取消"),
ADDRESS_CHANGED("地址变更");
// ...
}
用反射生成 Schema 时,List<String> 只能拿到 List.class,拿不到 String——除非你用 ParameterizedType。而 CancelReason 是枚举,不能简单当作 string,否则模型会乱填。
源码级解法:写一个 SchemaGenerator,递归解析 Type 接口:
public class SchemaGenerator {
// 核心:递归解析 Type,处理泛型和枚举
public static Map<String, Object> generateSchema(Type type) {
if (type instanceof ParameterizedType pType) {
// 处理 List<T>, Map<K,V> 等
Type rawType = pType.getRawType();
if (rawType == List.class) {
Type elementType = pType.getActualTypeArguments()[0];
return Map.of(
"type", "array",
"items", generateSchema(elementType) // 递归拿 T
);
}
} else if (type instanceof Class<?> clazz) {
if (clazz.isEnum()) {
// 枚举转成 string + enum 约束
Object[] constants = clazz.getEnumConstants();
return Map.of(
"type", "string",
"enum", Arrays.stream(constants)
.map(c -> ((Enum<?>) c).name())
.toArray()
);
}
// 普通 POJO 递归解析字段
// ...
}
return Map.of("type", "string"); // fallback
}
}
版本差异:JDK 8 的反射 API 和 JDK 17 的 instanceof pattern matching 写法不同,但底层 ParameterizedType 逻辑一致。边界情况:嵌套泛型 List<Map<String, List<Integer>>> 必须递归到底,否则模型会生成错误结构。
二、模型返回的 JSON 不是给你直接调用的:必须做"语义校验 + 类型还原"
模型返回的 arguments 是一个字符串,里面是 JSON。但你不能直接 ObjectMapper.readValue 然后反射调用——模型可能生成不存在的枚举值,或者把 orderIds 写成单个字符串而非数组。
order-service 里真实踩过的坑:模型返回 "orderIds": "ORD-12345"(字符串而非数组),Jackson 反序列化到 List<String> 时直接报 MismatchedInputException。更隐蔽的是枚举:模型返回 "reason": "用户取消"(中文描述),而我们的枚举是 USER_REQUEST。
解决方案:不直接反序列化到目标类型,而是先解析成 JsonNode,做一层"宽容校验":
public <T> T safeConvert(JsonNode args, Type targetType) {
// 1. 类型宽容:字符串 -> 单元素数组
if (targetType.getRawType() == List.class && args.isTextual()) {
String single = args.asText();
// 手动构造数组节点
ArrayNode arr = JsonNodeFactory.instance.arrayNode();
arr.add(single);
args = arr;
}
// 2. 枚举宽容:中文描述 -> 枚举 name
if (targetType.getRawType().isEnum() && args.isTextual()) {
String value = args.asText();
// 遍历枚举,匹配 description 或 name
for (Object constant : targetType.getRawType().getEnumConstants()) {
if (constant.toString().equals(value) ||
((Enum<?>) constant).name().equalsIgnoreCase(value)) {
return (T) constant;
}
}
}
// 3. 最终反序列化
return objectMapper.convertValue(args, objectMapper.getTypeFactory()
.constructType(targetType));
}
边界情况:模型可能返回 null 给非空字段,或者返回多余字段。你的 ObjectMapper 必须配置 FAIL_ON_UNKNOWN_PROPERTIES=false,否则一个多余字段就让整个调用失败。
三、Function Calling 的"执行链路":你的 Agent 框架在骗你
市面上 Java 的 Agent 框架(如 Spring AI、LangChain4j)都封装了 Function Calling,但它们默认是"模型决定调用 -> 执行 -> 返回结果给模型 -> 模型再生成最终回复"的同步循环。这在 order-service 里行不通——因为我们的 cancelOrders 是有副作用的写操作,不能自动执行。
真实场景必须做两级确认:
// 伪代码:order-service 的 Agent 执行链路
public String handleUserRequest(String userMessage) {
// 第一轮:模型只决定"要调用什么函数",不执行
AgentResponse response = chatClient.call(tools, userMessage);
if (response.hasFunctionCall()) {
FunctionCall call = response.getFunctionCall();
// 关键:在这里做业务校验,而不是直接执行
if (call.getName().equals("cancelOrders")) {
// 校验订单状态、权限、风控...
OrderValidationResult result = validateCancellation(call.getArguments());
if (!result.isPass()) {
// 不执行,把校验失败原因返回给模型,让它重新回答
return chatClient.callWithResult(tools, userMessage,
"函数调用被拒绝: " + result.getMessage());
}
}
// 校验通过,才真正执行
Object result = executeFunction(call);
// 第二轮:把执行结果交给模型,生成用户可读回复
return chatClient.callWithResult(tools, userMessage, result);
}
return response.getContent();
}
为什么必须这样:模型没有"记忆"能力,它在第一轮生成函数调用时,并不知道这个函数会被拒绝。如果你直接执行,那么用户说"帮我取消订单"时,模型会毫不犹豫地调用 cancelOrders——你根本没有机会做业务拦截。很多框架的 @Tool 注解直接绑定执行逻辑,这在只读查询场景没问题,但写操作必须拆成"提议 -> 校验 -> 执行"三步。
四、对比决策表:什么时候用 Function Calling,什么时候别用
| 场景 | 传统硬编码 | Function Calling | 备注 |
|---|---|---|---|
| 参数固定、流程固定(如"查订单状态") | ✅ 简单直接 | ❌ 过度设计 | 模型调用有延迟和 token 成本 |
| 参数组合多变(如"查最近3天金额>100的已发货订单") | ❌ 要写 N 个接口 | ✅ 一个函数搞定 | 模型负责解析自然语言到结构化参数 |
| 有副作用的写操作(取消、退款、改地址) | ✅ 人工确认 | ⚠️ 必须加业务校验层 | 见上文两级确认链路 |
| 函数数量 > 20 个 | ❌ 维护地狱 | ⚠️ 模型选择准确率下降 | 考虑分组或语义路由 |
| 延迟敏感(<200ms) | ✅ | ❌ 模型推理至少 1-2s | 不适合在线同步场景 |
五、什么时候别用 / 别踩的坑(反直觉清单)
- 别把枚举直接暴露成 string:模型会自由发挥,返回
"用户不想买了"。必须用enum约束,且把description放在字段描述里,而不是枚举值里。 - 别相信模型的参数顺序:模型返回的 JSON 字段顺序是随机的,你的反序列化必须不依赖顺序。
- 别在 Function Calling 里做重计算:模型只会把参数传给你,不会帮你算。如果你在函数里查数据库,那这个函数就是同步阻塞的——模型在等你。
- 别忽略
parallel_tool_calls:OpenAI 默认允许模型并行调用多个函数。如果你的函数间有依赖(如先getOrder再cancelOrder),必须设置parallel_tool_calls=false,否则模型会同时调用,拿到错误数据。 - 别把用户 ID 放在参数里:函数签名里不要有
userId,模型不知道当前用户是谁。用户身份必须从上下文(Token/Session)里取,否则任何人都能让模型帮你取消别人的订单。 - 别用
ObjectMapper默认配置:必须FAIL_ON_UNKNOWN_PROPERTIES=false,否则模型多带一个字段,整个调用直接 500。 - 别把异常抛给模型:函数执行失败时,返回一个结构化的错误对象给模型,让它生成"抱歉,订单已发货无法取消"这类回复,而不是抛异常中断链路。
六、下一步:让 Agent 自己处理"多轮工具调用"
order-service 目前只支持"一轮函数调用 -> 结果 -> 回复"。但真实用户会说"帮我取消订单 ORD-123,然后把退款金额发我手机"。这需要模型连续调用两个函数(cancelOrder + sendSms),且第二个函数依赖第一个的结果。这就是 Agent 的循环决策——你的框架必须支持把上一轮的函数执行结果作为上下文,重新喂给模型,直到模型认为任务完成。
下一期,我会拆解如何用 Java 实现一个有状态的多轮 Agent 循环,并解决"模型陷入死循环"和"上下文 token 爆炸"这两个真实生产问题。关注我,别错过 order-service 的 Agent 生产化改造。
更多推荐


所有评论(0)