当模型返回空 completion 时,JavaScript 报 Cannot read properties of null,Python 报 NoneType attribute error,根因多为工具调用返回而非文本。详细拆解三种空响应模式的辨别方法。
TypeError: Cannot read properties of null (reading 'trim') in JavaScript, or AttributeError: 'NoneType' object has no attribute 'strip' in Python. The stack points at your formatting code. The cause is two frames earlier, in a response you assumed always had text in it.
JavaScript 中的 TypeError: Cannot read properties of null (reading 'trim'),或 Python 中的 AttributeError: 'NoneType' object has no attribute 'strip'。堆栈指向你的格式化代码,但真正的原因在更早两帧——来自你以为总有文本的响应。
The error you pasted into search
你粘贴到搜索框的错误
Three error strings account for nearly all of these, and each one tells you something different about which shape you got.
三个错误信息几乎涵盖了所有这类情况,每个都告诉你得到了哪种响应形状。
Cannot read properties of null or 'NoneType' object has no attribute — the message object exists and its content field is null. This is the common one, and the usual cause is a tool call: the model returned a function invocation instead of text, and the text field is null by design.
Cannot read properties of null 或 'NoneType' object has no attribute —— 消息对象存在,但其 content 字段为 null。这是常见的情况,通常原因是工具调用:模型返回了函数调用而非文本,text 字段按设计就是 null。
IndexError: list index out of range or Cannot read properties of undefined on choices[0] — the choices array is empty. The response arrived, was well-formed, and contains nothing.
IndexError: list index out of range 或 Cannot read properties of undefined on choices[0] —— choices 数组为空。响应已到达,格式正确,但内容为空。
A validation error from your schema parser complaining that a required field is missing, when the model returned an empty string and JSON.parse was handed "". The reported error names your schema, which sends people to debug the wrong file.
你的 schema 解析器抛出一个校验错误,说某个必填字段缺失,而实际情况是模型返回了空字符串,JSON.parse 接收到的是 ""。报错信息中会提到你的 schema 名,这会把人引到错误的文件去调试。
None of these is a provider outage, and retrying blindly makes several of them worse. Which one you have determines whether a retry can help at all.
以上都不是提供商宕机,盲目重试反而会让其中几种情况更糟。你遇到的是哪一种,决定了重试是否有任何帮助。
Six ways a response can be empty
响应为空的六种方式
Write one fixture per shape. They are static JSON, they cost nothing, and they are the entire test suite for this bug.
为每种形状写一个 fixture。它们是静态 JSON,不花任何成本,却是你这个 bug 的完整测试套件。
Content null, tool call present. The normal shape for a tool-calling turn. Code that reads the text before checking for tool calls crashes on the happy path of a feature it already supports.
Content 为 null,但存在工具调用。这是工具调用轮次的正常形状。如果代码在检查工具调用之前就先去读取 text,就会对一个已支持功能的美妙路径崩溃。
Content an empty string, finish reason a normal stop. The model genuinely produced nothing. Common when the prompt ends in a way that makes an immediate stop token likely, or after a successful refusal that emitted no text.
Content 是空字符串,finish reason 是正常的 stop。模型真的什么都没生成。常发生在 prompt 以一种容易立即触发 stop token 的方式结尾时,或在一次成功拒绝但未发出任何文本之后。
Truncation at zero tokens. A length-based finish reason with no content, which happens when the output cap is very small or the prompt consumed the window. Retrying identically returns the same thing forever.
零 token 截断。基于长度的 finish reason 但没有 content,发生在输出上限非常小或 prompt 消耗了整个窗口时。完全相同地重试会永远返回相同的结果。
A content filter or refusal. The finish reason names a filter, or the message carries a separate refusal field with text in it while the content field is null. Reading only the content field loses the explanation you would want to show the user.
内容过滤或拒绝。finish reason 提到某个 filter,或者消息带有一个单独的 refusal 字段其中有文本,而 content 字段为 null。只读 content 字段会丢失你想展示给用户的解释。
An empty choices array. Rare, and it defeats every null check written against the message object because the crash is on the index.
空的 choices 数组。罕见,它击败了所有针对 message 对象写的 null 检查,因为崩溃发生在索引上。
Whitespace only. A newline, a space, a zero-width character. Not null, not empty by a length check, and still empty for your purposes. This one gets past the first three fixes people write.
仅有空白。换行符、空格、零宽字符。不是 null,长度检查也不认为空,但对你的用途来说仍然是空。这个会绕过人们写的头三个修复。
Providers differ in which of these they emit and what they name the reason field — one API family calls it a finish reason on the choice, another a stop reason on the message — so a codebase talking to more than one needs the normaliser below regardless of the crash.
提供商在发射哪些以及如何命名 reason 字段上各有不同——一个 API 系列把它叫作 choice 上的 finish reason,另一个叫作 message 上的 stop reason——所以一个代码库如果对接多个提供商,无论是否有崩溃都需要下面的标准化器。
One normaliser, tested against all of them
一个标准化器,用所有情况测试过
The wrong fix is a null check at the crash site. There will be four more crash sites, and each will get its own slightly different check. Put one function between the SDK and your code, give it a return type that makes the empty case impossible to ignore, and point every fixture at it.
错误的修复是在崩溃现场加一个 null 检查。还会有四个以上的崩溃现场,每个都会有自己略有不同的检查。在 SDK 和你的代码之间放一个函数,给它一个让空情况不可能被忽略的返回类型,然后把所有 fixture 指向它。
// normalise.ts
export type Completion =
| { kind: "text"; text: string }
| { kind: "tool_calls"; calls: ToolCall[] }
| { kind: "empty"; reason: EmptyReason; retryable: boolean };
export type EmptyReason =
| "no_choices" | "null_content" | "whitespace_only"
| "truncated" | "filtered" | "refusal";
export function normalise(res: unknown): Completion {
const choice = (res as any)?.choices?.[0];
if (!choice) return { kind: "empty", reason: "no_choices", retryable: true };
const calls = choice.message?.tool_calls;
if (Array.isArray(calls) && calls.length > 0) return { kind: "tool_calls", calls };
if (choice.message?.refusal)
return { kind: "empty", reason: "refusal", retryable: false };
const raw = choice.message?.content;
if (raw == null || String(raw).trim() === "") {
const fr = choice.finish_reason;
if (fr === "length") return { kind: "empty", reason: "truncated", retryable: false };
if (fr === "content_filter") return { kind: "empty", reason: "filtered", retryable: false };
return {
kind: "empty",
reason: raw == null ? "null_content" : "whitespace_only",
retryable: true,
};
}
return { kind: "text", text: String(raw) };
}
A discriminated union rather than a nullable string is doing real work here. Your call sites now cannot compile while ignoring the empty case, which is a stronger guarantee than any test, and the reason field turns a crash into a metric you can chart. The test is then one table:
一个区分联合体而不是可空字符串在这里做了真正的工作。你的调用点现在在忽略空情况时无法编译,这是一个比任何测试都强的保证,而 reason 字段把崩溃变成了可以绘图的指标。测试就是一张表:
it.each([
["tool call, null content", fixtures.toolCall, "tool_calls"],
["empty string", fixtures.emptyString, "empty"],
["truncated at zero", fixtures.truncated, "empty"],
["filtered", fixtures.filtered, "empty"],
["no choices", fixtures.noChoices, "empty"],
["whitespace only", fixtures.whitespace, "empty"],
["ordinary text", fixtures.text, "text"],
])("%s", (_n, res, kind) => expect(normalise(res).kind).toBe(kind));
it("does not treat a tool call as empty", () => {
expect(normalise(fixtures.toolCall).kind).toBe("tool_calls");
});
Note the ordering inside the function: tool calls are checked before content, and the refusal field before the content emptiness test. Get those the other way round and the tool-calling path reports itself as empty, which is the second-most-common version of this bug and the one discussed in a tool call that appears not to fire.
注意函数内部的顺序:工具调用在 content 之前检查,refusal 字段在 content 空性测试之前。如果把顺序搞反,工具调用路径会报告自己为空,这是这个 bug 第二常见的版本,也是那个看似没有触发的工具调用所讨论的情况。
Read the finish reason before you retry
在重试之前先读 finish reason
The retryable flag is the point of the whole exercise. An empty result from a truncation or a content filter will be empty again on an identical request, and retrying it three times triples the cost of a guaranteed failure — the arithmetic in retry cost applies directly.
retryable 标志是整个练习的关键。来自截断或内容过滤的空结果在相同请求下会再次为空,重试三次会把确定失败的代价乘以三——重试成本的算术直接适用。
Assert the classification, not just the emptiness. A test that says the filtered fixture is not retryable and the null-content fixture is has captured the actual decision. Then assert what the caller does with it: a non-retryable empty produces a user-visible message and a counter increment; a retryable empty goes through your normal backoff and, if it is still empty, surfaces the same way rather than looping. Cap it at one or two attempts, because a model that produced nothing twice with the same prompt is telling you about the prompt.
断言的是分类,而不只是空性。一个说 filtered fixture 不可重试而 null-content fixture 可重试的测试已经捕获了实际决策。然后断言调用方如何处理它:不可重试的空产生用户可见的消息和计数器递增;可重试的空走正常的退避策略,如果仍然为空,以同样方式浮出而不是循环。限制在一到两次尝试,因为用相同 prompt 两次什么都没生成的模型是在告诉你 prompt 有问题。
Empty streams are a different bug
空流是另一种 bug
If you stream, none of the above fires, because there is no response object to inspect — there is a sequence of events that ends without ever carrying content. The three cases worth fixtures are a stream that opens and closes with no delta at all, one that emits only role and finish events, and one that emits a finish reason and then no terminator.
如果你用流式,上述都不触发,因为没有响应对象可检查——而是一个事件序列,结尾从未携带过 content。值得写 fixture 的三种情况是:打开后没有任何 delta 就关闭的流、只发出 role 和 finish 事件的流、以及发出 finish reason 后没有终止符的流。
Test them by feeding a canned event sequence to your stream consumer rather than by opening a socket, and assert the consumer resolves with an empty classification instead of hanging. A stream that never finishes is the worst version of this: it holds a connection and a request slot, and it fails as a timeout somewhere unrelated. Give the consumer a deadline measured from the last received event, not from the start, and assert with fake timers that the deadline fires. Parsing structured output from a stream adds a further case: a stream that ends mid-object is empty as far as your parser is concerned, and the fixture for it is a truncated JSON prefix.
通过向你的流消费者喂一个罐装事件序列来测试它们,而不是打开一个 socket,并断言消费者解析出一个空分类而不是挂起。永远不结束的流是最糟糕的版本:它占用一个连接和一个请求槽,然后在某个不相关的地方以超时失败。给消费者一个从最后收到的事件开始计算的最后期限,而不是从开始,并用假计时器断言最后期限会触发。从流解析结构化输出增加了一个额外情况:一个在对象中间结束的流对你的解析器来说就是空的,它的 fixture 是一个截断的 JSON 前缀。
Testing That a Model Never Returns a Value Outside an Enum
Testing Partial Failures in a Batch Inference Job
Testing the Fallback Prompt Your App Uses When the Primary Model Errors
For further actions, you may consider blocking this person and/or reporting abuse