DeepSeek Harness · 모델과 외부 연동

비결정적 시스템 테스트하기

결정적 리플레이, 속성 기반 테스트, LLM 클라이언트를 속이는 전용 장애 서버. 핵심 자료: docs/testing.zh.mdpackages/test-support/.

강의 목표Agent 동작은 모델 출력에 의존하고, 모델 출력은 매번 달라요 — 이런 시스템을 어떻게 테스트할까요? 읽고 나면 DSH의 세 무기를 말할 수 있어요. 실제 세션 로그를 바로 리플레이 스크립트로 바꾸는 결정적 리플레이로, 비결정성은 녹음 그 한 번에만 남기고; LLM 클라이언트에 끊김·반쪽 패킷·레이트 리밋을 만들어 주는 스크립트형 장애 서버; 그리고 무작위 교차 시퀀스로 프로토콜 코드를 쓸어내는 속성 기반 테스트 — 첫 실행에서 진짜 버그를 잡은 그 테스트.
인터랙티브 데모 · 장애 주입과 리플레이 실험실

먼저 해보고 말해요. 시나리오 A는 진짜 HTTP 장애 서버예요: 동작을 큐에 세우고, 요청마다 하나를 소모하며, 클라이언트가 어떻게 받는지 봐요. 시나리오 B는 결정적 리플레이: 실제 세션 로그로 리플레이 스크립트를 한 번에 유도해 다시 돌리고, 한 줄을 변조해 diff가 그 자리에서 증언하는 걸 봅니다.

장애 서버 · 동작 스크립트 큐seed 0x5A3C
(비어 있음)
클라이언트 쪽 · 분류 / 재시도 / 로그 기록
(비어 있음)
시나리오를 고른 뒤 「재생」을 누르거나, 여기로 스크롤하면 시나리오 A가 자동 재생됩니다.
로직 분해 · 먼저 기초를 결정적으로

비결정적 시스템을 테스트하는 생각은 하나뿐이에요: 불확실한 부분을 가두고 나머지는 전부 결정적으로 만들어요. DSH의 테스트 계층(docs/testing.zh.md)은 그 생각 위에 쌓여 있어요. 유닛 테스트는 경계 조건, 오류 경로, 이벤트 순서, 동시성 레이스를 노리고; CI 커버리지 게이트는 packages/*/*/src에 파일별 100%를 요구해요(AGENTS.md 65행 Commands 절). 문서도 단호해요: 행 커버리지는 필요조건이지 충분조건이 아니며, 안 돈 행은 대개 지워야 할 죽은 코드이지 채워야 할 테스트가 아니에요.

키가 있는 실제 API 테스트는 또 다른 층이에요. 정체성이 드러나는 한 구절:

“우리는 DeepSeek예요 — 실제 API 테스트를 아끼지 마세요. 키 없는 테스트는 하위 경로만 증명하고, 키가 있는 실행만이 agent가 실제 모델과 붙어 정상 동작함을 증명합니다.” 출처: docs/testing.zh.md 「키 사용 전략」 절, 확인일 2026-08-13

추론이 우리에게는 싸니까 스모크 테스트는 진짜로 가요: 실제 예제를 띄우고, 프롬프트 하나를 보내고, 바깥세상을 확인해요. 단언도 까다로워야 해요. e2e는 파일을 다시 읽고 명령을 다시 돌려 결과를 검증해야 하며, agent 자기 출력에 키워드만 탐지하면 치팅 agent가 통과해요. 키가 없는 환경은 자동으로 건너뛰고, 누구의 막지도 않아요.

로직 분해 · 로그가 곧 테스트 자산

본편은 리플레이예요. 지난 레슨에서 다뤘죠( LLM 어댑터 계층 참고): 스트리밍 청크마다 assistant/chunk 이벤트로 세션 로그에 그대로 남아요. dsh-llm-replay 플러그인은 그걸 거꾸로 써요. 녹음된 session.jsonl을 가져와 chunk 이벤트를 (turn, step)으로 묶으면, 각 그룹이 그때 한 번 모델 호출의 완전한 청크 시퀀스예요. 테스트에서는 실제 agent가 그대로 돌고, 모델 쪽만 리플레이 어댑터로 바꿔 녹음된 청크를 프레임마다 토해내요. 비결정성은 녹음 그 한 번에만 있고, 이후 재실행은 바이트 단위로 같고 API Key도 필요 없어요.

이게 “로그가 곧 테스트 자산”의 뜻이에요. fixture는 손으로 쓴 mock이 아니라 프로덕션 포맷 세션 로그 그 자체예요. 스냅샷 테스트로 조립된 전체 동작을 고정하고, 코드 한 줄이 동작을 갈라놓으면 diff가 그 자리에서 빨개져요. fork(분기 세션)에도 정교한 디테일이 있어요: 자식 세션 로그 앞머리는 부모의 시드 이벤트를 물려받으니, 리플레이 스크립트를 유도할 때 seedLength 경계 뒤에서 잘라야 해요. 안 그러면 부모 청크가 자식 호출로 잘못 재생돼요:

