본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 24. 02:58

와일드카드 문장을 사용하여 모든 Home Assistant 음성을 커스텀 에이전트로 라우팅하기

요약

Home Assistant에서 모든 음성 명령을 커스텀 에이전트로 라우팅하는 과정에서 발생하는 기술적 문제와 해결 과정을 다룹니다. 와일드카드 사용 시 `MissingListError` 및 HTTP 500 오류가 발생하며, 심지어 정상적인 요청도 내장 인텐트(intents)에 의해 가로채지는 현상을 진단합니다.

핵심 포인트

  • 모든 음성 명령을 커스텀 에이전트로 라우팅하려면 와일드카드 사용이 필수적입니다.
  • HA 2026.5+ 버전에서는 와일드카드가 `MissingListError`와 HTTP 500 오류를 유발합니다.
  • 내장 인텐트들이 특정 키워드를 가로채어 커스텀 에이전트의 작동을 방해하는 '하이재킹' 문제가 발생합니다.

만약 Home Assistant의 내장 인텐트 (intents) 대신, 로컬 LLM이나 MCP 기반 어시스턴트 등 당신만의 대화 에이전트로 모든 음성 명령을 보내고 싶다면, 가장 명확한 방법은 모든 것을 잡아내는 와일드카드 (wildcard)를 사용하는 것입니다. 하지만 HA 2026.5+ 버전에서 이 명확한 접근 방식은 MissingListError와 함께 HTTP 500 에러를 반환하며, 정상적으로 파싱되는 쿼리들조차 내장 인텐트 (intents)에 의해 조용히 가로채집니다. 이것은 이를 작동시키기 위해 겪었던 실패 → 시도 → 해결의 과정입니다.

문제 (Problem)

목표: 모든 음성을 커스텀 에이전트(이 경우 MQTT 브릿지 뒤에 있는 로컬 Hermes 3 모델)로 라우팅하는 것입니다. 아무런 조건 없는 와일드카드를 포함한 conversation 트리거를 사용하는 명확한 방식으로 시도했을 때 나타나는 증상은 다음과 같습니다:

# automations.yaml — 명확한 catch-all 방식
- alias: "Route voice query to my agent"
  triggers:
...

무엇이든 말하면 대화 API (conversation API)는 다음과 같이 반환합니다:

HTTP 500 Internal Server Error
hassil.errors.MissingListError: Missing slot list {text}

그리고 500 에러가 발생하지 않는 요청들은 상황이 더 악화됩니다. 당신의 에이전트로 전달될 것이라 예상했던 쿼리들이 대신 내장 인텐트 (intents)에 의해 답변됩니다. "When is the next low tide(다음 저조 시간은 언제인가요)"라고 말하면 다음과 같이 돌아옵니다:

I'm sorry, no device is playing.
(죄송합니다, 재생 중인 장치가 없습니다.)

이는 "next"라는 단어에 MediaPlayerNext가 매칭되었기 때문입니다. 다른 내장 인텐트 (intents)들도 가세합니다: HassGetState는 "when is {name}"을 가로채고, HassTurnOn은 "turn on {name}"을 가로채며, HassFanSetSpeed는 "[set] {name} [to] {speed}"을 가로챕니다. 당신의 커스텀 라우팅 (custom routing)은 기회조차 얻지 못합니다.

진단 (Diagnosis)

두 가지 별개의 현상이 일어나고 있으며, 이를 혼동하기 쉽습니다.

진단 (Diagnosis)

두 가지 별개의 현상이 일어나고 있으며, 이를 혼동하기 쉽습니다.

