Laya unknown type ‘text’ ValueError, 답장은 Ollama로
고객 메일을 Laya로 분류하다 보면 “판정만 하지 말고 답장까지 한 번에 받으면 편하겠다”는 생각이 들기 마련입니다. 그래서 질문 사전의 type에 "text"를 적고 instructions에 “답장을 써라”를 넣으면, Laya는 답장 대신 ValueError: question 'q': unknown type 'text'를 던지고 멈춥니다. 오류 문장은 짧지만, 고칠 곳이 타입 이름 하나인지 아니면 설계를 바꿔야 하는지는 이 문장만 봐서는 드러나지 않습니다.
이 글은 그 오류를 GPU 없는 Debian 13에서 laya 0.4.3으로 다시 일으키고, 원인을 라이브러리 코드와 공식 설명에서 찾은 다음, 답장 요청을 두 단계로 쪼개는 방법으로 해결하는 과정을 보여 줍니다. 첫 단계는 Laya가 “답장이 필요한가”와 “어느 부서인가”를 숫자로 판정하는 것이고, 둘째 단계는 답장이 필요한 메일만 Ollama의 로컬 모델이 문장을 쓰게 하는 것입니다. 마지막으로 2026-10-18 기준 최신판인 0.4.11에서도 같은 오류가 나는지 확인하고, 배포 파이프라인에서 이 실수를 미리 걸러 내는 검사를 붙였습니다. 세 질문 타입의 차이나 multi 오류, min_confidence 게이트처럼 Laya 질문 설계 전반은 Laya 질문 타입은 choice·score·noul뿐, multi는 ValueError에서 메일 네 통으로 따로 정리했으니, 여기서는 text라는 서술형 요청 하나에 집중하겠습니다.
실행 환경은 아래와 같습니다. 같은 머신에서 버전만 바꿔 동작을 비교하려고 0.4.3과 0.4.11을 가상환경 두 개에 나눠 설치했습니다.
| 항목 | 값 | 확인한 출력 |
|---|---|---|
| OS | Debian GNU/Linux 13 (trixie), x86_64 | /etc/os-release, uname -m |
| CPU / RAM | 가상 CPU 8코어 / 15GiB | nproc, free -h |
| GPU | 없음 | nvidia-smi: command not found, torch.cuda.is_available() → False |
| Python | 3.12.15 | sys.version |
| laya | 0.4.3 (재현·해결), 0.4.11 (최신판 확인) | importlib.metadata |
| torch | 2.14.1+cu130 (CPU로 실행) | torch.__version__ |
| Ollama / 모델 | 0.40.3 / qwen2.5-0.5b-local, 491 MB | ollama –version, ollama list |
| 실행 시각 | 2026-10-18 19:36~19:37 KST | 터미널 머리글 |
표에서 GPU 줄을 굳이 남긴 이유가 있습니다. Laya는 CPU만으로도 돌아가는 판정 모델이라, 운영 전에 작은 인스턴스나 CI 러너에서 질문 스키마를 시험해 보기 좋습니다. 그런 환경에서 나오는 오류인지, 특정 가속기 설정에서만 나오는 오류인지를 먼저 갈라 두어야 원인을 엉뚱한 곳에서 찾지 않습니다.
type “text”를 넣으면 predict가 질문 검사에서 바로 멈춥니다
재현 코드는 세 줄이면 충분합니다. 질문 id는 q, 타입은 text, 지시문은 “답장을 써라”이고 메일 본문으로는 “환불해 주세요”를 넘깁니다. 모델 이름을 지정하지 않았으니 기본 체크포인트가 쓰이는 호출입니다.
from laya import Router
questions = {"q": {"type": "text", "instructions": "답장을 써라"}}
Router().predict("환불해 주세요", questions)
실행할 때 일부러 | tail -2로 마지막 두 줄만 받고, 바로 뒤에서 파이프 전체의 종료 코드와 파이썬 프로세스의 종료 코드를 따로 찍었습니다. CI 로그를 짧게 보려고 이렇게 자르는 경우가 많은데, 그때 무엇이 가려지는지 같이 보려는 의도입니다. 파이썬의 트레이스백은 표준 오류로 나가기 때문에 2>&1로 표준 출력에 합쳐야 tail이 오류 줄을 받습니다. 이 리디렉션을 빼먹으면 트레이스백은 화면에 전부 쏟아지는데 tail이 받는 표준 출력은 비어 있는, 반대 모양의 혼란이 생깁니다.
engineer@box:~/demo$ python ask_text.py 2>&1 | tail -2; echo "tail=$? python=${PIPESTATUS[0]}"
raise ValueError("question %r: unknown type %r; use one of %s" % (qid, t, sorted(QTYPES)))
ValueError: question 'q': unknown type 'text'; use one of ['choice', 'noul', 'score']
tail=0 python=1
engineer@box:~/demo$ python -c "from laya.common import QTYPES; print(QTYPES)"
{'choice': 0, 'score': 1, 'noul': 2}

