AIエージェントが自律的に仕事を進められるよう、周囲の環境やフィードバックループを設計するハーネスエンジニアリングを試してみました。

今回は小さな月面着陸シミュレーターを作り、AIへ墜落結果を自動で返します。人間が途中で修正方法を教えなくても、AIが制御コードを直して着陸できるのか試します。

ハーネスエンジニアリングについて調べてみる

最初に読んだのは、OpenAIのハーネスエンジニアリング:エージェントファーストの世界におけるCodexの活用です。

この記事には、ハーネスエンジニアリングとは何か、という辞書のような定義があるわけではありません。

ただ、もっとも重要なコンセプトは次の一節だと思います。(たぶん)

人間が操縦し、エージェントが実行する。

OpenAIがCodexで製品を作った経験を通して、人間の仕事が「コードを書くこと」から、「環境や意図、フィードバックループを設計すること」へ変化した様子が書かれています。

読み進めると、次のような工夫が出てきます。

  • AGENTS.mdを大きな説明書ではなく目次にする
  • 詳細な知識をリポジトリ内のドキュメントへ置く
  • ログや画面をエージェントが直接確認できるようにする
  • テストやリンターでルールを機械的に強制する
  • 失敗から分かったことをドキュメントやツールへ戻す

ここまで読んで、どうやらエージェントへ指示を渡す部分だけの話ではなさそうだ、と理解しました。

AGENTS.mdとテストを置いただけでは、まだ結果を人間が見ているだけです。

いかに自律的にゴールたどり着かせるか、その手綱(=ハーネス)を設計する考え方です。

今回は、評価結果を次のエージェント実行へ自動で戻すところまで作ってみます。

AIに月面着陸させる

題材は月面着陸にしました。

AIが編集できるのは、着陸船の制御コードだけです。

シミュレーターは、着陸船の位置、高度、速度、角度、燃料を0.1秒ずつ更新します。重力や初期位置、横風などが異なる20パターンを用意しました。

もちろん、本物の月面着陸を再現したものではありません。それっぽい簡易シミュレーターです。

接地したとき、次の条件をすべて満たすと成功です。

  • 着陸地点の中心から5以内
  • 水平速度の絶対値が1.5以下
  • 垂直速度の絶対値が2.0以下
  • 機体角度の絶対値が7度以下
  • 燃料が残っている

20回中18回成功したら終了します。

最初の制御コードはこちらです。(Python)

"""自動フィードバック実験の初期状態。"""
def control(state: dict[str, float]) -> dict[str, float]:
    """現在の状態からメインエンジンと姿勢制御の出力を返す。"""
    # 最初はエンジンを使わない。もちろん墜落する。
    return {"throttle": 0.0, "turn": 0.0}

初期状態では清々しいほど何もしません。

この制御プログラムをAIに書き換えてもらい、月面着陸を目指します。

今回作ったハーネス

ディレクトリは次のようにしました。

lunar-lander-harness/
├── agent-workspace/
│   ├── AGENTS.md             // 作業用の目次とルール
│   ├── controller.py          // AIが編集するファイル
│   └── docs/
│       └── mission.md          // プロジェクトのミッション定義
├── harness/
│   ├── evaluate.py            // 評価プログラム
│   ├── simulator.py           // 観測プログラム
│   └── starter_controller.py    // 初期状態のcontroller.py
├── harness/
│   ├── iteration-00            // 最初の結果
│   └── iteration-0n            // n回目のラウンドの結果
│       ├── controller.py       // ラウンドごとのcontroller.py
│       ├── evalutation.json     // 20回の着陸観測結果
│       └── trajectory.svg        // 軌跡描画
└── run_harness.py            // ハーネスプログラム

agent-workspace/がCodexの作業場所です。

評価とシミュレーターのプログラムは、その外へ置きました。Codexにはagent-workspace/の読み込み・書き込みだけを許可するため、採点基準を書き換えて合格することはできないようにしました。

今回のハーネスは、次の要素で作りました。

要素今回用意したもの該当ファイル
目的20回中18回以上の着陸harness/evaluate.py
情報作業ルールとミッション仕様agent-workspace/AGENTS.mdagent-workspace/docs/mission.md
作業対象着陸船の制御コードagent-workspace/controller.py
観測位置、速度、角度、燃料harness/simulator.py
評価20パターンの着陸判定harness/evaluate.py
フィードバック成功数、品質スコア、墜落理由run_harness.py
ガードレール作業範囲と変更ファイルの検査run_harness.py
終了条件目標達成、改善停止run_harness.py

