了解模型为什么停下#

当模型没有更多要请求的东西时,请求就结束了。这与模型*完成*回答不是一回事:它可能拒绝了,可能撞上了输出上限,可能填满了上下文窗口,也可能在句子说到一半时断开了连接。各个提供方报告这些情况的方式都不同,而只检查工具调用的程序会把它们全部看成“模型回答了”。

Zett Agent 会把每个提供方的报告读成同一套词汇,并放到宿主能看见的地方,而不是把它丢掉。

检查它是怎么结束的#

非正常停止会记录在运行返回的消息上:

message = await agent.run("Summarize this repository")

if message.stop is not None:
    # Not a normal ending: the text may be missing, partial, or a refusal.
    print(message.stop.reason, message.stop.category, message.stop.message)

当模型正常结束或请求了工具时,message.stop 为 None,这也包括在此功能出现之前存储的所有消息。设置了它时,它是一个 StopInfo:

字段

含义

reason

归一化后的 StopReason。请依据它来分支处理。

raw

提供方自己的取值,原样保留,例如 "content_filter"。

category

类别:提供方的策略类别("cyber"、"SAFETY");如果提供方没有给出,则为原始取值。

message

给人看的一句话:拒绝文本或提供方的解释。请展示它,不要解析它。

recommended_model

提供方建议改用重试的模型(如果它给出了的话)。

detail

没有归一化位置的提供方特有信息,例如 OpenRouter 的 native_finish_reason,或被扣下的工具调用的名称。

脚本可以选择被通知,而不是自己检查:

message = (await agent.run("...")).raise_for_stop()   # raises AgentStopError

运行时绝不会替你抛出它。拒绝是一种正常结果,所以不去检查的宿主依然能拿回对话。

在它发生时就得知#

流式 UI 会在 MODEL_COMPLETED 之后立即收到一个 MODEL_STOPPED 事件。它只在非正常停止时发送,所以无需过滤:

async for event in client.stream("Summarize this repository"):
    if event.type == AgentEventType.MODEL_STOPPED:
        show_notice(event.stop.describe())    # e.g. "refusal (cyber): ..."

使用 AgentEventDispatcher 时,请重写 on_model_stopped_event。扩展在 after_model 中看到的是同一个值,即 response.stop。

RUN_COMPLETED 仍然会随后到来。它表示请求运行到了结尾,而拒绝会结束并完成一次请求;要区分这两种情况,就靠 MODEL_STOPPED。

各个原因的含义#

原因

含义

运行时会怎么做

END_TURN

模型完成了回答,或遇到了你设置的停止序列。

什么也不做;不上报。

TOOL_USE

模型请求了工具。

运行这些工具;不上报。

MAX_OUTPUT

达到了输出上限。文本,或某个工具调用的参数,被截断了。

上报它。参数被截断的工具调用会像以前一样得到一个错误作为回应。

CONTEXT_FULL

上下文窗口被填满了。输出被截断。

上报它。

REFUSAL

模型,或位于它前面的安全系统,拒绝了。

扣下工具调用,结束请求,并上报。

INTERRUPTED

基础设施终止了响应:提供方错误、流中断、容量不足。

扣下工具调用,结束请求,并上报。

PAUSED

提供方把控制权交了回来,希望继续这一轮。

继续,最多 Agent.max_pause_continuations 次(5 次);只有次数用尽时才上报。

OTHER

本库不认识的取值。raw 中保留着它。

上报它,其余照常进行。

提供方新增的取值绝不会让运行中断:它会变成 OTHER,并保留原值。

随拒绝而来的工具调用不会被运行#

拒绝可能在一个完整的工具调用已经流出之后才到来,而被中断的流也可能恰好在某个调用中间结束。运行这样的调用,等于按模型或提供方已经否认的输出行事。因此对于 REFUSAL 和 INTERRUPTED,这些调用会从存储的消息中移除,工具永远不会启动,被扣下的调用名称保存在 stop.detail["withheld_tool_calls"] 中。提供方自己记录的该调用也会一并去掉,因为没有结果的 tool_use 被回放时会被 API 拒绝。

完整的离线程序请 下载 stop_reasons.py:它先流出一段部分回答和一个工具调用,然后拒绝,并展示工具从未运行。

async def main() -> None:
    agent = await create_agent(RefusingModel(), config=AgentRunConfig("stop-reasons"), tools=[delete_file])
    heard = []
    async for event in agent.stream("Clean up the project"):
        if event.type is AgentEventType.MODEL_STOPPED:
            assert event.stop is not None
            heard.append(event.stop.describe())
        if event.type is AgentEventType.RUN_COMPLETED:
            assert isinstance(event.message, AssistantMessage)
            message = event.message

    # The run completed, and the message says it was not an answer.
    assert message.stop is not None and message.stop.reason is StopReason.REFUSAL
    assert message.stop.category == "cyber" and not message.stop.ok
    assert heard == ["refusal (cyber): This request was declined."]

    # The tool call that came with the refusal was withheld: the tool never started.
    assert RAN == [] and message.tool_calls == ()
    assert message.stop.detail["withheld_tool_calls"] == ["delete_file"]

    # A script that prefers an exception to checking can ask for one.
    try:
        message.raise_for_stop()
    except Exception as error:
        assert type(error).__name__ == "AgentStopError"
    print("Refusal reported; the tool call was not run")

原因从哪里来#

