Skip to content

开发 LLM 适配器(LLM Adapter)

本章目标

读完本章后,你将能够:

  • 说清 LLM 适配器在 DSH 中的角色:注册到 ctx.llm 的 seam,agent loop 以提供方无关的方式消费;
  • StreamChunk 词汇把一个提供方的流式响应翻译成标准分片流;
  • 逐条遵守九条 adapter contract,写出能被直接接入的适配器,并用 cordis.yml 启用它。

适配器在 DSH 中的角色

DSH 把「模型调用」设计成一个可插拔的 seam:LlmAdapter 是提供方约定,适配器插件通过 ctx.llm.registerAdapter(providers, adapter) 把一组提供方路由注册进 LlmRuntime。agent loop 完全不知道也不关心提供方是谁——它组装一份提供方无关的 GenerateOptions,把结果当作原始的 StreamChunk 序列消费,再用共享的 BlockAssembler 折叠回内容块。参考实现有两个:llm-deepseek(直接 fetch + SSE)与 llm-pi-ai(封装第三方库),二者验证了同一套协议约定。

注册基于副作用,天然支持 HMR;每个提供方路由只能有一个适配器(重复注册抛 LlmError('DUPLICATE_ADAPTER')),多路由注册要么全部成功、要么全部失败。options.provider 选择适配器,options.model 是提供方模型 ID,原样传给适配器——动态模型目录不需要重新配置生命周期。密钥用 Cordis 原生方式管理:Config 里声明字段,cordis.yml 通过 !!js process.env.MY_KEY 注入环境变量;切勿在代码里读取自行约定的密钥文件。

接口形态很收敛:stream() 是唯一必须实现的方法;providerInfo()providerRetryPolicy()listModels()resolveModel() 提供可选元数据。resolveModel(provider, model, signal?) 是单次异步查询,返回确切模型身份,以及可选的对正确性敏感的 context 容量和 reasoning 强度信息;它独立于仅供参考的模型目录,并遵守传入的 AbortSignal

StreamChunk:适配器说给 loop 听的封闭协议

适配器不返回「完成的消息」,而是一条原始分片流。StreamChunk 是封闭的可辨识联合:block-starttext-deltareasoning-deltatool-call-deltablock-endusagefinishindex 把交错的增量关联到各自的块,同一块的每次 delta 复用同一个 index;block-end 携带完整组装好的 ContentBlock,消费方无需自己拼接增量。对 type 的 switch 以 assertNever 结尾——新增变体会在每个消费方触发编译错误。所有失败统一为可序列化的 LlmFailure:人类可读的 message、稳定的机器路由 code,以及可选的 statusproviderRetryAfterMsrequestId

九条 adapter contract

以下规则每个适配器必须遵守,每个消费方可以依赖它们:

  1. usage 先于 finishfinish 之后不再有任何分片。 把两者都推迟到提供方的流结束标记再统一发出,就能正确处理「尾部仅含 usage 的分片」。
  2. 工具调用的 arguments 全程保持原始 JSON 字符串,流式片段用 argumentsDelta 发送;若提供方返回已解析对象,适配器在 block-end 时重新序列化。
  3. 失败有且仅有两条路径,共用 LlmFailure:从 stream() 抛出(传输/协议故障,用带稳定 code 的 LlmError),或以 finish { kind: 'error' | 'aborted', failure } 结束流(提供方带内故障)。按故障类别选择路径并文档化;ctx.llm.stream() 会把抛出的失败规范化为终态 finish 再暴露给消费方。
  4. 一次适配器调用就是一次提供方尝试。 适配器禁用库级重试;恢复是 agent 层(dsh-llm-retry 监听 agent/request-error)的职责。
  5. 提供方停顿受传输层时限约束。 暴露正数有限的 streamIdleTimeoutMs(默认五分钟);watchdog 只在迭代器 next() 未完成时启动,整个请求使用同一个稳定 signal,自身到期映射为 TIMEOUT,更早的调用方中止保留为 ABORTED
  6. 上下文溢出只有一个规范 code:CONTEXT_WINDOW_EXCEEDED 消费方按 code 路由,绝不依赖提供方文本。
  7. 空 completion 是可重试错误,不是静默成功。 没有携带任何内容块的终止性 stop 映射为 finish { kind: 'error' } + 规范 code EMPTY_RESPONSE,默认会被重试。
  8. 每个提供方 HTTP 请求都携带 attributionHeaders() 作为 User-Agent 基线(AppIdentity 只含公开产品事实,不含 secret 或会话标识),并用协议级测试证明。
  9. 回放状态归适配器所有。 成功的 finish 可携带 replayState——重建提供方原生响应所需的最小无损 JSON 投影。LlmRuntime 仅当历史提供方与目标提供方当前由完全相同的适配器实例拥有时才传递它;由适配器决定跨模型/跨提供方恢复是否合法,状态缺失时切勿仅凭提供方/模型名称推断原生回放。