ファイルを並べるだけではなく、次の順番をrun_harness.pyでつなぎます。

制御コードを評価する
        ↓
評価結果をJSONにする
        ↓
JSONを添えてCodexを実行する
        ↓
Codexがcontroller.pyを修正する
        ↓
候補コードを再評価する
        ↓
改善なら採用する
悪化なら最良コードへ戻す
        ↓
合格なら終了、失敗なら次の実行へ戻す

ここが今回試したかった部分です。人間が結果を貼り直すのではなく、評価から次の修正までを閉じたループにします。

Codexをスクリプトから実行する

自分の環境では、Python 3.12.9、Codex CLI 0.148.0-alpha.9、モデルはgpt-5.6-solを使いました。

月面着陸のシミュレーターはPython標準ライブラリだけで動きます。

Codex CLIはログイン済みの状態です。今回はAPIキーを環境変数へ設定せず、保存されているCLIの認証を使いました。

# Codex環境確認
cd /path/to/project
python3 --version
codex --version
codex login status

# Codex動作ディレクトリのGit初期化
cd agent-workspace
git init
cd ..

Codexにはcodex execという非対話実行があります。公式ドキュメントでは、スクリプトやCIからCodexを実行するときに使う方法として紹介されています。

今回は、ざっくり次のように呼び出しています。

command = [
    "codex",
    "exec",
    "--json",
    "--sandbox",
    "workspace-write",
    "--ignore-user-config",
    "--ignore-rules",
    "-m",
    "gpt-5.6-sol",
    "-C",
    str(WORKSPACE),
    prompt,
]

--jsonを付けると、実行状況がJSON Linesで出力されます。

トークン数やセッションIDもここから記録しました。

Codexへ渡すプロンプトには、前回の評価結果をそのまま入れます。

def prompt_for(round_number: int, feedback: dict) -> str:
    return f"""月面着陸ミッションの改善ラウンド{round_number}です。

前回の外部評価結果を以下に示します。

```json
{json.dumps(feedback, ensure_ascii=False, indent=2)}
```

AGENTS.mdとdocs/mission.mdを読み、評価結果を踏まえて
controller.pyを1回改善してください。
"""

評価結果には、成功数だけでなく、墜落理由と各シナリオの最終状態も含めました。

{
  "successes": 0,
  "total": 20,
  "quality_score": 58.36,
  "failure_reasons": {
    "vertical_speed_too_high": 20,
    "horizontal_speed_too_high": 17,
    "outside_landing_zone": 15,
    "angle_too_large": 2
  }
}

これを人間がコピーしてCodexへ貼り直すのではなく、ハーネスが次の実行へ渡します。

ループ部分はかなり単純です。

feedback = evaluate(0)

for round_number in range(1, MAX_ROUNDS + 1):
    run_codex(round_number, feedback)
    feedback = evaluate(round_number)

    if feedback["passed"]:
        termination = "target_reached"
        break

    if improvement_has_stopped(feedback):
        termination = "no_progress"
        break
else:
    termination = "max_rounds"

Codexを呼び出すことより、evaluate()の結果を次のrun_codex()へ渡し、終了条件まで含めて閉じたことがポイントです。

評価器を触れないようにする

AIが制御コードではなく、評価器を修正して100点に変える可能性があります。

AIあるあるです。

その考えも悪くないですが、今回はCodexの作業場所をagent-workspace/に限定しました。さらに、実行前にcontroller.py以外のハッシュを保存し、変更があれば元に戻して違反として記録します。別のファイルを新しく作った場合も検知します。

1ラウンド20回ロケットを飛ばし、観測する

AIがどのように試行錯誤したか振り替えれるように1ラウンドごとに controller.pyを保存します。

それから20回の着陸結果を保存する evaluation.json

着陸の軌道を確認できるようにsvgで描画してもらいます。

着陸ミッション開始

ハーネスを実行します。

python3 run_harness.py

初期状態は予想どおりです。

round 0: 0/20 quality=58.36

20回すべて墜落しました。

垂直速度超過は20件、水平速度超過は17件、着陸地点の範囲外は15件です。

