自社開発モデル、サードパーティベンダーのエンドポイント、あるいはローカル推論サービスを DeepSeek Harness(以下 dsh)に接続しようとするとき、実際に書く必要があるものはただ一つです。LlmAdapter 抽象クラスを継承し、stream() メソッドを実装するアダプタークラスです。これは、あなたの提供元の API 呼び出しを、Harness が消費できる統一ストリーミングプロトコル StreamChunk に変換する役割を担い、さらに ctx.llm.registerAdapter() によって提供元のルーティングをアダプターインスタンスにバインドします。こうすることで、agent-loop は提供元に依存しないインターフェースに直面し、背後が DeepSeek であれ、他のクラウドベンダーであれ、あるいはあなたの機房で量子化重みを動かしているマシンであれ、常に規範化された非同期チャンク列だけを見ることになります。本記事は二つのキーワードを軸に展開します。LLM アダプターと StreamChunk です。前者は「どう接続するか」を解決し、後者は「接続後に何を吐き出すか」を解決します。読み終えれば、登録可能で、ストリーミング可能で、終了処理も可能な完全なアダプターを自力で書き上げられるようになります。
LlmAdapter 抽象クラス:継承関係、stream() シグネチャと AsyncIterable 契約
まず最も核心的な一文を最初に置きます:LLM アダプターとは、LlmAdapter を継承し stream() メソッドを実装したクラスです。これは二段階の翻訳作業を担います——Harness が発行する、提供元に依存しないリクエストを、具体的な提供元フォーマットの API 呼び出しに変換する。そして提供元が返すレスポンスを、Harness 自身のチャンク構造 StreamChunk に変換し戻す。この両端を引き受けるからこそ、agent-loop は安心して統一インターフェースを消費でき、背後がどこの API なのかを一切知る必要がありません。
このクラスを書くには、まずインポートパスを整理しなければなりません。抽象クラス自体、およびそのメソッドシグネチャで使われる二つの型 GenerateOptions と StreamChunk は、すべて同じパッケージ @deepseek-ai/dsh-llm に由来します。つまり、あなたのアダプターファイルの冒頭は基本的にこうなります:
import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'
import type { Context } from '@deepseek-ai/cordis'
import Schema from '@deepseek-ai/schemastery'このうち LlmAdapter は値インポート(これを extends するため)であり、GenerateOptions と StreamChunk は型インポートです。type 修飾子を付けることで、実行時に無意味な参照が残るのを避けられます。Context は @deepseek-ai/cordis に由来し、プラグイン体系におけるコンテキストオブジェクトで、後述の ctx.llm はここから取得します。Schema は @deepseek-ai/schemastery に由来し、プラグイン読み込み時に設定を検証する役割を担います。三つのパッケージの責務境界は明確で、互いに混ざらないようにしてください。
次に stream() のシグネチャです。これは抽象クラス全体で唯一、実装が必須となるメソッドです:
async *stream(options: GenerateOptions): AsyncIterable<StreamChunk>分解すると三つの要点があります。第一に、引数は GenerateOptions であり、各社 SDK 自身のリクエストオブジェクトではありません。GenerateOptions には Harness 視点での一回の生成リクエストが入っており、最も典型的なのは options.messages——統一されたロール(system / user / assistant / tool など)で組織された対話メッセージ配列です。アダプターが最初に行うことは、これをあなたの提供元が求めるフォーマットに翻訳することです。第二に、戻り値は AsyncIterable<StreamChunk> です。これが Promise でも配列でもなく、非同期イテラブルなオブジェクトである点に注意してください。これは消費側(agent-loop)が for await (const chunk of adapter.stream(options)) の形で一片ずつ取得し、届くそばから処理できることを意味し、本質的にストリーミングをサポートします。第三に、メソッドに * が付いていることから、async * で非同期ジェネレーターとして宣言され、内部で yield を使って StreamChunk を一片ずつ送り出すことが分かります。この三つが共同で一つの契約を構成します:呼び出し側は、あなたの内部で何回ネットワーク往復があり、どうパケットを組み立てるかを気にせず、順序どおりに一連のチャンクを受け取るだけです。
ここで非常に踏みやすい落とし穴があります:手抜きをして、レスポンス全体を溜め込んでから一度に yield してはいけません。技術的にはもちろん、完全なレスポンスが手元に届いてから yield で block-start、text-delta、block-end を一気に流し込むこともでき、ストリーミングプロトコルは構造的には依然として成立しますが、ストリーミングがもたらす初字遅延の利点を完全に失い、ユーザー体験は通常の同期リクエストへと退化してしまいます。アダプターの価値はまさに、ネットワーク層に個別に到着する増分を、リアルタイムに yield で送り出すことにあります。実装では、提供元の SSE(Server-Sent Events)やチャンク応答に対してブロックごとに読み取り、増分を一つ読むたびに直ちに delta チャンクを一つ yield し、無駄なバッファリングを行わないことを推奨します。
もう一つ、シグネチャのレベルで強調すべき細部がある:AsyncIterable<StreamChunk> は「for await で消費可能」を意味し、「StreamChunk の配列を返す」ことではない。もし async stream(...): Promise<StreamChunk[]> と書いてしまうと、型の上で抽象クラスの契約と一致しなくなり、消費側の for await も機能しなくなる。TS コンパイラの型チェックがここで止めるので、回避しようとしてはいけない。また、ジェネレータ関数が一度例外を投げると、その例外は for await の消費点に沿って上位へバブルアップする。これはまさに、後で転送エラー処理を行うための自然な切入点となる——yield の合間で提供側 SDK のエラーを捕捉し、適切なエラーセマンティクスに変換してから投げ直すことができる。生の HTTP エラーコードを agent-loop に直接漏らすのではなく。
階層関係を図にするとより明確になる:最上位は agent-loop で、「提供側に依存しないストリーミング生成サービス」という能力のみを認識する。中間は ctx.llm レジストリで、LlmAdapter という抽象契約を維持しており、いわば接縫(seam)に相当する。最下層は各社のアダプタで、それぞれ異なるフォーマットの API に対応する。あなたの作業はすべてこの最下層のマスで完結し、インターフェースさえ守れば、上の二層はまったく変更する必要がない。このような階層化の利点は、新しい提供側を追加する際の変更範囲が 1 ファイル、1 回の登録呼び出しに限定され、agent-loop に対してゼロ侵襲であることだ。
ctx.llm.registerAdapter(['my-provider'], adapter):ルート登録とプロバイダのバインディング
クラスを書き終えたら、Harness にその存在を知らせる必要があります。登録操作は 1 行の呼び出しで完了します:
ctx.llm.registerAdapter(['my-provider'], adapter)このメソッドの 2 つの引数はそれぞれ役割を持ちます。第 1 引数はプロバイダのルートリストです。配列であることに注意してください。例では ['my-provider'] ですが、この配列には複数の文字列を入れることができ、同じアダプタインスタンスが複数のプロバイダ識別子を同時に担当することを意味します。これはエイリアスやマルチテナントのシナリオで非常に有用です。たとえば my-provider と my-provider-eu という 2 つの名前を同じアダプタ実装にルーティングしたい場合、['my-provider', 'my-provider-eu'] と書くだけで済み、2 回インスタンス化する必要はありません。第 2 引数はアダプタインスタンスです。クラスではなくインスタンスであることに注意してください。つまり、まず new でインスタンスを生成し、apiKey などのコンストラクタ引数を渡してから、そのインスタンスをレジストリに渡す必要があります。
なぜ「ルートリスト + インスタンス」という組み合わせを登録するのでしょうか?なぜなら ctx.llm レジストリの本質はプロバイダ識別子からアダプタインスタンスへのマッピングテーブルだからです。agent-loop が生成を開始する必要があり、リクエストで特定のプロバイダが指定されている場合、レジストリは名前でテーブルを検索し、ヒットしたアダプタの stream() を使用します。これにより、agent-loop と具体的な API の間の結合が完全に切断されます。DeepSeek がどのようなプロトコルを使っているかを知る必要も、自社開発モデルがどのようなプロトコルを使っているかを知る必要もなく、必要なのは文字列識別子だけです。
登録操作は通常、プラグインの apply 関数内に置き、inject で依存関係を宣言して、ctx.llm が準備できてから登録を行うようにします。プラグインのエクスポート全体は次のようになります:
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)
}ここにはいくつか展開する価値のあるエンジニアリング上の詳細があります。inject = ['llm'] は省略できません。これはプラグインシステムに対して、このプラグインが llm という名前のサービスに依存していることを伝え、ctx.llm が利用可能になってから apply を呼び出すように指示します。この行を忘れ、さらにロード順が悪い場合、apply 実行時に ctx.llm が undefined である可能性があり、登録呼び出しが起動段階で直接エラーを投げ、調査も非常に厄介になります。providers 配列はハードコードではなく設定から来ます。例では config.providers を registerAdapter の第 1 引数に直接渡しており、これにより利用者は cordis.yml でこのアダプタがどのプロバイダ名を担当するかを自分で決めることができ、同じプラグインを異なる設定で複数回ロードすれば複数のルートグループを掛けることができます。apiKey はコンストラクタ経由で注入します。アダプタインスタンスはキーをプライベートフィールドとして保存し、後続で stream() 内からプロバイダ API を呼び出す際に取り出して使用し、機密情報があちこちに散らばるのを避けます。
設定の検証は Schemastery に任せます。公式スケルトンでは同名の interface Config と const Config schema をエクスポートしており、書き方は次のとおりです:
export interface Config {
apiKey: string
providers: string[]
}
export const Config: Schema<Config> = Schema.object({
apiKey: Schema.string().required(),
providers: Schema.array(Schema.string()).required(),
})interface はコンパイル時の型を担当し、Schema は実行時の検証を担当する。同名で共存させるのがこの体系の標準的なやり方だ。2つのフィールドに required() が付いている点に注意してほしい。apiKey が欠けていればアダプタは API を呼べず、providers が空配列なら誰も使えないルートを1つ登録したのと同じことになる。どちらも設定エラーであり、最初の実リクエストまで露呈させず、ロード段階で弾くべきだ。この「早期失敗」の設計は、オンラインでのトラブルシューティング時間を大幅に節約できる。
次に、登録関連の要素の責務を表で整理しておこう:
| 要素 | 由来 | 役割 | 典型的な値 / 形式 |
|---|---|---|---|
| LlmAdapter | @deepseek-ai/dsh-llm | 抽象基底クラス。stream() の実装を必須とする | class MyAdapter extends LlmAdapter |
| stream() | あなたのアダプタクラス | リクエストを提供側の呼び出しに変換し、レスポンスをチャンクに変換する | async *stream(options): AsyncIterable<StreamChunk> |
| registerAdapter 第1引数 | config.providers | 提供側のルーティングリスト。どの名前がこのアダプタにヒットするかを決める | ['my-provider'] |
| registerAdapter 第2引数 | new MyAdapter(apiKey) | 実際にリクエストを処理するアダプタインスタンス | adapter インスタンスオブジェクト |
| inject | プラグインのエクスポート | llm サービスへの依存を宣言し、ctx.llm の準備を保証する | ['llm'] |
| Config + Schema | @deepseek-ai/schemastery | ロード時に apiKey と providers を検証する | 両フィールドとも required() |
さらに、そのまま貼り付けて実行できる完全なアダプタの骨組みを挙げる。クラス、設定、登録の3部分をつなげたものだ(stream() の内部はまず3ステップのコメントでプレースホルダにしておき、次の節で展開する):
// 文件路径: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 schema,加载时校验配置
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)
}もう一つ見落としがちな点があります:登録のタイミングは一度だけです。apply は Plugin のロード時に一度だけ呼び出され、Adapter インスタンスも一度だけ登録されます。リクエストが来るたびに再登録されるわけではないと考えてください。また、stream() の中で逆にレジストリを変更しようとしないでください——Adapter は翻訳と生成だけを担当し、ルーティングはレジストリが起動段階で一度だけ処理します。責務を明確に分離しておけば、多くのことは混乱しません。
GenerateOptions からプロバイダーリクエストへ:メッセージ形式変換の3ステップ注釈
stream() の内部に戻ります。公式スケルトンには3行のコメントが残されており、まさにアダプターの完全なワークフローを描き出し、その唯一の責務境界を画定しています:
- Convert options.messages to the provider format.——Harness の統一形式のメッセージを、プロバイダーが要求するリクエストボディに変換します。
- Call the streaming API.——あなたの apiKey を携えて、プロバイダーのストリーミングインターフェースを呼び出します。
- Convert the response into StreamChunk values.——プロバイダーが返してくるレスポンスを、一片ずつ StreamChunk に翻訳します。
この3ステップがアダプターの全作業であり、一つも欠かせず、一つも余分であってはなりません。なぜ「唯一の責務境界」と言えるのか?なぜなら、この3ステップの外にあるすべて——セッション状態管理、ツールオーケストレーション、リトライ戦略の上位判断、コンテキスト圧縮——は Harness の仕事であり、アダプターが気を揉むべきではないからです。アダプターは薄ければ薄いほど、保守しやすく、再利用しやすくなります。逆に、アダプターにビジネスロジックを詰め込んでしまうと、「プロバイダーを変えても1ファイルを変更するだけ」という爽快さを失い、アダプターは誰も触りたがらない巨大クラスへと徐々に変貌していきます。
第1ステップの変換は、想像以上に細々としていることがよくあります。options.messages 内のロール命名やコンテンツ構造は Harness の統一規約に従っていますが、プロバイダーごとにこの規約の表現は異なります:あるものは system プロンプトを独立したフィールドとして抽出し、あるものはそれを messages の最初の要素として許可します。あるものはマルチモーダルコンテンツを type マーカー付きの配列に包むことを要求し、あるものは純粋な文字列しか受け付けません。あるものはツール呼び出し履歴に対して追加の schema 要件を持ちます。あなたの変換関数は、これらの差異を一つずつ埋めていく必要があります——注意すべきは、埋めることであり、捨てることではありません。プロバイダー形式で表現できる情報は、そのまま忠実にマッピングしてください。どうしても表現できないものは、少なくとも変換後のリクエストが意味的に依然として自己整合的であることを保証しなければなりません。
第2ステップでストリーミング API を呼び出す際、いくつかのエンジニアリング上の要点を注意喚起しておく価値があります。まず、必ずプロバイダーのストリーミングモードを使用してください。一度に完全なレスポンスを待つのではなく。多くのプロバイダーの SDK は stream スイッチを提供するか、非同期イテレーション可能なストリームオブジェクトを返します。これは私たちの AsyncIterable と天然に契合します。次に、認証は URL ではなくリクエストヘッダーに置いてください。キーがログやプロキシ記録に現れるのを避けるためです。さらに、プロバイダーのタイムアウトとリトライのセマンティクスを尊重してください。ただし、アダプター内で無限リトライを行わないでください——無限リトライは一度の失敗を永遠にぶら下がるリクエストに変え、agent-loop も一緒に引きずり込んでしまいます。リトライするなら、回数を限定し、接続確立段階でのみリトライすることを推奨します。ストリームがすでに有効な増分を吐き出し始めた後にリトライしないでください。そうしないと重複した分片を生成してしまいます。
第3ステップは StreamChunk への翻訳であり、次回で最も細かく説明する部分ですが、ここではまず一つの不変条件を示します:プロバイダーのストリーミング形式がどれほど多様であっても、最終的に出力するものは厳密に順序付けられた StreamChunk のシーケンスでなければなりません。内部的には任意の一時構造でバッファリング、組み立てを行って構いませんが、yield して外に出した瞬間、順序と構造はプロトコルに適合していなければなりません。この「内部は自由、出口は厳格」という制約が、複雑さをアダプター内に閉じ込めます。
3ステップと典型的な故障を対照して見ると、直感を養いやすくなります:
| ステップ | 核心アクション | 入力 | 出力 | よくある故障と対策 |
|---|---|---|---|---|
| 1. メッセージ形式変換 | 統一メッセージをプロバイダーのリクエストボディにマッピング | options.messages | プロバイダーのリクエストオブジェクト | ロール/マルチモーダル構造の不一致;フィールドごとに明示的にマッピングし、全体の透過的な受け渡しを避ける |
| 2. ストリーミング API の呼び出し | 認証付きでストリーミングリクエストを発行し、レスポンスストリームを読み取る | リクエストオブジェクト + apiKey | プロバイダーのレスポンスストリーム / イベントストリーム | 非ストリーミングインターフェースの誤用、無限リトライ、大きなバッファ;ストリーミングを有効化、リトライを限定、読みながら逐次プッシュ |
| 3. StreamChunk への変換 | レスポンスイベントを分片に翻訳して yield する | プロバイダーのレスポンスストリーム | AsyncIterable<StreamChunk> | 分片の順序錯乱、バッファリング過多;block-start/delta/block-end に厳密に従って産出する |
この節を一言で締めくくると:アダプターは翻訳だけを行い、意思決定は行わない。この3ステップをしっかりこなせば、それは合格だ。この3ステップを、たった3ステップだけになるまで磨き上げれば、それは優秀だ。続く3つの節では、第3ステップに集中して取り組む——StreamChunk とは一体どのようなものなのか。
StreamChunk プロトコル入門:block-start / delta / block-end の三態ラッピング
アダプターの stream() は一体どんなデータを出力するのか?その答えが StreamChunk であり、厳密な順序を持つ分割プロトコルです。これは Harness とアダプター間のストリーミング規約であり、各分割は小さなオブジェクトで、type フィールドで自身が何であるかを示します。このプロトコルを理解する上で最も基本的なルールは、1つのコンテンツブロックはまず block-start で始まり、途中は delta で増分転送し、最後に block-end で終了するということです。
この三態ラッピングは StreamChunk の骨格です。block-start は「ブロックを開始する」ことを宣言し、2つの重要な情報を運びます:index(このブロックの番号)と blockType(このブロックの種類)です。delta は増分充填で、一度に少量の新しい内容だけを運び、何度も出現できます。block-end は締めくくりで、index に加えて完全なブロック内容、つまり以前のすべての増分を結合した最終形態を伴います。なぜ締めくくりに完全なブロックをもう一度渡すのか?それは消費側がパフォーマンス上の理由で自ら結合せず、block-end 内の完全なオブジェクトを直接使う可能性があるからです。プロトコルは増分パスと完全パスの両方をサポートし、どちらの道も通じます。これは非常に気配りの利いた設計です。
この三態を貫くのは index です。それはシーケンス全体で一貫して保たれます:あるブロックの block-start が index 0 を使ったなら、その後のすべての delta の index も 0 でなければならず、最後の block-end の index も必ず 0 でなければなりません。index の役割は異なるブロックの増分を区別することです——実際のシナリオではブロック同士が交互に出現することがあり、index によって初めて消費側は「この delta がどのブロックに属するか」を知ることができます。
完全なシーケンスの締めくくりにはさらに2つの特殊な分割があります:usage と finish です。すべてのコンテンツブロックが終了した後、まず usage を送って token 使用量を報告し、次に finish を送って終了理由を宣言します。finish は最後の分割であり、その reason フィールド内の kind が stop なら正常終了、tool-calls ならモデルがツール実行を要求していることを示します。これら2つの分割の順序は逆にできません。その理由は次の節と最後の節でも再度強調します。
添付図を上から下へ見ていくと、シーケンスのリズムが一目瞭然です:まずテキストブロック(block-start → text-delta × 2 → block-end)、次にツール呼び出しブロック(block-start → tool-call-delta → block-end)、最後に usage と finish です。どんな生成でも、内容の多少にかかわらず、このテンプレートに当てはめることができます。以下に最小だが完全に実行可能な分割シーケンスの例を示します。上記の構造を直接 yield しており、対照実装の基準として使えます:
// 文件路径:示例代码,演示一次完整的 chunk 序列
import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
async function* exampleChunks(): AsyncIterable<StreamChunk> {
// 1. Start each content block with block-start.
// 开启一个文本块,index 为 0
yield { type: 'block-start', index: 0, blockType: 'text' }
// 2. Stream text through text-delta.
// 文本增量,可拆成多个分片
yield { type: 'text-delta', index: 0, text: 'runoob' }
yield { type: 'text-delta', index: 0, text: ' 教程' }
// 3. End each content block with block-end and the complete block.
// 用完整块结束,index 与 block-start 一致
yield {
type: 'block-end',
index: 0,
block: { type: 'text', text: 'runoob 教程' },
}
// 4. Tool-call block.
// 开启一个工具调用块,index 为 1
yield { type: 'block-start', index: 1, blockType: 'tool-call' }
// 工具名与参数增量,id 用 CallId 工厂生成
yield {
type: 'tool-call-delta',
index: 1,
id: CallId('call-123'),
name: 'bash',
argumentsDelta: '{"command":"echo runoob"}',
}
// 用完整块结束,arguments 是拼好的 JSON 文本
yield {
type: 'block-end',
index: 1,
block: {
type: 'tool-call',
id: CallId('call-123'),
name: 'bash',
arguments: '{"command":"echo runoob"}',
},
}
// 5. Token usage.
// 报告 token 用量,必须在 finish 之前
yield { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } }
// 6. Finish reason.
// 最后一个分片,声明结束原因
yield { type: 'finish', reason: { kind: 'stop' } }
// Alternatively, { kind: 'tool-calls' } requests tool execution.
}このシーケンスを分片タイプごとに整理して対照表にまとめると、アダプタを書くときにそのまま checklist として使えます:
| 分片タイプ | 必須フィールド | 役割 | 出現回数 |
|---|---|---|---|
| block-start | type, index, blockType | コンテンツブロックを開始する | 各ブロックにつきちょうど1回 |
| text-delta | type, index, text | テキストブロックの増分コンテンツ | 各テキストブロックにつき1回以上 |
| tool-call-delta | type, index, id, name, argumentsDelta | ツール呼び出しブロックの増分パラメータ(生の JSON テキスト) | 各ツール呼び出しブロックにつき1回以上 |
| block-end | type, index, block | コンテンツブロックを終了し、完全なブロックを渡す | 各ブロックにつきちょうど1回 |
| usage | type, usage(inputTokens / outputTokens) | token 使用量を報告する | 締めくくり時に1回 |
| finish | type, reason(kind) | 終了理由を宣言し、シーケンスを終結させる | 最後に1回だけ、かつ一度のみ |
三つの鉄則は繰り返し心に刻む価値があります:第一に、各ブロックは必ず block-start で始まり、block-end で終わらなければならず、その間に少なくとも一つの delta が必要です。決して start だけを送って end を送らないでください。それは消費側に永遠に来ない締めくくりを待ち続けさせ、多くの実装ではリクエストのハングとして直接現れます。第二に、index は同一ブロック内で厳密に一致し、異なるブロック間では重複してはなりません。実践的には単純にインクリメントすればよく、0、1、2……で問題ありません。第三に、usage と finish はすべてのブロックの block-end の後に現れなければなりません。これらはグローバルな締めくくりであり、特定のブロックの締めくくりではないため、ブロックの途中に混ざるとセマンティクスが完全に混乱します。
テキストブロックとツール呼び出しブロック:2種類の blockType それぞれの start / delta / end の進み方
StreamChunk には2種類のコンテンツブロックがあり、cubeType(つまり blockType フィールド)で区別されます:text と tool-call です。これらは構造上同じ三態ラッパーを共有しつつ、それぞれが独立して完全なライフサイクルを一通り実行し、互いに交錯しません。この点は極めて重要です——テキストブロックが途中まで進んだところにツール呼び出しを挟んでまたテキストに戻る、ということではなく、「あなたが自分の start / delta / end を走らせ終わってから、私が自分の start / delta / end を走らせる」という関係です。図中の2つの区間がまさに標準的な前後関係です。
まずテキストブロックを見ます。そのライフサイクルは:block-start(blockType は text、index を記録)→ 複数の text-delta(各々が小さな text を伴う)→ block-end(完全なテキストブロックを伴う)。テキストブロックの完全な塊は { type: 'text', text: 'runoob 教程' } の形をとり、text フィールドが連結後の最終テキストです。例ではこれを2つに分割しています:まず 'runoob'、次に ' 教程'、連結するとちょうど 'runoob 教程' になります。
次にツール呼び出しブロックを見ます。そのライフサイクルは同型ですが、内容フィールドが異なります:block-start(blockType は tool-call、index を記録)→ 複数の tool-call-delta(各々が id、name、argumentsDelta を伴う)→ block-end(完全なツール呼び出しブロックを伴う)。完全な塊は { type: 'tool-call', id: CallId('call-123'), name: 'bash', arguments: '{...}' } の形をとります。ここで arguments は連結済みの完全な JSON テキストであり、delta 段階の argumentsDelta はその増分の一部にすぎないことに注意してください。
2種類のブロックを並べて対照すると、差異と共通点が明確になります:
| 次元 | テキストブロック(blockType: text) | ツール呼び出しブロック(blockType: tool-call) |
|---|---|---|
| 開始分片 | block-start, blockType: 'text' | block-start, blockType: 'tool-call' |
| 増分分片タイプ | text-delta | tool-call-delta |
| 増分が運ぶフィールド | text(テキスト断片) | id、name、argumentsDelta(生の JSON テキスト増分) |
| 完全な塊のフィールド | type、text | type、id、name、arguments |
| 識別子 | 独立した id はなく、index で区別 | id があり、CallId ファクトリで生成 |
| 宿主の動作をトリガーするか | いいえ、単なる内容 | はい、finish reason が tool-calls とともに実行を要求 |
なぜテキストブロックとツール呼び出しブロックは混ざらず、それぞれが完全なライフサイクルを一通り実行する必要があるのか?それは両者の消費セマンティクスが完全に異なるからです。テキストブロックが生み出すのは人に見せる内容であり、消費側は受け取りながらレンダリングできます;ツール呼び出しブロックが生み出すのはシステムが実行するための構造化命令であり、消費側は通常 block-end まで待ち、完全かつ正当な arguments を取得してからでないと解析・実行できません。両者を独立したブロックに分けることで、消費側は「いつツールを実行してよいか」を最も単純なルールで判断できます——ツール呼び出しブロックの block-end を見ればよく、テキストがまだ後ろで流れ続けることを心配する必要はありません。
エンジニアリング上最も犯しやすい誤りは交錯産出です:モデルは一度の生成でまず2文の説明を述べ、それからツール呼び出しを発起することがあり、一部の提供元が返すイベントストリームもテキストとツール引数を交互に押し出してくることがあります。あなたのアダプタがすべきことは、内部でそれらを正しい位置に収め、yield で出すときに「テキストブロックが完全に終了 → ツール呼び出しブロックが完全に開始」という綺麗な順序になるよう保証することです。手を抜いて、交錯した増分を到着順のまま直接 yield で出すと、消費側はテキストブロックの delta とツール呼び出しブロックの delta が互いに挿入し合うのを見ることになり、軽ければレンダリングの乱れ、重ければツール引数が不完全にしか連結されません。
では、アダプタ内でどのように位置合わせを行うのか?堅実な方法は、stream() の内部で 2 つのバッファを維持することです。テキストバッファとツール呼び出しバッファです。テキストイベントを読み取ったらテキストバッファに追加し、即座に text-delta を 1 つ yield します。ツールパラメータイベントを読み取ったらツールバッファに追加し、即座に tool-call-delta を 1 つ yield します。ただし注意が必要です。もし 2 種類のイベントが本当に交互に到着するなら、yield する前にまずブロックの境界を決定しなければなりません。より堅牢な戦略は、新しいブロックの開始を遅延させることです。あるブロックが確実に開始されており、かつ前のブロックが確実に終了している場合にのみ、次の block-start を yield することを許可します。これには少し多めの状態をバッファリングする必要がありますが、その代わりに出力シーケンスの厳密な順序が得られます。「テキストが先、ツール呼び出しが後」という順序で返すほとんどのプロバイダにとっては、そのまま順次処理すれば十分であり、実際にテストして交互到着が確認された場合にのみ、より細かいバッファリング戦略を有効にすればよいのです。
もう 1 つの細かい点は index の割り当てです。例ではテキストブロックに 0、ツール呼び出しブロックに 1 を使っており、これが最も直感的な増分戦略です。これは 0 から始まることを要求するものではなく、同一シーケンス内で一意であり、ブロックの start / delta / end と一貫していることだけを要求します。stream() の冒頭でカウンタ let nextIndex = 0 を維持し、新しいブロックを開始するたびに現在の値を取得してからインクリメントすることを推奨します。そうすれば手動管理によるミスを避けられます。
text-delta の増分連結と tool-call-delta の argumentsDelta セマンティクス
delta 分割の核心的な考え方は「化整為零」です。テキストに関しては、text-delta は複数の断片に分割でき、消費側がそれらを連結して完全なテキストにします。例では 'runoob 教程' を 'runoob' と ' 教程' の 2 つの断片に分割していますが、これはあくまでデモ用です。実際のシナリオでいくつの断片に分割されるかは、提供側がどうプッシュするか、そしてあなたがどう読むかによって決まります。1 つの中国語の返信が、提供側のストリーム内で数十、場合によっては数百の増分に分割されることがあります。あなたは 1 つ読むたびに 1 つ yield し、消費側は index の順序で収集し、単純な文字列の加算で全文を復元できます。この「生産者が分割を担当し、消費者が結合を担当する」という役割分担が、ストリーミング体験の基盤です。最初の文字ができるだけ早く現れ、後続の内容が順次到着します。
ここでよくある誤解を 1 つ明確にしておきます。text-delta の text は「テキスト全体の N 番目の文字」ではなく、「新しく追加された部分」です。消費側が行うべきは累加(append)であり、置換ではありません。もしアダプタ側で誤って累計後の完全なテキストを各 text-delta に詰め込んでしまうと、消費側がさらにそれを累加し、結果は 'runoobrunoob 教程' のような重複連結になってしまいます。したがって、アダプタ側で毎回 yield するものは必ず純粋な増分でなければなりません。判定方法は簡単です。すべての text-delta の text を順に加算した結果は、ちょうど block-end 内の完全なブロックの text と一致するはずで、1 文字多くても少なくてもいけません。
ツール呼び出し側では、セマンティクスをさらに厳密にする必要があります。tool-call-delta の argumentsDelta は、生の JSON テキストの増分です。3 つのキーワードに注意してください。「生の」「JSON テキスト」「増分」です。これは解析後のオブジェクトではなく、あるフィールドの部分的な値でもなく、最終的な arguments という JSON 文字列の連続した 1 つのスライスです。例では argumentsDelta は '{"command":"echo runoob"}' で、ちょうど 1 つの断片で全体が与えられています。しかし実際のシナリオでは、'{"comm'、'and":"echo'、' runoob"}' のように複数の断片に分割されたり、エスケープシーケンスの途中で切れたりする可能性が極めて高いです。アダプタはそれを解析する必要はなく、また解析すべきでもなく、ただそのまま yield するだけです。本当に解析が必要になるのは block-end の後です。その時点では arguments フィールドに、すでに連結済みの完全な JSON テキストが入っています。
なぜツールパラメータは「解析済みオブジェクトを直接渡す」のではなく「ストリーミング JSON テキストの増分」として設計されているのでしょうか。なぜならストリーミングのシナリオでは、提供側自体が token ごとに JSON 文字を吐き出しており、吐き終わる前に有効なオブジェクトとして解析することはそもそも不可能だからです。プロトコルはこの物理的事実に従い、増分段階では文字列の運搬だけを担当させ、連結と解析はすべて収尾まで延期します。これによりアダプタはほぼゼロの解析ロジックで済み、運搬だけを行えます。
2 種類の delta のセマンティクスを並べて比較します。
| 比較項目 | text-delta.text | tool-call-delta.argumentsDelta |
|---|---|---|
| 内容の本質 | 純粋なテキスト断片 | 生の JSON テキスト断片 |
| 直接解析可能か | 解析不要、そのまま表示または連結 | 単独では解析不可、完全に連結した後に解析する必要がある |
| 消費側の動作 | 文字列の累加 | 文字列の累加、完全な JSON テキストを得てから JSON.parse |
| block-end との関係 | 累加結果は block.text と等しい | 累加結果は block.arguments と等しい |
| アダプタの責務 | 増分を運搬し、累計値を重複送信しない | 増分を運搬し、部分的な JSON 解析を絶対に行わない |
連結の一貫性はどうやって保証するのか?二つの実践的な提案を挙げる。第一に、delta 段階では新規内容のみを yield し、ブロックレベルの完全性は block-end で担保する。何らかの理由である delta で切りすぎたり切り足りなかったとしても、block-end 内の完全なブロックが正しければ、消費側は完全なブロックを基準にすることで修正できる。これもプロトコルが delta と完全なブロックの両方を提供する価値の一つである。第二に、開発期にアサーションを追加する:同一 index 下のすべての delta のテキストを連結したものと、block-end の block.text(または block.arguments)を比較し、必ず等しくなければならない。このアサーションを単体テストに入れれば、連結系のバグの绝大多数を防げる。
また、一つの境界ケースを注意喚起しておく:argumentsDelta が空文字列の場合、あるいは tool-call-delta がそもそも存在しない場合はどうするか?あるツール呼び出しに引数が一切ない場合でも、block-start は送るべきであり、その後(delta を省略してもよいし、argumentsDelta が空の delta を送ってもよい。これは提供側と消費側の取り決め次第である)、最後に block-end で arguments が空の JSON オブジェクトテキスト(例えば '{}')である完全なブロックを提示する。重要なのは block-start と block-end が必ずペアで出現することであり、間に delta があるかどうかはむしろ二次的である。ツール呼び出しの完全性はこの start / end のペアによって保証され、delta の数によって保証されるのではない。
CallId('call-123') ファクトリとツール呼び出しブロックの id / name / arguments フィールド
ツール呼び出しブロックには 3 つの重要なフィールドがあり、個別に取り上げて明確に説明する価値があります:id、name、arguments。これらはそれぞれ 3 つの問いに答えます——今回の呼び出しは誰か、どのツールを呼び出すのか、パラメータは何か。
まず id について。例では CallId('call-123') を使用していますが、これは通常の文字列ではなく、CallId ファクトリを通じて生成された識別子であることに注意してください。id は必ず CallId ファクトリで生成する必要があり、勝手に裸の文字列を入れてはいけません。ファクトリの役割は、プロバイダーが返す生の呼び出し識別子を、プロトコルが認める CallId 型にラップすることです。これにより、後続で会話履歴内でこのツール呼び出しを参照する際や、ツール実行結果をモデルに返却する際に、識別子の型が一貫し、型レベルで「文字列 vs CallId」の不一致が発生しません。stream() 内では、プロバイダーが提示した呼び出し id をそのまま CallId に渡すべきであり(例では 'call-123')、プロバイダーと無関係なランダム値を独自に作ってはいけません——そうしないとツール結果を返却する際に対応が取れなくなる可能性があります。
次に name について。これはツール名で、例では 'bash' です。このフィールドは delta 段階と block-end 段階の両方に現れ、アダプタは両方で一貫していることを保証する必要があります。ツール名は通常モデル出力に由来し、そのまま透過的に渡す必要があり、アダプタ内でリネームやマッピングを行ってはいけません——ツール名はホスト側に登録されたツールテーブルと一致している必要があり、名前を変えるとツールが解決できなくなります。name が断片的に到着する場合(例えばモデルが最初の数文字を先に吐き出した場合)もありますが、その場合は block-end の完全なブロック内の name を基準とし、delta 段階で含められるなら含め、完全に含められなくても問題ありません。重要なのは完全なブロックが正確であることです。
最後に arguments について。これは block-end の完全なブロック内にのみ「組み立て済みの JSON テキスト」として現れ、例では '{"command":"echo runoob"}' です。これは文字列であり、オブジェクトではないことに注意してください——プロトコルは生の JSON テキストを保持し、解析権を消費側に委ねます。このようにする利点は、アダプタが JSON 解析に関与せず、各プロバイダーのパラメータにおける微妙なフォーマット差異(例えば引用符を補完するか、末尾カンマを許可するかなど)を正規化する必要もないことです。消費側は完全なテキストを取得した後に自身で解析し、解析に失敗した場合もモデル出力に明確に帰因でき、アダプタのせいにはなりません。
3 つのフィールドの制約をチェックリストに整理します:
- id:必ず
CallId(...)ファクトリで生成する;delta と block-end で一貫させる;内容はプロバイダーの生の呼び出し識別子に由来すべきで、独自に作らない。 - name:ツール名文字列;delta と block-end で一貫させる;そのまま透過的に渡し、リネームしない。
- arguments:完全な JSON テキスト文字列で、block-end の完全なブロック内にのみ現れる;すべての argumentsDelta の累積結果と一致させる。
以下にツール呼び出しブロックのこれら 3 つのフィールドの記述対照を示します。そのままコピーできます:
// delta 阶段:携带 id、name 与参数增量
const callId = CallId('call-123')
yield {
type: 'tool-call-delta',
index: 1,
id: callId,
name: 'bash',
argumentsDelta: '{"command":"echo runoob"}',
}
// block-end 阶段:给出完整块,arguments 是拼好的 JSON 文本
yield {
type: 'block-end',
index: 1,
block: {
type: 'tool-call',
id: callId,
name: 'bash',
arguments: '{"command":"echo runoob"}',
},
}2つの細かい点をここで押さえておきます。1つ目は、同じ呼び出しは delta と block-end で同じ CallId 値を使うべきだということです。例ではどちらも CallId('call-123') と書かれており、意味的にはこれらは同じ呼び出しを表しています。実装でプロバイダから id を取得した場合は、それをローカル変数(上例の callId など)に保存し、2か所で再利用することで、うっかり異なる値を書いてしまうのを避けられます。2つ目は、1回の生成で複数のツール呼び出しが発生する可能性があるということです。その場合、各呼び出しはそれぞれ独自の index と独自の id を持ち、それぞれが start / delta / end を一通り実行します。index はブロックを区別するために、id は呼び出しを区別するために使い、この2つを混同しないでください。index はストリーム内の位置識別子であり、id は業務上の呼び出し識別子です。
モデルが同じ生成の中で複数のツール呼び出しを開始した場合(並列ツール呼び出しのシナリオでよく見られます)、あなたのアダプタは順番に各呼び出しごとに3状態のフラグメントを1組ずつ出力します。最初の呼び出しは block-start → tool-call-delta(複数になることもあります)→ block-end、その後2番目の呼び出しでもう一度同じ流れを繰り返します。それぞれが異なる index を持つことに注意してください。複数呼び出しのシナリオでも `finish` の reason は依然として { kind: 'tool-calls' } です。これは終了理由が「ツールの実行要求」であり、呼び出し数とは無関係だからです。
usage と finish の収尾順序:まず token 使用量を報告し、それから終了理由を宣言する
完全なシーケンスの最後には、必ず二つの断片がある:usage と finish だ。順序は厳格である——まず usage で token 使用量を報告し、次に finish で終了理由を宣言する。この順序は逆にしてはならない。例では usage は次のように書かれている:
yield { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } }
yield { type: 'finish', reason: { kind: 'stop' } }usage には二つの数値フィールドが含まれる:inputTokens と outputTokens で、それぞれ今回の生成で消費された入力と出力の token 数を表す。例では 100 と 50 が与えられている。これらの数値は提供側から返された使用量情報に由来すべきであり、取得できればそのまま記入し、取得できなければ取り決めに従って処理する(勝手に捏造してはならない)。なぜ finish の前に送るのか? 消費側は通常 finish を見た時点で収尾処理——使用量の決済、請求の更新、ログの書き込み、ストリームのクローズ——を行うため、「すべてのデータが揃った」タイミングが必要だからだ。usage を finish の前に並べることで、消費側は finish を処理する時点ですでに完全な使用量情報を握っており、一度で収尾を完了できる。もし逆に finish を先にして usage を後にすると、消費側は finish の瞬間にイテレーションを終了してしまい、usage は永遠に読まれず、統計が失われる可能性が高い。
finish は最後の断片であり、その reason.kind は今回の生成がなぜ終了したかを宣言する。例では { kind: 'stop' } で、正常終了、つまりモデルが自分で話し終えたことを表す。もう一つは { kind: 'tool-calls' } で、モデルがツールの実行を要求したことを表す——これは通常、すでに少なくとも一つのツール呼び出しブロックが先行して産出されていることを意味し、消費側はそれに基づいてツール実行フローに入り、実行結果を回填した後に次のラウンドの生成を開始する。こう理解できる:finish の reason が agent-loop の次の動作を決定する。stop ならこのラウンドを終了し、tool-calls ならツールを走らせ続ける。アダプタがやるべきことは、提供側が与えた終了理由をこれら二つの kind に正しくマッピングすることだ。
収尾の二つの断片のフィールドと意味を次のように整理する:
| 断片 | フィールド | 意味 | 位置制約 |
|---|---|---|---|
| usage | usage.inputTokens、usage.outputTokens | 今回の生成の token 使用量 | すべての block-end の後、finish の前 |
| finish | reason.kind | stop は正常終了;tool-calls はツール実行の要求 | シーケンス全体の最後の断片、これ一度のみ |
さらに守るべき収尾の規律がいくつかある。第一に、finish は必ず一つだけ存在しなければならない。正常パスの終了時に一度 yield し、異常パスで追加の finish を yield して「補救」しようとしてはならない。それは消費側に二つの終了シグナルを見せることになる。異常は例外をスローする方法で伝えるべきであり、finish を偽造してはならない。第二に、usage も収尾フィールドであり、ブロックの途中に置いてはならない。ある実装では「ついでに」最初のブロック終了後に usage を送ってしまうが、これは誤りだ。usage は生成全体の総使用量を記述するものであり、すべてのブロックが終了した後でなければ意味を持たない。第三に、今回の生成に内容ブロックが一切なくても(例えばモデルが直接終了した、またはツール実行のみを要求した場合)、usage と finish は依然として送らなければならない。空の応答も一つの正当な生成であり、収尾の二つの断片は省略できない。
シーケンス全体の順序を一句の口诀で覚えよう:ブロックを先に開き、増分を埋め、ブロックの後に閉じる;すべてを収尾し、使用量を報告し、それから宣言する。対応する順序は block-start → delta → block-end(複数ブロックで繰り返し可)→ usage → finish となる。アダプタが yield するオブジェクトが厳密にこのリズムに従うことを保証しさえすれば、agent-loop は背後がどこのモデルであっても安定して消費できる。ここまでで、インターフェースをどう書くか(LlmAdapter と registerAdapter)と断片をどう産出するか(StreamChunk プロトコル)という二つの主線がともに展開された。次の部分では、このプロトコルを実際のアダプタの実装詳細に落とし込み、In this 転送エラーをどの層で捕捉・変換すべきか、交錯イベントをどう帰位させるか、そして上記の断片シーケンスを走通した後、最小のテストケースでアダプタが確かに準拠していることをどう検証するかを議論する。
前の段落で、私たちはすでに LlmAdapter 抽象クラスと stream() メソッドの責務境界を整理した:アダプタは Harness の提供側非依存のリクエストを具体的なベンダー API 呼び出しに翻訳し、さらにベンダーの応答を Harness の断片に翻訳し戻す責任を負う。この段落では、カメラをプロトコルの詳細と実装エンジニアリングへと推し進める:StreamChunk は一体どのような形なのか、断片の順序はなぜ乱れてはならないのか、finish.reason がどのように「自然終了」と「ツール実行の要求」の間で分岐するのか、そして実際のプラグインが cordis.yml から registerAdapter に至る完全なライフサイクルとはどのようなものか。
finish.reason:stop による正常終了と tool-calls によるツール実行要求の分岐
finish はストリーミング生成における最後のチャンクであり、それが持つ reason フィールドが agent-loop の次の動作を決定します。素材が示す値のセマンティクスは非常に明確です。reason.kind が stop の場合はモデルが自然に生成を締めくくったことを意味し、この対話ラウンドは次のユーザー入力へ進むことができます。reason.kind が tool-calls の場合はモデルが Harness にツールの実行を要求していることを意味し、ツールの実行結果をコンテキストへ還流させたうえで、新たな生成ラウンドを開始する必要があります。この二つのパスの違いは「フォーマットの違い」ではなく、「制御フローの違い」です。
この分岐をよりエンジニアリング寄りに説明すると、agent-loop の視点では、それは単一の非同期ストリームを消費しています。index ごとにコンテンツブロックを集約しながら、最後の finish チャンクを注視します。もし reason.kind === 'tool-calls' であれば、ループはそこで終了してはならず、すでに収集し終えた tool-call ブロックを取り出してツール実行層に渡し、結果を取得したうえで新しい options.messages を構築し、アダプターの stream() を再度呼び出します。したがってアダプターの作者は次の一点を必ず保証しなければなりません。ツール呼び出しブロックが生成されたなら、finish は必ず tool-calls を報告しなければならない。ツール呼び出しブロックがなければ、ようやく stop を報告することが許される。この一貫性が破られると、Harness は空のツールリストで実行に進むか、モデルが明確に要求したツール呼び出しを失うかのどちらかになります——どちらも特定が困難なランタイム障害です。
| finish.reason.kind | セマンティクス | Harness の次のアクション | 典型的な発生シーン |
|---|---|---|---|
| stop | 生成が自然に終了し、対話ラウンドが完了 | 集約後のテキストブロックを上位層へ返し、次の入力ラウンドを待つ | 通常の質疑応答、プレーンテキストの要約、最終回答の提示完了 |
| tool-calls | Harness にツール実行を要求し、その後還流する | tool-call ブロックを取り出し、ツールを実行し、結果を messages に書き戻して再生成する | モデルが bash、検索、ファイル書き込みなどの外部能力の呼び出しを決定した場合 |
見落とされがちな細部に注意してください。finish はストリーム全体の最後のチャンクでなければならず、その前に必ず usage を送出しなければなりません。素材は例の中で「token 使用量を報告し、必ず finish の前に置く」と明確に注記しています。この順序制約の意味は、agent-loop がまず usage を取得して初めて、この生成ラウンドの終了時にコストと使用量の記帳を完了できるという点にあります。もしアダプターが finish を先に送り usage を後に送ると、消費者は finish を受け取った瞬間にアグリゲーターを閉じて上位へ返してしまう可能性があり、usage チャンクは破棄されてしまいます。したがって順序はスタイルの問題ではなく、プロトコル契約です。
さらにもう一層、判断の経験則を補足します。モデルがテキストを生成した後にツール呼び出しを開始した場合、正しい方法はまずテキストブロックを正常に閉じ(block-end が完全なテキストを伴う)、そのうえでツール呼び出しブロックを開くことです。テキスト delta とツール呼び出し delta を同じ index の下に混在させようとしてはいけません。なぜなら blockType は block-start の時点ですでに宣言されており、後続の delta でブロックの型を変更することはできないからです。素材の例示シーケンスもこの点を裏付けています。index 0 は text、index 1 は tool-call であり、二つのブロックがそれぞれ start / delta / end を完走し、最後にようやく締めくくられます。この「ブロック内で自己完結、ブロック間で順序付け」というモデルが、StreamChunk プロトコル全体の中核です。
exampleChunks の完全なチャンクシーケンスを1つずつ分解:0番のテキストブロック + 1番のツール呼び出しブロック
素材は exampleChunks という公式サンプルを提供しており、1回の生成におけるすべての chunk を順番に出力します。私たちは index ごとに順を追って確認し、2つのブロックがどのように端から端までつながるのか、そして最後の usage と finish がどこに位置するのかを見ていきます。
最初のチャンク:テキストブロックの開始。 { type: 'block-start', index: 0, blockType: 'text' }。ここで index はブロック番号で、0から始まります;blockType はこのブロックの型がテキストであることを宣言します。消費者はこのチャンクを受け取ると、次にテキストアグリゲータを初期化し、それを index 0 に登録すべきだと分かります。この時点ではまだテキスト内容は一切なく、block-start は「プレースホルダー + 型の宣言」だけを担当します。
2番目、3番目のチャンク:テキストの増分。 まず { type: 'text-delta', index: 0, text: 'runoob' }、次に { type: 'text-delta', index: 0, text: ' 教程' }。素材は「text-delta は複数のチャンクに分割でき、増分を連結して完全なテキストになる」とわざわざ説明しています。これは、アダプタが1つの返信を任意の数の delta に切って外へ吐き出してよいことを意味し、それらがすべて同じ index を指していれば、消費者が順番に連結するだけで復元できます。エンジニアリング上のトレードオフは:チャンクが粗すぎるとストリーミングの体感が悪くなり、細かすぎるとチャンク数とスケジューリングのオーバーヘッドが増大します。一般的なやり方は上流の SSE イベントの粒度に従うことで、上流が1区切り渡せば1区切り変換し、勝手にバッファリングして大きな塊にしてはいけません。
4番目のチャンク:テキストブロックの終了。 { type: 'block-end', index: 0, block: { type: 'text', text: 'runoob 教程' } }。重要な点は2つあります:1つ目は index が block-start と一致していなければならず、どちらも 0 であること;2つ目は block に含まれるのが完全なブロック、つまり2つの delta を連結した最終テキストであることです。これにより消費者に検証のアンカーポイントが与えられます——もし消費者自身が連結した結果と block-end が持つ完全なブロックが一致しなければ、チャンクが転送または変換の段階で問題を起こしたことを示します。素材のコメントは直接的に書かれています:「完全なブロックで終了し、index は block-start と一致させる」。
5番目のチャンク:ツール呼び出しブロックの開始。 { type: 'block-start', index: 1, blockType: 'tool-call' }。index は 1 に増加し、blockType は tool-call に変わります。テキストブロックとツール呼び出しブロックは2種類の異なるコンテンツブロックであり、それぞれが start / delta / end を一通り辿るため、ここは新しい起点であり、index 0 の上に重ねるのではありません。
6番目のチャンク:ツール呼び出しの増分。 { type: 'tool-call-delta', index: 1, id: CallId('call-123'), name: 'bash', argumentsDelta: '{\"command\":\"echo runoob\"}' }。ここはフィールド密度が最も高く、1つずつ説明します:id は CallId ファクトリで生成され、型安全性と統一された識別子を保証します;name はツール名です;argumentsDelta は生の JSON テキストの増分です——素材の原文どおり、これは解析後のオブジェクトではなく、文字列の断片です。上流の多くのベンダーのストリーミングツール呼び出しは、JSON パラメータを文字単位でスライスして送ってくるものであり、アダプタの責務はこれらの文字断片をそのまま運搬することであり、ここで JSON 解析や補完を行ってはいけません。そうしないと中途半端な JSON でエラーを投げやすくなります。
第七の断片:ツール呼び出しブロックを閉じる。 { type: 'block-end', index: 1, block: { type: 'tool-call', id: CallId('call-123'), name: 'bash', arguments: '{\"command\":\"echo runoob\"}' } }。index は依然として 1 であり、block 内の arguments は「組み立て済みの JSON テキスト」である。ここまでで、2 つのコンテンツブロックがすべて閉じられる。
第八の断片:使用量レポート。 { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } }。資料には「必ず finish より前でなければならない」と明記されている。フィールドは inputTokens と outputTokens で、サンプル値はそれぞれ 100 と 50 である。
第九の断片:終了宣言。 { type: 'finish', reason: { kind: 'stop' } }。資料には「最後の断片であり、終了理由を宣言する」と注記されており、{ kind: 'tool-calls' } に置き換えることでツール実行を要求できることも示唆されている。本例では実際に index 1 のツール呼び出しブロックが生成されているため、より自己整合的な変種としてはこの断片を { kind: 'tool-calls' } と書くことになる。サンプルで stop を保持しているのは、フィールドの位置を示すためだけである。
以下は、そのまま照合して実行できる最小のジェネレーターであり、上記 9 つの断片をそのまま出力する。ローカルでまずプロトコルを通してから実際のベンダーに接続するのに便利である:
// ファイルパス:examples/example-chunks.ts
import { CallId, type StreamChunk } from '@deepseek-ai/dsh-llm'
export async function* exampleChunks(): AsyncIterable<StreamChunk> {
// 1. テキストブロックを開始、index 0
yield { type: 'block-start', index: 0, blockType: 'text' }
// 2. テキスト差分、複数の断片に分割可能
yield { type: 'text-delta', index: 0, text: 'runoob' }
yield { type: 'text-delta', index: 0, text: ' 教程' }
// 3. 完全なブロックで終了、index は block-start と一致
yield {
type: 'block-end',
index: 0,
block: { type: 'text', text: 'runoob 教程' },
}
// 4. ツール呼び出しブロック、index 1
yield { type: 'block-start', index: 1, blockType: 'tool-call' }
yield {
type: 'tool-call-delta',
index: 1,
id: CallId('call-123'),
name: 'bash',
argumentsDelta: '{\"command\":\"echo runoob\"}',
}
yield {
type: 'block-end',
index: 1,
block: {
type: 'tool-call',
id: CallId('call-123'),
name: 'bash',
arguments: '{\"command\":\"echo runoob\"}',
},
}
// 5. token 使用量、必ず finish より前
yield { type: 'usage', usage: { inputTokens: 100, outputTokens: 50 } }
// 6. 終了理由;{ kind: 'tool-calls' } に置き換えるとツール実行を要求できる
yield { type: 'finish', reason: { kind: 'stop' } }
}
このジェネレーターを動かせるようになると、「ブロック」の概念が体に染みつく:各ブロックは start → delta* → end の閉じた単位であり、ブロック同士は index で区別され、ストリーム全体は usage + finish で締めくくられる。以降のあらゆるベンダー適応の難しさは、本質的にベンダー固有のイベントストリームをこれらの閉じた単位にマッピングすることにある。
cordis.yml 設定と Schemastery 検証:apiKey、providers の2つの必須項目
プロトコルの説明が済んだところで、プラグイン側に戻ります。アダプタプラグインが Harness にロードされるには、設定構造を宣言し、ロード時に検証する必要があります。cordis.yml は設定の载体であり、Schemastery は検証器です。素材が提示する設定インターフェースは非常に抑制が効いており、フィールドは2つだけですが、どちらも必須です。
// ファイルパス:src/my-llm-adapter.ts(設定部分)
import Schema from '@deepseek-ai/schemastery'
export interface Config {
apiKey: string
providers: string[]
}
export const Config: Schema<Config> = Schema.object({
apiKey: Schema.string().required(),
providers: Schema.array(Schema.string()).required(),
})
ここには1つの厳格な約束事があり、素材の原文では「同名の Schemastery schema、ロード時に設定を検証」と表現されています:エクスポートされる Config インターフェースとエクスポートされる Config schema は、同名・同構造でなければならない。TypeScript ではインターフェースと定数は同名で共存でき、前者はコンパイル時の型を提供し、後者は実行時の検証を提供します。どちらも欠かせません。インターフェースは apply(ctx, config) 内で config.apiKey を書く際に型ヒントを与える役割を担い、schema はユーザーが入力漏れや型の誤りをした場合に、最初のリクエスト時に難解な実行時例外を投げるのではなく、プラグインのロード段階でエラーを報告する役割を担います。
2つのフィールドの役割分担も明確にしておく価値があります。apiKey は Schema.string().required() で、認証情報に属し、通常はプロバイダごとに1つです。providers は Schema.array(Schema.string()).required() で、プロバイダのルート名リストであり、このアダプタがどのルートに応答するかを決定します。素材は例で ['my-provider'] のような配列を渡しています——配列であり、単一の文字列ではないことに注意してください。これは一度に複数のルートをバインドするためのインターフェースを用意しています。
| 設定項目 | Schemastery の書き方 | 必須か | 型 | 役割 |
|---|---|---|---|---|
| apiKey | Schema.string().required() | はい | string | 特定のプロバイダにアクセスするために必要な認証情報 |
| providers | Schema.array(Schema.string()).required() | はい | string[] | このアダプタがバインドするルート名リスト |
対応する cordis.yml の断片は次のような形です:
# ファイルパス:cordis.yml
plugins:
my-llm-adapter:
apiKey: sk-your-provider-key
providers:
- my-provider
- my-provider-backup
よくある落とし穴は3種類あります。第1類:フィールド名の綴りが一致しない。 インターフェースでは apiKey と書き、schema では api_key と書いても、TypeScript はエラーを報告しません(なぜなら2つの独立した宣言だからです)。しかしユーザーが schema に従って api_key を入力すると、コード内で apiKey を読んでも undefined になります。回避策は、インターフェースと schema を隣接して書き、明示的な代入で両者をバインドすることです。例えば export const Config: Schema<Config> = ... のようにすれば、ジェネリックパラメータが構造の差異をチェックしてくれます。
第二類:required を忘れる。 .required() を書き忘れたフィールドは、欠落時にエラーにならず、静かに undefined になります。apiKey が欠落していると最初のリクエストで 401 を受け取りますが、providers の欠落はさらに隠蔽的です——registerAdapter(undefined, adapter) はエラーを出さない可能性がありますが、ルーティングは永遠にヒットせず、「プラグインのロードは成功したがモデルに接続できない」という症状になります。したがって、素材が両方のフィールドを required にマークすることを強調しているのは、必要な防御です。
第三類:providers に単一の文字列を記述する。 ユーザーが YAML でリストではなく providers: my-provider と書いた場合、Schemastery の Schema.array() はロード段階で直接拒否しますが、これはむしろ良いことです——エラーが設定段階に前倒しされます。プラグインを公開する際に README へ最小限の cordis.yml サンプルを添付すれば、この種の問題を大量に省けます。
inject = ['llm'] と apply(ctx, config):依存性注入により ctx.llm の準備完了を保証する
設定検証が通過すると、プラグインはライフサイクルに入ります。素材で示された2つのエクスポート——inject と apply——がプラグインの実行エントリを構成します。それらの連携関係は次のとおりです:inject が依存関係を宣言し、apply が依存関係の準備完了後に実行される。
export const inject = ['llm'] の意味は、本プラグインが llm という名前のサービスに依存しているということです。Harness(Cordis ベースの依存性注入)はプラグインのロード時に、まず ctx.llm が利用可能であることを確認します。準備ができていなければ apply は呼び出されません。素材の原文は「llm サービスへの依存を宣言し、ctx.llm が準備完了であることを保証する」です。これは非常に現実的なタイミング問題を解決します。もしプラグインが ctx.llm の登録完了前に ctx.llm.registerAdapter(...) を呼び出すと、直接例外がスローされるか、あるいはサイレントに失敗します——そしてこうした失敗は、起動ログでは目立たない1行のエラーに過ぎないことが多く、調査コストが非常に高くなります。
apply(ctx, config) に入った後、ロジックはわずか2ステップです:アダプタインスタンスの構築と、ルートのバインド。素材のコードは次のとおりです:
export function apply(ctx: Context, config: Config) {
const adapter = new MyAdapter(config.apiKey)
// 把提供方路由列表绑定到这个适配器
ctx.llm.registerAdapter(config.providers, adapter)
}
順に分解します。第一に、new MyAdapter(config.apiKey) は検証済みの apiKey をアダプタインスタンスに注入し、アダプタ内部でそれを保持して stream() 内で認証に使用します。第二に、ctx.llm.registerAdapter(config.providers, adapter) は「ルート名の配列 → アダプタインスタンス」のマッピングを ctx.llm レジストリに書き込みます。素材は別の箇所でもレジストリの位置づけを説明しています:それは中間層であり、LlmAdapter の抽象契約を維持します。最上位は agent-loop であり、提供元に依存しないストリーミング生成サービスを消費します。最下位は各アダプタであり、それぞれ異なる API フォーマットに対応します。この三層サンドイッチ構造こそが、seam 図が描く継ぎ目です。
いくつかのエンジニアリング上の落とし穴がここに集中して現れます。落とし穴1:apply の外で登録する。 registerAdapter をモジュールのトップレベルスコープに書いてしまい、モジュールが import された瞬間に実行される人がいます。このとき ctx がまだ存在しないか、あるいは ctx.llm がまだ準備完了していない可能性があります。正しい方法は apply 内でのみ登録することです。
落とし穴2:inject を依存文字列以外のものとして書く。 inject はサービス名の配列でなければならず、名前を間違えると依存を宣言していないのと同じで、タイミング問題はそのまま残ります。
落とし穴3:apply 内で重い処理を行う。 apply は軽量なアセンブリロジックであるべきです——インスタンスの構築、ルートの登録。apply 内でエンドポイントの可用性を探るネットワークリクエストを発行したり、大量の初期化を行ったりしないでください。そうしないと起動が遅くなり、1回の探査失敗によってプラグイン全体のロードが失敗する可能性もあります。
落とし穴4:複数インスタンスの競合。 同じ providers 名が2つのプラグインによって同時に登録された場合、後から登録された側の動作はレジストリの実装に依存します。大規模デプロイ時には、各ルートに一意の名前を付けることを推奨し、設定レビューの段階でスクリプトによる重複チェックを行ってください。
アダプターの骨組みを実装する:src/my-llm-adapter.ts のファイル構造とエクスポート規約
これまでの断片をすべてつなぎ合わせると、最小限の動作する骨組みになります。素材で示されたファイルパスは src/my-llm-adapter.ts で、エクスポート規約には name、Config、inject、apply の四点セットが含まれます。完全な構造は次のとおりです:
// ファイルパス: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. options.messages を提供側の形式に変換する
// 2. ストリーミング API を呼び出す
// 3. レスポンスを StreamChunk に変換する
}
}
// プラグイン設定:apiKey と providers はどちらも必須
export interface Config {
apiKey: string
providers: string[]
}
// 同名の Schemastery schema。読み込み時に設定を検証する
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)
}
4 つのエクスポートの役割分担は次のように覚えられます。name はプラグイン識別子で、ログ、設定セクションのキー名、エラーの帰属に使われ、cordis.yml 内のキーと一致している必要があります。Config は二形態のエクスポートで、インターフェースが型を提供し、schema が検証を提供し、両者は同名かつ同構造です。inject は依存宣言で、現時点では ['llm'] のみが必要です。apply は組み立て関数で、準備済みの ctx と検証済みの config を受け取ります。
stream() のシグネチャは async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> です。ここには 3 層の情報があります。async generator は非同期ジェネレーターであることを示し、yield による断片ごとの生成と for await...of による断片ごとの消費を自然にサポートします。引数 GenerateOptions は Harness の提供側非依存のリクエストを担い、その中で最も重要なのが options.messages です。戻り値の型 AsyncIterable<StreamChunk> は、出力が StreamChunk のシーケンスでなければならないことを固定します。素材の 3 ステップのコメント——メッセージ形式の変換、ストリーミング API の呼び出し、レスポンスの StreamChunk への変換——こそが、すべてのアダプターに共通する三段構成です。
| エクスポート名 | 形態 | 必須 | 責務 | 誤った場合の結果 |
|---|---|---|---|---|
| name | 文字列定数 | はい | プラグイン識別子。cordis.yml のセクションキーに対応 | プラグインが正しくロードされない、またはログの帰属が混乱する |
| Config | interface + Schema の同名エクスポート | はい | コンパイル時の型 + 実行時の検証 | 設定エラーがロード段階で顕在化できない |
| inject | 文字列配列 | はい(llm に依存する場合) | 依存関係を宣言し、ctx.llm の準備完了を保証 | 登録タイミングが早すぎて registerAdapter が失敗する |
| apply | 関数 | はい | アダプターを構築しルーティングをバインド | アダプターが有効にならず、ルートが常に 404 になる |
もう一つの実践的な推奨事項があります。アダプタークラス MyAdapter はモジュールプライベート(非エクスポート)に保ち、四つのエクスポートのみを公開してください。こうすることで、外部からはルート名を通じてのみ機能を呼び出せ、直接 new MyAdapter() で設定検証を回避することができなくなります。同様に、コンストラクタの apiKey パラメータにはデフォルト値を設けず、呼び出し側に明示的な指定を強制することで、「設定が欠落しているのにデフォルトの空文字列で動作してしまう」事態を避けられます。
2026年9月の実践:マルチプロバイダールーティングとStreamChunk順序検証のエンジニアリング実装
アダプターが「一つ」から「一群」になると、エンジニアリングの重心は「動かすこと」から「回帰可能・観測可能・拡張可能」へと移行します。以下は2026年9月前後のマルチプロバイダーシナリオにおける主流の実装手法であり、すべて素材で示されたメカニズムを中心に展開しています。
第一に、providers配列で一度に複数のルートをバインドします。 素材の登録シグネチャ ctx.llm.registerAdapter(config.providers, adapter) 自体が配列を受け入れるため、「同一APIフォーマット、複数ルート名」のシナリオでは複数回登録する必要はありません。典型的な用法は、主系と待機系の2つのルート名を同一のアダプターインスタンスに指し向け、上位のルーティング戦略と組み合わせて切り替えを行う;あるいは、あるベンダーの複数のモデルファミリーを複数のルート名に分割しつつ、アダプター内部でルート名に応じて異なるリクエストパラメータを選択する、というものです。この利点は明らかで、アダプターインスタンスは一つだけで済み、コネクションプール、認証情報、リトライ戦略がすべて再利用されます。
第二に、チャンクシーケンスに対して順序アサーションを行います。 素材ではexampleChunksを用いて厳密な順序を示していますが、規模が拡大したら、この順序を人手によるレビューに頼るのではなく、実行可能なアサーションに変えるべきです。推奨されるアサーションは2種類あります:構造アサーション——各block-startには対になるblock-endが存在し、indexが同一でなければならない;状態機械アサーション——deltaは開かれていてまだ閉じられていないブロック内にのみ出現でき、「block-startのないdelta」や「block-endの後にまだdeltaがある」状態は許可されない。第三の種類は終端アサーション——ストリームの最後の2チャンクはusageとfinishでなければならず、usageはfinishより前でなければならない。
以下のバリデータはそのままCIに組み込むことができ、アダプターが生成したチャンクストリームを投入して検査できます:
// 文件路径:test/assert-chunk-sequence.ts
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
export function assertChunkSequence(chunks: StreamChunk[]): void {
const open = new Map<number, string>()
chunks.forEach((c, i) => {
if (c.type === 'block-start') {
if (open.has(c.index)) {
throw new Error(`第 ${i} 片:index ${c.index} 重复 block-start`)
}
open.set(c.index, c.blockType)
return
}
if (c.type === 'text-delta' || c.type === 'tool-call-delta') {
if (!open.has(c.index)) {
throw new Error(`第 ${i} 片:index ${c.index} 的 delta 落在未开启的块内`)
}
return
}
if (c.type === 'block-end') {
if (!open.has(c.index)) {
throw new Error(`第 ${i} 片:index ${c.index} 缺少配对的 block-start`)
}
open.delete(c.index)
return
}
if (c.type === 'usage') {
if (open.size > 0) {
throw new Error(`第 ${i} 片:usage 之前仍有未闭合的块`)
}
return
}
if (c.type === 'finish') {
const last = chunks[i - 1]
if (!last || last.type !== 'usage') {
throw new Error('finish 之前必须是 usage')
}
if (i !== chunks.length - 1) {
throw new Error('finish 必须是最后一个分片')
}
}
})
if (open.size > 0) {
throw new Error(`仍有未闭合的块:${[...open.keys()].join(', ')}`)
}
}
第三に、usage と finish を回帰テストケースに含めること。 多くのチームの回帰テストケースは「テキスト出力がある」ことだけをアサートし、usage と finish を見落としている。一度の変更でアダプタが異常パス上で早期 return するようになると、テキストは同様に現れるが usage と finish が欠落し、ツール呼び出しのシナリオが静かに機能しなくなる。「最後の分片が finish で、その前が usage である」とアサーションとして記述すれば、この種の問題は CI 段階で食い止められる。
第四に、ツール呼び出しと finish セマンティクスの連動検証。 回帰テストケースに次の一条を加えるべきである。分片シーケンス中に tool-call ブロックが出現した場合、finish の reason.kind は tool-calls でなければならない。いかなる tool-call ブロックもなければ、stop であるべきである。このルールは「モデルの意図」と「プロトコルの宣言」を結び付け、agent-loop が自己矛盾したストリームを受け取るのを防ぐ。
第五に、複数プロバイダ向けの統一された録画・再生メカニズムを構築すること。 各アダプタは少なくとも一つの実分片シーケンスを fixture として記録し、再生時には同一の順序アサーションを実行する。こうすれば新しいプロバイダを追加する際、fixture がアサーションを通過しさえすれば、プロトコル互換性の第一層の保証が得られる。オンラインで問題が発生した際も、同じアサーションでアダプタの変換ミスなのか、上流自体が異常なシーケンスを返しているのかを迅速に判定できる。
ストリーミング伝送でエラーが発生した際のトラブルシューティングチェックリスト:チャンク順序、index の整合性、終端欠落
本番環境で「モデルが応答しない」「ツールが実行されない」「返信が途中で切れる」といった症状が出た場合、根本原因のほとんどはチャンクプロトコルにあります。以下は「外から内へ、粗から細へ」の順に並べたチェックリストで、これに沿って進めれば迅速に原因を絞り込めます。
- まずストリームが終了しているか確認する。 finish チャンクを受信したかどうかを見ます。finish がなければ、アダプタが上流接続の切断時に例外をそのまま投げたり早期 return したりしている一方で、コンシューマはまだ待機している可能性があります。この場合、例外パスで「usage + finish」を統一的に補完送出するか、少なくとも識別可能な終了シグナルを発行しているかを確認すべきです。
- usage が finish より前にあるか確認する。 これは最も一般的な順序エラーです。usage が欠落しているとコスト計上が失われます。usage が finish の後にあると、コンシューマがすでにアグリゲータを閉じてしまい破棄される可能性があります。修正方法は、アダプタの終端処理で順序を固定することです:まず usage を yield し、次に finish を yield します。
- block-start と block-end の index が対応しているか照合する。 開始された各ブロックには、同じ index の閉じが必ず存在しなければなりません。よくあるバグは、コピー&ペースト時に index を 0 にハードコードしてしまい、複数ブロックのシナリオで index 1 のブロックが永遠に閉じられない、あるいは 2 つのブロックが互いに干渉するというものです。
- delta が正しいブロック内に収まっているか確認する。 text-delta の index は、すでに開始された text ブロックを指していなければならず、tool-call-delta の index は、すでに開始された tool-call ブロックを指していなければなりません。型の不一致(text ブロックに tool-call-delta を送る)は、寛容な実装ではエラーにならないかもしれませんが、消費側の集約結果は必然的に誤りになります。
- blockType と後続 delta の型の一貫性を照合する。 block-start で
blockTypeを宣言したら、後続の delta は同じ型でなければなりません。テキストとツール呼び出しを同時に生成したい場合、正しい方法は 2 つのブロックを開くことであり、1 つのブロック内で混在送出することではありません。 - block-end が持つ完全なブロックが delta の結合結果と一致するか確認する。 これは最後の自己チェックです。コンシューマが delta から組み立てたテキストと block-end の
blockが一致しない場合、アダプタがいずれかの側で追加加工(例えば delta に trim を施したり、完全なブロックに補完を施したり)を行っていることを意味します。 - ツール呼び出しの argumentsDelta が二重解析されていないか確認する。 多くのアダプタ作者は、最初の argumentsDelta を受信した時点で
JSON.parseを試みてしまい、結果として途中の JSON でエラーを投げてストリームを中断させます。正しい方法は、文字片段をそのまま透過させ、block-end 時にのみ結合済みの完全な JSON テキストを渡すことです。 - finish.reason とツール呼び出しブロックが自己整合しているか確認する。 tool-call ブロックがあるのに
stopの場合、Harness はツールを実行せず、「モデルがツールを呼ぶと言ったのに動作しない」という症状になります。tool-call ブロックがないのにtool-callsの場合、Harness は空のツールリストを受け取り、エラーになったり空回りしたりする可能性があります。 - inject と登録タイミングを確認する。 ログに「アダプタ未登録」「ルート未ヒット」が現れる場合、
inject = ['llm']が宣言されているか、registerAdapterがapply内で呼ばれているかを確認してください。この種の問題の症状は「ストリームがそもそも始まっていない」であり、チャンク順序の問題と混同しやすいです。 - providers のルート名が呼び出し側と一致しているか確認する。 設定で
my-providerをバインドしているのに、呼び出し側がmy-provider-2と書いていると、ルートが見つからないエラーになります。規模の大きいデプロイでは、起動ログに登録済みルートの一覧を出力することを推奨します。
このチェックリストを再利用可能な診断ツールにするには、アダプターの外側にデバッグプロキシをラップし、毎回生成されるチャンク列を出力してアサーションを実行します:
// ファイルパス:scripts/trace-adapter.ts
import { assertChunkSequence } from '../test/assert-chunk-sequence'
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
export async function traceStream(
label: string,
stream: AsyncIterable<StreamChunk>,
): Promise<StreamChunk[]> {
const seen: StreamChunk[] = []
for await (const chunk of stream) {
seen.push(chunk)
console.log(`[${label}] #${seen.length - 1}`, JSON.stringify(chunk))
}
assertChunkSequence(seen)
console.log(`[${label}] チャンク総数 ${seen.length}、順序検証に合格`)
return seen
}
// 使い方:アダプターが生成するストリームを包み込むだけ
// const chunks = await traceStream('my-provider', adapter.stream(options))
実行方法は簡単で、統合テストやローカルのデバッグスクリプト内で呼び出すだけです:
# チャンク順序のリグレッションを実行
npx tsx scripts/trace-adapter.ts
このチェックリストの価値は、順序を飛ばせない点にあります。まず終端を確認し、次にペアリングを確認し、次にチャンク内部を確認し、最後に登録を確認します。多くの人はいきなり具体的なテキスト内容を diff してしまい、かえって遠回りになります——プロトコル層の問題は、常にまずプロトコル層のアサーションで特定すべきです。
まとめとベストプラクティス
2つの内容を実行可能なチェックリストに圧縮し、「コードを書く前、コードを書く時、リリース後」の3段階で整理します:
- アダプターの責務を単一化する:
LlmAdapterは双方向の翻訳のみを行う——options.messagesをプロバイダー形式に変換し、プロバイダーのレスポンスをStreamChunkに変換する。アダプターにビジネスロジック、リトライオーケストレーション、コンテキストのトリミングを混ぜてはいけない。これらは上位層に属する。 - ブロックモデルを常に意識する:各コンテンツブロックは
block-start → delta* → block-endの閉じたユニットである;テキストブロックとツール呼び出しブロックはそれぞれ一度ずつ流れ、index で区別する;block-end は delta の連結結果と一致する完全なブロックを必ず伴う必要がある。 - 終了順序は逆にしてはならない:先に
usage(inputTokens / outputTokens を含む)、後にfinish;finish は最後のチャンクでなければならない。 - finish.reason とツール呼び出しは厳密に自己整合させる:tool-call ブロックを生成した場合は
{ kind: 'tool-calls' }を使って Harness にツール実行と還流を要求する;そうでなければ{ kind: 'stop' }を使う。 - argumentsDelta はそのまま透過させる:これは生の JSON テキストの差分であり、チャンク段階で JSON パースや補完を行ってはならない。連結とパースは消費側が block-end の後に完了させる。
- 設定は同名同構造で二重エクスポートする:
Configインターフェースで型を提供し、Configschema で検証を提供する。apiKeyとprovidersはどちらも.required()でマークし、設定エラーをロード段階で露見させる。 - 依存関係の宣言は省略できない:
export const inject = ['llm']でctx.llmの準備完了を保証し、registerAdapterはapply(ctx, config)内でのみ呼び出し、登録タイミングが早すぎることを杜绝する。 - 四点セットのエクスポート規約:
name、Config、inject、applyを揃える;アダプタークラスはモジュールプライベートに保ち、コンストラクターで apiKey の明示的な受け渡しを強制する。 - 複数ルートを一度にバインドする:
registerAdapter(config.providers, adapter)の配列機能を直接利用し、主系・待機系ルートや同一形式の複数ルートで1つのアダプターインスタンスを共有する。 - プロトコル順序をアサーションとして書く:CI で block-start / block-end の index のペアリング、delta が正しいブロック内に収まっているか、usage と finish の位置を検証し、さらに tool-call ブロックと finish.reason の連動関係を追加でアサートする。
- fixture でマルチプロバイダーのリグレッションを行う:各アダプターにつき1本の実チャンク列を再生サンプルとして記録し、新しいプロバイダーを追加する際はまずアサーションを通過させてからリリースする。
- トラブルシューティングは順序に従って進める:まず finish と usage が発行されているか、順序が正しいかを確認し、次に index のペアリングを調べ、次に delta の帰属を調べ、最後に inject と providers のルート登録を調べる。
以上の12項目を実践すれば、任意のベンダーに接続でき、かつ Harness に安定して消費される LLM アダプターを手に入れる:agent-loop は統一された StreamChunk プロトコルのみに向き合い、ctx.llm レジストリが抽象契約を維持し、下位のアダプターはそれぞれの API 形式を管理する——これこそが seam 図が描くレイヤリングの価値である。