← Docs hub

Gemini Live Server 소스 기반 서버 구성 분석

이 서버의 실행 스크립트 기본 구성은 기기 → Python WebSocket Relay → H200 Qwen3-Omni → Qwen3-TTS다. 저장소 이름에는 Gemini가 남아 있지만, run.sh는 Qwen 의도 분석과 별도 한국어 TTS를 기본으로 실행한다. Gemini Live는 RELAY_BACKEND=live로 선택하는 별도 경로다. 릴레이는 세션·모델 호출·명령 변환을 맡고, 실제 기기 동작은 function_call을 받은 기기가 실행한 뒤 function_result로 반환한다.

분석 기준은 gemini_live_server, 브랜치 qwen, 커밋 34b4d45(2026-09-07), 분석일 2026-09-08이다. 현재 체크아웃의 실행 코드·설정·배포 스크립트와 기존 오프라인 테스트를 확인했다. 원격 hop1/H200의 실행 환경·GPU 배치·실기기 성공 여부는 이번에 검증하지 않았다. 모델 이름과 포트는 소스 설정값이며 서비스의 현재 가용성을 뜻하지 않는다.

이 상세판은 배치도에 더해 객체·task 소유 구조, Qwen 명령 턴 시퀀스, 추측 발화 세 가지 타임라인, 기동·장애 확인 단계를 SVG로 설명한다. 표의 함수명과 행 번호는 위 커밋 기준이며, 예시 대화는 실제 캡처 로그가 아니다.

읽는 순서

알고 싶은 내용 읽을 절
서버가 어디에 몇 개로 나뉘어 있는가 1. 배치 / 2. 기본값 / 3.1 객체와 task
한 문장이 명령과 음성으로 바뀌는 과정 4. 턴 처리 / 4.1 시퀀스 / 4.2 prompt / 4.3 TTS
앱이 보내고 받아야 하는 데이터 5. 프로토콜 / 5.1 JSON 예 / 5.2 되묻기
취소했는데 실행되거나 문맥이 남는 이유 6. 정책 / 6.1 추측 발화 / 6.2 종료와 예외
운영 중 어느 구간을 확인해야 하는가 8. 배포 / 8.1 기동 / 8.2 지연 / 8.3 진단 / 9. 공백

1. 전체 서버 배치

기기·릴레이·H200·외부 서비스 구성

위치 프로세스 또는 자원 역할과 연결
기기 마이크·재생기·기능 실행기 PCM 입력, 발화 시작/종료/확정/취소, 기기 명령 실행과 결과 반환. 기기 구현은 이 저장소 밖에 있음
Relay 호스트(hop1) server.py, Python asyncio + websockets 기본 0.0.0.0:8765에서 WebSocket 연결 수신. FastAPI/Flask REST 서버가 아님
Relay 호스트 qwen_tunnel.sh SSH 터널 run/qwen.sock과 run/qwen_tts.sock을 H200의 로컬 TCP 포트로 전달
H200 Qwen3-Omni API, 설정 포트 8091 /v1/models, /v1/chat/completions. 의도 해석과 답변 생성이 같은 모델 endpoint를 사용
H200 Qwen3-TTS API, 설정 포트 8092 /v1/audio/voices, /v1/audio/speech. 문장별 음성 합성
외부 서비스 Gemini Live / Gemini Flash-Lite Gemini 경로를 선택했을 때 실시간 음성 세션과 명령 해석
외부 서비스 Tavily Search Qwen 검색 분기에서 RELAY_PROFILE=opt일 때 호출

Qwen HTTP 요청은 Relay의 UNIX socket에 연결되고 SSH가 H200으로 전달한다. 따라서 스크립트는 Relay에 Qwen용 TCP listen 포트를 추가하지 않는다. 실제 방화벽·라우팅은 별도 확인 대상이다. H200 모델 서버를 설치·실행하는 구성, GPU 스케줄링, vLLM 시작 옵션은 이 저장소에 포함되어 있지 않다.

2. 실행 방식에 따라 달라지는 기본값

아래 표는 관련 환경 변수를 사전에 지정하지 않았을 때의 값이다.

항목 python server.py 직접 실행 bash run.sh / 기본 배포 스크립트
Relay backend live qwen
명령 해석기 gemini qwen_tools
Qwen 응답 음성 설정 자체는 talker qwen3_tts
기본 음성 Gemini Aoede, ko-KR Qwen3-TTS sohee, Korean
Qwen 연결 기본 URL http://localhost/v1, UDS 없음 SSH 터널과 두 UDS 경로 주입
검색 profile dev dev

RELAY_BACKEND=qwen python server.py만 실행하면 run.sh가 설정하는 qwen_tools, TTS와 UDS는 자동 적용되지 않는다. resolve_command()는 오디오 입력이 있으면 카탈로그 Qwen 경로를 선택하지만, 텍스트 입력은 기본 FUNCTION_AGENT_BACKEND=gemini를 따를 수 있어 시험 경로도 달라진다. 반대로 run.sh의 live 분기에서는 Qwen용 환경 변수 초기화를 하지 않는다.

dev는 Tavily 호출을 막는 검색 profile이다. Gemini backend를 선택했을 때 Gemini API까지 차단하는 전역 무과금/오프라인 모드는 아니다. make_backend_factory()는 알 수 없는 backend 문자열도 Echo로 처리하므로 설정 오타가 명시 오류로 검출되지 않는다.

근거: server.py:271–296, run.sh:27–50, config.py, function_agent.py:185–198.

3. 모듈별 역할

