트레이스 계층에서의 환각 탐지: 지금 바로 적용 가능한 4가지 탐지기
요약
LLM 서비스의 지연 시간과 비용을 최소화하면서 환각을 탐지하기 위해 트레이스 계층(Trace layer)에서 비동기적으로 작동하는 4가지 탐지 기법을 소개합니다. 인라인 방식의 높은 비용과 지연 시간 문제를 해결하기 위해 OpenTelemetry SpanProcessor를 활용한 사후 탐지 전략을 제안합니다.
핵심 포인트
- 인라인 탐지 대비 지연 시간(Latency) 및 토큰 비용 절감
- 4가지 핵심 탐지기: 인용 근거, 신뢰도 이상, 스키마 위반, 자기 일관성 발산
- OpenTelemetry SpanProcessor를 통한 비동기적 구현 방식
- 사용자 경험을 해치지 않으면서 사후 알림 및 모니터링 가능
도서: LLM Observability Pocket Guide: Picking the Right Tracing & Evals Tools for Your Team
저자의 다른 저서: Thinking in Go (2권 시리즈) — Complete Guide to Go Programming + Hexagonal Architecture in Go
내 프로젝트: Hermes IDE | GitHub — Claude Code 및 기타 AI 코딩 도구를 사용하여 개발하는 개발자들을 위한 IDE
나: xgabriel.com | GitHub
런타임(Runtime)에서 모든 환각(Hallucination)을 잡아낼 수는 없습니다. 인라인(Inline)으로 사실 확인 모델을 실행하는 비용은 p99 지연 시간을 두 배로 늘리고, 아무 문제 없는 요청에 예산을 낭비하게 만듭니다. 하지만 트레이스 파이프라인(Trace pipeline)에서 사후에 저렴한 비용으로 네 가지 유형의 환각을 잡아낼 수 있습니다. 이 탐지기들은 앱이 이미 방출하는 스팬(Span) 위에서 작동합니다. 사용자는 정상적인 지연 시간으로 답변을 받습니다. 탐지기는 몇 초 후 잘못된 답변을 표시하며, 고객 지원 티켓이 접수되기 전에 Slack 알림이 도착합니다.
이 포스트에서는 실제 LLM 제품에서 그 가치를 증명한 네 가지 탐지기를 살펴봅니다: 인용 근거 확인(Citation grounding), 신뢰도 이상(Confidence anomaly), 스키마 위반(Schema violation), 그리고 자기 일관성 발산(Self-consistency divergence)입니다. 각 탐지기는 30~80줄의 Python 코드로 이루어져 있습니다. 그런 다음 이를 OpenTelemetry SpanProcessor에 연결하여 앱이 방출하는 모든 트레이스(Trace)에서 비동기(Async)로 실행되도록 합니다. 마지막으로 이들을 보정(Calibrate)해야 합니다. 보정하지 않으면 이들 모두가 거짓 정보를 제공하기 때문입니다.
왜 트레이스 계층 탐지가 인라인 체크보다 우수한가
인라인 탐지란 LLM 응답을 사용자에게 반환하기 전에, 해당 응답이 환각인지 점수를 매기기 위해 다른 모델을 실행하는 것을 의미합니다. NeMo Guardrails, Lynx, 그리고 대부분의 "evaluator-as-a-service" 제안들이 이런 방식으로 작동합니다. 하지만 수학적으로 사용자에게 유리하지 않습니다.
각 사용자 요청을 하나의 LLM 호출(~400ms)이라고 가정해 봅시다. 인라인 탐지는 근거 확인(Grounding)을 위해 또 다른 LLM 호출을 추가합니다(~근거 확인 모델은 더 느리고 전체 컨텍스트를 소모하는 경향이 있으므로 약 600ms 소요). 결과적으로 p50은 400ms에서 1000ms로 늘어납니다. p99는 2초로 두 배가 됩니다. 토큰 비용은 세 배로 뜁니다. 게다가 실제로 잡아낼 가치가 있는 환각은 아마 2%뿐일 텐데, 당신은 100%의 요청에 대해 이 비용을 지불하고 있는 것입니다.
트레이스 계층 탐지는 응답이 나간 후에 실행됩니다. 사용자는 네이티브 지연 시간(Native latency)으로 답변을 확인합니다.
스팬 프로세서(Span processor)는 완료된 스팬(Span)을 포착하여 비동기적으로 탐지기(Detector)를 실행하고, 그 결과를 관측성 백엔드(Observability backend)나 별도의 "인시던트(Incident)" 큐에 플래그(Flag)로 다시 기록합니다. 탐지기가 작동하더라도 사용자 경험이 저하되는 것이 아니라 알림을 받게 됩니다. 여기에는 솔직한 트레이드오프(Trade-off)가 존재합니다. 잘못된 응답을 차단할 수는 없다는 점입니다. 사용자는 이미 그 응답을 보았기 때문입니다. 대안과 비교해 보기 전까지는 이 상황이 나쁘게 들릴 수 있습니다. 가끔 발생하는 환각(Hallucination)을 잡기 위해 트래픽의 100%를 차단하는 것은, 환각의 80%를 잡아내고 나머지에 대해 사과하는 것보다 더 나쁩니다. 대부분의 제품에서는 환각 제로(Zero-hallucination) 보장보다 1초 미만의 지연 시간(Sub-second latency)이 더 중요합니다.
탐지기 1 — 인용 근거 확인 (Citation grounding)
RAG 시스템에서 가장 흔한 환각은 모델이 실제로는 주장을 뒷받침하지 않는 출처를 인용하는 것입니다. 인용(Citation)이 포함되어 있어 올바르게 보이지만, 인용된 구절이 주장과 다른 내용을 말하거나 주장에 대해 전혀 언급하지 않는 경우입니다. 인용 근거 확인(Citation grounding)은 각 인용된 스팬(Span)과 해당 인용이 포함된 문장 사이의 문자열/임베딩(String/Embedding) 검사입니다. 기본적인 버전에서는 두 번째 LLM 호출이 필요하지 않습니다.
import re
from dataclasses import dataclass
from sentence_transformers import SentenceTransformer, util
# 요청마다 로드하지 않고 모듈 수준에서 한 번만 로드
_embedder = SentenceTransformer("BAAI/bge-small-en-v1.5")
@dataclass
class GroundingResult:
sentence: str
citation_id: str
similarity: float
grounded: bool
CITATION_RE = re.compile(r"\[(\d+)\]")
def check_grounding(
answer: str,
sources: dict[str, str],
threshold: float = 0.55,
) -> list[GroundingResult]:
"""
[n] 인용이 포함된 각 문장에 대해, 인용된 출처가 실제로 해당 주장을 포함하고 있는지 확인합니다.
(문장, 인용) 쌍마다 하나의 행을 반환하므로, 여러 인용이 포함된 문장에서 어떤 인용이 취약한지 확인할 수 있습니다.
"""
results: list[GroundingResult] = []
sentences = re.split(r"(?<=[.!?])\s+", answer.strip())
for sentence in sentences:
cite_ids = CITATION_RE.
for sentence in sentences:
cite_ids = CITATION_RE.
findall ( sentence ) if not cite_ids : continue # strip citation markers before embedding — they're noise clean = CITATION_RE . sub ( "" , sentence ). strip () sent_emb = _embedder . encode ( clean , convert_to_tensor = True )
for cid in cite_ids :
src_text = sources . get ( cid , "" ) if not src_text : results . append ( GroundingResult ( clean , cid , 0.0 , False )) continue
src_emb = _embedder . encode ( src_text , convert_to_tensor = True )
sim = float ( util . cos_sim ( sent_emb , src_emb ). item ())
results . append ( GroundingResult ( clean , cid , sim , sim >= threshold ) )
return results
The threshold of 0.55 is a starting point, not a law. You'll move it after calibration. Cosine sim of BGE embeddings on factual claim vs supporting passage tends to sit between 0.4 and 0.85 in practice. Below 0.4 is almost always fabrication; above 0.7 is almost always supported. The grey zone needs labelled traces to tune (we'll get there). Gotcha: if your sources are very long, embedding the whole chunk dilutes the signal. The cited claim might match one paragraph in a 2000-token source. The cosine sim against the whole blob comes back middling and you miss the hit. Fix: chunk the source into sentence-windows, embed each, and take the max similarity across chunks.
Detector 2 — Confidence anomaly via logprobs
When a model hallucinates a fact it's never seen, the logprob distribution often looks weird. Confident-but-wrong is the dangerous case, but there's a flavor of confident-but-wrong that shows up as unusually flat per-token entropy. The model is committing to tokens it would normally hedge on. You need logprobs in the response. OpenAI exposes them via logprobs=True, top_logprobs=5 . Anthropic doesn't, last I checked, so this detector only works on providers that surface them.
import math
from statistics import mean
def token_entropy ( top_logprobs : list [ dict ]) -> float :
""" Shannon entropy over the top-k token distribution at one position.
높은 엔트로피 (High entropy) = 모델이 확신하지 못함. 낮은 엔트로피 (Low entropy) = 모델이 확신함. """
probs = [ math.exp(t["logprob"]) for t in top_logprobs ]
total = sum(probs)
if total == 0:
return 0.0
norm = [p / total for p in probs]
return -sum(p * math.log(p) for p in norm if p > 0)
def confidence_anomaly_score(
tokens: list[dict],
baseline_mean_entropy: float,
baseline_stdev: float,
) -> float:
"""
레이블링된 양질의 트레이스 (good traces)를 통해 계산한 베이스라인 대비,
이 응답의 평균 토큰 엔트로피 (mean token entropy)에 대한 Z-점수 (Z-score).
abs(z)를 반환함. 2.5를 초과하면 플래그를 지정할 가치가 있음.
"""
if not tokens:
return 0.0
per_token = [
token_entropy(t.get("top_logprobs", []))
for t in tokens
if t.get("top_logprobs")
]
if not per_token:
return 0.0
response_entropy = mean(per_token)
if baseline_stdev == 0:
return 0.0
return abs(response_entropy - baseline_mean_entropy) / baseline_stdev
`baseline_mean_entropy`와 `baseline_stdev`는 레이블링된 양질의 트레이스 (labelled-good traces)로부터 일주일에 한 번씩 계산합니다. 이를 Redis에 저장하거나 파이프라인이 다시 불러올 수 있는 YAML 파일에 저장하세요. 그러면 트레이스당 점수는 단일 Z-점수 (z-score)가 됩니다. 이 신호는 인용 근거 확인 (citation grounding)보다 노이즈가 더 많습니다. Z-점수 2.5는 "이 응답의 평균 토큰 확신도가 베이스라인으로부터 2.5 표준 편차만큼 떨어져 있음"을 의미합니다. 이는 확신에 찬 환각 (confident hallucinations)을 잡아내지만, 유효하지만 특이한 응답(예: "2+2는 무엇인가?
```python
import json
from jsonschema import Draft202012Validator, ValidationError
from dataclasses import dataclass
@dataclass
class SchemaResult:
valid: bool
errors: list[str]
parse_failed: bool
def check_schema(raw_output: str, schema: dict) -> SchemaResult:
"""
모델의 원문 텍스트(raw text)를 JSON 스키마(JSON Schema)에 따라 검증합니다.
파싱 실패(parse-fail)와 검증 실패(validation-fail) 모두 의도에 대한 환각 (hallucinations of intent)으로 간주합니다.
즉, 모델이 계약(contract)을 준수하지 않은 것입니다.
"""
try:
parsed = json.loads(raw_output)
except json.JSONDecodeError as e:
return SchemaResult(False, [f"json parse: {e}"], True)
validator = Draft202012Validator(schema)
errors = [
f"{'.'.join(str(p) for p in e.absolute_path) or '<root>'}: {e.message}"
for e in validator.iter_errors(parsed)
]
return SchemaResult(len(errors) == 0, errors, False)
OpenAI를 사용 중이라면 이를 response_format={"type": "json_schema", ...}와 함께 사용하세요. 이렇게 하면 생성 시점에 스키마 위반을 방지할 수 있습니다. 하지만 실제로는 구조화된 출력 (structured outputs)이라 하더라도, 객체 중간에 잘려버리는 긴 스트림 (long-running streams) 과정에서 선택적 필드 (optional fields)를 누락하거나 열거형 (enum) 값을 환각하는 경우가 가끔 발생합니다. 이 탐지기가 바로 그런 경우를 잡아냅니다.
흔한 패턴은 다음과 같습니다: 도구 호출 (tool-calling) 에이전트가 {"tool": "search_docs", "args": {"q": "..."}}를 출력해야 합니다. 그런데 모델이 재치를 부리며 {"tool": "search_documents", "args": {"q": "..."}}라고 출력합니다. JSON 자체는 유효하지만, 도구(tool)가 열거형 (enum)에 포함되어 있지 않기 때문에 스키마 검증에서 거부됩니다. 그러면 도구 디스패처 (tool-dispatcher)는 아무런 결과 없이 조용히 빈 결과를 반환하게 되고, 사용자는 검색 결과가 없는 상태에서 모델이 만들어낸 환각 답변을 받게 됩니다. 스키마 체크가 작동하면 이 문제를 확인할 수 있습니다.
탐지기 4 — 자기 일관성 발산 (Self-consistency divergence)
Temperature > 0 설정에서 동일한 답변을 N번 생성합니다. 만약 답변들이 임계값 (threshold)을 넘어 서로 불일치한다면, 모델은 이야기를 지어내고 있는 것입니다. 만약 답변들이 수렴 (converge)한다면, 모델은 자신의 답변에 확신을 가지고 있는 상태입니다 (이것이 정답임을 증명하지는 않지만, 모델이 동전 던지기 식으로 답변을 내놓고 있지는 않다는 점은 증명합니다). 이는 N배의 토큰 (tokens) 비용이 발생하므로, 모든 트레이스 (trace)에 실행하지 마세요. 샘플링 탐지기 (sampling detector)로서 실행하십시오.
"high-stakes" 속성(의료, 금융, 법률 질의)이 태그된 트레이스 (trace) 중 1%를 선택하여 해당 트레이스에 대해 일관성 (consistency) 검사를 실행하십시오.
import asyncio
from openai import AsyncOpenAI
_client = AsyncOpenAI()
async def _sample_once ( messages : list [ dict ], model : str ) -> str :
resp = await _client . chat . completions . create (
model = model ,
messages = messages ,
temperature = 0.7 ,
max_tokens = 400 ,
)
return resp . choices [ 0 ]. message . content or ""
async def consistency_score ( messages : list [ dict ], n : int = 4 , model : str = " gpt-4o-mini " , ) -> float :
""" N번 샘플링하고, 각 샘플을 임베딩(embed)한 후, 평균 쌍별 코사인 유사도(mean pairwise cosine similarity)를 반환합니다.
1.0 = 동일함, <0.65 = 발산함. """
samples = await asyncio . gather (
* ( _sample_once ( messages , model ) for _ in range ( n ))
)
embs = _embedder . encode ( samples , convert_to_tensor = True )
sims : list [ float ] = []
for i in range ( n ):
for j in range ( i + 1 , n ):
sims . append ( float ( util . cos_sim ( embs [ i ], embs [ j ]). item ( )))
return sum ( sims ) / len ( sims ) if sims else 0.0
주의사항: 자기 일관성 (self-consistency) 방식은 사실 관계 질문에서의 조작 (fabrication)은 잡아내지만, 스타일 환각 (style hallucination)은 놓칩니다. 두 응답이 모두 틀릴 수 있으며, (동일한 사전 지식을 공유하기 때문에) 거짓말을 하는 방식이 서로 같을 수 있습니다. 이 방식은 "X는 몇 년도에 일어났는가"와 같은 질문에는 유용하지만, 일관되지만 틀린 것이 실패 모드(failure mode)가 되는 "이 문서를 요약하라"와 같은 질문에는 취약합니다.
탐지기를 OTel 스팬 프로세서 (Span Processor)에 연결하기
트레이스 계층 탐지의 핵심은 애플리케이션이 이미 방출하는 스팬 (span) 위에서 실행된다는 점입니다. 이를 결합하는 가장 깔끔한 방법은 각 LLM 스팬이 종료된 후 실행되어, 백그라운드 스레드에서 탐지기를 실행하고 그 결과를 다시 스팬 속성 (span attributes)으로 추가하는 커스텀 스팬 프로세서 (custom SpanProcessor)를 사용하는 것입니다.
import json
import logging
from concurrent.futures import ThreadPoolExecutor
from opentelemetry.sdk.trace import SpanProcessor , ReadableSpan
from opentelemetry import trace
log = logging .
getLogger("hallucination-detector") _pool = ThreadPoolExecutor(max_workers=8)
class HallucinationSpanProcessor(SpanProcessor):
def init(self, schema_registry: dict, baseline: dict):
self.schema_registry = schema_registry # operation -> schema
self.baseline = baseline # {"mean": float, "stdev": float}
def on_start(self, span, parent_context=None): pass
def on_end(self, span: ReadableSpan):
# only run on LLM spans — convention: name starts with "llm."
if not span.name.startswith("llm."):
return _pool.submit(self._run_detectors, span)
def _run_detectors(self, span: ReadableSpan):
try:
attrs = dict(span.attributes or {})
answer = attrs.get("llm.response.content", "")
sources_json = attrs.get("rag.sources_json", "{}")
tokens_json = attrs.get("llm.response.tokens_json", "[]")
operation = attrs.get("llm.operation", "")
sources = json.loads(sou
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기