packages/test-support/llm-replay/src/index.ts532–542행
    const text = readFileSync(childFile, 'utf8')
    const header = parseSessionHeader(text)
    // Derive the child's script from its own events only — events AT OR after the seed
    // boundary.
    const ownEvents = parseSessionLog(text).slice(header.seedLength)
    children.push({
      recordedId: header.id,
      createdAt: header.createdAt,
      entries: deriveReplayScript(ownEvents),
      primary: false,
    })
소스 스냅샷 안내: 로컬 저장소 deepseek-harness-master 기준, 확인 파일 packages/test-support/llm-replay/src/index.ts, 확인일 2026-08-13. 코드 블록은 소스 원문을 유지합니다.

크로스 플랫폼 규율도 여기서 나와요. 체크인한 fixture는 macOS와 Linux에서 모두 리플레이돼야 하고, 녹음된 스냅샷이 어느 플랫폼에서든 깨지면 fixture 자체를 고치세요. AGENTS.md 123행 원문은 “fix fixtures, not normalizers”: fixture를 고치고, 정규화기를 쓰지 마세요. 정규화기는 테스트와 현실 사이에 솜을 끼우는 일이고, 많이 끼우면 현실을 테스트하지 못하게 돼요.

로직 분해 · LLM 클라이언트를 속이는 전용 서버

리플레이는 동작이 변하지 않는지를 봐요 — 아직 한 조각이 남아요: 전송 계층의 화려한 죽는 법. 연결 거부, 전송 중 socket 리셋, 정상 종료인데 [DONE] 없음, Retry-After가 있는 레이트 리밋, 아예 멈춰 있기. 각각은 어댑터와 복구 계층 눈엔 다른 것이고, 프로세스 안 mock으로는 전부 못 잡아요. mock이 fetch, SSE 프레이밍, socket 종료, 유휴 워치독 같은 실제 경계를 우회하거든요. 그래서 DSH는 dsh-llm-mock-server를 만들었어요: OpenAI 방언을 쓰는 진짜 Node HTTP 서버, 동작은 전부 스크립트 제어, 요청마다 동작 하나 소모, 스크립트가 바닥나면 명시적으로 오류(설계 동기는 Agent Note 2026-07-25-scriptable-llm-wire-fault-server.zh.md). 개발자가 수동으로 장애를 재현하려면 base URL과 key만 바꿔 어떤 앱이든 붙이면 돼요.

random 모드도 있어요. 가중치로 장애를 뽑아 스트레스 테스트하고, seed는 공개·재현 가능해요. 기본 가중치 표 자체가 LLM 클라이언트가 현장에서 만나는 것의 체크리스트예요: 정상 성공 48, 느린 성공 10, 중간 끊김(partial_disconnect) 10, 연결 리셋·끊김·빈 응답·레이트 리밋 각 5, 서버 오류 4, max_tokens 도달·정지 hang·503 각 2, 제일 고약한 둘은 각 1 — 스트림은 정상 끝나지만 덜 보낸 partial_eof, 그리고 깨진 JSON을 뱉는 malformed_json. 소스 주석도 짚어요: 이건 조절 가능한 테스트 압력 설정이지, 프로덕션 사고 빈도 추정이 아니에요.

출처: packages/test-support/llm-mock-server/src/index.ts 56–70행 DEFAULT_MOCK_LLM_RANDOM_WEIGHTS, 확인일 2026-08-13.

서버의 절제는 분명해요: 프로토콜 계층 사실만 보고하고, 재시도할지 말지는 판단하지 않아요 — 정책은 harness 몫이에요. 실제 조합 테스트는 요청을 DeepSeek 어댑터, agent loop, 재시도 플러그인 순으로 통과시키며, 검증은 구체적이에요: 요청 횟수 정확, 재시도 단계에 번호, 실패한 반쪽 청크가 히스토리로 새지 않음, 정상 EOF의 반쪽 출력은 STREAM_CLOSED로 분류되고 기본은 재시도 안 함.

로직 분해 · 속성 테스트, 첫 총에 피

마지막 무기는 아무도 생각 못 한 교차예요. 프로토콜 형태 코드(청크 스트림, 이벤트 로그, 인박스 스케줄링)의 입력 공간은 조합 폭발이고, 예시 테스트는 이미 떠올린 케이스만 고정해요. DSH는 프로토콜 형태 패키지마다 fast-check 기반 속성 테스트를 붙여요: 생성기가 현실적이지만 적대적인 입력(중복 인덱스, 지연 청크, block-start 없는 기형 스트림)을 만들고, 단언은 구체 출력이 아니라 불변조건이에요 — 조립된 블록 수는 본 서로 다른 인덱스를 넘을 수 없고, 반복 호출 결과는 안정해야 해요. 실패하면 재현 가능한 seed를 자동으로 찍어요.

전과는 Agent Note 첫 줄에 적혀 있어요:

