用下一个 token 的分布做判断,而不是让模型生成
把一个「聊天/角色扮演/推理」模型改造成判断器:读候选标签上的概率分布, argmax 得答案。适用于分类、路由、多选、A/B 决策、打分排序。
核心原则
要控制模型「下一个 token 是什么」,就必须把预填(prefill)放在答案之前。 预填答案本身 → 你读到的是答案之后的 token。
指令层(system 措辞、few-shot 示例)对对话模型经常完全无效—— 它的训练分布里没有「分类器」。管用的是结构层:用 assistant 预填把采样点 物理地挪到答案位置上。
Step 0 — 先写诊断日志,再写功能(不可跳过)
这是本工作流里唯一不能妥协的一步。模型不配合时,没有日志 = 盲猜。
失败分支必须打出模型实际输出的原始分布:
log(f"[{VER}] 标签全未命中。选项={choices} "
f"top-N={[(t, round(v, 2)) for t, v in items[:12]]}")
配套三件事:
| 做法 | 原因 |
|---|---|
| 日志带版本标记(VER = "job-2026-10-03f-prefix") | 线上无法确认部署的是哪一版时,排查全部停摆 |
| 错误响应里捞出服务端原话 | 裸的 HTTP 400 定位不到是端点用错还是参数不支持 |
| 记下服务端日志里的 prompt token 总数 | 改动有没有进到 prompt 里,不用等结果就能验证 |
只有需要远程/部署环境才能复现的项目,这条尤其重要:本地跑不了就意味着 每一轮实验都是一次完整部署,猜错的代价是成倍的。
Step 1 — 选端点
llama.cpp 有三套形状相近、参数互不通用的端点:
| 端点 | 认什么 | 传 messages |
|---|---|---|
| /v1/completions | 只认 prompt | 400 |
| /completion | 原生;较新 build 强制要求 prompt 键 | 400 |
| /v1/chat/completions | 认 messages 且支持 logprobs | ✓ |
要 messages 又要分布,只有第三个。
logprobs 用 OAI 形状,不是 llama 原生形状:
{"logprobs": True, "top_logprobs": 20} # 不是 n_probs
分布位置:
choices[0].logprobs.content[-1].top_logprobs
# ^^^^^^^ 数组,每项一个 token 位置
# ^^^^^^^ 取最后一项
# 形状 [{token, logprob}, …](列表,不是字典)
/v1/* 返回 pre-sampling logprob(softmax(logits)),正是要的。
不要开 post_sampling_probs,会把分布毁掉。top_logprobs 上限 20。
判据:400 且服务端日志里没有对应的 task → 请求根本没到模型。 先怀疑端点/参数形状,不要怀疑 prompt 内容。
细节见 references/llama-cpp-endpoints.md。
Step 2 — 构造预填(关键决策)
llama-server 的 --prefill-assistant 默认开启:末条为 assistant 时,
模板把它渲染成没有收尾标记的开放轮次,模型从预填内容后面继续写。
_ANSWER_PREFIX = "答案:" # 预填到答案「之前」的中性引导词
msgs = [{"role": "system", "content": _SYS}]
for st, q, ch in _SHOTS: # few-shot 可选,对对话模型常无效
msgs.append({"role": "user", "content": _body(st, q, ch)})
msgs.append({"role": "assistant", "content": ch[0]})
msgs.append({"role": "user", "content": _body(state, question, choices)})
msgs.append({"role": "assistant", "content": _ANSWER_PREFIX}) # ★
配套请求参数:
"max_tokens": 1, # 分布就在这一个 token 上
"temperature": 0.0,
"chat_template_kwargs": {"enable_thinking": False}, # 推理模型必须显式关
"add_generation_prompt": False, # 末条已预填,不能再补
"continue_final_message": True, # 显式续写,不靠服务端启发式
四种末尾预填的语义(这张表是本技能的核心):
| 末尾预填 | 模型所处状态 | 下一个 token 落在 |
|---|---|---|
| 不预填 + add_generation_prompt: true | 正常轮次开始 | 自由发挥:「我们」「首先」 |
| 预填答案本身 | 答案已写完 | 答案之后 → EOS |
| 预填空串 | 开放的空轮次,无约束 | EOS / 模板结构标记 |
| 预填中性引导词 | 正在填答案 | 答案本身 ✓ |
规则:
add_generation_prompt与continue_final_message互斥(同时 true 会 400)。- 显式带
continue_final_message: True,别只依赖服务端启发式——否则将来有人加了--no-prefill-assistant,请求会静默退化成模型自由发挥而不是报错。 老 build 不认识该字段会忽略,不会出错。 - 预填内容必须不含任何候选标签,否则又变成「预填答案」。
- 若模型是推理模型,
<think>会抢走第一个 token 的预算,必须显式关思维链。
Step 3 — 匹配标签(次词切分下)
只读一个 token 位置,所以整词标签往往压根不在 top_logprobs 里。
必须做两级匹配,并把命中方式如实返回给用户:
def _match(label, top):
want = label.strip()
for tok, lp in top: # 1) 整词
if tok.strip() == want:
return lp, "整词"
hits = [(lp, t) for t, lp in top # 2) 首字
if t.strip()[:1] == want[0]]
if len(hits) == 1:
return hits[0][0], "首字"
if len(hits) > 1:
hits.sort(key=lambda x: -x[0])
return hits[0][0], "首字(有歧义)" # 同首字,分不清
return None, ""
由此产生的限制必须写进 UI,不能藏着:
- 两个选项首字相同 → 分不清(会标
首字(有歧义))。 - 只在第一个 token 位置判断 → 选项若只在第 2 字及之后不同,首字匹配无区分度。 建议选项首字互不相同。
- 没进 top-N 的选项给极低概率(如
exp(-12))并在 note 里说明, 不能让它悄悄影响置信度。
英文/带空格标签用整词级即可(llama.cpp 会给 ' 支持',比较前统一 strip)。
Step 4 — 置信度
conf = 1 − H(p) / ln K
K = 参与归一化的候选数(先归一化再算熵);K <= 1 直接给 1.0。
设一个 LOW_CONF(如 0.35),低于它就显示「置信度偏低,建议人工确认」。
用熵而不是 top-1 概率:三选项均匀分布时 top-1 只有 0.33, 但熵能明确表达「模型没主意」。
Step 5 — 读日志,机械分类故障
拿到 top-N 后按 top-1 的形态归类,不要凭感觉猜:
| top-1 是什么 | 结论 | 动哪里 |
|---|---|---|
| 我们 首先 嗯 好的 Okay | 模型在接话,没进选择模式 | 采样点位置(Step 2) |
| ''(空串)且 logprob ≈ 0 | EOS,模型想收尾 | 预填了什么 |
| <think> | 思维链在抢概率 | enable_thinking |
| 紫 银 王(标签碎片) | 成了,只是被切成多 token | 匹配层(Step 3) |
| 全是英文/符号 | prompt 语言或模板不对 | system / 模板 |
完整案例库见 references/failure-log-atlas.md。
铁律
- 看不到模型实际输出了什么,就是在盲猜。 任何「模型没按我期望做」的故障, 日志里必须能看到它实际做了什么。
- 连续两轮 prompt 调整无效,就转向结构层。 把 few-shot 从 2 个加到 4 个 几乎没用;改采样点位置才有用。
- 去读服务端的 flag 文档。 一句
--prefill-assistant (default: prefill enabled)能一次性解释三个版本的全部现象,比推理快得多。 - logprob ≈ 0 的空 token 先当 EOS 解释,不是「模板坏了」。
特殊 token 在不开
--special时显示为空串。 - 400 且服务端无 task = 请求没到模型。 查端点和参数形状,别改 prompt。
- 不要把「无依据」显示成「有依据」。 判断结果的依据状态要显式记录
(
kb_used/kb_sources),裸判必须警告。 - 兜底检索会制造虚假依据。 检索不到就空着,别拿无关资料凑数。
参考
references/llama-cpp-endpoints.md— 端点/参数形状、prefill 与模板的交互、 思维链相关的已知坑references/failure-log-atlas.md— 六个真实版本的原始 top-N 与归因
微信扫一扫