文章

ClaudeCode WebSearch背后的服务端工具机制

ClaudeCode WebSearch背后的服务端工具机制

1. 从DeepSeek官网的一句话说起

最近翻阅DeepSeek API接入Claude Code的文档时,我看到了一句挺有意思的描述:

DeepSeek API原生支持Claude Code中的Web Search功能。当模型判断需要搜索时会自动调用该工具。由于搜索结果的总结需要额外的大模型API请求,因此会产生额外的模型Token费用。

第一眼看过去,这句话好像没什么特别的。DeepSeek兼容Anthropic API,Claude Code把模型地址切过去,Web Search自然也能用了。

但仔细想想,问题就来了:Claude Code的WebSearch到底是谁执行的?

它是不是调用了一个独立的搜索API?搜索结果为什么会以tool_result的形式出现?文档里提到的“额外的大模型API请求”又发生在哪里?

这里其实有三个不同层次的问题:

  1. tool-use/tool-result本身是什么;
  2. 搜索工具和普通的本地、远程工具有什么不同;
  3. Claude Code为什么要在主流程之外,再复用一次/v1/messages

为了把这些问题搞清楚,我做了两件事:

  1. 直接调用项目中配置的DeepSeek Anthropic兼容接口,观察原始请求和响应;
  2. 顺着Claude Code的WebSearchTool源码,追踪主对话、工具执行和结果回灌的完整流程。

本文的接口行为实测于2026年8月24日。源码判断来自本地Claude Code 2.1.88逆向参考工程,当前安装版本为2.1.232,具体代码可能已经变化;本文只把源码用于解释已观察到的协议行为,不把它当作当前版本实现的完整证明。

2. 先把tool-use/tool-result讲清楚

这一章先用本地和远程两个工具案例建立直觉,再把它们抽象成同一套tool-use/tool-result循环。搜索工具的特殊实现,要等这个基础协议讲清楚后再展开。

2.1 三个角色和一套消息协议

大模型调用工具时,模型本身通常不执行代码。模型只负责决定“要调用哪个工具”以及“传什么参数”,真正的工具执行由Agent客户端完成。

2.2 本地工具:模型先发出调用意图

假设我们给Agent注册一个本地Bash工具,用户问:“现在几点?”模型判断需要执行命令后,返回:

1
2
3
4
5
6
7
8
{
  "type": "tool_use",
  "id": "call_bash_001",
  "name": "Bash",
  "input": {
    "command": "date"
  }
}

这段内容只是模型的决定,不是命令执行结果。Claude Code收到后,在本机执行date,得到一段类似这样的日志:

1
2026年 8月24日 星期一 20:15:03 CST

2.3 本地工具:客户端回灌执行结果

客户端把tool_result追加到消息历史,再发起下一次/v1/messages请求。完整请求可以精简成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
{
  "model": "claude-sonnet-4",
  "max_tokens": 512,
  "messages": [
    {
      "role": "user",
      "content": "现在几点?"
    },
    {
      "role": "assistant",
      "content": [
        {
          "type": "tool_use",
          "id": "call_bash_001",
          "name": "Bash",
          "input": { "command": "date" }
        }
      ]
    },
    {
      "role": "user",
      "content": [
        {
          "type": "tool_result",
          "tool_use_id": "call_bash_001",
          "content": "2026年 8月24日 星期一 20:15:03 CST"
        }
      ]
    }
  ]
}

这里可以看到三条消息:用户问题、模型的tool_use、客户端追加的tool_result

模型拿到最后一条消息后,才有依据回答用户:“现在是20点15分。”

这里的关键是:模型没有执行date,客户端也没有把命令结果“猜”出来。

tool_use描述意图,客户端执行工具,tool_result携带事实。

2.4 远程工具:调用天气OpenAPI

如果工具不是本地命令,而是一个天气服务的OpenAPI,流程并没有变化。

变化的只是客户端执行工具时,从本地进程调用变成了HTTP请求。

客户端随后调用天气OpenAPI:

