目录
- 一、从「程序调工具」到「模型调工具」
- 二、模型和你的对话长什么样
- 三、agent loop 就是个 while 循环
- 四、桥接层为什么只有 30 行
- 五、六个别做错的地方
- 六、跑起来
- 七、一个值得做的实验
- 八、常见误区
- 九、自检清单
- 附:延伸阅读
一、从「程序调工具」到「模型调工具」
上一篇里,是你自己决定调 add,自己填了 a=3, b=4。
这一篇要让模型来做这个决定。你问一句:
北京天气怎么样?另外帮我算 1234 加 5678
模型自己判断出:先调 get_weather,再调 add,最后把两个结果组织成一句话。
新增的代码只有两个类,其中一个只有 30 行。
| 文件 | 作用 | 规模 |
|---|---|---|
McpToClaudeTool | 桥接层:MCP 工具 → Claude 工具格式 | 30 行 |
AgentMain | agent loop:让模型来决策 | 主角 |
那个 30 行的桥接层是这篇的第一个「意外」——第四节会讲为什么它这么短。
二、模型和你的对话长什么样
接上 LLM 之后,你和模型之间多了两种消息块。
┌──────────────────────────────────────────────────────────────┐
│ tool_use(模型发给你) │
│ ───────────────────────────────────────────────────────── │
│ { │
│ "type": "tool_use", │
│ "id": "toolu_abc123", ← 唯一 ID,回传时要带上 │
│ "name": "get_weather", │
│ "input": { "city": "北京" } │
│ } │
│ 「我想调 get_weather,参数是北京」 │
└──────────────────────────────────────────────────────────────┘
↓ 你去执行
┌──────────────────────────────────────────────────────────────┐
│ tool_result(你回给模型) │
│ ───────────────────────────────────────────────────────── │
│ { │
│ "type": "tool_result", │
│ "tool_use_id": "toolu_abc123", ← 必须对应上面那个 id │
│ "content": "北京:多云,11°C", │
│ "is_error": false │
│ } │
│ 「查到了,结果是这个」 │
└──────────────────────────────────────────────────────────────┘
↓ 模型继续推理
要么再调一个工具,要么给出最终回答
像什么?像和一个看不见你屏幕的同事远程协作:
- 他说「帮我查一下北京的天气」→
tool_use - 你查完告诉他「多云 11 度」→
tool_result - 他说「那再帮我算个数」→ 又一个
tool_use - 你算完告诉他 → 又一个
tool_result - 他说「好,总结一下……」→
end_turn,结束
他看不见你的屏幕,只能通过你的回报推进工作。 这就是 agent 的全部秘密。
三、agent loop 就是个 while 循环
这是整篇最需要记住的一句话。市场上所有「agent 框架」剥掉包装,核心都是这个循环:
用户提问
│
▼
┌─────────────────────────────────────┐
│ ① 把工具清单转成 Claude 格式, │
│ 连同问题一起发给 Claude │
└──────────────┬──────────────────────┘
▼
┌─────────────────────────────────────┐
│ ② Claude 返回 stop_reason=tool_use │
│ 带着它想调的工具和参数 │
└──────────────┬──────────────────────┘
▼
┌─────────────────────────────────────┐
│ ③ 拿着参数,通过 MCP Client │
│ 调 MCP Server │
└──────────────┬──────────────────────┘
▼
┌─────────────────────────────────────┐
│ ④ 把结果包成 tool_result 塞回对话, │
│ 再发给 Claude │
└──────────────┬──────────────────────┘
▼
┌─────────────────────────────────────┐
│ ⑤ Claude 不再要工具,给出最终回答 │
└──────────────┬──────────────────────┘
▼
循环结束
对应到代码:
for (int turn = 1; turn <= MAX_TURNS; turn++) {
Message response = claude.messages().create(params.build()); // ① ②
// ... 执行工具,收集 toolResults ...
if (toolResults.isEmpty()) return; // ⑤ 退出
messages.add(工具结果); // ③ ④
}
没有更玄的东西。 如果你之前觉得「agent」是个很高级的概念,看到这里应该松了一口气。
四、桥接层为什么只有 30 行
现在解释第一个意外。
把 MCP 工具转成 Claude 工具,本质是把同一份 JSON Schema 从左口袋挪到右口袋:
MCP 的 Tool.inputSchema → Map<String, Object>(一份 JSON Schema)
Claude 的 Tool.inputSchema → 结构化的 Properties
所以代码短得离谱:
public static Tool toClaudeTool(McpSchema.Tool mcpTool) {
Map<String, Object> schema = mcpTool.inputSchema();
// 搬 properties
Tool.InputSchema.Properties.Builder propsBuilder = Tool.InputSchema.Properties.builder();
if (schema.get("properties") instanceof Map<?, ?> props) {
props.forEach((k, v) -> propsBuilder.putAdditionalProperty(
String.valueOf(k), JsonValue.from(v)));
}
// 搬 required
Tool.InputSchema.Builder schemaBuilder = Tool.InputSchema.builder()
.properties(propsBuilder.build());
if (schema.get("required") instanceof List<?> required && !required.isEmpty()) {
schemaBuilder.required(required.stream().map(String::valueOf).toList());
}
return Tool.builder()
.name(mcpTool.name())
.description(mcpTool.description() == null ? "" : mcpTool.description())
.inputSchema(schemaBuilder.build())
.build();
}
为什么这么短? 因为 MCP 和 Claude 用的是同一个东西——JSON Schema。谁都没有发明新的 schema 语言。
「转换」这个词其实有点夸张,更准确的说法是「搬运」。这也再次印证了第一篇的观点:两者是正交的两层,所以连接它们只需要一层薄胶水。
五、六个别做错的地方
这六点是整篇最值钱的部分。错了不会报错,但会静默出问题。
① 用 block.toParam(),不要手搓
assistantBlocks.add(block.toParam());
模型返回的 thinking 块带一个 signature 字段,是密码学签名,必须逐字节保留。手动重建很容易破坏它,然后请求会报错。
Java SDK 提供了 toParam(),直接用就行。
② 读 content 前先判断 stop_reason
模型可能因安全策略拒绝请求,拒绝时 content 可能是空列表。
if (response.stopReason().orElse(null) == StopReason.REFUSAL) {
System.out.println("模型拒绝了本次请求。");
response.stopDetails().ifPresent(d -> System.out.println(" 类别: " + d.category()));
return;
}
这是生产代码里最容易漏、且一漏就 NPE 的地方。
③ 所有 tool_result 必须装在【同一条】user 消息里
// ✅ 正确:一个 list,一条消息
messages.add(MessageParam.builder()
.role(MessageParam.Role.USER)
.contentOfBlockParams(toolResults)
.build());
模型可以在一轮里并行请求多个工具(这个 Demo 的输出就是),这些结果必须合并成一条 user 消息回传。
拆成多条不会报错,但会悄悄训练模型不再并行调用工具。 你只会发现延迟慢慢变高,很难联想到是这个原因。这是六个里最阴的一个。
④ is_error 必须传
.isError(Boolean.TRUE.equals(mcpResult.isError()))
工具报错时要显式标记。否则模型会把错误信息当成正常返回值,然后基于它继续编下去。
⑤ 退出条件用 toolResults.isEmpty()
if (toolResults.isEmpty()) return;
不要只看 stopReason。有些情况(模型既说话又想调工具)stopReason 会有歧义。判断「这一轮到底有没有工具要执行」更稳。
⑥ maxTokens 要给足
.maxTokens(16000L)
新版 Claude 模型默认开启思考,而 maxTokens 是「思考 + 正文」的总上限。给得太紧,回答会在半路被截断。
想降成本/延迟,调 effort 而不是砍 maxTokens:
.outputConfig(OutputConfig.builder().effort(OutputConfig.Effort.LOW).build())
六、跑起来
# Windows cmd
set ANTHROPIC_API_KEY=sk-ant-...
mvn -q compile exec:exec -DmainClass=com.example.mcp.step4.AgentMain
用户: 北京天气怎么样?另外帮我算一下 1234 加 5678
────────────────────────────────────────────────────────────
已加载 2 个 MCP 工具:[add, get_weather]
────────────────────────────────────────────────────────────
[调用] get_weather {city=北京}
[结果] 北京:多云,11°C
[调用] add {a=1234, b=5678}
[结果] 1234 + 5678 = 6912
Claude: 北京现在多云,11°C。
另外 1234 + 5678 = 6912。
────────────────────────────────────────────────────────────
(共 2 轮)
注意那两行 [调用] 是挨着的——模型在一轮里同时请求了两个工具。它本可以分两轮做(先查天气,拿到结果,再算加法),但它选择了一次解决。
这是它自己的决策,也正好说明了为什么第 ③ 条规矩那么重要。
七、一个值得做的实验
用这个命令跑一次:
mvn -q compile exec:exec -DmainClass=com.example.mcp.step4.AgentMain \
-DappArgs="帮我算 999 乘 111"
注意:我们的 MCP Server 里根本没有乘法工具。
观察模型会怎么做。它可能老实说「我没有乘法工具」,也可能用加法硬凑(999×100 + 999×10 + 999×1)。
然后做一个对比实验:把 add 的描述改成「可以进行加减乘除运算」,再跑一次。看它的行为会不会变。
这个实验能让你直观感受到工具描述对模型行为的引导作用——而这正是下一篇的核心主题。
八、常见误区
误区一:以为 agent 有某种「自主性」。
没有。它就是一个循环,加一个上限(MAX_TURNS)防止跑飞。所谓「自主决策」是模型在每一步选下一个动作,而循环本身完全由你的代码控制。
想清楚这一点,很多「agent 会不会失控」的焦虑就消解了——失控的边界是你自己设的(轮数上限、工具白名单、权限)。
误区二:把 tool_use 和 tool_result 的 ID 对应搞错。
tool_use.id 和 tool_result.tool_use_id 必须一一对应。漏掉任何一个对应的 tool_result,下一次请求直接 400。
写循环时建议用「收集所有 tool_use → 执行 → 一次性回传」的结构,而不是边执行边发。
误区三:工具报错就不回 tool_result 了。
两个后果:对话会卡在「有个 tool_use 没有对应结果」的非法状态,下次请求 400;就算不报错,模型也会因为看不到错误而重复犯同一个错。
正确做法是回一条 is_error: true 的 tool_result,把错误信息给它,它通常能自己调整。
误区四:为了省钱把 maxTokens 调小。
maxTokens 是「思考 + 正文」的总上限。调小不会省钱,只会让回答被截断。要控制成本应该调 effort。
九、自检清单
-
tool_use和tool_result靠什么对应?漏一个会怎样? - 为什么多个
tool_result必须放在同一条消息里? - 桥接层为什么只要 30 行?
- 为什么 thinking 块不能手动重建?
- 模型拒绝请求时,
content可能是什么?不判断会怎样? -
maxTokens和effort分别控制什么? - agent loop 的退出条件应该怎么判断?
附:延伸阅读
相关资料
| 资源 | 说明 |
|---|---|
| Claude Tool Use 文档 | tool_use / tool_result 的官方定义与循环模式 |
| anthropic-sdk-java | 官方 Java SDK,examples/ 下有可运行的 tool use 示例 |
| Handling stop reasons | tool_use / end_turn / pause_turn / refusal 各是什么含义 |
| Building effective agents | 讲清楚了 agent 和 workflow 的边界,以及什么时候不该用 agent |
关联笔记
- MCP 实战(二):手写一个 MCP Server —— 本篇的前置,那条链路就是在这里搭起来的
- MCP 实战(一):MCP 是什么 —— 第三、四节讲的正交关系,在这里落地了
- RAG 实战(五):给 RAG 做量化评估 —— 同样会用到模型判断,可以对比「用模型做评估」和「用模型调工具」的差异
最后更新:2026-10-07