运行生命周期
一次 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 streamText) | stepCountIs(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, providerOptions | messages / prompt, abortSignal, timeout | originalMessages, generateMessageId, sendReasoning, sendSources, sendStart, sendFinish |
| 行为钩子 | stopWhen, prepareStep, prepareCall | experimental_transform | — |
| 回调(触发顺序) | experimental_onStart → prepareStep → experimental_onStepStart → experimental_onToolCallStart → experimental_onToolCallFinish → onStepFinish → onFinish | 同 L1(会和 L1 同名回调 merge,settings 先调) | messageMetadata → onStepFinish → onFinish → onError |
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 流之外主动推自定义事件、合并多条流。两者底下共用同一个handleUIMessageStreamFinish(index.js:8100/:8397),所以onStepFinish/onFinish/onError的触发时机与本页一致;但messageMetadata只存在于toUIMessageStream。execute-driven 路径的细节见 UI 流编排。
完整时序图
以一次跑 N 步的 agent.stream() 为例,时间从上往下流:
三条关键观察:
- L1/L2 的回调和 L3 的回调是并发的——L2 一边跑循环往 fullStream 推 chunk,L3 一边 pipe transform。所以 L1/L2 的
onStepFinish(n)和 L3 的onStepFinish(n)在时间上几乎同时发生,但在 event-loop 里是独立任务。 - L3 的
onFinish一定晚于 L1/L2 的onFinish——因为 L3 是下游 transform,必须等 fullStream 关闭 + 消费者读完才 flush。想”运行结束后做事”要分清:用 L1/L2onFinish做引擎侧清理(token 统计、close sandbox),用 L3onFinish做UI 侧持久化(保存 assistant message)。 messageMetadata在每个 chunk 都被调用——包括每个text-delta、tool-input-delta。一次多工具长回答动辄上千个 chunk,这里做任何同步 I/O 都会直接卡住流。
回调触发时机速查表
按触发顺序排列。L1 = ToolLoopAgent settings,L2 = streamText(agent.stream 直通),L3 = toUIMessageStream。
| 顺序 | 回调 | 层 | 触发时机 | Payload 形态 | 作用 |
|---|---|---|---|---|---|
| 1 | prepareCall(baseCallArgs) | L1 | 每次 agent.stream() 开始前,全部参数合并后 | 完整调用参数,可覆写返回 | 动态改写 model / tools / stopWhen |
| 2 | experimental_onStart() | L1+L2 | streamText 启动、第一步之前 | 无 | 初始化日志/计时 |
| 3 | prepareStep({...}) | L1+L2 | 每步模型调用之前 | { messages, steps, stepNumber, model } | 压缩、注入 reminder、过滤 activeTools、切换 model |
| 4 | experimental_onStepStart() | L1+L2 | 每步模型流启动之前(在 prepareStep 之后) | 无 | per-step 计时标记 |
| 5 | experimental_onToolCallStart() | L1+L2 | 每次 tool.execute 之前 | { toolCall } | 权限审计、重试前置 |
| 6 | experimental_onToolCallFinish() | L1+L2 | 每次 tool.execute 之后 | { toolCall, toolResult } | 观测、缓存写回 |
| 7 | onStepFinish(stepResult) | L1+L2 | 每步结束、finish-step chunk 发射后 | StepResult 结构:step 级详情 | token 累计、step 级持久化 |
| 8 | messageMetadata({ part }) | L3 | 每个 chunk 过 UI transform 时 | { part }(当前 chunk) | 给 UI 控制 chunk 挂元数据 |
| 9 | onStepFinish | L3 | 每个 finish-step chunk 经过 UI transform 时 | { responseMessage, messages, isContinuation } | UI 侧 step 级持久化 |
| 10 | onFinish({...}) | L1+L2 | 所有 step 结束、finish chunk 发射后 | { finishReason, totalUsage, steps, ... } | 引擎侧结算、清理 |
| 11 | onFinish({...}) | L3 | UI 流 drain / cancel 后 | { responseMessage, messages, isContinuation, isAborted, finishReason } | UI 侧 message 持久化 |
| 12 | onError(error) | L3 | UI transform 内异常 / error chunk / onStepFinish 抛错 | Error 或 string | SSE 错误序列化;返回值(string)会写入 error chunk 的 errorText 字段发给客户端 |
onFinish抛错不走这里:callOnFinish(index.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 时 |
| Payload | StepResult:{ 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 checkpoint | UI 级别的一次性结算:持久化最终 assistant message、通知 client 完成 |
L1/L2 onFinish 的闭包陷阱:L1/L2 onFinish 的参数 payload 包含整个 steps 数组(每一步的完整 StepResult——content、toolCalls、toolResults、request、response 全在里面)。如果回调闭包捕获了这个 payload 并挂在长寿命引用上(例如存进外层 session 对象),整次调用的大对象图都会被 pin 住、GC 不掉。长链对话尤其危险——20 步累计的 StepResult 可能上百 MB。
实战建议:
- 短结算逻辑(token 计数、step 日志)可以放 L1/L2
onFinish——回调返回后闭包释放 - 长结算逻辑(持久化、后台任务、checkpoint 写入)优先放 L3
onFinish,payload 已经是折叠过的responseMessage+messages,体量小得多 - 或者:用 L1/L2 的
onStepFinish每步增量收集你要的数据到一个小变量(只保留数字 / 字符串 / id,不保留StepResult本身),然后在 L3onFinish用这个小变量做结算
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:
prepareCall | prepareStep | |
|---|---|---|
| 层 | L1 | L1 或 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_onStepStart | step 级”开始”标记 | 重置 step 计数器、清 per-step buffer、启动单步计时 |
experimental_onToolCallStart | tool 级”开始”标记 | audit log、权限检查、阻塞式验证 |
experimental_onToolCallFinish | tool 级”完成”标记 | 结果缓存、metric 上报、工具级错误路由 |
和 onStepFinish 的区别:onStepFinish 是 step 结束后的聚合回调(整个 StepResult),experimental_onStepStart 是 step 开始时的切换点(无 payload,纯信号)。要做 “step 间清理” 用前者,要做 “step 初始化” 用后者。
延伸阅读
相关 SDK 章节
- 消息引用模型 — 前面频繁提到的 initialMessages / responseMessages 的引用关系
- prepareStep 语义 — 最常用的钩子的深挖
- UI 流编排 — L3
createUIMessageStream的 execute-driven 路径 + 错误捕获全貌
SDK 源码锚点(ai@6.0.134)
dist/index.js:6441-6532—streamText入口dist/index.js:6750-6810—onStepFinish发射点dist/index.js:8180-8317—ToolLoopAgent实现dist/index.js:7839-8108—toUIMessageStream实现
Zapvol 落地参考
packages/backend/src/agent/agent-stream.ts— 所有层回调的组装点(L1/L2 settings、L3toUIMessageStream参数、stepUsages 收集模式)packages/backend/src/agent/agent-factory.ts—stopConditions数组的构造方式apps/server/src/services/task-orchestrator.ts— L3onFinish做 assistant message 持久化