← 文章 / AI技术
Hacker News 2小时前 · 2026-09-26 22:28:09 · 2 阅读

LLM 与视觉模型的 Jev 风格单函数封装器

我对 Jev 以及围绕它出现的可自托管项目很感兴趣,比如 OpenJev 和 SemIf。在读这些资料时,我了解到一个很巧妙的技巧:读取 LLM 的 token 概率。

显然对一些人来说这是老把戏了,比如可以看看 OpenAI 的 logprobs cookbook。但对我来说是新鲜事。

基本思路是写一个这样的 prompt:

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.

然后在兼容的 Chat Completions 请求中加上几个 JSON 参数:

{
  "max_completion_tokens": 1,
  "logprobs": true,
  "top_logprobs": 20
}

LLM API 会返回那个字母,以及模型对各个备选 token 的对数概率。

对每个问题重复这一过程。强制只生成一个 token,既避免了冗长的回答,速度也极快——不过处理输入仍需要时间。如果后端支持,多个问题共享的 state 前缀还可以利用 KV cache。

有意思的是:这对视觉模型同样有效。Jev 的文档中的请求格式目前只描述了文本/JSON 形式的 state,我在本地实验时自己加了一个用于图片的 attachments 字段。

我的示例会捕获摄像头画面,发送 base64 编码的 JPEG,然后打印一张表:画面中是否有人、是室内还是室外、光线亮度如何?在我的 RTX 3090 上跑 Gemma 4 12B,每帧问三个问题,大约能达到 1 FPS。用 OpenAI 的 gpt-6-luna 跑则只有约 0.2 FPS,大概是因为我没做任何优化,每个问题的每帧请求都单独走了一遍他们的连接。

专用视觉模型固然效率更高,但我更欣赏的是这里的灵活性:只需通过纯文本描述即可更改条件。

这是一个独立的 Python 示例(OpenCV 仅用于便捷地访问摄像头,并不涉及实际的视觉处理):

