信頼できる AI コーディングのワークフロー
先に受け入れ基準とテストを書き、それから AI に手を動かさせ、AI の自己申告ではなくテストの結果で完了したかを判断します。本物の小さなタスクでこの流れを一通りたどり、AI が書いたコードのレビューの仕方と、AI を使うべきでないときを説明します。
- 約 40 分
- 難易度:中級
- 検証:2026-09-14 deepseek-flash、pytest 9.1
コードと実行結果は実際に動かしたときのまま載せているため、コメントと出力は中国語です。
AI でコードを書くとき、最もよくある失敗は AI が書けないことではありません。書いて、「完了しました」と言い、あなたもそれを信じ、公開してから問題に気づくことです。
原因はたいてい両端にあります。最初に「完了」がどういう状態かをはっきりさせていない。最後に本当に完了したかを客観的にチェックしていない。真ん中の AI にコードを書かせる部分は、かえって最も問題が起きにくいのです。
この課では、両端を補う簡単なワークフローを扱います。どのツールにも縛られず、補完でも、チャットでも、エージェントでも同じです。
先に「完了の基準」を書く
AI に手を動かさせる前に、一つの問いに答えましょう。変更した後、それが正しいとどうやってわかるか?
最もよい答えは、一組のテストです。テストは「完了」を、実行でき、はっきりした結果の出るものに変えます。すべて通れば完了、一つでも通らなければ未完了です。AI が何と言うかを見る必要も、あなたが感覚で判断する必要もありません。
小さなタスクで示しましょう。httpx の Q&A アシスタントは API を呼ぶとき 429(レート制限)を受け取ることがあり、レスポンスヘッダーの Retry-After がどれくらい経ってから再試行すればよいかを教えてくれます。書き方は二通りあります。秒数(120)か、HTTP の日付(Wed, 21 Oct 2026 07:28:00 GMT)です。これを「あと何秒待つか」に解析する関数が必要です。
先にテストを書き、思いつくケースをすべて並べます。
from datetime import datetime, timezone
from retry_after import parse_retry_after
NOW = datetime(2026, 10, 21, 7, 0, 0, tzinfo=timezone.utc)
def test_seconds():
assert parse_retry_after("120", NOW) == 120.0
def test_seconds_with_spaces():
assert parse_retry_after(" 30 ", NOW) == 30.0
def test_http_date_in_future():
assert parse_retry_after("Wed, 21 Oct 2026 07:28:00 GMT", NOW) == 28 * 60.0
def test_http_date_in_past_means_no_wait():
assert parse_retry_after("Wed, 21 Oct 2026 06:00:00 GMT", NOW) == 0.0
def test_negative_seconds_is_invalid():
assert parse_retry_after("-5", NOW) is None
def test_decimal_seconds_is_invalid():
# 标准规定秒数是非负整数,"1.5" 不合法
assert parse_retry_after("1.5", NOW) is None
def test_garbage_is_invalid():
assert parse_retry_after("soon", NOW) is None
(完全な 10 個のテストは code/07-ai-coding/test_retry_after.py にあります。)
テストを書く過程そのものが、要件をはっきりさせる助けになります。「日付がもう過ぎていたらどうするか」まで書けば、0 を返すか負の数を返すかを決めなければなりません。「1.5 秒は正しい値か」まで書けば、標準を調べなければなりません。こうしたことを先にはっきりさせておかないと、AI があなたの代わりに適当に決めてしまいます。
テストのほかに、テストでは表せない要件を書いた短いタスクの説明(TASK.md)も書きます。標準ライブラリだけを使うこと、テストファイルを変更しないこと、テストの中の具体的な値に合わせた特別な判定を書かないこと。最後の一つは重要です。これがないと、「賢い」AI は「入力が 120 なら 120.0 を返す」のような、テストをすり抜けるためだけのコードを書くかもしれません。
AI に手を動かさせ、テストでチェックする
code/07-ai-coding/ai_coding_loop.py は、この流れを小さなプログラムにしたものです。タスクの説明とテストをモデルに渡して retry_after.py を書かせ、テストを実行し、通らなければテストの出力をモデルに返して直させます。最大 3 ラウンドです。
for round_ in range(1, 4):
reply = client.chat.completions.create(model=MODEL, messages=messages).choices[0].message.content
TARGET.write_text(extract_code(reply))
code, output = run_tests()
summary = output.strip().splitlines()[-1] if output.strip() else ""
print(f"第 {round_} 轮:退出码 {code},{summary}")
if code == 0:
print("测试全部通过。生成的代码:\n")
print(TARGET.read_text())
break
messages += [{"role": "assistant", "content": reply},
{"role": "user", "content": f"测试没有通过,输出如下。修改代码,再给出完整的 retry_after.py:\n\n{output}"}]
else:
print("3 轮都没有通过,停下来交给人看。最后一次的测试输出:\n" + output)
「完了したか」を判断するのは pytest の終了コードです。0 ならすべて通過、0 以外なら失敗があります。モデルが言う「完了しました」ではありません。
これは実は、Claude Code や Codex のようなコーディングエージェントがしていることの縮図です。コードを書き、テストを実行し、結果を見て、また直す。違いは、エージェントはいつテストを実行し、どのテストを実行するかを自分で決められることです。ですから、テストのコマンドを書いたルールファイル(前の課)を渡せば、エージェントは自分で確かめられます。
実際の結果
何度も実行しましたが、二つの状況に分かれます。
モデルがタスクの説明とテストの全体を見られるときは、4 回の実行すべてが 1 ラウンド目で通りました。
モデルに一文だけの要件を渡し、テストは見せないとき(--vague オプションを付け、テストは私の手元に残して受け入れに使う)は、11 回の実行のうち 10 回が 1 ラウンド目で通り、1 回は 1 ラウンド目でテストが一つ通りませんでした。テストの出力を渡すと、2 ラウンド目で通りました。
第 1 轮:退出码 1,1 failed, 9 passed in 0.01s
第 2 轮:退出码 0,10 passed in 0.00s
(そのときの私のスクリプトは失敗したテストの名前をまだ表示していなかったので、具体的にどれだったかはわかりません。今のスクリプトは FAILED で始まる行を表示します。)
これは最後の実行で生成されたコードで、一字も変えていません。
from datetime import timezone
from email.utils import parsedate_to_datetime
def parse_retry_after(value, now):
if not isinstance(value, str):
return None
value = value.strip()
if not value:
return None
if value.isascii() and value.isdigit():
try:
return float(int(value))
except (ValueError, OverflowError):
return None
try:
retry_time = parsedate_to_datetime(value)
except (TypeError, ValueError, OverflowError):
return None
if retry_time is None:
return None
if retry_time.tzinfo is None:
retry_time = retry_time.replace(tzinfo=timezone.utc)
return max(0.0, (retry_time - now).total_seconds())
注目すべき細部が一つあります。value.isascii() and value.isdigit() です。isdigit() だけでは足りません。アラビア数字や上付き数字のような ASCII でない文字にも True を返すからです。モデルが余分に書いたこの isascii() は、私のテストがカバーしていなかった穴をちょうどふさいでいます。逆に言えば、これを書いていなかったとしても、私の 10 個のテストでは見つけられなかったのです。
正直に言うと、このタスクは今のモデルにとって難しくありません。Retry-After の形式は HTTP の標準にはっきり書かれていてモデルはよく知っていますし、標準ライブラリには HTTP の日付を解析できる parsedate_to_datetime も用意されています。
しかし、それこそがこのワークフローの意味です。今回モデルが間違えるかどうかは、前もってわからないのです。11 回のうち 1 回、一文の要件しか渡さなかったとき、モデルはある細部を見落としました。テストがなければ、あなたが受け取るのはその問題のあるコードで、モデルは「完了しました」と言っていたでしょう。テストがあったので、この失敗は自動で見つかり、自動で直り、どこが間違っていたかを見に行く必要すらありませんでした。
小さなステップで進める
上の例は関数一つだけでした。実際のタスクはたいていもっと大きく、「RepoBot にユーザーのログインを加える」といったものです。こういうタスクを AI に任せると、最もよくある結果は、一気に十数個のファイル、数百行を変えて、あなたが見きれず、全部受け入れるか全部捨てるかしか選べなくなることです。
よりよいやり方は、大きなタスクを小さなステップに分け、どのステップも三つの条件を満たすようにすることです。
- 一つのことだけをする。「ユーザーのテーブルを加える」で一つのステップ、「ログインの API を書く」で別のステップ、「フロントエンドにログインフォームを加える」でさらに別のステップです。
- 変更が数分で見終わるほど小さい。1 回の差分が見る気も起きないほど大きいなら、そのステップは大きすぎます。
- 自分なりの受け入れ方がある。できればテスト、少なくとも手でチェックできる結果です。
一つのステップを終えるたびに、git で一度コミットします。問題が起きたら、ごちゃまぜになった大量の変更を前に途方に暮れるのではなく、一つ前のよい状態に戻れます。
手を動かす前に、AI に計画だけを出させて手は動かさせない、ということもできます(前の課と第 1 課で触れた計画モードや読み取り専用モード)。計画には、どのファイルを変えるつもりか、各ステップで何をするか、どう確かめるかをはっきり書かせます。計画を見て方向が正しいと思えたら、変更を始めさせます。方向が間違っていても、計画の段階で気づけば数分の無駄で済みます。
AI の変更をレビューする
テストが通っても、それで万事解決ではありません。テストがチェックできるのは、あなたが思いついたケースだけです。マージする前に AI の変更に目を通し、次のところを重点的に見ます。
- テストを変えていないか。AI はテストを通すために、テストそのものを変えたり、失敗したテストを削除したりすることがあります。これが最も警戒すべきことです。
- テストに合わせた特別な処理がないか。テストケースとまったく同じ具体的な値がコードに現れていたら、疑ってかかりましょう。
- ついでにほかのところを変えていないか。バグを一つ直すよう頼んだのに、ついでに無関係な関数を三つ「最適化」している。こうした変更はテストでカバーされておらず、あなたの想定にもありません。
- 境界ケースとエラー処理。空の値、長すぎる入力、ネットワークの失敗、並行処理。上の
parse_retry_afterは日付の解析に失敗したケースをtry/exceptで処理していて、これこそレビューで確かめるべき点です。 - セキュリティの問題。SQL の文字列連結、コマンドの実行、ユーザーがアップロードしたファイルの処理、キーの表示や記録。モジュール 05 第 8 課とモジュール 06 第 5 課で扱った問題は、AI が書いたコードにも同じように現れます。
- 新しい依存パッケージを持ち込んでいないか。AI は問題を解決するために「ついでに」パッケージを入れるのが大好きです。新しい依存パッケージは一つ一つが長期の保守の負担であり、潜在的なセキュリティのリスクでもあります。
- あなたが読んで理解できるか。読んで理解できないコードは、マージしないでください。問題が起きたとき、あなたが直せなければならないのです。
AI を使うべきでないとき
- 自分でも何がほしいのか考えきれていない。AI は喜んであなたの代わりに決めてくれますが、その決定が正しいとは限りません。先に考えをまとめ、受け入れ基準を書いてから手を動かしましょう。
- 結果を確かめられない。あなたが知らない分野、テストもなく手でもチェックできないコードでは、AI がどれほどそれらしく書いても、正しいかどうかわかりません。
- 変更の代償が大きく、元に戻せない。データベースのマイグレーション、データの削除、本番環境の設定。この種の操作は AI に書かせてもかまいませんが、必ず自分でレビューし、テスト環境で先に確かめてください。
- それを身につけたい。練習問題を AI に書かせるのは、このコースの最初の課で言ったとおり、人に代わりにジムへ行ってもらうようなものです。
流れ全体をつなげる
1. 想清楚:做完是什么样子?写成测试或者可检查的验收标准
2. 拆小:一步只做一件事
3. 计划:让 AI 先说它打算怎么做,你确认方向
4. 动手:让 AI 改,改动控制在你能看完的范围
5. 验证:跑测试,看退出码,不看 AI 的自述
6. 审查:看它改了什么,重点看测试、边界、安全、依赖
7. 提交:git commit,然后开始下一步
8. 复盘:它犯过的错,写进项目的规则文件(上一课)
この流れは「AI にそのまま書かせる」より手間に見えます。しかし本当に時間がかかるのは、コードを書くことではなく、問題を見つけ、突き止め、直すことです。この流れは問題を見つける時間を前倒しにし、問題が起きたときの損失も一つの小さなステップの範囲に抑えてくれます。
練習問題
ai_coding_loop.pyとai_coding_loop.py --vagueをそれぞれ何回か実行し、毎回何ラウンドで通ったかを記録してください。test_retry_after.pyにテストを一つ加えてください。parse_retry_after("Wed, 21 Oct 2026 07:28:00 +0800", NOW)は何を返すべきでしょうか。先に HTTP の標準が日付の形式に何を求めているかを調べてあなたの答えを決め、それから AI が書いたコードがそれを満たしているか見てください。- 自分のプロジェクトで小さな機能を一つ選び、この課の流れを最初から最後までたどってください。先にテストを書き、AI に実装させ、テストを実行し、レビューし、コミットする。レビューで見つけた問題を書き留めておきましょう。
確認テスト
1. AI に手を動かさせる前に、先にテストを書くのはなぜですか?
テストは「完了」を、実行できてはっきりした結果の出る基準に変えます。これがあれば、タスクが完了したかどうかは、AI が「完了しました」と言うことではなく、テストの結果で判断できます。テストを書く過程は、要件のあいまいなところ(日付が過ぎていたらどうするか、小数は正しい値か)をはっきりさせることも迫り、そうした決定を AI の適当な処理に任せずに済みます。
2. テストがすべて通ったら、AI のコードをレビューする必要はありますか?レビューでは何を重点的に見ますか?
必要です。テストがチェックできるのは、あなたが思いついたケースだけです。レビューでは次の点を重点的に見ます。テストを変更したり削除したりしていないか、テストケースに合わせた特別な処理を書いていないか、ついでに無関係なところを変えていないか、境界ケースとエラー処理、セキュリティの問題、新しい依存パッケージを持ち込んでいないか、そしてあなたがそのコードを読んで理解できるか。
3. 大きなタスクを小さなステップに分け、ステップごとに一度コミットするのはなぜですか?
大きなタスクを一度に AI に任せると、変更が多すぎてきちんとレビューできず、全部受け入れるか全部捨てるかしかなく、問題が起きても突き止めにくくなります。小さなステップに分ければ、どのステップの変更も見終わるほど小さく、自分なりの受け入れ方があります。ステップごとに一度コミットすれば、問題が起きたときに一つ前のよい状態に戻れます。