6 min read

Langfuse로 프롬프트 엔지니어링하기 — LLM에게 UI 스키마를 설계시킨 과정

사내 AI 에이전트에 매출 추이 분석해줘같은 모호한 질문이 들어오면, 바로 분석에 들어가지 않고 사용자에게 조건을 확인하는 단계가 있습니다. 어떤 채널?, 기간은?, 어떤 관점으로? 같은 후속 질문을 던지는 건데, 이 확인 UI의 구조 자체를 LLM이 생성합니다.

이 모호성 확인 기능을 개선하면서, Langfuse를 활용해 프롬프트를 반복적으로 다듬어간 과정을 정리합니다.


기존 구조의 한계

기존에는 커스텀 입력(날짜 선택, 항목 선택 등)을 그룹 단위로 제어했습니다.

{
  "title": 분석 대상 채널,
  "selectionType": "single",
  "allowCustomInput": true,
  "customInputType": "channel",
  "options": [...]
}

이러면 하나의 그룹에 서로 다른 종류의 커스텀 입력을 넣을 수 없습니다. 예를 들어 온라인 채널 선택과 오프라인 채널 선택은 둘 다 채널이지만 호출하는 API가 다릅니다. 프론트에서 직접 선택이라는 매직 옵션을 자동으로 끼워넣어야 했고, LLM은 그 옵션의 존재를 모르는 상태였습니다.

해결 방향은 단순합니다. 커스텀 입력 제어를 옵션 레벨로 내리는 겁니다.

{
  "options": [
    { "label": 특정 온라인 채널 선택, "value": "specific_online", "customInputType": "onlineChannel" },
    { "label": 특정 오프라인 채널 선택, "value": "specific_offline", "customInputType": "offlineChannel" }
  ]
}

LLM이 "이 옵션을 선택하면 이런 UI가 뜬다"를 직접 결정하는 구조입니다.


Langfuse로 프롬프트 다듬기

프롬프트는 Langfuse에서 버전 관리합니다. 코드에 하드코딩하지 않고, Langfuse 콘솔에서 수정하면 배포 없이 반영됩니다.

처음에는 스키마 필드 설명과 customInputType 종류만 정의해서 넣었습니다. 결과는 대체로 괜찮았는데, Langfuse의 trace를 관찰하면서 LLM이 빠지는 함정들이 하나씩 보이기 시작했습니다.

함정 1: 빈 문자열 출력

가끔 LLM이 모호성이 있다고 판단했으면서도 빈 JSON을 반환했습니다. 명시적으로 "모호하다고 판단했으면 반드시 유효한 groups를 출력하라"는 규칙을 추가했습니다.

함정 2: 마크다운 코드 펜스

출력을 json으로 감싸는 경우가 있었습니다. 프론트 파서에서 방어적으로 제거하되, 프롬프트에도 "순수 JSON만 출력하라"를 넣었습니다.

함정 3: 분류만 하고 선택 UI를 안 붙이기

채널별로 보고 싶어라고만 물어보면, LLM이 { "label": "온라인", "value": "online" } 같은 단순 분류 옵션만 주고 실제 채널을 선택할 수 있는 customInputType을 빠뜨렸습니다. "채널이라고만 말한 경우 반드시 선택 UI를 포함하라"는 규칙을 추가했습니다.

함정 4: 이미 답한 걸 또 물어보기

사용자가 "지난 7일간 매출 추이"라고 했는데 기간을 또 물어보는 경우. 질문에서 이미 명시된 조건은 그룹에 포함하지 말 것을 넣었습니다.


Langfuse trace가 핵심이었던 이유

프롬프트 엔지니어링에서 가장 시간이 오래 걸리는 건 뭐가 문제인지 파악하는 것입니다. 입력과 출력만 로그로 남기면 패턴을 찾기 어렵습니다.

Langfuse trace에서는 각 요청의 전체 맥락 — 시스템 프롬프트, 유저 입력, LLM 출력, 토큰 사용량 — 을 한 화면에서 볼 수 있습니다. 실패한 케이스를 필터링해서 왜 이 출력이 나왔는지를 추적하고, 규칙을 추가하고, 다음 trace에서 개선됐는지 확인하는 루프를 빠르게 돌릴 수 있었습니다.

결국 프롬프트 작업 흐름은 이렇게 됐습니다:

  1. Langfuse에서 trace 관찰 — 실패 패턴 발견
  2. 프롬프트에 규칙 추가 (Langfuse 콘솔에서 바로 수정)
  3. 배포 없이 다음 요청부터 적용
  4. trace에서 개선 확인

코드를 건드리지 않고 프롬프트만으로 품질을 올리는 사이클이 가능했던 건, 프롬프트 관리와 모니터링이 한 도구에 있었기 때문입니다.


프론트엔드 파서: 방어적으로

LLM은 규칙을 무시할 수 있습니다. 프롬프트에 아무리 잘 써놔도 100%는 아니니까요. 파서는 방어적으로 만들었습니다.

export function parseAmbiguityResponse(text: string): AmbiguityGroup[] {
  try {
    const cleaned = text.replace(/^```(?:json)?\s*\n?/, "").replace(/\n?```\s*$/, "");
    const parsed = JSON.parse(cleaned);
    if (!parsed.groups?.length) return [];
    return parsed.groups.map((g) => ({
      ...g,
      options: g.options.map((o) => ({
        ...o,
        customInputType: o.customInputType ?? undefined,
      })),
    }));
  } catch {
    return [];
  }
}
  • 코드 펜스 제거 (함정 2 방어)
  • groups 없으면 빈 배열
  • 파싱 실패 시 빈 배열 → UI에서 오류 메시지 표시

알 수 없는 customInputType이 오면 무시하고 일반 옵션으로 처리합니다. 프론트가 크래시나는 것보다는 기능이 빠지는 게 낫습니다.


최종 프롬프트 구조

결과적으로 프롬프트는 이런 순서로 구성됩니다:

  • 스키마 정의 — 필드별 타입과 역할
  • customInputType 목록 — 각 타입이 어떤 UI를 트리거하는지
  • 제약 조건 — 관찰에서 나온 하드 규칙들
  • 예시 1개 — 완전한 입출력 쌍 (few-shot)

스키마를 먼저 이해시키고, 규칙은 뒤에 붙이는 순서가 안정적이었습니다. 규칙을 먼저 주면 LLM이 스키마 구조를 자의적으로 해석하는 경향이 있었거든요.


결과

항목 Before (v2.0) After (v2.1)
커스텀 입력 제어 그룹 레벨 옵션 레벨
1그룹에 다른 타입 불가 가능 (online + offline)
직접 선택 옵션 프론트 자동 추가 LLM이 직접 생성
파싱 실패 크래시 graceful fallback
프롬프트 수정 → 반영 코드 배포 필요 Langfuse에서 즉시

참고