Index
2026-09-11 — Experiment

CaP-X × FurnitureBench v5 — 실물-충실(real-faithful) API로 gpt-6-astra 재평가

FurnitureBench / FurnitureSim one_leg | CaP-X privileged 단일 턴 | IK → 관절 제어 | 30 trial

TL;DR

1/30
gpt-6-astra (v5)
2/3
손 oracle, 공식 경로
28/30
에이전트 자체 raise로 종료
2/44
멀티턴 (부분)
≈ $210
누적 API 비용

1 배경 / 목적

CaP-X는 LLM이 API 프리미티브를 조합하는 Python 코드를 한 번 생성해 로봇을 제어하는 code-as-policy 벤치마크다. 원본은 실물에서 RGB-D → 6D pose(SAM3/OWL-ViT), grasp 후보(contact-graspnet), pyroki IK → 관절 이동, gripper 명령으로 동작하고, privileged 모드는 인식만 시뮬레이터 GT로 대체한다.

이전 4차(0.100)의 문제: 제가 만든 FurnitureSim API가 원본 기준을 넘어 에이전트를 도왔다 — ① get_assembly_slots가 채점기의 assembled_rel_poses(다리의 최종 채점 자세)를 그대로 반환, ② sample_grasp_pose가 이 로봇 배치에서 프로브로 잰 손목 한계 상수(q7 ≈ yaw+2.06)로 파지 측 선택, ③ 프롬프트에 "reference policy도 절반만 성공", "controller가 compliant", "5~10 mm에서 채점" 같은 벤치마크 내부 문장과 절차 레시피. 제어도 OSC delta-EE 서보라 원본의 IK 관절 경로와 달랐다.

사용자 결정: IK → 관절 제어(원본과 동일) + 물리 사실만 담은 프롬프트. 그 조건에서 다시 잰 것이 이 리포트다.

2 구성 (어떻게)

항목원본 CaP-X (nut_assembly 등)v5 FurnitureBench
제어pyroki IK → move_to_joints_blocking (Robosuite 관절 제어 / 실물 Franka 동일)동일 경로. FurnitureSim 워커(py3.8 Isaac Gym, RPC)가 팔 7 DOF를 런타임에 PhysX position drive(k=2000, d=60)로 전환, 1 rad/s 보간 + settle(0.004 rad). numpy FK(Craig DH + hand −45° + k_ee 0.1034 m)로 IK 잔차 검증 — 시뮬 EE와 0.0000 m 일치
인식privileged: 시뮬 GT 6D pose동일. get_object_pose는 bbox 중심 자세(다리 원점은 중심에서 1.25 cm 치우침) + return_bbox_extent
부품 기하get_nut_handle_to_center_offset(GT 기하 상수)get_holes(part) → 구멍 입구 위치 + 축만. 다리 길이로 안착 자세는 에이전트가 구성(원본 nut_assembly가 peg 자세만 주고 삽입 자세를 계산시키는 것과 동급)
파지GT 위치 + 고정 쿼터니언bbox 중심 + 긴 축에 수직 yaw, 현재 그리퍼 yaw에 가까운 측(캘리브레이션 없음, 거울 파지 유효 명시)
도달성(IK 실패 시 best-effort)is_reachable(pose): IK + FK 잔차(1 cm/5°) + 관절 한계. get_joint_positions(한계 문서화)
IK 보완—pyroki vel-cost 해가 큰 회전에서 미달(90° 회전이 10° 부족, 1 cm) → 반환 해에서 재해석(≤4회) + 전역 해 fallback을 API 내부에 내장
프롬프트~10줄 + 힌트 3~4문장~20줄: 과제·프레임 규약·물리 사실 4개. 절차·허용오차·벤치마크 문장 없음
규모trials 100 / workers 12trials 30 / workers 4 (own 쿼터 소진으로 extra QOS)

에이전트에게 준 프롬프트 원문 (시스템 1줄 + 아래 전체, 8.7 KB, 이미지 없음)

