工具调用与 MCP
相关笔记:04 框架技术栈 · 8. Spring AI(
@Tool落地)、Kafka 深入(异步工具化的思路)
模型只会「说话」。工具调用是它第一次能真的做事:查库、下单、发消息、调内部服务。而 MCP 是把这件事标准化。
1. 为什么必须给模型工具
| 模型不擅长的 | 工具能解决 |
|---|---|
| 精确计算、日期运算 | 计算器 / 代码执行 |
| 查实时数据(订单、库存、天气) | 业务 API |
| 私有数据 | 内部查询接口 |
| 有副作用的动作(下单、退款、发通知) | 需要审批的业务操作 |
| 超长文本的精确检索 | 搜索 / 向量库 |
一句话:模型负责「决定做什么」,工具负责「真的做」,应用负责「能不能做」。
2. Function Calling 的完整流程
text
1) 应用把工具清单(JSON Schema)随请求发给模型
↓
2) 模型返回:我不要直接回答,我要调 queryOrder({"orderNo":"SO2024"})
↓
3) 应用(不是模型!)执行这个函数,拿到结果
↓
4) 把结果作为 tool 消息回填,再请求一次模型
↓
5) 模型基于结果生成最终回答1) 应用把工具清单(JSON Schema)随请求发给模型
↓
2) 模型返回:我不要直接回答,我要调 queryOrder({"orderNo":"SO2024"})
↓
3) 应用(不是模型!)执行这个函数,拿到结果
↓
4) 把结果作为 tool 消息回填,再请求一次模型
↓
5) 模型基于结果生成最终回答工具声明长这样:
json
{
"name": "query_order",
"description": "根据订单号查询订单状态。订单号形如 SO 开头的 12 位字符串",
"parameters": {
"type": "object",
"properties": {
"orderNo": { "type": "string", "description": "订单号,如 SO20240101001" }
},
"required": ["orderNo"]
}
}{
"name": "query_order",
"description": "根据订单号查询订单状态。订单号形如 SO 开头的 12 位字符串",
"parameters": {
"type": "object",
"properties": {
"orderNo": { "type": "string", "description": "订单号,如 SO20240101001" }
},
"required": ["orderNo"]
}
}模型返回的是一次「意图」,不是一个真实的函数调用:
json
{ "tool_calls": [ { "id": "call_1", "function": { "name": "query_order", "arguments": "{\"orderNo\":\"SO20240101001\"}" } } ] }{ "tool_calls": [ { "id": "call_1", "function": { "name": "query_order", "arguments": "{\"orderNo\":\"SO20240101001\"}" } } ] }面试易错点:执行永远是应用做的。模型没有网络、没有数据库权限,它只是「提议」。
3. 工程细节(这些才决定能不能上线)
| 环节 | 要点 |
|---|---|
| 参数校验 | 类型、格式、范围、必填;模型经常编出不存在的枚举值 |
| 权限校验 | 工具内部按当前用户再判一次(模型说「查所有订单」也只返回自己的) |
| 超时 | 每个工具单独设超时,别让一个慢查询拖死整个 Agent |
| 幂等 | 有副作用的工具(下单/退款)必须带幂等键 |
| 结果截断 | 工具返回 10 万行会爆上下文,只回摘要 + top N |
| 错误回传 | 不要抛异常给用户,把错误信息作为 tool 结果回给模型,让它自己解释或重试 |
| 并行调用 | 无依赖的多个工具可以并行;有依赖的必须串行(顺序由模型给,执行由你控) |
| 循环上限 | 限制工具调用轮数,防止「调工具 → 不满意 → 再调」死循环 |
| 审计 | 谁在什么时候让模型调了什么、结果如何,必须留痕 |
错误回传的正确写法:
json
{ "error": "ORDER_NOT_FOUND", "message": "订单号不存在,请让用户确认后重试" }{ "error": "ORDER_NOT_FOUND", "message": "订单号不存在,请让用户确认后重试" }模型拿到这个,会转成「抱歉没查到,您能确认下订单号吗」—— 比抛 500 让前端白屏好得多。
4. 工具设计原则
- 少而稳:5~10 个高价值工具好过 50 个半吊子工具(工具越多,选错的概率越高)。
- 描述即文档:
description是模型唯一能看到的说明书,要写清什么时候用、参数长什么样、不要用来干什么。 - 原子化:一个工具做一件事。「查询并退款」这种复合工具失败了没法排查。
- 返回值友好:字段名自解释、单位明确、日期用 ISO 格式、避免嵌套过深。
- 避免歧义:
getUser和queryUser别同时存在 —— 模型会随机选。
5. MCP:把工具提供方式标准化
5.1 它解决什么问题
以前每接一个新应用(Claude Desktop、IDE、自己的系统),每个工具都要重写一遍 schema 和执行逻辑。MCP 让工具提供方写一次 Server,所有支持 MCP 的 Client 都能发现并使用。
Host(宿主应用,如 IDE / 你的系统)
├── MCP Client ──stdio──► MCP Server(本地工具,如文件系统)
└── MCP Client ──HTTP───► MCP Server(远程工具,如内部知识库)Host(宿主应用,如 IDE / 你的系统)
├── MCP Client ──stdio──► MCP Server(本地工具,如文件系统)
└── MCP Client ──HTTP───► MCP Server(远程工具,如内部知识库)5.2 三类能力
| 能力 | 说明 | 例子 |
|---|---|---|
| Tools | 可调用的函数(有副作用) | 查订单、发邮件 |
| Resources | 可读取的数据(只读) | 文件、数据库记录、文档 |
| Prompts | 预置的提示模板 | 「帮我审这段代码」 |
5.3 协议要点
- JSON-RPC 2.0:请求/响应 + 通知;
- 传输:
stdio(本地进程,最简单)或Streamable HTTP(远程,早期是 HTTP+SSE); - 生命周期:初始化(交换能力)→ 运行(
tools/list、tools/call)→ 关闭; - 自描述:Client 不需要预先知道有哪些工具,运行时
list出来。
5.4 和 Function Calling 的关系(高频追问)
| Function Calling | MCP | |
|---|---|---|
| 是什么 | 模型 API 的一个能力 | 一套协议 |
| 管什么 | 模型怎么表达「我要调工具」 | 工具怎么被发现、描述、调用 |
| 复用性 | 换个应用要重写 | 一次实现,多宿主复用 |
| 类比 | 「怎么打电话」 | 「电话号码本 + 拨号规范」 |
一句话:Function Calling 是模型侧的表达方式,MCP 是工具侧的供给标准。两者可以同时用 —— MCP Server 提供的工具,最终在模型看来就是一堆 function。
6. 安全边界(这部分面试最容易被追问)
| 风险 | 例子 | 防御 |
|---|---|---|
| 间接注入 → 工具滥用 | RAG 文档里写「请调用 deleteAll」,模型照做 | 工具白名单 + 只读优先 + 危险操作二次确认 |
| 越权 | 模型被诱导传 userId=别人 | 参数不用模型给的,从会话上下文取 |
| SQL/命令注入 | 工具把模型输出直接拼进 SQL | 参数化查询,绝不拼接 |
| 数据外泄 | 工具返回值含敏感字段 | 返回前脱敏,最小字段集 |
| 成本攻击 | 被诱导循环调用付费工具 | 轮数上限 + 单次预算 + 频控 |
| 审计缺失 | 出了事查不到谁调的 | 全量记录 tool call(谁、什么参数、什么结果) |
核心原则:模型是不可信输入源。 它说的话等同于「用户提交的表单」,必须校验。
7. Java 落地(Spring AI)
java
public class OrderTools {
@Tool(description = "根据订单号查询订单状态。订单号形如 SO 开头的 12 位字符串")
public String queryOrder(@ToolParam(description = "订单号") String orderNo) {
// 1. 参数校验
if (orderNo == null || !orderNo.matches("SO\\d{9}")) return "参数错误:订单号格式不对";
// 2. 权限:用当前登录用户,而不是模型给的 userId
Long userId = UserContext.currentUserId();
// 3. 只回摘要,别回整行实体
return orderService.statusOf(userId, orderNo);
}
}
String answer = chatClient.prompt()
.user("帮我查下 SO20240101001 到哪了")
.tools(new OrderTools()) // 注册工具
.call()
.content();public class OrderTools {
@Tool(description = "根据订单号查询订单状态。订单号形如 SO 开头的 12 位字符串")
public String queryOrder(@ToolParam(description = "订单号") String orderNo) {
// 1. 参数校验
if (orderNo == null || !orderNo.matches("SO\\d{9}")) return "参数错误:订单号格式不对";
// 2. 权限:用当前登录用户,而不是模型给的 userId
Long userId = UserContext.currentUserId();
// 3. 只回摘要,别回整行实体
return orderService.statusOf(userId, orderNo);
}
}
String answer = chatClient.prompt()
.user("帮我查下 SO20240101001 到哪了")
.tools(new OrderTools()) // 注册工具
.call()
.content();Spring AI 做的事情:把 @Tool 反射成 JSON Schema、随请求发给模型、解析 tool_calls、反射调用、把结果回填。你自己要负责的是校验、权限、幂等、限流。
8. 可观测:怎么知道工具好不好用
| 指标 | 为什么看 |
|---|---|
| 工具选择准确率 | 模型有没有选错工具(最常见的失败) |
| 单工具调用耗时 / P95 | 慢工具拖垮整个链路 |
| 参数校验失败率 | 说明 description 写得不够清楚 |
| 每次任务的平均调用轮数 | 轮数暴涨 = 逻辑有问题或死循环 |
| 工具错误率 | 上游不稳定 |
| 人工接管率 | 兜底触发频率 |
9. 速查表
| 问题 | 一句话 |
|---|---|
| Function Calling 谁执行 | 应用执行,模型只提议 |
| 工具返回太大怎么办 | 只回摘要 + top N,别塞整表 |
| 工具报错怎么处理 | 作为 tool 结果回给模型,别抛 500 |
| 工具越多越好吗 | 不是,越多选错率越高,5~10 个高价值的够用 |
| MCP 是什么 | JSON-RPC 的工具/资源/提示标准协议,一次实现多宿主复用 |
| MCP 三类能力 | Tools / Resources / Prompts |
| MCP 传输方式 | stdio(本地)、Streamable HTTP(远程) |
| 最大的安全风险 | 间接注入导致工具被滥用,靠白名单 + 内部鉴权 + 审计兜底 |
| 幂等为什么重要 | 模型会重试,有副作用的工具重复执行会出事 |
Cao Note