Codex CLI 롤아웃 트레이스(rollout traces) 역공학
요약
본 글은 Codex CLI를 DeepSeek으로 구동하기 위해 번역 프록시(translation proxy)를 구축하는 과정에서 발생한 기술적 경험을 다룹니다. 특히, OpenAI의 Responses API 형식을 Chat Completions 형식으로 변환할 때 발생하는 세부적인 호환성 문제와, 실제 롤아웃 트레이스 파일의 구조가 개발자가 예상했던 표준화된 이벤트 타입과 크게 다르다는 점을 발견했습니다. 이러한 불일치 분석은 코딩 에이전트(coding agents)를 위한 런타임 툴링(runtime tooling)을 구축하는 데 있어 매우 중요한 시사점을 제공합니다.
핵심 포인트
- Codex CLI의 OpenAI Responses API 호출을 DeepSeek Chat Completions 형식으로 변환하기 위해 프록시가 필요했다.
- 프록시 구현 시, Codex가 기대하는 `function_call` 상태(`in_progress`)를 정확히 유지하는 것이 핵심이었다.
- 개발자가 예상한 표준화된 이벤트 타입(예: `exec_command_begin`, `agent_reasoning`)과 실제 롤아웃 트레이스 파일의 구조는 완전히 달랐다.
- 실제 트레이스 데이터는 `session_meta`, `event_msg`, `response_item` 등 다양한 고유한 타입을 가지며, 이는 에이전트 툴링 설계 시 고려해야 할 중요한 사실이다.
Codex CLI를 위한 DeepSeek 프록시를 구축하며 주말을 보냈습니다. 실제 트레이싱(tracing) 데이터를 생성하기 위해서였습니다. 프록시 구축 자체는 간단했습니다. 하지만 실제 트레이스 파일을 확인했을 때 발견한 것은 그렇지 않았습니다. 문서화된 형식과 실제 형식이 일치하지 않았습니다. 이것은 그 불일치에 관한 이야기입니다. 즉, 제가 무엇을 예상했는지, 무엇을 발견했는지, 그리고 코딩 에이전트(coding agents)를 위한 런타임 툴링(runtime tooling)을 구축하고 있다면 이것이 왜 중요한지에 대한 이야기입니다.
설정: 왜 프록시인가?
Codex CLI는 OpenAI의 Responses API(그들의 SDK를 통해)를 사용합니다. DeepSeek은 Chat Completions만 지원합니다. DeepSeek을 백엔드로 사용하려면 번역 프록시(translation proxy)가 필요했습니다. 즉, Responses API 호출을 가로채서 Chat Completions으로 번역해야 했습니다. 프록시(tools/codex_deepseek_proxy.py)는 간단했습니다:
- /responses (또는 /v1/responses)에서 POST 요청을 수락합니다.
- 입력 필드(Responses API 형식)를 Chat Completions 메시지로 번역합니다.
- 도구 정의(tool definitions)를 {"name": "bash", "parameters": {...}}에서 {"type": "function", "function": {"name": "bash", "parameters": {...}}}로 번역합니다.
- DeepSeek으로 전송하고, Chat Completions 응답을 수신한 뒤, 이를 다시 Responses API 이벤트 형식으로 번역합니다.
- 응답을 JSON 본문으로 반환합니다 (SSE 스트림이 아님 — DeepSeek의 스트리밍 출력은 호환되지 않습니다).
번역은 기계적이었지만, 한 가지 결정적인 세부 사항이 있었습니다. Codex는 function_call 항목의 상태(status)가 "completed"가 아닌 "in_progress"이기를 기대한다는 점입니다. 이 상태는 Codex에게 도구가 호출되었지만 아직 완료되지 않았음을 알려줍니다. 즉, function_call_output이 도착하기를 기다리고 있는 상태입니다. 이를 "completed"로 설정하면 Codex는 도구가 이미 실행되었으며 출력값이 없다고 생각합니다.
프록시가 정상적으로 작동하게 된 후, Codex CLI는 DeepSeek을 대상으로 문제없이 실행되었습니다. 이제 저는 실제 트레이스 데이터를 갖게 되었습니다.
제가 예상했던 것
Codex CLI는 세션 데이터를 ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl 경로에 롤아웃 JSONL 파일로 저장합니다. 각 줄은 type 필드와 payload를 가진 JSON 객체입니다.
Codex CLI의 소스 코드(특히 protocol.rs)를 읽은 결과, 다음과 같은 이벤트 타입들을 예상했습니다:
- exec_command_begin / exec_command_end — 명령 실행 경계 (command execution boundaries)
- mcp_tool_call_begin / mcp_tool_call_end — MCP 도구 호출 경계 (MCP tool call boundaries)
- agent_reasoning — 모델의 내부 추론 (the model's internal reasoning)
저는 이들을 기반으로 첫 번째 파서(parser)를 모델링했습니다. 하지만 결과는 세션당 정확히 1개의 이벤트만 생성되었습니다. 무언가 크게 잘못되었습니다.
실제 형상은 어떤 모습인가
원시 롤아웃(raw rollout) 파일을 덤프했습니다. 다음은 실제 형식입니다 (Codex v0.130.0을 기준으로 검증됨):
{ "timestamp" : "..." , "type" : "session_meta" , "payload" :{ "model_provider" : "deepseek" , "cli_version" : "0.130.0" }}
{ "timestamp" : "..." , "type" : "event_msg" , "payload" :{ "type" : "task_started" , ... }}
{ "timestamp" : "..." , "type" : "response_item" , "payload" :{ "type" : "message" , "role" : "developer" , "content" :[ ... ]}}
{ "timestamp" : "..." , "type" : "turn_context" , "payload" :{ "model" : "deepseek-chat" , ... }}
{ "timestamp" : "..." , "type" : "event_msg" , "payload" :{ "type" : "token_count" , ... }}
{ "timestamp" : "..." , "type" : "response_item" , "payload" :{ "type" : "function_call" , "name" : "exec_command" , "arguments" : "{}" , "call_id" : "call_xxx" }}
{ "timestamp" : "..." , "type" : "response_item" , "payload" :{ "type" : "function_call_output" , "call_id" : "call_xxx" , "output" : "..." }}
{ "timestamp" : "..." , "type" : "response_item" , "payload" :{ "type" : "message" , "role" : "assistant" , "content" :[{ "type" : "output_text" , "text" : "..." }]}}
{ "timestamp" : "..." , "type" : "event_msg" , "payload" :{ "type" : "agent_message" , "message" : "..." }}
{ "timestamp" : "..." , "type" : "event_msg" , "payload" :{ "type" : "task_complete" , ... }}
핵심 패턴은 다음과 같습니다:
- response_item/function_call — 모델이 도구 호출(tool invocation)을 요청했습니다. name, arguments (JSON 문자열), 그리고 call_id를 포함합니다.
- response_item/function_call_output — 해당 호출의 결과입니다.
call_id (해당하는 function_call과 쌍을 이룸) 및 output (문자열)을 포함합니다. event_msg/agent_message — 모델의 추론 (reasoning) 텍스트입니다. 이곳에 사고/추론 (thinking/reasoning) 블록이 위치합니다. response_item/message (role=assistant) — 사용자에 대한 모델의 텍스트 응답입니다. event_msg/token_count — 곳곳에 흩어져 있는 토큰 사용량 (token usage) 추적입니다. exec_command_begin, exec_command_end, mcp_tool_call_begin 또는 mcp_tool_call_end 이벤트는 존재하지 않습니다. 적어도 v0.130.0 롤아웃 (rollout) 형식에는 없습니다.
나를 놀라게 한 세 가지 사항:
-
call_id 페어링 (pairing), 중첩 (nesting) 아님
도구 호출 (Tool invocations)과 그 결과는 중첩된 이벤트가 아니라, call_id에 의해 연결된 평면 (flat) 구조입니다. function_call 라인이 나타난 후, 나중에 (잠재적으로 여러 줄 뒤에) 동일한 call_id를 가진 일치하는 function_call_output이 나타납니다. 그 사이에는 token_count 이벤트, 추론 (reasoning) 메시지, 또는 다른 함수 호출이 있을 수 있습니다. 이는 파서 (parser)가 시작/종료 중첩 구조를 가정하는 대신, 대기 중인 호출을 버퍼에 저장하고 call_id로 매칭해야 함을 의미합니다. -
토큰 카운트 (Token counts)가 어디에나 있음
event_msg/token_count 이벤트가 거의 모든 유의미한 이벤트 사이에 나타납니다. 이들은 예측 가능한 리듬을 따르는 것 같지 않습니다. 때로는 함수 호출 전에, 때로는 후에, 때로는 추론 블록 사이에 나타납니다. 인과적 추적 (causal tracing) 관점에서는 노이즈이지만, 이벤트 체인을 깨뜨리지 않고 이를 처리해야 합니다. -
명시적인 인과 관계 (causality) 없음
롤아웃 형식에는 parent_event_id 또는 그에 상응하는 인과 관계 필드가 없습니다. 인과 관계는 순서로부터 추론되어야 합니다. 모델은 function_call_output을 받은 다음 다음에 무엇을 할지 결정하므로, 출력 이후의 다음 function_call 또는 agent_message는 인과적으로 그 출력에 의존합니다. 이는 Copilot 및 Continue.dev의 로그 기반 테일러 (log-based tailers)가 사용하는 것과 동일한 시간적 휴리스틱 (temporal heuristic)입니다.
번역 체인 (The translation chain)
Codex CLI가 DeepSeek 프록시(proxy)를 통해 실행될 때 실제로 일어나는 과정은 다음과 같습니다:
Codex CLI (Responses API) → POST /responses { input: [...], tools: [...] } → 프록시가 input을 messages, tools로 번역 → 함수 정의 (function definitions) → DeepSeek Chat Completions API → 프록시가 response를 번역 → Responses API 이벤트 (events) → Codex가 call_id가 포함된 function_call을 수신 → Codex가 도구 (tool)를 실행 → Codex가 function_call_output을 다시 전송 → 프록시가 다음 요청으로 번역 → task_complete가 될 때까지 루프 반복
프록시에서의 각 루프 반복은 단일 Chat Completions 호출입니다. 응답에는 다음 중 하나가 포함됩니다:
- 도구 호출 (Tool calls, function_call 항목) → Responses API 출력 항목으로 번역
- 텍스트 응답 (text response, message content) → 어시스턴트 메시지 (assistant message)로 번역
- 둘 다 (모델은 동일한 응답 내에서 텍스트와 도구 호출을 모두 반환할 수 있음)
런타임 도구 (runtime tooling)에 대한 시사점
코딩 에이전트 (coding agents)를 위한 관측성 (observability) 또는 트레이싱 (tracing)을 구축하고 있다면, Codex CLI 포맷은 몇 가지 교훈을 줍니다:
-
소스 코드 주석을 믿지 말고, 와이어 데이터 (wire data)를 믿으세요.
protocol.rs는 한 가지 포맷을 제안했지만, 실제 롤아웃 (rollout) 파일은 다른 포맷을 사용했습니다. 소스 코드는 내부 데이터 구조를 보여줄 뿐, 직렬화 포맷 (serialization format)을 보여주는 것이 아닙니다. -
call_id 페어링 (pairing)은 반복되는 패턴입니다.
Codex CLI와 OpenAI의 Responses API 모두 function_call을 그 출력값과 연결하기 위해 call_id를 사용합니다. 이는 중첩 (nesting) 구조가 아니라 평면적인 키-값 (key-value) 관계입니다. 파서 (parser) 설계도 이에 맞춰야 합니다: call_id별로 버퍼링 (buffer)하고, 도착 시 매칭 (match)하십시오. -
로그 기반 인과관계 (Log-based causality)는 기본 모델이 아니라 대체 수단입니다.
Codex CLI 롤아웃 데이터에는 인과적 연결 (causal links)이 없습니다. 이는 추론되어야만 합니다. 이는 약 80%의 사례에서는 괜찮지만, 어떤 function_call_output이 어떤 후속 function_call을 트리거했는지 항상 알 수 없음을 의미합니다. -
이벤트 스트림 (event stream)은 이질적 (heterogeneous)입니다.
토큰 수 (token counts), 메타데이터 (metadata), 제어 이벤트 (control events)가 함수 호출 (function calls)과 섞여 있습니다. 견고한 파서는 고정된 이벤트 순서를 가정하지 않고 신호 (signal)와 노이즈 (noise)를 구분할 수 있어야 합니다.
제가 최종적으로 구축한 오픈 소스 구현체인 파서 (causetrace/hooks/codex_parser.py)는 다음을 처리합니다:
- response_item/function_call → call_id에 의해 추적되는 대기 중인 호출 (pending call) 생성
- response_item/function_call_output → call_id로 매칭하여, 해당 이벤트를 tool_output으로 업데이트
- event_msg/agent_message → 인과적 부모 (causal parent) 연결을 포함하는 추론 (reasoning) 이벤트 생성
- response_item/message (assistant) → 응답 텍스트 (response text) 이벤트 생성
이 파서는 465줄의 롤아웃 (rollout) 파일을 116개의 인과적으로 연결된 (causally-linked) 이벤트로 변환합니다. 이는 파서 정확도를 사실상 0% (protocol.rs 기반의 시도)에서 실제 포맷에서 발견 가능한 이벤트의 100%로 개선한 결과입니다.
전체 소스 코드는 다음에서 확인할 수 있습니다: https://github.com/milkoor/causetrace/blob/main/causetrace/hooks/codex_parser.py
그리고 실제 트레이스 (traces)를 가능하게 했던 DeepSeek 프록시 (proxy)는 다음과 같습니다: https://github.com/milkoor/causetrace/blob/main/tools/codex_deepseek_proxy.py
이 글은 코딩 에이전트 런타임 관측성 (runtime observability)에 관한 시리즈의 두 번째 글입니다. 첫 번째 게시물: Coding agents produce causal DAGs, not logs .
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기