很多人第一次看到 function call,会下意识把它理解成“让大模型调用函数”。

这句话不能算错,但很容易让人误会。更准确一点说,function call 不是模型真的跑了一段代码,而是模型根据你的工具描述,生成一份“我想调用哪个函数、参数是什么”的结构化请求。真正执行函数的,还是你的业务代码。

也就是说,模型负责判断“该不该调、调哪个、参数怎么填”,应用负责“能不能调、怎么调、结果怎么处理”。

这个边界很重要。因为一旦边界没想清楚,就会把权限、安全、异常处理这些本来属于工程系统的事情,错误地交给模型。

先搞清楚:function call 是什么

为什么需要 function call?

大模型本身擅长处理自然语言,但它有几个天然限制。

比如它不知道你数据库里的实时订单状态,不知道今天某个城市的真实天气,也不能直接给用户退款、创建工单、发邮件。它可以“猜”,但工程系统最怕的就是这种猜。

function call 做的事情,其实就是在模型和外部世界之间搭一座桥。

你可以把它理解成这样:

用户问一句自然语言,模型先判断这个问题是不是需要外部能力。如果需要,它不会直接编答案,而是返回一个结构化的调用意图。你的程序拿到这个意图之后,去调用真实的接口、数据库或者内部服务。拿到结果后,再把结果交回给模型,让模型组织成用户能看懂的话。

这里的关键不是“调用函数”这四个字,而是“把自然语言意图变成稳定的结构化参数”。

比如用户说:

帮我查一下上海今天的天气,用摄氏度。

模型可能会生成这样的调用:

{
  "name": "get_weather",
  "arguments": {
    "location": "上海",
    "units": "celsius"
  }
}

你看,用户说的是一句话,但业务系统拿到的是函数名和 JSON 参数。这就从“理解一句话”变成了“执行一次普通的程序调用”。

Function、Tool、Function Call,别被名字绕进去

OpenAI 现在更常用的说法是 tool calling。function 是 tool 的一种。

这个名字变化背后,其实是能力边界变大了。以前我们主要是把一个函数描述给模型,比如 get_weathersearch_ordercreate_ticket。后来工具不只可以是你自己定义的函数,也可以是平台内置能力,比如搜索、文件检索、代码执行,甚至远程 MCP 工具。

但从工程视角看,核心流程没变。

你给模型一组可用工具,模型在需要的时候返回 tool call,你的应用执行工具,然后把 tool output 再交回给模型。

如果只讨论最常见的业务集成场景,我们还是可以把它叫 function call。因为绝大多数时候,你给模型的就是一组函数 schema。

OpenAI 的 function call 是怎么跑起来的

OpenAI 文档里把整个过程拆得比较清楚。用白话说,就是五步:

应用先把用户问题和工具定义一起发给模型。模型看完之后,如果发现需要外部数据或动作,就返回一个 function call。应用拿到这个调用请求,解析函数名和参数,执行自己代码里的真实函数。函数执行完成后,应用把结果作为 tool output 发回模型。模型再基于这个结果,生成最终回复。

这段流程里最容易忽略的一点是:API 不会替你执行函数。

模型返回的只是调用意图。它不会真的访问你的数据库,也不会真的请求天气 API,更不会真的给用户退款。真正有副作用的动作必须发生在你的服务端代码里。

一个简化版流程大概长这样:

用户问题
应用把 tools + messages 发给 OpenAI
模型返回 tool call
应用解析参数并执行本地函数 / 内部 API
应用把 function_call_output 发回模型
模型生成最终自然语言回答

看起来像“模型调用函数”,本质上是“模型生成函数调用请求,应用完成闭环”。

第一步:把函数描述给模型

模型并不知道你的系统里有哪些方法,所以你要先把函数能力描述出来。这个描述一般包括函数名、用途、参数结构,以及参数的 JSON Schema。

比如一个天气查询函数可以这样定义:

{
  "type": "function",
  "name": "get_weather",
  "description": "查询指定城市的实时天气。",
  "parameters": {
    "type": "object",
    "properties": {
      "location": {
        "type": "string",
        "description": "城市名称,例如:上海、北京、杭州"
      },
      "units": {
        "type": "string",
        "enum": ["celsius", "fahrenheit"],
        "description": "温度单位"
      }
    },
    "required": ["location", "units"],
    "additionalProperties": false
  },
  "strict": true
}