1. MissingListError는 hassil이 {slot}을 해석하는 방식입니다. Home Assistant의 문장 매처(sentence matcher)는 hassil입니다. 3.x 라인에서는 (3.0.0은 2025년 3월에 재작성되었고, 3.5.0은 2025년 12월에 출시되었으며, 현재 HA가 사용하는 것이 3.x입니다 — 저희는 HA 2026.5에서 이 문제를 발견했습니다) 문장 템플릿 내의 {slot} 참조는 _이름 지정된 목록 조회(named list lookup)_로 해석됩니다. 이는 lists: 아래에 slot이라는 이름의 목록이 존재할 것을 기대합니다. 만약 그러한 목록이 등록되지 않으면, 파싱 과정에서 MissingListError가 발생하며, 이 오류는 /api/conversation/process 엔드포인트에서 HTTP 500으로 나타납니다. 단순히 {text}만 사용하는 것은 와일드카드가 아니며 — 이는 여러분이 정의하지 않은 text라는 이름의 목록을 참조하는 것입니다. (과거에 단순히 트리거 슬롯처럼 작동했던 설정에서 오셨다면, 이 변화를 인지해야 합니다. 이제는 와일드카드를 명시적으로 선언해야 합니다.)

2. 하이재킹은 의도 순서(intent ordering) 문제입니다. 500 에러가 나는 것을 막았다고 해도, HA의 내장 의도(built-in intents)들은 매치 세트(match set)에 포함되어 있습니다. 그중 몇 가지는 일반적인 영어까지 포괄할 만큼 광범위합니다 — MediaPlayerNext

이것은 오타가 아니라 구조적인 막다른 길입니다. conversation 트리거는 문장 템플릿(sentence templates)을 허용하지만, lists: 블록을 선언할 수 있는 곳을 제공하지 않습니다. 자동화 트리거(automation trigger)에는 wildcard: true라는 조절 장치가 없습니다. 따라서 트리거 명령에 넣는 모든 {slot}은 반드시 이름이 지정된 리스트 조회(named-list lookup)가 되어야 하며, 해당 리스트를 등록할 방법이 없습니다. Home Assistant 3.x의 자동화 트리거만으로는 진정한 catch-all(모든 것을 잡아내는 기능)을 구축할 수 없습니다. ({text}{slot}으로 이름을 바꾼다고 해서 달라지는 것은 없습니다. 동일한 오류가 발생하며 리스트 이름만 바뀔 뿐입니다.)

시도 2 — 탐욕적인 내장 기능(built-ins)의 범위를 좁히기

만약 일반적인 질문들이 MediaPlayerNext와 그 친구들에게 의해 먹히고 있다면, 차라리 이러한 내장 기능들이 덜 탐욕적으로 동작하도록 재정의하여 나머지 모든 것들이 통과(fall through)되도록 할 수 있습니다:

# custom_sentences/en/naturali.yaml
language: "en"
intents:
...

이 방법은 "when is the next low tide"가 MediaPlayerNext에 걸리는 것을 막아주었습니다. 문장의 범위를 좁히는 것은 효과가 있습니다. 하지만 이는 두더지 잡기 게임과 같습니다. 모든 광범위한 내장 기능(HassGetState, HassTurnOn, HassFanSetSpeed, …)은 각각 별도의 인텐트(intent)이며, 이를 일일이 찾아 범위를 재설정해야 합니다. 또한 향후 HA 릴리스에서 새로운 기능이 추가되거나 기존 기능의 범위가 넓어지면, 다시 조용히 사용자의 쿼리를 삼키기 시작할 수 있습니다. 또한 이 방법 역시 실제 fall-through를 잡아내기 위해 시도 1의 트리거에 의존해야 하는데, 이 트리거는 500 오류를 발생시킵니다. 내장 기능의 범위를 좁히는 것은 증상만을 완화할 뿐, 진정한 catch-all을 제공하지는 않습니다.

해결책

catch-all을 자동화 트리거에서 분리하여, 실제 와일드카드 리스트(wildcard list)를 가진 **커스텀 문장(custom sentence)**으로 옮긴 다음, intent_script를 사용하여 인텐트를 처리하십시오.

custom_sentences/en/naturali.yaml:

language: "en"
intents:
  NaturaliQuery:
...

configuration.yaml:

intent_script:
  NaturaliQuery:
    speech:
...

트리거가 할 수 없었던 일을 가능하게 만드는 두 가지 요소는 다음과 같습니다:

트리거가 할 수 없었던 일을 가능하게 만드는 두 가지 요소는 다음과 같습니다:

  • **lists: text: wildcard: true**를 등록하면 textWildcardSlotList로 인식되어, {text}가 누락된 이름 목록으로 해결되는 대신 임의의 음성을 일치시킵니다. 더 이상 MissingListError가 발생하지 않습니다. 사용자 지정 문장만 이 기능을 선언할 수 있는데, 이것이 바로 자동화 트리거가 부족했던 부분입니다.
  • 사용자 지정 문장이 내장 HA 인텐트보다 먼저 로드됩니다 (HA 2026.5 테스트 결과). 따라서 NaturaliQuery가 가장 먼저 일치하여 모든 것을 가로채고, 항해 관련 질문을 가로채던 HassGetState / HassTurnOn / HassFanSetSpeed / MediaPlayerNext 매치도 포함됩니다. 이 순서 덕분에 내장 기능을 단 하나도 건드리지 않고도 가로채기 문제를 해결할 수 있습니다.

intent_script(자동화가 아닌)가 일치된 인텐트를 처리합니다: 이는 정상적인 음성 파이프라인을 통해 확인 메시지를 말하고 쿼리 텍스트를 하위 시스템에 게시합니다. 슬롯은 intent_script에서 {{ text }}로 참조된다는 점에 주목하세요 — {{ trigger.slots.text }}가 아닙니다. 여기에는 트리거 객체가 없습니다.

문장 변경을 위해 사용자 지정 문장을 다시 로드(conversation.reload)해야 합니다. intent_scriptconfiguration.yaml에 존재하므로, 이 부분은 적용되려면 전체 HA 재시작이 필요합니다.

중요성 / 주의사항

해결책의 핵심은 함정이기도 한 것입니다: 사용자 지정 문장이 내장 기능보다 먼저 로드되므로, {text} 와일드카드 인텐트가 전체 구문을 집어삼킵니다. 이것이 바로 이 기능입니다 — 우리는 모든 것이 에이전트로 가기를 원합니다. 하지만 일부 문장만 에이전트로 라우팅하고 나머지는 HA가 네이티브하게 처리하기를 원한다면, 순수한 {text} 와일드카드는 쇠망치와 같습니다:

  • MissingListError → HTTP 500은 일반적인 오류입니다. 커스텀 문장(custom sentence)이나 트리거(trigger) 내에 선언되지 않은 {slot}이 있으면 와일드카드뿐만 아니라 어떤 경우에도 이 오류가 발생합니다. 이 오류가 보인다면, 일치하는 lists: 항목이 없는 {slot}이 있는지 확인하세요.
  • 응답을 위해 자동화 액션(automation action) 대신 intent_script를 사용하세요. intent_script에서 응답을 처리하면 대화 파이프라인(conversation pipeline)을 통해 음성이 계속 흐를 수 있습니다. 만약 사용 중인 TTS 새틀라이트(satellite)가 응답 도중 announce 서비스를 호출할 때 데드락(deadlock)이 발생하는 유형이라면(일부 assist-satellite 설정이 그러함), intent_script.speech를 통해 답변을 라우팅함으로써 이를 완전히 피할 수 있습니다. 즉, 경쟁하는 announce 호출을 절대 내보내지 않게 됩니다.
  • 내장 기능(built-ins)을 좁히려고 애쓰지 마세요. 와일드카드 커스텀 문장이 앞에 배치되면 그것이 먼저 매칭되므로, 내장 기능은 쿼리(query)를 받지 못하게 됩니다. 따라서 '시도 2(Attempt 2)'에서 수행했던 범위를 좁히는 작업은 죽은 코드(dead code)가 됩니다.

결론

이 내용은 모든 전기 추진 차터 카타마란(charter catamaran)을 위한 AI ops 레이어(로컬 LLM, SignalK, MCP 서버, Home Assistant 프런트엔드의 음성 기능 포함)를 구축하는 과정에서 도출되었습니다. 여기서는 "다음 저조는 언제인가요?"라는 질문이 미디어 플레이어가 아닌 내비게이션 에이전트(navigation agent)에 도달해야 합니다. 이 음성 라우팅 뒤에 있는 MCP 서버들은 오픈 소스입니다: github.com/sailingnaturali.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0