OpenClaw에서 outputSchema로 도구 결과를 검증하고, 타입 불일치를 에이전트 루프 안에서 복구하는 법
프로덕션 에이전트를 운영하다 보면 이런 상황을 만나게 됩니다. 도구는 분명히 성공했다고 표시되는데, 반환된 JSON 구조가 다음 단계 도구의 기대값과 미묘하게 어긋나서 루프 전체가 이상한 방향으로 흘러가는 거죠. 처음엔 프롬프트 문제인 줄 알고 뜯어보곤 하는데, 정작 원인은 도구 계약(tool contract)이 명시되지 않은 채로 결과가 오가고 있다는 데 있는 경우가 많습니다.
OpenClaw의 outputSchema는 도구가 어떤 구조를 돌려줄지를 JSON Schema로 선언하는 필드입니다. 단순 문서화 수단이 아니라, 런타임에서 실제 검증을 수행하고 실패 시 에이전트 루프에 복구 시그널을 주입하는 작동 요소죠. 파싱 성공률·수리 복구율·스키마 검증율을 신뢰성 지표로 관리하는 팀이 늘고 있는 만큼, 이 메커니즘을 어떻게 설계하느냐가 에이전트 안정성의 상당 부분을 결정합니다.
이 글에서는 outputSchema 선언 방식과 검증 실패 시 루프가 자기 교정(self-correction)을 수행하는 절차, 그리고 실무 판단이 갈리는 지점을 순서대로 짚어봅니다.
outputSchema가 에이전트 루프에서 하는 일
도구 계약이란 무엇인가
OpenClaw 플러그인 SDK에서 도구 플러그인은 outputSchema 필드를 선언해 실행 결과의 구조를 JSON Schema로 명시할 수 있습니다. JSON Schema 사양은 draft-07과 2020-12가 서로 호환되지 않는 별개 명세이며, $defs(2020-12) vs definitions(draft-07), prefixItems, unevaluatedProperties 등 키워드가 다릅니다. 어느 사양을 쓸지는 OpenClaw 런타임 버전이 지원하는 dialect를 Tool plugins 문서에서 먼저 확인한 뒤 스키마를 작성하는 편이 안전합니다. 섞어 쓰면 런타임에서 알 수 없는 키워드로 처리되어 검증이 조용히 무력화되는 사례가 있습니다.
이 스키마는 타입스크립트 타입 힌트처럼 컴파일 타임에만 소비되는 게 아니라, 런타임에서 실제로 검증이 돌아갑니다. 동작 순서를 짚어두면 다음과 같습니다.
- 도구 호출 전에 OpenClaw가
outputSchema자체의 유효성을 먼저 검사합니다. 스키마가 유효하지 않으면 도구 실행이 시작되지 않습니다(fail-fast). - 도구 실행이 끝나고 훅(hook) 처리가 완료된 시점에, 반환된
details값을 스키마에 대조해 검증합니다. - 검증에 실패하면
completed상태의 호출 자체가 실패 처리되고, 오류가 에이전트 루프로 반환됩니다.
자기 교정 루프의 실제 작동
검증에 실패하면 OpenClaw는 이전 호출이 어떤 이유로 실패했는지, 그리고 원본 outputSchema가 무엇이었는지를 함께 모델 컨텍스트에 재주입합니다. 스키마를 다시 보게 되므로 모델은 어떤 구조가 요구되는지를 참조해 호출을 재생성할 수 있고, 이 과정이 몇 차례 반복되면서 구조적 불일치가 해소되는 흐름입니다.
여기서 조심할 점은 "자동 복구율 몇 %"처럼 카테고리 없이 인용되는 숫자를 그대로 믿지 않는 것입니다. 회수율은 모델, 도구 종류, 오류 분포, 재시도 예산에 따라 크게 달라지므로 자기 환경에서 별도로 측정해야 합니다. 실무에서 더 중요한 물음은 "복구되지 않는 나머지 케이스가 어떤 유형인가"이고, 이걸 로그로 남길 수 있어야 다음 절에서 다룰 라우팅 로직을 설계할 수 있습니다.
실제로 어떻게 선언하고 쓰는가
기본 outputSchema 선언
아래는 개념적 예시입니다. OpenClaw Plugin SDK의 플러그인 정의 형태를 참고해 작성한 것이며, 구체적인 API 시그니처는 사용 중인 런타임 버전의 Building plugins 문서에서 확인하시는 게 좋습니다.
// 개념적 예시 - OpenClaw Plugin SDK 기반
const searchPlugin = {
name: "web_search",
description: "웹에서 정보를 검색합니다",
inputSchema: {
type: "object",
properties: {
query: { type: "string" },
maxResults: { type: "number" }
},
required: ["query"],
additionalProperties: false
},
outputSchema: {
type: "object",
properties: {
results: {
type: "array",
items: {
type: "object",
properties: {
title: { type: "string" },
url: { type: "string", format: "uri" },
snippet: { type: "string" }
},
required: ["title", "url", "snippet"],
additionalProperties: false
}
},
totalCount: { type: "number" },
searchedAt: { type: "string", format: "date-time" }
},
required: ["results", "totalCount"],
additionalProperties: false
},
execute: async (input) => {
// 실제 검색 로직
}
};additionalProperties: false를 습관화하면, 도구가 예상치 못한 필드를 반환했을 때 그 값이 다음 도구로 조용히 흘러들어가 먼 곳에서 터지는 시나리오를 막을 수 있습니다. 계약을 명확히 닫아둔다는 뜻이죠.
구조화된 오류 변형도 스키마에 포함하기
실무에서 자주 놓치는 부분입니다. 도구가 예외를 던지지는 않았지만 "정상적으로 실패"하는 경우 — 검색 결과 0건, 파일 미존재, 권한 거부 등 — 이 케이스도 스키마에 포함시켜야 합니다.
// 개념적 예시
outputSchema: {
oneOf: [
{
type: "object",
properties: {
status: { type: "string", const: "success" },
results: { type: "array", items: { /* ... */ } }
},
required: ["status", "results"],
additionalProperties: false
},
{
type: "object",
properties: {
status: { type: "string", const: "not_found" },
reason: { type: "string" }
},
required: ["status", "reason"],
additionalProperties: false
}
]
}이걸 빠뜨리면 "정상적으로 빈 결과를 반환"할 때마다 검증이 실패하고, 원인이 아닌 곳에서 재시도가 반복되는 헛수고가 발생합니다.
재시도 한계 설정
자기 교정 루프가 아무리 잘 동작해도, 상한을 걸지 않으면 비용이 폭발합니다. 재시도 한계 부재로 인한 무한 루프·조기 중단 문제는 BetterClaw의 운영 경험 정리에서도 반복적으로 지적되는데, 해당 글은 독립적인 통계 연구가 아니라 필자들의 운영 관찰을 모은 성격이라는 점을 감안해 자기 환경의 지표와 비교하며 참고하시면 됩니다.
# 개념적 예시 - 실제 키 이름은 런타임 버전에 따라 다를 수 있습니다
maxIterations: 15
costCeiling: 0.50 # USD per task
retryBackoff: exponentialmaxIterations는 전체 루프 반복 상한, costCeiling은 태스크 단위 비용 상한입니다. 어느 하나만 걸어두면 다른 한쪽이 폭주할 수 있으니 둘 다 두는 편이 안전합니다.
postToolCall 훅에서 실패 유형을 분기하기
스키마 검증 실패와 트랜지언트 오류(네트워크 타임아웃 같은 것)를 같은 재시도 경로로 몰아버리면, 구조적으로 절대 성공하지 못할 호출이 예산이 소진될 때까지 반복됩니다. 유형을 분기하는 게 복구 효율의 핵심입니다.
아래 코드에서 훅의 반환 형태와 오류 타입 문자열은 예시 스케치일 뿐이며, 실제 필드 이름·오류 코드·컨텍스트 구조는 Plugin hooks 문서에서 사용 중인 버전 기준으로 확인해야 합니다. 그대로 복사해 쓰면 필드가 어긋나 분기 자체가 무효화될 수 있습니다.
// 개념적 예시 - 실제 훅 시그니처는 Plugin hooks 문서 참조
type HookContext = {
result?: unknown;
error?: { type?: string; message?: string };
toolName: string;
};
type HookResult =
| { escalate: true; reason: string }
| { retry: true; backoff: 'exponential' | 'linear' }
| { continue: true };
const plugin = {
hooks: {
postToolCall: async (context: HookContext): Promise<HookResult> => {
const { error, toolName } = context;
// 아래 문자열은 예시이며, 실제 오류 코드는 런타임 문서 기준으로 매핑해야 합니다
if (error?.type === 'schema_validation_failed') {
console.error(`[${toolName}] 구조적 실패 감지, 에스컬레이션`);
return { escalate: true, reason: error.message ?? 'schema mismatch' };
}
if (error?.type === 'transient_error') {
return { retry: true, backoff: 'exponential' };
}
return { continue: true };
}
}
};구조적 실패는 같은 인자로 재시도해봐야 결과가 바뀌지 않습니다. 인자 자체가 스키마와 어긋난 상태라 모델이 새 호출을 만들어야 의미가 있기 때문에, 재시도 경로가 아니라 에스컬레이션·라우팅 경로로 보내는 게 맞습니다. 반대로 트랜지언트는 지수 백오프로 재시도하면 대개 해결됩니다.
에이전트 루프 내 복구 흐름
앞의 flowchart가 런타임 내부의 상태 전이를 다뤘다면, 아래 sequence diagram은 모델·런타임·도구 사이 메시지 교환 순서에 초점을 맞춥니다.
트레이드오프: 얻는 것과 감수해야 하는 것
각 항목을 이점·주의점 쌍으로 묶어 판단 지점을 정리합니다.
| 주제 | 얻는 것 | 함께 고려할 것 |
|---|---|---|
| 조기 실패 | 무효 스키마를 갖는 도구는 실행 자체가 차단되어 잘못된 결과가 루프로 전파되지 않음 | 스키마 컴파일이 콜드 스타트에 얹혀 도구 초기화가 느려질 수 있음. 호출 빈도 높은 도구는 컴파일 결과 캐싱 검토 |
| 자동 복구 | 오류 메시지와 스키마 재주입으로 구조적 불일치의 상당 부분이 몇 차례 재시도 안에 해결 가능 | 회수율은 환경 편차가 크므로 자기 워크로드에서 별도 측정 필요. 복구되지 않는 케이스의 유형 분포 로그화 필수 |
| 모델 독립 계약 | 모델을 교체하거나 여러 모델을 혼합해도 outputSchema가 타입 보증을 유지 |
새 모델이 이전 모델과 다른 방식으로 스키마 위반을 저지르는 경우가 있어, 마이그레이션 시 실패 유형 분포를 재분석해야 함 |
| 감사 가능성 | 요청·완료 이벤트가 구조화되어 복구 과정이 로그로 남음 | 로그 볼륨이 늘어나므로 오류가 아닌 정상 재시도까지 저장할지 정책 결정 필요 |
| 결과 변형 처리 | oneOf로 성공·부분성공·정상실패를 명시하면 모든 변형이 계약 안에 들어옴 |
변형을 빠뜨리면 정상 실패마다 검증이 실패해 오히려 재시도 폭증. 스키마 설계 초기에 도구별 결과 카탈로그 정리 필요 |
| 재시도 라우팅 | 구조적 실패와 트랜지언트 오류를 나눠 처리하면 예산이 헛되이 소진되지 않음 | 오류 타입 매핑을 잘못하면 분기 전체가 무력화. 실제 오류 코드를 런타임 문서 기준으로 확인해야 함 |
| 루프 안전장치 | maxIterations·costCeiling이 복구 루프의 최악 시나리오를 방어 |
상한이 낮으면 정상적으로 해결될 케이스도 조기 중단됨. 도구 조합의 평균 반복 수를 관측한 뒤 여유를 두고 설정 |
흔히 만나는 실수
outputSchema 없이 우선 배포하는 패턴이 가장 흔합니다. 초반엔 문제가 눈에 띄지 않다가, 도구 결과를 받아 다른 도구로 넘기는 체인이 길어질수록 디버깅 난이도가 급격히 올라갑니다. 처음부터 명시해두는 편이 결국 더 빠릅니다.
또 하나는 스키마를 너무 느슨하게 선언하는 경우입니다. type: "object"만 달고 properties가 비어 있으면 검증이 사실상 통과 스탬프에 지나지 않습니다. 필요한 필드는 required에 명시하고 additionalProperties: false로 닫아두는 걸 기본으로 삼는 게 좋습니다.
관련해서 참고할 만한 이슈로는 Issue #45049가 있습니다. 에이전트 루프가 실제 도구 호출을 강제하지 않고 시뮬레이션된 호출을 허용하는 문제가 보고된 바 있는데, 스키마 검증이 부분적으로는 도움이 되지만 완전한 해결책은 아닙니다. 실제 호출 여부는 별도 지표로 모니터링하는 게 안전합니다.
후처리 레이어에서의 repair-then-validate
OpenClaw 런타임 외부에서 도구 결과를 수신해 후처리하는 파이프라인이 있다면, Pydantic 같은 검증 라이브러리로 2차 방어선을 두는 것도 유효한 선택입니다. 이 패턴이 업계 표준이라고까지 말하긴 어렵지만, 스키마 위반이 잦은 필드에 한해 국지적으로 자동 수리를 시도하는 방식은 실무에서 자주 채택됩니다.
# 개념적 예시 - Pydantic v2 기반
from pydantic import BaseModel, ValidationError
from typing import List
class SearchResult(BaseModel):
title: str
url: str
snippet: str
model_config = {"extra": "forbid"}
class SearchOutput(BaseModel):
results: List[SearchResult]
total_count: int
def repair_and_validate(raw_output: dict) -> SearchOutput:
try:
return SearchOutput.model_validate(raw_output)
except ValidationError as e:
repaired = attempt_repair(raw_output, e)
if repaired is not None:
return SearchOutput.model_validate(repaired)
raise
def attempt_repair(data: dict, error: ValidationError) -> dict | None:
repaired = dict(data)
for err in error.errors():
if err["type"] == "int_parsing" and err["loc"] == ("total_count",):
try:
repaired["total_count"] = int(repaired.get("total_count", 0))
return repaired
except (ValueError, TypeError):
return None
return None수리 범위는 좁게 잡는 게 낫습니다. 수리 규칙이 늘어나면 원 도구가 계약을 어겨도 파이프라인이 삼켜버리게 되고, 결국 계약을 강제하는 목적 자체가 흐려집니다. 수리한 케이스는 반드시 별도 메트릭으로 카운트해서 "왜 이 도구는 상시적으로 계약을 어기고 있는지"를 회고할 수 있게 두는 편이 좋습니다.
정리하며
outputSchema를 도입할 때 실무에서 자주 마주치는 판단 지점은 대체로 세 방향으로 갈립니다.
첫째, 스키마를 어디까지 닫을 것인가입니다. additionalProperties: false로 계약을 완전히 닫으면 예상 밖 필드를 조기에 잡을 수 있지만, 도구 벤더가 응답 필드를 늘리는 순간 전량 실패가 발생합니다. 외부 API를 래핑한 도구라면 특정 하위 오브젝트만 열어두는 절충안을 고려할 수 있습니다.
둘째, 실패를 언제 에스컬레이션할 것인가입니다. 구조적 실패는 두어 차례 재시도로 해결되지 않으면 사람 개입 큐로 넘기는 편이 낫고, 트랜지언트는 지수 백오프로 예산 안에서 흡수하는 게 자연스럽습니다. 두 유형을 훅에서 분리하지 않으면 어느 쪽이든 예산만 태우게 됩니다.
셋째, 재시도 예산을 어떤 관측치로 정할 것인가입니다. maxIterations와 costCeiling을 감으로 잡는 팀이 많은데, 도구 조합별 평균 반복 수와 정상 종료까지의 비용 분포를 최소 며칠은 관측한 뒤 여유를 두고 설정하는 게 안전합니다. 상한이 너무 낮으면 회복 가능한 케이스가 조기 중단되고, 너무 높으면 안전장치 역할을 못합니다.
OpenClaw 런타임 버전에 따라 지원 dialect나 훅 시그니처가 바뀔 수 있으므로, 새 버전으로 올릴 때는 릴리스 노트에서 outputSchema 관련 변경 사항을 먼저 확인하고 기존 플러그인 정의를 함께 점검하시는 편이 좋습니다.