Technical Report · Independent Project기술 리포트 · 독립 프로젝트

JEV-CPU: Running Semantic-If Decisions on a CPUJEV-CPU: CPU에서 Semantic-If 결정 실행하기

leesk212 · Meanblock
September 19, 2026 · v12026년 9월 19일 · v1

Abstract. Semantic-if decisions — route this, is this a policy violation, what severity is this incident — are usually answered by having a chat model generate text that software then parses back into a branch. SemIf (an open reproduction of TypeSafe's Jev pattern) instead reads a decision directly from a model's option logits in a single forward pass, with no text generated. SemIf targets a GPU holding a 4B model. This report describes JEV-CPU, a small adaptation that runs the same engine on a commodity CPU with a 0.6B open model. It gives a full code-level account of the mechanism (§5) and reports measurements from an 8 GB, GPU-less machine: qualitative decisions across eight domains, a latency-versus-input-length curve, and the resulting practical input ceiling. We make no claim of methodological novelty — the contribution is engineering and empirical: showing the method is device-agnostic, documenting exactly how an open-weight model becomes a decision engine in code, and quantifying where CPU latency (not memory or the model) becomes the limit.

Contents

JEV-CPU deciding live across eight domains on CPU
Figure 1. JEV-CPU running live on a CPU across eight domains: the state is typed in, criteria are added, and each decision is read from Qwen3-0.6B's option logits in ~1 s — no text generated.

1. Introduction

Most decisions inside software agents are small and typed: choose a queue, pick a severity, decide whether evidence supports a claim. A chat model can answer them, but generating an answer sentence and parsing it back into an if is slow and brittle. A decision-native alternative is to present the options as tokens and read the model's probability over exactly those tokens in one forward pass. TypeSafe's closed Jev service popularized this interface; SemIf reproduces the interface pattern with open models on a GPU.

This report asks a narrow, practical question: does the method still work with no GPU and a tiny model, and where does it break down?

2. Background

Reading a categorical decision from an LM's next-token distribution — rather than sampling text — is a well-established idea (verbalizer-style zero-shot classification, answer-token scoring, NLI cross-encoders). We claim no novelty for the mechanism. JEV-CPU is a CPU adaptation and empirical study of SemIf, which is itself an independent reproduction of the Jev pattern; Jev and TypeSafe are the property of their owners and are not affiliated with this work.

3. Method: reading a decision from logits

A decision is one record: a state (evidence), a question (criterion), and 2–16 typed options. The direct scoring path (unchanged from SemIf) is:

  1. Letter-choice prompt. Each option is labeled A, B, C… ; the system turn asks for a single uppercase letter, so the next token is the answer.
  2. Single-token pinning. Each letter is verified to encode to one round-tripping token that does not perturb tokenization — one clean vocabulary slot per option.
  3. One forward pass. The prompt runs once; we keep the final-position logits. No sampling, no decode loop, no JSON to repair.
  4. Softmax over slots. We gather only the option letters' token ids and softmax over those, giving a probability per option conditional on the declared set.

Latency is dominated by prompt prefill, not generation length — the property JEV-CPU relies on to stay usable on a CPU. The concrete code is dissected in §5.

4. System: the CPU port

SemIf forces a GPU in exactly one place — its loader checks for a single CUDA device and loads with device_map={"":"cuda:0"}, bfloat16. Everything downstream is device-agnostic: the scoring code follows device = next(model.parameters()).device and the only CUDA-specific call, torch.cuda.synchronize(), is guarded by if device.type == "cuda" (a no-op on CPU). JEV-CPU therefore changes only the loader (CPU / float32) and reuses SemIf's original scoring. A standard-library web server exposes it with a three-pane UI.

JEV-CPU three-pane web UI
Figure 2. The web UI: state (top-left), criteria (bottom-left), and per-option probability bars with the chosen option (right).

5. Implementation in code: turning an open-weight model into a decision engine

This is the empirical core of the report. A "JEV" / decision engine is not a fine-tune and not new weights — it is a specific way of calling an ordinary open-weight causal LM so that a typed decision falls out of a single forward pass. Below is the whole pipeline, function by function, as it runs in JEV-CPU. The scoring code is SemIf's, reproduced verbatim; JEV-CPU changes only the loader (§5.9). Snippets are from src/semif_phase1/{core,direct}.py.

5.1 — Data flow at a glance

One decision travels through eight steps; no step generates text:

record {state, question, options}
  → validate_row              (5.2  strict schema check)
  → direct_messages           (5.3  option → letter, chat turns)
  → encode_prompt             (5.5  chat template → token ids)
      → _slot_ids             (5.4  each letter is one clean token)
      → boundary check        (5.5  answer boundary is stable)
  → _forward                  (5.6  ONE forward, last-position logits)
  → gather slots + softmax    (5.8  probability per option → arg-max)
  ⇒ decision, in ~1 forward pass

5.2 — The record and its validation

The input is one plain record. Validation is strict, so nothing is silently coerced or truncated later:

def validate_row(row):
    required = {"id", "state", "question", "options"}
    if not required <= row.keys(): raise ValueError(...)
    json.dumps(state, ensure_ascii=False, allow_nan=False)   # finite JSON
    if not isinstance(options, list) or not 2 <= len(options) <= len(LETTERS): raise ValueError(...)

state may be a nonempty string / JSON object / array; there must be 2–16 options, each {id, description}, with unique ids.

5.3 — Building a letter-choice prompt

Each option is assigned an uppercase letter; the record becomes a two-turn chat whose system turn forbids anything but a single letter. This is what makes the next token carry the whole decision:

LETTERS = "ABCDEFGHIJKLMNOP"
DIRECT_SYSTEM = ("Apply the supplied criterion to the supplied evidence. "
                 "Choose exactly one listed option. Respond with only its "
                 "uppercase letter, with no explanation or reasoning.")

def direct_messages(row):
    payload = {"evidence": row["state"], "criterion": row["question"],
               "options": [{"letter": LETTERS[i], "description": o["description"]}
                           for i, o in enumerate(row["options"])]}
    return [{"role":"system","content":DIRECT_SYSTEM},
            {"role":"user","content":json.dumps(payload, ensure_ascii=False)}]

5.4 — Pinning each option to exactly one token

The readout compares the model's probability of each answer letter, so each letter must be a single, clean vocabulary slot — one round-tripping token, no collisions:

def _slot_ids(tokenizer, count):
    result = []
    for letter in LETTERS[:count]:
        encoded = tokenizer.encode(letter, add_special_tokens=False)
        if len(encoded) != 1 or tokenizer.decode(encoded) != letter:
            raise ValueError(f"Answer slot {letter!r} is not one exact round-trip token")
        result.append(encoded[0])
    if len(result) != len(set(result)): raise ValueError("Answer-slot tokens collide")
    return result            # token ids for A, B, C, ...

5.5 — Encoding and verifying the answer boundary

The chat template is applied with the generation prompt on and thinking disabled, then two invariants are asserted: the prompt fits the token budget (no silent truncation), and appending any letter extends the tokenization by exactly that one slot token — the prompt/answer boundary is stable:

def encode_prompt(tokenizer, row, max_tokens):
    prompt = tokenizer.apply_chat_template(direct_messages(row), tokenize=False,
        add_generation_prompt=True, enable_thinking=False)
    ids = tokenizer.encode(prompt, add_special_tokens=False)
    if not ids or len(ids) > max_tokens: raise ValueError("exceeds limit; no truncation")
    slots = _slot_ids(tokenizer, len(row["options"]))
    for letter, token in zip(LETTERS, slots):
        if tokenizer.encode(prompt + letter, add_special_tokens=False) != ids + [token]:
            raise ValueError(f"Answer boundary changes tokenization for slot {letter}")
    return ids, slots, digest(prompt)   # sha256 for auditability

5.6 — One forward pass, last-position logits

The whole model runs once; we keep only the final position — the distribution over the next token. logits_to_keep=1 avoids materializing a full-sequence logit tensor. No sampling, no loop:

def _forward(model, inputs):
    params = inspect.signature(model.forward).parameters
    kwargs = dict(inputs, use_cache=False, return_dict=True)
    if "logits_to_keep" in params: kwargs["logits_to_keep"] = 1
    return model(**kwargs).logits[:, -1, :]     # (batch, vocab); no sampling

5.7 — Device handling: why it is CPU-agnostic

The scoring function reads whatever device the model is on and only synchronizes under CUDA. These two lines are the entire reason the same code runs on a GPU or a CPU unchanged:

device = next(model.parameters()).device          # follow the model
if device.type == "cuda": torch.cuda.synchronize(device)   # no-op on CPU

5.8 — Gather the slots, softmax, decide

From the full-vocabulary logit vector we index only the option-letter tokens and softmax over those, producing a probability per option (conditional on the declared set); the winner is the arg-max:

def score(model, tokenizer, row, metadata, max_tokens=4096):
    ids, slots, prompt_hash = encode_prompt(tokenizer, row, max_tokens)
    device = next(model.parameters()).device
    inputs = {"input_ids": torch.tensor([ids], device=device),
              "attention_mask": torch.ones((1, len(ids)), device=device)}
    if device.type == "cuda": torch.cuda.synchronize(device)
    with torch.inference_mode():
        vocabulary = _forward(model, inputs)[0].float()        # (vocab,)
    selected = vocabulary[slots].cpu().tolist()   # logits at A, B, C, ...
    return {"option_ids":[o["id"] for o in row["options"]],
            "option_logits": selected, "probabilities": softmax(selected), ...}

That is the entire "JEV-ification": a stock AutoModelForCausalLM is never asked to generate; it is asked once for its next-token logits, and the decision is the arg-max over the option slots.

5.9 — The only change to run on CPU

SemIf — load_causal_model (GPU)JEV-CPU — load_causal_model_cpu
if not torch.cuda.is_available() \
   or torch.cuda.device_count() != 1:
    raise ValueError("Expose exactly "
        "one CUDA GPU ...")
model = cls.from_pretrained(
    source, config=config,
    dtype=torch.bfloat16,
    device_map={"": "cuda:0"},
    low_cpu_mem_usage=True, **common)
# no CUDA check, no device_map
model = AutoModelForCausalLM.from_pretrained(
    source, config=config,
    dtype=torch.float32,   # CPU-stable
    low_cpu_mem_usage=True, **common)
model.eval()

Because §5.7–§5.8 derive the device from the model, loading on CPU is sufficient; the same scoring code runs with the CUDA sync skipped. Swapping the brain is the one other knob: set MODEL to any causal LM whose answer letters are single tokens.

5.10 — Reusing one state across many criteria (shared mode)

When many criteria judge the same state, shared.py prefills it once into a KV cache, replicates the cache with cache.reorder_cache(...), and evaluates every criterion's answer position in one batched forward via a vector logits_to_keep — one prefill, many decisions; also CUDA-synced only under CUDA. This is the path that makes high-throughput serving cheap (§8.1).

6. Empirical results across domains

Using Qwen/Qwen3-0.6B in float32 on CPU, the same engine ran across eight domains (Figure 1); each decision took ≈1 s.

DomainCriterionDecisionForward
Customer supportSentimentnegative — 97.4%~1.1 s
Customer supportRoute to teambilling — 100%~1.0 s
Content moderationPolicy violation?violation — 99.3%~1.1 s
Content moderationRecommended actionwarn — 69.2%~1.0 s
Code-review triageMerge riskhigh — 99.8%~1.2 s
Code-review triagePR dispositionblock — 94.9%~1.1 s
Incident / DevOpsSeveritysev1 — 100%~1.2 s
Incident / DevOpsPage on-call now?page_now — 100%~1.1 s
Email intentPrimary intentsales — 100%~1.2 s
Compliance gateChange ticket required?required — 100%~1.1 s
Loan / credit riskCredit riskhigh — 100%~1.2 s
Loan / credit riskRecommended decisionapprove — 82.8% ⚠︎~1.1 s
Support prioritizationPriorityp1 — 100%~1.2 s

Single illustrative runs, not a benchmark. The loan decision row is a deliberate example of a small-model slip (see §9).

7. Latency and input-length limits

Two numbers are often conflated. The model's context window is 40,960 tokens — large even at 0.6B. SemIf's engine caps a decision at 4,096 tokens by default (a guard; configurable). Neither is the real constraint on CPU. Measured on the 8 GB CPU box, one decision with growing input:

Input tokensForward (CPU)Peak RAM
2542.5 s~3.5 GB
7315.6 s~3.5 GB
1,36311.9 s~3.5 GB
2,62926.0 s~3.5 GB
5,16566.1 s~3.5 GB
7,697117.4 s~3.5 GB

RAM stayed flat at ~3.5 GB with no OOM even at 7,697 tokens — the ceiling here is prefill latency (≈ quadratic), not memory or the model.

Practical input ceiling. We treat ≈7,700 tokens (~117 s) as the usable maximum on this CPU: beyond it one decision crosses ~120 s, no longer useful, so larger inputs are considered unsupported here. Interactive ~1 s decisions want short states (≲~300 tokens).

8. Accuracy scaling with model size

Qwen3-0.6B is the smallest model on SemIf's ladder, the accuracy floor. From SemIf's evaluation:

Brain modelSizeAuthored balanced acc.TypeSafe subset agr.
Qwen3-0.6B (JEV-CPU default)0.6 B0.4400.407
MiniCPM5-2B2 B0.6860.637
Qwen3.5-4B4 B0.8130.845

The engine is model-agnostic, so moving up is a one-line change (MODEL = …), at the cost of RAM/compute beyond this box. The method runs anywhere; accuracy scales with the model you point it at.

8.1 Outlook — GPU serving and a production JEV

Accuracy (§8) and CPU latency (§7) are usually assumed to trade off, but a GPU relaxes both at once:

Together, a larger open-weight model (4B+) on a GPU is simultaneously more accurate, accepts far larger inputs, and answers many decisions per second — with typed, auditable outputs and nothing to parse. That is the shape of a production, potentially commercial, JEV: semantic-if as a low-latency, high-throughput hosted service. JEV-CPU is the floor (portable to any machine); a GPU-served larger model is the ceiling that turns the same engine into a product.

Scope. We did not run GPU experiments here. The GPU latency/throughput figures are SemIf's published single-GPU (RTX 3090, 4B) measurements plus projections from our CPU curve (§7) — an implication, not our own results. Quantifying a GPU-served JEV (tokens/s, decisions/s, cost, concurrency) is the natural next study.

9. Limitations

10. Conclusion

Decision-native, logit-readout inference is not tied to a GPU or a large model. As §5 shows in code, an ordinary open-weight model becomes a decision engine with a loader change alone; SemIf's engine then runs on a commodity CPU with a 0.6B model, decides across many domains at ~1 s each, and stays memory-stable well past its default token cap. On this hardware the honest limit is latency: inputs above ~7,700 tokens cross the ~120 s mark, and small-model accuracy — not context or memory — is what improves by scaling up. JEV-CPU is the reproducible floor; the same engine, given a larger open-weight model on a GPU, points toward the ceiling (§8.1) — an accurate, high-throughput, low-latency semantic-if service, i.e. a production JEV.

11. Reproducibility

python3 -m venv .venv && source .venv/bin/activate
pip install --index-url https://download.pytorch.org/whl/cpu torch
pip install transformers accelerate
python semif_cpu.py      # CLI: typed option probabilities
python server.py         # web UI on http://localhost:8080

References

  1. T. Lee (TheoLeeCJ). SemIf — Semantic ifs from open models. github.com/TheoLeeCJ/SemIf. Demo: openjev.com.
  2. TypeSafe. Jev (closed service). Names/marks belong to their owners; this work is independent and unaffiliated.
  3. Qwen Team. Qwen3-0.6B. huggingface.co/Qwen/Qwen3-0.6B.
  4. A. Liu et al. WANLI. dataset (used in SemIf's §8 evaluation).
  5. Released under the MIT License.

초록. Semantic-if 결정 — 이걸 어디로 보낼까, 정책 위반인가, 이 인시던트의 심각도는 — 은 보통 챗 모델이 텍스트를 생성하고 소프트웨어가 그 텍스트를 다시 분기로 파싱하는 방식으로 처리된다. SemIf(TypeSafe의 Jev 패턴을 오픈 모델로 재현한 프로젝트)는 텍스트를 생성하지 않고 모델의 옵션 로짓을 단 한 번의 forward로 읽어 결정을 낸다. SemIf는 4B 모델을 올릴 GPU를 전제로 한다. 본 리포트는 동일한 엔진을 0.6B 오픈 모델로 일반 CPU에서 돌리는 소규모 적응인 JEV-CPU를 기술한다. 메커니즘을 코드 수준에서 온전히 해부하고(§5), GPU가 없는 8 GB 머신에서의 측정을 보고한다. 8개 도메인의 정성적 결정, 입력 길이에 따른 지연 곡선, 그리고 거기서 도출한 실용 입력 상한이다. 방법론적 신규성은 주장하지 않는다. 기여는 엔지니어링과 실증에 있다. 이 방법이 디바이스에 독립적임을 보이고, 오픈웨이트 모델이 코드 수준에서 어떻게 결정 엔진이 되는지를 문서화하며, 메모리나 모델이 아니라 CPU 지연이 한계가 되는 지점을 정량화한다.

목차

JEV-CPU가 CPU에서 8개 도메인에 대해 실시간으로 결정하는 모습
그림 1. JEV-CPU가 CPU에서 8개 도메인에 걸쳐 실시간으로 동작한다. state를 입력하고 기준을 추가하면, 각 결정이 Qwen3-0.6B의 옵션 로짓에서 약 1초 만에 읽힌다 — 텍스트 생성은 없다.

1. 서론

소프트웨어 에이전트가 내리는 결정은 대개 작고 타입이 정해져 있다. 큐를 고르고, 심각도를 정하고, 증거가 주장을 뒷받침하는지 판단한다. 챗 모델로도 답할 수 있지만, 답 문장을 생성한 뒤 다시 if로 파싱하는 방식은 느리고 취약하다. 결정-네이티브 대안은 옵션을 토큰으로 제시하고, 바로 그 토큰들에 대한 모델의 확률을 한 번의 forward로 읽는 것이다. TypeSafe의 비공개 서비스 Jev가 이 인터페이스를 알렸고, SemIf가 이를 오픈 모델과 GPU로 재현했다.

본 리포트는 좁고 실용적인 질문을 던진다. GPU 없이, 아주 작은 모델로도 이 방법이 동작하는가? 그리고 어디에서 무너지는가?

2. 배경

텍스트를 샘플링하는 대신 LM의 다음-토큰 분포에서 범주형 결정을 읽는 것은 이미 잘 확립된 아이디어다 (verbalizer 기반 zero-shot 분류, 답-토큰 스코어링, NLI 크로스-인코더 등). 이 메커니즘 자체에는 새로움이 없다. JEV-CPU는 SemIf의 CPU 적응이자 실증 연구이고, SemIf 역시 Jev 패턴을 독립적으로 재현한 것이다. Jev와 TypeSafe는 각 소유자의 자산이며 본 작업과는 무관하다.

3. 방법: 로짓에서 결정 읽기

하나의 결정은 하나의 레코드다. state(증거), question(기준), 그리고 2–16개의 타입드 options로 이루어진다. direct 스코어링 경로는 SemIf 그대로이며 다음과 같다.

  1. 레터-초이스 프롬프트. 각 옵션에 A, B, C…를 붙인다. 시스템 턴이 "대문자 한 글자만" 답하도록 제약하므로, 생성될 다음 토큰이 곧 답이 된다.
  2. 단일 토큰 고정. 각 레터가 토큰화를 흔들지 않고 정확히 1토큰으로 round-trip되는지 검증한다. 옵션마다 깨끗한 어휘 슬롯 하나가 대응된다.
  3. 1회 forward. 프롬프트를 한 번만 실행하고 마지막 위치의 로짓만 취한다. 샘플링도, 디코딩 루프도, 복구할 JSON도 없다.
  4. 슬롯에 대한 softmax. 옵션 레터 토큰만 모아 softmax를 취해, 선언된 옵션 집합에 조건부인 옵션별 확률을 얻는다.

지연은 생성 길이가 아니라 프롬프트 prefill이 지배한다. JEV-CPU가 CPU에서 쓸 만한 이유가 바로 이 성질이다. 구체적인 코드는 §5에서 해부한다.

4. 시스템: CPU 포트

SemIf가 GPU를 강제하는 곳은 정확히 한 군데다. 로더가 단일 CUDA 디바이스를 확인하고 device_map={"":"cuda:0"}, bfloat16으로 모델을 올린다. 그 뒤의 코드는 전부 디바이스에 독립적이다. 스코어링 코드는 device = next(model.parameters()).device를 따르고, 유일한 CUDA 전용 호출인 torch.cuda.synchronize()if device.type == "cuda"로 감싸여 있어 CPU에서는 아무 일도 하지 않는다. 따라서 JEV-CPU는 로더만 바꾸고(CPU / float32) SemIf의 원본 스코어링을 그대로 쓴다. 표준 라이브러리 웹 서버가 3분할 UI로 이를 노출한다.

JEV-CPU 3분할 웹 UI
그림 2. 웹 UI. state(좌상단), 기준(좌하단), 옵션별 확률 막대와 선택된 옵션(우측)으로 구성된다.

5. 실증 · 코드로 보는 구현: 오픈웨이트 모델을 결정 엔진으로

이 절이 본 리포트의 실증적 핵심이다. "JEV"/결정 엔진은 파인튜닝도 새 가중치도 아니다. 평범한 오픈웨이트 causal LM을 호출하는 방식을 바꿔, 타입드 결정이 단 한 번의 forward에서 떨어지게 하는 것이다. 아래는 JEV-CPU에서 실제로 도는 전체 파이프라인을 함수 단위로 해부한 것이다. 스코어링 코드는 SemIf의 것을 그대로 실었고, JEV-CPU는 로더만 바꾼다 (§5.9). 스니펫 출처는 src/semif_phase1/{core,direct}.py이며 코드 주석은 원문을 유지한다.

5.1 — 한눈에 보는 데이터 흐름

하나의 결정은 여덟 단계를 거친다. 어느 단계도 텍스트를 생성하지 않는다.

record {state, question, options}
  → validate_row              (5.2  엄격한 스키마 검증)
  → direct_messages           (5.3  옵션 → 레터, 챗 턴 구성)
  → encode_prompt             (5.5  챗 템플릿 → 토큰 ids)
      → _slot_ids             (5.4  각 레터는 깨끗한 1토큰)
      → boundary check        (5.5  답 경계가 안정적인지)
  → _forward                  (5.6  단 한 번의 forward, 마지막 위치 로짓)
  → 슬롯 취합 + softmax        (5.8  옵션별 확률 → arg-max)
  ⇒ 결정, forward 약 1회

5.2 — 레코드와 검증

입력은 하나의 평범한 레코드다. 검증은 엄격해서, 이후 단계에서 조용히 강제 변환되거나 잘리는 일이 없다.

def validate_row(row):
    required = {"id", "state", "question", "options"}
    if not required <= row.keys(): raise ValueError(...)
    json.dumps(state, ensure_ascii=False, allow_nan=False)   # finite JSON
    if not isinstance(options, list) or not 2 <= len(options) <= len(LETTERS): raise ValueError(...)

state는 비어 있지 않은 문자열/JSON 객체/배열이고, 옵션은 2–16개이며 각각 {id, description}, id는 서로 달라야 한다.

5.3 — 레터-초이스 프롬프트 구성

각 옵션에 대문자 레터를 부여하고, 레코드를 두 턴짜리 챗으로 만든다. 시스템 턴이 레터 한 글자 외에는 아무것도 못 내게 하므로, 다음 토큰 하나가 결정 전체를 담는다.

LETTERS = "ABCDEFGHIJKLMNOP"
DIRECT_SYSTEM = ("Apply the supplied criterion to the supplied evidence. "
                 "Choose exactly one listed option. Respond with only its "
                 "uppercase letter, with no explanation or reasoning.")

def direct_messages(row):
    payload = {"evidence": row["state"], "criterion": row["question"],
               "options": [{"letter": LETTERS[i], "description": o["description"]}
                           for i, o in enumerate(row["options"])]}
    return [{"role":"system","content":DIRECT_SYSTEM},
            {"role":"user","content":json.dumps(payload, ensure_ascii=False)}]

5.4 — 각 옵션을 정확히 1토큰으로 고정

판독은 각 답 레터의 확률을 비교하므로, 레터마다 깨끗한 어휘 슬롯 하나가 필요하다. 정확히 1토큰으로 round-trip 되고 서로 충돌하지 않아야 한다.

def _slot_ids(tokenizer, count):
    result = []
    for letter in LETTERS[:count]:
        encoded = tokenizer.encode(letter, add_special_tokens=False)
        if len(encoded) != 1 or tokenizer.decode(encoded) != letter:
            raise ValueError(f"Answer slot {letter!r} is not one exact round-trip token")
        result.append(encoded[0])
    if len(result) != len(set(result)): raise ValueError("Answer-slot tokens collide")
    return result

5.5 — 인코딩과 답 경계 검증

챗 템플릿을 생성 프롬프트를 켜고 thinking을 끈 채 적용한 뒤, 두 불변식을 확인한다. 프롬프트가 토큰 예산에 들고 (자르지 않는다), 답 레터를 붙였을 때 토큰화가 정확히 그 슬롯 하나만 늘어난다(프롬프트/답 경계가 안정적이다).

def encode_prompt(tokenizer, row, max_tokens):
    prompt = tokenizer.apply_chat_template(direct_messages(row), tokenize=False,
        add_generation_prompt=True, enable_thinking=False)
    ids = tokenizer.encode(prompt, add_special_tokens=False)
    if not ids or len(ids) > max_tokens: raise ValueError("exceeds limit; no truncation")
    slots = _slot_ids(tokenizer, len(row["options"]))
    for letter, token in zip(LETTERS, slots):
        if tokenizer.encode(prompt + letter, add_special_tokens=False) != ids + [token]:
            raise ValueError(f"Answer boundary changes tokenization for slot {letter}")
    return ids, slots, digest(prompt)   # sha256, 감사용

5.6 — 1회 forward, 마지막 위치 로짓

모델을 한 번만 돌려 마지막 위치, 즉 다음-토큰 분포만 취한다. logits_to_keep=1은 전체 시퀀스 로짓 텐서 생성을 피한다. 샘플링도 루프도 없다.

def _forward(model, inputs):
    params = inspect.signature(model.forward).parameters
    kwargs = dict(inputs, use_cache=False, return_dict=True)
    if "logits_to_keep" in params: kwargs["logits_to_keep"] = 1
    return model(**kwargs).logits[:, -1, :]     # (batch, vocab); 샘플링 없음

5.7 — 디바이스 처리: CPU에서도 그대로 도는 이유

스코어링 함수는 모델이 올라간 디바이스를 읽고, CUDA일 때만 동기화한다. 이 두 줄이 같은 코드가 GPU에서도 CPU에서도 수정 없이 도는 이유의 전부다.

device = next(model.parameters()).device          # 모델을 따라감
if device.type == "cuda": torch.cuda.synchronize(device)   # CPU에선 no-op

5.8 — 슬롯 취합, softmax, 결정

전체 어휘 로짓 벡터에서 옵션 레터 토큰만 골라 softmax를 취하면, 선언된 집합에 조건부인 옵션별 확률이 나온다. 선택은 그 arg-max다.

def score(model, tokenizer, row, metadata, max_tokens=4096):
    ids, slots, prompt_hash = encode_prompt(tokenizer, row, max_tokens)
    device = next(model.parameters()).device
    inputs = {"input_ids": torch.tensor([ids], device=device),
              "attention_mask": torch.ones((1, len(ids)), device=device)}
    if device.type == "cuda": torch.cuda.synchronize(device)
    with torch.inference_mode():
        vocabulary = _forward(model, inputs)[0].float()        # (vocab,)
    selected = vocabulary[slots].cpu().tolist()   # A, B, C, ... 위치의 로짓
    return {"option_ids":[o["id"] for o in row["options"]],
            "option_logits": selected, "probabilities": softmax(selected), ...}

이것이 "JEV화"의 전부다. 표준 AutoModelForCausalLM에 생성을 시키지 않고, 다음-토큰 로짓을 한 번 물어본 뒤, 옵션 슬롯들의 arg-max를 결정으로 삼는다.

5.9 — CPU 실행을 위한 유일한 변경

SemIf — load_causal_model (GPU)JEV-CPU — load_causal_model_cpu
if not torch.cuda.is_available() \
   or torch.cuda.device_count() != 1:
    raise ValueError("Expose exactly "
        "one CUDA GPU ...")
model = cls.from_pretrained(
    source, config=config,
    dtype=torch.bfloat16,
    device_map={"": "cuda:0"},
    low_cpu_mem_usage=True, **common)
# CUDA 검사·device_map 제거
model = AutoModelForCausalLM.from_pretrained(
    source, config=config,
    dtype=torch.float32,   # CPU 안정
    low_cpu_mem_usage=True, **common)
model.eval()

§5.7–§5.8이 디바이스를 모델에서 가져오므로 CPU에 올리기만 하면 된다. 동일한 스코어링 코드가 CUDA sync만 건너뛴 채 실행된다. 다른 한 가지 노브는 브레인 교체다. 답 레터가 단일 토큰인 causal LM이면 무엇이든 MODEL로 지정할 수 있다.

5.10 — 하나의 state를 여러 기준에 재사용 (shared 모드)

여러 기준이 같은 state를 판단할 때, shared.py는 state를 KV 캐시에 한 번 prefill하고 cache.reorder_cache(...)로 브랜치에 복제한 뒤, 벡터 logits_to_keep로 모든 기준의 답 위치를 한 번의 배치 forward로 평가한다. 프리필은 한 번, 결정은 여럿이다. 이 경로 역시 CUDA에서만 sync하며, 고처리량 서빙을 싸게 만드는 핵심이다(§8.1).

6. 도메인별 실증 결과

CPU에서 Qwen/Qwen3-0.6Bfloat32로 사용해 동일한 엔진을 8개 도메인에서 실행했다 (그림 1). 결정당 약 1초가 걸렸다.

도메인기준결정Forward
고객 지원감성negative — 97.4%~1.1 s
고객 지원팀 라우팅billing — 100%~1.0 s
콘텐츠 모더레이션정책 위반?violation — 99.3%~1.1 s
콘텐츠 모더레이션권장 조치warn — 69.2%~1.0 s
코드리뷰 트리아지머지 리스크high — 99.8%~1.2 s
코드리뷰 트리아지PR 처리block — 94.9%~1.1 s
인시던트 / DevOps심각도sev1 — 100%~1.2 s
인시던트 / DevOps온콜 호출?page_now — 100%~1.1 s
이메일 의도주요 의도sales — 100%~1.2 s
컴플라이언스 게이트변경 티켓 필요?required — 100%~1.1 s
대출 / 신용 리스크신용 리스크high — 100%~1.2 s
대출 / 신용 리스크권장 결정approve — 82.8% ⚠︎~1.1 s
지원 우선순위우선순위p1 — 100%~1.2 s

벤치마크가 아니라 예시적 단일 실행이다. 대출 결정 행은 소형 모델의 오답을 의도적으로 드러낸 예시다(§9 참조).

7. 지연과 입력 길이 한계

두 수치가 자주 혼동된다. 모델의 컨텍스트 창은 40,960 토큰으로, 0.6B치고도 크다. SemIf 엔진은 결정당 기본 4,096 토큰 상한을 둔다(안전장치이며 조정 가능하다). CPU에서는 둘 다 실제 제약이 아니다. 8 GB CPU 머신에서 입력을 키워 가며 측정한 단일 결정은 다음과 같다.

입력 토큰Forward (CPU)최대 RAM
2542.5 s~3.5 GB
7315.6 s~3.5 GB
1,36311.9 s~3.5 GB
2,62926.0 s~3.5 GB
5,16566.1 s~3.5 GB
7,697117.4 s~3.5 GB

7,697 토큰에서도 RAM은 약 3.5 GB로 평평했고 OOM은 없었다. 즉 여기서의 한계는 메모리나 모델이 아니라 prefill 지연(대략 제곱으로 증가)이다.

실용 입력 상한. 이 CPU에서 쓸 수 있는 최대치를 약 7,700 토큰(약 117 초)으로 본다. 이보다 크면 결정 하나가 약 120 초를 넘겨 더 이상 실용적이지 않으므로, 더 큰 입력은 여기서 미지원으로 간주한다. 대화형으로 약 1 초 안에 답하려면 state를 짧게(대략 300 토큰 이하) 유지해야 한다.

8. 모델 크기에 따른 정확도 스케일

Qwen3-0.6B는 SemIf 사다리에서 가장 작은 모델로, 정확도의 바닥에 해당한다. SemIf의 평가 결과는 다음과 같다.

브레인 모델크기저작 balanced acc.TypeSafe subset 일치
Qwen3-0.6B (JEV-CPU 기본)0.6 B0.4400.407
MiniCPM5-2B2 B0.6860.637
Qwen3.5-4B4 B0.8130.845

엔진은 모델에 독립적이므로 상위 모델로 올리는 것은 한 줄 변경(MODEL = …)이면 된다. 대가는 이 머신의 한계를 넘는 RAM과 연산이다. 방법은 어디서든 돌아가고, 정확도는 어떤 모델을 가리키느냐에 따라 달라진다.

8.1 전망 — GPU 서빙과 상용 JEV

정확도(§8)와 CPU 지연(§7)은 흔히 서로 맞바꿔야 하는 관계로 여겨지지만, GPU는 이 둘을 동시에 푼다.

종합하면, GPU에서 서빙되는 더 큰 오픈웨이트 모델(4B 이상)은 동시에 더 정확하고, 훨씬 큰 입력을 받으며, 초당 많은 결정을 처리한다. 그것도 타입드하고 감사 가능한 출력으로, 파싱할 것도 없이 말이다. 이것이 상용(잠재적 으로 상업적) JEV의 모습이다. 저지연·고처리량의 호스팅형 semantic-if 서비스다. JEV-CPU가 바닥 이라면(어느 머신에도 이식할 수 있다), GPU로 서빙되는 더 큰 모델은 같은 엔진을 제품으로 끌어올리는 천장 이다.

범위. 본 리포트에서 GPU 실험을 직접 수행하지는 않았다. 위의 GPU 지연·처리량 수치는 SemIf가 공개한 단일 GPU(RTX 3090, 4B) 측정값과 우리의 CPU 곡선(§7)에서 끌어낸 추정을 합친 것으로, 우리 자신의 결과가 아니라 시사점이다. GPU 서빙 JEV의 정량화(tokens/s, decisions/s, 비용, 동시성)가 자연스러운 다음 연구다.

9. 한계

10. 결론

결정-네이티브 로짓 판독 추론은 GPU나 큰 모델에 묶이지 않는다. §5가 코드로 보였듯, 평범한 오픈웨이트 모델은 로더 변경만으로 결정 엔진이 된다. 그렇게 하면 SemIf 엔진은 0.6B 모델로 일반 CPU에서 돌아가고, 여러 도메인을 결정당 약 1초에 처리하며, 기본 토큰 상한을 훌쩍 넘겨도 메모리가 안정적이다. 이 하드웨어에서 정직한 한계는 지연이다. 약 7,700 토큰을 넘는 입력은 약 120초 선을 넘고, 나아지는 것은 컨텍스트나 메모리가 아니라 소형 모델의 정확도다. JEV-CPU는 재현 가능한 바닥이다. 같은 엔진에 GPU 위의 더 큰 오픈웨이트 모델을 얹으면 천장(§8.1)에 닿는다. 정확하고 처리량이 높고 지연이 낮은 semantic-if 서비스, 곧 상용 JEV다.

11. 재현

python3 -m venv .venv && source .venv/bin/activate
pip install --index-url https://download.pytorch.org/whl/cpu torch
pip install transformers accelerate
python semif_cpu.py      # CLI: 옵션별 확률
python server.py         # 웹 UI: http://localhost:8080

참고문헌

  1. T. Lee (TheoLeeCJ). SemIf — Semantic ifs from open models. github.com/TheoLeeCJ/SemIf. 데모: openjev.com.
  2. TypeSafe. Jev (비공개 서비스). 이름·상표는 각 소유자의 것이며, 본 작업은 독립적이고 무관하다.
  3. Qwen Team. Qwen3-0.6B. huggingface.co/Qwen/Qwen3-0.6B.
  4. A. Liu 외. WANLI. 데이터셋 (SemIf §8 평가에 사용).
  5. MIT 라이선스로 배포한다.