deepseek-harness 学习手册
动手实验室Lab 04 · 复刻一个 web search provider
60 min

复刻一个 web search provider

照着 web-search-exa 的骨架裁一个新后端出来,并把它接进真正的 seam。

最后核对于 v0.1.1-rc.2 · commit b150a551b8 · 2026-08-22

Lab 3 是「往注册面上加东西」,这一课是「往一个 seam 里加一个实现」——难度台阶主要不在代码,在于你要先读懂一份接口契约。

目标:写一个 WebSearchProvider,注册进 ctx.web,让它和官方的 deepseek / exa / perplexity 三个 provider 平级共存,然后用一段配置把它选中。

这一课需要从源码跑git clone + pnpm install + pnpm run build),因为你要读 packages/web/ 的真实源码作为参照。真正的插件仍然写在树外。

1 先读契约,别先写代码

整个 seam 对一个搜索 provider 的要求,就是这三行:

packages/web/web/src/types.tsts
export interface WebSearchProvider {
  readonly id: string
  /** 廉价的本地可用性检查;不得发起网络调用。 */
  available(): boolean
  /** 跑一次搜索;要尊重 signal 的取消。 */
  search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
}

然后读 Definition 那个包的文件头注释,它把选路规则写死了:

「重复 id 会被拒绝。执行时,被配置的 provider 必须存在且可用;没有配置的话,必须恰好有一个可用的 provider——所以选路永远不依赖注册顺序。」

还有一句在 WebRuntimeConfig 的注释里:「诸如环境变量之类的运行期覆盖,必须喂给这同一组字段,而不是引入一条隐藏的优先级链。」

这两句话决定了后面所有行为:「装了两个 provider 但都没配」是一个错误,不是「随便挑一个」。

2 照着真实 provider 裁一个骨架

packages/web/web-search-exa/ 是最好的参照,它一共只有 11 个文件。你的树外版本可以更薄:

~/my-search-provider/index.jsjs
/** Cordis 插件名。 */
export const name = 'my-search'

/** 它注册进去的那个 seam —— 注意:不是拥有它。 */
export const inject = ['web']

class MySearchProvider {
  constructor(options) { this.options = options }

  get id() { return 'my-search' }

  // 契约要求:廉价、纯本地、不发网络请求
  available() { return this.options.apiKey !== '' }

  async search(request, signal) {
    const res = await fetch(this.options.baseURL, {
      method: 'POST',
      headers: { 'content-type': 'application/json' },
      body: JSON.stringify({ q: request.query, n: request.maxResults }),
      signal,                       // ← 契约要求:尊重取消
    })
    const data = await res.json()
    return { sources: data.items.map(toSource) }
  }
}

export function apply(ctx, config) {
  ctx.web.registerSearchProvider(new MySearchProvider({
    apiKey: config.apiKey ?? process.env.MY_SEARCH_KEY ?? '',
    baseURL: config.baseURL ?? 'https://example.invalid/search',
  }))
}
注意 apply 里没写 ctx.effect

registerSearchProvider 自己就返回 disposer,并且跟着调用它的 fiber 一起被处置——这是「注册即 effect」在 API 层面的样子:注册表把撤销责任接过去了,调用方不用写清理代码。

3 把默认值放在正确的位置

上面那段 config.apiKey ?? … ?? '' 看着平常,其实踩在一条明确的规则上。AGENTS.md:

「包边界上显式优于隐式:默认值填充是拥有方实现里一个显式的 resolve(request): Spec 步骤,而绝不是 run() 里藏着的一个 ?? default。」

看真实的 exa 包怎么做的:所有默认值都在 apply() 里一次性解析完,然后把一个完整的 options 对象交给 provider 构造函数。search() 里再也见不到 ??

这条规矩的收益在调试时才显现:「这个值是从哪来的」永远只有一个答案,就是那个 apply而不是散落在三层调用里的兜底。

另外,Config 应该用 schema 声明(官方用 @deepseek-ai/schemasteryz),这样它会自动进 生成式 config catalog,而且非法配置会在加载时大声失败。

4 装上、选中、验证

terminal
$ dsh plugin --profile web add ~/my-search-provider
~/.dsh/profiles/web/cordis.patch.ymlyaml
- id: web
  config:
    searchProvider: my-search        # ← 必须是你 provider 的 id

- insert:
    - id: my-search
      name: my-search-provider

然后跑一个需要联网的问题。三种失败你应该都能认出来:

错误码你干了什么
WEB_DUPLICATE_PROVIDER你的 id 跟已注册的撞了
配置的 provider 不存在 / 不可用searchProvider 写错了,或者 available() 返回 false(多半是 key 没传进去)
可用 provider 有歧义你没配 searchProvider,但同时挂了两个可用的——这是设计内的大声失败

5 如果你要把它提到树内:还欠什么

树外插件到这里就完事了。但如果你打算给仓库提 PR,M7.3 那张清单上的每一行都得补齐。对一个 provider 来说,最容易漏的是这三样:

  • 双语 README + README.i18n.yaml那个 yaml 里是两侧的 git blob hash,verify-translation-pairing 靠它发现「只改了一边」。
  • Model Experience 一节。provider 后端用审计过的短式就行——一句 Indirectly, through … 加一个 KV Cache 字段。决策记录明写着:「provider 后端使用间接形式,即便它们会对数据做上限或过滤。」
  • 不变量伴生插件。如果这个包没有独立的事件序列或可变数据关系,就写一个带解释的空实现——「我检查过了,这里没有」比「我没写」多出来的信息,正是 review 要的。

还有一条容易忘:e2e 测试要按自己的 key 自跳过。官方的 exa 包就是这么写的(tests/exa.e2e.ts 在没有 EXA_API_KEY 时自跳过),这样无密钥的 CI 和无密钥的贡献者都不会被挡住。

对比即教学

同样这件事,在别家要几步?

dsh 3 个方法 id / available() / search()。接口是公开的、有文档的、有错误码的,而且官方自己就有三个实现——说明它真的被当作可替换点在维护。
OpenCode 无对等点 搜索实现在 server 侧,没有一个「注册一个搜索后端」的公开接口。
待核对
Codex / Claude Code +1 工具 经 MCP 提供一个搜索工具。你加的是一个新工具,不是一个新实现——内置那个还在。
待核对
判断一个「可替换点」是真是假,有个很省事的检验:看官方自己有没有第二个实现。只有一个实现的接口,通常是想象出来的灵活性——它没经过「第二个实现」的检验,很可能已经把第一个实现的假设泄漏进接口里了。
下一课

Lab 5 · 让 agent 现场造一个工具——这次不是你写插件,是模型自己写。25 分钟。

目录

本页

Lab 04 · 复刻一个 web search provider