
上周我把一个 Function Calling 任务切到 deepseek-v4.1-flash本地跑得好好的一上远端就开始 422。第一反应是参数写错了翻来覆去检查代码没看出问题直到把底层 HTTP 请求体完整 dump 出来才发现 tool 角色的 content 在序列化时被写成了 null直接被网关的非空校验拦下。这篇就把我从报错、定位到修复的完整链路写清楚。包括为什么网关要做这个校验、SDK 为什么容易踩中、正确写法和一套可以在请求发出前拦住坏消息的校验器。如果你也在接 v4.1-flash 这类带 Function Calling 的模型或者遇到任何看起来没啥问题但就是 422的情况这篇应该能帮你省掉一下午排查时间。1. 422 不是网络问题是网关把请求打回来了1.1 先看现场日志里那一行红色我在集成 deepseek-v4.1-flash 的多轮工具调用时第二轮请求开始稳定返回 422。当时日志里长这样POST /v1/chat/completions 422 420ms { error: { message: Invalid parameter: messages[3].content must be a non-empty string when role is tool., type: invalid_request_error, param: messages[3].content } }稍微说明一下HTTP 422 在 REST 语义里叫 Unprocessable Entity意思是请求语法没问题但实体内容过不了业务校验。放到 LLM API 场景下就是网关的请求校验层认为你的 messages 数组不符合接口协议直接拒绝调用根本没走到模型推理那一步。很多人在这一步会做两件浪费时间的事一是怀疑网络代理或超时配置二是反复重启服务。这两个方向都跑偏了。422 和连接层没有任何关系它就是业务层校验拒绝问题一定出在请求体本身。1.2 理解 422 之前先理解 LLM 网关的请求校验层现在的模型 API 普遍会做两层校验。第一层是 HTTP 层检查 URL、鉴权、Content-Type、请求体是不是合法 JSON这层挂了一般返回 400 或 401。第二层是协议层针对 chat/completions 接口的 messages 结构做严格检查包括字段类型、角色合法性、必填项是否存在。这层不合格返回的就是 422。协议层校验通常不是模型自己实现的而是网关侧基于 OpenAPI Schema 或者自定义校验规则在做。它不会理解你的业务语义只是按 Schema 一项项检查字段。所以报错信息里的messages[3].content这种定位本质上是在说你把 messages 数组第 4 条消息的 content 字段传成了不符合规定的值。问题是很多 SDK 的日志只打请求失败 422不会自动展示底层请求体。如果只盯着 SDK 层看很难发现问题。我第一次就对messages[3]这条消息反复检查了很久消息内容明明在代码里写得好好的后来才发现是序列化环节出了岔子。2. 完整还原一次 tool 角色 content 为空的坏请求2.1 坏请求长什么样为了把问题说清楚我把当时实际发出去的请求体简化一下。注意这不是最终修复版而是让网关返回 422 的那个版本。{ model: deepseek-v4.1-flash, messages: [ { role: user, content: 查一下订单 20240089 的发货状态 }, { role: assistant, content: null, tool_calls: [ { id: call_abc123, type: function, function: { name: query_order_status, arguments: {\order_id\: \20240089\} } } ] }, { role: tool, tool_call_id: call_abc123, content: null } ], tools: [ { type: function, function: { name: query_order_status, description: 查询订单状态, parameters: { type: object, properties: { order_id: { type: string } }, required: [order_id] } } } ] }这里最致命的一行是最后一条消息的content: null。我当时的代码逻辑是从数据库取查询结果结果这一单的物流接口确实没返回文本Python 侧拿到的是None塞进字典后直接序列化就成了 null。坏请求整体上有四个特征我逐个拆开说。2.2 我踩进去的四个坑位null、缺键、空白字符串、顺序错乱第一个坑是 content 为 null。这也是最容易被忽略的。代码里写{role: tool, tool_call_id: call_id, content: result}如果result为None序列化后就是 null。网关校验时发现你声明了 roletool但 content 不是字符串直接拒绝。修复方式不是删掉 content而是必须给它一个合法的字符串兜底。第二个坑是 content 缺键。有些 SDK 在消息构造时会自动省略值为 null 的字段导致序列化后的 tool 消息里压根没有 content 这个键。更麻烦的是有些 SDK 的行为不统一有的版本省略 null有的版本显式输出 null有的版本把空字符串也省略。如果你在排查时只检查了content 是否存在没检查content 是否合法很容易漏掉这种情况。v4.1-flash 的校验器对字段缺失和字段为 null是分别处理的报错信息可能都是must be a non-empty string但根因不同。第三个坑是 content 为纯空白字符串。我当时修完 null 之后为了省事直接传了 结果还是 422。这次报错信息变成了content 不能为空白文本或等价的提示。因为校验逻辑通常分两层类型层要求 string内容层要求不可为空白。也就是说content: 在某些宽松网关里能过但在 v4.1-flash 的严格模式下也会被拒。所以兜底值最好用一句有实际含义的文本比如该工具调用未产生文本结果而不是空字符串或空格。第四个坑是消息顺序错乱。这个坑不一定报 content 错误但我建议在看到 422 时一起排查。Function Calling 协议对消息顺序有隐含要求tool消息必须紧跟对应的assistant消息之后而assistant消息必须通过tool_calls带着与 tool 消息相同的tool_call_id。有的 SDK 在多线程并发写消息列表时会打乱顺序有的开发者在拼消息时把 tool 消息放到了数组最前面都会触发这类校验失败。3. 为什么要非空校验tool 消息在 Function Calling 链路里的定位3.1 tool 消息是被缝合进上下文的执行结果很多开发者不理解为什么 roletool 的 content 非要搞非空校验其他角色的 content 传空字符串往往没事。要理解这点得先搞清楚 tool 消息在 Function Calling 链路里到底扮演什么角色。正常链路是这样的第一轮请求里模型在需要工具时返回一个 assistant 消息里面带tool_calls但 content 通常是 null。然后你在本地执行真实工具拿到结果把结果作为一条 roletool 的消息附加到 messages 数组再原样提交第二轮请求。模型读到 tool 消息后才能结合工具输出生成对用户的最终回答。也就是说tool 消息是工具执行结果进入上下文的关键通道。如果这条消息的 content 是 null 或缺失模型就相当于拿到了一个执行过但不知道结果是什么的信号上下文出现了结构性残缺。网关做非空校验本质上是在保护模型侧的消息一致性防止用户把残缺对话序列发进来造成不可预期的生成结果。3.2 非空校验的动机防止消息结构失序与会话上下文残缺更进一步看非空校验不只是礼貌性检查它在做三件事。第一件事是类型保真确保 messages 数组里的每个字段都符合协议 Schema避免模型端拿到无法解析的字段类型。第二件事是上下文保序通过强制要求 tool 消息必须有实际内容阻断执行结果为空导致后续消息对不上的连锁问题。第三件事是兼容旧协议不同模型的工具调用协议细节不同网关侧用统一非空校验把不合规请求挡在模型之前既保护模型也保护调用方。我在排障时还验证过一个细节如果只是assistant消息里 content 为 null但带上了合法的 tool_callsv4.1-flash 是接受的。因为 assistant 消息在这个场景里的 content 本来就可以为空这个位置上的语义本来就是我要调用工具不直接回复。但 tool 消息不一样它必须携带有效载荷。消息角色content 可为 null 吗原因user通常不行用户输入是会话的基础载荷assistant带 tool_calls 时可以此时目标是调用工具而非直接回复tool不允许它是工具执行结果的唯一载体必须有效3.3 SDK 为什么容易把 content 序列化成 null这个问题在本地代码里很难肉眼发现因为你在内存里看到的是完整字典但发出去时已经过了一层序列化。Python 的json.dumps默认会把None序列化成nullJavaScript 的JSON.stringify会把undefined对应的键直接删掉而不是输出 null两个语言行为还不一样。所以同样的业务代码逻辑在 Python 侧报的是content 为 null在 Node 侧可能报的是content 字段不存在。很多团队在多语言对接同一模型时会出现同一套故障在不同环境表现不同本质原因就是序列化器差异。我后来在排查时把 SDK 请求体完整 dump 出来看才发现问题出在业务结果到消息字段的转换层底层函数返回 None上层直接把它当 content 塞进结构体。这种裸塞写法在常规 API 调用里没问题因为普通接口拿到 None 可能就跳过了但 Function Calling 的严格校验会暴露它。4. 我一步步定位根因从响应体 error.param 到最小化复现4.1 第一步把 SDK 的日志丢掉直接导出底层 HTTP 请求体遇到 422 后第一个动作是停止猜测把真实请求体抓出来。如果你用的 SDK 支持请求钩子或中间件优先在那里打印 body。以 Python 为例可以写个简单的辅助函数import json from openai import OpenAI client OpenAI( api_keyyour-api-key, base_urlhttps://your-gateway.example.com/v1, timeout30, ) # 需要依赖库的日志钩子将请求打到 stdout def debug_request(request): body request.body.decode(utf-8) if isinstance(request.body, bytes) else request.body print(json.dumps(json.loads(body), ensure_asciiFalse, indent2)) client.with_options(default_headers{}).request_options {_debug: debug_request}如果 SDK 不支持这种钩子最省事的办法是在网关前面加一层 HTTP 代理或者直接关掉 SDK 改用requests/httpx发原始请求。总之目标是看到完整的 messages 数组不是 SDK 帮你简化过的对象。这一步最花时间的地方在于环境本地复现不出来往往是因为本地测试数据里工具函数恰好都有返回值而上线后的真实数据里有一部分结果为空。建议直接在测试环境准备一条会返回空结果的工具链来复现。4.2 第二步用本地 JSON Schema 预检消息结构看到请求体之后不要人眼硬扫。写一个本地 JSON Schema 或直接跑一个字段检查函数把 messages 数组每个字段的类型过一遍。我当时写了一个粗暴的检查器效果很好def validate_tool_messages(messages): errors [] for i, msg in enumerate(messages): if msg.get(role) tool: content msg.get(content) if content is None: errors.append(fmessages[{i}].content is null) elif not isinstance(content, str): errors.append(fmessages[{i}].content is not str: {type(content)}) elif not content.strip(): errors.append(fmessages[{i}].content is blank) if not msg.get(tool_call_id): errors.append(fmessages[{i}].tool_call_id missing) return errors把坏请求跑一遍它会直接列出所有问题字段不用盯着 JSON 一点点看。这个检查器后来被我做成了正式工具函数放在 6.1 节详细说明。这一步的核心思路是把协议检查前置到本地而不是依赖网关反馈。4.3 第三步构造最小化请求二分定位如果检查器没报错但网关还是 422问题可能出在你没注意的字段上比如 tools 结构、temperature 类型、stream 参数。这时需要构造最小化请求做二分定位。我先构造一个只包含 user 消息的最小请求能通说明模型和基础参数没问题。再加一条带 tool_calls 的 assistant 消息能通说明工具声明和 assistant 结构没问题。最后加上 tool 消息这一步通常就复现 422 了。如果最后一步报了 content 相关错误根因基本锁定如果没报再考虑是其他字段的问题。curl -X POST https://your-gateway.example.com/v1/chat/completions \ -H Authorization: Bearer your-api-key \ -H Content-Type: application/json \ -d { model: deepseek-v4.1-flash, messages: [ {role: user, content: test}, { role: assistant, content: null, tool_calls: [{ id: call_1, type: function, function: {name: get_time, arguments: {}} }] }, {role: tool, tool_call_id: call_1, content: ok} ], tools: [{ type: function, function: { name: get_time, description: get current time, parameters: {type: object, properties: {}} } }] }注意这里我用content: ok这个请求应该是能通过的。然后你把ok改成null试试会发现返回 422根因瞬间就清楚了。4.4 第四步验证结果并发现第二个隐藏条件 tool_call_id在第三步确认 content 是根因后我做了修复结果还是偶发 422。继续抓请求体发现真正漏掉的还有tool_call_id。有一段老代码在组装 tool 消息时只从工具执行结果里取返回值没把 assistant 消息里的 tool_call_id 传递过来。当函数声明了多个工具调用时这个字段一旦缺失或对不上网关也会返回 422而且报错信息可能是tool_call_id does not match any prior tool_call。所以排查和修复时必须把tool_call_id当成与 content 同等重要的校验项。很多团队只盯着 content 修修完继续踩 tool_call_id 的坑反而觉得是同一个问题反复发作。实际上这是两个独立字段只是触发点一样。5. 正确写法消息组装与序列化的最终规范5.1 方案 Acontent 永远返回可读内容最简单也最稳妥的方案是在业务代码里给工具执行结果统一加一层兜底转换。不要直接contentresult而是显式判断后传入一个非空字符串。def tool_result_to_content(result): if result is None: return 该工具调用执行成功但没有返回文本内容。 if isinstance(result, str): return result.strip() or 该工具调用执行成功返回内容为空。 if isinstance(result, (dict, list)): return json.dumps(result, ensure_asciiFalse, defaultstr) return str(result)要点是任何时候都不要把 None 直接塞进 content。即使工具没有返回值也要生成一句语义通顺的占位文本。这样模型能看到工具执行了结果为空这个信息而不是看到结构残缺的消息。这个方法适用范围最广因为无论网关校验严格与否它都能通过。5.2 方案 B序列化兜底方案在封装层强制类型转换如果业务代码里工具调用点太多逐个改不现实可以在消息序列化层统一做兜底。写一个自定义消息构建函数把所有 tool 消息统一经过处理def build_tool_message(tool_call_id, content): if content is None: content 该工具调用执行成功但没有返回文本内容。 elif isinstance(content, str): if not content.strip(): content 该工具调用执行成功返回内容为空。 else: content json.dumps(content, ensure_asciiFalse, defaultstr) return { role: tool, tool_call_id: tool_call_id, content: content, }这个方案的核心是:在消息进入 messages 数组之前就保证 content 的值为非空字符串。它比方案 A 更集中适合已经有统一消息组装层的项目。缺点是如果哪天决定不用模型接口了这层封装会变成无用代码所以建议把函数命名贴近业务语义而不是模型协议。5.3 消息顺序与 tool_call_id 的固定套路除了 content正确写法还包含两个配套规则。第一个规则是消息顺序固定为user - assistant(带 tool_calls) - tool - assistant(最终回复)。不要在中间插其他消息尤其不要在 tool 消息之后马上塞一条 user 消息再发 assistant。顺序乱了校验器不一定报 content 错但大概率会在后续轮到问题。第二个规则是 tool_call_id 必须从 assistant 消息的 tool_calls 里原样复制不能自己生成也不能取错字段。一个工具调用对应一个 tool_call_id多个并行工具调用时tool 消息数量要与 assistant 消息里的 tool_calls 数量一致。写代码时最好直接遍历 assistant 消息里的 tool_calls 来生成返回列表assistant_msg response.choices[0].message tool_results await asyncio.gather(*[execute_tool(call.function) for call in assistant_msg.tool_calls]) for call, result in zip(assistant_msg.tool_calls, tool_results): messages.append(build_tool_message(call.id, result))用 zip 天然保证 id 和 result 对应避免手写索引出错。如果工具执行结果是异步的一定要等全部完成后再组合消息绝不能边执行边 append 半成品。5.4 错误姿势清单四种一眼就能看出的问题写法以下这四种写法都属于以后还会 422的典型姿势建议直接对照排查。第一种是contentNone裸传。很多新手在代码里写contentresult默认 result 一定有内容结果业务数据一转就是 None。第二种是content空字符串。部分宽松网关接受但 strict 模式可能拒绝。第三种是content 和多个空格。本地测试以为没问题后端 trim 后判定为空。第四种是tool_call_id自己 UUID 生成。这个坑最隐蔽因为你看着每轮调用的 id 都不同实际协议要求它必须等于 assistant 返回的 tool_call_id。错误姿势网关侧表现正确做法content Nonecontent 为 null 校验失败兜底为非空字符串content strict 模式下判空使用语义占位文本content trim 后判空同上tool_call_id 自定义生成id 不匹配前序调用从 assistant.tool_calls 原样复制6. 用一次封装把 422 消灭在网关之前6.1 我给消息组装写了一个本地校验器修完这次问题之后我意识到人工检查总会漏干脆把消息校验搓成一个独立函数放在公共模块里。每次组装完 messages 数组发请求之前先跑一遍校验有问题直接抛异常免得让网关来教育我们。class ToolMessageValidationError(ValueError): pass def ensure_valid_messages_for_chat(messages: list[dict]) - list[dict]: if not messages: raise ToolMessageValidationError(messages 不能为空) for i, msg in enumerate(messages): role msg.get(role) if role tool: content msg.get(content) if content is None: raise ToolMessageValidationError(fmessages[{i}] content 为 null) if not isinstance(content, str): raise ToolMessageValidationError( fmessages[{i}] content 类型为 {type(content).__name__}应为 str ) if not content.strip(): raise ToolMessageValidationError(fmessages[{i}] content 为空白字符串) if not msg.get(tool_call_id): raise ToolMessageValidationError(fmessages[{i}] tool_call_id 缺失) if role assistant and msg.get(tool_calls): content msg.get(content) if content is not None and not isinstance(content, str): raise ToolMessageValidationError( fmessages[{i}] assistant content 类型错误应为 str 或 null ) return messages使用的时候直接ensure_valid_messages_for_chat(messages)包裹一行如果抛异常就断在这里。不要用 try 包着继续发请求校验不通过说明代码逻辑有问题早暴露早修。6.2 校验器的回归测试覆盖最常见坏消息有了校验器还得测。我在项目里给校验器配了十几条测试用例其中四条是必测的content 为 None、tool_call_id 缺失、只传空字符串、正常的多轮工具消息通过。这四条用例把最容易踩的坑都覆盖了以后有人改坏了消息组装逻辑测试会第一时间变红。测试本身不复杂核心思路是由校验器拦截错误而不是靠网关反馈。因为网关反馈跨网络、耗时、且报错信息不一定直接指向代码位置。本地校验器可以在毫秒级完成同样的校验并把错误定位到具体的 messages 索引和字段。另外建议在测试环境加一条回归冒烟用例跑一次完整的工具调用闭环确认 assistant 返回的 tool_call_id 能和后续 tool 消息对上。很多时候 422 不是单条消息问题而是链路拼接问题只测单条消息测不出来。6.3 如果再遇到 422按这个顺序查以后不管换什么模型只要遇到 422我建议按这个固定顺序排查能省大量时间。第一步打印完整请求体用本地校验器跑一遍 messages。第二步确认所有 tool 消息的 content 都是非空字符串。第三步确认所有 tool_call_id 都来自 assistant 返回没有手写。第四步确认消息顺序与角色序列正确。第五步构造最小化请求做二分一个一个字段加回去。只要按这个顺序走绝大多数 422 都能在十分钟内定位。我自己现在遇到任何模型接口的协议类报错都会先做这一步而不是先开网络抓包。因为协议校验类问题基本与网络无关把请求体结构化地看一遍往往比追踪链路更快。最后分享一个小经验工具调用链路的代码一定要把消息组装和工具执行拆成两个函数一个负责业务逻辑一个负责协议合规。协议合规那层要写得特别保守所有可空字段都显式兜底。这样以后再换模型或接新能力只需要调整协议层业务函数一行不动。