DeepSeek Harness LLM アダプター:任意のモデルに接続
モデル提供元は DeepSeek だけではありません。dsh を自分のモデルエンドポイントに接続するには、LLM アダプターを書く必要があります。
この章では、LlmAdapter 抽象クラス、stream() メソッド、ルーティング登録、cordis.yml 設定について説明します。
LLM アダプターとは
LLM アダプターは、LlmAdapter を継承し、stream() メソッドを実装するクラスです。
Harness のプロバイダー非依存リクエストを、特定プロバイダーの API 呼び出しに変換します。
そしてレスポンスを Harness のチャンク、つまり StreamChunk に変換し戻します。
したがって agent-loop は統一インターフェースを消費するだけで、背後がどの API であるかは気にしません。
最上位は agent-loop で、プロバイダー非依存のストリーミング生成サービスを消費します。
中間は ctx.llm レジストリで、LlmAdapter の抽象契約を維持します。
最下層は各アダプターで、それぞれ異なる API 形式に対応します。
登録時にはctx.llm.registerAdapter(['my-provider'], adapter)ルートをバインドします。
最小実装
公式ドキュメントには最小の骨格が用意されているので、それに沿って書き、コメントを補足します。
stream() が核心で、非同期ジェネレータ AsyncIterable を返します。
3 つのステップのコメントが標準フローを示しています:メッセージ形式の変換、ストリーミング API の呼び出し、レスポンスの StreamChunk への変換。
実例
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 を呼び出してルート登録を完了します。
実例
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 を使わせます。
実例
# アダプタープラグインを読み込み、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 はどの設定項目で新しいアダプターを選択しますか?
その他の拡張