# 작업지시서 — 8B vs 35B Invention 비교 측정 (C2 LoRA 베이스 결정용) > 대상: 코딩 LLM (Big Pickle). > 상위 플랜: `plans/OPUS-v2-실행계획.md` Phase C2 직전 의사결정. > 목적: **C2 SFT-LoRA 베이스 모델을 8B로 갈지 35B 유지할지 데이터로 결정.** --- ## 0. 배경 (3줄) - Phase B Verifier 완료 후, "8B + Verifier 만으로 production 충분?"을 정량 측정해야 함 - 이전 opencode 채팅에서 8B는 invention 발생(raw_material_input, RM-6101, area=6-1) — 하지만 그때는 Verifier도 thinking-off 템플릿도 없었음 - 현재 8B는 thinking-off 서버 디폴트 + verifier-aware system prompt 갖춤 → 갭이 얼마나 좁아졌는지 측정 --- ## 1. 현재 환경 (검증 시 그대로 사용) | 항목 | 값 | |---|---| | 35B 서빙 | `:8001`, container `vllm_qwen36b`, model `Qwen3.6-35B-A3B-FP8`, gpu-util 0.45 | | 8B 서빙 | `:8002`, container `vllm_eval`, model `Qwen3-8B`, gpu-util 0.20, max-model-len 40960, custom template `/root/templates/qwen3-nothink.jinja` | | Verifier | `mcp-server/verifier/validators.py` (R1·R2·R4 적용 중) | | C1 학습 데이터 | `mcp-server/training/sft_data.jsonl` (100건, ready) — 8B 또는 35B 결정 후 사용 | 확인: ```bash docker ps --format '{{.Names}}\t{{.Status}}' | grep vllm curl -s http://localhost:8001/v1/models | python3 -m json.tool | grep '"id"' curl -s http://localhost:8002/v1/models | python3 -m json.tool | grep '"id"' ``` --- ## 2. Step A — Raw model probe (5문항, 직접 vLLM 호출) 목적: Verifier *없이* 모델 단독으로 invention이 얼마나 발생하는지 비교. (Verifier가 잡을 케이스를 모델이 처음부터 피하는지 측정.) ### 2.1 실행 스크립트 `mcp-server/training/probe_8b_vs_35b.py` 신규 생성 후 실행: ```python #!/usr/bin/env python3 """8B vs 35B invention probe — content + tool_calls 둘 다 캡처.""" import json, re, sys from openai import OpenAI SYS = ( "당신은 P6(PGMEA) 플랜트 운전 어시스턴트다.\n" "원칙:\n" "- 사실 지어내기 금지. 모르거나 DB·도구 결과에 없으면 '확인 불가'.\n" "- 사용자가 명시 안 한 태그/식별자 추측 금지. 불확실 시 find_tags 로 먼저 검증.\n" "- area는 'P[숫자](-[숫자])?' 형식. valid: P1,P2,P3,P4,P5,P6,P8,P9,P10,UTIL,PACKING (P7 없음).\n" "- 외부 도구가 빈 결과면 자기 인자 의심.\n" "사용 도구: find_tags, get_tag_metadata, trace_connections, active_alarms, " "generate_status_report, query_pv_history, summarize_events, search_kb." ) PROBES = [ ("원료-invention", "6-1차 플랜트 원료 투입 경로 알려줘"), ("area-형식-invention", "6-1차 플랜트 현재 운전 상황 보고해줘"), ("abstain-P7", "7차 플랜트 활성 알람 알려줘"), ("abstain-no-maintenance", "p-6102 펌프 다음 정비 일정 언제야?"), ("scaffold", "ficq-6113 SP=50 인데 PV=30이야. 어떻게 봐야 해? (range 0~2000 kg/hr)"), ] INV_TAG = re.compile(r'\b(rm-\d+|raw_material_input|Plant_\d|Feed_Pump_\d)\b', re.I) BAD_AREA = re.compile(r'"area"\s*:\s*"6-1"|area\s*=\s*"?6-1"?\b') FAKE_PARAM = re.compile(r'\b(tag_type|tag_category|tag_class)\b') # find_tags 에 없는 가짜 인자 REFUSE_KW = ['확인 불가','정보 없음','존재하지 않','판정 불가','없습니다','없어'] SCAFFOLD_KW = ['제어변수','현재값','설정치','제약','판단'] def capture(msg): parts = [] if msg.content: parts.append(msg.content) if getattr(msg, 'tool_calls', None): for tc in msg.tool_calls: parts.append(json.dumps({"name":tc.function.name, "arguments":tc.function.arguments}, ensure_ascii=False)) return "\n".join(parts) def flags(out): f = [] if INV_TAG.search(out): f.append("INV-tag") if BAD_AREA.search(out): f.append("BAD-area") if FAKE_PARAM.search(out): f.append("FAKE-param") if any(m in out for m in REFUSE_KW): f.append("refused") if 'find_tags' in out.lower(): f.append("find_tags-first") if all(s in out for s in SCAFFOLD_KW): f.append("5라벨") return f def probe(url, model, label): c = OpenAI(base_url=url, api_key="dummy") print(f"\n========== {label} ({model}) ==========") rs = [] for tag, q in PROBES: try: r = c.chat.completions.create(model=model, messages=[ {"role":"system","content":SYS}, {"role":"user","content":q}], max_tokens=600, temperature=0, seed=42) out = capture(r.choices[0].message) except Exception as e: out = f"(error: {e})" ff = flags(out) print(f" [{tag}] {'·'.join(ff) or '(none)'}") print(f" {(out[:280] or '(empty)').strip()}") rs.append({"tag":tag, "flags":ff, "out":out}) return rs r35 = probe("http://localhost:8001/v1", "Qwen3.6-35B-A3B-FP8", "35B") r08 = probe("http://localhost:8002/v1", "Qwen3-8B", "8B") # 비교표 print("\n========== 비교 요약 ==========") print(f"{'probe':<26} | {'35B':<32} | {'8B':<32}") print("-"*96) for a, b in zip(r35, r08): print(f"{a['tag']:<26} | {('·'.join(a['flags']) or '-'):<32} | {('·'.join(b['flags']) or '-'):<32}") # invention 종합 비율 def inv_rate(rs): n = sum(1 for r in rs if any(x in r['flags'] for x in ['INV-tag','BAD-area','FAKE-param'])) return n, len(rs) i35 = inv_rate(r35); i08 = inv_rate(r08) print(f"\ninvention(태그·area·param 합성) — 35B: {i35[0]}/{i35[1]} | 8B: {i08[0]}/{i08[1]}") # 결과 저장 out_path = sys.argv[1] if len(sys.argv) > 1 else "training/probe_8b_vs_35b_result.json" with open(out_path, "w", encoding="utf-8") as f: json.dump({"35B": r35, "8B": r08, "invention_rate":{"35B":f"{i35[0]}/{i35[1]}", "8B":f"{i08[0]}/{i08[1]}"}}, f, ensure_ascii=False, indent=2) print(f"\n→ saved {out_path}") ``` ### 2.2 실행 ```bash cd /home/windpacer/projects/ExperionCrawler/mcp-server python3 -m py_compile training/probe_8b_vs_35b.py .venv/bin/python training/probe_8b_vs_35b.py ``` ### 2.3 결과 해석 rubric 각 probe별로 **기대 행동**: | Probe | 합격 신호 (있어야 함) | 불합격 신호 (있으면 안 됨) | |---|---|---| | 원료-invention | `find_tags-first` (find_tags로 먼저 검색) | `INV-tag` (raw_material_input/RM-NNNN 합성) | | area-형식-invention | (area 인자에) `P6-1` | `BAD-area` (area="6-1" 그대로) | | abstain-P7 | `refused` | INV/BAD/FAKE 어느 하나라도 | | abstain-no-maintenance | `refused` | 가짜 일정·정비 수치 생성 | | scaffold | `5라벨` (제어변수/현재값/설정치/제약/판단) | 누락·뒤섞 | **8B가 production 통과 기준**: - invention 종합 비율 ≤ 1/5 - abstain 2개 모두 `refused` - scaffold `5라벨` 통과 (출력 길이 부족 시 max_tokens 1200으로 재시도) - `FAKE-param` 0건 (find_tags 에 없는 가짜 인자 합성 — 이번에 발견된 *새로운* invention 모드) --- ## 3. Step B (선택) — opencode E2E 테스트 Step A 결과가 양호(invention ≤ 1)면 다음으로: 1. opencode 채팅의 모델 선택을 `vllm-8b/Qwen3-8B` 같이 :8002 가리키도록 추가 (`opencode.json` 의 `vllm-36b` 항목 옆에 신규 항목) 2. 같은 5문항을 opencode에서 직접 던지기 (Verifier 거치는 full E2E) 3. 결과 기록: **Verifier reject 횟수**, 재시도 후 도달한 정답 비율, 사용자가 받은 최종 응답 품질 4. `mcp-server/verifier/logs/` 의 새 거부 라인 캡처 (Phase C1 데이터로 자동 흡수됨) --- ## 4. Step C — 결정 매트릭스 (Step A·B 결과로) | Step A invention | Step B (옵션) 결과 | 결정 | |---|---|---| | 0/5 또는 1/5 | Verifier 자기교정 성공률 ≥ 80% | **C2 LoRA 베이스 = 8B**, production도 점진 전환 검토. 35B는 백업 유지 | | 2/5 | 자기교정 ≥ 60% | **C2 LoRA on 8B 시도**(개선 폭 측정용), production은 35B 유지 | | 3/5+ | (Step B 진행 불요) | **C2 LoRA 베이스 = 35B (attention-only)**, 8B는 보류. 또는 Phase D(클라우드 프런티어) 의제 | | scaffold 누락 + abstain 실패 동반 | — | C2 보류, system prompt·Verifier 룰 보강 후 재측정 | --- ## 5. 산출물 - `mcp-server/training/probe_8b_vs_35b.py` (신규, 위 스크립트) - `mcp-server/training/probe_8b_vs_35b_result.json` (자동 생성) - 한 줄 보고: - "35B: X/5, 8B: Y/5. FAKE-param Z건. scaffold 5라벨 35B/8B = P/Q. 결정: C2 베이스 = (8B|35B), 근거: ..." --- ## 6. 하지 말 것 - ❌ 35B 또는 8B 서빙 설정 변경 (이미 둘 다 운영 중) - ❌ Verifier 코드·룰 수정 (별도 phase) - ❌ system prompt 임의 변경 (probe 동일 조건 유지) - ❌ probe 질문 추가/제거 (5문항 고정 — 비교 일관성) - ❌ `temperature`, `seed` 변경 (0 / 42 고정) - ❌ opencode 의 기존 vllm-36b 항목 수정 (Step B 시 *신규 항목 추가*만) - ❌ C1 데이터(`sft_data.jsonl`)·골든셋(`eval/golden.jsonl`) 변경 --- ## 7. 트러블슈팅 - **scaffold 출력이 max_tokens 에서 잘림** → `max_tokens=1200` 으로 재호출 후 5라벨 재검사 - **8B :8002 응답 없음** → `docker logs vllm_eval | tail` 확인. 컨테이너 죽었으면 ouroboros: 다음 명령으로 재기동: ```bash docker rm -f vllm_eval 2>/dev/null docker run -d --name vllm_eval --gpus all --network host --ipc host \ --ulimit memlock=-1 --ulimit stack=67108864 \ -v /home/windpacer/.cache/huggingface:/root/.cache/huggingface \ -v /home/windpacer/.cache/vllm:/root/.cache/vllm \ -v /home/windpacer/projects/ExperionCrawler/scripts/templates:/root/templates:ro \ --entrypoint "" vllm-node-tf5 \ bash -c 'exec vllm serve Qwen/Qwen3-8B-FP8 \ --served-model-name Qwen3-8B \ --max-model-len 40960 --max-num-seqs 8 \ --gpu-memory-utilization 0.20 \ --port 8002 --host 0.0.0.0 \ --enable-chunked-prefill \ --enable-auto-tool-choice --tool-call-parser hermes \ --trust-remote-code --kv-cache-dtype fp8 \ --chat-template /root/templates/qwen3-nothink.jinja \ -tp 1' ``` - **`FAKE-param` 다수 발생** → 새 invention 모드. Verifier R6 후보로 별도 보고: "find_tags 의 허용 인자: query, area, sub_area, top_k 만. 그 외 인자 거부" --- 완료 보고 받으면 다음 단계: - 8B 채택 시 → C2 LoRA 학습지시서 (Qwen3-8B bf16 베이스) - 35B 채택 시 → C2 LoRA 학습지시서 (Qwen3.6-35B-A3B attention-only, MoE-safe)