0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

OpenAI/Anthropic 互換サーバの裏側: DSML ツール呼び出しと live KV 再利用を読む【第5回/全8回】

本シリーズは、DeepSeek V4 Flash / Pro 専用推論エンジン DwarfStards4)のコードを読み解く連載です。
第5回は、ds4-server が OpenAI Chat Completions、OpenAI Responses、Anthropic Messages を受けながら、DeepSeek V4 の DSML ツール呼び出しと KV キャッシュをどう整合させているかを読みます。

主な参照箇所: README.md, ds4_server.c, ds4_kvstore.c, ds4.h

読みどころ: 互換 API の本質的な難所は HTTP ではなく、モデルが吐いた DSML とクライアントが返す正規化 JSON のバイト差が KV を壊す問題で、それを exact replay でどう防ぐかです。

連載「ds4.c を読み解く」全8回

TL;DR

  • ds4-server は OpenAI/Anthropic 互換 API を提供するが、内部のモデルは DeepSeek V4 の DSML ツール呼び出しテキストを生成する。
  • HTTP リクエストのパースとソケット I/O はクライアントスレッドが行う。推論は単一グラフワーカーに直列化され、ライブの ds4_session と KV 状態はワーカーが所有する。
  • 対応エンドポイントは /v1/chat/completions, /v1/responses, /v1/messages など。Codex CLI には Responses、Claude Code 系には Anthropic エンドポイントが想定されている。
  • 最大の問題は、モデルが生成した DSML テキストと、次リクエストでクライアントが送り返す正規化 JSON がバイト単位では一致しないこと。
  • 第一防衛線は exact replay。ツール呼び出し ID から、モデルが実際にサンプルした DSML ブロックを引き直す。
  • exact replay がない場合だけ、決定的な DSML 正準化(canonicalization)と KV チェックポイントの書き換え / ディスクへのフォールバックを使う。
  • ツール呼び出し生成中は、DSML の構文部分だけ temperature=0 にし、引数ペイロードは通常のサンプリングに戻す。

1. サーバは「互換 API」だが、中身は DSML

ds4-server は README 上で OpenAI/Anthropic 互換サーバとして紹介されています。

./ds4-server --ctx 100000 --kv-disk-dir /tmp/ds4-kv --kv-disk-space-mb 8192

対応エンドポイントは以下です。

GET  /v1/models
GET  /v1/models/deepseek-v4-flash
GET  /v1/models/deepseek-v4-pro
POST /v1/chat/completions
POST /v1/responses
POST /v1/completions
POST /v1/messages

それぞれの役割は次のように整理できます。

エンドポイント 想定クライアント 特徴
/v1/chat/completions OpenAI Chat Completions 互換クライアント OpenAI 形式の messages / tools
/v1/responses Codex CLI Responses イベントライフサイクル
/v1/messages Claude Code 系クライアント Anthropic tool_use / thinking
/v1/completions 旧来の補完 素に近い補完

ただし、内部の DeepSeek V4 は OpenAI の JSON ツール呼び出しを直接生成するわけではありません。モデルは DSML テキストを生成します。

DSML の形はざっくり次のような XML 風テキストです。

<|DSML|tool_calls>
<|DSML|invoke name="tool_name">
<|DSML|parameter name="path" string="true">README.md</|DSML|parameter>
</|DSML|invoke>
</|DSML|tool_calls>

サーバの仕事は、クライアントから来る OpenAI/Anthropic/Responses の JSON 世界と、モデルが扱う DSML テキスト世界を、KV キャッシュを壊さず往復させることです。


2. 単一グラフワーカーがライブ KV を所有する

ds4_server.c の冒頭のコメントは、サーバの並行モデルを端的に説明しています。

/* HTTP is intentionally simple: each client connection is handled by a small
 * blocking thread that parses one request, then queues a job to the single
 * Metal worker. The worker owns the ds4_session and therefore owns all live KV
 * cache state. */

クライアントスレッドはリクエストをパースし、ジョブキューに積みます。推論自体は単一ワーカーが行います。

これはスループット的には保守的です。README も、現在のサーバは複数の独立リクエストをバッチ化しないと説明しています。

一方で、ライブ KV 状態を一箇所に閉じ込める効果があります。

  • ds4_session を複数スレッドが同時に変更しない
  • プレフィックス再利用とディスクチェックポイントの判断をワーカーに集約できる
  • 将来バッチ化する場合もグラフ変更の責務が分散しない

ローカルのコーディングエージェント用サーバとしては、まず正しく長いセッションを維持することが優先されています。


3. ステートレス API と可変セッションの接続

チャット/Responses/Anthropic のクライアントは、基本的に毎回トランスクリプト全体を送ってきます。DS4 サーバ側は、前回のライブセッションと今回のプロンプトのプレフィックスを比較します。

