☰
大模型推理输出结构解析:SequenceOutput与SequenceGroupOutput关系实践
2026/10/20 7:00:59 网站建设 项目流程

连续翻了两天框架源码,我终于把SequenceOutput和SequenceGroupOutput这两个输出结构的关系彻底捋顺了。起因是给一个大模型推理引擎做服务端适配,前端模型服务拿到生成结果后要统一转成规范的 JSON 接口返回给上层调用方。结果一调试发现,引擎底层返回的压根不是一段干净文本,而是两层嵌套的数据结构:第一层是"单条生成路径"的细粒度输出,叫SequenceOutput;第二层是"一次请求的所有候选路径"的聚合输出,叫SequenceGroupOutput。如果对这两层结构理解不透,写出来的解析代码会到处都是边界 bug,而且流式返回时更是一踩一个坑。

说实话,这两个结构名字看着像,职责却完全不同。SequenceOutput是引擎内部调度与自回归解码的最小产物,描述的是"某个具体的序列在某一步(或者最终)生成了什么";SequenceGroupOutput则是面向请求出口的聚合容器,把同一请求下多个候选序列的输出打包在一起。对于做推理服务接入、二次开发、或者想深入理解生成式模型输出语义的人来说,把这两个结构吃透,能少走很多弯路。

1. 先说总体设计:这两级输出结构是怎么来的

1.1 一次请求在引擎内部的生命周期

绝大多数大模型推理框架的处理流程都可以抽象成四个阶段:请求解析、预处理与序列组构建、自回归解码、结果汇聚返回。在这套流程里,"序列"(Sequence)和"序列组"(SequenceGroup)是两个隐藏的核心概念,但它们通常只在引擎内部出现。到了输出阶段,这两个内部对象分别对应生成了SequenceOutput和SequenceGroupOutput两个数据结构,向外暴露。

我画一条时间线来理解。客户端提交一个请求,比如prompt="今天天气怎么样",采样参数里设置了n=3也就是要返回三条候选结果。引擎收到这个请求后,会创建一个SequenceGroup,组内初始化多个Sequence实例,数量取决于n、best_of、beam search 的beam width等参数。每个Sequence独立持有自己的 token 序列、KV cache 状态、生成进度。解码过程中,每个序列每一步都会产出一个 step 级结果,这个结果用SequenceOutput表达。一个解码步跑完,引擎把组内所有序列的 step 结果收集起来,封装成一个SequenceGroupOutput,这既是调度器做下一步决策的依据,也是流式接口向外吐出增量 token 的原始素材。

等到所有序列都走到终止状态(遇到 EOS、达到max_tokens或者触发停止词),引擎再基于每个序列的完整生成内容,构造最终的SequenceOutput,并塞进一个SequenceGroupOutput,作为请求的最终响应返回。所以这两个结构并不是非此即彼的关系,而是同一个信息在不同粒度和不同生命周期阶段的不同呈现。

1.2 为什么不能只保留一个统一结构

很多人第一次看到这两个结构时会问:既然SequenceGroupOutput里面已经包含了多个序列的输出,为什么还要单独搞一个SequenceOutput,直接全部塞进一个结构里不行吗?

答案在于职责分离。SequenceOutput的核心使用方是引擎内部,它要服务于三类场景:第一是自回归解码器的状态更新,每一步要知道当前序列生成的 token、对应的对数概率、是否终止;第二是 beam search 或者并行采样时的候选维护,需要根据cumulative_logprob排序和裁剪;第三是日志与可观测性,排查问题时我们需要精确到某个序列某一步做了什么决策。这些场景要求结构紧凑、字段清晰、方便高频读写,不适合把所有序列混在一起。

SequenceGroupOutput则面向引擎的输出出口,它要对外表达的是"这次请求整体生成了什么":有几条候选路径、每条路径的文本是什么、每条路径的整体置信度是多少。把组级信息抽出来,一方面方便服务端统一序列化,另一方面也方便业务方只拿一个对象就能处理整个响应。简单说,前者是引擎的"内部账本",后者是服务的"对外契约"。把这两层职责混在一个大结构里,会让内部调度变慢,也会让外部接口变得难以维护。

2. 拆开看 SequenceOutput:单条生成路径的完整记录

