调用对话接口时输出被截断怎么办:先看 finish_reason,再决定是续写一次、缩小范围,还是换成更短的格式(Python)

python 复制代码
调用对话接口时,有时返回的文本写到一半就停了:列表缺了最后几条,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 时,先区分限流、配额和缺少依据的服务特定错误,不要一律按限速退避,缺依据时不要自动重试;
- 续写仍不完整时,改成更短的输出格式或拆小任务,再重新请求。

示例中的字段名和地址都是占位,换成你自己的服务文档即可。有需要可以私信我交流。
相关推荐
Cooper2523 分钟前
单文件 Flask 跑通微信服务器校验:先跑通再谈工程化
python·flask
Cooper2524 分钟前
三源热榜去重加 AI 摘要:URL 唯一键踩出的三个坑
前端·python
用户40199255686127 分钟前
多源异构 B2B 公开需求信号去重与企业实体证据链存储设计(64-bit SimHash 分桶 + Jaccard)
python
东鸿电子Eastron31 分钟前
工厂节能降本落地指南:从精准能源计量搭建数字化管理基础
大数据·人工智能·能源·智能电表·能耗管理
师夷长技以自愈38 分钟前
手搓 AI Agent 第 6 篇:先别动刀——Plan Mode 让 AI 报完方案才动手
人工智能
Sincerelyplz39 分钟前
Voice Agent 实践与落地(二):从唤醒到多轮对话,拆解实时语音链路
人工智能
guuguo40 分钟前
Gemini 4 屠榜后连夜切到 Antigravity,顺手用一行命令搞定全中文界面与思考
人工智能·开源·github
码云社区41 分钟前
能碳管理系统开发要关注什么?能源数据、统计口径与AI分析的衔接
java·人工智能·ai·能源·能源管理系统·能碳管理系统·能碳管理系统源码
科研小刘带你玩学术44 分钟前
【科研快讯】OpenAI推出ChatGPT Wallet:AI Agent不再只会推荐商品,还能替用户完成支付
人工智能·数字经济·智能体·科技前沿·aiagent·ai支付·智能商务