음성 AI 에이전트 — 듣고, 찾고, 말하는 파이프라인
예약 에이전트를 상태 기계로 — 확인·재시도·넘기기
목표
전화 예약 에이전트를 상태 기계로 만들어, 확인 질문 없이는 예약하지 않고, 읽기 도구의 시간 초과만 다시 시도하고, 알아듣지 못하거나 사람을 찾으면 요약과 함께 넘기게 한다.
왜 중요한가
음성 에이전트의 사고는 도구에서 납니다. 되돌릴 수 없는 행동이 확인 없이 일어나거나, 시간 초과를 다시 보내 예약이 둘이 됩니다. 이 실습의 도구는 voicekit.clinic.ClinicAPI — 실패를 계획표로 넣을 수 있는 가짜 예약 API — 이고, 사용자의 말은 voicekit.nlu.parse 가 칸으로 바꿉니다. 채점기는 여러분의 /root/voice/agent/agent.py 에서 VoiceAgent 를 불러 시나리오 아홉 개(실패 계획 + 사용자 말)를 직접 돌리고, 말의 문구가 아니라 상태와 도구 호출 기록으로 판정합니다.
단계
ClinicAPI에 실패를 넣어 보고, 도구마다 되돌릴 수 없는지와 다시 해 볼 만한 오류를/root/voice/agent/tools.json에 적으세요.- 문장 8개의 의도를 LLM(json_schema)과 규칙 파서로 뽑아
/root/voice/agent/intents.jsonl과 정답 수/root/voice/agent/intent_acc.json을 만드세요. VoiceAgent(api, sleep)와handle(text)를 가진/root/voice/agent/agent.py를 만들어, 확인에 '예' 를 받은 뒤에만 예약하게 하세요.- 확인 질문에 '아니요'(새 시각이 있으면 그 시각으로 다시 확인)를 처리하세요.
- 읽기 도구(find_slots)의 시간 초과를 0.5·1.0초 쉬며 두 번까지 다시 부르고, 그래도 안 되면
tool_failure로 넘기세요. - 사람을 찾으면
user_request, 두 번 연달아 못 알아들으면not_understood로 넘기고, 넘길 때 채운 칸을 요약하세요. - 예약 도구의 거절(자리 없음)은 빈 시각을 다시 찾아 묻고, 시간 초과는 다시 보내지 말고 넘기세요.
- 시나리오 아홉 개를 돌린 턴 기록을
/root/voice/agent/traces.jsonl에 남기세요.
참고
handle(text)는{"say": 말할 글, "state": 상태, "tool_calls": [이번 턴에 부른 도구]}를 돌려줍니다. 상태 이름: LISTEN · ASK_DAY · ASK_TIME · ASK_NAME · CONFIRM · DONE · HANDOFF.- 넘긴 뒤
agent.handoff는{"reason": …, "summary": {"day": …, "time": …, "name": …}}입니다. parse("Book me on Tuesday at ten a m, my name is Jamie")→{"intent": "book", "day": "tuesday", "time": "10:00", "name": "Jamie"}. 오류:from voicekit.clinic import ToolTimeout, ToolError.- 흔한 실수: sleep 을 time.sleep 으로 박아 두기(채점기가 가짜를 끼웁니다), 예약 시간 초과를 재시도하기, 한 번 못 알아들었다고 바로 넘기기.
- 문서: LangGraph — human-in-the-loop · OWASP LLM Top 10 — Excessive Agency · llama.cpp server — response_format
도구의 성질을 적는다
voicekit.clinic 의 TOOLS 와 ClinicAPI(plan={"find_slots": ["timeout", "ok"], "book": ["taken"]}) 로 실패를 직접 일으켜 보고, /root/voice/agent/tools.json 에 tools(도구 이름마다 {"irreversible": true/false})와 retryable(다시 해 볼 만한 오류 이름 목록)을 적으세요.
TOOLS 의 값은 (되돌릴 수 없는가, 설명) 입니다. ToolTimeout 은 '응답이 없었다', ToolError 는 '거절됐다' — 어느 쪽을 다시 보낼 만한가요?
LLM 과 규칙, 누가 의도를 맞히나
문장 8개 — "I'd like to book an appointment for Tuesday"(book), "CAN I CANCEL MY VISIT ON FRIDAY"(cancel), "YEAH THAT WORKS"(yes), "NO THAT'S NOT RIGHT"(no), "CAN I TALK TO A REAL PERSON"(human), "WHAT TIME DO YOU CLOSE TODAY"(hours), "TEN THIRTY IN THE MORNING"(inform), "MY DOG ATE THE REMOTE"(unknown) — 를 이 순서로, intent 를 여덟 값의 enum 으로 묶은 json_schema 로 LLM 에 물은 결과(llm)와 voicekit.nlu.parse 결과(rule)를 /root/voice/agent/intents.jsonl 에 {"text", "llm", "rule"} 로 적고, 괄호 안 정답과 맞은 수를 /root/voice/agent/intent_acc.json 에 {"llm": n, "rule": m} 으로 적으세요.
voice-llm up 뒤 voicekit.llm.chat(messages, max_tokens=40, json_schema=schema). 형식은 문법이 강제하지만 내용은 모델 몫입니다. 결과가 이상하다면 그것이 이 단계에서 보려는 것입니다.
확인을 받은 뒤에만 예약한다
/root/voice/agent/agent.py 에 VoiceAgent(api, sleep=time.sleep, max_retries=2, backoff=(0.5, 1.0)) 와 handle(text) 를 만드세요. parse 로 칸(day·time·name)을 채우고, 빈 칸을 날짜 → (find_slots 로 빈 시각을 찾아 제시) 시각 → 이름 순으로 물은 뒤 CONFIRM 에서 되읽고, '예'(intent yes)를 들었을 때만 api.book(day, time, name) 을 불러 DONE 이 되게 하세요. 채점: 다섯 턴 대화와 한 번에 다 말하는 두 턴 대화에서 book 이 마지막 턴에 정확히 한 번.
'다음에 물을 것' 을 정하는 함수 하나(_next)를 두면 한 번에 여러 칸을 말한 경우도 같은 코드로 처리됩니다. 제시한 빈 시각(offered)에 없는 시각이면 다시 제시합니다. 이름 차례에 파서가 이름을 못 뽑으면 말의 마지막 낱말을 이름으로 씁니다.
확인의 '아니요' 는 수정이다
CONFIRM 에서 intent 가 no 일 때: 말에 새 시각·날짜가 있으면 그 칸만 바꿔 다시 확인(CONFIRM) 하고, 아무 정보가 없으면 시각을 비우고 빈 시각을 다시 제시하세요. 어느 쪽이든 book 은 부르지 않습니다. 채점: '…ten a m…' → 'no, make it two thirty' → 'yes' 에서 book 이 14:30 으로 마지막 턴에 한 번.
parse('no, make it two thirty') 는 intent no 와 time 14:30 을 함께 줍니다. no 라고 무조건 처음으로 돌아가면 사용자가 방금 말한 정보를 버립니다.
읽기 도구의 시간 초과는 다시 해 본다
find_slots 가 ToolTimeout 을 내면 self.sleep(backoff[시도 번호]) 로 0.5초, 1.0초 쉬며 최대 max_retries(2)번 다시 부르세요. 세 번 다 실패하면 상태를 HANDOFF 로, handoff.reason 을 tool_failure 로 두세요. 채점: 첫 호출만 시간 초과면 두 번 부르고 쉰 시간 [0.5] 로 ASK_TIME, 세 번 다 실패하면 세 번 부르고 쉰 시간 [0.5, 1.0] 로 HANDOFF.
sleep 을 생성자 인자로 받아 두어야 채점기가 기다리지 않고 '얼마나 쉬려 했나' 를 기록할 수 있습니다. 시험할 수 있게 만드는 것도 설계입니다.
사람에게 넘기기 — 요약과 함께
어느 상태에서든 intent 가 human 이면 곧바로 HANDOFF(user_request). intent 가 unknown 이면 한 번은 다시 말해 달라고 하고, 연달아 두 번째면 HANDOFF(not_understood). 알아들으면 횟수를 0 으로. 넘길 때 handoff.summary 에 지금까지 채운 day·time·name 을 담으세요. 채점: 'hmm' → 'blah blah' 에서 두 번째 턴에 넘김, 'Can I book on Friday' → 'can I talk to a person' 에서 요약에 friday.
이름 차례(ASK_NAME)에서는 파서가 unknown 을 내도 사람이 이름만 말한 것일 수 있습니다 — 그때는 알아들은 것으로 칩니다.
되돌릴 수 없는 도구가 실패할 때
book 이 ToolError(자리가 찼다)를 내면 다시 보내지 말고 시각을 비워 find_slots 로 빈 시각을 다시 찾아 제시하세요(ASK_TIME). book 이 ToolTimeout 을 내면 다시 보내지 말고 HANDOFF(tool_failure)로 넘기세요. 채점: 자리 없음 뒤 새 시각 14:30 으로 다시 확인받아 book 두 번(10:00 실패, 14:30 성공), 예약 시간 초과에서 book 한 번 뒤 HANDOFF.
예약의 시간 초과는 '안 됐다' 가 아니라 '모른다' 입니다. 서버는 예약을 만들었는데 응답만 늦었을 수 있습니다 — 다시 보내면 예약이 둘이 됩니다.
시나리오 아홉 개의 턴 기록
시나리오 아홉 개(happy · one_shot · confirm_no · retry_ok · retry_fail · not_understood · wants_human · taken · book_timeout — 실패 계획과 사용자 말은 각 단계 과제의 채점 설명과 같습니다)를 여러분의 agent.py 로 돌려, 턴마다 {"scenario", "turn", "user", "state", "say", "tools"(이번 턴에 부른 도구 이름), "outcomes"} 를 /root/voice/agent/traces.jsonl 에 적으세요. 채점기가 같은 시나리오를 다시 돌려 대조합니다.
도구 기록은 api.calls 에 (이름, 인자, 결과) 로 쌓입니다. 턴 전후의 길이 차이로 이번 턴에 부른 것만 뽑습니다. 이 기록이 곧 에이전트의 회귀 시험 자료입니다.