DeepSeek Harness LLM アダプター:任意のモデルに接続

モデル提供元は DeepSeek だけではありません。dsh を自分のモデルエンドポイントに接続するには、LLM アダプターを書く必要があります。

この章では、LlmAdapter 抽象クラス、stream() メソッド、ルーティング登録、cordis.yml 設定について説明します。


LLM アダプターとは

LLM アダプターは、LlmAdapter を継承し、stream() メソッドを実装するクラスです。

Harness のプロバイダー非依存リクエストを、特定プロバイダーの API 呼び出しに変換します。

そしてレスポンスを Harness のチャンク、つまり StreamChunk に変換し戻します。

したがって agent-loop は統一インターフェースを消費するだけで、背後がどの API であるかは気にしません。

LLM 适配器 seam 图

最上位は agent-loop で、プロバイダー非依存のストリーミング生成サービスを消費します。

中間は ctx.llm レジストリで、LlmAdapter の抽象契約を維持します。

最下層は各アダプターで、それぞれ異なる API 形式に対応します。

登録時にはctx.llm.registerAdapter(['my-provider'], adapter)ルートをバインドします。


最小実装

公式ドキュメントには最小の骨格が用意されているので、それに沿って書き、コメントを補足します。

stream() が核心で、非同期ジェネレータ AsyncIterable を返します。

3 つのステップのコメントが標準フローを示しています:メッセージ形式の変換、ストリーミング API の呼び出し、レスポンスの StreamChunk への変換。

実例

// ファイルパス:src/my-llm-adapter.ts
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'
import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'

// アダプター:抽象クラスを継承し、stream() を実装
class MyAdapter extends LlmAdapter {
  private apiKey: string

  constructor(apiKey: string) {
    super()
    this.apiKey = apiKey
  }

  // stream() は非同期ジェネレータを返し、チャンク単位で StreamChunk を生成
  async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
    // 1. Convert options.messages to the provider format.
    // 2. Call the streaming API.
    // 3. Convert the response into StreamChunk values.
  }
}

// プラグイン設定:apiKey と providers はどちらも必須
export interface Config {
  apiKey: string
  providers: string[]
}

// 同名の Schemastery スキーマ。読み込み時に設定を検証
export const Config: Schema<Config> = Schema.object({
  apiKey: Schema.string().required(),
  providers: Schema.array(Schema.string()).required(),
})

export const name = 'my-llm-adapter'
// llm サービスへの依存を宣言し、ctx.llm が準備済みであることを保証
export const inject = ['llm']

export function apply(ctx: Context, config: Config) {
  const adapter = new MyAdapter(config.apiKey)
  // プロバイダーのルート一覧をこのアダプターにバインド
  ctx.llm.registerAdapter(config.providers, adapter)
}

apiKey はプラグイン設定から取得するため、コードに鍵をハードコードせずに済みます。

providers 配列は、このアダプターが担当するプロバイダールートを宣言します。

apply 内でまず new MyAdapter(config.apiKey) を実行し、その後 registerAdapter で登録を完了します。

inject: ['llm'] により、ctx.llm サービスが準備できてから apply が実行されることを保証します。

stream() は非同期ジェネレータを返し、yield でチャンク単位にデータを出力します。

次の記事では StreamChunk のチャンクプロトコルを詳しく説明します。


GenerateOptions:アダプターが受け取るもの

stream() はリポジトリがエクスポートする GenerateOptions を受け取ります。

これにはモデル、推論強度、会話履歴、システムプロンプト、ツールスキーマ、生成パラメータ、停止シーケンス、中止シグナルが含まれます。

完全なフィールドは @deepseek-ai/dsh-llm がエクスポートする TypeScript 型が基準です。

フィールド説明
provider登録済みアダプターの選択
modelアダプターが所有するモデル ID。起動時に登録する必要はありません
messages会話履歴
system promptシステムプロンプト
toolsツールスキーマ
reasoningアダプターが所有する推論強度 ID(オプション)
signal中止シグナル。キャンセルとリソース解放で完全に停止させるために使用