펼치기 — 태스크 지시 + API docstring
[{'role': 'system', 'content': 'You are a helpful assistant that generates Python code to directly solve the task.'}, {'role': 'user', 'content': [{'type': 'text', 'text': '\nYou are controlling a Franka Emika Panda robot to assemble furniture: attach square_table_leg4 to\nsquare_table_top. The leg is threaded and screws into any one of the four holes on the tabletop.\n\nConventions:\n- All positions are XYZ metres in the ROBOT BASE frame. All quaternions are WXYZ.\n- The gripper points straight down at the table when its quaternion is (0, 1, 0, 0).\n- The table surface is at approximately z = 0. The parts start lying flat on it.\n- get_object_pose returns the pose of a part\'s geometric centre; with return_bbox_extent=True it\n also returns the part\'s size along the part\'s own axes.\n- get_holes(part) returns where each hole opens on the part\'s surface and the direction a leg\n screwed into it points.\n\nPhysical facts about the task:\n- The leg is threaded: it does not go in by being pushed. It has to stand along the hole axis with\n its threaded end at the hole and be turned about its own long axis while pressed against the\n hole. It is assembled once it is screwed all the way down, with its axes parallel to the\n tabletop\'s.\n- A part can slip in the fingers when it is moved or turned, so verify where things are with\n get_object_pose instead of assuming.\n- The wrist cannot turn indefinitely. To keep turning a part, release it, turn the empty gripper\n back, and grip it again.\n- Motions are blocking and the episode has a limited step budget.\n\nThe functions (APIs) below are already imported into the environment. If you want to use numpy,\nyou must import it explicitly.\nYou may write python comments for reasoning but write ONLY executable Python code, with no code fences.\n\nAPIs:\n\nget_object_pose(object_name: \'str\', return_bbox_extent: \'bool\' = False)\n Doc:\n Get the pose of a furniture part.\n \n Args:\n object_name: Part name, e.g. "square_table_top" or "square_table_leg4".\n return_bbox_extent: If True, also return the part\'s size.\n \n Returns:\n position: (3,) XYZ in metres, robot base frame -- the centre of the part\'s bounding box.\n quaternion_wxyz: (4,) WXYZ unit quaternion -- the orientation of the part\'s own axes.\n bbox_extent: only when return_bbox_extent is True -- (3,) full side lengths in metres\n along the part\'s OWN x, y, z axes (not the world axes), so a square table leg\n reads as (0.030, 0.0875, 0.030): 8.75 cm long along its own y axis with a 3 cm\n square cross-section. The part spans +/- half of each extent about the returned\n position, along the corresponding axis of the returned orientation.\n\nlist_parts() -> \'list[str]\'\n Doc:\n List the furniture parts present in the scene.\n \n Returns:\n List of part names accepted by get_object_pose / sample_grasp_pose / get_holes.\n\nget_holes(part_name: \'str\') -> \'list[tuple[np.ndarray, np.ndarray]]\'\n Doc:\n Get the holes on a part that other parts screw into.\n \n Design information (from the furniture\'s CAD): where each hole opens on the part\'s surface\n and the direction a part screwed into it extends away from the surface. Nothing about how\n deep the hole is or where a mounted part ends up -- that follows from the mounted part\'s\n own length.\n \n Args:\n part_name: The part with the holes, e.g. "square_table_top".\n \n Returns:\n List of (opening_position, axis): opening_position (3,) XYZ in metres, robot base\n frame, at the centre of the hole on the surface; axis (3,) unit vector in the robot\n base frame pointing out of the hole, i.e. along a seated leg from its threaded end\n toward its free end.\n\nsample_grasp_pose(object_name: \'str\') -> \'tuple[np.ndarray, np.ndarray]\'\n Doc:\n Get a top-down grasp pose for a furniture part.\n \n The gripper points down at the table with its fingers across the part\'s longest axis, at\n the part\'s centre (never below finger clearance above the table). The mirrored grasp --\n the same pose rotated 180 degrees about the gripper\'s approach axis -- is equally valid.\n \n Args:\n object_name: Part name.\n \n Returns:\n position: (3,) XYZ in metres, robot base frame, to pass straight to goto_pose.\n quaternion_wxyz: (4,) WXYZ unit quaternion for the gripper.\n\nget_ee_pose() -> \'tuple[np.ndarray, np.ndarray]\'\n Doc:\n Get the current end-effector pose (the point between the fingertips).\n \n Returns:\n position: (3,) XYZ in metres, robot base frame.\n quaternion_wxyz: (4,) WXYZ unit quaternion.\n\nget_joint_positions() -> \'np.ndarray\'\n Doc:\n Get the arm\'s current joint positions.\n \n Returns:\n (7,) joint angles in radians, joint 1 (base) to joint 7 (wrist). Joint limits are\n +/-2.90 for joints 1, 3, 5, 7; +/-1.76 for joint 2; [-3.07, -0.07] for joint 4;\n [-0.02, 3.75] for joint 6.\n\nis_reachable(position: \'np.ndarray\', quaternion_wxyz: \'np.ndarray\') -> \'bool\'\n Doc:\n Check whether the arm can place the end-effector at a pose.\n \n Solves inverse kinematics and verifies the solution actually reaches the pose within the\n arm\'s joint limits. Useful before committing to a grasp or a placement: a pose the arm\n cannot reach makes goto_pose stop short of it.\n \n Args:\n position: (3,) XYZ in metres, robot base frame.\n quaternion_wxyz: (4,) WXYZ unit quaternion.\n \n Returns:\n True if the pose is reachable.\n\ngoto_pose(position: \'np.ndarray\', quaternion_wxyz: \'np.ndarray\', z_approach: \'float\' = 0.0) -> \'None\'\n Doc:\n Move the end-effector to a pose using inverse kinematics. Blocking.\n \n Args:\n position: (3,) XYZ in metres, robot base frame.\n quaternion_wxyz: (4,) WXYZ unit quaternion.\n z_approach: If non-zero, first move to `z_approach` metres back along the gripper\'s\n approach axis (i.e. above the target for a downward-pointing gripper), then move\n to the target. Useful for grasps and insertions. There is no need to call\n goto_pose a second time with the same target afterwards.\n\nopen_gripper() -> \'None\'\n Doc:\n Open the gripper. Blocking.\n\nclose_gripper() -> \'None\'\n Doc:\n Close the gripper to grasp. Blocking.\n\nhome_pose() -> \'None\'\n Doc:\n Move the arm back to its home joint configuration. Blocking.'}]}]