ds4.h には、そのための API が用意されています。

int ds4_session_common_prefix(ds4_session *s, const ds4_tokens *prompt);
ds4_session_rewrite_result ds4_session_rewrite_from_common(...);
int ds4_session_sync(ds4_session *s, const ds4_tokens *prompt, ...);

ds4_server.c ではリクエスト処理中に次の流れを取ります。

  1. ライブセッションと新プロンプトのトークンレベルの共通プレフィックスを見る
  2. ライブチェックポイントが使えるならサフィックスだけ ds4_session_sync()
  3. ライブがミスならディスク KV キャッシュからレンダリング後バイトプレフィックスを探す
  4. ディスクヒットしたら DSV4 ペイロードを復元し、サフィックスだけトークナイズ/prefill
  5. それでもだめならトークン 0 から prefill

ここで第4回のディスク KV が効きます。トークン ID プレフィックスで一致しなくても、レンダリング後バイトプレフィックスが一致すればチェックポイントを復元できるからです。


4. なぜ DSML の exact replay が必要か

ツール呼び出しで問題になるのは、クライアントが「モデルが生成した正確な DSML テキスト」を次リクエストで返してこないことです。

モデルは前ターンで DSML テキストをサンプルしています。しかし OpenAI クライアントは次ターンで、例えば次のような正規化 JSON を送ります。

{
  "role": "assistant",
  "tool_calls": [
    {
      "id": "call_xxx",
      "type": "function",
      "function": {
        "name": "read_file",
        "arguments": "{\"path\":\"README.md\"}"
      }
    }
  ]
}

この JSON から DSML を再生成すると、以下のような差分が起こり得ます。

  • JSON オブジェクトのプロパティ順が変わる
  • 空白が変わる
  • 文字列のエスケープが変わる
  • string=true のパラメータ本体の表現が変わる
  • Anthropic と OpenAI でツール ID やブロック表現が変わる

この差分が 1 バイトでも起きると、レンダリング後プロンプトのバイト列が変わります。するとライブ KV チェックポイントは「そのバイト列を prefill した状態」ではなくなります。

この問題は、単なる整形の問題ではありません。KV キャッシュの整合性問題です。


5. exact replay: ツール ID からサンプル済み DSML ブロックを復元する

README は、第一防衛線を exact replay と説明しています。

モデルがツール呼び出しを生成すると、サーバは推測不能な API ツール ID を割り当てます。OpenAI なら call_...、Anthropic なら toolu_... のようなプレフィックスです。

ds4_server.c では random_tool_id() が API スタイルに応じてプレフィックスを変えます。

const char *prefix = api == API_ANTHROPIC ? "toolu_" : "call_";

その後、サーバは次の対応を覚えます。

tool id -> exact sampled DSML block

tool_calls 構造体には raw_dsml があり、プロンプトレンダラはこれが存在する場合、正準的な再生成ではなく生の DSML をそのまま出します。

static void append_dsml_tool_calls_text(buf *b, const tool_calls *calls) {
    if (calls->raw_dsml && calls->raw_dsml[0]) {
        buf_puts(b, calls->raw_dsml);
        return;
    }
    ...
}

これにより、クライアントが正規化 JSON を送り返しても、サーバはツール ID を見て、当時モデルが実際にサンプルした DSML バイト列を復元できます。

さらに第4回で見た通り、この tool-id map は KV キャッシュファイルのオプショントレーラにも保存できます。サーバ再起動後でも、キャッシュ済み履歴について exact replay が可能になります。

次ターンでツール結果が来たときの判定はこうです。


6. 正準化はバックアップ経路

正確な DSML ブロックが見つからない場合、サーバは決定的な DSML を JSON から作ります。これが正準化(canonicalization)です。

README は明確に、正準化はバックアップ経路だと書いています。

理由は簡単です。正準化は「今後はこの形に合わせる」処理であって、「過去にモデルがサンプルしたバイト列と同じ」とは限らないからです。

ds4_server.c には、ツールチェックポイントの正準化処理があります。流れは以下です。

  1. ライブのサンプル済みトークンストリームと正準プロンプトの共通プレフィックスを取る
  2. 完全一致なら何もしない
  3. 差分が小さければ ds4_session_rewrite_from_common() でライブチェックポイントを書き換える
  4. 必要ならディスク KV から古いチェックポイントをロードしてサフィックスだけ再生する
  5. それでもだめなら再構築

該当箇所では、次の API が使われています。

const int common = ds4_session_common_prefix(s->session, &canonical);
ds4_session_rewrite_from_common(s->session, &canonical, common, ...);

この設計は、ステートレスな JSON トランスクリプトとステートフルなサンプル済み KV の間にあるズレを、できるだけ局所的に直すためのものです。