这段配置不是写给人看的,是写给模型看的。

name 告诉模型工具叫什么,description 告诉模型什么时候该用它,parameters 告诉模型参数应该长什么样。enumrequiredadditionalProperties 这些约束越清楚,模型越不容易生成奇怪参数。

如果开启 strict,模型会更严格地贴合 schema 输出。对工程系统来说,这通常是好事。因为你后面要把参数喂给真实代码,越结构化,越好校验。

第二步:模型返回的不是答案,而是调用请求

假设用户问:

上海今天多少度?

如果模型认为这个问题需要实时天气,它可能不会直接回答,而是返回一个 function call。

在 OpenAI 的返回结果里,通常会出现类似这样的结构:

{
  "type": "function_call",
  "call_id": "call_abc123",
  "name": "get_weather",
  "arguments": "{\"location\":\"上海\",\"units\":\"celsius\"}"
}

这里有几个字段特别关键。

name 是模型想调用的函数。arguments 是模型生成的参数,通常是 JSON 字符串。call_id 是这次调用的唯一标识,后面你把工具执行结果发回去时,要用它把结果和这次调用对应起来。

不同 API 的字段名字可能略有差异,但意思是一样的:模型这轮还没给最终答案,它在等你执行工具。

第三步:应用执行真实函数

拿到 function call 之后,应用要做的事情就很像一个普通的 RPC 分发器。

先根据 name 找到本地注册的函数,再解析 arguments,然后做参数校验、权限校验、业务调用、异常处理。

如果你直接对接 OpenAI API,这里通常要自己维护一个“工具名到 Java 方法”的映射。写成 Java 大概是这个意思:

Map<String, Function<Map<String, Object>, Object>> tools = Map.of(
        "get_weather",
        args -> weatherService.query(
                (String) args.get("location"),
                TemperatureUnit.valueOf((String) args.get("unit")))
);

FunctionCall call = response.output().functionCalls().getFirst();
Map<String, Object> arguments = objectMapper.readValue(call.arguments(), Map.class);

Object toolResult = tools.get(call.name()).apply(arguments);

不过如果项目里用 Spring AI,这层映射通常不用你手写。你把 Java 方法标上 @Tool,Spring AI 会根据方法名、参数和注解生成工具描述,并在模型需要时帮你分发调用。

这段代码看着简单,但真正上线时不要这么裸奔。

你至少要检查函数是否在白名单里,参数是否符合 schema,当前用户是否有权限调用这个工具。如果这个工具有副作用,比如退款、删除数据、发送通知,那还要做二次确认、审计日志和幂等控制。

模型可以建议执行动作,但不能成为权限系统本身。

这句话在 agent 类应用里尤其重要。

第四步:把工具结果发回模型

工具执行完之后,应用还不能直接把原始结果甩给用户。因为原始结果可能是内部字段、错误码、数据库结构,用户不一定看得懂。

所以你要把工具输出再发回模型,让模型基于真实结果组织最终回答。

直接对接 OpenAI API 时,通常会把工具结果包装成类似这样的结构:

{
  "type": "function_call_output",
  "call_id": "call_abc123",
  "output": "{\"temperature\": 26, \"unit\": \"celsius\", \"condition\": \"多云\"}"
}

注意这里的 call_id,它要对应前面模型返回的那次 function call。

然后模型会继续生成最终回复:

上海今天大约 26°C,多云。

这一步看起来只是“润色”,但其实很有价值。因为模型可以把工具结果和用户上下文结合起来,比如补充单位、解释字段、回答追问,甚至在一次任务里继续发起下一轮工具调用。

一次请求里可能有多个 function call

很多示例为了简单,只展示一个函数调用。但真实场景里,模型可能一次返回多个 tool call。

比如用户说:

帮我查一下上海和北京今天的天气,对比一下哪个更热。

模型可能会同时生成两个 get_weather 调用,一个查上海,一个查北京。你的应用需要能处理这种情况,而不是默认只取第一个。

