别背公式:你的 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不适合在线同步场景

五、什么时候别用 / 别踩的坑(反直觉清单)

  1. 别把枚举直接暴露成 string:模型会自由发挥,返回 "用户不想买了"。必须用 enum 约束,且把 description 放在字段描述里,而不是枚举值里。
  2. 别相信模型的参数顺序:模型返回的 JSON 字段顺序是随机的,你的反序列化必须不依赖顺序。
  3. 别在 Function Calling 里做重计算:模型只会把参数传给你,不会帮你算。如果你在函数里查数据库,那这个函数就是同步阻塞的——模型在等你
  4. 别忽略 parallel_tool_calls:OpenAI 默认允许模型并行调用多个函数。如果你的函数间有依赖(如先 getOrder 再 cancelOrder),必须设置 parallel_tool_calls=false,否则模型会同时调用,拿到错误数据。
  5. 别把用户 ID 放在参数里:函数签名里不要有 userId,模型不知道当前用户是谁。用户身份必须从上下文(Token/Session)里取,否则任何人都能让模型帮你取消别人的订单。
  6. 别用 ObjectMapper 默认配置:必须 FAIL_ON_UNKNOWN_PROPERTIES=false,否则模型多带一个字段,整个调用直接 500。
  7. 别把异常抛给模型:函数执行失败时,返回一个结构化的错误对象给模型,让它生成"抱歉,订单已发货无法取消"这类回复,而不是抛异常中断链路。

六、下一步:让 Agent 自己处理"多轮工具调用"

order-service 目前只支持"一轮函数调用 -> 结果 -> 回复"。但真实用户会说"帮我取消订单 ORD-123,然后把退款金额发我手机"。这需要模型连续调用两个函数cancelOrder + sendSms),且第二个函数依赖第一个的结果。这就是 Agent 的循环决策——你的框架必须支持把上一轮的函数执行结果作为上下文,重新喂给模型,直到模型认为任务完成。

下一期,我会拆解如何用 Java 实现一个有状态的多轮 Agent 循环,并解决"模型陷入死循环"和"上下文 token 爆炸"这两个真实生产问题。关注我,别错过 order-service 的 Agent 生产化改造。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