소스 담당 범위
server.py Broker가 연결과 세션 등록, 교체, 제한 시간을 관리. Session이 backend·WebSocket과 감시 task 소유
protocol.py JSON 제어 frame과 binary PCM의 공통 계약. 필수 필드 전체를 검증하는 schema validator는 아님
qwen_session.py 발화 버퍼, 의도 → 기기/QA/검색 → 답변 → TTS 순서, 끼어들기와 추측 발화 출력 보류
live_session.py Gemini Live 연결, PCM/전사 중계, device_command·end_session tool 처리
function_agent.py gemini, qwen, qwen_tools 명령 해석 경로 선택, 기존 카탈로그 해석과 금지 동작 보정
function_agent_tools.py Hermes 형태의 도구 prompt, 응답 파싱, 토큰 렌더링, 제어·QA·검색·설명 분류
token_map.py 기존 카탈로그의 기능명/동작을 기기 토큰으로 변환. Gemini 경로에서도 사용
tts_client.py 문장 분할, 별도 TTS HTTP 호출, PCM16의 홀수 byte chunk 정렬
device_qa.py 로컬 JSON KB에서 단어 중첩과 QA 분류 점수로 안내문 선택
web_search.py profile 검사, Tavily 검색, 근거 문장 생성과 raw 검색 응답 보관
vital_explainer.py vital_result 텍스트를 Qwen으로 설명. 호출부의 Gemini 관련 주석과 달리 현재 구현은 Qwen 단독
client.py, scenario_client.py, virtual_server.py Echo/WAV·시나리오 시험과 가상 기기 상태 응답. 실서비스 기기 제어 서버가 아님
function_data/, tools/build_tools.py 도구·평가셋 생성의 원천 자료. 런타임에는 생성된 JSON을 읽음

현재 JSON 실수량은 tools_kr.json 35개, 도구 예시 130개, device_qa_kb.json 105개다. 도구 35개에는 device_qa, web_search가 포함된다. KB는 벡터 DB나 임베딩 검색이 아니라 분류 가점과 키워드 일치로 고르며, 점수가 낮으면 같은 분류의 첫 안내문으로 대체한다.

3.1 객체와 비동기 task는 누가 소유하는가

Broker·Session·Qwen backend 객체와 task 소유 구조

모델마다 OS 프로세스를 생성하는 서버가 아니다. Relay는 하나의 Python 프로세스 안에서 연결별 Broker.handle(ws) coroutine과 backend 객체를 만든다. Qwen 추론 프로세스는 원격 H200에 있다.

범위 객체·필드 생성·사용·정리
Relay 프로세스 Broker.sessions, daily_counts 모든 연결이 공유하는 dict. 날짜 변경 시 옛 key를 지우는 별도 코드 없음
WebSocket handler 지역 변수 session 이 연결의 현재 세션. 제어 frame을 읽고 backend method를 await
논리 세션 Session.ws, backend, started, last_voiced 세션 1개가 WebSocket·backend와 시계를 소유
세션 감시 limit_task, silence_task backend start 성공 후 등록. 종료 시 현재 task를 제외하고 취소
Qwen backend _http, _tts 세션 시작에 비동기 HTTP client 생성, 종료 때 close. 원격 모델 세션을 예약하는 객체는 아님
현재 발화 _buf, _in_utterance, _utt_started 입력 PCM 버퍼와 발화 시작 시각. 턴 시작 시 PCM snapshot 후 버퍼 초기화
현재 응답 _turn_task _run_turn() 또는 바이탈 음성 task. 일반 voice_start는 진행 중이면 무시
명령 결과 대기 _pending[call_id] asyncio.Future. 기기 결과, timeout 또는 취소로 정리
추측 출력 _gate_open, _held, _gate_timer 전송 직전 frame 보류. confirm/watchdog 시 해제, cancel 시 폐기
대화 문맥 _history, _last_need_param 답변 이력과 의도 되묻기 문맥을 별도로 보관
동기 작업 기본 executor thread 의도·검색·바이탈의 동기 HTTP 실행. WebSocket과 같은 event loop를 HTTP 완료까지 점유하지 않도록 분리

Backend는 abstract base class나 엄격한 interface validator가 아니라 기본 no-op method를 가진 클래스다. barge_in, confirm/cancel, device_event, text 입력 등 추가 기능은 Broker의 hasattr()로 지원 여부를 확인한다. 따라서 wire frame이 정의되어 있다는 사실만으로 모든 backend가 해당 기능을 구현했다고 볼 수 없다.

연결별 수신 루프는 frame handler를 순서대로 await한다. 일반 오디오 턴은 task를 만들어 바로 돌아오므로 기기 결과를 계속 받을 수 있지만, send_text()는 기존 턴을 기다리고 on_device_event()는 설명 생성까지 기다린다. 이 대기 중에는 같은 WebSocket의 다음 frame 처리도 지연될 수 있다. 프로세스 전체가 막힌다는 뜻은 아니다.

근거: server.py:33–51,74–184,186–265, qwen_session.py:88–149,246–267,330–335.

4. Qwen 한 턴의 처리 순서

  1. 기기가 session_start를 보낸다. Relay는 일일 횟수를 검사하고 기존 동일 device 세션을 교체한다.
  2. Qwen /models와 별도 TTS /audio/voices가 성공하면 session_ready를 보낸다.
  3. 기기의 voice_start 이후에만 PCM을 버퍼에 쌓는다. utterance_end에서 발화를 확정한다.
  4. PCM을 WAV/base64로 묶어 의도 모델에 보낸다. 별도 STT 서비스 없이 이 호출에서 전사와 의도를 함께 받는다.
  5. 결과에 따라 아래 분기로 진행한다. 명령 실행은 기기 응답을 기다린다.
  6. Qwen 답변 호출에 페르소나 prompt, 최근 최대 10턴, 현재 입력과 실행 결과/검색 근거를 전달한다.
  7. 텍스트 delta를 text_output으로 보내고, 완성된 문장을 Qwen3-TTS에 차례로 전달한다.
  8. PCM16 24 kHz를 스트리밍하고 마지막에 audio_end를 보낸다. 재생 완료 ACK는 별도로 받지 않는다.
