AI Shift Tech Blog
🏠

Local Coding Agent が身近なタスクをどれくらいこなせるのかを検証した

に公開1

こんにちは
AIチームの戸田です

最近のLocal LLM の進化はかなり目を引きます。
フロンティアモデルを見ると、2026年4月にリリースされたClaude Opus 4.7はSWE-bench Verifiedで 87.6% という水準まで到達しました。

https://www.anthropic.com/news/claude-opus-4-7

GPT-5 系や Gemini 3.1 Pro も同じ帯域で並びます。一方で、こうしたモデルはどれもクラウド経由でしか使えず、手元で動かすには現実的でないサイズです。

そして同じ4月、QwenがQwen3.6-27BというOpen Weightなモデルを公開しました。SWE-bench Verified で 77.2%、Qwen 公式の比較表では Claude 4.5 Opus (80.9%) と 3.7pt 差です。

https://qwen.ai/blog?id=qwen3.6-27b

注目すべきは、4bit 量子化すれば 約 24GB に収まるため、64GB の MacBook なら普通に動かせるサイズということです。Hacker News では M5 Pro 128GB で実際に動かして「20GB しか使わなかった、32GB 機でも問題なさそう」 という報告もあります。

https://news.ycombinator.com/item?id=47863217

そして harness 側、つまり LLM に「ファイルを読む / 編集する / コマンドを実行する」といった道具を使わせる実行環境 (OpenCode、Qwen Code、Claude Code、Aider など) もここ半年で大きく進化しています。

ここで気になるのが、「モデルの数字は SWE-benchで散々見たが、実際に MacBook 上で動かしたとき、どのくらい実用的な仕事ができるのか?」ということです。SWE-bench Verified は良いベンチマークですが、自分の手元のリポジトリでどう動くかの実感は別問題です。

そこで、検証のため現在開発しているプロダクトに近い形のダミーのリポジトリを作成し、性質の違う6タスクを Qwen3.6-27B + OpenCode に投げてみました。OpenCodeを選択したのは OpenAI 互換 endpoint を指定するだけで、Local Modelでもそのまま使えるという実装の容易さからです。

本記事はその検証の結果を共有します。

検証設定

実行環境

検証は以下の環境で実施しました。

項目 内容
マシン MacBook Pro M3 Max 64GB
モデル unsloth/Qwen3.6-27B-UD-MLX-4bit(約 24GB)
推論サーバ mlx-lm を OpenAI 互換 server として起動
harness OpenCode (provider を OpenAI 互換 endpoint として mlx-lm に向ける構成)
対象リポジトリ TypeScript + React + Hono を中心とした、自社プロダクト相当のダミーリポジトリ

起動コマンドは概ね以下のような形です。

# 推論サーバの起動
mlx_lm.server \
  --model unsloth/Qwen3.6-27B-UD-MLX-4bit \
  --port 8080

# タスク実行
npx --yes opencode-ai@1.14.30 run "<task>" \
  --model "mlx//path/to/workspace/models/Qwen3.6-27B-UD-MLX-4bit"

タスク

タスクは以下の6つです。各タスクには「最初から失敗するテスト」を仕込んであり、Agentがそれを通すことができれば Pass、できなければ Fail です。

No タスク名 求められる能力
1 ヘッダー整列 局所UIの編集
2 クエリ正規化 入力正規化 + 境界値テスト
3 URL自動判定 関数を切り出して既存コードに繋ぐ
4 上限設定 既存のレイヤー構造を読めるか
5 フィルタリング API / Web / 自動生成 client の三層同期
6 CRUD 0→1 フルスタック実装

なお、以下の各タスクで折りたたみ内に示すコードは、私が事前に用意した想定実装例で、LLM が実際に生成したコードではありません。Agent の出力はこれと一致する必要はなく、仕込んだテストを通せば Pass としています。LLM の実際の出力で気になった挙動については「結果」セクションで触れます。

それぞれ簡単に補足します。

1. ヘッダー整列

retrospect 詳細画面の Edit/Delete ボタンが別 wrapper に分かれているので、同じ data-testid="header-actions" グループに入れる。

