[에이전트] 11. UX : 기다림을 설계하다 — notice·타이핑·복원되는 타임라인

폐쇄망 사내 에이전트 11편 - UX

작성자

카테고리:

* 이 게시물은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

(폐쇄망 사내 에이전트 ⑪) UX

로컬 4B는 느리다. 인프로세스 실측 기준 답변 하나에 수십 초. 이 기다림을 없앨 수는 없지만 설계할 수는 있다. 그리고 기다림보다 나쁜 게 있다 — 시스템이 뒤에서 뭔가 했는데 사용자는 모르는 것. 이 프로젝트가 8일 내내 잡아온 “조용히 틀리는” 부류다. 이번 편은 그 둘을 고친 커밋들이다.

잘려나가는 건 사용자가 알아야 하는 사건이다

컨텍스트가 차면 압축이 돌고(9편), 더 차면 llama.cpp가 앞부분을 밀어낸다(시프트). 종전엔 이게 디버그 로그에만 남았다 — 화면에서는 “모델이 갑자기 앞 얘기를 잊은 것”으로만 보였다. 그래서 스트림에 notice라는 통로를 하나 팠다. 상태 표시(덮여 사라짐)도 오류(실패)도 아닌, “이런 일이 있었다”를 대화에 남기는 세 번째 통로다. 디테일: 시프트는 한 번 시작되면 매 턴 계속되므로 시작될 때 한 번만 알린다 — 알림도 소음이 되면 안 읽는다. 통지에는 “화면의 답변 전문은 그대로 남아 있습니다”를 함께 싣는다 — 이 알림을 받고 사용자가 제일 먼저 하는 걱정이 그것이라서다.

이 커밋엔 웃픈 곁가지가 있다. 소스 파일에 리터럴 NUL 바이트가 하나 박혀 있었는데, grep이 그 파일을 바이너리로 취급해 통째로 건너뛰는 바람에 이미 구현돼 돌고 있던 압축 처리를 “미구현”으로 오진했던 것. NUL을 String.fromCharCode(0)으로 바꿔서 해결했다. 도구가 못 보는 코드는 없는 코드다.

수십 초의 빈 화면 — 타이핑으로

웹은 SSE 스트리밍으로 일찍부터 타이핑이 됐는데 CLI는 완성된 답을 한 번에 냈다. 수십 초 빈 화면 뒤 텍스트 폭포. 이걸 실시간 타이핑으로 바꾸며 정한 규칙 두 개가 소형 모델 특화라 재밌다.

  • 사고(thought)는 그리지 않는다 — Qwen 계열의 사고는 본문의 몇 배 길이라 터미널을 뒤덮는다. 사고는 스피너(“생각 중”)가 대신하고, 본문 델타만 흘린다.
  • 정본은 여전히 최종 결과다 — 루프가 후처리(경고 접두 등)를 하면 델타의 합과 달라질 수 있어, 표시 계층이 흘린 텍스트와 정본을 비교해 같으면 재출력을 생략하고 다르면 정본을 다시 찍는다. 스트리밍은 미리보기지 정본이 아니다.

외부 서버(vLLM 등) 모드의 SSE 파서에는 폐쇄망다운 꼼꼼함이 들어갔다 — 3바이트 UTF-8 한글이 바이트 중간에서 쪼개진 경우, 빈 줄 없이 닫는 서버, 깨진 프레임까지 테스트로 고정. 그리고 stream 필드를 모르는 구식 서버면 비스트리밍으로 한 번 더 시도한다 — 옵션 하나 때문에 응답 전체가 죽는 것보다, 타이핑 없이라도 답이 오는 쪽이 낫다.

새로고침하면 사라지던 타임라인

코드로 — 프레임 경계와 한글 3바이트

SSE 파서의 핵심은 두 줄이다. 프레임 경계는 빈 줄, 경계가 안 온 꼬리는 다음 read로 이월.

// providers/openai-compatible/src/StreamingResponse.ts
buffered += decoder.decode(value, { stream: true });
// SSE 프레임 경계는 빈 줄이다. 경계가 아직 안 온 꼬리는 다음 read로 이월한다.
for (;;) {
    const boundary = buffered.indexOf("\n\n");
    if (boundary < 0) break;
    handleFrame(buffered.substring(0, boundary));
    buffered = buffered.substring(boundary + 2);
}
// 서버가 마지막 프레임 뒤 빈 줄 없이 닫는 경우 — 남은 꼬리도 한 프레임으로