해석 결과 실제 처리 기기 명령 발생
ok / outcome A 도구 token 또는 token_map → function_call → 최대 20초 결과 대기 → 결과 기반 답변 있음
need_param 부족한 값을 되묻고 직전 해석 결과를 다음 의도 호출의 context로 전달 없음
reject 거절/안내 문구를 답변 생성에 전달 없음
qa / outcome B 로컬 KB 안내문을 답변 근거로 사용 없음
search / outcome S Relay에서 검색, 근거를 Qwen에 전달. dev/무결과/실패는 고정 안내 없음
none / outcome C 전사를 일반 대화 입력으로 다시 답변 생성 없음

현재 C 결과의 answer_hint를 그대로 발화하지 않고 _run_turn()이 전사를 다시 _speak()에 전달한다. 즉 의도 모델과 발화 모델은 논리적으로 역할을 나눈 순차 두 호출이며, 별도 모델 프로세스 두 개라는 뜻은 아니다. TTS 문장 합성도 await say(sentence)로 순차 실행되어 이전 문장을 합성하는 동안 다음 텍스트 delta 소비가 대기한다.

근거: qwen_session.py:296–450, qwen_session.py:452–582, function_agent_tools.py:218–270.

4.1 풍량 설정 한 턴을 함수 단위로 따라가기

Qwen 의도·기기 실행·답변·TTS 순차 처리

예시 입력은 “풍량을 강풍으로 해줘”이고 모델이 set_air_cleaner_fan_speed(speed=4)를 골랐다고 가정한다. 도구 선택의 정확도를 주장하는 예시가 아니라, 선택 이후 서버의 실제 분기를 설명한다.

순서 코드에서 이어지는 호출 입출력과 대기
1 Broker.handle → on_voice_start → send_audio 세션 준비 후 PCM을 발화 버퍼에 누적
2 on_utterance_end → create_task(_run_turn) 최소 300 ms 검사 후 수신 루프로 복귀
3 _run_turn → pcm16_to_wav_bytes → executor의 resolve_command PCM을 WAV/base64로 만들고 현재 되묻기 context와 함께 전달
4 _resolve_qwen_tools → function_agent_tools.resolve 모델 응답의 전사와 <tool_call>을 분리. calls[0]만 선택
5 render_token → status=ok 도구 metadata의 SK 번호·인자 순서로 <sk_2>(speed=4) 구성
6 _device_roundtrip → _out(function_call) Future를 먼저 등록한 뒤 명령을 보내고 최대 20초 대기
7 Broker.handle → send_function_result 일치하는 call_id의 Future에 tts 문자열 전달
8 _run_turn → _out(tool_trace) → _speak 기기 결과를 답변 prompt에 넣음. timeout도 명시 안내문으로 합류
9 _speak_tts → SentenceChunker.feed → synth_stream Qwen text stream을 읽으며 문장별 TTS 요청
10 _send_pcm → _out → audio_end PCM 전송, 성공 경로의 대화 이력 append, 응답 전송 완료

서버는 한 턴에서 모델이 여러 tool call을 써도 첫 번째만 처리한다. 도구 prompt 역시 최대 하나를 지시한다. 이는 여러 기기 작업을 의존성 순서대로 실행하는 planner나 transaction 엔진이 아니다.

4.2 의도 prompt, 답변 prompt, 되묻기 문맥의 차이

구분 기본 Qwen 도구 경로의 입력 모델 결과를 쓰는 방식
의도 해석 NOUS_TEMPLATE + 도구 35개 + 한국어 규칙 + few-shot + 현재 음성/텍스트 전사, 제어 token, QA/검색 분류, 부족한 값 판단
의도 되묻기 직전 _last_need_param에서 tool 또는 기능명으로 prev_tool 추출 “직전 턴에서 이 도구 값을 물었음”이라는 문장으로 보강. 전체 대화 이력은 전달하지 않음
답변 생성 prompts/qwen_system_prompt_kr.txt + _history의 최근 10턴 + 현재 입력 기기 안내·검색 근거·일반 대화를 한국어 짧은 답변으로 생성
별도 TTS 완성 문장 + voice/language/model 및 선택 instructions PCM16 24 kHz 반환. 기기 도구를 다시 고르는 단계가 아님

기본 의도 호출은 temperature=0, max_tokens=200, modalities=[text]로 응답을 받는다. 입력은 음성일 수 있다. 답변 호출은 temperature=0.7, 기본 max_tokens=160, streaming text를 요청한다. 이 설정은 코드값이며 “항상 같은 도구를 선택한다”거나 “160토큰 안에 항상 문장이 끝난다”는 보증이 아니다.

_history에는 최종적으로 사용자 전사와 assistant 답변만 append된다. 현재 턴의 원본 검색 근거, 기기 결과 구조체, 도구 Future가 그대로 장기 저장되는 구조가 아니다. 다음 의도 판단에 이 이력이 전달되지 않으므로 “방금 것 취소해줘”처럼 이전 실행을 가리키는 표현을 일반적인 작업 기억으로 해결한다고 설명하면 안 된다.

[기기 안내문], [기기 결과], [검색 결과], [그대로 말하기]는 답변 prompt의 의미 표시다. 특히 일반 턴의 “그대로 말하기”도 _speak()를 거치므로 문자열을 TTS에 직접 고정 주입하는 강제 경로가 아니다. 되묻기·timeout·dev 검색 안내도 모델이 다시 생성한다. 문구 보존은 prompt 규칙과 평가 대상이다. 바이탈의 _say_verbatim() + 별도 TTS는 준비된 설명 문장을 직접 합성하는 예외 경로다.

4.3 오디오 형식과 문장 스트리밍

