第 6 課:実践応用:LLM アダプターと自己参照ツール
一言でいうと:この課では第 4 章の 2 つの「難関」に取り組みます——LLM アダプターを書くこと。
ctx.llmに新しいモデルプロバイダーを登録すれば、モデルの切り替えはアダプタープラグインを 1 つ交換するだけで、エージェントループは 1 行も変更不要です。もう 1 つは自己参照 Cordis ツール(明示的な有効化が必要)で、エージェントが自分自身のライブランタイムを検査し、実行中に自分へ一時プラグインをマウント・アンマウントできるようにします。この 2 つをつなげると、「自己改善する」プラグイン開発者の視点が得られます:モデルがツールを合成 → マウント → 使う → ダメならアンマウント。
1. ユーザーストーリー:新しいモデルベンダーを接続し、エージェントに新しい部品を装着する
まず 2 つの物語から始めましょう。1 つはあなたのもの、もう 1 つはあなたのエージェントのものです。
物語 1:チームのエージェントの「エンジン」を載せ替える。 あなたはスタートアップのエンジニアです。新しいモデルベンダーがより強力な新モデルを発表し、チームのエージェントにそれを使わせたいと考えています。プラグイン体系がなければ、これはエージェントループの変更、リクエストラッパーの変更、レスポンス解析の変更を意味するかもしれません——変更があちこちに波及します。しかし DSH では、やることは 1 つだけです:アダプタープラグインを書く。新しいベンダーの SDK をクラスに包み、ctx.llm に登録し、あとは cordis.yml でモデル名を書き換えるだけ——エージェントはエンジンを載せ替えたようになりますが、ハンドルやダッシュボード(ツール、イベント、サンドボックス)はすべてそのままです。
物語 2:深夜、もう 1 人の「エンジニア」も忙しく働いています——それはエージェントです。 自分には「設定を構造化データにパースする」ツールが足りないことに気づいたエージェントは、人間のエンジニアの救援を待たず、自分で動き出します:まず cordis_inspect で現在どのプラグインがマウントされ、どのツールが登録されているかを確認し、この能力を提供している者がいないことを確かめます。次に自分で JavaScript を書き、cordis_mount で一時プラグインとしてマウントし、その場で新しいツールを手に入れます。数ラウンド使ってみてあるフィールドのパースにバグを見つけると、cordis_unmount で取り外し、コードを直して再マウントします。この全過程で、DSH は一度も再起動されず、セッションは中断されず、他のプラグインも無傷でした。
2 つの物語の共通点は何でしょう?どちらも「シーム(継ぎ目)」で交換を行っている——1 つはモデルプロバイダーを、もう 1 つは自分自身の部品を交換しています。違うのは作業する主体だけです:前者は人間のエンジニア、後者はエージェント自身。これこそが本課タイトルの 2 つの言葉です:LLM アダプターと自己参照ツール。
2. LLM アダプター:ctx.llm に「新しいコンセント」を登録する
2.1 アダプターとは何か
リポジトリのドキュメントには非常に正確な定義があります(出典:docs/user/develop/practice/llm-adapter.zh.md):
LLM アダプターとは、
LlmAdapterを継承してstream()メソッドを実装するクラスであり、Harness のプロバイダー非依存リクエストを具体的なプロバイダーの API 呼び出しに変換し、レスポンスを Harness チャンクに変換し直すものです。
2 つの方向に分けて見てみましょう:
- リクエスト方向:エージェントループが発行するのは「プロバイダー非依存のリクエスト」です——モデル名、メッセージリスト、ツールスキーマといった汎用的な概念だけを認識し、特定ベンダーの独自フォーマットは認識しません。アダプターはそれを対象 API のリクエストに翻訳する責任を負います(例えば、相手が要求する JSON body を組み立て、要求される認証ヘッダーを付加する)。
- レスポンス方向:ベンダーが返すバイトストリームは千差万別です。アダプターはそれらを Harness のチャンクプロトコル(
StreamChunk)へ一律に翻訳し直す責任を負います——これによりループ側から見えるものは常に同じ形になります。
ドキュメントには最小実装が掲載されており、本章で最も真似する価値のあるコードです(出典:docs/user/develop/practice/llm-adapter.zh.md):
import type { Context } from 'cordis'
import Schema from 'schemastery'
import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
class MyAdapter extends LlmAdapter {
private apiKey: string
constructor(apiKey: string) {
super()
this.apiKey = apiKey
}
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.
}
}
export interface Config {
apiKey: string
models: string[]
}
export const Config: Schema<Config> = Schema.object({
apiKey: Schema.string().required(),
models: Schema.array(Schema.string()).required(),
})
export const name = 'my-llm-adapter'
export const inject = ['llm']
export function apply(ctx: Context, config: Config) {
const adapter = new MyAdapter(config.apiKey)
ctx.llm.registerAdapter(config.models, adapter)
}
お分かりでしょうか?この骨格は第 1 課で学んだプラグイン構造とまったく同じです——name、inject、Config、apply の 4 点セットで、唯一の新顔は ctx.llm.registerAdapter(config.models, adapter) だけです。登録こそがコンセントの差し込み口です:第 1 引数はこのアダプターがサポートするモデル名のリストで、ユーザーが cordis.yml に model: my-model-v1 と設定すると、フレームワークはリクエストをこのアダプターにルーティングします。
2.2 stream() と StreamChunk プロトコル:守るべき「語彙」
stream() は非同期ジェネレーターであり、固定のプロトコルに従ってチャンクを生成しなければなりません。プロトコルの完全な形(出典:docs/user/develop/practice/llm-adapter.zh.md):
async function* exampleChunks(): AsyncIterable<StreamChunk> {
// 1. 各コンテンツブロックは block-start で始まる
yield { type: 'block-start', index: 0, blockType: 'text' }
// 2. テキストは text-delta でストリーミング出力される
yield { type: 'text-delta', index: 0, text: 'Hello' }
yield { type: 'text-delta', index: 0, text: ' world' }
// 3. 各コンテンツブロックは block-end と完全な block で終わる
yield { type: 'block-end', index: 0, block: { type: 'text', text: 'Hello world' } }
// 4. ツール呼び出しブロック
yield { type: 'block-start', index: 1, blockType: 'tool-call' }
yield { type: 'tool-call-delta', index: 1, id: CallId('call-123'), name: 'bash', argumentsDelta: '{"command":"ls"}' }
yield { type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('call-123'), name: 'bash', arguments: '{"command":"ls"}' } }
// 5. トークン使用量(finish の前に来る必要がある)
yield { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } }
// 6. 終了理由(最後のチャンクでなければならない)
yield { type: 'finish', reason: { kind: 'stop' } }
}
重要なルールがいくつかあり、アダプターを書く際は 1 つも破ってはいけません:
- すべての
block-startに対応するblock-endが必要です。 indexは 0 からインクリメントされ、コンテンツブロックの順序を識別します。- ツール呼び出しの
argumentsは生の JSON テキストで、一括で渡しても、複数のargumentsDelta増分に分割して渡してもかまいません。 usageはfinishの前に現れなければならず、finishは必ず最後のチャンクでなければなりません——その後に何かを送るとプロトコル違反です。
💡 例え話:
StreamChunkプロトコルは、アダプターとエージェントループの間で話される「共通語」です。あなたのベンダーが方言を話していても大丈夫——アダプターが共通語に翻訳してから話します。
2.3 標準化された障害の事実:失敗は「明確に伝える」
アダプターは必ず失敗に遭遇します:ネットワーク断、ベンダーの 429 レスポンス、未サポートのフィールド……。問題は、失敗は標準化された「事実」として伝えられなければならないということです。そうして初めてループが理解し、正しい戦略を取れます。リポジトリは 2 つの正当なエラーパスを定めています(出典:docs/user/develop/practice/llm-adapter.zh.md):
- 転送・プロトコル障害:
stream()から安定した code を持つLlmErrorをスローします。エージェントループはこのエラーとその code を保持し、診断とポリシー処理に使います——通常のErrorが自動変換されることに依存してはいけません。 - プロバイダーのインバンド障害:
finish { kind: 'error' | 'aborted' }で締めくくります。 - 未サポートのフィールド:ベンダーが
GenerateOptionsのあるフィールド(例:停止シーケンス)をサポートしない場合、LlmError(..., 'UNSUPPORTED')をスローすべきで、黙って破棄してはいけません——サポートするふりをするより、はっきり「ノー」と言う方がましです。
ドキュメントのエラーハンドリング例(出典:docs/user/develop/practice/llm-adapter.zh.md):
class HttpAdapter extends LlmAdapter {
constructor(private readonly endpoint: string) {
super()
}
async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
const response = await fetch(this.endpoint, {
method: 'POST',
headers: {
'content-type': 'application/json',
...attributionHeaders(),
},
body: JSON.stringify({ model: options.model, messages: options.messages }),
...options.signal ? { signal: options.signal } : {},
})
if (!response.ok) {
throw new LlmError(`Provider API error: ${response.status}`, 'PROVIDER_HTTP_ERROR')
}
// A real adapter parses the response and emits the complete chunk sequence.
yield { type: 'finish', reason: { kind: 'stop' } }
}
}
2 つの細部に注意してください:すべてのプロバイダー HTTP リクエストに attributionHeaders() をマージすること(呼び出し元の帰属情報をベンダーに伝える)、そして options.signal を渡すこと——これによりキャンセルがきれいに停止し、リソースが無駄に占有されません。
では「リトライ」は? 答えは意外なほどきれいです:リトライはアダプターの中に書きません。リポジトリはリトライを独立した llm-retry プラグインとして実装しており、agent/request-error イベント(イベントシステムを扱う第 9 課で出てきたウォーターフォールイベント)をリッスンし、プロバイダー単位のスコープでリトライポリシーを適用します。アダプターは障害の事実を明確に伝えるだけでよく、リトライは専用のコンシューマーに任せます——これこそシームの威力であり、次節で展開します。
3. アダプターはシーム:モデルプロバイダーを替えてもループは 1 行も変えない
第 1 章のコアアイデア 3「能力はシーム(seam)である」を覚えていますか?交換可能な能力は能力定義、プロバイダー、コンシューマーの 3 要素からなり、どの端も単独で交換できます。ctx.llm はまさに教科書的なシームです(出典:packages/llm/README.zh.md):
LLM(大規模言語モデル)seam とそのプロバイダーアダプター。
llmパッケージは Service Definition と Consumer の両方の役割を担います:抽象サービス、コンテンツブロック語彙、ストリーミングチャンクアセンブラー。プロバイダーアダプターはctx.llmに登録されます。
シームの 3 要素を LLM に当てはめてみましょう:
| シームの役割 | LLM 能力における対応物 |
|---|---|
| 能力定義(どんな形か) | StreamChunk プロトコル、GenerateOptions 型——モデル側から見える固定語彙 |
| プロバイダー(誰がやるか) | ctx.llm に登録されるアダプタープラグイン |
| コンシューマー(誰が使うか) | エージェントループ、token-meter(トークン計量)、llm-retry(リトライ)——すべて独立したコンシューマー |
これこそ「コンセント交換」式の交換可能性です:モデルプロバイダーの切り替え = アダプタープラグインを 1 つ交換 + 設定を 1 行変更。エージェントループは 1 行も変更不要。 設定は次のようになります(出典:docs/user/develop/practice/llm-adapter.zh.md):
- id: my-llm
name: './src/my-llm-adapter.ts'
config:
apiKey: !!js process.env.MY_API_KEY
models:
- my-model-v1
- my-model-v2
- id: agent-loop
name: '@deepseek-ai/dsh-agent-loop'
config:
agents:
- id: main
provider: my-llm
model: my-model-v1 # References the model registered above.
workspaceContext: false
今日は DeepSeek を使い、明日は別のベンダーに替えたい?name を別のアダプタープラグインに向け、model をそのプラグインが登録したモデル名に変えるだけです——apply の中の ctx.llm.registerAdapter(...) の 1 行が、交換作業のすべてです。
リポジトリには見比べられる配布済みアダプターが 2 つあります:packages/llm/llm-deepseek/(DeepSeek API、OpenAI 互換フォーマット)と packages/llm/llm-pi-ai/(Pi AI、まったく異なる API フォーマット)。ドキュメントの原文はこうです:「この 2 つの配布済みアダプターを比較すると、同じ一揃いの harness 規約が異なるプロバイダー SDK の上にどう実装されるかが分かる」(出典:docs/user/develop/practice/llm-adapter.zh.md)。見た目も作法もまったく違いますが、どちらも同じ共通語を話します——これがシームの意味です。
🎁 例え話:
ctx.llmは壁のコンセント、StreamChunkは統一されたプラグ規格、アダプターは「変換プラグ」です。国によってコンセントの形が違っても大丈夫——変換プラグを替えれば電化製品はそのまま使え、壁の中の電線には 1 本も触れません。
4. 自己参照 Cordis ツール:エージェントに自分のランタイムを検査・改造させる
4.1 3 点セット:検査・マウント・アンマウント
物語 2 のエージェントは、なぜ自分自身にプラグインを装着できたのでしょう?答えは自己参照 Cordis ツールセット(self-referential Cordis toolset)です——モデル向けの 3 つのツールで、操作対象は現在の DSH プロセス内のライブランタイムです。リポジトリ README の機能説明(出典:packages/extensions/tool-cordis/README.zh.md):
| ツール | 公式説明(抜粋) | 平たく言うと |
|---|---|---|
cordis_inspect | 現在のプロセスランタイムの読み取り専用レポート:サービス、生存中の全プラグイン、登録済みツール、cordis_mount の一時プラグインサブセット | まず鏡を見る:自分には今何が載っている? |
cordis_mount | モデルが書いた JavaScript を即座に評価し、どこにも永続化しない。コードはメモリ内にのみ存在し、dyn-1、dyn-2…… という識別子で追跡される一時プラグインを返さなければならない | その場で自分に新しい部品を装着する |
cordis_unmount | 一時プラグインをアンマウントし、その effect が完全に停止してから初めて戻る。Loader プラグイン、設定済みプラグイン、インストール済みプラグインは削除できない | 装着した部品を、完全にきれいになるまで取り外す |
このツール群のループには、図がぴったり合います:
运行中的智能体自己改造自己——自指 Cordis 工具 + 时空可组合性
4.2 一時プラグインの一生
「一時プラグイン」とは一体どんな存在でしょう?README はそのライフサイクルを明確に書いています(出典:packages/extensions/tool-cordis/README.zh.md):
一時プラグインは共有 DSH プロセスのメモリ内にのみ存在します。後続のターンをまたいでアクティブであり続け、同一プロセス内の他のセッションに影響を与えることもありますが、
cordis_unmount、ツールセットのアンマウント、または DSH の再起動後には消滅します。プラグインファイルを作成せず、パッケージをインストールせず、cordis.ymlや個人/プロジェクト設定を変更せず、再起動をまたいで存続せず、正式プラグインに自動昇格することもありません。
3 つの文に分解して見ましょう:
- メモリ内に生きる:ディスクに書かず、パッケージを入れず、どの設定も変更しない——ファイルシステムは微動だにしません。
- いつでも消えうる:アンマウント、ツールセットのアンマウント、DSH の再起動のいずれでも消滅し、システムが自動復旧することも決してありません。
- 昇格できない:実験の成果を残したい?エージェントは通常の開発フローをたどり、正式なローカル・プロジェクト・リポジトリプラグインとして実装する必要があります。
エージェントにとって、このツールセットの振る舞いは「草稿用紙での実験」のようなものです:自由に書き、自由に直し、自由に捨てる。本番の仕事は常に正規のフローを通ります。
4.3 なぜ明示的な有効化が必要なのか
明確にしておかなければならない点が 1 つあります:このツールセットは明示的な有効化(opt-in)が必要であり、有効化する際の慎重さは bash ツールの付与と同程度であるべきです。理由は「信頼スタンス」セクションに書かれています(出典:packages/extensions/tool-cordis/README.zh.md):
このサンドボックスはグローバル変数を隔離しますが、セキュリティ境界ではありません。……
globalThisへの書き込みはローカルに保たれますが、host realm helper によりエスケープが可能です。マウントされたプラグインはフレームワーク内部機構を含まないファサードを受け取りますが、許可されたサービスは依然としてライブランタイムに影響を与えられます。……このツールセットは bash アクセスと同様に扱うべきです。
平易に言い換えると:サンドボックスは「正直なコードの誤記」は防げても、「悪意あるコード」は防げません——マウントされたプラグインは Node に到達でき、実際のファイルシステムとネットワークにアクセスできます。だからこそ明示的な有効化が必要で、デプロイする側は bash ツールの承認と同じくらい慎重であるべきです。これは、一時プラグインが「装着でき、跡形なく外せる」ように設計されている理由も説明します:プラグインを壊してしまった場合の最悪の結果はそれをアンマウントすることだけで、プロセスの再起動は不要ですし、「システムを復旧するためのプロセス」そのものが壊れることもありません——これが、Cordis の時空間コンポーザビリティが自己参照能力のために用意する安全網です。
4.4 統合:「自己改善する」プラグイン開発者の視点
この 2 つの半分を組み合わせると、本課の完全なループが完成します。プラグイン開発者の視点から見ると、DSH はまったく新しい開発方式をサポートします:
- モデルがツールを合成する:エージェント(またはあなた)が、ある能力を実装する JavaScript を書く。
- 装着する:
cordis_mountで一時プラグインとしてマウントし、その場で新しいツールを得る。 - 使う:後続のターンで実際に呼び出し、効果を確かめる。
- ダメなら外す:
cordis_unmountで取り外し、コードを直し、新バージョンを再マウントする。
これはまさに第 2 章の論文の結論が指し示す方向です——自己進化エージェントフレームワーク:エージェントが人間の監督をほとんど受けずに、自分自身のフレームワークコンポーネントを継続的に生成・交換する。DSH のこの仕組みはその方向のプロトタイプであり、現実で最初に実用化される形は「モデルが合成した再利用可能なツール」です。私たちのようなプラグイン開発者にとって、これは追加の受け入れ基準を意味します:書くプラグインは生まれつきコンポーザブルであるべき——マウント後にツールを登録し、リスナーを提供でき、アンマウント後には effect が完全に停止して残渣を残さない。第 2 課のツール、第 3 課のサービス、第 4 課のイベント、第 5 課の設定——第 4 章で学んだすべてが、ここで 1 つの受け入れ文言に集約されます:どの端も単独で交換可能にし、装着でき、跡形なく外せるようにせよ。
要点の振り返り
- LLM アダプター =
LlmAdapterを継承しstream()を実装するクラス——プロバイダー非依存リクエストを具体的な API 呼び出しに翻訳し、レスポンスをStreamChunkチャンクに翻訳し直す。ctx.llm.registerAdapter(モデル名リスト, adapter)で登録し、モデル名はcordis.ymlのmodelに対応します。 StreamChunkプロトコルは固定の「共通語」——block-startとblock-endは対になり、indexは 0 から増加し、ツール引数は生の JSON テキストで、usageはfinishより先に、finishは必ず最後のチャンクです。- 障害は標準的な事実として伝える——転送/プロトコル障害は安定した code を持つ
LlmErrorをスローし、インバンド障害はfinish { kind: 'error' | 'aborted' }で締め、未サポートのフィールドは黙って破棄せずUNSUPPORTEDをスローする。リトライは独立したllm-retry(agent/request-errorをリッスン)が担当し、アダプターには書きません。 - アダプターはシーム——
ctx.llmは LLM seam であり、能力定義(プロトコル/型)、プロバイダー(アダプター)、コンシューマー(ループ、計量、リトライ)の 3 者が分離されています。モデルプロバイダーの切り替え = アダプタープラグインを 1 つ交換 + 設定を 1 行変更。エージェントループは変更なし。 - 自己参照 Cordis ツール(明示的有効化、信頼レベルは bash と同等)——
cordis_inspectがランタイムを検査し、cordis_mountがメモリ内一時プラグイン(dyn-1、dyn-2……)をマウントし、cordis_unmountが effect が完全に停止するまでアンマウントします。一時プラグインはディスクに書かず、昇格せず、再起動で消えます——装着でき、跡形なく外せる。これこそ「自己進化エージェントフレームワーク」の今日のプロトタイプです。
🎓 第 4 章修了の言葉:第 1 課で
name/inject/Config/applyのプラグイン骨格を組み立て、ツールを書き、サービスを書き、イベントをリッスンし、設定を公開し、そして本課で新しいモデルを接続し、エージェントに自分自身を改造させる——この章を学び終えたあなたは、もう 1 つの完全なことを独力で成し遂げられるはずです:DSH リポジトリでシーム(ctxのキー)を 1 つ見つけ、その固定語彙に沿ってプラグインを書き、登録し、設定し、動かす。エージェントにツールを追加するにせよ、新しいモデルベンダーを接続するにせよ、実行時にマウント・アンマウントできるコンポーザブルなプラグインを書くにせよ、あなたはすでにすべての手段を握っています。次章「コミュニティと発展」では、あなたの作品をより多くの人に使ってもらう方法を話しましょう。
セルフテスト · 実践応用
回答を終えたら「解答を送信」をクリックすると、正誤と解説を確認できます。
