DeepSeek Harness · 세션과 루프

followup / steer / inject: 이중 큐 Inbox

Agent가 일하는 중에 한 마디 하고 싶을 때, 메시지는 어느 큐에 들어가고 언제 처리될까요. API 세 개가 send() 하나를 공유하고, 차이는 파라미터 두 개뿐입니다.

강의 목표이 레슨을 읽으면 세 가지를 말할 수 있어요. followup·steer·inject를 가르는 파라미터 두 개; 큐에 들어간 메시지가 언제 집어올려지는지; 중단 뒤에 다시 말하면 왜 다음 턴으로 강제 재배치되는지.

Agent가 한창인데 사용자가 갑자기 말합니다. 공학적으로는 세 가지예요. 끊고 다시 시작, 끝날 때까지 대기열, 몰래 말을 끼워 넣기. 대부분의 harness는 앞 두 가지만 합니다. DeepSeek Harness(이하 DSH)는 세 번째도 정식 API로 만들었어요. 세 의미가 send() 입구 하나를 쓰고, 파라미터 두 개로만 구분합니다.

인터랙티브 데모 · 대기열 시뮬레이터

먼저 해보고 배우기. Turn(한 바퀴의 완전한 일)을 버스 한 편으로, Step(모델 요청 한 번)을 정류장마다 가는 운행으로 생각해 보세요. next-turn 큐는 다음 버스를 기다리는 사람, next-step 큐는 지금 이 편에 끼어드는 사람이에요. Agent가 돌아갈 때 아래 버튼 셋을 눌러 메시지가 어느 큐에 떨어지고 어느 정류장에서 태워지는지 보세요. 하단 자막이 각 단계에서 무슨 일이 일어나는지 설명해 줍니다.

대기

Agent가 대기 중이고 두 큐가 비어 있어요. 위 버튼을 누르거나 「재생」으로 전체 흐름을 보세요.

이벤트 기록(시뮬레이션)
해보세요: Agent가 대기일 때 inject를 누르면 메시지가 큐에 남아요. 그게 “깨우지 않음”의 뜻입니다. 타임라인은 수업용 시뮬레이션이에요. 출처: packages/core/agent-loop/src/agent.ts 113–132행.
아이디어 1 · 끼어들기는 세 종류, 입도가 제품 능력

어떤 문제를 푸는가.메시지 큐가 하나뿐인 harness에서는 사용자 말에 운명이 둘뿐입니다. 끊거나, 줄을 서거나. 일상적인 사고 장면: Agent가 계획대로 파일 열 개를 고치다 세 번째에서 방향이 빗나간 걸 봅니다. 끊으면 앞 두 파일 일이 날아가고, 줄을 서면 열 개를 다 망치는 걸 지켜봐야 해요. 하고 싶은 건 한 마디 보탠 것뿐인데, 두 선택 모두 지금 진척을 내놓으라고 합니다.

아이디어는 무엇인가.먼저 루프를 두 층으로 나눕니다. Turn은 한 바퀴의 완전한 일, Step은 모델 요청 한 번에 그게 유발한 도구 실행까지—보통 한 Turn에 Step이 여러 개예요. 나누는 이유는 실용적입니다. 메시지에는 한 바퀴보다 더 세밀한 전달 지점이 필요하고, 모델이 새 메시지를 볼 다음 기회는 다음 Step의 시작이에요. 그다음 말하는 타이밍을 파라미터 두 개로 인코딩합니다. target은 어느 큐인지(next-turn은 다음 버스, next-step은 지금 이 편에 끼어들기), wakeup은 기사를 깨울지(Agent가 대기일 때 당장 시작할지). API 셋은 모두 send()의 파라미터 프리셋이고, 각각 세 줄뿐입니다.

followup = 대기열 + 깨우기  ·  steer = 끼어들기 + 깨우기  ·  inject = 끼어들기 + 조용히

followup다음 일—이번 턴이 끝난 뒤에

언제 쓰나이 말은 지금 일과 무관하고 다음 일입니다. 진행 중인 일을 방해하고 싶지 않을 때.
어느 큐로next-turn 큐. 현재 Turn이 완전히 끝난 뒤, 다음 버스가 출발할 때 태워집니다. 한 편에 한 줄.
깨울지 말지깨웁니다. 잠든 Agent도 불러 바로 새 버스를 출발시켜요.
일상 예문: “이 수정이 끝나면 테스트도 보태 주세요.”

steer방향을 고치되, 처음부터 다시 하지 않기

언제 쓰나진행 중인 일을 고치고 싶지만 이미 쌓인 진척은 버리고 싶지 않을 때. 방향이 빗나갔거나 제약을 보탤 때도 이거예요.
어느 큐로next-step 큐. 지금 이 스텝이 끝나면 다음 스텝 시작에서 모델이 보고, 한 번에 전부 집어올려요.
깨울지 말지깨웁니다. Agent가 대기여도 바로 부를 수 있어요.
일상 예문: “잠깐, 설정 파일은 YAML로 쓰고 JSON은 쓰지 마세요.”

