☰
SequenceOutput与SequenceGroupOutput:大模型推理输出数据结构详解
2026/10/20 7:00:57 网站建设 项目流程

第一次在源码里看到SequenceOutput和SequenceGroupOutput这两个类名时,我整个人是懵的。明明一次请求只填了一段 prompt,打印出来的对象却嵌套了好几层,两个类名字就差一个Group,谁包谁、哪个才是最终结果,不看实现根本分不清。后来我在调试一个流式返回 bug 时,因为把SequenceOutput当成完整输出直接解析,硬是排查了三个小时才定位到问题。这篇文章就把我对这两个数据结构的理解、踩过的坑和一些使用建议一次性说清楚,希望能帮你少走弯路。

1. 先别急着调接口,搞清楚两个类到底在描述什么

1.1 一次事故现场的复盘

当时我在做某个推理服务的日志采集模块,想从输出对象里拿到「本轮生成的那一个 token」。我参考了旧代码,发现有人直接用output.samples[0].output_token取 token id,于是我也照做。但换成n=4的采样参数之后,结果全乱了:明明一次请求有 4 条候选,samples有时候是 4 条,有时候又变成 1 条,还有一次拿到一个-1的 token id,直接把我整不会了。

后来我才意识到,SequenceOutput描述的是「一条序列在某一次 decode 步里产生的输出」,SequenceGroupOutput描述的才是「同一个请求下所有序列在某一步的输出集合」。这两个类根本不是继承关系,而是组合关系:SequenceGroupOutput里面挂着多个SequenceOutput。我之所以看到 1 条,是因为在流式接口的某一层,数据已经被按请求拆好、只暴露我当前关注的那一格;之所以看到 4 条,是因为我直接读了原始的SequenceGroupOutput.samples。

这类问题很典型:框架内部为了兼顾 batch 调度、beam search 和多序列采样,设计了几层中间结构,对外暴露的通常是一层更友好的最终对象。但如果你想做二次开发、写监控探针、接日志分析,就绕不开这些原始类。理解它们,是读懂整套推理输出链路的第一步。

1.2 一个请求是怎么变成一组序列的

我们先从源头捋一下。你给模型发一个请求,一般包含 prompt、采样参数、停止条件这些东西。框架内部会先做 tokenize,把 prompt 变成 token id 序列,然后创建一个「序列组」来管理这个请求。这个组里最少有一条序列,也就是你只指定n=1的情况;如果你指定n=4,或者开启 beam search,那同一个请求下面就会同时维护 4 条序列。

为什么要搞一个「组」的中间概念?因为多条序列共享同一个 prompt、同一份 KV cache,还要共用同一套停止条件判断。如果每次解码各自为政,调度器就很难统一管理,batch 也会变得零碎。分组之后,一次前向计算得到的 logits 会被拆回各个序列,每个序列各自采样,再汇总成这个组的输出。

这里有个新手容易犯的直觉错误:看到SequenceGroupOutput就以为是「一整轮请求的最终答案」。其实它只是「某一个 decode 步」的输出快照。没有Group的SequenceOutput反而更接近你关心的 token,只不过它只代表单个序列的当前步,不代表历史,也不代表最终结果。理解这一点,后面看流式输出和采样排序就顺了。

2. 拆开SequenceOutput和SequenceGroupOutput,每个字段都是有用意的

2.1 SequenceOutput:一条生成序列的“当步快照”

在某个典型的开源推理框架实现里,SequenceOutput的结构大致是这样:

@dataclass class Logprob: logprob: float # 当前 token 的对数概率,注意是负数 rank: int # 在词表排序中的名次 decoded_token: str # 这个 token 解码后的字符串 @dataclass class SequenceOutput: parent_seq_id: int # 父序列 id output_token: int # 这一步采样出来的 token id logprobs: dict[int, Logprob] # 候选 token 的 logprob 映射 cumulative_logprob: float # 从序列开始到当前的累计对数概率

parent_seq_id是用来追踪「这条输出是从哪条序列延伸出来的」。在普通采样场景下,新序列和父序列 id 通常一样;在 beam search 场景下,一个父序列会分裂出多条候选,这个字段就是回溯生成路径的关键线索。我见过有人把它当成「请求 id」来用,这是不对的,它属于序列树内部的引用关系,不是业务标识。

output_token不用多说,就是模型这一步采出来的 token id。为什么不直接存字符串?一方面 token id 是模型和词表之间的标准语言,另一方面很多上游逻辑需要拿 id 继续做输入拼接、做 logprob 计算,如果把 decode 开销放在每一步的采样里,性能会打折扣。你需要字符串的时候,再单独调 tokenizer 解码即可。

