DeepSeek Harness · 영속화와 인프라

자격증명, 설정, 저장, 텔레메트리

눈에 안 띄지만 함정투성이: 자격증명은 매번 다시 가져오고, 설정에는 비밀을 안 남깁니다.

강의 목표읽고 나면 세 가지를 말할 수 있어요. DSH에서 API key를 교체할 때 왜 프로세스를 재시작할 필요가 없는지; 두 프로세스가 설정 파일을 동시에 써도 왜 서로의 수정을 지우지 않는지; 그리고 익명 UUID 하나가 어떻게 텔레메트리·피드백·DeepSeek 요청 헤더를 동시에 받들면서, key를 한 번도 안 넣었을 때는 아예 만들어지지도 않는지.
인터랙티브 데모 · 자격증명 교체 훈련

먼저 해보고 말할게요. 위는 디스크의 자격증명 파일, 아래는 요청을 돌리는 프로세스 둘: 왼쪽은 DSH 방식—요청마다 파일에서 key를 다시 읽고; 오른쪽은 흔한 방식—기동 때 한 번 읽어 메모리에 죽을 때까지 둡니다. 스크립트가 중간에 key를 한 번 교체하고 비웁니다. 재생을 눌러 양쪽 운명을 보세요.

디스크의 자격증명 파일($DSH_HOME/.credentials.yaml, web Models 페이지가 쓰는 바로 그것) credentials/updated (DEEPSEEK_API_KEY)
DEEPSEEK_API_KEY:sk-live-01
DSH: 작업마다 다시 가져오기
프로세스 메모리에 key를 캐시하지 않음, 요청이 시작될 때마다 저장소에서 한 번 해석
(아직 요청 없음)
대조군: 기동 때 한 번 읽기
메모리 캐시:(프로세스 미기동)
(아직 요청 없음)
「재생」을 누르면 두 프로세스가 DeepSeek에 요청을 보내기 시작합니다.
데모는 수업용 시뮬레이션: key 값과 요청 내용은 강의용 fixture지만, 왼쪽 key 부재 오류는 packages/llm/llm-deepseek/src/index.ts 241–245행 템플릿을 글자 그대로 복제했어요. 실제 DSH에는 오른쪽 「기동 때 한 번 읽기」 프로세스가 없습니다—대조용 반면교사예요.
로직 분해 · 설정에는 참조만, 값은 매번 다시 가져온다

먼저 한 가지: DSH 설정 파일과 cordis.yml 어디에도 API key 값이 없어요. 담긴 건 참조, POSIX 스타일 환경 변수 이름(예: DEEPSEEK_API_KEY)입니다. 값은 자격증명 제공자 몫이고, 로컬 제공자는 네 층에서 찾아요. 프로세스 환경이 최우선, 그다음 $DSH_HOME/.credentials.yaml, 마지막이 프로젝트·사용자 .env. 부제가 말한 「설정에 비밀을 안 남긴다」: 디스크에는 이름만, 기밀은 설정 밖에 막혀 있어요(docs/subsystems/credentials.zh.md 5행).

그리고 이 레슨의 가장 중요한 규칙: 소비자는 작업마다 참조를 다시 해석하고, 작업을 넘어 캐시하지 않아요. 문서 원문이 직설적입니다—작업 단위 읽기가 곧 핫 업데이트 메커니즘(같은 문서 20행). DeepSeek 어댑터에서는 packages/llm/llm-deepseek/src/adapter.ts 214–222행: stream()마다 시작에 연결 설정과 key를 한 스냅샷으로 얼리고, 그 요청 내내 그걸 쓰며, 다음 요청은 자동으로 다시 해석합니다.