1
GET https://weather.example.com/current?city=Shanghai

天气服务返回JSON,客户端同样把结果追加到消息历史,再发起下一次/v1/messages请求。精简后的请求如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
{
  "model": "claude-sonnet-4",
  "max_tokens": 512,
  "tools": [
    {
      "name": "lookup_weather",
      "description": "查询城市天气",
      "input_schema": {
        "type": "object",
        "properties": { "city": { "type": "string" } },
        "required": ["city"]
      }
    }
  ],
  "messages": [
    { "role": "user", "content": "上海今天天气怎么样?" },
    {
      "role": "assistant",
      "content": [
        {
          "type": "tool_use",
          "id": "call_weather_001",
          "name": "lookup_weather",
          "input": { "city": "上海" }
        }
      ]
    },
    {
      "role": "user",
      "content": [
        {
          "type": "tool_result",
          "tool_use_id": "call_weather_001",
          "content": "上海,晴,31摄氏度"
        }
      ]
    }
  ]
}

其中,tool_use.idtool_result.tool_use_id必须对应。

模型拿到最后一条消息后继续推理,最终生成面向用户的回答。

2.5 共同循环:执行位置不同,协议不变

无论工具在本地还是远程,主循环都是同一套:模型先发tool_use,客户端执行,再发tool_result

区别只在于工具执行动作发生在哪里:

  • Bash在Agent客户端所在机器执行;
  • 天气查询在远程OpenAPI服务执行。
sequenceDiagram
    participant U as 用户
    participant C as Agent客户端
    participant M as 模型API
    participant T as 本地或远程工具

    U->>C: 提出问题
    C->>M: messages + 工具定义
    M-->>C: tool_use
    C->>T: 执行命令或调用API
    T-->>C: 工具结果
    C->>M: tool_result
    M-->>C: 最终回答
    C-->>U: 展示回答

这套循环的责任边界很清楚:

  • 模型负责决策;
  • 客户端负责调度;
  • 工具负责执行;
  • tool_use/tool_result负责把三者串起来。

到这里,我们还没有引入搜索,也没有引入Claude Code的特殊实现。下一步的问题是:如果工具本身就是“搜索网页”,能不能仍然把搜索服务放在客户端执行?

3. 搜索工具:从普通OpenAPI到带总结能力的API

前面已经确认,本地工具和远程工具共享同一套tool-use/tool-result循环。搜索的特殊之处不在于协议不同,而在于搜索往往包含多次检索、网页读取和结果整合,客户端需要额外承担编排工作。

3.1 把搜索当成普通远程工具

一个最直接的实现,是把搜索引擎包装成普通的远程工具:

sequenceDiagram
    participant M as 模型
    participant C as Claude Code
    participant S as 搜索OpenAPI

    M-->>C: tool_use(search)
    C->>S: 调用搜索OpenAPI
    S-->>C: 标题、URL和摘要
    C->>M: tool_result
    M-->>C: 组织最终答案

返回结果可以是标题、URL和摘要,客户端再把它包装成tool_result

3.2 普通搜索的编排边界

这个方案当然能工作,但客户端需要自己编排完整的搜索流程:

  • 是否改写Query;
  • 是否需要搜索多次;
  • 如何去重和重排;
  • 是否继续读取网页;
  • 如何汇总事实和引用。

3.3 假设存在一个带总结能力的Search API

如果把这些步骤集中到服务端,客户端就不必自己编排整套搜索流程。

这里可以先做一个简单的假设:Anthropic提供一个专用的Search API,负责搜索、筛选、聚合和总结。

这个API不只返回搜索命中,还直接返回适合主Agent使用的答案材料:

1
POST https://api.anthropic.com/v1/search
1
2
3
4
5
{
  "query": "Claude Code Web Search如何实现?",
  "max_uses": 5,
  "summarize": true
}

响应可以是:

