LLMOps 모니터링 항목과 구현: 호출 로그 스키마부터 프롬프트 버전별 집계까지
OpenTelemetry GenAI 속성 이름으로 로그를 남기고, 버전별 지연·오류·비용·피드백을 한 표로 비교하기

핵심 요약
- 모니터링 항목은 운영·비용·품질·버전 네 묶음으로 나누고, 호출 한 건마다 프롬프트 버전을 함께 남긴다.
- 로그 필드는 OpenTelemetry GenAI 속성 이름을 따르되, 현재 Development 단계라 바뀔 수 있음을 기억한다.
- 평균 지연 시간이 비슷해도 p95가 크게 늘 수 있으므로 p50과 p95를 함께 본다.
- 사용자 피드백은 표본 수와 함께 표시하고, 비용·지연 변화와 나란히 판단한다.
LLM 기능은 데모에서는 잘 동작하다가 서비스에 올라가면 응답이 느려지거나, 비용이 갑자기 늘거나, 특정 질문 유형에서 품질이 흔들린다. 서버의 CPU·메모리만 봐서는 이런 변화를 알 수 없다. LLMOps 모니터링의 핵심은 호출 한 건마다 어떤 프롬프트·모델로 처리했고, 얼마나 걸렸고, 토큰을 얼마나 썼고, 사용자가 어떻게 반응했는지를 남겨 버전 단위로 비교하는 것이다. 이 글은 그 로그 스키마와 집계 스크립트를 구현한다.
모니터링 항목을 네 묶음으로 나누기
| 묶음 | 항목 | 왜 필요한가 |
|---|---|---|
| 운영 | 요청 수, 오류율(오류 유형별), 지연 시간 p50·p95 | p95가 늘면 평균이 같아도 일부 사용자는 매우 느리다 |
| 비용 | 입력·출력 토큰, 요청당 예상 비용 | 프롬프트에 예시를 추가하면 입력 토큰이 바로 늘어난다 |
| 품질 | 사용자 피드백, 평가셋 점수, 정책 위반 응답 비율 | 운영 지표가 정상이어도 답변 품질은 떨어질 수 있다 |
| 버전 | 프롬프트 버전, 모델, 도구 설명 버전 | 변화가 생겼을 때 원인을 버전 단위로 좁힌다 |
호출 로그 스키마: OpenTelemetry GenAI 속성 이름 쓰기
필드 이름을 직접 정하기보다 OpenTelemetry GenAI 시맨틱 컨벤션의 속성 이름을 쓰면 나중에 트레이싱 도구로 옮기기 쉽다. 이 컨벤션에서 gen_ai.operation.name과 gen_ai.provider.name은 필수이고, gen_ai.request.model, gen_ai.usage.input_tokens, gen_ai.usage.output_tokens, gen_ai.response.finish_reasons, 오류 시 error.type 등이 정의되어 있다. 2026-10-07 확인 기준으로 이 속성들은 Development(실험) 단계라 이름이 바뀔 수 있다. 프롬프트 버전처럼 컨벤션에 없는 값은 app. 접두사로 구분한다.
{
"ts": "2026-10-07T00:00:00.000Z",
"gen_ai.operation.name": "chat",
"gen_ai.request.model": "model-a",
"app.prompt_version": "support-v2",
"gen_ai.usage.input_tokens": 2105,
"gen_ai.usage.output_tokens": 240,
"latency_ms": 1786,
"error.type": null,
"user_feedback": "up"
}
사용자 입력 원문과 모델 응답 원문은 기본 로그에 넣지 않는다. 개인정보가 섞일 수 있기 때문이다. 품질 분석에 원문이 필요하면 별도 저장소에 보관 기간과 접근 권한을 정해 두고, 저장 전에 마스킹한다. 마스킹 흐름은 LLM 호출 전 개인정보 마스킹에서 다뤘다.
프롬프트 버전별 집계 스크립트
아래 스크립트는 JSON 줄 로그를 읽어 프롬프트 버전별로 오류율, 지연 시간 p50·p95, 평균 토큰, 1,000건당 예상 비용, 긍정 피드백 비율을 표로 출력한다. 단가는 예시 값이므로 실제로는 사용하는 모델의 공식 요금표 값으로 바꾼다. Map.groupBy를 쓰므로 Node.js 21 이상이 필요하다.
// 사용법: node summarize.mjs llm-calls.jsonl
// 프롬프트 버전별로 오류율, 지연 시간 p50/p95, 토큰, 예상 비용, 피드백 비율을 집계한다.
import { readFileSync } from 'node:fs';
// 단가는 예시 값이다. 실제 단가는 사용하는 모델의 공식 요금표에서 가져와 넣는다.
const PRICE_PER_1M = { input: 3.0, output: 15.0 };
const rows = readFileSync(process.argv[2], 'utf8').trim().split('\n').map((l) => JSON.parse(l));
const groups = Map.groupBy(rows, (r) => r['app.prompt_version']);
const pct = (arr, p) => { const s = [...arr].sort((a, b) => a - b); return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))]; };
const avg = (arr) => arr.reduce((a, b) => a + b, 0) / arr.length;
const table = [];
for (const [version, rs] of groups) {
const inTok = rs.map((r) => r['gen_ai.usage.input_tokens']);
const outTok = rs.map((r) => r['gen_ai.usage.output_tokens']);
const cost = (avg(inTok) * PRICE_PER_1M.input + avg(outTok) * PRICE_PER_1M.output) / 1e6;
const fb = rs.filter((r) => r.user_feedback);
table.push({
version,
calls: rs.length,
'error%': ((rs.filter((r) => r['error.type']).length / rs.length) * 100).toFixed(1),
'p50 ms': pct(rs.map((r) => r.latency_ms), 50),
'p95 ms': pct(rs.map((r) => r.latency_ms), 95),
'in tok': Math.round(avg(inTok)),
'out tok': Math.round(avg(outTok)),
'$/1k calls': (cost * 1000).toFixed(2),
'up%': fb.length ? `${((fb.filter((r) => r.user_feedback === 'up').length / fb.length) * 100).toFixed(0)} (n=${fb.length})` : '-',
});
}
console.table(table);
실행 결과
집계 방식을 보여 주기 위해 고정 시드로 만든 합성 로그 400건(버전별 200건)에 실행했다. v2는 답변 품질을 높이려고 프롬프트에 예시 3개를 추가한 버전이라는 가정이다. 로그 생성 스크립트도 함께 두었으므로 같은 결과를 재현할 수 있다.
$ node make-sample-log.mjs && node summarize.mjs llm-calls.jsonl
llm-calls.jsonl 400줄 생성
┌─────────┬──────────────┬───────┬────────┬────────┬────────┬────────┬─────────┬────────────┬─────────────┐
│ (index) │ version │ calls │ error% │ p50 ms │ p95 ms │ in tok │ out tok │ $/1k calls │ up% │
├─────────┼──────────────┼───────┼────────┼────────┼────────┼────────┼─────────┼────────────┼─────────────┤
│ 0 │ 'support-v1' │ 200 │ '1.0' │ 1672 │ 2058 │ 1293 │ 241 │ '7.49' │ '60 (n=30)' │
│ 1 │ 'support-v2' │ 200 │ '3.5' │ 1786 │ 6768 │ 2105 │ 240 │ '9.91' │ '79 (n=34)' │
└─────────┴──────────────┴───────┴────────┴────────┴────────┴────────┴─────────┴────────────┴─────────────┘
이 표를 읽는 방법이 모니터링의 핵심이다. v2는 긍정 피드백이 60%에서 79%로 올랐지만, 피드백 표본은 30건 남짓이라 이것만으로 품질 개선을 확정하기 어렵다. 반면 입력 토큰이 약 63% 늘어 1,000건당 비용이 32% 올랐고, p50은 비슷한데 p95가 2초에서 6.8초로 세 배 넘게 늘었다. 평균만 봤다면 놓쳤을 꼬리 지연이다. 이런 결과가 나오면 예시 3개를 1개로 줄인 버전을 다시 비교하거나, 긴 예시를 쓰는 요청 유형을 따로 분리하는 식으로 다음 실험을 정한다.
운영에 붙이는 순서
- LLM 호출을 감싸는 함수 한 곳에서 위 스키마로 로그를 남긴다. 호출 지점마다 따로 남기면 필드가 어긋난다.
- 프롬프트 문구, 모델, 도구 설명이 바뀔 때마다
app.prompt_version을 올린다. 버전 관리 방식은 프롬프트 버전 관리와 롤백을 참고한다. - 하루 단위로 버전별 집계를 돌리고, 오류율과 p95에 경보 기준을 둔다.
- 새 버전은 일부 트래픽에만 적용해 같은 기간의 기존 버전과 비교한다. 시간대가 다르면 트래픽 성격이 달라 비교가 왜곡된다.
- 피드백은 표본 수를 함께 표시하고, 배포 전 평가셋 점수와 함께 판단한다. 평가셋 구성은 배포 전 LLM 평가에서 다뤘다.
비용 상한과 사용자별 예산 설계는 AI API 비용과 Rate Limit 설계로 이어진다.
초보자가 자주 실수하는 포인트
- 프롬프트를 바꾸고 버전 값을 남기지 않아 어떤 변경이 비용을 늘렸는지 모른다.
- 평균 지연 시간만 보고 꼬리 지연을 놓친다.
- 피드백 수십 건의 비율 변화를 품질 개선으로 확정한다.
- 사용자 입력 원문을 마스킹 없이 운영 로그에 남긴다.
체크리스트
- LLM 호출 로그가 한 함수에서 같은 스키마로 남는다.
- 로그에 프롬프트 버전, 모델, 토큰, 지연 시간, 오류 유형이 있다.
- 버전별 집계에 p50과 p95가 함께 나온다.
- 비용 계산 단가를 공식 요금표 값으로 넣었다.
- 새 버전은 같은 기간의 기존 버전과 비교한다.
자주 묻는 질문
처음부터 트레이싱 도구를 도입해야 하나요?
호출량이 적을 때는 이 글처럼 JSON 줄 로그와 집계 스크립트로 시작해도 충분합니다. 필드 이름을 OpenTelemetry 속성에 맞춰 두면, 나중에 트레이싱 도구로 옮길 때 로그 구조를 다시 바꾸지 않아도 됩니다.
피드백 버튼을 누르는 사용자가 적으면 어떻게 하나요?
피드백만으로 품질을 판단하기 어렵다면 배포 전 평가셋 점수, 같은 질문의 재질문 비율, 상담원 연결 비율처럼 행동에서 나오는 신호를 함께 봅니다.
참고 자료 · 검증 기준
- OpenTelemetry — Semantic conventions for GenAI spans
- GitHub Blog — How to evaluate LLMs before production
위 자료와 내용을 대조한 날짜: . 도구·서비스 정책은 이후 바뀔 수 있으므로 적용 전 공식 문서를 다시 확인하세요.
이 글은 실제 실무 환경 테스트 및 검증된 기술 자료를 바탕으로 작성되었으며, 환경 변화에 따라 지속적으로 갱신됩니다.