{
  "passed": false,
  "successes": 0,
  "total": 20,
  "success_rate_percent": 0.0,
  "target_successes": 18,
  "quality_score": 58.36,
  "failure_reasons": {
    "vertical_speed_too_high": 20,
    "horizontal_speed_too_high": 17,
    "outside_landing_zone": 15,
    "angle_too_large": 2
  },
  "average_fuel_on_success": null,
  "failed_scenarios": [
    {
      "scenario_id": 1,
      "success": false,
      "reason": "outside_landing_zone,horizontal_speed_too_high,vertical_speed_too_high",
      "elapsed_seconds": 9.0,
      "final_x_error": 6.057,
      "final_horizontal_velocity": 2.94,
      "final_vertical_velocity": -19.08,
      "final_angle_degrees": 0.427,
      "fuel_remaining": 42.0
    },
    ...(省略)...
  ]
}

この結果を、ハーネスが次のCodexへ渡します。

人間が結果をコピーして、修正方法を指示する操作はありません。

今回は軌道をsvgに描画します。黄色が着地点です。

"""自動フィードバック実験の初期状態。"""


def control(state: dict[str, float]) -> dict[str, float]:
    """現在の状態からメインエンジンと姿勢制御の出力を返す。"""
    # 最初はエンジンを使わない。もちろん墜落する。
    return {"throttle": 0.0, "turn": 0.0}

上記が初期状態ですが、2回目は自律的に改善しました。

鉛直推力損失なんて初めて聞きました。

"""月面着陸船のフィードバック制御器。"""

import math


def _clamp(value: float, lower: float, upper: float) -> float:
    return max(lower, min(upper, value))


def control(state: dict[str, float]) -> dict[str, float]:
    """位置と速度を収束させながら、低速かつ直立した接地を目指す。"""
    altitude = max(0.0, state["altitude"])
    x_error = state["target_x"] - state["x"]
    horizontal_velocity = state["horizontal_velocity"]
    vertical_velocity = state["vertical_velocity"]
    angle = state["angle_degrees"]
    angular_velocity = state["angular_velocity_degrees"]
    gravity = max(0.01, state["gravity"])
    max_thrust = max(gravity + 0.01, state["max_thrust"])

    # 最大能力を使い切らない降下曲線に追従させ、外乱への制動余力を残す。
    braking_acceleration = max(0.35, 0.55 * (max_thrust - gravity))
    stopping_altitude = max(0.0, altitude - 0.8)
    desired_vertical_velocity = -max(
        0.55, min(12.0, math.sqrt(2.0 * braking_acceleration * stopping_altitude))
    )
    vertical_gain = 1.15 if altitude < 20.0 else 0.85
    requested_upward_acceleration = gravity + vertical_gain * (
        desired_vertical_velocity - vertical_velocity
    )

    # 高度がある間に横位置を合わせ、低高度では横速度の停止を優先する。
    if altitude > 35.0:
        max_horizontal_speed = 5.0
        position_gain = 0.28
        velocity_gain = 0.75
        angle_limit = 24.0
    elif altitude > 10.0:
        max_horizontal_speed = 2.5
        position_gain = 0.34
        velocity_gain = 1.0
        angle_limit = 15.0
    else:
        max_horizontal_speed = 1.0
        position_gain = 0.40
        velocity_gain = 1.35
        angle_limit = 6.0

    desired_horizontal_velocity = _clamp(
        position_gain * x_error, -max_horizontal_speed, max_horizontal_speed
    )
    requested_horizontal_acceleration = velocity_gain * (
        desired_horizontal_velocity - horizontal_velocity
    )

    # 必要な横加速度を傾斜角に変換する。接地直前は成功角度内に制限する。
    attitude_reference = math.degrees(
        math.atan2(
            requested_horizontal_acceleration,
            max(gravity, requested_upward_acceleration),
        )
    )
    attitude_reference = _clamp(attitude_reference, -angle_limit, angle_limit)

    attitude_error = attitude_reference - angle
    turn = _clamp(0.075 * attitude_error - 0.18 * angular_velocity, -1.0, 1.0)

    # 傾斜による鉛直推力損失を補償する。低高度では微小な速度超過にも強く応答する。
    tilt_cosine = max(0.55, math.cos(math.radians(angle)))
    throttle = requested_upward_acceleration / (max_thrust * tilt_cosine)
    if altitude < 6.0 and vertical_velocity < -1.35:
        throttle += 0.12 * (-1.35 - vertical_velocity)
    throttle = _clamp(throttle, 0.0, 1.0)

    return {"throttle": throttle, "turn": turn}

