
인간에게는 요약, LLM에게는 전문 — OpenClaw의 툴 출력을 '표시'와 '문맥'으로 나눈 이유
요약
AI 에이전트가 툴을 실행할 때 인간을 위한 '요약'과 LLM을 위한 '전문'을 분리하여 처리하는 OpenClaw의 설계 방식을 설명합니다. 하나의 출력 스트림에 마커를 사용하여 사용자 표시용 데이터와 LLM 문맥용 데이터를 구분하는 '2층 출력' 구조를 제안합니다.
핵심 포인트
- 인간의 가독성과 LLM의 정보 유지라는 상충하는 요구사항 해결
- __PASSTHROUGH__ 마커를 활용한 2층 출력 프로토콜 구현
- 사용자에게는 요약을, LLM 문맥에는 전문을 전달하여 효율성 극대화
- 타입 기반 인터페이스를 지향하는 호환 레이어 설계 방식
채팅형 AI 에이전트에서 기사 취득이나 동영상 요약 툴을 실행하면, 툴 출력에는 실질적으로 두 명의 수신자가 있습니다.
하나는 지금 이 턴(turn)에서 채팅을 읽는 인간입니다. 12KB의 본문을 그대로 Telegram으로 보내면 읽기 어려우므로, 짧은 요약이 필요합니다.
다른 하나는 다음 턴에서 후속 질문(follow-up)에 답할 LLM입니다. "그 기사의 ○○에 대해 자세히 알려줘"라고 물었을 때, 짧은 요약만 남아 있다면 대답할 수 없습니다. 원래의 본문이나 그에 준하는 충분한 정보가 필요합니다.
긴 콘텐츠의 경우, 이 두 가지 요구사항은 서로 상충합니다.
- 인간을 위해서는 짧게 만들고 싶다
- LLM을 위해서는 정보를 놓치고 싶지 않다
적어도 툴 결과를 LLM에게 전달하고, 그 LLM이 생성한 답변만을 사용자에게 반환하는 구성에서는, 이 두 가지를 하나의 문면으로 동시에 충족해야 합니다. 그 결과, 채팅이 장문으로 채워지거나 다음 턴의 문맥(context)이 부족해지는 상황 중 하나가 되기 쉽습니다.
자택에서 사용하는 OpenClaw에서는 툴 출력을 '사용자 표시용'과 'LLM 문맥용'으로 나누었습니다. 이 기사에서는 그 설계에 이르기까지의 실패, 짧은 콘텐츠에서만 발생했던 버그, 실제 데이터를 통해 임계값(threshold)을 다시 결정한 경위, 그리고 현재도 남아 있는 빈틈을 정리합니다.
먼저 최종 형태부터
CLI는 표준 출력(standard output)으로 다음 형식의 결과를 내보냅니다.
{전문}
__PASSTHROUGH__
{요약}
...
tool_result_persist
후크(hook)에서 이 출력을 해석하여 용도별로 나눕니다.
CLI stdout
│
▼
...
현재 구현에서는 LLM 측의 툴 결과는 개념적으로 다음과 같은 형태입니다.
[이하는 사용자에게 직접 전달됨. 자발적으로 반복하지 말 것.
명시적인 후속 질문에는 전문에서 답할 것]
{전문}
...
사용자는 간결한 요약을 즉시 받고, 다음 턴에서 질문하면 LLM은 동일한 기사의 전문으로부터 답변할 수 있습니다. 요약을 직접 전달한 후 LLM이 같은 내용을 다시 말할 필요는 없으므로, 해당 턴의 통상적인 답변은 suppressReply로 중단합니다.
여기서 나누고 있는 것은 물리적인 두 개의 표준 출력이 아닙니다. 하나의 문자열 스트림에 간이 프로토콜을 얹어, 후크를 통해 '표시'와 '문맥'이라는 두 가지 책무로 분기시키고 있습니다. 이후 이를 '2층 출력'이라고 부릅니다.
본래 원했던 것은 문자열 마커가 아니라 타입(type)이었다
현재의 마커는 기존의 exec 출력과 후크의 제약 속에서 2층 구조를 표현하기 위한 호환 레이어(compatibility layer)입니다. 본래 원했던 인터페이스는 개념적으로 다음과 같은 것입니다.
type ToolResultEnvelope = {
modelContext: string;
userPresentation?: string;
...
}
modelContext는 LLM에게 남길 정보이고, userPresentation은 해당 턴에서 인간에게 보여줄 정보입니다. 중요한 것은 답변 억제를 단순한 불리언(boolean) 값이 아니라, 표시용 데이터의 전송에 성공했을 경우에만 억제하는 방침으로 가지는 것입니다.
이 타입을 기반 시스템이 네이티브하게 다룰 수 있다면, 본문 중의 고정 문자열을 제어 구문으로 해석할 필요가 없습니다.
참고로 덧붙이자면, 이 타입은 구현되어 있지 않으며, 당시 이것을 설계하고 만든 것도 아닙니다. 실제로 있었던 것은 마커를 이용한 구현뿐이었으며, 이 타입은 나중에 되돌아보며 "본래 원했던 것은 이것이었다"라고 정리한 것입니다. 그럼에도 불구하고 적어둘 가치가 있다고 생각합니다. __PASSTHROUGH__는 설계의 본체가 아니라, 이 타입을 하나의 표준 출력 위에서 모의(simulate)하기 위한 수단이었다고 읽는 것이 이식하기 더 쉽기 때문입니다.
전제: 어디에 개입하는가
토대는 OpenClaw라는 에이전트 게이트웨이입니다. Telegram에서 말을 걸면, 직접 만든 기사 취득, YouTube 요약, 검색, 메일 요약 등의 CLI를 exec 툴을 통해 호출합니다.
| 용어 | 역할 |
|---|---|
exec 툴 | LLM이 셸 명령(shell command)을 실행하여 직접 만든 CLI를 호출하는 입구 |
| extension (플러그인) | OpenClaw에 삽입하는 확장 메커니즘 |
tool_result_persist | 툴 결과가 대화 이력(conversation history)에 저장되기 직전에 개입할 수 있는 후크 |
suppressReply | 해당 턴의 LLM 텍스트 답변을 사용자에게 전달하지 않기 위한 반환값 |
중요한 것은, 툴 결과가 대화 이력(Conversation History)에 들어가기 직전에 다시 쓰여진다는 것과, LLM을 거치지 않고 사용자에게 직접 보낼 수 있는 경로가 있다는 점입니다. 같은 생각은 툴 결과를 가공할 수 있는 후크(Hook)와 직접 전달 수단을 가진 다른 에이전트 기반(Agent Framework)에서도 사용할 수 있습니다.
참고로, 여기서 사용하고 있는 suppressReply는 OpenClaw 본체에 원래 존재하던 기능이 아니라, 이 메커니즘을 위해 로컬 패치(Local Patch)로 추가한 것입니다.
하나의 경로만으로는 부족하다는 것을 깨닫기까지
제1단계: 전문을 그대로 인간에게 보내기
처음에 겪었던 문제는 LLM이 툴 출력을 마음대로 요약한다는 것이었습니다.
기사 본문을 그대로 출력되기를 원하는 상황에서도, LLM은 자신의 언어로 축약해서 반환합니다. 시스템 프롬프트(System Prompt)에 "그대로 출력해"라고 적어도 출력은 안정되지 않았습니다.
그래서 LLM을 전달 경로에서 제외하고, CLI의 출력을 Telegram으로 직접 보내는 exec-passthrough를 만들었습니다.
이로써 "마음대로 다시 쓰여지는 문제"는 해결되었습니다. 하지만 이번에는 12KB의 본문이 그대로 Telegram으로 전달됩니다. 충실하기는 하지만, 채팅으로 읽기에는 불편합니다.
제2단계: 요약만 보내기
다음으로, CLI 측에서 요약을 만들고 그것만을 Telegram으로 직송했습니다.
첫 화면 표시(Initial Display)는 개선되었지만, 다음 턴(Turn)에서 문제가 발생했습니다.
"이 기사의 ○○에 대해 자세히 알려줘"라고 물어도, LLM 측에 충분한 본문이 남아 있지 않습니다. 요약본밖에 손에 없다면 자세히 대답할 수 없으며, 실제로 새로운 검색을 시작하여 다른 기사를 가져오는 일도 있었습니다.
첫 화면의 모습만 고치고, 대화 상태(Conversation State)를 깎아먹고 있었던 것입니다.
제3단계: 표시와 문맥을 나누기
여기서 처음으로, 툴 결과에는 두 가지 용도가 있다고 정리했습니다.
- 현재 턴에서 인간에게 보여줄 표시(Display)
- 다음 턴에서 LLM이 사용할 대화 상태(Conversation State)
표시용으로는 요약을, 대화 상태에는 전문을 남깁니다. 나아가 표시용 요약은 LLM이 재생성하게 하지 않고, 코드로 직접 보냅니다. 이 세 가지를 동시에 만족하는 형태가 현재의 2층 출력(Two-layer Output)입니다.
본질은 "요약 기능"이 아닙니다. 표시와 상태를 동일한 텍스트로 취급하지 않는 것입니다.
가장 위험했던 버그: "짧으니까 예외"라며 한쪽을 비워버림
2층 출력을 도입한 후, 총 10개의 툴에 다음과 같은 분기(Branch)가 있었습니다.
if (fullText.length <= BRIEF_LIMIT) {
// 짧으므로 요약은 불필요. 전문을 표시용 섹션으로 넣음
console.log('__PASSTHROUGH__');
...
언뜻 보기에는 합리적입니다. 짧은 문장이라면 요약을 따로 만들 의미가 없습니다.
하지만 당시의 exec-passthrough는 LLM 측으로 실질적인 마커(Marker) 외의 remaining을 남기는 구현이었습니다. 이 분기에서는 마커 외 부분이 비게 되므로, 첫 번째 Telegram 표시는 성공하더라도 LLM의 이력에는 기사 본문이 남지 않습니다. 당시의 기록에는 이렇게 적혀 있습니다.
remaining (마커 외)이 비게 되어, LLM 컨텍스트(Context)에서 기사 본문이 소실됨.
증상은 "짧은 기사 직후에만 후속 질문(Follow-up)에 대답하지 못함"이었습니다. 첫 번째 턴은 정상적으로 보이기 때문에, 단일 턴의 표시 확인만으로는 찾아낼 수 없습니다.
여기서 혼동했던 것은 다음 두 가지입니다.
- 인간용과 LLM용으로 동일한 문구를 사용해도 된다
- 인간용 혹은 LLM용 중 한쪽의 전달처를 지워도 된다
전자는 성립하지만, 후자는 성립하지 않습니다.
짧은 콘텐츠를 다루는 안전한 방법은 두 가지가 있습니다.
A. 2층 출력을 사용한다
전문을 LLM 측에 남기고, 동일한 전문 또는 짧은 표시를 사용자 측에도 전달한다
B. 2층 출력을 사용하지 않는다
...
위험한 것은 2층 출력 구조에 들어간 상태에서 표시 측만 남기고, suppressReply까지 유효하게 만드는 것입니다.
현재의 플러그인은 마커 외의 전문뿐만 아니라, 마커 내부의 요약도 LLM 측 결과로 연결합니다. 따라서 현재의 파서(Parser)만을 전제로 한다면, 당시와 같은 형태의 문맥 소실은 일어나기 어렵습니다. 그럼에도 불구하고 생성 측의 규약(Contract)은 "전문은 마커 외, 요약은 마커 내"인 상태를 유지합니다. 받는 쪽의 보완에 의존하면, 플러그인의 변경만으로 다시 망가질 수 있기 때문입니다.
설계 원칙은 문서에 적어두는 것만으로는 지켜지지 않습니다. 분기 중 하나가 "짧으니까 괜찮겠지"라며 예외를 만들면, 원칙은 그곳에서 사라집니다.
"짧은 경우에는 나누지 않아도 된다"와 모순되지 않는가
모순되지 않습니다.
"2층 출력을 사용한다면 양쪽 층을 모두 성립시킨다"와 "요구가 같다면 2층 출력 자체를 사용하지 않는다"는 별개의 판단입니다.
짧은 트윗이라면 인간이 읽어야 할 내용과 LLM이 유지해야 할 내용은 거의 동일합니다. 이 경우에는 마커를 표시하지 않고 일반적인 에이전트 경로 (agent path)로 흘려보내도 문제없습니다. 반면, 기사나 영상은 짧더라도 구조화된 요약 (structured summary)이 더 읽기 편한 경우가 있기 때문에, 글자 수만으로 일률적으로 결정하면 부자연스러워집니다.
이 차이가 임계값 (threshold)을 툴별로 다르게 설정하는 이유입니다.
임계값은 실제 데이터로 결정한다
당초에는 짧은 콘텐츠에서 2층 출력을 사용하지 않는 임계값을 모든 툴에 대해 일률적으로 4000자로 설정했습니다. "대략 이 정도라면 그대로 읽을 수 있겠지"라는 감각적인 수치였습니다.
하지만 x-fetch에 저장되어 있던 989건을 조사해보니, 분포는 다음과 같았습니다.
- 평균 232자
- 중앙값 152자
- 최대 3466자
- 92.8%가 500자 이하
- 300자 부근에 명확한 분포의 경계선
- 2000자를 초과하는 9건은 모두 영어로 된 장문 분석 게시물
최대치가 3466자이므로, 임계값 4000에서는 989건 모두가 단문으로 취급됩니다. 이미 구현되어 있던 지연 요약 (lazy summarize)은 단 한 번도 실행되지 않았습니다.
여기서 중요한 것은 92.8%라는 숫자의 의미입니다. 이는 "4000자 이하의 비율"이 아니라 "500자 이하의 비율"입니다.
임계값을 500자로 낮추면, 짧은 게시물의 대부분은 기존 방식대로 일반 경로로 흘러갑니다. 반면, 장문 측의 약 7.2%만 요약을 생성하며, 전문을 LLM에 남겨둔 채 요약본을 Telegram으로 보낼 수 있게 됩니다. 특히 2000자를 초과하는 영어 분석 게시물은 바로 요약의 가치가 높은 영역이었습니다.
최종적인 방침은 다음과 같은 형태입니다.
x-fetch
500자 이하 → 마커 없음. LLM이 일반 응답
500자 초과 → 지연 요약 + 2층 출력
...
임계값은 "모든 툴 공통의 UI 상수"가 아니라, 소스의 분포와 이용자가 기대하는 표시 형식에 따라 결정되는 값이었습니다.
경계를 그으면, 경계에서 버그가 발생한다
2층 출력은 하나의 문자열에 간이 프로토콜 (protocol)을 추가하는 설계입니다. 도입 후에는 프로토콜 경계를 따라 일련의 결함들이 발생했습니다.
지시문의 금지 범위가 너무 넓었다
LLM 측에 "내용을 반복하지 마라"고 강하게 지시하면, 사용자가 명시적으로 "자세히"라고 요청할 때까지 답변을 억제했습니다. 게다가 문맥을 사용하지 않고 새로운 검색을 수행하는 경우도 있었습니다.
최종적인 지시는 금지 대상을 자발적인 재게시 (proactive repetition)로 한정했습니다.
Do not proactively repeat or summarize.
However, if the user explicitly asks, answer fully from the full text in your context.
무조건적인 금지는 억제하고 싶은 동작뿐만 아니라 정당한 후속 질문 (follow-up)까지 죽여버립니다. proactively와 explicitly로 범위를 나눈 것이 효과적이었습니다.
하류(downstream)의 마커를 절반만 제거했다
news-search가 하류 툴의 출력을 재사용할 때, 처음에는 마커 행만 삭제했습니다.
// 오류: 구분자(delimiter)만 지우고, 표시용 섹션의 내용은 남겨둠
.replace(/^__PASSTHROUGH__\n?/gm, '')
.replace(/^__END_PASSTHROUGH__\n?/gm, '')
그 결과, 하류 툴의 요약이 전문 측에 섞여 들어가 본문과 요약이 중복되었습니다.
다시 래핑 (re-wrap)한다면 표시용 섹션 전체를 제거해야 합니다.
// 다시 래핑하는 경우: 섹션 전체를 제거함
.replace(/__PASSTHROUGH__[\s\S]*?__END_PASSTHROUGH__\n?/g, '')
다른 선택지는 하류 툴이 이미 올바른 2층 출력을 만들고 있다면, 그 프로토콜을 그대로 투과(pass-through)시키는 것입니다. 위험한 것은 프로토콜을 절반만 해석하는 것이었습니다.
본문 자체에 마커가 나타났다
고정 문자열을 프로토콜로 사용하는 이상, 본문에 __PASSTHROUGH__가 포함되면 추출 범위가 깨집니다. 이 메커니즘 자체를 설명하는 문서를 가져오면 흔히 발생할 수 있는 일입니다.
현재는 본문 중의 동일한 문자열에 제로 너비 공백 (zero-width space)을 삽입하여, 표시 방식은 거의 바꾸지 않으면서 마커 일치(marker match)만 깨뜨리는 방식을 사용하고 있습니다.
const escaped = cleaned.replace(/__PASSTHROUGH__/g, '__PASSTHROUGH__')
.replace(/__END_PASSTHROUGH__/g, '__END_PASSTHROUGH__');
하지만, 변하지 않는 것은 겉모습뿐입니다. 바이트 열(byte sequence)은 변했기 때문에, 부작용은 표시 영역 너머까지 미칩니다.
- 코드 예시를 포함한 기사를 가져온 경우,
LLM이 인용한 코드를 독자가 복사하면, 보이지 않는 문자가 혼입되어 작동하지 않음 - 저장된 본문에 대한
grep
이나 완전 일치 검색이 실패함 - 동일한 기사를 다시 가져와도, 가져오기 전과 후의 내용이 일치하지 않음
즉, 이것은 "무해한 치환"이 아니라, 가져온 콘텐츠를 다시 쓰는 처리입니다. 충돌을 완화할 뿐, 프로토콜로서 보증을 제공하는 것도 아닙니다. 게다가 가져오는 기사나 게시물은 외부 입력입니다. 본문 중의 문자열이 제어 구문(control syntax)으로 해석되는 이상, 이는 우연한 충돌뿐만 아니라, 외부 콘텐츠가 표시 범위나 LLM 문맥을 바꿀 수 있는 인밴드 프로토콜 주입(in-band protocol injection)의 문제이기도 합니다.
이스케이프(escape)를 각 CLI에 맡기면, 하나의 경로에서 처리 하나를 잊는 것만으로도 재발합니다. 근본적으로는 고정 문자열이 아닌 구조화된 필드(structured field), 길이 지정 프레임(length-prefixed frame), 호출마다의 랜덤 구분자 등이 필요합니다.
표준 출력의 끝이 잘려, 마커까지 도달하지 못함
긴 결과를 stdout으로 쓴 직후에 Node.js의 process.exit(0)을 호출했기 때문에, 파이프(pipe)로의 쓰기가 완료되기 전에 프로세스가 종료되어 끝부분의 마커가 누락되는 일이 있었습니다.
파서(parser)의 결함처럼 보였으나, 원인은 출력 측의 플러시(flush)였습니다. writableLength와 drain을 확인한 후 종료하도록 하여 해결했습니다.
문자열 프로토콜은 파서만 올바르면 되는 것이 아닙니다. 생성, 버퍼링, 절단, 전송 중 어느 곳에서라도 일부가 유실되면 경계 그 자체가 사라집니다. 특히 종료 마커를 출력의 끝에 두는 방식에서는, 끝부분 절단이 그대로 프레임 손상(frame corruption)으로 이어집니다. 시작은 있는데 종료가 없는 경우를 어떻게 다룰지에 대해서도 명시적인 사양과 테스트가 필요합니다.
suppressReply가 후처리를 망가뜨림
replyPayloads가 비게 되면, 스트리밍 표시의 후처리가 "답변이 존재하지 않음"이라고 판단하여, 이미 표시되어 있던 draft bubble을 삭제했습니다.
증상은 직송(direct send) 후의 첫 번째 팔로업(follow-up)만 잠시 표시되었다가 사라지는 것이었습니다. 두 번째는 정상적으로 보이기 때문에, 재현과 원인 파악이 어려운 결함이었습니다.
replyPayloads의 유무와 실제로 스트리밍된 메시지가 있는지는 별개입니다. 후처리 조건을 answer/reasoning 레인(lane)별로 재검토하여, 이미 전송된 메시지를 삭제하지 않도록 했습니다.
도구 결과의 전달 방식을 바꿨을 뿐이라고 생각할 수도 있지만, 답변 억제 플래그는 스트리밍, 최종 전달, 후처리까지 영향을 미칩니다. 제어 플래그의 부작용은 해당 플래그를 읽는 모든 레이어(layer)에서 확인해야 합니다.
현재도 남아있는 허점
전달과 이력 저장이 별개로 진행됨
현재 플러그인의 해당 부분은 다음과 같습니다.
if (sender) {
sender.send(result.passthrough).catch((err) => { // ← await 하지 않음
api.logger.error(`exec-passthrough: send failed: ${...}`);
...
전송은 await 되지 않으며, .catch()는 에러를 기록할 뿐입니다. 성공 로그인 sent N chars directly는 전송 완료를 기다리지 않고 출력됩니다. 그리고 suppressReply: true는 전송이 실패하더라도, sender가 설정되지 않아 내용이 버려지더라도, 무조건 반환됩니다.
즉, Bot API의 장애, 속도 제한(rate limit), 설정 미비 중 어느 것이라도 이력에는 "사용자에게 직송 완료"라고 남지만, 사용자에게는 아무것도 도달하지 않으며 일반적인 답변도 억제됩니다. 대화가 무언으로 끝나버립니다.
역방향의 불일치도 생각할 수 있습니다. Telegram으로의 전송만 성공하고, 그 직후에 프로세스가 떨어져 이력 저장이 완료되지 않는다면, 사용자는 요약을 보았음에도 불구하고 다음 턴의 LLM은 무엇을 보여주었는지 알지 못하게 됩니다.
즉, 현재의 구현은 외부 전송과 대화 상태 업데이트를 하나의 처리로 다루지 못하고 있습니다. tool_result_persist라는 저장 전 훅 (pre-save hook) 안에서 네트워크 송신까지 수행하기 때문에, 두 가지 부작용 (side effect)을 하나로 묶어 성공 또는 실패를 보장(원자성, Atomicity)할 수 없습니다.
이상적인 형태는 플러그인이 문자열을 분석하여 '직송 데이터' 또는 '전송 의도'를 반환하고, OpenClaw 본체의 outbound 경로가 송신을 완료하는 방식입니다. 송신 성공 시에만 통상적인 회신을 억제하고, 실패 시에는 통상적인 경로로 폴백 (fallback)합니다. 재시도를 고려한다면, 전송 ID와 동일한 내용이 중복 송신되지 않도록 하기 위한 멱등성 (Idempotency)도 필요합니다.
현재의 송신처 결정은 단일 사용자 전제
현재의 Telegram sender는 집에서 사용하는 단일 사용자 봇 (Bot)으로서 고정된 송신처를 결정하는 구현입니다. 이 전제하에서는 실용상 문제가 없지만, 여러 사용자나 여러 채널로 확장하려면 송신처를 설정에서 하나 선택하는 것이 아니라, 원래의 툴 호출 (tool call)을 발생시킨 턴 (turn)에 결부시켜야 합니다.
이 부분을 모호하게 둔 채 일반화하면, 다른 사용자의 결과를 잘못된 채팅으로 보내 사용자 간의 정보 유출로 이어질 수 있습니다. 이 기사의 구현 예시는 어디까지나 단일 사용자의 홈 에이전트 (home agent)를 전제로 하고 있습니다.
고정 마커는 구조적으로 취약함
제로 너비 공백 (Zero-width space)은 우연한 충돌을 줄여주지만, 정적 문자열인 이상 충돌이나 의도적인 주입을 완전히 막을 수는 없습니다.
종료 마커가 누락되었을 때의 동작도 정의되어 있기는 하지만 안전한 편은 아닙니다. 현재 구현은 종료 마커를 찾지 못하면 시작 마커 이후의 모든 내용을 표시 대상으로 취급합니다.
if (endIdx !== -1) {
// 통상: 마커 사이가 표시 대상, 그 외 영역이 LLM 측
} else {
...
앞 절에서 언급한 stdout 절단은 바로 이 분기점을 건드립니다. 끝부분이 떨어져 나가 종단 마커가 사라지면, 프레임 손상은 "본문 전체가 사용자에게 흘러가고, LLM 측에서는 사라지는" 형태로 나타납니다. 망가지는 방식이 이 설계에서 가장 피하고 싶었던 "한쪽의 전달처가 비어버리는" 상태 그 자체입니다.
지시문은 툴 호출의 의미를 알지 못함
LLM에 전달하는 지시는 검색 결과 목록, 기사 읽기, 명시적인 상세 요청 등의 차이를 충분히 구분하지 못합니다. 따라서 가장 요구 사항이 까다로운 팔로업 (follow-up)에 맞추면, 다른 케이스에서는 과잉 대응이 됩니다.
코드만으로는 프로토콜의 의미를 볼 수 없음
__PASSTHROUGH__는 연동 대상을 모르는 에이전트에게는 의미를 알 수 없는 문자열 리터럴 (string literal)로 보입니다. 실제로 다른 세션의 에이전트에서 사용되지 않는 코드로 삭제되기도 했습니다. 주석과 설계 문서도 필요하지만, 구조화된 타입 (type)이나 테스트만큼 강력한 방어 수단은 아닙니다.
전체 문장 유지에는 컨텍스트 비용이 따름
전체 문장을 대화 이력에 남기면 직후의 팔로업에는 강해집니다. 반면, 긴 기사나 영상을 계속해서 가져오면 컨텍스트 (context)를 소비하게 되며, 컴팩션 (compaction) 이후에 어디까지 남을 것인가라는 별개의 문제가 발생합니다.
이 설계가 해결하고자 하는 것은 주로 "취득 직후의 대화를 망가뜨리지 않는 것"입니다. 장기 보관까지 다루려면 외부 저장과 필요 시의 재주입 (re-injection)을 별도로 설계해야 합니다.
이 설계는 두 턴(two-turn)으로 테스트한다
이 메커니즘은 첫 번째 표시만 확인해서는 품질을 판단할 수 없습니다. 망가지는 것은 다음 턴이기 때문입니다.
최소한 다음의 조합을 완료 조건으로 확인해야 합니다. 참고로 "송신 실패 시 통상 회신으로의 폴백"과 "망가진 프레임의 명시적인 폴백"은 현재 구현에서 달성했다는 의미가 아니라, 남아있는 빈틈을 메우기 위한 목표 사양입니다.
| 케이스 | 첫 번째 턴 | 다음 턴 |
|---|---|---|
| 긴 기사 | 요약만 전달됨 | 동일한 기사의 전체 내용으로부터 자세히 답변 가능 |
| ... |
"Telegram에 요약이 표시되었다"는 확인의 절반에 불과합니다. 표시와 문맥 모두를 나누었다면, 테스트도 양쪽 모두에 걸쳐 이루어져야 합니다.
교훈
1. 툴 출력의 수신자를 파악하라
채팅형 에이전트의 툴 결과에는 지금 읽는 인간과, 다음 턴에서 사용할 LLM이라는 두 가지 용도가 있습니다. 요구 사항이 다르다면, 하나의 문구를 LLM에 맞춰 잘 조정하게 만드는 것만으로는 안정적이지 않습니다.
2. "표현이 같다"와 "전달처가 하나다"는 별개다
짧은 문장에서는 인간용과 LLM용으로 같은 본문을 사용할 수 있습니다. 하지만 같은 본문이면 된다는 것이, 한쪽의 전달처를 지워도 된다는 것을 의미하지는 않습니다. 2층 출력 (two-layer output)을 사용한다면 양쪽 모두를 성립시켜야 합니다. 사용하지 않는다면 통상적인 경로로 되돌려야 합니다. 이 두 가지를 혼동하지 않는 것이 중요합니다.
3. 결정론적 (Deterministic)으로 다루고 싶은 출력은 LLM의 전달 경로에서 제외한다
"있는 그대로 출력해"라는 지시가 안정적이지 않다면, LLM이 이를 계속 지키도록 강제하는 대신 코드로 경로를 분리합니다. 요약을 직송하고, LLM의 재응답(re-response)까지 중단하고 나서야 비로소 표시가 안정되었습니다.
4. 임계값(Threshold)은 대상 데이터의 분포로부터 결정한다
"대략 4000자"라는 값은 x-fetch에서 전체 989건을 단문으로 취급하게 만들어, 이미 구현된 요약 기능을 단 한 번도 작동시키지 않았습니다. 임계값은 툴 공통의 상수가 아니라, 데이터 분포와 이용 목적에 속하는 것입니다.
5. 구조화는 효과적이지만, 가볍지는 않다
LLM의 변동성(fluctuation)을 경로에서 배제한 대신, 마커 충돌, 중첩 처리(nesting), 출력 절단, 스트리밍 후처리, 전달 실패 시의 폴백(fallback)이라는 책임을 직접 떠안았습니다.
이 설계의 가치는 __PASSTHROUGH__라는 문자열 그 자체에 있는 것이 아닙니다.
인간에게 보여주는 표시와, LLM이 다음에 사용할 상태를 동일한 것으로 취급하지 않는 것.
그 부분을 명시적으로 분리한 것이 핵심입니다.
이 환경에 설정해 두었던 패스 블록(pass block)이 * 한 글자에 뚫렸던 이야기는 별도의 기사에 작성했습니다. → Gemini 3.1 Pro가 "*" 한 글자로 자작 패스 블록을 뚫은 건
본문 중의 숫자(실제 데이터 989건, 92.8%, 최대 3466자, 임계값 500 등)는 기억이 아니라, 당시의 로그와 작업 기록, 그리고 현재의 구현 코드를 다시 읽어 확인한 것입니다. 날짜는 JST 기준입니다.
Discussion

AI 자동 생성 콘텐츠
본 콘텐츠는 Zenn AI의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기