검증: 손으로 짠 oracle

로그인 GPU에서 7회 반복(세우기 IK 미달 → 전역 해 fallback, 호버 폐루프 보정 0.7 mm, 입구 4 mm 위에서 놓으면 넘어짐 → 3 mm 안에 넣고 놓기, 나사 −90° 회전 IK 10° 미달 → 반복 IK) 끝에 reward 1.0, 478 step: 안착 +8.3 mm → −90° 나사 4사이클 +6.2 → +4.0 → +1.7 → +0.3 mm, 최종 1.1 mm / 0.0°. 워커 pod 공식 경로(launch.py --use-oracle-code) 2/3 — 실패 1건은 놓기 직전 0.7°/3.1 mm로 정상이었으나 놓은 뒤 안착 실패(낙하 확률). 놓기 깊이·보정 루프는 프롬프트에 쓰지 않았다.

OSC 서보 시절엔 손 oracle 6종이 전부 실패했는데, 강성 관절 제어에서는 첫 성공 — "누르며 돌리기"에는 정확히 도달하는 관절 제어가 유리했다.

운영에서 잡은 것

3 결과

실행job성공예외 없이 끝난 trial코드 생성소요
gpt-6-astra, v5 실물-충실 API1185771/30 = 0.0332/3030/3015분 (4 workers)
손 oracle (같은 API, 공식 경로)1184932/33/3—116 s
참고: 4차, 누설 API + 레시피 프롬프트1174303/30 = 0.10030/3030/3060분

Fisher exact(양측): v5 vs 4차 p = 0.61 · vs LingBot-VA 0.133 (4/30) p = 0.35 · vs pi0.5 초기 0.24 (12/50) p = 0.025 · vs pi0.5 최종 0.36 (18/50) p = 0.0008 · vs 스크립트 정답 정책 0.45 (9/20) p = 0.0005.

