메신저 위에서 도는 에이전트 런타임 설계
vooy의 에이전트는 사용자가 이미 쓰는 메신저 안에서 동작합니다. 세션, 도구 호출, 스트리밍을 하나의 런타임으로 묶기까지 내린 결정들을 정리했습니다.
vooy를 한 줄로 설명하면 "팀이 이미 쓰는 메신저 안에서 동작하는 AI 에이전트" 입니다. 새 앱을 깔게 하지 않는다는 제약은 단순한 마케팅 문구가 아니라, 런타임 설계 전체를 가르는 결정이었습니다. 이 글은 그 결정에서 출발해 지금의 에이전트 런타임이 어떤 모양이 되었는지를 다룹니다.
왜 메신저인가
대부분의 에이전트 제품은 자체 채팅 UI를 가집니다. 깔끔하지만, 사용자는 하루에 한 번 들어올까 말까 한 또 하나의 탭을 떠안게 됩니다. 우리는 반대로 갔습니다. 사용자가 이미 종일 떠 있는 창 — Slack, 카카오워크, 메신저 — 안으로 에이전트를 집어넣는 것입니다.
이 선택은 런타임에 두 가지 제약을 강제합니다.
- 수명이 길다. 대화는 며칠씩 이어집니다. 요청-응답으로 끝나는 무상태 함수가 아니라, 오래 사는 세션을 다뤄야 합니다.
- 응답이 끊겨선 안 된다. 메신저 사용자는 "타이핑 중…"에 익숙합니다. 토큰이 생성되는 즉시 흘려보내지 않으면 죽은 봇처럼 보입니다.
런타임의 한 사이클
런타임의 핵심은 단순한 루프입니다. 메시지가 들어오면, 컨텍스트를 모으고, 모델을 호출하고, 도구를 실행하고, 결과를 다시 모델에 먹입니다. 도구 호출이 없을 때까지 반복합니다.
async function runTurn(session: Session, input: UserMessage) {
const ctx = await buildContext(session, input);
for (let step = 0; step < MAX_STEPS; step++) {
const response = await model.stream(ctx, { tools: session.tools });
// 토큰은 생성되는 즉시 메신저로 흘려보낸다.
for await (const chunk of response.text) {
session.transport.push(chunk);
}
if (response.toolCalls.length === 0) {
return session.transport.commit();
}
const results = await executeTools(response.toolCalls, session);
ctx.append(response.assistant, results);
}
}세션과 컨텍스트
세션은 한 대화 채널에 묶인 상태 덩어리입니다. 누가 참여 중인지, 어떤 커넥터에 인증되어 있는지, 최근 메시지가 무엇인지를 담습니다. 우리는 세션을 Durable Object 하나에 매핑했습니다. 채널 하나 = 객체 하나, 직렬화된 단일 실행기. 동시성 버그의 절반은 "같은 대화에 두 턴이 동시에 들어왔다"에서 나오는데, 이 매핑이 그 문제를 구조적으로 없앱니다.
동시성은 해결하는 게 아니라 회피하는 게 가장 쌉니다. 채널당 하나의 실행기를 보장하면 락이 필요 없습니다.
컨텍스트는 매 턴 새로 조립됩니다. 시스템 프롬프트, 활성 커넥터의 도구 정의, 압축된 대화 히스토리, 그리고 검색된 메모리. 히스토리가 길어지면 오래된 구간을 요약으로 접습니다.
도구 호출 루프
도구는 전부 같은 인터페이스를 따릅니다. 입력 스키마는 Zod로 정의하고, 모델에는 JSON Schema로 변환해 넘깁니다.
export const sendCalendarInvite = defineTool({
name: "send_calendar_invite",
description: "참석자에게 캘린더 초대를 보낸다",
input: z.object({
title: z.string(),
attendees: z.array(z.string().email()),
startsAt: z.string().datetime(),
}),
run: async ({ title, attendees, startsAt }, { connectors }) => {
return connectors.google.calendar.invite({ title, attendees, startsAt });
},
});도구 실행은 항상 격리됩니다. 한 도구가 던진 예외가 턴 전체를 죽이지 않도록, 결과는 성공/실패가 태깅된 구조로 모델에 돌아갑니다. 모델은 실패를 보고 재시도하거나 사용자에게 되물을 수 있습니다.
스트리밍을 일급 시민으로
가장 많은 시간을 쓴 곳입니다. 모델의 토큰 스트림과 메신저의 메시지 편집 API 사이에는 임피던스 불일치가 있습니다. 모델은 토큰 단위로 뱉지만, 메신저는 메시지 단위로 편집하며 초당 수십 번 편집하면 레이트 리밋에 걸립니다.
그래서 트랜스포트 계층에 코얼레싱 버퍼를 두었습니다.
| 신호 | 동작 |
|---|---|
| 토큰 도착 | 버퍼에 누적 |
| 80ms 경과 | 누적분을 한 번에 flush |
| 도구 호출 시작 | 즉시 flush 후 상태 메시지 전환 |
| 턴 종료 | 최종 커밋, 버퍼 비움 |
결과적으로 사용자는 부드럽게 흐르는 응답을 보고, 메신저 API는 초당 12회 이하의 편집만 받습니다.
실패를 설계에 넣기
오래 사는 세션에서는 모든 것이 언젠가 실패합니다. 모델 타임아웃, 커넥터 401, 워커 재시작. 우리는 세 가지 원칙을 지킵니다.
- 턴은 멱등하게. 같은 입력 메시지 ID로 재실행해도 중복 부수효과가 없도록 도구에 멱등 키를 넘깁니다.
- 부분 진행을 저장한다. 도구 결과는 모델에 먹이기 전에 세션에 먼저 커밋합니다. 재시작해도 이미 한 일을 다시 하지 않습니다.
- 사용자에게 정직하게. 복구 불가능한 실패는 숨기지 않고 "지금 캘린더에 연결할 수 없어요"처럼 그대로 노출합니다.
지금의 런타임은 화려하지 않습니다. 루프 하나, 세션 하나, 버퍼 하나. 하지만 "메신저 안에서, 끊기지 않고, 오래" 라는 제약을 정면으로 받아낸 결과물입니다. 다음 글에서는 이 런타임이 수백 개의 외부 도구를 어떻게 붙이는지 — connector-hub 설계를 다루겠습니다.