🤖

プログラミング用途の生成AI関連ツールの評価 2025/04/14

2025/04/14に公開
1

現時点で個人の感想です。流動的なので、明日にでも意見は変わってると思います。

モデル

  • Claude-3.7-sonnet
    • コーディング性能が圧倒的に良い。迷ったらとりあえずこれを使っておけばよい
    • だいたい1ファイル1000行ぐらいが管理できる限界
  • Gemini 2.5
    • 今なら無料で使える。今のうちに使い込んでクセを把握するといい。
    • 巨大コンテキスト理解ができるので、「大量にコードを読んでちょっとだけコードを書く」つまり一般的な業務プログラミングに向いてる。
    • リリースから一週間は負荷が高くて不安定だったが、最近安定してきた
    • さすがに単純なコーディング性能は Claude-3.7-sonnet に劣る
  • deepseek-chat
    • Cline で使うには遅すぎて役に立たない
    • AIツール作るときの壁打ちに使っている。雑に巨大データ送りつけても安くて安心

コーディングエージェント/拡張

  • Cline
    • いわゆる本家
    • 自分はRooを優先してるが、Roo はその日のパッチ次第で盛大に壊れてるので、そのときに本家を使う
    • MCP Merket Place が有利
  • RooCode
    • 分家だが、正直もう別物になってる
    • Custom Mode を自分で使えるのが便利(というか本家がPlan/Codeしかないの使いづらい)
    • diff 適用が壊れがちで、パッチによっては動かない日がある
  • Gemini Code Assist
  • Copilot Agents
  • Copilot
  • Claud Code
    • vscode ではなく CLI のワークフロー
    • git status ベースにコンテキストを絞るので、巨大コンテキストに対する業務プログラミングに向いてる
    • GH PR/git commit/git status をプロンプトに使うので、真面目に git workflow やってないと機能しづらい
    • 確かに真面目にやるならいいんだけど、自分は雑に結構書き散らしてから後でコミット作るタイプなので、合わなかった

使用者が多いのはわかっているが、 Cursor は全然自分のスタイルに合わずに噛み合わなくて評価できない。なんかびっくりするぐらい肌に合わない。

MCP

今のところは信用できるものが全然ないので、基本コミュニティのものは使わず、自作してる。

playwright-mcp と brave-search だけたまに使っている

自作MCPというかツール実装はこの辺

https://github.com/mizchi/ai-toolkit/tree/main/tools

TypeScript 独特の都合

  • とにかく .ts の実行が下手
    • tsx, npx, type: module あたりを一生ガチャガチャやって壊す
  • 言わないとリファクタしない。表向き動いたりテスト通ったらユーザーに終わったと言ってくる
  • 全体的に try catch 書きすぎて品質が悪くなることが多い
    • 親でまとめてキャッチしたくても、コンテキストウィンドウが狭いので執拗に try catch で握りつぶして逆にコード全体の一貫性がなくなる
  • 一度プリントデバッグ書いたらプリントデバッグしていいんだと思い始め、大量のログを生成したのを自分で食ってコンテキスト上限超えて止まってる
    • 休憩前にタスク投げてると大抵これで止まってる
  • jQuery lodash のような古いライブラリを一度でも参照してしまうと、その連鎖で一気に秩序がなくなる。早期に人間が介入する必要がある。

プロンプトの書き方

あればあるほどいいのではなく、あんまり書かないようになってきた。

例えばTDDをやりたいとき、TDDのことについて詳しく書くのではなく(AIは学習済みだとする)単に TDDをやる とだけ書いてる。

最初の一件目のテストの書き出しの Oneshot の例示ぐらいはする。

// test は vitest で実装する
import {test, expect} from "vitest";
test("1+1=2", () => {
  expect(1+1, 2);
});

決定版のプロンプトがあるわけではなく、タスクを回してうまくいかなかったことを補正する、という感じで足していく。

プロジェクト全体で優先したいライブラリなどがあるなら書くといい程度。

その代わりに、docs 以下にナレッジを貯める

docs/
  how-to-test-with-lighthouse.md

実際のワークフロー

Cline で各ソースコードを人間に見立てるイメージで、X でリプライしまくる感じで参照リソースを明示する。

@/src/get-perf.ts を @/docs/how-to-test-with-lighthouse.md に従って書き直して

汎用プロンプトを書くのが減ってる理由は docs 側が増えてるから。