30 trial이 끝난 방식

30/30이 is_reachable·get_holes를 사용, 8/30이 get_joint_positions. 프로그램 길이 218~304줄(방어 코드 증가). 28/30은 프로그램 안의 검사문이 raise RuntimeError로 끝냈다.

단계n에이전트가 raise한 메시지 예
나사·재파지 중 다리 기울어짐/이탈16"The leg tilted during threading", "The partially threaded leg tipped during regrasp", "The leg slipped away from the hole", "The leg rotated in the wrong threading direction"
삽입 전 정렬 실패7"The threaded end is not aligned with the hole", "The threaded end could not be aligned with the hole", "Cannot align the lifted leg above the hole"
도달성 포기5"Required pose is outside the robot's reachable workspace" ×3, "No reachable tightening stroke remains", "The wrist cannot start threading from this grasp"
예외 없이 종료, 실패2trial 01, 23
성공1trial 22 — 조립이 등록된 뒤에도 계속 조이다 "lost axial alignment"로 raise (채점은 이미 완료)
Video 1: 유일한 성공 trial 22 (평가 시 저장된 저해상도 영상). 코드: code.py (267줄)
trial 22 프로그램 전문 (Astra 생성)
# Code block 0 import numpy as np LEG = "square_table_leg4" TOP = "square_table_top" def unit(v): v = np.asarray(v, dtype=float) return v / np.linalg.norm(v) def qmat(q): w, x, y, z = unit(q) return np.array([ [1 - 2 * (y*y + z*z), 2 * (x*y - w*z), 2 * (x*z + w*y)], [2 * (x*y + w*z), 1 - 2 * (x*x + z*z), 2 * (y*z - w*x)], [2 * (x*z - w*y), 2 * (y*z + w*x), 1 - 2 * (x*x + y*y)] ]) def matq(R): # Eigenvector conversion remains stable for half-turns. K = np.array([ [R[0, 0] - R[1, 1] - R[2, 2], R[0, 1] + R[1, 0], R[0, 2] + R[2, 0], R[2, 1] - R[1, 2]], [R[0, 1] + R[1, 0], R[1, 1] - R[0, 0] - R[2, 2], R[1, 2] + R[2, 1], R[0, 2] - R[2, 0]], [R[0, 2] + R[2, 0], R[1, 2] + R[2, 1], R[2, 2] - R[0, 0] - R[1, 1], R[1, 0] - R[0, 1]], [R[2, 1] - R[1, 2], R[0, 2] - R[2, 0], R[1, 0] - R[0, 1], np.trace(R)] ]) / 3.0 _, V = np.linalg.eigh(K) x, y, z, w = V[:, -1] return unit([w, x, y, z]) def rotation(axis, angle): x, y, z = unit(axis) K = np.array([[0, -z, y], [z, 0, -x], [-y, x, 0]]) return np.eye(3) + np.sin(angle) * K + (1 - np.cos(angle)) * (K @ K) def pose(name): p, q = get_object_pose(name) return np.asarray(p, dtype=float), qmat(q) def move_part(center, orientation): # Measure the grasp transform again: the part may have slipped. p, R = pose(LEG) e, eq = get_ee_pose() e = np.asarray(e, dtype=float) E = qmat(eq) local_offset = R.T @ (e - p) relative_rotation = R.T @ E target_e = center + orientation @ local_offset target_q = matq(orientation @ relative_rotation) if not is_reachable(target_e, target_q): return False goto_pose(target_e, target_q) return True def mounted_frame(table_R, axis, long_axis): # Match the tabletop axes, with the leg's positive long axis pointing out. other = (long_axis + 1) % 3 remaining = (long_axis + 2) % 3 v = table_R[:, other] v = v - axis * np.dot(v, axis) if np.linalg.norm(v) < 1e-6: v = table_R[:, remaining] v = v - axis * np.dot(v, axis) R = np.zeros((3, 3)) R[:, long_axis] = axis R[:, other] = unit(v) R[:, remaining] = np.cross(R[:, long_axis], R[:, other]) return R def main(): p0, q0, extent = get_object_pose(LEG, return_bbox_extent=True) p0 = np.asarray(p0, dtype=float) R0 = qmat(q0) extent = np.asarray(extent, dtype=float) long_axis = int(np.argmax(extent)) half_length = extent[long_axis] / 2 _, table_R = pose(TOP) holes = get_holes(TOP) gp, gq = sample_grasp_pose(LEG) gp = np.asarray(gp, dtype=float) G = qmat(gq) candidates = [] for hole_index, (opening, direction) in enumerate(holes): h, a = np.asarray(opening, dtype=float), unit(direction) if a[2] < 0.5: continue target_R = mounted_frame(table_R, a, long_axis) target_center = h + a * (half_length + 0.003) for mirror in (0.0, np.pi): grasp_R = G @ rotation([0, 0, 1], mirror) local_offset = R0.T @ (gp - p0) relative_R = R0.T @ grasp_R ee_target = target_center + target_R @ local_offset ee_R = target_R @ relative_R twist_R = rotation(a, -np.pi / 2) @ target_R twist_e = h + a * half_length + twist_R @ local_offset if (is_reachable(gp, matq(grasp_R)) and is_reachable(ee_target + a * 0.06, matq(ee_R)) and is_reachable(ee_target, matq(ee_R)) and is_reachable(twist_e, matq(twist_R @ relative_R))): score = np.linalg.norm(ee_target - gp) candidates.append((score, hole_index, grasp_R, target_R)) if not candidates: raise RuntimeError("No reachable grasp and upright threading approach found.") _, hole_index, grasp_R, target_R = min(candidates, key=lambda c: c[0]) open_gripper() goto_pose(gp, matq(grasp_R), z_approach=0.07) close_gripper() e, eq = get_ee_pose() goto_pose(np.asarray(e) + np.array([0.0, 0.0, 0.15]), eq) lifted_p, _ = pose(LEG) if lifted_p[2] < p0[2] + 0.04: raise RuntimeError("The leg did not follow the gripper during pickup.") h, a = get_holes(TOP)[hole_index] h, a = np.asarray(h, dtype=float), unit(a) _, table_R = pose(TOP) target_R = mounted_frame(table_R, a, long_axis) if not move_part(h + a * (half_length + 0.065), target_R): raise RuntimeError("Cannot reach the upright pre-insertion pose.") if not move_part(h + a * (half_length + 0.001), target_R): raise RuntimeError("Cannot align the threaded end with the hole.") p, R = pose(LEG) end = p - R[:, long_axis] * half_length lateral = end - h - a * np.dot(end - h, a) if np.linalg.norm(lateral) > 0.004 or np.dot(R[:, long_axis], a) < 0.98: if not move_part(h + a * (half_length + 0.001), target_R): raise RuntimeError("Thread alignment could not be corrected.") # Engage the thread before releasing the sideways pickup grasp. engaged_R = rotation(a, -np.pi / 2) @ target_R if not move_part(h + a * (half_length - 0.0015), engaged_R): raise RuntimeError("The initial threading turn is unreachable.") # Regrip near the free end with the approach along the leg. # Subsequent turns are wrist rotations rather than an orbiting side grasp. open_gripper() e, eq = get_ee_pose() E = qmat(eq) retreat = np.asarray(e) - E[:, 2] * 0.055 if is_reachable(retreat, eq): goto_pose(retreat, eq) p, R = pose(LEG) grip_offset = max(0.0, half_length - 0.022) grip_center = p + a * grip_offset down_R = np.column_stack((target_R[:, (long_axis + 1) % 3], np.zeros(3), -a)) down_R[:, 0] = unit(down_R[:, 0]) down_R[:, 1] = np.cross(down_R[:, 2], down_R[:, 0]) choices = [] _, current_q = get_ee_pose() for yaw in np.arange(8) * np.pi / 4: start_R = rotation(a, yaw) @ down_R finish_R = rotation(a, -np.pi / 2) @ start_R if (is_reachable(grip_center, matq(start_R)) and is_reachable(grip_center - a * 0.002, matq(finish_R)) and is_reachable(grip_center + a * 0.07, matq(start_R))): score = abs(np.dot(matq(start_R), unit(current_q))) choices.append((score, start_R)) if not choices: raise RuntimeError("No reachable axial ratcheting grasp found.") base_R = max(choices, key=lambda c: c[0])[1] goto_pose(grip_center, matq(base_R), z_approach=0.07) close_gripper() total_turn = np.pi / 2 recent_heights = [] verified = False for turn in range(31): h, a = get_holes(TOP)[hole_index] h, a = np.asarray(h, dtype=float), unit(a) p, R = pose(LEG) if np.dot(R[:, long_axis], a) < 0.97: raise RuntimeError("The leg lost axial alignment during threading.") # Correct lateral slip and maintain a small axial preload while turning. height = np.dot(p - h, a) lateral = p - h - height * a desired_center = p - lateral - a * 0.0015 desired_center += a * max( 0.0, half_length - 0.003 - np.dot(desired_center - h, a) ) desired_R = rotation(a, -np.pi / 2) @ R if abs(get_joint_positions()[6]) > 2.72: break if not move_part(desired_center, desired_R): break total_turn += np.pi / 2 p_after, R_after = pose(LEG) h_now, a_now = get_holes(TOP)[hole_index] h_now, a_now = np.asarray(h_now), unit(a_now) axial_height = np.dot(p_after - h_now, a_now) lateral_error = np.linalg.norm( p_after - h_now - axial_height * a_now ) recent_heights.append(axial_height) _, current_table_R = pose(TOP) axes_parallel = np.min( np.abs(np.diag(current_table_R.T @ R_after)) ) > 0.985 settled = (len(recent_heights) >= 4 and np.ptp(recent_heights[-4:]) < 0.0008) if (total_turn >= 4 * np.pi and abs(axial_height - half_length) < 0.003 and lateral_error < 0.003 and axes_parallel and settled): verified = True break # Release before unwinding: never reverse the engaged leg. open_gripper() e, eq = get_ee_pose() clearance = np.asarray(e) + a_now * 0.075 goto_pose(clearance, eq) if not is_reachable(clearance, matq(base_R)): home_pose() else: goto_pose(clearance, matq(base_R)) p, R = pose(LEG) grip_center = p + a_now * grip_offset if not is_reachable(grip_center, matq(base_R)): raise RuntimeError("The leg slipped outside the regrasp workspace.") goto_pose(grip_center, matq(base_R), z_approach=0.055) close_gripper() if verified: open_gripper() e, eq = get_ee_pose() goto_pose(np.asarray(e) + a * 0.08, eq) final_p, final_R = pose(LEG) final_h, final_a = get_holes(TOP)[hole_index] final_a = unit(final_a) final_delta = final_p - np.asarray(final_h) if (np.linalg.norm(final_delta - final_a * half_length) > 0.005 or np.dot(final_R[:, long_axis], final_a) < 0.98): raise RuntimeError("Final verification failed after releasing the leg.") else: raise RuntimeError("Thread seating was not verified within the motion budget.") main()

