에이전트 제품을 출시해 본 사람이라면 이런 느낌을 압니다. 작동하는 데모를 만드는 것은 빠르지만, 사용자가 매일 신뢰할 수 있는 제품, 자신의 컴퓨터에서 온종일 중단 없이 실행되는 제품으로 만드는 것은 어렵습니다. 어려운 부분은 모델을 연결하는 일이 아닙니다. 모델을 둘러싼 전체 계층입니다.
이 계층에는 여러 이름이 있지만 여기서는 Agent Harness라고 부르겠습니다. "대규모 모델"과 "제품 기능" 사이에 있는 이 계층이 실제 런타임입니다. 사용자의 요청 하나를 모델과의 여러 차례 대화로 전환하고, 그 사이에 도구 호출을 연결하며, 결과를 다시 전달하고, 맥락이 넘치기 전에 압축하며, 네트워크가 끊겨도 재시도하고, 프로세스가 비정상 종료된 뒤에도 대화를 복구합니다. 모델이 사고한다면 하네스는 그 사고를 신뢰할 수 있는 일련의 동작으로 실현합니다.
Orkas는 사용자의 컴퓨터에서 실행되는 데스크톱 에이전트 앱이며, 하네스는 전적으로 클라이언트에 있습니다. 이 글에서는 이 계층의 구축 방식을 살펴봅니다. 계층을 어떻게 나누는지, 실행 루프는 어떤 모습인지, 도구와 모델을 어떻게 추상화하는지, 메모리와 세션은 어떻게 처리하는지 설명합니다. 코드 세부 사항은 정리하고 일반화했지만 엔지니어링 구조는 실제입니다.
계층 구조
에이전트 제품을 펼쳐 보면 아래에서 위로 대략 다음과 같은 계층이 쌓여 있습니다.
┌──────────────────────────────────────────────┐
│ 제품 기능 (채팅 / 스킬 / 커넥터 / 동기화) │
├──────────────────────────────────────────────┤
│ 에이전트 하네스 (실행 루프 / 도구 / 세션) │
├──────────────────────────────────────────────┤
│ 공급자 추상화 (여러 LLM 공급자 통합) │
├──────────────────────────────────────────────┤
│ 기반 구조 (유형 / 오류 / 로깅 / 설정) │
└──────────────────────────────────────────────┘여기에는 중대한 선택이 내재되어 있습니다. 모든 모델 추론은 클라이언트에서 이루어집니다. 데스크톱 앱은 씬 클라이언트가 아닙니다. 하네스 자체를 포함하고 모델을 직접 호출합니다. 서버는 계정, 다중 기기 동기화, 결제만 처리하며 에이전트를 전혀 실행하지 않습니다. 이 결정이 이후의 거의 모든 구조를 형성했습니다. 세션은 로컬 디스크에 저장되고, 도구는 사용자의 작업 디렉터리에서 직접 작동하며, 민감한 데이터는 컴퓨터 밖으로 나가지 않습니다.
하네스 자체는 실행 루프(러너), 세션, 도구, Provider 계층, 메모리로 나뉩니다. 하나씩 살펴보겠습니다.
실행 루프: 스트리밍 제너레이터
하네스의 중심은 러너입니다. 하는 일을 한 문장으로 정리하면 다음과 같습니다. 모델이 "끝났습니다"라고 말할 때까지 계속 모델과 대화합니다.
이는 비동기 제너레이터로 구현되며, 이 선택은 중요합니다. 에이전트 실행 한 번은 "요청을 보내고 결과를 기다리는 것"보다 훨씬 복잡합니다. 그 사이에 모델이 토큰을 출력하고, 도구 호출을 요청하고, 도구 실행이 끝나고, 맥락이 길어져 압축이 시작되고, 네트워크 오류로 재시도하는 등 많은 일이 일어납니다. 콜백이나 일반 Promise로는 이런 중간 상태를 호출자에게 깔끔하게 드러내기 어렵습니다. 제너레이터를 사용하면 이 모든 것이 yield로 내보내는 이벤트 스트림이 됩니다.
type AgentRunEvent =
| { type: "text_delta"; text: string } // model emitting tokens
| { type: "tool_start"; name: string; input: unknown } // a tool starts executing
| { type: "tool_end"; name: string; result: string } // a tool finished
| { type: "compaction"; tokensBefore: number; tokensAfter: number } // context compacted
| { type: "retry"; attempt: number; reason: string } // error, retrying
| { type: "done"; result: AgentRunResult } // terminalUI는 이 이벤트 스트림을 구독하고 모델 출력과 도구 실행을 실시간으로 표시합니다. 비스트리밍 진입점은 내부적으로 "스트림을 끝까지 소비하고 마지막 done을 가져오는 것"일 뿐입니다. 두 진입점이 하나의 구현을 공유하므로 동기화가 어긋날 두 번째 코드 경로가 없습니다.
한 턴 안에서 일어나는 일
한 턴을 펼쳐 보면 대략 다음과 같습니다.
- 사용자 메시지(이미지가 포함될 수 있음)를 세션 기록에 추가합니다.
- 현재 사용 가능한 도구, 스킬 인덱스 등을 삽입하여 시스템 프롬프트를 구성합니다.
- 모델 문자열을 파싱하여 구체적인 Provider와 모델 ID로 해석합니다.
- 모든 도구를 모델이 이해하는 정의로 변환하고 기록과 함께 전송합니다.
- 모델의 응답 스트림을 소비하면서 텍스트를 토큰 단위로
yield로 내보내고 모델이 생성하는 도구 호출을 수집합니다. - 스트림이 끝나면 모델의 중지 사유를 확인합니다.
- 중지 사유가
tool_use이면 모델이 도구를 호출하려는 것입니다. 도구를 실행한 다음 5단계로 돌아가 모델에 다시 요청합니다. - 그렇지 않으면 턴이 끝난 것입니다. 결과를 구성하고
yield done를 실행한 뒤 반환합니다.
여기서 반드시 지켜야 할 불변 조건이 하나 있습니다. 모델이 생성한 모든 도구 호출 바로 뒤에는 기록상 대응하는 도구 결과가 와야 합니다. 모델 API는 이 짝을 엄격하게 강제합니다. 어기면 다음 요청이 오류로 끝나거나 그대로 멈춥니다. 세션 자가 복구를 설명할 때 다시 살펴보겠습니다.
도구 호출이 다시 라우팅되는 방식
모델은 도구를 직접 실행하지 않고 "이 인수들로 read_file 을 호출하고 싶다"라고 말할 뿐입니다. 러너가 그 의도를 파악하면 다음과 같이 처리합니다.
for (const call of toolUseBlocks) {
yield { type: "tool_start", name: call.name, input: call.input };
const tool = this.tools.get(call.name);
const ctx = { workingDir, signal, state: { sandboxEnv } };
const result = await tool.execute(call.input, ctx);
// append the result to the session as a tool-result message
session.addToolResult(call.id, result);
yield { type: "tool_end", name: call.name, result: result.content };
}도구는 순차적으로 실행되고, 결과는 모델이 선언한 순서대로 기록에 다시 쓰이며, 그 결과를 바탕으로 모델에 다시 요청합니다. 모델은 결과를 보고 다른 도구를 호출하거나 최종 답변을 할 수 있습니다. 이 "요청 → 호출 → 응답 → 재요청" 루프가 바로 에이전트가 여러 단계의 작업을 완료할 수 있게 하는 구조입니다.
따로 언급할 만한 세부 사항이 있습니다. 일부 도구는 스크린샷이나 생성된 그림 같은 이미지를 반환합니다. 하지만 많은 모델이 도구 결과 채널에서 이미지를 받지 않습니다. Orkas는 이미지를 별도의 사용자 메시지로 분리하여 도구 결과 뒤에 배치합니다. 모델은 먼저 "도구가 이 텍스트를 반환했다"는 내용을 읽고 바로 다음 턴에 해당 이미지를 봅니다. Provider 간 기능 차이를 우회하는 작은 절충입니다.
맥락이 넘치기 직전에 해야 할 일
긴 작업이 가장 자주 부딪히는 한계는 맥락 창입니다. Orkas는 가득 찰 때까지 기다리지 않고 60% 기준선을 설정합니다. 도구 실행 한 차례가 끝날 때마다 현재 토큰이 창에서 차지하는 비율을 추정하고, 60%를 넘으면 선제적으로 압축을 시작합니다.
압축은 모델에 이전 대화를 요약하도록 요청한 뒤, 최근 마지막 부분만 유지하고 오래된 메시지를 그 요약으로 대체합니다. 간단하게 들리지만 함정이 있습니다. 교체 후 남겨 둔 마지막 부분이 "짝 없는 도구 결과"로 시작해서는 안 됩니다. "대응하는 호출이 없는 결과"가 있으면 짝 불변 조건을 다시 위반하기 때문입니다. 따라서 압축 로직은 반드시 온전한 경계에서 잘라 내도록 합니다.
여기에는 더 자세히 살펴볼 만한 선택이 있습니다. 왜 각 메시지에 점수를 매겨 중요도에 따라 줄이거나, 도구 출력에서 구조화된 정보를 추출하거나, 계층형 메모리 트리를 유지하는 더 세밀한 방식 대신 "60%에서 전체 블록을 요약"하는 거친 방식을 택했을까요? 그런 방식은 논문에서는 훌륭해 보이지만, 우리는 세 가지 이유로 의도적으로 그 길을 택하지 않았습니다.
첫째, 캐싱입니다. 모델의 프롬프트 캐시는 접두부를 기준으로 적중합니다. 기록의 접두부가 바뀌지 않으면 해당 구간이 캐시에 적중하여 비용과 지연 시간을 모두 절약합니다. 세밀한 압축은 기록 중간을 계속 다시 쓰므로 캐시된 접두부가 반복해서 깨집니다. 수정할 때마다 대규모 재프리필이 필요해집니다. "그대로 두다가 기준선에서 한 번 압축"하는 전략은 대부분의 턴에서 접두부를 안정적으로 유지하고, 단 한 번의 압축에서만 무효화합니다. 캐시에 훨씬 유리합니다.
둘째, 복잡성입니다. 계속 강조하는 "모든 도구 호출은 짝을 이뤄야 한다"는 불변 조건은 기록을 세밀하게 줄일수록 어느 구석에서 깨질 가능성이 커집니다. 큰 단위의 요약은 온전한 절단 지점 하나만 지키면 되므로 잘못될 수 있는 지점이 약 십분의 일로 줄어듭니다. 경계 사례의 유형이 하나 줄면 운영 장애의 유형도 하나 줄어듭니다.
셋째, 모델 발전의 혜택 활용입니다. 지난 몇 년간 맥락 창은 꾸준히 커졌고 모델의 긴 맥락 처리 능력도 계속 좋아졌습니다. 지금 정교한 압축 알고리즘에 공을 들이는 것은 본질적으로 점점 작아지는 문제와 싸우는 일입니다. 튜닝을 마칠 즈음 차세대 모델이 창을 두 배로 늘려 버리면 그 복잡성은 순전히 부담이 될 수 있습니다. 반대로 요약을 모델 자체에 맡기면 모델이 좋아질수록 자동으로 개선됩니다. 중요한 내용을 더 잘 골라낼수록 요약 품질이 높아지며, 우리는 코드 한 줄도 바꾸지 않습니다. 모델이 대신 감당할 수 있는 복잡성을 직접 감당할 필요는 없습니다.
토큰 추정에는 놓치기 쉬운 문제가 하나 있습니다. 바로 중국어입니다. 영어에 대한 직관, 즉 몇 글자당 대략 토큰 하나라는 기준으로 중국어를 추정하면 크게 과소 계산하게 됩니다. Orkas는 추정 시 CJK 문자에 별도의 가중치를 부여합니다. 그렇지 않으면 중국어로만 이루어진 대화에서 기준선이 잘못 계산되어 필요한 시점에 압축이 시작되지 않습니다.
오류와 재시도
사용자의 컴퓨터에서 실행되고 외부 모델 API에 의존하는 환경에서 오류는 예외가 아니라 일상입니다. 러너는 오류를 몇 가지 유형으로 나누고 각각 다르게 처리합니다.
- 재시도 가능: 호출 한도, 시간 초과, 연결 끊김, 5xx. 무작위 지연을 포함한 지수 백오프를 최대 30초까지 적용합니다. 호출 한도 오류이고 서버가
retry-after를 보냈다면 그 값을 따릅니다. - 재시도 불가: 인증 실패 같은 경우입니다. 아무리 재시도해도 소용없으므로 즉시 오류로 종료합니다.
- 특수: 맥락 초과입니다. 먼저 압축을 시도하고 한 번 재시도한 뒤, 그래도 실패할 때만 오류로 종료합니다.
한 가지 유형이 더 있습니다. "도구 자체가 실패한 경우"입니다. 이는 턴 전체를 실패시키지 않습니다. 도구 실패 자체가 모델에게 정보가 되므로, 모델은 "그 명령에서 오류가 발생했다"는 것을 보고 충분히 다른 접근 방식을 시도할 수 있습니다. 하네스는 이러한 일시적 도구 오류와 실제 장애를 구분합니다. 흐름을 중단하지도, 오류를 잃어버리지도 않으며 사후 통계에 표시합니다. 이 데이터는 나중에 다음 글에서 다룰 자기 발전 메커니즘에 활용됩니다.
외부 취소 신호(AbortSignal)는 모든 주요 지점에서 확인합니다. 사용자가 "중지"를 누르면 현재 턴이 즉시 멈추며 새 재시도는 시작되지 않습니다.
도구 추상화: 확장하기에 충분히 단순하게
도구 인터페이스는 의도적으로 얇게 설계했습니다.
interface AgentTool {
readonly name: string;
readonly description: string; // shown to the model
readonly inputSchema: Record<string, unknown>; // JSON Schema to constrain inputs
execute(input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult>;
}도구는 그저 "이름 + 모델용 설명 + 입력 스키마 + 실행 함수"입니다. 파일 읽기, 파일 쓰기, 셸 명령 실행, 웹 검색과 가져오기 같은 기본 도구는 모두 이 인터페이스를 구현합니다. 데스크톱 계층은 그 위에 지식 베이스 검색, 이미지 생성, 외부 커넥터 호출 같은 로컬 환경에 맞춘 도구들을 추가하지만 인터페이스는 같습니다.
얇은 인터페이스의 이점은 러너가 도구의 출처를 신경 쓸 필요가 없다는 것입니다. 기본 제공 도구든, 사용자 정의 도구든, 스킬에서 불러온 도구든 모두 같은 종류이며 하나의 Map<string, AgentTool> 에 등록되고 매 턴마다 모델이 읽을 수 있는 정의로 변환됩니다.
셸 명령처럼 부수 효과가 있는 도구는 격리된 실행기를 거칩니다. 시간 제한, 출력 길이 제한, 명령 차단 목록이 적용되며, 프로세스의 전역 환경을 수정하는 대신 환경 변수를 별도로 전달합니다. 전역 환경을 수정하면 수많은 자식 프로세스로 영향이 퍼지고, Electron 같은 다중 프로세스 구조에서는 시작 과정이 쉽게 망가질 수 있습니다.
Provider 계층: 여러 모델을 하나의 인터페이스로 통합
사용자의 모델 선호는 매우 다양하므로 제품이 특정 공급업체에 강하게 결합되어서는 안 됩니다. Orkas는 하네스 아래에 서로 다른 공급업체의 모델을 하나의 인터페이스로 통합하는 Provider 추상화를 둡니다.
interface LLMProvider {
readonly id: string;
complete(params: CompletionParams): Promise<CompletionResult>;
stream(params: CompletionParams): AsyncIterable<StreamEvent>;
validateAuth(): Promise<boolean>;
}상위 러너는 이 인터페이스와만 통신하며, 그 뒤에 어떤 공급업체가 있는지 전혀 모릅니다. 레지스트리가 모델 문자열을 기준으로 라우팅을 처리합니다. 명시적인 provider/model 형식은 직접 분리하고, 모델 이름만 있으면 접두부로 제공업체를 판별합니다. 인증(API 키 또는 OAuth 토큰)도 여기에서 관리하며 만료된 OAuth 토큰은 자동으로 갱신합니다.
여러 모델을 통합할 때 진짜 골칫거리는 텍스트 완성이 아니라 공급업체마다 의미 체계가 다른 구석들입니다. 실제로 문제가 되었던 예 두 가지를 들겠습니다.
하나는 공급업체가 달라도 사고 블록을 보존하는 일입니다. 추론 모델은 "사고" 내용을 출력합니다. 어떤 공급업체는 이를 암호화하고 그대로 되돌려 보내도록 요구하며, 다른 공급업체는 다른 필드 집합으로 표현합니다. 사용자가 대화 중에 공급업체 A에서 B로 바꾸면 기록에 있는 사고 구간의 서명이 더 이상 맞지 않습니다. 해결책은 기록의 모든 메시지에 "어떤 모델이 생성했는지"를 표시하여 변환 계층이 그대로 유지할지 결정하게 하는 것입니다. 같은 모델이면 유지하고 다른 모델이면 규칙에 따라 낮은 수준의 표현으로 변환합니다.
다른 하나는 프롬프트 캐시입니다. 한 세션의 여러 턴에서는 접두부가 많이 반복되므로 캐싱하면 비용과 지연 시간을 크게 절약할 수 있습니다. 구현에서는 이를 지원하는 공급업체에 세션 ID를 캐시 키로 전달하며, 공급업체별 키 길이 제한도 함께 처리합니다. 예를 들어 너무 길면 잘라 내거나 해시합니다.
이 모두는 번거로운 실무 작업입니다. 하지만 바로 이 계층의 작업 덕분에 상위 러너는 "모델은 한 종류뿐"이라고 가정할 수 있습니다.
메모리: 각자의 역할을 맡은 두 가지 메커니즘
Orkas의 "메모리"는 실제로 완전히 다른 두 문제를 해결하는 두 개의 병렬 메커니즘입니다. 하나는 "필요할 때 찾아보는" 대량 자료를 위한 검색 기반 지식 베이스입니다. 다른 하나는 "항상 기억해야 하는" 소수의 핵심 사실을 위한 세션 간 메모리입니다. 많은 제품이 둘을 뒤섞지만, 분리하면 훨씬 명확해집니다.
지식 베이스: 하이브리드 검색
첫 번째 메커니즘은 분량은 많지만 가끔만 관련되는 콘텐츠, 즉 사용자의 문서, 과거 메모, 도메인 지식을 대상으로 합니다. 벡터 검색을 사용하는 로컬 지식 베이스이며 두 가지 백엔드가 있습니다. 테스트와 일시적 사용을 위한 가벼운 순수 메모리 버전, 그리고 전체 텍스트 인덱스와 벡터를 갖춘 운영용 로컬 데이터베이스 영속화 버전입니다.
데이터는 다음 경로로 들어옵니다.
문서 → 줄 경계 기준으로 분할 (일부 겹침) → 이중 색인
├─ 전문 색인 (키워드, 임베딩 비용 없음)
└─ 벡터 색인 (임베딩 모델이 설정된 경우)의미 단위를 중간에서 자르지 않도록 청크는 줄 경계에서 나누고 서로 조금씩 겹치게 합니다. 검색은 하이브리드 방식입니다. 벡터 검색(의미적으로 가까운 항목)과 키워드 검색(문자 그대로 일치하는 항목)을 수행하고, 두 결과 집합을 RRF(상호 순위 융합)로 합칩니다.
score = Σ 1 / (k + rank_i)한 검색에서 결과의 순위가 높을수록 기여도가 커집니다. 두 검색의 값을 합하면 의미적 관련성을 반영하면서 정확한 문자열 일치도 놓치지 않습니다. 벡터와 키워드의 가중치는 조절할 수 있으며 기본적으로 의미를 더 중시합니다. 합친 뒤에는 "(문서, 시작 줄)"을 기준으로 중복을 제거하여 위치마다 가장 좋은 결과만 남기고, 임계값 미만을 제외한 뒤 상위 K개를 반환합니다.
왜 벡터에만 의존하지 않을까요? 벡터 검색은 고유 명사, 코드 심벌, 정확한 문자열에서 자주 실패하기 때문입니다. 의미적으로 특별하지 않지만 문자 자체가 매우 중요한 검색어들입니다. 반면 키워드만으로는 "뜻은 같지만 표현이 다른" 경우를 잡을 수 없습니다. 둘을 함께 사용하는 것은 검색 품질과 비용 사이의 매우 실용적인 절충입니다.
세션 간 메모리: 사용자를 기억하기
지식 베이스는 "모두 담기에는 자료가 너무 많다"는 문제를 해결합니다. 하지만 분량은 아주 적어도 항상 기억해야 하는 다른 종류의 정보가 있습니다. 사용자가 누구인지, 무엇을 선호하는지, 지난번에 무엇을 합의했는지입니다. 이런 내용은 검색으로 "운 좋게 기억해 내는 것"에 의존해서는 안 되며 매 턴마다 있어야 합니다.
이를 위해 Orkas는 별도의 세션 간 메모리 계층을 만들고 내용을 두 부분으로 나눕니다.
- 사용자 프로필: 사람 에 관한 안정적인 사실 — 역할, 선호, 소통 방식, 기술 스택.
- 사실 메모: 업무 에 관한 지속적인 사실 — 결정, 주요 이정표, 프로젝트 관례.
둘 다 작고 각각 수천 자의 엄격한 상한이 있어 장기적으로 유용한 내용만 남기도록 합니다. 검색을 거치지 않고 매 턴 시작 시 시스템 프롬프트에 직접 고정하여 넣습니다. 따라서 에이전트는 찾아봐야 한다는 사실을 따로 기억하지 않아도 이 내용을 그저 "알고" 있습니다. 이는 지식 베이스와 정반대입니다. 지식 베이스는 "필요할 때만 가져오고 이후에는 사라지는 것"이고, 세션 간 메모리는 "항상 존재하고 항상 보이는 것"입니다.
쓰기는 전용 메모리 도구를 통해 이루어집니다. 모델이 대화 중에 "장기적으로 기억할 가치가 있다"고 판단하면 이 도구를 호출하며, 추가, 부분 문자열 교체, 삭제를 지원합니다. 무엇을 저장하고 무엇을 저장하지 않을지는 도구 설명에 명확히 적혀 있습니다. 사용자의 정정과 선호가 최우선이고, 지속적인 결정과 관례는 저장합니다. 반면 현재 작업의 일시적 상태, 일회성 디버깅 정보, 쉽게 다시 찾을 수 있는 내용은 저장하지 않습니다. 메모리는 "사용자와 프로젝트에 관한 지속적인 사실"을 위한 것이며 "이번에 어디까지 했는지"를 위한 것이 아닙니다.
놓치기 쉽지만 상당히 중요한 세부 사항이 있습니다. 매번 쓰기 전에 보안 검사를 실행합니다. 이 내용은 시스템 프롬프트에 그대로 들어가고 여러 세션에 걸쳐 오래 유지되므로, 사실상 지속적인 인젝션 경로가 됩니다. 따라서 디스크에 기록할 모든 메모리는 먼저 의심스러운 패턴을 검사합니다. 전형적인 프롬프트 인젝션 문구("이전의 모든 지시를 무시하라" 등), 키를 유출하려는 명령, 텍스트에 숨겨진 보이지 않는 유니코드 문자 등이 해당하며, 일치하면 즉시 거부합니다. 여기에 중복 제거와 한도 초과 내용 정리를 더해 메모리 계층이 부담이 되지 않으면서 유용하게 유지되도록 합니다.
두 메커니즘은 함께 "방대하지만 가끔 필요한 것"과 "작지만 항상 필요한 것"이라는 양 끝을 담당합니다. 전자는 지식 베이스가, 후자는 세션 간 메모리가 처리합니다. 여기에 에이전트의 자기 자신 에 대한 이해(다음 글의 주제)를 더하면, Orkas 에이전트는 자료, 사용자, 자기 자신에 관한 세 가지 메모리를 동시에 지닌 채 작업에 들어갑니다.
세션: 비정상 종료와 복구를 전제로 설계
세션은 메시지 기록을 관리합니다. 기본 버전은 기록 잘라내기와 압축을 지원하는 메모리 내 메시지 배열입니다. 하지만 사용자의 컴퓨터에서 실행되는 것은 언제든 종료될 수 있다고 가정해야 합니다. 사용자가 앱을 닫거나, 시스템이 재부팅되거나, 감시 타이머의 시간 초과로 프로세스가 종료될 수 있습니다. 따라서 운영 환경에서는 메시지 하나를 한 줄로 저장하는 로컬 JSONL 파일 기반의 영속 세션을 사용합니다.
쓰기 전략은 두 가지입니다. 새 메시지는 원자적으로 추가하고, 전체 파일을 다시 쓰는 작업(압축, 초기화)은 "임시 파일 쓰기 + 원자적 이름 변경"을 사용합니다. 이렇게 하면 쓰기 중 전원이 끊겨도 반쯤 손상된 레코드가 남지 않습니다.
가장 흥미로운 부분은 짝 없는 도구 호출 복구입니다. 앞서 말한 짝 불변 조건으로 돌아가 보겠습니다. 모델이 도구를 호출하고, 하네스가 실행하며, 결과를 다시 기록합니다. 이 세 단계 중 어디서든 중단되면 디스크에 "결과 없는 호출"이라는 고아 항목이 남습니다. 다음에 그 세션을 불러와 그대로 모델에 보내면 API가 거부하거나 멈춥니다.
복구 로직은 디스크에서 세션을 불러올 때마다 실행되며 멱등성을 갖습니다.
- 모든 어시스턴트 메시지를 스캔하고 생성된 도구 호출 ID를 수집합니다.
- 이후 기록에서 대응하는 도구 결과를 찾습니다.
- 대응하는 결과가 없는 호출마다 "중단됨"으로 표시된 결과를 합성합니다.
- 이 과정에서 결과 순서를 호출 선언 순서와 맞추고, 대응하는 호출이 없는 고아 결과는 제거합니다.
이 처리가 끝나면 세션은 API의 짝 요구 사항을 충족하고 안전하게 전송할 수 있는 상태임이 보장됩니다. 눈에 띄지 않는 메커니즘 같지만, "한 번의 비정상 종료 때문에 사용자의 대화가 영구적으로 멈추지 않도록" 지켜 주는 안전망입니다.
돌이켜 보니 중요했던 몇 가지 결정
이 모든 것을 연결하고 나면, 몇 가지 결정이 특히 가치 있게 보입니다.
제너레이터를 기본 인터페이스로 사용하기. 스트리밍과 비스트리밍이 하나의 구현을 공유하고, 중간 상태가 자연스럽게 드러나며, UI는 원하는 만큼 세부 정보를 표시할 수 있습니다. 덕분에 "비스트리밍을 먼저 구현하고 나중에 스트리밍을 덧붙이는" 방식에서 발생했을 불일치 버그의 한 유형을 통째로 피할 수 있었습니다.
가득 찼을 때가 아니라 60%에서 압축하기. 압축 자체에도 모델 호출이 필요하므로 그 여유를 남겨 두고 마지막 순간에 급히 대응하는 일을 피할 수 있습니다.
모든 과정에 짝 불변 조건 적용하기. 압축 절단 지점, 디스크 쓰기, 로드 시 복구에 이르기까지 세션을 다루는 모든 곳에서 같은 규칙을 지킵니다. 규칙이 하나이면 각 지점이 저마다 보정 로직을 만들 필요가 없습니다.
번거로운 실무 작업을 Provider 계층에 집중하기. 사고 블록, 캐시 키, 기능 차이 등 공급업체 사이의 까다로운 부분을 모두 이 한 계층에서 처리하여 상위 러너를 깔끔하게 유지합니다. 나중에 새 모델 공급업체를 추가해도 변경이 거의 밖으로 번지지 않습니다.
마무리
Orkas의 하네스에는 놀라운 알고리즘이 없습니다. 가치는 "실제 환경에서 에이전트를 안정적으로 실행하기"를 경계가 명확한 모듈 집합으로 나누고 각자 한 부분을 책임지게 하는 데 있습니다. 러너는 루프와 재시도, 도구는 기능, Provider 계층은 여러 모델의 통합, 메모리는 검색, 세션은 영속화와 복구를 맡습니다. 각각은 복잡하지 않지만 함께 있어야 사람들이 매일 사용하는 제품을 지탱할 수 있습니다.
기억할 내용이 있다면 다음과 같습니다. 실행 루프를 스트리밍 제너레이터로 만들면 중간 상태를 훨씬 쉽게 처리할 수 있습니다. "도구 호출은 짝을 이뤄야 한다" 같은 핵심 불변 조건을 정했다면 압축, 디스크 쓰기, 로딩 전반에서 일관되게 지키고 어느 구석도 예외로 두지 마세요. 공급업체 간 번거로운 실무 작업은 한 계층에 모아 비즈니스 로직 밖에 두세요. 그리고 무엇보다 프로세스가 최악의 순간에 종료될 것이라고 가정하고, 그 순간을 위한 복구 코드를 미리 작성하세요.
다음 글에서는 Orkas의 더 흥미로운 부분을 살펴봅니다. 에이전트가 자신의 사용 경험에서 배우고, 경험을 재사용 가능한 스킬로 정제하며, 서서히 더 유용해지는 방법입니다.