실제 터미널 캡처: laya 0.4.3 Router.predict에 type multi 질문을 넘기자 choice·noul·score만 허용한다는 ValueError가 출력된 화면과 글 제목

Laya 질문 타입은 choice·score·noul뿐, multi는 ValueError

고객 메일을 부서·긴급도·이탈 가능성으로 나누는 분류기에는 choice, score, noul 세 질문 타입이 필요합니다. 태그 여러 개를 한 질문에 담으려고 type을 multi로 적으면 질문 검사에서 ValueError가 나고 분류 결과도 나오지 않습니다. 타입을 고친 다음에도 min_confidence를 0.6으로 두었을 때 보류 여부는 answer_confidence와 비교해 정해집니다. 이 분류기를 운영에 올릴 수 있는지 보려고 GPU 없는 Debian에서 Laya 질문 타입이 어디까지 허용되는지부터 실행했고, 그 출력과 캡처를 아래에 그대로 실었습니다.

Debian 13 CPU에서 laya 0.4.3으로 재현 코드를 돌려 그 오류를 일으키고, choice·score·noul로 바꾼 메일 네 통과 이중 결제 메일의 min_confidence None·0.5·0.6 결과를 읽었습니다. 이어서 multi를 태그별 noul로 나누는 구성과, choice:3-5와 noul:2에 서로 다른 임계값을 주는 버킷 맵을 같은 문장으로 비교합니다. 인프라에 붙이기 전에 질문 스키마와 게이트 키를 라이브러리 규칙에 맞춰 로컬에서 고정해 두면, 배포한 뒤에 타입 거절로 멈추는 일이 없습니다. 범위는 여러 태그를 한 질문에 담으려다 막힌 multi와 그 뒤의 게이트 설정까지입니다. 답장 문장을 받으려고 type에 text를 넣어 같은 검사에서 멈추는 경우는 원인이 서술형 요청이라 해법도 결정 모델과 로컬 LLM의 역할 분리로 갈리고, 그 과정은 Laya unknown type ‘text’ ValueError, 답장은 Ollama로에 따로 정리했습니다.

type을 multi로 적으면 질문 검사에서 ValueError

여러 태그를 한 번에 고르려고 질문 타입을 multi로 두면 Router.predict가 그 질문을 받아 주지 않습니다. 예시 메일은 로그인 버튼을 누르면 500 오류가 나고 팀 전체가 업무를 못 한다는 내용이고, 체크포인트는 model=”multilingual”로 고정했습니다. criteria에는 billing과 technical만 넣었습니다.

from laya import Router

router = Router(max_loaded=1)
mail = "로그인 버튼을 누르면 500 오류가 나고 팀 전체가 업무를 못 하고 있습니다."
questions = {
    "tags": {
        "type": "multi",
        "instructions": "해당 태그를 모두 고르라",
        "criteria": {"billing": "결제, 환불, 청구서", "technical": "버그, 장애, 오류"},
    }
}
router.predict(mail, questions, model="multilingual")

이 코드를 돌리면 가중치를 쓰기도 전에 표준 오류에 ValueError가 찍힙니다. 아래가 그 재현 전체이고, 허용 타입 목록이 메시지에 같이 나오니 고칠 필드를 곧바로 찾을 수 있습니다.

+ /tmp/laya-venv/bin/python -u - <<'PY'
from laya import Router

