调用对话接口时,有时返回的文本写到一半就停了:列表缺了最后几条,JSON 少了右括号,或一段说明在句子中间断开。常见的应对是再发一句「请继续」。偶尔能接上,但也会遇到:
1. 它其实已经正常结束,再让它继续,会重复一段,或另起一个无关开头;
2. 结束原因是安全策略或工具调用,这时去续写没有意义,还可能把两次结果错误拼在一起;
3. 一直「请继续」,会多次产生用量,并且不一定能补完整。
这篇只处理一件事:**先读 `finish_reason`,再决定这次输出能不能直接用、要不要续写一次,还是该缩短范围后重新请求。** 11-10 那篇讲过流式 SSE 里如何把增量拼起来,并提到过这个字段;本稿不再解析事件流,只讨论非流式响应里看到该字段之后怎么处理。接口地址和字段路径都是占位,以你使用的服务文档为准。
## 1. 先分清「写完了」和「被截断了」
在常见的对话接口里,这一轮为什么结束,会写在选项的 `finish_reason` 里。名称不一定完全相同,但通常能对照到下面几类:
- `stop`:模型认为已经自然结束,一般可当完整结果;
- `length`:达到长度限制,输出很可能不完整;
- `content_filter`:被安全策略打断,不要当成普通截断去续写;
- `tool_calls` 或 `function_call`:进入工具调用,本稿不处理,应走工具协议。
HTTP 200 只表示请求成功,不表示文本完整。没有这个字段时,也不要默认它被截断。部分服务会使用 `max_tokens`、`stop_reason` 等名称;若无法映射到上面几类,按未知原因处理,不要自动续写。
```python
TERMINAL_OK = {"stop"}
TRUNCATED = {"length"}
FILTERED = {"content_filter"}
TOOL_CALL = {"tool_calls", "function_call"}
def extract_choice(body):
try:
choice = body["choices"][0]
except (KeyError, IndexError, TypeError) as e:
raise ValueError(f"响应里没有 choices: {e!r}") from e
message = choice.get("message") or {}
content = message.get("content") or ""
reason = choice.get("finish_reason")
return content, reason
def classify_reason(reason):
if reason in TERMINAL_OK:
return "complete"
if reason in TRUNCATED:
return "truncated"
if reason in FILTERED:
return "filtered"
if reason in TOOL_CALL:
return "tool"
return "unknown"
```
`classify_reason` 把文档里的取值收成五种分支:`complete`、`truncated`、`filtered`、`tool`、`unknown`。后面的策略只针对这五种,避免在业务代码里散落一串字符串判断。只有明确的正常终止,才能当作可用的完整正文;工具调用不要伪装成普通完成。
## 2. 被截断时,只自动续写一次
确认是 `length` 之后,可以把已经输出的部分作为助手消息加回去,再请它从断点处补完。建议只自动做 **一次**。正式环境里的 `client` 可以是 `httpx.Client()`,或其他提供 `post()` 的客户端;下面的函数只依赖这个方法,便于用模拟响应核对分支。
```python
API_URL = "https://your-api-host.example.com/v1/chat/completions"
CONTINUE_ONCE = (
"上面的内容在长度限制处被截断了。"
"请从断点处继续写完,不要重复已经输出的部分,不要另起一个开头。"
)
SHORTEN_FORMAT = (
"请只用短列表重写,最多 {n} 条,每条不超过 30 字。"
"只保留结论,不要前言和总结段。不要补充我没提供的事实。"
)
def complete_or_continue(client, headers, payload, *, max_items_when_shorten=8):
resp = client.post(API_URL, json=payload, headers=headers)
resp.raise_for_status()
content, reason = extract_choice(resp.json())
status = classify_reason(reason)
if status == "complete":
return {
"ok": True,
"content": content,
"finish_reason": reason,
"continued": False,
"outcome": "complete",
}
if status != "truncated":
return {
"ok": False,
"content": content,
"finish_reason": reason,
"continued": False,
"outcome": status,
"error": "结束原因不明或不适合自动续写,需要人工查看",
}
continued_payload = {
**payload,
"messages": list(payload["messages"])
+ [
{"role": "assistant", "content": content},
{"role": "user", "content": CONTINUE_ONCE},
],
}
resp2 = client.post(API_URL, json=continued_payload, headers=headers)
resp2.raise_for_status()
content2, reason2 = extract_choice(resp2.json())
status2 = classify_reason(reason2)
if status2 in {"filtered", "tool", "unknown"}:
errors = {
"filtered": "续写后被安全策略打断,已停止自动续写,未拼接第二次结果",
"tool": "续写后进入工具调用,应走工具协议,未拼接第二次结果",
"unknown": "续写后的结束原因缺失或未知,已停止自动续写,未拼接第二次结果",
}
return {
"ok": False,
"content": content,
"finish_reason": reason2,
"continued": True,
"outcome": status2,
"error": errors[status2],
}
prefix_len = 80
if len(content) >= prefix_len and content2.startswith(content[:prefix_len]):
merged = content2
overlap = True
else:
merged = content + content2
overlap = False
if status2 == "complete" and not overlap:
return {
"ok": True,
"content": merged,
"finish_reason": reason2,
"continued": True,
"outcome": "complete",
}
return {
"ok": False,
"content": merged,
"finish_reason": reason2,
"continued": True,
"overlap": overlap,
"outcome": "incomplete",
"error": "续写一次后仍不完整,或第二次从头重写,已停止自动续写",
"shorten_hint": SHORTEN_FORMAT.format(n=max_items_when_shorten),
}
```
三点补充:
1. **不要在循环里一直「请继续」。** 第二次仍是 `length`,或它又从标题重写了一遍,就停止自动续写。
2. **拼接前看一眼是否重复。** 上面用一个很保守的开头比对:如果第二次明显从原文开头重来,就不要再把两段接在一起。更复杂的去重可以以后再加,但不要在没检查时直接拼接。若第二次是安全策略、工具调用,或结束原因缺失、未知,不要把两次结果拼进 `content`,只保留第一次已得到的文本,并按最终状态分别标成过滤、转工具或原因不明。
3. **续写也是一次正式调用。** 要记日志和用量;需要请求 id 时,沿用 11-11 的记法。批量场景下的并发仍可沿用 11-12 的信号量;若遇到 HTTP 429,先根据响应区分限流、配额或服务特定错误,再决定是否退避,不要把所有 429 都当成限速。缺少可区分的依据时,不要自动重试,以免同一条任务被重复计费。也不要因为截断就把同一条任务连续打出去很多次。
下面这个函数只做 429 分类,不实现重试循环。只有能确认是限流时,才考虑按服务文档或 11-12 的方式退避;配额和依据不足的情况不要自动再发一次。
```python
def classify_http_429(status_code, headers=None, body=None):
headers = {str(k).lower(): v for k, v in (headers or {}).items()}
payload = body if isinstance(body, dict) else {}
error = payload.get("error") if isinstance(payload.get("error"), dict) else {}
code = str(error.get("code") or error.get("type") or payload.get("code") or "").lower()
message = str(error.get("message") or payload.get("message") or "").lower()
text = " ".join([code, message])
if status_code != 429:
return {
"kind": "other",
"retry": False,
"reason": "不是 429,不按限流或配额分支处理",
}
quota_marks = (
"insufficient_quota",
"quota_exceeded",
"billing_hard_limit",
"exceeded_current_quota",
"exceeded your current quota",
)
rate_marks = (
"rate_limit_exceeded",
"rate_limit",
"too_many_requests",
)
if any(mark in text for mark in quota_marks):
return {
"kind": "quota",
"retry": False,
"reason": "响应表明是配额或额度问题,不应按限速退避后重试同一任务",
}
retry_after = headers.get("retry-after")
if retry_after or any(mark in text for mark in rate_marks):
return {
"kind": "rate_limit",
"retry": True,
"retry_after": retry_after,
"reason": "响应带有限流依据,可按文档退避,不要立即重发",
}
return {
"kind": "unknown",
"retry": False,
"reason": "429 但缺少可区分限流或配额的依据,不自动重试,避免重复计费",
}
```
## 3. 不适合续写时,改短输出,而不是再要一次长文
自动续写失败后,更稳妥的往往不是第三次续写,而是缩小任务:改成短列表、减少条数,或先只要结构不要展开。可以用上一节的 `SHORTEN_FORMAT`,作为新的用户消息,**重新开一次请求**,不要把已经截断、可能不完整的长文再当唯一依据。
也要检查请求本身是不是太宽:例如输出长度上限设得过低,或一次要求「背景、分析、方案、风险、计划」全写。上限过低时,先调文档允许范围内的上限;任务过大时,先拆成两步,再分别请求。
`content_filter` 和工具调用不要走续写分支。前者应交给人工或你们自己的兜底逻辑;后者应按照工具协议补结果,不能把「请继续」接在后面。如果第一次已是 `length`、第二次才出现这两类结束原因,也不要把两段文本拼进 `content`;只保留第一次已得到的文本,标明第二次的结束原因,并停止自动续写。
## 4. 本地用模拟响应核对分支
下面这些分支不必先打真实接口,用模拟响应就能核对。预期结果是:`stop` 只请求一次,且可作为完整正文;`length` 后接 `stop` 会续写一次并拼接;连续两次 `length` 不会发出第三次请求,结果视为未完成;第一次或第二次出现 `content_filter`、`tool_calls`,以及缺少或未知的 `finish_reason`,都不会当成完整正文,后两类续写也不拼接第二次文本。示例数据是构造出来的,不是某次真实调用的记录。
```python
class DummyResp:
def __init__(self, body):
self._body = body
def raise_for_status(self):
return None
def json(self):
return self._body
class DummyClient:
def __init__(self, bodies):
self.bodies = list(bodies)
self.calls = 0
def post(self, url, json, headers):
self.calls += 1
return DummyResp(self.bodies.pop(0))
def make_body(text, reason):
return {
"choices": [
{"message": {"role": "assistant", "content": text}, "finish_reason": reason}
]
}
```
如果你用的是流式接口,在最后一条事件里读 `finish_reason` 即可,判断规则与上面相同;事件拼接仍看 11-10 那篇,不要把两套逻辑混在一个循环里。
## 5. 小结
- 先看 `finish_reason`,不要一遇到「写到一半」就直接说「请继续」;
- 只有明确的正常终止(通常是 `stop`)才当作可用的完整正文;`length` 最多自动续写一次,并检查是否从头重写;
- `content_filter`、工具调用或字段缺失、未知原因,不要自动续写,也不要伪装成普通完成;若它们出现在第二次响应里,只保留第一次文本,按最终状态标成过滤、转工具或原因不明;
- 遇到 429 时,先区分限流、配额和缺少依据的服务特定错误,不要一律按限速退避,缺依据时不要自动重试;
- 续写仍不完整时,改成更短的输出格式或拆小任务,再重新请求。
示例中的字段名和地址都是占位,换成你自己的服务文档即可。有需要可以私信我交流。