“속성 테스트 스위트가 첫 실행에서 BlockAssembler 중복 block-end 진짜 버그를 찾았습니다.” 같은 인덱스의 반복 block-end는 이미 끝난 블록을 덮어쓰고, 그 버그는 happy path 100% 행 커버리지 아래에서 살아남았어요. 출처: .agents/notes/implemented/testing/2026-06-11-property-based-testing.zh.md, 확인일 2026-08-13. 수정 뒤 「첫 닫힘이 우선」 방어는 LLM 어댑터 계층 레슨의 소스 패널을 보세요.

이 인프라의 부산물도 있어요. BENCHMARK.md의 공식 벤치마크 경로는 Python SDK에 minimal 변형이고, 작업마다 독립 workspace예요. 테스트 체계가 탄탄하면 평가는 입력만 바꾸면 돼요.

커버리지는 필요하지만 충분하지 않음

파일별 100%는 CI 게이트지만, 행이 실행됐다는 것만 증명해요. 진짜 버그는 교차 시퀀스에 숨고 — 속성 테스트 영역 — 전송 경계에 숨으면 장애 서버 영역이에요.

fixture는 세션 로그

리플레이 fixture는 손으로 만든 mock이 아니라 프로덕션 포맷 session.jsonl 그 자체예요. 한 번 녹음해 어디서나 재생하고, 크로스 플랫폼을 통과해야 하며, 깨지면 fixture를 고치고 정규화기는 건드리지 마세요.

장애 서버는 정책을 하지 않음

스크립트대로 끊고, 레이트 리밋하고, 멈출 뿐이에요. 재시도 여부는 harness 몫. 테스트 인프라가 중립이어야 어댑터·loop·재시도 계층에 동시에 증언할 수 있어요.

가로 비교 · 다른 곳은 어떻게 테스트하나

Grok Build: 스크립트형 mock도 있지만 HTTP 계층에서 멈춤

Grok Build의 xai-grok-test-support에는 MockInferenceServer가 있어요(crates/codegen/xai-grok-test-support/src/mock_server.rs): 기본 echo 모드는 마지막 사용자 메시지를 메아리치고, 경로별 큐 스크립트 응답(상태 코드·body·SSE 이벤트 정밀 제어)을 지원하며, 한 서버가 chat-completions·responses·messages 세 API 방언을 동시에 받고, 모든 요청을 헤더까지 전부 기록해 단언에 씁니다. 발상은 DSH 장애 서버와 한 뿌리예요. 차이는 커버리지: 확인한 소스 기준으로는 HTTP 응답 계층 스크립팅이고, DSH 장애 서버는 한 층 더 내려가 socket 리셋·전송 중 끊김·정지 hang 같은 전송 계층 죽음을 동작 어휘에 넣고, 재현 가능한 가중 랜덤 모드까지 갖춰요. 리플레이 쪽은 Grok이 xai-sqlite-journal로 영속화하지만, 공개 증거상 프로덕션 로그를 바로 리플레이 스크립트로 유도하는 동등 메커니즘은 보이지 않아요.

Claude Code: 클로즈드소스 제품의 테스트 블랙박스

복원 소스(restored-src)에서 보이는 테스트 흔적은 제한적이에요 — 복원의 성격과 맞아요. 산출물에서 거꾸로 뽑은 건 제품 코드이고, 테스트 코드는 원래 산출물과 함께 배포되지 않아요. 그래서 내릴 수 있는 결론은 하나뿐: 공개 증거만으로는 Claude Code 테스트 체계가 어떤지 바깥에서 평가할 수 없어요. 그게 오픈소스 harness의 가치를 돋보이게 해요. DSH의 테스트 전략·커버리지 게이트·fixture 규율이 전부 저장소에 있고, 테스트 인프라 자체가 배우고 재사용할 수 있는 산출물이에요.

수업 실습
01

당신만의 불변조건을 설계하세요

BlockAssembler에 속성 테스트를 하나 더 넣는다고 가정해요. 생성기는 합법·기형이 교차된 청크 스트림을 무작위로 뱉어요(중복 block-end, 빠진 block-start, 지연 delta). 이 레슨의 조립 블록 수 불변조건을 참고해, 단언할 가치가 있다고 생각하는 불변조건 두 개를 더 쓰고, 각각이 어떤 실제 고장 부류를 막는지 설명하세요. 이어서 추론: 녹음된 스냅샷 fixture가 macOS에서는 통과하고 Linux에서는 경로 구분자 diff로 깨질 때, “fix fixtures, not normalizers” 규율에 따라 어디를 고칠까요? 왜 비교기 안에서 경로를 일괄 치환하지 않을까요?

Takeaway: 비결정적 시스템을 테스트하는 법은 비결정성을 녹음 그 순간에 가두는 거예요. 세션 로그에서 리플레이 스크립트를 바로 유도하고, fixture는 프로덕션 포맷 로그 그 자체. 전송 계층의 죽음은 진짜 HTTP 장애 서버로 하나씩 연습하고, 교차 공간은 속성 기반 테스트가 쓸어내며 — 첫 전투에서 커버리지 게이트가 놓친 진짜 버그를 잡았어요. 커버리지는 코드가 돌았다는 증명이고, 코드가 맞다는 증명은 이 세 무기뿐이에요.