两处容易踩的坑:LLM 消息数组与字典工具的隐藏副作用

在 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
2
for key, val in non_tensors.items():
non_tensors[key] = np.array(val, dtype=object)

这里的意思就是把非 tensor 的特征列转为 array。

但是,如果我们每个 batch 的消息数长度不一致时,这里可能会产生两种不同的输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
msgs = [
{"role": "system", "content": "S1"},
{"role": "assistant", "content": "A1"},
{"role": "user", "content": "U2"},
]

val = [msgs, msgs]
arr = np.array(val, dtype=object)
print(type(arr), type(arr[1]))

val = [msgs, msgs[:-1]]
arr = np.array(val, dtype=object)
print(type(arr), type(arr[1]))

结果如下:

1
2
<class 'numpy.ndarray'> <class 'numpy.ndarray'>
<class 'numpy.ndarray'> <class 'list'>

这是因为如果给定 val 包含的元素个数相等时,NumPy 会尝试把它们变成一个多维数组,如果失败,则退回到一维 object array(里面的元素保持原始 Python list)。

显然,第一种情况下,每个子 list 转成 ndarray,然后堆叠成二维 ndarray,而第二种情况则退化为一维 object array。

解决方法

既然知道问题所在,解决起来其实比较容易啦。verl 在 0.6.0 时已经修改了这个可能引发问题的写法:

1
2
for key, val in non_tensors.items():
non_tensors[key] = np.fromiter(val, dtype=object, count=len(val))

fromiter 只能生成一维数组,并且所有元素必须是标量或 object(当 dtype=object)。修改后,输出就统一了:

1
2
3
4
5
6
7
8
9
10
11
12
13
msgs = [
{"role": "system", "content": "S1"},
{"role": "assistant", "content": "A1"},
{"role": "user", "content": "U2"},
]

val = [msgs, msgs]
np.fromiter(val, dtype=object, count=len(val))
print(type(arr), type(arr[1]))

val = [msgs, msgs[:-1]]
np.fromiter(val, dtype=object, count=len(val))
print(type(arr), type(arr[1]))

结果为:

1
2
<class 'numpy.ndarray'> <class 'list'>
<class 'numpy.ndarray'> <class 'list'>

或者,还可以用 empty + assignment 的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
msgs = [
{"role": "system", "content": "S1"},
{"role": "assistant", "content": "A1"},
{"role": "user", "content": "U2"},
]
val = [msgs, msgs]
arr = np.empty(len(val), dtype=object)
arr[:] = val
print(type(arr), type(arr[1]))

val = [msgs, msgs[:-1]]
arr = np.empty(len(val), dtype=object)
arr[:] = val
print(type(arr), type(arr[1]))

结果和 fromiter 是一样的。注意,这里的 arr[:] = val 其实等价于:

1
2
for i, v in enumerate(val):
arr[i] = v

不过直接 : 赋值的方法会先尝试 broadcast,发现 dtype=object 时,broadcast 很宽松,只要长度一致即可,然后按元素逐个赋值。它底层是 C 语言执行,比 Python 能稍微快点。

##字典赋值

问题描述

第二个小问题是在使用 transformers[2] 时遇到的,代码:https://github.com/huggingface/transformers/blob/bdee0889714e9cb3e53d3b1b2a626919479d356c/src/transformers/tokenization_utils_base.py#L1675,如下:

1
2
3
4
5
6
7
8
if isinstance(conversation, (list, tuple)) and (
isinstance(conversation[0], (list, tuple)) or hasattr(conversation[0], "messages")
):
conversations = conversation
is_batched = True
else:
conversations = [conversation]
is_batched = False

遇到问题的源头就是这句:hasattr(conversation[0], "messages")

当然,这里的小问题不是 transformers 的问题,而是我自己用了一个字典赋值工具的问题。这个工具就是:mewwts/addict: The Python Dict that’s better than heroin.[3] 平时简单的脚本任务时偷懒(不创建数据对象类)用这个快速创建对象,并用 . 来读取 key,就像对象的属性一样。如下:

1
2
3
4
5
6
7
8
from addict import Dict

d = Dict({"a": 1})
d.a # 输出1

d = Dict()
d.a.b.c = 1
# d 此时为 {'a': {'b': {'c': 1}}}

很方便是吧。这就是错误来源啦!

1
2
3
4
5
dct = {
"messages": [{"role": "user", "content": "hello"}],
# ...还有很多其他参数
}
d = Dict(dct)

这里如果把 d.messages 传给 tokenizer,就会遇到我们开始时的那个hasattr(conversation[0], "messages")。这里的 conversation 就是上面消息的 list,第一个元素那就是唯一的那条消息。而 addict.Dict 的特性就是不关心 key 存不存在,如果不存在默认 value 就是空字典。所以这句 hasattr 结果为 True

结果很显然就有问题,我们以 Qwen/Qwen3-0.6B 为例。

1
2
3
4
5
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained("Qwen/Qwen3-0.6B")

tokenizer.apply_chat_template(d.messages, tokenize=False) # 输出为:['']
tokenizer.apply_chat_template(dct["messages"], tokenize=False) # 输出为:'<|im_start|>user\nhello<|im_end|>\n'

或者用其他 tokenizer,比如 Qwen/Qwen2.5-7B-Instruct,输出分别为:

1
2
3
['<|im_start|>system\nYou are Qwen, created by Alibaba Cloud. You are a helpful assistant.<|im_end|>\n']

'<|im_start|>system\nYou are Qwen, created by Alibaba Cloud. You are a helpful assistant.<|im_end|>\n<|im_start|>user\nhello<|im_end|>\n'

也就是说,使用了 addit.Dict 后,会破坏掉原有的判断逻辑,导致输出异常结果。

解决方法

很简单,使用其他类似工具即可,比如 OmegaConf,用法如下:

1
2
3
4
5
6
7
from omegaconf import DictConfig, OmegaConf

d2 = OmegaConf.create(dct)
d3 = DictConfig(dct)

tokenizer.apply_chat_template(d2.messages, tokenize=False)
tokenizer.apply_chat_template(d3.messages, tokenize=False)

这两种用法都可以正常输出。或者,我们需要稍微修改一下 addict.Dict 的源代码。

具体是修改这里:https://github.com/mewwts/addict/blob/75284f9593dfb929cadd900aff9e35e7c7aec54b/addict/addict.py#L66,如下:

1
2
3
4
5
6
7
def __getattr__(self, item):
return self.__getitem__(item)

def __missing__(self, name):
if object.__getattribute__(self, '__frozen'):
raise KeyError(name)
return self.__class__(__parent=self, __key=name)

我们可以将其修改为:

1
2
3
4
5
6
7
8
def __getattr__(self, item):
try:
return self[item]
except KeyError:
raise AttributeError(item)

def __missing__(self, name):
raise KeyError(name)

注意需要同时修改 __missing__ 方法,不要自动创建,直接抛出异常。这样修改后,输出也是正常的。

1
2
d4 = Dict(dct)
tokenizer.apply_chat_template(d4.messages, tokenize=False)

不过修改后,原来的一些好用的功能就失效了,比如,你就不能再这样写了:

1
2
d = Dict()
d.a.b.c = 1

但是这样依然是没问题的:

1
2
3
dct = {"a": {"b": {"c": 1}}}
d = Dict(dct)
d.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