还有一种更复杂的情况:模型先调用一个工具拿到中间信息,再基于中间结果调用另一个工具。

比如用户问:

帮我查一下最近一笔订单的物流状态。

模型可能先调用 get_latest_order,拿到订单号之后,再调用 get_shipping_status。这就是多轮工具调用。

所以工程实现上,通常会写一个循环:只要模型返回 function call,就执行工具并把结果回填;直到模型返回最终文本,循环才结束。

工程落地时要想清楚的事

不要把所有工具都暴露给模型

前面一直在说“把工具给模型,模型自己判断要不要调用”。但这里有个前提:你到底给了模型哪些工具?

模型只能在你提供的工具集合里做选择。你给它天气工具,它才可能查天气;你给它订单工具,它才可能查订单;你什么工具都不给,它就只能正常生成文本。

所以工程上真正要控制的,往往不是“模型能不能调用工具”这个抽象问题,而是“当前场景允许模型看到哪些工具”。

在产品里,按钮、页面状态、用户权限、业务流程,往往比一句 prompt 更可靠。能用 Java 代码确定的地方,就别全靠模型猜。

比如一个普通聊天场景,可以完全不挂工具:

public String chat(String message) {
    return chatClient.prompt()
            .user(message)
            .call()
            .content();
}

如果用户进入了天气助手场景,再把天气工具挂上去:

public String askWeather(String message) {
    return chatClient.prompt()
            .user(message)
            .tools(weatherTools)
            .call()
            .content();
}

这时候模型会自己判断:用户问“上海今天多少度”,它就调用 WeatherTools#getWeather;用户只是说“谢谢”,它就不一定需要调用工具。

如果是订单详情页这种业务状态已经很明确的场景,就不要把一堆无关工具都暴露出去。只暴露当前页面允许的工具:

public String askOrderQuestion(String orderId, String message) {
    return chatClient.prompt()
            .system("""
                    你正在订单详情页回答用户问题。
                    当前订单号是 %s。
                    只能回答和当前订单相关的问题。
                    """.formatted(orderId))
            .user(message)
            .tools(orderStatusTools)
            .call()
            .content();
}

这个例子里,模型不需要自己从用户输入里猜 orderId,订单号由后端确定。模型只负责理解用户是不是在问订单状态,真正的上下文控制交给 Java 代码。

所以在 Spring AI 里可以记住一个简单原则:让模型在“你允许的工具集合里”选择工具,而不是把所有工具都丢给模型自由发挥。

参数约束不能只靠模型

function call 最吸引人的地方,是它可以让模型输出结构化参数。但结构化不等于永远正确。

即使你写了 JSON Schema,也建议在服务端再校验一遍。

原因很简单:模型输出是输入源,不是可信边界。用户可能 prompt injection,模型可能误选工具,参数也可能语义上不符合业务要求。

举个例子,schema 可以限制 units 必须是 celsiusfahrenheit,但它没法判断当前用户是否有权限查询某个企业账号的数据。这个权限判断必须由你的业务系统完成。

所以比较稳的做法是:

工具 schema 负责减少模型犯错,服务端校验负责兜住安全边界,业务代码负责保证动作可控。

function call 不适合做什么?

function call 很强,但不是万能胶。

如果只是让模型改写文案、总结内容、解释一段代码,其实没必要引入工具。工具越多,token 成本越高,模型选择工具的空间也越大,误调用概率也会上升。

如果某个参数你在代码里已经知道,也不要让模型再填一遍。

比如用户在订单详情页点了“申请退款”,页面上下文里已经有 order_id。这时工具最好设计成 submit_refund(),由服务端从会话或页面状态里拿订单号,而不是让模型从自然语言里再生成一个 order_id

用大白话说就是:模型只负责它擅长的“理解意图和补全语义”,确定性上下文应该交给代码。

如何定义一个好的 function call

定义 function call 的时候,最容易犯的错误是把它当成普通接口文档写。普通接口文档是给开发者看的,开发者看不懂还可以问你;function call 的描述是给模型看的,模型只能根据你给的名字、描述和参数结构来判断要不要调用、怎么调用。

所以一个好的 function call,第一件事是名字要清楚。

