logprob、finish reason 与 output processing#
执行层不只负责“选出下一个 token”,还必须把这一轮选出来的结果重新组织成调用方和上层 manager 能理解的输出。这一节处理的正是执行尾部:logprob、finish reason 和 output processing 怎样被统一归档回结果对象。
这一节解决什么问题#
这一节主要回答三件事:
- 执行层输出为什么不只是一个 token id;
- finish reason 在什么时候真正稳定下来;
- logprob、输出文本和各种附带信息是怎样被统一折叠回结果对象的。
一张图先看执行尾部#
flowchart LR
A["model forward"] --> B["token id / logits"]
B --> C["finish reason / logprob / extras"]
C --> D["BatchTokenIDOutput / BatchStrOutput"]
D --> E["serving / final response"]这张图最重要的一点是:执行层的尾部并不只产出 token,而是产出一整组随后还要继续回流到返回链上的结果语义。
为什么执行层的“结果”不只是一个 token#
对运行时来说,一轮执行至少可能产出这些信息:
- 新 token id
- logprob / top logprobs
- finish reason
- hidden states
- routed experts
也就是说,执行层不是只在做“生成文本”,而是在生产一组后续还会被不同路径消费的结果元数据。
这也是为什么第三章里 BatchTokenIDOutput 和 BatchStrOutput 会显得那么重:它们承接的并不是单一文本,而是执行尾部的一整组结果语义。
换句话说,执行尾部和返回链不是前后两章碰巧相邻,而是天然接在一起的。
finish reason 为什么不能只当作字符串#
finish reason 很容易被误解成一个最后才填的展示字段。但从全书前面已经见过的路径可以看出,它实际承担的是运行时状态收束语义:
- 是正常 stop 还是 length;
- 是 tool call 收束还是 abort;
- 是 waiting timeout 还是 running timeout;
- 是否还要继续回包或直接转成错误。
所以 finish reason 不是“结果出来以后再补一列说明”,而是执行链和返回链共同依赖的一条状态标签。
第三章里之所以能把 waiting timeout、running timeout、tool call stop 和普通 stop 区分开,靠的正是 finish reason 在这里已经被稳定地写进结果对象了。
logprob 为什么总会把执行层拖回输出层#
只要请求要求 logprob,执行层就不能只把 token 选完就结束。因为:
- input token 的 logprob
- output token 的 logprob
- top logprobs
- token_ids_logprob
这些信息都要继续沿结果链向上游回传,再由 tokenizer manager 和 serving 层重新组织。这也是为什么 logprob 看起来像采样特性,但在工程上更像执行尾部和返回层之间的桥。
如果只看工程结构,这一层很像“执行尾部附带信息管线”:前向算出来的不只是 token,后面所有想把结果变成可观测、可解释、可回包对象的路径,都要继续消费它。
output processing 真正做的是什么#
如果把执行尾部的职责压成一句话,就是:
- 前向层决定这一轮算出了什么;
- output processing 决定这些结果怎样变成可回传对象。
也就是说,执行层的最后一步并不是“直接把 token 写回文本”,而是把 token、finish reason、logprob 和其他附带信息整理成后面 detokenizer / tokenizer manager / serving 层都能继续消费的输出格式。
所以这一节不该被看成“第三章回包链的附属说明”,而应该被看成“执行层怎样把自己的结果做成后续层可消费对象”的一层收口。
从结果对象的定义看,这一点并不抽象。至少在返回链上,结果很快就会被组织成下面这种形状:
class BatchStrOutput(BaseBatchReq, SpeculativeDecodingMetricsMixin):
finished_reasons: List[dict]
output_strs: List[str]
output_ids: Optional[List[int]]
prompt_tokens: List[int]
completion_tokens: List[int]这段代码说明,执行尾部真正要交出去的已经不是“一个 token”,而是一组之后还要被继续消费的结果字段。
调试执行尾部时先看哪里#
如果你看到的现象是:
- token 生成看起来正常,但 finish reason 不对;
- logprob 缺失或格式异常;
- 文本回包正常,但附带信息不完整;
更稳的顺序通常是:
- 先确认执行层是否已经产出对应字段;
- 再确认这些字段有没有沿输出对象继续往上走;
- 最后再看 serving 层是不是只在最终响应阶段格式化错了。
这样做的好处是,你能把“执行没产出”和“执行产出了但后面没带上去”分开看。
finish_reason 的完整判断逻辑#
finish_reason 的最终值由执行层在每一步采样结束后确定。触发条件按优先级从高到低排列如下:
”abort”:请求被外部中止——包括客户端断开连接、请求超时,或者上层显式调用了 cancel。”length”:已生成的 token 数量达到了请求参数中的max_new_tokens上限,即num_output_tokens >= max_new_tokens。”stop”:模型生成了 EOS token(如 Llama 系列的<|eot_id|>),或者生成的输出命中了用户传入的某个 stop string。”tool_calls”:当工具调用功能开启时,FunctionCallParser检测到了一个完整的工具调用结构。
用伪代码写出来就是这个形状:
# 简化后的 finish_reason 判断逻辑(参考 sglang/srt/managers/schedule_batch.py)
if req.is_aborted:
finish_reason = “abort”
elif req.output_len >= req.sampling_params.max_new_tokens:
finish_reason = “length”
elif last_token_id == tokenizer.eos_token_id or matches_stop_string(output):
finish_reason = “stop”
elif tool_call_parser.is_complete():
finish_reason = “tool_calls”检查顺序为什么重要
这个顺序不是随意的,每一级都有具体理由:
abort必须排在最前面。如果一个请求已经被中止,继续判断它”是不是因为 length 结束”毫无意义,而且可能掩盖真实原因,让调用方以为是正常完成。length必须早于stop。一个请求完全可能在恰好触达max_new_tokens的同一步里生成了 EOS token——这时两个条件同时成立。返回”length”能告诉调用方”输出被截断了”,而返回”stop”会让调用方以为输出是完整的。stop里 EOS token 的判断早于 stop string 的判断,因为 EOS 是模型侧的完成信号,而 stop string 是用户侧的约束,二者语义不同,但最终都归入”stop”。tool_calls排在最后,因为它依赖 parser 累积多个 token 才能确认完整性,属于后置检测。
logprob 的数值形态#
logprob 是 log-probability,即对数概率,定义为 log(p),其中 p 是模型在当前上下文下对该 token 的概率估计。由于概率值在 (0, 1] 之间,对数之后始终是负数或 0——0 表示概率为 1.0,即模型完全确定。
几个有代表性的数值区间:
| logprob 值 | 对应概率 | 直觉含义 |
|---|---|---|
| -0.05 | ≈ 95% | 模型非常确定这个 token |
| -0.35 | ≈ 70% | 较有把握,但存在竞争候选 |
| -1.20 | ≈ 30% | 模型犹豫,有多个可能选择 |
| -2.30 | ≈ 10% | 低置信度,该 token 是少数派选择 |
| -6.91 | ≈ 0.1% | 极罕见,通常是异常或边缘 case |
当请求中设置 logprobs=true, top_logprobs=2 时,API 响应的 logprob 部分看起来是这样的:
{
“choices”: [{
“logprobs”: {
“tokens”: [“The”, “ cat”, “ sat”],
“token_logprobs”: [-0.12, -1.87, -0.34],
“top_logprobs”: [
{“The”: -0.12, “A”: -2.89},
{“ cat”: -1.87, “ dog”: -2.10},
{“ sat”: -0.34, “ lay”: -1.23}
]
}
}]
}在这个例子里,” cat” 的 logprob 是 -1.87(概率约 15%),而 ” dog” 以 -2.10 紧随其后(概率约 12%)。这说明模型在这个位置并不确定,两个候选词的概率相差不大。
input logprob 和 output logprob 来自哪里
- input token 的 logprob 来自 prefill forward pass。模型在处理 prompt 时,每个位置都会输出一个对下一个 token 的概率分布,从中就能读出 prompt 里每个 token 实际被选中的对数概率。
- output token 的 logprob 来自每一步 decode forward pass。模型输出 logits,采样选出 token_id,同时从同一个 logits 向量里读取该 token_id 对应的对数概率。
output 从 forward() 到响应对象的完整路径#
下面逐层追踪一个 token 从 ModelRunner.forward() 出发,最终抵达 HTTP 响应体的完整路径。
第一步:ModelRunner.forward() 输出 logits
ModelRunner.forward() 在 TP worker 上执行,返回值是一个形状为 [batch_size, vocab_size] 的 logits 张量。对于 Llama-3-8B(词表大小 128256),这意味着每个序列位置都有 128256 个未归一化的得分。
第二步:采样层选出 token_id,同时计算 logprob
采样逻辑(sample_token_ids)接管 logits,执行 temperature scaling、top-p/top-k 截断,然后从最终分布里抽样,得到 token_ids,形状 [batch_size]。如果请求开启了 logprob,同一次 softmax 的结果会被复用,直接读出被选中 token 的对数概率,以及 top-k 候选的 token_id 和 logprob。
第三步:打包成 BatchTokenIDOutput
采样结果被打包进 BatchTokenIDOutput:
@dataclass
class BatchTokenIDOutput:
req_to_token_indices: torch.Tensor # 每个请求对应的 token 位置
output_ids: torch.Tensor # [batch_size],选出的 token id
logprobs: Optional[torch.Tensor] # [batch_size],当前 token 的 log prob
top_logprobs: Optional[List] # top-k 候选及其 log prob
finished_reasons: List[Optional[BaseFinishReason]] # 每个请求的结束原因finished_reasons 里存的是 BaseFinishReason 的子类实例(EOS、LengthExceeded、Abort 等),而不是原始字符串,这样调用方可以做类型判断。
第四步:通过 IPC 发送到 DetokenizerManager
BatchTokenIDOutput 经由 ZeroMQ 管道(send_to_detokenizer)从 TP worker 侧发往 DetokenizerManager 进程。这一步跨进程边界,数据被序列化为字节流。
第五步:DetokenizerManager 解码文本
DetokenizerManager 接收到 BatchTokenIDOutput 后,对每个请求调用 tokenizer.decode(),将 token_id 序列转换成文本片段(delta)。streaming 模式下,delta 是本轮新增的那几个字符;非 streaming 模式下,所有 token 会累积后一起 decode 以避免边界断词问题。
第六步:文本 delta 回流到 TokenizerManager
decode 之后的文本 delta(连同 finish_reason 和 logprob)被打包进 BatchStrOutput,通过另一条 ZeroMQ 管道发回 TokenizerManager。
第七步:TokenizerManager 推送给 HTTP 客户端
TokenizerManager 收到 BatchStrOutput 后:
- streaming 模式:立即把 delta 封装成 SSE(Server-Sent Events)帧推送给客户端,每一帧对应一次
data: {...}事件,其中包含delta.content、当前的logprobs(如有)和最终步的finish_reason。 - 非 streaming 模式:累积所有 delta,等到
finish_reason不为None时,一次性组装完整的ChatCompletionResponse返回。
整条路径可以概括为:
ModelRunner.forward()
→ logits [batch, vocab]
→ sampling → token_ids [batch] + logprobs
→ BatchTokenIDOutput (IPC)
→ DetokenizerManager.decode()
→ BatchStrOutput (IPC)
→ TokenizerManager
→ HTTP SSE / JSON response每一段边界处都有类型转换:logits 张量 → token_id 张量 → Python 对象 → IPC 字节流 → 重建为 Python 对象 → JSON 字符串。调试输出异常时,确认问题发生在哪段边界往往比在单层里打 log 更有效。
小结#
这一节真正要建立的是一个判断:
- 执行层的结果从来都不只是一个 token;
- finish reason 是运行时状态收束的一部分;
- logprob 和其他附带字段会把执行尾部和返回链继续绑在一起。
理解了这一层,第六章就不只是”模型怎么前向”,而是”模型前向之后,结果怎样真正长成 runtime 可以继续流动的对象”。
叶王 © 2013-2026 版权所有。如果本文档对你有所帮助,可以请作者喝饮料。