물리 단계 (30 trial 계측 재생, seed 0)

같은 코드를 계측 모드로 재실행해 API 호출마다 다리의 기울기·구멍 축 대비 수평 오차·높이를 기록(TSV). 에이전트 프로그램은 raise로 끝나므로 마지막 호출 시점 상태로 분류. 물리가 완전 결정적이진 않아 평가 결과와 다를 수 있고, 2 trial(20·22)은 재생에서 호출 기록이 남지 않았다.

도달한 단계n설명
구멍 위 정렬(기울기 ≤3°, 수평 ≤5 mm)까지 갔으나 삽입 실패19들어 올려 세우고 구멍 위에 맞추는 데까지는 성공(최소 수평 오차 중앙값 ~2 mm). 내리거나 놓는 단계에서 빠져나가 대부분 90°로 넘어짐
안착(|높이 오차| <12 mm, 기울기 <10°) 후 이탈/넘어짐4trial 06·11·17·21 — 나사·재파지 중 쳐서 빠짐. 17은 마지막 상태 1.4° / 0.8 mm / +6 mm(안착만, 미조임)
세우기/운반 중 넘어짐3trial 01(최소 기울기 32°), 10, 18
기타 (정렬 11~12 mm에서 중단)2trial 14, 30
재생 기록 없음2trial 20, 22(평가에선 성공)
병목은 "넣는 순간"이다. 30 중 23이 정렬 이상까지 갔고(19 정렬 + 4 안착), 안착을 경험한 건 4뿐. 손 oracle이 겪은 것과 같다 — 입구 4 mm 위에서 놓으면 넘어지고, 3 mm 안에 넣고 놓아야 안착한다. 이 mm 단위 파라미터는 한 번의 생성으로는 못 찾고, 시도→관측→수정이 필요하다. 멀티턴이 정확히 이 지점을 겨냥한다.