구간 표현 코드상 처리
기기 → Relay raw PCM16 mono 16 kHz binary frame을 이어 붙임. 1초에 32,000 byte
Relay → 의도 모델 WAV container + base64 샘플률·폭·채널 header를 추가. 음성 전체를 한 요청에 포함
답변 모델 → Relay SSE의 data: JSON text delta [DONE]에서 종료. JSON 파싱 실패 이벤트는 건너뜀
Relay → 별도 TTS 문장별 /audio/speech JSON 기본 6자 최소 경계, 40자 길이 분할 기준
TTS → 기기 raw PCM16 mono 24 kHz 1초에 48,000 byte. 첫 chunk WAV header를 허용하고 홀수 byte를 다음 chunk로 이월

문장 분할은 구두점 → 줄바꿈 → 길이 기준 순서로 검사한다. 길이 기준에서는 공백을 우선 찾고 없으면 설정 길이에서 자른다. 먼저 구두점 조건을 처리하므로 40이 모든 반환 조각의 절대 최대 길이는 아니다. 끝에 남은 텍스트는 flush()로 합성한다.

PCM sample은 2 byte인데 HTTP chunk 크기는 홀수일 수 있다. 홀수 끝 byte를 버리지 않고 다음 chunk와 합쳐 항상 짝수 byte로 전달하는 것이 synth_stream()의 핵심이다. 이는 샘플 정렬 보정이며 샘플률 변환이나 AEC가 아니다. Talker 경로는 base64 WAV chunk를 해독하고 출력률을 검사하지만 불일치 시 resampling 대신 warning을 남긴다.

발화 20초는 정상 실시간 입력 기준 PCM 약 640,000 byte, base64 약 853,336문자에 WAV header가 더해진다. 이는 형식 계산이지 메모리 상한이 아니다. 강제 확정은 PCM 수신 시 벽시계 경과 시간을 보고 수행하므로, 빠르게 몰아 보낸 입력의 byte 수를 20초 분량으로 제한하지 않는다. WebSocket max_size=2**22도 메시지별 제한이다.

근거: function_agent_tools.py:119–164,218–270, qwen_session.py:50–81,345–450,469–582, tts_client.py:25–110, server.py:267–268.

5. 기기와의 통신 계약

WebSocket text frame은 JSON, binary frame은 header 없는 PCM16 mono다. 입력은 16 kHz, 출력은 24 kHz를 전제로 한다. Echo는 입력 byte를 그대로 반환하는 시험용 예외다. 현재 serve()에는 URL path 분기나 /healthz, /readyz HTTP handler가 없다.

방향 frame 의미
기기 → Relay session_start {device_id, language} 세션 요청. ID 생략 시 unknown 사용
Relay → 기기 session_ready / session_end backend 준비 완료 / 세션 종료와 이유
기기 → Relay voice_start, PCM, utterance_end Qwen 발화 경계와 음성 입력
기기 → Relay utterance_end {speculative:true} 먼저 추론하되 출력을 보류
기기 → Relay utterance_confirm / utterance_cancel 보류 출력 해제 / 현재 턴 취소와 보류 frame 폐기
Relay → 기기 function_call {call_id, token, function, available_function} 기기 실행 요청
기기 → Relay function_result {call_id, tts} 실행 결과 안내문. 시험용 JSON 문자열도 수용
기기 → Relay barge_in / session_stop 응답 중단 / 세션 종료 요청
Relay → 기기 interrupted / audio_end 재생 중단 통지 / 응답 전송 끝
양방향 확장 text_input, input_transcript, text_output, tool_trace, device_event 디버그 입력·전사·추적·바이탈 결과 이벤트

예를 들어 풍량 설정은 function_call에 <sk_2>(speed=4) 같은 토큰을 넣어 전달한다. Relay는 기기의 실제 상태를 직접 변경하지 않는다. call_id는 진행 중 Future를 찾는 상관관계 ID이며, 재접속을 넘는 중복 실행 방지 DB나 완료 이력으로 사용되지 않는다.

근거: protocol.py, server.py:121–184, qwen_session.py:452–467.

5.1 제어 frame 예시와 결과 해석

다음은 wire 형식을 설명하기 위한 합성 예시다. 실제 발화 캡처나 기기 실행 증거가 아니다.

{"type":"session_start","device_id":"DOC-DEMO","language":"korean"}
{"type":"session_ready"}
{"type":"voice_start"}

이후 PCM binary frame들을 보내고 아래 제어 frame을 보낸다.

{"type":"utterance_end"}
{"type":"function_call","call_id":"example-call-1","token":"<sk_2>(speed=4)","function":"풍량 설정","available_function":"{\"speed\": 4}"}
{"type":"function_result","call_id":"example-call-1","tts":"풍량을 강풍으로 설정했어요."}
{"type":"text_output","text":"풍량을 강풍으로 설정했어요."}

마지막 text는 응답 형식 예시다. 실제로는 여러 text delta와 PCM frame이 올 수 있고, 그 뒤 {"type":"audio_end"}가 전송된다. audio_end는 스피커 재생 완료도, 실제 기기 명령 성공 ACK도 아니다.

function_result.tts가 일반 문장이면 device_tts로, JSON으로 파싱되면 server 값으로 해석한다. 기기 JSON에 message가 있으면 이를 우선 안내하고, 그렇지 않으면 구조화된 결과를 모델이 문장으로 바꾼다. JSON으로 파싱된다는 사실만 검사하므로 기대하는 object schema까지 검증하는 계약은 아니다. 알 수 없거나 이미 timeout된 call_id는 warning을 남기며 진행 중 결과로 수용하지 않는다.

5.2 값이 없는 설정과 후속 답변

