MCP 实战(三):把 Claude 接进来,然后发现 agent 就是个 while 循环

目录


一、从「程序调工具」到「模型调工具」

上一篇里,是你自己决定调 add,自己填了 a=3, b=4。

这一篇要让模型来做这个决定。你问一句:

北京天气怎么样?另外帮我算 1234 加 5678

模型自己判断出:先调 get_weather,再调 add,最后把两个结果组织成一句话。

新增的代码只有两个类,其中一个只有 30 行。

文件作用规模
McpToClaudeTool桥接层:MCP 工具 → Claude 工具格式30 行
AgentMainagent 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 reasonstool_use / end_turn / pause_turn / refusal 各是什么含义
Building effective agents讲清楚了 agent 和 workflow 的边界,以及什么时候不该用 agent

关联笔记


最后更新:2026-10-07