1
2
3
4
5
6
7
8
9
10
11
{
  "summary": "Claude Code通过工具调用触发Web Search,并使用搜索结果生成带引用的回答。",
  "results": [
    {
      "title": "Claude Code documentation",
      "url": "https://example.com/docs",
      "snippet": "..."
    }
  ],
  "citations": ["https://example.com/docs"]
}

相比普通搜索API,这个接口把“搜索之后怎么办”也纳入了服务端。

它可以自动改写Query、搜索多次、去重、读取页面、提取事实并组织摘要。

3.4 客户端只接收整理后的结果

对客户端来说,调用关系会变成:

sequenceDiagram
    participant M as 模型
    participant C as Claude Code
    participant S as Anthropic Search API

    M-->>C: tool_use(search)
    C->>S: query + summarize=true
    S-->>C: 摘要、结果与引用
    C->>M: tool_result
    M-->>C: 最终回答

这个假设先帮我们建立一个直观模型:搜索和总结可以被封装成一个独立的服务端能力,客户端只接收整理后的结果。

但这还不是本文要分析的真实协议。

接下来再看Anthropic实际采用的方式:它没有暴露一个独立的/v1/search接口,而是把这种服务端能力嵌入既有的Messages工具协议。

4. 从专用Search API到服务端工具

上一章先假设存在一个独立的Search API,把搜索编排和总结封装在服务端。本章回到真实协议,观察这种服务端能力如何复用既有的Messages接口,以及DeepSeek实测返回了什么。

4.1 工具定义:复用既有Messages接口

正如Anthropic Tool Use文档所描述的,Messages API给出了服务端工具这一类能力,Web Search就是其中一种。

客户端仍然调用同一个/v1/messages接口,只是传入的工具不再是普通JSON Schema,而是一个具有特殊type的工具定义:

1
2
3
4
5
{
  "type": "web_search_20250305",
  "name": "web_search",
  "max_uses": 8
}

这个type不是任意字符串,而是客户端和API服务端之间约定好的能力标识。它告诉服务端:这个工具由服务端执行,不需要把控制权交还给客户端。

4.2 响应形态:从tool_use到服务端闭环

普通工具和服务端工具的差异,可以用两个时序分支表示:

sequenceDiagram
    participant M as 模型
    participant C as 客户端
    participant S as API服务端
    participant T as 工具实现

    alt 普通客户端工具
        M-->>C: tool_use
        C->>T: 执行工具
        T-->>C: 工具结果
        C->>M: tool_result
        M-->>C: 继续生成
    else 服务端工具
        M->>S: server_tool_use
        S->>T: 服务端执行工具
        T-->>S: 工具结果
        S-->>M: web_search_tool_result + 继续生成
    end

对于服务端工具,客户端不需要在工具执行和结果回填之间再发请求,只需要消费服务端返回的流,并把最终文本交给调用方。

4.3 DeepSeek接口实测:请求与响应内容

我直接向DeepSeek的Anthropic兼容接口发起一次带有web_search_20250305的请求:

1
POST https://api.deepseek.com/anthropic/v1/messages

请求体可以精简成下面这样。这里展示的是协议中真正相关的字段,具体的鉴权Header和其他客户端参数省略:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
  "model": "deepseek-chat",
  "max_tokens": 1024,
  "stream": true,
  "tools": [
    {
      "type": "web_search_20250305",
      "name": "web_search",
      "max_uses": 2
    }
  ],
  "messages": [
    {
      "role": "user",
      "content": "请搜索 Claude Code 的 Web Search 是如何实现的,并给出关键来源。"
    }
  ]
}

注意,这里只有用户消息和服务端工具定义,没有客户端预先拼接的tool_result。搜索工具的调用与结果回填发生在服务端响应流内部。

同一个HTTP响应中依次出现了以下内容块:

1
2
3
4
5
thinking
server_tool_use
web_search_tool_result
thinking
text

其中,server_tool_use.idweb_search_tool_result.tool_use_id完全一致。

最终stop_reason=end_turn,用量信息还明确记录了:

