ThinkPHP控制器不能用return返回流式响应,因框架会强制封装为Response对象并缓存整个body,导致数据截断、乱码或headers already sent错误;必须绕过return链路,改用exit/die终止流程,禁用PHP缓冲,使用StreamResponse配合Db::cursor()实现真流式输出。

ThinkPHP控制器不能用return返回流式响应
框架会把return值强制走Response封装流程,导致流式数据被截断、乱码,或直接报headers already sent。这不是你代码写错了,是ThinkPHP的响应生命周期决定的——它默认缓存整个body,等控制器执行完才统一输出。
常见错误现象包括:
- 浏览器收到空响应或“解析失败”提示
- 前端EventSource反复触发
error事件但status为0 - 日志里出现
ob_flush() has no effect警告
必须绕过return链路,改用exit或die终止框架后续流程。同时在输出前禁用PHP自身缓冲:ini_set('output_buffering', 'off')和ini_set('zlib.output_compression', 'Off')。
用StreamResponse构造真流式响应对象
ThinkPHP 6.1+ 提供了think\Response\StreamResponse,它是唯一能绕过框架缓存、直连HTTP输出流的类。别再用Response::create($data)->send(),那只是把大字符串塞进内存再吐出来。
立即学习“PHP免费学习笔记(深入)”;
正确写法是传入一个回调函数,在响应真正发送时才执行流操作:
use think\Response;
return Response::stream(function () {
$fp = fopen('php://output', 'wb');
// 这里逐行查库、写CSV、flush
$cursor = Db::table('logs')->cursor();
while ($row = $cursor->fetch()) {
fputcsv($fp, $row);
fflush($fp);
}
fclose($fp);
}, 200, [
'Content-Type' => 'text/csv; charset=utf-8',
'Content-Disposition' => 'attachment; filename="export.csv"',
'Cache-Control' => 'no-cache',
]);
注意三点:
- 响应头(如
Content-Type)必须在任何echo或fwrite之前调用header(),否则报错 - 数据库必须用
Db::cursor(),不是chunkById()——后者仍会把整批结果load进内存 - 不能在流式查询中用
with()关联预加载,会破坏逐行迭代
Guzzle流式转发OpenAI/DeepSeek等API
如果你在做AI代理接口(比如/api/chat),后端要实时转发大模型的流式响应,Guzzle比裸cURL更稳。关键配置只有三个:
- 必须设
stream => true,否则Guzzle会等完整响应才返回 - 必须设
timeout => 15(单位秒),防止上游卡死拖垮整个PHP-FPM进程 - 请求体JSON里要带
"stream": true,不是加在URL参数上
转发时别拼接、别缓存,收到chunk就立刻输出:
$response = $client->send($request, ['stream' => true]);
$body = $response->getBody();
while (!$body->eof()) {
echo "data: " . $body->read(1024) . "\n\n";
ob_flush();
flush();
}
前端用EventSource接收时,后端响应头必须包含:Content-Type: text/event-stream和X-Accel-Buffering: no(Nginx场景下防止代理层缓存)。
流式导出和AI流式共用一套底层机制
无论是导出百万行CSV,还是转发DeepSeek的data: {"choices":[{"delta":{"content":"..."}},本质都是HTTP分块传输。它们共享同一套底层约束:
- PHP-FPM需调大
request_terminate_timeout和request_slowlog_timeout - Nginx需关闭
proxy_buffering,并设proxy_read_timeout 300 - 浏览器侧没有超时重试逻辑,断连后得靠前端自己判断
eventSource.readyState再重建
最容易被忽略的是:流式响应期间不能触发任何ThinkPHP中间件里的echo或Log::write(),哪怕一行调试日志都会污染SSE格式,导致前端解析失败。



