#!/usr/bin/env -S uv run --script
# /// script
# dependencies = ["opencv-python"]
# ///
"""使用 llama.cpp 或 OpenAI 预览并评分摄像头画面。

uv run webcam.py
uv run webcam.py https://api.openai.com/v1 gpt-6-luna
OpenAI 通过环境变量 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 是我们在 Jev 请求格式中的自定义扩展。
data = json.loads("""
{
    "state": "检查此摄像头画面。仅判断画面中可见的内容。",
    "attachments": [],
    "questions": {
        "person": {
            "type": "noul",
            "instructions": "是否有人物可见?"
        },
        "plant": {
            "type": "noul",
            "instructions": "是否有植物可见?"
        },
        "setting": {
            "type": "choice",
            "instructions": "摄像头位于何处?",
            "criteria": {
                "indoors": null,
                "outdoors": null,
                "unclear": null
            }
        },
        "light": {
            "type": "score",
            "instructions": "场景有多亮?",
            "criteria": [
                "dark",
                "dim",
                "bright"
            ]
        }
    }
}
""")

def score(data, url, model):
    state = data["state"]
    if not isinstance(state, str):
        state = json.dumps(state)

    # attachments 是我们对 Jev 风格请求格式的扩展:
    # 图像文件路径或 base64 数据 URL。为所有问题一次性加载。
    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"不支持的图像文件:{path}")
        encoded = base64.b64encode(path.read_bytes()).decode()
        images.append(f"data:{mime_type};base64,{encoded}")

    # API 密钥仅发送给 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():
        # 将选项、布尔值和有序等级表示为字母选项。
        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"未知的问题类型:{question['type']}")
        if not 2 <= len(options) <= 20:
            raise ValueError("每个问题需提供 2 到 20 个标准项。")
        letters = "ABCDEFGHIJKLMNOPQRST"[:len(options)]

        # 请求单个选项字母,使其 logprob 代表该选项。
        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\n仅回答最佳选项的字母。"

        # OpenAI 需要 Responses 接口以获取足够的备选答案;llama.cpp 需要 Chat 接口以获取 logprobs。
        # top_p=1 避免剪枝备选答案。
        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,
            }

        # 发送请求并读取第一个输出 token 的备选答案。
        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}

        # 归一化返回的选项分数;缺失的选项初始值为零。
        missing = [letter for letter in letters if letter not in logprobs or logprobs[letter] <= -9999]
        if len(missing) == len(letters):
            raise ValueError("API 未返回任何选项的有效分数")
        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)

        # 被省略的 token 不能超过最后返回的备选答案的排名。
        # 仅当其归一化概率之和低于 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 省略了非可忽略的选项分数:{', '.join(missing)}")
        probabilities = {key: weight / total for key, weight in zip(options, weights)}

        # 返回获胜选项、true 的概率,或期望的有序等级。
        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:
            answers[name] = {
                "type": "score",
                "score": sum(int(key) * probability for key, probability in probabilities.items()),
                "legend": options,
                "probabilities": probabilities,
            }

    return {"answers": answers}

# 在打开摄像头前选择服务器和模型。
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("url", nargs="?", default="http://localhost:8060/v1")
parser.add_argument("model", nargs="?", default="gemma-4-12b")
args = parser.parse_args()

# 将 OpenCV 自带的 Qt 指向已安装的系统字体。
os.environ["QT_QPA_FONTDIR"] = "/usr/share/fonts/truetype/noto"

# 使用较小的捕获缓冲区打开默认 Linux 摄像头。
camera = cv2.VideoCapture(0, cv2.CAP_V4L2)
if not camera.isOpened():
    raise RuntimeError("无法打开 /dev/video0")
camera.set(cv2.CAP_PROP_BUFFERSIZE, 1)
print(f"Webcam -> {args.model}. Noul: 是 %; score: 值/最大值. 按 Ctrl-C 或 Esc 停止。", flush=True)
print(f"{'time':<8}" + "".join(f"{name:>10}" for name in data["questions"]) + f"{'fps':>10}", flush=True)

# 持续预览,同时后台工作线程一次评分一帧画面。
executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)
pending = None
try:
    while True:
        ok, frame = camera.read()
        if not ok:
            raise RuntimeError("无法读取摄像头画面")
        cv2.imshow("Webcam", frame)
        if cv2.waitKey(1) == 27 or cv2.getWindowProperty("Webcam", cv2.WND_PROP_VISIBLE) < 1:
            break

        # 打印已完成的结果,然后提交最新帧。
        if pending is not None:
            if not pending.done():
                continue
            result = pending.result()
            columns = []
            for name in data["questions"]:
                answer = result["answers"][name]
                if answer["type"] == "noul":
                    value = f"{answer['noul']:.1%}"
                elif answer["type"] == "choice":
                    value = answer["choice"]
                else:
                    value = f"{answer['score']:.2f}/{len(data['questions'][name]['criteria']) - 1}"
                columns.append(f"{value:>10}")
            columns.append(f"{1 / (time.perf_counter() - started):>10.2f}")
            print(captured + "".join(columns), flush=True)
        # 测量已评估帧的吞吐量,包括图像编码。
        started = time.perf_counter()
        captured = datetime.datetime.now().strftime("%H:%M:%S")
        ok, jpeg = cv2.imencode(".jpg", frame)
        if not ok:
            raise RuntimeError("无法编码摄像头画面")
        image = "data:image/jpeg;base64," + base64.b64encode(jpeg.tobytes()).decode()
        data["attachments"] = [image]
        pending = executor.submit(score, data, args.url, args.model)
except KeyboardInterrupt:
    print("\n已停止。")
finally:
    camera.release()
    cv2.destroyAllWindows()
    executor.shutdown()

这个脚本处理了 API 上的差异:llama.cpp 用的是 Chat Completions 接口,而 OpenAI 用的是 Responses 接口,这样才能让它展示候选结果。

我用 llama.cpp 跑了 Gemma 4 12B QAT。在安装了 NVIDIA 驱动、curl、zstd 和 uv 的 Linux 上:

# Model (~7 GB) and multimodal projector (~175 MB).
mkdir -p ~/models/gemma-4-12b/
cd ~/models/gemma-4-12b/
curl -fL -C - -o gemma-4-12b-it-qat-q4_0.gguf https://huggingface.co/google/gemma-4-12B-it-qat-q4_0-gguf/resolve/main/gemma-4-12b-it-qat-q4_0.gguf
curl -fL -C - -o mmproj-gemma-4-12b-it-qat-q4_0.gguf https://huggingface.co/google/gemma-4-12B-it-qat-q4_0-gguf/resolve/main/mmproj-gemma-4-12b-it-qat-q4_0.gguf

# Standalone llama.cpp binary for RTX 3090 (CUDA architecture 86).
curl -fL -o llama.zst https://huggingface.co/buckets/ggml-org/install.sh/resolve/b11160/x86_64/linux/cuda/86/llama-app.zst
mkdir -p ~/bin/
zstd -d llama.zst -o ~/bin/llama
chmod +x ~/bin/llama
~/bin/llama serve --models-dir ~/models/ --port 8060

把 Python 示例保存为 webcam.py。然后另开一个终端,在该目录下运行:

uv run webcam.py http://localhost:8060/v1 gemma-4-12b
# Or use OpenAI, with OPENAI_API_KEY set in your environment.
uv run webcam.py https://api.openai.com/v1 gpt-6-luna
原始来源: Hacker News

评论 (0)