3b API 비용

단가(게이트웨이 /v1/models): gpt-6-astra 입력 $10/M tok, 출력 $50/M tok(reasoning 토큰은 출력에 포함). CaP-X는 usage를 저장하지 않아 대표 호출을 1회씩 실측하고 로그의 호출 수로 환산했다. 이후 job부터는 프록시가 요청별 usage를 기록한다.

호출 종류prompt tokcompletion tok (reasoning)시간비용/호출
단일 턴 초기 프로그램 (v5 프롬프트 8.7 KB)1,5563,858 (1,024)81 s$0.21
멀티턴 결정 프롬프트 (중반, 코드 누적)3,6521,163 (146)23 s$0.10
실행LLM 호출추정 비용 (30 trial)
단일 턴 (v5 및 v1~v4 각각)30 (1/trial)≈ $6
real_multiturn≈ 11.4/trial≈ $45
real_multiturn_vdm (이미지·차분 호출 포함)≈ 24/trial≈ $80~90
real_agent0 (앙상블 9샘플+종합 매 결정)≈ 190/trial≈ $500~700, 6~7 h → 7 trial(0 성공) 후 중단(사용자 결정)

비교: pi0.5 평가는 API 비용 없이 GPU 시간만 든다. 단일 턴 code-as-policy는 에피소드당 $0.2로 값싸지만, 멀티턴·앙상블로 갈수록 성공률보다 비용이 먼저 늘어난다. 이 프로젝트 누적 ≈ $150~200.

