logprob、finish reason 与 output processing#

执行层不只负责“选出下一个 token”,还必须把这一轮选出来的结果重新组织成调用方和上层 manager 能理解的输出。这一节处理的正是执行尾部:logprob、finish reason 和 output processing 怎样被统一归档回结果对象。

这一节解决什么问题#

这一节主要回答三件事:

  1. 执行层输出为什么不只是一个 token id;
  2. finish reason 在什么时候真正稳定下来;
  3. 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

也就是说,执行层不是只在做“生成文本”,而是在生产一组后续还会被不同路径消费的结果元数据。

这也是为什么第三章里 BatchTokenIDOutputBatchStrOutput 会显得那么重:它们承接的并不是单一文本,而是执行尾部的一整组结果语义。

换句话说,执行尾部和返回链不是前后两章碰巧相邻,而是天然接在一起的。

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 缺失或格式异常;
  • 文本回包正常,但附带信息不完整;

更稳的顺序通常是:

  1. 先确认执行层是否已经产出对应字段;
  2. 再确认这些字段有没有沿输出对象继续往上走;
  3. 最后再看 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

检查顺序为什么重要

这个顺序不是随意的,每一级都有具体理由:

  1. abort 必须排在最前面。如果一个请求已经被中止,继续判断它”是不是因为 length 结束”毫无意义,而且可能掩盖真实原因,让调用方以为是正常完成。

  2. length 必须早于 stop。一个请求完全可能在恰好触达 max_new_tokens 的同一步里生成了 EOS token——这时两个条件同时成立。返回 ”length” 能告诉调用方”输出被截断了”,而返回 ”stop” 会让调用方以为输出是完整的。

  3. stop 里 EOS token 的判断早于 stop string 的判断,因为 EOS 是模型侧的完成信号,而 stop string 是用户侧的约束,二者语义不同,但最终都归入 ”stop”

  4. 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 的子类实例(EOSLengthExceededAbort 等),而不是原始字符串,这样调用方可以做类型判断。

第四步:通过 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 可以继续流动的对象”。