logprobs稍微绕一点。它不是只存被选中的那个 token,而是一个字典,key 是候选 token id,value 是Logprob对象。之所以用字典而不是列表,是因为你可能只想要 top-k 的候选概率,不想把整个词表几万个概率全带出来;字典天然支持按 token id 查。Logprob里的logprob是对数概率,正常情况下是负数,比如-1.2比-0.3概率小,别一看是负数就以为是 bug。很多日志系统在这里踩坑,把 logprob 当成 0 到 1 的原始概率去聚合,最后算出来的分数完全不对。

cumulative_logprob是当前这条序列从第一个生成 token 开始,到当前 token 为止所有 logprob 的总和。它常被用来比较不同采样序列的优劣,是 beam search 和n路采样排序的重要依据。注意它是累计值,不是当前步的概率;每次要把这一个 Step 的SequenceOutput里的当前 logprob 加进去,才能得到新的累计值。这个口径在下一节还会展开。

2.2 SequenceGroupOutput:把多条序列按请求收拢

SequenceGroupOutput在典型实现里大概是这样的:

@dataclass class SequenceGroupOutput: samples: list[SequenceOutput] prompt_logprobs: list[Logprob] | None = None

prompt_logprobs不一定有值,因为你要为「返回 prompt 中每个 token 的对数概率」付出额外的显存、带宽和数据组装成本。默认场景下,很多框架只返回生成 token 的 logprob,不返回 prompt 中每个 token 的 logprob。如果你做的是打分、重排、强化学习数据清洗这类任务,才需要专门打开这个开关。

SequenceGroupOutput的核心作用是把同一个请求下的所有输出收拢成一个包。一次 batch 推理的返回往往是一个SamplerOutput,它内部按 batch 顺序对应到每个序列组。如果没有这一层包装,调用方就得自己维护「哪个输出属于哪个请求」,一旦 batch 里请求多了,或者有请求提前结束,对应关系很容易错位。

你可以把SequenceOutput想象成一个生产线上的工件当班记录,记录的是「这条序列在这一步产生了什么」;SequenceGroupOutput就是工人手里的料盒,料盒上写的是批次号,里面装着同一批工件的记录。料盒本身不是产品,里面的记录也不是完整历史,只是这一个班次的生产数据。

2.3 字段取舍背后的三个现实问题

看过字段设计,我最大的感受是:数据结构里每一个取舍,背后都对应一个真实工程问题。

第一,为什么只有 token id 而没有完整字符串?因为 decode 是昂贵的操作。一个SequenceOutput可能在解码循环每一轮都产生,而循环可能持续几千步;如果每一步都先把 token 解码成完整字符串,再考虑要不要合并,CPU 开销和内存分配都上去了。框架选择延迟解码,把字符串转换留到最终组装结果时统一做,是合情合理的。

第二,为什么logprobs是字典而不是数组?因为词表可能有几万甚至几十万个 token。你要的是 top-k,是少数几个高频候选,不是全量概率。字典既能按需携带信息,又能让调用方直接通过 token id 取目标概率,避免遍历整个概率分布。同时,只保留 top-k 还能控制显存到 CPU 的数据拷贝量。很多跑长文本生成的团队,最后发现输出结构里最占内存的不是 token id,而是 logprobs 里附带的一堆 decoded_token 字符串,所以只在真正需要时才开启日志级别的 logprob 收集。

第三,为什么cumulative_logprob要单独维护?因为如果每次都要从序列历史里重新累加,复杂度就变成 O(序列长度 × 候选数)。序列长度一长,这种重复计算很浪费。维护一个累计值,每一步更新一次,成本从 O(n) 降到 O(1),代价是你要非常小心累计的口径,比如是不是包含首个 token、是不是包含 prompt 部分,不同框架的约定不完全一样。

3. 从模型吐出的logits到最终返回,数据是怎么流转的

3.1 每次decode到底构造了哪些对象

理解数据结构最好的方式,是看一遍解码循环的中枢逻辑。简化后大致是这样:

# 这里的代码是逻辑示意,不是某个框架的完整实现 def decode_step(sequences, model, sampler): # 1. 拿到所有序列的输入 token,拼成一个 batch input_ids = build_batch_input(sequences) # 2. 模型前向,得到每个序列位置对应的 logits logits = model(input_ids) # 3. sampler 负责从 logits 中采样,并返回按序列组组织的结果 sampler_outputs = sampler(logits) # 4. 把本步输出更新回序列状态 for group_output in sampler_outputs: # group_output 就是 SequenceGroupOutput for seq_output in group_output.samples: # seq_output 就是 SequenceOutput attach_to_sequence(seq_output)

注意第 3 步返回的SequenceGroupOutput是「某一个时间点」的输出。模型每前向一次,sampler会为每个序列组生成一个对应的SequenceGroupOutput;每个序列组内部,每个正在进行解码的序列会各有一个SequenceOutput。这一步结束后,框架会用SequenceOutput里的信息去更新序列状态:把 token 追加进去、更新累计 logprob、判断是否触发停止条件、决定要不要保留候选序列。等状态更新完,这步的SequenceOutput使命就算完成了。