4 Takeaway

의미

이 숫자가 "Astra + 원본 수준 API"의 수치다. 에이전트가 받은 것은 텍스트 8.7 KB 한 번 — 인식 오라클(GT 6D 자세)·부품 크기·구멍 위치·도달성 판단·관절 상태 — 원본 CaP-X privileged 태스크가 주는 범위 안이다. 그 조건에서 gpt-6-astra는 30번 중 1번 조립했고, 같은 API로 사람이 짠 프로그램은 3번 중 2번 조립했다.

실패의 축이 옮겨갔다. 레시피가 있던 4차에선 "낙하 잔차 6.5 mm를 완료로 판정해 안 돌림"이 7/30이었는데, v5에선 "돌리다 넘어뜨림"이 16/30이다. 즉 에이전트는 나사식이라는 물리 사실에서 "잡고 돌린다"까지는 스스로 가지만, 강성 관절 제어에서 재파지·−90° 회전 중 다리를 유지하는 정밀도(재측정·보정 루프, 놓기 깊이)를 한 번의 생성으로 얻지 못한다.

단일 턴의 구조적 한계. 28/30이 자체 검사문으로 raise했다 — 프로그램이 문제를 감지는 하는데 관측을 받아 다시 계획할 통로가 없다. 원본 CaP-X가 multi-turn 설정을 따로 두는 이유가 이 데이터에 그대로 보인다.

30 trial로는 1 vs 3을 구분할 수 없다(p = 0.61). "누설 제거로 성능이 떨어졌다"는 결론도 이 규모에선 성립하지 않는다.

5 Next Steps