想定実装例
   return (
     <header>
       <h1>Retrospect detail</h1>
-      <div data-testid="edit-action">
-        <Dialog label="Edit retrospect">Edit</Dialog>
-      </div>
       <div data-testid="header-actions">
+        <Dialog label="Edit retrospect">Edit</Dialog>
         <Dialog label="Delete retrospect">Delete</Dialog>
       </div>
     </header>

2. クエリ正規化

?status=TODO,DONE と ?status=TODO&status=DONE を同じ配列に正規化。empty segment、unknown status、dedupe まで含む。

想定実装例
export const statusSchema = z.enum(["TODO", "DOING", "DONE"]);
 
+const validStatuses = statusSchema.options;
+
 export const listQuerySchema = z.object({
   status: z
-    .union([statusSchema, z.array(statusSchema)])
+    .union([z.string(), z.array(z.string())])
     .optional()
-    .transform((value) => {
+    .transform((value, ctx) => {
       if (value === undefined) {
         return [];
       }
 
-      return Array.isArray(value) ? value : [value];
+      const items = Array.isArray(value) ? value : [value];
+      const flattened = items.flatMap((item) => item.split(",")).filter((s) => s !== "");
+
+      for (const s of flattened) {
+        if (!validStatuses.includes(s as (typeof validStatuses)[number])) {
+          ctx.addIssue({
+            code: z.ZodIssueCode.custom,
+            message: `Invalid status value: ${s}`,
+          });
+          return z.NEVER;
+        }
+      }
+
+      return Array.from(new Set(flattened));
     }),
 });

3. URL 自動判定

Zoom / Google Meet / Microsoft Teams の URL を見て meeting platform を自動選択。

想定実装例
 export type MeetingPlatform = "zoom" | "google_meet" | "microsoft_teams" | "unknown";
 
-export function detectMeetingPlatform(_url: string): MeetingPlatform {
+export function detectMeetingPlatform(url: string): MeetingPlatform {
+  try {
+    const hostname = new URL(url).hostname.toLowerCase();
+    if (hostname.includes("zoom.us")) return "zoom";
+    if (hostname.includes("meet.google.com")) return "google_meet";
+    if (hostname.includes("teams.microsoft.com") || hostname.includes("outlook.office.com")) {
+      return "microsoft_teams";
+    }
+  } catch {
+    // not a valid URL
+  }
   return "unknown";
 }

   return {
     onlineUrl: input.onlineUrl,
-    platform: input.platform ?? "unknown",
+    platform: input.platform ?? detectMeetingPlatform(input.onlineUrl),
   };
 }

4. 上限設定

tenant + department 単位で topic 数を特定の件数で頭打ちにし、超えたら 409 Conflict を返す。

想定実装例
-export function canCreateTopic(_topics: Topic[], _scope: { tenantId: TenantId; departmentId: DepartmentId }): TopicLimitResult {
+export function canCreateTopic(topics: Topic[], scope: { tenantId: TenantId; departmentId: DepartmentId }): TopicLimitResult {
+  const count = topics.filter(
+    (t) => t.tenantId === scope.tenantId && t.departmentId === scope.departmentId,
+  ).length;
+  if (count >= TOPIC_LIMIT_PER_SCOPE) {
+    return { ok: false, reason: "topic_limit_exceeded", limit: TOPIC_LIMIT_PER_SCOPE };
+  }
   return { ok: true };
 }
-  canCreateTopic(input.existingTopics, {
+  const result = canCreateTopic(input.existingTopics, {
     tenantId: input.tenantId,
     departmentId: input.departmentId,
   });
 
+  if (!result.ok) {
+    return { status: 409, body: { code: "TOPIC_LIMIT_EXCEEDED", limit: result.limit } };
+  }
+
   return { status: 201, body: { title: input.title } };
 }

5. フィルタリング

includeOtherDepartments=false のとき、DBクエリ時点で他部署を除外する。

想定実装例
 export function listRetrospects(repo: RetrospectRepository, query: RetrospectQuery): Retrospect[] {
-  const rows = repo.findMany({
+  const baseQuery = {
     offset: (query.page - 1) * query.pageSize,
     limit: query.pageSize,
-  });
+  };
 
   if (query.includeOtherDepartments === false) {
-    return rows.filter((row) => row.departmentId === query.userDepartmentId);
+    return repo.findMany({ ...baseQuery, departmentId: query.userDepartmentId });
   }
 
-  return rows;
+  return repo.findMany(baseQuery);
 }
       get: {
-        parameters: ["page", "pageSize"],
+        parameters: ["page", "pageSize", "includeOtherDepartments"],
       },
 export type RetrospectListParams = {
   page: number;
   pageSize: number;
+  includeOtherDepartments?: boolean;
 };
 export type RetrospectListSearch = {
   page: number;
   pageSize: number;
+  includeOtherDepartments?: boolean;
 };
@@
     page: input.page,
     pageSize: input.pageSize,
+    includeOtherDepartments: input.includeOtherDepartments,
   };
 }

6. CRUD

CRUD と settings UIを新規実装する。

想定実装例
 export type Template = {
   id: string;
   tenantId: string;
   title: string;
   body: string;
 };
 
+type CreateInput = {
+  tenantId: string;
+  title: string;
+  body: string;
+};
+
+type UpdateInput = {
+  tenantId: string;
+  id: string;
+  title: string;
+  body: string;
+};
+
+let nextId = 1;
+const store = new Map<string, Template>();
+
 export class TemplateService {
-  list(_tenantId: string): Template[] {
-    return [];
+  create(input: CreateInput): Template {
+    const template = { id: String(nextId++), ...input };
+    store.set(template.id, template);
+    return template;
+  }
+
+  list(tenantId: string): Template[] {
+    return Array.from(store.values()).filter((t) => t.tenantId === tenantId);
+  }
+
+  update(input: UpdateInput): Template {
+    const existing = store.get(input.id);
+    if (!existing || existing.tenantId !== input.tenantId) {
+      throw new Error("Template not found");
+    }
+    const updated = { ...existing, title: input.title, body: input.body };
+    store.set(input.id, updated);
+    return updated;
+  }
+
+  delete(input: { tenantId: string; id: string }): { ok: boolean } {
+    const existing = store.get(input.id);
+    if (!existing || existing.tenantId !== input.tenantId) {
+      throw new Error("Template not found");
+    }
+    store.delete(input.id);
+    return { ok: true };
   }
 }
   return (
     <main>
-      <h1>agent</h1>
+      <h1>templates</h1>
+      <button type="button">New template</button>
+      <label>
+        Template title
+        <input type="text" />
+      </label>
+      <label>
+        Template body
+        <textarea />
+      </label>
     </main>
   );
 }

結果

No タスク名 結果 時間
1 ヘッダー整列 Pass 6m59s
2 クエリ正規化 Pass 11m27s
3 URL自動判定 Pass 16m50s
4 上限設定 Pass 11m40s
5 フィルタ Pass 34m59s
6 CRUD Pass 45m40s

時間はかかったものの全てPassし、実装の質はおおむね妥当でした。以下でログを見て気になった部分をいくつか紹介します。

2. クエリ正規化

最初 z.enum (Zod の列挙型 schema) に寄せようとして失敗しましたが、途中で「これは preprocessing で flatten / validate / dedupe を自前で書くほうが素直」と方針を切り替え、テストを通しました。自己修正できた例と言えるのではないでしょうか。

5. フィルタリング

pnpm openapipnpm gen:api-client を実際に走らせるところまでできていました。途中で一度 departmentId: undefined を常に query object に含める実装にしていましたが、テスト期待値とのズレを Agent 自身が気づいて、object shape を分岐する形に直しました。Passはしたものの処理時間は約35分で、複数のレイヤーにまたがるタスクは精度よりも時間が厳しそうでした。

Claude Code + Sonnet 4.6 で同じ実験

ここで対照実験のためClaude Codeでも同じ環境で実験してみました。概算ですがAPI利用量も合わせて載せます。

No タスク名 結果 時間 コスト
1 ヘッダー整列 Pass 22s $0.13
2 クエリ正規化 Pass 1m23s $0.32
3 URL自動判定 Pass 34s $0.12
4 上限設定 Pass 1m06s $0.23
5 フィルタ Pass 57s $0.20
6 CRUD Pass 1m53s $0.39

6タスクをまとめて、最初の実験のOpenCode + Qwen3.6-27Bと比較すると以下のようになります。

OpenCode + Qwen3.6-27B Claude Code + Sonnet 4.6
API コスト $0.00 $1.39
処理時間 約 2 時間 約 6 分
データの外部送信 なし あり

約20倍速く、コストは $1.39、という結果でした。

まとめ

Qwen3.6-27B + MLX + OpenCode は、手元の MacBook で「動く」だけでなく、性質の違う 6 タスクをひとまず通せる水準でした。

一方処理時間はCladue Codeの20倍とも言えるので、他作業をしながら 30〜60 分単位で待てる小〜中規模タスクなら任せられる水準という印象でした。無料でデータを外に出さずに動くというのも環境によっては利点になるかもしれません。

個人的な感想ですが、実務で使うにはまだ実行速度の面で難しそうだと感じました。ここもベースモデルの小型化とハードウェアの進化で解決できるのでしょうか?引き続きウォッチを続けたいと思います。

最後までお読みいただきありがとうございました!

参考

AI Shift Tech Blog
AI Shift Tech Blog

Discussion

arawiarawi

こんにちは!記事楽しく読ませていただきました。
こちらの検証ですが、ThinkingはONでしたでしょうか?
Qwen3.6ですが、Thinking ONだと結構考える印象があり、性能面でもThinking OFFと大きな差はないという噂も聞いています。
もしかすると結果変わらず速くなったりするのかな?と思いました。

また、もしよければ35B-A3Bの方もぜひ試してほしいです!
こちらは27Bに賢さは劣りますが個人的に実用に足るほどのスピードが出る(Thinking OFFで)と感じており、今回のタスクたちに対してどこまで太刀打ちできるのか気になりました。

もちろん余裕があればで大丈夫ですので、ぜひお試しいただけると嬉しいです🙌

1