SSE 事件参考
本文档面向前端开发者,详细说明 AI 平台流式响应的全部 SSE 事件、典型序列和集成方式。
修改记录
| 日期 | 变更 |
|---|---|
| 2026-06-25 | DeerFlow 工具调用事件序列对齐 OpenAI 规范:模型声明 tool_calls 时立即产出 output_item.added(status="in_progress")+ function_call_arguments.done(完整 arguments);工具执行完产出 output_item.done(status="completed")。前端可监听 function_call_arguments.done 获取结构化参数(含 ask_clarification 的 question/options/clarification_type),不再依赖 node_update:* custom 事件。 |
| 2026-06-05 | 修复续话(previousResponseId)场景下 SSE 误透传上一轮历史:不再把 resume 快照里上一轮的附件(container_file_citation)与工具调用(write_file/present_files 等)当本轮内容下发;续话流只携带本轮真实产出,与 DeerFlow 原生前端一致。前端无需再自行按文件名/调用去重。 |
| 2026-06-02 | 对齐 API 文档的「取消生成 / 续话」说明(responseId 取值区别);品牌名更新。 |
| 2026-05-29 | 新增附件相关 SSE 事件与前端集成(上传识别、生成附件下载 / 预览)。 |
| 2026-05-21 | SSE 事件参考完善;链接适配线上。 |
什么是 SSE
SSE(Server-Sent Events)是一种从服务端向浏览器推送实时数据的 HTTP 协议。AI 平台在收到用户提问后,将回答逐字生成为 SSE 事件流,前端逐帧渲染,实现"打字机"效果。
所有事件已统一为 OpenAI Responses API 标准格式,无论底层接入哪家 AI 引擎,前端收到的都是同一套事件协议。
数据流向
fetch / EventSource
调用 SDK API
转发请求
生成回答
事件流输出
ResponseStreamEvent
透传 SSE 字节
解析 event/data 帧
请求阶段:浏览器发起 HTTP 请求 → 业务后端接收并调用 SDK API → SDK 转发至 AI 引擎。响应阶段:AI 引擎生成回答事件流 → SDK 归一化为标准 ResponseStreamEvent → 业务后端将 SSE 字节写入 HttpServletResponse → 前端逐帧解析,驱动打字机渲染。
事件全景图
每一次流式调用都从 response.created 开始,到 response.completed(或 failed/incomplete)结束。中间取决于 Agent 行为,可能经历文本输出、工具调用、推理或代码解释器等不同分支。
response.created 的 response.id 是占位(此时上游 run_id 尚未返回,仅含会话标识);response.completed 的 id 才是完整标识。前端如需稳定关联本轮会话,用 metadata.thread_id;后端如需"停止生成",用 StreamHandle.awaitResponseId() 取含 runId 的 id 再调 cancel(见 API 文档)。
此后根据 Agent 选择进入一个或多个输出项,每个 output_item 由一对 ADDED / DONE 包裹
事件格式
SSE 流由逐行的文本帧组成,每个事件包含一个 event 行和一个 data 行,以空行分隔:
// 每个事件由 event 行 + data 行组成,以空行结束
event:response.created
data:{"type":"response.created","response":{"id":"resp_abc123","status":"in_progress",...}}
event:response.output_item.added
data:{"type":"response.output_item.added","output_index":0,"item":{"id":"lc_run--019e0001-abcd-1234-ef00-000000000001","type":"message","status":"in_progress"}}
event:response.content_part.added
data:{"type":"response.content_part.added","output_index":0,"item":{"id":"lc_run--019e0001-abcd-1234-ef00-000000000001","type":"message"}}
event:response.output_text.delta
data:{"type":"response.output_text.delta","output_index":0,"content_index":0,"item_id":"lc_run--019e0001-abcd-1234-ef00-000000000001","delta":"你好"}
event:response.output_text.delta
data:{"type":"response.output_text.delta","output_index":0,"content_index":0,"item_id":"lc_run--019e0001-abcd-1234-ef00-000000000001","delta":",今天"}
event:response.completed
data:{"type":"response.completed","response":{"id":"resp_abc123","status":"completed","output":[...],"usage":{...}}}
关键规则:
- 每个事件 一定以
event:<名称>开头,紧跟data:<JSON> - 事件之间以空行分隔
event名称是前端路由事件处理逻辑的 key,必须按名监听- 所有
data载荷均为合法 JSON
快速上手
最小可用的 SSE 监听代码:
// 发送请求并读取 SSE 流 const response = await fetch('/api/ai/chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ agent: 'my-agent', input: '你好' }), }); const reader = response.body.getReader(); const decoder = new TextDecoder(); let buffer = ''; let currentEvent = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (line.startsWith('event:')) { currentEvent = line.slice(7); } else if (line.startsWith('data:')) { const data = JSON.parse(line.slice(6)); handleEvent(currentEvent, data); // 你的事件处理函数 } } }
生命周期事件 6 个
每次流式调用都会完整经历生命周期主线。这 6 个事件描述了响应从创建到结束的全过程。
| SSE 事件名 | 触发时机 | 携带字段 | 前端行为 |
|---|---|---|---|
response.created |
服务端接收请求、分配 response_id 的瞬间,永远是流的第一个事件 | response.id, response.status="in_progress" |
记录 response.id 用于多轮对话;显示"加载中"占位 |
response.in_progress |
进入内容生成阶段,位于 created 之后、第一个内容事件之前 |
response |
更新 UI 状态为"生成中",可开始展示骨架屏 |
response.completed |
全部内容生成完毕,流正常结束 | response.output[](完整), response.usage |
读取 output 和 usage;隐藏加载动画;保存会话记录 |
response.failed |
服务端内部错误导致流终止 | response.status="failed", response.error |
展示错误提示;上报错误日志 |
response.incomplete |
触发了 max_tokens 截断或其他提前终止,内容不完整但不报错 | response.status="incomplete" |
提示用户"回答已截断";可增大 maxOutputTokens 后重试 |
error |
网络层或 SDK 内部捕获的异常 | error.message, error.code |
展示网络错误提示;根据异常类型判断是否重试 |
输出项事件 4 个
每轮回答由一个或多个"输出项"组成。一段文字是 message 项,一次工具调用是 function_call 项,一段推理是 reasoning 项。每个输出项内部可再细分为 content part。
Item 生命周期(六阶段)
每个有文本内容的输出项(message/reasoning)完整经历以下六个事件,前端可依此管理 UI 组件的创建与销毁:
response.output_item.added ← 创建气泡占位(item.id 即后续 item_id) response.content_part.added ← content part 开始(富文本场景多个 part) response.output_text.delta × N ← 逐字追加,item_id 不变 response.output_text.done ← text 字段含完整文本,可做最终校正 response.content_part.done ← content part 结束 response.output_item.done ← 气泡渲染完成,item.item() 含完整内容
注意:流被用户取消时 output_text.done、content_part.done、output_item.done 均可能缺失,前端清理逻辑不能依赖它们必然到达。
| SSE 事件名 | 触发时机 | 携带字段 | 前端行为 |
|---|---|---|---|
response.output_item.added |
新输出项开始产出 | item.id(真实消息 id,DeerFlow 引擎产出 lc_run--* 格式), item.type (message/function_call/reasoning), output_index(该 item 在 output[] 中的真实位置) |
以 item.id 为 key 创建 UI 占位(消息气泡/工具卡片/推理面板);多个 item 按 output_index 顺序排列 |
response.output_item.done |
当前输出项的全部内容接收完毕 | item(内容完整), output_index |
标记该 item 渲染完成;若是 function_call,此时参数完整可展示 |
response.content_part.added |
输出项内部开始新的内容部分 | item |
富文本渲染时为新的内容块占位(如同一消息含文字+图片) |
response.content_part.done |
当前内容部分接收完毕 | item |
标记该 content part 完成 |
文本与拒绝事件 5 个
模型生成可读文字时触发。这是前端最常处理的事件类别。
| SSE 事件名 | 触发时机 | 携带字段 | 前端行为 |
|---|---|---|---|
response.output_text.delta |
模型每生成一小段文字触发一次,核心渲染事件 | delta(文本片段), item_id(所属 MessageItem 的真实 id,DeerFlow 引擎为 lc_run--* 格式), output_index(item 在 output[] 中的位置), content_index(content part 序号) |
追加 delta 到当前活跃气泡;DeerFlow 串行输出,当 item_id 变化时表示进入新一轮消息,重置文本区域(见 React Hook 示例) |
response.output_text.done |
当前文本 content part 输出结束 | text(完整文本,无需自行拼接), item_id, output_index, content_index |
可选:用完整文本替换自行拼接的结果,做最终渲染;按 item_id 定位对应气泡 |
response.output_text.annotation.added |
文本中出现引用/脚注 | annotation(含引用源、位置等元信息) |
在文本旁渲染角标或悬浮引用卡片 |
response.refusal.delta |
模型判断请求违反安全策略,走拒绝通道 | delta(拒绝文本片段) |
展示"该请求无法处理"类提示,而非正常回答 |
response.refusal.done |
拒绝文本输出完毕 | delta(完整拒绝文本) |
渲染完整拒绝提示 |
refusal.delta 意味着本轮不会再有 output_text.delta。
推理事件 3 个
部分模型在输出最终答案前有内部"思维链"过程。推理文本有两种形态:完整版和摘要版。
| SSE 事件名 | 触发时机 | 携带字段 | 前端行为 |
|---|---|---|---|
response.reasoning_text.delta |
完整推理过程逐段输出(详细、未压缩) | delta |
通常不展示给终端用户,仅用于调试日志 |
response.reasoning_summary_text.delta |
推理摘要逐段输出(模型自行精简) | delta |
在可折叠面板中展示"AI 正在思考…",增强用户体感 |
response.reasoning_summary_text.done |
推理摘要结束 | — | 隐藏"思考中"动画,切换到正式回答区 |
reasoning_summary_text.delta 展示可读的思考过程;开发调试时,监听 reasoning_text.delta 记录完整思维链日志。
函数调用事件 2 个
Agent 决定调用外部工具/API 时触发,此时模型停止生成文字,改为输出工具名和参数 JSON。
| SSE 事件名 | 触发时机 | 携带字段 | 前端行为 |
|---|---|---|---|
response.function_call_arguments.delta |
工具参数 JSON 逐片生成中,参数尚不完整 | item_id(工具调用 id), output_index, delta(参数片段字符串) |
展示"正在准备调用…"进度提示;工具名从对应 output_item.added 的 item.name 获取 |
response.function_call_arguments.done |
工具参数 JSON 完整,此时可解析 arguments 并可视化 | item_id, output_index, arguments(完整 JSON 字符串) |
展示工具调用详情卡片(工具名+格式化参数);标记调用完成 |
内置工具事件 10 个
以下 Web 搜索、文件检索、代码解释器三类事件来自 OpenAI Responses API 规范,SDK 已按规范定义对应事件类型。是否实际产出取决于上游 Provider 的能力:当前 DeerFlow / Dify 主要产出生命周期、文本、推理与函数调用事件,内置工具调用通常以函数调用事件(response.function_call_arguments.*)或自定义节点形态体现;下列事件在 Provider 支持时才会收到。前端应按"未知事件兼容"原则容错处理。
Web 搜索事件 3 个
Agent 使用联网搜索工具时触发。
| SSE 事件名 | 触发时机 | 前端行为 |
|---|---|---|
response.web_search_call.searching | 开始在线搜索 | 显示"正在搜索…"进度条 |
response.web_search_call.completed | 搜索完成,结果已注入上下文 | 更新进度为"搜索完成",agent 将基于搜索结果生成回答 |
response.web_search_call.failed | 搜索失败(网络问题或超时) | 展示"搜索失败,将基于已有知识回答"提示 |
文件检索事件 2 个
Agent 使用向量检索知识库文件时触发。
| SSE 事件名 | 触发时机 | 前端行为 |
|---|---|---|
response.file_search_call.searching | 开始向量检索知识库 | 显示"正在检索文档…"提示 |
response.file_search_call.completed | 检索完成,相关段落已注入上下文 | 更新进度为"文档检索完成" |
代码解释器事件 5 个
Agent 使用沙箱环境执行代码(数学计算、数据分析等)时触发。这是复杂的工具调用流程,涉及代码生成和执行两个阶段。
| SSE 事件名 | 触发时机 | 携带字段 | 前端行为 |
|---|---|---|---|
response.code_interpreter_call.in_progress |
沙箱环境就绪,代码解释器准备执行 | — | 显示"正在运行代码…"提示 |
response.code_interpreter_call_code.delta |
模型生成代码的增量片段(代码尚未执行) | delta(代码文本片段) |
实时展示代码生成过程(代码高亮预览) |
response.code_interpreter_call_code.done |
代码生成完毕,此刻代码完整但尚未执行 | delta(完整代码) |
展示完整代码块,准备进入执行阶段 |
response.code_interpreter_call.interpreting |
沙箱正在执行代码(运行时阶段) | — | 显示"执行中…"动画 |
response.code_interpreter_call.completed |
代码执行完成,输出结果已注入上下文 | — | 展示执行结果;更新进度为"代码执行完成",agent 将基于结果生成回答 |
DeerFlow 自定义事件 概述
以下事件来自 DeerFlow 引擎特有的 LangGraph 执行机制,不属于 OpenAI Responses API 规范。SDK 通过 CustomItem(type="custom")承载这些事件,以 customType 字段区分子类型。这些事件全程伴随流式响应,不进入 response.output,仅供前端消费进度信号。
node_update:*(updates 通道)→ 对应前端 onUpdateEvent,图执行进度custom(custom 通道)→ 对应前端 onCustomEvent,即时通知<原始event名>(未知兜底)→ 无对应 hook,透传日志
node_update:* 事件 18+ 种
来自 LangGraph stream_mode="updates",每个图节点执行完后 emit {"<节点名>": {状态增量}}。SDK 为每个 key 生成一个 CustomItem,customType = "node_update:节点名"。
| customType | 来源节点 | 携带数据 | 前端/后端行为 |
|---|---|---|---|
node_update:model |
核心 LLM 调用节点 | messages[] 含 AIMessage(含 usage_metadata、model_name、tool_calls) |
SDK 内部触发 Langfuse step 生命周期追踪;前端无需处理 |
node_update:tools |
核心工具执行节点 | messages[] 含 ToolMessage(含 tool_call_id、name、content、status) |
SDK 生成 ToolCallItem → 前端渲染工具调用卡片(onToolEnd) |
node_update:TitleMiddleware |
标题中间件(after_model) | title 字段 |
前端自动更新会话标题 |
node_update:TokenUsageMiddleware |
Token 统计中间件(after_model) | token 用量数据 | 前端累加 token 统计展示 |
node_update:SummarizationMiddleware |
摘要中间件(before_model) | 历史消息转移通知 | 超长会话触发摘要时,前端刷新消息缓存 |
node_update:TodoMiddleware.* |
计划模式中间件(before_agent / before_model / after_model / after_agent) | todo 列表状态更新 | 前端渲染/更新计划面板 |
node_update:LoopDetectionMiddleware.* |
循环检测中间件(before_agent / after_model / after_agent) | 循环触发标志 | 前端可展示"检测到循环,正在跳出"提示 |
node_update:MemoryMiddleware |
记忆中间件(after_agent) | 更新后的记忆内容 | 前端可刷新记忆面板(如有) |
node_update:ThreadDataMiddleware |
线程数据中间件(before_agent) | 用户消息写入状态 | 通常无需前端处理 |
node_update:SandboxMiddleware.* |
沙箱中间件(before_agent / after_agent) | 文件/环境初始化状态 | 通常无需前端处理 |
{MiddlewareClass.__name__}.{hook_name},hook 名固定为 before_agent / before_model / after_model / after_agent。具体哪些节点出现取决于启用的中间件组合(plan_mode、subagent、loop_detection 等配置)。
updates 通道中 messages[] 的子类型
上游 updates payload 的 messages[] 数组中每条消息有 type 字段,对应 LangChain 消息类名:
| msg.type | LangChain 类 | 含义 | SDK 处理方式 |
|---|---|---|---|
human | HumanMessage | 用户输入消息 | 忽略 |
ai | AIMessage | LLM 回复消息 | 提取 usage_metadata、model_name、tool_calls.args |
tool | ToolMessage | 工具执行结果 | 去重后生成 ToolCallItem(task 工具重命名为 sub_agent) |
custom 通道事件 3 种
来自 LangGraph stream_mode="custom",由工具/中间件通过 get_stream_writer() 主动 emit。SDK 注册了 "custom" 作为五种 stream_mode 之一,底层 SSE 帧格式为:
event:custom
data:{"type":"task_running","task_id":"uuid-xxx","message":{...}}
// 或
event:custom
data:{"type":"llm_retry","attempt":2,"max_attempts":3,"message":"..."}
// thinking 也走 custom 通道,但被 SDK 拦截转换
event:custom
data:{"type":"thinking","data":"让我想一想..."}
SDK 处理流程(DeerflowSseNormalizer.handleCustom())
SSE event="custom" 到达 ↓ normalize() → 匹配 case "custom" → handleCustom(data) ↓ 解析 JSON,取 root.get("type") ├── type="thinking" │ ↓ 拦截,提取 data 字段作为推理文本 │ ↓ appendReasoningDelta(text) │ ↓ return REASONING_TEXT_DELTA ← 不生成 CustomItem,走推理流程 │ └── type="task_running" ↓ 累加子代理 token(从 message.response_metadata.token_usage 提取) ↓ 不 return,继续往下 其余任意 type 值(包括 task_running / llm_retry / 未来的新类型) ↓ ↓ new CustomItem(null, "custom") ↓ 将整个 JSON 展开写入 raw(所有的 key-value 对) ↓ emit OUTPUT_ITEM_ADDED ← CustomItem,不进入 Response.output
核心要点:
- thinking 被拦截:直接转为
ReasoningItem并 emitREASONING_TEXT_DELTA,不会生成CustomItem - task_running 先累加再透传:从
message.response_metadata.token_usage中提取子代理的 prompt_tokens / completion_tokens,通过accumulateUsage()计入全局 usage 统计,再按普通 custom 事件透传 - 全部 raw 信息保留:
CustomItem.getRaw()可取得原始 JSON 所有字段;ResponseStreamEvent.rawData()拿到完整JsonNode - 不进入 output:这些 CustomItem 是流式进度信号,
response.completed的output[]中不包含它们 - 如果 future 出现新 type:自动走"其余任意 type"分支,前端可通过监听
OUTPUT_ITEM_ADDED用raw.type判别,无需升级 SDK
事件详细说明
type="thinking"
response.reasoning_text.delta 接收推理内容,而不是在 CustomItem 中查找 thinking。
| 字段 | 何时 | 详情 |
|---|---|---|
| 发射源 | DeerFlow LLM 中间件在推理模式(reasoning_effort)下的内部处理 | |
| 触发条件 | 模型启用了 reasoning_effort 或模型原生输出 thinking 块 | |
| SDK 入口 | handleCustom() 中提前 return,走 appendReasoningDelta() | |
| SDK 产出 | REASONING_TEXT_DELTA(ResponseStreamEvent),前端通过 ev.type() 或 SSE event 名识别 | |
| 共用 ReasoningItem | 与 messages-tuple 通道中 <think> 标签走的 appendReasoningDelta() 是同一个实例——两条路径的推理文本会合并到同一个 ReasoningItem | |
| SSE event 名 | response.reasoning_text.delta | |
| data JSON | {"type":"response.reasoning_text.delta","delta":"..."} | |
| 前端行为 | 逐次追加渲染推理面板,与标准推理流程一致(见 推理事件) | |
type="task_running"
子代理(sub-agent)运行中,每当子代理产出一条新的 AI 消息就 emit 一次。一批 task_running 最后接一个 task_completed 事件(也是 custom 通道,见表后说明)。
上游 JSON payload(deer-flow → SDK 原始帧)
{
"type": "task_running",
"task_id": "uuid-of-subagent-task", // 子代理任务 ID,前端按此去重/分组
"message": { // 完整的 LangChain AIMessage 对象
"type": "ai", // LangChain 消息类型
"content": "正在查询猪场数据...",
"id": "lc_run--019e0001-...",
"response_metadata": {
"model_name": "deepseek-v3",
"finish_reason": "stop",
"token_usage": { // ← SDK 从此处提取 token 累计到全局 usage
"prompt_tokens": 150,
"completion_tokens": 35,
"total_tokens": 185
}
}
},
"message_index": 1, // 第几条消息(1-based),前端可展示序号
"total_messages": 3 // 当前已累积的消息总数
}
SDK 对 task_running 的特殊处理
- 从
message.response_metadata.token_usage提取 prompt_tokens / completion_tokens - 调用
accumulateUsage()累加到全局计数器(最终汇总到response.completed的usage中) - 随后继续往下,生成
CustomItem(null, "custom"),将原始 JSON 所有字段写入raw
前端接收
case 'response.output_item.added': { const item = data.item; if (item.type !== 'custom') break; // method 1: raw 通过 SSE data 中的 item.raw 字段取得 // method 2: 将 data.item 作为 CustomItem,取 getRaw()["type"] const rawType = data.item.raw?.type; // 或 item.raw["type"] if (rawType === 'task_running') { const { task_id, message, message_index } = data.item.raw; // 更新子任务进度卡片 updateSubtask(task_id, { latestMessage: message, messageIndex: message_index, }); } break; }
| 字段 | 何时 | 详情 |
|---|---|---|
| 发射源 | task_tool.py:355,task 工具(子代理调度器)在轮询子代理结果时 | |
| 触发条件 | lead agent 使用了 task 工具派发子代理;子代理运行中每次产出新 AI 消息时 emit 一次 | |
| 频率 | 子代理每完成一轮 LLM 调用就 emit 一次;如果子代理调了多个工具,每条 AI 消息各 emit 一次 | |
| SDK 产出 | OUTPUT_ITEM_ADDED,item 为 CustomItem(type="custom", customType="custom"),raw 含全部字段 | |
| SSE event 名 | response.output_item.added | |
| 前端行为 | 按 task_id 找到对应子任务卡片,用 message.content 更新显示的最新消息预览 | |
type="llm_retry"
LLM 调用失败后,LLMErrorHandlingMiddleware 自动重试时 emit。前端应弹出即时通知而非静默忽略。
上游 JSON payload(deer-flow → SDK 原始帧)
{
"type": "llm_retry",
"attempt": 2, // 当前是第几次重试(从 1 开始)
"max_attempts": 3, // 最多重试次数
"wait_ms": 2000, // 距离上一次失败等待的毫秒数
"reason": "rate_limit", // 重试原因(rate_limit / timeout / server_error)
"message": "LLM 调用失败(限流),正在第 2/3 次重试(等待 2.0s)..." // 人类可读提示
}
SDK 处理:不拦截,完整保留 raw,生成 CustomItem(null, "custom"),emit OUTPUT_ITEM_ADDED。
前端接收
const rawType = data.item.raw?.type; if (rawType === 'llm_retry') { const { attempt, max_attempts, message } = data.item.raw; toast.warning(message, { duration: 3000 }); // 弹出提示但不要阻断用户操作 }
| 字段 | 何时 | 详情 |
|---|---|---|
| 发射源 | llm_error_handling_middleware.py:197 | |
| 触发条件 | LLM API 调用返回可重试错误(限流 429、服务端 5xx、超时)且 retry_max_attempts > 1 | |
| SDK 产出 | OUTPUT_ITEM_ADDED,item 为 CustomItem(type="custom", customType="custom"),raw 含全部字段 | |
| SSE event 名 | response.output_item.added | |
| 前端行为 | toast.warning(message),告知用户"AI 服务暂时不稳定,正在自动重试" | |
| 最终失败 | 超过 max_attempts 后中间件抛出异常 → SDK emit response.failed;前端从 toast 切换到错误横幅 | |
补充:type="task_completed"(非核心,现状未处理)
子代理运行结束后,task_tool.py 还会 emit 一条 task_completed 事件(含 result、usage 等字段),同样走 custom 通道。当前 SDK 将其作为普通 custom 事件透传,raw.type = "task_completed"。前端可通过 data.item.raw 取得结果摘要和用量信息。
兜底 CustomItem 生成场景
除上述 custom 通道外,以下场景也会产生 CustomItem:
| 场景 | 触发条件 | customType 值 | raw 内容 |
|---|---|---|---|
| 未知 SSE event 名 | 新版 deer-flow 引入新 stream_mode,SDK 的 normalize() 走 default 分支 |
<原始event名> |
原始 data JSON(若可解析)或原始字符串 |
| JSON 解析异常 | handleCustom() 或 normalize() 内 readTree() 抛 IOException |
<原始event名> |
{"data": "原始字符串"} |
| Jackson 反序列化回退 | OutputItem 的 @JsonSubTypes 不匹配(如未来 OpenAI 新 type) |
JSON 中 "type" 的值 |
完整反序列化后的 JSON 对象 |
OutputItem.type === "custom" 且不认识 customType / raw.type 的事件,记录日志后忽略,不抛异常不中断流。
role 字段映射
在 Message/MessageItem 中,role 字段遵循 OpenAI Messages API 规范,标识消息发言者身份:
| role 值 | deer-flow 来源 | 含义 |
|---|---|---|
user | HumanMessage | 用户输入 |
assistant | AIMessage | AI/LLM 的回复 |
system | SystemMessage | 系统提示词 |
tool | ToolMessage | 工具执行结果 |
type 的区别:type 是 updates 通道 messages[] 内的 LangChain 类名(human/ai/tool),仅出现在 raw payload 中;role 是 OpenAI 规范字段,对外暴露在 MessageItem 中供业务方使用。两者语义对应但字段名不同。
前端消费对照表
| SSE 通道 | customType | 前端事件分发 | UI 表现 |
|---|---|---|---|
updates | node_update:model | 产出 OUTPUT_ITEM_ADDED(ToolCallItem) + FUNCTION_CALL_ARGUMENTS_DONE | 工具声明 → 前端获知工具名和参数 |
updates | node_update:tools | 产出 OUTPUT_ITEM_DONE(ToolCallItem) | 工具完成 → 含 status 和 result |
updates | node_update:TitleMiddleware | onUpdateEvent | 自动更新会话标题 |
updates | node_update:TokenUsageMiddleware | onUpdateEvent | Token 用量更新 |
updates | node_update:SummarizationMiddleware | onUpdateEvent | 刷新消息缓存 |
updates | node_update:*(其他中间件) | onUpdateEvent | 状态同步(按需处理) |
custom | custom(raw.type=thinking) | ReasoningItem | 推理面板 |
custom | custom(raw.type=task_running) | onCustomEvent | 子任务进度卡片 |
custom | custom(raw.type=llm_retry) | onCustomEvent | 重试 Toast 提示 |
场景:纯文本回答
最常见场景——用户提问,模型直接生成文字回复,不涉及工具调用或推理。
要点:
output_text.delta是核心渲染事件,出现 0~N 次output_text.done的text字段是完整文本,可直接替换拼接结果completed的response.output是结构化输出,response.usage含 token 用量
场景:工具调用 → 生成回答
Agent 先调用工具获取数据,再基于工具返回结果生成文字回答。典型如查询数据库、调 API。
要点:
- 通过
output_item.added的item.type区分是工具调用还是消息 - 工具调用期间 没有
output_text.delta,等工具返回后才开始生成文字 - 服务端在
function_call_arguments.done后自动执行工具并获取结果,前端无需参与 - 可能多轮工具调用:工具→回答→工具→回答,前端需支持 item 列表的追加渲染
场景:澄清问题(ask_clarification)
Agent 发现用户输入信息不足时,调用 ask_clarification 工具向用户提问。前端收到结构化参数后渲染澄清卡片,用户选择/补充后继续对话。
前端处理:
- 监听
output_item.added,当item.name === "ask_clarification"时显示"正在提问…"加载态 - 监听
function_call_arguments.done,解析argumentsJSON 得到question/options/clarification_type,渲染澄清卡片 - 用户选择后将结果作为新的
user消息发送(继续对话),Agent 根据补充信息继续执行 - 降级兼容:若只收到
output_item.added无arguments.done(极端情况),从arguments.question或node_update:toolscustom 事件的messages[].content取纯文本作为兜底
场景:深度推理 → 生成回答
模型先进行内部推理(思维链),再输出正式回答。用户可见推理摘要。
要点:
- 推理阶段与文本阶段是两个独立的
output_item - 推理 item 的
type为"reasoning",消息 item 的type为"message" reasoning_text.delta是完整版(详细,不展示),reasoning_summary_text.delta是摘要版(给用户看)
场景:代码解释器
Agent 在沙箱中生成并执行代码(如数据分析、数学计算)。这是最复杂的内置工具流程。
要点:
- 代码解释器事件全部嵌套在
output_item.added和output_item.done之间 - 代码生成(
code.delta→code.done)与代码执行(interpreting→completed)是两个阶段 - 在
code.done时获取完整代码,completed后才进入文字回答
使用 fetch + ReadableStream
这是推荐的方式,支持 POST 传参、自定义 Header、AbortController 中断。兼容所有现代浏览器。
// 通用 SSE 流解析器 async function streamChat( body: { agent: string; input: string; previous_response_id?: string }, handlers: { onDelta?: (delta: string) => void; onReasoning?: (delta: string) => void; onToolCall?: (name: string, args: string) => void; onCompleted?: (response: any) => void; onError?: (message: string) => void; }, signal?: AbortSignal ): Promise<void> { const response = await fetch('/api/ai/chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal, }); if (!response.ok) { throw new Error(`HTTP ${response.status}`); } const reader = response.body!.getReader(); const decoder = new TextDecoder(); let buffer = ''; let currentEvent = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (line.startsWith('event:')) { currentEvent = line.slice(7); } else if (line.startsWith('data:')) { const data = JSON.parse(line.slice(6)); switch (currentEvent) { case 'response.output_text.delta': handlers.onDelta?.(data.delta); break; case 'response.output_text.done': if (data.text) handlers.onDelta?.(data.text); // 完整文本替换增量拼接结果 break; case 'response.reasoning_summary_text.delta': handlers.onReasoning?.(data.delta); break; case 'response.function_call_arguments.done': // item_id 对应 output_item.added 中的 item.id,name 需从彼处缓存 handlers.onToolCall?.(data.item_id, data.arguments); break; case 'response.completed': handlers.onCompleted?.(data.response); break; case 'response.failed': handlers.onError?.(data.response?.error?.message || 'AI 服务异常'); break; case 'error': handlers.onError?.(data.error?.message || '连接异常'); break; default: // 未知事件类型,记录日志后忽略,不抛异常 console.debug('未处理的事件:', currentEvent, data); } } } } }
使用 EventSource(仅 GET 场景)
如果业务后端支持 GET 请求传递参数(query string),可以用浏览器原生 EventSource,代码更简洁。但 不支持 POST、自定义 Header 和中断。
const params = new URLSearchParams({ agent: 'my-agent', input: '你好' }); const source = new EventSource(`/api/ai/chat/stream?${params}`); let fullText = ''; source.addEventListener('response.output_text.delta', (e) => { const { delta } = JSON.parse(e.data); fullText += delta; renderText(fullText); // 追加渲染 }); source.addEventListener('response.output_text.done', (e) => { const { text } = JSON.parse(e.data); if (text) renderText(text); // text 字段含完整文本,以服务端结果为准 }); source.addEventListener('response.completed', (e) => { const { response } = JSON.parse(e.data); console.log('完成,tokens:', response.usage?.total_tokens); source.close(); }); source.addEventListener('response.failed', (e) => { const { response } = JSON.parse(e.data); showError(response.error?.message || 'AI 服务异常'); source.close(); }); source.onerror = () => { showError('连接异常,请重试'); source.close(); };
Authorization)、不支持 AbortController 中断。生产环境建议使用 fetch + ReadableStream。
React Hook 完整示例
以下 Hook 覆盖全部 30 种 SSE 事件,包含文本渲染、推理面板、工具调用、内置工具状态和错误处理。
import { useState, useRef, useCallback } from 'react'; // ── 类型定义 ── type Status = 'idle' | 'streaming' | 'completed' | 'failed' | 'incomplete'; interface ToolCallState { id: string; name: string; arguments: string; done: boolean; } interface BuiltinStatus { webSearch?: 'searching' | 'completed' | 'failed'; fileSearch?: 'searching' | 'completed'; codeInterpreter?: 'in_progress' | 'coding' | 'interpreting' | 'completed'; } interface AiChatState { status: Status; answerText: string; // 当前活跃 item 的累积文本 currentItemId: string | null; // 正在流式输出的 item_id reasoningText: string; refusalText: string; toolCalls: ToolCallState[]; builtinStatus: BuiltinStatus; responseId: string | null; usage: { inputTokens: number; outputTokens: number } | null; error: string | null; } // ── Hook ── export function useAiChat(agent: string) { const [state, setState] = useState<AiChatState>({ status: 'idle', answerText: '', currentItemId: null, reasoningText: '', refusalText: '', toolCalls: [], builtinStatus: {}, responseId: null, usage: null, error: null, }); const abortRef = useRef<AbortController | null>(null); // 用 ref 持有最新 responseId,避免 send 闭包因 React batching 读到旧 state const responseIdRef = useRef<string | null>(null); const cancel = useCallback(() => { abortRef.current?.abort(); setState(prev => ({ ...prev, status: 'idle' })); }, []); const send = useCallback(async (input: string) => { cancel(); setState(prev => ({ ...prev, status: 'streaming', answerText: '', currentItemId: null, reasoningText: '', refusalText: '', toolCalls: [], builtinStatus: {}, usage: null, error: null, })); const abortController = new AbortController(); abortRef.current = abortController; try { const resp = await fetch('/api/ai/chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, // previous_response_id 为业务 DTO 字段名,可自定义;SDK Builder 对应 previousResponseId() // 读 ref 而非 state,确保拿到最新值(绕过 React 18 batching 闭包陈旧问题) body: JSON.stringify({ agent, input, previous_response_id: responseIdRef.current }), signal: abortController.signal, }); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const reader = resp.body!.getReader(); const decoder = new TextDecoder(); let buffer = '', currentEvent = ''; while (true) { const { done, value } = await reader.read(); if (done) break; buffer += decoder.decode(value, { stream: true }); const lines = buffer.split('\n'); buffer = lines.pop() || ''; for (const line of lines) { if (line.startsWith('event:')) { currentEvent = line.slice(7); } else if (line.startsWith('data:')) { const data = JSON.parse(line.slice(6)); // ref 在 setState 外更新(updater 内不应有副作用) if (currentEvent === 'response.created' && data.response?.id) { responseIdRef.current = data.response.id; } setState(prev => { const s = { ...prev }; switch (currentEvent) { // ── 生命周期 ── case 'response.created': s.responseId = data.response?.id; // 同步到 state 供 UI 展示 break; case 'response.in_progress': s.status = 'streaming'; break; // ── 文本增量 ── // item_id 串行出现(DeerFlow 顺序执行),新 item 直接覆盖旧文本 case 'response.output_text.delta': { const id = data.item_id; if (id && id !== s.currentItemId) { s.currentItemId = id; // 新 item:切换并重置文本 s.answerText = data.delta || ''; } else { s.answerText += data.delta || ''; } break; } case 'response.output_text.done': // 用服务端校正后的完整文本覆盖增量累积结果(可选) if (data.text) s.answerText = data.text; break; case 'response.output_text.annotation.added': // 可在此收集引用/脚注列表,与文本位置关联渲染 break; // ── 拒绝 ── case 'response.refusal.delta': s.refusalText += data.delta || ''; break; case 'response.refusal.done': if (data.delta) s.refusalText = data.delta; break; // ── 推理 ── case 'response.reasoning_text.delta': // 完整思维链(调试用),生产环境可跳过渲染 break; case 'response.reasoning_summary_text.delta': s.reasoningText += data.delta || ''; break; case 'response.reasoning_summary_text.done': s.reasoningText += '\n\n——思考完毕——'; break; // ── 输出项 ── case 'response.output_item.added': if (data.item?.type === 'function_call') { s.toolCalls = [...s.toolCalls, { id: data.item.id, name: data.item.name || '...', arguments: '', done: false, }]; } break; case 'response.output_item.done': if (data.item?.type === 'function_call') { s.toolCalls = s.toolCalls.map(tc => tc.id === data.item.id ? { ...tc, done: true } : tc ); } break; case 'response.content_part.added': case 'response.content_part.done': // 富文本场景可按需处理 break; // ── 工具调用 ── // item_id = 工具调用 id(与 output_item.added 的 item.id 对应) // name 在 output_item.added 时已写入 toolCalls,此处只追加 arguments case 'response.function_call_arguments.delta': s.toolCalls = s.toolCalls.map(tc => tc.id === data.item_id ? { ...tc, arguments: tc.arguments + (data.delta || '') } : tc ); break; case 'response.function_call_arguments.done': s.toolCalls = s.toolCalls.map(tc => tc.id === data.item_id ? { ...tc, arguments: data.arguments, done: true } : tc ); break; // ── 内置工具状态 ── case 'response.web_search_call.searching': s.builtinStatus = { ...s.builtinStatus, webSearch: 'searching' }; break; case 'response.web_search_call.completed': s.builtinStatus = { ...s.builtinStatus, webSearch: 'completed' }; break; case 'response.web_search_call.failed': s.builtinStatus = { ...s.builtinStatus, webSearch: 'failed' }; break; case 'response.file_search_call.searching': s.builtinStatus = { ...s.builtinStatus, fileSearch: 'searching' }; break; case 'response.file_search_call.completed': s.builtinStatus = { ...s.builtinStatus, fileSearch: 'completed' }; break; case 'response.code_interpreter_call.in_progress': s.builtinStatus = { ...s.builtinStatus, codeInterpreter: 'in_progress' }; break; case 'response.code_interpreter_call_code.delta': s.builtinStatus = { ...s.builtinStatus, codeInterpreter: 'coding' }; break; case 'response.code_interpreter_call_code.done': // 代码完整,即将执行 break; case 'response.code_interpreter_call.interpreting': s.builtinStatus = { ...s.builtinStatus, codeInterpreter: 'interpreting' }; break; case 'response.code_interpreter_call.completed': s.builtinStatus = { ...s.builtinStatus, codeInterpreter: 'completed' }; break; // ── 终止 ── case 'response.completed': s.status = 'completed'; s.usage = { inputTokens: data.response?.usage?.input_tokens || 0, outputTokens: data.response?.usage?.output_tokens || 0, }; break; case 'response.failed': s.status = 'failed'; s.error = data.response?.error?.message || 'AI 服务异常'; break; case 'response.incomplete': s.status = 'incomplete'; break; case 'error': s.status = 'failed'; s.error = data.error?.message || '连接异常'; break; default: console.debug('未处理事件:', currentEvent, data); } return s; }); } } } } catch (err: any) { if (err.name !== 'AbortError') { setState(prev => ({ ...prev, status: 'failed', error: err.message })); } } }, [agent, cancel]); // responseIdRef 不需要列入 deps(ref 变化不触发重渲染) return { ...state, send, cancel }; }
使用示例
function ChatPanel() { const { status, answerText, reasoningText, refusalText, toolCalls, builtinStatus, usage, error, send, cancel, } = useAiChat('pig-farm-agent'); const isStreaming = status === 'streaming'; return ( <div className="chat-panel"> {/* 错误提示 */} {error && <div className="error-banner">{error}</div>} {/* 内置工具状态 */} {builtinStatus.webSearch === 'searching' && <Spinner label="搜索中..." />} {builtinStatus.webSearch === 'failed' && <Hint>搜索失败,基于已有知识回答</Hint>} {builtinStatus.fileSearch === 'searching' && <Spinner label="检索文档..." />} {builtinStatus.codeInterpreter && <CodeStatusBadge status={builtinStatus.codeInterpreter} />} {/* 拒绝回答 */} {refusalText && <div className="refusal-box">{refusalText}</div>} {/* 推理面板 */} {reasoningText && ( <details open> <summary>AI 思考中...</summary> <p>{reasoningText}</p> </details> )} {/* 回答区 */} <Markdown content={answerText} /> {/* 工具调用 */} {toolCalls.map(tc => ( <ToolCard key={tc.id} name={tc.name} args={tc.arguments} loading={!tc.done} /> ))} {/* 底部状态 */} {isStreaming && <Spinner />} {status === 'incomplete' && <Hint>回答因长度限制被截断</Hint>} {usage && <div className="usage">消耗 {usage.inputTokens + usage.outputTokens} tokens</div>} {/* 操作 */} <InputBox onSend={send} disabled={isStreaming} /> <button onClick={cancel} disabled={!isStreaming}>取消</button> </div> ); }
多轮对话
多轮对话的关键字段是 previousResponseId。每轮完成后保存 response.id,下一轮请求时传入,服务端自动关联历史上下文。
// 首轮对话 let lastResponseId = null; async function chat(userInput) { const body = { agent: 'my-agent', input: userInput, // 非首轮时传入上一轮 responseId ...(lastResponseId && { previous_response_id: lastResponseId }), }; const response = await fetch('/api/ai/chat/stream', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); // ... 解析 SSE 流 ... // 在 response.created 事件中更新: // lastResponseId = data.response.id; }
要点:
previousResponseId是一个不透明字符串,前端无需解析其内容,只管存取- 从
response.created事件中获取新的response.id - 也可以在
response.completed时统一保存 - 传入
previousResponseId后,input只需传当前这一轮的用户输入,无需传完整历史
错误处理
前端需要区分两类错误:
| 错误类型 | 来源 | SSE 事件 | 处理方式 |
|---|---|---|---|
| 服务端业务错误 | AI 引擎返回错误(如超过限流、输入违规) | response.failed |
展示 response.error.message;建议用户修改输入后重试 |
| 连接/网络错误 | HTTP 请求失败、网络断开 | error 或 fetch 异常 |
展示"连接异常"提示;可实现指数退避重连 |
| 回答被截断 | 超过 max_tokens 限制 | response.incomplete |
提示用户"回答已截断";后端可增大 maxOutputTokens |
| 用户主动取消 | 前端 AbortController.abort() | AbortError | 静默处理,不展示错误提示 |
try { await streamChat(body, { onCompleted(response) { if (response.status === 'incomplete') { showWarning('回答因长度限制被截断,建议精简提问'); } }, onError(message) { // 区分是否可重试 if (message.includes('超时') || message.includes('连接')) { showRetryable('网络异常,请重试'); } else { showError(message); } }, }, abortSignal); } catch (err) { if (err.name === 'AbortError') { // 用户取消,不做任何处理 } else { showError('请求失败: ' + err.message); } }
防抖渲染
output_text.delta 可能每 10-30ms 触发一次。如果渲染 Markdown 或做 DOM 操作,建议做防抖:
function debounce(fn, ms) { let timer; return (...args) => { clearTimeout(timer); timer = setTimeout(() => fn(...args), ms); }; } const renderMarkdown = debounce((text) => { element.innerHTML = marked.parse(text); // 或其他 Markdown 渲染库 }, 50); // 50ms 防抖,平衡流畅度与性能
未知事件兼容
SDK 可能在 minor 版本中新增事件类型(如未来接入新 AI 引擎引入新事件)。前端代码 必须能处理未知事件,不应因为不认识某个事件就报错或中断流处理。
// ✅ 推荐:按事件名监听,自动兼容新事件 source.addEventListener(eventName, handler); // ✅ 也可以:switch 带 default 分支 switch (currentEvent) { case 'response.output_text.delta': /* ... */ break; case 'response.completed': /* ... */ break; default: console.debug('未知事件:', currentEvent); // 记录日志后忽略,不抛异常 } // ❌ 错误:switch 没有 default,未知事件导致静默丢弃 // ❌ 错误:default 分支 throw error,一条未知事件就中断整个流
断线重连
网络不稳定导致 SSE 连接断开时,通过 previousResponseId 实现无缝续接:
async function chatWithRetry(agent, input, maxRetries = 3) { let lastResponseId = null; let collectedText = ''; for (let attempt = 0; attempt < maxRetries; attempt++) { try { await streamChat( { agent, input: attempt === 0 ? input : collectedText || input, previous_response_id: lastResponseId, // 续接上下文 }, { onCreated(id) { lastResponseId = id; }, onDelta(delta) { collectedText += delta; render(collectedText); }, onCompleted() { return; }, // 成功,退出重试循环 } ); break; // 正常完成,退出重试 } catch (err) { if (attempt === maxRetries - 1) { showError('多次重试失败,请稍后再试'); } else { // 指数退避:1s, 2s, 4s... await new Promise(r => setTimeout(r, Math.pow(2, attempt) * 1000)); } } } }
附件:上传、识别生成附件、下载/预览(照着实现)
前端只跟业务后端的两个接口打交道,不直连 DeerFlow、不解析 file_id:
| 接口 | 说明 |
|---|---|
POST /ai/chat | multipart:message + 可选 file + 可选 previousResponseId;返回 text/event-stream |
GET /ai/file?fileId=&mode=preview|download | 返回附件字节,预览用 inline、下载用 attachment |
附件信号在 SSE 流里:assistant 文本的注解事件 response.output_text.annotation.added,其 annotation 为 container_file_citation,含 file_id + filename。
① 发起带附件的提问 + 接收流(含附件信号)
带文件的 POST 必须用 fetch + ReadableStream 解析 SSE(EventSource 只支持 GET、不能传文件)。
async function ask(message, file, previousResponseId, ui) { const form = new FormData(); form.append('message', message); if (file) form.append('file', file); // <input type=file> 选中的 File if (previousResponseId) form.append('previousResponseId', previousResponseId); const res = await fetch('/ai/chat', { method: 'POST', body: form }); const reader = res.body.getReader(); const decoder = new TextDecoder(); let buf = ''; while (true) { const { value, done } = await reader.read(); if (done) break; buf += decoder.decode(value, { stream: true }); // SSE 以空行分隔事件块 let idx; while ((idx = buf.indexOf('\n\n')) >= 0) { const block = buf.slice(0, idx); buf = buf.slice(idx + 2); const line = block.split('\n').find(l => l.startsWith('data:')); if (!line) continue; const data = JSON.parse(line.slice(5).trim()); handle(data, ui); } } } function handle(data, ui) { switch (data.type) { case 'response.created': ui.previousResponseId = data.response?.id; break; // 存起来供续话 case 'response.output_text.delta': ui.appendText(data.delta); break; // 增量正文 case 'response.output_text.annotation.added': { // 附件信号! const a = data.annotation; if (a && a.file_id) ui.addAttachment(a.file_id, a.filename); break; } case 'response.completed': ui.done(); break; case 'error': ui.error(data.message); break; default: break; // 未知事件忽略(前向兼容) } }
② 渲染附件卡片 + 预览/下载
拿到 file_id/filename 后渲染一个条目;预览用 inline URL(新标签/iframe),下载用 attachment URL。file_id 原样 URL 编码透传,不解析。
function addAttachment(fileId, filename) { const q = 'fileId=' + encodeURIComponent(fileId); const previewUrl = '/ai/file?' + q + '&mode=preview'; const downloadUrl = '/ai/file?' + q + '&mode=download'; const el = document.createElement('div'); el.className = 'attachment'; el.innerHTML = '📎 ' + escapeHtml(filename) + ' <a target="_blank" href="' + previewUrl + '">预览</a>' + ' <a href="' + downloadUrl + '" download>下载</a>'; document.getElementById('attachments').appendChild(el); }
<a target="_blank"> 或放进 <iframe src=previewUrl>(PDF/图片/文本可内联;HTML/SVG 后端会强制下载防 XSS)。
下载:<a download> 触发浏览器另存。两者都打到业务后端 /ai/file,由后端经 SDK 取字节。
对应的业务后端
/ai/chat 与 /ai/file 两个接口的服务端实现(金蝶云苍穹)见
SDK 文档 · 附件集成——后端用
createStreamAsSse 直通 SSE、用 files().download(fileId) 回吐字节并设
Content-Disposition。前后端契约即上方两接口表,无需额外约定。
小欣AI·服务台