마지막 줄의 메시지는 친절한 편입니다. 거부된 질문 id(q)와 타입 문자열(text), 그리고 허용되는 타입 세 개를 정렬해서 보여 줍니다. 눈여겨볼 곳은 그 아래 tail=0 python=1입니다. 파이썬은 1로 끝났지만 파이프의 종료 코드는 마지막 명령인 tail의 0이라서, 셸 스크립트나 CI 단계가 이 호출을 성공으로 넘길 수 있습니다. 배포 전 점검 스크립트가 이런 식으로 출력을 잘라 쓰고 있다면, 오류 문장은 로그에 남는데 단계는 초록색으로 지나가는 일이 생깁니다.
text가 허용 목록에 없는 이유는 Laya가 문장을 만들지 않기 때문입니다
허용 목록은 설정 파일이 아니라 코드에 박혀 있습니다. 앞 코드 블록의 마지막 명령처럼 laya.common의 QTYPES를 출력하면 {'choice': 0, 'score': 1, 'noul': 2}라는 사전이 나옵니다. 이 사전은 laya/common.py에 정의돼 있고, 질문을 받을 때마다 agent.py의 _check_question이 타입 문자열이 이 사전의 키인지 확인합니다. 키가 아니면 추론에 들어가지 않고 위의 ValueError를 냅니다. 0.4.3 소스에서 해당 검사는 if not isinstance(t, str) or t not in QTYPES: 한 줄이라, 옵션이나 환경 변수로 타입을 늘릴 여지는 없습니다.
사전의 값이 0, 1, 2라는 정수인 점도 힌트입니다. agent.py는 이 번호로 타입별 온도 보정 값을 고르고, 세 타입 모두 정해진 라벨이나 눈금, 예와 아니오 위에 확률을 매기는 방식입니다. PyPI 설명은 laya를 “non-autoregressive System 1 decision engine with calibrated probabilities”라고 소개하고, README는 질문 하나를 한 번의 순전파(single forward pass)로 평가하며 noul의 값을 “예일 확률(probability the answer is yes)”이라고 설명합니다. 토큰을 하나씩 이어 붙여 문장을 만드는 생성 모델이 아니니, 답장 같은 자유 서술을 돌려줄 타입이 처음부터 없는 것입니다.
그래서 이 오류는 타입 이름을 아무거나 허용된 것으로 바꾼다고 해결되지 않습니다. "text"를 "choice"로만 바꿔 보면 이번에는 a choice question takes 'criteria' as a dict of label -> description, or a list of labels라는 다른 ValueError가 납니다. 고를 라벨이 없으니 당연한 결과이고, 라벨을 채워 넣어도 Laya가 돌려주는 것은 라벨 하나일 뿐 문장이 아닙니다. 고쳐야 하는 것은 요청의 모양입니다. “답장을 써라”를 Laya가 답할 수 있는 판정 질문으로 바꾸고, 문장을 쓰는 일은 생성 모델에 넘겨야 합니다.
답장 요청을 noul과 choice 판정으로 바꿔 숫자를 먼저 받습니다
같은 환불 메일을 두 질문으로 다시 적었습니다. needs_reply는 “고객이 환불이나 조치를 요청하는가?”를 묻는 noul 질문이고, department는 billing, technical, other 중 하나를 고르는 choice 질문입니다. 앞의 질문은 답장을 쓸지 말지를, 뒤의 질문은 누구 이름으로 답할지를 정하는 데 씁니다. 한국어 메일이라 model="multilingual"을 명시했습니다. 아래는 판정 스크립트에서 질문과 호출 부분만 옮긴 것입니다. 라우터 생성은 다음 절의 전체 코드와 같고, 출력은 noul 값, 부서와 confidence, 호출 시간을 한 줄씩 찍도록 따로 짰습니다.
mail = "3월 요금이 두 번 결제됐습니다. 중복 결제분을 환불해 주세요."
questions = {
"needs_reply": {
"type": "noul",
"instructions": "고객이 환불이나 조치를 요청하는가?",
},
"department": {
"type": "choice",
"instructions": "이 메일을 처리할 부서는?",
"criteria": {"billing": "결제, 환불, 청구서", "technical": "버그, 장애, 오류", "other": "그 외"},
},
}
answers = router.predict(mail, questions, model="multilingual")["answers"]
스크립트는 noul 값, 부서와 confidence, 그리고 predict 한 번에 걸린 시간을 찍습니다. 시간을 함께 찍은 이유는 판정 단계가 요청 경로에 들어가도 되는지 가늠하기 위해서입니다. 실행 결과는 아래와 같습니다.
engineer@box:~/demo$ python fix.py
needs_reply noul = 0.975
department = billing (confidence 0.739 )
predict took 8532 ms

