OpenAI Codex · 도구 루프

스트림은 아직 가는데, 도구는 이미 일 시작했어요

모델이 아직 타이핑하는데 파일을 읽는 소리가 이미 났어요. 샘플링 루프의 순서는 이래요. 스트림 안에서 future를 만들고, 끝난 뒤에 한꺼번에 drain. 먼저 persist하고, 그다음 결과를 기다립니다.

강의 목표읽고 나면 세 가지를 말할 수 있어요. 첫째, 파서가 어느 프레임에서 도구 출발을 결정하는지. 둘째, 요청을 왜 히스토리에 먼저 쓰고 큐에 거는지. 셋째, 스트림이 정상 종료·일찍 닫히거나 사용자가 Esc를 누르면, 이미 일한 도구는 누가 마무리하고 히스토리에 무엇이 남는지.
먼저 해보세요 · 진행을 끌어 어느 프레임에서 출발하는지 보세요
같은 SSE 스트림: 진행을 끌어, 도구가 언제 일 시작하는지 보세요
출구
슬라이더를 끌거나 이벤트 칸을 누르세요. 왼쪽은 스트림 안에서 실행, 오른쪽은 스트림이 끝난 뒤. 출구 스위치가 마지막 프레임 마무리를 바꿉니다.
스트림 안 실행0 건 기록됨
시작을 기다려요.
스트림이 끝나길 기다림0 건 기록됨
시작을 기다려요.
논리 궤적 · 애니메이션 각 단계가 소스의 어느 구간인지
  1. SSE 프레임은 먼저 일반 이벤트로 풀리고, 아직 비즈니스 의미는 없어요responses.rs L164
  2. kind가 output_item.done이면 OutputItemDone을 만들어요responses.rs L352
  3. 샘플링 루프는 오는 즉시 handle_output_item_done에 넘겨요turn.rs L2384
  4. function_call을 히스토리와 rollout에 먼저 씁니다stream_events_utils.rs L316
  5. 그다음 도구를 pin하고 순서 큐를 밀어요stream_events_utils.rs L320
  6. 스트림 종료·끊김·취소는 수신 루프를 떠날 뿐이에요turn.rs L2282
  7. drain은 삽입 순서로 결과를 히스토리에 씁니다turn.rs L2135
  8. 그제야 취소 토큰을 봐요. Stream은 재시도 가능turn.rs L2760
진행을 끌거나 재생을 누르세요. 파서가 어느 프레임에서 도구 출발을 결정하는지 보세요.
출발점왼쪽은 첫 OutputItemDone에서 도장을 찍고 일 시작해요. 오른쪽은 마지막 프레임을 기다립니다. 모델이 아직 말하는 동안, 양쪽은 이미 갈라져요.
끊긴 스트림왼쪽은 이미 기록된 요청과 drain된 결과가 남아, 재시도가 그 히스토리를 읽어요. 오른쪽은 요청을 아직 안 써서, 재시도는 빈 히스토리에서 시작합니다.
Esc왼쪽은 요청을 남기고 출력을 중단 문구로 쓰며 TurnAborted를 찍고, 한 라운드 더 치지 않아요. 오른쪽은 아무것도 안 썼습니다.
수업용 스케치: 이벤트 띠와 시간은 수업용 설정으로, 두 시계를 대조해요. 궤적 오른쪽 행 번호는 openai/codex commit 4f39251a01에 대응합니다.
아이디어 1 · 요청에 먼저 도장을 찍고, 그다음 도구가 일 시작해요
어떤 문제를 푸는가

모델에게 파일 셋을 읽고 요약을 쓰라고 해요. 화면은 아직 타이핑 중인데 파일 읽는 소리가 이미 났어요. 그다음 Esc. 화면은 멈추지만 히스토리에는 그 요청이, 때로는 결과까지 남습니다. 취소는 아무 일도 없었던 거라고 생각하지만, 런타임은 그렇게 장부하지 않아요.

더 흔한 쪽은 이래요. 모델이 function_call 둘을 이미 보냈고 세 번째는 아직 오는 중인데, SSE가 response.completed 전에 닫힙니다. 다음 재시도는 빈 히스토리를 봐야 할까요, 이미 기록된 호출과 결과를 봐야 할까요?

