
千笔-AIWritePaper · https://www.aiwritepaper.com
把 Agents SDK 的 streaming 当成「多打几个 print」会踩坑:Runner.run_streamed() 返回的是 RunResultStreaming,真正完成要以 stream_events() 迭代器结束 为准;最后几个可见 token 到齐之后,会话持久化、审批记账、历史压缩仍可能在收尾。官方 Streaming 与 Results 写得很直:事件分 raw / run item / agent updated 三层;审批要先排空流再读 interruptions;cancel() 后仍要继续消费事件。本文按工程笔记写法,钉死选型、可跑片段、审批续跑与生产禁区。示例模型名写作 gpt-4o,以你账号可用快照与官方文档为准。

图:上方 run vs run_streamed;中部三类 StreamEvent 与审批续跑;下方生产禁区对照。
目标说明
读完你应能独立完成五件事:
- 用一句话说清:
run等终态;run_streamed边跑边推事件,但必须排空stream_events()才算完成。 - 写出可跑片段:token 级(
ResponseTextDeltaEvent)与语义级(tool/message/agent_updated)两套消费循环。 - 会处理 HITL:流结束后读
interruptions→to_state()→ approve/reject → 再run_streamed。 - 知道
cancel()/cancel(mode="after_turn")的差异,以及取消后仍要继续消费事件。 - 列出生产禁区:把「已看到 delta」当业务成功、中途 break 丢收尾、审批未完成就开新用户回合、把流式 UI 卡顿当 SDK bug 却不查是否排空。
规格钉死(对照官方 Streaming / Stream events / Results):
- 入口 :
Runner.run_streamed(agent, input)→RunResultStreaming。 - 事件联合 :
RawResponsesStreamEvent|RunItemStreamEvent|AgentUpdatedStreamEvent。 - 完成条件 :异步迭代器结束;之后看
result.is_complete;final_output在流未收完前可为None。 - 审批 :流兼容 tool approval;先消费完,再处理
interruptions。 - 取消 :默认立即停;
mode="after_turn"让本回合干净结束后再停;取消后仍 drain。
适用边界
适合上 streaming
- 聊天/客服 UI 需要逐字或逐段反馈,降低「卡住」感。
- 需要在工具调用、handoff 切换时推送进度条(
tool_called/agent_updated_stream_event)。 - 人类审批工具:流式跑到 interruption,再续跑。
- 观测:中途看
current_agent,不必等终态才知道路由。
更适合普通 Runner.run(不要硬上流)
- 批处理、定时任务、只要终态 JSON,不需要中间 UI。
- 下游强依赖完整
final_output/output_type校验,且无增量渲染需求。 - 测试断言只关心终答与 tracing 导出,流式只会增加 flake。
不该指望它单独搞定
- 业务成功:token 到齐 ≠ 退款入账 ≠ 写库提交。
- 用中途 break「优化体验」:提前退出迭代器会丢持久化与审批收尾。
- 把 raw delta 当唯一真源:语义事件(message/tool/handoff)才适合做进度与审计。
- 审批未决就
add_input开新话题:官方要求先处理 interruption / 用 state 续跑。
风险提示
流式失败会在 stream_events() 上抛出;Python 侧没有单独的 error 属性可轮询。生产里若前端只订阅 delta、后端在异常时不落盘,会出现「用户看见半句话、系统无失败记录」的撕裂。共同解法:drain + 显式终态检查 + 业务状态机分离。
步骤与机制
1. run vs run_streamed 对照
| 机制 | 谁拿到完整结果时机 | 中间可见性 | 失败时 | 典型用途 |
|---|---|---|---|---|
| Runner.run / run_sync | 返回时 | 无增量 | 异常抛出 | 批处理、单测 |
| Runner.run_streamed | 迭代器结束后 | raw + 语义事件 | stream_events 抛错 | UI、进度、HITL |
| 只打 LLM 原生流、自拼 Agent | 你自己拼 | 易漏 tool/handoff | 自担 | 不推荐替代 SDK |
| 中途 break 不 drain | 不定 | 半截 UI | 持久化/审批可能未完 | 生产禁区 |
2. 可跑:token 级流式
先 pip install openai-agents,并导出 OPENAI_API_KEY。
python
import asyncio
from openai.types.responses import ResponseTextDeltaEvent
from agents import Agent, Runner
MODEL = "gpt-4o" # 占位:以账号可用快照为准
async def main():
agent = Agent(
name="Assistant",
instructions="你是简洁助手,用中文回答。",
model=MODEL,
)
result = Runner.run_streamed(agent, "用三句话解释什么是流式输出。")
async for event in result.stream_events():
if event.type == "raw_response_event" and isinstance(event.data, ResponseTextDeltaEvent):
print(event.data.delta, end="", flush=True)
print()
print("complete=", result.is_complete)
print("final=", result.final_output)
asyncio.run(main())
验收:终端出现增量字符;循环结束后 is_complete 为真;final_output 非空。若你提前 break,不要宣称「流已跑通」。
3. 可跑:语义事件(忽略 raw)
python
import asyncio
from agents import Agent, ItemHelpers, Runner
from agents.decorators import tool
@tool
def ping() -> str:
return "pong"
async def main():
agent = Agent(
name="Toolish",
instructions="先调用 ping 工具,再简短汇报。",
model=MODEL,
tools=[ping],
)
result = Runner.run_streamed(agent, "请探测一下。")
async for event in result.stream_events():
if event.type == "raw_response_event":
continue
if event.type == "agent_updated_stream_event":
print("agent ->", event.new_agent.name)
elif event.type == "run_item_stream_event":
if event.item.type == "tool_call_item":
print("tool_called")
elif event.item.type == "tool_call_output_item":
print("tool_output", event.item.output)
elif event.item.type == "message_output_item":
print("message", ItemHelpers.text_message_output(event.item))
asyncio.run(main())
要点:RunItemStreamEvent.name 含 message_output_created、tool_called、tool_output、handoff_requested、handoff_occured(官方保留拼写)等。handoff 请求走 handoff_requested,不要指望它再冒充一次普通 tool_called。
4. 审批:排空 → interruptions → 续跑
python
# 伪结构:工具需审批时
result = Runner.run_streamed(agent, "执行需要审批的操作...")
async for _ in result.stream_events():
pass
if result.interruptions:
state = result.to_state()
for interruption in result.interruptions:
state.approve(interruption) # 或 reject
result = Runner.run_streamed(agent, state)
async for _ in result.stream_events():
pass
验收:未 approve 前不应出现业务副作用提交;续跑后再次 drain;审计能回答「谁在何时批准了哪次 interruption」。
5. 取消与 after_turn
result.cancel():尽快停。result.cancel(mode="after_turn"):让当前回合干净结束。- 取消后继续消费
stream_events(),否则清理不完整。 - 若
after_turn停在 tool 回合,用to_input_list(mode="normalized")续跑last_agent时,不要误开「全新用户回合」。
生产禁区
| 禁区 | 为什么危险 | 替代做法 |
|---|---|---|
| 看见 delta 就写业务成功 | 收尾未完;最终可能失败 | 以迭代器结束 + is_complete 为准 |
| 中途 break「省流量」 | 丢 session/审批记账 | 必须 drain;UI 可节流渲染 |
| 审批未决开新 user turn | 状态机错乱 | interruptions → to_state → 续跑 |
| 只订 raw、不订语义事件 | 丢 tool/handoff 进度与审计 | 双通道:UI 用 raw,审计用 item |
| 把 cancel 当事务回滚 | SDK 不停业务库 | 业务幂等键 + 补偿 |
| 流式 UI 卡顿就删 max 保护 | 放大 runaway tool loop | 查是否未 drain / 网络;保留上限 |
| final_output 为 None 就当空成功 | 流未完成 | 先查 is_complete |
可验证清单
- 能口述 run 与 run_streamed 的完成时机差异
- 本地跑通 token 级与语义级两段示例
- 有一条「必须 drain」的代码评审规则
- HITL 路径:interruptions → approve → 再 streamed
- cancel 用例会继续消费事件
- 生产配置里没有「delta 到达即付款/删库」
- tracing 能对照 tool_called / handoff_requested
踩坑
- 以为最后一个 delta 等于 run 完成:官方明确 post-processing 可能更晚。
- 前端断连就丢弃后端迭代器:应后端继续 drain,或显式 cancel 并记审计。
- 把 AgentUpdated 当 handoff 成功业务事件:那只是说话权变化。
- 单测里乱 break:制造「偶发 final_output 为空」。
- 混用自建 WebSocket 与 SDK 流却不统一完成语义:两套完成条件会双写。
与 handoffs / sessions / output_type 的协作位
| 层 | 解决什么 | 和 streaming 的交接面 |
|---|---|---|
| streaming | 增量可见性与 HITL 暂停点 | 必须 drain;中途可读 current_agent |
| handoffs | 本轮说话权 | agent_updated / handoff_* 事件可观测 |
| sessions | 跨轮记忆 | 持久化可能在最后 token 之后 |
| output_type | 终答形状 | 流未完成时不要强读 final_output |
实践顺序:先定完成语义(何时算成功),再定 UI 订阅哪些事件,最后才加审批与 session。反过来先接 WebSocket,再补完成条件,事故率高于演示。
FAQ:为什么 UI 已经出字,后台还在忙?
因为可见 token 与会话收尾不是同一阶段。UI 可以渲染 delta;后台仍可能写 session、压缩历史、登记审批。产品文案不要写「字出完=任务完成」,应写「回复生成中 / 已提交 / 待审批」等业务态。
最小观测字段
建议至少落盘:run_id、started_at、drain_finished_at、is_complete、cancel_mode、interruption_count、current_agent、error_type。验收:出事五分钟内能回答「流是否排空、是否有未批准 interruption、最后 agent 是谁」。
发布前代码审一眼
- 所有
run_streamed调用处都有完整async for或明确的 drain 辅助函数。 - 无「except: pass」吞掉 stream 异常。
- 前端断连策略写进 runbook(继续 drain / cancel + 审计)。
- 演示模型名非生产承诺。
端到端验收剧本(十分钟)
- 纯文本问题:token 增量可见;结束后
is_complete真。 - 强制 tool:语义事件出现 tool_called → tool_output → message。
- 需审批 tool:第一段流结束后有 interruptions;未 approve 无副作用。
- approve 后续跑:再次 drain;业务态才允许「成功」。
cancel(mode="after_turn"):仍 drain;审计有取消标记。
五步全过才算 smoke。任一步靠脑补,记入 _w/streaming-smoke-checklist.md。
文档口径
对外:「流式改善等待体验;完成以服务端排空与业务状态为准。」对内 runbook:drain 辅助函数位置、审批续跑样例、取消与重连策略。
事件名速查(写进代码注释)
RunItemStreamEvent.name 固定集合里,生产最常用的是:
| name | 何时出现 | UI/审计建议 |
|---|---|---|
| message_output_created | 一条完整消息产出 | 可刷新「最终气泡」 |
| tool_called | 普通函数工具被调用 | 进度:正在调用 X |
| tool_output | 工具结果返回 | 记录输出摘要(注意脱敏) |
| handoff_requested | 请求交接 | 不要当业务成功 |
| handoff_occured | 交接发生(官方拼写) | 更新 current_agent 展示 |
| reasoning_item_created | 推理项(若模型提供) | 默认不对终端用户全文展示 |
| mcp_approval_requested | MCP 审批请求 | 走与 tool 审批同类 HITL |
把这张表贴进服务代码注释,比口头说「我们处理了 stream 事件」更可审。
前端节流与后端 drain 分离
常见错误是把「UI 每秒渲染」和「后端消费事件」绑死:UI 卡了就停迭代器。正确拆法:
- 后端任务负责
async for直到结束或明确 cancel。 - UI 订阅环形缓冲,允许丢弃中间 delta,但不许要求后端停 drain。
- 断连时:标记
client_gone=true,后端仍 drain;必要时cancel(mode="after_turn")并写审计。
这样用户刷新页面时,你仍能回答「那次 run 到底有没有完成」。
与 WebSocket 示例的对齐方式
官方示例仓库里有流式 + 审批循环的写法(如 examples/basic/stream_ws.py 一类)。你自己接 WebSocket 时,至少对齐三点:先消费事件;遇 interruptions 再问人;批准后用 state 续跑而不是新开用户句。缺任何一点,都会在「第二轮工具」上出现重复执行或丢审批。
测试策略(减少 flake)
- 单测:对 drain 辅助函数注入假事件序列,断言必须读到哨兵「迭代结束」。
- 集成:真实 key 下跑「短文本 + 强制 tool」两条。
- 混沌:中途取消、断连、审批拒绝各一条。
- 禁止:在 CI 里
break后仍断言final_output。
成本与延迟笔记
流式不会让模型「算得更快」,它主要改善首字时间与进度可见性。若工具很多,语义事件比 raw 更适合做进度;raw 全量推到浏览器可能反而拖垮弱网用户。生产上对 raw 做聚合(每 50ms 刷一次)是体验优化,不是协议偷工。
半截输出的用户沟通模板
当 drain 未完成或出现 interruption 时,不要让前端显示「已完成」。可用三态:
generating:仍在收事件needs_approval:流已排空且存在 interruptionscompleted/failed:is_complete且业务态已落库
把 SDK 的流态与业务态分开命名,客服与值班才不会把「字出完了」理解成「单已办结」。
和 handoffs 联调时的最小断言
若 triage 可能交接:
- 订阅
agent_updated_stream_event,记录名字序列。 - 断言最终
current_agent(或 last_agent)符合路由预期。 - 不要只断言最终字符串包含「退款」------那可能是 triage 幻觉。
流式的价值之一,正是让路由过程可观测。
日志脱敏
tool_output 与 raw delta 可能含 PII。生产默认:审计存哈希与截断,完整内容进受限存储。UI 展示前做一次红线过滤。流式不等于「全量对外广播」。
总结
streaming 的工程价值是可控的增量可见性 ,不是「更快结束业务」。选型先问:要不要中间 UI / HITL?要就 run_streamed 并 drain ;不要就 run。生产禁区的共同主题是:可见增量 ≠ 运行完成 ≠ 业务成功。把这三层拆开,流式才能从演示走进可运维。