CH 07工具调用(Tool Calling)
这一章让模型从「只能说话」变成「能干活」。工具调用是 RAG 之后第二大核心能力: RAG 让模型「知道得更多」,工具调用让模型「能做得更多」——查数据库、算价格、发邮件、调第三方接口。 但请记住一条铁律:模型永远只负责「决定调用什么」,真正的执行永远由你的 Java 代码负责。
7.1工具调用的本质:模型只会「举手」,不会「动手」
先用一个比喻理解:模型像一位坐在办公室里的顾问, 他知道很多东西,但不能出门。当需要查实时数据时,他会写一张便条(工具名 + 参数)递给你, 你跑出去把事情办完,把结果写回来给他,他再基于结果给你最终答复。
模型负责(3 件事)
- 判断「这个问题需不需要调工具」
- 选择调用哪个工具
- 从用户的话里抽出参数(可能不全,会追问)
你负责(4 件事)
- 真正执行那个方法(查库、调接口)
- 权限校验!模型说查就查?绝对不行
- 参数校验与兜底
- 把结果(或错误)格式化后回传
7.2完整时序:为什么一次问答会有两次模型调用
2.0 把它提升成了 Advisor 链里的一等公民
ToolCallingAdvisor:
它是个「递归 Advisor」,每迭代一次就重新进入下游 Advisor 链一次。
这意味着你现在可以:观察每一次迭代、在迭代之间做审批、替换整个执行策略、把工具调用和记忆/日志/评估组合起来。这个变化是 2.0 做 Agent 应用的地基,第 9 章会围绕它展开。
.internalToolExecutionEnabled(false)。
该方法连同 spring.ai.*.chat.internal-tool-execution-enabled 属性在 2.0 中已被彻底移除,
各 ChatModel 内部的工具执行循环也不再存在。想自己控制循环,改用
AdvisorParams.toolCallingAdvisorAutoRegister(false)(单次调用)
或 spring.ai.chat.client.tool-calling.enabled=false(全局)——详见 9.4 节。
7.3@Tool 注解:把 Java 方法暴露给模型
package com.example.springai.ch07;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.ai.tool.annotation.ToolParam;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.LocalDate;
@Component
public class OrderTools {
/**
* 一个工具方法。三个要素决定模型能不能用好它:
* ① 方法名 → 模型看到的工具名(尽量用动词+名词,语义清晰)
* ② description → ★ 最重要!模型靠它决定"什么时候用这个工具、不用那个"
* ③ 参数描述 → 模型靠它知道"这个参数从用户的话里怎么抽"
*/
@Tool(description = """
查询指定订单号的当前状态与预计发货时间。
使用时机:当用户询问某个订单的发货情况、物流状态、是否已发货时调用。
不要用于:查询商品信息、退款进度、账户余额。
""")
public OrderStatus queryOrderStatus(
@ToolParam(description = "订单号,纯数字字符串,例如 2024001")
String orderNo) {
// ★★★ 这里就是安全边界!绝不能相信模型传来的 orderNo 就是"当前用户的订单"
String currentUser = SecurityContextHolder.currentUserId();
if (!orderRepository.belongsTo(orderNo, currentUser)) {
// 返回明确错误,而不是抛异常——让模型能理解并礼貌回复用户
return new OrderStatus(orderNo, "NOT_FOUND", null,
"系统中不存在该订单,或该订单不属于当前用户");
}
Order order = orderRepository.findByNo(orderNo);
return new OrderStatus(order.orderNo(), order.status(),
order.promisedDate(), "查询成功");
}
/**
* 无参工具也可以(模型不需要传任何参数)。
* 典型场景:获取"今天日期"这类模型自己不知道的信息。
*/
@Tool(description = "获取当前的日期与时间。当用户说'今天''本周''最近'等相对时间时,必须先调用此工具获取真实日期。")
public String currentDateTime() {
return LocalDate.now() + " " + java.time.LocalTime.now().withNano(0);
}
/** 工具的返回类型:建议用 record,字段名会进入模型的上下文 */
public record OrderStatus(String orderNo, String status,
LocalDate promisedDate, String message) {}
}
写工具描述的「四要素」模板
| 要素 | 说明 | 示例 |
|---|---|---|
| 做什么 | 一句话说明功能,动词开头 | 查询指定订单号的当前状态与预计发货时间。 |
| 何时用 | 给模型明确的触发条件 | 当用户询问发货情况、物流状态时调用。 |
| 何时不用 | ★ 很多人漏掉,但极其重要 | 不要用于查询商品信息、退款进度、账户余额。 |
| 参数约束 | 格式、取值范围、必填性 | 订单号为纯数字字符串,例如 2024001。 |
7.4注册工具的三种方式
// ---------- 方式一:直接传对象(最简,适合手工指定) ----------
String answer = chatClient.prompt()
.user("订单 2024001 发货了吗?")
.tools(new OrderTools(), new DateTimeTools()) // 传实例
.call()
.content();
// ---------- 方式二:用 ToolCallbacks 显式转换(可控性更强) ----------
import org.springframework.ai.support.ToolCallbacks;
ToolCallback[] callbacks = ToolCallbacks.from(new OrderTools(), new DateTimeTools());
String answer2 = chatClient.prompt()
.user("订单 2024001 发货了吗?")
.toolCallbacks(callbacks)
.call()
.content();
// ---------- 方式三:默认挂载(推荐,业务代码最干净) ----------
@Bean
public ChatClient orderChatClient(ChatModel chatModel,
OrderTools orderTools,
DateTimeTools dateTimeTools) {
return ChatClient.builder(chatModel)
// 所有通过这个 client 发起的请求都能用这些工具
.defaultTools(orderTools, dateTimeTools)
.defaultSystem("你是订单助手。涉及订单数据时必须调用工具查询,禁止凭记忆回答。")
.build();
}
三种方式的选择
| 方式 | 适用 | 特点 |
|---|---|---|
.tools(bean) | 临时实验、工具数量少 | 最直观 |
.toolCallbacks(...) | 需要动态决定注册哪些工具(例如按权限) | 最灵活 |
defaultTools(...) | ✅ 生产代码 | 业务代码零污染 |
- ChatClient 会自动注册
ToolCallingAdvisor,位置固定在Ordered.HIGHEST_PRECEDENCE + 300。你不需要(也不应该)自己再注册一个—— 2.0 强制链中只能有一个工具 Advisor,重复注册会直接报错。 想换掉它,就提供一个自己的ToolCallingAdvisor.Builder<?>bean。 - 每次调用的
.tools()是追加,不是替换:它会把工具加到defaultTools之上,而不是覆盖。 toolNames()被删除了。1.x 那套「注册一个裸Functionbean, 再用名字解析」的机制(SpringBeanToolCallbackResolver)在 2.0 中已完全移除, 工具必须作为显式的ToolCallback注册。见下面的「方式四」。
2.0 给了这个问题的官方解法(见 7.4.1 的「方式五」): 接了很多 MCP Server、工具上百个时,打开
ToolSearchToolCallingAdvisor 做渐进式工具披露,
官方实测能省 34%~64% 的 Token 且准确率没有可测量的下降。
方式四(2.0 新增重点):函数式工具用 FunctionToolCallback
如果你要暴露的不是一个带 @Tool 的 POJO 方法,而是一个现成的 Function、lambda 或方法引用,
2.0 用 FunctionToolCallback。这是 1.x 里 FunctionCallback + toolNames() 的替代品:
// ❌ 1.x 的写法(2.0 已删除,编译不过)
// @Bean @Description("Get the weather in location")
// Function currentWeather() { return weatherService::getWeather; }
// chatClient.prompt().toolNames("currentWeather"); ← toolNames 没了
// ✅ 2.0:注册成显式的 ToolCallback bean
@Configuration(proxyBeanMethods = false)
class WeatherToolsConfig {
@Bean
ToolCallback currentWeather(WeatherService weatherService) {
return FunctionToolCallback.builder("currentWeather", weatherService::getWeather)
.description("查询指定城市的当前天气")
// ★ inputType 决定生成的 JSON Schema,必须给,否则模型不知道该传什么
.inputType(WeatherRequest.class)
.build();
}
}
// 注入后按名字使用(不再靠字符串解析)
@Autowired ToolCallback currentWeather;
chatClient.prompt()
.user("北京现在天气怎么样?")
.tools(currentWeather) // 直接传工具对象
.call()
.content();
三种定义风格最终都产出 ToolCallback,走同一条循环,可以随意混用:
| 风格 | 写法 | 适用 |
|---|---|---|
@Tool 声明式 | 在方法上加注解,.tools(new MyTools()) | ✅ 大多数情况,最省事 |
MethodToolCallback | MethodToolCallback.builder().toolDefinition(...).toolMethod(...).toolObject(...) | 方法源码不受你控制、需运行时动态构建工具 |
FunctionToolCallback | FunctionToolCallback.builder(name, fn).inputType(...) | 暴露现成的 Function / lambda / 方法引用 |
方式五:工具太多时,打开渐进式工具披露
这是 2.0 最实用的新能力之一。原理:不再把全部工具定义一次性塞给模型,
而是给模型一个内置的 toolSearchTool,让它用自然语言先"检索"出相关工具,
再只把这几个工具的定义发过去。
<!-- 加一个 starter 即可,不需要改任何 Java 代码 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-tool-search-advisor</artifactId>
</dependency>
# 打开开关
spring:
ai:
chat:
client:
tool-search-advisor:
enabled: true
# 索引类型:
# regex —— 默认,零额外依赖,按关键词匹配
# lucene —— 需要 org.apache.lucene:lucene-core,检索质量更好
# vector —— 需要 classpath 上有 VectorStore,语义检索最准
tool-index-type: regex
- 工具数 < 15:不要开。多一层检索反而增加延迟和不确定性。
- 工具数 15~50:可以试,用评测集对比开了之后准确率有没有下降。
- 工具数 > 50,或接了很多 MCP Server:✅ 强烈建议开。 官方给的场景是「一个会话聚合成百上千个 MCP 工具定义」,不开的话每次请求光工具描述就要吃掉 10~21K token。这不是优化,而是能不能用的问题。
方式六:给工具调用加上次数上限(防失控)
2.0 的 DefaultToolCallingManager 内置了调用次数保护,默认就已生效:
单个工具每轮最多 40 次,所有工具合计最多 150 次。超过会抛 ToolCallLimitExceededException。
# 用配置调整
spring:
ai:
tools:
limits:
max-calls-per-tool-default: 40 # -1 表示不限制
max-total-tool-calls: 150
on-limit-exceeded: THROW # 或 RETURN_ERROR_RESPONSE(把错误当结果回给模型)
// 或者编程式精细控制
ToolCallingManager manager = ToolCallingManager.builder()
.maxCallsPerTool(40)
.maxCallsPerTool("search", 10) // 给某个工具单独设更小的上限
.excludeToolFromLimit("getCurrentDateTime") // 这个工具不限制
.maxTotalToolCalls(150)
.build();
// 装到自己的 ToolCallingAdvisor 上(会替换掉自动注册的那个)
@Bean
ToolCallingAdvisor.Builder<?> toolCallingAdvisorBuilder() {
return ToolCallingAdvisor.builder()
.toolCallingManager(manager)
.advisorOrder(Ordered.HIGHEST_PRECEDENCE + 300);
}
7.5返回值、ToolContext 与「结果直出」
工具返回值怎么设计
// ❌ 不好:返回一个复杂的内部实体,字段名模型看不懂
public OrderEntity query(String no) { return repo.find(no); }
// 返回 {"id":1,"ordNo":"2024001","sta":2,"gmtCreate":1709...}
// 模型看到 sta=2 完全不知道是什么意思
// ✅ 好:返回语义清晰的 DTO,字段名自解释,必要时附带说明文本
public record OrderStatus(
String orderNo, // 订单号
String status, // 已翻译成中文:未发货/已发货/已完成
String promisedDate, // 预计发货日期 yyyy-MM-dd
String message // 一句话说明,便于模型组织语言
) {}
2 翻译成「已发货」,把时间戳格式化成 2024-03-08,
把金额带上单位。这一步做得越贴心,模型最终的回答就越准确。
ToolContext:把上下文信息传进工具(不经过模型)
import org.springframework.ai.chat.model.ToolContext;
/**
* ★★ ToolContext 是一个非常重要的机制:
* 它允许你把「不能也不应该让模型看到/控制」的信息传给工具,
* 例如:当前登录用户的 ID、租户 ID、请求追踪 ID。
*
* 这些值由你的代码在调用时注入,模型无法伪造——这是安全性的关键。
*/
@Tool(description = "查询当前用户自己的全部订单列表")
public List<OrderStatus> queryMyOrders(ToolContext toolContext) {
// 从上下文取当前用户,而不是从模型的参数里取!
String userId = (String) toolContext.getContext().get("userId");
String tenantId = (String) toolContext.getContext().get("tenantId");
return orderRepository.findByUser(tenantId, userId).stream()
.map(o -> new OrderStatus(o.orderNo(), o.status(),
String.valueOf(o.promisedDate()), ""))
.toList();
}
调用时把上下文注入进去:
String answer = chatClient.prompt()
.user("帮我看看我有哪些订单")
// ★ 由服务端注入,模型看不到也改不了
.toolContext(Map.of(
"userId", SecurityContextHolder.currentUserId(),
"tenantId", SecurityContextHolder.currentTenantId()))
.call()
.content();
returnDirect:让工具结果直接返回给用户
/**
* 有些工具的返回值本身就是最终答案,不需要模型再加工一遍。
* 例如"导出一份明细报表"——加工反而会改动数据、增加成本。
* returnDirect = true 时,工具结果会跳过第二次模型调用,直接返回。
*/
@Tool(description = "导出指定订单的完整明细报表(CSV 文本)", returnDirect = true)
public String exportOrderDetail(@ToolParam(description = "订单号") String orderNo) {
return reportService.toCsv(orderNo);
}
7.6参数校验、异常处理与安全边界
工具里的错误:返回给模型,而不是抛给用户
/**
* 工具执行失败时的两种处理方式,效果差别巨大:
*
* ❌ 抛异常 → Spring AI 会把异常信息带回给模型(或直接失败),
* 用户看到的是"系统错误",体验极差。
*
* ✅ 返回结构化的错误对象 → 模型能"读懂"错误,并组织成礼貌的话回复用户,
* 例如:"没有找到订单 2024001,请确认订单号是否正确?"
*/
@Tool(description = "查询指定订单号的当前状态与预计发货时间")
public OrderStatus queryOrderStatus(
@ToolParam(description = "订单号,纯数字字符串,例如 2024001") String orderNo) {
// ---------- ① 参数格式校验:模型可能传 "订单2024001" 或空值 ----------
if (orderNo == null || !orderNo.matches("\\d{6,20}")) {
return new OrderStatus(orderNo, "INVALID", null,
"订单号格式不正确,应为 6~20 位数字。请向用户确认订单号后重试。");
}
// ---------- ② 权限校验:绝不接受模型传来的用户身份 ----------
String userId = SecurityContextHolder.currentUserId();
if (!orderRepository.belongsTo(orderNo, userId)) {
// ★ 注意这里不返回"订单属于别人"——那等于泄露了"这个订单存在"的信息
return new OrderStatus(orderNo, "NOT_FOUND", null,
"未查询到该订单。请确认订单号是否正确。");
}
// ---------- ③ 业务异常兜底:把技术异常翻译成业务语言 ----------
try {
Order order = orderRepository.findByNo(orderNo);
if (order == null) {
return new OrderStatus(orderNo, "NOT_FOUND", null, "未查询到该订单。");
}
return new OrderStatus(order.orderNo(), order.status(),
String.valueOf(order.promisedDate()), "");
} catch (Exception e) {
// 内部异常详情记日志,返回给模型的是"可读的错误说明"
log.error("查询订单 {} 失败", orderNo, e);
return new OrderStatus(orderNo, "ERROR", null,
"订单系统暂时不可用,请稍后重试或转人工客服。");
}
}
全局异常处理器:统一决定「异常要不要抛」
DefaultToolExecutionExceptionProcessor 现在的默认行为是:· 任意
RuntimeException → 把异常消息包装后发回给模型,
让模型有机会向用户解释、换参数重试或换一个工具;· 受检异常(checked exception)和
Error → 始终向上抛出,不会被吞掉。这个划分是有意设计的:能自愈的错误交给模型,程序性错误绝不能藏起来。
import org.springframework.ai.tool.execution.ToolExecutionException;
import org.springframework.ai.tool.execution.ToolExecutionExceptionProcessor;
/**
* 只有当默认策略不满足需求时,才需要自定义。
* 它是一个函数式接口,只有一个方法,所以可以直接写成 lambda。
*/
@Configuration
public class ToolConfig {
@Bean
public ToolExecutionExceptionProcessor toolExecutionExceptionProcessor() {
return exception -> {
// exception 里带着原始异常、工具定义等信息
log.error("工具执行失败:工具={} 原因={}",
exception.getToolDefinition().name(), exception.getMessage());
// ★ 返回值会作为"工具执行结果"回给模型。
// 所以这里的话术是"写给模型看的",要清楚、可行动,不要甩堆栈。
return "工具执行失败:" + exception.getMessage()
+ "。请向用户说明该功能暂时不可用,不要重复调用此工具。";
};
}
}
spring.ai.tools.throw-exception-on-error=true(默认 false)。
什么时候该设成 true?当工具的错误信息里可能含敏感内容时——
因为默认行为会把异常消息发给模型,而模型可能把它复述给用户。
例如数据库连接串、内部服务地址这类信息绝不能通过这条路泄露出去。
ToolCallback 时的注意事项:
如果你是自己实现 ToolCallback 而不是用 @Tool 注解,
执行失败时应该抛 ToolExecutionException——这是处理器期望的异常类型。
抛别的异常会绕过你的自定义逻辑。
工具的安全边界 Checklist
| 风险 | 错误做法 | 正确做法 |
|---|---|---|
| 越权读取 | 接受模型传入的 userId 去查数据 |
从 ToolContext / 登录态取用户身份,服务端强制过滤 |
| 提示词注入 | 把用户输入直接当指令执行 | 工具只做「参数化查询」,绝不拼接 SQL / 绝不做文件路径拼接 |
| 误删误改 | 提供 deleteOrder(orderNo) 这类破坏性工具直接给模型 |
破坏性操作必须「两步走」:先返回确认信息,用户确认后再执行(第 9 章 Human-in-the-loop) |
| 资源耗尽 | 工具里做全表扫描、调用无超时的外部接口 | 工具内部设超时(如 3 秒)、限制返回条数(如最多 50 条) |
| 重复执行 | 模型在 Agent 循环里反复调用「创建工单」 | 写操作工具要幂等:用业务唯一键去重,或引入幂等 token |
| 成本失控 | 工具返回 1 万条记录塞进上下文 | 工具只返回「摘要 + 前 N 条」,完整数据走另一条接口 |
findByNo + findItems + findLogistics 三个方法合并成一个
getOrderOverview(orderNo),一次调用返回模型需要的全部信息。
7.7完整示例:订单与天气查询助手
① 天气工具(演示外部 API 调用 + 超时保护)
@Slf4j
@Component
public class WeatherTools {
@Tool(description = """
查询指定城市未来 3 天的天气预报。
使用时机:用户询问天气、气温、是否下雨、穿衣建议时调用。
不要用于:查询历史天气(本工具只支持未来 3 天)。
""")
public WeatherInfo queryWeather(
@ToolParam(description = "城市名称,中文,例如 北京、上海、深圳;不要带'市'字后缀")
String city) {
// 城市名做白名单/格式校验,防止把用户输入拼进 URL 造成注入
if (city == null || city.isBlank() || city.length() > 20) {
return new WeatherInfo(city, List.of(), "城市名不合法,请向用户确认城市名称。");
}
try {
// 外部 API 调用必须设超时,否则会把整个请求拖死
WeatherApiResponse resp = weatherApi.get(city);
// ★ 关键:把返回值"翻译"成模型友好的中文,而不是原样丢 JSON
List<DailyWeather> days = resp.getDaily().stream()
.limit(3) // 最多 3 天,避免上下文过长
.map(d -> new DailyWeather(
d.getDate(),
d.getTextDay(), // "晴" / "小雨"
d.getTempMin() + "~" + d.getTempMax() + "℃",
d.getHumidity() + "%"))
.toList();
return new WeatherInfo(city, days, "");
} catch (Exception e) {
log.error("查询 {} 天气失败", city, e);
return new WeatherInfo(city, List.of(), "天气服务暂时不可用,请稍后重试。");
}
}
public record WeatherInfo(String city, List<DailyWeather> days, String message) {}
public record DailyWeather(String date, String weather,
String temperature, String humidity) {}
}
② 装配带工具的 ChatClient
@Configuration
public class AssistantConfig {
@Bean("orderAssistantClient")
public ChatClient orderAssistantClient(ChatModel chatModel,
OrderTools orderTools,
WeatherTools weatherTools) {
return ChatClient.builder(chatModel)
.defaultSystem("""
你是"智汇"公司的智能助手,可以查询订单状态和天气。
工作准则:
1. 凡是涉及订单数据、实时天气的问题,必须调用工具查询,
禁止凭已有知识或猜测回答。
2. 调用工具前如果缺少必要参数(如订单号、城市),
先向用户追问,不要编造参数。
3. 工具返回错误信息时,用友好、明确的语气告知用户,
并给出下一步建议。
4. 回答控制在 150 字以内。
""")
.defaultTools(orderTools, weatherTools)
.build();
}
}
③ 业务服务(注意 ToolContext 的注入)
@Slf4j
@Service
public class OrderAssistantService {
private final ChatClient orderAssistantClient;
public OrderAssistantService(
@Qualifier("orderAssistantClient") ChatClient orderAssistantClient) {
this.orderAssistantClient = orderAssistantClient;
}
/**
* 带工具的问答。
* 注意 ToolContext 里只放"服务端可信"的身份信息。
*/
public String ask(String question) {
return orderAssistantClient.prompt()
.user(question)
// ★ 身份信息走 ToolContext,模型看不见也改不了
.toolContext(Map.of(
"userId", SecurityContextHolder.currentUserId(),
"tenantId", SecurityContextHolder.currentTenantId()))
.call()
.content();
}
/**
* 进阶:手工查看模型到底调用了哪些工具。
* 调试工具调用时非常有价值——第 10 章的 Trace 就是基于这些数据做的。
*/
public String askWithToolTrace(String question) {
ChatResponse response = orderAssistantClient.prompt()
.user(question)
.toolContext(Map.of("userId", SecurityContextHolder.currentUserId()))
.call()
.chatResponse();
// 取出本次(最终一轮)的响应
var output = response.getResult().getOutput();
// 如果模型这一轮又发起了工具调用,会出现在这里
if (output.getToolCalls() != null && !output.getToolCalls().isEmpty()) {
output.getToolCalls().forEach(tc ->
log.info("模型请求调用工具:name={} arguments={} id={}",
tc.name(), tc.arguments(), tc.id()));
}
return output.getText();
}
}
④ 接口与验证
@RestController
@RequestMapping("/api/v1/assistant")
public class OrderAssistantController {
private final OrderAssistantService service;
public OrderAssistantController(OrderAssistantService service) {
this.service = service;
}
@PostMapping("/ask")
public Map<String, String> ask(@RequestBody Map<String, String> body) {
return Map.of("answer", service.ask(body.get("question")));
}
}
# ① 明确引用订单号 → 应触发 queryOrderStatus 工具
curl -X POST http://localhost:8080/api/v1/assistant/ask \
-H "Content-Type: application/json" \
-d '{"question":"订单 2024001 什么时候发货?"}'
# 期望日志(askWithToolTrace 或 debug 日志):
# 模型请求调用工具:name=queryOrderStatus arguments={"orderNo":"2024001"}
# 期望回答:"您的订单 2024001 显示未发货,预计 3 月 8 日发出。"
# ② 故意查一个不属于当前用户的订单 → 应返回友好提示而非数据
curl -X POST http://localhost:8080/api/v1/assistant/ask \
-H "Content-Type: application/json" \
-d '{"question":"订单 9999999 发货了吗?"}'
# 期望:模型回答"未查询到该订单,请确认订单号是否正确"
# ★ 这一步验证的是权限校验,而不是模型的"自觉"
# ③ 混合任务:订单 + 天气(会触发两次工具调用)
curl -X POST http://localhost:8080/api/v1/assistant/ask \
-H "Content-Type: application/json" \
-d '{"question":"我的订单 2024001 发货了吗?另外北京明天天气怎么样?"}'
# 期望:模型分别调用两个工具,最后合并成一段回答
# ④ 缺少参数 → 应追问而不是编造
curl -X POST http://localhost:8080/api/v1/assistant/ask \
-H "Content-Type: application/json" \
-d '{"question":"我的订单发货了吗?"}'
# 期望:模型回答"请提供订单号"
# 如果它随便编了一个订单号去查 → 你的系统提示约束不够强
7.8常见坑排错
| 现象 | 原因 | 解决 |
|---|---|---|
| 模型完全不用工具,直接回答 | 工具描述太模糊;模型不支持 function calling | 完善描述(含「何时用/何时不用」);换支持工具调用的模型 |
| 模型用错工具 | 工具职责重叠,描述有歧义 | 合并相似工具;在描述里写明「不要用于……」 |
| 参数字段名对不上 | 模型传的 key 与方法参数名不一致(如 order_no vs orderNo) | 在 @ToolParam 里描述清楚命名;或加多个可选参数做兼容 |
| 工具抛异常导致整个请求 500 | 没配 ToolExecutionExceptionProcessor,异常直接冒泡 | 2.0 默认行为:RuntimeException 的错误消息会回给模型;受检异常和 Error 始终重抛。可用 spring.ai.tools.throw-exception-on-error=true 改成全部抛出 |
| 报「Chain already contains a ToolAdvisor」 | 自己注册了 ToolCallingAdvisor,而 2.0 已自动注册一个 | 删掉自己的注册;要定制就提供 ToolCallingAdvisor.Builder<?> bean 替换自动配置的 |
internalToolExecutionEnabled 编译不过 | 该 API 与属性在 2.0 已移除 | 改用 AdvisorParams.toolCallingAdvisorAutoRegister(false) 或全局属性 spring.ai.chat.client.tool-calling.enabled=false |
toolNames(…) 编译不过 | 按名字解析工具的能力已删除 | 改用 FunctionToolCallback.builder(name, fn).inputType(...) 注册显式 ToolCallback bean |
| 模型请求了不存在的工具 | 2.0 默认关闭了「解析回退」 | 确认工具是通过 .tools()/.defaultTools()/toolCallbacks() 显式附加的。不要为此打开 spring.ai.tools.resolution.fallback.enabled(会放大越权风险) |
| 模型用不存在的参数调用工具 | 参数描述不完整,模型猜测 | 参数描述里给示例值;工具内部做格式校验并返回可读错误 |
| 工具被反复调用,成本飙升 | 模型没拿到预期结果,循环重试;或 Agent 循环没设上限 | 工具返回明确成功/失败标识;Agent 循环设最大轮次(第 9 章) |
| 同一个写操作被执行多次 | 模型重复调用创建类工具 | 写操作做幂等(唯一键 / 幂等 token) |
| 响应很慢 | 工具内部慢(外部 API 无超时、全表扫描) | 工具内部设超时、加索引、限制返回条数 |
| 工具返回的内容把上下文撑爆 | 一次返回上千条记录 | 只返回摘要 + 前 N 条;详细数据另开接口分页 |
ToolContext 里取不到值 | 调用时忘记 .toolContext(...) | 检查每次调用是否注入了上下文;或写个统一入口方法 |
7.9自测题
Q1. 一次带工具调用的问答,底层发生了几次模型请求?对成本有什么影响?
通常两次(多轮工具调用则更多):第一次模型返回「我要调用某工具」, 你的代码执行工具后,第二次把结果回传给模型生成最终回答。 成本影响:每次请求都要发送完整上下文(含工具定义),所以 Token 消耗接近翻倍,延迟也叠加。
Q2. 为什么身份信息要走 ToolContext 而不是作为工具参数?
因为工具参数由模型生成,而模型可以被提示词注入诱导、也可能自己编造。 把 userId 作为参数,等于让模型来决定「查谁的数据」——这是致命的越权漏洞。 ToolContext 由服务端代码注入,模型既看不到也无法修改,从机制上杜绝了越权。
Q3. 工具里遇到业务错误,应该抛异常还是返回错误对象?为什么?
应该返回结构化的错误对象。因为模型拿到「可读的错误说明」后, 可以组织成友好的话术回复用户(「未找到该订单,请确认订单号」),甚至换一个工具重试; 而抛异常会让请求直接失败,用户看到的是系统错误。技术异常细节则记日志,不要返回给模型。
Q4. 为什么「把所有 Service 方法都加 @Tool」是个坏主意?
三个原因:① 工具数量多会把大量描述塞进提示词,消耗 Token 并干扰模型判断; ② 职责重叠导致选错率上升;③ Service 方法往往面向内部调用,参数复杂、返回内部实体, 对模型不友好。正确做法是为 AI 场景单独设计一层精简、语义清晰、返回值友好的工具。
Q5.【动手题】设计一个「提交退款申请」工具,要求满足:参数校验、幂等、需要用户确认、返回结构友好。写出方法签名与关键逻辑。
要点:
· 签名:String submitRefund(String orderNo, String reason, ToolContext ctx);
· 幂等:用 ctx 里的 userId + orderNo 作为唯一键,先查是否已有进行中的退款单;
· 确认:不要直接提交,先返回「即将为订单 X 提交退款申请,金额 Y 元,确认吗?」,
由上层(Agent 或前端)拿到用户确认后再调用真正的 confirmRefund(refundId);
· 返回:统一返回含 status 和 message 的 record,错误也用同结构。
- 能描述工具调用的完整时序,并说明为什么会有两次模型调用。
- 能写出一个带「四要素描述」的工具方法。
- 能用 ToolContext 传递用户身份,并说明为什么必须这么做。
- 越权查询返回友好提示(而不是返回别人的数据,也不是 500)。
- 参数缺失时模型会追问而不是编造。
- 能说出至少四条工具的安全设计原则。