Laravel OpenAI SSE 文字断裂?详解修复方案与代码
2025-04-30 13:48:26
解决 Laravel + OpenAI SSE 实时输出文字断裂问题
使用 Laravel 结合 OpenAI 的流式接口(通过 Server-Sent Events, SSE)来实时生成内容,比如博客文章,是个挺常见的需求。但有时候会遇到一个头疼的问题:前端收到的不是流畅的句子,而是被打断的、破碎的单词或字符,就像这样:
"Free lan ce work is a gre at way to be come in dep end ent."
而不是期望的:
"Freelance work is a great way to become independent."
这种情况导致生成的文本基本没法阅读。看起来像是后端处理 OpenAI 返回的 token 流时,没能正确地将它们组合成完整的词语或句子片段。
问题出在哪儿?
要弄明白为什么会出现单词断裂,得先了解 OpenAI API 的流式 (stream: true
) 响应是怎么回事。
当你请求流式响应时,OpenAI 不会等到整个回答都生成完毕再一股脑发给你。相反,它会把生成的文本切分成一个个小的单元,通常是 token ,然后立刻发送。这些 token 可能是一个完整的单词,也可能只是单词的一部分、一个标点符号,甚至只是一个空格。
问题的关键往往出在后端如何处理这些零碎的 token ,以及前端如何将它们拼接起来 。
在你的代码里,主要有两处可能导致这个问题:
-
后端(
OpenAIService.php
)的缓冲和发送逻辑 :- 代码尝试读取响应体
read(4096)
,然后按行 (\n
) 分割。 - 它会从
data:
行提取delta.content
,这通常是一个 token 或者 token 的一部分。 - 核心问题在于这里的缓冲逻辑:
$buffer .= $text;
把收到的 token 碎片存起来,然后用if (preg_match('/\s$/', $text) || mb_strlen($buffer) > 6)
判断何时发送。这个判断逻辑比较粗糙:preg_match('/\s$/', $text)
:只有当收到的 当前 token 碎片以空格结尾时,才发送缓冲区的内容。这意味着如果一个单词被拆分成 "wor" 和 "d " 两个 token,只有收到 "d " 时,"word " 才会被发送。但如果单词是 "independent",被拆成 "in", "dep", "end", "ent",中间没有空格,缓冲区会一直累积,直到长度超过 6 (mb_strlen($buffer) > 6
) 才发送。这个长度阈值很随意,容易在单词中间断开。- 这种基于空格或固定长度的缓冲策略,无法精确识别实际的词语边界,尤其对于非空格分隔的语言或者跨 token 边界的单词,效果很差。
- 代码尝试读取响应体
-
前端(JavaScript)的处理逻辑 :
- 前端代码使用
fetch
和ReadableStream
来接收 SSE 事件,这本身没问题。 - 问题出在这一行:
outputDiv.innerHTML += kelime + " ";
。它在每次收到后端发送的data
(也就是后端缓冲后发送的那一小块文本kelime
)后,都会在末尾额外添加一个空格 。 - 结合后端的问题:假设后端因为缓冲策略,先后发送了 "Free", "lan", "ce ", "work ", "is ", "a " ...。前端接收到后,会渲染成 "Free " + "lan " + "ce " + "work " + "is " + "a "... 这就导致了比原始文本更多的空格,使得单词断裂看起来更严重。
- 前端代码使用
总结一下,根源在于后端对 token 流的处理不够智能,试图用简单的规则(空格或长度)猜测词语边界,再加上前端每次都画蛇添足地加空格,共同导致了最终看到的破碎文本。
怎么修复?
有几种方法可以解决这个问题,思路不同,各有优劣。
方案一:改进后端缓冲逻辑(更智能地组合)
这种方法的核心是让后端更聪明地判断何时发送数据块,尽量发送完整的词语或有意义的短语,而不是随意切断。
原理与作用:
不再依赖简单的空格或固定长度。可以在后端维护一个更可靠的缓冲区,持续累积收到的 delta.content
。当检测到累积的文本看起来像一个完整的词语(比如以空格或标点符号结尾),或者缓冲区积累到一定程度但明显还在一个词中间(比如达到了某个稍大的长度阈值,作为保险),才发送一次 SSE 事件。这能有效减少在单词中间断开的情况。
操作步骤与代码示例(修改 OpenAIService.php
):
调整 generateStreamedText
方法内的 while
循环逻辑。我们需要一个更精细的缓冲和发送策略。
// ... 在 OpenAIService.php 的 generateStreamedText 方法内 ...
return response()->stream(function () use ($response) {
// Headers 应该在 stream 方法的第三个参数中设置,这里不再需要 header() 函数调用
// header('Content-Type: text/event-stream');
// header('Cache-Control: no-cache');
// header('Connection: keep-alive');
// header('X-Accel-Buffering: no');
$buffer = ""; // 缓冲区,用于累积 token
$wordBoundaryChars = [' ', ',', '.', '!', '?', ':', ';', '\n', '\t']; // 定义词语边界字符
try {
$body = $response->getBody();
while (!$body->eof()) {
// 尝试读取更小的块,或者保持4096,但处理逻辑要精细
$chunk = $body->read(1024); // 可以尝试减小 read 的大小
// 不需要 trim($chunk),因为 SSE 格式依赖换行符
// 处理原始 chunk 数据可能更可靠
if (!empty($chunk)) {
// 简单的按行分割有时会因 chunk 边界切割换行符而出错
// 更稳妥的方式是处理累积的流数据
// 这里我们暂时沿用之前的行处理逻辑,但进行改进
$lines = explode("\n", $chunk);
foreach ($lines as $line) {
// 清理行数据,移除可能的 data: 前缀和前后空格
$trimmedLine = trim($line);
if (strpos($trimmedLine, "data: ") === 0) {
$jsonData = substr($trimmedLine, 5);
// 特殊处理流结束标记
if (trim($jsonData) === '[DONE]') {
// 如果缓冲区还有剩余内容,在结束前发送
if (!empty($buffer)) {
echo "event: update\n";
echo "data: " . json_encode(['text' => $buffer]) . "\n\n"; // 发送JSON封装的数据
ob_flush();
flush();
$buffer = ""; // 清空缓冲区
}
// 发送结束信号
echo "event: end\n";
echo "data: " . json_encode(['message' => 'Stream ended']) . "\n\n"; // 使用不同的事件名表示结束
ob_flush();
flush();
break 2; // 跳出两层循环 (foreach 和 while)
}
$json = json_decode($jsonData, true);
if (isset($json['choices'][0]['delta']['content'])) {
$text = $json['choices'][0]['delta']['content'];
$buffer .= $text; // 将新的 token 添加到缓冲区
// 判断是否达到发送条件:
// 1. 新收到的 text 包含词语边界字符
// 2. 或者缓冲区积累了一定长度(例如 > 30 字符,避免无限缓冲)
// 3. 注意判断时要考虑多字节字符
$lastChar = mb_substr($text, -1, 1, 'UTF-8');
$shouldSend = false;
if (!empty($text) && in_array($lastChar, $wordBoundaryChars)) {
$shouldSend = true;
} elseif (mb_strlen($buffer, 'UTF-8') > 30) { // 缓冲区阈值,可以调整
// 达到长度阈值时,也考虑发送,但最好是尝试找到最后一个边界字符断开
// 为了简化,这里达到长度就发送
$shouldSend = true;
}
if ($shouldSend) {
// 发送数据时,使用 JSON 格式封装,方便前端解析
// 转义特殊字符,避免破坏 SSE 格式
echo "event: update\n";
// 直接发送累积的 buffer,而不是原始的 $text
echo "data: " . json_encode(['text' => $buffer]) . "\n\n";
ob_flush();
flush();
$buffer = ""; // 发送后清空缓冲区
}
} else if (isset($json['error'])) {
// 处理 OpenAI 返回的错误信息
Log::error("OpenAI API stream error: " . json_encode($json['error']));
echo "event: error\n";
echo "data: " . json_encode(['error' => $json['error']['message'] ?? 'Unknown stream error']) . "\n\n";
ob_flush();
flush();
break 2;
}
}
}
}
}
// 循环结束后,如果缓冲区还有内容,确保发送出去
if (!empty($buffer)) {
echo "event: update\n";
echo "data: " . json_encode(['text' => $buffer]) . "\n\n";
ob_flush();
flush();
}
// 发送一个明确的结束信号(即使上面已经发过一次)
echo "event: end\n";
echo "data: " . json_encode(['message' => 'Stream ended gracefully']) . "\n\n";
ob_flush();
flush();
} catch (\Exception $e) {
Log::error("Stream reading error: " . $e->getMessage());
// 可以在这里尝试发送一个错误事件给前端
echo "event: error\n";
echo "data: " . json_encode(['error' => 'Server error while reading stream.']) . "\n\n";
ob_flush();
flush();
}
}, 200, [
// 将 Headers 设置在这里!
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'Connection' => 'keep-alive',
'X-Accel-Buffering' => 'no', // 对于 Nginx 很重要,禁用代理缓冲
]);
前端(JavaScript)需要相应调整:
既然后端发送的是 JSON 格式 ({'text': '...'}
), 前端需要解析 JSON。并且绝对不能 再自己加空格了。
<script>
document.addEventListener("DOMContentLoaded", function () {
const generateButton = document.getElementById('generate-blog');
const outputDiv = document.getElementById('streamedData');
const form = document.getElementById('ai-blog-form');
if (generateButton && outputDiv && form) {
generateButton.addEventListener('click', function() {
console.log("Blog oluşturma butonuna basıldı!");
outputDiv.innerHTML = ""; // 清空之前的内容
let formData = new FormData(form);
// 使用 EventSource API,它更适合处理 SSE
const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute("content");
const queryString = new URLSearchParams(formData).toString();
// 注意:将 POST 数据作为查询参数可能不适合所有场景,尤其是数据量大或敏感时。
// 实际应用中可能需要一个先创建任务ID,再用GET请求EventSource的模式。
// 这里为了简化,假设 /generate 能接受带查询参数的请求(或者你调整后端路由和控制器)
// 或者还是用 Fetch,但要正确处理 POST 和 stream
// 让我们坚持用 Fetch,因为它在你原来的代码里,并且处理 POST 更直接
fetch("{{ route('dashboard.blog.ai.generate') }}", {
method: "POST",
body: formData,
headers: {
"X-CSRF-TOKEN": csrfToken,
// 'Accept': 'text/event-stream' // 可以加上,但主要看服务器 Content-Type
}
})
.then(response => {
if (!response.ok) {
// 尝试读取错误信息体
return response.json().then(err => {
throw new Error(`HTTP error! Status: ${response.status}, Message: ${err.error || 'Unknown error'}`);
}).catch(() => {
throw new Error(`HTTP error! Status: ${response.status}`);
});
}
if (!response.body) {
throw new Error('ReadableStream not available');
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let currentBuffer = ''; // 用于处理跨块的 SSE 消息
function processText(text) {
// 将新文本块加入缓冲区
currentBuffer += text;
let eolIndex;
// 循环处理缓冲区中完整的 SSE 消息 (\n\n 分隔)
while ((eolIndex = currentBuffer.indexOf('\n\n')) >= 0) {
const message = currentBuffer.slice(0, eolIndex);
currentBuffer = currentBuffer.slice(eolIndex + 2); // 移除已处理的消息和分隔符
// 解析单条 SSE 消息
const lines = message.split('\n');
let eventType = 'message'; // 默认事件类型
let eventData = '';
lines.forEach(line => {
if (line.startsWith('event: ')) {
eventType = line.substring(7).trim();
} else if (line.startsWith('data: ')) {
// 累加可能的 data 行 (虽然通常只有一行)
eventData += line.substring(5).trim();
}
});
console.log("Received SSE Event:", eventType, "Data:", eventData);
if (eventType === 'update') {
try {
const parsedData = JSON.parse(eventData);
if (parsedData && parsedData.text) {
// 直接追加文本,不加空格!
// 使用 textContent 更安全,避免 XSS
// 如果你需要保留 HTML 格式(比如 OpenAI 返回 Markdown),则用 innerHTML 需要小心处理
outputDiv.textContent += parsedData.text;
console.log("Appended text:", parsedData.text);
}
} catch (e) {
console.error("JSON parse error for event data:", e, eventData);
}
} else if (eventType === 'end') {
console.log("Stream ended by server signal.");
// 这里可以做一些流结束后的操作,比如启用保存按钮
return; // 结束读取
} else if (eventType === 'error') {
try {
const parsedData = JSON.parse(eventData);
console.error("Server reported error:", parsedData.error);
outputDiv.textContent += `\n\n[Error: ${parsedData.error}]`;
} catch (e) {
console.error("Could not parse error event data:", e, eventData);
outputDiv.textContent += `\n\n[Unknown server error]`;
}
return; // 发生错误,停止处理
}
}
}
function readStream() {
reader.read().then(({ done, value }) => {
if (done) {
// 处理可能残留在缓冲区的数据
if(currentBuffer.trim()) {
console.warn("Stream finished, but buffer had leftover data:", currentBuffer);
// 可以尝试最后处理一次
processText(''); // 传递空字符串触发最后一次处理
}
console.log("Stream reading complete.");
return;
}
const chunkText = decoder.decode(value, { stream: true });
processText(chunkText); // 处理接收到的文本块
// 继续读取下一块
readStream();
}).catch(error => {
console.error("Stream reading error:", error);
outputDiv.textContent += `\n\n[Error reading stream: ${error.message}]`;
});
}
readStream(); // 开始读取流
})
.catch(error => {
console.error("Fetch setup error:", error);
outputDiv.textContent = `Error: ${error.message}`;
});
});
} else {
console.error("Required elements (button, output div, form) not found.");
}
});
</script>
优点: 前端体验可能更平滑,看到的文本片段更有意义。
缺点: 后端逻辑变复杂,需要更小心地处理各种边界情况和不同语言。可能会稍微增加一点点延迟,因为需要累积更多 token 才发送。
安全建议:
后端直接 echo 用户(间接来自 OpenAI)的内容到响应中,如果内容包含恶意脚本且前端直接用 innerHTML
插入,有 XSS 风险。虽然 OpenAI 通常会过滤,但不能完全依赖。如果使用 innerHTML
,需要进行严格的 XSS 清理。改用 textContent
(如上例) 可以避免 XSS,但会失去 HTML 格式。
方案二:简化后端,让前端处理拼接(推荐)
这种方法把复杂性推给前端,后端只负责把收到的 delta.content
透传。
原理与作用:
后端不再做任何缓冲判断,收到 delta.content
就立刻通过 SSE 发送给前端。前端负责将收到的所有文本片段(包括 OpenAI 返回的空格)按顺序拼接起来。这种方式最接近实时,延迟最低。
操作步骤与代码示例:
后端(OpenAIService.php
): 简化 while
循环逻辑。
// ... 在 OpenAIService.php 的 generateStreamedText 方法内 ...
return response()->stream(function () use ($response) {
try {
$body = $response->getBody();
while (!$body->eof()) {
$chunk = $body->read(1024); // 读小块可能减少延迟感知
if (!empty($chunk)) {
// 依然按行处理,因为SSE消息格式是基于行的
$lines = explode("\n", $chunk);
foreach ($lines as $line) {
$trimmedLine = trim($line);
if (strpos($trimmedLine, "data: ") === 0) {
$jsonData = substr($trimmedLine, 5);
if (trim($jsonData) === '[DONE]') {
// 发送结束信号 (保持和方案一类似的明确结束信号)
echo "event: end\n";
echo "data: " . json_encode(['message' => 'Stream ended']) . "\n\n";
ob_flush();
flush();
break 2; // 跳出循环
}
$json = json_decode($jsonData, true);
if (isset($json['choices'][0]['delta']['content'])) {
$text = $json['choices'][0]['delta']['content'];
// 直接发送收到的 delta 内容,不缓冲
// 依然使用 JSON 封装,保持一致性,且方便传递额外信息
echo "event: update\n";
echo "data: " . json_encode(['text' => $text]) . "\n\n";
ob_flush();
flush();
} else if (isset($json['error'])) {
// 处理错误 (保持错误处理)
Log::error("OpenAI API stream error: " . json_encode($json['error']));
echo "event: error\n";
echo "data: " . json_encode(['error' => $json['error']['message'] ?? 'Unknown stream error']) . "\n\n";
ob_flush();
flush();
break 2;
}
}
}
}
}
// 可能需要一个最后的结束事件以防万一 (可选,如果[DONE]总是可靠的话)
// echo "event: end\n";
// echo "data: {\"message\": \"Stream potentially finished reading\"}\n\n";
// ob_flush(); flush();
} catch (\Exception $e) {
Log::error("Stream reading error: " . $e->getMessage());
echo "event: error\n";
echo "data: " . json_encode(['error' => 'Server error while reading stream.']) . "\n\n";
ob_flush();
flush();
}
}, 200, [
// Headers 设置保持不变
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'Connection' => 'keep-alive',
'X-Accel-Buffering' => 'no',
]);
前端(JavaScript):
前端代码与方案一中的 JavaScript 代码几乎完全相同 。因为它本来就是设计用来接收后端发送的 JSON 数据 ({'text': '...'}
),并将其中的 text
值追加到 outputDiv.textContent
。关键在于它不再手动添加额外的空格 。OpenAI 返回的 token 流本身就包含了必要的空格,前端只需忠实地拼接即可。
// 使用方案一中提供的那个改进后的 JavaScript 代码即可。
// 它能正确处理 JSON 数据、事件类型 (update, end, error),并且使用 textContent 追加文本,避免了额外空格和 XSS 问题。
优点: 后端逻辑简单,接近“零延迟”感知,因为 token 几乎是实时转发的。通常是处理流式响应的首选方式。
缺点: 前端可能会看到非常快速地、字符接字符地出现文本,有些人可能觉得视觉上有点“跳”。对于性能较差的设备或非常长的文本流,频繁更新 DOM(即使是 textContent
)也可能带来一点性能开销(通常不明显)。
进阶使用技巧:
如果觉得字符跳动太快影响体验,可以在前端的 processText
函数里加一个简单的防抖 (Debounce) 或 节流 (Throttle) 机制。比如,不是每次收到 update
事件就立刻更新 outputDiv.textContent
,而是设置一个定时器,如果在比如 100 毫秒内没有收到新的 update
,才执行一次 DOM 更新,将这段时间内积累的所有文本一次性写入。这会牺牲一点实时性,换取更平滑的视觉效果。
// 概念示例:在 JS 中添加 Debounce
let accumulatedText = '';
let debounceTimer;
const DEBOUNCE_DELAY = 100; // 100ms
function updateOutput() {
if (accumulatedText) {
outputDiv.textContent += accumulatedText;
accumulatedText = ''; // 清空已写入的文本
console.log("Debounced update applied.");
}
}
// 在 processText 函数内部,替换直接更新 DOM 的地方:
if (eventType === 'update') {
try {
const parsedData = JSON.parse(eventData);
if (parsedData && parsedData.text) {
accumulatedText += parsedData.text; // 累积文本
clearTimeout(debounceTimer); // 清除之前的定时器
debounceTimer = setTimeout(updateOutput, DEBOUNCE_DELAY); // 设置新的定时器
}
} catch (e) { /* ... */ }
} else if (eventType === 'end') {
clearTimeout(debounceTimer); // 流结束时,确保最后的文本被写入
updateOutput();
console.log("Stream ended, final update forced.");
// ...
}
// 在 readStream 的 done 分支也可能需要强制更新一次
if (done) {
clearTimeout(debounceTimer);
updateOutput(); // 确保所有缓冲文本都被写入
// ...
}
方案三:使用现成的 PHP OpenAI 客户端库
与其手写 Guzzle 请求和流处理逻辑,不如用专门为 OpenAI API 设计的 PHP 库,比如 openai-php/laravel
或 openai-php/client
。这些库通常封装了流式请求的细节,能更方便地处理 token 流。
原理与作用:
这些库提供了高级抽象,你只需调用它们的 stream 方法,然后遍历返回的迭代器或响应对象即可。库内部会处理 HTTP 连接、读取分块、解析 SSE 格式等。
操作步骤与代码示例(假设已安装 openai-php/laravel
):
首先,安装库:
composer require openai-php/laravel
然后,发布配置(可选但推荐):
php artisan vendor:publish --provider="OpenAI\Laravel\ServiceProvider"
配置你的 .env
文件:
OPENAI_API_KEY=sk-...
OPENAI_ORGANIZATION=
(可选)
修改 OpenAIService.php
:
<?php
namespace App\Services;
use Illuminate\Support\Facades\Log;
use OpenAI\Laravel\Facades\OpenAI; // 引入 Facade
use Symfony\Component\HttpFoundation\StreamedResponse;
use App\Services\OpenAISettingsService;
use Exception; // 引入 Exception
class OpenAIService
{
protected $settingsService;
protected $settings;
public function __construct(OpenAISettingsService $settingsService)
{
$this->settingsService = $settingsService;
$this->settings = $this->settingsService->getSettings();
// 如果使用官方库的 Laravel Facade,API Key 通常由库自动从配置或 env 读取
// 你可以移除 settings->api_key 的显式检查,依赖库的配置
}
public function getSettings()
{
return $this->settings;
}
public function generateStreamedText($prompt, $maxTokens)
{
// 依赖库自动配置API密钥,如果配置不正确,库会抛出异常
try {
$stream = OpenAI::chat()->createStreamed([
'model' => $this->settings->default_model, // 从你的设置服务获取模型
'messages' => [
['role' => 'system', 'content' => 'Sen bir blog yazarı asistansın.'], // 你可以保持这个
['role' => 'user', 'content' => $prompt],
],
'max_tokens' => (int) $maxTokens,
'temperature' => 0.7,
// 'stream' => true, // createStreamed 方法已经隐含了 stream: true
]);
return response()->stream(function () use ($stream) {
try {
foreach ($stream as $response) {
$text = $response->choices[0]->delta->content;
// 检查 $text 是否为 null 或空字符串 (有时会有空 delta)
if (isset($text) && $text !== '') {
// 直接发送获取到的 delta 文本,保持简单(类似方案二的后端)
// 依然封装成 JSON
echo "event: update\n";
echo "data: " . json_encode(['text' => $text]) . "\n\n";
ob_flush();
flush();
}
// 检查流是否完成 (虽然 [DONE] 更可靠,但库可能有自己的结束逻辑)
if (isset($response->choices[0]->finishReason) && $response->choices[0]->finishReason !== null) {
// 可以根据 finishReason 做些判断,比如 'stop' 表示正常结束
break; // 接收到结束信号,可以跳出循环
}
}
// 发送明确的结束信号
echo "event: end\n";
echo "data: " . json_encode(['message' => 'Stream finished processing from library']) . "\n\n";
ob_flush();
flush();
} catch (Exception $e) { // 捕获流处理中的异常
Log::error("Error processing OpenAI stream: " . $e->getMessage());
echo "event: error\n";
echo "data: " . json_encode(['error' => 'Error processing stream: ' . $e->getMessage()]) . "\n\n";
ob_flush();
flush();
}
}, 200, [
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
'Connection' => 'keep-alive',
'X-Accel-Buffering' => 'no',
]);
} catch (Exception $e) { // 捕获 API 调用本身的异常
Log::error("OpenAI API request failed: " . $e->getMessage());
// 返回一个 JSON 错误响应可能比流式响应更合适,如果 API 调用本身就失败了
// 但这里为了保持流式,我们发送一个错误事件
return response()->stream(function () use ($e) {
echo "event: error\n";
echo "data: " . json_encode(['error' => 'API request failed: ' . $e->getMessage()]) . "\n\n";
ob_flush();
flush();
}, 500, [ // 返回 500 状态码表示服务器端错误
'Content-Type' => 'text/event-stream',
'Cache-Control' => 'no-cache',
]);
}
}
}
前端(JavaScript): 同样,使用方案一或方案二中改进后的 JavaScript 代码即可,因为它接收的仍然是包含 { "text": "..." }
的 update
事件。
优点: 大大简化后端代码,将复杂的流处理逻辑交给维护良好的库。代码更简洁、易读、易维护。通常能更好地处理错误和边缘情况。
缺点: 增加了一个外部依赖。需要学习库的使用方法。
安全建议:
确保你的 OPENAI_API_KEY
存储在 .env
文件中,并且 .env
文件不被纳入版本控制(添加到 .gitignore
)。不要在代码中硬编码 API 密钥。
总结一下:
- 问题核心: 后端缓冲策略不佳 + 前端错误地添加空格。
- 推荐方案: 方案二(简化后端,前端拼接)或方案三(使用专用库)。方案二最直接,改动相对小。方案三代码最整洁,长远看更易维护。
- 关键修复点:
- 后端要么实现更智能的缓冲(方案一),要么干脆不缓冲直接转发 token delta(方案二、三)。
- 前端绝对不能 在接收到的每个数据片段后添加自己的空格,应该直接拼接
delta.content
。 - 使用
textContent
而不是innerHTML
更新前端显示可以防止 XSS 攻击,除非你确定需要渲染 HTML 并且做了安全处理。 - 在后端正确设置 SSE 的 HTTP Headers (在
response()->stream()
的第三个参数里)。 - 实现明确的流结束和错误处理信号,让前端知道发生了什么。
选择哪个方案取决于你对代码简洁度、控制粒度以及是否愿意引入新依赖的权衡。通常,使用现成库(方案三)或者让前端处理拼接(方案二)是比较推荐的做法。