“볼륨 바꿔줘”를 모델이 set_volume으로 선택하되 인자를 비웠다고 가정하면, render_token()의 필수 값 검사에서 token을 만들지 못한다. 알고 있는 도구이므로 need_param이 되고 도구 metadata의 x_ask를 질문으로 사용한다. _last_need_param에 이 결과를 저장한다. 다음 “3으로”는 이전 도구 이름을 보강한 의도 prompt에 들어가 set_volume(volume=3)을 선택할 수 있다.

다만 모델이 처음부터 tool call 없이 설명문을 반환하면 status는 none이고, 이 경로는 동일한 need_param 상태를 보장하지 않는다. “의미상 되묻기처럼 들리는 답변”과 “서버가 되묻기 문맥을 저장한 상태”를 분리해 시험해야 한다.

GET은 get=True를 지원하는 도구에서 다른 인자를 제거하고 조회 token으로 변환한다. 해당 도구에 get 속성이 없으면 상태확인 QA로 보낸다. QA는 실제 기기 조회가 아니므로 안내 KB와 실시간 기기 상태를 같은 것으로 취급하면 안 된다.

근거: function_agent_tools.py:193–270, function_agent.py:168–182, qwen_session.py:337–343,354–356,380–397,452–467.

6. 세션·발화·중단 정책

설정 기본값 구현상 의미
RELAY_MAX_SESSION_SEC 600초 backend 시작 성공 후 최대 수명 감시 시작
RELAY_DAILY_SESSION_CAP 50 호스트의 오늘 날짜 + device ID별 시작 요청 횟수. 실패/초과 요청도 먼저 증가
RELAY_SILENCE_STOP_SEC 120초 유음 활동 부재 시 종료. 기기 측 EPD/대기 타이머와 별개
RELAY_SILENCE_RMS 300 입출력 PCM의 RMS 유음 판정. voice_start/confirm도 활동 시각 갱신
RELAY_MAX_UTTERANCE_SEC 20초 PCM 수신 때 경과 시간을 확인해 강제 확정. 독립 발화 watchdog은 아님
RELAY_MIN_UTTERANCE_MS 300 ms 이보다 짧은 입력은 모델에 보내지 않고 audio_end 반환
RELAY_FUNC_TIMEOUT_SEC 20초 기기 응답 timeout. 세션 전체를 끊지 않고 안내 답변으로 처리
RELAY_SPEC_CONFIRM_TIMEOUT_SEC 3초 confirm이 안 오면 보류 출력을 자동 해제
RELAY_END_GRACE_SEC 3초 Qwen 종료 발화의 송신 PCM 길이/48000 + 3초 후 종료
RELAY_HISTORY_TURNS 10 답변 prompt에 포함할 턴 수. _history 리스트 자체는 계속 append

Qwen barge_in은 진행 턴 취소, pending 기기 결과 대기 정리, 발화 버퍼 초기화 후 interrupted를 보낸다. 기기에서 이미 실행한 명령을 되돌리는 rollback은 없다. utterance_cancel도 아직 보류된 frame을 폐기할 뿐이다. confirm 없이 3초가 지나면 function_call도 전송될 수 있으므로 “명시 확정 없이는 절대 실행하지 않음”은 현재 계약이 아니다.

의도 분석과 검색은 동기 HTTP를 executor thread에서 실행한다. asyncio 턴 task를 취소해도 이미 시작된 thread의 HTTP 요청까지 즉시 중단되는 것은 아니다. 출력 취소와 원격 계산 취소는 구분해야 한다.

근거: server.py:103–119,186–265, qwen_session.py:127–244,306–328,345–379, config.py.

6.1 추측 발화: 먼저 추론하고 나중에 내보내기

추측 발화 confirm·cancel·watchdog별 결과

짧은 EPD에서 추론을 시작해 지연을 줄이되, 더 긴 확정 판단까지 외부 출력을 보류하는 방식이다. Relay에는 기기의 짧은/긴 EPD 시간을 정하는 코드가 없다. 기기가 보내는 frame을 처리한다. 3초 watchdog은 utterance_end(speculative=true)를 처리할 때 시작한다.

입력 또는 상태 실제 변화 주의할 경계
speculative end _gate_open=False, _held=[], watchdog task 생성 후 일반 utterance_end 호출 의도 계산은 바로 실행. 명령을 제외한 QA/검색/답변 계산도 gate 뒤에서 진행 가능
_out(frame) + gate 닫힘 frame을 _held에 추가하고 즉시 복귀 기기로 안 보냈어도 호출자는 전송 함수가 반환됐으므로 다음 단계를 진행
confirm gate를 열고 timer 취소, 보류 list를 순서대로 전송 confirm 전에 생성된 function_call·전사·PCM·audio_end를 해제
cancel, gate 해제 전 턴 취소, pending Future 취소, 보류 출력·버퍼 폐기 이 보류 구간의 기기 명령은 아직 안 나갔지만 이미 수행한 추론/검색은 되돌리지 않음
confirm 없음 + 3초 _open_gate("watchdog") 이후 cancel은 이미 전송·실행된 명령을 회수하지 못함
짧은 입력 300 ms 미만 모델 호출 없이 audio_end를 _out() speculative 상태면 이 audio_end도 confirm/watchdog까지 보류

기기 응답 timeout은 실제 소켓 전송 순간이 아니라 _out(function_call) 반환 후 시작한다. 보류 중에는 _out이 즉시 반환하므로 보류 시간도 기본 20초 안에 포함된다. confirm 시각, 의도 모델 완료 시각, watchdog 시각을 함께 봐야 기기의 실제 응답 가능 시간을 계산할 수 있다.

