qp-exa-dynamic
Verifiedqp-exa-dynamic · v0.2.0 · MIT
Exa web search provider for the DeepSeek Harness ctx.web seam, with Dynamic Highlights on by default, an /exa command to change highlights, search type and result count at runtime, and an exa_search tool that owns its own result cap.
Install
dsh plugin add qp-exa-dynamic Confirm the layer applied with dsh --profile default --dump-config — see the install guide.
Source
Tags
Creators
Readme
qp-exa-dynamic
English | 简体中文
Exa 支撑的 WebSearchProvider,接入
DeepSeek Harness 的 ctx.web seam。
默认开启 Exa Dynamic Highlights,提供 /exa 命令在运行时改高亮、检索类型和返回条数,以及一个
exa_search 工具让你按需走 Exa——而不改变 profile 里其它搜索用哪个提供方。
dsh plugin --profile web add qp-exa-dynamic
为什么需要它
官方的 @deepseek-ai/dsh-web-search-exa 够不到 Dynamic Highlights,是两处互相独立的硬伤:
请求体写死。 它发的是
contents.highlights.highlightsPerUrl,配置里没有任何字段能影响到dynamic。Dynamic Highlights 是 beta 能力,需要请求头。 每个设了
dynamic: true的请求都必须同时带上Exa-Beta: dynamic-highlights-2026-08-28。那个提供方只发authorization、content-type、accept、user-agent——没有Exa-Beta。缺了它 Exa 直接回 HTTP 400(实测):{"error":"'highlights.dynamic' is in beta. Send the 'Exa-Beta: dynamic-highlights-2026-08-28' request header to use it.","tag":"INVALID_REQUEST"}
本插件两样都发,并且彻底去掉了 highlightsPerUrl——对着真实 API 实测,Exa 已经忽略这个参数,
传 1 和传 5 返回逐字节相同的结果。改用 maxCharacters,那才是关掉动态高亮时真正生效的旋钮。
实测数据
真实 /search 调用,同一个查询,8 条结果:
| 配置 | 高亮字符数 |
|---|---|
| 官方提供方的默认值(无可用旋钮) | 51,152 |
本插件 dynamicHighlights: false + highlightsMaxCharacters: 1500 |
10,973 |
本插件 dynamicHighlights: true(默认) |
12,716 |
Dynamic Highlights 不是一刀切截断:它把所有召回文档拼成一条输入、只做一次前向,在全局范围分配 共享预算——好内容多给,冗余的不给。
安装
dsh plugin --profile web add qp-exa-dynamic
包自带 dsh.bundle 清单,安装后 bundle patch 会自动插入 provider 行,不需要手写。
这一步是可选的。 exa_search 工具按需走 Exa,完全不需要它;只有想让 web_search 本身也走 Exa
时才需要。在 $DSH_HOME/profiles/web/cordis.patch.yml 里覆盖 web 那一行:
- id: web
name: '@deepseek-ai/dsh-web'
config:
searchProvider: exa
fetchProvider: http
patch 是整块替换目标行的
config、不做合并,所以fetchProvider: http必须一起重写, 否则抓取提供方会丢。
然后给密钥,两种方式。写进插件配置:
- id: qp-exa-dynamic
name: qp-exa-dynamic
config:
apiKey: '你的 Exa 密钥'
或者走环境变量。apiKey 标了 role('secret'),不会出现在任何 describe() 响应里——但明文配置文件
终究是明文配置文件,能用环境变量就尽量用。
密钥按这个顺序读取: 配置里的字面
apiKey→ harness 凭据库里的apiKeyEnv(把EXA_API_KEY加进去,用 GUI 的凭据卡片或$DSH_HOME/.credentials.yaml)→ 启动环境快照(继承环境、 调用目录的.env、Harness 主目录的.env)。凭据库排在快照前面,是因为快照在某些 Windows 安装上按文档就会漏掉变量——本插件正是跑在这类部署上。 如果全局选中了
exa却报registered but unavailable,先查凭据库,再考虑退回明文配置里的字面apiKey。
改环境变量后要重启 dsh web。cordis.patch.yml 本身是热加载的,所以配置改动不用重启。
配置字段
全部有安全默认值,通常只需要提供密钥。
| 字段 | 默认值 | 含义 |
|---|---|---|
providerId |
exa |
注册 id。只在需要与另一个 Exa 提供方共存时改。 |
apiKey |
未设置 | 字面密钥;不设则回退到 apiKeyEnv。 |
apiKeyEnv |
EXA_API_KEY |
apiKey 未设时读取的环境变量名。 |
baseURL |
https://api.exa.ai |
Exa 端点;会追加 /search。 |
searchType |
auto |
检索类型,见下。可用 /exa type 运行时改。 |
numResults |
8 |
来源上限。可用 /exa results 运行时改。 |
dynamicHighlights |
true |
默认开;开启时自动带上必需的 Exa-Beta 头。可用 /exa 运行时改。 |
highlightsMaxCharacters |
未设置 | 每页高亮上限,仅在 dynamicHighlights 为 false 时生效。 |
dynamicHighlights 不会和 highlightsMaxCharacters 同时发——动态开启时共享预算由 Exa 自行分配,
官方文档也明确警告两者不要并用。
/exa 命令
敲在输入框里。它直接对界面执行,不产生模型消息。
| 命令 | 作用 |
|---|---|
/exa |
切换 Dynamic Highlights |
/exa on / /exa off |
明确设置 |
/exa type |
列出可用的检索类型 |
/exa type deep |
设置检索类型 |
/exa results |
报告来源上限 |
/exa results 3 |
设置来源上限 |
/exa status |
一次报全:当前状态、可用的 type 列表、真实天花板 |
命令声明了 input.hint(on|off|status|type deep|results 3)——这是 Web 输入框把参数交给处理函数的
前提,否则整行会被当成普通聊天消息发给模型。方括号和引号都会被当作透明字符,所以那个提示模板依然可
以就地编辑。/exa status 顺带把检索类型列出来,想不起来有哪些 type 时不用再敲第二条;参数打错时会回
一行可以直接照抄的示例。
写入走 settings 服务,命名空间是 Loader 的配置项 id qp-exa-dynamic,因此落在 profile patch 的
用户层,跨重启保留。清掉那一节即回到插件配置的默认值。命令会写的每个字段都在 schema 里标了
.volatile() —— 写入路径和自动生成的配置卡片都要求这一点。
实测同一个 provider 实例、同一个查询:关掉动态高亮让同一次搜索从 12,716 字符变成 57,958 字符—— 差 4.6 倍,下一次搜索即刻生效。
exa_search 工具
插件还在 web_search 旁边注册了第二个面向模型的工具:
exa_search(query: string, maxResults?: integer) // maxResults 1-50
它存在的目的是让 Exa 按需可达,而不改变 profile 里其它搜索用谁。seam 对每个 profile 只选一个
提供方——不是每次调用选,而且 searchProvider 在服务构造时就固化了——所以一个钉死官方搜索的
profile,只能靠一个绕过该选择的工具才够得着 Exa。这个工具就是直接调插件自己的 provider,不走
ctx.web.search。
路由就这么简单:
| 工具 | 路径 | 走谁 |
|---|---|---|
web_search |
ctx.web |
profile 钉死的那个(不受影响) |
exa_search |
直连 | Exa |
用哪个工具,就是选哪个提供方。 不改任何全局设置、不用重启,Exa 就在一次调用之外,而 web_search
照旧用默认提供方。
直连还顺带让这个工具拥有自己的 request.maxResults。dsh-tool-web 每次走 seam 都传自己的
searchMaxResults,所以任何经 seam 的提供方都不可能超过那个上限,要抬它就得 fork agent preset。
这个工具拥有自己的上限,于是条数从部署级天花板变成了每次调用的模型参数:
| 想要 | 怎么做 | 需要 fork preset 吗 |
|---|---|---|
| 某次要 20 条 | exa_search(query, maxResults: 20)——直接用话问就行 |
不需要 |
让 /exa results 20 生效 |
/exa 命令 |
需要 |
因为不再经过 seam,截断这份契约改由工具自己履行(capToolResult):Exa 如果对有界请求超量返回,
sources 会被裁掉并置 truncated。它 50 的上限是自己的——动态高亮实测每条约 1.6k 字符,50 条已经是
约 2 万 token 的上下文。
没有密钥时会明确报错(no Exa API key: …),而不是悄悄回落到部署的默认提供方替你查。
web_search 旁边会多一段提示告诉模型什么时候该用它(日常检索仍走 web_search)。如果组合里没有
tools 注册表,provider 照常挂载,只是没有这个工具。
检索类型
Exa 的 type 就是延迟/质量的旋钮。8 种全部对着真实 API 验证过;同一查询、8 条结果的实测耗时:
| 类型 | 实测 | 用途 |
|---|---|---|
keyword |
464 ms | 纯关键词,最快 |
neural |
737 ms | 语义检索 |
fast |
798 ms | 快,质量损失极小 |
instant |
856 ms | 实时场景(对话、语音) |
auto |
1,914 ms | 默认 |
deep-lite |
3,116 ms | 轻量综合输出 |
deep |
5,282 ms | 多步推理 |
deep-reasoning |
18,278 ms | 最难的研究任务 |
官方提供方的 schema 只列了 auto、keyword、neural 三种——那套已经过时。本插件全部开放。
deep* 在这个 seam 下有折扣
用本插件自己的类实测,同一查询,动态高亮开启:
| 类型 | 耗时 | 返回来源数 |
|---|---|---|
fast |
718 ms | 8 |
auto |
215 ms | 8 |
deep |
6,891 ms | 3 |
deep-reasoning |
14,776 ms | 4 |
原始 API 对 deep 是返回 8 条的;其余几条没有非空白高亮,被整个丢弃了——seam 没有别的字段能当
snippet,编造一个会让 seam 说谎。而 deep* 真正值钱的是跨来源综合出的 output,WebSearchSource
里没有字段承载它。所以在这个 seam 下,实用区间是 keyword、neural、fast、instant、auto。
返回条数归 dsh-tool-web 管
这一条容易误解,说清楚:
- 模型侧
web_search工具的参数只有queries——模型无法要求条数。 - 天花板归
dsh-tool-web:searchMaxResults,默认 8。它自己的注释: "The consumer owns the returned-context limit; providers and models do not." - 工具每次调用都会传
maxResults,seam 再按它截断结果——所以任何提供方都不可能超过天花板。
于是本插件的 numResults 是单向的:能拉低,拉不高。
| 你设的 | 工具天花板 | 实际发给 Exa |
|---|---|---|
| 3 | 8 | 3 |
| 12 | 8 | 8(夹取) |
| 20 | 8 | 8(夹取) |
/exa status 和 /exa results 会报出 provider 观测到的真实天花板,所以被夹取时是明说而不是
静默生效。
在 Web 端,抬天花板不是改 profile patch 能解决的。 dsh-web-app 把宿主那行 tool-web 设为
disabled: true——因为宿主侧只有 web 服务和它的搜索提供方,面向模型的工具是每会话一份的,
真正生效的那份来自 agent preset。随附的 standard preset 里那行只有 fetch 和
searchTimeoutMs,没有 searchMaxResults,于是取 schema 默认值 8。往 profile patch 里写
tool-web 只会落在被禁用的宿主行上,什么都不做。
所以要改就得 fork preset:把 standard 复制到 $DSH_HOME/.agent-presets/,在它的 tool-web 行加
searchMaxResults,再选为默认。这是笔真实的账——副本不会跟随随附 preset 的上游更新,而默认值是在
创建会话时读取的,运行中的会话仍停在它们当初组装的 preset 上。多数情况下留在 8 更划算:/exa results 仍然能把条数往下调,而那个方向才是省 token 的。
已知限制
- Exa 的 beta 接口可能变。
dynamic-highlights-2026-08-28是 research preview,Exa 改版后需要 更新DYNAMIC_BETA_VALUE。 - 没有高亮的结果会被整条丢弃,这是 seam 的规则。动态高亮下实测 8/8 条都带高亮,所以很少触发。
- 没有
category、域名/日期过滤,也没有全文。 这些 Exa 能力本插件尚未暴露。 - 本插件与官方 Exa 提供方每个 profile 只能选一个。 两者默认都注册 provider id
exa;要共存需给 其中一个设不同的providerId。 exa_search需要 Exa 密钥,web_search不需要。 这个工具直连 Exa,没密钥时会明确报错而不是 回落——这正是"选择是真的"的代价。web_search两边都不受影响。- 仅测过 dsh
0.2.0-rc.2,peer 范围也锁在这条线上(>=0.2.0-rc.2 <0.3.0-0)。 - 没有 settings 服务时
/exa只改内存。 provider 本身照常工作;没有命令注册表时就没有/exa。 settings 服务拒绝写入时(没有这个 entry id、字段不是 volatile、profile 只读)会如实回could not save: …,不会静默吞掉。
开发
node test/index.test.js # 44 个单元测试,不需要密钥
EXA_API_KEY=... node test/live.mjs # 打真 API,会消耗额度
请直接运行测试文件,不要用 node --test:后者的 runner 会为每个文件 spawn 子进程,在受限沙箱下
会以 spawn EPERM 失败。
从本地检出安装
dsh plugin --profile web add <路径> 记下的是一个 link: 依赖,profile 按包名链接它。而 Node 解析
被链接模块自身的 import 时,用的是它的真实路径、不是 profile 里那个链接——所以放在
$DSH_HOME/profiles/ 之外的检出没有能到达 harness 各包的 node_modules 祖先目录,每一个
@deepseek-ai/dsh-* 导入都会以 ERR_MODULE_NOT_FOUND 失败:插件挂载上了,然后在第一个 import 上炸。
给它配一份指向运行时同一批实例的 node_modules:
$plugin = "<这个检出的路径>"
$hoist = "$env:USERPROFILE\.dsh\profiles\node_modules\@deepseek-ai"
New-Item -ItemType Directory -Force "$plugin\node_modules\@deepseek-ai" | Out-Null
foreach ($pkg in @("dsh-web", "dsh-tools", "dsh-launch-environment", "schemastery")) {
cmd /c mklink /J "$plugin\node_modules\@deepseek-ai\$pkg" "$hoist\$pkg"
}
用 junction 而不是再跑一次 pnpm install,是刻意的:@deepseek-ai/dsh-tools 是运行时单例,
嵌套一份副本会让 agent loop 在 provider 被调用之前就崩。全部指向 hoist 目录,才能保证每个包只有
一个物理实例。
卸载
dsh plugin --profile web remove qp-exa-dynamic
把 cordis.patch.yml 里的 web 覆盖删掉即回到内置的 DeepSeek 搜索。那个改动是热加载的,立刻生效。
许可
MIT