---
tags: [rb8001, prompt, tone, context, quality, research]
---

# rb8001 응답 품질 — 톤 과장·문맥 유실·과잉 제안 전수 조사 리서치

## 관련 문서

### 트러블슈팅 (이 리서치가 닫아야 할 문제)
- [rb8001 프롬프트·의도분석·문맥응답 품질 문제](../troubleshooting/260317_rb8001_prompt_intent_context_response_quality_문제오픈.md)

### 계획 (구현 방향)
- [rb8001 응답 품질 수정 계획](../plans/260319_rb8001_응답품질_톤과장_문맥유실_수정계획.md)

### 상위 원칙
- [0_VALUE Coding Principles](https://github.com/happybell80/0_VALUE/blob/main/02_Governance/coding-principles.md) — #4 폴백 절제, #9 질문별 특례 하드코딩 금지

## 리서치 목적

트러블슈팅 문서의 재현 질문셋을 실제로 실행하고, 코드 경로를 추적하여 다음을 확정한다.
1. 톤 과장의 정확한 원인 (프롬프트 어느 부분이 과잉 서비스를 유발하는지)
2. 문맥 유실의 정확한 원인 (어디서 직전 대화가 끊기는지)
3. 과잉 제안의 정확한 원인 (왜 묻지 않은 것까지 나열하는지)
4. Pydantic JSON 강제 방식의 적용 가능성
5. 각 원인에 대한 최소 수정 범위

## 1. 현재 상태 (2026-03-19 E2E 실행 결과)

모델: `gpt-5-mini` (OpenAI handler 경유)

| 질문 | 현재 응답 | 문제 |
|------|----------|------|
| `로빙?` | `사용자님, 로빙입니다. 어떻게 도와드릴까요?` | 양호 |
| `오늘 날씨는 어때?` | 5줄 — 위치 없다며 장황한 안내, 준비물 언급 | 과잉 안내 |
| `난 지금 서울이야` | 7줄 — 투자자 추천, 동선 최적화, PDF 분석 등 나열 | **과잉 제안, 맥락 무시** |
| `내가 지금 어디라고?` | 6줄 — 위치 접근 불가, 안전 문제 운운 | **문맥 유실, 과장** |

## 2. 원인 분석

### 2-1. 톤 과장 — 시스템 프롬프트가 과잉 서비스를 유도

**OpenAI handler `_get_system_prompt()` (openai_handler.py:206~226)**

문제 문구:
```
- 항상 도움이 되는 방향으로 조언
- 필요시 구체적인 액션 아이템 제시
```

이 두 줄이 LLM에게 "매 응답마다 무언가를 제안해야 한다"는 압박을 줌. `서울이야`처럼 단순 사실 제공에도 투자자 추천, PDF 분석 등을 나열하는 원인.

**감정 주입 (llm_service.py:262~268)**

```
- {strategy} {length_instruction} 응답하세요.
- 사용자를 '{preferred_name}'으로 호칭하세요. 호칭은 응답 첫 문장에 1회만...
```

감정 분석 결과가 neutral이어도 `자연스럽게 중간 응답하세요`라는 지시가 붙어 짧게 끝내야 할 응답을 늘림.

**Gemini handler `_get_system_prompt()` (gemini_handler.py:606~648)**

```
<examples>
좋은 답변: "사용자님의 정보를 확인하여 정확한 답변을 드리겠습니다."
```

좋은 답변 예시 자체가 의전형. LLM이 이 톤을 학습함.

### 2-2. 문맥 유실 — OpenAI handler에 대화 이력이 전달되지 않음

**핵심 발견:**

- Gemini handler는 `context['recent_conversations']`를 읽어 `last_3_context`로 주입 → 직전 대화 최대 3개 포함
- OpenAI handler는 `context['previous_messages']`를 읽음 → **그런데 이 키는 어디에서도 채워지지 않음**
- `message_router.py:349`에서 context에 `recent_conversations`를 넣지만, `previous_messages`는 안 넣음
- 따라서 **OpenAI 경로에서는 대화 이력이 항상 비어있음**

이것이 `서울이야` → `어디라고?`에서 직전 맥락을 유실하는 직접 원인.

**CONTEXT_FOLLOWUP 우회 경로 (message_service.py:265~286):**

- 의도가 `CONTEXT_FOLLOWUP`으로 분류되면 직전 발화를 메시지에 직접 이어붙임
- 하지만 `내가 지금 어디라고?`가 `CONTEXT_FOLLOWUP`으로 분류되지 않으면 이 경로를 타지 않음
- 실제로 `CONTEXT_FOLLOWUP` 판정은 `decision_engine`의 규칙 기반 패턴 매칭에 의존

### 2-3. 과잉 제안 — 프롬프트에 제한이 없음

현재 프롬프트에는 다음이 **없음**:
- 응답 길이 제한 (최대 문장 수)
- "묻지 않은 것은 제안하지 마라" 지시
- "단순 확인이면 짧게 끝내라" 지시
- 출력 구조 강제 (JSON 등)

LLM은 "도움이 되는 방향으로 조언"이라는 지시를 따라 최대한 많은 제안을 나열함.

## 3. 해결 방안

### 3-1. 시스템 프롬프트 개선 (DB에서 v2로 교체)

방금 구현한 프롬프트 DB 폐루프를 활용하여 코드 배포 없이 프롬프트를 교체.

**v2 프롬프트 설계 원칙:**
- "도움이 되는 방향으로 조언" → 삭제
- "필요시 액션 아이템 제시" → "사용자가 요청한 경우에만 제시"로 변경
- 응답 길이 제한 추가: "2~3문장 이내로 답변. 사용자가 더 원하면 그때 확장"
- "묻지 않은 것은 제안하지 마라" 명시
- 의전형 예시 제거

**OpenAI v2 프롬프트 (제안):**
```
당신은 '로빙(Robeing)'이라는 이름의 AI 어시스턴트입니다.

응답 원칙:
1. 짧고 직접적으로 답변한다. 2~3문장 이내.
2. 묻지 않은 것은 제안하지 않는다.
3. 단순 확인이나 사실 제공이면 1문장으로 끝낸다.
4. 사용자가 더 원하면 그때 확장한다.
5. 이모지 사용 금지.
6. 한국어로 응답.
```

### 3-2. 문맥 유실 수정 — OpenAI handler에 대화 이력 전달

**수정 위치:** `llm_service.py`의 `process_request()` chat 경로

`recent_conversations`를 OpenAI의 `previous_messages` 형식으로 변환하여 context에 추가:
```python
if context.get("recent_conversations"):
    prev_msgs = []
    for conv in context["recent_conversations"][:5]:
        if conv.get("message"):
            prev_msgs.append({"role": "user", "content": conv["message"]})
        if conv.get("response"):
            prev_msgs.append({"role": "assistant", "content": conv["response"]})
    context["previous_messages"] = prev_msgs
```

**수정 파일:** `app/services/llm/llm_service.py` 1곳

### 3-3. Pydantic JSON 강제 — 선택적 적용

**적용 모델:**
```python
class ChatResponse(BaseModel):
    answer: str = Field(description="질문에 대한 직접 답변. 2~3문장 이내.")
    follow_up: bool = Field(default=False, description="추가 정보가 필요한지 여부")
```

**적용 방식:**
- `response_format={"type": "json_object"}`를 chat 경로에도 추가
- LLM 응답을 `ChatResponse.model_validate()`로 검증
- 사용자에게는 `answer` 필드만 전달

**장점:**
- 응답 길이를 구조적으로 제어 (Field description으로 강제)
- 과잉 제안이 `answer` 밖으로 나갈 수 없음
- 테스트 자동화 가능 (answer 길이, follow_up 값 검증)

**단점:**
- 자연스러운 대화 흐름에 영향 가능
- 토큰 소모 약간 증가

**권장:** 3-1(프롬프트 개선)과 3-2(문맥 수정)를 먼저 적용하고, 그래도 톤이 안 잡히면 3-3(JSON 강제)을 추가.

## 4. 수정 범위 요약

| 단계 | 내용 | 수정 파일 | 코드 배포 필요 |
|------|------|----------|--------------|
| **1단계** | 시스템 프롬프트 v2 교체 | DB만 | **아니오** (폐루프 활용) |
| **2단계** | OpenAI handler 대화 이력 전달 | `llm_service.py` | 예 |
| **3단계** (선택) | Pydantic JSON 강제 | `llm_service.py`, `openai_handler.py` | 예 |

## 5. 검증 기준 (닫힘 조건)

재현 질문셋 기준:

| 질문 | 기대 응답 |
|------|----------|
| `로빙?` | 1문장 인사 |
| `오늘 날씨는 어때?` | 실시간 조회 불가 안내 1~2문장, 과잉 안내 없음 |
| `난 지금 서울이야` | `서울이시군요.` 또는 `알겠습니다.` 1문장, 묻지 않은 제안 없음 |
| `내가 지금 어디라고?` | `서울이라고 하셨어요.` 1문장, 직전 맥락 유지 |

## 6. 감정 주입 constraints 조정

현재 (llm_service.py:262~268):
```
- 사용자를 '{preferred_name}'으로 호칭하세요. 호칭은 응답 첫 문장에 1회만 자연스럽게 사용하세요.
- {strategy} {length_instruction} 응답하세요.
```

**문제:** neutral 감정에서도 "자연스럽게 중간 응답하세요"가 붙어 응답을 늘림.

**개선:**
- neutral일 때 감정 constraints 자체를 생략 (빈 문자열)
- `length_instruction` 기본값을 "중간" → "짧게"로 변경
- 호칭 지시를 조건부로 (첫 대화에만 사용, 이후 생략)

## 7. OpenClaw 레퍼런스 분석

**소스:** `/home/admin/robeing/reference/openclaw` (commit `7f86be1037`)

### 7-1. OpenClaw의 컨텍스트 처리 구조

```
User Message
  → SessionManager.open() — JSONL에서 전체 대화 이력 로드
  → sanitizeSessionHistory() — provider별 메시지 형식 정규화
  → contextEngine.assemble(messages, tokenBudget) — 토큰 예산 내로 조립
  → LLM 호출
  → afterTurn() — 배경에서 auto-compaction
```

핵심 파일:
- 컨텍스트 엔진 인터페이스: `src/context-engine/types.ts`
- Compaction 알고리즘: `src/agents/compaction.ts`
- 세션 이력 정규화: `src/agents/pi-embedded-runner/google.ts:520+`
- 컨텍스트 조립: `src/agents/pi-embedded-runner/run/attempt.ts:2130-2189`

### 7-2. OpenClaw Context Engine의 핵심 메서드

| 메서드 | 역할 |
|--------|------|
| `assemble(messages, tokenBudget)` | 메시지를 토큰 예산 내로 조립, **provider별 형식 변환 포함** |
| `compact(sessionFile, tokenBudget)` | 오래된 메시지를 요약 압축 |
| `ingest(message)` | 새 메시지 추가 |
| `afterTurn()` | 턴 완료 후 배경 compaction |

**플러그인 방식:** `registerContextEngine()`으로 엔진 교체 가능.

### 7-3. OpenClaw의 provider 정규화 (`sanitizeSessionHistory`)

OpenClaw는 OpenAI/Gemini/Anthropic 각각 다른 메시지 형식을 **하나의 정규화 단계**에서 통일한 후 handler에 전달. 구체적으로:
- 이미지 해상도/형식 정규화
- thinking block 제거 (정책에 따라)
- tool_use/tool_result 페어링 수리
- OpenAI용 function call 다운그레이드
- 세션 간 메시지 경계 주석

**로빙과의 차이:** 로빙은 이 정규화 계층이 없음. OpenAI handler는 `previous_messages`, Gemini handler는 `recent_conversations`를 각자 읽어서 provider별 키 불일치가 발생.

### 7-4. OpenClaw의 Compaction 알고리즘

1. 메시지를 토큰 기준으로 청크 분할 (SAFETY_MARGIN 1.2, 적응적 청크 비율)
2. 청크별 LLM 요약 생성
3. 재귀적 병합 (부분 요약 → 통합 요약)
4. 식별자(UUID, URL, 파일명) 보존 강제
5. 실패 시 점진적 fallback: 전체 요약 → 부분 요약 → 개수만 기록

### 7-5. 로빙에 적용할 것 (범위 분리)

| 적용 대상 | 이번 계획 범위 | 향후 (별도 문서) |
|----------|-------------|---------------|
| provider별 형식 변환 | ✅ `recent_conversations` → `previous_messages` 변환 | Context Engine 중간 계층 도입 |
| 토큰 예산 관리 | ❌ | `assemble()` 패턴 도입 |
| Compaction | ❌ | [OpenClaw compaction 아이디어](../ideas/260317_OpenClaw_스타일_컨텍스트_compaction_아이디어.md)에서 추적 |
| 세션 메모리 | ❌ | daily log + MEMORY.md 도입 |

**이번 수정의 위치:** OpenClaw의 `assemble()` 중 "provider별 형식 변환" 부분만 가져옴. `llm_service.py`의 `process_request()`에서 `recent_conversations`를 OpenAI `messages[]` 형식으로 변환.

## 8. 리스크

| 리스크 | 대응 |
|--------|------|
| 프롬프트를 너무 짧게 바꾸면 필요할 때도 정보 부족 | "사용자가 더 원하면 그때 확장" 지시로 대응 |
| JSON 강제 시 자연스러움 저하 | 1~2단계 선적용 후 필요 시에만 3단계 |
| 문맥 이력 5개 추가 시 토큰 증가 | 이미 Gemini에서 3개 사용 중, OpenAI 5개는 허용 범위 |
| 감정 constraints 제거 시 감정 대응 약화 | neutral 외 감정에서는 유지 |
| Context Engine 없이 키 변환만 하면 또 다른 handler 추가 시 같은 문제 재발 | 향후 Context Engine 도입 시 해소. 이번은 최소 수정 원칙 (SSOT #2 Minimal Change) |

## 9. 구현 순서

| 순서 | 내용 | 종결 효과 |
|------|------|----------|
| **1** | DB에 시스템 프롬프트 v2 적재 + 활성화 | 톤 과장, 과잉 제안 해소 |
| **2** | `llm_service.py`에서 `recent_conversations` → `previous_messages` 변환 | 문맥 유실 해소 |
| **3** | neutral 감정 constraints 생략, length 기본값 "짧게" | 불필요한 응답 늘림 방지 |
| **4** | 재현 질문셋 E2E 검증 | 닫힘 조건 충족 확인 |
| **5** | (선택) Pydantic JSON 강제 | 구조적 톤 제어 |

1~4 완료 시 트러블슈팅 종결 가능.

## 10. 결론

- 톤 과장의 원인: 시스템 프롬프트의 "도움이 되는 방향으로 조언", "액션 아이템 제시" 문구 + 감정 constraints의 무조건 주입
- 문맥 유실의 원인: OpenAI handler가 `previous_messages` 키를 읽는데, 이 키를 아무도 채우지 않음
- 과잉 제안의 원인: "묻지 않은 것은 제안하지 마라" 지시 부재 + 응답 길이 제한 없음
- 해결: 프롬프트 v2 교체(DB, 배포 불필요) + 대화 이력 전달 수정(1곳) + 감정 constraints 조정(1곳)