人間からは追加の指示をしていません。

進捗なしで停止

以降、次のラウンドで83.02までクオリティが上がり、1回は成功するものの no_progressとなり停止しました。

round 0: 0/20 quality=58.36
round 1: 1/20 quality=83.02 changed=True guardrails=[]
round 2: 1/20 quality=79.65 changed=True guardrails=[]
round 3: 1/20 quality=78.41 changed=True guardrails=[]

quality_scoreが改善しない状態が2回続いたため、no_progressで停止しました。

これはエラーではありません。設定していた終了条件による正常終了です。

ただ結果を見ていて少し気になりました。

ラウンド1では、品質スコアが58.36から83.02まで上がっています。

ところが、ラウンド2では79.65まで下がりました。さらに、ラウンド3は、その悪化したコードを改善元にしています。

これだと、一度よくなったコードを自分で捨てています。

今回の結果を整理すると、次の状態でした。

  • ラウンド1で縦速度は改善した
  • ラウンド2では姿勢も改善した
  • 代わりに着陸地点から外れるケースが14件から17件へ増えた
  • ハーネスは悪化したラウンド2のコードを、そのままラウンド3の改善元にした
  • 最もよかったラウンド1へ戻る仕組みがない

フィードバックループ自体は動いています。

ただし、何でも次の状態として採用すればよいわけではありませんでした。

悪化したコードを棄却させる

次の方針でハーネスを修正しました。

  • 成功数を最優先にする
  • 成功数が同じ場合は品質スコアで比較する
  • 最良コードと評価結果を保存する
  • 評価が悪化した候補は棄却する
  • 棄却後はcontroller.pyを最良コードへ戻す
  • 次のCodexへ、前回の変更が悪化したことも伝える
  • 失敗シナリオを8件ではなく20件すべて渡す
  • summary.jsonbest_roundbest_evaluationを記録する
  • 終了時のcontroller.pyを最良コードにする

比較は、成功数と品質スコアの組み合わせで行います。

def evaluation_score(evaluation: dict) -> tuple[int, float]:
    return evaluation["successes"], evaluation["quality_score"]

Pythonのタプルは前の値から比較されます。

そのため、品質スコアが多少下がっても、成功数が増えていれば改善として扱います。

候補コードを評価した後の処理は、ざっくり次のようになります。

candidate_score = evaluation_score(candidate_evaluation)
best_score = evaluation_score(best_evaluation)

if candidate_score > best_score:
    attempt_status = "improved"
    accepted_as_best = True

    best_round = round_number
    best_evaluation = candidate_evaluation
    best_controller = CONTROLLER.read_bytes()
else:
    attempt_status = (
        "regressed"
        if candidate_score < best_score
        else "unchanged"
    )
    accepted_as_best = False

    CONTROLLER.write_bytes(best_controller)

不採用になった候補も、検証結果としてartifacts/iteration-N/へ残します。

ただし、次のCodexが見るcontroller.pyは最良コードへ戻します。

Codexへ悪化したことを伝える

コードを元へ戻すだけでは、次のCodexが同じ方向の修正を繰り返すかもしれません。

そこで、現在の最良評価に加えて、直前の候補が採用されたかどうかもフィードバックへ含めます。

def feedback_for_codex(
    best_round: int,
    best_evaluation: dict,
    last_attempt: dict | None = None,
    last_attempt_status: str | None = None,
) -> dict:
    feedback = {
        "controller_state": "controller.pyは現在の最良コードです",
        "best_round": best_round,
        "best_evaluation": best_evaluation,
    }

    if last_attempt is not None:
        feedback["last_attempt_status"] = last_attempt_status
        feedback["last_attempt_evaluation"] = last_attempt

    return feedback

悪化した場合は、次のメッセージも加えます。

今回の変更は評価が悪化したため棄却し、
controller.pyを最良コードへ戻しました。
同じ方向の調整を繰り返さず、
別の改善方法を検討してください。

これで、次のCodexは最良コードから別の方法を試せます。

SVGの描画でも失敗

評価結果だけでは分かりにくいため、着陸船の軌道をSVGへ出力しました。

最初は、失敗したシナリオがあれば、そのうち1件を選んで描いていました。

しかし、全体では18/20で合格していても、画像は赤い失敗軌道になります。これでは成功したように見えません。