이번에는 예외 없이 숫자 두 개가 돌아왔습니다. 환불을 요청하느냐는 질문에 0.975, 부서는 billing이고 그 선택의 confidence는 0.739입니다. 8,532ms라는 시간은 프로세스의 첫 호출이라 모델 적재 시간이 섞인 값이므로, 그대로 처리 속도로 읽으면 안 됩니다. 같은 프로세스에서 두 번째 메일을 넣은 다음 절의 실행에서는 판정이 249ms에 끝났습니다.
- 장점: 결과가 확률과 라벨이라 임계값으로 분기하기 쉽고, 같은 입력에 같은 판정이 나와 로그로 추적하기 좋습니다.
- 장점: 질문 정의가 JSON으로 고정되니 배포 전에 스키마를 검사해 둘 수 있습니다.
- 단점: 답장 문장은 여전히 없습니다. 고객에게 보낼 글은 다른 도구가 만들어야 합니다.
- 단점: choice는 어떤 메일이든 라벨 하나를 반드시 고르므로, 판정이 무의미한 메일까지 부서가 붙습니다.
답장 문장은 noul이 넘긴 메일만 Ollama 로컬 모델에 맡깁니다
남은 일은 문장입니다. 생성은 같은 서버의 Ollama에 맡기되, 모든 메일을 보내지 않고 needs_reply가 0.5 이상인 메일만 넘기도록 했습니다. 모델은 이미 받아 둔 qwen2.5-0.5b-local(491 MB)이고, 출력의 흔들림을 줄이려고 Ollama API 문서에 있는 temperature 0, seed 고정, num_predict 80을 옵션으로 줬습니다. 다만 이 설정으로도 답장 문장이 실행마다 똑같이 나오지는 않았습니다. stream을 false로 두면 응답이 JSON 한 덩어리로 와서 파싱이 간단합니다.
import json, time, urllib.request
from laya import Router
router = Router(max_loaded=1)
DECIDE = {
"needs_reply": {"type": "noul", "instructions": "고객이 환불이나 조치를 요청하는가?"},
"department": {
"type": "choice",
"instructions": "이 메일을 처리할 부서는?",
"criteria": {"billing": "결제, 환불, 청구서", "technical": "버그, 장애, 오류", "other": "그 외"},
},
}
def draft_reply(mail, dept):
body = json.dumps({
"model": "qwen2.5-0.5b-local",
"prompt": f"다음 고객 메일에 {dept} 담당자로서 두 문장짜리 한국어 답장을 써라.\n\n{mail}",
"stream": False,
"options": {"temperature": 0, "seed": 7, "num_predict": 80},
}).encode()
req = urllib.request.Request("http://127.0.0.1:11434/api/generate", body,
{"Content-Type": "application/json"})
return json.load(urllib.request.urlopen(req, timeout=120))["response"].strip()
mails = [
"3월 요금이 두 번 결제됐습니다. 중복 결제분을 환불해 주세요.",
"지난번 안내 감사합니다. 잘 해결됐어요.",
]
for mail in mails:
t0 = time.perf_counter()
a = router.predict(mail, DECIDE, model="multilingual")["answers"]
t1 = time.perf_counter()
p, dept = a["needs_reply"]["noul"], a["department"]["choice"]
print(f"[{mail[:14]}…] laya noul={p:.3f} dept={dept} ({(t1 - t0) * 1000:.0f}ms)")
if p >= 0.5:
reply = draft_reply(mail, dept)
print(f" -> qwen2.5 답장 ({(time.perf_counter() - t1) * 1000:.0f}ms): {reply}")
else:
print(" -> 답장 생략 (noul < 0.5)")
코드에서 손이 가는 곳은 두 군데입니다. 하나는 Laya가 고른 부서 이름을 프롬프트에 끼워 넣는 부분으로, 판정 결과가 생성 단계의 입력이 되도록 이어 주는 연결점입니다. 다른 하나는 HTTP 호출을 표준 라이브러리 urllib로 처리한 부분입니다. 판정 프로세스에 Ollama 클라이언트 패키지를 따로 넣지 않아도 되고, timeout=120으로 Ollama 응답 대기를 최대 120초로 제한합니다. 생성 서버가 멈춰도 판정 워커가 그 이상 묶여 있지 않게 하려는 장치입니다.
시험 메일은 성격이 반대인 두 통으로 골랐습니다. 하나는 조치를 요구하는 환불 메일이고, 다른 하나는 답장이 필요 없는 감사 인사입니다. 같은 프로세스에서 차례로 넣었기 때문에 두 번째 메일의 시간은 모델이 이미 올라간 상태의 판정 시간입니다.
engineer@box:~/demo$ python route.py
[3월 요금이 두 번 결제됐…] laya noul=0.975 dept=billing (6186ms)
-> qwen2.5 답장 (427ms): 3월의 요금으로 두 번 결제가 이루어졌습니다. 중복 결제분을 환불해 주시기 바랍니다.
[지난번 안내 감사합니다. …] laya noul=0.001 dept=technical (249ms)
-> 답장 생략 (noul < 0.5)

