从零构建 Coding Agent
English

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 一路冒泡到顶层。更好的做法是:

  1. 取消 provider 请求和正在执行的工具。
  2. 发出 aborted 事件。
  3. 形成一条 assistant 消息,stop reason 为 aborted
  4. 写入会话日志。

这样用户恢复会话时,能看到任务在哪里被中止。扩展和 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 录制事件序列,写一个断言保证事件顺序稳定。