我们用真实代码,拆解一个生产级 AI 编程 CLI 的搜索工具实现,帮你看懂"AI 联网搜索"背后那层被封装掉的设计决策。
你有没有在 Claude Code 里问过它"最新的 XXX 是什么",然后它"嗖"的一下去搜了网页,给了你带链接的回答?
这篇文章就是要把这个"嗖"字背后的所有细节,一层一层剥开给你看。
如果你是独立开发者,正在做一个类似的 AI 编程助手,这篇文章能省你好几天踩坑时间。如果你是求职中的学生,这里有一个真实的顶级开源项目是如何设计工具调用链的,简历上的"阅读过 Claude Code 源码"不是吹的。
一、先说结论:它不是你想象的那种搜索
很多人以为 Claude Code 联网搜索,是在客户端偷偷调了 Google Search API 或者 Bing API。
不是。
真实的架构是:Claude Code 客户端把搜索任务完整甩给 Anthropic 的服务器,服务器搜完之后,把结果流式传回来。
这个工具叫 web_search_20250305,是一个服务器端工具(Server-Side Tool)。
这个设计决策值得好好品一下,我们后面会细说为什么这样做。
二、整体架构:三句话说清楚
用户问问题 → Claude Code 客户端:把查询打包成请求,附上 web_search 工具声明 → Anthropic API 服务器:收到请求,服务器自己去搜索,把结果流式返回 → Claude Code 客户端:解析流式事件,格式化成工具结果,注入对话上下文
图解如下:
┌──────────────────────────────────┐│ 你的终端 / CLI 界面 │└────────────────┬─────────────────┘ │ 用户输入 ▼┌──────────────────────────────────┐│ Claude Code 客户端 ││ WebSearchTool.call(input) ││ - 构建请求消息 ││ - 声明 web_search 工具 ││ - 发起流式 API 调用 │└────────────────┬─────────────────┘ │ HTTPS 流式请求 ▼┌──────────────────────────────────┐│ Anthropic API 服务器 ││ 1. 解析请求,决定搜索词 ││ 2. 调用内部搜索基础设施 ││ 3. 生成流式事件返回 │└────────────────┬─────────────────┘ │ SSE 流式响应 ▼┌──────────────────────────────────┐│ Claude Code 客户端 ││ - 解析 server_tool_use 事件 ││ - 解析 web_search_tool_result ││ - 格式化输出,注入上下文 │└──────────────────────────────────┘
三、输入:你的查询是怎么被打包的?
3.1 用户侧输入 Schema
Claude Code 内部用 Zod 定义了工具的输入格式:
const inputSchema = z.strictObject({ query: z.string().min(2).describe('The search query to use'), allowed_domains: z.array(z.string()).optional() .describe('Only include search results from these domains'), blocked_domains: z.array(z.string()).optional() .describe('Never include search results from these domains'),})
三个字段:
query:搜索词,最少 2 个字符allowed_domains:只搜这些域名(白名单)blocked_domains:排除这些域名(黑名单)
关键约束:白名单和黑名单不能同时填,填了直接报错返回。这个设计非常合理,两个同时用在语义上就是矛盾的。
3.2 发给 API 的工具声明
客户端把上面的输入转成发给 Anthropic API 的工具定义:
function makeToolSchema(input: Input): BetaWebSearchTool20250305 { return { type: 'web_search_20250305', // 固定类型名 name: 'web_search', allowed_domains: input.allowed_domains, blocked_domains: input.blocked_domains, max_uses: 8, // 最多搜 8 次,硬编码 }}
3.3 完整的 HTTP 请求长什么样?
{ "model": "claude-sonnet-4-20250514", "max_tokens": 16384, "stream": true, "system": [ { "type": "text", "text": "You are an assistant for performing a web search tool use", "cache_control": { "type": "ephemeral" } } ], "messages": [ { "role": "user", "content": "Perform a web search for the query: 最新的 AI 编程工具有哪些?" } ], "tools": [ { "type": "web_search_20250305", "name": "web_search", "max_uses": 8, "allowed_domains": null, "blocked_domains": null } ], "tool_choice": { "type": "tool", "name": "web_search" }, "betas": ["web-search-2025-03-05"]}
注意两个细节:
- 用户消息是固定模板:
"Perform a web search for the query: {query}",不是原始的用户问题 tool_choice 强制指定:{ "type": "tool", "name": "web_search" },这告诉模型必须用 web_search 工具,不给它别的选择betas 字段:启用 beta 功能,这是 Anthropic 服务器端工具的激活方式
四、输出:流式响应是怎么被解析的?
这是整个实现里最复杂的部分,也是最值得学习的地方。
4.1 流式事件的完整时序
服务器返回的是 SSE(Server-Sent Events)格式的流,事件序列长这样:
① message_start → 消息开始,包含初始 usage 信息② content_block_start → type: text,模型准备说话③ content_block_delta → text_delta: "好的,我来搜索一下..."④ content_block_stop → 这段文本结束⑤ content_block_start → type: server_tool_use,搜索开始了!⑥ content_block_delta → input_json_delta: {"query":"AI编程工具 2026"}⑦ content_block_stop → 搜索词确定⑧ content_block_start → type: web_search_tool_result,结果来了! content: [{title, url, encrypted_content}, ...]⑨ content_block_stop → 搜索结果结束⑩ content_block_start → type: text,模型开始总结⑪ content_block_delta → text_delta: "根据搜索结果..."⑫ content_block_stop → 总结完成⑬ message_delta → stop_reason: "end_turn", usage: {web_search_requests: 1}⑭ message_stop → 完整消息结束
关键洞察:搜索结果 web_search_tool_result 是直接嵌在流里的 content_block,不需要客户端再去发一次"工具结果"消息。这就是服务器端工具和客户端工具最本质的区别。
4.2 客户端怎么解析这个流?
for await (const event of queryStream) { if (event.type === 'stream_event') { const evt = event.event // 阶段1:捕获搜索开始 if (evt.type === 'content_block_start' && evt.content_block?.type === 'server_tool_use') { currentToolUseId = evt.content_block.id currentToolUseJson = '' } // 阶段2:实时提取搜索词(用于 UI 进度提示) if (currentToolUseId && evt.type === 'content_block_delta') { if (delta?.type === 'input_json_delta') { currentToolUseJson += delta.partial_json // 用正则从增量 JSON 里抠出 query,不等 JSON 完整就更新 UI const queryMatch = currentToolUseJson.match( /"query"\s*:\s*"((?:[^"\\]|\\.)*)"/ ) if (queryMatch?.[1]) { // → 触发 UI 显示 "正在搜索: xxx" } } } // 阶段3:搜索结果到达 if (evt.type === 'content_block_start' && evt.content_block?.type === 'web_search_tool_result') { // → 触发 UI 显示 "找到 N 条结果" } }}
这段代码有个很精妙的点:它不等 JSON 完整再解析,而是用正则实时匹配增量 JSON 片段里的 query 字段,让 UI 能立刻显示"正在搜索: xxx"。
这就是你在 Claude Code 里看到的那个实时进度提示的实现原理。
4.3 搜索结果的数据结构
{ "type": "web_search_tool_result", "tool_use_id": "srvtu_01ABC123", "content": [ { "title": "GitHub - anthropics/claude-code: ...", "url": "https://github.com/anthropics/claude-code", "encrypted_content": "这是加密的页面内容,用于模型引用时追溯来源" }, { "title": "Claude Code 快速上手指南", "url": "https://example.com/claude-code-guide", "encrypted_content": "..." } ]}
注意 encrypted_content 字段——这是加密的页面正文,Anthropic 服务器用它让模型能"引用"页面内容,但客户端拿不到明文。
五、格式化:搜索结果怎么注入对话上下文?
解析完流式响应后,结果要注入回对话上下文,让主模型能引用。格式化后的工具结果长这样:
Web search results for query: "AI 编程工具 2026"根据搜索,目前最流行的 AI 编程工具包括:1. Claude Code —— Anthropic 推出的命令行 AI 助手2. GitHub Copilot —— 微软/GitHub 的代码补全工具...Links: [{"title":"Claude Code 官网","url":"https://..."},{"title":"...","url":"..."}]REMINDER: You MUST include the sources above in your response to the user using markdown hyperlinks.
最后那行 REMINDER 是一个提示词注入,强制主模型在给用户回答时必须带上来源链接。这就是为什么你在 Claude Code 里问联网问题,回答里永远有 Sources 部分——不是模型自觉的,是代码强制的。
六、错误处理:搜索失败了怎么办?
当搜索出错时,content 字段不是数组,而是一个错误对象:
{ "type": "web_search_tool_result", "tool_use_id": "srvtu_...", "content": { "error_code": "max_uses_exceeded" }}
四种错误码:
| | |
|---|
max_uses_exceeded | | |
query_too_long | | |
rate_limited | | |
internal_error | | |
七、启用条件:并非所有场景都能用
isEnabled() { const provider = getAPIProvider() const model = getMainLoopModel() if (provider === 'firstParty') return true // 直连 Anthropic:直接用 if (provider === 'vertex') { // Google Vertex AI return model.includes('claude-opus-4') || model.includes('claude-sonnet-4') || model.includes('claude-haiku-4') // 只有 Claude 4.0+ 支持 } if (provider === 'foundry') return true // Azure Foundry:直接用 return false // AWS Bedrock:不支持!}
如果你在用 AWS Bedrock 跑 Claude,不要指望 Web Search 能用,源码里直接写死 return false。
八、计费:每次搜索都是独立计费的
这个细节很多人不知道——Web Search 不按 Token 计费,按搜索次数计费:
// cost-tracker.tsmodelUsage.webSearchRequests += usage.server_tool_use?.web_search_requests ?? 0// 计费公式const cost = (input_tokens / 1_000_000) * inputTokenPrice + (output_tokens / 1_000_000) * outputTokenPrice + web_search_requests * webSearchRequestPrice // ← 额外按次计费
API 响应的 usage 字段会告诉你用了几次搜索:
{ "usage": { "input_tokens": 1500, "output_tokens": 500, "server_tool_use": { "web_search_requests": 2 } }}
九、设计亮点:为什么这样设计?
读完代码,我想聊几个值得学习的设计决策。
9.1 服务器端工具 vs 客户端工具
Claude Code 里的大多数工具(Bash、FileEdit、Read 等)是客户端工具——模型说"请帮我执行这条命令",客户端本地真的跑,再把结果发回给模型。
Web Search 是服务器端工具——把执行权完全交给 Anthropic 服务器。
这个设计让 Anthropic 可以在服务器端统一控制搜索质量、地区合规、反爬虫等问题,客户端只是一个消费者。
9.2 流式解析里的正则技巧
从增量 JSON 里实时抠出 query 字段,不等 JSON 完整:
const queryMatch = currentToolUseJson.match( /"query"\s*:\s*"((?:[^"\\]|\\.)*)"/)
这个正则能处理 JSON 字符串里的转义字符。实际工程中,等 JSON 完整再解析会造成 UI 卡顿(用户看不到"正在搜索 xxx"),这个小技巧让进度反馈更实时。
9.3 max_uses: 8 为什么硬编码?
防止模型在一次任务里无限搜索,控制成本,也防止响应时间过长。8 次是个经验值,搜 8 次已经可以覆盖大多数信息需求了。
十、如果你要自己实现一个类似的功能
假设你在做一个编程 CLI 工具,想加上联网搜索,有两条路:
路线 A:复刻 Claude Code 的方案(推荐)
直接用 Anthropic 的 web_search_20250305 服务器端工具:
import anthropicclient = anthropic.Anthropic()response = client.beta.messages.create( model="claude-sonnet-4-20250514", max_tokens=4096, messages=[{ "role": "user", "content": f"Perform a web search for the query: {user_query}" }], tools=[{ "type": "web_search_20250305", "name": "web_search", "max_uses": 5, }], betas=["web-search-2025-03-05"],)
优点:几行代码搞定,搜索质量有保证,不用管爬虫、去重、排序。缺点:按次计费,依赖 Anthropic 服务,地区有限制(目前仅美国可用)。
路线 B:自己接搜索 API
用 Tavily、Brave Search、SerpAPI 等,自己实现工具调用:
import tavilydef search_web(query: str) -> list[dict]: client = tavily.TavilyClient(api_key="...") results = client.search(query, max_results=5) return results["results"]# 然后把结果格式化注入到 LLM 的对话上下文里
优点:可控性高,可以选择数据源,不受地区限制。缺点:需要自己处理结果质量、格式化、引用追踪等问题。
推荐学习路线:先用路线 A 跑通 demo,理解整个流程;再用路线 B 做定制化需求。
十一、用一张图总结整个流程
你输入问题 │ ▼Claude Code 打包请求 │ messages: ["Perform a web search for..."] │ tools: [web_search_20250305] │ tool_choice: { name: "web_search" } │ ▼Anthropic 服务器接收 │ ├── 模型分析,生成搜索词 │ 流式事件: server_tool_use { query: "xxx" } │ ↓ │ 客户端 UI 更新: "正在搜索: xxx" │ ├── 服务器执行搜索 │ 流式事件: web_search_tool_result { content: [...] } │ ↓ │ 客户端 UI 更新: "找到 N 条结果" │ └── 模型生成总结 流式事件: text { "根据搜索结果..." } ↓ 客户端格式化,注入 REMINDER ↓ 主模型生成最终回答(带 Sources) ↓ 你看到带链接的回答
最后
这篇文章的源头是 Claude Code 的开源代码(CCB 分支),里面有完整的 TypeScript 实现。
如果你看完这篇文章,有几个可以立刻去做的事情:
- 独立开发者:去 Anthropic 文档看看
web_search_20250305 的官方文档,今天就能把联网搜索加进你的 CLI 项目里 - 找工作的同学:去 GitHub 搜 Claude Code 源码,找到
src/tools/WebSearchTool/WebSearchTool.ts,把这篇文章对照着读一遍,面试的时候说"我读过 Claude Code 的 WebSearch 工具实现"是真的 - 所有人:下次用 Claude Code 搜索的时候,脑子里会自动浮现出那个流式事件的时序图——那说明你真的理解了