运行生命周期

一次 agent.stream() 从触发到流关闭的完整时序——三层 API 的 12 个回调触发顺序、双层同名回调对照、stopWhen / timeout 默认值链

一眼看懂

一次 agent.stream() 调用涉及 3 层 API、12 个回调、4 条消息链、2 份同名回调。这页把它们全部钉到时间线上。

关键数字
回调总数12(去重后)
同名回调对数2(onStepFinish × 2 / onFinish × 2)
stopWhen 默认值(L1)stepCountIs(20)
stopWhen 默认值(L2 streamTextstepCountIs(1)
timeout 分级3(totalMs / stepMs / chunkMs
锚定 SDK 版本ai@6.0.134

三层 API 能力矩阵

在读时序图之前先建立地图——每个参数/回调属于哪一层:

L1: new ToolLoopAgent({...})L2: agent.stream({...})L3: result.toUIMessageStream({...})
角色静态配置(定义 agent)单次执行(触发一次运行)下游消费(把结果转 UI 流)
生命周期构造一次,复用每次调用一次每次调用一次
结构参数id, model, instructions, tools, experimental_context, providerOptionsmessages / prompt, abortSignal, timeoutoriginalMessages, generateMessageId, sendReasoning, sendSources, sendStart, sendFinish
行为钩子stopWhen, prepareStep, prepareCallexperimental_transform
回调(触发顺序)experimental_onStartprepareStepexperimental_onStepStartexperimental_onToolCallStartexperimental_onToolCallFinishonStepFinishonFinish同 L1(会和 L1 同名回调 merge,settings 先调)messageMetadataonStepFinishonFinishonError

L1 和 L2 的同名回调会合并:如果两层都设了 onStepFinish,L1 的先执行、L2 的后执行(源码 dist/index.js:8224-8232)。L3 的同名回调是完全独立的——和 L1/L2 不会合并、payload 也不同。

L3 的另一个入口:本页 L3 列聚焦 result.toUIMessageStream() 这条 transform 路径(被动消费 agent 结果)。L3 还有另一个入口 createUIMessageStream({ execute })——execute-driven 路径,可以在 agent 流之外主动推自定义事件、合并多条流。两者底下共用同一个 handleUIMessageStreamFinishindex.js:8100 / :8397),所以 onStepFinish / onFinish / onError 的触发时机与本页一致;但 messageMetadata 只存在于 toUIMessageStream。execute-driven 路径的细节见 UI 流编排

完整时序图

以一次跑 N 步的 agent.stream() 为例,时间从上往下流:

sequenceDiagram actor Caller participant L1 as ToolLoopAgent<br/>(settings) participant L2 as streamText<br/>(loop) participant L3 as toUIMessageStream<br/>(pipe) Caller->>L1: new ToolLoopAgent({ ... }) Note over L1: 仅保存 settings<br/>不触发任何回调 Caller->>L1: agent.stream({ messages, ... }) L1->>L1: prepareCall(baseCallArgs) Note over L1: 整次调用改写一次<br/>很少人用的上游钩子 L1->>L2: streamText(mergedArgs) L2-->>Caller: experimental_onStart() Note over L2: 全局仅一次 loop N 步 L2->>L2: stepInputMessages =<br/>[...initialMessages, ...responseMessages] L2-->>Caller: prepareStep({ messages, steps, stepNumber, model }) Note over L2: 可返回 { messages, system,<br/>model, toolChoice, activeTools,<br/>experimental_context } L2-->>Caller: experimental_onStepStart() L2->>L2: 模型流开始<br/>"start-step" chunk L2->>L3: "start-step" L2->>L3: text-delta / reasoning-delta / ... loop 每个 tool call L2-->>Caller: experimental_onToolCallStart() L2->>L2: tool.execute(input,<br/>{ abortSignal, experimental_context,<br/>messages, toolCallId }) L2-->>Caller: experimental_onToolCallFinish() end L2->>L2: "finish-step" chunk L2->>L3: "finish-step" L2-->>Caller: onStepFinish(stepResult) [L1/L2] Note over L2: payload: { stepNumber, content,<br/>toolCalls, toolResults,<br/>finishReason, usage, response } L3->>L3: 每个 chunk 过 transform L3-->>Caller: messageMetadata({ part }) Note over L3: 每个 part 调一次!<br/>热路径,禁做 I/O L3->>L3: "finish-step" chunk 经过 L3-->>Caller: onStepFinish [L3] Note over L3: payload: { responseMessage,<br/>messages, isContinuation } L2->>L2: isStopConditionMet?<br/>break or continue end L2->>L2: "finish" chunk<br/>聚合 totalUsage L2->>L3: "finish" L2-->>Caller: onFinish({ ... }) [L1/L2] Note over L2: payload: { finishReason,<br/>totalUsage, steps, content,<br/>response, request, warnings } L2->>L3: fullStream 关闭 L3->>L3: flush() L3-->>Caller: onFinish({ ... }) [L3] Note over L3: payload: { responseMessage,<br/>messages, isContinuation,<br/>isAborted, finishReason } opt 任意错误 L3-->>Caller: onError(error) end

三条关键观察

  1. L1/L2 的回调和 L3 的回调是并发的——L2 一边跑循环往 fullStream 推 chunk,L3 一边 pipe transform。所以 L1/L2 的 onStepFinish(n) 和 L3 的 onStepFinish(n) 在时间上几乎同时发生,但在 event-loop 里是独立任务。
  2. L3 的 onFinish 一定晚于 L1/L2 的 onFinish——因为 L3 是下游 transform,必须等 fullStream 关闭 + 消费者读完才 flush。想”运行结束后做事”要分清:用 L1/L2 onFinish引擎侧清理(token 统计、close sandbox),用 L3 onFinishUI 侧持久化(保存 assistant message)。
  3. messageMetadata 在每个 chunk 都被调用——包括每个 text-deltatool-input-delta。一次多工具长回答动辄上千个 chunk,这里做任何同步 I/O 都会直接卡住流。

回调触发时机速查表

按触发顺序排列。L1 = ToolLoopAgent settings,L2 = streamTextagent.stream 直通),L3 = toUIMessageStream

