05. 流式输出与事件模型
没有流式输出的 Agent 会显得迟钝。用户提交任务后,模型可能先思考数秒,再请求工具,工具又运行数秒。如果界面只在最终结果出现时刷新,用户无法判断系统是在工作、卡住还是已经失败。流式事件不是视觉优化,而是 Agent 的可观察性基础。
流式的真实难点
普通聊天流式只需要不断追加文本。Agent 流式要处理更多事件:
- assistant 文本增量。
- thinking(推理)内容增量——支持思维链的模型会先输出一段推理,UI 通常需要区分渲染。
- tool call 开始、参数增量、参数完成。
- 工具执行开始、进度、结束。
- turn 结束。
- abort、retry、compaction、queue 更新。
尤其是 tool call 参数。很多 provider 会把 JSON 参数分片吐出,UI 可以先展示“模型准备调用 read”,但运行时必须等参数完整并校验通过后才能执行。把半截 JSON 当成参数执行,是流式 Agent 的常见 bug。
半截 JSON 也不是完全没用。成熟系统会用“修复式解析”让 UI 提前看到参数的可读部分:先尝试修复常见问题(字符串里的裸控制字符、非法转义),再用容忍截断的 partial JSON 解析器解析,都失败就退回空对象。解析结果只用于渲染,绝不用于执行。执行只认参数流结束后完整校验过的对象。这一条边界值得写进代码注释里。
事件联合
先定义一组稳定事件。它们不应该绑定某个 UI 框架。参考成熟系统的协议层事件形态:
type StreamEvent =
| { type: "start"; partial: AssistantMessage }
| { type: "text_start"; contentIndex: number; partial: AssistantMessage }
| { type: "text_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
| { type: "text_end"; contentIndex: number; content: string; partial: AssistantMessage }
| { type: "toolcall_start"; contentIndex: number; partial: AssistantMessage }
| { type: "toolcall_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
| { type: "toolcall_end"; contentIndex: number; toolCall: ToolCallBlock; partial: AssistantMessage }
| { type: "done"; reason: "stop" | "length" | "toolUse"; message: AssistantMessage }
| { type: "error"; reason: "error" | "aborted"; error: AssistantMessage };
有两个设计值得注意。第一,每个事件都携带 partial——一条随流式进度逐步构建的 assistant 消息。消费者不需要自己累积 delta,任何时刻拿到事件就能渲染当前完整状态;掉线重连、中途订阅的场景也因此变得简单。第二,contentIndex 标明增量属于消息里第几个内容块。一条 assistant 消息可以同时有文本、thinking 和多个 tool call,没有索引,UI 无法把 delta 归到正确的块。
done 和 error 是互斥的终止事件。error 也携带一条 assistant 消息:stop reason 为 error 或 aborted,errorMessage 说明原因,已收到的内容片段保留在 content 里。这样“流中断”和“正常结束”走同一条类型出口,日志和 UI 不需要特判。
双视图:可迭代事件与最终消息
流式接口最好同时支持两种消费方式:
- 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 来即时渲染。两者来自同一个底层流,不需要发两次请求。
两层事件:协议流与生命周期
上面的事件联合描述“一次模型请求”内部发生了什么。Agent 层还需要一组更粗粒度的生命周期事件,把多个请求、工具执行和队列状态串起来:
agent_start
turn_start
message_start / message_update / message_end
tool_execution_start / tool_execution_update / tool_execution_end
turn_end
agent_end
协议流事件会被包装成 message_update 向上传递。这样订阅者可以选择自己关心的粒度:状态栏只听 agent_start 和 agent_end,聊天区听 message_update,工具面板听 tool_execution_*。第六章会给出完整的顺序保证。
Abort 不等于异常泄漏
用户按下停止时,底层请求会收到 AbortSignal。运行时不应该只让 AbortError 一路冒泡到顶层。更好的做法是:
- 取消 provider 请求和正在执行的工具。
- 发出
error事件,reason 为aborted,携带已构建的部分消息。 - 形成一条 assistant 消息,stop reason 为
aborted,保留已收到的文本。 - 写入会话日志。
这样用户恢复会话时,能看到任务在哪里被中止、模型说到了哪里。扩展和 UI 也能根据明确状态清理资源。
运行观察
一个读取文件的流式 turn 可能产生这些事件:
start
text_delta: 我先检查配置文件。
toolcall_start (contentIndex=1)
toolcall_delta: {"path":
toolcall_delta: "src/config.ts"}
toolcall_end: read {"path":"src/config.ts"}
done reason=toolUse
tool_execution_start: read
tool_execution_end: read isError=false
注意 done 的 reason 是 toolUse:这次模型请求结束了,但 turn 还没有——工具执行完成后还要发起下一轮模型请求。流式事件告诉用户发生了什么,stop reason 告诉 runtime 下一步做什么。
生产化取舍
事件是公共契约,一旦被 UI、SDK 和扩展使用,就不能随意改字段。设计事件时应保持稳定、细粒度、可组合。不要把事件命名成某个界面组件的动作,比如 appendToChatBubble;应该命名为领域事实,比如 text_delta。
事件还要带足够的关联 id。一个 assistant turn 可以并行请求多个工具,多个工具也可能同时输出进度。如果没有 tool call id,UI 无法把进度归到正确工具,日志也无法重放。
频率也是真实问题。快速滚动的 bash 输出可能每秒产生几百次更新,逐条渲染会把终端刷成幻灯片。成熟系统会在工具进度事件上做节流(比如每 100 毫秒最多一次 update,结束事件不节流),并在消费端缓存渲染结果。节流放在事件生产侧还是消费侧可以讨论,但“无限速事件流直连 UI”在长输出下一定会出问题。
练习
给上一章的 loop 加事件流。
验收标准:
- 文本 delta 拼接结果和最终 assistant 消息一致。
- 每个事件携带的 partial 消息在任意时刻都是结构合法的。
- tool call 参数未完整前不会执行工具;partial 参数只用于渲染。
- 每个工具执行都有 started 和 finished 事件。
- 用户 abort 后,事件流以
error(reason=aborted)终止,部分文本保留。 - 用 faux provider 录制事件序列,写一个断言保证事件顺序稳定。