Skip to content

Demo 4 · 注册一个 Mock LLM 适配器

目标:独立运行 dsh-llmLlmRuntime,把一个"假模型"注册成提供方,走一遍完整的流式调用、取消与注销。对应原理篇 04。代码:demos/04-llm-mock/main.ts

运行

sh
cd demos
npm run demo:4

核心代码

Mock 适配器

ts
class MockAdapter extends LlmAdapter {
  async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
    const text = `我是 MockAdapter。你刚才说:"…"。(provider=…, model=…, 工具数=…)`
    yield { type: 'block-start', index: 0, blockType: 'text' }
    for (const piece of chunk3(text)) {                    // 逐字模拟流式
      await sleep(30)
      options.signal?.throwIfAborted()                     // 协议义务:遵守取消
      yield { type: 'text-delta', index: 0, text: piece }
    }
    yield { type: 'block-end', index: 0, block: { type: 'text', text } }
    yield { type: 'usage', usage: { inputTokens: 42, outputTokens: 13 } }  // 先于 finish
    yield { type: 'finish', reason: { kind: 'stop' } }
  }
}

对照协议义务清单逐条检查:块按首次出现分配 index ✓;usage 在 finish 前 ✓;options.signal 被检查 ✓;取消路径发出 finish { kind: 'aborted' } ✓。

组装

ts
const root = new Context()
await root.plugin(LlmRuntime)          // LlmRuntime 自己就是一个 Service(注册名 'llm')
const disposeAdapter = root.llm.registerAdapter(['mock'], new MockAdapter())

两行代码,一个"模型提供方"就上线了。registerAdapter 返回 disposer,最后 disposeAdapter() 注销——这就是"模型提供方也是插件"的全部秘密

消费

ts
const request: GenerateOptions = {
  provider: 'mock', model: 'mock-1',
  messages: [{ role: 'user', content: [{ type: 'text', text: '你好' }] }],
  tools: [{ name: 'echo', description: '…', parameters: {…} }],
}
for await (const chunk of root.llm.stream(request)) { /* 打印 chunk */ }

消费方只认识统一词汇表,完全不知道(也不关心)后面是真模型还是假模型。

输出解读

text
LlmRuntime 已挂载,ctx.llm 可用
MockAdapter 已注册到提供方路由 "mock"
已注册提供方: mock(MockAdapter)
== 流式调用开始 ==
  chunk: block-start #0 (text)
  chunk: text-delta #0 "我是 "
  …(逐字增量)…
  chunk: usage {"inputTokens":42,"outputTokens":13}
  chunk: finish stop
== 取消演示:300ms 后中止调用 ==
  …(正常流式输出)…
  chunk: finish aborted
适配器已注销,提供方列表: (空)

三个观察点:

  1. 流式增量:每个 text-delta 都是一小片,BlockAssembler 负责把它们组装回完整块(demo 里用 assembler.message() 验证了组装结果)。
  2. 取消AbortController.abort() 后,下一次 throwIfAborted() 抛出,适配器转换为 finish aborted 终止——消费方永远拿到合法终止,而不是裸异常。
  3. 注销:disposer 一调,提供方列表立刻清空。想象一下:dsh 的设置页切换模型服务,本质上就是在调这个 disposer + 一次新注册。

亲手做实验

实验 1:故意违反协议

usage 放到 finish 后面再跑。观察 BlockAssembler 的 usage 字段——它会被忽略(finish 之后到达的 chunk 不参与组装)。这就是协议义务存在的原因:违反者得不到正确结果,而不是框架崩溃。

实验 2:重复注册

registerAdapter(['mock'], new MockAdapter()) 一次,观察 DUPLICATE_ADAPTER 错误。

实验 3:加一个 llm/stream 监听器

仿照原理篇 4.5的 token 统计监听器,统计一次调用输出的 chunk 数。注意监听器里必须把 next() 的每个 chunk yield 出去。

下一步

Demo 4 在"空转"里验证了接缝。Demo 5 把它放进真实的 dsh 进程——用你自己的 Mock 适配器跑通完整 Agent 链路

基于 DeepSeek Harness(开发者预览版 0.1.0-rc.6)与 Cordis 撰写