実際にはこういうコーディング手順になる。

  • 最大1000行ぐらい目安に Claude で実装検証用のコードを作らせる
  • 出来上がったコードに対して、Claude でユニットテストを書かせる
  • 動作を確認したら、自分でリファクタする
  • Gemini に既存のコードベースに対して組み込ませる
  • Gemini にインテグレートテストを書かせる
  • リファクタして十分にモジュール境界を分離できたら(コンテキストが狭くなったら)、Claude にレビューしてもらう
  • 得られた教訓を .clinerules あるいは docs/*.mdに反映

いわゆるバイブコーディングから入って、動いてから自分でリファクタする、あるいはそれを見本に全部書き直すという感じになっている。「やりたかったことをこのライブラリで実現できるか」という事実確認が一番価値がある。

対象を実現するためのAPIがわかっていれば、その事実だけで自分には十分なので、ゼロから書き直せる。

リファクタとコンテキストウィンドウ

一番困るのは、AI は大規模コードを管理する視点がなさそうに見えること。

モデルの訓練段階でとりあえず動いたという事実をゴールに訓練されているように見えており、たぶん「悪い設計の臭い」みたいな概念を獲得していない。

とはいえ「良い品質のコード」に一般解がないのだが、プロジェクトを破綻させたくないなら、とにかく人間が介入してリファクタする必要がある。

悪いコードはどんどん伝搬する。AI も堕落するので、妥協してはいけない。現時点のAIは自分の要求品質に満たず、実際にどんどん破綻していく。

うまく管理できるのは1000行ほどで、コンテキストが不足する場合は 3000 行あたりで限界になる。

  • Claude はコンテキストが入らず、1000行ぐらいのスクリプトを修正し続けては $10 ぐらいで限界を迎えて止まる
  • Gemini 2.5 はコンテキストウィンドウは大きいのでコード理解はできるが、生成するコードの品質が悪いので、コード品質面で破綻して動かすところにたどり着けない

あと、AI は元コードを尊重しすぎるので大胆なリファクタには向かない感がある。

感想

  • 良い面
    • 複雑なライブラリ(例: lighthouse, puppeteer)を自分に用途に合わせて使えるコードを作るのが、明らかに速くなった
    • データ分析のワークロードが特に速い
    • 既存コードに対して何らかのアルゴリズムを適用するのはAIの独壇場
      • 例えば「クロールしてきたドキュメントの重要度を判定するのに、リンク構造からページランクアルゴリズムで重みを付けて」など
  • 悪い面
    • 参照するドキュメントを明示的に指示すると品質が上がるが、ドキュメントを揃えるの自体が大変だし、指示する人間が大変
    • 既存コード理解は本当に難しい。指示するのが人間の負荷に直結する。
    • 生成コードを高速に目視レビューしてるせいで、最近ずっと目が痛い

おまけ: CLI の雛形

Cline以外にも Function Calling を前提とした対話的な CLI ツールを無限に作っている。

必要なパターンをわかってるので、それを実装した 350行ぐらいのDenoコードがある。これを使いまわして色々作ってる。

※ bashTool はだいぶ危険なので、理解できないなら使わないように

/**
 * No local dependency chat example with ai-sdk
 */
import {
  streamText,
  tool,
  jsonSchema,
  ToolResult,
  Tool,
  type CoreMessage,
} from "ai";
import { anthropic } from "@ai-sdk/anthropic";
import { google } from "@ai-sdk/google";
import { deepseek } from "@ai-sdk/deepseek";
import { parseArgs } from "node:util";
import { extract, toMarkdown } from "@mizchi/readability";
import path from "node:path";

async function runCommand(
  command: string
): Promise<{ code: number; stdout: string; stderr: string }> {
  const [cmd, ...args] = command.split(/\s+/g);
  const c = new Deno.Command(cmd, {
    args,
    stdout: "piped",
    stderr: "piped",
  });

  const { stdout, stderr, code } = await c.output();
  const decoder = new TextDecoder();
  return {
    code,
    stdout: decoder.decode(stdout),
    stderr: decoder.decode(stderr),
  };
}

const SYSTEM = `
あなたはユーザーの質問に答えるアシスタントです。
ユーザーの質問に答えるために、必要に応じてツールを使用してください。
URL のみを渡された場合、その URL の内容を読み取って、要約してください。
<environment>
  pwd: ${Deno.cwd()}
</environment>
`
  .split("\n")
  .map((line) => line.trim())
  .join("\n");

// getModelByName 関数をここに定義
export function getModelByName<
  T extends
    | Parameters<typeof anthropic>[0]
    | Parameters<typeof google>[0]
    | Parameters<typeof deepseek>[0]
