프로세스 안에서만 실행되는 Agent라면 한 번의 실행을 함수 호출로 이해할 수 있습니다. 입력이 들어오고, 모델과 도구가 반복해서 동작한 뒤 함수가 반환되며, 호출자는 그 결과를 받습니다. 하지만 Web에 들어서면 이 가정은 곧 성립하지 않습니다.
브라우저 페이지가 새로고침될 수도 있고, 네트워크 연결이 끊길 수도 있으며, 사용자가 탭을 닫을 수도 있습니다. 그럼에도 Agent는 계속 실행될 수 있습니다. 다른 페이지에서 같은 Session을 다시 열었다면, 이미 발생한 메시지, 도구 호출, 현재 실행 상태까지 복원해야 합니다. 이때 HTTP 요청, SSE, WebSocket 연결은 모두 그저 특정 통신 구간을 나타낼 뿐, Agent의 실행 수명 주기를 자연스럽게 떠맡을 수 없습니다.
그래서 Web Agent가 먼저 해결해야 할 것은 “token을 브라우저로 스트리밍하는 방법”이 아니라, 여러 수명 주기 사이의 소유 관계입니다:
Session 长期会话事实
Run 一次独立执行
Connection 一段网络连接
Browser Page 一个前端实例
이 중 어느 두 객체만 합쳐도 새로고침, 연결 끊김, 복구, 동시 접근에서 의미 충돌이 생깁니다.
일반 Streaming 요청에서 시작하기
가장 직접적인 Web 구현은 보통 다음과 같습니다:
Browser
│ POST /chat
▼
Server
│ stream model output
▼
HTTP Response / SSE
│
▼
Browser UI
실행 시간이 짧다면 이 구조만으로 충분합니다. 요청 객체가 AbortSignal을 보유하고, 모델의 Streaming이 연결을 따라가며, 연결이 끝나면 실행도 함께 끝납니다.
문제는 실행 수명 주기가 연결 수명 주기보다 길어지기 시작할 때 나타납니다.
예를 들어 Agent 작업 하나가 다음과 같다고 합시다:
LLM
↓
Tool A
↓
LLM
↓
等待外部审批
↓
Tool B
↓
LLM
대기하는 동안 기존 HTTP 호출 스택을 유지할 필요는 없습니다. 브라우저도 이미 새로고침됐을 수 있습니다. Server가 “이번 실행”을 Response에 직접 묶어 두면, Response가 사라지는 순간 Runtime은 서로 충돌하는 두 선택 사이에 놓입니다:
- 실행을 취소한다. 그러면 네트워크 장애가 업무의 의미를 바꾸게 됩니다.
- 실행을 계속한다. 그러면 기존 Response는 더 이상 실행 상태의 소유자가 아니게 됩니다.
두 번째 경우는 실제로 시스템에 연결과 독립적인 실행 객체를 도입해야 한다는 뜻입니다.
Session은 Run을 대신할 수 없다
Session은 장기적으로 유지되는 사실을 표현하는 데 적합합니다. 여기에는 메시지, 브랜치, 사용자 입력, 모델 출력, 도구 결과, 세션 메타데이터, 그리고 이후 Context를 구성하는 데 필요한 영속 상태가 포함됩니다.
한 번의 실행에는 또 다른 상태 집합이 필요합니다:
queued
running
waiting
completed
failed
cancelled
다음과 같은 필드도 필요합니다:
startedAt
finishedAt
abortHandle
currentStep
waitingReason
parentRunId?
이 필드들을 모두 Session에 직접 기록하면 구조적인 문제가 생깁니다. Session의 수명 주기는 단일 실행보다 훨씬 길지만, Run 상태에는 명확한 시작과 종료 경계가 있습니다. 같은 Session에서는 Run이 차례로 여러 개 생길 수도 있고, 백그라운드 Run, SubAgent Run, 재시도 Run이 존재할 수도 있습니다.
따라서 애플리케이션 계층의 더 안정적인 모델은 다음과 같습니다:
Session
├── Message / Event ...
├── Run 1
├── Run 2
└── Run 3
여기서 Run은 제품과 Durable Web Runtime 계층의 추상화입니다. 모든 Agent 프레임워크가 Run을 핵심 도메인 객체로 승격시키는 것은 아닙니다. 예를 들어 DeepSeek Harness는 현재 Session, Turn, Step을 더 강조하지만, Web 제품은 그 위에 독립적인 Run 레코드를 구축해 작업 상태, 복구, 감사, 스케줄링에 사용할 수 있습니다.
Run이 존재하는 이유는 “특정 실행이 지금 어떤 상태인가”를 알려 주는 것이고, Session은 “이 장기적인 상호작용에서 지금까지 무엇이 일어났는가”를 담습니다.
Connection은 Run을 소유하면 안 된다
연결의 수명 주기는 우연히 결정됩니다.
여기서 가장 중요한 관계는 다음과 같습니다:
연결은 특정 클라이언트가 Run과 Session을 관찰하는 통로일 뿐입니다.
네트워크가 끊겼다고 Server가 Run을 자동으로 취소한다면, Connection이 실행의 종료 권한을 갖게 됩니다. 반대로 페이지 새로고침으로 새 Run이 만들어진다면 Browser Page가 그 권한을 갖게 됩니다. 어느 경우든 “통신 장애”와 “업무상 취소”를 뒤섞게 됩니다.
더 명확하게 통제하려면 두 가지를 분리하면 됩니다:
transport disconnect
→ 结束当前连接
cancel run
→ 显式调用 Runtime cancellation
사용자가 Stop을 클릭하는 것은 후자에 해당합니다. Wi-Fi 연결이 끊기는 것은 전자에 해당합니다.
Vercel AI SDK가 제공하는 경계
Vercel AI SDK의 useChat은 Web Agent의 UI와 Transport 계층을 관찰하는 데 적합합니다. 현재 API에서 useChat은 UIMessage[], status, 오류와 전송·중지·복구 작업을 관리합니다. Transport는 사용자 정의 HTTP, WebSocket 또는 Agent를 직접 호출하는 구현으로 교체할 수 있습니다.[1][2]
즉, useChat의 주요 역할은 다음에 국한됩니다:
UI State
+
Transport
애플리케이션에 완전한 Durable Session 모델을 정의해 주는 것이 아닙니다.
AI SDK의 영속화 문서도 메시지 영속화를 애플리케이션에 맡긴다고 명시합니다. UIMessage는 프런트엔드 표시를 위한 구조로, 모델에 전송하는 ModelMessage와는 구조가 다릅니다.[3]
이 점은 중요한 계층 분리와 대응합니다:
Frontend UIMessage[]
│
│ Transport payload
▼
Server Canonical Session
│
│ Context Builder
▼
ModelMessage[]
프런트엔드는 표시에 필요한 UI State를 소유하고, Server는 여전히 세션 사실을 최종적으로 해석할 권한을 가져야 합니다.
앱 규모가 작다면 UIMessage[]를 직접 영속화해도 됩니다. Agent에 Event Log, Tool State, Approval, SubAgent, Compaction이 도입되면 Server Canonical State가 대개 UIMessage[]보다 더 풍부해지고, UI Message는 그 상태의 한 가지 프로젝션이 됩니다.
Stream Resume에도 여전히 영속 상태가 필요하다
AI SDK는 현재 useChat의 stream resume을 지원하지만, 공식 문서는 복구 메커니즘을 위해 Message와 Active Stream을 애플리케이션이 직접 영속화하고 Chat ID와 Stream ID의 관계를 관리해야 한다고도 명확히 합니다.[4]
복구의 실제 문제는 다음과 같이 나타낼 수 있습니다:
Streaming Transport 자체만으로는 이 문제에 답할 수 없습니다.
복구 가능한 사실 원천이 최소한 하나는 필요합니다:
Durable Event Tail
或
Resumable Stream Store
或
Server Projection + Durable Delta
그러면 클라이언트가 다음을 실행할 수 있습니다:
load baseline at N
+
replay durable events > N
+
attach live stream
시스템이 엄격한 복구를 지원한다고 주장한다면, “히스토리 읽기 완료”와 “실시간 구독 시작” 사이의 gap을 반드시 처리해야 합니다. 일반적인 방법은 Server 측에서 replay와 live attach가 하나의 연속된 sequence 공간을 공유하게 하거나, checkpoint를 설정한 뒤 구독하는 것입니다.
Abort와 Resume 사이에는 실제로 충돌이 있다
AI SDK의 현재 문서는 resume: true를 사용하는 resumable stream과 abort/stop 메커니즘이 충돌한다고 별도로 지적합니다. 페이지 새로고침이나 닫기 동작은 AbortSignal을 트리거해 stream resumption을 깨뜨릴 수 있습니다.[4][5]
이는 특정 라이브러리에서 우연히 생긴 제약이 아니라, 더 일반적인 문제를 드러냅니다:
Connection Abort
与
Execution Cancel
단순한 Streaming 아키텍처에서는 두 종류의 취소가 흔히 같은 AbortSignal을 공유합니다.
Run이 연결과 독립적으로 계속 존재하도록 요구하면 두 종류의 취소를 분리해야 합니다:
connection.abort()
只关闭客户端消费通道
run.cancel()
终止 Agent Runtime
이것은 Web Agent가 “스트리밍 HTTP 요청”에서 Runtime으로 진화하는 데 대한 명확한 경계입니다.
Browser Page는 뷰 인스턴스만 소유한다
페이지를 새로고침하면 JavaScript 메모리가 전부 사라집니다. 페이지 객체가 Session의 유일한 사본을 소유한다면, 새로고침은 Session을 잃는 것과 같습니다. 페이지 객체가 Run의 유일한 제어 상태를 소유한다면 현재 실행을 잃게 됩니다.
더 안정적인 프런트엔드 관계는 다음과 같습니다:
페이지는 전체 Client Runtime을 파괴할 수 있습니다. 다시 로드하면 새 Runtime이 Server Canonical State에서 복원됩니다.
Client Runtime은 lastSeq, 현재 Session ID, UI preference를 캐시할 수 있지만, 이 데이터는 복구 효율을 높이는 데 쓰일 뿐 업무 사실의 유일한 출처가 되어서는 안 됩니다.
DeepSeek Harness의 Client Runtime
DeepSeek Harness는 현재 브라우저 측에서 이미 이러한 계층 분리를 명확하게 채택하고 있습니다. 클라이언트 제약 문서에서는 데이터 객체 계층을 다음과 같이 정의합니다:
ConnectionController
↓
SessionManager
↓
Session
이 계층은 React에 의존하지 않습니다. Session 자체가 이벤트 창, Streaming accumulation, Reconnect 복구, 관찰 가능한 Snapshot을 유지합니다. React 바인딩은 독립적인 web-react 계층에 있습니다.[6]
ConnectionController는 connection generation과 exponential backoff를 직접 관리합니다. 새로운 generation이 설정되면 onConnected가 트리거되고, SessionManager.handleConnected()가 Session 목록을 새로 고친 뒤, 이미 인스턴스화된 Session들이 resync()를 실행합니다.[7][8]
Session의 resync()는 openGeneration을 증가시키고, 기존 연결에서 진행 중인 open을 무효화하며, 창을 비운 뒤 히스토리를 다시 읽습니다. 실시간 Event는 open 또는 gap repair 중에 liveBuffer로 들어갑니다. 히스토리가 반영된 후에는 seq를 기준으로 buffer를 창 끝에 이어 붙입니다.[9]
이 구현은 Web Runtime이 복구하는 대상이 React Component도 Socket도 아니라, 명시적인 상태와 sequence 규칙을 갖춘 Client Session임을 보여 줍니다.
Server State, Client State, Run State
이 관계들을 정리하면 비교적 안정적인 소유권 모델을 얻을 수 있습니다:
따라서 몇 가지 경계를 분명히 할 수 있습니다:
- 브라우저 연결은 Session의 사실을 소유하지 않습니다.
- Run의 종료는 네트워크 연결이 끊겼다는 사실만으로 암묵적으로 결정되지 않습니다.
- Event Stream은 변화의 전달을 담당할 뿐, 유일한 상태 소스는 아닙니다.
- Client Runtime은 복구와 프로젝션을 담당하며, 이러한 업무 상태를 React Component 안에 넣지 않습니다.
- UI는 표시와 로컬 인터랙션 상태를 담당합니다.
하나의 설계 판단
Web Agent의 핵심 문제는 SSE와 WebSocket 중 어느 쪽을 선택할지가 아닙니다.
Transport는 교체할 수 있습니다. 더 안정적인 설계 질문은 실행, 사실, 연결, 뷰의 소유권을 각각 누가 가지는지, 그리고 한 객체가 사라진 뒤에도 나머지 객체가 올바른 의미를 유지할 수 있는가입니다.
이 네 가지 수명 주기를 분리하면 연결 복구 구조도 자연스럽게 만들어집니다:
Session / Run 继续存在
↓
Connection 可以反复建立
↓
Client Runtime 根据 checkpoint 重建
↓
UI 只是当前投影
다음 글에서는 Client Runtime 내부의 가장 중요한 문제를 계속 다루겠습니다. Server가 Event를 보내지만 React가 최종적으로 필요로 하는 것은 View State입니다. 그 중간에 있는 Projection은 어디에 있어야 하고, 과거 Replay와 실시간 Append는 어떻게 같은 계산 로직을 공유할 수 있을까요?
참고 자료
[1] Vercel AI SDK useChat: https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat
[2] Vercel AI SDK Transport: https://ai-sdk.dev/docs/ai-sdk-ui/transport
[3] Vercel AI SDK 챗봇 메시지 영속화: https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-message-persistence
[4] Vercel AI SDK 챗봇 스트림 재개: https://ai-sdk.dev/docs/ai-sdk-ui/chatbot-resume-streams
[5] Vercel AI SDK, Abort가 resumable stream을 중단하는 문제: https://ai-sdk.dev/docs/troubleshooting/abort-breaks-resumable-streams
[6] DeepSeek Harness 웹 클라이언트 규칙: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/AGENTS.md
[7] DeepSeek Harness ConnectionController: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/connection/src/client/connection.ts
[8] DeepSeek Harness SessionManager: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/runtime/src/client/sessions/manager.ts
[9] DeepSeek Harness Client Session: https://github.com/deepseek-ai/deepseek-harness/blob/master/packages/client/runtime/src/client/sessions/session.ts