Model-visible ⟺ logged: 눈앞에서 터지는 불변조건
모델이 본 모든 것은 로그에서 재구성할 수 있어야 하고, 요청을 보내기 전에 그 자리에서 다시 검증하며, 검증에 실패하면 바로 크래시합니다.
먼저 해보고 설명해요. 왼쪽은 뒤에만 이어 붙는 이벤트 로그, 오른쪽은 로그에서 다시 만든 메시지 배열과 모델로 곧 보낼 요청이에요. 「재생」을 눌러 이벤트가 흘러드는 걸 보세요. 끝나면 한 번 악당이 되어 보세요: 로그 한 줄을 지우거나, 로그를 건너뛰고 요청을 직접 바꾼 뒤 「다음 요청 보내기」를 누르면, 비교가 한 줄씩 체크하고 갈라진 곳에 빨간 엑스, 그리고 눈앞에서 터지는 걸 볼 수 있어요.
실험대의 비교 순서는 소스와 같고, 빨간 띠의 오류는 소스 원문을 유지합니다: 비교 로직은 packages/core/agent-loop/src/invariant.ts 31–42행, 오류 접두사 조립은 packages/runtime-diagnostics/invariants/src/index.ts 62행. 확인일 2026-08-13.
어떤 문제를 푸는가.대부분의 채팅 프로그램은 대화를 두 벌로 둡니다. 메모리 배열 하나, 디스크 아카이브 하나, 각자 따로 씁니다. 어느 날 프로세스가 죽으면 아카이브에서 세션을 복구하는데, 복구된 기록에는 모델이 실제로 본 도구 결과가 하나 빠져 있어요. 모델의 다음 답이 전부 엇나가고, 두 상태가 서로를 증명하지 못하니 원인조차 찾을 수 없습니다. 진실이 두 곳에 있으면 언젠가 어긋납니다.
아이디어는 무엇인가.DSH는 진실을 한 벌로 압축합니다. 규칙은 저장소 루트 AGENTS.md 107행에 있어요:
AGENTS.md 107행, 확인일 2026-08-13
풀어 말하면, 세션 로그는 추가만 되는 이벤트 스트림이고, 모델에게 보이게 하려는 내용은 먼저 이벤트 한 줄로 로그에 써야 합니다. 메시지 기록은 로그에서 파생되며, 공식 문서는 “따로 저장하지 않는다”고 합니다. 그래서 DSH에서 로그가 곧 대화 자체이고, 시스템에 두 번째 대화 상태는 없어요.
출처: docs/subsystems/session.zh.md 5행, 확인일 2026-08-13.
그리고 제목의 양방향 화살표 ⟺. 로그로 요청을 이끌어낼 수 있어야 하고, 요청도 로그로 설명되어야 합니다. 로그만 써서 되는 게 아니에요—요청을 보내는 길목에 지키는 사람이 필요합니다. 요청이 나갈 때마다 DSH는 로그에서 그 자리에 있어야 할 메시지 배열을 다시 파생해, 요청에 실제로 실린 것과 전체 문자열로 비교합니다. System Prompt, 모델 이름, 샘플링 파라미터, 도구 목록도 로그의 요청 헤더 스냅샷과 필드마다 맞아야 합니다. 전부 맞아야 통과해요.
지키는 핵심은 네 줄뿐이라 벽에 붙여 둘 만큼 짧아요. 증명하는 건 하나: 매번 디스패치 전에 DSH가 정말 로그에서 메시지를 다시 파생해 실제 요청과 전체 문자열로 비교하고, 안 맞으면 그 자리에서 실패한다는 것:
const expected = session.deriveMessages()
if (JSON.stringify(options.messages) !== JSON.stringify(expected)) {
fail(`llm request for session "${String(session.id)}" diverges from the dispatch-time durable derivation (log-reconstruction desync)`)
}
deepseek-harness-master 기준, 확인 파일 packages/core/agent-loop/src/invariant.ts, 확인일 2026-08-13. 코드 블록은 소스 원문을 유지합니다.세부 하나가 더 있어요. 검사용 파생 함수는 복구·리플레이와 같은 공개 함수 세트입니다. 검사하는 쪽과 검사받는 쪽이 같은 재구성 규칙을 공유하고, 누구도 비공개 경로가 없어요. 요청 헤더 재구성도 이벤트만 훑어 마지막 스냅샷을 취하는 일곱 줄짜리 순수 함수일 뿐입니다.
출처: 전체 검사 순서는 packages/core/agent-loop/src/invariant.ts 22–52행; 요청 헤더 재구성은 packages/core/session/src/request-header.ts 65–71행. 확인일 2026-08-13.
왜 오래가는가.single source of truth(단일 진실 원천)는 데이터베이스 분야에서 오십 년 쓴 오래된 원칙입니다. 장부는 한 권뿐이고 나머지는 전부 장부의 뷰이며, 뷰는 버려도 다시 만들어도 됩니다. DSH는 그 규율을 agent 대화 관리로 옮겼을 뿐이에요. 소스를 Rust로 다시 쓰고 이벤트 타입이 세 배가 되어도, 장부는 한 권이라는 사실은 그대로입니다.
어떤 문제를 푸는가.지키는 쪽이 어긋남을 발견하고 경고 한 줄만 남긴다고 상상해 보세요. 경고는 이미 잘못된 요청이 모델에 갔다는 뜻입니다. 어떤 플러그인이 로그를 건너뛰고 메시지를 몰래 바꿨고, 그 순간부터 모델이 본 것과 로그가 적은 것은 다른 이야기예요. 로그는 계속 쓰이지만 전부 틀린 장부입니다. 사흘 뒤 누군가 그 로그로 리플레이해 보면 온라인의 이상한 행동을 재현할 수 없어요. 조용한 어긋남이 크래시보다 무섭습니다—조사 비용을 미래로 몰래 넘기니까요.
아이디어는 무엇인가.그래서 DSH는 가장 딱딱한 길을 골랐어요. 비교에 실패하면 예외를 던지고, 이번 요청은 그 자리에서 무효—아예 나가지 않습니다. 설계 노트는 부드러운 안을 분명히 기각했고, “연속 요청을 비교하고 발산하면 경고”라는 대안에 대한 판결은 “위반은 인터페이스 수준에서 표현 불가능해야 하므로 기각”이었어요.
출처: .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md 「검토했던 대안」절, 확인일 2026-08-13.
곁에 작은 장치 두 개도 있어요. 검사기는 이벤트 리스너 큐의 맨 앞에 끼워져, 다른 리스너가 먼저 단락해 검사를 조용히 건너뛰지 못하게 합니다—지키는 사람이 요청을 먼저 봐야 해요. 그리고 요청 객체와 메시지 배열은 깊게 동결되어, 검사 통과 뒤 내용을 바꾸는 뒷문을 막습니다.
출처: 큐 선두 등록은 invariant.ts 20–21행과 54행, 동결과 세션 검사는 22–29행. 확인일 2026-08-13.
왜 오래가는가.이것이 fail-fast(빠른 실패)예요. 크래시는 손실을 0에 잠급니다. 로그에 오염된 턴이 없으니 버그를 고치고 다시 돌리면 됩니다. 병을 안고 실행하는 비용은 복리라서, 오래 갈수록 나쁜 데이터가 늘고 나중엔 언제부터 망가졌는지조차 찾을 수 없어요. 경계에서 한 번 터지는 편이, 사흘 뒤 이상한 현상 속에서 고고학하는 것보다 훨씬 쌉니다. 이 판단은 언어·프레임워크와 무관해요.
앞의 두 아이디어가 각각 힘을 썼고, 보상이 여기서 함께 정산됩니다. 로그가 유일한 진실이니 그 둘레로 능력 세트를 공짜로 얻어요:
- 복구: 프로세스가 죽으면 로그에서 다시 파생해 대화를 이어가요.
- 분기: 임의의 이벤트에서 갈라져 평행 세션을 열어요.
- 리플레이: 로그를 다시 파생하면 당시 요청이 되고, 리플레이 테스트는 API Key조차 필요 없어요.
- 감사: UI에 보이는 궤적이 곧 모델이 본 내용이고, 런타임 어설션이 뒤에서 보증합니다.
Compaction(압축, 너무 긴 기록을 요약으로 바꿈)조차 이 불변조건 아래예요. 압축으로 생긴 요약도 이벤트로 로그에 쓰이고, 압축 뒤 요청도 출구 비교를 통과해야 하며, Compaction 구현에 버그가 있으면 그 자리에서 크래시—도망갈 수 없어요.
왜 오래가는가.이 방식엔 학명이 있어요—event sourcing(이벤트 소싱). 은행·회계 시스템이 오랫동안 써 온 것처럼, 잔액은 저장하지 않고 거래만 남기며 잔액은 항상 거래에서 계산합니다. DSH는 거래 흐름을 대화 이벤트로 바꿨을 뿐이에요. 이벤트 스트림이 유일한 진실인 한, 이 능력들은 계속 공짜 부산물입니다.
세션 영속화는 코딩 agent의 거의 기본이고, 차이는 방향에 있어요. Grok Build(xAI 오픈소스 Rust 코딩 agent)의 영속화는 일방통행입니다. 메모리의 대화 상태가 주이고 디스크는 종. 메시지마다 메모리 항목을 복사해 디스크 채널에 넣고, 전송 결과는 버리고, 디스크 실패도 대화를 끊지 않으며, 압축 때는 디스크 기록 전체를 한 번에 바꿀 수도 있어요. 합리적인 제품 취사선택이지만, 영속 계층은 정확성 책임을 지지 않습니다—로그를 역으로 파생해 출구 요청과 비교하는 경로가 없어요.
출처: grok-build-main 저장소 crates/codegen/xai-grok-shell/src/session/chat_persistence.rs 30–38행(persist_message와 replace_history), 확인일 2026-08-13.
Claude Code는 클로즈드 소스이고, 공개된 것은 ~/.claude 아래 .jsonl 세션 로그와 복구 기능입니다. 공개 증거상 사후 기록형 영속화에 가깝고, 디스패치 시점에 로그에서 요청을 재구성해 비교하는 런타임 어설션이 있다는 공개 자료는 없으며, 내부에 동등한 메커니즘이 있는지는 미지수예요.
그래서 여기서는 메커니즘 방향만 비교합니다. Grok Build와 Claude Code는 영속화를 복구 수단으로 쓰고, DSH는 로그를 런타임에서 증명해야 하는 제1원리로 끌어올렸어요.
트리거 경로 한 번 추론하기
어떤 플러그인이 요청 출구 이벤트를 듣고, 보내기 전에 메시지 배열에 시스템 프롬프트를 넣고 싶어합니다. 이번 수업의 검사 순서로 두 경우를 추론해 보세요. 동결된 배열을 직접 바꾸면 어떻게 될까요? 요청 전체를 복제해 복제본을 고친 뒤 전달하면, 어느 단계에서 막힐까요? 힌트: 동결 검사와 바이트 단위 비교가 각각 무엇을 지키나요.