Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions ai/scripts/llm_eval/prefill_bench.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
"""입력 길이에 따른 첫 토큰 지연(TTFT) 스케일링 측정 (RQ3·RQ5).

운영 꼬리질문 프롬프트의 참고 자료(context) 자리를 한국어 기술 문서로 채워 입력 길이만 바꾼다.
프리픽스 캐시를 끄고 한 번에 한 요청만 보내 큐 대기를 배제하므로, TTFT 기울기가 곧 프리필 처리율이다.
같은 한국어 원문이 모델마다 몇 토큰이 되는지도 함께 기록해 토크나이저 효율이 지연으로 번역되는 양을 잰다.

python prefill_bench.py --label qwen3-4b-q4 --base-url http://stackup-llmtest:8080/v1 \
--corpus /q2/ko_eval.txt --chars 400,1200,3000,6000,12000,24000 --reps 5 --out /tmp/pf/x.jsonl
"""

from __future__ import annotations

import argparse
import asyncio
import importlib
import json
import os
import random
import statistics
import sys
import time

import httpx

sys.path.insert(0, os.path.dirname(__file__))
_C = importlib.import_module(os.environ.get("LLM_EVAL_CASES", "cases")) # noqa: E402

from langchain_core.prompts import ChatPromptTemplate # noqa: E402

from ai_server.chain.prompts.followup_generation import ( # noqa: E402
HUMAN_PROMPT,
SYSTEM_PROMPT,
)

TEMPLATE = ChatPromptTemplate.from_messages(
[("system", SYSTEM_PROMPT), ("human", HUMAN_PROMPT)]
)
VARS = (
"job_category",
"mode",
"previous_question",
"answer_text",
"context",
"parent_category",
"expected_signal",
"history",
)


def load_corpus(path: str) -> str:
with open(path, encoding="utf-8", errors="ignore") as f:
text = " ".join(line.strip() for line in f if line.strip())
return text