顺序回调触发时机Payload 形态作用
1prepareCall(baseCallArgs)L1每次 agent.stream() 开始前,全部参数合并后完整调用参数,可覆写返回动态改写 model / tools / stopWhen
2experimental_onStart()L1+L2streamText 启动、第一步之前初始化日志/计时
3prepareStep({...})L1+L2每步模型调用之前{ messages, steps, stepNumber, model }压缩、注入 reminder、过滤 activeTools、切换 model
4experimental_onStepStart()L1+L2每步模型流启动之前(在 prepareStep 之后)per-step 计时标记
5experimental_onToolCallStart()L1+L2每次 tool.execute 之前{ toolCall }权限审计、重试前置
6experimental_onToolCallFinish()L1+L2每次 tool.execute 之后{ toolCall, toolResult }观测、缓存写回
7onStepFinish(stepResult)L1+L2每步结束、finish-step chunk 发射后StepResult 结构:step 级详情token 累计、step 级持久化
8messageMetadata({ part })L3每个 chunk 过 UI transform 时{ part }(当前 chunk)给 UI 控制 chunk 挂元数据
9onStepFinishL3每个 finish-step chunk 经过 UI transform 时{ responseMessage, messages, isContinuation }UI 侧 step 级持久化
10onFinish({...})L1+L2所有 step 结束、finish chunk 发射后{ finishReason, totalUsage, steps, ... }引擎侧结算、清理
11onFinish({...})L3UI 流 drain / cancel 后{ responseMessage, messages, isContinuation, isAborted, finishReason }UI 侧 message 持久化
12onError(error)L3UI transform 内异常 / error chunk / onStepFinish 抛错Error 或 stringSSE 错误序列化;返回值(string)会写入 error chunk 的 errorText 字段发给客户端

onFinish 抛错不走这里callOnFinishindex.js:5927-5943)是裸 await,没有 try/catch——onFinish throw 会冒到 TransformStream 的 flush(),让消费端 iterator reject,不会触发 onError。任何生产级 onFinish 都必须自己在回调里兜一层 try/catch/finally。完整错误捕获分层见 UI 流编排 - 错误捕获全貌