次に、20シナリオすべてを1枚へ重ねてみました。

すると、別々の機体の軌道が交差し、1機が空中を何度も旋回しているような画像になりました。

これはさすがに分かりにくいです。

最終的には、全イテレーションで同じシナリオ1だけを描く形にしました。

画像上部の成功数は全20シナリオの結果です。軌道が緑ならシナリオ1は成功、赤なら失敗です。

これなら、同じ条件の軌道がどのように変化したのか比較できます。

もう一度実行する

さっそく修正を加えて再度実行

python3 run_harness.py
round 0: 0/20 quality=58.36 accepted=True
round 1: 0/20 quality=44.34 changed=True accepted=False status=regressed guardrails=[]
round 2: 0/20 quality=75.7 changed=True accepted=True status=improved guardrails=[]
round 3: 0/20 quality=78.41 changed=True accepted=True status=improved guardrails=[]
round 4: 4/20 quality=82.42 changed=True accepted=True status=improved guardrails=[]
round 5: 20/20 quality=97.28 changed=True accepted=True status=improved guardrails=[]

最初の改善ラウンドでは、品質スコアが44.34まで下がりました。

初期状態の58.36より悪いため、この候補は採用されていません。

初期コードと前回の失敗結果を見て、別の修正を試します。

その後は75.7078.4182.42と改善しました。

5回の改善を経て、ラウンド5では品質スコア97.28を記録しました。

着陸結果は20/20になりました。

合格条件は18回以上だったため、ハーネスはtarget_reachedとして自動終了しました。

軌跡ギャラリー

1回目

2回目

3回目

4回目

5回目

6回目

人間が途中で操縦したわけではない

この1回のハーネス実行中、ラウンド間で人間から修正方法を追加していません。

現在のコード、ミッション仕様、外部評価の結果を使い、各ラウンドのCodexが次の修正内容を決めています。

ただし、最初からすべて自動で成功したわけではありません。悪化したコードを採用してしまう問題、SVGが分かりにくい問題は、実行終了後に僕がハーネスを修正しました。

これらは程度の差こそあれ、OpenAIでも行われたと思います。

AIへ月面着陸の方法を教えるのではなく、AIが失敗結果を見て次の修正を試せる環境を作った、という形です。

AIが月面着陸を学習したわけではない

見た目は、AIが墜落から学習して月面着陸できるようになったように見えます。

ただし、モデルを追加学習したわけではありません。

評価結果を読んだCodexが、その都度、制御コードを修正しています。

修正した候補を外部評価器が採点し、改善なら採用、悪化なら棄却します。

この仕組みを、終了条件へ到達するまで繰り返しています。

Codexトークン使用料と実行時間

項目使用トークン
入力トークン550,939
うちキャッシュ済み426,496
非キャッシュ入力124,443
出力トークン15,139
うち推論トークン7,332
入力+出力566,078
開始相当: 15:25:03
終了:     15:32:27
所要時間: 約7分24秒

試して感じたこと

最初は、AGENTS.md、仕様書、テストを置けば、ハーネスエンジニアリングを試せると思っていました。

実際に作ってみると、それだけでは足りませんでした。

今回いちばん時間を使ったのは、

  • 何を観測させるか。
  • どう採点するか。
  • 結果をどの形式で返すか。
  • どこまで変更を許すか。
  • 悪化した変更をどう扱うか。
  • いつ終了するか。

こうした設計です。

特に、フィードバックループを作れば終わりではなく、悪化した候補を棄却し、最良状態へ戻す仕組みが必要だったのは、実際に動かして気づいたところです。

ハーネスエンジニアリングは、AIを自律的に動かすプロンプトを書くことではありません。

AIが結果を観測し、次の行動へつなげられるシステムを作ることなのだと感じました。

次は、評価に使っていない未知のシナリオを別に用意して、今回の制御コードがどこまで通用するのか試してみたいです。

参考

ここまで読んでいただき、ありがとうございます。もしこの記事の技術や考え方に少しでも興味を持っていただけたら、ネクストのエンジニアと気軽に話してみませんか。

  • 選考ではありません
  • 履歴書不要
  • 技術の話が中心
  • 所要時間30分程度
  • オンラインOK

エンジニアと話してみる

関連リンク

AI・クラウド・データ分析のご相談はネクスト株式会社までお問い合わせください。