# 架构与机制 > 本文是 `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)。 - 来源:本地已安装 DSH 源码(`@deepseek-ai/dsh-web`、`dsh-web-search-deepseek`、`dsh-tool-web`、`dsh-credentials`、`dsh-settings`、`dsh-host-apiproxy`、`dsh-client-ui-settings-plugins`)+ 本项目 `src/` 源码。 - 结论按**当前代码实测**书写;宿主大版本升级时需重新核对(见 §3.5 双线兼容)。 ## 1. 在 DSH 中的位置 ``` 模型调用 web_search 工具(@deepseek-ai/dsh-tool-web:schema/校验/结果格式化) └─ ctx.web.search(request, signal) # @deepseek-ai/dsh-web 的 WebRuntime(能力 seam) └─ 选中的 WebSearchProvider.search() # 本项目:id = search-pool └─ 归一化 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`,因此必须覆盖 host 的 `web` row(见 §3.2)。 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)、`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-connection", "@deepseek-ai/dsh-client-ui-settings"] } } ``` `peerDependencies` 用区间 `^0.1.0-rc.7 || ^0.1.2-rc.1` 同时覆盖两条版本线,不用 `*`。 ### 3.2 `cordis.patch.yml`:id 定位的 patch 是**整体替换** ```yaml - id: web config: searchProvider: search-pool fetchProvider: http # 必须一并重述,否则被抹掉 - insert: - id: web-search-pool name: dsh-web-search-pool config: { ...默认配置... } ``` - patch 按 `id` 定位后**替换整段 config**,不是深合并;0.1.2 的官方 `web` 行新增了 `fetchProvider: http`,只写 `searchProvider` 会把它抹掉(`web_fetch` 会退化为自动选择)。 - 因此 `WEB_ROW_CONFIG` 常量与 `syncSearchProvider()` 都遵循「先读出该行现有 config 再合并」。 - Bundle 是 provider row 的**唯一来源**;用户 profile patch 再插入同名 row 会产生 duplicate。 ### 3.3 keyed slot(0.1.0-rc.7 起的破坏性变更) 设置卡片注册必须用 `key`,且与 Host 的 settings namespace 严格一致: ```js ctx.slots.register({ name: 'settings.plugin.item', key: 'web-search-pool', order: 21 }, Card); ``` ### 3.4 settings 与 Remote 两代兼容 | 能力 | 0.1.1-rc.x | 0.1.2+ | 本项目做法 | |---|---|---|---| | 注册设置段 | 顶层 `installSettingsSection(ctx, ns, schema, entry, hooks)` | `settings.installSection(owner, ns, schema, entry, hooks)` | 运行时能力探测:新 API 同步注册,旧 API **动态 import** 回退 | | Client 凭据读写 | `ctx.connection.api`(IApiClient) | `ctx.remote.credentials.describe/set` | `ctx.inject(["remote","remote.credentials"], …)` scoped 注入;0.1.1-rc.x 无该服务 → 回调不执行 → 自动退回 `connection.api` | | settings 写入 | `scope.set` | `settingsScope.bind().mutate(ops)` | 优先新链路,回退旧链路 | > **不得静态导入** `installSettingsSection` / `settingsNamespace` / `deepEqualJson`:0.1.2 已移除,静态命名导入会在 ESM 链接期抛 `SyntaxError`,导致整棵插件树启动失败。 ### 3.5 版本线 | 插件版本 | 宿主 | 要点 | |---|---|---| | 0.1.0-rc.7 | 0.1.0-rc.7 | 原生 Bundle + 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`) | ## 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²),常见路径首轮命中。 - 记录接口:`recordRateLimit`(冷却)/ `recordError`(失败计数)/ `recordSuccess`(清零)/ `clearCooldown`(额度恢复)。 ### 4.4 请求生命周期(`src/dsh/provider.js`) 1. `available()`:`!disposed && 至少一个 entry`(纯本地检查,不发网络)。 2. 每次 `search()` 从 settings 快照 `resolveOptions()` 生成**请求级 pool 快照**(一次搜索不混用两份配置)。 3. 循环:`scheduler.acquire()` → 解析凭据 → 额度闸门 → 调 adapter → 成功 `recordSuccess` 返回;失败按类型处置并换 key。 4. 重试上限由 key 总数决定;全部耗尽时抛出携带 retry-after 语义的 `WebError`。 5. 每次尝试都通过 `recordRequest` 写 `ctx.logger`(**不写会话事件**)。 ## 5. 适配器 | | Tavily | Exa(有 key) | Exa(匿名) | |---|---|---|---| | 端点 | `POST https://api.tavily.com/search` | `POST https://api.exa.ai/search` | `POST https://mcp.exa.ai/mcp`(JSON-RPC `tools/call`) | | 鉴权 | body `api_key` | header `x-api-key` | 无 | | 额度查询 | `GET https://api.tavily.com/usage` | 无公开接口(Team Management 需 service key) | 无(限流而非额度) | | 结果映射 | `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 = `web-search-pool`,schema 见 `src/dsh/config.js`(默认值唯一来源是 `core/constants.js` 的 `DEFAULTS`)。 | 字段 | 默认 | 说明 | |---|---|---| | `enabled` | `true` | 搜索开关;false 时把 `include:web` 的 `searchProvider` 切回 `deepseek-official` | | `providers.tavily.keys[]` | `[]` | `{ id?, 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 | | `usageRefreshTick` | `0` | 运行时:Client 点「立即刷新」+1,Host 比对变化后刷新 | | `usageDiagnostic` | `''` | 运行时:额度发布诊断(空 = 上次成功) | | `usage` | `{updatedAt:0,totalUsed:0,totalLimit:0,keys:[]}` | 运行时:额度快照(Host 写、Client 展示) | ## 7. 数据流 - **凭据**:密钥只存 DSH credentials;`resolveOptions()` 每次操作 `credentials.resolve(ref)`,失败回退 `launchEnvironmentOf(ctx).get(ref)`;不缓存明文。 - **Host → Settings**:`ctx.inject(['settings'])` 拿服务;服务未就绪时把快照存 `pendingUsage`,服务出现后补发。 - **运行时 usage 发布**:`writeUsage(snapshot, diagnostic)` 一次 `settings.update` 同时写 `usage` + `usageDiagnostic`;失败再单独写诊断兜底。 - **Client → Host 触发**:Client 用不带 `expectedRevision` 的 `api.settings.mutate` 递增 `usageRefreshTick`(避免与 Host 刚写 usage 的 revision 冲突)。 - **Host 响应**:入口同时监听 `settings/updated` 事件(兜底 `scope.watch` 在某些 include/loader 场景不触发),用 tick 比较防重,触发 `refreshUsage()`。 ## 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_PUBLISH_FAILED` | 写 `usageDiagnostic` | 观测:`recordRequest({provider, keyId, ok, code, message?, retryAfterMs?})` → `ctx.logger.info('search-pool attempt …')`,**不含密钥明文**。 ## 9. 并发与生命周期 - **额度刷新单飞**:`_usageRefreshPromise` 复用,后台与手动两条路径共用;单次刷新带 15s `AbortController` 总超时。 - **配置热更新**:每次搜索重算 options 快照;pool 重建后比对 `_poolCache === pool` 再写回缓存,避免与 in-flight 刷新竞态。 - **dispose**:`apply` 返回 dispose(`ctx.off('settings/updated')` + `provider.dispose()`);此后 `available()` 为 false、`search()` 抛 `WEB_PROVIDER_UNAVAILABLE`。 - **超时与取消**:`core/http-utils.js` 的 `withTimeout` 组合外部 signal 与内部定时器(Node 18 兼容),超时与用户取消严格区分。 ## 10. 关键取舍 - **额度闸门「尽力而为」**:搜索不等待 `/usage` 刷新(旧版会同步等最长 15s);缓存过期时后台单飞刷新,本轮用旧数据、无缓存先放行,漏判由上游错误兜底并进入冷却——换取搜索首字节延迟大幅下降。 - **限流状态在进程内存**:个人单实例足够;多实例共享需换存储后端(见 §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)。