给模型挂工具的教程很多,但很少有人说清楚一件事:不挂会怎样?
我用同一个 ChatClient、同一条 system prompt、同一批问题,只切换「有没有 .tools()」这一行,跑了 60 次。结论和大部分人的直觉相反。
先给结论:
- 不挂工具,它 6 道题一道都答不上。 18 次回答,0 次答对。
- 但它也一次都没编造。 18 次全部是明确拒答,不是含糊回避。
- 「挂工具是为了防幻觉」是错的——防幻觉靠 system prompt,工具解决的是「有没有数据」。
- 延迟跟着工具往返次数走,不是「挂了就慢」:1 次往返 +0.2~0.8s,2 次 +1.6s,0 次基本持平。
- 副作用:即使一次工具都不调用,回答也会变。 问「你好」,无工具组答「有什么技术问题需要帮忙吗」,有工具组则改成罗列四项工具能力。
一、实验条件
| 项 | 值 |
|---|---|
| JDK | 17 |
| Spring Boot | 4.1.1 |
| Spring AI | 2.0.1 |
| 模型 | deepseek-chat(兼容别名,实际路由 deepseek-flash) |
| 温度 | 默认,未显式固定(见第十节) |
| 无工具接口 | POST /api/chat |
| 有工具接口 | POST /api/agent/chat |
| 每题重复 | 3 次 |
| 会话隔离 | 每次独立 conversationId |
| 样本量 | 10 题 × 3 次 × 2 组 = 60 次调用 |
两个接口除了 .tools() 之外是同一条路径:同一个 ChatClient bean、同一条 default system prompt、同一个 MessageChatMemoryAdvisor、同一份温度与模型配置。
二、被测的工具
src/main/java/.../tool/OrderTools.java(包名按项目实际)
@Component
public class OrderTools {
private static final Map<String, String> ORDERS = Map.of(
"A1001", "已发货,预计送达 2026-10-22",
"A1002", "已签收,签收时间 2026-10-19T14:30",
"A1003", "待发货,仓库拣货中"
);
@Tool(name = "getCurrentTime", description = "查询当前时间,返回 ISO-8601 格式的本地时间")
public String getCurrentTime() {
String now = OffsetDateTime.now().toString();
log.info("[tool] getCurrentTime -> {}", now);
return now;
}
@Tool(name = "queryOrder", description = "根据订单号查询物流状态,订单号格式如 A1001")
public String queryOrder(
@ToolParam(description = "订单号,例如 A1001") String orderId) {
String status = ORDERS.getOrDefault(orderId, "未找到该订单,请确认订单号");
log.info("[tool] queryOrder({}) -> {}", orderId, status);
return status;
}
@Tool(name = "daysUntil", description = "计算距离指定日期还有多少天;日期格式 yyyy-MM-dd,例如 2026-10-22")
public String daysUntil(
@ToolParam(description = "目标日期,格式 yyyy-MM-dd") String date) {
try {
long days = ChronoUnit.DAYS.between(LocalDate.now(), LocalDate.parse(date));
String result = days >= 0 ? "还有 " + days + " 天" : "已过去 " + (-days) + " 天";
log.info("[tool] daysUntil({}) -> {}", date, result);
return result;
} catch (DateTimeParseException ex) {
log.warn("[tool] daysUntil({}) 日期格式错误", date);
return "日期格式错误,应为 yyyy-MM-dd";
}
}
@Tool(name = "refundPolicy", description = "查询退货政策;category 取值:数码 或 服饰")
public String refundPolicy(
@ToolParam(description = "商品类别,取值 数码 或 服饰") String category) {
String policy = switch (category) {
case "数码" -> "数码产品支持 7 天无理由退货,需保留包装";
case "服饰" -> "服饰支持 15 天无理由退货,吊牌需完整";
default -> "未知类别,请提供 数码 或 服饰";
};
log.info("[tool] refundPolicy({}) -> {}", category, policy);
return policy;
}
}
共 4 个工具。 注意 Z9999 不在 ORDERS 里,用来测「查不到时模型怎么表现」。
三、唯一的变量
ChatService#agentChat()
public String agentChat(String conversationId, String message) {
String content = chatClient.prompt()
.user(message)
.advisors(spec -> spec.advisors(memoryAdvisor)
.param(ChatMemory.CONVERSATION_ID, conversationId))
.tools(orderTools)
.call()
.content();
if (!StringUtils.hasText(content)) {
throw new ModelEmptyResponseException("模型返回空内容");
}
return content;
}
无工具组走的是同一个 ChatService 的 chat() 方法:chatClient.prompt().user(message).advisors(...).call().content(),连 memory advisor 都一模一样,只少了 .tools(orderTools) 这一行。
但这一行的作用比想象中大——见第八节。
四、题目集
10 题,6 道需要查数据,4 道不需要。
| ID | 问题 | 类型 | 应该触发工具 |
|---|---|---|---|
| Q01 | 订单 A1001 到哪了? | 订单存在 | 是 |
| Q02 | 订单 A1002 到哪了? | 订单存在 | 是 |
| Q03 | 订单 A1003 是什么状态? | 订单存在 | 是 |
| Q04 | 订单 Z9999 到哪了? | 订单不存在 | 是 |
| Q05 | 现在几点了? | 需要实时时间 | 是 |
| Q06 | 订单 A1001 到哪了?距离送达还有几天? | 订单存在(两步) | 是(两次) |
| Q07 | 你好 | 闲聊 | 否 |
| Q08 | 1 加 1 等于几? | 常识 | 否 |
| Q09 | 用一句话解释什么是物流。 | 常识 | 否 |
| Q10 | 你是什么模型? | 自我认知 | 否 |
Q07–Q10 不是凑数的。 只测「该调的调了没有」,测不出「不该调的乱调了」——而后者在生产上更烦人。
题目都刻意预设了「这个信息存在」:「订单 A1001 到哪了」而不是「你能查订单吗」。
五、结果:不挂工具,一次都没编
Q01–Q06,6 道题 × 3 次 = 18 次回答。
| 组 | 回答次数 | 编造 | 答对 |
|---|---|---|---|
| 无工具 | 18 | 0 | 0 |
| 有工具 | 18 | 0 | 18 |
无工具组的 18 次全部是明确拒答,不是含糊回避:
我这边没有你的订单或物流数据,无法直接查询 A1001 的实时状态。
我无法获取当前时间,因为我没有实时时钟或联网查询的能力。
问不存在的订单 Z9999,它也是拒答——「我无法查询订单物流信息」,既没编状态也没编订单号。
它很诚实,但它一道题也没答上。
六、为什么它不编:这条不能省
跑之前我的预测是「不挂工具会编」,理由是问题里预设了信息存在,模型顺着编一个「已发货」太容易。
预测落空了。原因在 system prompt:
// ChatClientConfig.SYSTEM_PROMPT 中包含
// 「不确定的内容要明确说明,不要编造」
这条 prompt 有工具组和没工具组都带着。
所以这个实验能支持的结论只有一条:在有明确「不要编造」约束的前提下,工具不是防幻觉的手段。
如果 system prompt 里没有这句话,结果可能完全不同。不要把这一节当成「模型自动就有边界感」。
七、延迟:跟着往返次数走,不是「挂了就慢」
3 次平均,单位秒。
| ID | 题目 | 无工具 | 有工具 | 差 | 工具往返 |
|---|---|---|---|---|---|
| Q01 | 订单 A1001 | 1.56 | 1.89 | +0.33 | 1 次 |
| Q02 | 订单 A1002 | 1.28 | 2.07 | +0.79 | 1 次 |
| Q03 | 订单 A1003 | 1.38 | 1.74 | +0.36 | 1 次 |
| Q04 | 订单 Z9999 | 1.66 | 1.86 | +0.20 | 1 次 |
| Q05 | 现在几点 | 1.23 | 1.75 | +0.52 | 1 次 |
| Q06 | 订单 + 天数 | 1.75 | 3.35 | +1.60 | 2 次 |
| Q07 | 你好 | 0.65 | 1.21 | +0.56 | 0 次(回答更长) |
| Q08 | 1 加 1 | 0.94 | 0.80 | −0.14 | 0 次 |
| Q09 | 什么是物流 | 1.04 | 1.27 | +0.23 | 0 次 |
| Q10 | 你是什么模型 | 1.02 | 1.09 | +0.07 | 0 次 |
三个可用的结论:
- 一次往返 = 一次额外的完整模型调用,成本 0.2–0.8 秒。
- 两次往返翻倍,Q06 多了 1.6 秒。
- 没有调用工具的题,两组差异在波动内——Q08 有工具组甚至快了 0.14 秒。
所以「挂工具会让响应变慢」这个说法不准确。准确的说法是「每多一次工具调用,就多一次模型往返」。
工具本身执行很快(queryOrder 查一个 Map,微秒级)。贵的是中间那一趟模型调用。
八、副作用:一次工具都不调,回答也变了
Q07「你好」,两组都没有触发任何工具调用(有工具组 12/12 零调用)。但回答不同:
| 组 | 回答 |
|---|---|
| 无工具 | 「有什么技术问题需要帮忙吗」 |
| 有工具 | 列出四项能力:查订单、退货政策、算日期、查时间 |
原因是 .tools() 做的事不只是「开权限」:它把每个工具的名称、描述、参数 schema 都序列化进了请求。
请求变长了,模型对「我是谁、我能做什么」的理解也跟着变了。
这条对工程实践有直接影响:你不能假设「加了工具,其他行为不变」。 加工具后,连闲聊的措辞都会漂移;如果业务上有对回答措辞的断言(比如客服话术检查),这些断言会失效。
九、两个值得单独记的行为
9.1 它在工具结果之上又判断了一次
Q06 的完整链路:
queryOrder("A1001") → 「已发货,预计送达 2026-10-22」
daysUntil("2026-10-22") → 「已过去 18 天」(运行日 2026-11-09)
模型的回答:
订单 A1001 已发货,预计 2026-10-22 送达。注意:按当前时间计算,这个预计送达日期已经过去 18 天,物流信息可能未更新,建议联系客服确认。
它没有照抄工具返回。 工具给出的「预计送达 2026-10-22」放到 11-09 是自相矛盾的,模型发现了这个矛盾并说出来了。3 次重复行为一致。
这一点值得注意:工具返回的是事实,不是结论。模型有能力在事实之上再做一层推理——这比「能调用工具」难得多,也是评估一个 Agent 是否可用的关键指标。
9.2 它有一次说错了自己是谁
Q10「你是什么模型」,6 次回答里 5 次说是 DeepSeek 最新版,1 次(有工具组 run2)说:
我是 Claude,Anthropic 开发的模型。
general 类不计入核心指标,但它是一次真实的身份臆断。如果你的业务对「模型自述」有依赖,这一条说明它不可靠。
十、局限
- 温度未固定。 本轮没有设置
DEEPSEEK_TEMPERATURE,同一题三次之间已有波动。复现时先看这一项。 - n=10 题 × 3 次。 只能看方向,不能谈比率。文中没有使用任何百分比。
- 「编造」的判定是人工的。 脚本做了关键词初筛,与人工判定有 1 处分歧(Q04 脚本判
unclear,人工判拒答)。引用的是人工结果。 - 两边共用同一条禁止编造的 system prompt。 这是第五节结论的前提,换一条 prompt 结果可能完全不同。
- 工具执行耗时未单独测。 文中「0.2–0.8 秒」混合了模型往返和工具执行,但工具是查内存 Map,可以认为绝大部分是模型往返。
- 单一模型、单一任务。 结论可以迁移的是方法,不是具体数字。
十一、总结
| 问题 | 答案 |
|---|---|
| 不挂工具会编吗 | 不会。18 次 0 次编造,前提是有「不要编造」的 system prompt |
| 不挂工具能答吗 | 不能。18 次 0 次答对 |
| 挂上工具呢 | 18 次全部正确 |
| 代价是什么 | 每多一次工具调用,多一次模型往返,0.2–0.8 秒 |
| 有副作用吗 | 有。即使不调用工具,回答措辞也会变 |
工具解决的不是「会不会胡说」,是「有没有答案」。 前者靠 prompt,后者只能靠工具。
十二、复现
# 终端 1:起服务,日志留档(脚本靠日志判断工具是否触发)
MCP_ENABLED=false ./mvnw spring-boot:run \
-Dspring-boot.run.arguments=--server.port=8081 > /tmp/stage3.log 2>&1
# 终端 2
BASE_URL=http://127.0.0.1:8081 RUNS=3 LOG_FILE=/tmp/stage3.log \
python3 scripts/stage3_hallucination.py
脚本:scripts/stage3_hallucination.py 记录:docs/experiments/stage3-tool-hallucination.md 原始数据:docs/experiments/raw/stage3-hallucination.jsonl(60 行)
如果你们线上也挂了工具,不妨做个同样的对照:把 .tools() 摘掉跑一遍。 如果结果和我一样——不编,但答不了——那说明你的 prompt 写得没问题,工具也确实在做正事。