1
2
3
4
5
{
  "server_tool_use": {
    "web_search_requests": 1
  }
}

4.4 DeepSeek接口实测:服务端闭环

这说明在一次客户端HTTP请求内部,DeepSeek服务端至少完成了如下逻辑闭环:

sequenceDiagram
    participant C as API客户端
    participant M as DeepSeek模型
    participant S as DeepSeek搜索服务

    C->>M: /v1/messages + web_search工具
    M->>S: server_tool_use
    S-->>M: web_search_tool_result
    M-->>C: 搜索结果 + 最终文本

这个实测结果正好对应上一节的服务端闭环:搜索工具的执行与结果回填都由API服务端托管。

4.5 对外契约与内部实现边界

从客户端契约来看,Web Search不是下面这种单独接口:

1
POST /v1/web-search

客户端始终调用/v1/messages

特别之处只体现在两处:请求里的web_search_20250305工具定义,以及响应里的server_tool_use/web_search_tool_result内容块。

当然,DeepSeek服务端内部肯定还要连接某种搜索能力。它可能来自自建索引,也可能来自第三方搜索引擎,但官方文档没有披露这一层。

本文能确认的是对外协议行为,不能据此猜测DeepSeek内部使用了哪家搜索服务,也不能断言服务端内部恰好调用了几次模型。

5. Claude Code如何组合两套工具语义

到这里,服务端搜索的协议已经清楚了。

但还有一个更具体的问题:如果/v1/messages已经支持服务端搜索,Claude Code为什么不直接把web_search_20250305放进主对话请求?

答案是:Claude Code在主流程和服务端工具之间,又保留了一层普通的客户端工具封装。

5.1 外层:主Agent调用普通WebSearch

在主对话中,WebSearch是Claude Code注册的普通客户端工具。主模型只会看到类似这样的工具定义:

1
WebSearch(query, allowed_domains?, blocked_domains?)

当主模型认为需要搜索时,它返回普通的:

1
tool_use(WebSearch)

到这里为止,WebSearch和Bash、Read没有本质区别。Claude Code的通用工具执行器拿到tool_use后,会在本地调用WebSearchTool.call()

5.2 内层:WebSearch复用Messages API

真正特别的地方在WebSearchTool.call()内部:它不直接调用搜索引擎,而是发起一次独立的POST /v1/messages请求。

在本文的DeepSeek兼容入口中,对应完整路径是POST https://api.deepseek.com/anthropic/v1/messages

这次请求只携带新建的搜索任务和web_search_20250305工具定义,不带主对话的完整messages

请求进入API服务端后,服务端完成搜索,并在同一条响应流中返回server_tool_useweb_search_tool_result

5.3 结果回到主对话

Claude Code先收集内层响应中的搜索摘要、标题和URL。

然后,它把这些内容重新包装成外层主Agent认识的普通tool_result

1
2
3
4
5
{
  "type": "tool_result",
  "tool_use_id": "外层WebSearch的tool_use.id",
  "content": "Web search results for query: ...\n\nLinks: ..."
}

随后,Claude Code把这个tool_result追加到主对话历史,再发起主流程的下一次/v1/messages请求。

主模型此时不需要知道搜索是如何完成的,只需要消费WebSearch的最终结果。

完整流程如下:

sequenceDiagram
    participant M as 主模型
    participant C as Claude Code
    participant S as 搜索模型请求
    participant P as DeepSeek服务端搜索

    C->>M: 主messages + WebSearch普通工具
    M-->>C: tool_use(WebSearch)
    Note over C: 执行WebSearchTool.call()
    C->>S: 独立messages + web_search_20250305
    S->>P: server_tool_use(web_search)
    P-->>S: web_search_tool_result
    S-->>C: 搜索结果 + 摘要
    Note over C: 转换为普通tool_result
    C->>M: 主messages + tool_result(WebSearch)
    M-->>C: 最终回答

这里要区分两个层次。

“一次API调用完成搜索”指内层Messages请求内部完成了服务端工具闭环;“Claude Code额外调用一次模型API”指相对主对话而言,它确实多发起了一次内层Messages请求。