最小适配器骨架

ts
// 最小适配器骨架:把提供方的流式响应翻译成 StreamChunk
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
import {
  LlmAdapter, LlmError, attributionHeaders,
  type GenerateOptions, type StreamChunk,
} from '@deepseek-ai/dsh-llm'

class MyAdapter extends LlmAdapter {
  constructor(private apiKey: string) { super() }

  /** 唯一必实现的方法:一次模型调用 → 一条原始分片流 */
  async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
    // —— 1. 组装提供方请求(必须遵守 options.signal,契约 #5)——
    const response = await fetch('https://api.example.com/v1/chat/completions', {
      method: 'POST',
      headers: {
        'content-type': 'application/json',
        authorization: `Bearer ${this.apiKey}`,
        ...attributionHeaders(), // 契约 #8:User-Agent 归属
      },
      body: JSON.stringify({
        model: options.model, // 提供方模型 ID,原样传递
        messages: options.messages,
        stream: true,
      }),
      signal: options.signal,
    })

    if (!response.ok) {
      // 契约 #3 路径 A:传输/协议故障从 stream() 抛出,使用稳定 code
      throw new LlmError('provider rejected the request', {
        code: response.status === 401 ? 'AUTH' : 'SERVER',
        status: response.status,
      })
    }

    // —— 2. 逐分片解析:按 index 关联增量,block-end 携带完整块 ——
    let index = 0
    for await (const delta of parseSse(response.body!)) {
      if (delta.type === 'content') {
        yield { type: 'block-start', index, blockType: 'text' }
        yield { type: 'text-delta', index, text: delta.text }
        yield { type: 'block-end', index, block: { type: 'text', text: delta.text } }
        index += 1 // 同一块的每次 delta 复用同一 index
      }
      // 工具调用增量用 argumentsDelta 发送原始 JSON 片段(契约 #2)
    }

    // —— 3. 收尾(契约 #1):usage 先于 finish;finish 后不再有任何内容 ——
    yield { type: 'usage', usage: { inputTokens: 0, outputTokens: 0 } }
    yield { type: 'finish', reason: { kind: 'stop' } }
  }
}

export const name = 'llm-myprovider'
export const inject = ['llm'] // 等 LlmRuntime 就绪
export const Config = Schema.object({
  apiKey: Schema.string().required(), // 密钥字段,值由 cordis.yml 注入
})
export function apply(ctx: Context, config: Config) {
  // 注册基于副作用,天然支持 HMR;重复路由抛 DUPLICATE_ADAPTER
  ctx.llm.registerAdapter(['my-provider'], new MyAdapter(config.apiKey))
}

对应的 cordis.yml(密钥经环境变量注入,绝不写进配置):

yaml
# 启用 llm-myprovider 适配器
- insert:
    - id: llm-myprovider
      name: '/absolute/path/to/llm-myprovider/src/index.ts'
      config:
        apiKey: '!!js process.env.MY_PROVIDER_KEY'

小结

适配器就是一次「提供方私有协议 → StreamChunk 词汇」的翻译。遵守九条契约后,模型选择、重试、token 计量、流式 UI 全部由 harness 接管,你的适配器无缝进入管线。

下一步

适配器让 harness 有了说话的嘴巴。下一章看它如何记住说过的话:会话与持久化。