アダプターは、サポートするフィールドを特定の API にマッピングする必要があります。

あるフィールドをサポートできない場合は、安定した code を持つ LlmError をスローしなければならず、黙って破棄してはいけません。

サポートしていないフィールドは、黙って無視するのではなく、明確にエラーを報告する必要があります。そうしないと、モデルは不完全な結果を受け取ることになります。


アダプターの登録

apply 内で ctx.llm.registerAdapter を呼び出してルート登録を完了します。

実例

// ファイルパス:src/my-llm-adapter.ts(登録部分)
ctx.llm.registerAdapter(['my-provider'], adapter)

最初の引数は、アダプターが処理するプロバイダールートのリストです。

GenerateOptions.provider で登録済みアダプターが選択されます。

GenerateOptions.model には、アダプターが所有し、ライフサイクル起動時に登録する必要のないモデル ID が渡されます。

アダプターがセレクターにモデルオプションを公開できる場合は、listModels() をオーバーライドできます。

正確なプロバイダーとモデル ID を返す必要がある場合は、resolveModel(provider, model, signal?) をオーバーライドできます。

resolveModel は 1 回のクエリで、正確なプロバイダー、モデル ID、およびオプションの context と reasoning メタデータを返します。

reasoning メタデータは、モデルが選択できる推論強度を記述します:順序付けられた不透明な ID、表示名、およびオプションの設定デフォルト値。

アダプターが提供する信頼できるオプションリストを保持し、アップストリームの能力 API が返す off も含め、これらの値をコアの列挙型に昇格させないでください。

非同期クエリはオプションの signal に応答し、キャンセルとリソース解放のプロセスを完全に停止させなければなりません。

サービスは集約結果を検証します:明示的に指定されたがサポートされていない推論強度は、stream() の呼び出し前に拒否されます。

reasoning を省略すると、そのモデルに選択可能な推論強度機能がないことを示します。


cordis.yml での使用

アダプタープラグインと agent-loop を一緒に読み込み、agent-loop に新しい provider と model を使わせます。

実例

# ファイルパス:cordis.yml
# アダプタープラグインを読み込み、apiKey は環境変数から取得
- id
: my-llm
  name
: './src/my-llm-adapter.ts'
  config
:
    apiKey
: !!js process.env.MY_API_KEY
    providers
:
     - my-provider

# agent-loop が新しいアダプターの provider と model を使うよう設定
- id
: agent-loop
  name
: '@deepseek-ai/dsh-agent-loop'
  config
:
    agents
:
      - id
: main
        provider
: my-provider
        model
: my-model-v1

my-llm プラグインの config.apiKey は環境変数 MY_API_KEY から読み取られ、ディスクに保存されません。

providers 配列が my-provider というルートを MyAdapter に登録します。

agent-loop の agents.main で provider と model を設定すると、生成リクエスト時に新しいアダプターが使用されます。


実践参考

リポジトリには、照合できる完全な実装が 2 つ用意されています。

llm-deepseek は DeepSeek API に対応し、OpenAI 互換フォーマットを使用します。

llm-pi-ai は Pi AI に対応し、異なる API フォーマットです。

これらの 2 つのアダプターを比較すると、同じハーネス契約が異なるプロバイダー SDK の上でどのように実装されるかがわかります。

最初に llm-deepseek を読み、次に llm-pi-ai を読むと、「契約は不変、実装はさまざま」というシームの考え方が最もわかりやすくなります。


まとめと自己テスト

一言でまとめると:LLM アダプター = LlmAdapter の継承 + stream() の実装 + registerAdapter によるルート登録 + cordis.yml での設定。

自己テスト 1:stream() の戻り値の型は何ですか?

自己テスト 2:registerAdapter の最初の引数は何を表しますか?

自己テスト 3:agent-loop はどの設定項目で新しいアダプターを選択しますか?

その他の拡張