DeepSeek Harness 소스 읽고 정리한 '최소 CLI 에이전트' 설계도
DSH 소스를 통독한 작성자는 최소 CLI 에이전트가 해야 할 일, 7개 핵심 결정, 위반 시 결과가 있는 12개 불변식, 독립 실행 가능한 5개 마일스톤을 소스 출처와 함께 정리했다.
중국어 원문을 AI로 번역했습니다. 고유명사와 수치는 원문 표기를 우선하며, 중요한 판단에는 아래 출처 원문을 함께 확인하세요.
서문: 두 가지 극단, 그리고 내가 가고 싶은 세 번째 길
Agent가 도대체 어떻게 돌아가는지 이해하고 싶을 때, 인터넷이 제시하는 길은 보통 두 가지 극단이다.
하나는 "30줄 코드로 직접 작성하는 Agent"다. 그대로 베껴 쓰면 돌아가긴 하는데, 왜 돌아가는지는 모른다. 로그도 없고, 동시성도 없고, 권한도 없다. 모델이 조금만 이상하게 행동하면 print로 추측하는 수밖에 없다.
다른 하나는 성숙한 프레임워크를 직접 읽는 것이다. 이를테면 DeepSeek Harness(이하 DSH)가 오픈소스로 공개한 이것: Cordis 플러그인 커널, 20여 개의 도구, 세션 로그, 샌드박스, 서브 에이전트, PTY, 텔레메트리…… 다 읽고 나면 "너무 강력하다"는 느낌이 들고, 그러고 나서는 더더욱 직접 쓰기가 겁난다.
내가 원하는 것은 세 번째다: 스스로 설명할 수 있는 최소 agent 루프.
입력 읽기 → 요청 조립 → 스트리밍 수신 → tool_call 조립 → 도구 실행 → 결과를 히스토리에 넣기 → 다시 전송
여기에 두 가지를 더한다: 하나의 이벤트 로그를 유일한 진원(single source of truth)으로 삼고, 하나의 권한/샌드박스를 관문으로 삼는다. 그게 전부다.
성공 여부를 판단하는 기준도 하나뿐이다: 어느 대화든, 나는 로그 파일에서 모델이 그때 본 모든 바이트를 재현할 수 있어야 한다.
아래 내용은 내가 DSH 소스 코드(MIT 라이선스, 바로 clone 가능)를 통독한 뒤 스스로에게 정리해 준 시공도다. 모든 "DSH는 어떻게 했는가"는 소스를 하나하나 대조해 확인하고 path:line을 표기했으며, 행 번호는 내가 읽은 그 시점의 master 스냅샷을 기준으로 한 것이니 참고용일 뿐이다.
1. 먼저 경계를 긋는다, 이 단계가 코드를 쓰는 것보다 중요하다
1.1 할 것 (MVP 필수)
- 단일 프로세스 REPL, 스트리밍 출력, Ctrl-C로 현재 라운드 취소 가능
- OpenAI 호환 모델 어댑터 계층 (baseURL만 바꾸면 provider 교체)
- 도구 5개: read / write / edit / bash / glob+grep
- 추가식 JSONL 세션 로그 + 로그로부터 모델 히스토리 도출 + --resume
- 3단계 권한 모드 + 위험 작업 단건 승인 (fail-closed)
- 출력 절단 / 단건 도구 결과 트리밍 / token 추정
1.2 명확히 하지 않을 것 (README에 적어, 스스로 손이 근질거리지 않게)
하지 않음 | 지금 하지 않는 이유 --- | --- 다중 provider 추상화 계층 / 라우팅 테이블 | OpenAI 호환 adapter 하나면 충분하고, 추상화는 두 번째 provider가 실제로 등장할 때까지 미룬다 플러그인 시스템 / Cordis식 조합 | 나는 "agent loop 교체"가 필요 없다. 먼저 돌아가게 하는 게 필요하다 세션 자동 압축(compaction 요약) | 우선 "추정 + 하드 절단 + 단건 결과 트리밍"으로 버틴다. M5에서 다시 논한다 MCP / skill / 서브 agent / 워크플로 | 전부 승수(multiplier)다. 메인 루프가 없으면 무의미하다 TUI 프레임워크 / 리치 텍스트 렌더링 | ANSI를 직접 20줄 쓰면 충분하다. textual / prompt_toolkit을 끌어들이지 말 것 벡터 메모리 / RAG | "자체 사용 CLI 어시스턴트"의 일상 작업과 무관하다 다중 라운드 실패 자동 반성 / 자기 수정 루프 | 먼저 단일 라운드가 정확하게, 그다음에 자동 재시도 이야기
1.3 하나의 반직관적 발견: 최소 ≠ 안전
DSH 공식에는 sdk-minimal profile이 있는데, 이는 base를 겹치지 않은 유일한 완전한 트리로 단 37줄이다. 그런데 이게 아주 흥미로운 짓을 했다:
그것의 tool-fs는 read/write/edit을 전부 꺼 버리고( readImage만 남김), 파일 작업은 영속 bash가 대신하게 했다. 권한은 danger-full-access다.
( packages/bundle/sdk-minimal/cordis.patch.yml:44-50, :73, :144 )
즉, 공식의 "최소"는 안전성을 희생해 얻은 것이다 — 셸 하나만 남을 때까지 깎아낸 뒤, 그 셸 안에서 알몸으로 달린다.
이것이 내게 첫 번째 명확한 판단을 주었다: 따라 베끼지 말 것. 내 버전은 "도구는 두 개 더, 권한은 한 겹 더"여야 하며, 그 대가는 대략 150줄의 코드다.
2. 일곱 가지 결정, 그리고 그 후회 비용
작은 프로젝트에서 가장 두려운 것은 잘못 선택하는 게 아니라, 3주 뒤에 바꿀 수 없는 선택지를 고르는 것이다. 그래서 각 결정에 "후회 비용"을 표기했다.
| 결정 포인트 | 나의 선택 | 이유 | 후회 비용 --- | --- | --- | --- | --- D1 | 언어 | Python 3.11+, 서드파티 의존성 제로 | 기존 툴체인이 Python이다; urllib + json + subprocess로 충분하다; 한 줄 고치면 바로 적용되고 빌드 단계가 없다 | 낮음(약 1000줄) D2 | 모델 연결 방식 | OpenAI 호환 /v1/chat/completions + SSE | 내 로컬 중계 서비스가 바로 openai-completions 프로토콜을 쓰고, adapter 하나로 GLM / DeepSeek / 混元(Hunyuan)을 모두 커버한다 | 낮음 D3 | 세션 저장 형태 | 추가식 이벤트 로그(JSONL, 한 줄 한 이벤트), 히스토리는 투영으로 도출 | 이것이 DSH 전체 설계의 뿌리다: deriveMessages()가 로그로부터 투영하며, 두 번째 메시지 배열을 절대 유지하지 않는다 | 높음 (후기에 바꾸는 것은 재작성과 같다) D4 | 도구 집합 규모 | 첫 배치 5개 (read/write/edit/bash/glob) | DSH 완전판은 20+개 도구지만, 90%의 시간에는 이 5개만 쓴다 | 낮음 D5 | 동시성 기본값 | 기본 exclusive; read / glob / grep만 safe로 표기; 풀 상한 4 | DSH도 read/read_image/web_search/web_fetch/session_query만 safe로 선언하고, 기본 10 동시성이다 ( packages/core/tools/src/pipeline.ts:110-120 ) | 낮음 D6 | 권한 모델 | 3단계 mode + 단건 승인, fail-closed | §4 불변량 11 참조 | 중간 D7 | 컨텍스트 초과 전략 | 우선 "트리밍"만 하고 "요약"은 하지 않는다 | 단건 도구 출력 트리밍만으로도 80%의 창 초과를 막을 수 있다 (DSH의 pruner 임계값 8192/4096/1024 ) | 낮음
D3는 따로 말할 가치가 있다: 왜 messages 배열을 상태로 삼으면 안 되는가
모든 사람이 Agent를 처음 쓸 때의 1판은 이렇게 생겼다: 메모리에 messages: list 를 유지하고, 매 라운드 append하고, 종료할 때 JSON으로 dump한다. 이写法은 3일 안에는 절대 문제가 없지만, 3주 뒤에는 세 가지 버그를 동시에 만나게 된다:
- --resume 후에 모델이 보는 컨텍스트가 지난번 종료 시점과 다르다
- 도구 결과와 call_id가 어긋나서, provider가 바로 400을 낸다
- "그때 도대체 뭘 보냈는지" 보고 싶으면 터미널 scrollback을 뒤지는 수밖에 없다
DSH는 이 말을 한 섹션 전체에 걸쳐 설명한다(docs/architecture.md:128):
Model-visible means logged. 요청으로 들어가는 모든 것은 먼저 로그 이벤트여야 한다.
내 방식은 로그를 데이터베이스로, 히스토리를 뷰로 취급하는 것이다. 매 요청 전에 이벤트 스트림에서 messages를 투영(project)하고, 투영이 끝나면 버리며 캐시하지 않는다. O(n)의 시간을 더 쓰는 대신 버그 한 범주를 통째로 없애는 것이다.
3. 아키텍처: 10개 모듈, 약 1000줄
┌─────────────┐ stdin ──────────►│ main.py │ REPL / argparse / SIGINT └──────┬──────┘ │ user 입력 ┌──────▼──────────────────────────────┐ │ agent.py turn/step 상태 머신 │ │ ┌─────────────────────────────────┐ │ │ │ while : 조립→요청→조립→실행→로그 기록 │ │ │ └─────────────────────────────────┘ │ └──┬──────────┬──────────┬────────────┘ │ │ │ ┌──▼──────┐ ┌──▼──────┐ ┌──▼────────┐ │ prompt.py │ │ llm.py │ │ tools.py │ │ 시스템 프롬프트 │ │ SSE+조립 │ │ 레지스트리+파이프라인 │ └───────────┘ └────┬─────┘ └──┬─────────┘ │ │ ┌──────▼─────┐ ┌──▼────────┐ │ (네트워크) │ │ batch.py │ 독점 barrier + 순차 제출 └────────────┘ │ policy.py │ 권한 판정 └─┬─────────┘ │ ┌───────────▼─────────┐ │ base_tools.py │ read/write/edit/bash/glob └─────────────────────┘
┌──────────────────────────────────────────────┐ │ events.py 유일한 진원: append / derive_messages │ │ / JSONL 읽기·쓰기 / 크래시 꼬리 복구 │ └──────────────────────────────────────────────┘ compact.py (M5에서야 추가)
| 모듈 | 줄 수 | 역할 | 핵심 제약 | | --- | --- | --- | --- | | events.py | ~150 | SessionEvent(type, seq, time, data); append() 검증 + seq 연속; JSONL 추가 쓰기; derive_messages() 네 가지 투영 규칙; 크래시 꼬리 복구 | 유일한 진원, 다른 모듈은 메시지 리스트를 캐시 금지 | | agent.py | ~180 | turn/step 상태 머신, inbox(next-turn / next-step), 여섯 가지 종료 출구, 실패 시 tool result 보충 | 도구를 직접 호출하지 않고 tools / batch만 호출 | | llm.py | ~140 | SSE 파싱, StreamChunk 네 가지 증분, BlockAssembler, 에러 코드 정규화 | bytes ↔ chunk만 담당, 의사결정은 안 함 | | tools.py | ~150 | 레지스트리, JSON Schema 부분집합 검증, 파이프라인 prepare → guards → dispatch → complete | pre-execute에서 arguments 재작성 불가 | | batch.py | ~90 | 분류(exclusive/safe), barrier, 유계 롤링 풀, 모델 순서대로 제출, 취소 보상 | 제출 포인터는 단조 증가만 가능 | | policy.py | ~70 | 3단계 sandbox mode, workspace 접두사 검사, ask 단일 승인, never 정책 | 진입점은 pre-execute 위치에 고정 | | base_tools.py | ~140 | read(줄 번호+윈도우+바이트 상한), write(버전 가드), edit(literal replace), bash(타임아웃+출력 절단), glob/grep | 모든 도구가 구조화된 error를 반환하고 스택을 던지지 않음 | | prompt.py | ~50 | persona + 도구 schema를 시스템 텍스트로 렌더링, surface 노드로 사용 | 0 디스크 쓰기, 시스템 프롬프트는 "요청 필드"가 아님 | | compact.py | 0(M5) / ~90 | 문자 추정, 임계값 판정, 요약 호출, replace 이벤트 삽입 | M5 이전에는 그냥 pass | | main.py | ~80 | argparse(-p 일회성 / --resume / --cwd / --mode), REPL, SIGINT→cancel, 종료 전 flush | — |
의존 방향은 반드시 단방향이어야 한다:
main → agent → {llm, tools, prompt} → {batch, policy} → base_tools 모든 모듈 → events (events는 아무것도 import하지 않음)
3.1 이벤트 로그는 어떻게 생겼나
@dataclass class SessionEvent : type : str # turn/start | user | assistant | tool/call | tool/result | turn/end seq: int time: float data: dict class SessionLog : def __init__ ( self, path ): self.path, self.seq = path, 0 def append ( self, type_: str , **data ) -> dict : self.seq += 1 ev = { "type" : type_, "seq" : self.seq, "time" : time.time(), "data" : data} with open (self.path, "a" , encoding= "utf-8" ) as f: f.write(json.dumps(ev, ensure_ascii= False ) + "\n" ) return ev # 검증 + seq 연속 + flush, 모두 여기서 한다
한 줄에 이벤트 하나, 추가 방식으로 쓰고, 사람이 눈으로 읽을 수 있다. 문제가 생겼을 때 tail -f가 곧 디버거다.
3.2 투영: 히스토리는 로그의 뷰다
def derive_messages ( events: list [ dict ] ) -> list [ dict ]: out = [] for ev in events: t, d = ev[ "type" ], ev[ "data" ] if t == "user" : out.append({ "role" : "user" , "content" : d[ "text" ]}) elif t == "assistant" : if not d[ "blocks" ]: # 불변식 10: 빈 content는 투영하지 않는다 continue out.append({ "role" : "assistant" , "content" : d[ "text" ], "tool_calls" : d[ "tool_calls" ]}) elif t == "tool/result" : out.append({ "role" : "tool" , "tool_call_id" : d[ "call_id" ], "content" : d[ "content" ]}) return out
이 코드에는 정렬이 없다는 점에 주목하자. 로그 안의 tool/result는 이미 모델 순서대로 디스크에 기록되어 있기 때문이다(불변식 3 참조). "제출 직렬화"라는 제약의 가치는 여기서 더 단순한 투영 함수로 직접 드러난다.
4. 반드시 맞춰야 하는 12가지 불변식
이 절은 내가 이 글에서 가장 나 자신에게 쓰고 싶은 부분이다. 아래의 각 항목은 DSH에서 실제로 밟아 본 함정이며, 스타일 선호가 아니다. 위반하면 곧 버그다.
불변식을 위반하면 어떻게 되는가 DSH 출처
1. tool/call은 먼저 디스크에 기록한 뒤 실행한다; tool/result는 call_id 참조를 단다 → 크래시 후 재생 시 "주인 없는 result"가 나오고, provider가 dangling tool_result를 보고한다 → packages/core/tools/src/batch.ts:243-265
2. 각 assistant의 tool_call에는 반드시 짝이 맞는 result가 있어야 한다: 성공 / 실패 / 취소(미발송), 세 가지 모두 보충해야 한다 → 다음 라운드 요청 시 400; 모델이 결손된 히스토리를 보고 환각을 시작한다 → packages/core/agent-loop/src/agent.ts:363-385, packages/core/session/src/repair.ts:105-178
3. 결과는 모델 순서대로 제출하며, 완료 순서가 아니다 → tool_use / tool_result가 어긋나고, 모델이 결과를 다른 호출에 잘못 연결한다 → batch.ts:139-150 (commitReady는 연속으로 준비된 슬롯만 전진시킨다)
4. 동시성 안전은 기본이 exclusive이며, 명시적으로 safe를 선언해야만 병렬 실행된다 → write / edit이 서로 덮어쓴다 → packages/core/tools/src/pipeline.ts:110-120
5. 스트리밍 조립: index로 그룹화; 도구 매개변수는 argumentsDelta를 이어 붙이는 것에 의존; block-end는 최초로 닫은 것이 승리 → 도구가 반토막 난 JSON을 받거나 / 텍스트가 중복된다 → packages/llm/llm/src/assembler.ts:69-83
6. finish.kind == 'max-tokens'일 때 모든 tool_call을 버린다 → 매개변수가 반토막 난 JSON인 호출을 실행하게 되어, 부작용을 예측할 수 없다 → assembler.ts:137-140
7. usage는 finish 이전에 보내며; finish 이후에는 어떤 chunk도 더 있어서는 안 된다 → 회계가 맞지 않는다 / 스트림이 닫힌 뒤 쓰면 오류가 난다 → docs/subsystems/llm-streaming.md:298
8. 빈 응답(stop이면서 content block이 없음)은 EMPTY_RESPONSE로 분류되는 재시도 가능 오류다 → 조용히 공회전하여, turn은 정상 종료된 것처럼 보이지만 아무것도 하지 않는다 → docs/subsystems/llm-streaming.md:304
9. 요청에 들어가는 모든 내용은 반드시 먼저 로그 이벤트여야 한다(시스템 프롬프트, 주입된 컨텍스트 포함) → resume 후 히스토리와 요청이 불일치한다 → docs/architecture.md:128
10. 빈 content 메시지는 히스토리로 투영되지 않는다(derive_messages가 None을 반환) → 디스크에 기록되면 히스토리에 들어간다고 생각하지만, 실제로 모델은 볼 수 없다 → packages/core/session/src/surface.ts:135-141
11. 승인은 allowed-once만 권한 부여로 간주; answerer 결여 / 타임아웃 / 예외는 일괄 deny → "아무도 답하지 않음"을 allow로 처리 = 조용히 전부 통과시킨다 → docs/subsystems/approval.md:21, pipeline.ts:444-492
12. write / edit 전에는 반드시 해당 파일을 읽어야 한다(버전 가드), 버전이 맞지 않으면 stale을 보고한다 → 사용자가 직접 고친 내용을 덮어쓴다 → packages/fs/fs-observation-policy
4.1 가장 틀리기 쉬운 것은 5/6/7/8이며, 모두 스트리밍 계층에 있다
조립기의 핵심은 딱 세 가지다: index로 그룹화하기, 매개변수를 대입이 아니라 이어 붙이기, 닫기는 첫 번째만 인정하기.
``` class BlockAssembler: def __init__(self): self.blocks: dict[int, dict] = {} self.closed: set[int] = set()
def feed(self, chunk): i = chunk["index"] if i in self.closed: # 불변식 5: block-end 먼저 온 쪽이 승리 return b = self.blocks.setdefault(i, {"type": chunk["type"], "text": "", "args": ""}) if chunk["kind"] == "text-delta": b["text"] += chunk["delta"] elif chunk["kind"] == "tool-call-delta": b["args"] += chunk["arguments_delta"] # 이어 붙이기, = 이 아니다 elif chunk["kind"] == "block-end": self.closed.add(i) ```
그리고 finish 단계에서 버리는 처리를 한다:
``` tool_calls = assembled_tool_calls() if finish.kind == "max-tokens": tool_calls = [] # 불변식 6: 반토막 매개변수는 일괄 실행하지 않는다 if finish.kind == "stop" and not blocks: raise LLMError("EMPTY_RESPONSE") # 불변식 8: 빈 응답은 성공이 아니라 오류로 처리한다 ```
6번 항목은 소스 코드에서 "읽어 낸" 것이지만, 진짜로 이해한 것은 한 가지를 스스로 깨닫고 나서였다: 모델이 잘렸을 때 주는 tool_call은 문법적으로 완전히 합법일 수 있다. {"path": "/etc/hos는 명백히 잘린 것이지만, {"path": "/etc"}는 완전한 JSON이다—다만 네가 원하는 것이 아닐 뿐이다. 그래서 판단 기준은 "JSON이 파싱되는지"가 될 수 없고, "이번 라운드가 max-tokens로 끊겼는지"만 될 수 있다.
4.2 문서에 없지만 반드시 물어뜯는 두 가지
- timeoutMs를 도구 정의에 적어 두는 것은 선언일 뿐이고 파이프라인이 강제하지 않는다(packages/core/tools/README.md:205). → 반드시 dispatch 계층에서 직접 subprocess.run(timeout=...)을 감싸야 하며, 그렇지 않으면 tail -f 하나만으로도 에이전트 전체가 멈춰버린다.
- turn에는 내장 예산이 없고, 무한 루프의 유일한 출구는 turn_stopping에서 카운트해서 취소하는 것이다(packages/core/agent-loop/README.md:220). → 직접 max_steps_per_turn을 추가해야 한다. 모델은 당신의 지갑이 대신 빙글빙글 돌아줄 때까지 아주 인내심 있게 "같은 파일을 다시 한 번" 읽을 것이다.
5. 단계 구분: 각 마일스톤이 독립적으로 실행될 수 있어야 한다
이것이 내가 가장 배워야 한다고 생각하는 엔지니어링 습관이다. 모듈 단위로 만들지 말고, "실행되는 것" 단위로 만들어라.
M0 · 골격(약 2시간)
stdin을 읽고, 출력하고, Ctrl-C로 종료되는 껍데기. 작업: main.py의 REPL + argparse, events.py의 append / JSONL 저장, 가짜 "에코 모델" 대역.
검수: python -m miniagent -p "hi"가 hi를 출력하고, .miniagent/sessions/<id>.jsonl에 4개의 이벤트(turn/start, user, assistant, turn/end)가 있어야 한다.
M1 · 실제 모델 + 스트리밍(1~2일 밤)
단일 턴 질의응답이 동작하고, 스트리밍 출력하며, 도구 없음. 작업: llm.py의 SSE 클라이언트 + BlockAssembler(먼저 text-delta와 finish만 처리), 에러 코드 정규화(429/RATE_LIMIT, CONTEXT_WINDOW_EXCEEDED, EMPTY_RESPONSE), prompt.py가 system 텍스트를 생성해 노드 0으로 저장.
검수: -p "SSE가 무엇인지 세 문장으로 설명해줘"가 글자 단위로 스트리밍 출력됨; 로그의 request/header에 provider / model / contextWindow가 기록됨. 이 단계에서 가장 잘 뒤집어진다: 불변량 5, 7, 8.
M2 · 도구 루프 폐쇄(2~3일 밤, 핵심)
모델이 실제로 파일을 읽고, 명령을 실행하고, 파일을 수정할 수 있다. 순서가 중요하다:
- tools.py 레지스트리 + schema를 요청에 렌더링
- base_tools.py: 먼저 read + bash만(가장 많이 쓰이고 가장 검증하기 쉬움)
- assembler에 tool-call-delta 조립 추가
- batch.py: 먼저 순수 직렬 구현(풀 없이), 단 순서대로 제출하는 골격은 유지
- agent.py: step 루프 + 여섯 가지 종료 출구 + 실패 시 result 보충
- 그다음 write / edit / glob 보충
검수(모두 통과해야 완료):
- -p "README.md를 읽고 프로젝트가 무엇을 하는지 알려줘" → 실제로 read 호출
- -p "foo.py의 bar를 baz로 바꿔줘" → read 호출 후 edit 호출
- -p "false를 실행하고 결과를 알려줘" → 에러가 tool_result로 모델에 돌아가고, 모델이 계속할 수 있으며 turn이 깨지지 않음
- 네트워크를 뽑거나 프로세스를 죽인 뒤 --resume: 히스토리 완전, dangling tool_result 없음
M3 · 자기 보호(1~2일 밤, 가장 건너뛰기 쉽지만 가장 해야 하는 것)
목표는 아주 소박하다: 내가 잠든 사이에 DB를 삭제하지 않는 것.
작업: policy.py 세 단계 mode(read-only / workspace-write / danger-full-access) + 경로 접두사 검사; ask 단일 승인(stdin 한 번 질의응답, 타임아웃 시 unavailable → deny); 출력 절단(단일 도구 결과 > 8192자면 head 4096 + tail 1024 + 절단 안내로 자름); max_steps_per_turn; Ctrl-C → cancel(signal) 후 미파견 호출에 ABORTED_BEFORE_DISPATCH 보충.
검수: --mode workspace-write에서 -p "~/Documents 아래 모든 파일 삭제" 실행 → deny되고 turn은 정상 종료; --mode read-only에서 write는 일괄 거부.
참조한 DSH 수치: pruner의 thresholdChars: 8192 / headChars: 4096 / tailChars: 1024(packages/bundle/base/cordis.patch.yml:421-425); read 도구 상한 2000자/줄, 50KB/회(packages/fs/tool-fs/src/read-render.ts:11,14).
M4 · 체험(1일 밤)
렌더링(도구 호출을 한 줄로 접기, diff 하이라이트), /compact /new /model 슬래시 명령, 매 턴 종료 시 token 사용량과 누적 비용 출력, 세션 제목, --json 이벤트 스트림(스크립트용).
검수: 일상에서 원시 로그를 보지 않아도 그것이 무엇을 하는지 읽을 수 있음.
M5 · 선택적 강화
compaction 요약(임계값 0.8 × contextWindow, 꼬리 0.16 유지), 반복 호출 소프트 알림(3/5/8회), 하위 에이전트, skill 디렉터리, MCP.
6. 고빈도 함정 Top 10("날려버린 시간" 순 정렬)
- messages 배열을 상태로 착각하는 것. 3주쯤 쓰고 나서야 resume, 취소, 실패 복구를 전부 고쳐야 한다는 걸 알게 된다. → 이벤트 로그가 유일한 진실의 원천이다.
- 도구 호출 인자 이어붙이기 오류. argumentsDelta는 누적이지 대입이 아니다; content_block_delta 안의 input_json_delta도 마찬가지다.
- tool_call 에 짝이 되는 result 가 없다. 예외 경로에서 result 쓰는 걸 잊으면 다음 턴에 바로 400. → try/finally 로 모든 call 이 반드시 남도록 보장한다.
- 동시성이 기본으로 켜져 있다. write 와 edit 이 동시에 돈다. → 기본은 exclusive.
- 결과가 완료 순서대로 append 된다. 모델이 A 의 결과를 B 의 결과로 착각한다. → 풀은 동시성으로, 제출은 직렬로.
- max-tokens 로 잘렸는데도 도구를 실행하러 간다. 파라미터가 {"path": "/etc/hos 인 상태로.
- usage 를 finish 이후에 보낸다. 회계가 틀어진다.
- 빈 응답을 성공으로 처리한다. HTTP 200 이지만 내용이 비어 있는데, 사용자는 "완료"를 보지만 실제로는 아무것도 바뀌지 않았다.
- 승인 시 "아무도 대답 안 함"을 allow 로 처리한다. 사실상 조용히 전부 통과시키는 것과 같다. → allowed-once 만 인정한다.
- max_steps_per_turn 을 넣지 않았다. 모델이 "같은 파일을 한 번 더 읽기"에 갇히면 당신의 지갑이 대신 빙빙 돈다.
7. 검수: 어떻게 해야 "혼자 쓸 수 있는" 것인가
모두 체크한 다음에야 완공을 선언한다:
- -p "..." 단일 작업이 끝까지 돌고, 종료 코드가 정확하다 (0 완료 / 1 실패)
- 스트리밍 출력이 글자 단위로 보이고, 전체 응답을 기다렸다가 출력하는 게 아니다
- 읽고, 고치고, 명령을 실행할 수 있고, 세 도구가 한 작업 안에서 이어서 돌아간다
- 도구가 오류를 내면 모델이 오류를 보고 스스로 한 번 교정한다
- Ctrl-C 로 즉시 멈추고, 이력에 interrupted 표시가 남으며, 후속 요청이 터지지 않는다
- 프로세스 강제 종료 → --resume → 대화 계속, 모델이 보는 이력이 중단 전과 일치한다
- 경계를 벗어난 쓰기(workdir 밖)가 거부되고, 거부 사유가 로그에 기록된다
- 한 건의 초장문 출력이 임계값 내로 잘리고, 모델이 "여기가 잘렸다"는 걸 인지할 수 있다
- 한 세션의 로그 파일을 독립적으로 읽고 이해할 수 있다 (사람 눈으로 읽히는 이벤트 스트림)
- 턴당 스텝 상한이 발동하면, 조용히 멈춰 버리는 게 아니라 모델 / 사용자에게 명확히 알린다
8. 부록 A: DSH 의 minimal profile 에는 대체 무엇이 걸려 있나
그 37줄을 요약하면 12가지 분류다: 앱 셸(2) / 공식 원격 측정 사이드로드(3) / 모델 라우팅(1) / 모델 런타임(2) / 세션 로그(4) / 에이전트 커널(3) / 도구 레지스트리와 실행기(2) / 프롬프트 조립(1) / 샌드박스와 정책(2) / 파일 첨부 작업 디렉터리(5) / 자식 프로세스와 영속 셸(4) / 컨텍스트와 백그라운드 작업(6).
- 따라 베낄 것: 세션 로그 네 종 세트(이벤트 로그 + 프로젝션 + JSONL + checkpoint), 에이전트 커널 세 종 세트(agent / agent-loop / timer), 도구 레지스트리와 실행기의 분리.
- 베낄 필요 없는 것: JSON-RPC 셸, 원격 측정 사이드로드, MCP resources, image offload, session-title, jobs, 영속 PTY(일반 subprocess 로 셸을 한 번 띄우면 충분하다).
- 반대로 베낄 것: danger-full-access 기본값, tool-fs 전면 차단, 승인 줄이 없음.
DSH 에는 있지만 내게는 없는 것(그리고 내게 없다는 걸 분명히 알아야 하는 것): compaction, spill(12500 토큰 초과 시 파일로 넘김), repeat-tool-reminder, timeout-policy, checkpoint barrier, OS 수준 샌드박스(bwrap / Landlock / Seatbelt).
앞의 네 가지는 30줄로 조악하게 대체할 수 있지만, 마지막 하나는 그럴 수 없다. "경로 프리픽스 검사"로는 bash -c "cd / && rm -rf ~" 를 도저히 막을 수 없기 때문이다. 그래서 workspace-write 모드에서 bash 에 대해서는 명령을 파싱해 의도를 추측하는 게 아니라 항상 ask 해야 한다.
이것이 내가 이번에 소스를 읽으며 얻은 가장 중요한 하나의 인식이다: OS 수준 격리가 없을 때는, 애플리케이션 계층에서 격리가 있는 척하지 마라.
9. 부록 B: 90분짜리 최소 검증 경로
처음부터 정식 코드를 쓰고 싶지 않다면, 우선 하루 중 90분을 써서 일회성 150줄 스크립트를 작성하라:
stdin 읽기 → system + user 하나로 조립 → POST /v1/chat/completions (stream= true ) → 블록 단위로 print → 종료
이걸 돌려 보면 알게 된다: 네트워크 계층에 문제가 없고, 모델에 연결되고, SSE 가 도대체 어떤 모습인지를.
그다음 그것을 버리고 §3 의 모듈대로 다시 작성하라.
그 150줄 스크립트 위에서 반복 개선하지 마라 — 그것은 로그도, 도구도, 권한도 없고 유지보수 불가능한 물건이 될 운명이다. 나는 "조금만 더 고치면 쓸 만해진다"는 스크립트가 이 단계에서 죽는 것을 너무 많이 봤다.
마지막으로
이 글을 쓰는 과정에서 가장 가치 있는 것은 그 27개 작업이 아니라, 그 12개 불변량이다. 작업 목록은 구현에 따라 변하지만, 불변량은 "한 번 잘못하면 다시 써야 하는" 그 층이다.
딱 한 문장만 가져갈 수 있다면:
에이전트의 복잡성은 루프 안에 있는 게 아니라, 루프 밖의 "모델이 보는 것이 로그에 있는 것과 같아야 한다"는 제약들 안에 있다.
(DSH 는 MIT 라이선스 오픈소스 프로젝트이며, 이 글의 모든 소스 인용은 직접 clone 해서 대조할 수 있다; 행 번호는 내가 읽을 당시의 master 스냅샷 기준이라 버전이 바뀌면 어긋날 수 있다.)
lyai007
@유연 취업
6
글
137
읽음
1
팔로워