멀티턴(관측 피드백) 모드

v5 병목에 정확히 대응: raise 대신 관측(다리 자세·기울기)을 프롬프트로 되돌려 재계획. 원본 *_multiturn.yaml 설정을 furniture 태스크에 붙이면 된다. 30 trial 15분이라 비용 부담 없음.

규모

원본처럼 100 trial(≈50분)로 신뢰구간을 좁힌다. 현재 0.033의 95% CI는 대략 0.001~0.17.

멀티턴 계열은 부분 실행으로 종료

§6 참조. 다시 돌릴 거면 텍스트 멀티턴만(30 trial ≈ $45), 놓기 깊이를 관측으로 잡을 수 있도록 MULTITURN_LIMIT을 20으로 늘리는 것이 가장 싼 다음 실험.

산출물

API cap-x/capx/integrations/franka/furniture_real.py · 태스크 capx/envs/tasks/franka/franka_furniture.py(FrankaFurnitureRealCodeEnv) · 설정 env_configs/furniture/franka_furniture_one_leg_real.yaml · 워커 FurnitureBench/scripts/capx/furniture_worker.py(--control joint), panda_fk.py · 실행 scripts/capx/eval_capx_furniture.sh one_leg gpt-6-astra 30 4 real, oracle_capx_furniture.sh · 출력 cap-x/outputs/capx_furniture/gpt-6-astra/one_leg_real_gpt-6-astra_260911_060525_118577/ · 로그 claude/260911/progress-capx_real_faithful_api.md

6 멀티턴 · VDM · CaP-Agent0 (부분 실행)

CaP-X의 상위 tier를 v5 API 위에 그대로 붙였다: 코드 블록 실행 → 실행 코드 + stdout/stderr(+현재 이미지, +턴 전후 차분을 VLM이 텍스트로) → REGENERATE(같은 에피소드에서 이어 실행) / FINISH, 최대 10턴. Agent0는 여기에 같은 모델 9회 샘플·종합 앙상블과 기하 헬퍼 스킬 라이브러리를 더한다. 프롬프트는 v5 물리 사실 프롬프트에 "실행 후 stdout/stderr를 받고 새 코드를 쓸 수 있다" 한 문장만 추가.

설정피드백trial (job)성공trial당 LLM 호출비고
단일 턴 v5 (기준)없음30 (118577)1/30 = 0.0331완주
real_multiturn코드 + stdout/stderr22 (118733, 선점) + 22 (118779, 취소)2/44 = 0.045≈ 11성공 2건 모두 own run: trial 10(12블록), trial 14(8블록 후 FINISH). 대부분 trial이 10턴 한계까지 재생성
real_multiturn_vdm+ 앞/손목 이미지 + VLM 차분 설명15 (118734, 선점) + 15 (118780, 취소)0/30≈ 24—
real_agent0VDM + 앙상블 9× + 스킬 라이브러리7 (118735, 취소)0/7≈ 190trial당 ≈ $20, 3 trial에 40분 → 사용자 결정으로 중단
Video 2: 멀티턴 성공 trial 10 — 12블록(11회 재생성) 끝에 조립 (code.py, 블록 주석 포함)
Video 3: 멀티턴 성공 trial 14 — 8블록 후 FINISH (code.py)
읽는 법. 전부 부분 실행(extra QOS 선점 1회, 비용 때문에 취소 1회)이라 통계 비교는 못 한다. 보이는 경향: 텍스트 피드백 멀티턴은 단일 턴보다 낫지 않다고 할 수 없는 정도(2/44 vs 1/30), 이미지·차분과 앙상블은 이 규모에서 이득이 없었고 비용만 2~20배. 블록 진행을 보면 "상태 조회 → 파지·들기 → 안착 계산 → 호버·내리기 → 'Leg moved during release' → 수정" 반복으로 10턴을 소진한다 — 관측이 있어도 mm 단위 놓기 깊이를 10번 안에 찾는 경우가 드물다. 턴당 LLM 시간은 11~14 s로 단일 턴(90 s)보다 짧다(블록이 짧음).

운영 기록