사내 AI 에이전트 개선기
AI Agent의 Context 비용을 줄인 3가지 설계 패턴
Ari Kim • AX
- AI
안녕하세요, 채널톡 Forward Deployed Engineer(FDE) 아리입니다.
저는 AX 팀의 업무를 도와주는 사내 AI 에이전트 채널랩스(channel-labs)를 개선하는 업무를 맡았어요.
AI 에이전트는 본질적으로 비결정론적이에요. 같은 질문에도 매번 다른 도구를 다른 순서로 부르고, 가끔은 안 해도 될 일을 합니다. 그런데 production 시스템은 결정론적이어야 해요 — 비용은 예측 가능해야 하고, 잘못된 호출은 차단되어야 하고, 토큰은 새지 말아야 하죠.
이러한 설계 관점을 반영하여 채널랩스 개선 작업을 진행했습니다.
채널랩스는 무엇인가요?
채널랩스를 설명하려면 먼저 두 가지를 짧게 짚어야 해요.
채널톡: 기업이 자사 웹사이트·앱에 붙여 고객 상담을 처리하는 메신저 솔루션이에요. 우하단의 채팅 버튼이 채널톡이에요.
ALF: 채널톡이 제공하는 AI 챗봇 서비스예요. 고객사가 등록한 지식(자주 묻는 질문에 대한 답변 자료), 규칙(상황별 응대 정책), 태스크(여러 단계에 걸친 시나리오 — 예: "환불 요청 → 주문번호 확인 → 환불 가능 여부 분기 → 안내") 를 기반으로 답변해요.
채널랩스는 채널톡 사내 AX 도구로, 위와 같은 ALF 설정과 채널 운영 작업을 자연어로 다룰 수 있게 만든 AI 에이전트입니다. 주로 AX, 세일즈 인원들과 외부 고객사들이 사용자입니다.
사용자가 채팅창에 "이 채널 설정 확인해줘", "이런 시나리오 만들어줘"라고 말하면, 에이전트가 채널톡 API를 호출해서 처리해줘요.
내부적으로는 위와 같이 OpenAI Agents SDK의 agent loop가 도는 백엔드 서버입니다.
채널랩스가 어떤 상황에서 쓰이나요?
가벼운 진단부터 무거운 자동화까지, 운영자가 평소에 던지는 요청은 대략 이런 모양이에요.
① 설정 진단
"고객사A의 ALF 지식 설정 확인해줘", "지금 작동 중인 규칙 몇 개야?"
가장 자주 쓰이는 패턴이에요. 운영자가 채널 ID만 던지면 에이전트가 채널톡 API로 현재 설정 상태(어떤 지식·규칙·태스크가 활성화되어 있고, 누락된 게 뭔지)를 긁어와서 정리해줘요. 평소엔 콘솔 화면을 여기저기 클릭하며 확인하던 작업이에요.
② 상담 정책 자료를 일괄적으로 import 하기
"이 엑셀에 정리해둔 상담 흐름, 고객사A의 채널에 그대로 등록해줘"
"[TASK1.json] 이 시나리오를 고객사A의 채널에 올려줘"
고객사는 주로 회사의 상담 정책을 엑셀이나 노션, JSON 정의 파일 같은 형태로 정리해둬요. "문의 유형 / 응대 분기 / 안내 멘트 / 상담사 연결 조건"이 수십 행에 걸쳐 적혀 있는 시트 같은 거예요. 채널랩스 이전에는 운영자가 이 파일을 보면서 채널톡 콘솔을 켜고 한 항목씩 옮겨 적어야 했어요. 시나리오 10개짜리 파일을 등록하면 30분~1시간 정도 걸리는 작업이었어요.
채널랩스에서는 이 파일을 채팅창에 그대로 첨부하기만 하면 돼요. 에이전트가 파일을 읽고, 구조를 분석하고, 채널톡 API로 한 번에 등록해요. 운영자는 "이거 등록해줘" 한 줄만 쓰면 됩니다.
③ 자연어로 챗봇 시나리오 만들기
"고객사A 의 채널에 배송조회 시나리오 넣고 싶어"
"헬스+수영+골프 운영하는 스포츠센터인데, 운영시간 외에는 상담사 연결 말고 안내만 가게 해줘"
"운영시간 정보를 외부 API로 연동하는 시나리오 만들어줘"
고객사는 "분기 노드", "메모리 변수" 같은 챗봇 내부 모델을 모르고, "어떨 때는 X, 어떨 때는 Y" 같은 자연어로 시나리오를 던집니다. 채널랩스는 이 자연어를 챗봇이 실행할 수 있는 단계별 그래프로 분해해요.
한 문장처럼 보이는 운영자 요구가 실제로는 분기 처리 노드, 정보 기억(메모리) 노드, 외부 API 호출 노드, 상담사 연결 액션 노드 등 노드 7~10개로 분해되는 작업이에요. 운영자가 직접 손으로 만들기 어려운 단위인데, 자연어 한 줄로 처리돼요.
이렇게 한 채널랩스 세션에서 운영자는 "채널 설정 진단 → 파일 첨부 → 자연어 시나리오 → 추가 수정"을 한 번에 이어 가요. 콘솔에서 30분~1시간 걸리던 세팅이 채팅 몇 줄로 끝나고, 운영자가 손에 들고 있는 raw한 실무 자산이 그대로 입력이 돼요. 즉 채널랩스는 단순한 설정 챗봇이 아니라 한 세션에서 수십 턴을 도는 작업 실행 에이전트예요. 본문에서 다룰 3가지 컨텍스트 설계 패턴은 모두 이 형태의 사용 패턴에서 출발했습니다.
에이전트 전환기
기존 버전(v1)은 Anthropic Message API의 단건 호출을 반복하는 구조였어요. 그런데 채널랩스가 다루는 작업이 점점 무거워지면서, v1 구조에서는 더 확장하기 어렵다는 판단이 섰습니다.
문제(v1)
v1을 다시 들여다보면 구조적으로 다음 문제들이 있었어요.
agent loop가 아니라 단발성 message API의 반복 호출이었어요. 토큰 잘라내기, 캐싱 마커 부착, 도구 호출 라우팅, 스트리밍, 토큰 사용량 집계까지 전부 수작업으로 들고 있었어요. 표준화된 세션·상태·에러 모델이 없었고, 코너 케이스(스트리밍 중 취소, 도구 호출 실패 재시도, 병렬 도구 호출)가 생길 때마다 따로 막아야 했어요.
agent loop란?
"사용자 메시지 → 모델 추론 → 도구 호출 → 도구 결과 → 다시 모델 추론 → …"이 도구 호출이 끝날 때까지 반복되는 구조. 에이전트의 기본 실행 단위예요
스킬 문서가 매 턴 system prompt에 통째로 들어갔어요. 스킬의 매뉴얼과 참고 자료를 매 턴 다시 주입했고, 토큰 누수의 원인이었어요. "필요할 때 읽는 스킬"이 아니라 "처음부터 전부 들고 있는 스킬"이었어요.
파일은 다음 턴에서 다시 읽을 수 없는 모델이었어요. 사용자가 PDF나 엑셀을 첨부하면, 본문을 첫 메시지 안에 inline으로 박아 넣었어요. "첫 턴에서 봤으니 기억해라" 식으로 동작했어요. 같은 파일을 나중에 다시 조회할 방법이 없어서, 모델이 까먹으면 그걸로 끝이었어요.
read_file같은 도구로 다시 펼쳐 보는 모델이 아니었어요.파일 저장 모델이 임시 저장소·세션 저장소로, 두 개의 업로드 엔드포인트로 이중화되어 있었어요.
모델이 호출하는 도구 안에 인증 토큰 갱신 함수가 있었어요. 사용자의 채널톡 인증 토큰(JWT)이 만료되면, 사용자가 채팅창에 새 토큰을 직접 붙여 넣어야 했고, 모델이 그걸 보고 도구를 호출해서 갱신하는 흐름이었어요. 토큰을 채팅 메시지에 노출시키는 방식이라 보안 측면에서도, 사용성 측면에서도 자연스럽지 않은 구조였어요.
한 사용자가 여러 채팅을 동시에 진행할 수 없는 구조였어요.
그래서 v2는 새 엔드포인트로 분리하고, agentic loop 를 더 잘 제어하고 구현하기 위해 OpenAI Agents SDK 위에서 다시 설계했어요.
새로운 에이전트 버전(v2)
v2에서 가장 먼저 잡은 건 HTTP 처리와 실제 작업 로직을 분리하는 것이었어요. v2 route는 동시 실행 잠금과 SSE 스트림 여는 일까지만 하고, 그 이후의 그래프·세션·도구·실행 책임은 runtime 레이어로 통째 넘겨요.
그 위에서 OpenAI Agents SDK의 실행 모델에 맞춰 다음 구조를 정리했어요.
세션 저장 방식을 SDK 인터페이스에 맞췄습니다. 메시지 저장, 불러오기 같은 작업을 agent loop 바깥의 세션 구현으로 분리했습니다. v1에서는 이런 처리가 메인 실행 로직 사이사이에 흩어져 있었는데, v2에서는 runtime이 세션 인터페이스를 통해 일관되게 다루게 했습니다.
도구 실행 컨텍스트를 요청마다 분리했습니다. 한 채팅 요청에 필요한 세션 ID, 사용자 ID, 실행 환경, 취소 신호, 인증 토큰 공급자를 하나의 runtime context로 묶었습니다. 도구는 전역 상태나 사용자 메시지에 섞인 값에 의존하지 않고, 이 요청 단위 컨텍스트를 기준으로 실행됩니다. 덕분에 같은 사용자가 여러 채팅을 동시에 실행해도 도구 실행 상태가 섞이지 않습니다.
위험한 API 호출은 runtime에서 막았습니다. 모델이 채널 API를 호출하려고 할 때, 실행 직전에 허용된 HTTP method와 path 조합인지 검사합니다. 허용 목록 밖의 호출은 모델이 요청하더라도 실행되지 않습니다. 프롬프트에 “조심해”라고 부탁하는 것이 아니라, 코드가 실행 경계를 강제하는 방식입니다.
여기까지 정리하니 v1에서 겪던 실행 구조의 문제는 많이 줄었습니다. route는 얇아졌고, 세션과 도구 실행은 runtime으로 모였고, 위험한 호출은 guardrail을 통과해야만 실행됐습니다.
v1 자체 Agent Loop와 v2 OpenAI Agents SDK 기반 구조 비교
v1에서는 Message API 를 사용해서 자체 Agent Loop 를 구성했던 거예요.
영역 | v1 자체 loop | v2 OpenAI Agents SDK 기반 구조 |
|---|---|---|
Agent loop |
| SDK의 Runner가 모델 호출, tool call, tool result 반영, 다음 턴 진행 |
Streaming | 모델 API stream 직접 파싱 | SDK event를 SSE로 변환 |
Session | 메시지 히스토리 직접 관리 | SDK |
Runtime context | 사용자/환경/토큰 정보가 흐름 곳곳에 흩어짐 | 요청 단위 context로 묶어 도구에 전달 |
Guardrail | prompt 지시에 의존 | 애플리케이션 runtime의 policy allowlist + validator로 실행 전 차단 |
Context budget | 파일/채널/스킬이 model input에 쉽게 누적 | reference-first + bounded tool response로 제한 |
하지만 이 전환만으로 완전히 문제가 사라지지는 않았습니다. SDK가 agent loop를 정리해주더라도, 결국 매 턴 모델에게 무엇을 보여줄지는 애플리케이션이 결정해야 했기 때문입니다.
에이전트는 비결정론적이지만, 일반적인 설계 원칙으로 추상화될 수 있다고 생각해요. 만들면서 제가 고민했던 설계 패턴에 대하여 작성하려고 합니다. 작고 소중한 인사이트를 얻어가시기를 바라요!
문제 발견 — tool output이 새 누수 지점
v1(자체 agentic loop)에서는 누수 위치가 명확했습니다. 매 턴 system prompt를 재구성하면서 활성 스킬의 md 파일 전체를 다시 주입했어요. 스킬 문서 총 168KB(~42,000 토큰)가 5턴만 돌아도 누적 16만 토큰을 차지했습니다.
v2로 전환하면서 이 문제는 풀렸어요. v2에서는 동적 정보를 system prompt 밖으로 빼면서 공통 prefix를 더 안정적으로 유지할 수 있었고, 그 결과 OpenAI의 prompt caching이 적용되기 쉬운 구조가 됐습니다.
반복되는 prefix에 대해서는 cached token으로 처리될 수 있어, 같은 내용을 매번 새로 처리하는 비용을 줄일 수 있었습니다.
그런데도 한 세션의 누적 토큰은 줄지 않았는데, 그 이유는 Tool output 을 제어하지 않았기 때문이에요.
누수 지점 | v1 (자체 loop) | v2 (Agents SDK) |
|---|---|---|
System prompt | 동적 값이 매 턴 섞여 prefix가 자주 달라짐 | 동적 값을 prompt 밖으로 빼 stable prefix를 유지 |
Tool output | 영향 적음 | 히스토리에 매 턴 누적 |
파일 첨부 | text_content를 DB에서 매번 로드 | input_file 첨부로 매 요청 포함 |
모델이 출력하는 함수 호출은 짧지만, 들어가는 context는 매 턴 누적된 tool output으로 계속 커져요. tool output은 history에 누적되기 때문에, 그대로 두면 다음 model call의 입력이 계속 커집니다. 입력이 커질수록 비용과 latency가 증가하고, 모델이 실제로 봐야 할 정보의 밀도도 낮아집니다.
저는 3가지 설계 패턴으로 문제를 해결하려고 했어요.
파일 첨부 구조: 동적으로 데이터 로딩
사용자가 채팅에 파일을 첨부하면, 채널랩스는 어떻게 처리했을까요?
파일 첨부 구조를 개선할 때 핵심은 “파일 업로드”와 “모델이 파일 본문을 읽는다”는 동작을 분리하는 것이었습니다. 우리가 만든 구조는 저장 계층과 모델 입력 계층, 두 레이어에서 lazy loading을 적용한 형태입니다.
AS-IS: 파일 본문을 DB와 모델 컨텍스트에 함께 묶어두는 구조
사용자가 파일 업로드 → S3에 저장 + DB
session_files.text_content에 본문 추출본 저장매 요청마다 DB에서
text_content를 읽어 system message에 첨부모델은 항상 파일 전체를 본다
이 구조는 구현이 단순하다는 장점이 있었습니다. 파일 본문을 미리 추출해두면, 별도의 검색이나 파일 읽기 도구 없이도 모델이 항상 첨부 파일 내용을 참고할 수 있기 때문입니다.
하지만 production 환경에서는 두 가지 문제가 생겼습니다.
첫째, DB에 큰 텍스트 본문이 누적된다. 사용자가 100KB 파일을 올리면 DB row가 100KB 무거워진다.
둘째, 매 요청에 파일 전체가 context로 들어간다. 모델이 그 파일을 보지 않아도 된다 — 사용자 질문이 "안녕"이어도 100KB가 따라간다.
1차 개선: DB에서 파일 본문 분리
text_content컬럼을 제거하고 S3 키만 DB에 저장요청 시 S3 presigned URL을 만들어
input_file로 첨부OpenAI SDK가 URL을 fetch해서 모델 context에 넣는다
이 방식은 DB는 가볍게 만들었지만, 모델 입력 관점에서는 여전히 파일 전체를 매 요청에 첨부하는 구조였습니다. 사용자가 안 봐도 되는 파일까지 LLM이 매번 전체를 본다. 같은 파일을 매 호출마다 재다운로드해야 했어요.
TO-BE: 두 레이어의 lazy loading
lazy loading 으로 파일 본문을 읽어오도록 했습니다.
본문은 S3에만 있어요. 서버는 필요할 때만 S3에서 다운받고, 자주 호출되는 파일은 메모리 cache 에 둡니다.
DB에는 메타데이터(파일명, 크기, S3 키)만 있어요.
LLM은 파일 전체가 아니라 호출된 tool의 output만 봅니다. LLM 컨텍스트에는 파일 이름과 ID만 들어가고, 본문은 모델이 명시적으로 tool call 할 때에만 컨텍스트에 들어갑니다.
미리 넣지 않고, 필요할 때 로드한다는 설계 원칙이에요.
read_file 도구는 bounded text를 반환한다
여기서 한 발 더 나갔어요. Anthropic의 just-in-time retrieval 패턴은 "필요할 때 로드"까지인데, 저는 "로드할 때도 범위 제한"을 추가했어요.
read_file 에는 세 가지 파라미터가 있어요.
파라미터를 지정해준 이유는 모델이 큰 값을 요청해도 서버가 잘라낼 수 있기 때문이에요.
큰 파일에 대해서는 search_file 도구도 추가했습니다. 키워드와 그 주변 줄만 반환하는 grep 같은 도구예요. 예를 들어, 100KB 로그 파일에서 error라는 단어가 있는 줄만 보고 싶을 때, 파일 전체를 읽지 않아도 돼요.
여기에서 잠깐
"여기서 이런 의문이 들 수 있어요. '그냥 매번 input_file로 첨부하는 게 단순하고 좋지 않나요? read_file 도구까지 만들고 cache 붙이고... 오버엔지니어링 아닌가요?'
한 번의 호출이라면 input_file이 가장 단순해요. 사용자가 PDF를 올리고 “요약해줘”라고 하면, 모델이 그 파일을 한 번 보고 답하면 돼요.
하지만 채널랩스는 한 세션에서 수십 턴을 도는 작업 실행 에이전트예요. 사용자가 파일을 첨부한 뒤에도 매 턴 그 파일을 봐야 하는 것은 아닙니다. “안녕”이라고 답하거나, 전혀 다른 작업을 요청하는 턴에도 파일 전체가 context에 들어가면 비용은 계속 누적됩니다.
그래서 파일을 모델에게 직접 첨부하지 않고, reference로만 전달했어요. LLM context에는 파일명과 ID만 넣고, 본문은 모델이 필요하다고 판단할 때 read_file이나 search_file로 제한된 범위만 읽게 했어요.
Anthropic의 coding agent flow도 같은 방향을 보여줍니다. 에이전트는 파일 본문을 항상 들고 있는 것이 아니라, environment에 필요한 정보를 질의하고 결과를 받아 다음 행동을 정해요. 우리 구조도 이 관점에 가까워요. 파일은 context 안의 덩어리가 아니라, 필요할 때 도구로 조회하는 외부 데이터예요.
정리하면, 채널랩스에는 “파일 전체를 매 턴 보여주는 방식”보다 “파일은 reference로 두고 필요한 범위만 읽는 방식”이 더 적합했습니다.
덧붙이면 우리에게 input_file 첨부는 안티패턴이었어요. OpenAI가 제공한 모양 그대로 받아 썼고, 우리 에이전트의 동작 패턴(수십 턴 + 같은 파일에 가끔만 접근)에 맞춰 재설계하지 않았던 것입니다.
채널 컨텍스트: 전체 목록 대신 작업 대상만 전달하기
문제
채널랩스 사용자는 자기가 권한을 가진 채널톡 채널들을 다 다룰 수 있어요. 어떤 사용자는 5개, 어떤 사용자는 50개의 채널을 가져요. 에이전트가 작업할 때 "어느 채널에 작업할지"를 알아야 합니다.
처음 구조는 단순했습니다. 매 요청마다 사용자가 접근 가능한 채널 목록을 가져오고, 그 결과를 모델 입력에 넣었습니다.
채널 10개만 있어도 턴당 약 2,000 토큰이 추가됐어요.. 10턴 세션이면 채널 목록에만 약 20,000 토큰이 누적돼요. 더 큰 문제는 대부분의 세션이 실제로는 한 채널만 다룬다는 점이었습니다. 매 턴 전체 채널 목록을 보여주는 것은 비용적으로도, 추론 관점에서도 낭비였습니다.
아래는 위 문제에 대한 해결책들이에요.
선택된 채널은 먼저 전달하고, 전체 목록은 필요할 때만 조회한다
채널 컨텍스트는 정적 정보와 동적 정보로 나눴어요.
대부분의 작업에 필요한 것은 “현재 선택된 채널”이에요. 이 경우에는 선택된 채널의 ID와 이름만 작게 전달합니다.
반대로 “다른 채널로 복사해줘”, “내가 가진 채널 중에서 찾아줘”처럼 여러 채널을 탐색해야 하는 작업의 경우, 채널 조회 도구를 호출해 필요한 목록을 가져옵니다.
이 구조는 Anthropic이 말하는 hybrid strategy와 가까워요. 항상 필요한 최소 정보는 먼저 넣고, 나머지는 에이전트가 필요할 때 도구로 탐색하게 해요. 채널랩스에서는 선택된 채널을 먼저 전달하고, 전체 채널 목록은 필요할 때만 조회하도록 바꿨습니다.
이 변경은 prompt caching에도 유리해요. 사용자마다 다른 채널 목록을 매번 모델 입력 앞쪽에 넣으면 prompt prefix가 사용자/세션마다 달라집니다. 반대로 동적 채널 목록을 도구 조회로 빼면, 공통 시스템 지시문은 더 안정적으로 유지됩니다.
채널 이름→ID 매칭을 입력 시점에 끝낸다
채널 목록을 도구로 옮겨도 비용이 완전히 사라지지는 않았어요. 사용자가 “데모채널에 옮겨줘”라고 자연어로 쓰면, 모델은 여전히 채널 이름을 ID로 매칭해야 했어요. 이를 위해 다시 전체 채널 목록을 조회하면, 이전과 비슷한 비용이 tool output으로 이동할 뿐이었어요.
그래서 다음 단계에서는 모호함을 입력 단에서 제거했습니다. 사용자가 @를 입력하면 프론트엔드의 멘션 픽커가 채널 목록을 보여주고, 사용자가 선택하는 순간 channelId가 결정됩니다. 서버는 이 값을 모델에게 “이미 확정된 작업 대상 채널”로 전달해요.
이렇게 바꾸면 자연어 채널명에서 ID를 찾는 추론 단계가 사라집니다. 사용자가 입력 시점에 이미 알고 있던 정보를 모델이 다시 풀 필요가 없어진 거예요.
검색 결과와 쓰기 대상을 분리한다
또 하나의 설계가 있어요. 검색으로 찾은 채널은 “후보”일 뿐, 바로 쓰기 대상이 되어서는 안 돼요.
예를 들어 search_channels("demo") 결과에 어떤 채널이 포함되어 있다고 해서, 그 채널에 곧바로 설정을 복사하면 위험합니다. 실제 사용 중인 고객사의 서비스에 영향을 줄 수 있기 때문이에요. 그리고 앞서 말했듯, 검색 결과는 tool output이고, tool output은 외부 데이터이기 때문이에요.
OpenAI Model Spec의 chain of command 관점에서도 tool output과 file attachment는 기본적으로 신뢰할 수 없는 데이터이며, 그 안의 문장은 instruction이 아니라 information으로 취급해야 한다고 해요.
그래서 채널 컨텍스트에는 출처를 함께 남겼습니다. UI 에서사용자가 명시적으로 선택한 채널과, 검색 도구로 찾은 후보 채널을 구분해요. 쓰기 작업은 명시적으로 선택된 채널에만 허용하고, 검색 결과는 사용자의 확인이나 추가 선택이 필요한 후보로 취급해요.
모델이 찾은 객체와 사용자가 의도한 객체는 같지 않을 수 있으니, Production agent에서는 이 둘을 같은 것으로 취급하지 않는 경계가 필요해요.
도구 응답은 모델용 인터페이스로 줄인다
마지막으로 도구 응답 자체도 줄였습니다. 채널톡 API의 채널 객체는 20~30개 이상의 필드를 가져요. 생성일, 국가, 전화번호, 태그, 과금 정보, 내부 플래그 같은 값은 운영 화면에서는 유용하지만, 모델이 다음 행동을 결정하는 데는 대부분 필요하지 않아요.
LLM에게 API 응답을 그대로 전달하면, 모델은 내부 필드까지 의사결정의 근거로 사용해요. 그래서 채널 도구는 필요한 필드만 남겼습니다.
즉, API response schema와 tool response schema를 분리했어요. API 응답은 제품의 데이터 모델이고, tool 응답은 모델의 행동 인터페이스예요. 둘을 1:1로 매핑하면 내부 필드, deprecated flag, billing metadata 같은 noise가 모델의 의사결정 공간에 들어옵니다.
채널 도구의 목표는 유연한 API wrapper가 아니라, 모델이 다음 행동을 안전하게 결정할 수 있는 작은 인터페이스를 제공하는 거예요.
Skill: 스킬 문서는 prompt가 아니라 선택된 tool로 읽게 하기
문제
채널랩스에는 약 30개의 스킬이 있어요. 각 스킬은 사용 방법을 설명하는 문서와 참고 파일들로 구성되어 있어요. 스킬이 늘어날수록 LLM에게 이 문서들을 어떻게 보여줄지가 고민이 됐습니다.
처음에는 system prompt에 스킬 이름과 한 줄 설명만 넣고, 모델이 필요할 때 스킬 문서를 직접 읽게 했습니다. 겉으로 보기에는 합리적인 구조였어요. 모든 문서를 처음부터 prompt에 넣지 않고, 필요한 문서만 나중에 읽게 하는 방식이었기 때문입니다.
문제는 한 번의 문서 읽기 호출이 너무 컸다는 점이었습니다. 스킬 설명서 하나가 평균 6,000~12,000자 정도였고, 도구 응답은 다음 model call의 context로 그대로 들어갑니다. 모델이 여러 스킬을 비교하려고 3~4개 문서를 연달아 읽으면, 단일 턴에서 수만 토큰이 context에 누적될 수 있었습니다.
또 다른 문제는 탐색이었습니다. 모델은 어떤 스킬이 있는지 확인하기 위해 디렉토리를 훑거나, 여러 문서를 한 번에 읽으려는 시도를 했습니다. 스킬 문서 자체뿐 아니라 “어떤 문서가 있는지 찾는 과정”도 새로운 토큰 누수 지점이 된 거예요.
해결
해결 방향은 세 가지였습니다.
모델이 디렉토리를 직접 탐색하지 않게 했습니다. 런타임이 사용 가능한 스킬 목록을 짧은 카탈로그 형태로 제공하고, 모델은 그 목록을 보고 필요한 스킬을 선택하게 했어요. 즉, 스킬 탐색을 shell 명령에 맡기지 않고, 구조화된 메타데이터로 처리한 것입니다.
스킬 문서를 읽더라도 응답 크기는 서버에서 제한했습니다. 모델이 더 큰 출력을 요청하더라도 서버 hard cap을 넘을 수 없게 했습니다. 도구 응답은 모델의 요청값이 아니라, 런타임의 상한을 기준으로 잘립니다.
한 번의 호출에서 여러 문서를 이어 읽는 흐름을 막았습니다. 여러 명령을 연결해 한 번에 많은 문서를 읽으면 context가 예측 불가능하게 커질 수 있기 때문입니다. 모델은 한 번에 하나의 읽기 작업만 수행할 수 있고, 결과도 정해진 크기 안에서만 반환됩니다.
이 구조의 핵심은 단순합니다.
스킬 문서는 LLM prompt의 일부가 아니라, 런타임 바깥에 있는 외부 지식으로 두는 것입니다. 모델에게는 작은 카탈로그만 보여주고, 실제 문서는 필요할 때 제한된 도구 응답으로만 전달합니다.
system prompt에 “필요한 섹션만 읽어라”라고 쓸 수는 있습니다. 하지만 모델이 항상 그 지시를 지킨다고 가정할 수는 없습니다. production agent에서는 가이드를 쓰는 것만으로는 부족합니다. 도구 응답의 크기, 탐색 범위, 실행 형태는 런타임에서 결정적으로 제한해야 합니다.
고민한 지점
채널랩스 에이전트는 compaction 를 도입하지 않았습니다. 왜 이런 결정을 했을까요?
사용자가 멀티 턴을 진행할 수록 컨텍스트 윈도우가 가득 차게 됩니다.
멀티 턴 에이전트는 시간이 지날수록 context window가 차게 됩니다. 사용자 메시지, 모델 응답, tool call, tool result가 모두 history에 남고, 다음 model call의 입력이 되기 때문입니다.
이 문제를 다루는 방법 중 하나는 compaction입니다. OpenAI Agents SDK에는 context가 일정 크기를 넘었을 때 Responses API의 compaction을 사용할 수 있는 context management 옵션이 있습니다.
Compaction보다 먼저 한 일: tool response를 작게 만들기
하지만 채널랩스에는 이 방식을 먼저 적용하지 않았습니다. 이유는 채널랩스가 일반 채팅봇이 아니라 작업 실행 에이전트에 가깝기 때문입니다.
작업 실행 에이전트는 이전 턴의 정확한 정보를 바탕으로 다음 작업을 이어갑니다. 예를 들어 5턴 전에 사용자가 지정한 channelId, 이전 tool call의 파라미터, 방금 읽은 파일의 특정 line range 같은 값들이 다음 도구 호출에 그대로 영향을 줍니다.
이런 정보는 단순히 “대화의 의미”만 남기면 충분하지 않습니다. “아까 그 채널에 적용해줘”라는 요청에서 중요한 것은 대화의 요지가 아니라 정확한 채널id입니다. “방금 읽은 파일의 그 부분을 기준으로 설정해줘”라는 요청에서는 파일 이름과 읽은 결과가 중요합니다.
Compaction은 본질적으로 의미 보존과 정밀도 손실 사이의 trade-off를 가집니다. 요약을 통해 전체 흐름은 보존할 수 있지만, 실행에 필요한 작은 값이 사라지거나 흐려질 수 있습니다. 채널랩스에서는 그런 작은 손실이 잘못된 도구 호출로 이어질 수 있다고 판단했습니다.
그래서 우선순위를 다르게 잡았습니다. 누적된 context를 나중에 압축하기보다, 각 tool response가 처음부터 작게 들어오게 만들었습니다.
대신에 tool response 를 처음부터 작게 만드는 쪽을 선택했어요.
파일 본문은
read_file/search_file로 필요한 범위만 읽는다.스킬 문서는 작은 카탈로그만 먼저 보여주고, 실제 문서 읽기는 서버 hard cap으로 제한한다.
채널 목록은 매 턴 넣지 않고, 선택된 채널만 전달하거나 필요할 때만 조회한다.
API 응답은 모델이 다음 행동을 결정하는 데 필요한 필드만 남긴다.
즉, compaction은 “커진 context를 사후에 줄이는 전략”이고, 저희가 먼저 선택한 방식은 “context가 처음부터 크게 들어오지 않게 하는 전략”입니다.
이 결정의 핵심은 compaction을 쓰지 말자는 것이 아닙니다. Production agent에서 compaction을 도입하기 전에 먼저 물어야 할 질문이 있습니다.
이 정보는 요약해도 되는가, 아니면 정확한 값으로 남아야 하는가?
채널랩스에는 정확한 값으로 남아야 하는 정보가 많았습니다. 그래서 사후 압축보다 사전 budget control을 먼저 선택했습니다.
배운 점
이번 작업을 하면서 배운 가장 큰 점은, agent 개발의 핵심이 모델을 잘 고르는 것만은 아니라는 점입니다. OpenAI Agents SDK가 agent loop, tool call, streaming, session 같은 실행 구조를 정리해주더라도, 결국 무엇을 모델에게 보여줄지는 애플리케이션이 결정해야 했습니다.
채널랩스처럼 실제 고객사 설정을 변경할 수 있는 작업 실행 에이전트에서는 “모델이 알아서 잘하겠지”라는 가정이 위험했습니다. prompt는 방향을 제시할 수 있지만, 비용과 안전성은 runtime이 강제해야 했습니다. 모델이 보는 정보의 경계, 도구 응답의 크기, API 호출의 허용 범위는 코드로 제한해야 했습니다.
결국 production agent의 핵심은 더 긴 context window를 쓰는 것이 아니라, 모델이 볼 필요가 없는 정보를 context boundary 밖에 두는 것이었습니다.
이번 작업을 통해 OpenAI Agents SDK 위에서 agent loop를 구성하는 것뿐 아니라, 파일·채널·스킬 같은 외부 context를 어떻게 작게, 안전하게, 필요한 순간에만 모델에게 전달할지 설계하는 경험을 얻었습니다.
