流式输出、错误与停止

对应源码

主题源码
stream、timeout 和停止分支nanobot/agent/runner.py::AgentRunner._request_model、AgentRunner._run_core
Provider stream 与 retrynanobot/providers/base.py::LLMProvider.chat_stream_with_retry、LLMProvider._run_with_retry
Provider fallbacknanobot/providers/fallback_provider.py::FallbackProvider
progress/reasoning hooknanobot/agent/progress_hook.py、nanobot/agent/hook.py
bus stream segmentnanobot/agent/loop.py::AgentLoop._dispatch
取消入口nanobot/agent/loop.py::AgentLoop.stop、nanobot/command/builtin.py

学习目标

读完后,你应当能够:

  • 区分内容 delta、reasoning、progress 和最终响应;
  • 说明 LLM wall timeout 与 stream idle timeout;
  • 理解 retry、fallback、空响应恢复和 max iteration;
  • 根据 stop reason 找到相应 Runner 分支。

前置知识

  • 理解 AgentRunner 主循环;
  • 理解异步 callback 和取消;
  • 理解 HTTP/Provider 错误的基本分类。

1. “流式”包含多种事件

nanobot 运行中可能出现:

类型含义
content delta模型最终文本的增量
reasoning deltaProvider 返回的思考/推理内容
tool hint即将调用工具的提示
progress工具或 Agent 生命周期进度
retry wait正在等待重试
stream end当前 stream segment 结束
final response完整 Turn 的最终文本
runtime eventTurn/Goal/Model 状态变化

它们不是同一个 Channel metadata,也不应由一个 UI 标记混合表达。

2. 核心流式路径

sequenceDiagram
    participant P as Provider
    participant R as AgentRunner
    participant H as Hook/Callback
    participant E as CLI/API/Channel

    P-->>R: content delta
    R-->>H: on_content_delta
    H-->>E: 展示增量
    P-->>R: tool call 或最终完成
    R-->>H: on_stream_end
    R-->>E: 最终 AgentRunResult

入口适配:

  • CLI:终端 renderer;
  • API:SSE chunk;
  • SDK:typed stream event;
  • Channel:send_delta();
  • WebSocket:协议消息。

3. 一个 Turn 可以有多个 stream segment

当模型先输出内容、再调用工具、再继续回答时,stream 可能暂停和恢复。AgentLoop 为 Channel stream 生成 segment ID,避免不同阶段的 delta 被错误拼成同一个气泡。

因此不能把第一次 stream_end 直接理解为整个 Turn 已完成。最终完成仍以:

  • Runner 返回;
  • SAVE;
  • RESPOND;
  • TurnCompleted/runtime wire event

为准。

4. 两种主要 timeout

LLM wall timeout

限制一次模型请求允许占用的总时间。

  • 环境变量:NANOBOT_LLM_TIMEOUT_S
  • 默认值以 AgentRunner 当前源码为准。

Stream idle timeout

限制流在没有任何事件的情况下可以空闲多久。

  • 环境变量:NANOBOT_STREAM_IDLE_TIMEOUT_S
  • 解析位置:nanobot/providers/base.py
  • 不是整个请求的总时长。
wall timeout:从请求开始到结束的总墙钟边界
idle timeout:两次流事件之间的最大沉默时间

AgentRunner._request_model() 还明确避免把两种 timeout 叠加到健康的长流上:

if wants_streaming:
    coro = self.provider.chat_stream_with_retry(...)
else:
    coro = self.provider.chat_with_retry(**kwargs)
 
# Streaming requests already have provider-level idle timeouts.
outer_timeout_s = None if (wants_streaming or wants_progress_streaming) else timeout_s
response = (
    await coro
    if outer_timeout_s is None
    else await asyncio.wait_for(coro, timeout=outer_timeout_s)
)

这是压缩后的源码节选。非流式请求可由外层 wall timeout 限制;流式请求依靠 Provider 的 idle timeout,避免一个持续正常产生事件、但总耗时较长的 stream 被错误终止。

5. Provider retry

LLMProvider 的 retry 逻辑根据:

  • HTTP status;
  • error kind;
  • Provider retry-after;
  • retry mode;
  • 是否已产生 stream 内容;
  • 调用方 callback