개요가 물은 경계 조건의 답이 여기 있어요. 요청 중간에 key를 교체하면 이번 요청은 옛 key로 끝나고, 새 key는 다음 요청부터—반반 섞임이 없습니다. 게다가 key는 연결 스냅샷에서 해석되므로 엔드포인트와 비밀은 늘 같은 세대 설정에서 오고, 설정 롤백 때 새 엔드포인트+옛 key 잡종이 안 생깁니다(그곳 주석이 의도를 밝힘).

놓치기 쉬운 seam급 규칙 둘. 첫째, 빈 저장 값은 어디서나 없음으로 보고, key를 빈 문자열로 두면 미설정과 같아 다음 요청이 바로 MISSING_CREDENTIAL—데모 마지막 단계가 그것. 둘째, 설정 UI는 describe(ref)로 「설정 여부·어느 층·쓰기 가능」만 돌려 주고 값은 절대 안 보여요. 프로세스 환경이 값을 주는 참조는 writable: false로 보고되는데, 거기에 쓰면 겉으로는 성공인데 해석은 환경의 옛 값을 계속 돌려주니 seam이 미리 거부합니다(같은 문서 34행).

이 구조의 깨끗함이 가장 잘 보이는 건 credentials/updated 이벤트(같은 문서 50행). 자격증명이 바뀌면 이벤트가 나가지만, 문서는 소비자가 필요 없다고 써 두었어요—설정 UI의 「설정됨」뱃지 새로고침용일 뿐. 핫 업데이트는 읽기 타이밍에 기대고 알림 브로드캐스트에 안 기대니, 쫓을 무효화 메시지도 관리할 구독도 없습니다.

작업마다 다시 가져오기

key 교체에 재시작 없이, 다음 요청이 새 값을 자동으로 씁니다. 진행 중 요청은 같은 세대 스냅샷으로 끝내고, 엔드포인트와 비밀은 절대 잡종되지 않아요.

빈 값 = 미설정

seam급 규칙, 어디서나 같음. key가 없으면 MISSING_CREDENTIAL과 설정 입구를 지목; describe는 다 답하되 값은 절대 메아리치지 않아요.

익명 id 하나, 소비자 셋

OTel user.id, /feedback 회신, DeepSeek 요청 헤더가 UUID 하나를 공유하고, 게으른 생성: 제대로 쓴 적 없으면 디스크에 안 남겨요.

핵심 증거 · 해석은 요청마다 일어난다

이 부분은 resolveApiKey 함수체(225행부터)에 있고, 모델 요청마다 한 바퀴 돌아요. 자격증명 seam이 있으면 그쪽으로 바로 해석하고, 없으면 기동 환경 변수로 물러납니다. else 분기 주석을 보세요—seam이 없으면 순위 매길 관리 저장이 없어 환경이 곧 전체 자격증명 평면:

packages/llm/llm-deepseek/src/index.ts230–240행
    if (credentials !== undefined) {
      const hit = await credentials.resolve(ref)
      if (hit !== undefined) return assertUsableApiKey(hit.value, 'llm-deepseek', ref)
    } else {
      // Without the seam there is no managed store to rank against, so the
      // environment is the whole credential plane.
      const ambient = launchEnvironmentOf(ctx).get(ref)
      if (ambient !== undefined && ambient.value.length > 0) {
        return assertUsableApiKey(ambient.value, 'llm-deepseek', ref)
      }
    }

두 길이 다 비면 바로 이어 MISSING_CREDENTIAL(241–245행)을 던지고, 오류 문구에 설정 입구 둘을 적어요. 데모 왼쪽 마지막 빨간 글자가 그 원문입니다.

소스 스냅샷 안내:로컬 저장소 deepseek-harness-master 기준, 확인 파일 packages/llm/llm-deepseek/src/index.ts, 확인일 2026-08-13. 코드 블록은 소스 원문을 유지하며, resolveApiKey 함수체 발췌입니다.
설정 파일 · 누구나 쓰되, 누구의 것도 지우지 마라

