Spring AI工具已经调用成功,为什么最终回答仍为空?返回值、循环与上下文完整排查
文章摘要
有些Spring AI项目可以在日志中看到工具已经被调用,数据库查询或HTTP请求也成功执行,但客户端最终收到空字符串、模型重复调用同一工具,或者回答完全没有使用工具结果。这类问题与“模型没有选择工具”不同,通常发生在工具返回值序列化、异常处理、Tool Calling循环、流式事件拼接、结果过长、消息持久化和终止条件等环节。本文给出从工具执行结果到最终Assistant回答的完整排查路径。
一、先把链路分成五个阶段
一个完整工具调用不是只有“方法执行成功”:
模型选择工具
→ 参数解析
→ 工具执行
→ 结果写入Tool Response
→ 模型基于结果生成最终回答
日志只显示:
orderService.query()执行成功
只能证明第三阶段完成。
后面仍可能失败:
- 返回对象无法序列化;
- Tool Response为空;
- 结果没有进入下一轮模型请求;
- 模型再次调用工具;
- 流式客户端漏掉最终事件;
- 上下文超限;
- Advisor提前返回;
- 最终回答被安全策略拦截。
二、工具不要返回null
错误实现:
@Tool(description = "查询订单")
public OrderResult queryOrder(String orderId) {
return repository.find(orderId)
.orElse(null);
}
工具返回null时,模型可能只看到一个空结果,无法区分:
订单不存在
系统异常
没有权限
返回值丢失
推荐结构化返回:
public record ToolResult(
boolean success,
String code,
String message,
T data
) {
public static ToolResult success(T data) {
return new ToolResult(
true,
"OK",
"执行成功",
data
);
}
public static ToolResult failure(
String code,
String message
) {
return new ToolResult(
false,
code,
message,
null
);
}
}
不存在时返回:
{
"success": false,
"code": "ORDER_NOT_FOUND",
"message": "订单不存在",
"data": null
}
三、不要直接返回数据库Entity
数据库实体可能包含:
- Hibernate代理;
- 懒加载集合;
- 双向关系;
- 循环引用;
- 内部字段;
- 敏感字段;
- 超大关联对象。
例如:
Order
→ Customer
→ Orders
→ Customer
→ ……
序列化可能失败或生成巨大结果。
推荐返回专用DTO:
public record OrderSummary(
String orderId,
String status,
BigDecimal amount,
String currency,
Instant updatedAt
) {
}
只返回模型完成任务真正需要的字段。
四、返回值是否被异常转换成字符串
一些工具为了“方便”这样写:
catch (Exception exception) {
return exception.getMessage();
}
模型会把错误字符串当成正常业务结果。
更危险的写法:
return "查询完成";
但没有返回真实数据,模型无法回答用户问题。
推荐区分:
业务成功
业务失败
技术异常
权限拒绝
等待确认
每类都使用稳定错误码。
五、检查工具结果的实际序列化内容
不要只打印Java对象:
log.info("result={}", result);
还应在安全脱敏后检查发送给模型的内容:
tool_result_json
result_size_bytes
serialization_status
可以在测试环境中显式序列化:
String json = objectMapper.writeValueAsString(result);
检查:
- 是否为合法JSON;
- 是否包含所需字段;
- 是否出现空对象
{}; - 是否被截断;
- 是否包含敏感信息;
- 是否大到无法进入上下文。
六、工具结果过长会发生什么
如果工具返回:
5000条数据库记录
整个日志文件
完整网页HTML
几十万字文档
下一轮模型请求可能:
- 超过上下文窗口;
- 被Provider拒绝;
- 成本骤增;
- 模型忽略关键信息;
- 最终回答变空;
- 流式连接超时。
工具应该返回:
摘要
+分页信息
+少量关键记录
+可继续查询的游标
例如:
{
"total": 2387,
"returned": 20,
"nextCursor": "eyJwYWdlIjoyfQ==",
"items": []
}
七、Tool Calling循环是否继续进入下一轮
Spring AI 2.0通过ToolCallingAdvisor执行循环:
模型请求工具
→ 执行工具
→ 把结果加入对话
→ 再次调用模型
→ 得到最终回答
如果自动Tool Advisor被关闭:
AdvisorParams
.toolCallingAdvisorAutoRegister(false)
应用必须自己完成后续循环。
否则你只能拿到:
工具调用请求
或工具执行结果,却没有最终自然语言回答。
八、是否错误地注册了多个ToolAdvisor
一个调用链中不应该同时存在多个负责工具执行循环的Advisor。
重复注册可能造成:
- 工具执行两次;
- 对话历史重复;
- 循环顺序混乱;
- 最终消息被覆盖;
- 幂等冲突。
检查:
ChatClient自动注册的ToolCallingAdvisor
自定义ToolCallingAdvisor
ToolSearchToolCallingAdvisor
应该只有一个工具循环策略。
九、模型为什么重复调用同一个工具
常见原因:
1. 结果不包含完成信号
返回:
{
"status": "PROCESSING"
}
模型可能继续查询。
2. 工具描述暗示需要再次确认
3. 返回值缺少用户要求的字段
用户问物流单号,工具只返回订单状态。
4. Tool Response没有进入下一轮上下文
5. Prompt要求“直到确认成功为止”
6. 工具调用失败却被包装为成功
建议在结果中加入:
terminal
retryable
nextAction
例如:
{
"success": true,
"terminal": true,
"retryable": false,
"data": {
"trackingNo": "SF123456"
}
}
十、必须为副作用工具设置幂等键
如果重复调用的是:
- 创建订单;
- 退款;
- 发邮件;
- 修改权限;
- 提交审批;
- 发布内容;
后果可能很严重。
幂等键建议由业务系统生成或验证:
tenant_id
+user_id
+conversation_id
+tool_name
+business_request_id
示例:
String idempotencyKey = String.join(
":",
tenantId,
conversationId,
"cancel_order",
orderId
);
数据库建立唯一约束,不能只依赖内存缓存。
十一、流式接口是否漏掉最终事件
流式工具调用可能包含:
模型文本增量
工具参数增量
工具调用开始
工具结果
下一轮模型文本
完成事件
如果前端只处理第一类文本事件,工具执行后生成的第二轮回答可能被忽略。
检查:
- SSE事件类型;
- 是否在工具轮次后继续订阅;
- 是否过早调用
takeUntil; - 是否收到Complete;
- Nginx是否断开长连接;
- 客户端是否因空Chunk判定结束;
- 取消信号是否传播到上游。
十二、Memory位置是否导致工具消息丢失
MessageChatMemoryAdvisor放在Tool Calling循环外部时,通常只持久化最终用户消息和Assistant消息。
如果业务需要完整工具轨迹,必须确认所用Memory Repository支持:
- AI Tool Call Request;
- Tool Response Message;
- 多轮工具消息。
否则工具结果可能在当前请求可用,但下一轮对话无法恢复。
不要为了保存工具消息,盲目把Memory Advisor移入循环。还要同时处理:
- 重复写入;
- Repository序列化能力;
- 上下文膨胀;
- 敏感参数存储。
十三、最终回答是否被其他Advisor改变
调用链可能还有:
- 内容审核;
- 输出过滤;
- 结构化输出验证;
- 缓存;
- 日志;
- 自定义响应转换。
工具执行成功后,最终回答可能被:
- 安全策略阻断;
- Schema校验反复重试;
- 缓存返回旧空结果;
- 自定义Advisor提前替换;
- 响应转换器解析失败。
建议记录每个Advisor的:
enter
exit
order
duration
response_present
十四、最终回答为空时的最小实验
第一步:固定工具返回值
@Tool(description = "返回测试订单")
public OrderSummary testOrder() {
return new OrderSummary(
"A1001",
"SHIPPED",
new BigDecimal("99.00"),
"CNY",
Instant.now()
);
}
第二步:要求模型必须复述字段
调用testOrder,并返回orderId和status。
第三步:关闭其他Advisor
只保留Tool Calling。
第四步:分别测试call与stream
如果同步正常、流式失败,重点检查事件消费。
第五步:查看第二轮模型请求
确认Tool Response是否真的进入上下文。
十五、建议记录的观测字段
tool_call_id
tool_name
arguments_hash
execution_status
execution_duration_ms
result_serialization_status
result_size_bytes
tool_result_hash
loop_iteration
model_after_tool_called
final_response_present
stream_completed
advisor_chain
高风险业务还应记录:
idempotency_key
approval_id
operator
business_result_id
十六、完整排查清单
□ 工具没有返回null
□ 返回的是DTO而不是数据库Entity
□ 结果可以稳定序列化
□ 错误没有被伪装成成功字符串
□ 结果大小受到限制
□ ToolCallingAdvisor完成了第二轮模型调用
□ 没有重复注册ToolAdvisor
□ 结果包含terminal与retryable语义
□ 副作用工具使用数据库级幂等
□ 流式客户端没有漏掉工具后的回答
□ Memory与工具消息能力匹配
□ 其他Advisor没有替换最终响应
□ Trace中可以看到工具结果进入下一轮模型请求
总结
“工具调用成功但最终回答为空”说明问题已经越过工具选择阶段,应该重点检查:
返回值序列化
→ Tool Response写入
→ Tool Calling下一轮
→ 流式事件消费
→ 最终Advisor处理
只有把工具调用拆成完整阶段并逐段观测,才能判断结果究竟丢在了哪里。
延伸阅读
如果你正在关注企业级 AI 应用、Spring AI、RAG、Agent 与 MCP 工程化落地,欢迎访问 智元界:
https://www.zyentor.com/
智元界将持续分享可运行的技术实战、架构设计、问题排查与企业应用案例。