跳到主内容

智能体流式输出实战:事件流、游标续跑与终态处理

100%
智能体流式输出实战:事件流、游标续跑与终态处理

结论:智能体流式输出不是”把 stream=true 打开”就完事,真正要处理的是三件事——按 sequence_number 有序拼接增量、把”工具调用中”这类中间状态显式暴露给用户、以及断流后能续跑。只渲染 delta 文本而忽略事件类型和游标,你得到的会是一个偶尔乱序、中断即丢、看不到进度的半成品。

流式输出的核心价值不是”看起来快”,而是让长任务在等待期间保持可感知、可打断、可续跑。如果只是为了打字机效果,收益远小于你要付的工程复杂度。

为什么智能体尤其需要流式

普通对话流式只是提前展示文本;智能体的每一次运行里,模型可能在多轮工具调用之间来回,单次响应耗时从几秒到几分钟不等。没有流式时用户面对的是一个空白转圈,无法判断”是在思考、在调工具、还是卡住了”。

流式的第二个价值是可中断:用户看到方向不对可以立刻叫停,省掉整轮 token 。第三个价值是可续跑:拿到游标后即使连接断开,也能从断点继续消费事件。

两种流式粒度

粒度消费的东西适合场景复杂度
文本增量(delta)output_text.delta 一类事件聊天界面、纯生成型回答低
运行事件流(run events)工具调用、 handoff 、审批暂停、完成等完整事件智能体控制台、需要显示进度与中间态中

做智能体产品,第二种才是应该选的。只消费 delta,你看不到”正在调用天气 API”这种状态,界面就只能干等。

最小实现:消费运行事件流

以 OpenAI Agents SDK 为例,流式运行用的是同一套循环和状态策略,区别只是在运行还没结束时就能消费事件:

import asyncio
from openai.types.responses import ResponseTextDeltaEvent
from agents import Agent, Runner

agent = Agent(name="Planet guide", instructions="Answer with short facts.")

async def main():
    stream = Runner.run_streamed(agent, "Give me three short facts about Saturn.")
    async for event in stream.stream_events():
        if event.type == "raw_response_event" and isinstance(event.data, ResponseTextDeltaEvent):
            print(event.data.delta, end="", flush=True)
    print(f"\nFinal: {stream.final_output}")

asyncio.run(main())

JavaScript 版的形状一致:

import { Agent, run } from "@openai/agents";

const stream = await run(agent, "Give me three short facts about Saturn.", { stream: true });
for await (const event of stream) {
  if (event.type === "raw_model_stream_event" && event.data.type === "output_text_delta") {
    process.stdout.write(event.data.delta);
  }
}
await stream.completed;
console.log("\nFinal:", stream.finalOutput);

事件模型的三层结构

把 Responses API 的事件流按层级理解,实现时不容易乱:

  1. 信封层:描述整个响应对象状态,如 response.created、response.in_progress、response.completed、response.failed。真正的 token 用量只在 response.completed 里给出。
  2. 输出项层:response.output_item.added / .done,一个输出项可以是消息、工具调用或推理。
  3. 内容块层:response.content_part.added → response.output_text.delta → response.output_text.done。文本就在这层以增量形式到达。

  1. 按 output_index + item_id + content_index 三级建缓冲区,收到的 delta 追加到对应缓冲区,绝不要直接往一个全局字符串上拼。

  2. 严格按 sequence_number 排序消费。乱序到达在网络层是常态,靠序号纠正比靠到达顺序可靠。

  3. 把非文本事件(工具调用、 handoff 、审批请求)映射成界面上的显式状态,让用户知道智能体在做什么,而不是对着空屏等。

  4. 在 response.completed(或 failed / incomplete)时落库并结算用量;中途断开则保存游标,用 starting_after 续消费。

断流与续跑

这是流式实现里最容易被跳过、也最容易在演示时翻车的一环。要点如下:

  • 记录游标:每个事件都带 sequence_number,持续更新最后一个值。
  • 支持从断点续读:以 Responses API 为例,可用 ?stream=true&starting_after=<cursor> 重新接入;后台模式下响应会继续跑,重连即可接上。
  • 取消是幂等的:重复调用 cancel 只会返回最终状态,可以放心重试。
  • 流结束才算结束:务必等 stream.completed 再把这次运行当作已定稿,否则你会拿到半截结果。

后台模式 + 流式的组合值得单独说一句:同时设 background=true 与 stream=true,可以立刻开始消费事件,同时保留”客户端掉线后重连”的能力。代价是首 token 延迟比同步模式略高。

中断不是新一轮对话

这是最常犯的设计错误。用户在流式进行中点”停止”,或者运行因为等待人工审批而暂停,都属于暂停,不是新一轮。

区别很实际:如果当成新的一轮,轮次计数、历史记录和服务端的续接 ID 都会错位,恢复后上下文断裂。正确做法是把审批视为”暂停的运行”,从保存的状态恢复继续执行——这一点在 智能体人机协作(HITL) 和 断点续跑 里有更完整的展开。

和结构化输出怎么共存

一个常见误解是”流式了就没法要结构化结果”。其实两者可以并存:界面消费事件流,落库时用最终的结构化对象。做法是先定义 JSON Schema 约束输出(见 智能体结构化输出),流式阶段照常渲染增量文本,收到 response.completed 后拿完整输出做校验;校验失败按重试策略处理,而不是把半截 JSON 丢给用户。

要点是:流式负责体验,结构化负责正确性,两者由 completed 事件衔接。

上线检查清单

  • 缓冲区按三级索引隔离,delta 不乱串。
  • 按 sequence_number 有序消费,能容忍乱序与重复。
  • 工具调用、审批暂停有显式 UI 状态。
  • 游标持久化,断线可续;取消操作幂等。
  • completed / failed / incomplete 三种终态都有处理分支。
  • 用量只在 completed 结算,中途不估算。

如果你刚开始搭智能体,可以先跑通 OpenAI Agents SDK 入门,再回来补流式这一层。


流式输出会让结果不一致吗?
不会。流式只是把同一份结果分次送达,最终内容与非流式一致。需要注意的是消费端实现——必须等 completed 事件再定稿,并把 delta 按序号有序拼接,否则会拿到乱序或截断的内容。

断线后还能接上吗?
可以,前提是你在消费时保存了 sequence_number 游标。重连时用该游标作为起始位置继续读取即可;后台模式下即使客户端掉线,响应仍在服务端继续生成。

流式还能要求结构化输出吗?
能。界面消费事件流以获得即时反馈,收到 completed 事件后用完整输出做 Schema 校验并落库。流式负责体验,结构化负责正确性,两者不冲突。

用户中途取消,这次运行还能继续吗?
取决于你的实现。取消是幂等操作,重复调用安全。若希望同一轮后续继续执行,应从保存的状态恢复(视为暂停),而不是发起新一轮——后者会导致轮次和历史错位。

token 用量什么时候能拿到?
只在 response.completed 事件里携带真实用量。流式过程中的用量是未知的,不要在结束前估算或展示,也不要用它做计费。

了解智能体结构化输出做法
这篇有帮助吗?
云上的幻象
云上的幻象查看主页

七彩云博客,分享 WordPress 建站实战与 AI 工具测评,覆盖服务器运维、站长工具、软件资源与电商运营干货,专注原创实用的主题插件、网站加速与安全优化教程。

942文章4评论

相关文章

评论 (0)

欢迎你,新朋友,感谢参与互动!文明发言,理性交流 · 首次评论将在审核后展示