가장 작은 Claude 클라이언트: 셸 스크립트로 구현하기
요약
이 글은 Claude Messages API를 bash와 curl만을 사용하여 단일 파일로 구현한 초소형 클라이언트를 소개합니다. 이 클라이언트는 스트리밍 응답 처리, 도구 호출 왕복 과정 완료, 그리고 API 오류 보고 기능을 모두 수행하며, 복잡한 SDK 없이도 핵심 기능을 구현할 수 있음을 보여줍니다.
핵심 포인트
- bash와 curl만으로 Claude Messages API 클라이언트 구현 가능
- 스트리밍 응답(SSE) 및 도구 호출 과정을 완벽하게 처리
- 복잡한 SDK 대신 기본 셸 스크립트로 최소 기능 구현 시연
언어 모델 API를 위한 모든 SDK는 수천 줄에 달하며, 그 크기는 사람들이 클라이언트가 거대한 것이라고 생각하게 만듭니다. 하지만 그렇지 않습니다. 근본적으로 Claude Messages API는 하나의 HTTPS POST 요청과 JSON 본문으로 이루어져 있으며, 응답은 여섯 가지를 이해해야 하는 Server-Sent Events 스트림입니다. 이 게시물에서는 jq 없이 bash와 curl을 사용하여 완전한 클라이언트를 단일 파일로 구현했습니다. 이는 도착하는 대로 응답을 스트리밍하고, 도구 호출(tool call) 왕복 과정을 완료하며, API 오류를 적절하게 보고합니다. 이것은 매주 목요일마다 다른 언어로 동일한 작업을 수행하고 서로 비교하는 시리즈의 기준점입니다.
코드와 테스트 환경(harness)은 github.com/AndyCross/smallest-client에 있으며, 리더보드는 /smallest-client/에서 확인할 수 있습니다.
사양 (The spec)
시리즈의 모든 클라이언트는 동일한 일곱 가지 포인트를 통과해야 하며, 제가 코드를 읽는 대신 테스트 환경(harness)이 이를 확인합니다.
ANTHROPIC_API_KEY에서 키를,ANTHROPIC_BASE_URL에서 기본 URL을 읽습니다. 환경 변수 중 다른 것은 사용하지 않습니다.- stdin으로부터 프롬프트를 읽습니다.
x-api-key,anthropic-version: 2023-06-01, 그리고content-type: application/json헤더와, 본문에model,max_tokens,stream: true,messages,tools를 포함하여POST /v1/messages요청을 보냅니다.- 도착하는 대로 각
text_delta를 출력합니다. 전체 응답을 버퍼링한 후 마지막에 출력하는 방식은 실패합니다. - IANA 시간대를 받는 하나의 도구인
local_time을 선언해야 합니다. 스트림이 `stop_reason:
Point 5는 클라이언트와 단순히 curl 래퍼를 사용하는 것의 차이를 만드는 부분입니다. 도구 사용(Tool use)은 프로토콜이 단순한 요청과 응답을 넘어 유지해야 하는 대화가 되며, 이때 도구의 인자들은 스스로 조립해야 하는 JSON 조각들로 도착합니다.
전송되는 내용물 (What comes down the wire)
stream: true를 사용하면 응답은 text/event-stream 형식입니다. 각 이벤트는 event: 라인, JSON을 담고 있는 data: 라인, 그리고 빈 줄로 구성됩니다. 실제 녹화본에서 잘라낸 첫 번째 턴의 형태는 다음과 같습니다:
event: message_start
data: {"type":"message_start","message":{"id":"msg_...","model":"claude-opus-5-5",...}}
...
제가 녹화본을 보기 전에 잘못 알고 있던 두 가지가 있었습니다. 모델은 서문 텍스트 없이 바로 도구 호출로 넘어갔기 때문에, 항상 텍스트 블록이 먼저 온다고 가정하는 클라이언트는 틀립니다. 그리고 문서에는 언급되어 있지만 아무도 머릿속으로 예상하지 못했던 중간의 ping 이벤트가 있습니다. 클라이언트는 자신이 모르는 이벤트 유형은 무시해야 합니다.
처리해야 할 여섯 가지는 message_start, content_block_start, content_block_delta, content_block_stop, message_delta, 그리고 message_stop입니다. 델타(Deltas)는 두 종류가 있습니다. 하나는 text 필드를 가진 text_delta이고, 다른 하나는 partial_json 필드를 가진 input_json_delta이며, 여기에 속한 콘텐츠 블록은 index를 통해 알 수 있습니다.
클라이언트 (The client)
전체 코드는 93줄입니다. 흥미로운 부분은 아래에 있으며, 나머지는 레포지토리(repo)에 있습니다.
jq 없이 JSON을 다루려면 문자열 자체를 인코딩해야 합니다. 프롬프트는 stdin에서 오기 때문에 어떤 내용이든 포함할 수 있으므로, 백슬래시(\), 따옴표("), 제어 문자들은 이스케이프(escape) 처리를 해야 합니다:
json_str() {
local s=$1
s=${s//\/\\}; s=${s//"/\"}
...
디코딩은 그 반대 과정입니다. 텍스트 델타는 이스케이프 처리된 JSON 문자열 형태로 도착하며, bash의 printf '%b'가 우연히 , , \, 그리고 ꪪ를 이해하기 때문에, "를 "로 바꾼 후 해당 문자열을 전달할 수 있습니다:
json_unstr() {
local s=$1
s=${s//\"/"}; s=${s//\\[/\}
...
이 \uXXXX 지원을 위해서는 UTF-8 로케일이 필요합니다. 슬림 Debian 이미지에는 설정된 것이 없기 때문에, printf는 이스케이프 시퀀스를 문자 그대로 출력하고 "café"는 café로 나옵니다. Dockerfile에서 LC_ALL=C.UTF-8을 설정하는 것이 바로 이 시리즈가 다루고자 하는 종류의 문제입니다.
스트림 루프는 curl -N을 사용하여 출력 버퍼링을 비활성화하고, 라인을 읽고 각 이벤트 유형별로 정규 표현식을 적용합니다:
while IFS= read -r line || [[ -n $line ]]; do
[[ $line == data:* ]] || { [[ $line == event:* || -z $line ]] || errbody+="$line"; continue; }
data=${line#data: }
...
read의 || [[ -n $line ]]은 오류 응답이 후행 개행 문자(trailing newline)가 없는 단일 JSON 라인이기 때문에 필요합니다. 그렇지 않으면 read는 개행 문자가 없는 파일의 마지막 라인에서 false를 반환하므로, 이 구문이 없으면 중요한 유일한 라인에 대해 루프 본체가 실행되지 않습니다. data:, event: 또는 공백이 아닌 라인은 오류 본문(error body)으로 수집되며, 루프가 끝난 후 상태 코드는 curl이 -D 옵션과 함께 작성한 헤더 파일에서 가져옵니다.
정규 표현식은 JSON 파서가 하는 역할을 수행합니다. 이는 API가 키를 알려진 순서로 포함하는 간결한(compact) JSON을 방출하기 때문에 작동하며, 이것이 솔직한 주의사항입니다: bash 클라이언트는 패턴 매칭으로 와이어 포맷을 파싱하고 있으며, 서버가 두 개의 키 순서를 재배열하면 깨집니다. 시리즈의 다른 모든 클라이언트들은 실제 파서(real parser)를 사용합니다. 이 클라이언트는 최소 사양이며, 그럴 의도입니다.
첫 번째 스트림이 tool_use로 끝날 때, 클라이언트는 TZ="$timezone" date를 사용하여 도구를 실행하고, 수집한 내용(모든 텍스트, 그리고 조립된 입력을 가진 tool_use 블록)을 바탕으로 어시스턴트 턴을 재구성하여 세 메시지 대화 전체를 두 번째 요청으로 전송합니다. 그에 대한 응답이 답변입니다.
측정하는 것
해당 하네스(harness)는 각 클라이언트를 개별 컨테이너에서 모의 환경(mock)과 비교하며, 이 모의 환경은 기록된 교환을 원래 청크 간격으로 재현합니다. 네트워크를 통해 아무것도 측정되지 않습니다. 클라이언트당 유일한 실제 호출은 적합성 검사(conformance)용이며, 그 타이밍은 발표되지 않습니다.
| bash 및 curl | |
|---|---|
| Artefact | 3.7 KB (스크립트) |
| ... | |
| Apple M4 Max의 Docker 환경에서, linux/arm64, bash 5.2.37, OpenSSL 3.5.7을 사용한 curl 8.14.1. |
콜드 스타트(Cold start)는 첫 번째 요청을 받는 목(mock)으로 프로세스가 생성되는 것을 의미합니다. 오버헤드는 종료까지의 시간에서 목이 재현하는 데 소비된 시간을 뺀 값이며, 이는 클라이언트 자체가 비용을 지불하는 부분입니다. 런타임 수치는 공유 라이브러리를 계산에 포함하는데, 정적 바이너리는 TLS와 HTTP를 자체적으로 처리하기 때문에 그 비용을 지불하고, 동적으로 연결된 curl 역시 libcurl과 OpenSSL의 비용을 동일하게 지불해야 합니다. 그렇지 않으면 bash는 1.7 MB를 보여주며 기술적인 면에서 승리할 것입니다.
두 개의 요청에 대한 오버헤드가 57밀리초라는 것은 두 번의 curl 프로세스 생성과 수백 개의 정규 표현식 매칭을 의미합니다. 뒤따르는 Go 및 C# 클라이언트는 이와 동일한 작업을 그 절반 이하의 시간 안에 수행하며, 나머지 시리즈에서 흥미로운 질문은 어떤 것이 Go가 달성하는 것보다 훨씬 낮거나 Native AOT .NET 바이너리가 달성하는 것보다 훨씬 작은지 여부입니다. 저는 두 가지 모두에 대한 의견을 가지고 있으며, 리더보드가 제가 맞았는지 알려줄 것입니다.
실행 방법
git clone https://github.com/AndyCross/smallest-client
cd smallest-client
./bench/bench.sh bash
Docker와 Python 3이 필요하며 키는 필요하지 않습니다. 리포지토리의 테스트 데이터(fixtures)는 실제 녹화 기록입니다. 만약 이들을 다시 녹화하고 싶다면, ./bench/bench.sh record bash를 실행하면 프록시를 통해 한 교환을 수행하는데, 이 프록시는 셸에서 키를 읽고 어디에도 쓰지 않으며, 테스트 대상 클라이언트에게는 플레이스홀더가 전달됩니다.
새로운 언어를 추가하는 것은 소스 파일이 있는 디렉토리, Dockerfile, 그리고 네 줄짜리 meta.env로 구성되며, CI(Continuous Integration)는 모든 풀 리퀘스트에서 벤치마크를 실행합니다. 제가 그 언어에 도달하기 전에 리더보드에서 여러분의 언어를 보고 싶다면, 그것이 방법입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기