执行摘要
- 一句话:归一化工具消息内容从数组格式到字符串,修复GLM5.1等聊天模板问题。
- 推荐动作:建议工程师精读此PR,重点关注
normalize_tool_content函数的设计决策,如如何通过检查type == "text"来区分文本部分和结构化列表,以及单元测试的全面覆盖,这对于处理API兼容性问题和消息格式归一化有借鉴意义。
功能与动机
根据OpenAI API规范,工具消息的content字段支持字符串或内容部分数组两种格式。许多客户端(如Claude Code / Cursor)使用数组格式,但模型聊天模板(如GLM-5和GLM-5.1的Jinja模板)只正确处理字符串格式,导致工具结果无法正确渲染,模型持续生成工具调用循环。SGLang作为OpenAI兼容API层应接受所有有效格式,并归一化为字符串以确保模板工作,避免逐个修补上游模板。
实现拆解
- 新增归一化函数:在
python/sglang/srt/entrypoints/openai/serving_chat.py中新增normalize_tool_content函数,检查消息角色是否为"tool"且内容是否为列表,如果是,则验证所有部分是否为OpenAI文本部分(类型为"text"的字典或字符串),若是则拼接为字符串(使用空格分隔),否则保留原列表。
- 集成到消息处理流程:在
_apply_jinja_template方法中调用normalize_tool_content,在消息处理流程中归一化工具消息内容,确保传递给聊天模板的内容为字符串格式。
- 添加单元测试:在
test/registered/openai_server/basic/test_serving_chat.py中添加TestNormalizeToolContent单元测试类,覆盖多种场景:文本部分扁平化、多部分拼接、非文本部分保留、字符串内容不变、空列表返回空字符串、非工具角色不变、混合字符串和字典部分,确保逻辑正确性和边缘情况处理。
关键文件:
python/sglang/srt/entrypoints/openai/serving_chat.py(模块 OpenAI服务;类别 source;类型 core-logic;符号 normalize_tool_content): 核心逻辑文件,新增归一化函数并在消息处理中调用,确保工具消息内容从数组格式转换为字符串。
test/registered/openai_server/basic/test_serving_chat.py(模块 测试套件;类别 test;类型 test-coverage;符号 TestNormalizeToolContent, test_openai_text_parts_flattened, test_multiple_text_parts_joined, test_non_text_part_list_preserved): 测试配套文件,添加单元测试验证归一化逻辑的正确性和覆盖各种场景。
关键符号:normalize_tool_content
关键源码片段
python/sglang/srt/entrypoints/openai/serving_chat.py
核心逻辑文件,新增归一化函数并在消息处理中调用,确保工具消息内容从数组格式转换为字符串。
def normalize_tool_content(role: str, content):
"""Normalize tool message content from OpenAI array format to plain string.
OpenAI clients may send tool content as a list of content parts
(e.g. [{"type":"text","text":"..."}]) but most chat templates expect
a plain string for tool messages. Only flatten when ALL items are
pure OpenAI text parts; preserve lists containing non-text-type items
that some templates intentionally iterate over.
"""
if role != "tool" or not isinstance(content, list):
return content # 非工具角色或非列表内容直接返回,无需处理
parts = content
is_openai_text_parts = all(
(isinstance(p, dict) and p.get("type") == "text") or isinstance(p, str)
for p in parts
) # 检查所有部分是否为OpenAI文本部分(字典类型为"text"或字符串)
if is_openai_text_parts:
text_parts = [p.get("text", "") if isinstance(p, dict) else p for p in parts]
return " ".join(text_parts) # 拼接为字符串,使用空格分隔以保持一致性
return content # 否则保留原列表,如包含非文本部分的结构化列表
test/registered/openai_server/basic/test_serving_chat.py
测试配套文件,添加单元测试验证归一化逻辑的正确性和覆盖各种场景。
class TestNormalizeToolContent(unittest.TestCase):
"""Unit tests for normalize_tool_content()."""
def test_openai_text_parts_flattened(self):
# 测试单个OpenAI文本部分数组被扁平化为字符串
result = normalize_tool_content("tool", [{"type": "text", "text": "10525"}])
self.assertEqual(result, "10525")
def test_multiple_text_parts_joined(self):
# 测试多个文本部分拼接为字符串
result = normalize_tool_content(
"tool",
[{"type": "text", "text": "hello"}, {"type": "text", "text": "world"}],
)
self.assertEqual(result, "hello world")
def test_non_text_part_list_preserved(self):
# 测试非文本部分列表被保留,如结构化工具语义字段
content = [{"name": "func", "output": "result"}]
result = normalize_tool_content("tool", content)
self.assertIs(result, content) # 确保原列表引用不变
# 其他测试方法类似,覆盖字符串内容不变、空列表、非工具角色、混合部分等场景
评论区精华
Review中主要讨论了三个点:注释准确性、分隔符一致性和单元测试覆盖。JustinTong0323指出原注释误描述过滤逻辑(基于name/output字段),建议修正为'preserve lists containing non-text-type items',同时建议分隔符使用空格以与jinja_template_utils.py保持一致;作者在第二个commit中响应了这些建议,调整了注释和分隔符。ShangmingCai询问是否适用于dpsk v32,作者回复dpsk v32不使用聊天模板机制,因此本修复不影响其特殊处理。所有讨论点均已解决,无未解决疑虑。
- 注释准确性和分隔符一致性 (design): 作者修正了注释以准确描述基于
type == "text"的过滤逻辑,并将分隔符从换行符改为空格,确保代码一致性。
- 单元测试覆盖 (testing): 作者添加了TestNormalizeToolContent类,包含七个测试方法,全面覆盖了文本部分扁平化、非文本部分保留等场景。
- dpsk v32适用性 (correctness): 确认本修复仅针对使用聊天模板的模型,dpsk v32由于其特殊处理不受影响,无需额外调整。
风险与影响
- 风险:技术风险较低:回归风险小,因为只针对工具消息内容进行归一化,且保留了非文本部分列表,不影响其他角色或格式;性能影响可忽略,新增逻辑简单,仅增加少量条件检查和字符串操作;无安全风险;兼容性方面,改善了OpenAI API兼容性,但需注意dpsk v32等不使用模板的场景不受影响,设计决策已明确区分。
- 影响:对用户而言,使用OpenAI兼容客户端的用户将看到工具调用结果正确传递,避免无限循环问题,提升工具使用体验;对系统,修复了关键功能缺陷,确保模型能接收工具结果,提高稳定性和兼容性;对团队,通过集中修复避免了逐个修补模型模板的维护成本,简化了API层处理逻辑。
- 风险标记:格式兼容性修复, 已添加测试覆盖
关联脉络
参与讨论