deepseek-harness 一切皆插件!一个 plugin 就是一个“实现 Service 接口的对象“

"插件"在 dsh 里到底是什么形状的一段代码?
一次"跟模型对话"是怎么从一段命令式代码变成N 个插件的装配结果的?

1. 未架构化的形态:我们要打破的对照组

// ❌ 未架构化 —— 强耦合
const openai = new OpenAI({ apiKey: process.env.KEY })
const answer = await openai.chat.completions.create({ model, messages })
for (const call of answer.tool_calls ?? []) {
  await executeTool(call)
}
appendToLog(answer)

它把四件事强绑在一起:

  • Provider 选择(new OpenAI
  • 凭证注入(process.env.KEY
  • 工具执行(executeTool
  • 日志记录(appendToLog

任何一件事想改,都要改这段代码。以及:

  • 想换成 DeepSeek?改 new
  • 想加重试?包一层 try/catch,answer 传参
  • 想让工具走审批?在 executeTool 前面塞 if
  • 想把日志换成 event 流?改 appendToLog

这就是所谓的"雪球代码",每加一个能力都在同一个洞里塞。

2. dsh 的答案:把每个能力做成一个"插件"

Cordis(dsh 底层的 vendored 框架)只有两种插件形态,一张表看清:

形态用途导出形式一句话记忆例子
Service 类插件定义一种新能力,在 ctx 上开一个 keyexport default class Xxx extends Service“我要在 ctx 上摆一张桌子”LlmRuntime / Tools / AgentLoop
函数插件消费已有能力,做配置/注册/挂载命名导出 name / inject / Config / apply“我不摆桌子,我往桌子上放菜”llm-deepseek / tool-bash / tool-plan

packages/CLAUDE.md 的原文:
service packages default-export their service class; function plugins named-export name / inject / Config / apply and have no default export. Mixing the forms makes the Loader discard the function plugin’s namespace.

两种形态是有严格分工的,混用会被 Loader 静默丢弃 inject——这是踩过坑总结出来的规矩(详见 docs/postmortem/0001-acp-default-export-drops-inject.md)。

3. Service 类插件是怎么"自动上桌"的

来看 vendor/cordis/src/service.ts 里的 Service 基类构造函数:

// vendor/cordis/src/service.ts:42
constructor(protected ctx: Context, name: string) {
  name ??= this.constructor['provide'] as string

  let self = this
  const tracker: Tracker = { associate: name, property: 'ctx' }
  if (self[symbols.invoke]) {
    self = createCallable(name, joinPrototype(Object.getPrototypeOf(this), Function.prototype), tracker)
  }
  self.ctx = ctx
  self.name = name
  defineProperty(self, symbols.tracker, tracker)

  self.ctx.reflect.provide(name, self, this[symbols.check])   // ← 关键一行
  return self
}

一句话理解:super(ctx, 'llm') 调用完的瞬间,ctx.llm 就是 this

ctx.reflect.provide(name, value) 内部做的事,看 vendor/cordis/src/reflect.ts:277

provide(name: string, value?: any, check?: () => boolean) {
  return this.ctx.fiber.effect(() => {
    // ...
    const key = this.ctx[symbols.isolate][name]
    const impl: Impl = { name, value, fiber: this.ctx.fiber, check }
    if (this.store[key]) {
      throw new Error(`service "${name}" has been registered at <${this.store[key].fiber.name}>`)
    }
    this.store[key] = impl
    this.ctx.fiber.store![name] = impl
    if (this.ctx.fiber.state === FiberState.ACTIVE) {
      this.notify([name])
    }
    return async () => {                                // ← teardown
      delete this.store[key]
      const fibers = this.notify([name])
      await Promise.allSettled(fibers.map(fiber => fiber.await()))
      delete this.ctx.fiber.store![name]
    }
  }, `ctx.provide(${JSON.stringify(name)})`)
}

三件事,一件不少:

  1. 写入 store:把服务实例挂到 reflect.store 上,同一命名空间里同名 service 会直接抛"重复注册"。
  2. notify 依赖者:所有 inject 了这个名字的插件从 pending 变 active。
  3. 返回 teardown:一旦 fiber 卸载,反向删掉 store、通知依赖者、再删 fiber 本地缓存。注册本身即副作用

3.1 LLM 能力的落地:LlmRuntime

以本系列的主线插件为例(packages/llm/llm/src/index.ts:284):

export class LlmRuntime extends Service {
  private adapters = new Map<string, AdapterRegistration>()
  private directory = new Map<string, LlmConfigurableProvider>()
  private discoveries = new Map<
    string,
    (request: LlmModelDiscoveryRequest) => Promise<readonly LlmDiscoveredModel[]>
  >()

  constructor(ctx: Context) {
    super(ctx, 'llm')      // ← 一句话就把 ctx.llm 摆好了
  }

  registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle { /* … */ }
  registerConfigurableProviders(entries): DirectoryRegistrationHandle { /* … */ }
  registerModelDiscovery(ns, discover): () => void { /* … */ }
  stream(options: GenerateOptions): AsyncIterable<StreamChunk> { /* … */ }
  // ...
}
export default LlmRuntime      // ← Service 类插件必须 default export

设计上它同时是Service Definition:给出 LlmAdapter 抽象类(packages/llm/llm/src/index.ts:180)——

export abstract class LlmAdapter {
  providerInfo(provider: string): LlmProviderInfo { /* … */ }
  providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined { /* … */ }
  listModels(_provider: string): Promise<readonly LlmModelInfo[]> { /* … */ }
  resolveModel(provider, model, signal?): Promise<LlmResolvedModelInfo> { /* … */ }
  abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk>   // 唯一必须实现的方法
}

Provider 插件(llm-deepseek)实现这个抽象类,Consumer 插件(agent-loop)调用 ctx.llm.stream(...)。这就是完整的 capability seam。

4. 函数插件是怎么"往桌子上放菜"的

函数插件的骨架非常干净(packages/llm/llm-deepseek/src/index.ts:41):

export const name = 'llm-deepseek'
export const inject = ['llm']                    // 依赖声明
export const Config: z<Config> = z.object({
  apiKeyEnv: z.string().role('credential-ref').default(DEFAULT_API_KEY_ENV),
  baseURL: z.string(),
  thinking: z.union(['enabled', 'disabled']),
  reasoningEffort: z.union(['off', 'low', 'high', 'max']),
  maxTokens: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_TOKENS),
  // ...
})

export function apply(ctx: Context, config: Config): void {
  // 到 apply 被调用这一刻,ctx.llm 一定就绪
  const adapter = new DeepSeekAdapter({ options, resolveApiKey, resolveUserId })

  ctx.llm.registerConfigurableProviders([
    { provider: PROVIDER, displayName: 'DeepSeek', settingsNs: NS, settingsPath: [] },
  ])

  const registration = ctx.llm.registerAdapter([PROVIDER], adapter)
  // …
}

四件命名导出,一件不能多、一件不能少:

  • name — 用于日志/诊断的显示名
  • inject — 声明必需服务(详见 03
  • Config — schemastery 校验器,Loader 在 apply 前自动跑
  • apply(ctx, config) — 唯一副作用入口

Loader 认的就是这四件。混入 default export 会让 Loader 认成 object-plugin,name/inject/Config 全丢——这就是那条 postmortem 记录的事故。

5. Consumer 插件示范:tool-bash

只知道 ctx.tools,完全不知道 LLM 在哪(packages/shell/tool-bash/src/index.ts):

export const name = 'tool-bash'
export const inject = ['tools', 'shell', 'systemPrompt', 'shellEnv']

export function apply(ctx: Context, config: Config) {
  ctx.systemPrompt.section({
    name: 'tool:bash',
    order: 105,
    text: '…',
  })
  ctx.tools.register(defineTool({
    name: 'bash',
    description: bashDescription(/* … */),
    parameters: { command: { /* … */ }, description: { /* … */ } },
    // …
  }))
}

注意它 import 的都是抽象类型和辅助函数,从不 import LlmRuntimeimport DeepSeekAdapter。这是"一切皆插件"最直接的验证:如果不同插件之间用 import 互相拉,那就是回到雪球了。

6. 从 cordis.yml 到一次工具调用:完整装配链

cordis.yml
   │
   ▼
Cordis Loader
   ├─ ctx.plugin(llm)             → new LlmRuntime(ctx)  ★ ctx.llm 就位
   ├─ ctx.plugin(llm-deepseek)    → apply(ctx, cfg): ctx.llm.registerAdapter([...], adapter)
   ├─ ctx.plugin(tools)           → new Tools(ctx)       ★ ctx.tools 就位
   ├─ ctx.plugin(tool-bash)       → apply(ctx, cfg): ctx.tools.register(bashTool)
   ├─ ctx.plugin(agent-loop)      → new AgentLoop(ctx)   ★ ctx.agents 就位
   └─ …session / sysprompt / credentials / settings / …
                   │
                   ▼
用户输入:"帮我看下这个目录"
   └─ ctx.agents.run(input)
        └─ 组装 GenerateOptions → ctx.llm.stream(options)
              └─ 触发 llm/stream waterfall → DeepSeekAdapter.stream()
                    └─ 模型返回 tool_call: bash('ls')
                         └─ ctx.tools.execute → 触发 tools/pre-execute → tools/execute → tools/post-execute
                               └─ ctx.shell.run('ls')

这条链上的每一层都是插件,从 ctx.plugin(X) 装进来的。任何一层想换掉、想插一层、想加一层拦截,都不动别人一行代码。

7. “为什么这样拆”

  1. 换 provider 只改一行 yml。

    # 之前
    llm-deepseek:
      config: { apiKeyEnv: DEEPSEEK_API_KEY }
    # 换成 pi-ai
    llm-pi-ai:
      config: { apiKeyEnv: PI_AI_API_KEY }
    

    agent-loop / tool-bash / session 一个字都不用改。

  2. 加一层重试/审计不用改产品代码。
    写一个新插件,inject: ['llm'],在 applyctx.on('llm/stream', …)(详见 04 · 类型化事件)。装或不装完全由 yml 决定。

  3. 卸载一个插件不会留残骸。
    ctx.tools.register(...) 返回 disposer;插件 fiber 卸载时自动摘工具,agent-loop 会通过 inject 联动 得知工具目录变了。详见 05 · 可逆副作用

8. 源码具体实现

主题文件关键位置
Service 基类构造函数vendor/cordis/src/service.tsL42-59
ctx.reflect.provide 实现vendor/cordis/src/reflect.tsL277-305
Plugin 入口签名vendor/cordis/src/registry.tsPlugin.Function / Plugin.Constructor / Plugin.Object
LLM Service Definitionpackages/llm/src/index.tsLlmRuntime L284、LlmAdapter L180
DeepSeek Provider 插件packages/llm/llm-deepseek/src/index.tsapply L200
Bash Consumer 插件packages/shell/tool-bash/src/index.tsapply
Service vs 函数插件规范packages/CLAUDE.md“Plugin exports” 段
混用形式的 postmortemdocs/postmortem/0001-acp-default-export-drops-inject.md全文
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

ADRU

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值