了解模型为什么停下#
当模型没有更多要请求的东西时,请求就结束了。这与模型*完成*回答不是一回事:它可能拒绝了,可能撞上了输出上限,可能填满了上下文窗口,也可能在句子说到一半时断开了连接。各个提供方报告这些情况的方式都不同,而只检查工具调用的程序会把它们全部看成“模型回答了”。
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:
字段 |
含义 |
|---|---|
|
归一化后的 |
|
提供方自己的取值,原样保留,例如 |
|
类别:提供方的策略类别( |
|
给人看的一句话:拒绝文本或提供方的解释。请展示它,不要解析它。 |
|
提供方建议改用重试的模型(如果它给出了的话)。 |
|
没有归一化位置的提供方特有信息,例如 OpenRouter 的 |
脚本可以选择被通知,而不是自己检查:
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。
各个原因的含义#
原因 |
含义 |
运行时会怎么做 |
|---|---|---|
|
模型完成了回答,或遇到了你设置的停止序列。 |
什么也不做;不上报。 |
|
模型请求了工具。 |
运行这些工具;不上报。 |
|
达到了输出上限。文本,或某个工具调用的参数,被截断了。 |
上报它。参数被截断的工具调用会像以前一样得到一个错误作为回应。 |
|
上下文窗口被填满了。输出被截断。 |
上报它。 |
|
模型,或位于它前面的安全系统,拒绝了。 |
扣下工具调用,结束请求,并上报。 |
|
基础设施终止了响应:提供方错误、流中断、容量不足。 |
扣下工具调用,结束请求,并上报。 |
|
提供方把控制权交了回来,希望继续这一轮。 |
继续,最多 |
|
本库不认识的取值。 |
上报它,其余照常进行。 |
提供方新增的取值绝不会让运行中断:它会变成 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")
原因从哪里来#
提供方可以用四种方式说明,每一种都会被读取:
一个取值,位于
finish_reason或stop_reason。所有提供方都有。内容里的拒绝。 OpenAI 的
refusal字段和内容块。文本会被保留,并像其他回答一样展示,同时成为stop.message。这正是过去会丢失的那一种:这样的响应以stop结束,而且没有文本。任何输出之前的拒绝,它以错误而不是停止的形式到来:Google 上被拦截的提示、Kimi 上
content_filter的400、OpenRouter 上的审核或护栏403、OpenAI Responses API 上的策略失败。这些会被当作消息为空的REFUSAL来回应,使对话得以继续,并让宿主知道原因。其他所有错误状态仍然像以前一样抛出异常。成功流内部的错误。 OpenRouter 会在
200已经提交之后发送这种错误。如果已经有输出,响应会以INTERRUPTED结束并保留文本;如果还没有输出,则会作为错误抛出,因此通常的重试仍然适用。
各个提供方发送什么#
下面是各提供方在文档中列出的取值,以及它们会变成什么。未列出的取值是 OTHER。
提供方 |
取值 |
拒绝的到达方式 |
|---|---|---|
Anthropic |
|
|
OpenAI Chat |
|
|
OpenAI Responses |
|
|
DeepSeek |
|
|
GLM(智谱 Z.ai) |
|
|
Kimi(Moonshot) |
|
|
OpenRouter |
|
|
OpenCode Zen |
网关:取你所选协议(Responses、Chat、Messages 或 Gemini)的取值 |
与所选协议相同 |
|
上述结束原因,或者在提示被拦截、没有候选时的 |
|
Mistral |
|
没有专门的取值 |
Qwen、Groq、xAI、Ollama |
Qwen 在文档中列出了 |
提供方没有在文档中说明 |
无论是否调用了函数,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。