7. DSML パーサは「実行可能な表面」だけを見る

DSML はテキストなので、モデルが説明文の中で DSML 例を出すこともありえます。thinking 内に DSML らしきテキストが出ることもあります。

ds4_server.c の生成メッセージパーサは、</think> の後、つまり実行可能なアシスタント表面に入った DSML だけをツール呼び出しとして扱う設計です。

コメントには、thinking が閉じられていない場合は reasoning 内の DSML を無視すると書かれています。

/* Model did not close thinking, ignore any DSML in reasoning */

また、DSML が途中で切れた場合の修復もあります。

/* Try to repair a truncated DSML block.
 * DSML nesting order is: tool_calls > invoke > parameter.
 */

ツール呼び出しのパースはユーザーに見えるテキストのパースではなく、実行可能なプロトコル表面の判定です。ここを誤ると、ただの説明文をツール呼び出しとして実行してしまいます。


8. ストリーミング中もモデルは DSML をサンプルしている

OpenAI や Anthropic のストリーミング API では、クライアントはツール呼び出しの JSON デルタを期待します。しかし DS4 のモデルは DSML バイト列を生成しています。

ds4_server.c のコメントはこの変換を説明しています。

/* Shared states for protocol-specific DSML stream projections. The model
 * still samples DSML; these states only translate already-sampled bytes into
 * OpenAI / Anthropic wire events ... */

つまり、ワイヤイベントは DSML からの投影です。

  • OpenAI Chat Completions では tool_calls[].function.arguments のデルタとして出す
  • Anthropic では最終的に tool_use ブロックとして出す
  • Responses では response.function_call_arguments.delta などのライフサイクルイベントとして出す

モデルのサンプリングストリームは DSML のまま保持されるため、後で exact replay に使えます。ワイヤプロトコルの見た目だけをクライアントに合わせています。


9. DSML 構文部は貪欲、ペイロードは通常サンプリング

ツール呼び出し生成でさらに面白いのは、サンプリングのポリシーが DSML の状態によって変わる点です。

ds4_server.c には DSML のデコード状態があります。

typedef enum {
    DSML_DECODE_OUTSIDE,
    DSML_DECODE_STRUCTURAL,
    DSML_DECODE_STRING_BODY,
    DSML_DECODE_JSON_STRUCTURAL,
    DSML_DECODE_JSON_STRING,
} dsml_decode_state;

ペイロードサンプリングを使ってよい状態は、文字列本体だけです。

static bool dsml_decode_state_uses_payload_sampling(dsml_decode_state state) {
    return state == DSML_DECODE_STRING_BODY ||
           state == DSML_DECODE_JSON_STRING;
}

生成ループでは、ツール呼び出し中かつペイロードサンプリング状態でなければ temperature を 0 にします。

if (in_tool_call && !dsml_decode_state_uses_payload_sampling(dsml_state)) {
    temperature = 0.0f;
}

これにより、DSML タグ、パラメータヘッダ、JSON 区切り、閉じマーカーのような構文部分は決定的になります。一方、ファイル内容や編集テキストのような長い引数ペイロードは通常のサンプリングのままです。

README も、この分離が重要だと説明しています。構文を決定的にするのはパース可能性に効きますが、長いコード/ファイル本体まで貪欲にすると繰り返しを招くことがあるからです。


10. Responses と Anthropic のライブツール継続

Responses API や Anthropic Messages には、ツール結果だけが次リクエストに来るライブ継続経路があります。

ds4_server.c には、Responses 用に次のようなフラグがあります。

bool responses_requires_live_tool_state;
bool responses_requires_live_reasoning;

Anthropic 側にも同様に:

bool anthropic_requires_live_tool_state;
stop_list anthropic_live_call_ids;
char *anthropic_live_suffix_text;

これは、見えているトランスクリプトだけでは復元できない隠れた thinking やサンプル済み DSML 状態がライブセッションに残っている場合、ツール結果の継続をライブ状態に束ねるためです。

ライブ状態が残っていれば高速経路。残っていなければ、クライアントに全履歴の再生を求めるか、ディスク KV / exact replay で復元を試みます。

ローカルのエージェントサーバでは、この「クライアントが見えているトランスクリプト」と「モデル内部でサンプル済みの隠れ状態」が一致しないことが多く、そこを丁寧に扱っているのが DS4 サーバの特徴です。


11. SSE ストリーミングは API ごとのネイティブ形状

README は、thinking モードでは reasoning を最終テキストに混ぜず、API ごとのネイティブ形状でストリームすると説明しています。

  • Chat Completions: ツール呼び出しデルタや content デルタ
  • Responses: response.output_text.delta、function-call の引数イベント、終端の completed/incomplete/failed イベント
  • Anthropic: thinking/text をライブストリームし、完了後に構造化された tool_use

