Java 架构师的 AI 工程笔记(三):Function Calling——让 LLM 长出"手脚"
这是 Spring AI Alibaba 系列的第三篇,聊聊怎么让 LLM 调用你的 Java 方法。 上一篇搞清楚了 ChatClient 的每一层,但 LLM 的回答全是"编"的——它没有能力查真实数据。这一篇解决一个核心问题:怎么让 LLM 调用我的 Java 方法?
前置知识:需要读完第 02 章 ChatClient 与 Advisor 链,了解 ChatClient 链式 API 和 Advisor 拦截器机制。
本篇速览:LLM 本身不能查数据库、调 API,Function Calling 让它"点菜"——LLM 决定调哪个 Java 方法、传什么参数,你的代码负责执行。这篇从 HTTP 裸调讲到 Spring AI 封装,最终实现一个带身份验证的机票查询工具。
最终效果预览:
curl "http://localhost:8082/api/flight/chat?q=北京飞上海明天的机票,帮我选最划算的&userId=user001"
综合对比 3 个航班:
- MU5678(东方航空)08:00-10:15 ¥520 ← 最便宜
- HU7890(海南航空)17:00-19:20 ¥550
- CA1234(中国国航)12:30-14:40 ¥680
推荐 MU5678,价格最低且是早班机。
LLM 自动调用了 searchFlights 查询航班,又调用 compareFlights 做对比,最后用自然语言总结推荐——这就是本章要实现的效果。
理论篇
一、先看问题——没有工具的 LLM 有多"瞎"
上一篇用 ChatClient 跑通了对话,但有一个问题一直没解决:LLM 的回答是"编"出来的。
试一下就知道。用上一章的 /chat 接口问一个需要真实数据的问题:
curl "http://localhost:8081/api/chat?q=北京飞上海明天有什么航班"
LLM 会一本正经地回答:
为您推荐以下航班:
1. CA1234 中国国航 08:00-10:15 ¥850
2. MU5678 东方航空 12:30-14:45 ¥720
3. HU7890 海南航空 17:00-19:20 ¥680
看起来像模像样?全是编的。 航班号、时间、价格没有一个是真的。LLM 是语言模型,它的能力是"根据上下文预测下一个 Token",不是"查数据库"。
这就引出了一个核心矛盾:
用户期望 LLM 能帮忙查真实数据,但 LLM 本身只会"说话",不会"做事"。
怎么解决?两个思路:
| 方案 | 做法 | 问题 |
|---|---|---|
| 塞进 Prompt | 把所有航班数据写进 System Prompt | 数据量大时撑爆 Context Window,且数据无法实时更新 |
| 给 LLM 装"手脚" | 让 LLM 能调用你的 Java 方法去查真实数据 | 就是本章要讲的 Function Calling |
第一种方案对于少量静态数据还行(后面 RAG 章节会深入),但对于航班这种实时、大量、需要参数化查询的数据,Function Calling 才是正解。
二、从 HTTP 响应说起——LLM 原来会"点菜"
知道了问题,来看解决方案。还是从 HTTP 层面入手——上一篇用 curl 调过通义千问的 API,当时返回的 JSON 里 finish_reason 是 "stop",表示模型说完了。
但如果我在请求里告诉模型"你有一个查机票的工具可以用",返回的 JSON 会变成另一种画风:
curl -X POST "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions" \
-H "Authorization: Bearer $AI_DASHSCOPE_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "qwen-plus",
"messages": [
{"role": "system", "content": "你是机票分析师"},
{"role": "user", "content": "北京飞上海明天有什么航班"}
],
"tools": [
{
"type": "function",
"function": {
"name": "searchFlights",
"description": "根据出发城市、目的城市和日期查询可用航班",
"parameters": {
"type": "object",
"properties": {
"from": {"type": "string", "description": "出发城市"},
"to": {"type": "string", "description": "目的城市"},
"date": {"type": "string", "description": "出发日期 yyyy-MM-dd"}
},
"required": ["from", "to", "date"]
}
}
}
]
}'
这次返回的 JSON 不一样了:
{
"choices": [
{
"message": {
"role": "assistant",
"content": null,
"tool_calls": [
{
"id": "call_abc123",
"type": "function",
"function": {
"name": "searchFlights",
"arguments": "{\"from\":\"北京\",\"to\":\"上海\",\"date\":\"2026-03-18\"}"
}
}
]
},
"finish_reason": "tool_calls"
}
]
}
注意三个关键变化:
content是null——模型没有直接回答问题finish_reason变成了"tool_calls"——模型在说"我需要调用一个工具才能回答"tool_calls数组——模型告诉你:调用searchFlights,参数是{from: "北京", to: "上海", date: "2026-03-18"}
LLM 没有执行任何代码。它只是输出了一段 JSON,说"我想调用这个函数,参数是这些"。 真正执行函数的是你的 Java 代码。
这就像餐厅里的顾客:LLM 是"只会点菜的顾客",你的代码是"端菜的服务员"。
2.1 完整的一次 Function Calling 要两轮 HTTP
理解了上面的 JSON,整个流程就清楚了:
第一轮请求:
你 → LLM:用户问"北京飞上海",这里有个 searchFlights 工具可以用
LLM → 你:我想调用 searchFlights(from=北京, to=上海, date=明天)
(你的代码执行 Java 方法,拿到真实数据)
第二轮请求:
你 → LLM:工具返回了 [MU5678 ¥520, CA1234 ¥680, HU7890 ¥550]
LLM → 你:为您查到3个航班,最便宜的是东航MU5678,520元...(finish_reason: stop)
每次 Function Calling 至少 2 轮 LLM 调用,Token 消耗翻倍。如果 LLM 连续调用多个工具(比如先查询再对比),轮次会更多——后面实战篇会看到一个 3 轮的真实案例。这是做成本估算时容易忽略的。
2.2 跟传统 IF-ELSE 对比
写过后端的人第一反应可能是:这不就是我用 if-else 判断意图然后调接口吗?
| 对比项 | 传统 IF-ELSE | Function Calling |
|---|---|---|
| 意图识别 | 关键词匹配,覆盖有限 | LLM 理解自然语言,覆盖无限变体 |
| 参数提取 | 正则 / NLP 规则,脆弱 | LLM 自动提取,对模糊表述鲁棒 |
| 复合意图 | 难以处理 | LLM 自主拆分并调用多个工具 |
| 维护成本 | 规则越写越多 | 只需注册工具,LLM 自动决策 |
| Java 类比 | if-else 路由 | Spring DispatcherServlet 自动分发 |
💡 关键认知:Function Calling 的魔法在 LLM 的决策能力,不在框架。框架只是帮你把 LLM 输出的 JSON 解析成 Java 方法调用——这件事用 Jackson + 反射自己写也能做到。
三、Spring AI 怎么封装 Function Calling
想一想:如果你来设计,怎么把一个 Java 方法"告诉"LLM?LLM 不认识 Java 字节码,从上面的 HTTP 请求可以看到,它只看得懂 JSON Schema。那 Spring AI 需要做的事情就很明确了——把你的 Java 方法自动转成 JSON Schema,再把 LLM 返回的 JSON 参数反序列化回 Java 对象。
搞清楚了 HTTP 层面的原理,再来看 Spring AI 怎么帮我们处理这些脏活。
3.1 工具注册——LLM 看到的是 JSON Schema,不是你的代码
LLM 不知道你写了什么 Java 类。它看到的是 HTTP 请求里的 tools 数组,每个元素是一个 JSON Schema。Spring AI 做的第一件事就是把你的 Java 方法转换成 JSON Schema。
你写一个 Record:
public record FlightQuery(
@JsonProperty(required = true) String from,
@JsonProperty(required = true) String to,
@JsonProperty(required = true) String date
) {}
Spring AI 帮你生成这个 JSON:
{
"name": "searchFlights",
"description": "根据出发城市、目的城市和日期查询可用航班",
"parameters": {
"type": "object",
"properties": {
"from": {"type": "string"},
"to": {"type": "string"},
"date": {"type": "string"}
},
"required": ["from", "to", "date"]
}
}
描述(description)写得好不好,直接决定 LLM 调用准确率。 因为 LLM 是根据描述来决定"这个工具能不能解决当前问题"的。
3.2 工具描述的好坏差别
差描述:
"查机票" → 太模糊,LLM 不确定什么时候该用
"searchFlights method" → 在重复方法名,没有新信息
好描述:
"根据出发城市、目的城市和日期查询可用航班,返回航班号、时间和价格"
→ 清楚说明了:能做什么 + 需要什么 + 返回什么
更好的描述(有边界约束):
"查询国内航班信息。支持按出发城市、目的城市和日期查询。
返回最多10条结果,包含航班号、航空公司、时间和价格。
仅支持查询未来30天内的航班,不支持国际航线。"
→ 额外说明了:限制条件 + 不能做什么
| 原则 | 说明 | 示例 |
|---|---|---|
| 说明能力 | 能做什么 | "查询国内航班价格和时间" |
| 说明边界 | 不能做什么 | "不支持国际航线和历史航班" |
| 说明输入 | 需要什么参数 | "需要出发城市、目的城市、日期" |
| 说明输出 | 返回什么 | "返回航班号、时间、价格列表" |
| 区分相似工具 | 多个工具时要有辨识度 | "查询航班" vs "比较航班" vs "预订航班" |
3.3 三种注册方式
Spring AI 提供了三种注册工具的方式,按使用场景选:
方式一:@Bean + Function(最简单)
// 示意代码——Bean 名称就是工具名
@Bean
@Description("根据出发城市、目的城市和日期查询可用航班")
public Function<FlightQuery, String> searchFlights() {
return query -> flightService.search(query.from(), query.to(), query.date());
}
方式二:FunctionToolCallback.builder()(需要 ToolContext 时用这个)
// 示意代码——支持 ToolContext 的写法
FunctionToolCallback.builder("searchFlights",
(FlightQuery query, ToolContext ctx) -> {
String userId = ctx.getContext().get("userId").toString();
return flightService.search(query, userId);
})
.description("查询航班信息")
.inputType(FlightQuery.class)
.build();
方式三:Per-Request 动态指定(不同场景暴露不同工具)
// 示意代码——查询场景只给只读工具,下单场景多给写工具
chatClient.prompt(q).toolNames("searchFlights").call(); // 只读
chatClient.prompt(q).toolNames("searchFlights", "bookFlight").call(); // 可写
💡 开发建议:方式一最简单但不支持 ToolContext。如果你的工具需要知道"谁在调用"(userId、权限),用方式二。
3.4 ToolContext——运行时上下文,不经过 LLM
上面三种注册方式解决了"LLM 怎么调用工具"的问题。但真实业务中还有一个绕不开的问题:工具方法需要知道"谁在调用"。
想象一个场景:用户说"帮我查我的历史订单"。你的 queryOrders 工具方法需要 userId 才能查询——但 userId 从哪来?
方案 A:把 userId 塞进 Prompt,让 LLM 当参数传给工具
// System Prompt 里写:当前用户 ID 是 U12345
// 然后在工具参数里加一个 userId 字段,指望 LLM 填上去
public record OrderQuery(String userId, String keyword) {}
这个方案有三个致命问题:
| 问题 | 后果 |
|---|---|
| LLM 可能编造 | 用户 A 对话时,LLM 可能把 userId 填成 U99999——查到别人的数据 |
| LLM 可能遗忘 | 多轮对话中 LLM 忘了 userId,传个 null 过来 |
| 信息泄露 | userId 出现在 Prompt 里,意味着它会被发送到 LLM 服务端,增加数据泄露风险 |
用 Java 类比就是:这相当于把 Session ID 写在 URL 参数里让前端自己传——你信任前端吗?
方案 B:ToolContext 旁路传递(Spring AI 的做法)
userId 不经过 LLM,不出现在 Prompt 里,不作为工具参数——而是从 Controller 层直接"旁路"注入到工具方法中。LLM 完全看不到这个值。
ToolContext 的典型用途:
| 用途 | 示例 |
|---|---|
| 用户身份 | "userId": "U12345" |
| 权限控制 | "role": "admin" |
| 会话信息 | "sessionId": "S-abc" |
| 业务参数 | "currency": "CNY" |
| 审计追踪 | "traceId": "T-001" |
⚠️ 安全提醒:userId、权限等敏感信息永远走 ToolContext,不要让 LLM 传递。ToolContext 中的数据不会发送给 LLM,LLM 看不到这些信息。
3.5 returnDirect——跳过第二轮 LLM 调用
前面说过 Function Calling 至少要两轮 HTTP——第一轮 LLM 返回 tool_calls,第二轮 LLM 基于工具结果生成自然语言回答。但有些场景,工具结果本身就是最终答案,不需要 LLM 再"润色"一遍。
典型场景:
| 场景 | 工具返回 | 需要 LLM 润色吗? |
|---|---|---|
| 查航班列表 | 结构化航班数据 | 需要——LLM 组织成推荐文案 |
| 查订单状态 | "已发货,预计 3 月 20 日到达" | 不需要——直接给用户就行 |
| 生成报表链接 | "report.example.com/xxx" | 不需要——链接直接返回 |
对于"不需要"的场景,多一轮 LLM 调用纯属浪费 Token 和时间。returnDirect 就是解决这个问题的:
// 方式一:@Tool 注解(推荐)
@Tool(description = "查询订单状态", returnDirect = true)
public String queryOrderStatus(String orderId) {
return orderService.getStatus(orderId); // 结果直接返回给用户
}
// 方式二:FunctionToolCallback + ToolMetadata
FunctionToolCallback.builder("queryOrderStatus", (OrderQuery q) -> {
return orderService.getStatus(q.orderId());
})
.description("查询订单状态")
.inputType(OrderQuery.class)
.toolMetadata(ToolMetadata.builder().returnDirect(true).build())
.build();
设了 returnDirect = true 后,源码里 internalCall() 的递归会被打断——执行完工具就直接返回,不再发第二轮请求给 LLM。省一轮 HTTP = 省 Token + 省时间。
💡 开发建议:默认不设 returnDirect(让 LLM 润色回答,体验更好)。只在工具结果已经是"最终答案"且不需要自然语言包装时才开启。开启后的代价是用户看到的是工具原始输出,不够友好。
3.6 Tool Argument Augmenter——让 LLM "解释"自己的工具调用
ToolContext 解决了"怎么往工具里注入运行时信息",但有一个方向它覆盖不了——怎么让 LLM 在调用工具时说明自己的推理过程?
在调试 Agent 时经常遇到一个困境:LLM 调了 searchFlights,传了参数 {from: "北京", to: "上海"},但为什么选这个工具?为什么提取的参数是这些?日志里只有调用记录,没有推理过程。
AugmentedToolCallbackProvider 解决了这个问题——在工具的 JSON Schema 里注入额外字段(比如"推理过程"、"置信度"),LLM 填完工具参数时顺便填上这些字段,你的代码就能拿到 LLM 的决策解释。
// 1. 定义你想让 LLM 额外填写的字段
public record AgentThinking(
@ToolParam(description = "调用这个工具的理由", required = true)
String reasoning,
@ToolParam(description = "对参数提取的置信度: high/medium/low", required = false)
String confidence
) {}
// 2. 用 AugmentedToolCallbackProvider 包装现有工具
AugmentedToolCallbackProvider<AgentThinking> provider = AugmentedToolCallbackProvider
.<AgentThinking>builder()
.toolObject(myFlightTools) // 你的 @Tool 注解类
.argumentType(AgentThinking.class) // 额外字段定义
.argumentConsumer(event -> { // 拦截额外字段
AgentThinking thinking = event.arguments();
log.info("LLM 推理: {} | 置信度: {}", thinking.reasoning(), thinking.confidence());
})
.removeExtraArgumentsAfterProcessing(true) // 传给工具前删掉额外字段
.build();
// 3. 注册到 ChatClient
ChatClient chatClient = builder
.defaultTools(provider)
.build();
工作原理:框架在发给 LLM 的 JSON Schema 里自动多加了 reasoning 和 confidence 两个字段。LLM 填工具参数时一并填上。argumentConsumer 回调拿到这些值后,框架再把它们从参数里删掉(removeExtraArgumentsAfterProcessing),确保你的原始工具方法签名不用改。
| 对比 | ToolContext | Tool Argument Augmenter |
|---|---|---|
| 方向 | 代码 → 工具(注入 userId 等) | LLM → 代码(获取推理过程) |
| LLM 是否可见 | 不可见 | 可见(作为 Schema 字段) |
| 用途 | 身份、权限、会话信息 | 推理解释、置信度、记忆笔记 |
| 版本要求 | Spring AI 1.0+ | Spring AI 1.1.3+ |
⚠️ 注意:
AugmentedToolCallbackProvider在 Spring AI 1.1.3+ 版本中提供(org.springframework.ai.tool.augment包)。如果你用的是 1.1.2.2,需要升级或等后续版本。当前项目中暂不引入,但了解这个方向很重要——它是 Explainable AI Agent 的基础设施。
3.7 Tool Search Tool——工具太多时的动态发现
FAQ 里提到过工具数量上限的问题——qwen-plus 可靠处理 10-15 个工具,多了就不准。但真实的企业系统可能有几十甚至上百个工具(特别是接入了多个 MCP Server 之后)。
把所有工具定义塞进每次请求的 JSON Schema 里,有两个代价:
- Token 消耗暴增——每个工具定义大约 200-500 Token,30 个工具就多 6K-15K Token
- 选择准确率下降——工具越多,LLM 越容易选错
Spring AI 社区提供了一个方案:Tool Search Tool——一个"搜索工具的工具"。
传统方式:
请求 = 用户消息 + 30 个工具定义(15K Token)
→ LLM 从 30 个里选一个(容易选错)
Tool Search Tool 方式:
请求 = 用户消息 + 1 个搜索工具定义(200 Token)
→ LLM 先调搜索工具:"我需要查航班的工具"
→ 搜索返回 2 个匹配工具:searchFlights、compareFlights
→ LLM 再调具体工具
Token 节省实测数据(来自 Spring AI 官方博客):
| 模型 | Token 节省 |
|---|---|
| OpenAI | 34% |
| Anthropic | 64% |
| Gemini | 60% |
依赖和用法:
<!-- Spring AI 社区项目,非核心包 -->
<dependency>
<groupId>org.springaicommunity</groupId>
<artifactId>tool-search-tool</artifactId>
<version>2.0.0</version>
</dependency>
<dependency>
<groupId>org.springaicommunity</groupId>
<artifactId>tool-searcher-lucene</artifactId>
<version>2.0.0</version>
</dependency>
// 1. 创建工具搜索器(Lucene 全文搜索)
@Bean
ToolSearcher toolSearcher() {
return new LuceneToolSearcher(0.4f); // 相似度阈值
}
// 2. 创建 Advisor
@Bean
ToolSearchToolCallAdvisor toolSearchAdvisor(ToolSearcher toolSearcher) {
return ToolSearchToolCallAdvisor.builder()
.toolSearcher(toolSearcher)
.build();
}
// 3. 注册到 ChatClient——注意用 defaultTools 而不是 toolNames
@Bean
ChatClient chatClient(ChatClient.Builder builder,
ToolSearchToolCallAdvisor advisor) {
return builder
.defaultTools(allMyTools) // 所有工具注册进来,但不会全部发给 LLM
.defaultAdvisors(advisor) // Tool Search Advisor 拦截请求
.build();
}
工作流程:所有工具被索引但不发给 LLM → LLM 只看到一个 searchTools 工具 → LLM 描述需求 → 搜索返回匹配的工具定义 → LLM 调用具体工具。
什么时候用:工具数量超过 15 个,或者接入了多个 MCP Server 导致工具膨胀。工具少于 10 个时没必要引入,反而多了一轮 LLM 交互。
💡 开发建议:提供了三种搜索器——
LuceneToolSearcher(关键词匹配)、VectorstoreToolSearcher(语义搜索)、RegexToolSearcher(正则匹配)。工具描述写得好的场景用 Lucene 就够了,描述质量参差不齐时用 VectorStore 语义搜索更鲁棒。
3.8 异步工具——当前的限制
你可能会想:如果一个工具需要调用外部 API,响应时间 3 秒,能不能用 CompletableFuture 异步执行?
目前不行。 Spring AI 1.1.2.2 明确不支持异步工具:
Method tools don't support
CompletableFuture,Future, reactive types (Mono,Flux), or functional types as parameters or return types. —— Spring AI 官方文档
这意味着所有工具方法必须是同步的。如果 LLM 一次调用两个工具,它们会串行执行,不是并行。
当前的绕行方案:
// 工具方法内部可以用 CompletableFuture 并行调用多个外部 API
@Tool(description = "查询多个平台的机票价格")
public String searchMultiPlatform(String from, String to, String date) {
// 内部并行——但工具方法本身仍然是同步返回
var ctrip = CompletableFuture.supplyAsync(() -> ctripApi.search(from, to, date));
var qunar = CompletableFuture.supplyAsync(() -> qunarApi.search(from, to, date));
CompletableFuture.allOf(ctrip, qunar).join(); // 等所有平台返回
return merge(ctrip.join(), qunar.join()); // 合并结果
}
工具方法签名是同步的,但内部可以用并行调用加速。Spring AI 有 open issue(#4755)在讨论原生异步工具支持,后续版本可能会加入。
四、翻源码——DashScopeChatModel 怎么处理 tool_calls
上一篇分析了 DashScopeChatModel.internalCall() 的三步走:buildRequestPrompt() → createRequest() → HTTP 调用。但当时跳过了一个关键分支:如果 LLM 返回的是 tool_calls 而不是普通文本,会发生什么?
翻了一下源码,核心逻辑在 internalCall() 方法的后半段:
// DashScopeChatModel.internalCall() 简化版源码
ChatResponse internalCall(Prompt prompt, ChatResponse previousChatResponse) {
// 第一步:发 HTTP 请求,拿到 LLM 的响应
ChatCompletion chatCompletion = this.retryTemplate.execute(ctx -> {
return this.dashscopeApi.chatCompletionEntity(request).getBody();
});
// 第二步:把 API 响应转成 Spring AI 的 ChatResponse
ChatResponse chatResponse = toChatResponse(chatCompletion, previousChatResponse);
// 第三步:关键判断——LLM 是否要求调用工具?
if (this.toolCallingManager.isToolExecutionRequired(chatResponse, prompt.getOptions())) {
// 3a. 执行工具调用
var toolExecutionResult = this.toolCallingManager.executeToolCalls(prompt, chatResponse);
if (toolExecutionResult.returnDirect()) {
// 工具标记了 returnDirect=true:结果直接返回给用户,不再调 LLM
return ChatResponse.builder()
.withGenerations(/* 包装工具结果 */)
.build();
}
// 3b. 递归!把工具结果加入对话历史,再调一次 LLM
return this.internalCall(
new Prompt(toolExecutionResult.conversationHistory(), prompt.getOptions()),
chatResponse // 把上一轮 response 传进去,用于累加 token 用量
);
}
// 普通文本回答,直接返回
return chatResponse;
}
这段代码有几个值得注意的点:
1. 递归调用实现多轮工具执行
internalCall() 调用自己——如果 LLM 第二轮还想调工具(比如先查航班再比价),它会继续递归。理论上可以无限递归,所以需要控制最大轮次。
2. toolCallingManager 负责实际执行
toolCallingManager.executeToolCalls() 做了这些事:
- 从
ChatResponse里取出tool_calls列表 - 根据函数名找到对应的
ToolCallback - 反序列化参数 JSON 为 Java 对象
- 通过反射调用你的函数
- 把结果包装成
ToolResponseMessage - 返回更新后的对话历史
3. returnDirect 标志
如果你的工具标记了 returnDirect = true,工具结果会直接返回给用户,跳过第二轮 LLM 调用。适用场景:工具结果已经是最终答案,不需要 LLM 再润色。
4. previousChatResponse 累加 Token
每次递归都传入上一轮的 response,用来累加 Token 用量统计。这样最终返回的 ChatResponse 里的 usage 是所有轮次的总和。
整个流程用 Java 思维理解就是:递归 + 策略模式。internalCall() 是递归主体,toolCallingManager 是策略实现。
4.1 对话历史在递归中的变化
用一个具体例子看对话历史怎么随递归变化的:
第一轮 internalCall():
messages = [
SystemMessage("你是机票分析师"),
UserMessage("北京飞上海明天的机票")
]
→ LLM 返回 tool_calls: searchFlights(...)
→ toolCallingManager 执行工具,得到航班数据
→ conversationHistory 更新为:
[
SystemMessage("你是机票分析师"),
UserMessage("北京飞上海明天的机票"),
AssistantMessage(tool_calls: [searchFlights(...)]), ← LLM 的工具调用请求
ToolResponseMessage("查到3个航班: MU5678 ¥520...") ← 工具执行结果
]
第二轮 internalCall()(递归):
messages = 上面的 4 条消息
→ LLM 看到工具结果,生成最终回答
→ finish_reason = "stop"
→ 返回 "为您查到3个航班,最便宜的是..."
💡 开发建议:如果你在日志里看到一次
.call()触发了多次 HTTP 请求,不要慌——这就是 Function Calling 的递归在工作。
4.2 常见陷阱
| 陷阱 | 现象 | 原因和解决方案 |
|---|---|---|
| 该调不调 | LLM 直接编造数据,不调用工具 | 工具描述太模糊,或 System Prompt 没强调"必须用工具" |
| 不该调而调 | 闲聊时也触发工具 | 描述加约束:"仅当用户明确要求查询航班时使用" |
| 参数提取错 | 日期、城市名解析不准 | 参数描述加格式示例 + 工具内做校验 |
| 无限递归 | LLM 反复调用同一个工具 | 设置 proxyToolCallsMaxIterations,或工具返回更明确的结果 |
| 工具超时 | 外部 API 慢导致整体超时 | 工具内设置超时 + 返回降级结果 |
实战篇
五、动手编码——机票查询工具
下面用 function-calling 模块完整实现一个带 Function Calling 的机票查询助手。
5.1 项目结构
function-calling/
├── pom.xml
└── src/main/java/com/ai/course/functioncalling/
├── FunctionCallingApplication.java
├── config/
│ └── FlightToolConfig.java // 工具注册
├── controller/
│ └── FlightAgentController.java // API 入口
├── model/
│ ├── FlightQuery.java // 查询参数(LLM 填充)
│ ├── CompareRequest.java // 对比参数
│ ├── FlightInfo.java // 航班数据
│ └── OrderStatusQuery.java // 订单查询参数(returnDirect 示例)
└── service/
└── MockFlightService.java // Mock 航班数据源
5.2 数据模型
package com.ai.course.functioncalling.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
/**
* 航班查询参数——LLM 会自动填充这些字段
* 每个字段的 description 会出现在 JSON Schema 里,影响 LLM 的参数提取准确率
*/
public record FlightQuery(
@JsonProperty(required = true)
@JsonPropertyDescription("出发城市名称,如'北京'、'上海'")
String from,
@JsonProperty(required = true)
@JsonPropertyDescription("目的城市名称")
String to,
@JsonProperty(required = true)
@JsonPropertyDescription("出发日期,格式 yyyy-MM-dd")
String date
) {}
package com.ai.course.functioncalling.model;
/**
* 航班信息——返回给 LLM 的数据
* 只包含 LLM 需要的字段,不暴露内部 ID、成本价等敏感信息
*/
public record FlightInfo(
String flightNo,
String airline,
String departure,
String arrival,
String departureTime,
String arrivalTime,
int price,
String aircraft
) {}
package com.ai.course.functioncalling.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyDescription;
import java.util.List;
/**
* 航班对比请求参数
*/
public record CompareRequest(
@JsonProperty(required = true)
@JsonPropertyDescription("要对比的航班号列表,如 ['MU5678', 'CA1234']")
List<String> flightNos
) {}
验证一下:到这一步可以先确认项目骨架没问题。运行
mvn compile -pl function-calling,应该编译通过。如果报@JsonPropertyDescription找不到,检查jackson-annotations版本是否 >= 2.15。
5.3 Mock 航班服务
package com.ai.course.functioncalling.service;
import com.ai.course.functioncalling.model.FlightInfo;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* Mock 航班数据源
* 生产环境替换为真实的航班 API 调用
*/
@Service
public class MockFlightService {
private static final List<FlightInfo> FLIGHTS = List.of(
new FlightInfo("MU5678", "东方航空", "北京", "上海", "08:00", "10:15", 520, "A320"),
new FlightInfo("CA1234", "中国国航", "北京", "上海", "12:30", "14:40", 680, "B737"),
new FlightInfo("HU7890", "海南航空", "北京", "上海", "17:00", "19:20", 550, "A330"),
new FlightInfo("CZ3456", "南方航空", "北京", "广州", "09:00", "12:00", 890, "A321"),
new FlightInfo("MU2345", "东方航空", "北京", "广州", "14:00", "17:10", 760, "B787"),
new FlightInfo("CA5678", "中国国航", "上海", "深圳", "10:00", "12:30", 620, "A320"),
new FlightInfo("ZH1234", "深圳航空", "上海", "深圳", "15:30", "17:50", 480, "B737"),
new FlightInfo("MU9999", "东方航空", "北京", "深圳", "07:30", "10:45", 920, "A350")
);
public List<FlightInfo> search(String from, String to, String date) {
return FLIGHTS.stream()
.filter(f -> f.departure().equals(from) && f.arrival().equals(to))
.toList();
}
public List<FlightInfo> getByFlightNos(List<String> flightNos) {
return FLIGHTS.stream()
.filter(f -> flightNos.contains(f.flightNo()))
.toList();
}
}
验证一下:运行
mvn compile -pl function-calling确认编译通过。MockFlightService 是纯 Java 逻辑,不依赖外部服务。
5.4 工具注册——重点是 ToolContext 的用法
package com.ai.course.functioncalling.config;
import com.ai.course.functioncalling.model.CompareRequest;
import com.ai.course.functioncalling.model.FlightInfo;
import com.ai.course.functioncalling.model.FlightQuery;
import com.ai.course.functioncalling.service.MockFlightService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.function.FunctionToolCallback;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.Comparator;
import java.util.List;
@Configuration
public class FlightToolConfig {
private static final Logger log = LoggerFactory.getLogger(FlightToolConfig.class);
/**
* 机票查询工具——带 ToolContext
* 用 FunctionToolCallback.builder() 而不是 @Bean + Function,因为需要 ToolContext
*/
@Bean
public ToolCallback searchFlightsFunction(MockFlightService service) {
return FunctionToolCallback.builder("searchFlights",
(FlightQuery query, ToolContext ctx) -> {
// 从 ToolContext 获取用户身份——不经过 LLM,安全可靠
String userId = ctx.getContext().getOrDefault("userId", "anonymous").toString();
log.info("[Tool] 用户 {} 查询航班: {} → {} ({})", userId, query.from(), query.to(), query.date());
List<FlightInfo> flights = service.search(query.from(), query.to(), query.date());
if (flights.isEmpty()) {
return "未找到从" + query.from() + "到" + query.to() + "的航班,建议换个日期试试";
}
// 返回值要为 LLM 优化——格式清晰、精简、无敏感信息
StringBuilder sb = new StringBuilder();
sb.append("查询到 ").append(flights.size()).append(" 个航班:\n");
for (FlightInfo f : flights) {
sb.append(String.format("- %s(%s) %s-%s ¥%d [%s]\n",
f.flightNo(), f.airline(),
f.departureTime(), f.arrivalTime(),
f.price(), f.aircraft()));
}
return sb.toString();
})
.description("根据出发城市、目的城市和日期查询可用航班。" +
"返回航班号、航空公司、起飞到达时间、价格和机型。" +
"仅支持国内航线,不支持国际航班。")
.inputType(FlightQuery.class)
.build();
}
/**
* 航班对比工具——不需要 ToolContext,用更简单的写法
*/
@Bean
public ToolCallback compareFlightsFunction(MockFlightService service) {
return FunctionToolCallback.builder("compareFlights",
(CompareRequest req) -> {
List<FlightInfo> flights = service.getByFlightNos(req.flightNos());
if (flights.size() < 2) {
return "至少需要2个有效航班号才能对比,请先查询航班获取航班号";
}
StringBuilder sb = new StringBuilder("航班对比结果:\n");
for (FlightInfo f : flights) {
sb.append(String.format("- %s(%s) %s-%s ¥%d 机型%s\n",
f.flightNo(), f.airline(),
f.departureTime(), f.arrivalTime(),
f.price(), f.aircraft()));
}
FlightInfo cheapest = flights.stream()
.min(Comparator.comparingInt(FlightInfo::price)).orElse(null);
FlightInfo earliest = flights.stream()
.min(Comparator.comparing(FlightInfo::departureTime)).orElse(null);
sb.append("\n推荐:");
if (cheapest != null) {
sb.append("最便宜 ").append(cheapest.flightNo())
.append("(¥").append(cheapest.price()).append(") ");
}
if (earliest != null) {
sb.append("最早出发 ").append(earliest.flightNo())
.append("(").append(earliest.departureTime()).append(")");
}
return sb.toString();
})
.description("对比多个航班的价格、时间和机型,给出最便宜和最早出发的推荐。" +
"需要先通过 searchFlights 获取航班号后才能调用此工具。")
.inputType(CompareRequest.class)
.build();
}
}
验证一下:编译通过后,确认
FlightToolConfig里的两个@Bean方法没有报红。如果ToolContext找不到,检查 Spring AI 版本是否 >= 1.0.0——老版本的FunctionToolCallback不支持双参数 lambda。
5.5 Controller——不同场景暴露不同工具
package com.ai.course.functioncalling.controller;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Flux;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Map;
@RestController
@RequestMapping("/api/flight")
public class FlightAgentController {
private final ChatClient chatClient;
public FlightAgentController(ChatClient.Builder builder) {
this.chatClient = builder
.defaultSystem("""
你是机票分析师「票小蜜」。
工具使用规则:
1. 用户查机票时,调用 searchFlights 工具获取真实航班数据
2. 用户要求对比时,先查询获取航班号,再调用 compareFlights 对比
3. 绝不编造航班信息
4. 信息不足时(缺出发地/目的地/日期),先追问
5. 闲聊不调用工具
当前日期:%s
""".formatted(LocalDate.now()))
.build();
}
/**
* 查询场景——只给只读工具
* 遵循最小权限原则:查询场景不暴露比价工具
*/
@GetMapping("/search")
public String search(@RequestParam String q,
@RequestParam(defaultValue = "guest") String userId) {
return chatClient.prompt(q)
.toolNames("searchFlights")
.toolContext(Map.of(
"userId", userId,
"requestTime", LocalDateTime.now().toString()
))
.call()
.content();
}
/**
* 智能对话——给查询 + 比价两个工具
* LLM 自主决定调用顺序:先查询再比价
*/
@GetMapping("/chat")
public String chat(@RequestParam String q,
@RequestParam(defaultValue = "guest") String userId) {
return chatClient.prompt(q)
.toolNames("searchFlights", "compareFlights")
.toolContext(Map.of(
"userId", userId,
"requestTime", LocalDateTime.now().toString()
))
.call()
.content();
}
/**
* 流式输出版本
*/
@GetMapping(value = "/stream", produces = "text/event-stream;charset=UTF-8")
public Flux<String> stream(@RequestParam String q,
@RequestParam(defaultValue = "guest") String userId) {
return chatClient.prompt(q)
.toolNames("searchFlights", "compareFlights")
.toolContext(Map.of("userId", userId))
.stream()
.content();
}
}
验证一下:运行
mvn compile -pl function-calling确认全部编译通过。到这一步,所有 Java 代码已经就位。
5.6 application.yml
spring:
ai:
dashscope:
api-key: ${AI_DASHSCOPE_API_KEY}
chat:
options:
model: qwen-plus
temperature: 0.3 # 工具调用场景用低 temperature,让 LLM 更确定性
server:
port: 8082
对比实验:把
temperature从0.3改成0.9,用同一个查询连续调 5 次。观察两点:(1) LLM 是否每次都调用工具?(2) 提取的参数是否一致?低 temperature 更确定性,这在 Agent 场景下很关键。
验证一下:启动应用
mvn spring-boot:run -pl function-calling,确认控制台输出Started FunctionCallingApplication,端口 8082 没有冲突。
5.7 运行测试
启动项目后,试几个场景:
场景一:查询航班
curl "http://localhost:8082/api/flight/search?q=北京飞上海明天有什么航班&userId=user001"
预期输出:
为您查到 3 个北京→上海的航班:
| 航班号 | 航空公司 | 起飞-到达 | 价格 | 机型 |
|--------|---------|----------|------|------|
| MU5678 | 东方航空 | 08:00-10:15 | ¥520 | A320 |
| HU7890 | 海南航空 | 17:00-19:20 | ¥550 | A330 |
| CA1234 | 中国国航 | 12:30-14:40 | ¥680 | B737 |
最便宜的是东方航空 MU5678,520元。需要帮您详细对比吗?
场景二:智能对话——LLM 自主编排多工具
curl "http://localhost:8082/api/flight/chat?q=北京飞上海明天的机票,帮我选最划算的&userId=user001"
这时 LLM 会自主决定先调 searchFlights 再调 compareFlights:
sequenceDiagram
participant User as 用户
participant LLM as 通义千问
participant T1 as searchFlights
participant T2 as compareFlights
User->>LLM: "北京飞上海明天的机票,帮我选最划算的"
LLM->>T1: searchFlights(from=北京, to=上海, date=2026-03-18)
T1-->>LLM: MU5678 ¥520, CA1234 ¥680, HU7890 ¥550
LLM->>T2: compareFlights([MU5678, CA1234, HU7890])
T2-->>LLM: 对比结果 + 最低价推荐
LLM-->>User: "综合考虑价格和时间,推荐MU5678..."
这就是 Agent 的雏形——LLM 具备了规划能力,自主决定先查询再对比。
眼见为实——看看真实的 3 轮 HTTP 交互
光看时序图不够直观,我加了一个 RestClientCustomizer 拦截器把每轮 HTTP 的请求/响应 body 打了出来。下面是实际运行日志(精简掉了时间戳和无关行):
第一轮:用户问题 + 工具定义 → LLM 决定调 searchFlights
// >>> POST https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation
// 请求体(精简)
{
"model": "deepseek-v3.1",
"input": {
"messages": [
{"role": "system", "content": "你是机票分析师「票小蜜」..."},
{"role": "user", "content": "北京飞上海的2026年3月18机票,帮我选最划算的"}
]
},
"parameters": {
"tools": [
{"type": "function", "function": {"name": "searchFlights", "description": "根据出发城市、目的城市和日期查询可用航班..."}},
{"type": "function", "function": {"name": "compareFlights", "description": "对比多个航班的价格、时间和机型..."}}
]
}
}
// <<< 响应体
{
"choices": [{
"finish_reason": "tool_calls", // 注意:不是 "stop"
"message": {
"content": "", // 没有文字回答
"tool_calls": [{
"function": {"name": "searchFlights", "arguments": "{\"date\":\"2026-03-18\",\"from\":\"北京\",\"to\":\"上海\"}"}
}]
}
}],
"usage": {"input_tokens": 512, "output_tokens": 31, "total_tokens": 543}
}
Spring AI 拿到 tool_calls 后,在 Java 侧执行 searchFlights 方法,拿到 3 个航班数据。
第二轮:拼入工具结果 → LLM 又决定调 compareFlights
// >>> 请求体(精简)——注意 messages 多了两条
{
"messages": [
{"role": "system", "content": "你是机票分析师「票小蜜」..."},
{"role": "user", "content": "北京飞上海的2026年3月18机票,帮我选最划算的"},
{"role": "assistant", "tool_calls": [{"function": {"name": "searchFlights", "arguments": "..."}}]},
{"role": "tool", "name": "searchFlights", "content": "查询到 3 个航班:MU5678 ¥520, CA1234 ¥680, HU7890 ¥550"}
]
}
// <<< 响应体
{
"choices": [{
"finish_reason": "tool_calls", // 还是 tool_calls!LLM 认为还需要对比
"message": {
"tool_calls": [{
"function": {"name": "compareFlights", "arguments": "{\"flightNos\":[\"MU5678\",\"CA1234\",\"HU7890\"]}"}
}]
}
}],
"usage": {"input_tokens": 629, "output_tokens": 28, "total_tokens": 657}
}
LLM 看到航班数据后,自主决定还需要调 compareFlights 来对比——这不是代码写死的,是 LLM 的推理结果。
第三轮:拼入对比结果 → LLM 生成最终回答
// >>> 请求体——messages 现在有 6 条(system + user + assistant/tool × 2 轮)
// <<< 响应体
{
"choices": [{
"finish_reason": "stop", // 终于是 "stop" 了
"message": {
"content": "根据查询结果,2026年3月18日北京飞上海共有3个航班:\n\n最划算推荐:MU5678(东方航空)\n- 价格最便宜:¥520\n- 出发时间最早:08:00..."
}
}],
"usage": {"input_tokens": 762, "output_tokens": 165, "total_tokens": 927}
}
三轮下来,总共消耗 543 + 657 + 927 = 2127 个 Token。如果没有工具只是一轮闲聊,可能只要 200 Token。这就是 Function Calling 的 Token 成本代价——后面 FAQ 会专门讨论这个 Trade-off。
💡 开发建议:想自己看这些日志?在项目的
config包下加一个RestClientCustomizer,用BufferingClientHttpRequestFactory+ClientHttpRequestInterceptor拦截请求/响应体。代码在本章配套模块的HttpLoggingConfig.java中。
场景三:闲聊不触发工具
curl "http://localhost:8082/api/flight/chat?q=你好,今天天气怎么样&userId=user001"
预期:LLM 直接回答,不调用任何工具。(因为 System Prompt 里写了"闲聊不调用工具")
动手试一试:把
searchFlights的 description 从详细版改成只写"查机票"三个字,再用相同的 prompt 调用——LLM 还能正确提取出发城市、目的城市和日期吗?改完跑一下,你会直观感受到工具描述质量对调用准确率的影响。
动手试一试:在
/search端点里,把.toolNames("searchFlights")改成.toolNames("searchFlights", "compareFlights"),然后问"北京飞上海明天有什么航班"——LLM 会不会"多此一举"调用 compareFlights?这个实验能帮你理解最小权限原则在工具暴露中的重要性。
5.8 返回值设计——为 LLM 而非人类设计
这一点在写工具方法时很容易忽略。对比一下好坏:
// 差:直接返回数据库实体——包含内部 ID、成本价等 LLM 不需要的字段
return flightRepository.findAll();
// 好:返回精简的、LLM 易理解的格式化文本
StringBuilder sb = new StringBuilder();
sb.append("查询到 ").append(flights.size()).append(" 个航班:\n");
for (FlightInfo f : flights) {
sb.append(String.format("- %s(%s) %s-%s ¥%d\n",
f.flightNo(), f.airline(), f.departureTime(), f.arrivalTime(), f.price()));
}
return sb.toString();
原则:精简(只返回 LLM 需要的字段)、可读(格式化文本比嵌套 JSON 对 LLM 更友好)、限量(最多返回 10 条,避免撑爆 Context Window)。
5.9 错误处理——永远不要让异常传到 LLM
// 工具方法内部必须 catch 所有异常
return query -> {
try {
if (query.from() == null || query.from().isBlank()) {
return "请提供出发城市"; // 参数校验——返回友好提示
}
List<FlightInfo> flights = service.search(query.from(), query.to(), query.date());
if (flights.isEmpty()) {
return "未找到符合条件的航班,建议换个日期试试"; // 空结果——给建议
}
return formatFlights(flights);
} catch (Exception e) {
log.error("航班查询失败: {}", e.getMessage(), e);
return "航班查询服务暂时不可用,请稍后重试"; // 异常——不暴露堆栈
}
};
如果让 Java 异常堆栈传到 LLM,LLM 会把 NullPointerException at com.ai.course... 当作"航班信息"返回给用户——那画面很尴尬。
5.10 returnDirect 实战——订单状态查询
理论篇 3.5 介绍了 returnDirect 的原理,这里用一个订单状态查询的例子动手验证。
在 FlightToolConfig 中新增一个带 returnDirect 的工具:
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.ToolMetadata;
import org.springframework.ai.tool.function.FunctionToolCallback;
import com.ai.course.functioncalling.model.OrderStatusQuery;
/**
* 订单状态查询——returnDirect = true
* 状态信息就是最终答案,不需要 LLM 再润色
*/
@Bean
public ToolCallback queryOrderStatusFunction() {
return FunctionToolCallback.builder("queryOrderStatus",
(OrderStatusQuery query) -> {
// 模拟查询订单状态
return switch (query.orderId()) {
case "ORD-001" -> "订单 ORD-001:已出票,航班 MU5678,3月19日 08:00 北京→上海";
case "ORD-002" -> "订单 ORD-002:待支付,请在30分钟内完成支付";
default -> "未找到订单 " + query.orderId() + ",请检查订单号";
};
})
.description("根据订单号查询机票订单状态。当用户问'我的订单怎么样了'时使用。")
.inputType(OrderStatusQuery.class)
.toolMetadata(ToolMetadata.builder().returnDirect(true).build()) // 关键:跳过第二轮 LLM
.build();
}
对应的参数 Record:
public record OrderStatusQuery(
@JsonProperty(required = true)
@JsonPropertyDescription("订单号,格式如 ORD-001")
String orderId
) {}
在 Controller 中暴露这个工具:
@GetMapping("/order")
public String orderStatus(@RequestParam String q,
@RequestParam(defaultValue = "guest") String userId) {
return chatClient.prompt(q)
.toolNames("queryOrderStatus")
.toolContext(Map.of("userId", userId))
.call()
.content();
}
验证一下:
curl "http://localhost:8082/api/flight/order?q=帮我查一下订单ORD-001的状态"
预期输出:
订单 ORD-001:已出票,航班 MU5678,3月19日 08:00 北京→上海
注意这个输出是工具的原始返回值,不是 LLM 润色后的。对比一下不设 returnDirect 时,LLM 会把它包装成类似"为您查询到订单 ORD-001 的状态,该订单已出票..."的自然语言。
对比实验:把
returnDirect(true)改成returnDirect(false)(或删掉 toolMetadata),同样的查询再跑一次。观察两个差异:(1) 回答是否被 LLM 重新组织了语言;(2) 控制台 Token 日志——returnDirect=true 省了一轮 LLM 调用,Token 消耗大约少 40%。
六、与机票比价 Agent 的集成
到这一章结束,我们的机票比价 Agent 已经有了这些能力:
| 章节 | 能力 | 状态 |
|---|---|---|
| 第 01 章 | 基本对话 + 流式输出 | ✅ |
| 第 02 章 | ChatClient 链式调用 + Advisor 拦截 | ✅ |
| 第 03 章 | 工具调用 + ToolContext 身份传递 | ✅ 本章完成 |
| 第 04 章 | Prompt 模板 + 结构化输出 | 下一章 |
现在的 Agent 可以:
- 理解用户的自然语言查询意图
- 自动调用
searchFlights获取真实航班数据 - 自动调用
compareFlights对比多个航班 - 通过 ToolContext 传递用户身份,按权限返回数据
- LLM 自主编排工具调用顺序
还缺什么?记忆。现在每次对话都是独立的,用户说"我想从北京飞上海" → "明天的" → "最便宜的那个",Agent 记不住上一轮的信息。
架构演进图——第 03 章后的系统
对比上一章,本章新增了 Tool 层(searchFlights、compareFlights)和 ToolContext 旁路:
对比第 02 章:上一章只有 ChatClient → LLM 的直线调用。本章加入了 Tool 层,LLM 具备了"做事"的能力。下一章(Prompt 工程)会在 ChatClient 和 LLM 之间加入 PromptTemplate 和结构化输出,让 LLM 的输入输出更可控。
七、FAQ 与踩坑记录
Q1:LLM 不调用工具,直接编造航班数据怎么办?
这是最常见的问题。排查顺序:
- 工具描述太模糊——检查
@Description或.description(),明确写"当用户查机票时使用此工具" - System Prompt 没强调——加上"绝不编造航班信息,必须通过 searchFlights 工具获取真实数据"
- 工具名拼写错误——
.toolNames("searchFlights")的字符串必须与FunctionToolCallback.builder("searchFlights", ...)的第一个参数一致 - temperature 过高——Agent 场景建议设为 0.1-0.3
Q2:ToolContext 中的值在工具方法里取出来是 null?
常见原因:
- key 名大小写不一致——Controller 里写
"userId",工具里写"UserId" - 用了
@Bean + Function<Input, Output>方式注册——这种方式不支持 ToolContext。换成FunctionToolCallback.builder()的双参数 lambda:(FlightQuery query, ToolContext ctx) -> { ... }
Q3:多工具场景下 LLM 调用顺序混乱?
比如先比价后查询。缓解方案:
- 在 compareFlights 的描述里加约束:"需要先通过 searchFlights 获取航班号后才能调用"
- 在 compareFlights 内部做防御:传入的航班号在数据中不存在时,返回"请先查询航班"
- 用更强的模型(qwen-max),推理能力越强编排越准确
Q4:工具数量多了以后 LLM 选择不准确?
不同模型的工具数量上限:
| 模型 | 可靠处理工具数 |
|---|---|
| qwen-max / GPT-4o | 20-30 个 |
| qwen-plus / GPT-4o-mini | 10-15 个 |
| qwen-turbo / 小模型 | 5-8 个 |
工具越多,选择准确率越低,Token 消耗越大。解决方案:按场景分组,每次只暴露相关工具(就是上面 Per-Request 的做法)。
Q5:Function Calling 的代价是什么?什么时候不该用?
这不是一个踩坑问题,而是方案选型时必须正视的 trade-off:
| 代价 | 说明 |
|---|---|
| Token 消耗翻倍 | 每次工具调用至少 2 轮 LLM 请求,tools 定义的 JSON Schema 本身也占 Token(3 个工具大约多 500-800 Token) |
| 延迟增加 | 2 轮 HTTP + 工具执行时间,p95 通常在 3-5 秒,比普通对话慢 2-3 倍 |
| 不确定性 | LLM 可能不调、错调、乱调工具,需要 Prompt 工程 + 防御逻辑兜底 |
| 调试困难 | 问题可能出在 Prompt、description、参数 Schema、工具实现任一环节,排查链路长 |
什么时候用 if-else 更好? 如果意图只有 3-5 种且格式固定(比如"查天气+城市名"),关键词匹配更快、更便宜、更可控。Function Calling 的优势在处理模糊表述和复合意图——当用户说"帮我找个明天下午北京飞上海便宜点的航班",关键词匹配需要写十几条规则,LLM 一句话搞定。
本章小结 & 下一章预告
本章知识点速查:
| 模块 | 核心知识点 | 一句话总结 |
|---|---|---|
| HTTP 层面 | tool_calls / finish_reason / 两轮调用 | LLM 不执行代码,只输出"我想调哪个函数、参数是什么" |
| 工具注册 | @Bean + Function / FunctionToolCallback.builder / Per-Request | 三种方式按需选,需要 ToolContext 就用 builder |
| ToolContext | 旁路传递 userId、权限 | 敏感信息不经过 LLM,安全可控 |
| returnDirect | 跳过第二轮 LLM 调用 | 工具结果是最终答案时省 Token 省时间 |
| Tool Argument Augmenter | 让 LLM 填写推理过程 | Explainable Agent 的基础,1.1.3+ 可用 |
| Tool Search Tool | 工具太多时动态发现 | 15+ 工具时 Token 节省 34%-64% |
| 异步工具 | 当前不支持原生异步 | 工具内部可用 CompletableFuture 并行 |
| 源码 | internalCall 递归 / toolCallingManager | 框架自动递归处理多轮工具调用 |
| 设计原则 | 描述质量 / 返回值精简 / 异常兜底 | 描述写得好不好,直接决定 LLM 调用准不准 |
下一章预告:Prompt 工程与结构化输出
下一篇聚焦两件事——怎么让 LLM "听话"按格式回答,怎么让 LLM 的输出直接变成 Java 对象。
这一章的工具函数返回的都是文本,下游代码如果要解析就很痛苦。下一篇用 BeanOutputConverter + PromptTemplate 解决这个问题:
- Prompt 设计四原则——System 写规则、User 写任务
- PromptTemplate 模板引擎——ST4 语法和源码解析
- BeanOutputConverter 源码——JSON Schema 生成 + Jackson 反序列化
- 实战:
.entity(FlightSearchResult.class)一行代码拿到结构化航班数据
延伸阅读
- Spring AI Tool Calling 官方文档 —— ToolCallback、ToolContext 的完整 API 参考
- OpenAI Function Calling Guide —— 协议层的设计原理,Spring AI 的 tool_calls 格式与此兼容
- 通义千问 Tool Use 文档 —— DashScope 的实现细节和模型差异
- Spring AI Alibaba GitHub —— 源码和示例工程
聊聊你的想法
几个开放性问题,欢迎在评论区讨论:
- 工具描述该谁写? 开发写的描述偏技术,产品写的偏业务。你觉得工具的 description 应该由开发还是产品来定义?有没有可能让 LLM 自己生成工具描述?
- 工具返回值是 String 好还是 JSON 好? 本章用的是格式化文本,但也有人主张返回结构化 JSON 让 LLM 自己组织语言。你在实际项目中倾向哪种?
- Function Calling vs MCP: 后面第 11 章会讲 MCP 协议,它和 Function Calling 的关系是什么?如果你已经了解 MCP,聊聊你觉得两者的边界在哪里。
- 你遇到过 LLM "该调不调"的问题吗? 什么场景下 LLM 死活不调用工具?你是怎么解决的——改描述、改 Prompt、还是换模型?
如果这篇文章对你有帮助,欢迎点赞收藏。有问题欢迎评论区交流。