在 LLM 应用开发里,我们经常需要处理多轮消息、对话历史等结构化内容。理论上,这些对象应该是简单、透明、可控的——但在 NumPy 和特定字典工具(如 addict.Dict)参与后,一些微妙的行为会悄悄改变数据结构,让输出变得诡异甚至完全不对。本篇记录我在实际开发(尤其是 verl 与 transformers)中遇到的两个“小问题”:一个来自 NumPy 的自动维度推断,另一个来自字典工具的默认属性行为。它们不是 bug,却可能让你花一阵子 debug。
TL;DR
- NumPy 变长消息问题:当使用
np.array(..., dtype=object)处理长度不一致的消息列表时,NumPy 可能返回不同维度的数组,导致后续处理出错。改用np.fromiter或预分配 object 数组并赋值,可确保输出结构统一。 - 字典赋值工具干扰问题:使用
addict.Dict等动态字典工具包装消息数据时,其默认行为会干扰 transformers 对消息结构的正确判断,导致模板生成错误。可换用OmegaConf或修改addict源码禁用自动建键功能以修复问题。
本文记录两个 LLM 消息处理时可能遇到的小问题。
NumPy变长消息
问题描述
第一个小问题是在使用 verl[1] 时遇到的,代码:https://github.com/volcengine/verl/blob/8fdc4d3f202f41461f4de9f42a637228e342668b/verl/utils/dataset/rl_dataset.py#L63,如下:
1 | for key, val in non_tensors.items(): |
这里的意思就是把非 tensor 的特征列转为 array。
但是,如果我们每个 batch 的消息数长度不一致时,这里可能会产生两种不同的输出。
1 | msgs = [ |
结果如下:
1 | <class 'numpy.ndarray'> <class 'numpy.ndarray'> |
这是因为如果给定 val 包含的元素个数相等时,NumPy 会尝试把它们变成一个多维数组,如果失败,则退回到一维 object array(里面的元素保持原始 Python list)。
显然,第一种情况下,每个子 list 转成 ndarray,然后堆叠成二维 ndarray,而第二种情况则退化为一维 object array。
解决方法
既然知道问题所在,解决起来其实比较容易啦。verl 在 0.6.0 时已经修改了这个可能引发问题的写法:
1 | for key, val in non_tensors.items(): |
fromiter 只能生成一维数组,并且所有元素必须是标量或 object(当 dtype=object)。修改后,输出就统一了:
1 | msgs = [ |
结果为:
1 | <class 'numpy.ndarray'> <class 'list'> |
或者,还可以用 empty + assignment 的方法:
1 | msgs = [ |
结果和 fromiter 是一样的。注意,这里的 arr[:] = val 其实等价于:
1 | for i, v in enumerate(val): |
不过直接 : 赋值的方法会先尝试 broadcast,发现 dtype=object 时,broadcast 很宽松,只要长度一致即可,然后按元素逐个赋值。它底层是 C 语言执行,比 Python 能稍微快点。
##字典赋值
问题描述
第二个小问题是在使用 transformers[2] 时遇到的,代码:https://github.com/huggingface/transformers/blob/bdee0889714e9cb3e53d3b1b2a626919479d356c/src/transformers/tokenization_utils_base.py#L1675,如下:
1 | if isinstance(conversation, (list, tuple)) and ( |
遇到问题的源头就是这句:hasattr(conversation[0], "messages")。
当然,这里的小问题不是 transformers 的问题,而是我自己用了一个字典赋值工具的问题。这个工具就是:mewwts/addict: The Python Dict that’s better than heroin.[3] 平时简单的脚本任务时偷懒(不创建数据对象类)用这个快速创建对象,并用 . 来读取 key,就像对象的属性一样。如下:
1 | from addict import Dict |
很方便是吧。这就是错误来源啦!
1 | dct = { |
这里如果把 d.messages 传给 tokenizer,就会遇到我们开始时的那个hasattr(conversation[0], "messages")。这里的 conversation 就是上面消息的 list,第一个元素那就是唯一的那条消息。而 addict.Dict 的特性就是不关心 key 存不存在,如果不存在默认 value 就是空字典。所以这句 hasattr 结果为 True。
结果很显然就有问题,我们以 Qwen/Qwen3-0.6B 为例。
1 | from transformers import AutoTokenizer |
或者用其他 tokenizer,比如 Qwen/Qwen2.5-7B-Instruct,输出分别为:
1 | ['<|im_start|>system\nYou are Qwen, created by Alibaba Cloud. You are a helpful assistant.<|im_end|>\n'] |
也就是说,使用了 addit.Dict 后,会破坏掉原有的判断逻辑,导致输出异常结果。
解决方法
很简单,使用其他类似工具即可,比如 OmegaConf,用法如下:
1 | from omegaconf import DictConfig, OmegaConf |
这两种用法都可以正常输出。或者,我们需要稍微修改一下 addict.Dict 的源代码。
1 | def __getattr__(self, item): |
我们可以将其修改为:
1 | def __getattr__(self, item): |
注意需要同时修改 __missing__ 方法,不要自动创建,直接抛出异常。这样修改后,输出也是正常的。
1 | d4 = Dict(dct) |
不过修改后,原来的一些好用的功能就失效了,比如,你就不能再这样写了:
1 | d = Dict() |
但是这样依然是没问题的:
1 | dct = {"a": {"b": {"c": 1}}} |
也就是说,赋值方法读还是没问题的,写就不支持了。这原因当然就是 __missing__ 里面那个 return 啦。
小结
本文介绍了两个处理 LLM 消息时可能遇到的小问题,其实也不算是 bug,应该算是 library 相关的副作用。不过,只要我们找到了触发问题的原因,修起来还是很容易的。
References
[1] verl: https://github.com/volcengine/verl
[2] transformers: https://github.com/huggingface/transformers
[3] mewwts/addict: The Python Dict that’s better than heroin.: https://github.com/mewwts/addict