실시간 경로 — 오디오 · 채팅 · 회의 중 에이전트
갱신일 2026-08-12 · 기준: 확장 개요 · 현재 판 SA · interfaces/server-web.md · 도메인 모델 FR-10 이 문서가 갖는 것: 두 실시간 경로(오디오 WebSocket · 채팅 SSE)의 개선과, 회의 중 에이전트를 그 위에 어떻게 얹는가. 여기 없는 것: 경계표(SA) · AWS 형상(CA) · 그래프 스키마(온톨로지) · 테이블 컬럼(ERD).
⚠︎ 이건 계획이지 현황이 아니다. 지금 서 있는 것은 현재 판 SA가 갖는다.
1. 지금 두 경로
둘 다 동작한다. 아래는 규모가 커질 때 부러지는 지점, 그전에 고치면 싼 것, 그리고 이 위에 얹는 회의 중 에이전트다.
2. 회의 중 에이전트 — 무엇을 만드나
Event Assistant 는 페르소나가 아니라 Event 형이고 개입 조건이 3종으로 한정돼 있다(FR-10).
| 조건 | 지금 | 실시간으로 옮기면 |
|---|---|---|
| C1 결론 없이 종료 | 종료 시점 | 유지 — 회의 중엔 아직 정하는 중이다 |
| C2 담당자·기한 미지정 | 종료 시점 | 유지 — 종료 시점. 당기는 안은 §13 |
| C3 이전 결정 충돌 | 종료 시점 | ⬅ 실시간이어야 값어치가 있다 |
C3가 본체다. "3주 전에 반대로 정했다"를 회의 뒤에 알려주면 늦었다. 그 자리에서 말해야 결정이 바뀌고, 그건 그래프가 있어야 된다. 부수로는 사실 확인 · 미결 액션 아이템 환기 · 부재 담당자 · 명시 호출 @heymoa(개입이 아니라 요청이라 챗봇에 답한다).
3. S1 10:23:14 — 초 단위로 추적
이서연: "그냥 STT 공급자 ElevenLabs로 다시 가시죠" (10:23:14.0 시작 · 10:23:17.2 종료)
| 구간 | 예산 | S1 (추정) |
|---|---|---|
| 발화 끝 → final 세그먼트 | 1.5초 | 1.2초 |
| 세그먼트 → 게이트 진입 | 조기 0.1초 / 틱 대기 0~30초 | 0.1초 |
| 싼 게이트 (임베딩 + pgvector + 사전) | 0.5초 | 0.4초 |
| ai 왕복 (Neo4j 1회 + LLM 게이트) | 3초 | 2.7초 |
| 카드 영속화 + 팬아웃 | 0.3초 | 0.3초 |
| 목표 — 조기 트리거 | p50 5초 · p95 8초 | 4.7초 |
| 목표 — 틱만 | p50 20초 · p95 35초 | 16.8초 |
발화 후 45초를 넘긴 판정은 버린다. 논의가 이미 다음 화제로 갔다. 늦은 카드는 방해이고, 놓친 충돌은 회의 후 요약이 잡는다.
지연의 본체는 틱 대기다. 틱을 15초로 줄이는 대안은 임베딩 부하를 2배로 만들면서 최악 지연은 절반밖에 못 줄인다. 어휘 스캔은 정규식이라 공짜이므로 어휘가 걸릴 때만 창을 앞당긴다. 조기 트리거로 돈 창은 다음 틱에서 건너뛰어 임베딩 총량이 오히려 준다.
이 흐름이 지키는 것: 틱·싼 게이트는 server가 돈다(세그먼트를 이미 저장한다) · ai는 창 단위 무상태 호출이다(D13) · 그래프 조회는 턴당 1회 · 맥락 프리픽스는 prompt caching으로 재사용한다. 버린 대안은 회의마다 살아 있는 세션 액터 — 인스턴스 고정을 부른다.
4. 싼 게이트 — 실제로 무엇을 보나
세 신호를 본다. 어느 하나도 단독으로 못 쓴다.
| 신호 | 구현 | 임계 | 오탐의 성격 |
|---|---|---|---|
| 결정 어휘 | 정규식 사전 · 창 텍스트 | 히트 ≥1 | 재현율만 높다. "점심 뭐 먹기로 하죠"가 그대로 걸린다 |
| 임베딩 유사도 | 창 임베딩 vs 프로젝트 확정 Decision (pgvector) | 코사인 ≥0.75 | 같은 주제면 무조건 높다. 반복 논의와 충돌을 구분 못한다 |
| 고유명사 | 결정 본문에서 뽑은 사전 + 창의 신규 고유명사 | 기존 ≥1 ∧ 신규 ≥1 | 스치기만 해도 걸린다. "ElevenLabs도 있긴 한데" |
| 어휘 부류 | 예 | C3 가중치 |
|---|---|---|
| 번복형 | 다시 되돌리 바꾸 대신 아니라 취소 엎 | 높다 — C3의 직접 신호 |
| 확정형 | 하기로 가기로 정합시다 가시죠 갑시다 확정 결론은 | 중간 |
| 액션형 | 까지 맡 해주세요 담당 | C2 전용 |
결합 규칙 — 셋 중 하나면 LLM 게이트로 올린다. S1은 첫 줄로 통과한다(다시 + 0.81).
번복형 ∧ 유사도 ≥0.75
확정형 ∧ 유사도 ≥0.82
(기존 고유명사 ∧ 신규 고유명사) ∧ 유사도 ≥0.70
한국어가 아닌 창은 첫 줄이 통째로 죽는다. 어휘 사전이 전부 한국어 정규식이라 영어 발화는 히트가 0 이고, 그 창은 게이트에 아예 안 올라온다 — §5의 intervene:false 비율로도 안 잡힌다. 위험의 본체는 오탐이 아니라 조용한 재현율 0 이다.
그래서 창의 언어를 판정해 한국어가 아니면 어휘 신호를 끄고 임베딩·고유명사 둘로만 판정한다(유사도 임계 0.75 → 0.82, 추정). 「어휘 미적용 창 비율」을 지표로 같이 센다 — 이 비율이 조용히 오르는 것이 기능이 꺼지고 있다는 유일한 신호다.
상한: 창당 후보 1건 · 회의당 LLM 게이트 20회(초과분은 큐에 안 남기고 버린다) · 회의당 카드 5건(§6).
버린 대안: 발화마다 소형 분류 모델. 10만 규모에서 초당 17회 추론이고 상시 GPU가 필요하다. 정규식 + 임베딩 1회가 같은 자리를 채운다.
5. LLM 게이트 — 무엇을 받고 무엇을 내놓나
받는 것 (POST /internal/v1/meeting-agent/evaluations)
| 필드 | 무엇 | S1 |
|---|---|---|
window | 최근 60초 전사 + 타임코드. 화자 라벨이 없다 | [23:14] 그냥 STT 공급자 ElevenLabs 로 다시 가시죠 |
candidates[] | Neo4j가 준 확정 Decision 최대 5건 — 한 홉 순회다. 값은 온톨로지 §7 (a)가 갖는다 | STT 는 Soniox 로 간다 (2026-07-22 · 근거 23:14) |
meetingContext | 워크스페이스·프로젝트·회의 제목 (캐시 프리픽스) | 민손 팀 / PRO-1 실시간 STT |
alreadyShown[] · rejected[] | 이미 띄운 decisionId · 사용자가 기각한 (창 주제, 결정) 쌍 | [] |
화자가 없다는 사실이 카드 문구를 정한다. 화자 분리는 배치라(D3) 회의 중에는 누가 말했는지 모른다. 그래서 카드는 사람 이름을 쓰지 않는다. "이서연이 뒤집으려 한다"가 아니라 "방금 발언"이다.
내놓는 것 — 구조화 출력. 자유 서술은 파싱 실패·인용 환각·길이 폭주를 전부 부른다.
{ "intervene": true, "type": "C3", "confidence": 0.86,
"decisionId": "0K3M8QW2R5V7Z",
"cardTitle": "…", "cardBody": "…",
"quote": "STT 공급자 ElevenLabs 로 다시",
"evidence": { "noteId": "0K1…", "segmentIds": ["0K2…"], "tStart": "23:14" } }
| 필드 | 규칙 | 어기면 |
|---|---|---|
type | C1 C2 C3 FACT MENTION 만 — 그대로 agent_cards.condition 이 된다(ERD §5). 부재 담당자는 C2 로 낸다 | 버린다 |
confidence | 0.7 미만이면 카드를 안 만든다 | 요약이 회수 |
decisionId | candidates[] 안에 있어야 한다 | 버린다 · 오류 카운터 |
quote | window 원문의 부분 문자열. server가 검사한다 | 버린다 — 환각 인용 차단 |
cardTitle·cardBody | 20자 / 80자. 문구는 IA §1이 갖고 여기는 길이 상한만 본다 | 자른다. 긴 카드는 안 읽는다 |
evidence | 온톨로지 §4와 같은 모양 | 버린다 |
intervene:false 도 남긴다. 싼 게이트의 정밀도는 이 비율로만 측정된다.
6. 카드 — 문구와 오탐 처리
회의 중 봇이 끼어드는 것은 기본적으로 방해다. 한 번 헛소리하면 아무도 다시 안 본다. 개입 형태가 정확도보다 먼저다. 카드 제목·본문·버튼 라벨은 IA §1이 갖는다. 여기는 그 화면 뒤의 상태 전이만 본다.
| 개입 형태 | 왜 |
|---|---|
| 음성으로 끼어들지 않는다 | 사람 말을 자르고, 회의실 마이크가 그 소리를 다시 전사한다 |
| 공유 챗봇에 쏟지 않는다 | 사람 대화를 밀어낸다 |
| 사이드 패널에 조용히 쌓고 배지만 띄운다 | 놓쳐도 손해가 없어야 켜둘 수 있다(D12) |
| 강도는 두 단계 — 조용한 카드 · C3 배지 강조 | 전부 같은 강도면 C3가 묻힌다 |
| 회의 후 요약의 "회의 중 감지" 절이 회수한다 | 놓친 카드가 사라지지 않는다 |
마지막 줄이 이 기능을 살린다 — 놓쳐도 손해가 없고 맞으면 이득이 크다. 정밀도 > 재현율.
노출 상태와 사용자 조작은 다른 칼럼이다 — status(SHOWN·RECALLED)는 카드가 화면에 있나, resolution(ACCEPTED·REJECTED·DEFERRED·null)은 사람이 무엇을 눌렀나(ERD §5). 아래는 resolution 만 본다.
| 조작 | resolution | 무엇이 달라지나 |
|---|---|---|
[근거 보기] | 안 바뀐다 | 2026-07-22 노트의 23:14 줄로 이동만 한다 |
[관련 없음] | REJECTED + (workspaceId, decisionId, 창 임베딩) 억제 항목 | 이 회의에서 그 decisionId 로 다시 안 뜬다. 다음 회의부터는 유사도 ≥0.9 인 창만 억제 |
[닫기] | DEFERRED | 요약에 남는다 |
| 아무도 안 누름 | null | 요약에 남는다. 이게 기본값이다 |
회의 후 맥락 배너 [뒤집는다] | ACCEPTED | 요약이 이 창을 SUPERSEDES 후보로 우선 올린다. 그래도 관계는 미확인으로 남는다 — 온톨로지 §5 |
[관련 없음] 은 그래프를 고치지 않는다. 카드 기각은 "이 창은 그 결정과 충돌이 아니다"이지 "그 결정이 틀렸다"가 아니다. 온톨로지 §5의 부정 기록과 저장소는 같이 쓰되 스코프가 다르다 — 카드 기각은 (창, 결정) 쌍, 그래프 부정은 (결정, 결정) 쌍이다. 억제는 워크스페이스 스코프 · 90일. 영구가 아닌 이유는 석 달 뒤엔 정말로 충돌일 수 있어서다.
오탐 예산은 회의당 1건이다. 45분에 두 번 틀리면 사용자가 패널을 닫는다(추정). confidence 0.7 커트와 카드 5건 상한이 둘 다 있어야 그 안에 든다.
에이전트 카드를 transcript_segments 에 넣지 않는다. 같은 테이블이면 다음 요약이 자기 출력을 근거로 삼는 피드백 루프가 생긴다.
7. 경로 A — 오디오
7.1 형식 — 지금 정확히 두 배를 낸다
측정값은 시간당 230MB = 512kbps. 16kHz 마이크에서 이 수가 나오는 경우는 하나뿐이다.
| 형식 | 시간당 | 10만 규모 (월 41,250시간) |
|---|---|---|
| Float32 16kHz mono | 230MB | 9.5TB — 측정값과 일치. AudioWorklet이 그대로 주는 형식 |
| Int16 PCM 16kHz mono | 115MB | 4.7TB — 같은 정보량, STT 품질 차이 없음 |
| Opus 32kbps | 14MB | 578GB |
| Opus + VAD | 약 7MB | 289GB |
셋을 다 하면 약 30배다. CA §16의 egress $1,190이 $40이 된다.
7.2 단계 1 — Float32 → Int16 (워클릿 안, 6줄)
AudioWorkletProcessor.process() 안에서 변환한다. 밖에서 하면 postMessage 로 넘기는 버퍼가 이미 두 배다.
const src = inputs[0][0]; // Float32Array(128)
const pcm = new Int16Array(src.length);
for (let i = 0; i < src.length; i++) {
const s = Math.max(-1, Math.min(1, src[i])); // 클램프를 빼면 int16 이 래핑돼 딱딱 튄다
pcm[i] = s < 0 ? s * 0x8000 : s * 0x7fff; // int16 범위가 비대칭이라 스케일이 다르다
}
ring.push(pcm); // 40~100ms 모아서 transfer
128프레임 = 8ms 다. 그대로 보내면 초당 125회 postMessage 다. 워클릿 안에 링 버퍼를 두고 40~100ms 배치로 모아 postMessage(buf, [buf]) 로 transfer 한다 — 현재 계약이 이미 40–100ms를 강제한다. 링 버퍼는 §7.4에서 다시 쓴다.
7.3 단계 2 — Opus (WebCodecs AudioEncoder)
const enc = new AudioEncoder({
output: (chunk) => { const b = new Uint8Array(chunk.byteLength); chunk.copyTo(b); ws.send(b); },
error: () => fallbackToPcm16(),
});
enc.configure({ codec: 'opus', sampleRate: 16000, numberOfChannels: 1, bitrate: 32000 });
// 링 버퍼의 Int16 배치 → new AudioData({ format: 's16', … }) → enc.encode()
폴백이 필수다 — AudioEncoder 부재 또는 isConfigSupported() 거짓이면 단계 1로 내려간다. 코덱은 핸드셰이크에서 협상하고 세션 중간에 바꾸지 않는다(바꾸면 릴레이가 프레임 경계를 잃는다). Soniox가 Opus를 받는지가 미확인이다 — 안 받으면 server가 디코딩해야 하고, egress는 줄지만 릴레이 CPU가 는다. 그 교환을 재기 전에는 착수하지 않는다.
7.4 단계 3 — VAD (링 버퍼 위에서 배치 단위)
const rms = Math.sqrt(sum / n);
if (rms < floor * 1.5) silent++; else silent = 0; // floor = 첫 2초 조용한 구간의 중앙값
if (silent > 25) return; // 1초 넘게 조용하면 안 보낸다
| 규칙 | 왜 |
|---|---|
| 무음 중에도 250ms 마다 keepalive 프레임 1개 | 안 보내면 서버·Soniox가 세션이 죽은 줄 안다 |
| 말 시작 전 200ms(배치 5개)를 되돌려 보낸다 | 안 그러면 첫 음절이 잘려 STT가 단어를 통째로 놓친다. 링 버퍼가 여기서 회수된다 |
| 고정 임계 금지 · 사용자가 끌 수 있게 | 방마다 바닥 소음이 다르고, 에어컨·팬이 뜨는 방이 있다. 캘리브레이션 값을 화면에 남긴다 |
VAD 오작동의 대가가 제일 크다 — 잘못 자르면 전사가 빈다. 그래서 착수 순서가 마지막이다.
7.5 오디오를 STOMP에서 뗀다
이 절은 §11 6번 이후의 형상이다. 1만 형상의 채널은 SA §3 ④ 가 정한다 — 거기서는 오디오가 STOMP 위에 그대로 있고 인코딩만 바뀐다.
STOMP는 프레임 헤더가 있는 제어 프로토콜이다. 초당 수십 프레임을 태우면 헤더·파싱이 그대로 비용이고, 오디오 백프레셔가 제어 메시지를 밀어낸다 — sendTimeLimit 10s 를 넘겨 세션이 끊기면 전사 팬아웃이 같이 죽는다. 제어·팬아웃은 STOMP로 두고 오디오만 전용 바이너리 WS로 뗀다. 경로가 갈리면 나중에 전사 릴레이만 별도 서비스로 떼기 쉬워진다(AA §1).
7.6 세션 재개 프로토콜
아래 프레임 표기는 채널 분리(§11 6번) 이후의 전용 WS를 전제한다. 분리 전에는 같은 규약을 STOMP 프레임에 실어 §11 3번에서 먼저 켠다 — 재개는 배포·장애·네트워크가 다 쓰는 물건이라 분리를 기다릴 수 없다.
web → server connect { transcriptionSessionId, lastAckedSeq: 8412, codec, sampleRate }
server → web connected { resumeFrom: 8413 }
또는 { resumeFrom: null, reason: "SESSION_GONE" } → 새 세션 개설
| 항목 | 값 | 왜 |
|---|---|---|
seq | 세션 스코프 uint32 · 오디오 배치마다 +1 | 정렬키가 이미 (sessionStartedAt, sessionId, sequence) 다 |
| ack | Soniox에 넘긴 지점까지 200배치(약 10초)마다 ack{seq} | 매 배치 ack는 역방향 낭비 |
| 클라이언트 버퍼 | 미ack 최대 90초분. 넘치면 오래된 것부터 버리고 gap 표시 | 무한 버퍼는 탭 메모리를 먹는다. 90초 = 지하철 한 구간(추정) |
| 중복 제거 | server가 (sessionId, seq) 로 기수신분을 버린다 | 재연결 경계에서 겹쳐 보내는 편이 빠뜨리는 것보다 안전하다 |
| 갭 | 유실 구간에 gap 마커 세그먼트 | 빈 구간을 조용히 숨기지 않는다. 화면에 "이 구간 녹음 유실" |
| 세션 수명 | 마지막 프레임으로부터 10분 | ECS 롤링·크래시가 이 창 안에 들어와야 한다 |
| 재연결 | close 코드 ≠ 1000 이면 전부. 백오프 1·2·4·8·최대 15초, 10분까지 | |
| 토큰 | 연결 위 auth.refresh 프레임 · 실패해도 5분 유예 | TTL 30분 · 회의 2시간. 회의 중에 인증으로 끊지 않는다 |
버린 대안: 서버가 오디오를 버퍼링해 재전송. 서버가 상태를 갖고 인스턴스 고정이 필요해진다. 버퍼는 클라이언트에 둔다.
S5의 함정 — 배포 중 태스크는 "새 세션 안 받음"으로 가되 resume은 계속 받아야 진행 중 3건이 안 끊긴다. 이때 인계 가능 여부가 물건마다 갈린다.
| 무엇 | 인계 | 왜 |
|---|---|---|
| S3 멀티파트 오디오 보관 | 된다 | uploadId 가 DB에 있다. 다른 태스크가 그대로 이어 올린다(SA §9 3번) |
| Soniox 릴레이 세션 | 안 된다 | 세션 상태가 JVM 안이고 공급자 쪽 세션도 그 연결에 매여 있다. 새로 연다 |
그래서 resume이 다른 태스크로 가도 오디오는 안 끊기고 전사만 그 경계에서 갈라진다 — 이음매에 gap 마커를 남긴다. 같은 태스크로 붙이는 스티키 라우팅이 필요한지는 SA §13.
8. 경로 B — 채팅
8.1 공유 챗봇 관전자를 STOMP로
새 토픽을 파지 않는다. /topic/notes/{noteId} 에 이미 chat.token·chat.message_end·chat.lock 이 실려 있다(현재 판 SA).
| 관전자가 알아야 할 것 | 지금 | 토픽 |
|---|---|---|
| 누가 입력 중인가 | 폴링 응답의 lock | chat.lock — 있다 |
| 답변 토큰 · 답변 끝 | 안 보인다 / 폴링 | chat.token · chat.message_end — 있다 |
| 승인 대기 중인가 | 폴링 | chat.lock 에 상태만 싣는다. approvalId 는 안 싣는다 |
| 턴이 실패했나 | 안 보인다 | chat.turn_failed 를 새로 판다 |
승인 상태를 싣는 이유: 안 실으면 관전자가 멈춘 화면을 고장으로 읽는다. approvalId 를 빼는 이유: 승인 주체가 입력자 본인이라는 경계를 UI 구조로 유지한다.
관전자를 얹으면 팬아웃 백프레셔가 새 실패 축이 된다. 10만에서 동시 구독 2,000이라, 느린 관전자 하나가 브로커 큐를 쌓으면 그 노트의 팬아웃 전체가 같이 늦는다. 그래서 구독자 prefetch와 구독자당 미확인 상한을 걸고 넘치면 그 구독을 끊는다 — 값은 SA §8 이 갖는다. 끊어도 되는 이유는 정본이 Postgres 이기 때문이다. 끊긴 관전자는 REST로 다시 맞춘다.
개인 챗봇은 SSE 그대로 둔다 — 진짜 1:1이라 토픽을 파면 구독자 1명짜리 토픽이 사용자 수만큼 생기고, 오버레이라 noteId 스코프가 없어 토픽 키 자체가 없다. 게다가 enableSimpleBroker 는 JVM 내부라 얹을수록 브로커 교체 시점이 당겨진다. 바꿀 이유가 없는 것은 안 바꾼다.
8.2 턴을 DB row로 + Last-Event-ID 재개
도구 왕복이 60~135초인데 그 사이 끊기면 사용자는 무슨 일이 있었는지 모른다. 상태값(컬럼은 ERD가 소유):
PENDING → STREAMING → (AWAITING_APPROVAL ⇄ STREAMING) → SUCCEEDED | FAILED | ABANDONED
| 상태 | 언제 | 재개하면 |
|---|---|---|
PENDING | POST 접수, ai 호출 전 | 처음부터 |
STREAMING | message_start 이후 | Last-Event-ID 이후만 재생 |
AWAITING_APPROVAL | tool_approval_request 발행 | 승인 카드를 다시 그린다 |
SUCCEEDED · FAILED | message_end tee 완료 · error 또는 계약 위반 | 재생 없이 히스토리 / 실패 표시 |
ABANDONED | 승인 300초 만료 · SSE 30분 상한 · 워치독 | 실패 표시 |
| 재개 규약 | |
|---|---|
이벤트 id: | {turnId}:{seq} · seq 는 턴 스코프 1부터 |
| 진입점 | GET /v1/notes/{noteId}/chat/turns/{turnId}/stream — GET이라 EventSource 가 Last-Event-ID 를 자동으로 보낸다. 최초 전송이 POST 인 이유(요청 본문)가 재개에는 없다 |
token 은 보관하지 않는다 | 전부 보관하면 저장 비용이 답이 없다. 재개 시 그 시점까지의 부분 텍스트를 message_resume 한 건으로 준다 |
| 보관하는 것 | 구조 이벤트만 — tool_call_start·tool_approval_request·tool_approval_resolved·tool_call_result·message_end. 턴당 10건 이내(추정) · 보존 24시간 |
8.3 나머지 둘
승인을 STOMP 로 옮기면 노트 행 락·멤버십 재검증·keepalive·"claim 후 회의 종료" 잔여 창의 상당수가 사라진다. 다만 지금 것이 틀린 게 아니다 — 승인 규칙을 다시 건드릴 때 같이 한다. 가상 스레드는 확인하고 켜는 것이 사실상 무료다. 안 켜져 있으면 동시 채팅의 천장이 스레드 풀 크기다.
9. 실패 경로
| 실패 | 감지 | 결과 |
|---|---|---|
| Soniox final이 10초 넘게 안 옴 | 창 조립 시 세그먼트 부재 | 그 창을 건너뛴다 |
| ai 게이트 5초 초과 | server read timeout | 카드 없음. 재시도 안 한다 — 45초 지나면 값어치가 없다 |
quote 가 창 원문에 없음 · decisionId 가 후보 밖 | server 검증 | 버린다 · 오류 카운터 |
| Neo4j 무응답 | ai 타임아웃 2초 | intervene:false. 그래프 장애가 회의를 막지 않는다 |
| 카드 5건 초과 | server 카운터 | 이후 판정은 DB 에만 쌓고 팬아웃 안 한다 |
| 오디오 WS 끊김 | close ≠ 1000 | 재개 핸드셰이크. 90초 초과분은 gap |
| resume이 다른 태스크로 감 | SESSION_GONE | 멀티파트 보관은 uploadId 로 이어받고 STT 세션만 새로 연다. 그 경계에 gap 마커 |
| 인스턴스 2대에서 카드 팬아웃 반쪽 | 없다 | 조용히 깨진다. 브로커 외부화 전에는 에이전트를 켜지 않는다 |
10. 부하와 비용 — 브리프 수치로 다시 확인
| 등록 1만 | 등록 10만 | |
|---|---|---|
| 피크 동시 회의 · WebSocket | 50 · 200 | 500 · 2,000 |
| 30초 틱 = 싼 게이트 임베딩 | 초당 1.7 | 초당 17 |
| LLM 게이트 | 분당 3~10 | 분당 30~100 |
| 회의당 LLM 호출 | 요약 1 + 에이전트 5~20 | 같음 |
| 월 LLM 호출 (5,500 / 55,000건) | 33,000~115,500 | 330,000~1,155,000 |
요약 대비 6~20배다. CA §16의 "OpenAI 요약·채팅" 줄이 이 기능 하나로 한 자릿수 배가 된다. 줄이는 수단은 셋뿐이다 — 싼 게이트 정밀도(후보가 절반이면 원가가 절반, 여기가 본체) · 작은 모델 · prompt caching. 그리고 기능을 조직 단위로 끌 수 있어야 한다. 원가가 이만큼 움직이면 요금제와 묶인다.
지금까지 LLM 부하는 회의 종료 후 버스트였다. 실시간 에이전트는 회의 내내 깔리는 지속 부하다. 성격이 다르다.
11. 무엇부터 하나
| # | 무엇 | 왜 이 자리 |
|---|---|---|
| 1 | Float32 → Int16 | 워클릿 6줄로 대역폭 절반. 규모와 무관하게 이득 |
| 2 | 가상 스레드 확인·활성화 | 사실상 무료 |
| 3 | 세션 재개 (오디오) | 배포·장애·네트워크가 한 코드로 처리된다. ECS 이전의 전제 |
| 4 | 관전자 STOMP 구독 | 이벤트 3종이 이미 토픽에 있다 |
| 5 | Opus | egress의 본체. Soniox 수용 포맷 확인 먼저 |
| 6 | 오디오 채널 분리 | 릴레이 분리의 준비 |
| 7 | 턴 DB row + Last-Event-ID | |
| 8 | 회의 중 에이전트 — C3 만 | 그래프 + 외부 브로커 다음 |
| 9 | VAD | 잘못 자르면 전사가 빈다. 마지막 |
| 10 | 승인을 STOMP로 | 승인 규칙을 다시 건드릴 때 같이 |
8번은 선행 조건이 둘이다. 그래프가 없으면 싼 게이트가 성립하지 않고(매번 전체 검색), SimpleBroker를 못 벗어나면 카드 팬아웃이 조용히 반쪽 난다.
12. 설계 전제
- 오디오와 제어를 같은 채널에 태우지 않는다. 백프레셔 성격이 다르다.
- 보내기 전에 줄인다. 전송량은 인프라가 아니라 클라이언트가 정한다.
- 끊김은 정상이다. 재개 버퍼는 클라이언트에 둔다. 서버는 무상태로 남는다.
- 1:N은 브로커로, 1:1은 스트림으로.
- ai는 무상태로 유지한다. 회의 세션 상태를 ai 프로세스에 두지 않는다.
- 에이전트는 놓쳐도 되게 만든다. 정본은 회의 후 요약이다. 그래야 켜둘 수 있다.
- 정밀도 > 재현율. 틀린 개입 한 번이 기능 전체를 죽인다.
- 모델 출력은 검증 가능한 것만 믿는다. 인용은 원문 부분 문자열, 결정은 후보 목록 안.
- 봇 산출과 사람 발화를 데이터에서 분리한다. 피드백 루프를 막는다.
13. 미확인
| 무엇 | 지금 아는 것 | 확인해야 할 것 |
|---|---|---|
| 지금 오디오 형식 | 230MB/h 실측. 그런데 현재 판 SA 계약은 PCM16 24kHz · 48 bytes/ms(= 173MB/h)로 적혀 있다 | 둘이 안 맞는다. 실측이 맞으면 §7.2가 즉시 이득, 계약이 맞으면 절감폭이 달라진다 |
| Soniox 수용 포맷 · final 지연 | 없다 | Opus를 받나(Int16은 거의 확실) · §3 예산 첫 줄의 1.2초는 추정이다 |
| 관전자 폴링이 남은 이유 | 토픽 계약에 chat.* 3종이 이미 있다 | 이벤트가 있는데 왜 폴링인가. web 미구현인지 다른 제약인지 |
| 싼 게이트 정밀도 | 없다 | §10 원가의 본체. 프로토타입으로 먼저 잰다 |
| 다국어 회의 | 어휘 사전이 전부 한국어 정규식이다. 비한국어 창은 어휘를 끄고 임계 0.82로 가기로 했다(§4 · 추정) | 임계 0.82가 맞나 · 창 단위 언어 판정을 무엇으로 하나 · Soniox 언어 설정이 회의 단위인지 자동 판정인지 · 「어휘 미적용 창 비율」의 정상 범위 |
| 한국어 어미 변형 | 정규식 사전으로 잡는다는 가정 | 하기로/하죠/하입시더. 형태소 분석 없이 재현율이 나오나 |
| 유사도 임계 0.75 | 근거 없는 초기값 | 임베딩 모델과 결정 문장 길이에 따라 달라진다 |
| 개입 수용률 | 없다 | 사용자가 카드를 실제로 보나. 안 보면 §6을 다시 짠다 |
| C2를 종료 5분 전으로 당기기 | 지금은 C1·C2 둘 다 종료 시점이다(D11 · §2) | 종료 5분 전은 아직 회의 중이라 "실시간은 C3 만"을 건드린다. 담당자 미지정 알림이 회의 중에 값어치가 있나 · 종료 시각을 미리 아는 회의가 얼마나 되나 |
| 실시간 LLM의 법적 취급 | 종료 후 처리와 다를 수 있다 | 동의 문구 · 국외 이전 |
| 화제 전환 감지 | 없다 | 고정 60초 창 + 조기 트리거보다 나은지 |
| 가상 스레드 활성 여부 | Spring Boot 3.5 · Java 21 | 켜져 있나 |