>(model: T, settings?: any) {
  if (model === "claude") {
    return anthropic("claude-3-7-sonnet-20250219", settings);
  }
  if (model === "gemini") {
    return google("gemini-2.5-pro-exp-03-25", settings);
  }
  if (model === "deepseek") {
    return deepseek("deepseek-chat", settings);
  }
  if (model.startsWith("claude-")) {
    return anthropic(model, settings);
  }
  if (model.startsWith("gemini-")) {
    return google(model, settings);
  }
  if (model.startsWith("deepseek-")) {
    return deepseek(model, settings);
  }
  throw new Error(`Model ${model} not supported`);
}

/// Tools
export const bashTool = tool({
  description: `
  bash コマンドの実行をユーザーに提案します。
  ユーザーはコマンドを確認してからコマンドの実行を行います。拒否されることがあります。
  `.trim(),
  parameters: jsonSchema<{ command: string }>({
    type: "object",
    properties: {
      command: {
        type: "string",
        describe: "The command to execute",
      },
      cwd: {
        type: "string",
        describe: "Current Working Directory",
      },
    },
    required: ["command", "cwd"],
  }),
  async execute({ command }) {
    const ok = confirm(`Run: ${command}`);
    if (!ok) {
      return `User denied.`;
    }
    try {
      const result = await runCommand(command);
      return result;
    } catch (e) {
      const message = e instanceof Error ? e.message : String(e);
      return message;
    }
  },
});

export const askTool = tool({
  description: "Ask a question to the user. Call this for user input",
  parameters: jsonSchema<{ question: string }>({
    type: "object",
    properties: {
      question: {
        type: "string",
        describe: "The question to ask the user",
      },
    },
    required: ["question"],
  }),
  async execute({ question }) {
    console.log(`\n%c[ask] ${question}`, "color: green");
    const ret = prompt(">") ?? "no answer";
    if (!ret.trim()) Deno.exit(1);
    console.log(`\n%c[response] ${ret}`, "color: gray");
    return ret;
  },
});

export const readUrlTool = tool({
  description: "Read a URL and extract the text content",
  parameters: jsonSchema<{ url: string }>({
    type: "object",
    properties: {
      url: {
        type: "string",
        describe: "The URL to read",
      },
    },
    required: ["url"],
  }),
  async execute({ url }) {
    const res = await fetch(url).then((res) => res.text());
    const extracted = extract(res);
    return toMarkdown(extracted.root);
  },
});

export const readFileTool = tool({
  description: "Read an absolute file path and extract the text content",
  parameters: jsonSchema<{ filepath: string }>({
    type: "object",
    properties: {
      filepath: {
        type: "string",
        describe: "The absolute file path to read",
      },
    },
    required: ["filepath"],
  }),
  async execute({ filepath }) {
    if (!path.isAbsolute(filepath)) {
      return `Denied: filepath is not absolute path`;
    }
    const res = await Deno.readTextFile(filepath);
    return res;
  },
});

export const writeFileTool = tool({
  description: "Write text content to an absolute file path. User checks it",
  parameters: jsonSchema<{ filepath: string; content: string }>({
    type: "object",
    properties: {
      filepath: {
        type: "string",
        describe: "The absolute file path to write",
      },
      content: {
        type: "string",
        describe: "The content to write to the file",
      },
    },
    required: ["filepath", "content"],
  }),
  async execute({ filepath, content }) {
    if (!path.isAbsolute(filepath)) {
      return `Denied: filepath is not absolute path`;
    }
    const ok = confirm(
      `Write ${filepath}(${content.length})\n${truncate(content)}\n`
    );
    if (!ok) return `User denied`;
    await Deno.writeTextFile(filepath, content);
    return "ok";
  },
});

const BUILTIN_TOOLS: Record<string, Tool> = {
  askTool,
  bashTool,
  readUrlTool,
  readFileTool,
  writeFileTool,
};

/// utils
function truncate(input: unknown, length: number = 100) {
  const str =
    typeof input === "string" ? input : JSON.stringify(input, null, 2);
  return str.length > length ? str.slice(0, length) + "..." : str;
}
const write = (text: string) => {
  Deno.stdout.write(new TextEncoder().encode(text));
};

async function loadMessages(filepath: string): Promise<CoreMessage[]> {
  try {
    const _ = await Deno.stat(filepath);
    const content = await Deno.readTextFile(filepath);
    return JSON.parse(content);
  } catch (_e) {
    return [];
  }
}

