On-device E2E Source Map
이 문서는 LLM 운영 담당자가 제품 음성 기능의 실제 온디바이스 처리 경로를 소스레벨로 따라갈 수 있도록 만든 지도입니다. “발화 하나가 어디서 어떤 상태로 바뀌는가”를 이 문서 기준으로 확인합니다.
1. 핵심 결론
온디바이스 Agent는 단순히 STT 결과를 LLM에 넣고 TTS를 출력하는 구조가 아닙니다. 실제 흐름은 아래 책임 단위로 나뉩니다.
| 단계 | 대표 파일 | 핵심 책임 |
|---|---|---|
| 음성 이벤트/녹음 상태 | MyAccessibilityService.kt, ForegroundService.kt |
WUW/F1/F2/F3 이벤트 수신, 녹음 시작/종료, 상태 전환 |
| 오디오 버퍼 처리 | ForegroundService.kt, SttPreprocessPipeline.kt |
stream buffer 수집, 전처리, recognizerStream.acceptWaveform() 전달 |
| STT 디코딩 | ForegroundService.kt |
Sherpa ONNX 결과 수집, originalSttResult 저장 |
| 로컬 기능 라우팅 | ForegroundService.processLLAMA() |
딕셔너리 매칭 또는 로컬 LLM 추론으로 <sk_xx> 생성 |
| 파싱 | LLMResponseParser |
<sk_48>(action=1)<sk_end> 같은 문자열을 LLMResponse로 변환 |
| 사전 검증 | ApiCallValidator.kt |
홈잠금, 프라이버시, 음량, 에러코드, 기능별 실행 가능 여부 검증 |
| 기능 실행 | FunctionCallHandler.kt |
토큰별 TTS/Device API/화면 전환 수행 |
| 음성 출력 | ForegroundService.playTTS() |
TTS 정규화, AI Log 저장, 오디오 재생 |
| 종료/재개 | ForegroundService.resetState(), determineMultiTurn() |
멀티턴, Cloud continue, resume/idle 상태 정리 |
2. 실제 메인 호출 흐름
핵심 진입점은 ForegroundService.processVoiceCmd(sttResult: String)입니다.
processVoiceCmd(sttResult)
├─ deviceCommunicator.callSttApi(originalSttResult)
├─ if sttResult blank -> resetState(false)
├─ if isCloudContinue && Wi-Fi -> processCloudLLM()
├─ else -> processLLAMA(sttResult)
├─ if local result == error_answer && Wi-Fi -> processCloudLLM()
├─ deviceCommunicator.callTtsApi(ttsInput)
├─ playWavAnswer() or playTTS()
└─ resetState()는 TTS 완료/상태 흐름에서 이어짐
핵심 포인트는 “Cloud fallback은 처음부터 항상 타는 경로가 아니라 로컬 이해 실패 또는 Cloud continue 상태에서 진입한다”는 점입니다.
3. STT 결과의 두 종류
온디바이스 내부에서는 STT 결과가 최소 두 관점으로 나뉩니다.
| 이름 | 의미 | 사용처 |
|---|---|---|
originalSttResult |
Sherpa ONNX가 만든 원문에 가까운 STT 결과 | Cloud LLM user content, STT 로그 |
sttResult / filteredSttResult |
로컬 딕셔너리/LLM에 넣기 전 구두점 제거, prefix 제거, contains 치환 등이 적용된 값 | processLLAMA() 로컬 라우팅 |
주의할 점은 raw/processed가 항상 동일한 문자열 로그 태그로 출력되는 것은 아니라는 점입니다. 실제 분석에서는 아래 근거를 조합합니다.
| 분석 관점 | 대표 근거 |
|---|---|
| raw 관점 | 테스트 발화 원문, originalSttResult, STT setAILogData, Cloud request user content |
| processed 관점 | processLLAMA() 입력 처리, 구두점 제거, CommandContainsFinder, [DICTIONARY_MATCH] |
| 최종 기능 관점 | parsedLlamaResponse, LLMResponseParser, ApiCallValidator, FunctionCallHandler |
중요한 운영 판단:
Cloud LLM으로 넘어가는 user content는 originalSttResult 기반입니다.
로컬 딕셔너리 매칭은 filteredSttResult 기반입니다.
따라서 “Cloud가 이상하게 이해했다”는 이슈는 original STT와 Cloud request messages를 같이 봐야 합니다.
4. 로컬 LLM 전처리 순서
processLLAMA() 내부의 핵심 순서는 다음과 같습니다.
1. DeviceStatus.STATE_LLM_RUNNING 전환
2. setLlmStatus(STATE_LOCAL_LLM_RUN)
3. filteredSttResult = sttResult.trimEnd()
4. multiTurnBusy면 multiTurnTextFragment와 현재 STT 결합
5. `.`, `,`, `!`, `?` 제거
6. removePrefixIfExists()
7. commandContainsFinder.replaceWithKey()
8. 딕셔너리 매칭 순서 수행
9. 매칭 실패 시 mQaClient.Infer(prompt)
10. parsedChunk에 `<sk_end>` 부착
11. LLMResponseParser로 토큰/파라미터 파싱
12. 운영 빌드에서는 ApiCallValidator.preApiCallValidate()
13. FunctionCallHandler로 기능 실행/TTS 생성
이 순서 때문에 테스트에서는 아래 로그가 중요합니다.
processVoiceCmd()
Processing with on-device LLM
LLM inference started
[DICTIONARY_MATCH][...]
parsedLlamaResponse
Cutted parsedLlamaResponse
HI_NAMUH_LLMResponseParser
ApiCallValidator(preApiCallValidate)
FunctionCallHandler
mappingTtsTextAndDeviceApi
ttsText
5. 상태관리 핵심 변수
| 변수 | 의미 | 주의점 |
|---|---|---|
multiTurnBusy |
로컬/Cloud 후속 발화를 기다리는 상태 | parsedChunk.contains("-1") 또는 Cloud question 응답으로 true |
multiTurnTextFragment |
이전 턴의 불완전 발화 조각 | 다음 STT와 결합되어 로컬 LLM 입력으로 사용 |
multiTurnCount |
멀티턴 반복 횟수 | 일정 횟수 이상이면 종료/안내 필요 |
isCloudContinue |
Cloud 대화가 이어지는 상태 | true면 determineMultiTurn()이 parsedChunk 기반 판단을 하지 않음 |
skipResume |
TTS 후 바로 resume하지 않을지 여부 | Cloud continue/멀티턴에서 청취 유지에 영향 |
isDetermineMultiturnNeeded |
이번 턴에서 멀티턴 판단을 할지 여부 | 오류/검증 실패에서 false 처리될 수 있음 |
6. 핵심 실패 패턴
패턴 A: STT raw부터 틀림
발화: 오십분
STT raw: 오0분
로컬 딕셔너리/Cloud 모두 잘못된 입력을 받음
판단:
원인 후보는 STT/음성 전처리/발음/환경 소음입니다.
딕셔너리로 해결할 수는 있지만, 전체 문장 치환은 사이드이펙트가 크므로 제한적으로만 적용합니다.
패턴 B: STT는 맞는데 토큰이 틀림
STT processed: 오늘 날씨 알려줄래
실제 토큰: <sk_48>(action=4)
판단:
딕셔너리 contains 조건이 너무 넓거나, 기능명/동작어 그룹이 잘못 묶인 경우입니다.
`[DICTIONARY_MATCH]` 로그에서 어떤 JSON 파일이 매칭됐는지 확인합니다.
패턴 C: 토큰은 맞는데 실행이 안 됨
토큰: <sk_46>
ApiCallValidator: SafeCareFail 또는 MoveFunctionFail
TTS: 현재 보안 모드 실행이 불가능합니다...
판단:
LLM 문제가 아니라 현재 기기 상태/에러 상태 때문에 실행 전 차단된 것입니다.
Device API errorcode와 validation rule을 같이 확인해야 합니다.
패턴 D: Cloud가 rewrite_query를 만들었지만 다시 실패
Cloud response: case=2, rewrite_query="Check the air quality map history, please"
로컬 재진입 결과: error_answer
판단:
Cloud Case는 맞지만 rewrite_query가 온디바이스 딕셔너리에서 기대 토큰으로 매칭되지 않는 경우입니다.
Cloud prompt와 온디바이스 dictionary를 동시에 수정해야 할 수 있습니다.
7. 소스 읽는 순서
처음 보는 LLM 운영 담당자는 아래 순서로 코드를 읽습니다.
1. ForegroundService.processVoiceCmd()
2. ForegroundService.processLLAMA()
3. PreDefinedCommandMatcher / CommandContainsFinder / CommandContainsMatcher / ThreeKeywordContainsTokenMatcher
4. LLMResponseParser
5. ApiCallValidator.preApiCallValidate()
6. FunctionCallHandler
7. DeviceCommunicator
8. ForegroundService.processCloudLLM() / callCloudLLMAPI()
9. ChatRepository
8. 점검 질문
1. originalSttResult와 filteredSttResult의 차이는 무엇인가?
2. Cloud LLM으로 넘어가는 문장은 어느 값을 기반으로 하는가?
3. 로컬 딕셔너리 매칭 순서는 무엇인가?
4. `<sk_xx>`가 맞아도 기능이 실행되지 않는 대표 이유는 무엇인가?
5. Cloud Case 2가 성공하려면 Cloud와 On-device 중 어느 쪽이 맞아야 하는가?