def build(case: dict, corpus: str, chars: int, seed: int) -> list[dict]:
"""참고 자료 자리를 corpus 로 채워 목표 글자 수를 맞춘다 (요청마다 다른 구간)."""
rng = random.Random(seed)
start = rng.randrange(0, max(1, len(corpus) - chars - 1))
filler = (corpus * (chars // max(1, len(corpus)) + 2))[start : start + chars]
c = dict(case)
c["context"] = filler
msgs = TEMPLATE.format_messages(**{k: c[k] for k in VARS})
return [
{"role": "system" if m.type == "system" else "user", "content": m.content}
for m in msgs
]


async def one(client, url, model, messages, extra: dict) -> dict:
body = {
"model": model,
"messages": messages,
"stream": True,
"stream_options": {"include_usage": True},
"temperature": 0.0,
"max_tokens": 8,
"cache_prompt": False,
**extra,
}
t0 = time.perf_counter()
first = None
usage = None
try:
async with client.stream("POST", url, json=body, timeout=900) as resp:
resp.raise_for_status()
async for line in resp.aiter_lines():
if not line.startswith("data: "):
continue
data = line[6:]
if data.strip() == "[DONE]":
break
obj = json.loads(data)
if obj.get("usage"):
usage = obj["usage"]
ch = obj.get("choices") or []
if ch and (ch[0].get("delta") or {}).get("content") and first is None:
first = time.perf_counter()
return {
"ok": True,
"ttft": round((first or time.perf_counter()) - t0, 4),
"total": round(time.perf_counter() - t0, 4),
"in_tokens": (usage or {}).get("prompt_tokens"),
}
except Exception as exc: # noqa: BLE001
return {"ok": False, "error": f"{type(exc).__name__}: {str(exc)[:200]}"}


async def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--label", required=True)
ap.add_argument("--base-url", required=True)
ap.add_argument("--model", default="testmodel")
ap.add_argument("--corpus", required=True, help="한국어 텍스트 파일")
ap.add_argument("--chars", default="400,1200,3000,6000,12000,24000")
ap.add_argument("--reps", type=int, default=5)
ap.add_argument("--extra-body", default="")
ap.add_argument("--out", required=True)
args = ap.parse_args()
extra = json.loads(args.extra_body) if args.extra_body else {}
corpus = load_corpus(args.corpus)
case = _C.FOLLOWUP_CASES[0]
url = args.base_url.rstrip("/") + "/chat/completions"
lengths = [int(x) for x in args.chars.split(",")]
plan = [(c, r) for c in lengths for r in range(args.reps)]
random.Random(f"{args.label}|plan").shuffle(plan) # 길이 순서 효과 제거
os.makedirs(os.path.dirname(args.out) or ".", exist_ok=True)
async with httpx.AsyncClient(timeout=900) as client:
await one(client, url, args.model, build(case, corpus, 400, 0), extra) # 워밍업
with open(args.out, "a", encoding="utf-8") as fh:
for chars, rep in plan:
msgs = build(case, corpus, chars, seed=chars * 100 + rep)
rec = await one(client, url, args.model, msgs, extra)
rec.update(label=args.label, chars=chars, rep=rep)
fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
fh.flush()
print(
f" {args.label} chars={chars:6d} rep={rep} "
f"tok={rec.get('in_tokens')} ttft={rec.get('ttft')}"
f"{' ERR ' + rec.get('error', '') if not rec.get('ok') else ''}",
file=sys.stderr,
)
rows = [
json.loads(x)
for x in open(args.out, encoding="utf-8")
if json.loads(x).get("label") == args.label and json.loads(x).get("ok")
]
by: dict[int, list[float]] = {}
for r in rows:
by.setdefault(r["chars"], []).append(r["ttft"])
print(f"== {args.label}", file=sys.stderr)
for c in sorted(by):
tok = [r["in_tokens"] for r in rows if r["chars"] == c and r["in_tokens"]]
print(
f" chars={c:6d} tokens={statistics.median(tok) if tok else '?':>7} "
f"ttft_median={statistics.median(by[c]):.3f} n={len(by[c])}",
file=sys.stderr,
)


if __name__ == "__main__":
asyncio.run(main())
39 changes: 39 additions & 0 deletions ai/scripts/llm_eval/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
4. 판정자 계열 편향: (Gemini 판정 − Claude 판정) 을 Google 계열 모델 vs 그 외로 비교,
차이의 부트스트랩 CI + Mann-Whitney U
5. 검정력: 관측된 쌍대 차이 표준편차로 0.3 / 0.5 점 차이를 검출하는 데 필요한 케이스 수
+ 동등성 검정(TOST): '차이가 없다'를 주장하려면 비유의(p>.05)가 아니라 등가 구간 안에 있음을 보여야 한다
6. 자동 지표 비율의 Wilson 95% CI, 지연 중간값의 부트스트랩 95% CI
"""

Expand Down Expand Up @@ -101,6 +102,44 @@ def n_for_paired(
return int(math.ceil(((za + zb) * sd / delta) ** 2)) if delta > 0 and sd > 0 else 0


def tost_paired(diffs: list[float], margin: float) -> dict:
"""쌍대 차이의 동등성 검정 (TOST). |차이| < margin 이면 '실질적 차이 없음'.

두 단측 t 검정 중 큰 p 값이 결과다. p < .05 면 등가 구간 안에 있다고 본다.
margin 은 연구자가 정하는 실질적 유의미 경계이며, 근거를 함께 보고해야 한다.
"""
d = np.asarray(diffs, dtype=float)
n = len(d)
mean = float(d.mean())
sd = float(d.std(ddof=1)) if n > 1 else 0.0
se = sd / math.sqrt(n) if n > 1 and sd > 0 else 0.0
if se == 0:
p = 0.0 if abs(mean) < margin else 1.0
return {
"n": n,
"mean": mean,
"margin": margin,
"p_tost": p,
"ci90": (mean, mean),
}
df = n - 1
t_lo = (mean + margin) / se
t_hi = (mean - margin) / se
p_lo = 1 - stats.t.cdf(t_lo, df) # H0: 차이 <= -margin
p_hi = stats.t.cdf(t_hi, df) # H0: 차이 >= +margin
tcrit = stats.t.ppf(0.95, df)
return {
"n": n,
"mean": mean,
"sd": sd,
"margin": margin,
"p_lower": float(p_lo),
"p_upper": float(p_hi),
"p_tost": float(max(p_lo, p_hi)),
"ci90": (mean - tcrit * se, mean + tcrit * se),
}


def load_judge(path: str):
"""→ scores[suite][model][case][judge] = overall"""
scores = defaultdict(lambda: defaultdict(lambda: defaultdict(dict)))
Expand Down
2 changes: 2 additions & 0 deletions docs/research/thesis/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
| [`research-design.md`](./research-design.md) | 연구 질문 RQ1~RQ7, 가설, 분석 계획, 대체 가능성 판정 기준 |
| [`related-work.md`](./related-work.md) | 선행연구 정리와 연구 공백 |
| [`quantization-study.md`](./quantization-study.md) | RQ2 — 비트 폭·한국어 보정 행렬·재양자화 |
| [`prefill-scaling.md`](./prefill-scaling.md) | RQ3·RQ5 — 입력 길이와 첫 응답 시간, 토크나이저 효율의 지연 환산 |
| [`latency-experiment.md`](./latency-experiment.md) | RQ3 — 개방형 Poisson 부하, 프리픽스 캐시, 처리 용량 경계, 에너지 |
| [`tokenizer-efficiency.md`](./tokenizer-efficiency.md) | RQ5 — 한국어 토크나이저 효율 |
| [`judge-panel-analysis.md`](./judge-panel-analysis.md) | RQ6 — 3계열 판정자 패널, 일치도, 같은 계열 편향 |
Expand All @@ -27,6 +28,7 @@
| `quant/raw/`, `quant/analysis/` | 양자화 조건별 모델 출력과 자동 지표 |
| `judges/` | 판정자 원 응답과 패널 점수 |
| `latency/` | 개방형 부하 요청별 기록, GPU 전력 원격 측정 |
| `prefill/` | 입력 길이별 첫 토큰 지연 원자료 (모델 3종 × 6단계 × 5회) |
| `human-eval/` | 라벨 시트와 2차 주석자 응답 |

## 재현
Expand Down
6 changes: 3 additions & 3 deletions docs/research/thesis/outline.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@
| 5.2 규칙 준수·의도 분류 | RQ4 | v2 70케이스로 16조건 측정 완료. 태그 준수·의도·점수 라벨·사실대조 전부 집계 | 게이트웨이 모델 v2 재측정(운영 키 외 필요) | 🔄 |
| 5.3 양자화 | RQ2 | **완료** — 비트 곡선 10종 KL, 한국어 보정 효과(0.739→0.337), 재양자화 손실, 크기 의존성 | — | ✅ `quantization-study.md` |
| 5.4 서빙 병목·동시성 | RQ3 | **완료** — 개방형 Poisson 부하, 프리픽스 캐시 필수성, 처리 용량 경계(0.6 req/s), J/token | — | ✅ `latency-experiment.md` |
| 5.5 토크나이저 | RQ5 | 구어체 최대 1.66배 차이, 부하 실험의 입력 토큰 실측치 확보 | 토큰 수 → TTFT 회귀 분석 | 🔄 |
| 5.5 토크나이저 | RQ5 | **완료** — 토큰 수 → TTFT 회귀(R² ≥ .996), 토크나이저가 지연 격차의 49% | — | ✅ `prefill-scaling.md` |
| 5.6 판정자 편향 | RQ6 | 3계열 패널 α 0.715, 같은 계열 편향 +0.43 [0.27, 0.58], 양자화 판정에서도 재현 | 인간 평가(평가자 필요) | 🔄 |
| 5.7 비용 | RQ7 | 손익분기 월 2,400~5,500 세션 | 민감도 분석 | ✅ (모형) |

Expand All @@ -77,10 +77,10 @@

| 순위 | 작업 | 막힌 이유 | 없으면 |
|---|---|---|---|
| 1 | 양자화 판정 나머지 28케이스 (163회 호출) | 게이트웨이 채점 키 크레딧 소진 | 5.3절은 규칙 지표만으로 서술 가능, 5.1 은 약해짐 |
| 1 | 양자화 판정 나머지 28케이스 (163회 호출) | 게이트웨이 채점 키 크레딧 소진 | 엄격 경계 동등성(48케이스 필요) 미확인, Gemma 8비트 우위 판정 불가 |
| 2 | 게이트웨이 모델(Gemini·Gemma 31B 등) v2 케이스 재측정 | 실험 전용 키 필요 | 1·2라운드 v1 결과로 대체 서술 |
| 3 | 인간 전문가 평가 3인 | 평가자 섭외 | 5.6절이 LLM 판정자 간 비교에 머무름 |
| 4 | 토큰 수 → TTFT 회귀 | 없음 (분석만 하면 됨) | 5.5절이 토크나이저 계산에 머무름 |
| 4 | 비트 폭별 처리 용량 (개방형 부하) | 없음 (GPU 야간 실행) | 양자화와 서빙 장을 잇는 결론이 빠짐 |
| 5 | 24GB GPU 실측 | 하드웨어 없음 | 6.1절 경계 주장이 추정으로 남음 |

## 부록
Expand Down
72 changes: 72 additions & 0 deletions docs/research/thesis/prefill-scaling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# 입력 길이 스케일링 (RQ3·RQ5) — 프리필이 첫 응답 시간을 지배한다

> 2026-09-18. 프리픽스 캐시를 끄고 한 번에 한 요청만 보내 큐 대기를 배제한 상태에서, 입력 길이만 바꿔 첫 토큰까지 걸리는 시간(TTFT)을 측정했다.
> 도구: `ai/scripts/llm_eval/prefill_bench.py` · 원자료: `prefill/*.jsonl` (모델 3종 × 길이 6단계 × 5회 = 90건)

---

## 1. 왜 이 실험이 필요했나

앞선 부하 실험의 케이스들은 입력이 1,343~1,987 토큰 범위에만 흩어져 있었다. 이 자료로 입력 길이와 지연의 관계를 회귀하면 결정계수가 0.02~0.14 에 그치고 기울기 부호조차 뒤집힌다. **범위가 좁아 기울기를 추정할 수 없는 것이지, 관계가 없는 것이 아니다.** 그래서 참고 자료 자리를 한국어 기술 문서로 채워 입력을 1,688 토큰에서 6,090 토큰까지 3.6배로 벌렸다.

- 길이 순서는 무작위로 섞어 워밍업·발열 효과가 특정 길이에 몰리지 않게 했다.
- `cache_prompt=false` 로 매 요청 전체를 다시 계산하게 했다. 캐시가 켜지면 재계산이 사라져 프리필 비용을 잴 수 없다.
- 출력은 8토큰으로 제한했다. 측정 대상은 첫 토큰까지이므로 디코딩 시간이 섞이지 않는다.
- 서빙은 llama.cpp, 문맥 8192, 슬롯 1개, 모두 Q4_K_M 로 고정했다.

## 2. 결과 — 거의 완벽한 직선

| 모델 | 기울기 (토큰당) | 95% 신뢰구간 | 프리필 처리율 | 결정계수 |
|---|---|---|---|---|
| Gemma 4 E2B | 2.408 ms | [2.385, 2.431] | **415 tok/s** | 0.9994 |
| Gemma 4 E4B | 4.416 ms | [4.393, 4.438] | **226 tok/s** | 0.9998 |
| Qwen3 4B | 5.476 ms | [5.343, 5.609] | **183 tok/s** | 0.9961 |

결정계수가 0.996 이상이라는 것은 **첫 응답 시간의 거의 전부가 입력 길이로 설명된다**는 뜻이다. 모델 크기나 양자화 수준이 아니라 입력 토큰 수가 지배 변수다.

| 입력 | Gemma E2B | Gemma E4B | Qwen3 4B |
|---|---|---|---|
| 1,688 토큰 | 3.52초 | 6.95초 | 7.45초 (1,901 토큰) |
| 3,226 토큰 | 7.04초 | 13.59초 | 15.41초 (3,558 토큰) |
| 5,493 토큰 | 12.70초 | 23.77초 | 30.43초 (6,090 토큰) |

## 3. 토크나이저 효율이 지연으로 번역되는 양

같은 한국어 원문을 넣었을 때 모델마다 토큰 수가 다르다. 두 Gemma 는 같은 토크나이저를 쓰고, Qwen3 는 같은 글자에 **10.9% 더 많은 토큰**을 쓴다.

| 원문 글자 수 | Gemma (E2B·E4B) | Qwen3 4B | 차이 |
|---|---|---|---|
| 400 | 1,688 | 1,901 | +12.6% |
| 2,400 | 2,611 | 2,923 | +11.9% |
| 9,000 | 5,493 | 6,090 | +10.9% |

9,000자 입력에서 Qwen3 는 Gemma E4B 보다 6.66초 느리다. 이를 나누면 이렇다.

- **토큰 수가 많아서 생긴 손해 3.27초 (49%)** — 597개 더 많은 토큰 × 토큰당 5.476 ms
- 토큰당 처리 속도가 느려서 생긴 손해 3.39초 (51%)

즉 **한국어 토크나이저 비효율이 체감 지연의 절반을 만든다.** 토크나이저 효율은 그동안 어휘 통계로만 이야기돼 왔지만, 이 실험은 그것이 초 단위 지연으로 얼마나 번역되는지 실측으로 보여준다.

## 4. 운영 기준으로 환산하면

운영 로그에서 질문 풀 생성의 입력은 p90 기준 약 4,700 토큰이다. 프리필만으로 이만큼 걸린다.

| 모델 | 4,700 토큰 | 문맥 8,192 토큰을 채웠을 때 |
|---|---|---|
| Gemma E2B | 11.3초 | 19.7초 |
| Gemma E4B | 20.8초 | 36.2초 |
| Qwen3 4B | 25.7초 | 44.9초 |

앞선 양자화 스윕에서 질문 풀 생성 지연 중앙값이 26~32초로 나온 것과 맞아떨어진다. **서로 다른 방법으로 잰 두 수치가 일치하므로, 프리필 병목이라는 진단은 측정 방식의 산물이 아니다.**

이 결과는 세 가지를 함의한다.

1. **이력서·GitHub 분석처럼 긴 입력을 넣는 기능은 이 하드웨어에서 로컬로 감당할 수 없다.** 문맥을 채우면 첫 글자가 20~45초 뒤에 나온다.
2. **프리픽스 캐시가 선택이 아닌 이유가 설명된다.** 대화가 이어질 때 공통 접두부를 재계산하지 않는 것이 유일한 탈출구다.
3. **한국어 서비스는 토크나이저를 모델 선택 기준에 넣어야 한다.** 품질이 비슷하면 한국어를 적은 토큰으로 쪼개는 모델이 체감 속도에서 앞선다.

## 5. 한계

- 절편이 음수로 나온다(−0.56 ~ −3.52초). 측정 구간이 1,688 토큰부터라 그 아래로 외삽한 값이며, 고정 비용이 음수라는 뜻은 아니다. Qwen3 의 절편이 특히 큰 음수인 것은 입력이 길수록 배치 효율이 좋아져 기울기가 완만해진다는 뜻으로 읽힌다. 짧은 입력 구간을 따로 재야 확인된다.
- 양자화 수준을 Q4_K_M 하나로 고정했다. 비트 폭이 프리필 속도에 주는 영향은 별도 실험이 필요하다.
- 단일 GPU·단일 서빙 엔진 결과다.
Loading
Loading