环境:TensorRT-LLM 1.3.0rc6 / Qwen3-32B-NVFP4 / NVIDIA DGX Spark (GB10)
上游客户端:OpenClaw(个人 AI 助理框架)
背景
在 DGX Spark 上通过 TensorRT-LLM 部署 Qwen3-32B-NVFP4 后,模型可以正常对话,但上游客户端 OpenClaw 始终无法成功触发工具调用(tool calling / function calling)。表现为:模型收到带有 22 个工具定义的请求,却从不尝试调用任何工具,直接以纯文本回复。
TensorRT-LLM 的日志只记录 HTTP 状态码,不记录请求/响应体,无法直接定位问题。我们需要先搭建调试工具,再逐步排查。
调试工具:HTTP 代理抓包
TensorRT-LLM 默认日志不包含请求体和响应体,tcpdump 又需要 root 权限。我们编写了一个轻量级 Python HTTP 代理,部署在 8001 端口,转发到 TRT-LLM 的 8000 端口,记录完整的请求/响应(含流式 chunk)到 JSONL 文件:
# log_proxy.py - 核心逻辑
# 监听 8001,转发到 localhost:8000
# 记录请求体(POST body)和响应体(含 SSE 流式 chunk)
# 将所有数据写入 api_log.jsonl
部署后将 OpenClaw 的 API 地址改为 8001 端口,即可捕获完整流量。
问题诊断
通过代理日志,我们发现了 6 个问题,按严重程度排列:
P0: developer 角色被静默丢弃(致命)
OpenClaw 使用 role: "developer" 发送系统提示(这是 OpenAI 在 GPT-4.5 后引入的角色),包含所有工具定义和使用说明。但 Qwen3 的 chat template 只认识 system / user / assistant / tool 四种角色,遇到 developer 直接丢弃。
后果:模型根本不知道有工具可用。这是工具调用完全失败的根本原因。
证据(代理日志):
{
"messages": [
{"role": "developer", "content": "你是 OpenClaw 助手,可以使用以下工具..."},
{"role": "user", "content": "查看北京天气"}
],
"tools": [...]
}
P1: <think> 推理内容泄露到 content 字段
Qwen3 是推理模型,会在输出中生成 <think>...</think> 标签包裹的思维过程。TRT-LLM 需要配置 --reasoning_parser 来分离这些内容到 reasoning_content 字段。但当未显式配置该参数时(值为 None),推理解析器不会被激活。
后果:content 字段包含 <think> 标签和推理过程,客户端误以为是正常回复内容。同时 reasoning_content 返回空字符串 "" 而非 null。
P2: 流式响应的 chunk id 不一致
OpenAI 规范要求同一个请求的所有流式 chunk 共享相同的 id(如 chatcmpl-xxx)。TRT-LLM 的实现中,每个 chunk 会生成独立的 id。
后果:客户端无法将多个 chunk 关联为同一个响应,影响流式 tool_calls 的组装。
P3: tool_calls 默认值为空数组而非 null
TRT-LLM 的 ChatMessage.tool_calls 默认值为 [](空列表),OpenAI API 规范中,无工具调用时该字段应为 null(不存在)。
后果:客户端收到 tool_calls: [] 后可能误判为”有工具调用但为空”,而非”无工具调用”。
P4: 响应中包含非标准的 null 字段
TRT-LLM 的响应中包含多个 OpenAI API 规范外的字段:
{
"stop_reason": null,
"mm_embedding_handle": null,
"disaggregated_params": null,
"avg_decoded_tokens_per_iter": null,
"prompt_token_ids": null
}
后果:严格校验的客户端可能拒绝包含未知字段的响应。
P5: 回传 reasoning_content 导致输入验证 400 错误
P1 修复后,响应中正确包含了 reasoning_content 字段。但当 OpenClaw 将模型的回复(含 reasoning_content)回传到下一轮请求时,TRT-LLM 的输入验证无法识别该字段。
ReasoningAssistantMessage 类只定义了 reasoning 字段(必填),不接受 reasoning_content。Pydantic 的 Union 类型验证尝试所有类型均失败,返回 400。
后果:多轮工具调用完全中断。首轮 tool_call 成功 → 回传结果 → 第二轮请求 400 → 无限重试。在我们的日志中观察到 24 次连续 400 错误。
修复方案
涉及文件
| 文件 | 容器内路径 | 挂载方式 |
|---|---|---|
openai_protocol.py |
/usr/local/lib/python3.12/dist-packages/tensorrt_llm/serve/openai_protocol.py |
Docker volume mount |
postprocess_handlers.py |
同上目录 | docker cp |
openai_server.py |
同上目录 | docker cp |
P0 修复:developer 角色映射
在 ChatCompletionRequest 中添加 Pydantic model_validator,在验证前将 developer 角色映射为 system:
# openai_protocol.py - ChatCompletionRequest 类
@model_validator(mode="before")
@classmethod
def map_developer_role(cls, data):
for msg in data.get("messages", []):
if isinstance(msg, dict) and msg.get("role") == "developer":
msg["role"] = "system"
return data
P1 修复:推理内容分离 + 空字符串归一化
两步修复:
a) 激活推理解析器(openai_server.py):
# 原代码:
postproc_args.reasoning_parser = self.generator.args.reasoning_parser
# 修改为:
postproc_args.reasoning_parser = self.generator.args.reasoning_parser or self.tool_parser
当 --reasoning_parser 未配置时,回退使用 --tool_parser(即 qwen3),因为 Qwen3 的推理解析器与 DeepSeek R1 相同(均使用 <think> 标签)。
b) 空字符串归一化 + reasoning 字段排除(openai_protocol.py):
class ChatMessage(OpenAIBaseModel):
role: str
content: Optional[str] = None
reasoning_content: Optional[str] = None
reasoning: Optional[str] = Field(default=None, exclude=True) # 不序列化
tool_calls: Optional[List[ToolCall]] = None
@model_validator(mode="after")
def _normalize_fields(self):
if self.tool_calls is not None and len(self.tool_calls) == 0:
self.tool_calls = None
if self.reasoning_content is not None and self.reasoning_content == "":
self.reasoning_content = None
return self
P2 修复:流式 chunk id 统一
在 ChatPostprocArgs 中添加请求级别的唯一 ID,所有 chunk 共享:
# postprocess_handlers.py
import uuid
@dataclass
class ChatPostprocArgs:
# ... 其他字段 ...
_stream_chunk_id: str = field(
default_factory=lambda: f"chatcmpl-{uuid.uuid4().hex}")
在构造 ChatCompletionStreamResponse 时使用该 ID:
chunk = ChatCompletionStreamResponse(
id=args._stream_chunk_id, # 替代自动生成的随机 id
choices=[choice_data],
model=args.model
)
P3 修复:tool_calls 默认值改为 None
# 原代码:
tool_calls: List[ToolCall] = Field(default_factory=list)
# 修改为:
tool_calls: Optional[List[ToolCall]] = None
配合 _normalize_fields 验证器,确保空列表也被转为 None。
P4 修复:排除非标准 null 字段
使用 Pydantic 的 exclude=True 阻止非标准字段被序列化:
class ChatCompletionResponseChoice(OpenAIBaseModel):
# ...
stop_reason: Optional[Union[int, str]] = Field(default=None, exclude=True)
mm_embedding_handle: Optional[Dict[str, Any]] = Field(default=None, exclude=True)
disaggregated_params: Optional[DisaggregatedParams] = Field(default=None, exclude=True)
avg_decoded_tokens_per_iter: Optional[float] = Field(default=None, exclude=True)
class ChatCompletionResponse(OpenAIBaseModel):
# ...
prompt_token_ids: Optional[List[int]] = Field(default=None, exclude=True)
class DeltaMessage(OpenAIBaseModel):
# ...
reasoning: Optional[str] = Field(default=None, exclude=True)
class ChatCompletionResponseStreamChoice(OpenAIBaseModel):
# ...
stop_reason: Optional[Union[int, str]] = Field(default=None, exclude=True)
avg_decoded_tokens_per_iter: Optional[float] = Field(default=None, exclude=True)
P5 修复:接受 reasoning_content 输入
修改 ReasoningAssistantMessage 使其同时接受 reasoning 和 reasoning_content,且均为可选:
# 原代码:
class ReasoningAssistantMessage(ChatCompletionAssistantMessageParam):
reasoning: Optional[str] # 必填,不接受 reasoning_content
# 修改为:
class ReasoningAssistantMessage(ChatCompletionAssistantMessageParam, total=False):
reasoning: Optional[str]
reasoning_content: Optional[str]
total=False 使两个字段都变为非必填(TypedDict 的 NotRequired 语义)。
验证结果
修复前后对比:
| 指标 | 修复前 | 修复后 |
|---|---|---|
| 工具调用成功 | 0 次 | 8 次(exec, web_search, read, write, process) |
| 400 错误 | 24 次连续 | 0 次 |
<think> 泄露 |
有 | 无 |
| chunk id 统一 | 否(每 chunk 不同) | 是(每请求统一) |
| 非标准字段 | 5 个 null 字段 | 无 |
| 多轮对话 | 第二轮即 400 | 18 轮连续正常 |
使用补丁
环境要求
- TensorRT-LLM 1.3.0rc6(容器镜像
nvcr.io/nvidia/tensorrt-llm/release:1.3.0rc6) - 使用
--tool_parser qwen3参数启动
方法一:Docker Volume Mount(推荐,持久化)
适用于 openai_protocol.py,该文件可以通过 volume mount 覆盖容器内的原始文件:
# 1. 创建补丁目录
mkdir -p ~/trtllm-patches
# 2. 从容器提取原始文件
docker create --name tmp nvcr.io/nvidia/tensorrt-llm/release:1.3.0rc6
docker cp tmp:/usr/local/lib/python3.12/dist-packages/tensorrt_llm/serve/openai_protocol.py ~/trtllm-patches/
docker rm tmp
# 3. 应用补丁
cd ~/trtllm-patches
patch -p0 < openai_protocol.patch
# 4. 在 docker run 或 docker-compose 中挂载
docker run ... \
-v ~/trtllm-patches/openai_protocol.py:/usr/local/lib/python3.12/dist-packages/tensorrt_llm/serve/openai_protocol.py \
...
方法二:docker cp(容器重建后需重新执行)
适用于 postprocess_handlers.py 和 openai_server.py:
# 1. 提取、打补丁
docker cp <container>:/usr/local/lib/python3.12/dist-packages/tensorrt_llm/serve/postprocess_handlers.py ~/trtllm-patches/
docker cp <container>:/usr/local/lib/python3.12/dist-packages/tensorrt_llm/serve/openai_server.py ~/trtllm-patches/
cd ~/trtllm-patches
patch -p0 < postprocess_handlers.patch
patch -p0 < openai_server.patch
# 2. 复制回容器
docker cp ~/trtllm-patches/postprocess_handlers.py <container>:/usr/local/lib/python3.12/dist-packages/tensorrt_llm/serve/postprocess_handlers.py
docker cp ~/trtllm-patches/openai_server.py <container>:/usr/local/lib/python3.12/dist-packages/tensorrt_llm/serve/openai_server.py
# 3. 重启容器
docker restart <container>
补丁文件
以下三个 patch 文件基于 TensorRT-LLM 1.3.0rc6:
openai_protocol.patch(P0 + P1 + P3 + P4 + P5)
```diff --- orig/openai_protocol.py +++ openai_protocol.py @@ -491,8 +491,16 @@ role: str content: Optional[str] = None reasoning_content: Optional[str] = None - reasoning: Optional[str] = None - tool_calls: List[ToolCall] = Field(default_factory=list) + reasoning: Optional[str] = Field(default=None, exclude=True) + tool_calls: Optional[List[ToolCall]] = None + + @model_validator(mode="after") + def _normalize_fields(self): + if self.tool_calls is not None and len(self.tool_calls) == 0: + self.tool_calls = None + if self.reasoning_content is not None and self.reasoning_content == "": + self.reasoning_content = None + return self -class ReasoningAssistantMessage(ChatCompletionAssistantMessageParam): +class ReasoningAssistantMessage(ChatCompletionAssistantMessageParam, total=False): """Assistant message that includes reasoning tokens.""" reasoning: Optional[str] + reasoning_content: Optional[str] @@ -557,13 +566,10 @@ finish_reason: Optional[str] = None - stop_reason: Optional[Union[int, str]] = None - mm_embedding_handle: Optional[Dict[str, Any]] = None - disaggregated_params: Optional[DisaggregatedParams] = Field(default=None) - avg_decoded_tokens_per_iter: Optional[float] = Field(default=None) + stop_reason: Optional[Union[int, str]] = Field(default=None, exclude=True) + mm_embedding_handle: Optional[Dict[str, Any]] = Field(default=None, exclude=True) + disaggregated_params: Optional[DisaggregatedParams] = Field(default=None, exclude=True) + avg_decoded_tokens_per_iter: Optional[float] = Field(default=None, exclude=True) - prompt_token_ids: Optional[List[int]] = None + prompt_token_ids: Optional[List[int]] = Field(default=None, exclude=True) - reasoning: Optional[str] = None + reasoning: Optional[str] = Field(default=None, exclude=True) - stop_reason: Optional[Union[int, str]] = None - avg_decoded_tokens_per_iter: Optional[float] = Field(default=None) + stop_reason: Optional[Union[int, str]] = Field(default=None, exclude=True) + avg_decoded_tokens_per_iter: Optional[float] = Field(default=None, exclude=True) + @model_validator(mode="before") + @classmethod + def map_developer_role(cls, data): + for msg in data.get("messages", []): + if isinstance(msg, dict) and msg.get("role") == "developer": + msg["role"] = "system" + return data ```postprocess_handlers.patch(P2)
```diff --- orig/postprocess_handlers.py +++ postprocess_handlers.py @@ -1,3 +1,4 @@ +import uuid from dataclasses import dataclass, field @@ -62,6 +63,8 @@ chat_template_kwargs: Optional[dict[str, Any]] = None + _stream_chunk_id: str = field( + default_factory=lambda: f"chatcmpl-{uuid.uuid4().hex}") @@ -172,7 +175,8 @@ - chunk = ChatCompletionStreamResponse(choices=[choice_data], + chunk = ChatCompletionStreamResponse(id=args._stream_chunk_id, + choices=[choice_data], model=args.model) @@ -281,7 +285,8 @@ - chunk = ChatCompletionStreamResponse(choices=[choice], model=args.model) + chunk = ChatCompletionStreamResponse(id=args._stream_chunk_id, + choices=[choice], model=args.model) @@ -301,7 +306,8 @@ - final_usage_chunk = ChatCompletionStreamResponse(choices=[], + final_usage_chunk = ChatCompletionStreamResponse(id=args._stream_chunk_id, + choices=[], ```openai_server.patch(P1 推理解析器激活)
```diff --- orig/openai_server.py +++ openai_server.py @@ -747,7 +747,7 @@ - postproc_args.reasoning_parser = self.generator.args.reasoning_parser + postproc_args.reasoning_parser = self.generator.args.reasoning_parser or self.tool_parser ```社区现状
截至 2026 年 3 月,TensorRT-LLM 的工具调用支持仍处于早期阶段:
- #9784:Qwen3 tool_call 格式不一致,官方建议使用
--tool_parser qwen3,但未解决reasoning_content空字符串等问题 - #9256:GPT-OSS 模型 Harmony 格式 token 泄露(Open)
- #10651:缺失 stop token 导致工具调用场景内容泄露(Open)
如果工具调用是核心需求,建议同时评估 vLLM,它提供了 20+ 种模型专用的 tool parser,OpenAI API 兼容性更好,在 DGX Spark 上也有官方镜像支持。
致谢
感谢 NVIDIA TensorRT-LLM 团队提供的高性能推理引擎。本文的补丁基于 TRT-LLM 1.3.0rc6,随着上游的迭代,这些问题可能会在未来版本中得到官方修复。
补丁下载:https://pan.quark.cn/s/6d2008a775be 提取码:FIEj
正在加载评论...