别把 Tool Calling 当函数调用:Spring AI 上线后最容易翻车的 7 个细节
先说结论
如果你最近在用 Spring AI 做 Agent,大概率已经走过了这个阶段:
@Tool方法能被模型调用了- MCP Server 能被客户端发现了
- ChatClient 也能正常返回结果了
- 本地 demo 看起来已经很像一个智能助手了
这时候最容易出现一个误会:
Tool Calling 不就是让模型帮我调用一个 Java 方法吗?
真上线以后你会发现,Tool Calling 最麻烦的地方,根本不是“能不能调到方法”,而是下面这些问题:
- 模型选中了工具,不代表它有权执行这个工具
- 工具执行成功,不代表参数是安全的
- Memory 里有上下文,不代表它记住了工具调用过程
- MCP 连通了,不代表工具边界、权限边界、租户边界都对了
- Advisor 配了好几个,不代表执行顺序就是你脑子里想的顺序
- 日志开得越全,越可能把 prompt、参数、返回结果一起暴露出去
所以这篇不讲“怎么写第一个 Tool Calling demo”。
这篇只讲一件事:
Spring AI 里的 Tool Calling,不应该被当成一个函数调用问题,而应该被当成一条可治理、可观测、可授权、可回放的调用链路。
Tool Calling 真正长什么样
很多人第一次接触 Tool Calling 时,会把它想成这样:
用户提问 -> 模型判断 -> 调用 Java 方法 -> 返回结果
这个理解没错,但太薄了。
在真实项目里,它更像下面这条链路:
这张图里任何一个环节做错,都可能导致线上问题:
- Advisors 顺序错了,工具拿不到正确上下文
- 模型参数没校验,工具被注入异常查询条件
- 权限检查放在 prompt 里,服务端没有硬校验
- Memory 只记住用户消息,却没处理工具调用带来的状态变化
- MCP Server 自动暴露太多工具,客户端侧没有筛选
- 日志为了排障全量输出,最后先把敏感信息泄露了
下面这 7 个细节,就是我觉得最值得单独写出来的地方。
第一个细节:模型可以选择工具,但不能决定权限
Tool Calling 最容易被误解的一点是:
模型既然选中了这个工具,那就说明这个工具适合当前请求。
这句话只对了一半。
模型能做的是“语义选择”:它根据用户问题、工具描述、参数 schema,判断哪个工具可能有用。
但它不能替你做“权限判断”。
比如用户问:
帮我查一下这个客户最近 3 个月的订单。
模型可能正确选择了 queryCustomerOrders。
但下面这些问题,绝不能交给模型自己判断:
- 当前用户有没有查这个客户的权限
- 当前用户属于哪个租户
- 查询范围是不是超过了岗位权限
- 是否允许查询脱敏前的数据
- 是否允许跨部门、跨组织、跨项目访问
也就是说,模型选中工具,只能说明“它觉得该调用”。
真正能不能调,必须由你的服务端代码决定。
一个比较稳的原则是:
Prompt 只能表达意图,权限必须落在代码里。
不要在 system prompt 里写一句“只能访问当前租户数据”,就以为多租户隔离完成了。
这句话对模型有帮助,但它不是安全边界。
真正的安全边界应该长这样:
| 位置 | 应该做什么 |
|---|---|
| Tool 入参前 | 校验参数格式、范围、枚举值 |
| Tool 执行前 | 校验用户身份、角色、租户、资源权限 |
| Tool 查询时 | 服务端强制拼接租户和权限条件 |
| Tool 返回前 | 做脱敏、裁剪、字段过滤 |
| Tool 失败时 | 返回可控错误,不暴露内部异常 |
如果你的工具本质上是在访问企业数据,那它就不是一个普通 Java 方法。
它更像一个被模型触发的业务接口。
既然是业务接口,就必须有接口级别的权限控制。
第二个细节:不要把 Tool 描述写成“给模型看的接口文档”
很多人写工具描述时,会习惯性把它写得很像接口文档:
@Tool(description = "Query customer order list by customerId and startTime")
public List<Order> queryOrders(String customerId, LocalDateTime startTime) {
...
}
这能跑,但不一定好用。
因为模型读工具描述时,关心的不是“这个方法叫什么”,而是:
- 什么情况下该用这个工具
- 什么情况下不该用这个工具
- 参数应该怎么理解
- 参数缺失时应该先追问,还是可以默认
- 工具结果适合回答什么问题
所以更好的描述,应该把“使用边界”写清楚。
比如:
@Tool(description = """
Use this tool only when the user asks for existing customer orders.
Do not use it for customer profile, payment status, invoice, or refund questions.
customerId must be an internal customer id, not a phone number or company name.
If the user only provides a customer name, ask a clarification question first.
""")
public List<Order> queryOrders(String customerId, LocalDate startDate, LocalDate endDate) {
...
}
这类描述不是为了好看,而是为了降低模型误选工具的概率。
工具描述里至少要写清楚四件事:
| 要写什么 | 为什么 |
|---|---|
| 什么时候用 | 帮模型做正向选择 |
| 什么时候不用 | 避免相近工具混淆 |
| 参数从哪里来 | 避免模型把自然语言硬塞成 ID |
| 缺参数怎么办 | 避免模型猜参数 |
尤其是企业系统里,很多字段名对模型来说很像:
userIdcustomerIdaccountIdtenantIdorgIdmemberId
这些字段在人眼里有业务区别,但在模型眼里很容易混。
如果工具描述只写“根据 id 查询”,误调用会非常常见。
第三个细节:ChatClient、Advisor、Tool Calling 要分清责任
Spring AI 里很容易出现一种写法:
所有东西都往一个 ChatClient 调用里塞。
Prompt、Memory、Tool、MCP、日志、重试、权限提示、格式化要求,全都混在一起。
短期能跑,长期一定难排查。
更清晰的拆法是:
| 层次 | 主要责任 |
|---|---|
| ChatClient | 组织一次对话请求 |
| Advisors | 注入上下文、记忆、策略、拦截行为 |
| Tool Calling | 把模型选择的工具调用落到真实执行 |
| Tool / MCP Tool | 执行业务能力 |
| Guard / Policy | 做权限、租户、风险控制 |
| Observability | 记录可排障但不过度泄露的信息 |
也就是说,ChatClient 不应该变成一个“万能胶水层”。
它更像一次对话编排入口。
真正影响线上稳定性的,是你有没有把这些事情拆出来:
- 哪些 Advisor 负责补上下文
- 哪些 Advisor 负责 memory
- 哪些逻辑负责工具执行前校验
- 哪些逻辑负责工具结果裁剪
- 哪些日志可以进审计,哪些绝对不能进日志
一个很实用的检查方式是:
你能不能只看配置和少量入口代码,就说清楚一次 Tool Calling 请求会经过哪些环节?
如果说不清,后面线上排障时也基本说不清。
第四个细节:Memory 不等于完整执行轨迹
很多人以为加了 Chat Memory,就等于 Agent 记住了所有事情。
这也是一个很危险的误会。
Chat Memory 主要解决的是对话上下文问题,它不应该被当成完整的执行日志、审计日志、业务状态存储。
尤其是 Tool Calling 场景里,你要分清三类东西:
| 类型 | 该放在哪里 |
|---|---|
| 用户和助手的自然语言上下文 | Chat Memory |
| 工具调用参数、结果、耗时、错误 | Trace / Audit Log |
| 业务状态变化 | 业务数据库 |
比如用户说:
帮我把这个客户标记为重点客户。
如果模型调用了 markCustomerAsKeyAccount,那真正重要的状态变化应该落在业务库里,而不是指望 memory 记住“刚才标记过”。
再比如工具查询出了 50 条订单。
这些订单结果是否应该全部进入 memory,也要非常谨慎:
- 里面有没有敏感字段
- 是否会污染后续对话
- 是否会超出上下文窗口
- 是否会让模型在后续回答里错误复用旧结果
- 是否应该只存摘要,而不是存原始结果
我的建议是:
Memory 里尽量存“对后续对话有用的摘要”,不要把它当成工具执行流水表。
工具调用轨迹应该有单独的 trace。
业务状态应该由业务系统自己保证。
Memory 只负责让下一轮对话更自然,而不是替代数据库和审计系统。
第五个细节:MCP 工具不是越多越好
MCP 很吸引人的地方,是它可以把外部能力变成统一的工具协议。
但这也带来一个新问题:
工具一多,模型并不会自动变聪明,反而更容易选错。
如果一个会话里同时暴露几十个工具,模型需要在大量相似描述里做选择。
这时候常见问题会变多:
- 相似工具选错
- 参数 schema 混淆
- 本该追问却直接调用
- 本该调用 A,却调用了更宽泛的 B
- 工具描述里有过多内部术语,模型理解偏了
所以 MCP 接入以后,不要急着把所有工具一次性暴露出去。
更稳的做法是按场景暴露:
| 场景 | 暴露策略 |
|---|---|
| 客服助手 | 只暴露客户、订单、工单相关工具 |
| 运维助手 | 只暴露服务、日志、指标、发布相关工具 |
| 数据分析助手 | 只暴露查询、聚合、报表相关工具 |
| 管理后台助手 | 按角色动态暴露管理工具 |
如果工具数量已经变多,可以考虑做一层工具路由:
这件事听起来像“少给模型一点能力”,实际上是在提高命中率和安全性。
Agent 工程化里有一个很朴素的原则:
当前任务用不到的工具,就不要出现在当前上下文里。
第六个细节:工具结果要裁剪,不要原样丢给模型
很多 Tool Calling demo 会直接把工具返回结果丢回模型,让模型继续生成最终回答。
但在企业项目里,原样返回经常有风险。
比如工具返回了:
- 内部主键
- 手机号
- 邮箱
- 身份证号
- 成本价
- 内部备注
- SQL 查询条件
- 异常堆栈
- 第三方接口 token
这些内容可能对业务系统有用,但不一定应该进入模型上下文。
所以工具结果返回给模型前,最好做一次“模型可见结果转换”。
public CustomerOrderView toModelVisibleView(Order order) {
return new CustomerOrderView(
order.getOrderNo(),
order.getStatus(),
order.getCreatedAt(),
maskAmountIfNeeded(order.getAmount()),
summarizeItems(order.getItems())
);
}
注意,这不是简单的 DTO 转换。
它的目标是回答一个问题:
模型为了完成这次回答,最少需要看到哪些信息?
不要把“工具真实返回结果”和“模型可见结果”混成一份。
更稳的设计是三层:
| 层次 | 说明 |
|---|---|
| Raw Result | 工具真实执行结果,只在服务端内部使用 |
| Model Visible Result | 给模型继续推理的裁剪结果 |
| User Visible Answer | 最终展示给用户的自然语言结果 |
这三层分开以后,很多安全问题会自然变清楚。
第七个细节:日志不是越详细越好
Spring AI、MCP、模型调用、工具调用都很需要日志。
没有日志,线上排障会非常痛苦。
但 AI 应用的日志有一个特殊问题:
你为了排障记录下来的内容,可能正是最敏感的内容。
比如:
- system prompt
- 用户原始问题
- 工具调用参数
- 工具返回结果
- 模型最终回答
- token
- session id
- tenant id
- 内部错误堆栈
这些内容不是不能记,而是要分级记。
我会建议至少分成三类:
| 日志类型 | 记录内容 |
|---|---|
| Trace 日志 | requestId、toolName、耗时、状态码、错误类型 |
| Audit 日志 | 谁在什么时间尝试调用什么能力,是否成功 |
| Debug 日志 | 受控环境下临时开启,且必须脱敏和限时 |
尤其不要在生产环境里长期全量记录 prompt 和工具返回。
短期排障可以开,但要有开关、脱敏、保留周期和访问控制。
一个比较稳的日志事件可以长这样:
{
"requestId": "req_20260605_001",
"conversationId": "conv_abc",
"userId": "u_123",
"tenantId": "t_001",
"toolName": "queryCustomerOrders",
"allowed": true,
"durationMs": 183,
"resultSize": 12,
"errorType": null
}
这类日志对排障很有用,但没有把完整 prompt、完整参数、完整结果都暴露出来。
一张上线前检查清单
如果你已经准备把 Spring AI Tool Calling 或 MCP 能力推到测试环境,可以先按这张表过一遍。
| 检查项 | 建议标准 |
|---|---|
| 工具描述 | 写清楚使用条件、不使用条件、参数来源 |
| 工具数量 | 按场景暴露,不把所有 MCP 工具一次性塞进上下文 |
| 参数校验 | 不信任模型生成的参数,服务端重新校验 |
| 权限控制 | 模型只负责选择,服务端负责授权 |
| 租户隔离 | 查询条件和资源访问必须服务端强制注入 |
| 结果裁剪 | Raw Result 和 Model Visible Result 分开 |
| Memory | 只存必要摘要,不把它当审计日志 |
| 日志 | 默认记录元信息,敏感内容脱敏、限时、受控 |
| 错误处理 | 工具失败时返回可控错误,不暴露堆栈 |
| 回放能力 | 至少能按 requestId 查到一次调用经过哪些工具 |
这张表不复杂,但很实用。
很多线上问题,其实就是其中一两项没做。
我会怎么设计一个更稳的 Tool Calling 层
如果让我从零设计一个企业内部的 Spring AI Agent,我不会直接把所有 @Tool 或 MCP 工具扔给 ChatClient。
我会拆成这几层:
每一层只做一件事:
ChatClient Entry:负责一次对话入口Advisors:负责上下文、记忆、策略信息注入Scenario Tool Registry:负责按角色和场景筛工具Model Tool Selection:让模型在候选工具里选择Authorization and Risk Guard:做硬权限和风险控制Tool Executor:真正执行本地工具或 MCP 工具Result Shaping:把真实结果裁剪成模型可见结果Memory Summary:只沉淀必要对话状态Trace and Audit:记录可排障、可追责、不过度泄露的事件
这样拆完以后,后续扩展会舒服很多:
- 要加新工具,不需要改核心调用链
- 要换 MCP Server,只影响工具注册和执行层
- 要加强权限,只改 Guard
- 要调整日志,只改 Audit
- 要优化 Memory,只改摘要策略
这就是为什么我不建议把 Tool Calling 当成普通函数调用。
普通函数调用只关心“调不调得到”。
Agent 工具调用还要关心“该不该调、以谁的身份调、调完给谁看、怎么被记录”。
最后
Spring AI 现在最有意思的地方,不只是它能把模型、工具、MCP、Memory、Advisor 接起来。
真正有意思的是,它正在把 Java 后端熟悉的那套工程化问题重新带回 AI 应用里:
- 权限
- 配置
- 观测
- 隔离
- 审计
- 灰度
- 回滚
如果你只是做 demo,Tool Calling 很像“模型调用方法”。
但如果你准备上线,它就更像一条新的业务调用链。
这条链路越早治理,后面越少救火。
所以我对 Spring AI Tool Calling 的一句话理解是:
别问模型能不能调工具,先问你的系统能不能管住这次调用。
参考资料
更多推荐




所有评论(0)