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请求”又发生在哪里?
这里其实有三个不同层次的问题:
tool-use/tool-result本身是什么;- 搜索工具和普通的本地、远程工具有什么不同;
- Claude Code为什么要在主流程之外,再复用一次
/v1/messages。
为了把这些问题搞清楚,我做了两件事:
- 直接调用项目中配置的DeepSeek Anthropic兼容接口,观察原始请求和响应;
- 顺着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.id和tool_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.id与web_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_use和web_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的凭据、文件系统和写操作。
返回主线程之前,还可以对内容做结构化提取、脱敏和指令剥离。
但需要特别强调:上下文隔离不自动等于安全。如果只是让另一个模型读完外部内容,再把原文或自由文本总结原样返回,恶意指令仍可能穿透边界。
要让这层隔离真正形成安全围栏,至少还需要:
- 内层Agent只挂载完成任务所需的最小权限工具;
- 明确把外部数据标记为不可信数据,而不是系统指令;
- 使用固定输出Schema,只允许返回任务需要的结构化字段;
- 在回灌主线程前过滤凭据、隐藏指令和不必要的原始内容;
- 保留来源与数据血缘,让主Agent能够区分“外部材料”和“可信指令”;
- 对调用次数、结果大小、数据范围和超时设置硬限制。
本文核验到的Claude Code参考实现证明了“上下文隔离”与“结果压缩”,但没有证明它已经完成上述全部脱敏和Prompt Injection防护。
因此,安全能力应该被视为这种架构可以承载的设计收益,而不是现有实现天然具备的保证。
7.4 封装下游实现与演进成本
下游数据源、数据库、处理模型和摘要模型都可能变化。如果这些细节散落在每个Agent客户端里,迁移一次下游实现就要修改所有调用方。
服务端工具把变化封装在Messages API背后。客户端只依赖稳定的工具契约,服务端可以独立调整数据源、路由策略、模型版本和缓存机制。这一点和RPC接口封装后端实现的思路没有本质区别。
7.5 两层调用的代价
收益之外,两层工具调用也带来了额外成本,至少需要面对以下四个问题。
额外延迟与Token费用。
一次用户请求可能变成:主模型请求、内层任务请求、主模型续写请求。内层任务还可能触发多次下游调用。链路更长,成本和尾延迟都会增加。
摘要造成的信息损失。
隔离上下文意味着主模型看不到全部原始材料。内层结果如果遗漏关键限定条件,主模型很难自行恢复。因此,返回结果应保留必要的来源、原文定位和可追溯信息。
服务端实现不透明。
客户端只看到server_tool_use和tool_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”继续下钻,可以得到几条比较有意思的结论:
- 普通
tool_use/tool_result是客户端托管的工具循环:模型决策,客户端执行,再把结果交回模型; server_tool_use/web_search_tool_result是服务端托管的工具循环:客户端只发一次Messages请求,服务端完成搜索与结果回填;- Claude Code又在两者之间加了一层封装:主Agent把
WebSearch当普通工具,工具内部再发起独立Messages请求,调用web_search_20250305; - 内层搜索请求与主会话上下文隔离,搜索完成后只把摘要和链接包装成外层
tool_result; - 这套模式可以抽象为通用的服务端工具API,搜索只是其中一个业务场景;隔离本身不能替代真正的输入净化与权限控制。
太阳底下没有新鲜事。把这套设计拉回熟悉的工程模型来看,它仍然是分层、封装和上下文边界。
主Agent负责业务任务,内层Agent负责消化局部任务,Messages API则承担两层之间的协议。
特别之处不在于发明了一个全新的业务接口,而在于复用既有协议,把一次复杂的任务工作流包装成了一个可组合的工具。