DeepSeek HarnessをQwenとDeepSeekにつないでコード修正を試した
目次
AIモデルの指示でファイルを編集したりコマンドを実行したりするDeepSeek Harness(DSH)を、M4 Mac miniで動かしてみた。
DeepSeek製のツールだが、公式のモデル設定ガイドには他社APIへの接続方法も載っている。
今回はモデルをAPIで呼び出せるModelScopeを使い、QwenとDeepSeekに同じプログラムの修正を頼んでみた。
DSHとモデルの役割
モデルは次に何をするかを判断し、DSHはその指示を受けてファイルを編集したり、コマンドを実行したりする。
実行結果をモデルへ返し、必要なら次の操作を繰り返す。DSHのように、モデルとツールのやり取りを管理するソフトウェアをハーネスと呼ぶ。
DSHのアーキテクチャ資料によれば、モデル接続、ツール、会話履歴、処理を繰り返すループまでプラグインとして実装されている。
公式サイトではMacのApple Silicon向けとWindowsの64ビット向けデスクトップ版も配布している。
今回は同じプロンプトで繰り返すため、画面を使わずに一件の作業を実行して終了するheadlessプロファイルを使った。プロファイルは、使う機能と設定をまとめたものだ。
M4 Mac miniとModelScopeで実行
測定日は2026年10月13日。モデルの推論はModelScope側で行い、MacではDSHと修正対象のNode.jsプログラムを動かした。
| 項目 | 条件 |
|---|---|
| ローカル環境 | Apple M4、メモリ16GB |
| Node.js | 22.22.0 |
| DSH | @deepseek-ai/dsh@0.2.0-rc.2 |
| 接続先 | https://api-inference.modelscope.ai/v1 |
| Qwen | Qwen-Ambassador/Qwen3.8-Flash-Next |
| DeepSeek | deepseek-ai/DeepSeek-V4.1-Flash |
| 実行方法 | headless、通常のツール呼び出しを使うnative設定 |
| ファイルの書き込み権限 | 作業フォルダーへの書き込みを許可するworkspace-write |
| 一回の出力上限 | 両モデルとも8,192トークンに設定 |
トークンは、モデルが文章を処理するときの単位。文字数とは数え方が異なる。
DSHは版を固定してインストールした。
npm install --prefix .dsh-work/runtime --save-exact @deepseek-ai/dsh@0.2.0-rc.2
この版のpackage.jsonが指定するNode.jsの範囲は^22.19.0 || >=24.0.0。
DSHの設定と会話履歴は専用ディレクトリへ分けた。実験に無関係な指示を読み込ませないよう、指示ファイルやスキルを読み込むagent-instructionsとskill-filesystemも無効にした。
ModelScopeへの接続設定
DSHにはOpenAIと同じ形式でリクエストや応答を扱うAPIへ接続するアダプターがあり、今回は接続先をmodelscope-experimentという名前で登録した。設定からQwenの接続部分を抜粋すると、こんな感じ。
- id: llm-pi-ai
config:
providers:
modelscope-experiment:
apiKeyEnv: DSH_EXPERIMENT_API_KEY
api: openai-completions
baseURL: https://api-inference.modelscope.ai/v1
compat:
supportsDeveloperRole: false
maxTokensField: max_tokens
models:
- id: Qwen-Ambassador/Qwen3.8-Flash-Next
apiKeyEnvには、APIキーを設定する環境変数の名前を指定した。今回は実行スクリプトが、DSHを起動するときにこの名前でキーを設定している。
compatはリクエスト形式の調整で、指示文にdeveloperという役割を使わないようにし、出力上限の項目名にはmax_tokensを指定している。この部分は両モデルで共通にした。
実行モデルの選択や権限などを含む設定全体は、再現用コードの実行スクリプトに入れた。
今回はQwenアンバサダー用のAPIキーを使っており、利用できるモデルIDはキーによって異なる。
記事一覧を作るプログラムに3件の不具合を入れた
修正対象には、記事の公開日時やファイル名から一覧を作る15行のJavaScriptプログラムを用意し、DSHの動作確認用に不具合を入れた。
| 処理 | 入れておいた不具合 | 期待する動作 |
|---|---|---|
| 公開日時の判定 | 現在時刻と同じ日時の記事を除外する | 同時刻も公開対象に含める |
| 新しい順への並べ替え | 日時を文字列のまま比較する | タイムゾーンを含めた実際の時刻で比較する |
| ファイル名の抽出 | パス区切りが/の場合しか処理しない | Windowsの\も処理する |
README.mdに仕様を書き、9件のテストを用意した。修正前は6件成功・3件失敗した。
あらかじめ用意した正しい修正例では9件すべて成功することも確認した。
最初にQwenへこのREADME.mdと実装の要約を頼むと、ファイルを読み取り、日本語で説明が返ってきた。
このときはテストも実行していて、起動から終了まで55.9秒。
修正の試行では、各回でプログラムを初期状態からコピーし、新しい会話へ同じプロンプトを送った。
このプロジェクトのREADME.mdを読んで仕様を確認し、npm testを実行して不具合を調べてください。
src/catalog.jsを修正して、既存のテストをすべて成功させてください。
既存のテストファイルやpackage.jsonは変更しないでください。
実際のプロンプトには、修正後の再テストと日本語での報告、外部パッケージの追加不要、作業対象の指定も含めた。全文は再現用コードのtask.txtに保存した。
DSHが終了したあと、モデルの報告とは別に、こちらでも9件のテストを再実行した。
作業フォルダーに入れなかった追加3件でも、別の日時やファイル名を正しく処理できるか確認し、README.md、package.json、既存テストは元のファイルと比較した。
QwenとDeepSeekを3回ずつ実行した結果
初回はQwen、DeepSeekの順で動かし、その後も交互に1回ずつ実行した。
出力のばらつきに関わる温度・シードと、モデルの思考量を調整する推論強度は指定しないで、接続先の既定値を使った。
表の「モデル呼び出し」はモデルへ判断を求めた回数、「ツール呼び出し」はファイル操作やコマンド実行などを呼び出した回数だ。一つのプロンプトに対して、モデルの判断とツール操作が何回か繰り返されている。
| モデル | 回 | 起動から終了まで | モデル呼び出し | ツール呼び出し |
|---|---|---|---|---|
| Qwen3.8 Flash-Next | 1 | 76.9秒 | 8回 | 10回 |
| Qwen3.8 Flash-Next | 2 | 91.9秒 | 10回 | 12回 |
| Qwen3.8 Flash-Next | 3 | 67.3秒 | 8回 | 10回 |
| DeepSeek V4.1 Flash | 1 | 48.5秒 | 8回 | 11回 |
| DeepSeek V4.1 Flash | 2 | 47.9秒 | 7回 | 10回 |
| DeepSeek V4.1 Flash | 3 | 37.3秒 | 6回 | 10回 |
全6回で既存9件と追加3件のテストがすべて成功した。変更はsrc/catalog.jsだけで、追加ファイルはなく、README.md・package.json・既存テストも元のままだった。
この課題を3回ずつ実行した時間の平均と、ばらつきの大きさを表す標本標準偏差を計算した。Qwenは78.7秒±12.4秒、DeepSeekは44.5秒±6.3秒。
この時間にはDSHの起動、APIの応答待ち、ツール実行、最終回答を含んでいる。
Qwenの1回目は、ファイルを読み込んで修正前のテストを実行し、writeツールで実装全体を書き換えていた。
DeepSeekの1回目は、editツールで該当箇所を変更した。どちらも、編集後にnpm testを実行してから回答していた。
QwenとDeepSeekの修正コード
Qwenの1回目の差分はこんな感じ。-が修正前、+が修正後の行を表す。
+ const nowMs = Date.parse(now);
- .filter((record) => Date.parse(record.publishedAt) < Date.parse(now))
- .sort((a, b) => b.publishedAt.localeCompare(a.publishedAt))
+ .filter((record) => {
+ const publishedMs = Date.parse(record.publishedAt);
+ return Number.isFinite(publishedMs) && publishedMs <= nowMs;
+ })
+ .sort((a, b) => Date.parse(b.publishedAt) - Date.parse(a.publishedAt))
- const slug = record.source.split('/').at(-1).replace(/\.md$/, '');
+ const filename = record.source.split(/[/\\]/).at(-1);
+ const slug = filename.replace(/\.md$/, '');
Qwenは現在時刻を変換した結果を変数に代入し、不正な日時をNumber.isFiniteで除外していた。
DeepSeekの1回目は、比較式とパスの区切りを変更していた。
- .filter((record) => Date.parse(record.publishedAt) < Date.parse(now))
- .sort((a, b) => b.publishedAt.localeCompare(a.publishedAt))
+ .filter((record) => Date.parse(record.publishedAt) <= Date.parse(now))
+ .sort((a, b) => Date.parse(b.publishedAt) - Date.parse(a.publishedAt))
- const slug = record.source.split('/').at(-1).replace(/\.md$/, '');
+ const slug = record.source.split(/[\\/]/).at(-1).replace(/\.md$/, '');
どちらもDate.parseでタイムゾーンを含む日時の文字列を数値へ変換し、新しい順への並べ替えには数値の差を使っている。パスの分割では/と\の両方を区切りにしている。
再現用コードと各回の記録には、修正前のプログラム、共通プロンプト、評価用テスト、実装の差分、DSHのツール呼び出し記録を置いた。
コマンドラインの履歴をブラウザでも確認
DSHにはブラウザで操作する画面もある。測定後に同じ会話保存先で起動すると、コマンドラインで実行したQwenの会話も表示できた。