2.1 核心字段逐一拆解

SequenceOutput的字段设计其实非常克制,我见过的主流实现通常只保留几类信息:序列标识、生成内容、生成得分、以及可选的对数概率细节。下面这个 dataclass 基本还原了典型定义:

@dataclass class SampleData: seq_id: int token_ids: List[int] texts: List[str] cumulative_logprob: float logprobs: List[Dict[int, float]] @dataclass class SequenceOutput: seq_id: int outputs: List[SampleData] prompt_logprobs: Optional[List[Dict[int, float]]] = None cumulative_logprob: Optional[float] = None text: Optional[str] = None

先说seq_id。它标识这条输出属于哪个序列。在一个SequenceGroup内部,seq_id是唯一的,但在引擎全局范围内并不一定唯一,因为不同请求的序列可能复用同一个 ID,所以跨请求做全局追踪时,一定要把request_id和seq_id联合起来使用,只看seq_id会撞车。

outputs是一个SampleData列表,里面保存的是具体的采样结果。这里有个容易混淆的点:通常情况下outputs长度是 1,因为一个Sequence对应一条生成路径;但如果框架支持单序列多次采样(某些并行采样模式下,一个序列会保存多次采样尝试的记录),outputs就会包含多个采样结果。每个SampleData内部,token_ids是原始整数 ID 序列,texts是 token 级别的文本列表,cumulative_logprob是这条采样路径的累计对数概率,logprobs则记录每一步各个候选 token 的分数。

prompt_logprobs是可选的。只有在生成时显式请求了echo或者要求返回 prompt 级别的对数概率,这个字段才会有值,否则通常为None。它的格式一般是"每个输入 token 一个字典,字典的 key 是候选 token ID,value 是对数概率"。这个字段在生产环境里很少开,因为 prompt 往往很长,整体存下来内存开销非常大。

2.2 为什么累计对数概率要单独拎出来

这是我们后端接入时最容易忽略的一个细节。很多同学以为cumulative_logprob就是把logprobs里每一步的最大概率值相加,其实完全不是。logprobs里保存的是每一步采样的完整候选分布,包含很多被淘汰的 token;而最终选中的 token 只是其中某一个。cumulative_logprob是对实际生成的路径逐 token 对数概率求和,如果每一步只存 top token,那这个值还算得出来;但如果框架支持一步采样多次、或者 beam search 中路径发生了合并,直接对logprobs做累加就会得到错误结果。

所以框架选择在生成过程中实时维护cumulative_logprob,而不是让下游去推断。这个设计还考虑到了长度惩罚(length_penalty)。最终排序时用的分数通常是cumulative_logprob / (len ** length_penalty)之类带惩罚项的公式,而不是原始累计概率。把原始累积分单独存下来,上游可以在不同惩罚参数之间灵活切换,不用重新解码。

实操中我建议:做候选排序时优先使用引擎已经算好的cumulative_logprob,不要自己从logprobs重新汇总;如果你确实需要自己算,一定要留意框架是否对逐步概率做过温度缩放。温度缩放发生在 softmax 之前,logprobs里记录的是缩放后的分布,而不是原始分布,直接相加会有偏差。

2.3 文本还原:这里藏着 tokenizer 的坑

text字段的生成时机,是另一个值得讲明白的点。有些实现里text是惰性计算的,也就是只有在真正需要输出文本时才调用 tokenizer 的decode方法;有些实现则会在构造SequenceOutput时就一次性把token_ids解码成文本。

这两种策略各有适用场景。惰性解码适合流式输出和高吞吐服务,因为很多 token 可能永远不会被消费方读取,提前解码浪费 CPU;提前解码适合批量离线推理,因为最终反正都要完整文本,早解码还能把成本均摊掉。

但无论哪种策略,都有一个共同的坑:不要尝试通过拼接texts字段来还原整句文本。分词器在解码时对空格、标点、Unicode 组合字符有特殊处理,一个英文单词可能被拆成两个 token,其中一个 token 的文本表示里带空格,另一个不带。如果你把texts里每个 token 的字符串直接连起来,会出现"hello world"变成"helloworld"或者多一个空格的问题。必须对完整的 token ID 列表调用一次decode。

