让 DSH 的联网搜索走自己的 Gemini 反代
上一篇把服务器上的 antigravity-manager 用一个子域名开放了出来,日常的模型调用都改走 https://api.example.com/v1。之后我在另一台电脑上的 DSH(DeepSeek 出的命令行 agent 工具)里接上了 Gemini,对话没问题,但一用联网搜索,DSH 就提示要配置凭证。
我原本以为对话模型换成 Gemini,搜索也会跟着走 Gemini,结果不是。下面从两边的接口定义讲起,把弄通它的过程记下来。
文中域名写成
example.com,服务器地址写成<服务器IP>,密钥一律省略。
DSH 的搜索是怎么工作的
DSH 的联网搜索和对话模型是分开的。对话模型只负责决定"要搜",调用一个叫 web_search 的工具;工具背后是一个 web 能力层(代码里叫 ctx.web),能力层再把请求交给一个"搜索提供方"插件。DSH 自带和可装的提供方有 DeepSeek、Exa、Perplexity 几种,各自要各自的 key。所以那个"要配置凭证",要的是搜索提供方的 key,和对话模型用什么没有关系。
web_search 工具由 dsh-tool-web 提供,它给搜索设了一个超时 searchTimeoutMs,默认 30 秒。一次工具调用可以带好几个搜索词,它们共用这 30 秒。
DeepSeek 的搜索提供方有两个插件,接口完全不一样。
第一个是 web-search-deepseek,走 Anthropic 的 Messages 格式。它会向 {baseURL}/messages 发这样一个请求:
{
"model": "deepseek-v4-flash",
"max_tokens": 4096,
"messages": [{
"role": "user",
"content": [{"type": "text", "text": "Perform a web search for the query: LPL 2026 战绩"}]
}],
"tools": [{"type": "web_search_20250305", "name": "web_search", "max_uses": 5}]
}
key 同时放在 x-api-key 和 Authorization: Bearer 两个请求头里,请求禁止跟随跳转。web_search_20250305 是 Anthropic 定义的服务端搜索工具,意思是让服务端自己去搜,再把结果放进回复。插件读回复时只认 web_search_tool_result 块:
{
"type": "web_search_tool_result",
"tool_use_id": "srvtoolu_...",
"content": [
{"type": "web_search_result", "url": "https://...", "title": "...", "page_age": null, "encrypted_content": ""}
]
}
url、title 直接变成结果,page_age 变成发布时间。摘要不在这个块里,插件会去 text 块的 citations[] 里按 URL 找 cited_text,每个 URL 取第一条。插件是严格模式,回复里没有 web_search_tool_result 就直接报 WEB_PROVIDER_ERROR,不会退回去从正文里抓链接。
第二个是 web-deepseek-api,走 DeepSeek 新出的 Search API,简单得多:
POST {baseURL}/search
Authorization: Bearer <key>
{"query": "LPL 2026 战绩", "count": 3, "need_content": false, "ori_question": "用户原话的前 128 个字"}
回复是:
{"results": [{"url": "https://...", "title": "...", "snippet": "...", "date": null}]}
插件对每一条都会校验:url 必须是非空字符串,title 和 snippet 必须是字符串,date 是字符串或 null,少一个字段就报错;空数组算正常。count 取配置值和 DSH 传来的 maxResults 里小的那个。这个插件还带一个 /fetch 接口,用来抓网页正文,在 DSH 设置里打开"启用新版 Web 搜索 API"会把搜索和抓取一起切过去。
Gemini 这边能做什么
antigravity 背后是 Google 的 Gemini。Gemini 的联网搜索是在请求里加一个 googleSearch 工具,用原生接口时长这样:
{
"contents": [{"role": "user", "parts": [{"text": "Search Google for: LPL 2026 战绩"}]}],
"tools": [{"googleSearch": {}}]
}
模型会自己决定搜什么,读完结果再回答,同时在回复的 groundingMetadata 里记下用了哪些网页、哪句话引用了哪个网页:
"groundingMetadata": {
"webSearchQueries": ["LPL 2026 季后赛 冠军"],
"groundingChunks": [
{"web": {"uri": "https://vertexaisearch.cloud.google.com/grounding-api-redirect/AUZIYQ...", "title": "wikipedia.org"}}
],
"groundingSupports": [
{"segment": {"text": "**冠军**:**BLG**"}, "groundingChunkIndices": [0]}
]
}
groundingChunks 里的链接是 Google 的跳转地址,title 只是域名;访问这个地址会得到一个 302,Location 才是真实网址。另外,这些信息只在流式接口 streamGenerateContent 的最后一段里才有,非流式接口我测的时候没有返回。
第一次尝试:直接把 DSH 指向 antigravity
最省事的想法是把 web-search-deepseek 的 baseURL 直接填成 https://api.example.com/v1,让它去打 antigravity 的 /v1/messages。DSH 那边的 agent 测完的结论是:接口通了,返回 200,但回复里既没有 web_search_tool_result,也没有任何引用,模型直接编了一个答案,连语言都答错了。
我在服务器上把几种搜索写法都试了一遍,判断有没有真的去搜,看的是 antigravity 日志里有没有 Using account … (type: web_search) 这一行:
| 写法 | 接口 | 有没有搜 |
|---|---|---|
web_search_20250305(Anthropic) |
/v1/messages |
没有,被忽略 |
web_search_options(OpenAI) |
/v1/chat/completions |
没有,被忽略 |
web_search_preview(OpenAI) |
/v1/chat/completions |
没有,被忽略 |
{"googleSearch": {}}(Gemini) |
/v1/chat/completions 或 /v1beta |
搜了 |
antigravity 做的事是把各种格式的请求翻译成 Google 内部接口再转发,Anthropic 和 OpenAI 那几种服务端搜索工具,它翻译时直接丢掉了,只有 Google 自己的 googleSearch 会生效。用 web_search_options 的那次,问"2026 年 10 月 nginx 最新稳定版",模型回答"这是未来的日期,无法提供";换成 googleSearch,答出了 10 月 14 日发布的 1.30.6,还附了 nginx.org 的链接。
顺手也看了 OpenAI 的 Responses 接口(/v1/responses)。antigravity 支持它,但不保存对话状态:第一轮告诉模型暗号,第二轮带上 previous_response_id 问它,它不记得。input 用纯字符串写的时候内容还会被丢掉,模型只回了一句"Hello! How can I help you today?"。所以 Responses 在这里没有任何优势,后面也没用它。
DSH 的插件代码里把 web_search_20250305 写死了,发不出 googleSearch;antigravity 又只认 googleSearch。两边都改不了,只能在中间加一层翻译。
中间的适配服务
适配服务要做的是:接住 DSH 的请求,取出搜索词,改写成带 googleSearch 的 Gemini 请求发给 antigravity,再把 groundingMetadata 改写成 DSH 要的格式还回去。
我用 Python 标准库写的,没有依赖,一个文件三百多行。有几个地方是特意这么做的:
- 不保存密钥。DSH 发来的 key 原样转给 antigravity,由 antigravity 去校验,所以 DSH 那边填的就是 antigravity 后台建的用户 token,用量也能在后台按 token 看到。
- 跳转链接不跟随,只读 302 里的
Location。8 个线程并发处理,每个最多等 4 秒,失败就保留原链接。 - 同一个网址只留一条,摘要取引用它的最长那段话。一开始取的是第一段,结果 nginx 那条只截到了
"x stable branch)"半句。 - 日志每次只记一行状态码、结果数和耗时,不记搜索词和 key。
两种协议最后都落到同一个函数上:
def grounded_sources(grounding):
"""Deduped [{url, title, snippet}] in Gemini's order; snippet is the longest cited segment."""
chunks = [c.get("web") or {} for c in grounding.get("groundingChunks") or []]
with ThreadPoolExecutor(max_workers=8) as pool:
resolved = list(pool.map(lambda c: resolve_redirect(c.get("uri", "")), chunks))
sources, index_to_url = {}, {}
for i, (chunk, url) in enumerate(zip(chunks, resolved)):
if not url:
continue
index_to_url[i] = url
sources.setdefault(url, {"url": url, "title": chunk.get("title") or "", "snippet": ""})
for support in grounding.get("groundingSupports") or []:
text = ((support.get("segment") or {}).get("text") or "").strip()
for i in support.get("groundingChunkIndices") or []:
source = sources.get(index_to_url.get(i))
if source and len(text) > len(source["snippet"]):
source["snippet"] = text
return list(sources.values())
Search API 只是把它包成 {"results": [...]},date 一律给 null,因为 Google 的结果里没有发布时间;Messages 则包成 server_tool_use、web_search_tool_result 和带 citations 的 text 三个块。
部署和上一篇的 nginx 一样,放进 Docker 内部网络。容器同时接两个网络:antigravity-gateway 用来连 antigravity、也让 nginx 能找到它;自己的 egress 网络用来出网解析跳转链接,因为 antigravity-gateway 是 --internal 的,出不了网。
services:
dsh-search-adapter:
image: python:3.12-alpine
container_name: dsh-search-adapter
restart: unless-stopped
command: ["python", "-u", "/app/app.py"]
user: "65534:65534"
environment:
UPSTREAM: http://antigravity:8045
SEARCH_MODEL: gemini-3.8-flash-low
MAX_OUTPUT_TOKENS: "400"
CACHE_TTL: "900"
PORT: "8080"
volumes:
- type: bind
source: ./app.py
target: /app/app.py
read_only: true
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
mem_limit: 96m
memswap_limit: 96m
cpus: 0.25
pids_limit: 64
networks:
egress: {}
gateway:
aliases:
- dsh-search
networks:
egress:
driver: bridge
gateway:
name: antigravity-gateway
external: true
nginx 在 api 子域名的 server 块里加一条,跟模型 API 共用限速:
set $dsh_search_upstream dsh-search:8080;
location ~ ^/dsh-search/v1/(messages|search)$ {
client_max_body_size 1m;
limit_req zone=llm_api_requests burst=40 nodelay;
limit_conn llm_api_connections 30;
limit_req_status 429;
limit_conn_status 429;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_read_timeout 180s;
proxy_send_timeout 60s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://$dsh_search_upstream/v1/$1;
}
$dsh_search_upstream 用变量写,是为了让 nginx 在请求时才解析这个名字,适配服务没起来时博客照样能启动。
验证的时候我没有自己拼请求,而是直接导入本机 DSH 包里的 DeepSeekSearchProvider 类,用它去请求公网地址,等于让 DSH 自己的代码来检查格式。三个搜索词都拿到了真实网址、标题和摘要;故意填错 key,插件报的是 WEB_PROVIDER_ERROR 和上游的 403,没有崩。
插曲:路径和另一套协议
DSH 那边的 agent 接着测,报告说 /dsh_search/ 一直 404。翻 nginx 日志才看出来,它请求的是下划线的 /dsh_search/v1/search,我部署的是连字符的 /dsh-search/v1/messages,请求根本没到适配服务。
它请求 /search,是因为那台设备上的 DSH 比我本机的新,装的是 web-deepseek-api 插件,走的是 Search API。我本机的 DSH 里没有这个插件,就从 npm 下了 @deepseek-ai/dsh-web-deepseek-api 0.2.1-rc.2 的源码来读(只读,没有执行),照着它的 mapSearchResponse 把 /search 加进了适配服务,nginx 的规则也从精确匹配改成了上面那个正则。
/fetch 我没有做。它的作用是让服务器替调用方去抓任意网址,放在服务器上等于给了别人一个从内网往外、甚至往内网其他服务发请求的入口。抓网页继续用 DSH 本地的 HTTP 抓取就行,所以 DSH 那边只改 searchProvider,不要打开"启用新版 Web 搜索 API"那个总开关。
DSH 那边最后的配置是:
- name: '@deepseek-ai/dsh-web'
config:
searchProvider: deepseek-search-api
- name: '@deepseek-ai/dsh-web-deepseek-api'
config:
baseURL: https://api.example.com/dsh-search/v1
apiKeyEnv: AG_SEARCH_KEY
count: 3
key 放在单独的 AG_SEARCH_KEY 里,因为 DEEPSEEK_API_KEY 还被 DeepSeek 对话模型用着。
搜索经常超时
能用了以后,DSH 又反馈搜索经常超时,并给了三条建议:count 已经是 3,再降意义不大;检查是不是"搜索 + 模型总结"串行,改成先返回原始结果、摘要异步补;加一层服务端缓存。
第一条它说得对,结果条数几乎不影响耗时。第二条我先在服务器上测了一下,记录每次请求"来源出现的时间"和总耗时,结果来源全都是跟最后一段一起到的。搜索发生在模型生成回答的过程里,中间没有一份原始结果可以先拿出来,所以"先返回、后补摘要"做不到。能做的只是让模型少写一点。
原来的提示词让模型写一段完整的回答,一次要 9 到 24 秒;LPL 那条最慢,25 秒。DSH 一次 web_search 只有 30 秒,还要被几个搜索词分着用,超时很正常。我换了几种配置各跑了几轮:
| 配置 | 平均耗时 | 来源数 |
|---|---|---|
| 3.8-flash,完整回答 | 9~24 秒 | 2~10 |
| 3.8-flash,只回一句,关掉思考 | 7~9 秒 | 1~2 |
| 3.8-flash-low,只回一句 | 5 秒左右 | 1~2 |
| 3.1-flash-lite,只回一句 | 2~3 秒 | 0~1,有一次没去搜 |
| 3.8-flash-low,最多 5 条一行要点 | 7.6 秒 | 3~7 |
| 3.8-flash,5 条要点,关掉思考 | 22 秒 | 5 |
来源的数量取决于回答里引用了几处事实,只回一句话快是快,来源只剩一两条。最后选的是 gemini-3.8-flash-low 加"最多 5 条一行要点,每条来自不同的搜索结果",输出上限 400 token。flash-lite 虽然最快,但有时根本不去搜,不能用。
缓存按"key 的哈希 + 规范化后的搜索词"存 15 分钟。key 一定要算进缓存键,不然别人随便填个 key,就能直接读到我搜过的结果,等于绕过了鉴权。同时到达的相同请求只查一次上游,DSH 超时重试的那一次会直接等第一次的结果。失败和空结果不进缓存,免得一次没搜到的结果被复用 15 分钟。ori_question 也不再拼进提示词:DSH 插件的文档里自己写了,换不同的 ori_question 结果没有变化,留着它反而会让同一个搜索词命中不了缓存。
改完从公网按 DSH 的格式测,没命中缓存每次 7 到 9 秒,每次 3 条结果;马上再搜一遍同样的词,每次 0.4 秒,基本只剩网络往返。
会不会被限流
用了一段时间,我又把三层都查了一遍。nginx 过去三天返回过 16 次 429,全是我自己压测限速时连发 40 个请求打出来的。antigravity 的用户 token 没设 IP 上限,IP 黑名单是空的。上游三个账号的 Flash 系列额度都还剩 96% 到 98%。
失败的请求倒是有,三天 87 次 503,原因分三类。45 次是 antigravity 刷新 Google 登录凭证时,经代理连不上 oauth2.googleapis.com,3.5 秒超时,三个账号接连失败,集中在两个时间段;这是代理节点的问题。20 次是有人请求了 gemini-4-argon-disabled,这个模型本来就不可用。剩下的是 Claude 5.5 和 Gemini 3.1 Pro,账号池里这些模型的额度已经用完。我只用 Flash,后两类不影响。
限速本身没碰到,但我的 Hermes、DSH、AstrBot 都从同一个出口出去,共用 nginx 按 IP 的额度,搜索一次又要占着连接七八秒,所以还是放宽了一些:每秒请求从 5 调到 10,burst 从 20 调到 40,同时连接从 10 调到 30。连发 60 个请求测了一下,47 个放行,13 个被限。
为什么放在服务器上
后来我想过,这层翻译是不是在 DSH 那边做就够了,没必要动服务器。可以做,但省不掉服务器的改动。要拿到结构化的来源和摘要,必须用 Gemini 的原生流式接口 /v1beta/...:streamGenerateContent,而这个路径被上一篇的 nginx 白名单挡在外面,在 DSH 端做也得去服务器上开放它。如果不开放,只用 /v1/chat/completions 加 googleSearch,来源只会以 Markdown 链接的形式拼在正文末尾,标题只有域名,也没有摘要。
放在服务器上,DSH、AstrBot 和以后换的设备都能共用同一个接口和同一份缓存,解析 Google 跳转链接也是从香港直连,不用绕本机的代理。DSH 那边只需要填一个 baseURL 和一个 key,DSH 升级也不用跟着改。代价是服务器上多一个占 20 多 MB 内存的容器。
留一张这次的改动清单:
| 对象 | 改动 |
|---|---|
服务器 ~/dsh-search-adapter/ |
新增 app.py 和 compose.yaml,容器 dsh-search-adapter |
~/aurora/nginx/nginx.conf |
新增 /dsh-search/v1/(messages|search) 路由;按 IP 限速放宽到 10r/s、burst 40、30 个连接 |
| DSH 设备 | searchProvider: deepseek-search-api,baseURL 指向 /dsh-search/v1,key 用 AG_SEARCH_KEY |