흐름은 의도대로 갈렸습니다. 환불 메일은 noul 0.975로 문턱을 넘어 답장 초안이 붙었고, 감사 메일은 0.001이라 Ollama를 부르지 않았습니다. 생성에 걸린 427ms는 Ollama가 모델을 이미 메모리에 올려 둔 뒤에 잰 값이라, 이것도 그대로 처리 속도로 읽으면 안 됩니다. 같은 서버에서 모델을 처음 불러온 생성 호출은 서버 로그 기준 1.67초가 걸렸습니다. 감사 메일에 technical이라는 부서가 붙은 것은 앞에서 말한 choice의 성질 그대로입니다. noul로 먼저 거르지 않았다면 이 라벨을 믿고 기술팀 큐로 보냈을 테니, 판정 순서를 noul 먼저로 둔 이유가 여기서 드러납니다.
답장 내용은 따로 짚어야 합니다. 0.5B 모델이 쓴 두 문장은 고객의 말을 거의 그대로 되풀이했을 뿐, 환불을 처리하겠다는 담당자의 답이 아닙니다. 파이프라인 구조는 맞게 돌았지만 이 크기의 모델에 고객 응대 문장을 맡기기는 어렵다는 뜻이고, 운영에서는 더 큰 모델로 바꾸거나 부서별 답장 틀을 주고 빈칸만 채우게 하는 편이 낫습니다. 어느 쪽이든 Laya 쪽 코드는 바꿀 필요가 없다는 점이 역할을 나눈 구성의 이점입니다. 문장이 고정되지 않는다는 점도 기억해 둘 만합니다. temperature 0과 seed 7을 그대로 두고 같은 메일로 앞서 돌린 실행에서는 둘째 문장이 “환불해 주세요.”로 끝났습니다. 답장 문구를 테스트에서 문자열 그대로 비교하는 방식은 이 단계에 맞지 않습니다.
이 구성의 장점은 생성 호출이 답장이 필요한 메일로 한정된다는 데 있습니다. 감사 인사나 공지 회신이 많은 큐일수록 Ollama를 부르는 횟수가 줄고, 판정 모델과 생성 모델을 서로 상관없이 교체할 수 있습니다. 대신 서비스가 Laya 프로세스와 Ollama 서버 두 개가 되므로 헬스체크와 타임아웃도 각각 걸어 두어야 합니다. 문턱 0.5는 이번 시험을 위한 값일 뿐이라, 실제 큐의 메일 몇십 통으로 noul 분포를 먼저 본 뒤에 정해야 합니다.
0.4.11에서도 같은 오류, 배포 전에 걸러 내는 검사
버전이 올라가면서 text 같은 서술형 타입이 추가됐을 가능성도 있습니다. PyPI 기록상 0.4.3은 2026-10-12 02:08 KST, 0.4.11은 2026-10-18 02:03 KST에 올라왔습니다. 아래 출력은 0.4.11 가상환경에서 같은 파일을 실행한 결과, 0.4.3 환경에서 set -o pipefail을 켰을 때의 종료 코드, 그리고 질문 JSON을 배포 전에 검사하는 작은 스크립트의 결과입니다.
import json, sys
from laya.common import QTYPES
qs = json.load(open(sys.argv[1]))
bad = {k: v.get("type") for k, v in qs.items() if v.get("type") not in QTYPES}
print(sys.argv[1], "| allowed:", sorted(QTYPES), "| bad:", bad or "none")
sys.exit(1 if bad else 0)
검사 대상은 두 파일입니다. questions_old.json에는 처음의 text 질문을, questions.json에는 앞 절에서 고친 noul·choice 질문을 그대로 옮겨 두었습니다. 아래 출력의 첫 명령은 0.4.11 가상환경의 파이썬을 직접 지정해 실행한 것입니다.
engineer@box:~/demo$ ~/.venv-new/bin/python ask_text.py 2>&1 | tail -1
ValueError: question 'q': unknown type 'text'; use one of ['choice', 'noul', 'score']
engineer@box:~/demo$ source ~/.venv/bin/activate; set -o pipefail
engineer@box:~/demo$ python ask_text.py 2>&1 | tail -1; echo "exit=$?"
ValueError: question 'q': unknown type 'text'; use one of ['choice', 'noul', 'score']
exit=1
engineer@box:~/demo$ python check_schema.py questions_old.json; echo "exit=$?"
questions_old.json | allowed: ['choice', 'noul', 'score'] | bad: {'q': 'text'}
exit=1
engineer@box:~/demo$ python check_schema.py questions.json; echo "exit=$?"
questions.json | allowed: ['choice', 'noul', 'score'] | bad: none
exit=0