_history append도 _out(audio_end)보다 먼저 실행된다. 일반 대화 턴이 보류 상태에서 끝난 뒤 cancel되면 기기에서 듣지 못한 답변이 이력에 남을 수 있다. 현재 cancel은 이력을 rollback하지 않는다. 또한 _open_gate()는 gate를 먼저 연 뒤 기존 frame을 await 전송하므로, flush 중 새 출력과 순서가 엄격하게 직렬화되는지는 느린 소켓 조건에서 추가 검증할 대상이다.

이 도식의 gate는 일반 _run_turn() 출력 경로 기준이다. 바이탈 _say_verbatim()의 text/audio_end와 일부 Broker 제어 메시지는 _send()를 직접 사용하므로 모든 서버 송신을 포괄하는 전역 장벽이 아니다.

6.2 끼어들기와 세션 종료는 다른 동작

사건 응답 턴 세션·연결 기기에서 기대할 일
Qwen barge_in 현재 task·결과 Future·입력 버퍼 정리 유지 interrupted를 받고 재생 queue를 비운 뒤 새 발화 시작
utterance_cancel 추측 턴 취소, 보류 frame 폐기 유지 아직 전달되지 않은 응답을 듣지 않음. 별도 interrupted 송신은 없음
function timeout timeout 결과로 안내 답변 생성 유지 늦은 결과는 진행 중 Future를 찾지 못함
일반 _run_turn 예외 오류 로그 후 audio_end 전송 시도 기본적으로 유지 상세 오류 안내 음성은 보장되지 않음
session_stop backend close session_end + WebSocket close 사용자 종료 처리
silence / limit 감시 task가 _drop() 호출 session_end + WebSocket close 종료 이유에 맞춰 기기 UI·오디오 정리
같은 ID의 새 시작 기존 세션을 먼저 end(replaced) 기존 연결 종료, 새 backend 준비 새 시작 실패 시 기존 세션 자동 복구는 없음
WebSocket 단절 등록된 현재 세션이면 finally에서 _drop(error) 정리 재접속 문맥 복원 계약 없음

세션 시작은 “새 backend 준비 → 기존 세션 교체” 순서가 아니라 기존 세션 종료 → 새 backend 준비 순서다. 또한 새 세션을 dict에 넣기 전에 backend start를 await하므로 같은 ID의 동시 start가 교차할 수 있다. 일반 순차 교체 테스트 통과와 경쟁 상황의 단일 세션 보장은 별도 문제다.

Qwen stop phrase는 의도 모델이 반환한 전사에서 공백을 제거한 부분 문자열로 찾는다. 따라서 종료 의도가 부정·인용된 문장까지 의도적으로 제외하는 parser는 아니다. stop 감지 뒤에도 실제 goodbye PCM의 재생 완료를 확인하지 않고 송신 byte 길이 + 유예 시간을 기다린다.

근거: qwen_session.py:151–244,315–328,345–379,441–467,574–582, server.py:186–265.

7. Gemini 경로와의 차이

항목 Qwen 경로 Gemini Live 경로
입력 처리 기기 경계 기준 발화 WAV를 HTTP로 전송 마이크 PCM을 Live 세션으로 지속 전송
대화 판단 모든 발화가 의도 해석기를 거침 Live 모델이 필요할 때 device_command 호출
명령 변환 기본 qwen_tools가 만든 token 우선 카탈로그 결과를 token_map으로 변환
응답 HTTP 텍스트 + 별도 TTS가 스크립트 기본 Live의 음성 및 전사 출력
추측 발화 confirm/cancel gate 구현 전용 handler 없음
종료 전사 stop phrase 매칭 + 오디오 길이 유예 end_session tool + 응답 turn 완료, 8초 안전 타이머
초기 연결 실패 세션 거절 설정된 두 Gemini 모델을 순서대로 시도

Gemini 모델 후보는 gemini-3.1-flash-live-preview, gemini-2.5-flash-native-audio-latest다. 후보 전환은 세션 setup 실패 시 적용하며 Qwen/Gemini 간 자동 장애 전환은 없다. Gemini Live 키는 synthetic 키 또는 일반 키를 사용할 수 있지만 기존 Gemini 명령 해석기의 _get_client()는 GEMINI_SYNTHETIC_KEY만 읽는다. 일반 키 하나만으로 모든 Gemini 명령 기능이 준비됐다고 판단하면 안 된다. qwen_tools와 Live를 임의 혼합하면 Live가 해석 결과의 token을 직접 쓰지 않는 차이도 호환 시험이 필요하다.

근거: live_session.py:58–109,155–287, function_agent.py:44–52, config.py.

8. 배포와 운영 관측

deploy_hop1.sh는 코드 rsync → 기존 Relay 종료 → run.sh 실행 → TCP 8765 연결 확인 순서다. rsync --delete를 사용하고 logs/, test/, run/, .git/ 등은 제외한다. Python 의존성을 설치하지 않으며, 이 체크아웃에는 requirements/lockfile, Dockerfile, compose, systemd unit이 없다. server.py의 audioop 의존성도 런타임 버전 검토 대상이다.

run.sh는 중복 프로세스를 검사한 뒤 setsid nohup으로 백그라운드 실행하고 logs/relay.log에 기록한다. cron @reboot 연결은 주석에 설명돼 있으나 cron 등록 명령은 포함하지 않는다. 터널은 실패 후 5초 간격으로 재시도하지만 Relay 프로세스를 재기동하는 supervisor는 없다. 터널 준비 검사는 Qwen /models, 실제 Qwen 세션 준비는 TTS /audio/voices까지 확인한다. 배포 마지막 TCP 검사만으로 모델 추론·TTS·기기 명령 왕복이 정상임을 증명할 수 없다.