CLI显示的搜索进度,也来自这条内层响应流中的工具事件。

6. 从WebSearch抽象服务端工具API

前面已经看清了WebSearch的具体做法:外层把搜索当成普通工具,内层复用/v1/messages,再由服务端工具完成检索和总结。沿着这个模式,可以进一步抽象出一种通用的服务端工具API。

这个API和传统的业务REST接口有一个重要区别:它携带了Agentic语义,服务端把一次或多次LLM推理封装在接口内部。它不只是按固定函数执行一次操作,还可以理解任务、决定下一步、调用下游工具,并把中间结果整理成最终结果。

因此,这里不是另造一个独立的业务REST接口,而是把一段需要多步编排的任务封装进受控的服务端工具,让不同Agent复用同一套能力。搜索只是其中一个例子,也可以替换成知识库问答、文档分析或数据查询。

6.1 服务端工具API的基本形态

WebSearch已经展示了这种API的基本形态:客户端只提交任务,服务端负责完成中间步骤,最后返回适合主Agent消费的结构化结果。

服务端可以继续复用Messages API,只需要定义一个受控的服务端工具。下面用一个需要多步处理的任务作为示例:

1
2
3
4
5
{
  "type": "server_task_v1",
  "name": "server_task",
  "max_steps": 5
}

6.2 服务端托管的任务流程

其内部流程可以是:

flowchart LR
    A[理解任务] --> B[调用下游工具]
    B --> C[整理中间结果]
    C --> D[校验与过滤]
    D --> E[生成结构化结果]
    E --> F[回传主Agent]

对调用方而言,仍然只有一次Messages请求;对服务端而言,它托管了一段完整的工具循环。替换工具定义和内部执行流程后,同一协议也可以承载其他业务任务。

7. 服务端工具API的收益与边界

前面分析的是DeepSeek与Claude Code已经表现出的协议和实现;从这里开始,则是基于这套机制做进一步的架构推演。服务端工具API可以承载搜索,也可以承载其他需要隔离上下文和多步编排的任务,但多包一层模型请求并不天然更好。

7.1 交付整理后的任务结果

传统业务API通常只返回原始数据,调用方还要自行完成筛选、去重、事实抽取和结果整合。

服务端工具API把这些步骤收敛在服务端,可以直接产出:

  • 面向任务的结构化结果;
  • 必要的说明、来源和可追溯信息;
  • 多步处理后的聚合结论;
  • 执行次数、耗时和错误等可观测信息。

这可以显著降低Agent客户端的编排成本,也让不同客户端复用同一套任务处理策略。

7.2 隔离任务上下文,控制主会话膨胀

服务端工具请求只携带当前任务,不携带主会话的完整历史;执行过程中产生的大量中间结果,也不会原样进入主会话。

最终进入主会话的只是经过整理的结果。这种结构很像一次轻量的sub agent调用:

flowchart LR
    A[主Agent上下文] --> B[任务Agent]
    B --> C[中间材料]
    C --> D[结构化结果]
    D --> A

收益不只是单次Token数量减少,更重要的是避免中间材料长期驻留在主会话历史里,影响后续推理和上下文缓存。

当然,Token并没有凭空消失。内层模型仍然要读取中间材料并产生结果,所以额外的模型Token费用仍然存在。这里优化的是主会话的上下文质量和生命周期,不是把执行成本降为零。

7.3 建立外部数据的安全边界

搜索结果、网页内容、RAG文档和外部业务数据都属于不可信输入。它们完全可能夹带“忽略此前指令”“读取本地密钥”之类的Prompt Injection内容。

独立任务上下文提供了一个合适的安全边界。内层Agent只拿到完成任务所需的最小上下文和最小权限,不接触主Agent的凭据、文件系统和写操作。

返回主线程之前,还可以对内容做结构化提取、脱敏和指令剥离。