inject정보를 끼우되, 일을 재촉하지 않기

언제 쓰나플러그인이 모델에 환경 알림을 넣고 싶지만 그 때문에 빈 턴을 돌리고 싶지 않을 때. 사람 쪽 보정은 steer, 환경 나레이션은 inject예요.
어느 큐로next-step 큐—steer와 같은 줄. 다음 자연 정류장에서 지나가는 길에 태워집니다.
깨울지 말지깨우지 않습니다. Agent가 자면 큐에 누워 있고, 다른 일이 깨울 때 같이 처리돼요.
일상 예문: “참고로, 사용자가 방금 브랜치를 main으로 바꿨어요.”

왜 오래가는가.이 세 의미는 중단의 분류학이고, 구현 언어와 무관해요. 어떤 agent 시스템을 다시 써도 같은 두 질문에 답해야 합니다. 새 메시지가 현재 일이 끝날 때까지 기다릴까, 끼어들까? 끼어들 때 당장 행동을 촉발할까? 모델 호출에 턴 경계가 있는 한 이 삼분법은 성립해요. Rust나 Python으로 다시 쓰면 파라미터 이름은 바뀌어도 분류는 안 바뀝니다. 분류를 API로 만들면 사용자 한 마디에 세 번째 운명이 생기고—중단 입도를 제품 능력으로 다루는 일입니다.

출처: packages/core/agent-loop/src/agent.ts 113–132행(send와 별칭 메서드 셋).

아이디어 2 · 메시지는 수령제(claim)로

어떤 문제를 푸는가.큐는 생겼고, 다음은 누가·어떻게 집어올릴지입니다. 여러 코드가 같은 큐를 읽으면 사고 둘이 머지않아 납니다. 루프가 한 번, 플러그인이 또 한 번—같은 메시지가 대화 이력에 두 번; 또는 읽고 처리 중에 프로세스가 죽어 재시작 후 메시지가 사라짐. 사고 장면: 크래시 복구로 로그를 리플레이하다 steer 하나가 두 번 소비되고, 모델이 같은 지시를 두 번 받아 같은 수정을 두 번 합니다.

아이디어는 무엇인가.DSH의 답은 claim(수령)입니다. 각 Step 시작 전에 루프가 claim을 한 번 호출해 next-step 큐의 메시지를 원자적으로 전부 가져가고, 턴 경계에서는 next-turn 메시지 하나를 더 가져갑니다. 주의: 하나—followup을 세 번 연타하면 Turn 셋이 따로 생겨요. 가져가기는 순수 삭제 이벤트로 디스크에 남으니 메시지 상태는 둘뿐입니다. 아직 큐에 있거나, 어떤 Turn에 속하거나—중간 상태 없음. 크래시 후 로그를 리플레이하면 삭제 이벤트를 따라가 이중 소비가 없습니다. 헷갈리기 쉬운 점: pre-step 플러그인이 거부한 배치는 큐에 되돌리지 않아요. claim이 판정보다 먼저이고, 거부 시 새 Step을 열지 않으며 턴은 blocked로 끝납니다.

send(메시지, target, wakeup) next-turn 큐 턴마다 1개만 next-step 큐 스텝마다 전부 claim() 원자적 수령 Step 시작
수업용 구조도. 가져가기는 순수 삭제 이벤트로 남습니다. 메시지는 큐에 있거나 어떤 Turn에 속하거나—중간 상태 없음.

왜 오래가는가.수령제는 메시지 큐의 수십 년 된 합의예요. DB에서는 SELECT FOR UPDATE, SQS에서는 가시성 타임아웃—본질은 읽기와 점유를 하나의 원자 동작으로 합치는 것. 잠재 소비자가 여럿이고 크래시 후 복구가 필요하면 수령제가 표준 답입니다. DSH는 그 합의를 agent 루프에 옮겼을 뿐이고, 소스 버전이 몇 번 바뀌어도 이 집어올리기 방식은 안 바뀝니다.

출처: packages/core/agent/src/inbox.ts 71–78행(claim 본체); packages/core/agent-loop/src/agent.ts 229행(매 스텝 시작 호출), 266–269행(reject 분기—거부 배치는 재큐 안 함).

아이디어 3 · 중단 뒤의 깨우기는 강등해야 함

어떤 문제를 푸는가.Esc로 현재 활동을 중단한 직후 steer를 보냅니다. steer의 의미는 현재 Turn의 다음 Step에 끼어드는 것인데, 그 Turn은 죽어 가는 중이라 다음 Step은 영원히 오지 않아요. 그대로 입큐하면 나쁜 결말이 둘뿐입니다. 메시지가 큐에 영원히 누워 세션이 굳거나, 마무리 중인 턴에 억지로 끼어들어—죽어 가는 턴에 끼는 셈—행동이 예측 불가.

