DeepSeek Harness 관측성, Agent 동작 파악 방법
DeepSeek Harness의 관측성 메커니즘인 세션 이벤트 로그, 토큰 계량, 텔레메트리 Seam, OpenTelemetry를 설명한다.
중국어 원문을 AI로 번역했습니다. 고유명사와 수치는 원문 표기를 우선하며, 중요한 판단에는 아래 출처 원문을 함께 확인하세요.
먼저, 골치 아픈 시나리오 하나부터
당신의 Agent가 3분 동안 돌다가 마지막에 에러 하나를 뱉었다.
그리고 당신은 로그를 들여다본다——아무것도 없다. 어떤 도구를 호출했는지, 어느 단계에서 막혔는지, Token이 도대체 어디에 쓰였는지 알 수 없고, 왜 실패했는지는 더더욱 모른다.
이것이 바로 관측성(可观测性)이 결여된 전형적인 결과다.
왜 관측성이 Agent에게 특히 중요한가
전통적인 서비스에 문제가 생기면 브레이크포인트를 걸고 스택을 볼 수 있다. Agent는 다르다.
Agent는 비결정적이다. 같은 입력이라도 모델은 완전히 다른 도구 호출 시퀀스를 만들어낼 수 있다. 당신은 "모델의 의사결정" 단계에 브레이크포인트를 걸 수 없다——그것은 블랙박스다.
디버깅은 로그 역추적에만 의존할 수 있다. 모델의 "생각"은 오직 그것이 출력한 텍스트와 도구 호출에만 드러난다. 이 모든 것을 기록해 두어야만 사후에 그것의 추론 체인을 재구성할 수 있다.
Token 비용은 불투명하다. 다중 턴 Agent 대화에서 도대체 어느 단계가 가장 비쌀까? 세 번째 턴의 도구 결과가 너무 길어서일까? 아니면 system prompt가 큰 비중을 차지해서일까? 계측이 없으면 최적화 방향조차 찾을 수 없다.
프로덕션 환경에서 문제가 생기면 증거가 있어야 한다. 사용자가 "그게 나에게 틀린 답을 줬어"라고 말한다면, 당신은 당시의 완전한 실행 체인을 복원해야 한다——어떤 도구를 썼고, 무엇을 반환했고, 모델이 무엇을 봤는지를.
Session 로그: 가장 완전한 관측 데이터
05편에서 다룬 내용을 떠올려 보자. dsh Session은 append-only(追加 전용) 타입화된 이벤트 로그다.
이 설계는 단지 영속화를 위한 것이 아니라, 그 자체가 가장 완전한 관측 데이터 소스다.
모든 Session 이벤트는 다음을 포함한다:
각 SessionEvent의 구조: - type: 이벤트 타입(예: 'tool/call', 'turn/end', 'assistant/message') - seq: 단조 증가하는 일련번호(0부터 시작) - time: 타임스탬프(밀리초 단위 Unix 타임스탬프) - data: 타입화된 이벤트 데이터(타입마다 data 구조가 다름)
이것이 의미하는 바는: Session 로그를 읽을 수만 있다면 전체 실행 과정을 재구성할 수 있다는 것이다——각 단계가 무엇을 했고, 얼마나 시간을 썼고, 에러가 있었는지까지.
실시간 감시: session/event
로그가 다 쓰일 때까지 기다렸다가 분석할 필요는 없다. dsh는 session/event 이벤트를 제공하여 Session의 각 기록을 실시간으로 감시할 수 있게 한다:
// 특정 Session의 모든 이벤트 감시(실시간) ctx.on('session/event', (session, event) => { // 각 이벤트마다 이 콜백이 실행된다 console.log(`[${event.type}] seq=${event.seq} time=${event.time}`) // 도구 호출인지 확인 if (event.type === 'tool/call') { // event.data.name은 도구 이름 // event.data.arguments는 원본 JSON 문자열(모델이 출력한, 파싱되지 않은 것) // event.data.callId는 이번 호출의 고유 ID console.log(`Tool: ${event.data.name}`) console.log(`Args: ${event.data.arguments}`) } // 도구 실행 결과인지 확인 if (event.type === 'tool/result') { // content block 안에 isError: true가 있는지 검사하여 실패 여부 판단 const isError = event.data.message.content.some(b => b.type === 'tool_result' && b.isError) console.log(`Result: ${isError ? 'ERROR' : 'OK'}`) } })
이 리스너는 개발·디버깅 시 매우 유용하다——Agent가 실시간으로 무엇을 하는지 볼 수 있고, 다 돌 때까지 기다릴 필요가 없다.
Token 계측: ctx.tokenMeter
"무슨 일이 일어났는지" 아는 것은 첫 단계일 뿐이다. "얼마를 썼는지" 아는 것도 똑같이 중요하다.
dsh는 ctx.tokenMeter를 제공하여 현재 Session의 Token 압력을 측정할 수 있다:
// TokenMeasurement 인터페이스(packages/llm/token-meter/src/types.ts에서) interface TokenMeasurement { // 이번 계측이 소비한 이벤트 수(캐시용, 중복 계산 방지) readonly logRevision: SessionLogOffset // 현재 요청의 총 token 압력(입력 + 출력의 합) readonly totalTokens: number // 현재 surface(모델이 볼 수 있는 히스토리 메시지)의 token 수 readonly surfaceTokens: number // 마지막 성공 요청 대비 surface의 token 변화량(부호 있음, 음수 가능) readonly surfaceDeltaTokens: number // 위치별로 나열한 surface 노드 및 그 token 수(각 메시지가 얼마나 차지하는지 볼 수 있음) readonly nodes: readonly TokenSurfaceNode[] }
몇 가지 핵심 개념:
- surface tokens: 모델이 이번 요청에서 실제로 본 히스토리 내용이 몇 token인지. 이것이 당신의 API 비용 중 "입력 token" 부분을 결정한다.
- total tokens: 입력 + 출력의 총합으로, 이번 요청의 완전한 비용을 반영한다.
- surfaceDeltaTokens: 직전 요청과 비교해 surface가 얼마나 늘었는지. 이 숫자가 계속 커지면 컨텍스트가 팽창하고 있다는 뜻이며, 압축 전략이 필요할 수 있다.
사용 예시:
// 각 Turn이 끝날 때 Token 사용 요약 출력 ctx.on('session/event', (session, event) => { // Turn 종료 이벤트만 관심 있음 if (event.type !== 'turn/end') return // measure를 호출해 현재 Session의 Token 측정 결과 획득 const measurement = ctx.tokenMeter.measure(session) console.log(`Turn ${event.data.turn} ended:`) console.log(` Surface tokens: ${measurement.surfaceTokens}`) console.log(` Total tokens: ${measurement.totalTokens}`) // delta 표시, 양수면 컨텍스트가 증가 중 const delta = measurement.surfaceDeltaTokens const sign = delta > 0 ? '+' : '' console.log(` Delta: ${sign}${delta}`) // 컨텍스트가 너무 빨리 증가하면 경고 발송 if (delta > 2000) { console.warn('⚠ Context growing fast, consider compression') } })
텔레메트리 Seam: ctx.sessionTelemetry
session/event 리스닝은 개발·디버깅에 적합하지만, 프로덕션 환경에서는 데이터를 외부 시스템으로 보내야 한다——예컨대 Grafana, Datadog, CloudWatch.
dsh는 이를 위해 "텔레메트리 Seam"을 설계했다: ctx.sessionTelemetry.
Seam(이음매)이라는 단어가 아주 정확하게 쓰였다——그것은 표준화된 인터페이스로, 텔레메트리 데이터를 임의의 백엔드에 연결할 수 있게 하면서 동시에 harness 자체는 어떤 구체적인 모니터링 시스템에도 의존하지 않게 한다.
각 텔레메트리 레코드의 구조:
// SessionTelemetryRecord(packages/session/session-telemetry/src에서) interface SessionTelemetryRecord { // 두 가지 channel: // 'ledger': Session 로그 이벤트의 완전한 미러, 이벤트와 1:1 대응 // 'ops': 운영 신호, 특수한 경우에만 생성 channel: 'ledger' | 'ops' // 타임스탬프(밀리초) time: number // 심각도 severity: 'info' | 'warn' | 'error' // 식별 속성(쿼리 및 필터링용) // 예: session.id, event.type, event.seq 등 attributes: Record<string, string | number> // 완전한 payload: event.data의 깊은 복사 body: unknown }
두 channel은 각각 무엇을 하는가
ledger channel: Session 로그의 완전한 미러. 모든 Session 이벤트마다 대응하는 ledger 레코드 하나가 생성된다. 이것이 감사(audit)와 재생(replay)의 데이터 소스다.
포함하는 것:
- 모든 assistant/message(완전한 스트림 데이터 포함)
- 모든 tool/call과 tool/result
- 실패한 assistant/attempt(모델이 시도했지만 최종적으로 쓰이지 않은 것)
- 모든 turn/start, turn/end 등 라이프사이클 이벤트
ops channel: 운영 신호, 두 가지뿐이다:
- agent-error: Agent가 Turn 밖에서 실패한 경우(예: 초기화 에러)
- shutdown: Agent 정상 종료
심각도는 어떻게 판정되는가
- error: 도구 결과 isError: true, turn/end에 에러 원인 포함, agent-error 운영 이벤트
- 그 외의 경우: info
이 매핑 덕분에 모니터링 시스템에서 severity === 'error'를 직접 필터링하여 모든 이상을 볼 수 있고, 판단 로직을 직접 짤 필요가 없다.
OpenTelemetry 연동
dsh는 공식 OTel Provider 플러그인을 제공한다: dsh-session-telemetry-otel.
연동 방식(개념적):
// 당신의 Bundle 설정에 이 플러그인을 추가한다(의사 코드) // 이것은 ctx.sessionTelemetry를 OTel 백엔드에 연결한다 '@deepseek-ai/dsh-session-telemetry-otel' // 이 플러그인 내부에서는: // 1. ctx.sessionTelemetry의 OTel 백엔드 구현을 등록 // 2. 모든 SessionTelemetryRecord를 OTel JS SDK의 Logger API를 통해 전송 // 3. 다양한 Exporter(OTLP, Console, File 등) 설정 지원
몇 가지 설계 원칙은 알아둘 만하다:
경계 공리: harness는 emit() 호출만 담당하고, 배치 처리·재시도·큐잉은 OTel SDK의 책임이며 harness는 관여하지 않는다. 이렇게 하면 양쪽이 독립적으로 진화할 수 있다.
최선 노력(best effort): 텔레메트리 레코드는 중복될 수도 있고 유실될 수도 있다. 수신 측은 각 레코드가 정확히 한 번 도착한다고 가정하지 말고, (session.id, format_version, event.seq) 조합을 기반으로 ledger 레코드를 중복 제거해야 한다.
flush는 선택 사항: 매 Turn이 끝난 뒤 flush()를 호출할 수 있지만, OTel 백엔드는 기본적으로 구현하지 않는다(동시성 충돌 방지). 강한 일관성이 필요하다면 직접 설정해야 한다.
실전: 간단한 디버깅 플러그인 작성하기
위 내용을 하나의 완전한 디버깅·관측 플러그인으로 통합해 보자:
// debug-observer.ts — 디버깅용 가시성(Observability) 플러그인 // 사용법: 개발 시에는 Bundle에 추가하고, 프로덕션에서는 실제 텔레메트리 백엔드로 교체
export const name = 'debug-observer'
// 의존성 주입 tokenMeter 선언 export const inject = [ 'tokenMeter' ]
export function apply ( ctx: Context ): void { // ── 1. 도구 호출 감시 ──────────────────────────────────────── ctx. on ( 'session/event' , ( session, event ) => { if (event. type !== 'tool/call' ) return console . log ( `[Tool Call] ${event.data.name} ` ) console . log ( ` Call ID: ${event.data.callId} ` ) // arguments는 원시 JSON 문자열 (모델이 직접 출력한 것으로, 아직 파싱되지 않음) console . log ( ` Args: ${event.data. arguments } ` ) })
// ── 2. 도구 실행 결과 감시 ──────────────────────────────────── ctx. on ( 'session/event' , ( session, event ) => { if (event. type !== 'tool/result' ) return const blocks = event. data . message . content const isError = blocks. some ( b => b. type === 'tool_result' && b. isError ) const icon = isError ? '✗' : '✓' // 첫 번째 block에서 대응하는 toolUseId를 가져옴 (tool/call 이벤트와 연결) const toolUseId = blocks[ 0 ]?. toolUseId ?? 'unknown' console . log ( `[Tool Result] ${icon} (call: ${toolUseId} )` ) })
// ── 3. 각 Turn이 끝날 때마다 Token 요약 출력 ──────────────────── ctx. on ( 'session/event' , ( session, event ) => { if (event. type !== 'turn/end' ) return const reason = event. data . reason . kind // 'complete' | 'error' | 'interrupted' 등 const measurement = ctx. tokenMeter . measure (session) console . log ( `\n[Turn ${event.data.turn} ] ended: ${reason} ` ) console . log ( ` Surface: ${measurement.surfaceTokens} tokens` ) console . log ( ` Total: ${measurement.totalTokens} tokens` ) const delta = measurement. surfaceDeltaTokens const sign = delta > 0 ? '+' : '' console . log ( ` Delta: ${sign} ${delta} ` ) // 오류로 종료된 경우, 구체적인 오류 정보를 출력 if (reason === 'error' ) { console . error ( ` Error: ${ JSON .stringify(event.data.reason)} ` ) } })
// ── 4. Session 라이프사이클 감시 ─────────────────────────────── ctx. on ( 'session/created' , ( session ) => { console . log ( `\n[Session] created: ${session.id} ` ) })
ctx. on ( 'session/disposed' , ( session ) => { console . log ( `[Session] disposed: ${session.id} ` ) }) }
이 플러그인은 개발 시에 빠르게 Bundle에 추가하여 완전한 실행 궤적을 볼 수 있다. 프로덕션 환경에서는 dsh-session-telemetry-otel 플러그인으로 교체하여 데이터가 모니터링 시스템으로 흐르게 한다.
디버깅 팁: JSONL 로그 파일 읽기
dsh는 기본적으로 Session 로그를 JSONL 파일로 영속화한다 (한 줄에 하나의 JSON 객체, 즉 하나의 SessionEvent).
다음은 자주 쓰이는 명령줄 분석 기법이다:
모든 도구 호출 확인 (도구 이름 목록 추출) cat session.jsonl | grep '"type":"tool/call"' | jq '.data.name'
실패한 어시스턴트 시도 확인 (모델이 생성했지만 최종적으로 사용되지 않은 내용) cat session.jsonl | grep '"type":"assistant/attempt"' | jq '.'
각 턴의 token 사용량 통계 (assistant/message의 usage 필드에서) cat session.jsonl | grep '"type":"assistant/message"' | jq '.data.usage'
모든 Turn의 종료 원인 확인 (정상 완료인지 오류인지) cat session.jsonl | grep '"type":"turn/end"' | jq '.data.reason.kind'
도구 실행 실패가 있는지 검사 cat session.jsonl | grep '"type":"tool/result"' | jq 'select(.data.message.content[].isError == true)'
이 명령들은 jq 도구가 있다고 가정한다. Windows 환경이라면 PowerShell의 ConvertFrom-Json으로 비슷한 분석을 할 수 있다.
가시성 계층 총정리
네 개의 계층으로, 개발부터 프로덕션까지를 아우른다:
실시간 관측 (개발 디버깅) └─ session/event 리스너 → 각 이벤트를 즉시 콘솔에 출력 감사와 재생 (사후 분석) └─ JSONL 로그 파일 → 실행 체인을 완전히 재구성, jq와 함께 분석 Token 사용량 분석 └─ ctx.tokenMeter.measure(session) → 각 surface 노드의 token 계량 → 컨텍스트 팽창의 주범 찾기 프로덕션 모니터링 (시스템 레벨) └─ ctx.sessionTelemetry + OTel 플러그인 → Grafana / Datadog / CloudWatch 연동 → 알림, 대시보드, 오류 추적 전부 연결
소결
가시성은 "있으면 더 좋은" 부가 항목이 아니라, Agent에게 있어 그것은 디버깅의 유일한 수단 이다.
dsh는 설계 단계에서부터 이 점을 고려했다: Session 자체가 이벤트 로그이고, 이벤트 로그는 자연히 감사 데이터가 된다; ctx.tokenMeter는 Token 소모를 더 이상 블랙박스가 아니게 만든다; ctx.sessionTelemetry는 표준화된 이음새(Seam)를 제공하여 백엔드를 자유롭게 선택할 수 있게 한다.
핵심 패턴은 매우 간단하다: Session 이벤트 리스너 → JSONL 영속화 → 텔레메트리 Seam → OTel 백엔드 . 어느 계층을 쓸지는 여러분의 시나리오에 달려 있지만, 이 계층들은 동시에 동작할 수 있고 서로 간섭하지 않는다.
다음 편은 시리즈의 마지막 편으로, 앞서 배운 모든 메커니즘을 통합하여 프로덕션급 플러그인을 완전하게 하나 작성한다 — 도구 등록, Session 관리, 오류 처리부터 가시성까지, 함께 구현해 본다.
PrimeSkills에서는 실제 기업 시나리오에서 검증된 AI Agent 스킬과 워크플로를 찾을 수 있다. 데모 수준이 아니라, 실제 프로젝트에 쓰이는 것이다.
더 많은 내용은 나의 개인 홈페이지 에서
冬奇Lab
소프트웨어 아키텍처
511
글
322k
읽음
745
팔로워