决定是否等待和重试。

不是所有错误都应重试。例如:

  • 认证错误通常不应持续重试;
  • 限流或临时服务错误可能重试;
  • 已经向用户输出部分 stream 后,盲目重试可能产生重复文本。

6. Fallback model

providers/factory.py::make_provider() 可以根据配置创建 fallback chain。fallback 候选可以是:

  • named preset;
  • inline Provider/model 配置。

关键理解:

  • fallback 发生在 Provider 层;
  • primary 和 fallback 可以有不同 model、context window、token 和 reasoning 参数;
  • 具体错误是否触发 fallback 由 Provider/fallback 实现决定;
  • fallback 不等于 AgentRunner 再启动一个全新 Session。

7. 空响应恢复

模型可能成功返回但 content 为空。Runner 会:

  1. 进行有限次数空响应重试;
  2. 必要时尝试 finalization retry;
  3. 如果仍没有最终内容,产生统一空响应结果;
  4. AgentLoop SAVE 阶段确保需要回复的 Turn 不保存空 final content。

这与 Provider 抛异常不同。

8. finish_reason=length

如果模型输出因长度截断且已有非空内容,Runner 可以继续请求完成剩余内容,受恢复次数限制。

调试时要区分:

  • 内容为空;
  • 内容被截断;
  • Tool Call;
  • Provider error;
  • max iterations。

9. Max iterations

AgentRunSpec.max_iterations 限制模型—工具循环次数,防止无限调用。

达到上限后可能:

  • 进行 finalization;
  • 返回 max-iteration 提示;
  • 对活跃 Goal 采用 continuation 策略。

不要简单把上限设置得极大来掩盖循环不终止。先检查:

  • 模型是否反复调用同一工具;
  • 工具错误是否给了可行动信息;
  • goal predicate 是否一直为真;
  • injection 是否持续进入;
  • prompt 是否缺少终止条件。

10. Tool error

普通 Tool 错误通常回到模型。以下情况更可能结束 Runner:

  • fail_on_tool_error;
  • 明确的 fatal error;
  • 重复 workspace 违规;
  • 无法恢复的安全错误;
  • Runner 自身异常。

AgentRunResult.error 和 stop_reason 应一起观察。

11. 取消

取消可能来自:

  • /stop;
  • 进程 shutdown;
  • 上层 Task cancel;
  • timeout。

AgentLoop 在取消时需要:

  • 停止对应活跃 Task;
  • 恢复或保留 checkpoint/pending user turn;
  • 不破坏 Session 合法边界;
  • 关闭 MCP 等资源;
  • 让真实 Task cancellation 正确传播。

取消后的“没有最终回复”不等于 Session 中什么都没保存,因为 user 消息可能已提前持久化。

调试矩阵

现象观察
UI 有 delta 但一直不结束on_stream_end、Runner 是否返回、TurnCompleted
输出重复是否在已输出 delta 后发生 retry
长时间无内容stream idle timeout、reasoning stream、Provider 状态
工具无限重复iteration、tool event、tool result、终止提示
用户 /stop 后历史丢失pending user turn、checkpoint、取消恢复
内容突然截断finish reason、length recovery
primary 失败后模型变化fallback chain、runtime model event

源码与测试

  • nanobot/agent/runner.py::_request_model
  • nanobot/agent/runner.py::_run_core
  • nanobot/providers/base.py
  • nanobot/providers/fallback_provider.py
  • nanobot/agent/progress_hook.py
  • tests/agent/test_runner_progress_deltas.py
  • tests/agent/test_runner_reasoning.py
  • tests/agent/test_runner_errors.py
  • tests/agent/test_runner_fallback.py
  • tests/agent/test_task_cancel.py
  • tests/providers/test_stream_idle_timeout_config.py

自检

  1. stream end 为什么不一定等于 Turn end?
  2. idle timeout 与 wall timeout 有什么区别?
  3. 为什么已输出部分 delta 后重试需要更谨慎?
  4. 空响应与 Provider error 的恢复路径有什么不同?
  5. 达到 max iterations 时为什么不应只提高上限?

下一步

阅读 保存、响应与 Runtime Event。