Completed를 기다렸다가 쓰면, 일찍 닫힌 스트림이 이미 완성된 호출까지 버립니다. 재시도는 모델에게 같은 호출을 다시 보내게 해요. 취소 때 쓰기를 건너뛰면 히스토리에 요청 반쪽만 남고, 모델과 화면 모두 닫히지 않은 호출을 봅니다.

아이디어는 무엇인가

OutputItemDone은 파서 층이 response.output_item.done 한 프레임을 비즈니스 이벤트로 접은 것이에요. 오는 순간 샘플링 루프는 세션 히스토리와 rollout에 먼저 쓰고, 도구 실행을 future로 싸 순서 큐에 겁니다. 취소 토큰은 자식 토큰이라 부모가 켜지면 이 도구도 멈춥니다. 취소가 아무리 빨라도 이 function_call은 이미 히스토리에 있어요. 많아 봐야 aborted by user를 한 줄 더 씁니다.

출처: codex-rs/core/src/stream_events_utils.rs 190–192행; 316–327행. 타입 별칭 위 주석이 계약을 못 박아요. 끝난 모델 출력은 즉시 기록하고, 나중에 turn이 취소돼도 히스토리와 rollout은 동기 상태를 지킵니다.

SSE 바이트 스트림 아직 프레임을 뱉는 중 OutputItemDone 완성된 도구 요청 하나 히스토리에 쓰기 function_call을 먼저 기록 future 걸기 도구는 이미 달리는 중 이후 delta 아직 오는 중 입력은 이미 끝난 도구 요청 하나. 출력은 영수증이에요. 히스토리에 한 줄, 큐에 아직 달리는 future 하나. 모델은 뒷말을 아직 안 끝냈어요. 요청에는 이미 도장이 찍혔습니다.
교육용 구조도: 같은 프레임에서 먼저 persist, 그다음 pin. 뒤의 타이핑과 도구 시간이 겹쳐요.
왜 오래 성립하는가

히스토리는 위로만 더하고 고쳐 쓰지 않아요. 도구가 이미 디스크를 읽었다면 그 사실은 일어난 겁니다. 취소 트리는 실행을 끊을 수 있어도, 이미 쓴 요청은 못 지워요. 요청을 먼저 쓰고 결과를 나중에 써야 transcript가 항상 닫힙니다. 이 계약은 Rust에 묶이지 않아요. TypeScript로 바꿔도 먼저 append하고 그다음 promise를 push합니다.

출처: AGENTS.md 91–100행, Model visible context 첫 조항: No history rewrite.

아이디어 2 · 스트림 안에서 걸고, 모든 출구에서 drain
어떤 문제를 푸는가

모델은 종종 파일 읽기를 먼저 보낸 뒤 설명을 이어서 써요. 퇴근 호루라기를 기다렸다가 일 시작하면, 파일 읽기 지연과 타이핑 지연이 직렬이 됩니다. 스트림 안 출발은 그 두 시간을 겹쳐요. 대가는 취소와 끊긴 스트림이 이미 일한 future를 인수해야 한다는 것. 인수자가 없으면 고아 태스크가 생깁니다. 도구는 달리는데 히스토리가 안 맞아요.

아이디어는 무엇인가

수신 루프는 정상 Completed·일찍 닫기·or_cancel 어느 쪽이든 loop를 떠날 뿐이에요. 함수는 아직 반환하지 않았습니다. 이어서 항상 drain_in_flight를 호출해 삽입 순서로 future마다 결과를 기다린 뒤 히스토리에 쓰고, 그제야 취소 토큰을 봅니다.

끊긴 스트림은 Stream으로 가고 재시도할 수 있어요. 재시도는 clone_history로 prompt를 다시 만들며, 이미 쓴 호출과 결과는 남습니다. Esc는 TurnAborted로 가고 재시도하지 않아요. 달리던 도구는 중단 문구를 쓰고, drain이 그걸 평범한 출력으로 기록합니다.

출처: codex-rs/core/src/session/turn.rs 2282–2284행; 2744–2762행. codex-rs/protocol/src/error.rs 88–93행, 364–390행.