双层同名回调——最容易踩的坑

onStepFinish:L1/L2 vs L3

L1/L2(streamText)L3(UI stream)
触发时机步循环结束,finish-step 发射后finish-step chunk 经过 UI transform 时
PayloadStepResult{ stepNumber, content, text, toolCalls, toolResults, finishReason, usage, response, request, ... }{ responseMessage: UIMessage, messages: UIMessage[], isContinuation }
看到什么引擎视角:step 级原始输出(tool 调用对象、usage 分项)消费者视角:UI 级累计消息(assistant message 结构)
用来做什么累计 token 用量(billing)、写 step-level 日志、驱动压缩 / 上下文裁剪增量持久化 assistant message、增量 prefetch

onFinish:L1/L2 vs L3

L1/L2(streamText)L3(UI stream)
触发时机finish chunk 发射后、fullStream 关闭前fullStream 关闭 + UI transform flush 后
Payload{ finishReason, totalUsage, steps, content, text, reasoningText, toolCalls, toolResults, response, request, warnings, providerMetadata }{ responseMessage, messages, isContinuation, isAborted, finishReason }
时间先后(上游)(下游 drain)
用来做什么引擎级别的一次性结算:total usage 写 DB、close sandbox、commit compaction checkpointUI 级别的一次性结算:持久化最终 assistant message、通知 client 完成

L1/L2 onFinish 的闭包陷阱:L1/L2 onFinish 的参数 payload 包含整个 steps 数组(每一步的完整 StepResult——contenttoolCallstoolResultsrequestresponse 全在里面)。如果回调闭包捕获了这个 payload 并挂在长寿命引用上(例如存进外层 session 对象),整次调用的大对象图都会被 pin 住、GC 不掉。长链对话尤其危险——20 步累计的 StepResult 可能上百 MB。

实战建议

  • 短结算逻辑(token 计数、step 日志)可以放 L1/L2 onFinish——回调返回后闭包释放
  • 长结算逻辑(持久化、后台任务、checkpoint 写入)优先放 L3 onFinish,payload 已经是折叠过的 responseMessage + messages,体量小得多
  • 或者:用 L1/L2 的 onStepFinish 每步增量收集你要的数据到一个小变量(只保留数字 / 字符串 / id,不保留 StepResult 本身),然后在 L3 onFinish 用这个小变量做结算

stopWhen 默认值兜底链——第二个容易踩的坑

同一个参数名,在 L1 和 L2 的默认值不同

L1 new ToolLoopAgent({ stopWhen? })  默认值: stepCountIs(20)   ← dist/index.js:8210
L2 streamText({ stopWhen? })         默认值: stepCountIs(1)    ← dist/index.js:6452

ToolLoopAgent.stream() 会把 L1 的 stopWhen(默认 20)传给 streamText,所以正常用法下 agent 最多跑 20 步。

但如果你直接调 streamText(...) 不设 stopWhen,agent 只会跑一步——出单个 tool call 就停,不会继续。初学者常见踩坑。

内置 stop 条件(可以组合:stopWhen: [stepCountIs(N), hasToolCall('complete')]):

条件工厂语义
stepCountIs(N)到达第 N 步后停止
hasToolCall(name)出现指定名字的 tool call 后停止

典型生产组合[stepCountIs(N), hasToolCall('complete')]——数字作为硬性上限(避免 agent 陷入死循环),hasToolCall('complete') 作为”明确任务完成”的信号(让 agent 自己声明结束)。N 的取值取决于任务复杂度:简单问答 10-20、通用助理 30-50、深度研究 / 多步编辑 50-100。

三级 timeout——第三个容易踩的坑

timeout 是个对象,三级粒度:

agent.stream({
  timeout: {
    totalMs: 600_000,   // 整次调用:10 分钟
    stepMs: 300_000,    // 单步:5 分钟(模型+工具总和)
    chunkMs: 120_000,   // 两个 chunk 之间间隔:2 分钟
  },
});

