Jev와 OpenJev, SemIf 같은 셀프호스팅 프로젝트에서 착안해, LLM의 토큰 확률(logprobs)을 읽는 기법으로 질문에 답하는 단일 함수 래퍼를 소개합니다. max_completion_tokens를 1로 제한해 짧고 빠르게 답을 얻으며, 이미지 첨부(attachments) 필드를 직접 확장해 웹캠 프레임을 비전 모델(Gemma 4 12B, gpt-6-luna)로 분석하는 실험도 포함됩니다.
번역된 본문
본문 간략 번역: 필자는 Jev와 이를 둘러싼 셀프호스팅 프로젝트(OpenJev, SemIf)에 흥미를 가지게 되었고, 그 과정에서 LLM의 토큰 확률(logprobs)을 읽는 깔끔한 트릭을 알게 되었다. 핵심 아이디어는 다음과 같은 프롬프트를 작성하는 것이다:
상태: 주문한 상품이 파손되어 도착해 환불을 원한다.
질문: 어느 팀이 처리해야 하는가?
[A] 결제 [B] 배송 [C] 반품
가장 적합한 옵션의 문자만으로 답하라.
그러면 API가 문자 하나와 함께 대체 토큰들에 대한 로그 확률을 반환한다. 질문마다 이를 반복한다. 토큰을 하나만 생성하게 강제하면 긴 답변이 나오지 않아 매우 빠르지만, 입력 처리에는 여전히 시간이 든다. 백엔드가 지원한다면 공유 상태 접두사를 KV 캐싱할 수 있다.
재미있는 부분은 이 방식이 비전 모델에서도 작동한다는 점이다. Jev의 공식 요청 형식은 현재 텍스트/JSON 상태만 다루므로, 필자는 로컬 실험용으로 이미지를 위한 attachments 필드를 추가했다. 예제는 웹캠 프레임을 캡처해 base64 JPEG를 전송하고, '사람이 보이는지', '실내인지 실외인지', '장면이 얼마나 밝은지'를 표로 출력한다. RTX 3090에서 Gemma 4 12B로 약 초당 1프레임(프레임당 질문 3개)을 얻었고, OpenAI gpt-6-luna로는 약 0.2 FPS였다. 이는 프레임당 질문마다 별도 연결 비용을 회피하려 노력하지 않았기 때문으로 보인다.
전용 컴퓨터 비전 모델이 분명 훨씬 효율적이겠지만, 이 방식의 장점은 유연성이다. 조건을 바꾸고 싶으면 일반 텍스트로 서술하기만 하면 된다. (첨부된 Python 예제 코드는 llama.cpp 또는 OpenAI로 웹캠 프레임을 미리보고 점수화하며, OpenCV는 단지 웹캠 접근 편의를 위해 사용되고 실제 컴퓨터 비전 처리에는 사용되지 않는다.)
skip to main | skip to sidebar Allan's Blog Allan Riordan Boll's blog Friday, September 25, 2026 A Jev-like wrapper for LLMs, including vision models I was intrigued by Jev and the self-hostable projects appearing around it, such as OpenJev and SemIf . Reading about them introduced me to a neat trick: reading an LLM's token probabilities. Apparently this is an old trick for some people. See e.g. OpenAI's logprobs cookbook . But it was new to me. I believe the basic idea is to write a prompt like this: State: My order arrived broken and I want a refund. Question: Which team should handle this? [A] billing [B] shipping [C] returns Answer with the letter of the best option only. Then add a few JSON request parameters to a compatible Chat Completions request: { "max_completion_tokens": 1, "logprobs": true, "top_logprobs": 20 } The LLM API will return the letter plus the model's log probabilities for alternative tokens. Repeat for each question. Forcing it to generating only one token avoids a lengthy answer and is super quick, though processing the input still costs time. Though for each of the questions a shared state prefix can be KV-cached if the backend supports it. The fun part: this works with vision models too. Jev's documented request format currently describes only text/JSON state. I added an attachments field for images for my local experiments. My example captures webcam frames, sends base64 JPEGs, and prints a table: is a person visible, are we indoors or outdoors, and how bright is the scene? With Gemma 4 12B on my RTX 3090, I get around 1 frames per second , with three questions per frame. I also ran it against OpenAI gpt-6-luna and got around 0.2 FPS. Presumably because I didn't make any effort to avoid the cost of a separate connection through their system per question per frame. Specialized computer vision models surely are much more efficient, but what I like here is the flexibility: change a condition by describing it in plain text. Here's the standalone Python example (OpenCV is just used for convenient access to the webcam, not for any actual computer vision): #!/usr/bin/env -S uv run --script # /// script # dependencies = ["opencv-python"] # /// """Preview and score webcam frames with llama.cpp or OpenAI. uv run webcam.py uv run webcam.py https://api.openai.com/v1 gpt-6-luna OpenAI reads OPENAI_API_KEY. """ import argparse import base64 import concurrent.futures import datetime import json import math import mimetypes import os import pathlib import time import urllib.parse import urllib.request import cv2 # attachments is our custom addition to the Jev request format. data = json.loads(""" { "state": "Inspect this webcam frame. Judge only what is visibly present.", "attachments": [], "questions": { "person": { "type": "noul", "instructions": "Is a person visible?" }, "plant": { "type": "noul", "instructions": "Is a plant visible?" }, "setting": { "type": "choice", "instructions": "Where is the camera?", "criteria": { "indoors": null, "outdoors": null, "unclear": null } }, "light": { "type": "score", "instructions": "How bright is the scene?", "criteria": [ "dark", "dim", "bright" ] } } } """) def score(data, url, model): state = data["state"] if not isinstance(state, str): state = json.dumps(state) # Attachments are our extension to the Jev-style request format: # image file paths or base64 data URLs. Load them once for all questions. images = [] for attachment in data.get("attachments", []): if attachment.startswith("data:image/"): images.append(attachment) continue path = pathlib.Path(attachment).expanduser() mime_type, _ = mimetypes.guess_type(path) if mime_type not in {"image/png", "image/jpeg", "image/webp", "image/gif"}: raise ValueError(f"Unsupported image file: {path}") encoded = base64.b64encode(path.read_bytes()).decode() images.append(f"data:{mime_type};base64,{encoded}") # Send the API key only to OpenAI. is_openai = urllib.parse.urlsplit(url).hostname == "api.openai.com" headers = {"Content-Type": "application/json"} if is_openai: headers["Authorization"] = "Bearer " + os.environ["OPENAI_API_KEY"] answers = {} for name, question in data["questions"].items(): # Represent choices, booleans, and ordinal levels as lettered options. if question["type"] == "choice": options = question["criteria"] elif question["type"] == "noul": options = {"true": None, "false": None} | question.get("criteria", {}) elif question["type"] == "score": options = {str(i): description for i, description in enumerate(question["criteria"])} else: raise ValueError(f"Unknown question type: {question['type']}") if not 2 <= len(options) <= 20: raise ValueError("Provide 2 to 20 criteria per question.") letters = "ABCDEFGHIJKLMNOPQRST"[:len(options)] # Ask for a single option letter, so its logprob represents that option. instructions = question["instructions"] if not isinstance(instructions, str): instructions = json.dumps(instructions) lines = [f"State:\n{state}\n\nQuestion: {instructions}\nOptions:"] for letter, (key, description) in zip(letters, options.items()): line = f"[{letter}] {key}" if description is not None: line += f": {description}" lines.append(line) prompt = "\n".join(lines) + "\n\nAnswer with the letter of the best option only." # OpenAI needs Responses for enough alternatives; llama.cpp needs Chat for logprobs. # top_p=1 avoids pruning alternatives. if is_openai: endpoint = "/responses" content = [{"type": "input_text", "text": prompt}] content.extend({"type": "input_image", "image_url": image} for image in images) body = { "model": model, "input": [{"role": "user", "content": content}], "reasoning": {"effort": "none"}, "max_output_tokens": 16, "top_p": 1, "top_logprobs": 20, "include": ["message.output_text.logprobs"], } else: endpoint = "/chat/completions" content = [{"type": "text", "text": prompt}] content.extend({"type": "image_url", "image_url": {"url": image}} for image in images) body = { "model": model, "messages": [{"role": "user", "content": content}], "max_completion_tokens": 1, "temperature": 0, "reasoning_effort": "none", "logprobs": True, "top_logprobs": 1024, } # Send the request and read the first output token's alternatives. request = urllib.request.Request( url.rstrip("/") + endpoint, headers=headers, data=json.dumps(body).encode(), ) with urllib.request.urlopen(request) as response: result = json.load(response) if is_openai: message = next(item for item in result["output"] if item["type"] == "message") candidates = message["content"][0]["logprobs"][0]["top_logprobs"] else: candidates = result["choices"][0]["logprobs"]["content"][0]["top_logprobs"] logprobs = {item["token"]: item["logprob"] for item in candidates} # Normalize the returned option scores; missing options initially get zero. missing = [letter for letter in letters if letter not in logprobs or logprobs[letter] <= -9999] if len(missing) == len(letters): raise ValueError("API did not return usable scores for any option") peak = max(logprobs[letter] for letter in letters if letter not in missing) weights = [math.exp(logprobs[letter] - peak) if letter not in missing else 0 for letter in letters] total = sum(weights) # An omitted token cannot outrank the last returned alternative. # Allow zero only when their combined normalized probability is below 1e-6. if missing: cutoff = min(value for value in logprobs.values() if value > -9999) missing_weight = len(missing) * math.exp(cutoff - peak) if missing_weight / (total + missing_weight) >= 1e-6: raise ValueError(f"API omitted non-negligible option scores for: {', '.join(missing)}") probabilities = {key: weight / total for key, weight in zip(options, weights)} # Return the winning choice, probability of true, or expected ordinal level. if question["type"] == "choice": answers[name] = { "type": "choice", "choice": max(probabilities, key=probabilities.get), "probabilities": probabilities, } elif question["type"] == "noul": answers[name] = {"type": "noul", "noul": probabilities["true"]} else