async function loadExternalTools(exprs: string[], cwd = Deno.cwd()) {
  const tools: Record<string, Tool> = {};
  for (const toolPath of exprs ?? []) {
    // from URL
    if (toolPath.startsWith("https://")) {
      const mod = await import(toolPath);
      tools[mod.toolName] = mod.default as Tool;
      continue;
    }
    // from local file
    const resolvedToolPath = path.join(cwd, toolPath);
    const mod = await import(resolvedToolPath);
    const baseName = path.basename(resolvedToolPath).replace(/\.tsx?$/, "");
    tools[baseName] = mod.default as Tool;
    console.log(`\n%c[tool-added] ${toolPath}`, "color: blue");
  }
  return tools;
}

/// Run
if (import.meta.main) {
  const parsed = parseArgs({
    args: Deno.args,
    options: {
      input: { type: "string", short: "i" },
      debug: { type: "boolean", short: "d" },
      modelName: { type: "string", short: "m" },
      maxSteps: { type: "string", short: "s" },
      maxTokens: { type: "string" },
      noBuiltin: { type: "boolean" },
      persist: { type: "string", short: "p" },
      tools: { type: "string", short: "t", multiple: true },
    },
    allowPositionals: true,
  });
  const modelName = parsed.values.modelName ?? "claude-3-7-sonnet-20250219";
  const debug = parsed.values.debug ?? false;
  const externals = parsed.values.tools
    ? loadExternalTools(parsed.values.tools, Deno.cwd())
    : {};
  const usingTools: Record<string, Tool> = parsed.values.noBuiltin
    ? externals
    : {
        ...BUILTIN_TOOLS,
        ...externals,
      };
  let messages: CoreMessage[] = [];
  let writeMessages: (() => Promise<void>) | undefined = undefined;
  if (parsed.values.persist) {
    const outpath = path.join(Deno.cwd(), parsed.values.persist);
    messages = await loadMessages(outpath);
    writeMessages = async () => {
      await Deno.writeTextFile(outpath, JSON.stringify(messages, null, 2));
    };
    Deno.addSignalListener("SIGINT", async () => {
      try {
        await writeMessages?.();
      } finally {
        Deno.exit(0);
      }
    });
  }

  const firstPrompt = parsed.positionals.join(" ");
  if (firstPrompt) {
    messages.push({
      role: "user",
      content: firstPrompt,
    });
  }

  if (debug) {
    console.log("[options]", parsed.values);
    console.log("[tools]", Object.keys(usingTools));
    console.log("[messsages]", messages.length);
  }

  const model = getModelByName(modelName, {});
  while (true) {
    if (messages.length > 0) {
      const stream = streamText({
        model,
        tools: usingTools,
        system: SYSTEM,
        messages: messages,
        maxSteps: parsed.values.maxSteps ? Number(parsed.values.maxSteps) : 100,
        maxTokens: parsed.values.maxTokens
          ? Number(parsed.values.maxTokens)
          : undefined,
        toolChoice: "auto",
      });
      for await (const part of stream.fullStream) {
        if (part.type === "text-delta") {
          write(part.textDelta); // 画面に表示
          continue;
        }
        if (part.type === "tool-call") {
          console.log(
            `%c[tool-call:${part.toolName}] ${truncate(part.args)}`,
            "color: blue"
          );
          // @ts-ignore no-type
        } else if (part.type === "tool-result") {
          const toolResult = part as ToolResult<string, any, any>;
          console.log(
            `%c[tool-result:${toolResult.toolName}]\n${truncate(
              toolResult.result
            )}`,
            "color: green"
          );
        } else if (debug) {
          console.log(
            `%c[debug:${part.type}] ${truncate(part, 512)}`,
            "color: gray;"
          );
        }
      }
      const response = await stream.response;
      messages.push(...response.messages);
      await writeMessages?.();
      write("\n\n");
    }
    // Next input
    const nextInput = prompt(">");
    if (!nextInput || nextInput.trim() === "") {
      Deno.exit(0);
    }
    messages.push({ role: "user", content: nextInput });
  }
}

Discussion

たろきちたろきち

今までで一番参考になった記事!!
煽り記事(AIがコーディングするからコーダーは要らない等)とか、コーディングはGemini2.5proが最強とかのガセとか、そんなのばっかりだった。
煽りはともかく、他の記事は本当に生成したことあるのか疑問なくらい。単純な実力を測りたい場合、単純なものだと例えば【C++の関数名を列挙する正規表現】を生成してみればその実力が分かる。
って、愚痴になったけど本当に有用な記事をありがと!!