router = Router(max_loaded=1)
mail = "로그인 버튼을 누르면 500 오류가 나고 팀 전체가 업무를 못 하고 있습니다."
questions = {
    "tags": {
        "type": "multi",
        "instructions": "해당 태그를 모두 고르라",
        "criteria": {"billing": "결제, 환불, 청구서", "technical": "버그, 장애, 오류"},
    }
}
router.predict(mail, questions, model="multilingual")
PY
Traceback (most recent call last):
  File "<stdin>", line 12, in <module>
  File "/tmp/laya-venv/lib/python3.12/site-packages/laya/router.py", line 1387, in predict
    result = agent.system_one(ctx.states[0], ctx.questions,
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/laya-venv/lib/python3.12/site-packages/torch/utils/_contextlib.py", line 124, in decorate_context
    return func(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/laya-venv/lib/python3.12/site-packages/laya/agent.py", line 1922, in system_one
    return self.predict_batch([state], questions, lang=lang, hooks=hooks,
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/laya-venv/lib/python3.12/site-packages/torch/utils/_contextlib.py", line 124, in decorate_context
    return func(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^
  File "/tmp/laya-venv/lib/python3.12/site-packages/laya/common.py", line 65, in wrapped
    return fn(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^
  File "/tmp/laya-venv/lib/python3.12/site-packages/laya/agent.py", line 1495, in predict_batch
    self._check_question(qid, questions[qid])
  File "/tmp/laya-venv/lib/python3.12/site-packages/laya/agent.py", line 986, in _check_question
    raise ValueError("question %r: unknown type %r; use one of %s" % (qid, t, sorted(QTYPES)))
ValueError: question 'tags': unknown type 'multi'; use one of ['choice', 'noul', 'score']
Laya 질문 타입 실행 화면: type="multi"가 ValueError를 내는 재현 화면입니다
2026-10-14 14:54:27 KST, Debian 13 — type=”multi”가 ValueError를 내는 재현 화면입니다.

마지막 줄은 ValueError: question ‘tags’: unknown type ‘multi’; use one of [‘choice’, ‘noul’, ‘score’]입니다. 트레이스백은 router.py의 predict가 agent.system_one으로 들어가고, predict_batch가 _check_question을 호출한 뒤 agent.py 986행에서 예외를 일으킨다고 적습니다. 질문 id는 tags이고, 거부된 타입 문자열은 multi입니다.

허용 타입은 choice, noul, score 세 가지뿐

질문 타입으로 허용되는 것은 choice, noul, score 세 가지입니다. 공식 README는 choice를 라벨 하나, score를 순서 있는 눈금의 기대 수준, noul을 답이 예일 확률로 설명합니다. multi는 그 목록에 없어서 _check_question이 ValueError를 냅니다. 메시지가 허용 타입을 정렬해서 보여 주니, 손볼 곳은 질문 딕셔너리의 type입니다.

태그가 여러 개라서 multi를 넣었다면, 태그마다 noul 질문을 하나씩 두는 쪽이 이 라이브러리의 타입 체계와 맞습니다. 같은 장애 메일로 돌린 그 실행은 뒤의 트러블슈팅 절에 있습니다.

choice, score, noul로 바꿔 메일 네 통 분류하기

타입을 허용된 세 가지로 바꾸면 같은 라우터가 답을 돌려줍니다. department는 billing, technical, other 중 하나를 고르는 choice, urgency는 급하지 않음, 곧, 업무 중단의 세 단계인 score, churn_risk는 고객이 해지하거나 떠나겠다고 말하는지를 묻는 noul입니다. 메일 네 통을 먼저 분류하고, 이중 결제 메일에는 min_confidence를 비우거나 0.5, 0.6으로 걸어 보류 표시만 비교했습니다. 그다음 0.6을 장애 메일과 감사 메일에도 걸어, 엔트로피 confidence가 낮아도 answer_confidence가 높으면 통과하는지를 같은 출력에서 봅니다.

from laya import Router

router = Router(max_loaded=1)
questions = {
    "department": {
        "type": "choice",
        "instructions": "이 메일을 처리할 부서는?",
        "criteria": {
            "billing": "결제, 환불, 청구서",
            "technical": "버그, 장애, 오류",
            "other": "그 외",
        },
    },
    "urgency": {
        "type": "score",
        "instructions": "얼마나 급한가?",
        "criteria": ["급하지 않음", "곧", "업무 중단"],
    },
    "churn_risk": {
        "type": "noul",
        "instructions": "고객이 해지하거나 떠나겠다고 말하는가?",
    },
}
mails = [
    ("outage", "로그인 버튼을 누르면 500 오류가 나고 팀 전체가 업무를 못 하고 있습니다."),
    ("quote", "다음 분기 견적서를 받아 볼 수 있을까요? 급하지 않습니다."),
    ("refund", "환불이 일주일째 처리되지 않네요. 이번 주 안에 안 되면 다른 서비스로 옮기겠습니다."),
    ("thanks", "지난번 안내 감사합니다. 잘 해결됐어요."),
]

def fmt(x):
    return format(float(x), ".4f")

def gate(answer):
    return answer.get("abstention", "-")

for name, mail in mails:
    result = router.predict(mail, questions, model="multilingual")
    answers = result["answers"]
    department = answers["department"]
    urgency = answers["urgency"]
    churn = answers["churn_risk"]
    print(
        "MAIL", name,
        "department=" + department["choice"],
        "confidence=" + fmt(department["confidence"]),
        "answer_confidence=" + fmt(department["answer_confidence"]),
        "urgency=" + fmt(urgency["score"]),
        "urgency_confidence=" + fmt(urgency["confidence"]),
        "urgency_answer_confidence=" + fmt(urgency["answer_confidence"]),
        "churn_noul=" + fmt(churn["noul"]),
        "churn_confidence=" + fmt(churn["confidence"]),
        "churn_answer_confidence=" + fmt(churn["answer_confidence"]),
        "model=" + result["routing"]["model"],
    )

billing_mail = "3월 요금이 두 번 결제됐습니다. 오늘 중으로 중복 결제분을 환불해 주지 않으면 해지하겠습니다."
pair = {"department": questions["department"], "churn_risk": questions["churn_risk"]}
seen = {}
for threshold in (None, 0.5, 0.6):
    kwargs = {} if threshold is None else {"min_confidence": threshold}
    result = router.predict(billing_mail, pair, model="multilingual", **kwargs)
    department = result["answers"]["department"]
    churn = result["answers"]["churn_risk"]
    label = "None" if threshold is None else format(threshold, ".1f")
    seen[label] = (gate(department), gate(churn), fmt(department["answer_confidence"]), fmt(churn["answer_confidence"]), fmt(department["confidence"]), fmt(churn["noul"]))
    print(
        "THRESH", "min_confidence=" + label,
        "department=" + department["choice"],
        "confidence=" + fmt(department["confidence"]),
        "answer_confidence=" + fmt(department["answer_confidence"]),
        "abstention=" + str(gate(department)),
        "churn_noul=" + fmt(churn["noul"]),
        "churn_confidence=" + fmt(churn["confidence"]),
        "churn_answer_confidence=" + fmt(churn["answer_confidence"]),
        "churn_abstention=" + str(gate(churn)),
    )

# 엔트로피 confidence 가 0.6 미만이어도 answer_confidence 가 높으면 보류되지 않는 메일.
for name, mail in (("outage", mails[0][1]), ("thanks", mails[3][1])):
    result = router.predict(mail, questions, model="multilingual", min_confidence=0.6)
    department = result["answers"]["department"]
    churn = result["answers"]["churn_risk"]
    print(
        "GATE06", name,
        "department=" + department["choice"],
        "confidence=" + fmt(department["confidence"]),
        "answer_confidence=" + fmt(department["answer_confidence"]),
        "abstention=" + str(gate(department)),
        "churn_noul=" + fmt(churn["noul"]),
        "churn_answer_confidence=" + fmt(churn["answer_confidence"]),
        "churn_abstention=" + str(gate(churn)),
    )

none_dept, none_churn, _, _, _, _ = seen["None"]
low_dept, low_churn, _, _, _, _ = seen["0.5"]
high_dept, high_churn, dept_ans, churn_ans, _, _ = seen["0.6"]
if none_dept != "-" or none_churn != "-":
    raise SystemExit("ungated call wrote an abstention field")
if low_dept != "passed" or low_churn != "passed":
    raise SystemExit("min_confidence 0.5 did not pass both answers")
if high_dept != "passed" or high_churn != "abstained":
    raise SystemExit("min_confidence 0.6 did not split department and churn")
print(
    "SUCCESS choice score noul accepted;"
    " min_confidence=0.6 department abstention=passed"
    " answer_confidence=" + dept_ans +
    " churn abstention=abstained"
    " churn_answer_confidence=" + churn_ans
)

실행 환경과 분류 숫자는 아래 출력 한 블록에 같이 있습니다. OS·Python·laya·torch 줄부터 메일별 점수와 게이트 비교까지 이어서 읽으면 됩니다.

+ sed -n 's/^PRETTY_NAME=//p' /etc/os-release | tr -d '"'
Debian GNU/Linux 13 (trixie)
+ /tmp/laya-venv/bin/python -c 'import sys; print(sys.version)'
3.12.15 (main, Oct  3 2026, 01:03:07) [Clang 22.1.3 ]
+ /tmp/laya-venv/bin/python -c 'import importlib.metadata; print(importlib.metadata.version("laya"))'
0.4.3
+ /tmp/laya-venv/bin/python -c 'import torch; print(torch.__version__); print("cuda", torch.cuda.is_available())'
2.14.1+cu130
cuda False
+ date +%F
2026-10-14
+ date +%Z
KST
+ /tmp/laya-venv/bin/python -u - <<'PY'
from laya import Router

router = Router(max_loaded=1)
questions = {
    "department": {
        "type": "choice",
        "instructions": "이 메일을 처리할 부서는?",
        "criteria": {
            "billing": "결제, 환불, 청구서",
            "technical": "버그, 장애, 오류",
            "other": "그 외",
        },
    },
    "urgency": {
        "type": "score",
        "instructions": "얼마나 급한가?",
        "criteria": ["급하지 않음", "곧", "업무 중단"],
    },
    "churn_risk": {
        "type": "noul",
        "instructions": "고객이 해지하거나 떠나겠다고 말하는가?",
    },
}
mails = [
    ("outage", "로그인 버튼을 누르면 500 오류가 나고 팀 전체가 업무를 못 하고 있습니다."),
    ("quote", "다음 분기 견적서를 받아 볼 수 있을까요? 급하지 않습니다."),
    ("refund", "환불이 일주일째 처리되지 않네요. 이번 주 안에 안 되면 다른 서비스로 옮기겠습니다."),
    ("thanks", "지난번 안내 감사합니다. 잘 해결됐어요."),
]

def fmt(x):
    return format(float(x), ".4f")

def gate(answer):
    return answer.get("abstention", "-")

for name, mail in mails:
    result = router.predict(mail, questions, model="multilingual")
    answers = result["answers"]
    department = answers["department"]
    urgency = answers["urgency"]
    churn = answers["churn_risk"]
    print(
        "MAIL", name,
        "department=" + department["choice"],
        "confidence=" + fmt(department["confidence"]),
        "answer_confidence=" + fmt(department["answer_confidence"]),
        "urgency=" + fmt(urgency["score"]),
        "urgency_confidence=" + fmt(urgency["confidence"]),
        "urgency_answer_confidence=" + fmt(urgency["answer_confidence"]),
        "churn_noul=" + fmt(churn["noul"]),
        "churn_confidence=" + fmt(churn["confidence"]),
        "churn_answer_confidence=" + fmt(churn["answer_confidence"]),
        "model=" + result["routing"]["model"],
    )

billing_mail = "3월 요금이 두 번 결제됐습니다. 오늘 중으로 중복 결제분을 환불해 주지 않으면 해지하겠습니다."
pair = {"department": questions["department"], "churn_risk": questions["churn_risk"]}
seen = {}
for threshold in (None, 0.5, 0.6):
    kwargs = {} if threshold is None else {"min_confidence": threshold}
    result = router.predict(billing_mail, pair, model="multilingual", **kwargs)
    department = result["answers"]["department"]
    churn = result["answers"]["churn_risk"]
    label = "None" if threshold is None else format(threshold, ".1f")
    seen[label] = (gate(department), gate(churn), fmt(department["answer_confidence"]), fmt(churn["answer_confidence"]), fmt(department["confidence"]), fmt(churn["noul"]))
    print(
        "THRESH", "min_confidence=" + label,
        "department=" + department["choice"],
        "confidence=" + fmt(department["confidence"]),
        "answer_confidence=" + fmt(department["answer_confidence"]),
        "abstention=" + str(gate(department)),
        "churn_noul=" + fmt(churn["noul"]),
        "churn_confidence=" + fmt(churn["confidence"]),
        "churn_answer_confidence=" + fmt(churn["answer_confidence"]),
        "churn_abstention=" + str(gate(churn)),
    )

# 엔트로피 confidence 가 0.6 미만이어도 answer_confidence 가 높으면 보류되지 않는 메일.
for name, mail in (("outage", mails[0][1]), ("thanks", mails[3][1])):
    result = router.predict(mail, questions, model="multilingual", min_confidence=0.6)
    department = result["answers"]["department"]
    churn = result["answers"]["churn_risk"]
    print(
        "GATE06", name,
        "department=" + department["choice"],
        "confidence=" + fmt(department["confidence"]),
        "answer_confidence=" + fmt(department["answer_confidence"]),
        "abstention=" + str(gate(department)),
        "churn_noul=" + fmt(churn["noul"]),
        "churn_answer_confidence=" + fmt(churn["answer_confidence"]),
        "churn_abstention=" + str(gate(churn)),
    )

none_dept, none_churn, _, _, _, _ = seen["None"]
low_dept, low_churn, _, _, _, _ = seen["0.5"]
high_dept, high_churn, dept_ans, churn_ans, _, _ = seen["0.6"]
if none_dept != "-" or none_churn != "-":
    raise SystemExit("ungated call wrote an abstention field")
if low_dept != "passed" or low_churn != "passed":
    raise SystemExit("min_confidence 0.5 did not pass both answers")
if high_dept != "passed" or high_churn != "abstained":
    raise SystemExit("min_confidence 0.6 did not split department and churn")
print(
    "SUCCESS choice score noul accepted;"
    " min_confidence=0.6 department abstention=passed"
    " answer_confidence=" + dept_ans +
    " churn abstention=abstained"
    " churn_answer_confidence=" + churn_ans
)
PY
MAIL outage department=technical confidence=0.8199 answer_confidence=0.9552 urgency=1.8978 urgency_confidence=0.6911 urgency_answer_confidence=0.9047 churn_noul=0.0190 churn_confidence=0.9810 churn_answer_confidence=0.9810 model=multilingual
MAIL quote department=billing confidence=0.2027 answer_confidence=0.6560 urgency=1.1586 urgency_confidence=0.0305 urgency_answer_confidence=0.4545 churn_noul=0.0083 churn_confidence=0.9917 churn_answer_confidence=0.9917 model=multilingual
MAIL refund department=billing confidence=0.9844 answer_confidence=0.9976 urgency=1.9123 urgency_confidence=0.7245 urgency_answer_confidence=0.9148 churn_noul=0.0627 churn_confidence=0.9373 churn_answer_confidence=0.9373 model=multilingual
MAIL thanks department=technical confidence=0.5035 answer_confidence=0.8398 urgency=1.1560 urgency_confidence=0.0246 urgency_answer_confidence=0.3891 churn_noul=0.0028 churn_confidence=0.9972 churn_answer_confidence=0.9972 model=multilingual
THRESH min_confidence=None department=billing confidence=0.5725 answer_confidence=0.8724 abstention=- churn_noul=0.5814 churn_confidence=0.5814 churn_answer_confidence=0.5814 churn_abstention=-
THRESH min_confidence=0.5 department=billing confidence=0.5725 answer_confidence=0.8724 abstention=passed churn_noul=0.5814 churn_confidence=0.5814 churn_answer_confidence=0.5814 churn_abstention=passed
THRESH min_confidence=0.6 department=billing confidence=0.5725 answer_confidence=0.8724 abstention=passed churn_noul=0.5814 churn_confidence=0.5814 churn_answer_confidence=0.5814 churn_abstention=abstained
GATE06 outage department=technical confidence=0.8199 answer_confidence=0.9552 abstention=passed churn_noul=0.0190 churn_answer_confidence=0.9810 churn_abstention=passed
GATE06 thanks department=technical confidence=0.5035 answer_confidence=0.8398 abstention=passed churn_noul=0.0028 churn_answer_confidence=0.9972 churn_abstention=passed
SUCCESS choice score noul accepted; min_confidence=0.6 department abstention=passed answer_confidence=0.8724 churn abstention=abstained churn_answer_confidence=0.5814
Laya 질문 타입 실행 화면: choice·score·noul과 min_confidence 비교를 실행한 화면입니다
2026-10-14 14:54:41 KST, Debian 13 — choice·score·noul과 min_confidence 비교를 실행한 화면입니다.

실행 환경

아래 표의 값은 위 출력 블록의 OS·Python·laya·torch 줄과 실행 시각에서 옮겼습니다.

항목값
OSDebian GNU/Linux 13 (trixie)
Python3.12.15
laya0.4.3
torch2.14.1+cu130
CUDAFalse
실행일2026-10-14
시간대KST
체크포인트multilingual

torch 버전 문자열에는 cu130이 들어 있지만, 같은 출력의 cuda는 False입니다. 이번 분류는 CPU에서 계산된 결과이고, 메일 네 통의 model은 모두 multilingual입니다.

메일 네 통의 choice, score, noul

장애 메일은 department가 technical이고 confidence는 0.8199, answer_confidence는 0.9552입니다. urgency는 1.8978로, 급하지 않음에서 업무 중단까지인 눈금의 위쪽에 있습니다. urgency_answer_confidence는 0.9047입니다. churn_noul은 0.0190인데 churn_confidence와 churn_answer_confidence는 둘 다 0.9810입니다. 문장은 장애를 말하고, 출력된 P(예)는 0.0190입니다. 보류 게이트가 읽는 숫자는 answer_confidence 0.9810입니다.

견적 메일은 다음 분기 견적을 묻고 급하지 않다고 적혀 있습니다. department는 billing이고 confidence는 0.2027, answer_confidence는 0.6560입니다. 네 통의 부서 confidence 가운데 이 0.2027이 가장 낮습니다. urgency는 1.1586이고 urgency_confidence는 0.0305, urgency_answer_confidence는 0.4545입니다. 문장은 급하지 않다고 하는데 기대 수준은 0에 붙어 있지 않습니다. churn_noul은 0.0083이고, 그 answer_confidence는 0.9917입니다.

환불 메일은 환불이 일주일째이고, 이번 주 안에 처리되지 않으면 다른 서비스로 옮기겠다고 합니다. department는 billing, confidence는 0.9844, answer_confidence는 0.9976입니다. urgency는 1.9123, urgency_answer_confidence는 0.9148입니다. 부서와 긴급도는 환불과 기한이라는 말과 같은 방향입니다. churn_noul은 0.0627입니다. 문장은 이탈을 말하는데 P(예)는 0.0627에 머뭅니다. churn_answer_confidence는 0.9373이라, 게이트가 보는 값은 확신 있는 아니오입니다.

감사 메일은 안내 덕분에 해결됐다는 문장인데 department는 technical, confidence는 0.5035, answer_confidence는 0.8398입니다. urgency는 1.1560이고 urgency_answer_confidence는 0.3891입니다. churn_noul은 0.0028, churn_answer_confidence는 0.9972입니다. 출력된 부서 라벨은 technical입니다.

min_confidence를 비울 때, 0.5일 때, 0.6일 때

비교에 쓴 문장은 3월 요금이 두 번 결제됐고, 오늘 중으로 환불하지 않으면 해지하겠다는 메일입니다. 질문은 department와 churn_risk 두 개입니다. 세 줄의 점수 자체는 같고 보류 표시만 달라집니다.

min_confidence를 넘기지 않으면 department는 billing, confidence는 0.5725, answer_confidence는 0.8724입니다. abstention 키가 없을 때는 하이픈으로 표시하므로, 이 줄의 abstention은 하이픈입니다. churn_noul과 churn_confidence와 churn_answer_confidence는 모두 0.5814이고 churn_abstention도 하이픈입니다. 게이트를 끄면 그 키를 쓰지 않습니다.

0.5에서는 점수가 그대로이고 department와 churn의 abstention이 모두 passed입니다. 0.6에서는 department는 passed이고 churn은 abstained입니다. department의 엔트로피 confidence 0.5725는 0.6보다 작은데 보류되지 않았습니다. 같은 줄의 answer_confidence 0.8724가 0.6 이상이기 때문입니다. churn의 answer_confidence 0.5814는 0.6보다 작아서 abstained입니다.

이 이중 결제 메일에서는 noul 값과 confidence, answer_confidence가 0.5814로 같습니다. P(예)가 더 큰 쪽이라 세 숫자가 겹친 경우입니다. 장애 메일은 반대쪽으로, churn_noul 0.0190과 answer_confidence 0.9810이 다릅니다. docs/structured.md는 choice와 score의 confidence를 1 – H(p) / log(k)로, noul의 confidence를 max(p_true, 1 – p_true)로, answer_confidence를 max(p)로 적고, min_confidence는 answer_confidence와 비교한다고 설명합니다. 이번 두 줄은 그 설명과 같이 움직입니다.

같은 0.6을 장애 메일과 감사 메일에도 걸었습니다. 장애 메일은 department abstention이 passed이고, churn_noul이 0.0190인데도 churn_abstention은 passed입니다. 감사 메일은 엔트로피 confidence가 0.5035로 0.6에 못 미치지만 answer_confidence 0.8398 덕분에 department abstention이 passed입니다. confidence만 보고 0.6에서 보류될 거라 짐작하면 이 두 줄과 어긋납니다.

질문 타입을 허용 목록으로 바꾸면 분류가 되고, min_confidence를 걸어도 원점수는 그대로 남습니다. 아쉬운 점은 스칼라 하나로는 질문 종류마다 임계값을 나눌 수 없다는 것입니다. 0.6은 부서의 answer_confidence 0.8724를 통과시키면서 noul의 0.5814는 보류합니다.

multi 대신 태그별 noul, 스칼라 대신 버킷별 임계값

multi를 쓰려던 이유는 태그가 여러 개였기 때문입니다. 장애 메일에 billing과 technical을 각각 noul로 물으면 타입 검사를 통과합니다. 이어서 버킷 맵의 키를 not-a-bucket으로 일부러 잘못 적어 거절 원문을 보고, choice:3-5는 0.50, noul:2는 0.90, 나머지 default는 0.0으로 이중 결제 메일을 다시 게이트합니다. 문서 주소의 상태 코드는 같은 점검에서 HEAD 요청으로 찍었습니다.

from laya import Router

router = Router(max_loaded=1)
outage = "로그인 버튼을 누르면 500 오류가 나고 팀 전체가 업무를 못 하고 있습니다."
billing_mail = "3월 요금이 두 번 결제됐습니다. 오늘 중으로 중복 결제분을 환불해 주지 않으면 해지하겠습니다."

def fmt(x):
    return format(float(x), ".4f")

# multi 한 칸 대신, 태그마다 noul 질문을 둔다.
split = {
    "billing": {"type": "noul", "instructions": "결제, 환불, 청구 메일인가?"},
    "technical": {"type": "noul", "instructions": "버그, 장애, 오류 메일인가?"},
}
split_result = router.predict(outage, split, model="multilingual")
for name, answer in split_result["answers"].items():
    print(
        "SPLIT", name,
        "noul=" + fmt(answer["noul"]),
        "confidence=" + fmt(answer["confidence"]),
        "answer_confidence=" + fmt(answer["answer_confidence"]),
        "model=" + split_result["routing"]["model"],
    )

pair = {
    "department": {
        "type": "choice",
        "instructions": "이 메일을 처리할 부서는?",
        "criteria": {
            "billing": "결제, 환불, 청구서",
            "technical": "버그, 장애, 오류",
            "other": "그 외",
        },
    },
    "churn_risk": {"type": "noul", "instructions": "고객이 해지하거나 떠나겠다고 말하는가?"},
}
try:
    router.predict(billing_mail, pair, model="multilingual", min_confidence={"not-a-bucket": 0.5})
    raise SystemExit("invalid bucket key was accepted")
except ValueError as exc:
    print("BADKEY", exc)

mapped = router.predict(
    billing_mail,
    pair,
    model="multilingual",
    min_confidence={"choice:3-5": 0.50, "noul:2": 0.90, "default": 0.0},
)
department = mapped["answers"]["department"]
churn = mapped["answers"]["churn_risk"]
print(
    "MAP",
    "department=" + department["choice"],
    "answer_confidence=" + fmt(department["answer_confidence"]),
    "abstention=" + department["abstention"],
    "threshold=" + fmt(department["abstention_threshold"]),
    "churn_noul=" + fmt(churn["noul"]),
    "churn_answer_confidence=" + fmt(churn["answer_confidence"]),
    "churn_abstention=" + churn["abstention"],
    "churn_threshold=" + fmt(churn["abstention_threshold"]),
)
if department["abstention"] != "passed" or churn["abstention"] != "abstained":
    raise SystemExit("bucket map did not split the two answers")
print(
    "SUCCESS noul split accepted;"
    " bucket map department abstention=passed threshold=0.5000"
    " churn abstention=abstained threshold=0.9000"
)

공식 문서 HEAD 상태와 noul 분리·버킷 맵 실행 숫자는 아래 출력에 있습니다. 잘못된 버킷 키가 거절되는 줄과, 올바른 맵이 부서와 이탈을 갈라 놓는 줄을 이어서 보면 됩니다.

+ curl -sI -o /dev/null -w '%{http_code} %{url_effective}\n' https://github.com/NandhaKishorM/laya/blob/main/README.md
200 https://github.com/NandhaKishorM/laya/blob/main/README.md
+ curl -sI -o /dev/null -w '%{http_code} %{url_effective}\n' https://github.com/NandhaKishorM/laya/blob/main/docs/structured.md
200 https://github.com/NandhaKishorM/laya/blob/main/docs/structured.md
+ /tmp/laya-venv/bin/python -u - <<'PY'
from laya import Router

router = Router(max_loaded=1)
outage = "로그인 버튼을 누르면 500 오류가 나고 팀 전체가 업무를 못 하고 있습니다."
billing_mail = "3월 요금이 두 번 결제됐습니다. 오늘 중으로 중복 결제분을 환불해 주지 않으면 해지하겠습니다."

def fmt(x):
    return format(float(x), ".4f")

# multi 한 칸 대신, 태그마다 noul 질문을 둔다.
split = {
    "billing": {"type": "noul", "instructions": "결제, 환불, 청구 메일인가?"},
    "technical": {"type": "noul", "instructions": "버그, 장애, 오류 메일인가?"},
}
split_result = router.predict(outage, split, model="multilingual")
for name, answer in split_result["answers"].items():
    print(
        "SPLIT", name,
        "noul=" + fmt(answer["noul"]),
        "confidence=" + fmt(answer["confidence"]),
        "answer_confidence=" + fmt(answer["answer_confidence"]),
        "model=" + split_result["routing"]["model"],
    )

pair = {
    "department": {
        "type": "choice",
        "instructions": "이 메일을 처리할 부서는?",
        "criteria": {
            "billing": "결제, 환불, 청구서",
            "technical": "버그, 장애, 오류",
            "other": "그 외",
        },
    },
    "churn_risk": {"type": "noul", "instructions": "고객이 해지하거나 떠나겠다고 말하는가?"},
}
try:
    router.predict(billing_mail, pair, model="multilingual", min_confidence={"not-a-bucket": 0.5})
    raise SystemExit("invalid bucket key was accepted")
except ValueError as exc:
    print("BADKEY", exc)

mapped = router.predict(
    billing_mail,
    pair,
    model="multilingual",
    min_confidence={"choice:3-5": 0.50, "noul:2": 0.90, "default": 0.0},
)
department = mapped["answers"]["department"]
churn = mapped["answers"]["churn_risk"]
print(
    "MAP",
    "department=" + department["choice"],
    "answer_confidence=" + fmt(department["answer_confidence"]),
    "abstention=" + department["abstention"],
    "threshold=" + fmt(department["abstention_threshold"]),
    "churn_noul=" + fmt(churn["noul"]),
    "churn_answer_confidence=" + fmt(churn["answer_confidence"]),
    "churn_abstention=" + churn["abstention"],
    "churn_threshold=" + fmt(churn["abstention_threshold"]),
)
if department["abstention"] != "passed" or churn["abstention"] != "abstained":
    raise SystemExit("bucket map did not split the two answers")
print(
    "SUCCESS noul split accepted;"
    " bucket map department abstention=passed threshold=0.5000"
    " churn abstention=abstained threshold=0.9000"
)
PY
SPLIT billing noul=0.0677 confidence=0.9323 answer_confidence=0.9323 model=multilingual
SPLIT technical noul=0.9324 confidence=0.9324 answer_confidence=0.9324 model=multilingual
BADKEY min_confidence map keys must be a bucket like 'choice:3-5' or 'default', got 'not-a-bucket'
MAP department=billing answer_confidence=0.8724 abstention=passed threshold=0.5000 churn_noul=0.5814 churn_answer_confidence=0.5814 churn_abstention=abstained churn_threshold=0.9000
SUCCESS noul split accepted; bucket map department abstention=passed threshold=0.5000 churn abstention=abstained threshold=0.9000
Laya 질문 타입 실행 화면: noul 분리와 버킷 맵 게이트를 점검한 화면입니다
2026-10-14 14:58:57 KST, Debian 13 — noul 분리와 버킷 맵 게이트를 점검한 화면입니다.

HEAD 결과는 README와 docs/structured.md 모두 200입니다. 장애 메일의 billing noul은 0.0677, answer_confidence는 0.9323입니다. technical noul은 0.9324, answer_confidence도 0.9324입니다. 로그인 500으로 업무가 멈춘다는 문장에서 technical 쪽 P(예)가 높고 billing 쪽은 낮습니다. 각 줄은 독립된 P(예)입니다.

존재하지 않는 키 not-a-bucket을 넘기면 min_confidence map keys must be a bucket like ‘choice:3-5’ or ‘default’, got ‘not-a-bucket’이라는 ValueError가 납니다.

올바른 맵에서는 department가 billing, answer_confidence는 0.8724, abstention은 passed, threshold는 0.5000입니다. churn_noul은 0.5814, churn_answer_confidence도 0.5814, churn_abstention은 abstained, churn_threshold는 0.9000입니다. 스칼라 0.6일 때와 같이 부서는 통과하고 이탈은 보류되지만, 출력에 질문마다 적용된 임계값이 따로 남습니다. noul:2를 0.90으로 두었기 때문에 0.5814가 보류됐고, choice:3-5의 0.50은 0.8724를 통과시켰습니다.

공식 README는 min_confidence가 기본으로 꺼져 있고, 켜면 answer_confidence가 임계값보다 낮을 때 abstention을 abstained로 남긴다고 적습니다. 임계값 이상이면 passed입니다. 같은 문서는 출하 상태의 체크포인트가 과신하는 편이고, 다국어 체크포인트에는 맞춰 둔 온도가 없다고 적습니다. 이번 실행이 읽은 것은 그 게이트의 표시이고, answer_confidence를 맞을 확률로 바꿔 쓰지는 않습니다.

이 서버의 laya는 0.4.3, torch는 2.14.1+cu130이고 CUDA 출력은 False이니, 이번 분류는 CPU 경로의 결과로 읽으면 됩니다. laya 0.4.3은 PyPI의 laya 0.4.3 릴리스 페이지에 올라와 있는 배포판이고, 그 페이지가 밝힌 요구 Python은 3.10 이상이라 표의 Python 3.12.15와 맞습니다(확인일 2026-10-17). 온도를 맞추는 방법은 같은 README에 있고, 이 글의 출력 블록에 남은 값은 타입 수정, 스칼라 게이트, noul 분리, 버킷 맵의 표시입니다. 운영에 반영하기 전에는 같은 메일 표본으로 answer_confidence 분포를 한 번 더 보고, 부서 라우팅과 이탈 알람의 임계값을 나눠 두는 구성을 권합니다.

태그마다 noul을 두면 태그별 예 확률을 받을 수 있고, 버킷 맵은 부서와 이탈의 임계값을 한 호출 안에서 갈라 줍니다. 잘못된 키는 ValueError로 바로 드러납니다. 반면 태그가 늘수록 질문도 같이 늘고, 각 질문은 서로 독립된 P(예)를 돌려줍니다. 맵의 0.50과 0.90은 이 메일에서 보류가 어디에 붙는지 보여 주려고 고른 값이지, 이번 네 통으로 오류율을 맞춰 얻은 컷이 아닙니다.

type은 choice·score·noul로, 임계값은 버킷 맵으로

출력 블록에 결과가 남은 선택지만 표에 넣었습니다. 타입 수정, 스칼라 게이트, noul 분리, 버킷 맵 가운데 상황에 맞는 행을 고르면 됩니다.

선택이번 실행의 결과이렇게 읽습니다
choice, score, noul. 게이트 없음네 통이 분류됩니다원점수만 필요할 때 맞습니다
스칼라 min_confidence 0.5이중 결제 메일의 department와 churn이 모두 passedanswer_confidence 0.5814도 통과시킬 때 맞습니다
스칼라 min_confidence 0.6department는 passed(0.8724), churn은 abstained(0.5814)0.6 미만의 answer_confidence만 보류할 때 맞습니다
multi 대신 noul 두 개장애 메일 technical 0.9324, billing 0.0677태그 여러 개의 예 확률이 필요할 때 맞습니다
버킷 맵 choice:3-5=0.50, noul:2=0.90department passed, churn abstained질문 종류마다 임계값이 달라야 할 때 맞습니다

질문 정의를 처음 붙이는 경우에는 type을 choice, score, noul 중에서 고르면 됩니다. 태그가 여러 개라면 하나의 multi 대신 태그마다 noul을 두면 됩니다. 보류 표시가 필요할 때는 min_confidence를 켜고, 비교 대상은 출력의 answer_confidence로 읽으면 됩니다. 엔트로피 confidence가 0.6보다 작다는 사실만으로 보류를 예상하면, 감사 메일의 0.5035와 이중 결제 메일의 0.5725가 모두 passed였던 이번 결과와 어긋납니다.

부서 라우팅과 이탈 질문에 서로 다른 임계값이 필요한 경우에는 버킷 맵을 권장합니다. 이번 맵은 choice:3-5를 0.50, noul:2를 0.90으로 두었고, department는 통과하고 churn은 보류됐습니다. 환불 메일처럼 문장은 다른 서비스로 옮기겠다고 하는데 churn_noul은 0.0627이고 answer_confidence는 0.9373입니다. 그 문장은 분류 결과와 따로 사람이 읽어야 합니다. 견적 메일처럼 department confidence가 0.2027이고 urgency_answer_confidence가 0.4545이며, 급하지 않다는 말과 urgency 1.1586이 떨어져 있는 경우에는 사람 검토를 권장합니다.

같은 Laya 라이브러리에서 model 인자와 다국어 라우팅 문제를 다룬 글은 Laya에 system1을 모델로 넘기면 ValueError가 나는 이유입니다.

Similar Posts

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다