설정도 함정이에요. 사용자가 편집기로 settings.yaml을 고치고 web도 고치며, harness 프로세스 둘이 동시에 열려 있을 수 있어요. 순진한 구현은 메모리 스냅샷을 그대로 직렬화해 쓰니 나중 쓴 쪽이 이기고 먼저 쓴 전체를 지웁니다. 편집기에서 방금 넣은 설정이 다른 프로세스 한 번 저장에 깨끗이 날아가요.

DSH 쓰기 경로는 그걸 막았어요(Agent Note 2026-07-30-settings-write-path-integrity.md). 디스크에 쓰기 전마다 디스크를 다시 읽어 외부 수정을 합치고, 프로세스 간 파일 잠금 안에서 「읽기·렌더·원자 커밋」한 바퀴를 끝냅니다. 잠금은 withFileLock: wx<파일이름>.lock을 독점 생성—성공하면 보유; 남이 쥐고 있으면 초기 지연부터 상한까지 지수 백오프 재시도, 시한 초과면 오류. 독자는 잠금 경쟁에 안 끼고, 커밋은 임시 파일 rename 원자 교체라 읽는 쪽은 늘 완전한 한 판만 봅니다.

디테일 하나: 잠금 대기가 타임아웃되면 남의 잠금 파일을 지우기보다 오류를 내요. 함수 위 주석 이유—잠금 파일 나이는 주인이 죽었다는 증거가 아니고, 살아 있는 잠금을 뺏는 일이 타임아웃보다 훨씬 위험하며, 고아 잠금 정리는 운영 작업입니다. 익숙한 레시피: 확 안 서면 시끄럽게 실패하고, 조용히 사고 치지 마세요.

출처:packages/util/atomic-write/src/index.ts withFileLock, 86–111행, 확인일 2026-08-13.

저장과 텔레메트리 · 마이그레이션 거부, 신원 하나

KV 저장 SQLite 백엔드도 같은 버전 입장을 이어가요. STORAGE_SQLITE_SCHEMA_VERSION은 현재 1, PRAGMA user_version에 쓰고; DB를 열 때 새 빈 DB만 현재 도장을 찍고 다른 버전은 전부 열기 거부—제자리 마이그레이션 없음. 이전 레슨 세션 로그 버전과 같은 철학: 미출시 소프트웨어엔 지킬 이력이 없으니, 마이그레이션 더미를 지기보다 분명히 거부합니다.

작고 단단한 취사선택 하나: journal 모드는 기본 WAL, 나쁜 파일시스템은 몇 가지 롤백 저널로 물러날 수 있지만 memoryoff는 타입에서 배제됐어요(같은 파일 23–29행 주석). 이유 한 줄, 로그 영속성을 버리면 KV 백엔드 계약의 영속성 조항을 조용히 어깁니다. 빠른 건 괜찮고, 거짓말할 만큼 빠른 건 안 돼요.

텔레메트리에서 가장 무서운 건 본말을 뒤집는 일. DSH는 선택 능력 seam으로 만들어요: agent loop 본줄기에 없고, 텔레메트리 내용은 모델 요청에 안 들어가며, harness 책임은 emit()까지(docs/subsystems/session-telemetry.zh.md). 레코드마다 내보내기 전 비식별 파이프라인을 지나고 배포 쪽이 규칙 리스너를 걸며; 리스너 예외는 fail-closed로 그 레코드를 막고 안 보냅니다. 비식별은 내보내기 사본만 고치고 권위 세션 로그는 한 글자도 안 움직여요.

마지막으로 익명 신원 설계. 난수 UUID v4가 $DSH_HOME/.anonymous-user-id에 떨어지고, 소비자 셋이 공유해요: OTel user.id, /feedback 확인 회신, DeepSeek로 가는 x-deepseek-harness-user-id 헤더(packages/identity/anonymous-user-id/README.zh.md). id 하나를 공유해야 수신 측이 세 길을 연관하고, 각자 신원 셋을 만들 필요가 없습니다.