아이디어는 무엇인가.입큐 전에 send()가 현장을 봅니다. 이 메시지가 깨우기를 원하고, 현재 활동이 이미 중단됐나요? 그러면 전달 목표를 next-turn으로 바꾸고 깨우기 요청을 잠가 둔 뒤, 중단된 활동의 뒷정리가 끝나고 상태가 대기로 수렴하면 깨우기를 리플레이해 새 버스를 출발시킵니다. 판단이 입큐 전이므로 큐에는 처음부터 끝까지 아무도 안 태울 메시지가 없습니다. inject는 깨우기를 요구하지 않아 이 강등의 영향을 받지 않고, next-step에 그대로 들어가 새 버스의 첫 정류장에서 지나가는 길에 태워져요.

깨우기를 원하는 메시지 steer 또는 followup 도착 입큐 전 검사 현재 활동이 중단됐나요 아니요 중단됨 원래 목표로 입큐 steer는 그대로 next-step next-turn으로 재지정 깨우기 잠금, 수렴 후 새 Turn
수업용 구조도. 강등 판단은 입큐 전에 일어나, 큐에는 아무도 안 태울 메시지가 없습니다.

왜 오래가는가.동시성 시스템의 보편 명제예요. 이벤트가 도착할 때 목표가 죽어 가는 중. 답도 보편적입니다. 종료 중인 실행체를 쫓지 말고, 다음 안정 경계로 다시 배치하세요. OS가 종료 중 프로세스에 시그널을 주고, Actor 시스템이 정지 중 Actor에 메시지를 보내는 처리도 같은 틀입니다. DSH는 그 틀을 send() 입구에 두었고, 위치는 리팩터에 따라 바뀌어도 판단 자체는 안 바뀝니다.

출처: packages/core/agent-loop/src/agent.ts 113–120행(wakingAfterAbort 판단과 목표 재작성), 164–193행(wakeDriver의 잠금과 리플레이). 메인 루프 행동 둘 더: 299행(next-step이 비지 않으면 Turn을 닫지 않고 새 Step), 324행(Turn 종료 후 큐에 재고가 있으면 다음 턴 계속).

가로 비교 · 세 곳의 실행 중 끼어들기

세 곳 모두 대기열이 있고, 차이는 끼어들기 입도입니다.

DeepSeek Harness이중 큐 + 세 의미

영속 큐 두 개, 세 의미가 send() 입구 하나를 공유합니다. 끼어들되 끊지 않는 inject는 독립 정식 API예요. 전달, 깨우지 않음, 끊지 않음—다음 자연 Step 경계에서 수령.

Claude Code현재 스트림 중단 + 메시지 대기열

사용자 중단은 제너레이터 종료로 갑니다. Ctrl+C가 .return()을 트리거하고 중첩 제너레이터가 함께 마무리됩니다. 실행 중 입력은 대기열 명령 스트림에 들어가 현재 스트림이 끝난 뒤 소비되고, 스텝급 끼어들기는 없어요. 출처: claude-code-sourcemap-main/study/chapters/01-architecture.md. 전체 스케줄러 소스는 비공개라, 결론은 공개 증거에 기반한 추론입니다.

Grok Build단일 큐 + CancellationToken

각 Session은 독립 스레드의 Actor예요(사이트 내 Session Actor 레슨 참고). 취소는 CancellationToken 협조적 마무리. 큐는 하나: 항목이 position으로 정렬돼 기다리고, running_prompt_id가 실행 중 항목을 표시합니다. inject에 해당하는 중도 끼어들기 의미는 없어요. 출처: crates/codegen/xai-prompt-queue/src/types.rs 44–56행.

비교하면, 끼어들되 끊지 않고 한 바퀴 끝날 때까지 기다리지 않아도 되는 inject를 공개 API로 만든 곳은 DSH뿐이고, 메시지는 현재 Turn의 다음 Step에서 모델이 볼 수 있어요.

수업 실습
01

타이밍을 놓친 inject 한 번 추론하기

Agent가 Turn 3의 Step 2를 스트리밍 중인데 플러그인이 inject()를 호출합니다. 추론해 보세요. 이 메시지가 가장 빨리 수령되는 시점은? Step 2가 원래 이번 턴의 마지막 스텝이었다면 버려질까요, 아니면 Turn을 한 스텝 더 살릴까요? Agent가 이미 대기라면 언제 소비될까요? 끝난 뒤 위 데모로 검증하세요.

Takeaway:DSH는 next-turn / next-step 두 영속 큐로 말하는 타이밍을 데이터로 인코딩하고, API 셋은 send()의 파라미터 프리셋일 뿐입니다. claim은 원자적 인계: 메시지는 큐에 있거나 어떤 Turn에 속하거나, 거부는 재큐하지 않아요. 중단 뒤의 깨우기 입력은 무조건 next-turn으로 재배치됩니다—죽은 Turn에는 다음 Step이 없으니까요.