# 架构与机制 > 本文是 `dsh-web-search-pool` 的**唯一技术权威**:DSH 集成机制、调度核心、配置模型、数据流与扩展路径。 > 安装与排障见 [安装与升级](安装与升级.md),开发纪律见 [开发规范与事故复盘](开发规范与事故复盘.md)。 ## 0. 事实边界 - 调研日期:2026-08-14 ~ 2026-08-18(宿主 0.1.0-rc.6 / 0.1.0-rc.7),2026-09-08 复核(宿主 0.1.2-rc.1),2026-09-25 大改复核(宿主 = 官方桌面版 0.1.7-rc.2,从 `resources/app.asar` 提取源码实测),2026-09-28 双线复核(宿主 = 官方桌面版 0.2.0-rc.1),**2026-10-19 接管机制复核(宿主 = 官方桌面版 0.2.1-rc.2:源码核对 + `scripts/e2e-boot.mjs` 真实启动全绿)**,**2026-10-21 外部 issue #1 复核(重试推进缺陷,单测 + 内联复现验证,未跑真实宿主 e2e)**。 - 来源:本地已安装 DSH 源码(0.1.7-rc.2 / 0.2.0-rc.1 / 0.2.1-rc.2 的 `@deepseek-ai/dsh-web`、`dsh-web-deepseek-api`、`dsh-web-search-deepseek`、`dsh-tool-web`、`dsh-settings`、`dsh-config-editor`、`dsh-client-modules`、`dsh-client-ui-settings`、`dsh-client-ui-plugin-manager`、`dsh-plugin-manager`、`dsh-app-boot`、`cordis`、`cordis-plugin-loader`)+ 本项目 `src/` 源码。 - 结论按**当前代码实测**书写;宿主大版本升级时需重新核对(见 §3.5 版本线)。 ## 1. 在 DSH 中的位置 ``` 模型调用 web_search 工具(@deepseek-ai/dsh-tool-web:schema/校验/结果格式化) └─ ctx.web.search(request, signal) # 0.5.0:本插件在此安装可逆 wrapper ├─ enabled=true → SearchPoolProvider.search()(key 池,多供应商调度) └─ enabled=false → 原 WebRuntime.search → 官方 provider └─ 归一化 WebSearchResult { content?, sources[], truncated } ``` `WebSearchProvider` 契约(`@deepseek-ai/dsh-web`): ```ts interface WebSearchProvider { readonly id: string; available(): boolean; // 本地可用性检查,不发网络请求 search(request, signal?): Promise; } ``` 关键约束(决定了本插件形态): 1. **seam 只选一个 provider**:配置了 `searchProvider`(或环境变量 `$DSH_WEB_SEARCH_PROVIDER`)就用它;未配置且恰好一个可用则自动选;多个可用抛 `WEB_PROVIDER_AMBIGUOUS`。⇒ 多 key、多供应商的负载均衡**必须封装在单个 provider 内部**。 2. **内置 provider 只有 `deepseek-official`**(单 key,走 DeepSeek Messages API),没有内置 Tavily/Exa。 3. **动态 Cordis 插件的沙箱没有 `fetch`**,发 HTTP 的 provider 必须是真实 Node 包(composition 插件)——本项目即以 npm 包 + `dsh.bundle.patch` 形态分发。 4. **配置优先于环境变量**:`WebRuntime.searchProviderId = config.searchProvider ?? env`,provider 在**调用时**才选。0.5.0 因此在 `WebRuntime.search` 方法上接管(见 §3.2),不改 `web` 行配置。 5. `web_search` 工具 schema 只有 `query`,模型无法传额外参数;「按 query 语义自动决定搜索参数」只能落在 provider 层(`core/query-intent.js` + `core/resolve-params.js`)。 ## 2. 模块结构 | 层 | 目录 | 职责 | 依赖 | |---|---|---|---| | 核心调度 | `src/core/` | KeyPool、TokenBucketLimiter、Scheduler、错误类型、HTTP 工具、query 意图解析 | **不依赖 DSH**,可独立单测,可被未来 HTTP 外壳复用 | | 适配器 | `src/adapters/` | TavilyAdapter、ExaAdapter(REST + 匿名 MCP),字段映射与 429 识别 | 仅 `core` | | DSH 封装 | `src/dsh/` | `index.js` 插件入口、`config.js` schema/选项解析、`provider.js` provider 实现、`client.js` 设置页卡片 | DSH peer 包 | | 脚本 | `scripts/` | `run-tests.mjs`(免 spawn 测试)、`check-usage.mjs`(Tavily 额度 CLI)、`e2e-boot.mjs`(真实运行时隔离 profile 端到端验证;`--user-patch` 验真实用户层组合,`--live-search` 真机搜索冒烟)、`sync-peer-deps.mjs`(把宿主 app.asar 的 peer 包同步进本地 node_modules 供测试)、`privacy-scan.mjs`(发布前全历史隐私扫描)、`patch-api-proxy-namespace.mjs`(旧版诊断) | Node 内置 | ## 3. 装配机制(原生 Bundle) ### 3.1 package.json 的 `dsh` 字段 ```jsonc "dsh": { "bundle": { "patch": "./cordis.patch.yml" }, // Host:composition patch "client": { // Web:client half "platform": "web", "inject": ["@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-ui-settings", "@deepseek-ai/dsh-client-ui-plugin-manager"] } } ``` - `dsh.client.platform` 必须是 `'web'`(0.1.7 的 `dsh-client-modules` 只编排 `platform: 'web'` 的包;桌面版 Electron 里跑的就是这套 web client)。 - `dsh.client.inject` 只声明**到达顺序**(这些包的浏览器 bundle 先于本包加载),与卡片自己的 `exports.inject`(cordis 服务依赖)是两回事。 - `exports["./client"]` 必须指向浏览器 bundle 文件(`dsh-client-modules` 按它读取并 serve)。 - `peerDependencies` 的实际范围见 [package.json](../package.json)。`^0.1.7-rc.1 || ^0.2.0-rc.1` 是**双线并存**声明(0.4.0 起):DSH 兼容门(`evaluatePluginCompatibility`,`semver.satisfies(运行时, 区间, {includePrerelease: true})`,另支持 `workspace:^` 等指代当前运行时的写法)逐条核对每个 `@deepseek-ai/dsh*` peer,不满足即整体跳过 bundle 层。范围被接受不等于新宿主行为已经验证,实测边界见本文「事实边界」。 ### 3.2 运行时接管:`ctx.web.search` 与 `ctx.web.fetch` 服务方法(0.5.0 / 0.6.0) ```yaml - insert: - id: web-search-pool name: dsh-web-search-pool config: { ...默认配置... } ``` **为什么不再改 `web` 行**:官方 `WebRuntime.search` 在每次调用时按 `web.searchProvider`(volatile)或 `$DSH_WEB_SEARCH_PROVIDER` 选 provider。用户 profile 的 `cordis.patch.yml` 在 bundle 层之后应用,官方 Web search 设置页也写同一行——0.4.0 在 `apply` 里改一次 `searchProvider: search-pool`,会被后应用的层覆盖,表现为「启用了 key 池却仍走官方 API key」。 **0.5.0 的接管点**(`src/dsh/takeover.js`): 1. `apply` 时在 `ctx.web` 实例上 `Object.defineProperty(web, 'search', ...)`,wrapper 保留原 descriptor; 2. 启用态直接调用池 provider,**不经过** `searchProvider` 选择,因此与 provider id、注册顺序、官方设置页都无关; 3. 关闭态委托原 `web.search`(原型方法),官方 provider 原样可用; 4. dispose / 插件卸载时按 descriptor 恢复(own property 删除即回到原型方法)。 **0.6.0 起同一模式扩展到 `ctx.web.fetch`**(2026-11-01 依桌面版 0.2.1-rc.5 app.asar 核对):`web_fetch` 工具 → `WebRuntime.fetch` 按 `web.fetchProvider` / `$DSH_WEB_FETCH_PROVIDER` 选 provider(官方 `deepseek-fetch-api` 走 `api.deepseek.com/v1/tools/fetch` 消耗平台额度;宿主自带的 `dsh-web-fetch-http` 是零额度的本机直连抓取)。启用且 `fetchEnabled` 时 wrapper 直接走池的 Tavily Extract;`fetchEnabled: false` 时原样转发原 `web.fetch`。池 provider **不注册**进 `fetchProviders`,因此转发原方法无递归风险;官方 fetch 回退路径显式挑 provider(按配置 → `['deepseek-fetch-api', 'http']` → 任意可用),与搜索的 `_officialSearch` 对称。 **模型无感**:search wrapper 返回官方 seam 同形的 `{ content?, sources, truncated }`,去掉池内部的 `query` / `provider` 字段,并按 `maxResults` 截断;fetch wrapper 返回同形的 `{ url, statusCode, body: { kind: 'html'|'text', content }, truncated }`。池不可用/失败时默认 `fallbackToOfficial` / `fetchFallbackToOfficial: false`,抛中性的 `WEB_PROVIDER_UNAVAILABLE`(不含 Tavily / Exa / key / 官方端点字样);打开回退才调用官方 provider。`WEB_INVALID_URL`(URL 非法/非 http(s))原样透传,不中性化——这是模型自己传参的错误,需要看见。 **降级路径**:宿主若把 `web.search` 冻结/设为不可配置,改为替换 `deepseek-search-api` / `deepseek-official` provider 对象的 `search` / `available`(fetch 同理替换 `deepseek-fetch-api` / `http` 的 `fetch` / `available`);若连注册表也不可检查,再退回配置同步(`loader.update` 写 `web.searchProvider`,按 `['web', 'include:web']` 探测行 id)。 - Bundle 是 provider row 的**唯一来源**;用户 profile patch 再插入同名 row 会产生 duplicate。 - 历史:0.1.1/0.1.2 的 `web` 行在 `include` 组内(`include:web`);0.1.7 与 0.2.0 起 dsh-base 直接插根组(`web`)。运行时接管不依赖行 id,只有降级路径仍需探测。 ### 3.3 设置卡片 slot:0.1.7 是 `plugins.row.config` 0.1.7 的插件管理页(`@deepseek-ai/dsh-client-ui-plugin-manager`)用 keyed slot 承载**插件行的配置页**: ```js ctx.slots.register({ name: 'plugins.row.config', key: 'dsh-web-search-pool#web-search-pool' }, Card); ``` - key 格式 = `#`;行的「配置」按钮据此出现,点开进入该行的配置页。 - 历史线:rc.7/0.1.2 用 `settings.plugin.item` keyed slot(key = settings namespace);**0.1.7 起该 slot 不复存在**,官方 companion 包全部迁到 `plugins.item`(official 插件组)或 `plugins.row.config`(bundle 行配置)。 - 0.2.0 复核(2026-09-28):`dsh-client-ui-plugin-manager` 0.2.0 仍按 `rowConfigKey(bundle, rowId)`(`<包名>#<行 id>`)建 ledger 索引,slot 契约与 key 语义不变;官方 companion `dsh-client-ui-settings-web-search@0.2.0-rc.1` 的浏览器半侧写法(`whileServed` + `slots.inject` + `configForms`)与本卡片同构。 ### 3.4 0.1.7 的设置体系(Config schema 即设置面) | 能力 | 0.1.1-rc.x | 0.1.2 | 0.1.7 / 0.2.0(当前双线) | |---|---|---|---| | 注册设置段 | 顶层 `installSettingsSection(ctx, ns, schema, entry, hooks)` | `settings.installSection(owner, ns, schema, entry, hooks)` | **已移除**:`@deepseek-ai/dsh-settings` 只剩 `SettingsForms` 服务,设置表单由 loader 行的 **Config schema 自动投影**(namespace = 行 id) | | 表单可见性 | 注册即出现 | 同左 | 字段必须显式 `.volatile()`;`settings.configure({auto:false}, fiber)` 可关掉某实例的自动表单 | | 运行期 config | 普通值 | 普通值 | volatile 字段是 **Ref**(`config.enabled.get()`);编辑经 `settings.mutate` 写 profile patch 后**就地提交**(`loader/volatile-update` 事件,不重挂插件) | | Client 读 | `settingsScope.bind(ns)` | 同左 | **`ctx.configForms`**(`dsh-client-ui-settings` 提供):`get(ns)` / `whileServed([ns], cb)` | | Client 写 | `scope.set` | `scope.mutate(ops)` | `form.mutate(ops, expectedRevision)`(Host 侧 `settings.mutate` → `configEditor.edit` → 落盘 profile patch + loader reconcile) | | 凭据读写 | `ctx.connection.api` | `ctx.remote.credentials` | 同左(Typert Remote) | | 运行时状态 | 写进 settings namespace | 同左 | **不允许**:settings 写入即持久化进用户 profile patch,Host 周期发布的运行时会话数据只能走 `ctx.logger` | > **不得静态导入** `installSettingsSection` / `installSection`:0.1.2 / 0.1.7 已移除,静态命名导入会在 ESM 链接期抛 `SyntaxError`,导致整棵插件树启动失败。 ### 3.5 版本线 | 插件版本 | 宿主 | 要点 | |---|---|---| | 0.1.0-rc.7 | 0.1.0-rc.7 | 原生 Bundle + `settings.plugin.item` keyed slot | | 0.2.0 | 0.1.2-rc.1 | `installSection` + Typert Remote + `fetchProvider` 回补;旧符号动态导入 | | 0.2.1 | 0.1.2-rc.1 | client `remote` 命名空间 scoped inject(否则 Web UI 报 `Failed to load plugins`) | | **0.3.0** | **0.1.7+(桌面版实测 0.1.7-rc.2)** | **设置面改为 Config schema 自动投影(全 volatile)+ `plugins.row.config` slot + `configForms` 数据面;peer 范围 `^0.1.7-rc.1`;运行时 usage 移出 settings 改日志观测;不再支持 0.1.1/0.1.2** | | **0.4.0** | **0.1.7+ / 0.2.0+(桌面版 0.2.0-rc.1 源码核对 + e2e 实测)** | **peer 范围扩为 `^0.1.7-rc.1 \|\| ^0.2.0-rc.1` 双线;0.2.0 把 loader 拆到 `cordis-plugin-loader`,但本文所有契约(volatile-update / configForms / plugins.row.config / remote.credentials / 根组 web 行)逐项核对无破坏性变化,src/ 无功能改动** | | **0.5.0** | **0.2.1-rc.2(本机桌面版实测)** | **接管点从 `web` 行配置改为 `ctx.web.search` 服务方法:启用即完全替代官方搜索,不依赖 provider id、不被 profile patch 覆盖;默认不回退官方,结果/错误对模型中性;`fallbackToOfficial` 可选** | ## 4. 调度核心 ### 4.1 KeyPool(`core/key-pool.js`) - 只持有 `credentialRef`,**不持有密钥明文**;构造时建 `id` / `credentialRef` / `provider` 三组 Map 索引,查找 O(1)。 - 运行时状态(进程内、可序列化):`cooldownUntil`、`failCount`。 - 冷却时长 = `max(cooldownMs, Retry-After)`;非限流失败连续达 `allowedFails` 即熔断(进入默认冷却)并清零计数(半开重试)。 ### 4.2 TokenBucketLimiter(`core/rate-limiter.js`) - 每 key 一个令牌桶:`capacity = rpm`,补充速率 `rpm/60` 每秒。 - 存储后端抽象为 `{ get, set }`:当前内存实现;未来 Redis 实现(Lua 原子化)可换实现而不改调度。 - 状态 `{ tokens, lastRefill }` 可序列化,能直接映射 Redis key/TTL。 ### 4.3 Scheduler(`core/scheduler.js`) - 候选 = 未冷却的 entry;供应商按 `providerPriority`(默认 `[tavily, exa]`)依次尝试 → **供应商内均衡 + 供应商间 failover**。 - 策略: - `weighted-round-robin`(默认):smooth WRR,权重 = `max(1, rpm)`;令牌不足的候选本轮不减总权重,令牌恢复后优先被选中。 - `least-used`:剩余令牌最多者优先。 - 选 token 用单遍选最大(`_acquireByRank`),避免数组复制/排序分配;最坏 O(n²),常见路径首轮命中。 - `acquire(exclude?)`(0.5.1):可选「本次搜索已试过的 entry id」集合(`Set` 或任意可迭代对象),候选在冷却过滤之外再排除这些 id。语义是**调用级**的——不写冷却、不改失败计数、不影响权重累加,只保证一次搜索内每个 entry 至多尝试一次。 - 记录接口:`recordRateLimit`(冷却)/ `recordError`(失败计数)/ `recordSuccess`(清零)/ `clearCooldown`(额度恢复)。 ### 4.4 请求生命周期(`src/dsh/provider.js`) 1. `available()`:`!disposed && 至少一个 entry`(纯本地检查,不发网络)。 2. 每次 `search()` / `fetch()` 从 settings 快照 `resolveOptions()` 生成**请求级 pool 快照**(一次请求不混用两份配置)。`fetch()` 与 `search()` 同构但更简单:无 query 意图解析、只有 Tavily entry 参与(Exa entry 直接跳过);URL 在进调度前做本地校验(非空、可解析、仅 http/https → `WEB_INVALID_URL`)。 3. 循环:`scheduler.acquire(tried)` → 选中的 entry 立刻加入 `tried` → 解析凭据 → 额度闸门 → 调 adapter → 成功 `recordSuccess` 返回;失败按类型处置(429 冷却 / 非 429 计失败)后换下一个 entry。 4. 上限 = key 总数,但 `tried` 排除已试 entry,因此**每个 key 在一次搜索内至多尝试一次**(`acquire` 返回 null 时提前结束);全部耗尽时抛出携带 retry-after 语义的 `WebError`。0.5.1 前这里会重复选中同一 key,5xx/超时时可能耗尽重试次数而根本不尝试健康 key 或下一个供应商(见[事故复盘 §1.9](开发规范与事故复盘.md))。 5. 每次尝试都通过 `recordRequest` 写 `ctx.logger`(**不写会话事件**)。 ## 5. 适配器 | | Tavily | Exa(有 key) | Exa(匿名) | |---|---|---|---| | 端点 | `POST https://api.tavily.com/search`;抓取 `POST https://api.tavily.com/extract`(0.6.0) | `POST https://api.exa.ai/search` | `POST https://mcp.exa.ai/mcp`(JSON-RPC `tools/call`) | | 鉴权 | search 用 body `api_key`;extract/usage 用 `Bearer` 头 | header `x-api-key` | 无 | | 额度查询 | `GET https://api.tavily.com/usage` | 无公开接口(Team Management 需 service key) | 无(限流而非额度) | | 计费口径 | search basic 1 / advanced 2 credits;extract basic 1 credit/5 URL、advanced 2 credits/URL(与搜索共享月度额度) | — | — | | 结果映射 | `results[]` → sources;`answer` → `content` | `results[].text` → snippet;`publishedDate` → publishedAt | 只传 `query`/`numResults`,高级参数忽略 | | 默认高级参数 | `search_depth: advanced`、`include_answer: true` | `useAutoprompt`、`contents: {text, highlights, summary}` | — | | 限流 | 429 + `Retry-After` | 同左 | **全局共享桶 1 req/s**(`exa-anonymous`,配置不可覆盖) | `auto` 语义:`topic` / `timeRange` / `includeDomains` / `startPublishedDate` 默认 `auto`,由 `core/query-intent.js` 按 query 语义(时间词、`site:`、新闻/财经主题)解析,`core/resolve-params.js` 做三态(auto / 具体值 / off);`off` 会同时关闭同维度的派生参数(如 `timeRange: off` 也禁 `days`)。 ## 6. 配置模型 settings namespace = loader 行 id `web-search-pool`(0.1.7 的设置表单 key),schema 见 `src/dsh/config.js`(默认值唯一来源是 `core/constants.js` 的 `DEFAULTS`)。**所有用户可编辑字段都必须 `.volatile()`**(否则表单不可见、`mutate` 拒绝)。 | 字段 | 默认 | 说明 | |---|---|---| | `enabled` | `true` | 搜索开关;false 时 wrapper 委托回原 `web.search`(官方 provider) | | `fallbackToOfficial` | `false` | 池不可用/全部 key 失败时是否回退官方搜索;默认关闭 = 启用即完全替代 | | `fetchEnabled` | `true` | 是否接管 `web_fetch`(Tavily Extract);false 时 `web.fetch` 原样走宿主 fetch provider | | `fetchFallbackToOfficial` | `false` | 池抓取不可用/失败时是否回退官方 fetch provider;语义与 `fallbackToOfficial` 对齐 | | `providers.tavily.keys[]` | `[]` | `{ apiKeyEnv, rpm=60, remark? }`;`apiKeyEnv` 必须是环境变量名(CredentialRef) | | `providers.exa.keys[]` | `[]` | 同上;`apiKeyEnv` 留空 → 匿名免费层 entry | | `strategy` | `weighted-round-robin` | 或 `least-used` | | `providerPriority` | `[tavily, exa]` | 供应商 failover 顺序 | | `allowedFails` | `3` | 连续失败多少次熔断 | | `cooldownMs` | `30000` | 默认冷却时长 | | `retryAfterFallbackMs` | `1000` | 无 `Retry-After` 时的回退冷却 | | `usageCacheMs` | `300000` | Tavily `/usage` 缓存与后台刷新间隔 | | `quotaReserveCredits` | `2` | 判定「不够下一次搜索」的保留额度 | | `quotaExhaustedCooldownMs` | `2592000000`(30 天) | 额度耗尽后的长冷却,额度恢复并刷新后自动解除 | | `requestTimeoutMs` | `20000` | 单次尝试超时;0 禁用;超时按 key 失败换 key | | `providers.*.searchDepth/topic/timeRange/domains/useAutoprompt/…` | 见 schema | Tavily/Exa 高级参数(patch 层配置,设置卡片不编辑) | | `providers.tavily.fetchDepth` | `basic` | Extract 抓取深度。与搜索默认 advanced 不同:advanced 按 2 credits/URL、basic 按 1 credit/5 URL,成本差约 10 倍且抓取高频,故默认 basic | | `providers.tavily.fetchFormat` | `markdown` | Extract 输出格式(markdown / text) | > 0.2.x 曾把 `usageRefreshTick` / `usageDiagnostic` / `usage` 三个**运行时字段**放进 settings;0.1.7 起 settings 写入 = profile patch 落盘,运行时数据一律移出(观测走 `ctx.logger`,见 §7)。 ## 7. 数据流 - **凭据**:密钥只存 DSH credentials;`resolveOptions()` 每次操作 `credentials.resolve(ref)`,失败回退 `launchEnvironmentOf(ctx).get(ref)`;不缓存明文。 - **Host 读配置**:`apply(ctx, config)` 的 config 里 volatile 字段是 Ref,`snapshotConfig()` 投影成纯值快照喂给 `resolveOptions()`;每次搜索现取快照。 - **Client → Host(写配置)**:卡片 `form.mutate(ops, revision)` → Host `settings.mutate` → `configEditor.edit`(profile patch 落盘 + loader reconcile)→ volatile 就地提交(`loader/volatile-update`,**不重挂插件**)。 - **Host 接管**:`apply` 在 `ctx.web.search` 与 `ctx.web.fetch` 上安装可逆 wrapper;监听 `loader/volatile-update` 切换 `enabled` / `fallbackToOfficial` / `fetchEnabled` / `fetchFallbackToOfficial`(不重挂插件),并触发一次 Tavily 额度刷新。`internal/service`、`loader/config-update` 等事件会重新 attach(服务重挂/官方行晚激活)。 - **额度刷新(进程内)**:启动时 + 配置变化时 `refreshUsage()`;搜索路径后台单飞维护缓存;快照与诊断只写 `ctx.logger`(`search-pool usage used=… limit=… keys=…`)。 ## 8. 错误映射与可观测性 核心错误 → DSH `WebError`: | 核心错误 | 观测码 | 对外 `WebError.code` | |---|---|---| | `RateLimitError`(429 / 匿名 Exa 1req/s) | `RATE_LIMIT` | `WEB_PROVIDER_ERROR`(携带 retry-after 语义) | | `ProviderHttpError`(4xx/5xx、网络失败) | `ERROR` | `WEB_PROVIDER_ERROR` | | 单次尝试超时 | `TIMEOUT` | 换 key;全失败 → `WEB_PROVIDER_ERROR` | | 凭据缺失 | `CREDENTIAL_MISSING` | `WEB_PROVIDER_CREDENTIAL_MISSING` | | `resolveKey` 抛错 | `KEY_RESOLVE_FAILED` | 换 key | | 额度低于保留值 | `QUOTA_EXHAUSTED` | 长冷却后换 key | | 无可用 key(全冷却/限流) | — | `WEB_PROVIDER_UNAVAILABLE` | | 未配置任何 key | — | `WEB_PROVIDER_CREDENTIAL_MISSING` | | 额度刷新链路 | `USAGE_REFRESH_FAILED` / `USAGE_QUERY_FAILED` / `USAGE_LOG_FAILED` | 写 `ctx.logger`(不再写 settings) | 观测:`recordRequest({provider, keyId, ok, code, message?, retryAfterMs?})` → `ctx.logger.info('search-pool attempt …')`,**不含密钥明文**。 ## 9. 并发与生命周期 - **额度刷新单飞**:`_usageRefreshPromise` 复用,后台与手动两条路径共用;单次刷新带 15s `AbortController` 总超时。 - **配置热更新**:每次搜索重算 options 快照;pool 重建后比对 `_poolCache === pool` 再写回缓存,避免与 in-flight 刷新竞态。 - **dispose**:清理函数注册在 `ctx.effect`(cordis Loader 不把 `apply` 返回值当 disposer);恢复 `web.search` descriptor、注销池 provider、`provider.dispose()`。此后 `available()` 为 false、`search()` 抛 `WEB_PROVIDER_UNAVAILABLE`。 - **超时与取消**:`core/http-utils.js` 的 `withTimeout` 组合外部 signal 与内部定时器(Node 18 兼容),超时与用户取消严格区分。 ## 10. 关键取舍 - **额度闸门「尽力而为」**:搜索不等待 `/usage` 刷新(旧版会同步等最长 15s);缓存过期时后台单飞刷新,本轮用旧数据、无缓存先放行,漏判由上游错误兜底并进入冷却——换取搜索首字节延迟大幅下降。 - **单次搜索内不重试同一 key**(0.5.1):失败即推进到下一个 entry,避免 WRR 在 `allowedFails` 熔断前反复选中同一个坏 key。代价是「所有 key 同时挂起」时仍会逐个等到 `requestTimeoutMs`;宿主 `web_search` 的截止时间不向 provider 暴露(signal 只在 abort 后可用),因此不引入总预算猜测,多 key 同时挂起仍是已知限制。 - **限流状态在进程内存**:个人单实例足够;多实例共享需换存储后端(见 §11)。 - **零第三方运行时依赖**:核心只用 Node 内置能力;测试用 `node:test`。 ## 11. 扩展路径(当前不实施) 触发条件:多 DSH 实例共享限流状态 / 需要服务 DSH 之外的客户端 / 需要持久化用量与审计。核心库与 DSH 解耦、限流后端已抽象,三条路径都只换外壳: - **B2 Redis 限流**(最小改动):令牌桶 Lua 原子化 + `SET key EX cooldownSec` + `INCR` 失败计数,各实例指向同一 Redis。 - **B1 独立 HTTP 中转站**(通用性最强):核心库套 HTTP 壳,`POST /search` 兼容 Tavily 原生请求格式;DSH 侧 provider 改为单 endpoint 转发。 - **B3 改造现成网关**:one-api / new-api(渠道=key + 权重 + 自动禁用)或 LiteLLM(deployment 池 + cooldown + fallback + Redis 限流),需补非 LLM 搜索 API 适配。 参考:[LiteLLM routing](https://docs.litellm.ai/docs/routing)、[Tavily API key 管理](https://docs.tavily.com/documentation/best-practices/api-key-management)、[Exa MCP](https://exa.ai/docs/reference/exa-mcp)。