0.4.11도 메시지 한 글자 다르지 않은 ValueError를 냈습니다. 최신판으로 올린다고 서술형 타입이 생기지는 않으니, 업그레이드를 기다리기보다 구조를 나누는 쪽이 맞습니다. 0.4.3 이후 0.4.11까지 여덟 번(0.4.4~0.4.11) 릴리스가 나오는 동안 허용 타입이 그대로였다는 것은, 이 제약이 일시적인 미구현이 아니라 설계의 일부라는 뜻으로 읽힙니다. pipefail을 켜자 같은 파이프의 종료 코드가 0에서 1로 바뀌었습니다. 출력을 잘라 보는 습관은 그대로 두더라도, 이 옵션 하나만큼은 배포 스크립트 맨 위에 있어야 합니다.
스키마 검사는 모델을 읽지 않고 QTYPES만 가져와 질문 파일의 타입을 대조합니다. text가 남아 있는 옛 파일에는 bad: {'q': 'text'}와 함께 1을, noul·choice로 고친 파일에는 0을 돌려줬습니다. 체크포인트를 읽지 않으므로 한 번 실행이 1.3~1.4초 안팎에 끝납니다. 다만 laya.common을 불러오는 순간 torch도 함께 로드되기 때문에, 검사를 돌릴 이미지에는 laya와 torch가 설치돼 있어야 합니다. 그 조건만 갖추면 컨테이너 빌드 단계나 쿠버네티스 배포 전 잡(Job)에 넣기 어렵지 않고, 질문 정의를 다른 팀이 고치는 구조라면 여기서 먼저 막히는 편이 운영 중 예외보다 훨씬 다루기 쉽습니다. 라이브러리 내부 사전을 직접 읽는 방식이라 버전을 올릴 때마다 이 검사도 함께 돌려 보는 것이 안전합니다.
판정은 Laya, 문장은 로컬 LLM: 나눠 두는 편이 운영이 편합니다
| 선택 | 실행 결과 | 어울리는 자리 |
|---|---|---|
| type “text” 그대로 | 0.4.3·0.4.11 모두 unknown type ValueError, 파이프 종료 코드는 0 | 쓰지 않습니다 |
| noul·choice 판정만 | needs_reply 0.975, billing(0.739) | 라우팅·태깅처럼 문장이 필요 없는 큐 |
| noul 판정 → Ollama 생성 | 환불 메일만 답장 초안 427ms(모델 적재 후, 첫 호출 1.67초), 감사 메일은 생략 | 답장 초안이 필요한 고객 메일 |
| pipefail + 스키마 검사 | 옛 질문 파일 exit=1, 고친 파일 exit=0 | 배포 파이프라인, CI |
unknown type 'text'는 오타가 아니라 도구의 성격을 알려 주는 오류로 읽는 것이 맞습니다. Laya는 정해진 선택지 위에서 확률을 내는 판정 엔진이고, 서술형 답을 원하면 그 일을 생성 모델에 맡겨야 합니다. 메일을 부서별로 나누거나 태그만 붙이면 되는 큐라면 noul과 choice만으로 충분하고, 생성 모델을 붙일 이유가 없습니다.
이미 text 타입으로 적어 둔 질문이 여러 개라면, 지시문을 하나씩 읽고 답의 모양으로 옮길 곳을 정하면 됩니다. “환불을 요청하는가”처럼 예와 아니오로 끝나는 질문은 noul로, “어느 부서인가”처럼 정해진 이름 중 하나를 고르는 질문은 choice로 옮깁니다. “얼마나 급한가”처럼 단계가 있는 질문은 score가 맞습니다. 이 세 갈래 어디에도 들어가지 않고 문장을 요구하는 지시문만 남는데, 그것이 생성 모델로 넘길 몫입니다.
답장 초안까지 필요하다면 noul로 답장 여부를 먼저 거르고, 통과한 메일만 Ollama에 넘기는 구성을 추천합니다. 이때 생성 모델의 크기는 답장 품질로 따로 정해야 합니다. 이번 0.5B 모델은 흐름 확인에는 충분했지만 고객의 문장을 되풀이하는 수준이었습니다. 어떤 구성을 고르든 배포 스크립트에는 set -o pipefail과 질문 스키마 검사를 넣어, 타입 실수가 운영 큐에 닿기 전에 빌드에서 멈추게 해 두시길 권합니다. 세 타입의 의미와 min_confidence로 보류를 거는 방법은 Laya 질문 타입은 choice·score·noul뿐, multi는 ValueError에 이어서 정리해 두었습니다.
참고한 1차 자료는 다음과 같습니다. 모두 확인일 2026-10-18입니다. 패키지 설명과 릴리스 시각은 PyPI laya 0.4.3과 PyPI laya 0.4.11, 모델 카드는 Hugging Face convaiinnovations/laya, 소스 저장소는 GitHub NandhaKishorM/laya에서 확인했습니다. 생성 API의 stream, seed, num_predict 옵션은 Ollama API 문서, pipefail의 동작은 GNU Bash 매뉴얼의 Pipelines를 따랐습니다.
이 글은 ainewsnote가 작성했습니다. 작성 절차는 글 작성 기준 페이지에 정리해 두었습니다. 내용에 틀린 부분을 발견하면 해당 글 주소를 적어 문의 페이지로 보내 주세요. 제휴 링크는 없습니다.