三级独立生效,任一超时都会通过 AbortSignal.timeout() 触发 abort(dist/index.js:6483-6495)。

维度看什么典型超时场景
totalMs整次调用绝对时长长任务整体兜底
stepMs单步从 prepareStep 到 finish-step模型单次响应卡住
chunkMs相邻两个 chunk 之间的间隙流启动后突然 hang(provider 侧断连的救命稻草)

社区教程通常只提 totalMs——但 chunkMs 是生产里最常救命的一级。模型流可能开头吐出两行就卡住(provider TCP 半连接、reasoning 阶段 thinking 太久等),此时 totalMs 还远没到,但 chunkMs 会直接终止。生产环境一个稳妥的默认值是 chunkMs: 120_000(2 分钟)——足以覆盖大多数 reasoning 模型的长思考间隙,又不至于让真断连悄悄拖很久。

prepareCall —— 很少人用的上游钩子

除了 prepareStep(每步一次),L1 还有 prepareCall(每次 agent.stream() 一次):

new ToolLoopAgent({
  model, instructions, tools,
  prepareCall: async (baseCallArgs) => {
    // baseCallArgs 是所有合并后的参数(settings + method 选项)
    // 返回改写版——或什么都不返回沿用 baseCallArgs
    return {
      ...baseCallArgs,
      tools: dynamicallyDeciderTools(baseCallArgs.messages),
      stopWhen: stepCountIs(deriveStepLimit(user)),
    };
  },
});

什么时候用

  • 动态切换工具集(user tier / A-B test)而不想新建 agent 实例
  • 基于调用时的输入动态选 model
  • 按调用动态设 stopWhen

prepareCall vs prepareStep

prepareCallprepareStep
L1L1 或 L2
触发次数每次 agent.stream() 一次每步一次(1 次调用内 N 次)
能改整个调用参数(tools、stopWhen、instructions、messages、model …)单步参数(messages、system、model、toolChoice、activeTools)
用途静态配置的动态化运行时上下文自适应(压缩、reminder、动态工具集)

实战选择建议:大多数项目用 prepareStep 就够了——运行时压缩、每步注入 reminder、根据对话长度切换工具集,都是 per-step 场景。prepareCall 更适合 agent 实例跨请求复用的部署模式(模块顶层构造一次 agent,每个 HTTP 请求内根据 user tier / AB 实验动态改写)。如果你的 agent 是每个请求重新构造的(orchestrator 层已经做了配置动态化),prepareCall 就显得多余。

experimental_* 回调——被低估的四个钩子

官方标 experimental_ 前缀,意思是 API 签名可能变;但这四个在 ai@^6 里已经稳定,是构建可观测 agent 的核心:

钩子用途典型落地场景
experimental_onStart调用级”开始”标记发 UI 初始化事件、启动整次调用的计时器、记 run 开始日志
experimental_onStepStartstep 级”开始”标记重置 step 计数器、清 per-step buffer、启动单步计时
experimental_onToolCallStarttool 级”开始”标记audit log、权限检查、阻塞式验证
experimental_onToolCallFinishtool 级”完成”标记结果缓存、metric 上报、工具级错误路由

onStepFinish 的区别onStepFinish 是 step 结束后的聚合回调(整个 StepResult),experimental_onStepStart 是 step 开始时的切换点(无 payload,纯信号)。要做 “step 间清理” 用前者,要做 “step 初始化” 用后者。

延伸阅读

相关 SDK 章节

SDK 源码锚点ai@6.0.134

  • dist/index.js:6441-6532streamText 入口
  • dist/index.js:6750-6810onStepFinish 发射点
  • dist/index.js:8180-8317ToolLoopAgent 实现
  • dist/index.js:7839-8108toUIMessageStream 实现

Zapvol 落地参考

  • packages/backend/src/agent/agent-stream.ts — 所有层回调的组装点(L1/L2 settings、L3 toUIMessageStream 参数、stepUsages 收集模式)
  • packages/backend/src/agent/agent-factory.tsstopConditions 数组的构造方式
  • apps/server/src/services/task-orchestrator.ts — L3 onFinish 做 assistant message 持久化
这页有帮助吗?