로그에는 세션 device ID/종료 이유/수명, 사용자 전사, 모델 답변, 도구 판단과 기기 안내문, Qwen 첫 오디오 및 전체 처리 시간이 기록된다. opt 검색은 logs/tavily/에 질의와 raw 응답도 저장한다. 따라서 이 서버를 “전사나 대화 내용을 저장하지 않는 단순 프록시”로 설명하면 틀린다. Qwen last_total_tokens는 최근 관측 응답의 값을 대입하므로 의도·답변·TTS를 합한 세션 총 비용 지표가 아니다.

8.1 기동 순서와 health check의 층

배포·터널·세션 준비·기능 확인의 단계

단계 실행·확인 항목 성공해도 아직 모르는 것
코드 전달 deploy_hop1.sh rsync 의존성, 모델 가용성, 환경 변수
터널 준비 socket 존재 + Qwen /models 성공 TTS 준비, 실제 음성 추론 성공
Relay 기동 run.sh의 pgrep 중복 검사 + nohup async backend 세션 준비. Relay 기동 자체는 모델 세션 생성이 아님
배포 TCP 검사 2초 후 Relay 8765 연결 JSON handshake, 모델·TTS·명령 실행
사용자 세션 시작 Qwen /models, TTS /audio/voices에 각각 8초 timeout 지정 모델·voice로 실제 생성 가능한지, 실기기 제어 결과
기능 확인 음성 턴 → 명령 왕복 → 실제 재생 장시간 안정성, 동시 부하, 실패 복구

마지막 기능 확인은 별도 시험이 필요하며 배포 스크립트가 자동 수행하지 않는다. Qwen과 TTS 설정의 30초 HTTP timeout은 요청의 네트워크 timeout 설정이지, 전체 의도→기기→답변→TTS를 합친 턴 전체의 30초 deadline이 아니다.

8.2 지연을 어느 구간에서 나눠 봐야 하는가

정상 명령 턴의 첫 소리 지연을 해석하는 구성식은 다음과 같다. 실측 숫자를 대입한 결과가 아니다.

사용자 발화 끝 → 첫 소리
  ≈ 기기 EPD 대기 + 입력 전달/변환 + 의도 모델
    + 기기 왕복 + 답변의 첫 문장 생성 + 첫 TTS chunk
    + 출력 네트워크 + 기기 재생 queue

추측 발화에서는 계산과 확정 대기가 겹치므로 두 지연을 단순 합산하면 안 된다. 명령이 있는 턴은 command gate가 먼저 열려야 기기 왕복을 시작할 수 있다. 일반 대화는 PCM을 미리 합성해 보류할 수 있으므로 두 경우의 이득도 같다고 가정할 수 없다.

_run_turn()의 t0는 Relay가 턴 task를 시작할 때다. 로그의 intent: ... ms는 이 t0 이후 의도 해석까지, first_audio는 이 t0부터 TTS PCM을 처음 얻을 때까지다. 사용자가 듣기 시작한 시각은 아니다. gate가 닫혀 있어도 첫 PCM이 생성되면 first_audio가 기록될 수 있다. total 역시 기기 재생 완료를 기다린 시간이 아니며 기기 EPD 대기는 이 t0 밖에 있다.

관측값 위치 사용할 때의 한계
사용자 전사 user: 인식 결과 확인 가능. 실제 마이크 원문과 비교하려면 별도 음성 자료 필요
의도 판단·시간 intent: 도구 선택 구간 추적. GPU-only 추론 시간과 동일하지 않음
검색 구간 web_search[...], results/credits/ms dev stub도 같은 분기에 들어옴. source를 함께 확인
기기 결과 device_tts: / tool_trace 기기가 반환한 안내문. 물리적 동작 완료는 해당 기기 계약에 달림
첫 PCM·턴 시간 model(tts...) / first_audio / total 기기 EPD·재생 queue·청감 지연 미포함
세션 요약 session summary 종료 이유·수명 추적. tokens는 Qwen 전체 비용 합산값이 아님

8.3 증상별 확인 순서

증상 먼저 구분할 단계 관련 코드
TCP는 되는데 session_ready가 안 옴 daily_cap, backend start의 /models·/audio/voices, backend 설정 server.py:_start_session, QwenOmniBackend.start
음성을 보내도 전사 없음 session_start 완료 여부, voice_start 수신, 최소 길이, 의도 호출 실패, 닫힌 gate send_audio, on_utterance_end, _run_turn, _out
답변은 나오는데 기기가 안 움직임 status=none/qa/need_param인지, token 생성 여부, function_call 송신, call_id 결과 resolve, render_token, _device_roundtrip
추측 발화에서 명령이 늦음 의도 완료 시각과 confirm/watchdog 시각, 남은 function timeout _gate_watchdog, _open_gate, _device_roundtrip
자막은 있는데 PCM이 안 옴 TTS 첫 chunk·voice 설정·HTTP 오류, 문장 경계, gate _speak_tts, SentenceChunker, synth_stream
검색이 늘 같은 문구 RELAY_PROFILE=dev, opt 키 유무, source=no_key/error/실제 무결과 web_search.search
답변 중 끼어들기가 늦음 수신 handler 대기, task 취소, 기기 playback flush Broker.handle, barge_in, on_device_event
재접속 후 문맥·횟수가 달라짐 새 backend 객체, Relay 재시작, 분산 인스턴스 여부 _history, Broker.daily_counts

검색은 기본 최대 5개 결과, 결과당 content 최대 700자를 근거로 만든다. 현재 날짜와 제목·출처·내용을 답변 모델 입력에 넣고 읽을 때 출처·번호는 말하지 않도록 prompt로 지시한다. Tavily 오류와 실제 무결과는 사용자 안내가 같을 수 있으므로 내부 source 값으로 구분해야 한다.

8.4 변경 시 어느 파일과 시험을 함께 봐야 하는가

