LLM의 출력을 형성하는 다섯 가지 방식에 기여하게 된 과정
요약
LLM의 구조화된 데이터 출력을 효율적으로 관리하기 위한 라이브러리 ShapeCraft의 개발 과정과 주요 기능을 소개합니다. OpenAI, Anthropic, Ollama 등 각 모델별로 상이한 스키마 강제 방식을 통합하여 Zod, JSON Schema, 정규 표현식 등을 통해 검증된 데이터를 얻는 방법을 다룹니다.
핵심 포인트
- 모델별로 다른 구조화된 데이터 출력 방식을 통합 관리
- Zod, JSON Schema, 정규 표현식을 지원하는 유연한 스키마 정의
- 데이터 신뢰도를 나타내는 guaranteeLevel 기능 제공
- 검증 함수와 힌트를 통한 복잡한 논리적 규칙 적용 가능
몇 달 전, 저는 정규 표현식(regex)으로 일일이 처리해야 하는 산문(prose)이 아니라, 실제 구조화된 데이터(structured data)를 반환해야 하는 프로젝트를 진행하고 있었습니다. 이론적으로는 간단해 보였지만, 제공업체마다 요구하는 형태가 모두 달랐습니다. Anthropic의 SDK는 도구 스키마(tool schema)를 원했고, OpenAI는 자체적인 응답 형식(response-format)을 가지고 있었으며, Ollama는 기본적으로 원시 프롬프팅(raw prompting)을 하고 결과가 나오길 바라는 방식이었습니다. 저는 기능 하나를 구현할 때마다 동일한 스키마를 세 번씩 다시 작성해야 했고, 출력을 보장하지 않는 모든 것에 대해 직접 유효하지 않은 출력 재시도(retry-on-invalid-output) 루프를 작성해야 했습니다.
그렇게 해서 ShapeCraft (@aviasole/shapecraft on npm)를 발견하게 되었습니다. 스키마를 한 번 정의하고 모델에 전달하면, 검증된 데이터와 함께 해당 데이터를 얼마나 신뢰할 수 있는지 알려주는 guaranteeLevel을 돌려받을 수 있습니다. 여기서 "보장됨(guaranteed)"이라는 의미는 OpenAI의 네이티브 스키마 강제(schema enforcement) 방식, Anthropic의 프롬프트 및 검증(prompt-and-validate) 방식, 그리고 Ollama의 토큰 수준 문법 제약(token-level grammar constraints) 방식에서 각각 다르게 적용됩니다.
이 라이브러리는 이미 Zod를 기본적으로 지원하고 있었습니다:
import { z } from "zod";
import { generate, openai } from "@aviasole/shapecraft";
...
이것으로 제가 필요로 했던 대부분의 기능이 충족되었습니다. 하지만 실제 프로젝트의 몇몇 부분은 "Zod 스키마 정의" 방식에 맞지 않았고, 저는 이를 우회하는 대신 몇 개의 PR(Pull Request)을 보냈습니다.
제가 추출하던 데이터 중 일부는 이미 상위 도구(upstream)에서 생성된 JSON 스키마(JSON Schema)를 가지고 있었습니다. 단순히 라이브러리를 맞추기 위해 이를 Zod 타입으로 다시 작성하는 것은 불필요한 작업처럼 느껴졌고, 이것이 첫 번째 추가 기능이 되었습니다. 바로 JSON 스키마를 그대로 전달하는 기능입니다:
const result = await generate(model, {
jsonSchema: {
type: "object",
...
그다음에는 객체(object)가 전혀 필요하지 않고, 특정 형식의 문자열만 필요한 경우를 마주했습니다. 이를 객체 스키마로 감싸는 것은 마치 모자 위에 모자를 쓰는 것처럼 불필요하게 느껴졌기에, 다음으로 일반 정규 표현식(regex) 옵션을 추가했습니다:
const result = await generate(model, {
pattern: /^\d{4}-\d{2}-\d{2}$/,
}, "What is today's date?");
그 후, 타입(type)이 아닌 논리(logic)로서만 의미가 있는 규칙, 즉 "다른 필드가 X와 같을 때만 필수(required)"와 같은 규칙이 필요했습니다. 기술적으로는 스키마(schema)로 표현할 수 있지만, 읽기가 매우 고통스러웠습니다. 그래서 검증 함수(validator function)를 직접 전달하는 방법과, 모델이 여전히 목표로 삼을 수 있는 hint를 추가했습니다.
const result = await generate(model, {
validate: (output) => typeof output === "object" && output !== null && "id" in output,
hint: { type: "object", properties: { id: { type: "string" } } },
...
저에게 가장 중요했던 것은 이 프로젝트와는 완전히 별개의 것이었습니다. 저는 Tally/TDL 및 GST 통합 작업도 수행하는데, 그 세계는 XML을 기반으로 돌아갑니다. 중첩된 태그(nested tags)와 몇 단계 깊숙이 숨겨진 필수 필드(required fields)가 특징이죠. 위에서 언급한 방식들은 출력 형식이 JSON 형태가 전혀 아니기 때문에 그곳에서는 아무런 도움이 되지 않았습니다. 그래서 마지막으로 추가된 것이 템플릿 기반 XML(template-based XML)입니다. 타입이 지정된 플레이스홀더(placeholder)가 포함된 예시를 제공하면, 모델이 이를 채워 넣는 방식입니다.
const result = await generate(model, {
xml: {
template: `<book>
...
이 부분은 가장 많은 시행착오를 거쳤습니다. 플레이스홀더는 {string}, {number}, {boolean}으로 의도적으로 제한되어 있으며, 오타가 발생하면 모델이 호출되기도 전에 오류를 던집니다. 이는 템플릿 버그(template bug)이며, 재시도(retry)할 가치가 있는 것이 아닙니다.
더 까다로운 부분은 리터럴 텍스트(literal text)였습니다. {} 외부의 모든 것은 손상되지 않고 유지되어야 하지만, 기본적으로는 최선(best-effort)을 다하는 방식입니다. 모델은 고정된 텍스트가 명령(instruction)처럼 읽힐 경우, 그것이 건드려서는 안 되는 영역임을 표시하는 장치가 없기 때문에 가끔씩 해당 텍스트를 "개선"하려고 시도합니다. 만약 이것이 보장되어야 한다면, 사후에 모든 리터럴을 강제로 교정하는 enforceLiterals: true 플래그가 있습니다. 또는 더 간단한 방법으로, 고정된 값을 템플릿에서 제외하고 나중에 직접 result.data에 결합(splice)하는 방법도 있습니다.
required는 또한 모든 깊이에서 비어 있지 않은지(non-emptiness)를 확인하므로, <items></items>는 존재하는 것으로 간주되지 않고 재시도(retry)를 유발합니다. 그리고 arrays와 parse: true를 함께 사용하면 가공되지 않은 XML 대신 파싱된 JS 객체(parsed JS object)를 받을 수 있으며, 단일 항목이 있더라도 특정 노드들이 배열로 강제 변환(coerced)됩니다.
미리 알아두어야 할 주의 사항이 하나 있습니다. XML 생성은 모든 백엔드에서 프롬프트 기반(prompt-driven)으로 이루어지며, Ollama가 JSON에 대해 제공하는 것과 같은 토큰 수준의 문법 제약(token-level grammar constraint)은 없습니다. 이는 모델의 역량에 의존하며, 특히 깊게 중첩된 템플릿(deeply nested templates)의 경우 더욱 그러합니다.
이 다섯 가지 방식 모두 내부적으로는 동일한 generate() 호출을 거칩니다. 즉, 동일한 재시도(retries), 동일한 guaranteeLevel, 동일한 오류 유형(SchemaViolationError, MaxRetriesExceededError)을 사용합니다. 같은 방으로 들어가는 서로 다른 문들이라고 할 수 있으며, 이것이 아마도 새로운 기능들을 계속해서 추가하기 쉬웠던 이유일 것입니다.
몇 가지 진심으로 궁금한 점이 있습니다. 이전에 템플릿 기반의 XML 생성을 구축해 본 적이 있다면, enforceLiterals가 적절한 접근 방식이라고 느껴지시나요? 아니면 재직렬화(re-serialize) 과정을 거치지 않는 더 깔끔한 방법이 있을까요? 실제로 프로덕션 환경에서 필요했지만 아직 빠져 있는 스키마 스타일이 있나요? 예를 들어 YAML이나 protobuf 형태의 무언가 말이죠. 그리고 검증기(validator) 경로의 경우, hint 객체만으로 충분한 컨텍스트가 될까요, 아니면 더 많은 것을 원하시나요?
저장소(Repo)는 여기 있습니다: github.com/aviasoletechnologies/shapecraft. 패키지 명은 @aviasole/shapecraft입니다. 현재 여러 프로젝트에서 사용 중이므로, 더 많은 엣지 케이스(edge cases)를 마주할 때마다 계속해서 기능을 추가해 나갈 예정입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기