所以这些结构本质上是「流经解码循环的中间消息」,不是持久化对象。如果你在流式回调里把它们缓存下来,过几个步骤再回来用,很可能拿到的引用已经被复用、改写,或者至少语义上已不再代表当前状态。

3.2 多序列和beam search场景下的组装逻辑

当请求参数n=4时,同一个 prompt 会并行生成 4 条序列。此时一个SequenceGroupOutput.samples里通常会包含 4 个SequenceOutput,分别对应 4 条序列的当前步输出。它们都来自同一个SequenceGroup,但彼此独立采样。调度器下一步拼接输入时,会把这些不同序列的 token 分别拼到各自的历史上,不能搞混。

Beam search 更复杂一点。每个 beam 在每一步都可能产生多个候选,框架需要根据cumulative_logprob做选择,保留 top beam 数目的序列。parent_seq_id在 beam search 中特别有用:一个父序列可能会产出两个候选 token,这两个候选会拥有相同parent_seq_id,但最终它们会走向不同的下一轮。如果你想还原生成路径,就必须按parent_seq_id构建一棵生成树,而不是简单地把SequenceOutput当作线性顺序。

我踩过的坑是:当n=4且其中两条序列因为触发停止条件提前结束时,后续每一步的samples数量会少于 4。这不是框架出 bug,而是因为已经结束的序列不再参与解码,自然也不会产生新的SequenceOutput。如果代码里写死len(samples) == n,就会在后半段报错或漏数据。正确做法是结合序列状态、结束标志位、还有每条序列当前的seq_id一起判断。

3.3 流式返回里拿到的为什么不是完整结果

流式接口给你的感觉是「模型一个字一个字往外蹦」。这个表现,本质就是因为每次 decode 会生成一个携带单步 token 信息的SequenceGroupOutput;流式层把它解码成字符串后发给客户端。

很多人在流式场景下犯同一个错误:以为某个回调参数里的SequenceGroupOutput是「目前已经生成的全部内容」,想直接拿samples拼出完整回复。实际上samples只代表「这一刻刚生成的那个 token」,不是历史累积。完整历史在Sequence对象的内部状态里,由框架在每次 attach token 时维护;你如果只拿SequenceOutput,永远拼不出完整句子,除非自己把每一步的输出都攒起来。

这里给大家一个干净的做法:流式回调里,只从SequenceOutput.output_token拿增量 token,然后丢给 tokenizer 解码并缓存;要算序列分数时,再单独维护一个累计变量。不要在流式回调里尝试从对象追溯完整序列,那样既绕又容易读脏数据。

4. 实战里的坑:我踩过的和帮你避开的

4.1 samples数量和序列数对不上怎么排查

我上面提到过,n=4时后面几步samples可能少于 4。排查这类问题,我建议先做一个断言:如果某一步samples数量减少,检查是不是对应的序列状态已经变成FINISHED。一种常见情况是 max_tokens 到了,序列先结束,但仍留在序列组里直到最后整体返回。此时流式接口不一定主动通知你这条序列结束了,你需要自己去状态表确认。

还有另一种情况是 beam search 的裁剪。每次保留 beam 数不变,但中间会产生超过 beam 数目的候选,裁剪后samples里保留的确实是 top 几个。如果你看到数量少于 beam 数,很可能是某些序列因为停止条件被提前淘汰。遇到这种场景,不要硬凑数量,先看每条输出对应的parent_seq_id和序列状态,再决定下一步逻辑。

4.2 cumulative_logprob不是你想的“当前步概率”

这是我运维监控时踩得最惨的一个坑。当时想算「当前这一步生成得有多自信」,直接取了SequenceOutput.cumulative_logprob,结果发现数值越来越小,跟直觉完全反着来。后来才意识到,这是从序列开头到当前的累计和,理论上 token 越多,加起来的负数越多,数值自然越小。

正确取法是:当前步 logprob = 本次cumulative_logprob减去上一步的cumulative_logprob。前提是你缓存了上一步的值。如果只想比较两条候选序列的质量,直接用最终cumulative_logprob排序即可,但要保证两条序列长度相差不大;长度差很多时,短的累计值通常更大(负得少),长的反而吃亏。这就是为什么一些框架在做打分时会额外引入长度归一化,cumulative_logprob只是一个原始信号,不是最终评分。

浮点精度也值得留意。累计几千步之后,两个很接近的序列分数差可能落在浮点误差范围里。如果你在做重排或者 beam 选择,不要只看 logprob 小数后三位,可以改用更高精度的数值类型,或者在累加时做 Kahan 补偿。大部分场景不用这么夸张,但当你发现排序结果偶尔不稳定,优先怀疑这里。