def decode_sequence(token_ids: List[int], tokenizer) -> str: # 正确方式:整体 decode return tokenizer.decode(token_ids, skip_special_tokens=True)

我在实际项目中遇到过不止一次线上文案缺空格的问题,最后定位下来全都是因为业务方图省事直接拼接texts字段。这个教训想分享给所有做推理服务接入的人:texts字段只适合逐 token 高亮展示,不适合用来拼完整文本。

3. SequenceGroupOutput:一个请求的所有候选答案

3.1 结构定义与关键字段

看完了单序列的输出,再来看组级输出就顺理成章了。SequenceGroupOutput的典型结构长这样:

@dataclass class SequenceGroupOutput: seq_id: int samples: List[SampleData] prompt_logprobs: Optional[List[Dict[int, float]]] = None

seq_id在组级输出里通常指代序列组的 ID。字段命名上,不同框架差异很大,有的叫group_id,有的直接复用seq_id,但语义都是"这一组候选结果属于哪个请求"。如果你在对接不同引擎,建议先看协议文档里对这个字段的定义注释。

samples是核心,它和SequenceOutput.outputs看起来很像。两者都是SampleData的列表,但samples的语义完全不同:它聚合的是这个请求组内所有序列的最终输出。举例来说,如果客户端请求n=3,那么这里的samples通常就是 3 个元素,对应三条独立生成的回答,每条都有自己的token_ids、text和cumulative_logprob。

prompt_logprobs在组级输出里通常是空值。因为这个信息属于 prompt 层面,不属于某条具体候选路径,如果每个序列都放一份,内存浪费严重。更合理的做法是只在请求级保留一份。

3.2 采样策略如何影响 samples 数量

samples的长度并不总是等于客户端请求的n值。这个变量如果不搞清楚,解析时写死长度就会踩坑。

在纯采样模式下(temperature > 0),引擎通常会尝试生成n条独立路径,所以正常情况下samples长度等于n。但有一种例外:如果一些路径提前终止(比如生成了 EOS token),而引擎配置里设置了提前结束整个请求而不是继续补足路径,samples的长度就可能小于n。

在 beam search 模式下,情况更复杂。beam search 的每条 beam 是一条局部路径,最终输出时引擎会选择num_return_sequences条 beam 作为结果。有些框架的samples只包含最终入选的 beam,有些则会把所有 beam 的路径都放进去,由上层自己过滤。我做接入时踩过一次坑:某次测试里我按n=3解析 beam search 输出,结果samples返回了 5 条,前三条是最终结果,后两条是被裁剪的历史 beam。最后查文档才发现,这个框架的samples默认保留全部 beam,需要自行按cumulative_logprob取前 K 条。

所以解析samples时有一条铁律:永远先取len(samples)再逐条处理,禁止用请求参数硬编码期望数量;对顺序有要求时,显式按cumulative_logprob降序排序,不要依赖引擎返回的顺序。

3.3 流式场景下的增量输出

SequenceGroupOutput还有一个容易被忽略的身份:它是流式返回的基础分帧单位。每解码一步,引擎会把当前步所有序列新生成的 token 整理成一个"增量版SequenceGroupOutput",发送给输出通道。在这个增量版本里,samples中每个SampleData的token_ids通常只包含当前步新生成的一个 token,而不是整句。

流式接口的消费方必须自己维护状态:收到第一帧时初始化一个缓存结构,之后每一帧把新 token 追加到对应序列的 token 列表尾部。如果消费方错误地以为每个分帧都是完整结果,直接覆盖上一帧的文本,那最后拿到的只会是最后一个 token。

这里还要区分"按序列增量"和"按样本增量"。有的引擎一个分帧只包含一个序列的新 token,流式接口里就会出现多帧对应同一序列的情况;有的引擎每个分帧包含组内所有序列的新 token。所以在流式解析逻辑里,要用seq_id作为 key 来分桶管理,而不是按帧序号做假设。

4. 实操:从引擎拿到数据并解析成响应

4.1 引擎侧构造 SequenceOutput 的示例

为了让你理解构造端逻辑,我写一个简化的引擎内构造示例。假设在自回归解码的每一步,调度器拿到上一步的 token 概率分布后,决定每个序列下一步生成什么 token:

def build_step_output(seq, step_logprobs, tokenizer) -> SequenceOutput: token_id = seq.last_token_id token_text = tokenizer.convert_tokens_to_string( tokenizer.convert_ids_to_tokens([token_id]) ) sample = SampleData( seq_id=seq.seq_id, token_ids=[token_id], texts=[token_text], cumulative_logprob=seq.cumulative_logprob, logprobs=step_logprobs, ) return SequenceOutput( seq_id=seq.seq_id, outputs=[sample], cumulative_logprob=seq.cumulative_logprob, text=None # 惰性模式,不在每步解码 )

这个函数里,step_logprobs是 shape 为[vocab_size]的完整概率分布,还是只包含 top-k 候选的稀疏字典,取决于引擎生成时的logprobs参数。如果设置logprobs=0,引擎通常不会保存任何分数;logprobs=5则每一步保留概率最高的 5 个候选 token 的分数。这个细节对下游非常重要,因为没有保存分数的情况下,logprobs字段会是空列表,而不是"概率最高的 token 的分数列表"。

4.2 业务侧消费 SequenceGroupOutput 的完整示例

业务侧拿到一个SequenceGroupOutput对象之后,标准解析逻辑可以这样写:

def parse_group_output(group_output: SequenceGroupOutput, tokenizer, num_return: int) -> List[Dict]: # 先按分数降序排序,再截取需要的条数 samples = sorted( group_output.samples, key=lambda s: s.cumulative_logprob, reverse=True )[:num_return] parsed = [] for sample in samples: text = tokenizer.decode(sample.token_ids, skip_special_tokens=True) parsed.append({ "seq_id": sample.seq_id, "text": text, "cumulative_logprob": sample.cumulative_logprob, "token_count": len(sample.token_ids), "finish_reason": infer_finish_reason(sample), }) return parsed

注意两点。第一,samples排序时如果分数相同,引擎内部还会参考生成顺序做稳定排序,所以直接用 Python 的sorted即可,结果和引擎一致。第二,infer_finish_reason是一个辅助函数,用于判断这条路径是自然结束还是被max_tokens截断。判断依据是:最后一个 token 是 EOS,或者len(token_ids) == max_tokens。如果你希望返回给用户时能精确区分这两种结束原因,最好在解码阶段就让引擎把 finish reason 直接写入SampleData,而不是在解析时靠推理。

4.3 字段映射与序列化约定

把内部结构转成 JSON 返回给上层时,我建议单独建立一层 DTO(数据传输对象),不要直接把引擎结构序列化出去。原因有三:

第一,内部字段名不稳定。引擎版本升级后seq_id可能改成sequence_id,如果你直接透传,上层调用的字段名就要跟着变,牵一发动全身。加一层 DTO 可以把内部差异隔离在适配层。

第二,内部结构里有大量用不到的信息。logprobs这类细粒度数据在绝大多数业务场景里都不会被展示,直接全量序列化会让响应体膨胀几十倍。默认只输出文本、token 数、结束原因,按需开启分数输出更合理。

第三,类型安全和兼容性。DTO 层可以用显式字段定义和版本号,方便做向前兼容。这在对外 API 场景里几乎是必须的。

@dataclass class SampleDTO: index: int text: str finish_reason: str logprobs: Optional[List[Dict[str, float]]] = None @dataclass class ResponseDTO: request_id: str prompt: str samples: List[SampleDTO]

序列化时只输出ResponseDTO,内部任何结构调整都不影响外部调用方。

5. 常见问题与排查技巧实录

5.1 输出 token 数与文本对不上

这是接入过程中最常遇到的 bug。表面上表现是:token_ids有 80 个元素,解码出来的文本却只有 60 来个词,感觉"少了内容"。排查后发现,问题出在我前面提到的拼接陷阱:业务方使用texts字段做了逐 token 拼接。texts中每个元素只是 token 的"表观文本",比如英语单词里的空格常常附着在下一个 token 头部。逐 token 拼接会把附着的空格丢失或者重复。

排查方法很简单:从引擎拿一条完整输出,分别用"逐 token 拼接"和"整体 decode"两种方式得到两版文本,对比差异。只要两版不一致,就是你踩了拼接坑。修正后建议把texts字段在适配层直接丢弃,只保留token_ids作为文本还原的最终依据。

5.2 logprobs 数量不足或顺序错乱

logprobs列表长度不匹配是另一个高频问题。有的序列前几步的logprobs是空字典,后面几步突然有了值;有的序列则反过来,前几步有值,后面全是空。出现这种情况,绝大多数是因为 prompt 部分和生成部分被框架分开处理了。

很多框架对 prompt 的处理是不做打分采样优化的,因此prompt_logprobs和生成步logprobs的索引体系会错位。你在解析时如果直接用统一的for i in range(len(logprobs))读取,就会对不上。我的做法是在适配层分别维护两套索引:prompt_logprobs从0到prompt_len - 1,生成步logprobs从0到generated_len - 1。中间绝不混用。

还有一个细节:某些引擎只在return_token_log_probs=True时才记录每一步的完整分布;如果你只是把logprobs参数设成一个正整数,框架可能只保留 top-k 候选,此时字典里只会出现 k 个 key。解析时不要默认字典包含整个词表。

5.3 多候选模式下 samples 为空

n>1时偶尔会出现samples为空列表的情况。我碰到过的原因主要有三个:一是所有序列都在第一步触发了停止条件(比如max_tokens=1且首 token 是 EOS),没有产出任何有效路径;二是引擎有"指定终止条件"的逻辑,当某条路径达到停止词后,整组请求提前结束,其余未完成的序列被直接丢弃;三是ignore_eos=False且 prompt 里天然带了 EOS token,导致序列长度为零。

解决方向不是改解析逻辑,而是在请求参数上确认:离线任务建议把ignore_eos设置为True,或者把min_tokens设为大于 0,强行保证至少生成若干个 token。如果参数不便改动,那适配层必须对空samples做兜底,返回一个明确的错误码,而不是让上层拿到空列表后踩 IndexError。

5.4 流式返回时第一个分帧字段缺失

流式接口中,第一个分帧往往只包含seq_id和prompt_logprobs,samples为空。原因是引擎把"请求开始"事件也作为一帧推送到输出通道,这一帧仅用于建立上下文、同步请求元数据,真正生成的 token 从第二个分帧才开始。有的业务方在流式模式下一拿到第一帧就尝试解析文本,结果拿到空字符串,就误以为引擎出了问题。

建议在流式解析器里设置一个"上下文初始化完成"的标志位:第一帧到位时先缓存seq_id映射,第二帧及之后才开始累积 token。同时,要处理"首个 token 帧"里texts是一个空列表开头的情况,不要假设每个分帧的token_ids非空。

5.5 内存与序列化性能的权衡

最后一个问题来自性能侧。SequenceGroupOutput在n很大、logprobs完整保留时,对象体积可能非常惊人。我们曾在一个超长文本生成任务里,因为打开了完整logprobs,单个响应 JSON 超过了 50MB,序列化耗时接近 600ms,直接把服务端 CPU 打满。

优化做法有两个方向。第一是裁剪:在适配层默认丢弃所有logprobs字段,只有请求参数显式要求时才透传;对texts里面细粒度 token 文本,也可以只在调试日志里保留。第二是流式序列化:不要等整个SequenceGroupOutput构建完再一次性序列化,而是分帧序列化并逐帧写入响应体。流式处理既能降低内存峰值,又能显著提升首 token 延迟。

提示:如果你的服务是把响应体完整返回后再做鉴权或者后处理,建议把logprobs的默认关闭策略写死在适配层,避免出现线上接口被大响应拖垮的情况。

最后再分享一个实际摸索出来的小经验

调试这两个结构时,最快的方式不是读文档,而是在引擎里直接打日志,把步级SequenceOutput和最终SequenceGroupOutput的关键字段都打印出来,拼接到一起,观察每一步 token 如何一步步组成最终文本。我当初就是在日志里发现texts拼接出的句子多了个空格,才定位到 tokenizer 解码的问题。搞清楚两级结构之后,后续做响应统一、流式转发、以及多候选排序都顺了很多。

如果后续你想扩展,可以在适配层再包一层"可观测性中间件",把每次请求的SequenceGroupOutput都记录一份精简版元信息(候选条数、平均生成长度、top-1 与 top-2 的累计概率差),这对服务监控和质量评估都很有帮助。

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

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

立即咨询