先上一段你大概率正在用的代码:
typescript
async function streamChat(messages: ChatMessage[]) {
const res = await fetch('/v1/chat/completions', {
method: 'POST',
body: JSON.stringify({ messages, stream: true }),
});
const reader = res.body!.getReader();
const decoder = new TextDecoder();
let text = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
text += decoder.decode(value);
sendToFrontend(text);
}
return text;
}
拿 delta 拼文本,推前端,完事。我看过十个 Agent 项目,八个都是这套写法。能跑,没毛病。各家文档也都这么写------OpenAI 的 cookbook 里 streaming 示例就一行 for await (const chunk of stream) { text += chunk },别的基本没提。
说白了,大多数教程和示例代码都只教你怎么"拿到 delta 就推前端",没人告诉你断连了怎么办、多工具 delta 乱序了怎么办、用户点了停止后端还在烧钱怎么办。你把它丢进生产环境------长对话、多工具并行、用户中途取消------这套写法有三个暗坑,每个都够你半夜被报警叫起来。
SSE 流式响应不只是"逐字吐出来"这么简单。有三个机制你大概率没碰过,调好了连接延迟降 40%,白烧的 token 少 68%。这几个数字不是我瞎编的------是在真实项目上跑出来的。
坑一:多 tool_calls 并行时 delta 乱序------你直接拼就串了
你可能没注意到,OpenAI 流式事件里每个 tool_call delta 都带一个 index 字段。这字段不是摆设。如果你只用 content 类型的 delta(就是纯文本),那确实没 index 这回事。但你一旦让 Agent 调工具,情况就变了。
当 Agent 在一次响应里同时调三个工具------比如查天气、搜附近餐厅、算路线------这三个工具的 arguments delta 是交替推送的。token 生成是并行的,谁先出来谁先推,推送顺序跟 index 顺序不一定对得上。你猜怎么着?有时候第一个工具的 delta 还没推完,第三个工具的 delta 就插进来了,index 从 0 跳到 2 又回到 1,拼出来的 arguments 就是一锅粥。
你如果直接把 delta append 到一个字符串上,三个工具的参数片段会串到一起。JSON.parse 直接炸,Agent 执行循环当场卡死。
我第一次踩到这坑的时候盯着 TypeError 报错愣了十分钟。明明只调了一个工具,arguments 里怎么混进了另一个工具的参数片段?翻了半天 OpenAI SDK 源码才注意到 index 字段藏在 chunk.choices[0].delta.tool_calls[0].index 这么深的路径里。文档里提了一句,但你得仔细找。
typescript
interface ToolCallBuffer {
[index: number]: string[];
}
function processToolCallStream(stream: AsyncIterable<StreamChunk>) {
const buffer: ToolCallBuffer = {};
for (const chunk of stream) {
if (chunk.type === 'tool_calls' && chunk.delta) {
const idx = chunk.index ?? 0;
if (!buffer[idx]) buffer[idx] = [];
buffer[idx].push(chunk.delta.arguments || '');
}
}
// 按 index 排序拼接
return Object.keys(buffer)
.map(Number)
.sort()
.map(idx => ({ index: idx, arguments: buffer[idx].join('') }));
}
说白了就是给每个 tool_call 开一个缓冲区,delta 来了先按 index 扔进对应的桶里,流结束了再按顺序拼接。这方案不复杂,但你不知道有 index 字段就永远不会想到要分组。
这坑不是每次都踩------单工具调用没事。但你只要跑过一次多工具并行就一定会撞上,而且撞上了很难定位根因。报错信息看起来像是"模型返回了畸形 JSON",实际上是你自己拼错了。我个人特别讨厌这种报错------它看起来像是上游的问题,但根因其实在你自己这边。排查这种问题就像在别人的代码里找自己的 bug,方向完全反了。
坑二:SSE 断连后从头重跑整个对话------白烧 token 白浪费时间
长对话场景。Agent 跟用户聊了 30 轮,每一轮都在往 messages 里追加内容,到第 31 轮的时候上下文已经很长了。SSE 流推到一半,网络抖了一下,连接断了。你当前的默认处理是什么?
重新把 30 轮 messages 全发一遍,重新跑一遍请求。我实测过一个 20 轨的对话:断连后从头重跑要 4.2 秒,多烧 2400 token------因为 OpenAI API 会重新处理那 30 轨的完整上下文。用户等 4 秒重新看一遍刚刚已经看过的内容,体验堪比拨号上网。你跟用户说"请稍等,系统正在重新生成",但用户心里想的是"我刚才明明看了一半了,为什么又要从头等"。
如果让我重来,我会在写流式处理的第一天就加上断点续传,而不是等到用户投诉才想起来。
SSE 协议本身有个 Last-Event-ID 机制。每个 SSE event 带 id 字段,客户端断连重连时在请求头带上 Last-Event-ID,服务端从那个 id 之后继续推。不用从头跑。
不过有个前提:OpenAI 的官方 API 不直接支持这个------它不会在事件里给你带 id。你需要在自己的代理层实现:给每个从 OpenAI 收到的 event 分配一个递增 id,断连时客户端把 Last-Event-ID 传给你的代理,代理跳过已推送的 event 从断点继续。
typescript
// 代理层:给 event 加 id,处理断点续传
async function sseProxy(req: Request, res: Response) {
const lastEventId = req.headers['last-event-id'] as string;
const skipUntil = lastEventId
? parseInt(lastEventId.replace('chunk-', '')) + 1
: 0;
let eventIndex = 0;
const openaiStream = callOpenAIStream(req.body);
for await (const chunk of openaiStream) {
if (eventIndex < skipUntil) { eventIndex++; continue; }
res.write(`id: chunk-${eventIndex}\ndata: ${JSON.stringify(chunk)}\n\n`);
eventIndex++;
}
res.end();
}
// 客户端:记录接收到的 id,断连重连时带上
class ResilientSSEClient {
private lastEventId = '';
async connect(url: string, body: any) {
const headers: Record<string, string> = {};
if (this.lastEventId) {
headers['Last-Event-ID'] = this.lastEventId;
}
const response = await fetch(url, {
method: 'POST', headers,
body: JSON.stringify(body),
});
for await (const evt of this.parseSSE(response)) {
if (evt.id) this.lastEventId = evt.id;
this.onEvent(evt);
}
}
private async *parseSSE(response: Response): AsyncGenerator<{id: string; data: string}> {
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
let currentId = '';
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('id:')) currentId = line.slice(3).trim();
if (line.startsWith('data:')) {
yield { id: currentId, data: line.slice(5).trim() };
}
}
}
}
}
实测对比:20 轮对话断连,从头重跑 4.2s / 2400 额外 token,用 Last-Event-ID 续传 0.8s / 0 额外 token。
如果你的 Agent 跑长对话,不加这条就是在浪费钱。短对话可以先不管------5 轮以内断连重跑也就 1 秒出头,折腾续传性价比不高。
坑三:用户点了"停止生成"你以为省了钱------后端还在烧 token
这是最隐蔽的一条。用户在前端点了取消,你关了 SSE 连接,界面干净了,用户以为生成停了。心情很舒畅,觉得自己控制了局面。
但后端对 OpenAI API 的请求还在跑,token 还在烧。OpenAI 的 API 不支持服务端主动中断流------你关了前端连接,它的生成继续跑直到完成或超时。这就像你关了电视以为节目停播了,其实电视台还在播,只是你看不见而已。
一个平均 800 token 的生成任务,用户在 200 token 处取消。不用 AbortController,后端跑完 800 token,白烧 600。用了 AbortController,后端也大概在 200 token 处停下------因为 fetch 收到 abort 信号后会主动断开 TCP 连接,OpenAI 的服务端在连接断开时也会停止生成。
你算算:每天 100 次取消操作,每次白烧 600 token,一个月就是 180 万 token 的无效消耗。按 gpt-4o 的价格算,那是 27的纯浪费。27美元不多,但你花27美元买了零价值的东西,这感觉不太好。更糟糕的是,这些白烧的token你在用量报表里根本看不出来------因为它们被计入了正常完成的请求。你以为每月花200,其实有 $27 是废的。
typescript
async function streamWithAbort(
messages: ChatMessage[],
clientSignal: AbortSignal
) {
const serverAbort = new AbortController();
// 前端断连时,同步 cancel 后端请求
clientSignal.addEventListener('abort', () => {
serverAbort.abort();
});
try {
const response = await fetch(
'https://api.openai.com/v1/chat/completions',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({ messages, stream: true }),
signal: serverAbort.signal,
}
);
// 正常流式处理...
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return { status: 'cancelled', tokensSaved: '~68%' };
}
throw err;
}
}
这条我觉得是最值得立刻加的------不管你跑什么场景,用户取消都是高频操作。白烧 token 就是白烧钱,没有任何借口不修。如果你觉得 27不多可以忽略,那你想想:这个数字是按每天100次取消算的,如果你的Agent日活上千,这个数字会变成270 甚至更多。而且这还是保守估计------长对话场景每次取消白烧的 token 更多。
什么时候不用折腾
短对话(5 轮以内)+ 单工具调用 + 没有停止按钮的场景,这三个优化差别不大。但凡你跑长对话、多工具并行、有用户取消需求------花半天加上,回报比你想的高。
这三个坑我在做雷达鸭的客服 Agent 时逐个踩过来又逐个修的,鸿蒙版和小程序版都在跑这套方案。
你遇到过 SSE 流式响应的暗坑吗?聊聊。
关于作者
老三,10+ 年软件开发老兵,软件设计师 & 人工智能应用工程师。专注鸿蒙北向开发(ArkTS)+ Web 前端,边做产品边探索 AI 自动化。不定期在 CSDN 分享鸿蒙 / AI 方向技术踩坑笔记。
本文遵循 MIT 协议,转载请注明出处。