ds4_server.c には responses_sse_* 関数群が並び、Codex が期待するシーケンス番号やライフサイクルを作ります。

ここでも内部表現は DSML とサンプル済みトークンで、ワイヤプロトコルは投影です。


12. 実際に繋ぐ: Claude Code と Codex

ここまでの仕組みは、既存のローカルコーディングエージェントからそのまま使えます。--ctx で起動した値以下にクライアント側のコンテキスト上限を設定するのが原則です。

以下の接続手順は README から変わりやすい箇所です。環境変数名やフラグは更新されることがあるので、実際に設定する際は最新 README の該当節(commit ba00a8a 時点を基にしています)を確認してください。
ここでは「概念例」として読んでください。

Claude Code:
(Anthropic 互換エンドポイントへ向けるラッパ例。README の ~/bin/claude-ds4 相当)

#!/bin/sh
unset ANTHROPIC_API_KEY

export ANTHROPIC_BASE_URL="${DS4_ANTHROPIC_BASE_URL:-http://127.0.0.1:8000}"
export ANTHROPIC_AUTH_TOKEN="${DS4_API_KEY:-dsv4-local}"
export ANTHROPIC_MODEL="deepseek-v4-flash"

export ANTHROPIC_CUSTOM_MODEL_OPTION="deepseek-v4-flash"
export ANTHROPIC_CUSTOM_MODEL_OPTION_NAME="DeepSeek V4 Flash local ds4"
export ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION="ds4.c local GGUF"

export ANTHROPIC_DEFAULT_SONNET_MODEL="deepseek-v4-flash"
export ANTHROPIC_DEFAULT_HAIKU_MODEL="deepseek-v4-flash"
export ANTHROPIC_DEFAULT_OPUS_MODEL="deepseek-v4-flash"
export CLAUDE_CODE_SUBAGENT_MODEL="deepseek-v4-flash"

export CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1
export CLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK=1
export CLAUDE_STREAM_IDLE_TIMEOUT_MS=600000

exec "$HOME/.local/bin/claude" "$@"

Sonnet/Haiku/Opus のエイリアスをすべて deepseek-v4-flash に向け、サブエージェントモデルも同じにそろえます。CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFICCLAUDE_CODE_DISABLE_NONSTREAMING_FALLBACK でローカルサーバが扱わない経路を無効化し、CLAUDE_STREAM_IDLE_TIMEOUT_MS でローカル推論の長い無音時間に備えます。

Codex CLI(Responses ワイヤ API):

[model_providers.ds4]
name = "DS4"
base_url = "http://127.0.0.1:8000/v1"
wire_api = "responses"
stream_idle_timeout_ms = 1000000
codex --model deepseek-v4-flash -c model_provider=ds4

opencode / Pi 用のプロバイダ設定例は README に揃っています。Claude Code は初回に 25k トークン規模の大きなプロンプトを送ることがあるので、--kv-disk-dir を有効にしておくと、最初の高コストな prefill を後続セッションで再利用できます(第4回参照)。


13. この記事の要点

ds4-server の難しさは「HTTP サーバを書くこと」ではありません。
難しいのは、次の 3 つを同時に満たすことです。

  1. OpenAI/Responses/Anthropic のステートレス JSON API と互換に見せる
  2. DeepSeek V4 の DSML ツール呼び出しテキストをそのままモデルに扱わせる
  3. ライブ KV チェックポイントとレンダリング後プロンプトのバイト列を壊さない

そのための設計が、

  • 単一グラフワーカー
  • 共通プレフィックス再利用
  • レンダリング後バイトの SHA1 によるディスク KV
  • ツール ID による exact なサンプル済み DSML replay
  • バックアップとしての正準化 / チェックポイント書き換え
  • DSML 構文だけ貪欲にするサンプリング切り替え
  • API ごとの SSE 投影

です。

次回は、1 台のマシンを超える話に進みます。DS4 の分散推論は、コーディネータが全処理を中継するのではなく、ワーカーが連続した層スライスを持ち、隠れ状態を TCP でパイプラインする構成です。


本記事は クイックイタレート株式会社 のローカル LLM 研究の一環として、
公開リポジトリ antirez/ds4 のコードを読み解いたものです。行番号・定数・ベンチ値は閲覧コミット ba00a8a(2026-05-30)/README 取得日 2026-06-01 時点のものです。ds4-agent は alpha、エンジン本体は beta 品質で活発に変化するため、引用箇所は各自で最新の README / ソースに当たって再確認してください。


クイックイタレート株式会社
IoT / 電力監視 / AI / 衛星・無線通信 / システムインテグレーション/
ローカル LLM・エージェント基盤に関するお問い合わせはお気軽にどうぞ。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?