LLM 어댑터 계층: 단일 시도, 명시적 재시도, 이중 스트림 영속화
추론 스트림과 본문 스트림을 나눠 저장하고, 재시도는 명시적 이벤트입니다. 핵심 소스:packages/llm/llm/src/assembler.ts와 packages/core/agent-loop/src/agent.ts.
먼저 해보고 말할게요. 왼쪽은 와이어의 SSE 청크(provider가 프레임마다 내보내는 원본), 오른쪽은 세션 로그(청크마다 바로 assistant/chunk, 스트림이 끝나면 파생 assistant/message), 위는 사용자가 보는 UI예요. 시나리오 셋: A는 깨끗한 이중 스트림 저장; B는 중간에 끊겨 재시도가 타임라인에 명시 이벤트로 뜨는 모습; C는 반면교사—SDK 조용한 재시도, 같은 고장인데 로그에 흔적이 없음.
먼저 규칙을 세워요. DSH 어댑터 계약은 단호해요: 어댑터 호출 한 번이 제공자 시도 한 번이고, 라이브러리 내장 재시도는 반드시 꺼야 합니다(docs/subsystems/llm-streaming.zh.md 어댑터 계약 절). HTTP 라이브러리는 조용히 재시도해 주려 하죠—친절해 보이지만 정보를 숨깁니다. 왜 느렸는지, 몇 번 재시도했는지, 매번 왜 실패했는지 전부 내부 루프에 사라져요. DSH는 그 층을 벗겨요. 어댑터는 한 일만 합니다: 요청 한 번 보내고, 응답을 통일 StreamChunk로 내보내며, 실패면 직렬화 가능한 LlmFailure(안정적 error code)로 정규화. 그 외는 관여하지 않아요.
행 방지도 이 층에서 해결해요. 출시된 원격 어댑터 둘 다 streamIdleTimeoutMs 워치독(기본 5분)을 달고, provider가 멈추면 TIMEOUT으로 매핑합니다. 놓치기 쉬운 한 줄: 빈 응답은 오류예요. 내용 블록 없는 stop은 조용한 성공이 아니라 EMPTY_RESPONSE로 매핑해야 재시도 층이 살릴 기회가 생깁니다.
이제 저장을 봐요. agent loop가 청크 스트림을 소비할 때 두 일을 합니다: 청크마다 그대로 assistant/chunk를 세션 로그에 붙이고, 동시에 BlockAssembler에 넣어요. 스트림이 깨끗이 끝나면 조립 결과를 assistant/message로 한 번 더 씁니다. 이게 이중 스트림이에요. 청크 스트림은 원본 녹화—리플레이 테스트가 프레임마다 그때 응답을 재구성하고; 메시지 스트림은 파생 이력—다음 요청의 대화 컨텍스트가 여기서 나옵니다. reasoning 블록과 text 블록은 청크 프로토콜에서 타입이 달라 각자 조립·저장돼요.
const assembler = new BlockAssembler()
const chunkSeqs: number[] = []
const stream = preparedCall?.stream(request) ?? this.loopCtx.llm.stream(request)
signal.throwIfAborted()
for await (const chunk of stream) {
signal.throwIfAborted()
chunkSeqs.push(this.session.append('assistant/chunk', { turn, step, chunk }).seq)
assembler.push(chunk)
}
packages/core/agent-loop/src/agent.ts, 확인일 2026-08-13. 코드 블록은 소스 원문을 유지합니다.이 아홉 줄이 이중 스트림의 심장이에요. 먼저 저장, 그다음 조립, 청크 예외 없음. 스트림이 끝나면 갈라져요: finish가 오류/중단이면 실패를 agent/request-error에 넘겨 재시도 여부를 정하고(354–371행); 성공일 때만 381행에서 assistant/message를 붙이며 이 배치의 청크 seq를 sourceEventSeqs에 적어 어디서 파생됐는지 표시합니다. 실패한 반쪽 출력의 귀착지가 분명해요: 청크 스트림에 증거로 남고, 파생 이력에는 절대 안 들어갑니다. 다음 요청이 컨텍스트를 재구성할 때 그 반쪽은 없었던 일이에요.
조립기 자체도 잠깐 멈춰 볼 만해요. BlockAssembler는 저장소 유일의 청크 접기 구현이라, 어댑터는 index대로 올바른 청크만 내면 되고 블록 재조립은 각자 안 짜도 됩니다. 기형 스트림에는 단호해요: block-end를 처리할 때 이미 닫힌 블록이면 바로 return하고 뒤따르는 중복 닫기는 무시합니다. 주석에선 “First close wins”라 불러요—첫 닫힘만 유효해야 스트리밍 출력과 최종 조립 블록이 일치하니까요. 이 방어 한 줄이 바로 property test가 실제 버그를 잡은 자리예요(복기: 비결정적 시스템 테스트하기).
출처:packages/llm/llm/src/assembler.ts block-end 분기 75–82행, 확인일 2026-08-13.
재시도는 한 층 위에 살아요. dsh-llm-retry 플러그인이 agent/request-error를 듣고, provider 라우트 등록 때 잡은 정책으로 살릴지 정합니다. 기본 normal 정책: EMPTY_RESPONSE, RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT 다섯 code만, 최대 두 번, 백오프 500ms–10s에 10% 지터; provider의 유효 Retry-After는 로컬 백오프를 대체해요(packages/llm/llm-retry/README.zh.md).
핵심은 기장 방식이에요. 백오프 대기 전에 플러그인이 세션 로그에 llm/retry를 붙입니다—재시도 id, 제공자, 정책 mode, 전체 실패 정보, 계획 지연; 백오프가 끝나 실제로 쏘면 llm/retry-started를 또 붙어요. 둘 다 모델 가시 표면에는 안 들어가서 모델은 재시도를 모릅니다. 하지만 UI와 사후 분석은 여기에 기대요: UI가 실패한 반쪽 출력을 거두고 “1/2회 재시도, 3초 후”를 보여 주고; 디버깅할 때 시도마다·대기마다 타임스탬프가 있습니다. 재시도 자체는 새 번호 Turn을 열어 영속 이력에서 같은 요청을 재구성해 보내고, 옛 Turn 기록은 한 글자도 안 고칩니다.
어댑터 간 디테일 하나: replayState. 성공 finish 청크는 어댑터 전용 리플레이 상태(예: DeepSeek 추론의 네이티브 표현)를 실어 assistant/message와 함께 저장할 수 있어요. 다음 요청이 이력을 어댑터에 주기 전 LlmRuntime.forAdapter()가 메시지마다 검사합니다: 이력 provider와 대상 provider가 같은 어댑터 인스턴스일 때만 상태를 넘기고; 어댑터를 바꾸면 상태를 벗겨 제공자 무관 내용만 줍니다(packages/llm/llm/src/index.ts 822–836행). 한집 비공개 데이터는 다른 집에 안 먹입니다.
재시도는 새 Turn을 연다실패한 Turn은 정상 종료하고, 재시도 Turn은 새 번호로 처음부터 시작해요. 어떤 기록도 고쳐 쓰지 않아 두 시도가 로그에 각자 완전하고, 타임라인은 앞으로만 갑니다.
반쪽 출력은 chunk에만, message에는 안 들어감실패 전 받은 청크는 assistant/chunk 스트림에 남아 리플레이가 사고 현장을 정확히 재현할 수 있어요. 하지만 파생 이력에는 그 반쪽이 없어 모델이 다음에 보는 컨텍스트는 깨끗합니다.
빈 응답은 재시도 가능한 오류내용 블록 없는 stop은 EMPTY_RESPONSE로 매핑되고 기본 정책이 재시도해요. 빈 성공을 조용히 받으면 퇴화 응답을 대화 이력에 쓰는 셈입니다.
Grok Build: 재시도가 샘플러에 내장
Grok Build는 스트리밍과 재시도를 xai-grok-sampler에 함께 넣어요. retry.rs는 순수 분류·백오프 모듈이고 actor 층이 재시도 루프를 감쌉니다. 예산이 넉넉해요: 기본 최대 15회(DEFAULT_MAX_RETRIES = 15, 30초 백오프 상한이면 약 6분); 429는 2회면 보고(RATE_LIMIT_RETRY_THRESHOLD = 2); 413 이미지 초과는 특수 경로로 이미지를 벗기고 한 번 더 시도하며 예산을 안 쓰고; 서버는 x-should-retry로 거부할 수 있어요(crates/codegen/xai-grok-sampler/src/retry.rs 1–34행 동작 요약 주석). 분류는 세밀하지만 재시도 루프가 샘플러 안에 있어 상위에는 유난히 느린 호출 한 번일 뿐입니다.
Claude Code: API 클라이언트 층의 재시도 래퍼
복원 소스에서 재시도는 restored-src/src/services/api/withRetry.ts에 있어요. Anthropic SDK 밖의 애플리케이션 래퍼로, 기본 최대 10회(52행 DEFAULT_MAX_RETRIES = 10), Retry-After 인지와 fast mode 쿨다운이 있습니다. 재시도는 API 클라이언트 내부 for 루프에서 일어나 디버그 로그는 남기지만, 공개 증거 기준으로 재시도마다 영속 세션 이벤트로 남기는 메커니즘은 보이지 않아요.
셋을 맞춰 보면 차이는 한 문장이에요. Grok과 Claude Code의 재시도는 함수 안 루프이고, DSH의 재시도는 로그의 1등 시민입니다. 앞둘은 쉽고, 후자는 감사 가능해요—정책·지연·실패 원인·몇 번째 시도가 모두 영속화되어 UI와 리플레이 테스트가 사실로 쓸 수 있습니다. 대가는 DSH 재시도 경계가 agent Turn 층뿐이라는 점—loop를 우회해 ctx.llm.stream()을 직접 부르는 쪽은 맨몸 시도 한 번입니다.
블록 중간에서 끊긴 스트림을 추리해 보세요
모델이 두 번째 text 블록을 출력 중이고 text-delta 3개 뒤 연결이 리셋되어 어댑터가 finish {kind:'error', TRANSPORT}로 끝나요. 답해 보세요: 로그에 이벤트 종류가 몇 개, 각 몇 줄? assistant/message는 나오나? UI의 반쪽 문장은 어떻게 거둬지고 근거 이벤트는? 재시도 성공 후 모델이 컨텍스트를 재구성할 때 그 3개 delta를 보나? 한 층 더: 이 이력을 다른 provider 어댑터에 주면 직전 성공 메시지의 replayState는 어디로 가나?
llm/retry 이벤트로 영속 로그에 떨어져 대기마다 감사할 수 있어요. 응답 한 번을 두 벌 저장: 청크 스트림은 리플레이 충실, 메시지 스트림은 이력 청결, 실패한 반쪽은 전자에만. 재시도를 SDK에 숨기면 코드는 아끼고 증거는 잃습니다.