decoder.decode(value, {stream: true})가 조용한 주인공이다. 네트워크 read는 바이트 경계로 오지 문자 경계로 오지 않는다 — 한글은 UTF-8 3바이트라, 한 글자가 두 read에 걸쳐 쪼개져 도착할 수 있다. stream: true가 그 미완성 바이트를 다음 호출까지 들고 있는다. 이거 없이 toString()으로 짜면 가끔, 한글에서만, 재현 어렵게 깨진다 — 최악의 버그 프로필이다. 그래서 ‘3바이트 문자를 3바이트 조각으로 자른 경우’가 테스트 케이스로 박혀 있다.

타임라인 복원의 자료 구조

새로고침에도 온전한 타임라인의 비결은 저장 role의 확장이다. 화면용 3종을 기존 스키마 그대로(role은 원래 TEXT 컬럼) 끼워 넣었다.

messages 테이블 (스키마 변경 없음)
  user        사용자 발화          ← 모델 컨텍스트에 포함
  assistant   모델 답변            ← 모델 컨텍스트에 포함
  reasoning   사고 본문            ← 표시 전용
  tool_call   도구 호출(JSON)      ← 표시 전용
  tool_result 도구 결과(JSON)      ← 표시 전용
※ 다음 턴 프롬프트 조립은 user/assistant만 고른다 — 화면의 기록과
  모델의 기억은 다른 층이다

사고는 델타로 흘러오니 다음 사건이 확정되는 순간 묶어 저장한다 — created_at 순서가 실제 흐름(사고→행동→답)과 일치해야 복원이 맞다. 그리고 저장된 JSON이 깨져 있어도 빈 필드로 물러선다 — 이력 한 줄 때문에 복원 전체가 죽으면 안 되니까. 방어적 파싱은 화려하지 않지만, 몇 달치 세션이 쌓인 뒤에 진가가 나온다.

✅ 확인

  • 스트림 파서가 바이트 경계 분할(멀티바이트 문자 쪼개짐)을 테스트로 갖고 있다.
  • 화면 전용 기록과 모델 컨텍스트가 분리돼 있다 — 표시 role이 프롬프트에 섞이지 않는지.
  • 시스템이 컨텍스트를 변형(압축·시프트)할 때 사용자에게 보이는 흔적이 남는다.
  • 깨진 저장 데이터 한 줄이 복원 전체를 죽이지 않음을 확인했다.

실사용 세션 분석에서 나온 발견: user/assistant 본문만 저장하니, 새로고침하면 사고 패널과 도구 실행 기록이 통째로 증발했다. “일은 했는데 기록이 없는” 상태. 그래서 표시 전용 role 3종(reasoning·tool_call·tool_result)을 이력에 함께 저장하게 했다. 중요한 경계가 하나 있다 — 모델 컨텍스트는 오염되지 않는다. 다음 턴 프롬프트를 조립할 때는 user/assistant만 고른다. 화면의 기록과 모델의 기억은 다른 층이다(9편의 표시/모델 분리가 여기서도 반복된다). 사고는 델타로 흘러오므로 다음 사건이 확정되는 순간 묶어 저장한다 — 기록 순서가 실제 흐름(사고→행동→답)과 일치해야 복원이 맞으니까.

UX 커밋들의 공통 원칙 — 시스템이 한 일은 사용자에게 보이게, 모델이 한 생각은 넘치지 않게, 화면의 기록과 모델의 기억은 분리되게. 느린 모델일수록 ‘무슨 일이 벌어지고 있는지’의 가시성이 체감 품질을 결정한다.

자주 묻는 질문 (FAQ)

스트리밍 출력의 정본 문제란 뭔가요?

델타를 이어붙인 텍스트와 최종 결과가 후처리 때문에 달라질 수 있습니다. 이 프로젝트는 ‘정본은 최종 결과’로 정하고, 표시 계층이 스트리밍된 텍스트와 정본을 비교해 다르면 정본을 다시 출력합니다. 스트리밍은 미리보기로 취급하는 것이 안전합니다.

사고(reasoning)를 화면에 다 보여주는 게 좋지 않나요?

소형 추론 모델의 사고는 본문의 몇 배 길이라 CLI에선 소음이 됩니다. 이 프로젝트는 CLI에선 스피너로 대체하고, 웹에선 접히는 패널로 제공합니다. 매체별로 정보 밀도를 다르게 가져가는 것이 낫습니다.

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다