4.3 logprobs字典和finish_reason的几个误用

先说logprobs字典。如果你没有在采样参数里打开 logprobs 开关,某些实现里它可能只有被选中 token 的一栏,甚至为空,而不是默认给你一整套 top-k。我做日志采集时,为了拿到完整 top-k,需要显式设置logprobs=5,否则字典大小稳定在 1,后来排查半天才发现不是代码问题,是参数没开。

还有一个经典误用:把logprobs字典里的logprob当概率直接乘。它是对数概率,正确的还原概率是exp(logprob)。如果你要聚合,建议一直保持在 log 空间里做加法,不要来回转换,否则精度损失严重。

finish_reason是个更隐蔽的点。最初我以为SequenceGroupOutput里会带一个 finish reason,毕竟它看起来像是「这轮请求的输出结果」。实际上,停止条件往往由序列状态机或调度器在 attach token 时判断,不会塞进SequenceOutput/SequenceGroupOutput这种采样结果结构里。你如果想准确拿到每条序列是「正常结束」还是「达到 max_tokens」,要么在框架最终对象里取,要么看序列状态码。直接在这些类里找属性,找不到很正常,不代表框架没实现。

4.4 常见问题速查表

现象可能原因处理建议
samples数量小于 n部分序列已结束或 beam 裁剪检查序列状态和停止标志,不要硬凑数量
cumulative_logprob越来越小这是累计值不是当前步概率用相邻两步差值计算当前步 logprob
logprobs只有 1 个候选没开启 top-k 返回开关设置采样参数logprobs=N
取不到finish_reason停止状态不在采样输出结构里去Sequence/状态机或最终结果对象里查
token id 是-1或异常值读取了已结束/未激活序列的输出先判断序列状态,再按parent_seq_id过滤
流式输出拼不出完整句子把增量当全量用自己按步缓存 token id,再做 decode

5. 个人使用建议与后续扩展思路

5.1 哪些场景值得直接消费原始数据结构

不是所有二次开发都要碰SequenceOutput。如果你只是调用 HTTP 接口拿最终文本,那完全没必要。但当你在做下面这几类事情时,直接消费原始结构能省掉很多中间转换:

  • 你在做日志和监控,需要按序列记录每一步 token 的 logprob,以便事后分析生成信心。
  • 你在做评估和对比实验,需要精确比较不同采样参数下多条候选序列的分数。
  • 你在做流式输出增强,比如自定义 token 级过滤、敏感词拦截,那增量 token id 和 logprob 是必需的输入。
  • 你在做强化学习或数据后处理,需要 prompt 的 logprob、生成中间步骤的完整轨迹,这些只有原始数据结构里才有。

5.2 加一层适配器,别让业务代码依赖框架内部结构

一个让我印象深刻的经验是:框架升级时,SequenceOutput的字段名变化比接口返回值变化更频繁。比如某次升级把cumulative_logprob改名成cum_logprob,又把sample列表顺序调整了一下,直接导致我的业务代码崩溃。后来我在自己项目里加了一个薄薄的适配层,把所有下游需要的字段(token id、logprob、累计分数)映射成自己的 DTO,只有适配层依赖框架类型,业务层完全不感知。

这个做法的额外好处是,你可以针对框架版本写契约测试。每次升级前跑一遍,看看字段有没有变、数据类型有没有变、samples数量约定有没有变。一套测试下来,升级风险基本可控。否则,等你真上线了才发现流式返回的 token 拼不齐,那就只能连夜救火了。

5.3 最后的小技巧:把token_id流接回tokenizer

最后说一个很实用的小技巧。当你手里拿到的是SequenceOutput.output_token时,很多人第一反应是丢给解码器跑一下,但要注意tokenizer.decode对单个 token id 的解码结果可能包含特殊字符、空白符、或者跨 token 的字节片段。正确的流式解码不是每步独立 decode,而是要把已经累积的 token id 列表整体交给 tokenizer,或者使用支持增量的 decode 接口,否则你会看到中文拆成乱码、英文单词中间多空格这类问题。

我通常的做法是:维护一个当前序列的 token id 列表,每收到一个SequenceOutput就 append 一个 id,然后调用一次带上下文状态的 decode,返回增量文本。这样既不会丢信息,又能保持流式的实时性。配合 logprob 一起记录,你几乎可以复现出模型每一步的完整生成决策,这对调试生成质量、分析 badcase 非常有帮助。

如果你正在做流式输出、评估系统或者日志分析,建议先花半小时把SequenceOutput和SequenceGroupOutput的关系理清楚,再动手写代码。别像我一样,上来就冲进samples里取数据,最后被parent_seq_id绕得团团转。理解好这两个结构,很多看似玄学的问题,其实一眼就能看穿。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询