提供方可以用四种方式说明,每一种都会被读取:

  1. 一个取值,位于 finish_reason 或 stop_reason。所有提供方都有。

  2. 内容里的拒绝。 OpenAI 的 refusal 字段和内容块。文本会被保留,并像其他回答一样展示,同时成为 stop.message。这正是过去会丢失的那一种:这样的响应以 stop 结束,而且没有文本。

  3. 任何输出之前的拒绝,它以错误而不是停止的形式到来:Google 上被拦截的提示、Kimi 上 content_filter 的 400、OpenRouter 上的审核或护栏 403、OpenAI Responses API 上的策略失败。这些会被当作消息为空的 REFUSAL 来回应,使对话得以继续,并让宿主知道原因。其他所有错误状态仍然像以前一样抛出异常。

  4. 成功流内部的错误。 OpenRouter 会在 200 已经提交之后发送这种错误。如果已经有输出,响应会以 INTERRUPTED 结束并保留文本;如果还没有输出,则会作为错误抛出,因此通常的重试仍然适用。

各个提供方发送什么#

下面是各提供方在文档中列出的取值,以及它们会变成什么。未列出的取值是 OTHER。

提供方

取值

拒绝的到达方式

Anthropic

end_turn、stop_sequence、tool_use、max_tokens、pause_turn、refusal、model_context_window_exceeded

stop_reason: "refusal",可能出现在流的中途,并带有 stop_details(category:cyber、bio、frontier_llm、reasoning_extraction、general_harms;explanation;recommended_model)

OpenAI Chat

stop、length、tool_calls、content_filter、function_call

content_filter,或 refusal 字段

OpenAI Responses

completed,或者 incomplete 并带有 max_output_tokens 或 content_filter

refusal 内容块、incomplete + content_filter,或带有策略类 error.code 的失败(bio_policy、image_content_policy_violation)

DeepSeek

stop、length、content_filter、tool_calls、insufficient_system_resource、aborted

content_filter。length 同时涵盖对话过长的情况,所以映射为 MAX_OUTPUT。

GLM(智谱 Z.ai)

stop、tool_calls、length、sensitive、model_context_window_exceeded、network_error

sensitive

Kimi(Moonshot)

stop、length、tool_calls

HTTP 400,且 error.type: "content_filter"

OpenRouter

tool_calls、stop、length、content_filter、error;native_finish_reason 是上游的取值

content_filter;审核 403(metadata.reasons);护栏 403(metadata.patterns)

OpenCode Zen

网关:取你所选协议(Responses、Chat、Messages 或 Gemini)的取值

与所选协议相同

Google

STOP、MAX_TOKENS、SAFETY、RECITATION、BLOCKLIST、PROHIBITED_CONTENT、SPII、IMAGE_SAFETY、IMAGE_PROHIBITED_CONTENT、IMAGE_RECITATION;其余(OTHER、MALFORMED_FUNCTION_CALL、LANGUAGE 等)为 OTHER

上述结束原因,或者在提示被拦截、没有候选时的 prompt_feedback.block_reason

Mistral

stop、length、model_length、error、tool_calls

没有专门的取值

Qwen、Groq、xAI、Ollama

Qwen 在文档中列出了 stop、length 和 tool_calls。Groq 和 xAI 的页面没有列出取值。Ollama 的 done_reason 是开放字符串。

提供方没有在文档中说明

无论是否调用了函数,Gemini 都报告 STOP,所以带着工具调用结束的步骤被视为 TOOL_USE。google-genai SDK 的 Gemini API 路径会丢弃 finishMessage(只有 Vertex 会保留),所以 Google 的拒绝只有类别,没有说明文字。

选择模型是否再次读到被拒绝的回答#

默认情况下,被拒绝的回答会保留在历史中,并随之后每个请求再次发送,与其他助手消息一样。Anthropic 建议把拒绝之前产生的输出视为不完整并丢弃。添加 RefusalReplayExtension 来做出选择:

from zett_agent.extensions.refusal_replay import RefusalReplay, RefusalReplayExtension

agent = await create_agent(model, extensions=[RefusalReplayExtension(replay=RefusalReplay.DROP)])

只有请求会改变。消息仍保留在存储的历史和屏幕上,所以更改设置绝不会改写会话。丢弃一条消息可能导致连续出现两条用户消息,而各个提供方是否接受这种情况并不相同,所以请针对你所用的提供方测试一下。

子智能体与目标#

被拒绝的子智能体仍然会返回一条消息,它的文本可能为空或不完整。父模型会得到提示:报告以一条通知开头,SubAgentResult.stop 带有原因。子智能体提前停止的后台任务仍然是 completed,因为子智能体已经结束,并会在 stop、它的备注和完成通知中说明这一点。

目标不会针对被拒绝或被中断的回答进行评估。让模型再试一次只会遇到同样的拒绝,所以请求会结束,宿主也会得知原因。

它不做什么#

  • 不会换另一个模型重试。 拒绝只会被上报,不会被绕过。Anthropic 在文档中介绍了回退到其他模型的服务端和客户端方案;是否依据 stop.recommended_model 行动,由宿主决定。

  • 除非你按上文选择,否则不会从历史中移除被拒绝的那一轮。

  • 不会自己续写被截断的响应。 停止原因为 INTERRUPTED 或 MAX_OUTPUT 的响应,需要调用 resume() 来接着做;参见 恢复被中断的会话。拒绝和 CONTEXT_FULL 无法恢复。

  • 不保证提供方会说明原因。 没有类别也没有文本的拒绝,会被报告为一个没有任何内容可展示的拒绝。

下一步:参阅 把流式输出接入终端或界面 了解完整的事件列表,或参阅 连接模型提供商 连接提供方。

相关 API:StopInfo、StopReason、AgentStopError、RefusalReplayExtension。