get_weatherquery_order_statussubmit_refund_request 这种名字,模型一看就知道大概是干什么的。反过来,processhandledoActionqueryV2 这种名字,对模型就很不友好。它不是你团队里的老同事,不知道这些内部缩写背后代表什么。

第二个关键点是描述要写具体。

不要只写“查询天气”,最好写清楚什么时候用、什么时候不用。比如 Spring AI 里的 @Tool 可以这样写:

@Tool(description = "查询指定城市的实时天气。只有当用户询问实时天气、温度、天气状况时才调用")
public WeatherResult getWeather(
        @ToolParam(description = "城市名称,例如:上海、北京、杭州") String location,
        @ToolParam(description = "温度单位") TemperatureUnit unit) {
    return weatherService.query(location, unit);
}

这段描述里有一个很重要的信息:只有当用户询问实时天气、温度、天气状况时才调用。它不只是解释函数能做什么,也在告诉模型边界在哪里。

参数设计也要尽量扁平。

很多后端开发者习惯设计一个大对象,把各种上下文都塞进去。对普通接口来说这没问题,但对模型来说,嵌套越深、字段越多,填错的概率越高。能用两个简单参数表达清楚,就不要设计成三层嵌套对象。

比如天气查询里,locationunit 就够了,没必要搞成这样:

public record WeatherQuery(
        RequestContext context,
        LocationInfo locationInfo,
        DisplayOptions displayOptions) {
}

这类结构对模型不友好。模型要先理解 contextlocationInfodisplayOptions 的边界,再决定每个对象里怎么填。字段一多,它就容易犹豫,也容易胡填。

参数数量也别太多。

如果一个工具需要十几个参数才能调用,通常说明这个工具对模型来说太粗糙了。你可以反过来想:一个人只看函数名和参数描述,能不能稳定填对这些参数?如果人都要翻半天文档,模型也很难一次填准。

能用枚举限定的参数,就不要让模型自由发挥。

在 Java 里这点很好做,直接用 enum:

public enum TemperatureUnit {
    CELSIUS, FAHRENHEIT
}

这样比让模型随便输出 "摄氏度""c""Celsius""degree_c" 稳得多。枚举的价值不只是限制输入,更是在告诉模型:这里就这几个合法选项,不要自己造词。

如果某个工具很容易被误用,可以在描述里适当加一点 few-shot。

few-shot 不一定非要写成很长的样例,短一点也可以。比如:

@Tool(description = """
        查询订单物流状态。
        当用户问“订单到哪了”“快递进度”“物流状态”时使用。
        不要用它查询订单金额、退款状态或商品详情。
        示例:用户说“帮我看看订单 A123 到哪了”,orderId 应为 A123。
        """)
public ShippingStatus queryShippingStatus(
        @ToolParam(description = "订单号,例如 A123") String orderId) {
    return shippingService.query(orderId);
}

这里的 few-shot 不是为了凑字数,而是为了帮模型区分相似工具。尤其是你有 query_order_statusquery_shipping_statusquery_refund_status 这类长得很像的工具时,例子会很有用。

错误提示也要对模型友好。

很多后端接口出错时喜欢返回 500PARAM_ERRORINVALID_REQUEST。这些对程序员还行,对模型不够友好。因为模型拿到工具结果后,还要继续给用户组织回答。如果工具只返回一个冷冰冰的错误码,模型很可能不知道下一步该问用户什么。

更好的返回方式是把错误原因和建议动作说清楚:

public record ToolError(
        String code,
        String message,
        String suggestion) {
}

比如订单不存在时,不要只返回 ORDER_NOT_FOUND,可以返回:

{
  "code": "ORDER_NOT_FOUND",
  "message": "没有找到这个订单",
  "suggestion": "请让用户确认订单号是否输入正确"
}

这样模型就知道应该追问用户,而不是硬编一个结果。

返回值也要让模型能看懂。

工具返回结果不是只给代码看的,后面还要交给模型继续生成自然语言回答。所以返回值里最好包含语义明确的字段,而不是内部系统字段。

比如不要返回:

{
  "s": 1,
  "t": 26,
  "c": "cloudy"
}

模型当然也可能猜出来,但没必要增加这个成本。更好的返回是:

{
  "location": "上海",
  "temperature": 26,
  "unit": "CELSIUS",
  "condition": "多云"
}

最后,不要把一个 function call 设计得太复杂。

一个工具最好只做一件边界清楚的事。查天气就查天气,查订单就查订单,退款就退款。不要设计一个 handle_user_request,里面既能查订单,又能退款,还能改地址、发短信、创建工单。

这种“万能工具”看起来省事,实际上是把复杂度全丢给模型。模型不仅要理解用户意图,还要在一个巨大的工具内部决定动作类型、参数组合和执行路径,出错概率会明显上升。

好的 function call,不是能力越大越好,而是边界越清楚越好。

工具数量也别一上来全量暴露。OpenAI 文档里也提到,函数定义会占用上下文 token。工具越多,模型越难选,成本也越高。工程上可以按场景加载工具,或者把低频工具延迟加载。

还有一点很关键:函数要符合“最小惊讶原则”。一个新人只看函数名、描述和参数,也应该大概知道怎么用。如果人看了都迷糊,模型大概率也会迷糊。

用 Spring AI 写一个完整的最小闭环

我们把前面的流程串起来,看一个最小闭环。

先写一个普通的 Spring Bean。真实项目里,这个工具方法里可以调用 Feign、RestClient、Repository,或者任何已有的业务服务。

import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;

@Component
public class WeatherTools {

    private final WeatherService weatherService;

    public WeatherTools(WeatherService weatherService) {
        this.weatherService = weatherService;
    }

    @Tool(description = "查询指定城市的实时天气。只有当用户询问实时天气、温度、天气状况时才调用")
    public WeatherResult getWeather(
            @ToolParam(description = "城市名称,例如:上海、北京、杭州") String location,
            @ToolParam(description = "温度单位") TemperatureUnit unit) {

        return weatherService.query(location, unit);
    }
}

返回值建议用明确的 DTO 或 record,不要直接返回一坨 Map。参数里能用枚举就用枚举,这样 Java 侧类型清楚,Spring AI 生成出来的工具 schema 也更稳定。

public enum TemperatureUnit {
    CELSIUS, FAHRENHEIT
}

public record WeatherResult(
        String location,
        int temperature,
        TemperatureUnit unit,
        String condition) {
}

public interface WeatherService {

    WeatherResult query(String location, TemperatureUnit unit);
}

然后在业务入口里注入 ChatClient 和工具 Bean:

import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;

@Service
public class WeatherAssistant {

    private final ChatClient chatClient;
    private final WeatherTools weatherTools;

    public WeatherAssistant(ChatClient.Builder chatClientBuilder, WeatherTools weatherTools) {
        this.chatClient = chatClientBuilder
                .defaultSystem("你是一个天气助手。需要实时天气时必须调用工具,不要编造天气数据。")
                .build();
        this.weatherTools = weatherTools;
    }

    public String ask(String userMessage) {
        return chatClient.prompt()
                .user(userMessage)
                .tools(weatherTools)
                .call()
                .content();
    }
}

这时用户问“上海今天多少度?用摄氏度回答”,模型会根据 WeatherTools#getWeather 的描述生成工具调用参数,Spring AI 调用你的 Java 方法,拿到 WeatherResult 后再交回模型,最后生成自然语言回答。

这就是 function call 的本质闭环。

最后

function call 不是让大模型变成后端服务,也不是让模型绕过你的业务系统去执行动作。

它更像一个“自然语言到结构化调用”的翻译层。

模型负责理解用户意图,生成函数名和参数;应用负责执行函数、控制权限、处理异常、保证副作用可控。这个分工想清楚了,function call 就很好用。想不清楚,它就很容易从“智能助手”变成“不可控的自动化脚本”。

简单总结一下:

  • function call 的核心价值,是把自然语言转成结构化工具调用。
  • OpenAI API 只返回调用请求,不会替你执行真实函数。
  • 工具结果要回填给模型,模型再生成最终回答。
  • 多工具、多轮调用、参数校验、权限控制,是落地时必须考虑的工程问题。
  • 不要把确定性业务逻辑交给模型,能用代码确定的,就用代码确定。

把它当成一个严肃的工程接口,而不是一个“魔法开关”,基本就不会走偏。

参考资料