묘미는 생성 시점. llm-deepseek에서 이 id는 게으른 생성—userId ??= getOrCreateAnonymousUserId()—정말 처음 쓸 때 파일이 생기고(index.ts 248–249행); stream()에서는 자격증명 해석이 신원 해석보다 앞이에요(adapter.ts 221–222행). 이어 보면: key를 한 번도 안 넣은 기계는 자격증명 단계에서 실패하고, 디스크에 추적 신원이 공짜로 안 생깁니다. 도구가 아직 한 일도 안 했는데 번호를 먼저 매기는 일—DSH는 안 해요.

가로 비교 · 다른 집은 자격증명을 어떻게 받드나

Grok Build

자격증명은 AuthCredentialProvider 인터페이스(crates/codegen/xai-grok-auth/src/auth_provider.rs). 인터페이스 문서는 구현에게 스냅샷마다 싼 디스크 재읽기를 요구해 grok-desktop·grok login 같은 형제 프로세스가 쓴 새 자격증명을 현재 프로세스가 보게 합니다—방향이 DSH의 작업마다 재해석과 같아요.

사후 안전망도 한 층 더: refresh_after_unauthorized(), 401을 먹으면 토큰을 갱신해 한 번 재시도—주로 만료되는 OAuth용. 사전 재해석 + 사후 재시도가 캐시만 믿는 쪽보다 훨씬 든든해요.

Claude Code

숙제는 기동 순간에: utils/secureStorage/keychainPrefetch.ts가 프로세스 기동 때 macOS Keychain 읽기를 병렬로 쏴, 약 135ms 모듈 import와 겹치고, 비즈니스 코드가 진짜 필요할 때만 기다려 원래 약 200ms 직렬 읽기를 거의 0으로 줄여요(원고 1장 기동 분석).

최적화 방향은 DSH와 반대: 그쪽은 기동 한 번 읽기가 얼마나 빠른지, DSH는 교체 후 다음 읽기가 얼마나 맞는지. 터미널 제품은 재시작 비용이 낮고 자격증명 교체가 드물어 프리페치+캐시가 이득; 인프라 프로세스는 오래 상주하고 재시작이 모든 세션을 끊으니 매번 다시 가져오기가 이득. 둘 다 맞고, 받드는 장면이 다를 뿐이에요.

수업 실습
01

key를 교체했는데 왜 안 먹히나

배포가 기동 스크립트에 export DEEPSEEK_API_KEY=옛key를 두고, 나중에 web Models 페이지로 .credentials.yaml에 새 값을 썼어요. 이제 옛 key가 새어 긴급 폐기해야 하는데 Models에 새 key를 넣고 저장은 됐지만 다음 요청은 여전히 옛것. 이유 추리: 네 층 중 프로세스 환경이 최우선이라 파일 층이 아무리 새도 뒤로 밀립니다. UI가 어떻게 구해줄 수 있었나: describe가 이 참조를 writable: false로 보고 입력칸을 미리 읽기 전용으로 그리면 헛수고를 안 했을 거예요. 진짜 길은 기동 환경을 바꾸거나, 환경에 이 변수를 두지 않는 것입니다.

Takeaway:설정에는 참조만 두고, 값은 작업마다 한 번 다시 가져오며, 교체에 재시작이 없고, 핫 업데이트는 알림 브로드캐스트가 아니라 읽기 타이밍에 기대요. 설정 쓰기는 외부 수정을 먼저 합친 뒤 프로세스 간 파일 잠금에서 원자 커밋하고, 고아 잠금은 타임아웃 오류를 내도 뺏지 않아요. 저장 schema는 현재 버전이 아니면 열기를 거부하고 제자리 마이그레이션을 안 합니다. 텔레메트리는 emit()에서 멈추고 비식별은 fail-closed, 게으른 익명 id 하나가 소비자 셋을 받들며 쓴 적 없으면 디스크에 안 남겨요.