读完这篇你能:把 DSH 的一项能力换成自己的实现——让
web_search查自己的笔记而不是公网,消费者一行代码不用改。前置:07-配置与源码的热重载。约 25 分钟。
示例代码:dsh-web-search-swap
DSH 版本:0.2.0-rc.2
模型手里的 web_search 工具,背后是 DSH 自带的搜索:一次检索就是一次完整的模型请求,走 DeepSeek 的官方接口加服务端检索,要 API key,也按 token 计费。
我在本机攒了一堆 Markdown 笔记。想让模型搜的是它们——随手一问「缓存那个结论是哪次会定的」,比翻公网有用得多。
麻烦在于 web_search 这个工具不该动。它属于 DSH 自带的一百多行配置(03-用 patch 覆盖 DSH 的默认配置),改它等于给自己开一个永远要跟着上游走的 fork。所以这篇不是改工具,而是把工具背后那项能力换掉。
目标
- 一处配置改动,
web_search从”查公网”变成”查我的笔记”。 - 想换回去,改的还是那一处;工具、提示词、schema 全程不动。
- 顺手能用 curl 看见”这次换源到底生效没有”,不必每次都去问模型。
DSH 设计
要理解「改一行配置就换掉一项能力」,得先看运行中的 DSH 长什么样。它是一张按行组织的插件清单,由启动器按当前配置方案「profile」(DSH 允许保存多套配置方案,启动时选一套)叠加各层补丁得到。清单上的插件之间不直接 import 对方的实现,而是通过共享上下文上的服务名找到对方。这两件事合起来,就是可替换性的地基。
DSH 把一项可替换能力叫一个「seam」(能力缝:一道「整层可以换掉」的接缝),并规定它永远拆成三种角色:
- 服务定义声明接口——这项能力叫什么、方法长什么样;
- 提供方实现接口——具体怎么把这件事做成;
- 消费者使用接口——通常是面向模型的工具。
添加一项能力意味着把三者一并设计;只写其中一个角色,不构成 seam。可替换性因此是产品结构的一部分,不是某个包的好心。架构文档的原话是「seam 正是替换一个提供方就能改变整个产品的原因」:把文件系统与进程提供方指向远程沙箱,Bash、终端、语言服务就一并搬了过去,不需要为哪个提供方单独开分叉版本。消费者这边的耦合被刻意收窄成上下文上的一个服务名:它只认那个键,不认识任何提供方,所以换掉提供方动不到它。
那「这次用哪个提供方」是什么时候定的?答案是每次调用时现算,注册表只是候选集。以「访问 Web」这个 seam 为例(搜索与抓取共用一项选择服务),它的说明写明三条设计理念:一个 seam 管搜索与抓取两个独立操作;选择绝不依赖注册或加载顺序;结果上限由服务在提供方返回后强制执行。每次调用都在执行时解析提供方,规则是公开的:配置里指定了提供方 id 且它已注册、可用,就用它;没指定、恰好一个可用,就自动选;其余情况一律抛带稳定错误码的失败——配置了但没注册、注册了但不可用、多个可用、一个可用都没有,各有自己的码。绝不「悄悄用先注册的那个」。
每次调用都要跑选择,这个设计能成立,是因为可用性检查被规定得很廉价。提供方的「我能不能用」只能是廉价的本地检查(比如 key 在不在、目录在不在),从不发起网络请求。选择因此保持快速且确定。
最后是注册的寿命。DSH 把一个已加载的插件实例叫「fiber」,实例上注册的每个动作都是一个可逆的「effect」(配好清理函数,实例卸载时自动撤销)。提供方注册就基于 effect 实现:随调用方的 fiber 注册,fiber 释放时自动注销。所以注册只要一行、卸载不用写清理。提供方映射是服务的私有数据,同一种能力下重复的 id 在注册时直接被拒——那算编程错误,不是配置错误。
「换一个提供方」在这张图里只动一条虚线:注册表、消费者、工具的描述全程不碰。
三件套
web_search 背后的搜索能力分成三个角色(DSH 管这套分工叫能力 seam——seam 是”整层可以换掉的接缝”),跟”面向接口编程”是同一个套路:接口 / 实现 / 调用方。
| 角色 | 相当于 | 在 web 这一项里是谁 | 它只负责回答 |
|---|---|---|---|
| 服务定义 | 接口 | ctx.web(@deepseek-ai/dsh-web) |
这项能力叫什么、长什么样、现在该用哪个实现 |
| 提供方 | 实现 | web-search-deepseek;我们要加的 dsh-notes-search |
具体怎么把这一次搜索做成 |
| 消费者 | 调用方 | dsh-tool-web(web_search 工具) |
什么时候搜、结果怎么给模型看 |
调用链就是接口编程里那条,中间那层是路口:
提供方的契约
提供方要满足的接口不长(下面这份来自 @deepseek-ai/dsh-web 的类型):
interface WebSearchProvider {
/** 稳定的 id,在 search 这张表里唯一;配置里引用的就是它。 */
readonly id: string
/** 廉价的可用性检查:只能看本地状态,禁止发网络请求。 */
available(): boolean
/** 执行一次搜索;被取消时通过 signal 得知。 */
search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
}
接口之外还有三条约定:
available()每次搜索前都会被调用,所以它只能做本地的廉价判断(key 在不在、目录在不在)。在这里发网络请求,等于每次搜索都先付一次往返。search()不用自己按条数截断。 上限maxResults由消费者传进来(dsh-tool-web的searchMaxResults配置,默认 8),ctx.web.search()拿到 provider 的完整结果后再按它裁一遍,裁了就返回truncated: true。所以 provider 只管”我找到了这些”。signal是取消通知,要一路传到底。 一次搜索可以被中途叫停:用户点了停止、或者这一轮被取消时,DSH 会把一个AbortSignal从web_search经ctx.web.search()传到provider.search()。provider 不能把它丢掉——交给fetch(url, { signal })由它自动中止,或者在遍历过程中像我们这样查一句signal?.throwIfAborted()。丢掉的后果不是报错,是明明没人要了还在继续读盘、继续发请求。
提供方的写法
src/dsh-notes-search.ts,全文如下。
/**
* 把自己的 Markdown 笔记接到 Web 搜索 seam 上。
*
* 这个插件只做一件事:向 `ctx.web` 注册一个 `WebSearchProvider`。
* 它不知道搜索请求是谁发起的(模型的 `web_search` 工具?还是一个 HTTP 路由?),
* 也不需要知道——消费者认的是 `ctx.web` 这个名字,不是它。
*/
import { existsSync } from 'node:fs'
import { readFile, readdir } from 'node:fs/promises'
import { extname, join, relative } from 'node:path'
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
import type {
WebSearchProvider,
WebSearchRequest,
WebSearchResult,
WebSearchSource,
} from '@deepseek-ai/dsh-web'
export const name = 'dsh-notes-search'
/** 必需依赖:`ctx.web` 不在,本插件就不启动。 */
export const inject = ['web']
/** provider 在 seam 里的唯一键;`web` 行的 config 用它选中我们。 */
export const PROVIDER_ID = 'notes-local'
const TEXT_EXTENSIONS = new Set(['.md', '.markdown', '.txt'])
/** 单次搜索最多看这么多文件,免得一次查询把磁盘翻个底朝天。 */
const MAX_FILES = 2000
export interface Config {
/** 笔记根目录;不配就是"没准备好",由 available() 报告。 */
root?: string
/** 返回的摘要最多多少字符。 */
snippetChars: number
}
export const Config: Schema<Config> = Schema.object({
root: Schema.string(),
snippetChars: Schema.number().step(1).min(1).default(160),
})
/** 列出 root 下所有像笔记的文件,跳过点开头的目录(.git、.obsidian 之类)。 */
async function collectFiles(root: string): Promise<string[]> {
const found: string[] = []
const queue = [root]
while (queue.length > 0 && found.length < MAX_FILES) {
const dir = queue.shift() as string
let entries
try {
entries = await readdir(dir, { withFileTypes: true })
} catch {
continue
}
for (const entry of entries) {
if (entry.name.startsWith('.')) continue
const full = join(dir, entry.name)
if (entry.isDirectory()) queue.push(full)
else if (TEXT_EXTENSIONS.has(extname(entry.name).toLowerCase())) found.push(full)
}
}
return found
}
/** 笔记里第一行命中关键词的正文;一行都没命中就返回 undefined。 */
function findLine(lines: readonly string[], terms: readonly string[]): string | undefined {
return lines.find(line => terms.some(term => line.toLowerCase().includes(term)))
}
class NotesSearchProvider implements WebSearchProvider {
/** seam 用它做注册表的键,也是 `searchProvider` 配置里写的那个值。 */
readonly id = PROVIDER_ID
// 不用参数属性(`constructor(private readonly config: Config)`):
// dsh 直接跑你的 .ts,走 Node 的类型擦除,参数属性这种"带运行时代码的类型语法"
// 不在可擦除范围内,会直接 import 失败。
private readonly config: Config
constructor(config: Config) {
this.config = config
}
/**
* 廉价的本地检查:只看目录在不在。
* 这里发网络请求的话,每次搜索前都要付一次往返——所以约定禁止。
*/
available(): boolean {
return this.config.root !== undefined && existsSync(this.config.root)
}
async search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult> {
const root = this.config.root
// available() 已经拦过没配 root 的情况,这里只是收窄类型。
if (root === undefined) return { sources: [], truncated: false }
const terms = request.query.toLowerCase().split(/\s+/).filter(term => term.length > 0)
const limit = this.config.snippetChars
const sources: WebSearchSource[] = []
for (const path of await collectFiles(root)) {
// 用户点停止时 signal 会被中止:每读一篇查一次,别把已经没人要的结果做完
signal?.throwIfAborted()
const lines = (await readFile(path, 'utf8')).split('\n')
const hit = findLine(lines, terms)
if (hit === undefined) continue
const relativePath = relative(root, path)
const title = lines.find(line => line.startsWith('#'))?.replace(/^#+\s*/, '').trim() || relativePath
const snippet = hit.trim()
sources.push({
// 我们的源不是网页,就自己定一个 scheme;seam 只要求 url 是个字符串。
url: `note://${relativePath}`,
title,
snippet: hit.length <= limit ? hit : `${hit.slice(0, limit)}…`,
})
}
return {
content: `本地笔记检索:命中 ${sources.length} 篇(关键词 ${terms.join(' / ')})`,
sources,
// 截断交给 seam:maxResults 是消费者给的上限,provider 只负责"我找到了这些"。
truncated: false,
}
}
}
export function apply(ctx: Context, config: Config): void {
ctx.web.registerSearchProvider(new NotesSearchProvider(config))
// 07-配置与源码的热重载说过:ctx.logger 不往终端打,所以想要一行肉眼可见的证据就自己 console.log。
console.log(`[${name}] 已注册 provider ${PROVIDER_ID}(root=${config.root ?? '(未配置)'})`)
}
这段代码里有四处要说明。
注册就一行。 registerSearchProvider 把 provider 塞进 ctx.web 的 search 表里,键就是它的 id,没有别的注册代码。
root 是可选的,snippetChars 有默认值。 06-用 Config schema 声明插件配置讲过 schema:Schema.string() 没加 .required() 就是可选,缺了不报错、由代码自己判断;Schema.number().step(1).min(1).default(160) 则是”没配就用 160”。把”没配置”和”配置不对”两种状态分开,available() 才有东西可报。
available() 只做 existsSync。 这是有意的:让”笔记目录不在”在搜索之前就变成一个明确的失败,而不是等遍历完磁盘返回一个空列表——空结果分不清”没命中”和”根目录写错了”。
search() 不自己截断。 命中多少就返回多少,truncated 恒为 false。消费者的 maxResults 由 seam 在返回路上执行,所以 provider 不用为每个消费者各写一遍截断逻辑。
消费者的写法
消费者是 dsh-tool-web,不用我们写。但要看换源生效没有,总不能每次都去问模型。所以演示包里多一个探针插件,把 ctx.web.search() 接到一条 HTTP 路由上,用 curl 就能看。
src/dsh-search-probe.ts:
import type { Context } from '@deepseek-ai/cordis'
// 只引类型声明(module augmentation),编译后剥离,运行时零开销。
import type {} from '@deepseek-ai/dsh-host-webserver'
export const name = 'dsh-search-probe'
/** 消费者的依赖就是这两个服务名,没有任何 provider 的名字。 */
export const inject = ['web', 'webServer']
const PATH = '/dsh-search-probe/search'
export function apply(ctx: Context): void {
ctx.effect(() => ctx.webServer.register({
kind: 'exact',
path: PATH,
handler: async (req, res) => {
const url = new URL(req.url ?? '/', 'http://127.0.0.1')
const query = url.searchParams.get('q') ?? ''
const limit = url.searchParams.get('limit')
const payload = await run(ctx, query, limit)
res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' })
res.end(JSON.stringify(payload, null, 2))
},
}))
}
/** 调用 seam;选择失败时把结构化错误码原样打出来,省得猜。 */
async function run(ctx: Context, query: string, limit: string | null) {
try {
return await ctx.web.search(limit === null ? { query } : { query, maxResults: Number(limit) })
} catch (error) {
return {
error: (error as { code?: string }).code ?? 'UNKNOWN',
message: (error as Error).message,
}
}
}
这个插件从装上到能被 curl 命中走三步:patch.yaml 在树尾插一行,loader 加载这个 .ts 并给它建一个 fiber;fiber 先停在 PENDING 等 inject 里的两个服务就位;就位后才跑 apply,把 /dsh-search-probe/search 注册进 webServer 的路由表——路由在,请求才命得中。卸载时 effect 收着的注销函数把它摘掉,路由跟着消失。
这段代码有几处要说明:
- 依赖声明是模块级的。 04-注册 HTTP 路由与注入页面样式写的是
ctx.inject(['webServer'], (serverCtx) => …),只有那个回调里的代码等webServer;这里是export const inject = ['web', 'webServer'],整个插件都等这两个服务——任何一个不在,插件整体卸载。后面「服务消失与重现」里”停用web行、路由跟着 404”能成立,就是因为写的是这一种。 web和webServer是两回事,只是名字都带 web。ctx.webServer(@deepseek-ai/dsh-host-webserver)是本机的 HTTP 服务器:监听端口、把 DSH 的页面发给浏览器,我们借它注册一条路由,管的是”请求怎么进来”。ctx.web(@deepseek-ai/dsh-web)是访问 Web 的能力:搜公网、抓页面,管的是”怎么访问出去”。树上它们分别是webserver行和web行。消费者一方的耦合就是这两个名字。- catch 里读的是
error.code。选不中 provider 时,seam 抛的是一个带code字段的错误对象(WebError),code是字符串、以后可能加新的,所以这里什么都不分支,原样打出来——比打印堆栈有用得多。
配置
配置相关的是三个文件。package.json 跟 06-用 Config schema 声明插件配置那份一样,只是多一行依赖——@deepseek-ai/dsh-web,用它的类型:
"dependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-host-webserver": "^0.2.0-rc.2",
"@deepseek-ai/dsh-web": "^0.2.0-rc.2",
"@deepseek-ai/schemastery": "^3.18.2"
}
插件包里被 dsh.bundle.patch 声明的那份补丁(本包叫 patch.yaml)插两行,不带 config(配置是”这台机器上的选择”,留给 profile):
- insert:
- id: dsh-notes-search
name: '/绝对路径/dsh-web-search-swap/src/dsh-notes-search.ts'
- id: dsh-search-probe
name: '/绝对路径/dsh-web-search-swap/src/dsh-search-probe.ts'
换源那一处写在 profile 的 cordis.patch.yml:
# 1) 换供应商:只改这一行 config 里的 searchProvider
- id: web
config:
searchProvider: notes-local
fetchProvider: http
# 2) 给自己那行配参数:笔记根目录
- id: dsh-notes-search
config:
root: '/绝对路径/dsh-web-search-swap/notes'
第 1 条要留意 03-用 patch 覆盖 DSH 的默认配置那个易错点:config 是整块替换。这里把 fetchProvider 又写了一遍,不是因为”要改它”,而是因为不写就等于没指定——web 这行的 schema 里两个字段都是可选的,漏写不报错,含义却从”保持原值”变成了”不指定”,选择方式跟着就变了。这正是下面「选择失败」一节的第一个实验。
install.sh 一次装完(本目录 pnpm install + dsh plugin add link:. + 写覆盖层):
cd dsh_plugin/dsh-web-search-swap && ./install.sh
验证
先看配置层:
dsh --profile test --dump-config | grep -A4 'id: web$'
# == @deepseek-ai/dsh-base, patched by ~/.dsh/profiles/test/cordis.patch.yml
- id: web
name: '@deepseek-ai/dsh-web'
config:
searchProvider: notes-local
fetchProvider: http
注意 # == 那行:这一行还是 base 装的那个 @deepseek-ai/dsh-web,只是被你的覆盖层改了 config。换的是它选中谁,不是它自己。
再启动,日志里 provider 自己打了一行(07-配置与源码的热重载说过 ctx.logger 不往终端打,所以要肉眼可见的证据就自己 console.log):
[dsh-notes-search] 已注册 provider notes-local(root=…/dsh-web-search-swap/notes)
dsh web: http://127.0.0.1:3099/?token=…
搜个词看看:
curl -sG 'http://127.0.0.1:3099/dsh-search-probe/search' --data-urlencode 'q=缓存'
{
"content": "本地笔记检索:命中 2 篇(关键词 缓存)",
"sources": [
{
"url": "note://九月周会记录.md",
"title": "九月周会记录",
"snippet": "## 缓存一致性"
},
{
"url": "note://前端构建缓存.md",
"title": "前端构建缓存笔记",
"snippet": "# 前端构建缓存笔记"
}
],
"truncated": false
}
结果来自我的笔记,不是公网。content 是 provider 顺手给的一句话摘要(WebSearchResult.content 是可选字段,DeepSeek 和 Exa 都不给,Perplexity 会给一段生成答案);url 用的是自定义 scheme,因为我们的源本来就不是网页。
加上 limit 再看一次,能看到 seam 替 provider 做的截断:
curl -sG 'http://127.0.0.1:3099/dsh-search-probe/search' --data-urlencode 'q=缓存' --data-urlencode 'limit=1'
{
"content": "本地笔记检索:命中 2 篇(关键词 缓存)",
"sources": [ { "url": "note://九月周会记录.md", "title": "九月周会记录", "snippet": "## 缓存一致性" } ],
"truncated": true
}
同一个 provider,两次返回的 content 都是”命中 2 篇”,sources 却是 2 条和 1 条、truncated 是 false 和 true:裁剪发生在 provider 返回之后,是 ctx.web.search() 按请求里的 maxResults 做的。provider 既不知道这次要几条,也不用知道。
最后确认”消费者一行没改、换的还是同一件事”:把覆盖层里的 searchProvider 改回 deepseek-official,保存(配置热重载默认开着,不用重启),同一个地址、同一个词,返回的是真的网页:
{
"sources": [
{ "url": "https://wsjk.tj.gov.cn/…", "title": "【专家共识】10保9减8防:早期预防慢性疾病" },
{ "url": "https://news.medlive.cn/…", "title": "指南共识|慢加急性肝衰竭临床分型专家共识(2026年版)" }
],
"truncated": true
}
(这条要真发一次检索请求,走官方接口、花一次 token;没配 DEEPSEEK_API_KEY 就别试。)从头到尾,web_search 的工具 schema、描述、提示词一个字没动。
选择失败
选不中 provider 时,DSH 不报一个含糊的错,而是给结构化的 code。
选择规则一共六条,search() 每次执行时现算:
searchProvider |
注册情况 | 结果 |
|---|---|---|
| 写了 | 注册了且 available() |
用它 |
| 写了 | 没注册(比如拼错) | WEB_PROVIDER_CONFIGURED_MISSING |
| 写了 | 注册了但 available() 为 false |
WEB_PROVIDER_CONFIGURED_UNAVAILABLE |
| 没写 | 恰好一个可用 | 用它 |
| 没写 | 多个可用 | WEB_PROVIDER_AMBIGUOUS |
| 没写 | 一个可用的都没有 | WEB_PROVIDER_UNAVAILABLE |
四个实测。把覆盖层第 1 条的 searchProvider 删掉(只留 fetchProvider)——两个 provider 都注册在案、都可用:
{"error":"WEB_PROVIDER_AMBIGUOUS",
"message":"multiple usable web providers are registered (notes-local, deepseek-official); configure one explicitly"}
把 searchProvider 写成 notes-locl:
{"error":"WEB_PROVIDER_CONFIGURED_MISSING",
"message":"configured web provider \"notes-locl\" is not registered"}
把 root 指向一个不存在的目录:
{"error":"WEB_PROVIDER_CONFIGURED_UNAVAILABLE",
"message":"configured web provider \"notes-local\" is registered but unavailable"}
再把官方那行也停掉,就落到最后一格:
{"error":"WEB_PROVIDER_UNAVAILABLE","message":"no usable web provider is registered"}
四个分别对应”没选”、”选错了”、”选了但用不了”、”一个都没有”。注意中间两个的区别:注册发生在插件启动时,可用在每次搜索时才算;一个是配置错误,一个是运行状态。
还有一条隐含的规则:选择从不看注册顺序。多个 provider 可用又没指定 id 时,DSH 不会”用最先注册的那个”,而是直接报歧义——就是为了防止你因为配置顺序变了、搜索结果悄悄换了个源。
服务消失与重现
inject = ['web'] 是必需依赖。试试把 web 那一行停掉(03-用 patch 覆盖 DSH 的默认配置的写法):
- id: web
disabled: true
保存后等一两秒,再 curl 那个地址:
$ curl -sG 'http://127.0.0.1:3099/dsh-search-probe/search' --data-urlencode 'q=共识'
(404,路由没了)
web 服务一消失,两个插件跟着卸载:provider 注销、探针路由注销。把 disabled 去掉保存,日志里那行”已注册”会重新出现,路由也回来了。这就是 Cordis 的依赖语义:服务没了就 dispose,服务回来就重新 apply,你不需要写任何重连代码。
一次停用和恢复,依次经过这些状态:
由此得到一条硬要求:provider 的注册必须能被撤销。否则每次重载都会往表里多加一条,撞上 WEB_DUPLICATE_PROVIDER。这件事我们没写 ctx.effect,是 seam 自己做的:
// packages/web/web/src/index.ts
private registerProvider<P extends { readonly id: string }>(store: Map<string, P>, provider: P): () => void {
if (store.has(provider.id)) throw new WebError(`…`, 'WEB_DUPLICATE_PROVIDER')
const dispose = this.ctx.effect(function* () {
store.set(provider.id, provider)
yield () => store.delete(provider.id)
}, 'web.registerProvider()')
return () => void dispose()
}
注意写的是 this.ctx.effect(...),而 this.ctx 是服务实例上的 ctx。它为什么会等于调用方(我们的插件)的上下文?因为 Cordis 取服务时会顺手把这个实例的 ctx 绑到你这次访问用的上下文上(源码里管这叫 tracker)。所以这一行落在调用方 fiber 上——你的插件卸载,注册自动消失。
不是所有依赖都该写进 inject。官方那个 provider 要在”有凭据服务就用它、没有就退回环境变量”之间选,所以它没声明依赖,而是用的时候现问:
const credentials = ctx.get('credentials')
if (credentials !== undefined) return (await credentials.resolve(apiKeyEnv))?.value
// 没有凭据服务,退回启动环境变量
return launchEnvironmentOf(ctx).get(apiKeyEnv)?.value
ctx.get(name) 拿到的是 undefined 或者服务本身,不阻塞加载。必需依赖写 inject,可选依赖用 ctx.get——区别在于你要的是”没有它我就不该启动”,还是”有它更好”。
注意事项
config整块替换。 改web行时把fetchProvider一起写全,别指望”只写要改的那个键”。- 官方那行不用动。
web-search-deepseek还挂在树上、还在注册,只是没被选中。注册和选中是两件事,留着它反而方便你随时改回来。真不想要,用disabled: true停掉。 - id 不能撞。 两个 provider 用同一个 id,第二个注册时直接抛
WEB_DUPLICATE_PROVIDER——这是编程错误,不是配置错误。 .ts只能写可擦除的语法。 插件源码是 Node 直接跑的(类型擦除),参数属性、enum、namespace这类”带运行时代码的类型语法”会让插件failed to import,而且终端只说一句”1 entry did not activate”,不告诉你原因。类里把字段声明出来、在构造函数里赋值就行。- 笔记不是 HTTP 资源。
note://的 URL 交给web_fetch是抓不到的——fetch 是同一 seam 的第二张 provider 表(fetchProvider)。真想让模型读笔记原文,就得照这篇再写一个WebFetchProvider,注册进registerFetchProvider。 - 改 provider 源码要重启。 配置热重载默认开着(改
searchProvider保存就生效),源码热重载默认关着,见07-配置与源码的热重载。