바꾸려는 계약 함께 볼 소스 기존 검증 자산과 추가 확인
도구·기기 token 원천 function_data, build_tools, tools JSON, render_token, 실제 기기 parser test_p42_tools + 기기 handler 비교. 생성 전후 SK·인자·기본값 diff
backend 전환 run.sh, config, make_backend_factory, function_agent Echo/Qwen helper만으로 Live 연결을 검증할 수 없음. 조합별 smoke 필요
세션·취소 정책 protocol, Broker, Qwen gate, 기기 EPD/재생 코드 test_p0_echo, test_p2_silence, test_p46_speculative + 지연 confirm·재접속 경쟁
TTS·오디오 config, SentenceChunker, synth_stream, 기기 AudioTrack test_p33_tts + 실제 음성 품질·재생률·끼어들기
QA·검색 답변 KB, device_qa, web_search, 답변 prompt test_p44_search, test_p44c_wording + 근거 충실도·문구 보존 평가

이 표는 다음 변경의 영향 범위다. 이번 상세화 작업은 서버 로직을 수정하거나 원격 서버를 재기동하지 않는다.

9. 운영 전 확인할 구현 공백

아래는 소스에서 확인한 구조적 공백 또는 위험 후보다. 이번 작업에서 운영 장애를 재현했다는 뜻은 아니다.

항목 코드 근거와 영향 다음 검증
인증·전송 보호 serve()에 TLS·인증·origin/path 정책이 없고 device ID는 클라이언트 입력. 동일 ID 세션 교체 가능 실제 ingress/사설망 보호와 인증된 device ID 결합 확인
세션 일관성·확장 세션과 일일 횟수가 프로세스 dict에만 존재. 재시작 시 초기화, 다중 인스턴스 간 공유 없음 동시 시작 경쟁, 재시작, 인스턴스 분산 시험
명령 인자 검증 render_token()은 필수 값·기본값을 처리하지만 enum/범위/타입 전체를 검증하지 않고 추가 인자도 렌더링 잘못된 인자와 문자열을 포함한 서버·기기 양쪽 계약 시험
추측 발화 실행 3초 자동 해제로 확정 없는 명령도 기기로 전송 가능. 기기 응답 timeout은 gate 보류 중에도 진행 지연 confirm, cancel, 기기 실행 경계와 timeout 조합 시험
세션 정리 Qwen close()가 _gate_timer를 직접 취소하지 않음. start 실패 경로는 만든 backend를 close하지 않음 종료/시작 실패 이후 timer·HTTP client 잔존 여부 확인
바이탈 마지막 문장 _say_verbatim()에서 chunker.flush()를 조건과 값에 두 번 호출. 첫 호출이 버퍼를 비우므로 tail 소실/None 처리 오류 후보 문장부호 없는 짧은 바이탈 설명 재현 시험
언어와 출력률 handshake language를 받아도 prompt와 음성은 전역 한국어 설정 중심. Talker 출력률 불일치는 warning만 출력 언어 변경, 출력률과 실제 기기 재생 일치 확인
관측·재현성 대화/검색 로그 보관 정책, 의존성 고정과 application readiness가 이 소스에 없음 보관·마스킹·rotation 정책, 재현 가능한 패키지와 end-to-end 점검 구성

10. 기존 문서와의 관계 및 검증 범위

기존 현재 Broker는 backend-cloud-llm-lambda-main/gemini/live_broker.py를 다룬다. 그 문서의 /llm/live, search_rag, /healthz, /readyz와 이번 저장소의 session_start, binary PCM, device_command, Qwen 경로는 서로 다른 구현이다. 기존 페이지의 2026-08 PoC 결과를 이 서버의 검증 결과로 옮기지 않는다.

이 저장소의 implementation_notes/01_gemini_live_server_구현_기록.md에는 과거 GPU4 배치, 성능·비용 측정과 기기 시험 이력이 있다. 이번 체크아웃의 Git은 fresh history로 시작하므로 기록에 등장하는 이전 plan 커밋들이 모두 현재 Git에 존재한다고 가정할 수 없다. 과거 처리량·비용 수치는 이번 분석에서 재측정하지 않았다.

검증 결과는 아래에 기록한다. 오프라인 테스트 통과는 실제 음성 인식 정확도, 검색 품질, GPU 처리량이나 APK 실행을 보장하지 않는다.

검증 2026-09-08 결과
기존 오프라인 테스트 9개 파일 48 passed in 4.97s. 최초 분석 시 임시 Python 3.10.12 venv에서 실행; 상세판도 같은 소스 커밋이며 재실행하지 않음
포함 범위 Echo WebSocket·세션 교체/제한, 무음, 토큰 매핑, Qwen helper, TTS chunk 정렬, 도구 파싱, 검색 mock, 문구 규칙, 추측 발화 gate
실행 명령 python -m pytest -q test/test_p0_echo.py test/test_p2_silence.py test/test_p5_token_map.py test/test_p32_qwen.py test/test_p33_tts.py test/test_p42_tools.py test/test_p44_search.py test/test_p44c_wording.py test/test_p46_speculative.py
서버 소스 변경 없음. 분석 문서와 위키 연결만 추가
미실시 Gemini/H200 추론, 실제 Tavily 호출, 원격 배포·재시작, APK 설치, 기기 오디오·기능 왕복, 부하 시험

Connections

Keyboard shortcuts

⌘K / Ctrl+KOpen command palette
/Focus search
g hGo to home
g pGo to projects
g sGo to sessions
j / kNext / prev row (tables)
?Show this help
EscClose dialogs

Structured queries

Mix key:value filters with free text in the palette:

type:sessionOnly session pages
project:llm-wikiFilter by project name (substring)
model:claudeFilter by model name (substring)
date:>2026-03-01Sessions after a date
date:<2026-04-01Sessions before a date
tags:rustPages mentioning a tag/topic
sort:dateSort results by date (newest first)

Example: type:session project:llm-wiki date:>2026-04 sort:date