TECH NOTE · DESKTOP AI PET
로컬 LLM은 컨텍스트가 좁아 프롬프트를 잘라야 합니다. 대화·예시·선호·요약·페르소나 순으로 버리고 사용자 입력은 자르지 않는 이유를, 실제 코드의 상한값과 함께 정리했습니다.
컨텍스트가 모자라면 무엇을 버릴 것인가
지난 편에서 음성을 받아 걸렀다. 걸러진 말은 이제 캐릭터의 프롬프트로 들어간다. 여기서 다음 문제가 생긴다.
데스크톱 펫에 로컬 LLM을 붙였다. Ollama로 작은 모델을 돌리니 대화 내용이 밖으로 나가지 않는다. 대신 컨텍스트가 좁다. 캐릭터에게 넣어야 할 것은 이만큼이다.
- 성격과 말투를 정하는 페르소나
- 지난 대화를 줄인 요약
- 이렇게 말하라는 예시 몇 개
- 사용자가 이전에 밝힌 선호
- 최근 주고받은 대화
- 그리고 지금 사용자가 한 말
다 넣으면 넘친다. 넘치면 잘라야 하는데, 무엇부터 자르느냐가 캐릭터의 품질을 정한다.
이번 편의 코드는 여기 있다.
https://github.com/bluecafe/pet-reference/tree/main/prompt-budget
cargo run을 하면 예산을 좁혀 가며 무엇이 먼저 사라지는지 표로 나온다.
버리는 순서
위에서부터 버린다.
1 최근 대화 오래된 것부터
2 예시 최신 것부터
3 선호 최신 것부터
4 요약 잘라 맞추고, 안 되면 통째로 비운다
5 페르소나 잘라 맞추고, 안 되면 통째로 비운다
─────────────────────────────
사용자의 현재 입력은 자르지 않는다
페르소나가 마지막이다. 캐릭터가 누구인지를 가장 늦게까지 지킨다.
예제를 돌리면 이 순서가 눈에 보인다.
예산 대화↓ 예시↓ 선호↓ 요약B 페르소나B
914 0 0 0 192 464
878 3 0 0 192 464 ← 대화가 먼저 사라진다
835 3 2 0 192 464 ← 그 다음 예시
788 3 2 2 192 464 ← 그 다음 선호
577 3 2 2 0 464 ← 요약이 비어도 페르소나는 남는다
113 3 2 2 0 0
110 더 버릴 것이 없다 → 실패로 끝낸다
상한은 미리 걸려 있다
예산에 맞춰 자르기 전에, 각 항목에 개별 상한이 이미 있다.
pub const MAX_PROMPT_BYTES: usize = 32 * 1024;
pub const MAX_PERSONA_BYTES: usize = 16 * 1024;
pub const MAX_SUMMARY_BYTES: usize = 4 * 1024;
pub const MAX_CURRENT_USER_BYTES: usize = 4 * 1024;
pub const MAX_CURRENT_USER_SCALARS: usize = 1_000;
/// 목록은 들어오는 순간 최근 이만큼만 남긴다. 예산 계산은 그 다음이다.
pub const MAX_CONTEXT_ITEMS: usize = 8;
목록 세 종류는 예산을 재기도 전에 잘린다.
let turn_start = input.recent_turns.len().saturating_sub(MAX_CONTEXT_ITEMS);
let mut turns = input.recent_turns[turn_start..].to_vec();
saturating_sub라 항목이 8개보다 적어도 음수가 되지 않는다. 뺄셈 하나를 잘못 쓰면 여기서 패닉이 난다.
페르소나가 전체의 절반을 차지한다. 캐릭터를 정의하는 데 그만큼 쓰겠다는 뜻이다.
목록 세 종류는 들어오는 순간 최근 8개만 남기고 나머지를 버린다. 예산 계산은 그 다음이다.
자르는 방향이 항목마다 다르다
버리는 순서 전체가 if / else if 사슬 하나다. 예산에 들어갈 때까지 이 루프를 돈다.
if !turns.is_empty() {
// 대화는 시간순이라 앞이 오래된 것이다. 방금 한 말을 남긴다.
turns.remove(0);
removed_turns += 1;
} else if examples.pop().is_some() {
// 예시는 앞쪽이 더 중요하도록 넣어 둔 목록이라 뒤부터 뺀다.
removed_examples += 1;
} else if preferences.pop().is_some() {
removed_preferences += 1;
} else if !summary.is_empty() {
// 덩어리는 비우기 전에 잘라서 맞춰 본다.
summary = halve_or_clear(&summary);
} else if !persona.is_empty() {
// 캐릭터의 정체성을 가장 늦게 버린다.
persona = halve_or_clear(&persona);
} else {
// 빈 캐릭터로 답하느니 답하지 않는다.
return Err(BuildError::BudgetExceeded);
}
else if로 이어 붙인 것이 핵심이다. 앞 항목이 남아 있는 동안에는 뒤 항목을 건드리지 않는다. 대화가 하나라도 있으면 예시는 그대로다.
여기서 눈에 걸리는 건 첫 두 줄이다. 최근 대화는 remove(0)으로 앞에서 빼고, 예시와 선호는 pop()으로 뒤에서 뺀다.
같은 목록인데 반대로 빼는 이유는 순서가 뜻하는 게 달라서다.
대화는 시간순이다. 앞이 오래된 것이고 뒤가 방금 한 말이다. 방금 한 말을 버리고 어제 한 말을 남기면 대화가 어긋난다. 그래서 앞에서 뺀다.
예시와 선호는 시간순이 아니다. 앞쪽에 더 중요한 것이 오도록 넣어 둔 목록이라, 버릴 때는 뒤부터 빼는 게 맞다.
목록이라고 다 같은 방향으로 자르면 안 된다. 그 목록의 앞이 무엇을 뜻하는지를 봐야 한다.
사용자 입력은 자르지 않는다
이게 다른 것들과 결이 다르다. 다른 항목은 넘치면 줄이는데, 사용자의 현재 입력은 넘치면 처음부터 거절한다.
if input.current_user_text.len() > MAX_CURRENT_USER_BYTES
|| input.current_user_text.chars().count() > MAX_CURRENT_USER_SCALARS
{
return Err(BuildError::UserTextTooLarge);
}
방금 한 말을 잘라서 모델에 넣으면 무슨 일이 생기는지 생각해 보면 답이 나온다. 질문의 앞부분만 들어가고 뒷부분이 잘린다. 모델은 잘린 줄 모르고 성실하게 답한다. 사용자는 자기가 하지 않은 질문에 대한 답을 받는다.
차라리 “너무 깁니다”가 낫다. 짧게 다시 쓰면 되고, 무엇이 잘못됐는지도 알 수 있다.
여기서도 상한이 두 개인 이유는 지난 편과 같다. 한글은 한 글자가 3바이트라 바이트만 재면 언어에 따라 허용량이 세 배 차이 난다.
요약과 페르소나는 통째로 안 버린다
목록은 항목 단위로 빼면 되지만, 페르소나와 요약은 덩어리 하나다. 없애면 캐릭터가 사라진다.
그래서 이 둘은 먼저 잘라서 맞춰 본다. 예산에 들어가는 만큼만 앞에서 남기고 나머지를 버린다. 잘라도 안 들어갈 때에만 비운다.
UTF-8이라 아무 데서나 자르면 글자가 깨진다. 3바이트짜리 한글을 2바이트에서 자르면 깨진 바이트가 남는다. 그래서 글자 경계를 찾아 자른다.
pub fn truncate_utf8_bytes(value: &str, max_bytes: usize) -> String {
if value.len() <= max_bytes {
return value.to_string();
}
let mut end = max_bytes;
while end > 0 && !value.is_char_boundary(end) {
end -= 1;
}
value[..end].to_string()
}
is_char_boundary가 아니면 한 바이트씩 뒤로 물러난다. 한글은 최대 세 번 물러나면 경계를 만난다. 예제 테스트가 이걸 확인한다 — "가나다"를 8바이트로 자르면 "가나"가 나온다.
순서를 다시 보면, 페르소나를 비우는 것은 정말 마지막이다. 그마저 안 되면 실패로 끝낸다. 빈 캐릭터로 답하느니 답하지 않는다.
자르는 순서는 요구사항이다
이 순서가 우연히 그렇게 된 게 아니라는 근거가 코드 안에 있다. 테스트 이름이 이렇다.
categories_are_removed_in_the_required_order
예산을 1바이트씩 줄여 가며 무엇이 먼저 사라지는지 확인한다. 순서가 바뀌면 테스트가 깨진다.
성능 최적화라면 테스트까지 붙일 일이 아니다. 무엇을 먼저 버리는가는 동작 규격이다.
페르소나조차 신뢰하지 않는다
마지막으로 프롬프트를 조립하는 방식이다. 완성된 프롬프트는 두 부분으로 나뉜다.
[신뢰 구간] 정책 + 응답 형식 앱이 쓴 것
[비신뢰 구간]
untrusted_persona 페르소나와 예시
untrusted_memory 요약·선호·최근 대화
untrusted_user_text 지금 사용자가 한 말
사용자 입력이 비신뢰인 건 당연하다. 눈에 띄는 건 페르소나와 예시도 비신뢰 구간에 있다는 것이다.
페르소나는 앱이 들고 있는 값이라 안전해 보인다. 하지만 사용자가 편집할 수 있고, 편집할 수 있으면 거기에 “이전 지시를 무시하라”를 적을 수 있다. 앱 안에 있다는 게 안전하다는 뜻은 아니다.
지난 편의 STT 필터가 <think를 막는 이유도 여기서 만난다. 음성으로 들어온 문자열이 이 비신뢰 구간에 그대로 실린다.
정리
로컬 모델은 컨텍스트가 좁다. 좁으면 잘라야 하고, 자르는 규칙이 곧 캐릭터의 성격이 된다.
- 버리는 순서를 정해 둔다 — 대화 → 예시 → 선호 → 요약 → 페르소나
- 목록마다 자르는 방향이 다르다. 그 목록의 앞이 무엇을 뜻하는지 본다
- 사용자의 현재 입력은 자르지 않는다. 넘치면 거절한다
- 덩어리는 비우기 전에 잘라서 맞춰 본다
- 캐릭터의 정체성을 가장 늦게 버린다
- 앱 안에 있는 값이라고 신뢰 구간에 넣지 않는다
클라우드 모델만 써 왔다면 안 해 본 고민이다. 컨텍스트가 넉넉하면 다 넣으면 되니까. 좁아지는 순간 무엇이 중요한지를 순서로 적어야 한다.
돌아가는 코드는 pet-reference/prompt-budget에 있다. 예산 숫자를 바꿔 가며 무엇이 먼저 사라지는지 직접 볼 수 있다.
앱을 받아서 로컬 LLM을 붙이면 이 잘라내기가 실제로 어떻게 작동하는지 볼 수 있다. 설치 안내는 다운로드 페이지의 가이드에 있다.
다음 편은 그렇게 만든 대화가 쌓이는 곳이다. 기억을 디스크에 두면서 두 프로세스가 동시에 열었을 때와, 지우기를 어떻게 설계했는지 다룬다.