但需要特别强调:上下文隔离不自动等于安全。如果只是让另一个模型读完外部内容,再把原文或自由文本总结原样返回,恶意指令仍可能穿透边界。

要让这层隔离真正形成安全围栏,至少还需要:

  1. 内层Agent只挂载完成任务所需的最小权限工具;
  2. 明确把外部数据标记为不可信数据,而不是系统指令;
  3. 使用固定输出Schema,只允许返回任务需要的结构化字段;
  4. 在回灌主线程前过滤凭据、隐藏指令和不必要的原始内容;
  5. 保留来源与数据血缘,让主Agent能够区分“外部材料”和“可信指令”;
  6. 对调用次数、结果大小、数据范围和超时设置硬限制。

本文核验到的Claude Code参考实现证明了“上下文隔离”与“结果压缩”,但没有证明它已经完成上述全部脱敏和Prompt Injection防护。

因此,安全能力应该被视为这种架构可以承载的设计收益,而不是现有实现天然具备的保证。

7.4 封装下游实现与演进成本

下游数据源、数据库、处理模型和摘要模型都可能变化。如果这些细节散落在每个Agent客户端里,迁移一次下游实现就要修改所有调用方。

服务端工具把变化封装在Messages API背后。客户端只依赖稳定的工具契约,服务端可以独立调整数据源、路由策略、模型版本和缓存机制。这一点和RPC接口封装后端实现的思路没有本质区别。

7.5 两层调用的代价

收益之外,两层工具调用也带来了额外成本,至少需要面对以下四个问题。

额外延迟与Token费用。

一次用户请求可能变成:主模型请求、内层任务请求、主模型续写请求。内层任务还可能触发多次下游调用。链路更长,成本和尾延迟都会增加。

摘要造成的信息损失。

隔离上下文意味着主模型看不到全部原始材料。内层结果如果遗漏关键限定条件,主模型很难自行恢复。因此,返回结果应保留必要的来源、原文定位和可追溯信息。

服务端实现不透明。

客户端只看到server_tool_usetool_result,却未必知道服务端调用了哪些下游、如何处理、是否命中缓存。一个成熟实现需要补充调用次数、数据源、耗时、错误码和结果血缘等可观测信息。

两层循环增加排障难度。

“主模型没有调用工具”“客户端没有执行工具”“内层模型没有触发服务端工具”“下游服务没有返回结果”“结果没有回灌主线程”,都会表现为最终答案没有使用内层能力。

排障时不能只看最终文本,而要分层检查:

flowchart LR
    A[外层tool_use] --> B[客户端工具执行器]
    B --> C[内层Messages请求]
    C --> D[server_tool_use]
    D --> E[web_search_tool_result]
    E --> F[外层tool_result]
    F --> G[主模型续写]

8. 总结

从DeepSeek官网一句“原生支持Claude Code Web Search”继续下钻,可以得到几条比较有意思的结论:

  1. 普通tool_use/tool_result是客户端托管的工具循环:模型决策,客户端执行,再把结果交回模型;
  2. server_tool_use/web_search_tool_result是服务端托管的工具循环:客户端只发一次Messages请求,服务端完成搜索与结果回填;
  3. Claude Code又在两者之间加了一层封装:主Agent把WebSearch当普通工具,工具内部再发起独立Messages请求,调用web_search_20250305
  4. 内层搜索请求与主会话上下文隔离,搜索完成后只把摘要和链接包装成外层tool_result
  5. 这套模式可以抽象为通用的服务端工具API,搜索只是其中一个业务场景;隔离本身不能替代真正的输入净化与权限控制。

太阳底下没有新鲜事。把这套设计拉回熟悉的工程模型来看,它仍然是分层、封装和上下文边界。

主Agent负责业务任务,内层Agent负责消化局部任务,Messages API则承担两层之间的协议。

特别之处不在于发明了一个全新的业务接口,而在于复用既有协议,把一次复杂的任务工作流包装成了一个可组合的工具。

9. 参考

本文由作者按照 CC BY 4.0 进行授权