易源合一流式接入点 3054-3:SSE 分块解析与选型取舍
# 易源合一流式接入点 3054-3:SSE 分块解析与选型取舍
> 接入点:3054-3 智能对话(流式) | 请求方式:POST | 返回格式:SSE 文本流(透传,无 showapi_res_body 封装) | 计费:5.5 厘/次,失败不扣费 | 最后实测核对:2026-09-15
## 核心要点
- 3054-3 是**透传模式**,直接返回服务商原始数据流,没有 `showapi_res_body` 封装,也没有 `showapi_fee_num` 计费字段。
- 响应格式是 `id` / `event` / `data` 三个字段,块与块之间用空行分隔,`data` 里是一段 JSON 字符串,要再解一次。
- 实测天气、笑话、新闻三类意图都只收到**一个 `finish` 块**,没有观察到文档里描述的 `add` 增量分片。要不要用它,先按这个事实估。
## 先看清它和 3054-1 的差别
| 项目 | 3054-1 智能对话 | 3054-3 智能对话(流式) |
|------|----------------|---------------------|
| 返回形态 | 一次性完整 JSON | SSE 文本流,`\n\n` 分块 |
| 外层封装 | 有 `showapi_res_body` | 无,透传 |
| 计费字段 | 返回 `showapi_fee_num` | 不返回 |
| 请求参数 | `text` + 可选 `args_list` | 相同 |
| 鉴权 | query 里的 `appKey` | 相同 |
请求体完全一样,差别只在响应怎么给你。所以选哪个不是「功能多少」的问题,是「你的前端要不要边收边渲染」的问题。
## 响应格式逐字段拆
一次真实响应(2026-09-15,「帮我查找一下昆明今天的天气」):
```
id: 94fc2278-f037-4dc9-aa5a-1e754bb22a6a
event: finish
data: {"reply_msg":{"img_list":[],"audio_list":[],"text":"#### 昆明 26年09月15日的天气\n\n预计白天气温:24℃,多云,南风0-3级,夜间气温:17℃,多云,东风0-3级。\n\n","video_list":[]},"ret_code":0,"result":[...],"intent":{"name":"weather","args":{"area":"昆明","date":"2026-09-15"}}}
```
三个字段的用途:
| 字段 | 类型 | 说明 |
|------|------|------|
| `id` | String | 当前流式 id,本次实测为 UUID 格式,同一响应内多次出现时值相同 |
| `event` | String | 事务类型:`add` 增量、`error` 异常、`finish` 结束、`interrupted` 中断 |
| `data` | String | 内容是**一段 JSON 字符串**,结构等同于 3054-1 的 `showapi_res_body` |
注意 `data` 字段本身是字符串,不是嵌套对象。要拿到 `reply_msg.text`,得对它再做一次 `json.loads`。
`data` 里的结构与 3054-1 的 `showapi_res_body` 一致,包括 `reply_msg`、`ret_code`、`result`、`intent` 四块。这意味着两个接入点的解析代码可以复用,只在最外层解包方式上分叉。
## 实测观察:只收到一个 finish 块
这一点在使用前必须知道。
用三类意图各调一次 3054-3,实测结果:
| 输入 | `event` 序列 | 耗时 |
|------|-------------|------|
| 帮我查找一下昆明今天的天气 | 仅 1 个 `finish` | 1.58 秒 |
| 讲个笑话 | 仅 1 个 `finish` | 1.57 秒 |
| 今天有什么新闻 | 仅 1 个 `finish` | 2.00 秒 |
三次调用都没有出现 `add` 分片,数据是一次性给完的。
这说明**在这三类意图上,流式没有带来「边收边渲染」的效果**。它和 3054-1 的实际差别,只剩「响应格式不同」和「少了计费字段」。
如果你打算用 3054-3 提升首字延迟,建议先用你自己的目标意图做一轮验证:记录第一个非 `finish` 事件的到达时间,和 3054-1 的整体返回时间比一比。我这次的样本不足以支撑「流式一定更快」或「流式一定没用」的结论。
## 解析代码
要兼容 `add` / `error` / `finish` / `interrupted` 四种事件,别只处理 `finish`。
```python
import json, requests
APP_KEY = "YOUR_APPKEY"
def ask_stream(text, timeout=60):
"""流式接入点:逐块解析,按事件类型分派。"""
with requests.post(
f"https://route.showapi.com/3054-3?appKey={APP_KEY}",
data={"text": text},
stream=True, # 关键:不要一次性读完整响应
timeout=timeout, # 文档标注读超时 60s
) as resp:
resp.raise_for_status()
buffer = ""
chunks = []
for raw in resp.iter_content(chunk_size=None, decode_unicode=True):
if not raw:
continue
buffer += raw
# 块与块之间用空行分隔
while "\n\n" in buffer:
block, buffer = buffer.split("\n\n", 1)
event = None
payload = None
for line in block.splitlines():
if line.startswith("id:"):
pass
elif line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
payload = line[5:].strip()
if event == "add":
# 增量分片:data 本身是 JSON 字符串,可能是不完整的
try:
chunks.append(json.loads(payload))
except json.JSONDecodeError:
print("增量块暂不完整,跳过:", payload[:60])
elif event == "finish":
body = json.loads(payload)
return {"ok": body.get("ret_code") == 0,
"text": body.get("reply_msg", {}).get("text", ""),
"intent": body.get("intent", {}).get("name"),
"result": body.get("result"),
"chunks": chunks}
elif event in ("error", "interrupted"):
return {"ok": False, "event": event, "raw": payload}
return {"ok": False, "reason": "流结束但未收到 finish"}
print(ask_stream("帮我查找一下昆明今天的天气"))
```
```javascript
const APP_KEY = "YOUR_APPKEY";
async function askStream(text) {
const res = await fetch(`https://route.showapi.com/3054-3?appKey=${APP_KEY}`, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ text }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let idx;
while ((idx = buffer.indexOf("\n\n")) !== -1) {
const block = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
const event = (block.match(/^event:\s*(.+)$/m) || [])[1];
const dataLine = (block.match(/^data:\s*([\s\S]*)$/m) || [])[1];
if (event === "finish") {
const body = JSON.parse(dataLine); // data 是 JSON 字符串,需要再解一层
return { ok: body.ret_code === 0, text: body.reply_msg?.text, intent: body.intent?.name };
}
if (event === "error" || event === "interrupted") {
return { ok: false, event, data: dataLine };
}
}
}
return { ok: false, reason: "未收到 finish 块" };
}
```
两版代码里最容易写错的一处是 `data` 的二次解析。`data:` 后面那一长串是 JSON 文本,不是已经解析好的对象。
## 什么时候值得用它
**值得用的场景只有一个前提:你需要把响应改成流式通道。**
比如你的后端到前端已经是 SSE 或 WebSocket,中间不想再转换成一次性 JSON;或者你要接语音合成,希望在文本产出过程中就开始合成。这种情况下 3054-3 的响应格式正好对上,省一层转换。
**不值得用的场景更常见:**
- 你要靠 `showapi_fee_num` 记每次调用的费用。3054-3 不返回这个字段,得改用账单侧对账。
- 你要在返回里做统一的外层错误判断。3054-3 没有 `showapi_res_body`,错误处理路径和 3054-1 不一样。
- 你的目标意图实测也是「只返回一个 finish 块」。那就没有增量可渲染,不如直接用 3054-1,格式更规整。
## FAQ
**Q1:3054-3 为什么不返回 `showapi_fee_num`?**
因为它是透传模式,直接返回服务商原始数据,不再套 showapi 的统一响应封装。文档的返回参数说明里已注明「该接口为透传模式,直接返回服务商原始数据,无 showapi_res_body 封装」。
**Q2:`add` 事件什么时候会出现?**
文档描述 `add` 是增量事件。本次实测的天气、笑话、新闻三类意图都没有收到 `add`,只收到 `finish`。哪些意图会产生增量分片,官方文档没有列举,需要你在自己的目标场景里实测确认。
**Q3:`data` 字段为什么还要再解析一次?**
因为它是字符串类型的 JSON 文本。直接取 `body.data.reply_msg` 会报错,正确写法是对这个字符串做一次 JSON 解析。
**Q4:流式请求要设多久超时?**
OpenAPI 文档给 3054-3 标注的读超时和连接超时都是 60 秒。客户端超时不要低于这个值,否则长响应会被自己切断。
**Q5:三个接入点的计费一样吗?**
一样。产品价格页显示三个接入点的单次费用同为 5.5 厘,失败不扣费。
## 下一步阅读
- [易源合一返回三层结构:reply_msg / intent / result 逐层拆解](https://www.showapi.com/guides/united-api-response-structure-3054)
- [易源合一在对话式应用里的位置:意图层与执行层的切分方案](https://www.showapi.com/guides/united-api-dialog-architecture-3054)
- [易源合一调用失败扣不扣费:ret_code 与 showapi_fee_num 实测对照表](https://www.showapi.com/guides/united-api-retcode-fee-3054)
- **本系列共 12 篇**:查看[易源合一指南总目录](https://www.showapi.com/guides/united-api-guides-3054)