수신 루프 Completed 스트림을 일찍 닫기 Esc drain_in_flight 삽입 순서로 결과를 쓰고, 그제야 취소 토큰을 봐요
교육용 출구 그림: 세 길이 먼저 drain에 모인 뒤, 이어가기·재시도·중지로 갈라져요.
스트림 안에서 future를 만들고, 끝난 뒤에 한꺼번에 drain.
왜 오래 성립하는가

중간에 일 시작했으면 모든 출구에서 맞춰 기다려야 해요. 소유권은 샘플링 함수의 지역 변수에 있고, 따로 백그라운드 회수 큐는 없습니다. 성공·오류·취소가 이 마무리를 공유해요. 언어를 바꿔도 같아요. 비동기 루프를 떠난 뒤 먼저 allSettled하고, 그다음 재시도와 중지를 정합니다.

아이디어 3 · 실행은 병렬이어도 돼요. 히스토리는 보낸 순서로 씁니다.

도구 셋은 동시에 달려도 돼요. 누가 먼저 끝나든 히스토리는 모델이 보낸 순서로 결과를 씁니다. 끝난 순서로 쓰면 같은 세션을 두 번 재생해도 안 맞을 수 있고, prompt cache도 더 약해져요. 순서 큐는 관측 순서와 실행 순서를 갈라 둡니다. 동시성 게이트는 다른 층. 여기선 이것만 기억하세요. 건 순서가 나중에 drain할 순서입니다.

출처: codex-rs/core/src/session/turn.rs 2130–2154행; 2391–2397행.

가로 비교 · 같은 문제의 다른 답

Claude Code: 기본은 스트림이 끝나길 기다리고, 스트림 안 게이트가 하나 더 있어요

기본 경로에서 스트림 루프는 tool_use만 모으고, for await가 끝난 뒤에야 runTools에 들어가요. 스트림이 끊기면 모은 block만 버리면 되고, 모든 출구에서 drain하는 지역 소유권은 아끼죠. 대가는 도구 지연과 타이핑 지연이 직렬이라는 것.

streamingToolExecution이 켜지면 행동이 Codex에 가까워져요. 스트림 안에서 addTool하고 즉시 일 시작. 실패 롤백은 이미 일한 도구를 discard해 옛 id가 재시도에 새지 않게 합니다. Codex에는 맞먹는 discard가 없어요. 먼저 persist하고 재시도가 히스토리를 읽도록 골랐거든요.

양쪽 모두 소스 대조 완료 · query.ts 551–568행, 1380–1382행

DSH: 3단 폭포와 단조 Guard, 누가 거부할 수 있는지를 봐요

DSH의 입구는 이미 만들어진 도구 호출이에요. pre / guard / around / post는 누가 거부할 수 있는지, 거부 뒤 결과가 남는지 답합니다. Guard는 거부 이유 또는 기권만 있고, 통과라는 선택지는 없어요. 그것의 drained는 한 번 execute 안의 마무리. SSE가 아직 날 때 이 폭포는 시작도 안 했어요.

양쪽 말은 비슷해 보여도 출구가 달라요. 한쪽은 권한 단조를 지키고, 한쪽은 스트리밍 transcript가 닫혀 있게 지킵니다. Guard를 Codex에 옮겨도 끊긴 스트림이 transcript를 잃는 것을 못 막고, persist-then-drain을 DSH에 옮겨도 플러그인이 거부를 통과로 바꿀 수 있는지는 답하지 못해요.

양쪽 모두 소스 대조 완료 · tools/src/index.ts 1–4행, 703–711행, 1328–1337행 · DSH · 3단 폭포와 단조 Guard
수업 실습
01

쓰기와 걸기를 맞바꾸기

handle_output_item_done의 도구 분기에서 record_completed_response_itemBox::pin(handle_tool_call)을 맞바꾸세요. 취소가 pin 전, persist 전에 일어납니다. 다음 샘플링과 세션 복구는 무엇을 볼까요?

답은 히스토리가 증분으로만 더해진다는 것과, drain_in_flight가 쓰는 타이밍에 떨어뜨리세요.

Takeaway: OutputItemDone이 오면 먼저 쓰고 일 시작해요. 스트림의 모든 출구는 먼저 drain하고, 결과는 보낸 순서로 씁니다. 취소는 실행을 끊지만, 이미 쓴 transcript는 그 자리에 남아요.