LLM 에이전트를 위한 타임 트래블 디버거를 Burr로 구축한 방법과 '변경되지 않은 브랜치 재실행 및 아무것도 변경되지 않았음을 증명'하는
요약
본 기사는 LLM 에이전트의 비결정론적 특성으로 인해 발생하는 디버깅 문제를 해결하기 위한 '타임 트래블 디버거' 구축 방법을 설명합니다. 핵심은 특정 단계에서 결과를 포크하고, 오직 하나의 입력만 변경한 후 다운스트림 단계를 재실행하여 두 궤적을 비교하는 Rewind 기능을 구현한 것입니다.
핵심 포인트
- 에이전트 추적 도구의 한계: 비결정론적 모델 변동성(jitter) 때문에 원인 파악이 어려움.
- Rewind 기능 핵심: 특정 단계에서 포크하여 입력만 변경하고 다운스트림을 재실행, 궤적 비교 가능.
- 디버깅 아키텍처: Vite + React로 구축되었으며, 시간 흐름에 초점을 맞춘 그래프 구조를 가짐.
- 재현성 확보 규칙: 상태는 의미론적이어야 하며, 정확한 포크 지점(다음 상태)을 요청하는 것이 중요함.
되감기(Rewind): 에이전트 파이프라인을 위한 반사실적 재실행
제가 사용했던 모든 에이전트 추적 도구들은 같은 한계를 가지고 있습니다. 이 도구들은 단계들의 폭포수처럼 보여주며, 저는 여섯 번째 단계에서 무언가 멍청한 결과물이 나왔다는 것을 발견합니다. 그리고 그곳에서 막힙니다. 왜 그런지—혹은 그것이 모델 때문인지, 검색(retrieval) 때문인지, 아니면 프롬프트 때문인지를 알아내려면 전체 파이프라인을 다시 실행하고 같은 지점에 도달하기를 바랄 수밖에 없습니다. 비결정론적(non-deterministic) 모델의 경우 그렇게 되지 않기 때문에, 저는 제 변경 사항과 모델의 변동성(model jitter)을 절대 분리할 수 없습니다.
그래서 제가 다른 것을 만들었습니다: 어떤 단계에서든 실행된 결과를 포크(fork)하고, 정확히 하나의 입력만 변경한 다음, 다운스트림 단계들만 재실행하여 두 궤적(trajectories)을 비교하는 것입니다. Rewind가 이것을 수행하며—이 부분이 정말 많은 주의를 기울인 부분인데—변경하지 않은 브랜치를 재실행할 때마다 모든 출력 해시 값은 동일하게 돌아오고, 재실행 비용은 토큰이 0에 수렴합니다. 이것이 신뢰할 수 있는 비교(diff)와 단순히 노이즈에 불과한 비교의 차이입니다.
전체 내용은 다음과 같습니다: 아키텍처, 결정론을 증명 가능하게 만드는 네 가지 설계 규칙, 그리고 제가 겪었던 몇 가지 함정들을 여러분이 겪지 않도록 설명합니다.
구조(The shape of it)
browser (Vite + React 18 + TS + Tailwind)
Graph · NodeEditor · DiffPanel · CostBar · RawJSON · Settings
│ /api (Vite proxy)
...
여덟 개의 노드, 네 번의 실제 모델 호출, 그리고 실제 데이터셋 파일에 대한 네 번의 실제 로컬 계산으로 이루어져 있습니다. 그래프는 의도적으로 지루합니다—선형 체인이기 때문입니다—왜냐하면 흥미로운 축은 토폴로지(topology)가 아니라 _시간_이기 때문입니다.
Burr는 이미 포킹 기능을 가지고 있었습니다. 저는 올바른 문만 찾으면 되었습니다.
저는
함정(The trap): 노드 N을 재실행하려면, N의 이전 행(predecessor row)에서 포크해야 합니다. completed된 행의 다음 행이 진입점(entrypoint)이 됩니다. 노드 N 자신의 행을 전달하면, 아무도 모르게 N+1부터 시작하게 되는데 — 이것이 제가 처음 시도했을 때 정확히 발생한 일이며, 프레임워크가 고장 난 것처럼 보였습니다. 하지만 그렇지 않았습니다. 저는 재실행하려는 노드 다음 상태를 요청했던 것입니다.
parent ── plan ─ research ── analyse ─ critique ── … ── publish
rows seq 0 seq 1 seq 2 seq 3 seq 7
└── fork_from_sequence_id = 1, resume_at_next_action=True
...
첫 번째 노드를 포크할 때는 이전 행이 없으므로, 그 경우는 동일한 오버라이드(override)를 가진 새로운 애플리케이션을 시작합니다. 그리고 중첩된 포크는 각 노드 행이 해당 앱이 실제로 생성했을 때의 src_app_id와 sequence_id를 유지하기 때문에 작동합니다 — 따라서 포크 내부에서 포킹하는 것은 복사본의 복사본(copy of a copy)으로 해결되는 것이 아니라 실제 실행된 상태로 해결됩니다.
규칙 1: 상태는 의미론적일 뿐 (state is semantic only)
이것이 바이트 단위로 동일한 재현(byte-identical replay)을 가능하게 하는 규칙이며, 제가 주의를 기울이지 않았다면 가장 먼저 위반했을 규칙입니다.
지연 시간(Latency), 토큰 수(token counts), 비용(costs) 및 실행 ID(run ids)는 절대로 Burr 상태에 기록되지 않습니다. 이들은 액션의 결과(result) 페이로드로 전달되어 PostRunStepHook을 통해 nodes 테이블에 기록됩니다:
class NodeTelemetryHook(PostRunStepHook):
def post_run_step(self, *, app_id, partition_key, sequence_id,
state, action, result, exception, **kw):
...
왜 그런지 생각해 보세요. 노드 3의 프롬프트는 state_json을 임베드합니다. 만약 노드 2의 지연 시간(latency)이 상태에 있었다면, 노드 3의 프롬프트에는 벽시계(wall-clock) 숫자가 포함될 것이고, 그 캐시 키(cache key)는 실행할 때마다 달라질 것이며,
Burr는 __PRIOR_STEP과 __SEQUENCE_ID를 영속화된(persisted) 상태 내부에 유지합니다. 자식 실행(child run)의 시퀀스 ID는 구조적으로 부모와 다르므로, 프롬프트나 해시로 전달되어서는 안 됩니다:
def semantic_state(state: State) -> dict:
return {k: v for k, v in state.data.items() if not k.startswith("__")}
규칙 3: 오버라이드는 상태 외부에 존재한다 (overrides live outside state)
포크(fork)의 오버라이드는 상태에 주입되는 것이 아니라 실행 컨텍스트(run context)로 전달됩니다. 만약 오버라이드가 상태 키였다면, 변경되지 않은 리플레이(no-change replay)는 부모가 그러한 키를 가지지 않았음에도 불구하고 {"node_overrides": {}}를 포함하게 됩니다. 이는 다른 state_json, 다른 프롬프트, 콜드 캐시를 유발하며, 핵심 기능인 '변경 없음'이 조용히 거짓이 되게 만듭니다. 오버라이드는 여전히 있어야 할 상태에 자리 잡습니다. 즉, 노드의 출력을 변경하고, 출력은 곧 상태(state)이기 때문입니다.
규칙 4: 컨테이너가 아닌 내용을 해싱한다 (hash the content, not the container)
아티팩트 해시(artifact hash)는 마크다운 바이트의 해시입니다. 실행 범위별 파일명은 실행 행에 기록되며 의도적으로 해시된 도구 결과에서는 제외됩니다:
hashed_result = {"tool": result["tool"], "bytes": result["bytes"], "written": True}
저는 처음에 이것을 잘못 이해했습니다. publish 노드는 경로를 도구 결과에 저장했기 때문에, 모든 실행이 8번 노드에서 분기되었습니다. 그래서 제 diff는 '첫 번째 분기: publish'라고 성실하게 보고했고, 이는 사실이었지만 의미가 없었고, 이를 알아차리는 데 검증 사이클(verification cycle)을 소모했습니다.
캐시 키 (The cache key)
key = sha256( model | temperature | exact prompt | tool result )
길이 접두사(Length-prefixed)를 사용했기 때문에 연결된 문자열이 충돌할 수 없습니다:
def _part(value: str) -> str:
return f"{len(value.encode()):d}:{value}"
...
도구 노드들은 (도구 이름, 정규화된 인자)를 키로 하는 자체 테이블을 가집니다. 이것은 장식이 아닙니다. 만약 그렇지 않다면, 변경되지 않은 리플레이는 모든 도구 단계를 재계산(recompute)하여 운이 좋아서 같은 답을 얻게 될 뿐, 증명에 의한 것이 아닐 것입니다. 그리고 '모든 노드가 캐시 히트'라는 주장은 산술에 대한 것이 아니라 시스템 자체에 대한 주장이어야 합니다.
각 노드는 또한 자신이 히트였는지 아니었는지를 왜 그랬는지 기록합니다. 왜냐하면 불리언(boolean) 값으로는 여기서 진실을 말할 수 없기 때문입니다.
cache_source | 의미 |
|---|---|
live | 제공자(provider)로 전송되었거나 재계산됨; 토큰 비용 발생 |
| ... |
디프 엔진 (The diff engine): 필드 이름으로 명명하라, 블롭(blob)이 아닌
두 개의 중첩된 딕셔너리(dict)를 비교하고
npm run verify는 OS가 할당한 사용 가능한 포트에서 자체 서버를 부팅하고, 작업별로 다음 사항들을 단언합니다: 7개 이상의 노드에 해시된 출력과 실제 아티팩트 파일이 존재해야 합니다; 노드 3에서 분기(fork)하여 상속받은 1~2을 재실행하고 3개 이상을 다시 실행해야 합니다; diff는 노드 3과 정확한 필드를 명명하며; 변경되지 않은 리플레이가 100% 캐시에 도달하고 증분 토큰이 0이어야 합니다; 그리고 분기된 경우 다른 아티팩트 해시를 가져야 합니다. 14/14, 종료 코드 0.
제가 가장 좋아하는 숫자는 다음과 같습니다: 부모 패스 비용은 10,054 토큰이었고; 변경되지 않은 리플레이 비용은 0이었으며, 그 아티팩트 파일은 분기의 것과 바이트 단위로 동일합니다 (cmp clean). 또한 CSV 수학 계산을 앱 외부에서 독립적으로 다시 수행했고, 이는 아티팩트와 정확히 일치합니다. 따라서
- OpenAI SDK와 Particle의
metadata. 게이트웨이는 툴(tools)/구조화된 출력(structured output)이 관련될 때마다metadata를 _객체(object)_로 반환합니다. SDK는 이를str | None으로 타입 지정하여, 모든 호출이UnexpectedModelBehavior로 실패하게 만듭니다. 순수한httpxPOST는 본문 자체를 파싱하고 알 수 없는 필드는 무시하므로 — 누락에 의해 면역입니다(immune by omission). 사양 자체가 어차피 단순한 가져오기(plain fetch)를 요구했습니다. - **`response_format: {
가장 효과적인 추가 기능은 what-if 그리드입니다. N개의 오버라이드를 병렬로 포크하고 N방향 매트릭스를 렌더링하여, '이 다섯 가지 검색 쿼리 중 어느 것이 결론을 변경하는가?'라는 질문을 다섯 개의 포크 대신 하나의 화면에서 볼 수 있게 합니다. 그 다음으로는 비선형 그래프(두 트리를 정렬하는 방식), 1.2초 폴링 대신 SSE를 통한 스트리밍 노드 업데이트, 그리고 동일한 브랜치를 k번 동안 라이브 모드로 실행하고 출력 해시의 분포를 보고하는 해시 안정성 모드가 있습니다. 이는 '내 에이전트가 결정론적인가?'라는 질문을 이진값(binary) 대신 숫자로 바꿔줍니다.
사용하기
git clone <your-fork> && cd rewind
uv venv --python 3.12 .venv && uv pip install -p .venv/bin/python -r requirements.txt
cp .env.example .env # 기본 URL / 키 / 모델 설정
...
README에는 포크 의미론, 캐시 키, 데이터 모델, API 표면(API surface), 그리고 원본 브리프에 대해 제가 수정한 모든 사항—이 프로젝트의 환경에 제공자 키가 없었기 때문에 기록된 검증이 로컬 모델에서 실행되었다는 사실까지 포함하여—이 문서화되어 있습니다.
피드백과 PR을 환영합니다.
코드 및 더 많은 정보: https://www.dailybuild.xyz/project/274-rewind
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기