Agent Runtime에서는 Session, Run, Turn, Step이 자주 함께 등장한다. 정의를 그대로 외우면 경계가 쉽게 흐려진다. 프레임워크마다 명칭이 완전히 같지 않고, 어떤 프레임워크는 특정 계층을 명시적인 객체로 만들지 않기 때문이다.
더 안정적으로 이해하려면 시스템에 동시에 존재하는 몇 가지 시간 규모를 살펴보면 된다.
Provider 요청이 끝났다고 해서 현재 Agent의 작업까지 끝난 것은 아니다. Tool Loop가 멈췄다고 해서 이 세션이 끝난 것도 아니며, 브라우저 연결이 끊겼다는 사실만으로 백엔드 실행이 종료되었다고 단정할 수도 없다.
이 시간 척도를 모두 messages[] + isLoading에 압축하면 시스템은 당장은 작동할 수 있지만, 취소, 복구, 승인, 동시 입력, 영속화는 점차 암묵적인 관례에 의존하게 된다.
이러한 제약에서 다층 라이프사이클이 점진적으로 생겨난다.
Model Call 하나로 시작하기
가장 단순한 모델 호출에는 하나의 라이프사이클만 있다:
Input
↓
Model Request
↓
Output
요청이 시작될 때 입력이 확정되고, 요청이 끝날 때 결과가 확정된다.
시스템이 이런 호출만 한다면 Provider Request 자체가 가장 중요한 실행 경계다. 타임아웃, 취소, 오류는 모두 이 요청에 묶을 수 있다.
Tool Calling이 이를 바꾼다.
모델이 먼저 Tool Call을 반환할 수도 있다:
Input
↓
Model Request #1
↓
Tool Call
↓
Tool Execution
↓
Tool Result
↓
Model Request #2
↓
Final Output
Model Request #1은 이미 끝났지만, 현재 작업이 아직 끝나지 않았다는 것은 분명하다.
즉, Provider Request는 한 번의 모델 계산만 설명할 수 있고, Agent의 전체 진행 과정을 설명할 수는 없다.
Step: 모델 결정 하나에 안정적인 경계 세우기
DeepSeek Harness는 단일 모델 결정에 가까운 경계를 Step으로 설명한다. [1]
하나의 Step은 다음과 같이 추상화할 수 있다:
여기서 분명히 해둘 부분이 있다. Step은 보통 단일 HTTP Provider Request와 동치이지 않다.
모델이 Tool Call을 생성한 뒤에도 그 Tool Call은 현재 모델 결정의 직접적인 결과다. Step을 모델 스트림 종료 시점에 바로 닫으면 Tool Execution은 주체가 없는 중간 상태가 된다. 반대로 도구 결과를 다음 Step에 넣으면 ‘한 번의 모델 결정이 어떤 효과를 냈는가’를 관찰할 수 있다는 성질이 약해진다.
따라서 Model Decision과 직접 발생한 Tool Work를 같은 Step에 두면 더 안정적인 실행 경계를 만들 수 있다:
Context N
↓
Model Decision N
↓
Tool Effects N
↓
Step N ends
↓
Context N+1
이 경계는 Runtime에 매우 값지다.
다음 Step을 시작하기 전에 다음을 안전하게 처리할 수 있다:
- 새로운 Steering Message
- Context Compaction
- Hook
- Model/Tool 구성 변경
- 취소 상태
- 런타임이 주입하는 정보
이미 시작된 Model/Tool 작업은 이런 변화 때문에 도중에 뒤집힐 필요가 없다.
따라서 Step은 Runtime의 가장 작고 안정적인 진행 단위에 더 가깝다.
Assistant Message를 Step으로 바로 볼 수 없는 이유
UI에서 가장 직관적인 단위는 보통 하나의 Assistant Message지만, Runtime의 실행 경계와 완전히 일치하지는 않는다.
하나의 Assistant Message가 여러 Tool Call을 포함할 수 있다:
Assistant Message
├── text
├── Tool Call A
└── Tool Call B
Runtime은 A와 B의 결과를 기다렸다가 다음 모델 요청으로 진행할지 결정해야 한다.
Message만 관찰하면 다음을 판단하기 어렵다:
这次模型决策是否已经完整结束
所有 Tool 是否都已经完成
下一次 Context 是否已经可以构造
Steering 是否可以被消费
따라서 Message는 대화 내용을 표현하는 데 더 적합하고, Step은 실행 라이프사이클을 표현하는 데 더 적합하다.
둘은 긴밀하게 연관될 수 있지만, 서로 다른 문제를 담당한다.
Turn: 여러 Step이 여전히 같은 작업에 속하는 이유
Step을 도입한 다음에는 두 번째 문제가 남는다.
모델이 처음에 Tool Call을 반환하고 도구 실행이 완료된 뒤 모델을 다시 요청한다. 모델이 두 번째로 도구를 계속 호출할 수도 있고, 세 번째에야 최종 텍스트를 만들 수도 있다.
이 Step들은 각각 독립적인 작업으로 보아야 할까?
보통은 그렇지 않다.
여전히 하나의 연속된 작업이 이를 구동한다:
Turn은 바로 이 연속적인 진행 구간을 뜻한다.
DeepSeek Harness는 step/end 이후 next-step Inbox를 확인한다. 현재 작업에서 소비해야 할 입력이 남아 있으면 다음 Step으로 계속 진행한다. 자연스럽게 멈추기 전에는 agent/turn-stopping을 거쳐 Turn이 실제로 끝나기 전에 Hook이 작업을 다시 끼워 넣을 수 있고, 남은 작업이 없을 때만 turn/end를 기록한다. [1][2]
Pi는 Step을 같은 이름의 1급 객체로 노출하지 않지만, runLoop()의 구조가 동일한 시간 계층을 보여 준다. 내부 루프는 Tool Calls와 Steering Messages를 계속 처리하고, 현재 연속 작업이 끝난 뒤 외부 루프가 Follow-up을 처리한다. [3]
Turn의 종료 조건은 ‘모델이 이미 텍스트를 반환했다’만으로 정할 수 없다. 더 정확한 조건은 다음과 같다:
当前连续工作已经没有必须立即推进的内容
이 때문에 Steer와 Follow-up에는 서로 다른 경계가 필요하다:
Steer
进入当前 Turn 后续最近的 Step
Follow-up
等待当前 Turn 到达停止边界
3편과 4편에서는 Pi 소스 코드에서 이 관계를 계속 검증한다.
Session: Turn이 끝난 뒤에도 유지해야 하는 것
Turn이 끝난 뒤 Agent가 이미 idle 상태에 들어갔더라도 세션 자체가 사라지지는 않는다.
다음 사용자 입력도 기존 이력, 분기, Memory 또는 세션 구성을 이어받아야 한다.
따라서 Turn보다 뚜렷하게 더 긴 라이프사이클이 필요하다:
Session
│
├── Turn A
├── Turn B
├── Turn C
└── ...
Session은 장기 세션 경계에 더 가깝다.
보통 다음을 저장한다:
历史 Message / Event
会话元数据
当前分支或 lineage
持久化状态
长期 Memory 引用
多次执行产生的结果
Pi Coding Agent는 Session을 JSONL로 저장하고 id / parentId로 트리 형태의 이력을 나타내며, 하나의 Session 파일 안에 여러 분기가 존재할 수 있다. [4]
DeepSeek Harness의 Session은 append-only Event Log를 채택하고, Session Log를 live Agent의 durable source of truth로 사용한다. [2]
두 영속화 구조는 크게 다르지만 라이프사이클 위치는 같다. Session은 단일 Agent Loop 위에 놓인다.
실행 중 상태를 Session에 전부 넣으면 안 되는 이유
Session의 라이프사이클은 길기 때문에 프로세스 간 또는 복구 후에도 유지되어야 하는 사실을 저장하는 데 적합하다.
하지만 현재 실행 중인 임시 객체는 다르다. 예를 들면:
AbortController
正在 Streaming 的半条 Assistant Message
当前网络连接
某个 Tool 的内存句柄
当前 active promise
이런 객체는 보통 장기 영속화할 의미가 없고, 심지어 직렬화할 수도 없다.
Session이 장기 사실과 프로세스 내 임시 상태를 동시에 책임진다면 복구 로직이 매우 어려워진다. 시스템은 어떤 필드를 다시 구성할 수 있고, 어떤 필드는 이미 무효화되었으며, 어떤 필드는 과거 프로세스가 남긴 참조일 뿐인지 판별해야 하기 때문이다.
더 안정적인 경계는 다음과 같다:
Session
保存 durable facts
Runtime State
保存当前进程内活动状态
영속화할 때 Runtime에서 가치 있는 상태를 Event, Checkpoint 또는 Run Status로 바꿀 수 있지만, Runtime 객체 전체를 저장할 필요는 없다.
Run이 필요한 이유
여기까지 Session, Turn, Step을 정의했다. 많은 Agent Core는 이 수준이면 충분하다.
하지만 Web 제품과 작업형 애플리케이션은 여전히 제품 계층의 실행 관리가 필요해서 Run을 도입하는 경우가 많다.
사용자 작업 하나가 Agent를 촉발하면 시스템에는 보통 조회 가능한 실행 엔티티가 필요하다:
QUEUED
RUNNING
WAITING_APPROVAL
PAUSED
COMPLETED
FAILED
CANCELLED
다음 항목도 기록해야 할 수 있다:
runId
startedAt / endedAt
triggerMessageId
cost / tokens
error
approval state
parentRunId
childRunIds
resume cursor
이 상태들은 분명히 개별 Step에 속하지 않는다.
전체 Session에 곧바로 붙이기도 적합하지 않다. 하나의 Session은 여러 번의 독립적인 실행을 거칠 수 있기 때문이다:
Session S1
│
├── Run R1 completed
├── Run R2 failed
└── Run R3 running
Session에 전역 status 하나만 두면 각 실행의 독립적인 라이프사이클을 보존할 수 없다.
따라서 Run은 관리 가능하고 관찰 가능하며 복구 가능한 애플리케이션 계층의 실행 구간을 장기 Session에서 분리하는 문제를 주로 해결한다.
Run이 반드시 Agent Core에 속할 필요는 없는 이유
여기서 흔한 오해를 피해야 한다. Run이 유용하니 바로 Agent Loop에 고정해 넣는다는 것이다.
이렇게 하면 다음과 같은 다량의 제품 의미가 실행 커널로 들어온다:
审批
计费
任务队列
重试策略
父子任务
SLA
页面恢复
审计
이 능력들은 모델이 Tool을 호출하는 방식과 직접적인 관련이 없다.
DeepSeek Harness의 문서는 여기서 가치 있는 경계를 제시한다. whenIdle()은 Agent가 현재 활성 상태에서 quiescence에 도달할 때까지의 구간을 관찰한다. 이 구간을 호출부가 명확히 소유할 때만 run으로 모델링하는 것이 적절하다. [2]
Pi의 Agent 내부에도 promise, resolve, AbortController를 보관하는 ActiveRun이 있지만, 현재 active processing interval만 관리할 뿐 업무 계층의 영속적인 Run이 지닌 완전한 의미를 담당하지는 않는다. [5]
따라서 다음과 같이 계층을 나눌 수 있다:
Run은 존재할 수 있지만 모든 Agent Core가 이를 이해하도록 강제할 필요는 없다.
Turn과 Run이 가장 쉽게 혼동되는 지점
둘 다 ‘한 번의 작업’으로 이해할 수 있지만关注가 다르다.
Turn은 Agent 실행 의미론 속의 연속 작업 구간이고, Run은 제품 및 스케줄링 의미론에 더 가깝다.
예를 들어 Run은 승인 대기 때문에 일시 중단될 수 있다:
Run R1
│
├── Turn A
│ └── 요청执行敏感操作
│
├── WAITING_APPROVAL
│
└── Turn B
└── 审批通过后继续
이 경우 하나의 Run이 여러 Turn을 가로지를 수 있다.
더 단순한 제품은 ‘Run 하나는 Turn 하나에 대응한다’를 선택해도 된다. 객체 모델이 반드시 복잡해져야 하는 것은 아니다. 핵심은 개념 경계가 앞으로의 일시 중단, 복구, 스케줄링 요구를 수용할 수 있어야 한다는 점이다.
따라서 이 시리즈에서는 다음 정의를 사용한다:
Turn
Agent Core 中连续推进的工作边界
Run
应用层可持久管理的一次执行边界
이 두 정의는 모든 프레임워크에 동일한 이름을 요구하는 것보다 유용하다.
Connection은 별도의 독립 라이프사이클
Web Agent에는 더 짧고 훨씬 불안정한 라이프사이클, 즉 클라이언트 연결도 존재한다.
Connection은 실시간 전송만 담당한다. 페이지 새로 고침, 네트워크 전환 또는 SSE 재연결이 생기면 Connection은 교체되지만 Run과 Session은 그대로 유지될 수 있다.
이들을 묶어 버리면:
connection close
→ run cancel
→ session activity lost
시스템이 진정한 장기 작업과 복구를 지원하기 어려워진다.
따라서 Web Agent는 최소한 다음을 명확히 구분해야 한다:
Connection
通信生命周期
Run
执行生命周期
Session
长期会话生命周期
이후의 Web Runtime 단위에서는 Connection이 교체된 뒤 Run의 실시간 화면을 어떻게 복구할지 별도로 다룬다.
Agent와 Session은 일대일이어야 할까
다층 라이프사이클을 정리한 뒤에도 Agent와 Session의 바인딩 문제가 남는다.
DeepSeek Harness는 현재 하나의 live Agent가 하나의 Session을 구동하는 방식을 선호한다. 공개된 Agent handle은 session, inbox, status, agent-scoped ctx를 직접 보유해 두 관계가 강하게 묶여 있다. [2]
이 설계의 장점은 복구 경로가 직접적이라는 것이다:
Session
↓
resume live Agent
Agent의 Prompt, Tools, 어댑터와 Session 이력을 일관되게 유지할 수 있다.
다른 유형의 제품은 하나의 Session에 여러 AgentDefinition이 참여하도록 허용할 수도 있다:
Session S1
│
├── Run R1 → CodingAgent v3
├── Run R2 → ReviewAgent v2
└── Run R3 → CodingAgent v3
이 패턴은 여러 역할의 협업이나 orchestration에 더 적합하지만, 각 Run은 실제 사용된 Agent 구성을 반드시 기록해야 한다. 그러지 않으면 이력을 복구한 뒤 당시의 Prompt, Tools, Model Policy를 판단하기 어렵다.
두 구조 모두 성립한다. 버전 정보 없이 Session이 호환되지 않는 Agent 구성으로 임의 전환하도록 만드는 것은 피해야 한다.
취소, 오류, 복구는 각각 어느 계층에 있어야 할까
라이프사이클 모델이 합리적인지는 예외 경로를 거꾸로 살펴보면 알 수 있다. 정상 실행 중에는 많은 객체가 하나로 합쳐진 것처럼 보여도 취소, 오류, 복구가 발생하면 경계가 빠르게 드러난다.
먼저 취소부터 보자.
사용자가 현재 Provider Request만 종료하려 한다면 취소 범위는 Model Call에서 멈출 수 있다. 현재 Tool과 이후 Step을 더 이상 실행할 의미가 없다면 취소 범위는 현재 Turn을 덮어야 한다. 제품이 이 작업 구간을 영속적인 Run으로 관리한다면 Run 상태도 CANCELLED로 기록해야 한다.
이 동작들은 하나의 사용자 작업이 동시에 촉발할 수 있지만, 내부적으로는 여전히 여러 계층의 상태 변화가 생긴다.
오류도 비슷하다. Provider 타임아웃은 현재 Step만 재시도하면 될 수 있다. Tool 실패는 Tool Result로 모델에 전달해 계속 처리하게 할 수 있다. 복구 불가능한 오류는 Turn을 직접 끝내며, 제품의 재시도 한도에 도달한 뒤에야 Run이 FAILED로 전환된다. Session은 보통 그대로 남아 있으며, 사용자는 이후 같은 Session에서 새로운 Run을 시작할 수 있다.
Provider error
↓ retry?
Step
↓ recoverable?
Turn
↓ unrecoverable?
Run = FAILED
Session remains
전역 session.status 하나뿐이라면 이 오류 계층을 표현하기 어렵다. Tool 하나의 실패가 전체 세션을 실패로 표시할 수 있지만, 이후 사용자는 분명 같은 Session을 계속 사용할 수 있다.
복구 경로 역시 경계를 검증한다.
페이지 새로 고침은 Connection만 복구하면 된다. 프로세스 재시작은 Runtime을 재구축해야 한다. Run이 승인 대기 중이라면 Run 상태와 pending work를 복구해야 한다. Session은 장기 이력을 제공한다.
따라서 라이프사이클 객체의 의미는 정상 경로에서만큼 아니라 각 계층이 서로 다른 실패, 취소, 복구 조건을 갖는다는 데서도 드러난다.
누가 만들고, 누가 끝내며, 누가 상태를 소유하는가
세 가지 질문으로 객체 경계를 점검할 수도 있다:
谁创建它?
谁有权结束它?
它的状态由谁持久化?
Step은 보통 Agent Loop이 생성하고 자연스럽게 끝난다. Turn도 Runtime이 연속적인 진행을 제어한다. Run은 대개 애플리케이션 또는 스케줄링 계층이 생성하며, Runtime은 실행하고 상태를 보고할 뿐이다. Session은 보통 제품 세션 계층이 생성해 장기적으로 영속화한다.
어떤 객체의 생성자, 종료 권한을 가진 주체, 상태 소유자가 모두 다르면 그 객체는 여러 아키텍처 계층에 걸칠 가능성이 높다. 명시적인 인터페이스로 연결해야 하며, 단순히 어떤 핵심 클래스에 합치기에는 적합하지 않다.
Context가 이 영속 라이프사이클 트리에 없는 이유
Session, Run, Turn, Step은 모두 언제 시작하고 언제 끝나는지를 이야기할 수 있다. Context의 성격은 다르다.
Context는 어떤 Step이 모델 요청을 보내기 전에 구성한 계산 뷰에 더 가깝다.
Pi의 Agent.prompt()이 runPromptMessages()에 들어가면 먼저 다음을 호출한다:
현재 구현은 다음을 반환한다:
그런 다음 AgentContext를 runAgentLoop()에 전달한다. [5]
이번 Prompt는 다시 Loop Local Context에 추가되고, 실제로 Provider에 요청하기 전에는 transformContext, convertToLlm 및 기타 처리를 거칠 수 있다.
따라서 더 정확한 관계는 다음과 같다:
Session / Agent State
│
▼
Context Snapshot
│
▼
Current Turn / Step
│
▼
Provider Context
Context는 실행이 진행되는 동안 계속 변하므로 Session처럼 장기 엔티티로 따로 저장할 필요가 없다.
영속화 시스템이 실제로 저장해야 하는 것은 Context를 다시 구성할 수 있는 사실들이다.
이것이 두 번째 단위의 핵심 문제가 된다.
Session과 Step만 남겨서는 안 되는 이유
추상화의 최소화를 기준으로 보면 중간 계층이 모두 필요한지 의심할 수 있다.
Session
└── Step*
이 구조는 구현상 가능하지만, 여러 제어 의미가 안정적인归属을 잃는다.
Follow-up은 어느 Step 그룹이 끝난 뒤 실행되는가?
승인 요청으로 일시 중단되는 것은 어느 작업 구간인가?
사용자가 촉발한 실행이 실패한 뒤 오류는 어디에 붙는가?
긴 작업의 소요 시간, 비용, 상태는 어떻게 조회하는가?
시스템에 이런 능력이 필요해지기 시작하면 Turn과 Run은 자연스럽게 나타난다.
따라서 다층 라이프사이클은 고정된 교훈이 아니다. 더 합리적인 원칙은 다음과 같다:
두 종류의 상태가 생성, 종료, 복구 또는 제어되는 조건이 다르면 서로 다른 라이프사이클 객체로 나눌 것을 고려해야 한다.
제품에 해당 유형의 요구가 없다면 그 객체를 생략해도 된다.
라이프사이클 개념을 모두 데이터베이스 테이블에 직접 대응시킬 필요는 없는 이유
Session, Run, Turn, Step을 이해한 뒤에도 흔한 질문은 각 계층마다 영속 엔티티를 만들어야 하는가이다.
답은 제품에서 조회와 복구를 어느 정도의 세분성까지 요구하는지에 달려 있다.
Session은 장기 세션을 담기 때문에 거의 항상 영속화해야 한다. Run이 연결을 넘어 실행되거나 승인, 재시도, 독립적인 상태 조회가 필요하다면 보통 영속 ID를 둘 가치가 있다.
Turn과 Step은 반드시 독립 테이블을 가질 필요는 없다.
단순한 시스템은 Message만 저장해도 된다:
sessions
messages
Turn / Step은 Runtime 메모리 안의 실행 개념일 뿐이다.
시스템에 완전한 감사, Replay, 비용 통계, 장애 위치 추적이 필요하다면 라이프사이클을 Event로 기록할 수 있다:
session_events
├── turn/start
├── step/start
├── assistant/message
├── tool/call
├── tool/result
├── step/end
└── turn/end
이렇게 하면 Turn과 Step은 여전히 명확한 의미를 갖지만, 각각 변경 가능한 행 상태를 관리할 필요는 없다. DeepSeek Harness의 Session Event Log도 이 방향에 가깝다. [2]
다른 작업 플랫폼은 실제로 다음이 필요할 수도 있다:
runs
run_steps
각 Step을 독립적으로 표시하고 재시도하거나 과금해야 하기 때문이다. 이때 Step을 엔티티로 만들 때만 제품적 가치가 생긴다.
따라서 객체 모델과 저장 모델은 분리해서 생각해야 한다.
Conceptual lifecycle
说明系统如何运行
Persistence model
说明哪些事实需要长期保存和查询
둘은 일대일로 대응할 수도 있고, Event Log, Projection 또는 집계 필드로 표현할 수도 있다. 아키텍처 다이어그램을 ‘완전하게’ 만들기 위해 모든 개념에 기계적으로 테이블을 만들면 대개 동기화 비용만 늘어난다.
라이프사이클이 길수록 영속화 요구도 대체로 높아진다
대략 다음 규칙을 도출할 수 있다:
이 규칙은 Agent Core가 Step / Turn에 더 집중하는 이유와 Web 제품이 Run / Session에 더 집중하는 이유를 함께 설명한다. 둘은 동일한 실행 시스템의 서로 다른 시간 규모를 바라보고 있기 때문이다.
시간 규모로 전체 모델을 통합하기
마지막으로 이 객체들을 하나의 시간 그림에 놓으면 다음과 같다:
각각 다음 질문에 답한다:
- Model Call: 한 번의 Provider 계산은 언제 끝나는가
- Step: 한 번의 모델 결정과 그 직접적인 Tool Effects는 언제 끝나는가
- Turn: 현재 연속 Agent 작업은 언제 중단 경계에 도달하는가
- Run: 한 번의 애플리케이션 계층 실행은 언제 최종 상태를 갖는가
- Session: 이 장기 세션은 언제 종료되거나 아카이브되는가
- Connection: 특정 클라이언트의 실시간 연결은 언제 끊기는가
이 구분을 통해 이후 설계가 직접 영향을 받는다.
Steer가 어느 경계로 들어가야 하는지, Follow-up이 어느 경계를 기다리는지, Abort가 어느 계층을 종료하는지, Session을 복구할 때 어떤 사실을 저장해야 하는지, Web 재연결에서 Run을 복구해야 할지 새 Run을 만들어야 할지는 모두 이 라이프사이클 모델에 달려 있다.
두 번째 편이 남기는 최종 설계 판단은 다음과 같다:
Agent 시스템의 객체 경계는 우선 라이프사이클의 차이로 결정해야 한다. Session, Run, Turn, Step은 각각 서로 다른 시작, 종료, 제어, 복구 조건을 가지며, 이름은 그 경계를 표현하는 것에 불과하다.
다음 편에서는 Pi의 실제 호출 체인을 따라가며 Agent.prompt()이 활성 구간을 어떻게 만드는지, Context를 어떻게 구성하는지, 두 겹의 Loop를 어떻게 진행하는지, Event가 하위 실행 결과를 어떻게 외부 Agent State에 동기화하는지 살펴본다.
참고 자료
[1] DeepSeek Harness Agent Lifecycle: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/agent-lifecycle.md
[2] DeepSeek Harness Core Subsystem: https://github.com/deepseek-ai/deepseek-harness/blob/master/docs/subsystems/core.md
[3] Pi agent-loop.ts: https://github.com/badlogic/pi-mono/blob/main/packages/agent/src/agent-loop.ts
[4] Pi Coding Agent Sessions: https://github.com/badlogic/pi-mono/blob/main/packages/coding-agent/README.md
[5] Pi Agent source: https://github.com/badlogic/pi-mono/blob/main/packages/agent/src/agent.ts