
자막 파이프라인에서 ASR 출력을 Claude로 교정하는 단계가 있다. Claude Code의 headless 모드(claude -p)를 subprocess로 돌리는데, --json-schema 옵션을 붙이면 구조화된 JSON 응답을 받을 수 있다. 간단해 보였는데 파싱이 자꾸 깨졌다.
처음엔 --max-turns 1로 설정했다. 한 번만 물어보고 끝내면 되니까. 그런데 응답이 안 왔다.
Claude Code는 내부적으로 첫 번째 턴을 도구 호출 등에 소모한다. --max-turns 1이면 내부 턴 하나 쓰고 바로 error_max_turns로 종료해버린다. result 필드 자체가 없는 응답이 돌아온다.
해결은 단순하다. --max-turns 2로 올려서 내부 턴 + 실제 응답 턴을 확보하면 된다. 문서에 이 동작이 명확히 안 나와 있어서 한참 헤맸다.
max-turns를 고치고 나니 응답은 오는데, result 필드를 파싱하면 빈 문자열이었다.
--json-schema 옵션을 쓰면 구조화된 응답이 result가 아닌 structured_output 필드에 들어간다. 이것도 문서에서 바로 찾기 어려웠고 stdout을 직접 로깅해서 알아냈다.
Claude Code 호출 부분.
result = subprocess.run(
[
"claude",
"-p", f"{input_json}",
"--system-prompt", SYSTEM_PROMPT,
"--output-format", "json",
"--json-schema", CORRECTION_SCHEMA,
"--max-turns", "2", # 1이면 내부 턴 소모로 응답 없음
"--dangerously-skip-permissions",
],
capture_output=True, text=True, env=env,
)응답 파싱은 structured_output을 먼저 보고 없으면 result로 fallback한다.
outer = json.loads(result.stdout)
# --json-schema는 structured_output 필드에 결과 저장
inner = outer.get("structured_output")
if inner is None:
# Fallback: 이전 CLI 버전 호환
inner = outer.get("result", outer)
if isinstance(inner, str):
inner = json.loads(inner)이전에는 JSON 파싱 실패에 대비해서 정규식으로 {"segments": ...} 패턴을 추출하는 fallback도 있었는데 --json-schema를 쓰면 그럴 필요가 없다. 코드가 꽤 깔끔해졌다.
외부 CLI 도구를 subprocess로 붙일 때 출력 포맷이 "당연히 이럴 것"이라고 가정하면 안 된다. 특히 LLM CLI는 버전마다 응답 구조가 미묘하게 바뀔 수 있어서 방어적 파싱이 필수다.
디버깅할 때 가장 도움이 된 건 raw stdout을 통째로 로깅한 것이다. 파싱 에러 메시지만 보면 원인을 알 수 없지만 실제 응답 원문을 보면 바로 보인다. 파싱 로직 앞에 logger.error("raw: %s", result.stdout) 한 줄 넣어두는 게 삽질 시간을 확 줄여준다.