很多人第一次看到 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_weather、search_order、create_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 告诉模型参数应该长什么样。enum、required、additionalProperties 这些约束越清楚,模型越不容易生成奇怪参数。
如果开启 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 必须是 celsius 或 fahrenheit,但它没法判断当前用户是否有权限查询某个企业账号的数据。这个权限判断必须由你的业务系统完成。
所以比较稳的做法是:
工具 schema 负责减少模型犯错,服务端校验负责兜住安全边界,业务代码负责保证动作可控。
function call 不适合做什么?
function call 很强,但不是万能胶。
如果只是让模型改写文案、总结内容、解释一段代码,其实没必要引入工具。工具越多,token 成本越高,模型选择工具的空间也越大,误调用概率也会上升。
如果某个参数你在代码里已经知道,也不要让模型再填一遍。
比如用户在订单详情页点了“申请退款”,页面上下文里已经有 order_id。这时工具最好设计成 submit_refund(),由服务端从会话或页面状态里拿订单号,而不是让模型从自然语言里再生成一个 order_id。
用大白话说就是:模型只负责它擅长的“理解意图和补全语义”,确定性上下文应该交给代码。
如何定义一个好的 function call
定义 function call 的时候,最容易犯的错误是把它当成普通接口文档写。普通接口文档是给开发者看的,开发者看不懂还可以问你;function call 的描述是给模型看的,模型只能根据你给的名字、描述和参数结构来判断要不要调用、怎么调用。
所以一个好的 function call,第一件事是名字要清楚。
get_weather、query_order_status、submit_refund_request 这种名字,模型一看就知道大概是干什么的。反过来,process、handle、doAction、queryV2 这种名字,对模型就很不友好。它不是你团队里的老同事,不知道这些内部缩写背后代表什么。
第二个关键点是描述要写具体。
不要只写“查询天气”,最好写清楚什么时候用、什么时候不用。比如 Spring AI 里的 @Tool 可以这样写:
@Tool(description = "查询指定城市的实时天气。只有当用户询问实时天气、温度、天气状况时才调用")
public WeatherResult getWeather(
@ToolParam(description = "城市名称,例如:上海、北京、杭州") String location,
@ToolParam(description = "温度单位") TemperatureUnit unit) {
return weatherService.query(location, unit);
}
这段描述里有一个很重要的信息:只有当用户询问实时天气、温度、天气状况时才调用。它不只是解释函数能做什么,也在告诉模型边界在哪里。
参数设计也要尽量扁平。
很多后端开发者习惯设计一个大对象,把各种上下文都塞进去。对普通接口来说这没问题,但对模型来说,嵌套越深、字段越多,填错的概率越高。能用两个简单参数表达清楚,就不要设计成三层嵌套对象。
比如天气查询里,location 和 unit 就够了,没必要搞成这样:
public record WeatherQuery(
RequestContext context,
LocationInfo locationInfo,
DisplayOptions displayOptions) {
}
这类结构对模型不友好。模型要先理解 context、locationInfo、displayOptions 的边界,再决定每个对象里怎么填。字段一多,它就容易犹豫,也容易胡填。
参数数量也别太多。
如果一个工具需要十几个参数才能调用,通常说明这个工具对模型来说太粗糙了。你可以反过来想:一个人只看函数名和参数描述,能不能稳定填对这些参数?如果人都要翻半天文档,模型也很难一次填准。
能用枚举限定的参数,就不要让模型自由发挥。
在 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_status、query_shipping_status、query_refund_status 这类长得很像的工具时,例子会很有用。
错误提示也要对模型友好。
很多后端接口出错时喜欢返回 500、PARAM_ERROR、INVALID_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 只返回调用请求,不会替你执行真实函数。
- 工具结果要回填给模型,模型再生成最终回答。
- 多工具、多轮调用、参数校验、权限控制,是落地时必须考虑的工程问题。
- 不要把确定性业务逻辑交给模型,能用代码确定的,就用代码确定。
把它当成一个严肃的工程接口,而不是一个“魔法开关”,基本就不会走偏。