05. 流式输出与事件模型
没有流式输出的 Agent 会显得迟钝。用户提交任务后,模型可能先思考数秒,再请求工具,工具又运行数秒。如果界面只在最终结果出现时刷新,用户无法判断系统是在工作、卡住还是已经失败。流式事件不是视觉优化,而是 Agent 的可观察性基础。
流式的真实难点
普通聊天流式只需要不断追加文本。Agent 流式要处理更多事件:
- assistant 文本增量。
- tool call 开始。
- tool call 参数增量。
- tool call 参数完成。
- 工具执行开始、进度、结束。
- turn 结束。
- abort、retry、compaction、queue 更新。
尤其是 tool call 参数。很多 provider 会把 JSON 参数分片吐出,UI 可以先展示“模型准备调用 read”,但运行时必须等参数完整并校验通过后才能执行。把半截 JSON 当成参数执行,是流式 Agent 的常见 bug。
事件联合
先定义一组稳定事件。它们不应该绑定某个 UI 框架:
type AgentEvent =
| { type: "assistant_text_delta"; text: string }
| { type: "tool_call_started"; id: string; name: string }
| { type: "tool_call_arguments_delta"; id: string; delta: string }
| { type: "tool_call_ready"; id: string; name: string; input: unknown }
| { type: "tool_execution_started"; id: string; name: string }
| { type: "tool_execution_update"; id: string; text: string }
| { type: "tool_execution_finished"; id: string; isError: boolean }
| { type: "turn_finished"; message: AssistantMessage };
UI、日志、扩展、测试都可以订阅同一条事件流。这样不会出现“终端显示一种状态,日志记录另一种状态,SDK 又是第三种状态”的分裂。
双视图:可迭代事件与最终消息
流式接口最好同时支持两种消费方式:
- UI 逐个消费事件。
- Agent loop 等待最终 assistant 消息。
教学项目可以用一个小包装表达这个思想:
type EventStream<TEvent, TResult> = {
events: AsyncIterable<TEvent>;
result: Promise<TResult>;
};
Provider adapter 在流式过程中发出 delta,同时累积最终 assistant 消息。Agent loop 可以 await result 来决定 stop reason;UI 可以遍历 events 来即时渲染。两者来自同一个底层流,不需要发两次请求。
Abort 不等于异常泄漏
用户按下停止时,底层请求会收到 AbortSignal。运行时不应该只让 AbortError 一路冒泡到顶层。更好的做法是:
- 取消 provider 请求和正在执行的工具。
- 发出
aborted事件。 - 形成一条 assistant 消息,stop reason 为
aborted。 - 写入会话日志。
这样用户恢复会话时,能看到任务在哪里被中止。扩展和 UI 也能根据明确状态清理资源。
运行观察
一个读取文件的流式 turn 可能产生这些事件:
assistant_text_delta: 我先检查配置文件。
tool_call_started: read
tool_call_arguments_delta: {"path":
tool_call_arguments_delta: "src/config.ts"}
tool_call_ready: read {"path":"src/config.ts"}
tool_execution_started: read
tool_execution_finished: read false
turn_finished: stopReason=toolUse
注意 turn_finished 仍然是 toolUse,因为工具执行完成后还要发起下一轮模型请求。流式事件告诉用户发生了什么,stop reason 告诉 runtime 下一步做什么。
生产化取舍
事件是公共契约,一旦被 UI、SDK 和扩展使用,就不能随意改字段。设计事件时应保持稳定、细粒度、可组合。不要把事件命名成某个界面组件的动作,比如 appendToChatBubble;应该命名为领域事实,比如 assistant_text_delta。
事件还要带足够的关联 id。一个 assistant turn 可以并行请求多个工具,多个工具也可能同时输出进度。如果没有 tool call id,UI 无法把进度归到正确工具,日志也无法重放。
练习
给上一章的 loop 加事件流。
验收标准:
- 文本 delta 和最终 assistant 消息一致。
- tool call 参数未完整前不会执行工具。
- 每个工具执行都有 started 和 finished 事件。
- 用户 abort 后,会话里能看到
aborted状态。 - 用 faux provider 录制事件序列,写一个断言保证事件顺序稳定。